From 502bcbdefc91bd679ec55f810c281bc162855791 Mon Sep 17 00:00:00 2001 From: "Valentin F." <95174216+DevSkyLex@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:24:33 +0200 Subject: [PATCH] feat(maintenance): add fire-equipment park maintenance and resource management --- README.md | 21 +- composer.json | 9 +- composer.lock | 3 +- config/modules/customer.yaml | 53 + config/modules/equipment.yaml | 71 + config/modules/facility.yaml | 30 + config/modules/inspection.yaml | 39 + config/modules/intervention.yaml | 65 + config/modules/inventory.yaml | 44 + config/modules/maintenance.yaml | 33 + config/modules/maintenance_cost.yaml | 83 + config/modules/maintenance_export.yaml | 35 + config/modules/procurement.yaml | 32 + config/modules/service_request.yaml | 52 + config/packages/api_platform.yaml | 13 +- config/packages/doctrine.yaml | 36 + config/packages/modules.yaml | 9 +- docs/README.md | 6 + migrations/main/Version20261006090000.php | 33 + migrations/main/Version20261006100000.php | 32 + migrations/main/Version20261006100001.php | 38 + migrations/main/Version20261006100002.php | 61 + migrations/main/Version20261006101000.php | 29 + migrations/main/Version20261006101001.php | 40 + migrations/main/Version20261006102000.php | 38 + migrations/main/Version20261006102001.php | 27 + migrations/main/Version20261006102002.php | 43 + migrations/main/Version20261006103000.php | 38 + migrations/main/Version20261006110000.php | 45 + migrations/main/Version20261006111000.php | 33 + migrations/main/Version20261006112000.php | 40 + migrations/main/Version20261006112001.php | 31 + migrations/main/Version20261006112002.php | 32 + migrations/main/Version20261006113000.php | 34 + migrations/main/Version20261006113001.php | 32 + migrations/main/Version20261006113002.php | 31 + migrations/main/Version20261007100000.php | 63 + migrations/main/Version20261007150000.php | 33 + openapi.json | 57018 ++++++++++------ .../Application/Contract/CustomerSnapshot.php | 18 + .../Application/Contract/CustomerView.php | 24 + .../Port/Inbound/CustomerLookupPort.php | 13 + .../Port/Outbound/CustomerRepositoryPort.php | 22 + .../Service/CustomerAccessGuard.php | 29 + .../ChangeCustomer/ChangeCustomerCommand.php | 18 + .../ChangeCustomer/ChangeCustomerHandler.php | 53 + .../ChangeCustomer/ChangeCustomerResult.php | 16 + .../CreateCustomer/CreateCustomerCommand.php | 18 + .../CreateCustomer/CreateCustomerHandler.php | 34 + .../CreateCustomer/CreateCustomerResult.php | 16 + .../Query/GetCustomer/GetCustomerHandler.php | 30 + .../Query/GetCustomer/GetCustomerQuery.php | 15 + .../Query/GetCustomer/GetCustomerResult.php | 16 + .../ListCustomers/ListCustomersHandler.php | 35 + .../ListCustomers/ListCustomersQuery.php | 15 + .../ListCustomers/ListCustomersResult.php | 19 + .../Domain/Event/CustomerChangedEvent.php | 21 + .../Domain/Exception/CustomerException.php | 41 + .../Domain/Model/Customer/Customer.php | 166 + .../Directory/CustomerLookupAdapter.php | 24 + .../FacilityCustomerValidationAdapter.php | 26 + ...intenanceExportCustomerIdentityAdapter.php | 63 + .../Doctrine/Record/CustomerRecord.php | 83 + .../Repository/CustomerRepository.php | 88 + src/Customer/MODULE.md | 99 + .../Api/Dto/Input/ChangeCustomerInput.php | 36 + .../Api/Dto/Input/CreateCustomerInput.php | 37 + .../Api/Dto/Output/CustomerOutput.php | 68 + .../CustomerExceptionSubscriber.php | 39 + .../Api/Operation/CustomerOperations.php | 21 + .../Api/Processor/CustomerProcessor.php | 87 + .../Api/Provider/CustomerProvider.php | 67 + .../Api/Resource/CustomerResource.php | 33 + .../Equipment/EquipmentListCriteria.php | 2 + .../EquipmentTypeDescriptor.php | 39 + .../Intervention/ServicedEquipmentEntry.php | 8 + .../EquipmentReserveReceiptRequest.php | 16 + .../EquipmentReserveReceiptResult.php | 16 + .../EquipmentReplacementReceipt.php | 39 + .../EquipmentReplacementSuccessor.php | 49 + .../Port/Inbound/EquipmentParkScopePort.php | 26 + .../Inbound/EquipmentReserveReceiptPort.php | 15 + .../EquipmentReplacementRepositoryPort.php | 62 + .../Port/Outbound/EquipmentRepositoryPort.php | 9 + .../Outbound/EquipmentTypeCatalogPort.php | 77 + .../Outbound/FacilityCustomerScopePort.php | 23 + .../EquipmentReserveReceiptService.php | 69 + .../EquipmentSelectionScopeResolver.php | 75 + .../AssignToFacilityHandler.php | 6 + .../AssignToFacilityResult.php | 7 + .../CommissionEquipmentHandler.php | 6 + .../CommissionEquipmentResult.php | 7 + .../CreateEquipmentCommand.php | 5 + .../CreateEquipmentHandler.php | 14 +- .../CreateEquipment/CreateEquipmentResult.php | 7 + .../DecommissionEquipmentHandler.php | 6 + .../DecommissionEquipmentResult.php | 7 + .../PatchCanonicalEquipmentCommand.php | 9 + .../PatchCanonicalEquipmentHandler.php | 12 + .../PutUnderMaintenanceHandler.php | 6 + .../PutUnderMaintenanceResult.php | 7 + ...ecordInterventionServiceHistoryHandler.php | 5 +- .../ReplaceEquipmentCommand.php | 42 + .../ReplaceEquipmentHandler.php | 197 + .../ReplaceEquipmentResult.php | 39 + .../UnassignFromFacilityHandler.php | 6 + .../UnassignFromFacilityResult.php | 7 + .../UpdateEquipmentCommand.php | 9 + .../UpdateEquipmentHandler.php | 19 +- .../UpdateEquipment/UpdateEquipmentResult.php | 7 + .../CreateEquipmentTypeCommand.php | 37 + .../CreateEquipmentTypeHandler.php | 54 + .../CreateEquipmentTypeResult.php | 31 + .../PatchEquipmentTypeCommand.php | 41 + .../PatchEquipmentTypeHandler.php | 61 + .../PatchEquipmentTypeResult.php | 31 + .../GetEquipment/GetEquipmentHandler.php | 25 +- .../GetEquipment/GetEquipmentResult.php | 17 +- .../GetFacilityEquipmentSummaryHandler.php | 5 +- .../GetFacilityEquipmentSummaryQuery.php | 2 +- ...GetOrganizationEquipmentSummaryHandler.php | 49 + .../GetOrganizationEquipmentSummaryQuery.php | 25 + .../ListEquipments/ListEquipmentsHandler.php | 59 +- .../ListEquipments/ListEquipmentsQuery.php | 2 + .../ListEquipmentTypesHandler.php | 55 + .../ListEquipmentTypesQuery.php | 30 + .../ListEquipmentTypesResult.php | 31 + ...uipmentAssetCodeAlreadyExistsException.php | 26 + .../Exception/EquipmentNotFoundException.php | 8 + .../EquipmentReplacementConflictException.php | 33 + .../EquipmentTypeCatalogException.php | 36 + .../Model/Equipment/CanonicalEquipment.php | 18 + .../Domain/Model/Equipment/Equipment.php | 54 +- .../EquipmentTypeDefinition.php | 115 + .../Replacement/EquipmentReplacement.php | 59 + .../Replacement/ReplacementEquipment.php | 65 + .../ValueObject/CanonicalEquipmentPatch.php | 9 + .../ValueObject/EquipmentCatalogDetails.php | 1 + .../Domain/ValueObject/EquipmentIdentity.php | 93 + .../Domain/ValueObject/EquipmentTypeCode.php | 45 + .../Inspection/EquipmentParkScopeAdapter.php | 58 + .../Inspection/EquipmentValidationAdapter.php | 15 + .../EquipmentInterventionResourceAdapter.php | 50 +- .../EquipmentReplacementLookupAdapter.php | 61 + .../InterventionEquipmentSnapshotAdapter.php | 125 + ...ntenanceExportEquipmentIdentityAdapter.php | 63 + .../ServiceRequestEquipmentTargetAdapter.php | 30 + .../EquipmentChangeOutboxListener.php | 2 +- .../Mapper/CanonicalEquipmentMapper.php | 7 +- .../Doctrine/Mapper/EquipmentMapper.php | 12 +- .../Doctrine/Record/EquipmentRecord.php | 41 + .../EquipmentReplacementReceiptRecord.php | 60 + .../Record/EquipmentTypeCatalogRecord.php | 59 + .../CanonicalEquipmentRepository.php | 14 +- .../EquipmentReplacementRepository.php | 159 + .../Repository/EquipmentRepository.php | 36 + .../EquipmentTypeCatalogRepository.php | 192 + src/Equipment/MODULE.md | 143 +- .../ExportEquipmentReportController.php | 12 +- .../Input/Equipment/CreateEquipmentInput.php | 33 +- .../PatchCanonicalEquipmentInput.php | 26 + .../Input/Equipment/ReplaceEquipmentInput.php | 48 + .../Equipment/ReplacementSuccessorInput.php | 101 + .../Input/Equipment/UpdateEquipmentInput.php | 33 +- .../CreateEquipmentTypeInput.php | 44 + .../PatchEquipmentTypeInput.php | 48 + .../Dto/Output/Equipment/EquipmentOutput.php | 76 +- .../Equipment/ReplaceEquipmentOutput.php | 44 + .../EquipmentTypeOutput.php | 75 + .../EquipmentIdentityFailureSubscriber.php | 49 + .../EquipmentReplacementFailureSubscriber.php | 82 + .../EquipmentTypeCatalogFailureSubscriber.php | 69 + .../Api/Factory/EquipmentOutputFactory.php | 14 +- .../EquipmentReplacementOperations.php | 22 + .../EquipmentTypeCatalogOperations.php | 37 + .../CanonicalEquipmentMutationProcessor.php | 10 +- .../Equipment/CreateEquipmentProcessor.php | 4 + .../Equipment/ReplaceEquipmentProcessor.php | 112 + .../Equipment/UpdateEquipmentProcessor.php | 15 + .../EquipmentTypeCatalogProcessor.php | 84 + .../Equipment/CanonicalEquipmentProvider.php | 30 +- .../FacilityEquipmentSummaryProvider.php | 11 +- .../Equipment/ListEquipmentsProvider.php | 2 + .../OrganizationEquipmentSummaryProvider.php | 76 + .../EquipmentTypeCatalogProvider.php | 79 + .../Resource/EquipmentReplacementResource.php | 53 + .../Api/Resource/EquipmentResource.php | 14 +- .../Resource/EquipmentTypeCatalogResource.php | 97 + .../FacilityEquipmentSummaryResource.php | 5 +- .../OrganizationEquipmentSummaryResource.php | 39 + .../Service/EquipmentTypeCatalogAccess.php | 74 + .../Facility/FacilityListCriteria.php | 1 + .../Contract/FacilityCustomerUnavailable.php | 16 + .../FacilityCustomerValidationPort.php | 14 + .../CreateFacility/CreateFacilityCommand.php | 1 + .../CreateFacility/CreateFacilityHandler.php | 16 + .../CreateFacility/CreateFacilityResult.php | 1 + .../PatchCanonicalFacilityCommand.php | 2 + .../PatchCanonicalFacilityHandler.php | 6 + .../UpdateFacility/UpdateFacilityCommand.php | 2 + .../UpdateFacility/UpdateFacilityHandler.php | 14 + .../UpdateFacility/UpdateFacilityResult.php | 1 + .../GetFacility/GetFacilityHandler.php | 1 + .../GetFacility/GetFacilityResult.php | 1 + .../GetFacilityChildrenHandler.php | 1 + .../GetFacilityDescendantsHandler.php | 1 + .../ListFacilities/ListFacilitiesHandler.php | 6 + .../ListFacilities/ListFacilitiesQuery.php | 1 + .../FacilityCustomerAssignmentException.php | 16 + ...FacilityCustomerScopeNotFoundException.php | 22 + .../Model/Facility/CanonicalFacility.php | 20 +- .../Facility/CanonicalFacilityVersion.php | 1 + .../Domain/Model/Facility/Facility.php | 24 + .../Domain/Model/Facility/FacilityDetails.php | 1 + .../ValueObject/CanonicalFacilityPatch.php | 2 + .../ValueObject/FacilityCustomerReference.php | 23 + .../FacilityCustomerScopeAdapter.php | 49 + .../FacilityInterventionResourceAdapter.php | 6 + .../InterventionEconomicScopeAdapter.php | 84 + ...nterventionSiteCustomerSnapshotAdapter.php | 60 + .../FacilityMaintenanceLifecycleAdapter.php | 32 + .../MaintenanceExportSiteIdentityAdapter.php | 63 + .../ServiceRequestSiteTargetAdapter.php | 88 + .../Mapper/CanonicalFacilityMapper.php | 3 +- .../Doctrine/Mapper/FacilityMapper.php | 2 + .../Doctrine/Record/FacilityRecord.php | 7 + .../FacilityOrganizationQueryRepository.php | 3 + .../Repository/FacilityRepository.php | 1 + .../FacilityInterventionPatchApplier.php | 20 +- src/Facility/MODULE.md | 30 + .../Input/Facility/CreateFacilityInput.php | 8 + .../Facility/PatchCanonicalFacilityInput.php | 6 + .../Input/Facility/UpdateFacilityInput.php | 8 + .../Dto/Output/Facility/FacilityOutput.php | 7 + .../FacilityCustomerExceptionSubscriber.php | 42 + .../Factory/FacilityDetailOutputFactory.php | 1 + .../CanonicalFacilityMutationProcessor.php | 4 +- .../Facility/CreateFacilityProcessor.php | 2 + .../Facility/UpdateFacilityProcessor.php | 3 + .../Facility/CanonicalFacilityProvider.php | 1 + .../Facility/ListFacilitiesProvider.php | 2 + .../Facility/ListFacilityChildrenProvider.php | 1 + .../ListFacilityDescendantsProvider.php | 1 + .../Api/Resource/FacilityResource.php | 9 + .../Equipment/EquipmentInspectionSummary.php | 35 + .../Inspection/InspectionSubjectCriteria.php | 2 + .../Contract/Park/ParkAnomaliesCounts.php | 33 + .../Contract/Park/ParkAnomalyEntry.php | 53 + .../EquipmentInspectionSummaryPort.php | 25 + .../Port/Outbound/EquipmentValidationPort.php | 16 + .../Outbound/InspectionRepositoryPort.php | 7 + .../Port/Outbound/ParkAnomalyGatewayPort.php | 71 + .../GetEquipmentInspectionSummaryHandler.php | 59 + .../GetEquipmentInspectionSummaryQuery.php | 26 + .../GetEquipmentInspectionSummaryResult.php | 25 + .../ListInspectionsHandler.php | 29 +- .../ListInspections/ListInspectionsQuery.php | 3 + .../GetParkAnomaliesSummaryHandler.php | 65 + .../GetParkAnomaliesSummaryQuery.php | 41 + .../GetParkAnomaliesSummaryResult.php | 35 + .../ListParkAnomaliesHandler.php | 103 + .../ListParkAnomaliesQuery.php | 47 + ...InspectionSummaryAccessDeniedException.php | 16 + ...mentInspectionSummaryNotFoundException.php | 16 + .../EquipmentInspectionSummaryAdapter.php | 71 + .../InterventionInspectionResultAdapter.php | 60 + .../ServiceRequestOriginAdapter.php | 67 + .../Repository/InspectionRepository.php | 23 + .../Repository/ParkAnomalyRepository.php | 220 + src/Inspection/MODULE.md | 58 + .../EquipmentInspectionSummaryOutput.php | 36 + .../Park/ParkAnomaliesSummaryOutput.php | 28 + .../Api/Operation/InspectionOperations.php | 2 + .../Api/Operation/ParkAnomaliesOperations.php | 20 + .../GetEquipmentInspectionSummaryProvider.php | 69 + .../Inspection/ListInspectionsProvider.php | 17 +- .../Provider/Park/ParkAnomaliesProvider.php | 114 + .../EquipmentInspectionSummaryResource.php | 32 + .../Api/Resource/InspectionResource.php | 6 + .../Api/Resource/ParkAnomaliesResource.php | 59 + .../Api/Service/ParkAnomaliesAccess.php | 58 + .../Contract/Cost/InterventionCostContext.php | 16 + .../Cost/InterventionTimeCostFact.php | 15 + .../Draft/InterventionDraftWorkItem.php | 6 + .../Draft/InterventionMaintenanceWork.php | 30 + .../InventoryInterventionContext.php | 13 + .../InterventionEconomicContext.php | 51 + .../InterventionEconomicContextPage.php | 35 + .../InterventionEquipmentSnapshot.php | 41 + .../InterventionFactsScopeTooLarge.php | 18 + .../InterventionPublicationFacts.php | 52 + .../InterventionPublicationFactsPage.php | 35 + .../InterventionPublishedWorkFact.php | 49 + .../Resource/InterventionEquipmentWork.php | 34 + .../Result/InterventionInspectionResult.php | 38 + .../InterventionCostSourceFactsPort.php | 18 + .../InterventionInventoryContextPort.php | 15 + .../InterventionMaintenanceWorkPort.php | 60 + .../InterventionPublicationFactsPort.php | 118 + .../InterventionEconomicScopePort.php | 30 + .../InterventionEquipmentReplacementPort.php | 30 + .../InterventionEquipmentSnapshotPort.php | 45 + .../InterventionEquipmentWorkPort.php | 29 + .../InterventionInspectionResultPort.php | 32 + ...rventionPublicationResourcesIssuesPort.php | 16 + .../InterventionSiteCustomerSnapshotPort.php | 29 + .../Service/InterventionDraftFactory.php | 3 + .../Service/InterventionIssueFinder.php | 6 +- .../ListEquipmentOpenWorkHandler.php | 61 + .../ListEquipmentOpenWorkQuery.php | 32 + .../ListEquipmentOpenWorkResult.php | 31 + .../Domain/ValueObject/InterventionType.php | 6 + .../ValueObject/WorkItemExecutionResult.php | 146 + .../InterventionCostSourceFactsAdapter.php | 60 + .../InterventionEquipmentWorkAdapter.php | 71 + .../InterventionServiceReportAdapter.php | 21 + .../InterventionInventoryContextAdapter.php | 81 + ...ntionPublicationResourcesIssuesAdapter.php | 33 + .../InterventionMaintenanceWorkAdapter.php | 173 + .../DoctrinePublicationAdapter.php | 24 +- .../InterventionClosureSnapshotAdapter.php | 188 + ...nterventionOperationPublicationAdapter.php | 162 + .../InterventionPublicationFactsAdapter.php | 531 + .../ServiceRequestWorkAdapter.php | 371 + .../DataFixtures/InterventionFixtures.php | 69 +- .../InterventionPublicationFactsMapper.php | 317 + .../Mapper/InterventionViewMapper.php | 7 +- .../Doctrine/Record/InterventionRecord.php | 8 + .../Record/InterventionWorkItemRecord.php | 26 + .../InterventionWorkflowMutationSupport.php | 17 + .../InterventionWorkflowWorkItemWriter.php | 106 +- src/Intervention/MODULE.md | 86 + .../ExportInterventionReportController.php | 108 +- .../Api/Dto/Input/CreateInterventionInput.php | 2 +- .../Input/CreateInterventionTemplateInput.php | 2 +- .../Input/CreateInterventionWorkItemInput.php | 20 +- .../Input/UpdateInterventionTemplateInput.php | 2 +- .../Input/UpdateInterventionWorkItemInput.php | 7 + .../Dto/Output/EquipmentOpenWorkOutput.php | 50 + .../Api/Dto/Output/InterventionOutput.php | 7 + .../Dto/Output/InterventionWorkItemOutput.php | 22 + .../Api/Factory/InterventionOutputFactory.php | 4 + .../InterventionWorkItemOutputFactory.php | 7 + .../Api/Operation/InterventionOperations.php | 5 + .../InterventionWorkItemProcessor.php | 5 +- .../Provider/EquipmentOpenWorkProvider.php | 92 + .../Api/Provider/InterventionProvider.php | 2 +- .../Resource/EquipmentOpenWorkResource.php | 22 + .../InterventionExportCriteriaFactory.php | 2 +- .../Directory/InventoryPartDescriptor.php | 13 + .../Contract/Stock/InventoryCostFact.php | 15 + .../Stock/InventoryOperationReceipt.php | 16 + .../InventoryPublicationBlockedException.php | 12 + .../Stock/InventoryReceiptRequest.php | 13 + .../Contract/Stock/InventoryReceiptResult.php | 13 + .../Stock/InventoryReceiptReturnRequest.php | 13 + .../InventoryInterventionResourcesPort.php | 40 + .../Inbound/InventoryPartDirectoryPort.php | 20 + .../Inbound/InventoryStockReceiptPort.php | 26 + .../Port/Outbound/InventoryStorePort.php | 78 + .../ApplyInventoryStockCommand.php | 16 + .../ApplyInventoryStockHandler.php | 374 + .../ApplyInventoryStockResult.php | 16 + .../ManageInventoryReferenceCommand.php | 15 + .../ManageInventoryReferenceHandler.php | 49 + .../ManageInventoryReferenceResult.php | 15 + .../ListInventory/ListInventoryHandler.php | 67 + .../ListInventory/ListInventoryQuery.php | 18 + .../ListInventory/ListInventoryResult.php | 18 + .../Exception/InventoryConflictException.php | 12 + .../Exception/InventoryNotFoundException.php | 12 + .../Model/Stock/ConsumptionDeclaration.php | 15 + .../Domain/Model/Stock/InventoryReference.php | 22 + .../Domain/Model/Stock/StockBalance.php | 13 + .../Domain/Model/Stock/StockIssue.php | 13 + .../Domain/Model/Stock/StockMovement.php | 15 + .../Domain/Model/Stock/StockValuation.php | 49 + .../Domain/ValueObject/StockQuantity.php | 30 + .../InventoryInterventionResourcesAdapter.php | 70 + .../InventoryPartDirectoryAdapter.php | 46 + .../InventoryStockReceiptAdapter.php | 45 + .../Record/InventoryBalanceRecord.php | 36 + .../Record/InventoryDeclarationRecord.php | 58 + .../Record/InventoryMovementRecord.php | 71 + .../Record/InventoryOperationRecord.php | 30 + .../Doctrine/Record/InventoryPartRecord.php | 36 + .../Record/InventoryWarehouseRecord.php | 30 + .../Repository/InventoryRepository.php | 347 + src/Inventory/MODULE.md | 70 + .../Dto/Input/CorrectInventoryStockInput.php | 39 + .../Dto/Input/CreateInventoryPartInput.php | 31 + .../Input/CreateInventoryWarehouseInput.php | 22 + .../DeclareInventoryConsumptionInput.php | 49 + .../Api/Dto/Input/PatchInventoryPartInput.php | 23 + .../Input/PatchInventoryWarehouseInput.php | 19 + .../ReconcileInventoryConsumptionInput.php | 10 + .../Input/ReturnInventoryConsumptionInput.php | 31 + .../Api/Dto/Output/InventoryBalanceOutput.php | 28 + .../Dto/Output/InventoryConsumptionOutput.php | 55 + .../Dto/Output/InventoryMovementOutput.php | 61 + .../Api/Dto/Output/InventoryPartOutput.php | 31 + .../Dto/Output/InventoryValuationOutput.php | 23 + .../Dto/Output/InventoryWarehouseOutput.php | 25 + .../InventoryFailureSubscriber.php | 50 + .../Api/Operation/InventoryOperations.php | 43 + .../Api/Processor/InventoryProcessor.php | 94 + .../Api/Provider/InventoryProvider.php | 74 + .../Api/Resource/InventoryBalanceResource.php | 19 + .../Resource/InventoryConsumptionResource.php | 24 + .../Resource/InventoryMovementResource.php | 20 + .../Api/Resource/InventoryPartResource.php | 25 + .../Resource/InventoryWarehouseResource.php | 25 + .../Resource/InventorycorrectionsResource.php | 17 + .../Api/Resource/InventoryreturnsResource.php | 17 + .../Api/Service/InventoryAccess.php | 61 + .../Api/Service/InventoryOutputFactory.php | 83 + .../MaintenanceEquipmentOperationsDue.php | 20 + .../Plan/MaintenanceOccurrenceState.php | 27 + .../Plan/MaintenanceOperationResult.php | 24 + .../Contract/Plan/MaintenancePlanDetails.php | 13 + .../Contract/Plan/MaintenancePlanState.php | 33 + .../MaintenanceOperationResultsPort.php | 15 + .../Inbound/MaintenanceOperationsDuePort.php | 18 + .../Inbound/MaintenancePlanAuthorityPort.php | 20 + .../MaintenanceFacilityLifecyclePort.php | 11 + .../Plan/MaintenancePlanStorePort.php | 48 + .../MaintenancePlanAuthorityService.php | 54 + .../Service/MaintenanceScheduleService.php | 18 + .../GenerateInspectionCampaignHandler.php | 16 + .../ManageMaintenancePlanCommand.php | 35 + .../ManageMaintenancePlanHandler.php | 572 + .../ManageMaintenancePlanResult.php | 24 + .../SetMaintenanceScheduleOverrideHandler.php | 7 + .../RecomputeMaintenanceSchedulesHandler.php | 4 + .../ReadMaintenancePlansHandler.php | 64 + .../ReadMaintenancePlansQuery.php | 24 + .../ReadMaintenancePlansResult.php | 21 + .../Domain/Model/MaintenanceOccurrence.php | 286 + .../Domain/Model/MaintenancePlan.php | 260 + .../ValueObject/MaintenanceOperationKind.php | 55 + .../Domain/ValueObject/PlanCadence.php | 240 + .../MaintenanceOperationsDueAdapter.php | 120 + .../Lock/MaintenanceScheduleLockAdapter.php | 9 + .../Record/MaintenanceEngineRecord.php | 25 + .../Record/MaintenanceOccurrenceRecord.php | 53 + .../MaintenanceOperationReceiptRecord.php | 31 + .../Doctrine/Record/MaintenancePlanRecord.php | 71 + .../Repository/MaintenancePlanRepository.php | 196 + src/Maintenance/MODULE.md | 100 +- .../Dto/Input/ChangeMaintenancePlanInput.php | 45 + .../Input/GenerateMaintenancePlanInput.php | 19 + .../Output/GenerateMaintenancePlanOutput.php | 26 + .../Output/MaintenanceOccurrenceOutput.php | 32 + .../Output/MaintenancePlanEngineOutput.php | 23 + .../Api/Dto/Output/MaintenancePlanOutput.php | 70 + .../Output/MaintenancePlanPreviewOutput.php | 17 + .../MaintenancePlanExceptionSubscriber.php | 48 + .../Factory/MaintenancePlanOutputFactory.php | 41 + .../Operation/MaintenancePlanOperations.php | 29 + .../Processor/MaintenancePlanProcessor.php | 116 + .../Api/Provider/MaintenancePlanProvider.php | 82 + .../Api/Resource/MaintenancePlanResource.php | 41 + .../Contract/Cost/MaintenanceCostItem.php | 24 + .../Contract/Cost/MaintenanceCostPlanning.php | 16 + .../Contract/Cost/MaintenanceCostSnapshot.php | 13 + .../Contract/Cost/MaintenanceCostTotals.php | 16 + .../Contract/Cost/MaintenanceCostView.php | 13 + .../Contract/Cost/MaintenanceExpense.php | 15 + .../Currency/MaintenanceCurrencySnapshot.php | 13 + .../Contract/Rate/MaintenanceRateSnapshot.php | 18 + .../Reporting/MaintenanceEconomicAmount.php | 46 + .../Reporting/MaintenanceEconomicReport.php | 26 + .../Reporting/MaintenanceEconomicRow.php | 30 + .../MaintenanceCostPublicationPort.php | 13 + .../Port/Inbound/MaintenanceCostReadPort.php | 34 + .../Port/Inbound/MaintenanceCurrencyPort.php | 19 + .../Port/Inbound/MaintenanceRatePort.php | 13 + .../Currency/MaintenanceCurrencyStorePort.php | 24 + .../Outbound/MaintenanceCostStorePort.php | 48 + .../Rate/MaintenanceRateStorePort.php | 33 + .../Service/MaintenanceCostAccessGuard.php | 34 + .../MaintenanceCostAllocationResolver.php | 145 + .../Service/MaintenanceCostProjection.php | 79 + .../MaintenanceEconomicAccumulator.php | 48 + .../Service/MaintenanceEconomicAggregator.php | 247 + .../Service/MaintenanceEconomicBucket.php | 70 + .../Service/MaintenanceEconomicDirectory.php | 161 + .../WriteMaintenanceCostCommand.php | 18 + .../WriteMaintenanceCostHandler.php | 244 + .../WriteMaintenanceCostResult.php | 16 + .../ConfigureMaintenanceCurrencyCommand.php | 15 + .../ConfigureMaintenanceCurrencyHandler.php | 33 + .../ConfigureMaintenanceCurrencyResult.php | 16 + .../CreateMaintenanceRateCommand.php | 21 + .../CreateMaintenanceRateHandler.php | 58 + .../CreateMaintenanceRateResult.php | 16 + .../GetMaintenanceCostHandler.php | 26 + .../GetMaintenanceCostQuery.php | 15 + .../GetMaintenanceCostResult.php | 16 + .../ReadMaintenanceCurrencyHandler.php | 24 + .../ReadMaintenanceCurrencyQuery.php | 15 + .../ReadMaintenanceCurrencyResult.php | 16 + .../ListMaintenanceRatesHandler.php | 36 + .../ListMaintenanceRatesQuery.php | 15 + .../ListMaintenanceRatesResult.php | 19 + ...ListMaintenanceEconomicDossiersHandler.php | 51 + .../ListMaintenanceEconomicDossiersQuery.php | 21 + .../ListMaintenanceEconomicDossiersResult.php | 25 + .../ReadMaintenanceEconomicReportHandler.php | 76 + .../ReadMaintenanceEconomicReportQuery.php | 21 + .../ReadMaintenanceEconomicReportResult.php | 22 + .../Exception/MaintenanceCostException.php | 46 + .../Model/Currency/MaintenanceCurrency.php | 30 + .../Model/Rate/MaintenanceHourlyRate.php | 41 + .../Service/MaintenanceCostCalculator.php | 44 + .../ValueObject/MaintenanceEconomicWindow.php | 75 + .../Currency/MaintenanceCurrencyAdapter.php | 70 + .../MaintenanceCostPublicationAdapter.php | 55 + .../Adapter/Rate/MaintenanceRateAdapter.php | 115 + .../Reporting/MaintenanceCostReadAdapter.php | 49 + .../Currency/MaintenanceCurrencyRecord.php | 23 + .../Doctrine/Rate/MaintenanceRateRecord.php | 36 + .../Record/MaintenanceCostPlanningRecord.php | 36 + .../Record/MaintenanceCostSnapshotRecord.php | 67 + .../Record/MaintenanceExpenseRecord.php | 56 + .../Repository/MaintenanceCostRepository.php | 179 + src/MaintenanceCost/MODULE.md | 67 + .../Cost/CreateMaintenanceExpenseInput.php | 29 + .../Dto/Input/Cost/WriteCostPlanningInput.php | 23 + .../ConfigureMaintenanceCurrencyInput.php | 17 + .../Input/Rate/CreateMaintenanceRateInput.php | 32 + .../Dto/Output/Cost/MaintenanceCostOutput.php | 77 + .../Currency/MaintenanceCurrencyOutput.php | 31 + .../Dto/Output/Rate/MaintenanceRateOutput.php | 43 + .../MaintenanceEconomicDossierOutput.php | 91 + .../MaintenanceEconomicReportOutput.php | 144 + .../MaintenanceCostFailureSubscriber.php | 52 + .../Cost/MaintenanceCostOperations.php | 15 + .../MaintenanceCurrencyOperations.php | 13 + .../Rate/MaintenanceRateOperations.php | 13 + .../MaintenanceEconomicOperations.php | 19 + .../Cost/MaintenanceCostProcessor.php | 50 + .../ConfigureMaintenanceCurrencyProcessor.php | 38 + .../Rate/CreateMaintenanceRateProcessor.php | 38 + .../Provider/Cost/MaintenanceCostProvider.php | 43 + .../Currency/MaintenanceCurrencyProvider.php | 37 + .../Provider/Rate/MaintenanceRateProvider.php | 46 + .../Reporting/MaintenanceEconomicProvider.php | 74 + .../Resource/Cost/MaintenanceCostResource.php | 29 + .../Currency/MaintenanceCurrencyResource.php | 28 + .../Resource/Rate/MaintenanceRateResource.php | 30 + .../Reporting/MaintenanceEconomicResource.php | 31 + .../Contract/ExportDocumentSummary.php | 86 + .../Application/Contract/ExportOperation.php | 27 + .../Contract/ExportSourceState.php | 27 + .../MaintenanceExportIdentityPort.php | 27 + .../MaintenanceExportRepositoryPort.php | 111 + .../Outbound/MaintenanceExportSourcePort.php | 28 + .../ManageMaintenanceExportCommand.php | 29 + .../ManageMaintenanceExportHandler.php | 249 + .../ManageMaintenanceExportResult.php | 29 + .../ReadMaintenanceExportHandler.php | 110 + .../ReadMaintenanceExportQuery.php | 27 + .../ReadMaintenanceExportResult.php | 29 + .../Event/MaintenanceExportChangedEvent.php | 27 + .../Exception/MaintenanceExportException.php | 102 + .../Domain/Model/ExportDocument.php | 132 + .../Domain/Model/ExternalReference.php | 50 + .../Domain/ValueObject/ExportArtifact.php | 147 + .../Domain/ValueObject/ExportIdentity.php | 86 + .../Domain/ValueObject/ExportRows.php | 110 + .../ExportIdentityValidationAdapter.php | 47 + .../Adapter/Export/ExportSourceAdapter.php | 233 + .../MaintenanceExportDocumentRecord.php | 165 + .../MaintenanceExportOperationRecord.php | 68 + .../MaintenanceExternalReferenceRecord.php | 72 + .../MaintenanceExportRepository.php | 634 + src/MaintenanceExport/MODULE.md | 70 + .../Input/AdjustMaintenanceExportInput.php | 30 + .../Input/ConfirmMaintenanceExportInput.php | 30 + .../Input/CreateMaintenanceExportInput.php | 41 + .../WriteMaintenanceExportReferenceInput.php | 35 + .../Dto/Output/MaintenanceExportOutput.php | 129 + .../MaintenanceExportReferenceOutput.php | 76 + .../Output/MaintenanceExportSourceOutput.php | 94 + .../MaintenanceExportExceptionSubscriber.php | 67 + .../Operation/MaintenanceExportOperations.php | 34 + .../Processor/MaintenanceExportProcessor.php | 97 + .../Provider/MaintenanceExportProvider.php | 91 + .../Resource/MaintenanceExportResource.php | 40 + .../Service/OrganizationSetupService.php | 9 +- src/Onboarding/MODULE.md | 11 + .../CreateOrganizationCommand.php | 1 + .../CreateOrganizationHandler.php | 5 +- .../CreateOrganizationResult.php | 1 + .../UpdateOrganizationSettingsCommand.php | 1 + .../UpdateOrganizationSettingsHandler.php | 4 + .../GetOrganizationHandler.php | 1 + .../GetOrganization/GetOrganizationResult.php | 1 + .../ListUserOrganizationsHandler.php | 1 + .../Catalog/OrganizationPermissionCatalog.php | 19 + .../Catalog/OrganizationSystemRoleCatalog.php | 1 + .../Model/Organization/Organization.php | 25 + .../OrganizationCreationOptions.php | 3 +- .../RestoredOrganizationProfile.php | 3 +- .../OrganizationOperatingProfile.php | 32 + .../Doctrine/Mapper/OrganizationMapper.php | 3 + .../Doctrine/Record/OrganizationRecord.php | 8 + .../Repository/OrganizationRepository.php | 1 + src/Organization/MODULE.md | 14 + .../Organization/CreateOrganizationInput.php | 10 + .../UpdateOrganizationSettingsInput.php | 10 + .../Organization/OrganizationOutput.php | 9 + .../CreateOrganizationProcessor.php | 2 + .../UpdateOrganizationSettingsProcessor.php | 1 + .../ListUserOrganizationsProvider.php | 1 + .../Trait/OrganizationOutputMapperTrait.php | 1 + .../Contract/ProcurementOperationState.php | 16 + .../Contract/ProcurementReceiptState.php | 45 + .../Contract/ProcurementReturnState.php | 28 + .../Reporting/ProcurementEconomicAmount.php | 32 + .../Reporting/ProcurementEconomicOverview.php | 55 + ...ProcurementEconomicOverviewUnavailable.php | 34 + .../ProcurementEconomicOverviewPort.php | 38 + .../Persistence/ProcurementRepositoryPort.php | 69 + .../Service/ProcurementProjection.php | 76 + .../ManageProcurementCommand.php | 18 + .../ManageProcurementHandler.php | 584 + .../ManageProcurementResult.php | 18 + .../ReadProcurementHandler.php | 100 + .../ReadProcurement/ReadProcurementQuery.php | 15 + .../ReadProcurement/ReadProcurementResult.php | 18 + .../Domain/Event/ProcurementChangedEvent.php | 15 + .../Domain/Exception/ProcurementException.php | 46 + .../Domain/Model/PurchaseOrder.php | 565 + src/Procurement/Domain/Model/Supplier.php | 452 + .../Domain/ValueObject/ProcurementLine.php | 292 + .../ValueObject/PurchaseOrderStatus.php | 60 + .../ProcurementEconomicOverviewAdapter.php | 147 + .../Record/ProcurementOperationRecord.php | 36 + .../Record/ProcurementReceiptRecord.php | 73 + .../Record/ProcurementReturnRecord.php | 55 + .../Doctrine/Record/PurchaseOrderRecord.php | 49 + .../Doctrine/Record/SupplierRecord.php | 52 + .../Repository/ProcurementRepository.php | 313 + src/Procurement/MODULE.md | 84 + .../Dto/Input/ChangePurchaseOrderInput.php | 25 + .../Api/Dto/Input/ChangeSupplierInput.php | 29 + .../Dto/Input/IndividualizeReceiptInput.php | 17 + .../Dto/Input/ReceivePurchaseOrderInput.php | 34 + .../Input/ReturnProcurementReceiptInput.php | 25 + .../Dto/Output/ProcurementReceiptOutput.php | 112 + .../Dto/Output/ProcurementReturnOutput.php | 76 + .../Api/Dto/Output/PurchaseOrderOutput.php | 75 + .../Api/Dto/Output/SupplierOutput.php | 75 + .../ProcurementExceptionSubscriber.php | 50 + .../Api/Operation/ProcurementOperations.php | 47 + .../Api/Processor/ProcurementProcessor.php | 88 + .../Api/Provider/ProcurementProvider.php | 76 + .../Api/Resource/ProcurementResource.php | 45 + .../Contract/ServiceRequestView.php | 72 + .../ServiceRequestOriginUnavailable.php | 22 + .../Target/ServiceRequestEquipmentTarget.php | 19 + .../Target/ServiceRequestSiteTarget.php | 22 + .../Contract/Work/ServiceRequestWorkLink.php | 19 + .../Work/ServiceRequestWorkRequest.php | 29 + .../ServiceRequestEquipmentTargetPort.php | 20 + .../Outbound/ServiceRequestOriginPort.php | 20 + .../Outbound/ServiceRequestRepositoryPort.php | 35 + .../Outbound/ServiceRequestSiteTargetPort.php | 25 + .../Port/Outbound/ServiceRequestWorkPort.php | 25 + .../Service/ServiceRequestAccessGuard.php | 45 + .../Service/ServiceRequestTargetGuard.php | 46 + .../ChangeServiceRequestCommand.php | 18 + .../ChangeServiceRequestHandler.php | 67 + .../ChangeServiceRequestResult.php | 16 + .../ConvertServiceRequestCommand.php | 15 + .../ConvertServiceRequestHandler.php | 90 + .../ConvertServiceRequestResult.php | 16 + .../CreateServiceRequestCommand.php | 15 + .../CreateServiceRequestHandler.php | 37 + .../CreateServiceRequestResult.php | 16 + .../GetServiceRequestHandler.php | 27 + .../GetServiceRequestQuery.php | 15 + .../GetServiceRequestResult.php | 16 + .../ListServiceRequestsHandler.php | 48 + .../ListServiceRequestsQuery.php | 15 + .../ListServiceRequestsResult.php | 19 + .../Event/ServiceRequestChangedEvent.php | 15 + .../Exception/ServiceRequestException.php | 53 + .../Model/ServiceRequest/ServiceRequest.php | 272 + .../ServiceRequestConversionReceipt.php | 37 + .../ServiceRequestConversionReceiptRecord.php | 44 + .../Doctrine/Record/ServiceRequestRecord.php | 95 + .../Repository/ServiceRequestRepository.php | 219 + src/ServiceRequest/MODULE.md | 141 + .../Dto/Input/ConvertServiceRequestInput.php | 34 + .../Dto/Input/CreateServiceRequestInput.php | 63 + .../Dto/Input/DecisionServiceRequestInput.php | 20 + .../Dto/Input/QualifyServiceRequestInput.php | 26 + .../Dto/Input/UpdateServiceRequestInput.php | 33 + .../Api/Dto/Output/ServiceRequestOutput.php | 175 + .../ServiceRequestExceptionSubscriber.php | 41 + .../Operation/ServiceRequestOperations.php | 25 + .../Api/Processor/ServiceRequestProcessor.php | 119 + .../Api/Provider/ServiceRequestProvider.php | 75 + .../Api/Resource/ServiceRequestResource.php | 33 + .../Domain/ValueObject/DecimalAmount.php | 270 + templates/equipment/report.html.twig | 34 +- templates/intervention/report.html.twig | 12 + tests/Functional/Api/CustomerApiTest.php | 396 + tests/Functional/Api/EquipmentApiTest.php | 112 + .../Api/EquipmentInspectionSummaryApiTest.php | 267 + .../Api/EquipmentReplacementApiTest.php | 399 + .../Api/EquipmentTypeCatalogApiTest.php | 268 + tests/Functional/Api/InterventionApiTest.php | 187 + tests/Functional/Api/InventoryApiTest.php | 416 + .../Functional/Api/MaintenanceCostApiTest.php | 526 + .../Api/MaintenanceCurrencyRatesApiTest.php | 277 + .../Api/MaintenanceEconomicApiTest.php | 506 + .../Api/MaintenanceExportApiTest.php | 766 + .../Functional/Api/MaintenancePlanApiTest.php | 392 + .../OrganizationOperatingProfileApiTest.php | 277 + tests/Functional/Api/ParkAnomaliesApiTest.php | 310 + tests/Functional/Api/ProcurementApiTest.php | 516 + .../Functional/Api/ServiceRequestApiTest.php | 662 + ...nanceExportCustomerIdentityAdapterTest.php | 93 + ...terventionEquipmentSnapshotAdapterTest.php | 196 + ...anceExportEquipmentIdentityAdapterTest.php | 121 + ...intenanceExportSiteIdentityAdapterTest.php | 143 + ...nterventionInspectionResultAdapterTest.php | 178 + .../Repository/ParkAnomalyRepositoryTest.php | 196 + .../InterventionServiceReportAdapterTest.php | 30 + ...nterventionInventoryContextAdapterTest.php | 142 + .../DoctrinePublicationAdapterTest.php | 96 +- ...nterventionPublicationFactsAdapterTest.php | 685 + .../PreventiveOperationPublicationTest.php | 261 + .../ServiceRequestWorkAdapterTest.php | 383 + .../InterventionFixturesIntegrationTest.php | 47 +- .../Mapper/InterventionViewMapperTest.php | 4 +- .../InventoryLastPieceConcurrencyTest.php | 130 + .../MaintenanceOperationsDueAdapterTest.php | 111 + .../MaintenancePlanRepositoryTest.php | 248 + .../MaintenanceCurrencyAdapterTest.php | 68 + .../Rate/MaintenanceRateAdapterTest.php | 58 + .../MaintenanceCostPublicationTest.php | 349 + .../MaintenanceExportConcurrencyTest.php | 141 + .../MaintenanceExportRepositoryTest.php | 414 + ...ProcurementEconomicOverviewAdapterTest.php | 270 + .../Repository/ProcurementRepositoryTest.php | 256 + .../ServiceRequestRepositoryTest.php | 408 + .../UseCase/CustomerHandlersTest.php | 128 + .../Domain/Model/Customer/CustomerTest.php | 65 + ...nanceExportCustomerIdentityAdapterTest.php | 37 + .../Api/CustomerTransportTest.php | 79 + .../EquipmentReserveReceiptServiceTest.php | 84 + .../EquipmentSelectionScopeResolverTest.php | 45 + ...dInterventionServiceHistoryHandlerTest.php | 19 + .../ReplaceEquipmentHandlerTest.php | 234 + .../EquipmentTypeCatalogHandlersTest.php | 93 + .../GetEquipment/GetEquipmentHandlerTest.php | 39 + ...rganizationEquipmentSummaryHandlerTest.php | 39 + .../ListEquipmentsHandlerTest.php | 106 + .../EquipmentTypeDefinitionTest.php | 63 + .../Replacement/EquipmentReplacementTest.php | 98 + .../ValueObject/EquipmentIdentityTest.php | 48 + .../EquipmentValidationAdapterTest.php | 40 + ...anceExportEquipmentIdentityAdapterTest.php | 37 + .../Repository/EquipmentRepositoryTest.php | 2 +- .../ExportEquipmentReportControllerTest.php | 19 + .../EquipmentTypeCatalogPresentationTest.php | 120 + ...ipmentReplacementFailureSubscriberTest.php | 82 + .../Factory/EquipmentOutputFactoryTest.php | 102 + .../ReplaceEquipmentProcessorTest.php | 115 + .../CanonicalEquipmentProviderTest.php | 58 +- ...ganizationEquipmentSummaryProviderTest.php | 64 + .../FacilityCustomerReferenceTest.php | 52 + ...intenanceExportSiteIdentityAdapterTest.php | 37 + ...tEquipmentInspectionSummaryHandlerTest.php | 101 + .../GetParkAnomaliesSummaryHandlerTest.php | 62 + .../ListParkAnomaliesHandlerTest.php | 130 + ...nterventionInspectionResultAdapterTest.php | 82 + ...EquipmentInspectionSummaryProviderTest.php | 51 + .../Park/ParkAnomaliesProviderTest.php | 117 + .../Service/InterventionIssueFinderTest.php | 19 +- .../ExecutePublicationHandlerTest.php | 4 +- .../RequestPublicationHandlerTest.php | 4 +- .../ListEquipmentOpenWorkHandlerTest.php | 66 + .../ValueObject/InterventionTypeTest.php | 12 +- .../WorkItemExecutionResultTest.php | 53 + ...octrineInterventionActivityAdapterTest.php | 4 +- ...ventionOperationPublicationAdapterTest.php | 111 + .../ServiceRequestWorkAdapterTest.php | 74 + .../Mapper/InterventionViewMapperTest.php | 4 +- ...ExportInterventionReportControllerTest.php | 23 +- .../InterventionWorkItemProcessorTest.php | 11 + .../EquipmentOpenWorkProviderTest.php | 31 + .../ApplyInventoryStockHandlerTest.php | 290 + .../Inventory/Domain/StockValuationTest.php | 71 + .../InventoryEconomicIdentityTest.php | 39 + .../InventoryPartDirectoryAdapterTest.php | 41 + .../Presentation/InventoryProcessorTest.php | 69 + .../Presentation/InventoryProviderTest.php | 73 + .../ManageMaintenancePlanHandlerTest.php | 539 + .../ReadMaintenancePlansHandlerTest.php | 431 + .../Model/MaintenanceOccurrenceTest.php | 252 + .../Domain/Model/MaintenancePlanTest.php | 298 + .../MaintenanceOperationKindTest.php | 39 + .../Domain/ValueObject/PlanCadenceTest.php | 221 + .../MaintenancePlanProcessorTest.php | 492 + .../Provider/MaintenancePlanProviderTest.php | 335 + .../MaintenanceCostAllocationResolverTest.php | 302 + .../Service/MaintenanceCostProjectionTest.php | 91 + .../MaintenanceEconomicAggregatorTest.php | 723 + .../MaintenanceEconomicDirectoryTest.php | 380 + .../MaintenanceCurrencyHandlersTest.php | 74 + .../Rate/CreateMaintenanceRateHandlerTest.php | 120 + .../Rate/ListMaintenanceRatesHandlerTest.php | 47 + .../MaintenanceEconomicHandlersTest.php | 147 + .../Currency/MaintenanceCurrencyTest.php | 36 + .../Model/Rate/MaintenanceHourlyRateTest.php | 46 + .../Service/MaintenanceCostCalculatorTest.php | 44 + .../MaintenanceEconomicWindowTest.php | 153 + .../MaintenanceCurrencyAdapterTest.php | 25 + .../Rate/MaintenanceRateAdapterTest.php | 26 + .../MaintenanceCurrencyTransportTest.php | 57 + .../Api/Rate/MaintenanceRateTransportTest.php | 57 + .../MaintenanceEconomicProviderTest.php | 52 + .../ManageMaintenanceExportHandlerTest.php | 222 + .../Domain/Model/ExportDocumentTest.php | 152 + .../Domain/ValueObject/ExportArtifactTest.php | 161 + .../Domain/ValueObject/ExportIdentityTest.php | 84 + .../ValueObject/ExportIdentityUnicodeTest.php | 35 + .../Domain/ValueObject/ExportRowsTest.php | 137 + .../Export/ExportSourceAdapterTest.php | 180 + .../MaintenanceExportProcessorTest.php | 47 + .../MaintenanceExportProviderTest.php | 56 + .../OrganizationSetupOperatingProfileTest.php | 122 + .../CreateOrganizationHandlerTest.php | 2 +- .../OrganizationSystemRoleCatalogTest.php | 2 + .../OrganizationOperatingProfileTest.php | 80 + .../OrganizationMemberRepositoryTest.php | 1 + .../OrganizationRoleRepositoryTest.php | 1 + .../ManageProcurementHandlerTest.php | 342 + .../ReadProcurementHandlerTest.php | 521 + .../Domain/Model/PurchaseOrderTest.php | 508 + .../Procurement/Domain/Model/SupplierTest.php | 307 + .../ValueObject/ProcurementLineTest.php | 416 + .../ValueObject/PurchaseOrderStatusTest.php | 40 + .../Processor/ProcurementProcessorTest.php | 453 + .../Api/Provider/ProcurementProviderTest.php | 325 + .../ChangeServiceRequestHandlerTest.php | 219 + .../ConvertServiceRequestHandlerTest.php | 442 + .../ServiceRequest/ServiceRequestTest.php | 648 + .../Domain/ValueObject/DecimalAmountTest.php | 113 + translations/pdf.en.yaml | 34 + translations/pdf.es.yaml | 34 + translations/pdf.fr.yaml | 34 + 858 files changed, 95276 insertions(+), 21449 deletions(-) create mode 100644 config/modules/customer.yaml create mode 100644 config/modules/inventory.yaml create mode 100644 config/modules/maintenance_cost.yaml create mode 100644 config/modules/maintenance_export.yaml create mode 100644 config/modules/procurement.yaml create mode 100644 config/modules/service_request.yaml create mode 100644 migrations/main/Version20261006090000.php create mode 100644 migrations/main/Version20261006100000.php create mode 100644 migrations/main/Version20261006100001.php create mode 100644 migrations/main/Version20261006100002.php create mode 100644 migrations/main/Version20261006101000.php create mode 100644 migrations/main/Version20261006101001.php create mode 100644 migrations/main/Version20261006102000.php create mode 100644 migrations/main/Version20261006102001.php create mode 100644 migrations/main/Version20261006102002.php create mode 100644 migrations/main/Version20261006103000.php create mode 100644 migrations/main/Version20261006110000.php create mode 100644 migrations/main/Version20261006111000.php create mode 100644 migrations/main/Version20261006112000.php create mode 100644 migrations/main/Version20261006112001.php create mode 100644 migrations/main/Version20261006112002.php create mode 100644 migrations/main/Version20261006113000.php create mode 100644 migrations/main/Version20261006113001.php create mode 100644 migrations/main/Version20261006113002.php create mode 100644 migrations/main/Version20261007100000.php create mode 100644 migrations/main/Version20261007150000.php create mode 100644 src/Customer/Application/Contract/CustomerSnapshot.php create mode 100644 src/Customer/Application/Contract/CustomerView.php create mode 100644 src/Customer/Application/Port/Inbound/CustomerLookupPort.php create mode 100644 src/Customer/Application/Port/Outbound/CustomerRepositoryPort.php create mode 100644 src/Customer/Application/Service/CustomerAccessGuard.php create mode 100644 src/Customer/Application/UseCase/Command/ChangeCustomer/ChangeCustomerCommand.php create mode 100644 src/Customer/Application/UseCase/Command/ChangeCustomer/ChangeCustomerHandler.php create mode 100644 src/Customer/Application/UseCase/Command/ChangeCustomer/ChangeCustomerResult.php create mode 100644 src/Customer/Application/UseCase/Command/CreateCustomer/CreateCustomerCommand.php create mode 100644 src/Customer/Application/UseCase/Command/CreateCustomer/CreateCustomerHandler.php create mode 100644 src/Customer/Application/UseCase/Command/CreateCustomer/CreateCustomerResult.php create mode 100644 src/Customer/Application/UseCase/Query/GetCustomer/GetCustomerHandler.php create mode 100644 src/Customer/Application/UseCase/Query/GetCustomer/GetCustomerQuery.php create mode 100644 src/Customer/Application/UseCase/Query/GetCustomer/GetCustomerResult.php create mode 100644 src/Customer/Application/UseCase/Query/ListCustomers/ListCustomersHandler.php create mode 100644 src/Customer/Application/UseCase/Query/ListCustomers/ListCustomersQuery.php create mode 100644 src/Customer/Application/UseCase/Query/ListCustomers/ListCustomersResult.php create mode 100644 src/Customer/Domain/Event/CustomerChangedEvent.php create mode 100644 src/Customer/Domain/Exception/CustomerException.php create mode 100644 src/Customer/Domain/Model/Customer/Customer.php create mode 100644 src/Customer/Infrastructure/Adapter/Directory/CustomerLookupAdapter.php create mode 100644 src/Customer/Infrastructure/Adapter/Facility/FacilityCustomerValidationAdapter.php create mode 100644 src/Customer/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportCustomerIdentityAdapter.php create mode 100644 src/Customer/Infrastructure/Persistence/Doctrine/Record/CustomerRecord.php create mode 100644 src/Customer/Infrastructure/Persistence/Doctrine/Repository/CustomerRepository.php create mode 100644 src/Customer/MODULE.md create mode 100644 src/Customer/Presentation/Api/Dto/Input/ChangeCustomerInput.php create mode 100644 src/Customer/Presentation/Api/Dto/Input/CreateCustomerInput.php create mode 100644 src/Customer/Presentation/Api/Dto/Output/CustomerOutput.php create mode 100644 src/Customer/Presentation/Api/EventSubscriber/CustomerExceptionSubscriber.php create mode 100644 src/Customer/Presentation/Api/Operation/CustomerOperations.php create mode 100644 src/Customer/Presentation/Api/Processor/CustomerProcessor.php create mode 100644 src/Customer/Presentation/Api/Provider/CustomerProvider.php create mode 100644 src/Customer/Presentation/Api/Resource/CustomerResource.php create mode 100644 src/Equipment/Application/Contract/EquipmentTypeCatalog/EquipmentTypeDescriptor.php create mode 100644 src/Equipment/Application/Contract/Procurement/EquipmentReserveReceiptRequest.php create mode 100644 src/Equipment/Application/Contract/Procurement/EquipmentReserveReceiptResult.php create mode 100644 src/Equipment/Application/Contract/Replacement/EquipmentReplacementReceipt.php create mode 100644 src/Equipment/Application/Contract/Replacement/EquipmentReplacementSuccessor.php create mode 100644 src/Equipment/Application/Port/Inbound/EquipmentParkScopePort.php create mode 100644 src/Equipment/Application/Port/Inbound/EquipmentReserveReceiptPort.php create mode 100644 src/Equipment/Application/Port/Outbound/EquipmentReplacementRepositoryPort.php create mode 100644 src/Equipment/Application/Port/Outbound/EquipmentTypeCatalogPort.php create mode 100644 src/Equipment/Application/Port/Outbound/FacilityCustomerScopePort.php create mode 100644 src/Equipment/Application/Service/EquipmentReserveReceiptService.php create mode 100644 src/Equipment/Application/Service/EquipmentSelectionScopeResolver.php create mode 100644 src/Equipment/Application/UseCase/Command/Equipment/ReplaceEquipment/ReplaceEquipmentCommand.php create mode 100644 src/Equipment/Application/UseCase/Command/Equipment/ReplaceEquipment/ReplaceEquipmentHandler.php create mode 100644 src/Equipment/Application/UseCase/Command/Equipment/ReplaceEquipment/ReplaceEquipmentResult.php create mode 100644 src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/CreateEquipmentType/CreateEquipmentTypeCommand.php create mode 100644 src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/CreateEquipmentType/CreateEquipmentTypeHandler.php create mode 100644 src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/CreateEquipmentType/CreateEquipmentTypeResult.php create mode 100644 src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/PatchEquipmentType/PatchEquipmentTypeCommand.php create mode 100644 src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/PatchEquipmentType/PatchEquipmentTypeHandler.php create mode 100644 src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/PatchEquipmentType/PatchEquipmentTypeResult.php create mode 100644 src/Equipment/Application/UseCase/Query/Equipment/GetOrganizationEquipmentSummary/GetOrganizationEquipmentSummaryHandler.php create mode 100644 src/Equipment/Application/UseCase/Query/Equipment/GetOrganizationEquipmentSummary/GetOrganizationEquipmentSummaryQuery.php create mode 100644 src/Equipment/Application/UseCase/Query/EquipmentTypeCatalog/ListEquipmentTypes/ListEquipmentTypesHandler.php create mode 100644 src/Equipment/Application/UseCase/Query/EquipmentTypeCatalog/ListEquipmentTypes/ListEquipmentTypesQuery.php create mode 100644 src/Equipment/Application/UseCase/Query/EquipmentTypeCatalog/ListEquipmentTypes/ListEquipmentTypesResult.php create mode 100644 src/Equipment/Domain/Exception/EquipmentAssetCodeAlreadyExistsException.php create mode 100644 src/Equipment/Domain/Exception/EquipmentReplacementConflictException.php create mode 100644 src/Equipment/Domain/Exception/EquipmentTypeCatalogException.php create mode 100644 src/Equipment/Domain/Model/EquipmentTypeCatalog/EquipmentTypeDefinition.php create mode 100644 src/Equipment/Domain/Model/Replacement/EquipmentReplacement.php create mode 100644 src/Equipment/Domain/Model/Replacement/ReplacementEquipment.php create mode 100644 src/Equipment/Domain/ValueObject/EquipmentIdentity.php create mode 100644 src/Equipment/Domain/ValueObject/EquipmentTypeCode.php create mode 100644 src/Equipment/Infrastructure/Adapter/Inspection/EquipmentParkScopeAdapter.php create mode 100644 src/Equipment/Infrastructure/Adapter/Intervention/EquipmentReplacementLookupAdapter.php create mode 100644 src/Equipment/Infrastructure/Adapter/Intervention/InterventionEquipmentSnapshotAdapter.php create mode 100644 src/Equipment/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportEquipmentIdentityAdapter.php create mode 100644 src/Equipment/Infrastructure/Adapter/ServiceRequest/ServiceRequestEquipmentTargetAdapter.php create mode 100644 src/Equipment/Infrastructure/Persistence/Doctrine/Record/EquipmentReplacementReceiptRecord.php create mode 100644 src/Equipment/Infrastructure/Persistence/Doctrine/Record/EquipmentTypeCatalogRecord.php create mode 100644 src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentReplacementRepository.php create mode 100644 src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentTypeCatalogRepository.php create mode 100644 src/Equipment/Presentation/Api/Dto/Input/Equipment/ReplaceEquipmentInput.php create mode 100644 src/Equipment/Presentation/Api/Dto/Input/Equipment/ReplacementSuccessorInput.php create mode 100644 src/Equipment/Presentation/Api/Dto/Input/EquipmentTypeCatalog/CreateEquipmentTypeInput.php create mode 100644 src/Equipment/Presentation/Api/Dto/Input/EquipmentTypeCatalog/PatchEquipmentTypeInput.php create mode 100644 src/Equipment/Presentation/Api/Dto/Output/Equipment/ReplaceEquipmentOutput.php create mode 100644 src/Equipment/Presentation/Api/Dto/Output/EquipmentTypeCatalog/EquipmentTypeOutput.php create mode 100644 src/Equipment/Presentation/Api/EventSubscriber/EquipmentIdentityFailureSubscriber.php create mode 100644 src/Equipment/Presentation/Api/EventSubscriber/EquipmentReplacementFailureSubscriber.php create mode 100644 src/Equipment/Presentation/Api/EventSubscriber/EquipmentTypeCatalogFailureSubscriber.php create mode 100644 src/Equipment/Presentation/Api/Operation/EquipmentReplacementOperations.php create mode 100644 src/Equipment/Presentation/Api/Operation/EquipmentTypeCatalogOperations.php create mode 100644 src/Equipment/Presentation/Api/Processor/Equipment/ReplaceEquipmentProcessor.php create mode 100644 src/Equipment/Presentation/Api/Processor/EquipmentTypeCatalog/EquipmentTypeCatalogProcessor.php create mode 100644 src/Equipment/Presentation/Api/Provider/Equipment/OrganizationEquipmentSummaryProvider.php create mode 100644 src/Equipment/Presentation/Api/Provider/EquipmentTypeCatalog/EquipmentTypeCatalogProvider.php create mode 100644 src/Equipment/Presentation/Api/Resource/EquipmentReplacementResource.php create mode 100644 src/Equipment/Presentation/Api/Resource/EquipmentTypeCatalogResource.php create mode 100644 src/Equipment/Presentation/Api/Resource/OrganizationEquipmentSummaryResource.php create mode 100644 src/Equipment/Presentation/Api/Service/EquipmentTypeCatalogAccess.php create mode 100644 src/Facility/Application/Contract/FacilityCustomerUnavailable.php create mode 100644 src/Facility/Application/Port/Outbound/FacilityCustomerValidationPort.php create mode 100644 src/Facility/Domain/Exception/FacilityCustomerAssignmentException.php create mode 100644 src/Facility/Domain/Exception/FacilityCustomerScopeNotFoundException.php create mode 100644 src/Facility/Domain/ValueObject/FacilityCustomerReference.php create mode 100644 src/Facility/Infrastructure/Adapter/Equipment/FacilityCustomerScopeAdapter.php create mode 100644 src/Facility/Infrastructure/Adapter/Intervention/InterventionEconomicScopeAdapter.php create mode 100644 src/Facility/Infrastructure/Adapter/Intervention/InterventionSiteCustomerSnapshotAdapter.php create mode 100644 src/Facility/Infrastructure/Adapter/Maintenance/FacilityMaintenanceLifecycleAdapter.php create mode 100644 src/Facility/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportSiteIdentityAdapter.php create mode 100644 src/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapter.php create mode 100644 src/Facility/Presentation/Api/EventSubscriber/FacilityCustomerExceptionSubscriber.php create mode 100644 src/Inspection/Application/Contract/Equipment/EquipmentInspectionSummary.php create mode 100644 src/Inspection/Application/Contract/Park/ParkAnomaliesCounts.php create mode 100644 src/Inspection/Application/Contract/Park/ParkAnomalyEntry.php create mode 100644 src/Inspection/Application/Port/Outbound/EquipmentInspectionSummaryPort.php create mode 100644 src/Inspection/Application/Port/Outbound/ParkAnomalyGatewayPort.php create mode 100644 src/Inspection/Application/UseCase/Query/Equipment/GetEquipmentInspectionSummary/GetEquipmentInspectionSummaryHandler.php create mode 100644 src/Inspection/Application/UseCase/Query/Equipment/GetEquipmentInspectionSummary/GetEquipmentInspectionSummaryQuery.php create mode 100644 src/Inspection/Application/UseCase/Query/Equipment/GetEquipmentInspectionSummary/GetEquipmentInspectionSummaryResult.php create mode 100644 src/Inspection/Application/UseCase/Query/Park/GetParkAnomaliesSummary/GetParkAnomaliesSummaryHandler.php create mode 100644 src/Inspection/Application/UseCase/Query/Park/GetParkAnomaliesSummary/GetParkAnomaliesSummaryQuery.php create mode 100644 src/Inspection/Application/UseCase/Query/Park/GetParkAnomaliesSummary/GetParkAnomaliesSummaryResult.php create mode 100644 src/Inspection/Application/UseCase/Query/Park/ListParkAnomalies/ListParkAnomaliesHandler.php create mode 100644 src/Inspection/Application/UseCase/Query/Park/ListParkAnomalies/ListParkAnomaliesQuery.php create mode 100644 src/Inspection/Domain/Exception/EquipmentInspectionSummaryAccessDeniedException.php create mode 100644 src/Inspection/Domain/Exception/EquipmentInspectionSummaryNotFoundException.php create mode 100644 src/Inspection/Infrastructure/Adapter/Equipment/EquipmentInspectionSummaryAdapter.php create mode 100644 src/Inspection/Infrastructure/Adapter/Intervention/InterventionInspectionResultAdapter.php create mode 100644 src/Inspection/Infrastructure/Adapter/ServiceRequest/ServiceRequestOriginAdapter.php create mode 100644 src/Inspection/Infrastructure/Persistence/Doctrine/Repository/ParkAnomalyRepository.php create mode 100644 src/Inspection/Presentation/Api/Dto/Output/Equipment/EquipmentInspectionSummaryOutput.php create mode 100644 src/Inspection/Presentation/Api/Dto/Output/Park/ParkAnomaliesSummaryOutput.php create mode 100644 src/Inspection/Presentation/Api/Operation/ParkAnomaliesOperations.php create mode 100644 src/Inspection/Presentation/Api/Provider/Equipment/GetEquipmentInspectionSummaryProvider.php create mode 100644 src/Inspection/Presentation/Api/Provider/Park/ParkAnomaliesProvider.php create mode 100644 src/Inspection/Presentation/Api/Resource/EquipmentInspectionSummaryResource.php create mode 100644 src/Inspection/Presentation/Api/Resource/ParkAnomaliesResource.php create mode 100644 src/Inspection/Presentation/Api/Service/ParkAnomaliesAccess.php create mode 100644 src/Intervention/Application/Contract/Cost/InterventionCostContext.php create mode 100644 src/Intervention/Application/Contract/Cost/InterventionTimeCostFact.php create mode 100644 src/Intervention/Application/Contract/Draft/InterventionMaintenanceWork.php create mode 100644 src/Intervention/Application/Contract/Inventory/InventoryInterventionContext.php create mode 100644 src/Intervention/Application/Contract/Publication/InterventionEconomicContext.php create mode 100644 src/Intervention/Application/Contract/Publication/InterventionEconomicContextPage.php create mode 100644 src/Intervention/Application/Contract/Publication/InterventionEquipmentSnapshot.php create mode 100644 src/Intervention/Application/Contract/Publication/InterventionFactsScopeTooLarge.php create mode 100644 src/Intervention/Application/Contract/Publication/InterventionPublicationFacts.php create mode 100644 src/Intervention/Application/Contract/Publication/InterventionPublicationFactsPage.php create mode 100644 src/Intervention/Application/Contract/Publication/InterventionPublishedWorkFact.php create mode 100644 src/Intervention/Application/Contract/Resource/InterventionEquipmentWork.php create mode 100644 src/Intervention/Application/Contract/Result/InterventionInspectionResult.php create mode 100644 src/Intervention/Application/Port/Inbound/InterventionCostSourceFactsPort.php create mode 100644 src/Intervention/Application/Port/Inbound/InterventionInventoryContextPort.php create mode 100644 src/Intervention/Application/Port/Inbound/InterventionMaintenanceWorkPort.php create mode 100644 src/Intervention/Application/Port/Inbound/InterventionPublicationFactsPort.php create mode 100644 src/Intervention/Application/Port/Outbound/InterventionEconomicScopePort.php create mode 100644 src/Intervention/Application/Port/Outbound/InterventionEquipmentReplacementPort.php create mode 100644 src/Intervention/Application/Port/Outbound/InterventionEquipmentSnapshotPort.php create mode 100644 src/Intervention/Application/Port/Outbound/InterventionEquipmentWorkPort.php create mode 100644 src/Intervention/Application/Port/Outbound/InterventionInspectionResultPort.php create mode 100644 src/Intervention/Application/Port/Outbound/InterventionPublicationResourcesIssuesPort.php create mode 100644 src/Intervention/Application/Port/Outbound/InterventionSiteCustomerSnapshotPort.php create mode 100644 src/Intervention/Application/UseCase/Query/Equipment/ListEquipmentOpenWork/ListEquipmentOpenWorkHandler.php create mode 100644 src/Intervention/Application/UseCase/Query/Equipment/ListEquipmentOpenWork/ListEquipmentOpenWorkQuery.php create mode 100644 src/Intervention/Application/UseCase/Query/Equipment/ListEquipmentOpenWork/ListEquipmentOpenWorkResult.php create mode 100644 src/Intervention/Domain/ValueObject/WorkItemExecutionResult.php create mode 100644 src/Intervention/Infrastructure/Adapter/Cost/InterventionCostSourceFactsAdapter.php create mode 100644 src/Intervention/Infrastructure/Adapter/Equipment/InterventionEquipmentWorkAdapter.php create mode 100644 src/Intervention/Infrastructure/Adapter/Inventory/InterventionInventoryContextAdapter.php create mode 100644 src/Intervention/Infrastructure/Adapter/Inventory/InterventionPublicationResourcesIssuesAdapter.php create mode 100644 src/Intervention/Infrastructure/Adapter/Maintenance/InterventionMaintenanceWorkAdapter.php create mode 100644 src/Intervention/Infrastructure/Adapter/Publication/InterventionClosureSnapshotAdapter.php create mode 100644 src/Intervention/Infrastructure/Adapter/Publication/InterventionOperationPublicationAdapter.php create mode 100644 src/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapter.php create mode 100644 src/Intervention/Infrastructure/Adapter/ServiceRequest/ServiceRequestWorkAdapter.php create mode 100644 src/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionPublicationFactsMapper.php create mode 100644 src/Intervention/Presentation/Api/Dto/Output/EquipmentOpenWorkOutput.php create mode 100644 src/Intervention/Presentation/Api/Provider/EquipmentOpenWorkProvider.php create mode 100644 src/Intervention/Presentation/Api/Resource/EquipmentOpenWorkResource.php create mode 100644 src/Inventory/Application/Contract/Directory/InventoryPartDescriptor.php create mode 100644 src/Inventory/Application/Contract/Stock/InventoryCostFact.php create mode 100644 src/Inventory/Application/Contract/Stock/InventoryOperationReceipt.php create mode 100644 src/Inventory/Application/Contract/Stock/InventoryPublicationBlockedException.php create mode 100644 src/Inventory/Application/Contract/Stock/InventoryReceiptRequest.php create mode 100644 src/Inventory/Application/Contract/Stock/InventoryReceiptResult.php create mode 100644 src/Inventory/Application/Contract/Stock/InventoryReceiptReturnRequest.php create mode 100644 src/Inventory/Application/Port/Inbound/InventoryInterventionResourcesPort.php create mode 100644 src/Inventory/Application/Port/Inbound/InventoryPartDirectoryPort.php create mode 100644 src/Inventory/Application/Port/Inbound/InventoryStockReceiptPort.php create mode 100644 src/Inventory/Application/Port/Outbound/InventoryStorePort.php create mode 100644 src/Inventory/Application/UseCase/Command/ApplyInventoryStock/ApplyInventoryStockCommand.php create mode 100644 src/Inventory/Application/UseCase/Command/ApplyInventoryStock/ApplyInventoryStockHandler.php create mode 100644 src/Inventory/Application/UseCase/Command/ApplyInventoryStock/ApplyInventoryStockResult.php create mode 100644 src/Inventory/Application/UseCase/Command/ManageInventoryReference/ManageInventoryReferenceCommand.php create mode 100644 src/Inventory/Application/UseCase/Command/ManageInventoryReference/ManageInventoryReferenceHandler.php create mode 100644 src/Inventory/Application/UseCase/Command/ManageInventoryReference/ManageInventoryReferenceResult.php create mode 100644 src/Inventory/Application/UseCase/Query/ListInventory/ListInventoryHandler.php create mode 100644 src/Inventory/Application/UseCase/Query/ListInventory/ListInventoryQuery.php create mode 100644 src/Inventory/Application/UseCase/Query/ListInventory/ListInventoryResult.php create mode 100644 src/Inventory/Domain/Exception/InventoryConflictException.php create mode 100644 src/Inventory/Domain/Exception/InventoryNotFoundException.php create mode 100644 src/Inventory/Domain/Model/Stock/ConsumptionDeclaration.php create mode 100644 src/Inventory/Domain/Model/Stock/InventoryReference.php create mode 100644 src/Inventory/Domain/Model/Stock/StockBalance.php create mode 100644 src/Inventory/Domain/Model/Stock/StockIssue.php create mode 100644 src/Inventory/Domain/Model/Stock/StockMovement.php create mode 100644 src/Inventory/Domain/Model/Stock/StockValuation.php create mode 100644 src/Inventory/Domain/ValueObject/StockQuantity.php create mode 100644 src/Inventory/Infrastructure/Adapter/Intervention/InventoryInterventionResourcesAdapter.php create mode 100644 src/Inventory/Infrastructure/Adapter/Procurement/InventoryPartDirectoryAdapter.php create mode 100644 src/Inventory/Infrastructure/Adapter/Procurement/InventoryStockReceiptAdapter.php create mode 100644 src/Inventory/Infrastructure/Persistence/Doctrine/Record/InventoryBalanceRecord.php create mode 100644 src/Inventory/Infrastructure/Persistence/Doctrine/Record/InventoryDeclarationRecord.php create mode 100644 src/Inventory/Infrastructure/Persistence/Doctrine/Record/InventoryMovementRecord.php create mode 100644 src/Inventory/Infrastructure/Persistence/Doctrine/Record/InventoryOperationRecord.php create mode 100644 src/Inventory/Infrastructure/Persistence/Doctrine/Record/InventoryPartRecord.php create mode 100644 src/Inventory/Infrastructure/Persistence/Doctrine/Record/InventoryWarehouseRecord.php create mode 100644 src/Inventory/Infrastructure/Persistence/Doctrine/Repository/InventoryRepository.php create mode 100644 src/Inventory/MODULE.md create mode 100644 src/Inventory/Presentation/Api/Dto/Input/CorrectInventoryStockInput.php create mode 100644 src/Inventory/Presentation/Api/Dto/Input/CreateInventoryPartInput.php create mode 100644 src/Inventory/Presentation/Api/Dto/Input/CreateInventoryWarehouseInput.php create mode 100644 src/Inventory/Presentation/Api/Dto/Input/DeclareInventoryConsumptionInput.php create mode 100644 src/Inventory/Presentation/Api/Dto/Input/PatchInventoryPartInput.php create mode 100644 src/Inventory/Presentation/Api/Dto/Input/PatchInventoryWarehouseInput.php create mode 100644 src/Inventory/Presentation/Api/Dto/Input/ReconcileInventoryConsumptionInput.php create mode 100644 src/Inventory/Presentation/Api/Dto/Input/ReturnInventoryConsumptionInput.php create mode 100644 src/Inventory/Presentation/Api/Dto/Output/InventoryBalanceOutput.php create mode 100644 src/Inventory/Presentation/Api/Dto/Output/InventoryConsumptionOutput.php create mode 100644 src/Inventory/Presentation/Api/Dto/Output/InventoryMovementOutput.php create mode 100644 src/Inventory/Presentation/Api/Dto/Output/InventoryPartOutput.php create mode 100644 src/Inventory/Presentation/Api/Dto/Output/InventoryValuationOutput.php create mode 100644 src/Inventory/Presentation/Api/Dto/Output/InventoryWarehouseOutput.php create mode 100644 src/Inventory/Presentation/Api/EventSubscriber/InventoryFailureSubscriber.php create mode 100644 src/Inventory/Presentation/Api/Operation/InventoryOperations.php create mode 100644 src/Inventory/Presentation/Api/Processor/InventoryProcessor.php create mode 100644 src/Inventory/Presentation/Api/Provider/InventoryProvider.php create mode 100644 src/Inventory/Presentation/Api/Resource/InventoryBalanceResource.php create mode 100644 src/Inventory/Presentation/Api/Resource/InventoryConsumptionResource.php create mode 100644 src/Inventory/Presentation/Api/Resource/InventoryMovementResource.php create mode 100644 src/Inventory/Presentation/Api/Resource/InventoryPartResource.php create mode 100644 src/Inventory/Presentation/Api/Resource/InventoryWarehouseResource.php create mode 100644 src/Inventory/Presentation/Api/Resource/InventorycorrectionsResource.php create mode 100644 src/Inventory/Presentation/Api/Resource/InventoryreturnsResource.php create mode 100644 src/Inventory/Presentation/Api/Service/InventoryAccess.php create mode 100644 src/Inventory/Presentation/Api/Service/InventoryOutputFactory.php create mode 100644 src/Maintenance/Application/Contract/Plan/MaintenanceEquipmentOperationsDue.php create mode 100644 src/Maintenance/Application/Contract/Plan/MaintenanceOccurrenceState.php create mode 100644 src/Maintenance/Application/Contract/Plan/MaintenanceOperationResult.php create mode 100644 src/Maintenance/Application/Contract/Plan/MaintenancePlanDetails.php create mode 100644 src/Maintenance/Application/Contract/Plan/MaintenancePlanState.php create mode 100644 src/Maintenance/Application/Port/Inbound/MaintenanceOperationResultsPort.php create mode 100644 src/Maintenance/Application/Port/Inbound/MaintenanceOperationsDuePort.php create mode 100644 src/Maintenance/Application/Port/Inbound/MaintenancePlanAuthorityPort.php create mode 100644 src/Maintenance/Application/Port/Outbound/Directory/MaintenanceFacilityLifecyclePort.php create mode 100644 src/Maintenance/Application/Port/Outbound/Plan/MaintenancePlanStorePort.php create mode 100644 src/Maintenance/Application/Service/MaintenancePlanAuthorityService.php create mode 100644 src/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanCommand.php create mode 100644 src/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanHandler.php create mode 100644 src/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanResult.php create mode 100644 src/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansHandler.php create mode 100644 src/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansQuery.php create mode 100644 src/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansResult.php create mode 100644 src/Maintenance/Domain/Model/MaintenanceOccurrence.php create mode 100644 src/Maintenance/Domain/Model/MaintenancePlan.php create mode 100644 src/Maintenance/Domain/ValueObject/MaintenanceOperationKind.php create mode 100644 src/Maintenance/Domain/ValueObject/PlanCadence.php create mode 100644 src/Maintenance/Infrastructure/Adapter/Equipment/MaintenanceOperationsDueAdapter.php create mode 100644 src/Maintenance/Infrastructure/Persistence/Doctrine/Record/MaintenanceEngineRecord.php create mode 100644 src/Maintenance/Infrastructure/Persistence/Doctrine/Record/MaintenanceOccurrenceRecord.php create mode 100644 src/Maintenance/Infrastructure/Persistence/Doctrine/Record/MaintenanceOperationReceiptRecord.php create mode 100644 src/Maintenance/Infrastructure/Persistence/Doctrine/Record/MaintenancePlanRecord.php create mode 100644 src/Maintenance/Infrastructure/Persistence/Doctrine/Repository/MaintenancePlanRepository.php create mode 100644 src/Maintenance/Presentation/Api/Dto/Input/ChangeMaintenancePlanInput.php create mode 100644 src/Maintenance/Presentation/Api/Dto/Input/GenerateMaintenancePlanInput.php create mode 100644 src/Maintenance/Presentation/Api/Dto/Output/GenerateMaintenancePlanOutput.php create mode 100644 src/Maintenance/Presentation/Api/Dto/Output/MaintenanceOccurrenceOutput.php create mode 100644 src/Maintenance/Presentation/Api/Dto/Output/MaintenancePlanEngineOutput.php create mode 100644 src/Maintenance/Presentation/Api/Dto/Output/MaintenancePlanOutput.php create mode 100644 src/Maintenance/Presentation/Api/Dto/Output/MaintenancePlanPreviewOutput.php create mode 100644 src/Maintenance/Presentation/Api/EventSubscriber/MaintenancePlanExceptionSubscriber.php create mode 100644 src/Maintenance/Presentation/Api/Factory/MaintenancePlanOutputFactory.php create mode 100644 src/Maintenance/Presentation/Api/Operation/MaintenancePlanOperations.php create mode 100644 src/Maintenance/Presentation/Api/Processor/MaintenancePlanProcessor.php create mode 100644 src/Maintenance/Presentation/Api/Provider/MaintenancePlanProvider.php create mode 100644 src/Maintenance/Presentation/Api/Resource/MaintenancePlanResource.php create mode 100644 src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostItem.php create mode 100644 src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostPlanning.php create mode 100644 src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostSnapshot.php create mode 100644 src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostTotals.php create mode 100644 src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostView.php create mode 100644 src/MaintenanceCost/Application/Contract/Cost/MaintenanceExpense.php create mode 100644 src/MaintenanceCost/Application/Contract/Currency/MaintenanceCurrencySnapshot.php create mode 100644 src/MaintenanceCost/Application/Contract/Rate/MaintenanceRateSnapshot.php create mode 100644 src/MaintenanceCost/Application/Contract/Reporting/MaintenanceEconomicAmount.php create mode 100644 src/MaintenanceCost/Application/Contract/Reporting/MaintenanceEconomicReport.php create mode 100644 src/MaintenanceCost/Application/Contract/Reporting/MaintenanceEconomicRow.php create mode 100644 src/MaintenanceCost/Application/Port/Inbound/MaintenanceCostPublicationPort.php create mode 100644 src/MaintenanceCost/Application/Port/Inbound/MaintenanceCostReadPort.php create mode 100644 src/MaintenanceCost/Application/Port/Inbound/MaintenanceCurrencyPort.php create mode 100644 src/MaintenanceCost/Application/Port/Inbound/MaintenanceRatePort.php create mode 100644 src/MaintenanceCost/Application/Port/Outbound/Currency/MaintenanceCurrencyStorePort.php create mode 100644 src/MaintenanceCost/Application/Port/Outbound/MaintenanceCostStorePort.php create mode 100644 src/MaintenanceCost/Application/Port/Outbound/Rate/MaintenanceRateStorePort.php create mode 100644 src/MaintenanceCost/Application/Service/MaintenanceCostAccessGuard.php create mode 100644 src/MaintenanceCost/Application/Service/MaintenanceCostAllocationResolver.php create mode 100644 src/MaintenanceCost/Application/Service/MaintenanceCostProjection.php create mode 100644 src/MaintenanceCost/Application/Service/MaintenanceEconomicAccumulator.php create mode 100644 src/MaintenanceCost/Application/Service/MaintenanceEconomicAggregator.php create mode 100644 src/MaintenanceCost/Application/Service/MaintenanceEconomicBucket.php create mode 100644 src/MaintenanceCost/Application/Service/MaintenanceEconomicDirectory.php create mode 100644 src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostCommand.php create mode 100644 src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostHandler.php create mode 100644 src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostResult.php create mode 100644 src/MaintenanceCost/Application/UseCase/Command/Currency/ConfigureMaintenanceCurrency/ConfigureMaintenanceCurrencyCommand.php create mode 100644 src/MaintenanceCost/Application/UseCase/Command/Currency/ConfigureMaintenanceCurrency/ConfigureMaintenanceCurrencyHandler.php create mode 100644 src/MaintenanceCost/Application/UseCase/Command/Currency/ConfigureMaintenanceCurrency/ConfigureMaintenanceCurrencyResult.php create mode 100644 src/MaintenanceCost/Application/UseCase/Command/Rate/CreateMaintenanceRate/CreateMaintenanceRateCommand.php create mode 100644 src/MaintenanceCost/Application/UseCase/Command/Rate/CreateMaintenanceRate/CreateMaintenanceRateHandler.php create mode 100644 src/MaintenanceCost/Application/UseCase/Command/Rate/CreateMaintenanceRate/CreateMaintenanceRateResult.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Cost/GetMaintenanceCost/GetMaintenanceCostHandler.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Cost/GetMaintenanceCost/GetMaintenanceCostQuery.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Cost/GetMaintenanceCost/GetMaintenanceCostResult.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Currency/ReadMaintenanceCurrency/ReadMaintenanceCurrencyHandler.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Currency/ReadMaintenanceCurrency/ReadMaintenanceCurrencyQuery.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Currency/ReadMaintenanceCurrency/ReadMaintenanceCurrencyResult.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Rate/ListMaintenanceRates/ListMaintenanceRatesHandler.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Rate/ListMaintenanceRates/ListMaintenanceRatesQuery.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Rate/ListMaintenanceRates/ListMaintenanceRatesResult.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Reporting/ListMaintenanceEconomicDossiers/ListMaintenanceEconomicDossiersHandler.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Reporting/ListMaintenanceEconomicDossiers/ListMaintenanceEconomicDossiersQuery.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Reporting/ListMaintenanceEconomicDossiers/ListMaintenanceEconomicDossiersResult.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Reporting/ReadMaintenanceEconomicReport/ReadMaintenanceEconomicReportHandler.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Reporting/ReadMaintenanceEconomicReport/ReadMaintenanceEconomicReportQuery.php create mode 100644 src/MaintenanceCost/Application/UseCase/Query/Reporting/ReadMaintenanceEconomicReport/ReadMaintenanceEconomicReportResult.php create mode 100644 src/MaintenanceCost/Domain/Exception/MaintenanceCostException.php create mode 100644 src/MaintenanceCost/Domain/Model/Currency/MaintenanceCurrency.php create mode 100644 src/MaintenanceCost/Domain/Model/Rate/MaintenanceHourlyRate.php create mode 100644 src/MaintenanceCost/Domain/Service/MaintenanceCostCalculator.php create mode 100644 src/MaintenanceCost/Domain/ValueObject/MaintenanceEconomicWindow.php create mode 100644 src/MaintenanceCost/Infrastructure/Adapter/Currency/MaintenanceCurrencyAdapter.php create mode 100644 src/MaintenanceCost/Infrastructure/Adapter/Publication/MaintenanceCostPublicationAdapter.php create mode 100644 src/MaintenanceCost/Infrastructure/Adapter/Rate/MaintenanceRateAdapter.php create mode 100644 src/MaintenanceCost/Infrastructure/Adapter/Reporting/MaintenanceCostReadAdapter.php create mode 100644 src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Currency/MaintenanceCurrencyRecord.php create mode 100644 src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Rate/MaintenanceRateRecord.php create mode 100644 src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Record/MaintenanceCostPlanningRecord.php create mode 100644 src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Record/MaintenanceCostSnapshotRecord.php create mode 100644 src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Record/MaintenanceExpenseRecord.php create mode 100644 src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Repository/MaintenanceCostRepository.php create mode 100644 src/MaintenanceCost/MODULE.md create mode 100644 src/MaintenanceCost/Presentation/Api/Dto/Input/Cost/CreateMaintenanceExpenseInput.php create mode 100644 src/MaintenanceCost/Presentation/Api/Dto/Input/Cost/WriteCostPlanningInput.php create mode 100644 src/MaintenanceCost/Presentation/Api/Dto/Input/Currency/ConfigureMaintenanceCurrencyInput.php create mode 100644 src/MaintenanceCost/Presentation/Api/Dto/Input/Rate/CreateMaintenanceRateInput.php create mode 100644 src/MaintenanceCost/Presentation/Api/Dto/Output/Cost/MaintenanceCostOutput.php create mode 100644 src/MaintenanceCost/Presentation/Api/Dto/Output/Currency/MaintenanceCurrencyOutput.php create mode 100644 src/MaintenanceCost/Presentation/Api/Dto/Output/Rate/MaintenanceRateOutput.php create mode 100644 src/MaintenanceCost/Presentation/Api/Dto/Output/Reporting/MaintenanceEconomicDossierOutput.php create mode 100644 src/MaintenanceCost/Presentation/Api/Dto/Output/Reporting/MaintenanceEconomicReportOutput.php create mode 100644 src/MaintenanceCost/Presentation/Api/EventSubscriber/MaintenanceCostFailureSubscriber.php create mode 100644 src/MaintenanceCost/Presentation/Api/Operation/Cost/MaintenanceCostOperations.php create mode 100644 src/MaintenanceCost/Presentation/Api/Operation/Currency/MaintenanceCurrencyOperations.php create mode 100644 src/MaintenanceCost/Presentation/Api/Operation/Rate/MaintenanceRateOperations.php create mode 100644 src/MaintenanceCost/Presentation/Api/Operation/Reporting/MaintenanceEconomicOperations.php create mode 100644 src/MaintenanceCost/Presentation/Api/Processor/Cost/MaintenanceCostProcessor.php create mode 100644 src/MaintenanceCost/Presentation/Api/Processor/Currency/ConfigureMaintenanceCurrencyProcessor.php create mode 100644 src/MaintenanceCost/Presentation/Api/Processor/Rate/CreateMaintenanceRateProcessor.php create mode 100644 src/MaintenanceCost/Presentation/Api/Provider/Cost/MaintenanceCostProvider.php create mode 100644 src/MaintenanceCost/Presentation/Api/Provider/Currency/MaintenanceCurrencyProvider.php create mode 100644 src/MaintenanceCost/Presentation/Api/Provider/Rate/MaintenanceRateProvider.php create mode 100644 src/MaintenanceCost/Presentation/Api/Provider/Reporting/MaintenanceEconomicProvider.php create mode 100644 src/MaintenanceCost/Presentation/Api/Resource/Cost/MaintenanceCostResource.php create mode 100644 src/MaintenanceCost/Presentation/Api/Resource/Currency/MaintenanceCurrencyResource.php create mode 100644 src/MaintenanceCost/Presentation/Api/Resource/Rate/MaintenanceRateResource.php create mode 100644 src/MaintenanceCost/Presentation/Api/Resource/Reporting/MaintenanceEconomicResource.php create mode 100644 src/MaintenanceExport/Application/Contract/ExportDocumentSummary.php create mode 100644 src/MaintenanceExport/Application/Contract/ExportOperation.php create mode 100644 src/MaintenanceExport/Application/Contract/ExportSourceState.php create mode 100644 src/MaintenanceExport/Application/Port/Outbound/MaintenanceExportIdentityPort.php create mode 100644 src/MaintenanceExport/Application/Port/Outbound/MaintenanceExportRepositoryPort.php create mode 100644 src/MaintenanceExport/Application/Port/Outbound/MaintenanceExportSourcePort.php create mode 100644 src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportCommand.php create mode 100644 src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportHandler.php create mode 100644 src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportResult.php create mode 100644 src/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportHandler.php create mode 100644 src/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportQuery.php create mode 100644 src/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportResult.php create mode 100644 src/MaintenanceExport/Domain/Event/MaintenanceExportChangedEvent.php create mode 100644 src/MaintenanceExport/Domain/Exception/MaintenanceExportException.php create mode 100644 src/MaintenanceExport/Domain/Model/ExportDocument.php create mode 100644 src/MaintenanceExport/Domain/Model/ExternalReference.php create mode 100644 src/MaintenanceExport/Domain/ValueObject/ExportArtifact.php create mode 100644 src/MaintenanceExport/Domain/ValueObject/ExportIdentity.php create mode 100644 src/MaintenanceExport/Domain/ValueObject/ExportRows.php create mode 100644 src/MaintenanceExport/Infrastructure/Adapter/Export/ExportIdentityValidationAdapter.php create mode 100644 src/MaintenanceExport/Infrastructure/Adapter/Export/ExportSourceAdapter.php create mode 100644 src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Record/MaintenanceExportDocumentRecord.php create mode 100644 src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Record/MaintenanceExportOperationRecord.php create mode 100644 src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Record/MaintenanceExternalReferenceRecord.php create mode 100644 src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Repository/MaintenanceExportRepository.php create mode 100644 src/MaintenanceExport/MODULE.md create mode 100644 src/MaintenanceExport/Presentation/Api/Dto/Input/AdjustMaintenanceExportInput.php create mode 100644 src/MaintenanceExport/Presentation/Api/Dto/Input/ConfirmMaintenanceExportInput.php create mode 100644 src/MaintenanceExport/Presentation/Api/Dto/Input/CreateMaintenanceExportInput.php create mode 100644 src/MaintenanceExport/Presentation/Api/Dto/Input/WriteMaintenanceExportReferenceInput.php create mode 100644 src/MaintenanceExport/Presentation/Api/Dto/Output/MaintenanceExportOutput.php create mode 100644 src/MaintenanceExport/Presentation/Api/Dto/Output/MaintenanceExportReferenceOutput.php create mode 100644 src/MaintenanceExport/Presentation/Api/Dto/Output/MaintenanceExportSourceOutput.php create mode 100644 src/MaintenanceExport/Presentation/Api/EventSubscriber/MaintenanceExportExceptionSubscriber.php create mode 100644 src/MaintenanceExport/Presentation/Api/Operation/MaintenanceExportOperations.php create mode 100644 src/MaintenanceExport/Presentation/Api/Processor/MaintenanceExportProcessor.php create mode 100644 src/MaintenanceExport/Presentation/Api/Provider/MaintenanceExportProvider.php create mode 100644 src/MaintenanceExport/Presentation/Api/Resource/MaintenanceExportResource.php create mode 100644 src/Organization/Domain/ValueObject/OrganizationOperatingProfile.php create mode 100644 src/Procurement/Application/Contract/ProcurementOperationState.php create mode 100644 src/Procurement/Application/Contract/ProcurementReceiptState.php create mode 100644 src/Procurement/Application/Contract/ProcurementReturnState.php create mode 100644 src/Procurement/Application/Contract/Reporting/ProcurementEconomicAmount.php create mode 100644 src/Procurement/Application/Contract/Reporting/ProcurementEconomicOverview.php create mode 100644 src/Procurement/Application/Contract/Reporting/ProcurementEconomicOverviewUnavailable.php create mode 100644 src/Procurement/Application/Port/Inbound/ProcurementEconomicOverviewPort.php create mode 100644 src/Procurement/Application/Port/Outbound/Persistence/ProcurementRepositoryPort.php create mode 100644 src/Procurement/Application/Service/ProcurementProjection.php create mode 100644 src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementCommand.php create mode 100644 src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandler.php create mode 100644 src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementResult.php create mode 100644 src/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementHandler.php create mode 100644 src/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementQuery.php create mode 100644 src/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementResult.php create mode 100644 src/Procurement/Domain/Event/ProcurementChangedEvent.php create mode 100644 src/Procurement/Domain/Exception/ProcurementException.php create mode 100644 src/Procurement/Domain/Model/PurchaseOrder.php create mode 100644 src/Procurement/Domain/Model/Supplier.php create mode 100644 src/Procurement/Domain/ValueObject/ProcurementLine.php create mode 100644 src/Procurement/Domain/ValueObject/PurchaseOrderStatus.php create mode 100644 src/Procurement/Infrastructure/Adapter/Reporting/ProcurementEconomicOverviewAdapter.php create mode 100644 src/Procurement/Infrastructure/Persistence/Doctrine/Record/ProcurementOperationRecord.php create mode 100644 src/Procurement/Infrastructure/Persistence/Doctrine/Record/ProcurementReceiptRecord.php create mode 100644 src/Procurement/Infrastructure/Persistence/Doctrine/Record/ProcurementReturnRecord.php create mode 100644 src/Procurement/Infrastructure/Persistence/Doctrine/Record/PurchaseOrderRecord.php create mode 100644 src/Procurement/Infrastructure/Persistence/Doctrine/Record/SupplierRecord.php create mode 100644 src/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepository.php create mode 100644 src/Procurement/MODULE.md create mode 100644 src/Procurement/Presentation/Api/Dto/Input/ChangePurchaseOrderInput.php create mode 100644 src/Procurement/Presentation/Api/Dto/Input/ChangeSupplierInput.php create mode 100644 src/Procurement/Presentation/Api/Dto/Input/IndividualizeReceiptInput.php create mode 100644 src/Procurement/Presentation/Api/Dto/Input/ReceivePurchaseOrderInput.php create mode 100644 src/Procurement/Presentation/Api/Dto/Input/ReturnProcurementReceiptInput.php create mode 100644 src/Procurement/Presentation/Api/Dto/Output/ProcurementReceiptOutput.php create mode 100644 src/Procurement/Presentation/Api/Dto/Output/ProcurementReturnOutput.php create mode 100644 src/Procurement/Presentation/Api/Dto/Output/PurchaseOrderOutput.php create mode 100644 src/Procurement/Presentation/Api/Dto/Output/SupplierOutput.php create mode 100644 src/Procurement/Presentation/Api/EventSubscriber/ProcurementExceptionSubscriber.php create mode 100644 src/Procurement/Presentation/Api/Operation/ProcurementOperations.php create mode 100644 src/Procurement/Presentation/Api/Processor/ProcurementProcessor.php create mode 100644 src/Procurement/Presentation/Api/Provider/ProcurementProvider.php create mode 100644 src/Procurement/Presentation/Api/Resource/ProcurementResource.php create mode 100644 src/ServiceRequest/Application/Contract/ServiceRequestView.php create mode 100644 src/ServiceRequest/Application/Contract/Source/ServiceRequestOriginUnavailable.php create mode 100644 src/ServiceRequest/Application/Contract/Target/ServiceRequestEquipmentTarget.php create mode 100644 src/ServiceRequest/Application/Contract/Target/ServiceRequestSiteTarget.php create mode 100644 src/ServiceRequest/Application/Contract/Work/ServiceRequestWorkLink.php create mode 100644 src/ServiceRequest/Application/Contract/Work/ServiceRequestWorkRequest.php create mode 100644 src/ServiceRequest/Application/Port/Outbound/ServiceRequestEquipmentTargetPort.php create mode 100644 src/ServiceRequest/Application/Port/Outbound/ServiceRequestOriginPort.php create mode 100644 src/ServiceRequest/Application/Port/Outbound/ServiceRequestRepositoryPort.php create mode 100644 src/ServiceRequest/Application/Port/Outbound/ServiceRequestSiteTargetPort.php create mode 100644 src/ServiceRequest/Application/Port/Outbound/ServiceRequestWorkPort.php create mode 100644 src/ServiceRequest/Application/Service/ServiceRequestAccessGuard.php create mode 100644 src/ServiceRequest/Application/Service/ServiceRequestTargetGuard.php create mode 100644 src/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestCommand.php create mode 100644 src/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestHandler.php create mode 100644 src/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestResult.php create mode 100644 src/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestCommand.php create mode 100644 src/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestHandler.php create mode 100644 src/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestResult.php create mode 100644 src/ServiceRequest/Application/UseCase/Command/CreateServiceRequest/CreateServiceRequestCommand.php create mode 100644 src/ServiceRequest/Application/UseCase/Command/CreateServiceRequest/CreateServiceRequestHandler.php create mode 100644 src/ServiceRequest/Application/UseCase/Command/CreateServiceRequest/CreateServiceRequestResult.php create mode 100644 src/ServiceRequest/Application/UseCase/Query/GetServiceRequest/GetServiceRequestHandler.php create mode 100644 src/ServiceRequest/Application/UseCase/Query/GetServiceRequest/GetServiceRequestQuery.php create mode 100644 src/ServiceRequest/Application/UseCase/Query/GetServiceRequest/GetServiceRequestResult.php create mode 100644 src/ServiceRequest/Application/UseCase/Query/ListServiceRequests/ListServiceRequestsHandler.php create mode 100644 src/ServiceRequest/Application/UseCase/Query/ListServiceRequests/ListServiceRequestsQuery.php create mode 100644 src/ServiceRequest/Application/UseCase/Query/ListServiceRequests/ListServiceRequestsResult.php create mode 100644 src/ServiceRequest/Domain/Event/ServiceRequestChangedEvent.php create mode 100644 src/ServiceRequest/Domain/Exception/ServiceRequestException.php create mode 100644 src/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequest.php create mode 100644 src/ServiceRequest/Domain/ValueObject/ServiceRequestConversionReceipt.php create mode 100644 src/ServiceRequest/Infrastructure/Persistence/Doctrine/Record/ServiceRequestConversionReceiptRecord.php create mode 100644 src/ServiceRequest/Infrastructure/Persistence/Doctrine/Record/ServiceRequestRecord.php create mode 100644 src/ServiceRequest/Infrastructure/Persistence/Doctrine/Repository/ServiceRequestRepository.php create mode 100644 src/ServiceRequest/MODULE.md create mode 100644 src/ServiceRequest/Presentation/Api/Dto/Input/ConvertServiceRequestInput.php create mode 100644 src/ServiceRequest/Presentation/Api/Dto/Input/CreateServiceRequestInput.php create mode 100644 src/ServiceRequest/Presentation/Api/Dto/Input/DecisionServiceRequestInput.php create mode 100644 src/ServiceRequest/Presentation/Api/Dto/Input/QualifyServiceRequestInput.php create mode 100644 src/ServiceRequest/Presentation/Api/Dto/Input/UpdateServiceRequestInput.php create mode 100644 src/ServiceRequest/Presentation/Api/Dto/Output/ServiceRequestOutput.php create mode 100644 src/ServiceRequest/Presentation/Api/EventSubscriber/ServiceRequestExceptionSubscriber.php create mode 100644 src/ServiceRequest/Presentation/Api/Operation/ServiceRequestOperations.php create mode 100644 src/ServiceRequest/Presentation/Api/Processor/ServiceRequestProcessor.php create mode 100644 src/ServiceRequest/Presentation/Api/Provider/ServiceRequestProvider.php create mode 100644 src/ServiceRequest/Presentation/Api/Resource/ServiceRequestResource.php create mode 100644 src/Shared/Domain/ValueObject/DecimalAmount.php create mode 100644 tests/Functional/Api/CustomerApiTest.php create mode 100644 tests/Functional/Api/EquipmentInspectionSummaryApiTest.php create mode 100644 tests/Functional/Api/EquipmentReplacementApiTest.php create mode 100644 tests/Functional/Api/EquipmentTypeCatalogApiTest.php create mode 100644 tests/Functional/Api/InventoryApiTest.php create mode 100644 tests/Functional/Api/MaintenanceCostApiTest.php create mode 100644 tests/Functional/Api/MaintenanceCurrencyRatesApiTest.php create mode 100644 tests/Functional/Api/MaintenanceEconomicApiTest.php create mode 100644 tests/Functional/Api/MaintenanceExportApiTest.php create mode 100644 tests/Functional/Api/MaintenancePlanApiTest.php create mode 100644 tests/Functional/Api/OrganizationOperatingProfileApiTest.php create mode 100644 tests/Functional/Api/ParkAnomaliesApiTest.php create mode 100644 tests/Functional/Api/ProcurementApiTest.php create mode 100644 tests/Functional/Api/ServiceRequestApiTest.php create mode 100644 tests/Integration/Customer/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportCustomerIdentityAdapterTest.php create mode 100644 tests/Integration/Equipment/Infrastructure/Adapter/Intervention/InterventionEquipmentSnapshotAdapterTest.php create mode 100644 tests/Integration/Equipment/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportEquipmentIdentityAdapterTest.php create mode 100644 tests/Integration/Facility/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportSiteIdentityAdapterTest.php create mode 100644 tests/Integration/Inspection/Infrastructure/Adapter/Intervention/InterventionInspectionResultAdapterTest.php create mode 100644 tests/Integration/Inspection/Infrastructure/Persistence/Doctrine/Repository/ParkAnomalyRepositoryTest.php create mode 100644 tests/Integration/Intervention/Infrastructure/Adapter/Inventory/InterventionInventoryContextAdapterTest.php create mode 100644 tests/Integration/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapterTest.php create mode 100644 tests/Integration/Intervention/Infrastructure/Adapter/Publication/PreventiveOperationPublicationTest.php create mode 100644 tests/Integration/Intervention/Infrastructure/Adapter/ServiceRequest/ServiceRequestWorkAdapterTest.php create mode 100644 tests/Integration/Inventory/InventoryLastPieceConcurrencyTest.php create mode 100644 tests/Integration/Maintenance/Infrastructure/Adapter/Equipment/MaintenanceOperationsDueAdapterTest.php create mode 100644 tests/Integration/Maintenance/Infrastructure/Persistence/Doctrine/Repository/MaintenancePlanRepositoryTest.php create mode 100644 tests/Integration/MaintenanceCost/Infrastructure/Adapter/Currency/MaintenanceCurrencyAdapterTest.php create mode 100644 tests/Integration/MaintenanceCost/Infrastructure/Adapter/Rate/MaintenanceRateAdapterTest.php create mode 100644 tests/Integration/MaintenanceCost/Infrastructure/Publication/MaintenanceCostPublicationTest.php create mode 100644 tests/Integration/MaintenanceExport/Infrastructure/Persistence/Doctrine/Repository/MaintenanceExportConcurrencyTest.php create mode 100644 tests/Integration/MaintenanceExport/Infrastructure/Persistence/Doctrine/Repository/MaintenanceExportRepositoryTest.php create mode 100644 tests/Integration/Procurement/Infrastructure/Adapter/Reporting/ProcurementEconomicOverviewAdapterTest.php create mode 100644 tests/Integration/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepositoryTest.php create mode 100644 tests/Integration/ServiceRequest/Infrastructure/Persistence/Doctrine/Repository/ServiceRequestRepositoryTest.php create mode 100644 tests/Unit/Customer/Application/UseCase/CustomerHandlersTest.php create mode 100644 tests/Unit/Customer/Domain/Model/Customer/CustomerTest.php create mode 100644 tests/Unit/Customer/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportCustomerIdentityAdapterTest.php create mode 100644 tests/Unit/Customer/Presentation/Api/CustomerTransportTest.php create mode 100644 tests/Unit/Equipment/Application/Service/EquipmentReserveReceiptServiceTest.php create mode 100644 tests/Unit/Equipment/Application/Service/EquipmentSelectionScopeResolverTest.php create mode 100644 tests/Unit/Equipment/Application/UseCase/Command/Equipment/ReplaceEquipment/ReplaceEquipmentHandlerTest.php create mode 100644 tests/Unit/Equipment/Application/UseCase/EquipmentTypeCatalog/EquipmentTypeCatalogHandlersTest.php create mode 100644 tests/Unit/Equipment/Application/UseCase/Query/Equipment/GetOrganizationEquipmentSummary/GetOrganizationEquipmentSummaryHandlerTest.php create mode 100644 tests/Unit/Equipment/Domain/Model/EquipmentTypeCatalog/EquipmentTypeDefinitionTest.php create mode 100644 tests/Unit/Equipment/Domain/Model/Replacement/EquipmentReplacementTest.php create mode 100644 tests/Unit/Equipment/Domain/ValueObject/EquipmentIdentityTest.php create mode 100644 tests/Unit/Equipment/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportEquipmentIdentityAdapterTest.php create mode 100644 tests/Unit/Equipment/Presentation/Api/EquipmentTypeCatalog/EquipmentTypeCatalogPresentationTest.php create mode 100644 tests/Unit/Equipment/Presentation/Api/EventSubscriber/EquipmentReplacementFailureSubscriberTest.php create mode 100644 tests/Unit/Equipment/Presentation/Api/Factory/EquipmentOutputFactoryTest.php create mode 100644 tests/Unit/Equipment/Presentation/Api/Processor/Equipment/ReplaceEquipmentProcessorTest.php create mode 100644 tests/Unit/Equipment/Presentation/Api/Provider/Equipment/OrganizationEquipmentSummaryProviderTest.php create mode 100644 tests/Unit/Facility/Domain/ValueObject/FacilityCustomerReferenceTest.php create mode 100644 tests/Unit/Facility/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportSiteIdentityAdapterTest.php create mode 100644 tests/Unit/Inspection/Application/UseCase/Query/Equipment/GetEquipmentInspectionSummary/GetEquipmentInspectionSummaryHandlerTest.php create mode 100644 tests/Unit/Inspection/Application/UseCase/Query/Park/GetParkAnomaliesSummary/GetParkAnomaliesSummaryHandlerTest.php create mode 100644 tests/Unit/Inspection/Application/UseCase/Query/Park/ListParkAnomalies/ListParkAnomaliesHandlerTest.php create mode 100644 tests/Unit/Inspection/Infrastructure/Adapter/Intervention/InterventionInspectionResultAdapterTest.php create mode 100644 tests/Unit/Inspection/Presentation/Api/Provider/Equipment/GetEquipmentInspectionSummaryProviderTest.php create mode 100644 tests/Unit/Inspection/Presentation/Api/Provider/Park/ParkAnomaliesProviderTest.php create mode 100644 tests/Unit/Intervention/Application/UseCase/Query/Equipment/ListEquipmentOpenWork/ListEquipmentOpenWorkHandlerTest.php create mode 100644 tests/Unit/Intervention/Domain/ValueObject/WorkItemExecutionResultTest.php create mode 100644 tests/Unit/Intervention/Infrastructure/Adapter/Publication/InterventionOperationPublicationAdapterTest.php create mode 100644 tests/Unit/Intervention/Infrastructure/Adapter/ServiceRequest/ServiceRequestWorkAdapterTest.php create mode 100644 tests/Unit/Intervention/Presentation/Api/Provider/EquipmentOpenWorkProviderTest.php create mode 100644 tests/Unit/Inventory/Application/ApplyInventoryStockHandlerTest.php create mode 100644 tests/Unit/Inventory/Domain/StockValuationTest.php create mode 100644 tests/Unit/Inventory/Infrastructure/Adapter/Intervention/InventoryEconomicIdentityTest.php create mode 100644 tests/Unit/Inventory/Infrastructure/InventoryPartDirectoryAdapterTest.php create mode 100644 tests/Unit/Inventory/Presentation/InventoryProcessorTest.php create mode 100644 tests/Unit/Inventory/Presentation/InventoryProviderTest.php create mode 100644 tests/Unit/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanHandlerTest.php create mode 100644 tests/Unit/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansHandlerTest.php create mode 100644 tests/Unit/Maintenance/Domain/Model/MaintenanceOccurrenceTest.php create mode 100644 tests/Unit/Maintenance/Domain/Model/MaintenancePlanTest.php create mode 100644 tests/Unit/Maintenance/Domain/ValueObject/MaintenanceOperationKindTest.php create mode 100644 tests/Unit/Maintenance/Domain/ValueObject/PlanCadenceTest.php create mode 100644 tests/Unit/Maintenance/Presentation/Api/Processor/MaintenancePlanProcessorTest.php create mode 100644 tests/Unit/Maintenance/Presentation/Api/Provider/MaintenancePlanProviderTest.php create mode 100644 tests/Unit/MaintenanceCost/Application/Service/MaintenanceCostAllocationResolverTest.php create mode 100644 tests/Unit/MaintenanceCost/Application/Service/MaintenanceCostProjectionTest.php create mode 100644 tests/Unit/MaintenanceCost/Application/Service/MaintenanceEconomicAggregatorTest.php create mode 100644 tests/Unit/MaintenanceCost/Application/Service/MaintenanceEconomicDirectoryTest.php create mode 100644 tests/Unit/MaintenanceCost/Application/UseCase/Currency/MaintenanceCurrencyHandlersTest.php create mode 100644 tests/Unit/MaintenanceCost/Application/UseCase/Rate/CreateMaintenanceRateHandlerTest.php create mode 100644 tests/Unit/MaintenanceCost/Application/UseCase/Rate/ListMaintenanceRatesHandlerTest.php create mode 100644 tests/Unit/MaintenanceCost/Application/UseCase/Reporting/MaintenanceEconomicHandlersTest.php create mode 100644 tests/Unit/MaintenanceCost/Domain/Model/Currency/MaintenanceCurrencyTest.php create mode 100644 tests/Unit/MaintenanceCost/Domain/Model/Rate/MaintenanceHourlyRateTest.php create mode 100644 tests/Unit/MaintenanceCost/Domain/Service/MaintenanceCostCalculatorTest.php create mode 100644 tests/Unit/MaintenanceCost/Domain/ValueObject/MaintenanceEconomicWindowTest.php create mode 100644 tests/Unit/MaintenanceCost/Infrastructure/Adapter/Currency/MaintenanceCurrencyAdapterTest.php create mode 100644 tests/Unit/MaintenanceCost/Infrastructure/Adapter/Rate/MaintenanceRateAdapterTest.php create mode 100644 tests/Unit/MaintenanceCost/Presentation/Api/Currency/MaintenanceCurrencyTransportTest.php create mode 100644 tests/Unit/MaintenanceCost/Presentation/Api/Rate/MaintenanceRateTransportTest.php create mode 100644 tests/Unit/MaintenanceCost/Presentation/Api/Reporting/MaintenanceEconomicProviderTest.php create mode 100644 tests/Unit/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportHandlerTest.php create mode 100644 tests/Unit/MaintenanceExport/Domain/Model/ExportDocumentTest.php create mode 100644 tests/Unit/MaintenanceExport/Domain/ValueObject/ExportArtifactTest.php create mode 100644 tests/Unit/MaintenanceExport/Domain/ValueObject/ExportIdentityTest.php create mode 100644 tests/Unit/MaintenanceExport/Domain/ValueObject/ExportIdentityUnicodeTest.php create mode 100644 tests/Unit/MaintenanceExport/Domain/ValueObject/ExportRowsTest.php create mode 100644 tests/Unit/MaintenanceExport/Infrastructure/Adapter/Export/ExportSourceAdapterTest.php create mode 100644 tests/Unit/MaintenanceExport/Presentation/Api/Processor/MaintenanceExportProcessorTest.php create mode 100644 tests/Unit/MaintenanceExport/Presentation/Api/Provider/MaintenanceExportProviderTest.php create mode 100644 tests/Unit/Onboarding/Application/Service/OrganizationSetupOperatingProfileTest.php create mode 100644 tests/Unit/Organization/Domain/ValueObject/OrganizationOperatingProfileTest.php create mode 100644 tests/Unit/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandlerTest.php create mode 100644 tests/Unit/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementHandlerTest.php create mode 100644 tests/Unit/Procurement/Domain/Model/PurchaseOrderTest.php create mode 100644 tests/Unit/Procurement/Domain/Model/SupplierTest.php create mode 100644 tests/Unit/Procurement/Domain/ValueObject/ProcurementLineTest.php create mode 100644 tests/Unit/Procurement/Domain/ValueObject/PurchaseOrderStatusTest.php create mode 100644 tests/Unit/Procurement/Presentation/Api/Processor/ProcurementProcessorTest.php create mode 100644 tests/Unit/Procurement/Presentation/Api/Provider/ProcurementProviderTest.php create mode 100644 tests/Unit/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestHandlerTest.php create mode 100644 tests/Unit/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestHandlerTest.php create mode 100644 tests/Unit/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequestTest.php create mode 100644 tests/Unit/Shared/Domain/ValueObject/DecimalAmountTest.php diff --git a/README.md b/README.md index effea0d55..a78393ece 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Fireguard API -Symfony/API Platform backend for Fireguard identity, organization access, -field operations and collaboration. The [web application](https://github.com/DevSkyLex/fireguard-web) +Symfony/API Platform backend for FireGuard fire-equipment park management, +organization access, controls, maintenance and field interventions. The [web application](https://github.com/DevSkyLex/fireguard-web) is a separate repository. Dependency versions live in [composer.json](composer.json) and its lockfile. @@ -23,7 +23,10 @@ The API uses four-layer modules and two independent PostgreSQL databases: ## Features Identity, OAuth/OIDC, organizations and billing, facilities and equipment, -inspections, maintenance, interventions, workload, messaging, notifications, +internal customers, inspections, independent preventive operations, traceable +replacement, repair requests, interventions, quantitative parts, procurement, +private costs and economic reports, versioned maintenance exports, workload, +messaging, notifications, assistant, calendar, approvals, automation, imports and webhooks. The [documentation index](docs/README.md) links every module contract. @@ -58,7 +61,17 @@ See [Organization](src/Organization/MODULE.md) and [Billing](src/Billing/MODULE. See [Facility](src/Facility/MODULE.md), [Equipment](src/Equipment/MODULE.md), [Inspection](src/Inspection/MODULE.md), [Maintenance](src/Maintenance/MODULE.md) -and [Compliance](src/Compliance/MODULE.md). +and [Compliance](src/Compliance/MODULE.md). Internal customers and optional site +ownership are described by [Customer](src/Customer/MODULE.md). + +### Maintenance resources and economic reporting + +See [ServiceRequest](src/ServiceRequest/MODULE.md), [Inventory](src/Inventory/MODULE.md), +[Procurement](src/Procurement/MODULE.md), [MaintenanceCost](src/MaintenanceCost/MODULE.md) +and [MaintenanceExport](src/MaintenanceExport/MODULE.md). Internal financial facts +require dedicated permissions and stay outside ordinary customer reports. Retained +exports distinguish generation from confirmed import; corrections append linked +adjustments. Commercial invoicing remains in the external ERP. ### Interventions, approval and automation diff --git a/composer.json b/composer.json index e226dc72f..18ae8b197 100644 --- a/composer.json +++ b/composer.json @@ -4,6 +4,7 @@ "minimum-stability": "stable", "prefer-stable": true, "require": { + "ext-bcmath": "*", "php": ">=8.4", "ext-ctype": "*", "ext-iconv": "*", @@ -97,7 +98,13 @@ "Approval\\": "src/Approval/", "Assistant\\": "src/Assistant/", "Calendar\\": "src/Calendar/", - "Workload\\": "src/Workload/" + "Workload\\": "src/Workload/", + "Customer\\": "src/Customer/", + "ServiceRequest\\": "src/ServiceRequest/", + "Inventory\\": "src/Inventory/", + "Procurement\\": "src/Procurement/", + "MaintenanceCost\\": "src/MaintenanceCost/", + "MaintenanceExport\\": "src/MaintenanceExport/" }, "classmap": [ "src/Kernel.php" diff --git a/composer.lock b/composer.lock index 44012bdea..63e48c781 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3868650f8915ae9725995648180eb8ba", + "content-hash": "bc680544362e4ea38fc2c70d436779db", "packages": [ { "name": "api-platform/doctrine-common", @@ -16923,6 +16923,7 @@ "prefer-lowest": false, "platform": { "php": ">=8.4", + "ext-bcmath": "*", "ext-ctype": "*", "ext-iconv": "*" }, diff --git a/config/modules/customer.yaml b/config/modules/customer.yaml new file mode 100644 index 000000000..6ec26358f --- /dev/null +++ b/config/modules/customer.yaml @@ -0,0 +1,53 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + Customer\Presentation\: + resource: '../../src/Customer/Presentation' + + Customer\Application\Service\CustomerAccessGuard: ~ + + Customer\Infrastructure\Persistence\Doctrine\Repository\CustomerRepository: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Customer\Application\Port\Outbound\CustomerRepositoryPort: + alias: Customer\Infrastructure\Persistence\Doctrine\Repository\CustomerRepository + + Customer\Infrastructure\Adapter\Directory\CustomerLookupAdapter: ~ + + Customer\Infrastructure\Adapter\Facility\FacilityCustomerValidationAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Customer\Application\Port\Inbound\CustomerLookupPort: + alias: Customer\Infrastructure\Adapter\Directory\CustomerLookupAdapter + + customer.main_transaction_manager: + class: Shared\Infrastructure\Symfony\Adapter\Outbound\DoctrineTransactionManagerAdapter + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Customer\Application\UseCase\Command\CreateCustomer\CreateCustomerHandler: + arguments: + $transactions: '@customer.main_transaction_manager' + $events: '@Shared\Infrastructure\Messaging\Outbox\MainTransactionEventDispatcher' + tags: ['messenger.message_handler'] + + Customer\Application\UseCase\Command\ChangeCustomer\ChangeCustomerHandler: + arguments: + $transactions: '@customer.main_transaction_manager' + $events: '@Shared\Infrastructure\Messaging\Outbox\MainTransactionEventDispatcher' + tags: ['messenger.message_handler'] + + Customer\Application\UseCase\Query\GetCustomer\GetCustomerHandler: + tags: ['messenger.message_handler'] + + Customer\Application\UseCase\Query\ListCustomers\ListCustomersHandler: + tags: ['messenger.message_handler'] + + Customer\Infrastructure\Adapter\MaintenanceExport\MaintenanceExportCustomerIdentityAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + tags: ['maintenance_export.identity_validator'] diff --git a/config/modules/equipment.yaml b/config/modules/equipment.yaml index 018083218..3017494a4 100644 --- a/config/modules/equipment.yaml +++ b/config/modules/equipment.yaml @@ -3,6 +3,23 @@ services: autowire: true autoconfigure: true + Equipment\Infrastructure\Adapter\Intervention\InterventionEquipmentSnapshotAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Intervention\Application\Port\Outbound\InterventionEquipmentSnapshotPort: + alias: Equipment\Infrastructure\Adapter\Intervention\InterventionEquipmentSnapshotAdapter + + Equipment\Infrastructure\Adapter\ServiceRequest\ServiceRequestEquipmentTargetAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Equipment\Application\Service\EquipmentReserveReceiptService: + arguments: + $transactions: '@equipment.main_transaction_manager' + Equipment\Application\Port\Inbound\EquipmentReserveReceiptPort: + alias: Equipment\Application\Service\EquipmentReserveReceiptService + Equipment\Presentation\: resource: '../../src/Equipment/Presentation' @@ -306,3 +323,57 @@ services: Equipment\Infrastructure\EventSubscriber\EquipmentPlanAuditListener: arguments: $events: '@Shared\Infrastructure\Messaging\Outbox\TransactionalEventDispatcher' + + Equipment\Infrastructure\Persistence\Doctrine\Repository\EquipmentTypeCatalogRepository: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Equipment\Application\Port\Outbound\EquipmentTypeCatalogPort: + alias: Equipment\Infrastructure\Persistence\Doctrine\Repository\EquipmentTypeCatalogRepository + + Equipment\Application\UseCase\Query\EquipmentTypeCatalog\ListEquipmentTypes\ListEquipmentTypesHandler: + tags: ['messenger.message_handler'] + + Equipment\Application\UseCase\Command\EquipmentTypeCatalog\CreateEquipmentType\CreateEquipmentTypeHandler: + tags: ['messenger.message_handler'] + + Equipment\Application\UseCase\Command\EquipmentTypeCatalog\PatchEquipmentType\PatchEquipmentTypeHandler: + tags: ['messenger.message_handler'] + + Equipment\Infrastructure\Persistence\Doctrine\Repository\EquipmentReplacementRepository: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Equipment\Application\Port\Outbound\EquipmentReplacementRepositoryPort: + alias: Equipment\Infrastructure\Persistence\Doctrine\Repository\EquipmentReplacementRepository + + Equipment\Application\UseCase\Command\Equipment\ReplaceEquipment\ReplaceEquipmentHandler: + arguments: + $transactionManager: '@equipment.main_transaction_manager' + tags: ['messenger.message_handler'] + + Equipment\Application\Service\EquipmentSelectionScopeResolver: ~ + + Equipment\Application\UseCase\Query\Equipment\GetOrganizationEquipmentSummary\GetOrganizationEquipmentSummaryHandler: + tags: ['messenger.message_handler'] + + Equipment\Infrastructure\Adapter\Inspection\EquipmentParkScopeAdapter: ~ + + Equipment\Application\Port\Inbound\EquipmentParkScopePort: + alias: Equipment\Infrastructure\Adapter\Inspection\EquipmentParkScopeAdapter + + Equipment\Infrastructure\Adapter\Intervention\EquipmentReplacementLookupAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Equipment\Application\Port\Outbound\FacilityCustomerScopePort: + alias: Facility\Infrastructure\Adapter\Equipment\FacilityCustomerScopeAdapter + + Facility\Infrastructure\Adapter\Equipment\FacilityCustomerScopeAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Equipment\Infrastructure\Adapter\MaintenanceExport\MaintenanceExportEquipmentIdentityAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + tags: ['maintenance_export.identity_validator'] diff --git a/config/modules/facility.yaml b/config/modules/facility.yaml index 9226f1dd9..bee3eb84b 100644 --- a/config/modules/facility.yaml +++ b/config/modules/facility.yaml @@ -3,9 +3,34 @@ services: autowire: true autoconfigure: true + Facility\Infrastructure\Adapter\Intervention\InterventionEconomicScopeAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Intervention\Application\Port\Outbound\InterventionEconomicScopePort: + alias: Facility\Infrastructure\Adapter\Intervention\InterventionEconomicScopeAdapter + Facility\Presentation\: resource: '../../src/Facility/Presentation' + Facility\Infrastructure\Adapter\ServiceRequest\ServiceRequestSiteTargetAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Facility\Application\Port\Outbound\FacilityCustomerValidationPort: + alias: Customer\Infrastructure\Adapter\Facility\FacilityCustomerValidationAdapter + + Facility\Infrastructure\Adapter\Intervention\InterventionSiteCustomerSnapshotAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Intervention\Application\Port\Outbound\InterventionSiteCustomerSnapshotPort: + alias: Facility\Infrastructure\Adapter\Intervention\InterventionSiteCustomerSnapshotAdapter + + Facility\Infrastructure\Adapter\Maintenance\FacilityMaintenanceLifecycleAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + Facility\Presentation\Api\Provider\Facility\CanonicalFacilityProvider: arguments: $entityManager: '@doctrine.orm.main_entity_manager' @@ -366,3 +391,8 @@ services: Facility\Infrastructure\EventSubscriber\FacilityPlanAuditListener: arguments: $events: '@Shared\Infrastructure\Messaging\Outbox\TransactionalEventDispatcher' + + Facility\Infrastructure\Adapter\MaintenanceExport\MaintenanceExportSiteIdentityAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + tags: ['maintenance_export.identity_validator'] diff --git a/config/modules/inspection.yaml b/config/modules/inspection.yaml index 76e9b0232..e9926f810 100644 --- a/config/modules/inspection.yaml +++ b/config/modules/inspection.yaml @@ -1,4 +1,11 @@ services: + Inspection\Infrastructure\Adapter\ServiceRequest\ServiceRequestOriginAdapter: + arguments: + $connection: '@doctrine.dbal.main_connection' + + ServiceRequest\Application\Port\Outbound\ServiceRequestOriginPort: + alias: Inspection\Infrastructure\Adapter\ServiceRequest\ServiceRequestOriginAdapter + Inspection\Infrastructure\Adapter\Maintenance\InspectionMaintenanceHistoryAdapter: arguments: $connection: '@doctrine.dbal.main_connection' @@ -30,6 +37,25 @@ services: # InspectionResponseProvider names no entity manager either: both of its # reads went to Application\UseCase\Query\Response\. + # Repositories + Inspection\Infrastructure\Adapter\Equipment\EquipmentInspectionSummaryAdapter: + arguments: + $connection: '@doctrine.dbal.main_connection' + + Inspection\Application\Port\Outbound\EquipmentInspectionSummaryPort: + alias: Inspection\Infrastructure\Adapter\Equipment\EquipmentInspectionSummaryAdapter + + Inspection\Application\UseCase\Query\Equipment\GetEquipmentInspectionSummary\GetEquipmentInspectionSummaryHandler: + tags: ['messenger.message_handler'] + + # Repositories + Inspection\Infrastructure\Adapter\Intervention\InterventionInspectionResultAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Intervention\Application\Port\Outbound\InterventionInspectionResultPort: + alias: Inspection\Infrastructure\Adapter\Intervention\InterventionInspectionResultAdapter + # Repositories Inspection\Infrastructure\Persistence\Doctrine\Repository\InspectionRepository: arguments: @@ -380,3 +406,16 @@ services: Inspection\Infrastructure\Adapter\Checklist\ChecklistLockAdapter: arguments: $connection: '@doctrine.dbal.main_connection' + + Inspection\Infrastructure\Persistence\Doctrine\Repository\ParkAnomalyRepository: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Inspection\Application\Port\Outbound\ParkAnomalyGatewayPort: + alias: Inspection\Infrastructure\Persistence\Doctrine\Repository\ParkAnomalyRepository + + Inspection\Application\UseCase\Query\Park\ListParkAnomalies\ListParkAnomaliesHandler: + tags: ['messenger.message_handler'] + + Inspection\Application\UseCase\Query\Park\GetParkAnomaliesSummary\GetParkAnomaliesSummaryHandler: + tags: ['messenger.message_handler'] diff --git a/config/modules/intervention.yaml b/config/modules/intervention.yaml index f423959a5..42aa166aa 100644 --- a/config/modules/intervention.yaml +++ b/config/modules/intervention.yaml @@ -6,6 +6,15 @@ services: Intervention\Application\Port\Inbound\InterventionWorkloadContributionsPort: alias: Intervention\Infrastructure\Adapter\Workload\InterventionWorkloadContributionsAdapter + Intervention\Application\Port\Inbound\InterventionMaintenanceWorkPort: + alias: Intervention\Infrastructure\Adapter\Maintenance\InterventionMaintenanceWorkAdapter + + Intervention\Application\Port\Outbound\InterventionEquipmentReplacementPort: + alias: Equipment\Infrastructure\Adapter\Intervention\EquipmentReplacementLookupAdapter + + Intervention\Application\Port\Outbound\InterventionEquipmentWorkPort: + alias: Intervention\Infrastructure\Adapter\Equipment\InterventionEquipmentWorkAdapter + Intervention\Presentation\: resource: '../../src/Intervention/Presentation' @@ -25,6 +34,52 @@ services: Intervention\Infrastructure\: resource: '../../src/Intervention/Infrastructure' + Intervention\Application\Port\Inbound\InterventionPublicationFactsPort: + alias: Intervention\Infrastructure\Adapter\Publication\InterventionPublicationFactsAdapter + + Intervention\Infrastructure\Adapter\Publication\InterventionPublicationFactsAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Intervention\Infrastructure\Adapter\Equipment\InterventionEquipmentWorkAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Intervention\Application\Port\Inbound\InterventionCostSourceFactsPort: + alias: Intervention\Infrastructure\Adapter\Cost\InterventionCostSourceFactsAdapter + + Intervention\Infrastructure\Adapter\Cost\InterventionCostSourceFactsAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Intervention\Application\Port\Inbound\InterventionInventoryContextPort: + alias: Intervention\Infrastructure\Adapter\Inventory\InterventionInventoryContextAdapter + + Intervention\Infrastructure\Adapter\Inventory\InterventionInventoryContextAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Intervention\Application\Port\Outbound\InterventionPublicationResourcesIssuesPort: + alias: Intervention\Infrastructure\Adapter\Inventory\InterventionPublicationResourcesIssuesAdapter + + Intervention\Infrastructure\Adapter\Inventory\InterventionPublicationResourcesIssuesAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + ServiceRequest\Application\Port\Outbound\ServiceRequestWorkPort: + alias: Intervention\Infrastructure\Adapter\ServiceRequest\ServiceRequestWorkAdapter + + Intervention\Infrastructure\Adapter\ServiceRequest\ServiceRequestWorkAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Intervention\Infrastructure\Adapter\Maintenance\InterventionMaintenanceWorkAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Intervention\Application\UseCase\Query\Equipment\ListEquipmentOpenWork\ListEquipmentOpenWorkHandler: + tags: ['messenger.message_handler'] + Intervention\Infrastructure\Adapter\Workload\InterventionWorkloadContributionsAdapter: arguments: $entityManager: '@doctrine.orm.main_entity_manager' @@ -164,6 +219,16 @@ services: $owners: !tagged_iterator intervention.resource_owner Intervention\Infrastructure\Adapter\Publication\DoctrinePublicationAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + $operations: '@Intervention\Infrastructure\Adapter\Publication\InterventionOperationPublicationAdapter' + $snapshots: '@Intervention\Infrastructure\Adapter\Publication\InterventionClosureSnapshotAdapter' + + Intervention\Infrastructure\Adapter\Publication\InterventionOperationPublicationAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Intervention\Infrastructure\Adapter\Publication\InterventionClosureSnapshotAdapter: arguments: $entityManager: '@doctrine.orm.main_entity_manager' diff --git a/config/modules/inventory.yaml b/config/modules/inventory.yaml new file mode 100644 index 000000000..d60458e5b --- /dev/null +++ b/config/modules/inventory.yaml @@ -0,0 +1,44 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + Inventory\Presentation\: + resource: '../../src/Inventory/Presentation' + + Inventory\Infrastructure\Persistence\Doctrine\Repository\InventoryRepository: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Inventory\Application\Port\Outbound\InventoryStorePort: + alias: Inventory\Infrastructure\Persistence\Doctrine\Repository\InventoryRepository + + inventory.main_transaction_manager: + class: Shared\Infrastructure\Symfony\Adapter\Outbound\DoctrineTransactionManagerAdapter + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + Inventory\Application\UseCase\Command\ApplyInventoryStock\ApplyInventoryStockHandler: + arguments: + $transactions: '@inventory.main_transaction_manager' + tags: ['messenger.message_handler'] + + Inventory\Application\UseCase\Command\ManageInventoryReference\ManageInventoryReferenceHandler: + arguments: + $transactions: '@inventory.main_transaction_manager' + tags: ['messenger.message_handler'] + + Inventory\Application\UseCase\Query\ListInventory\ListInventoryHandler: + tags: ['messenger.message_handler'] + + Inventory\Infrastructure\Adapter\Procurement\InventoryStockReceiptAdapter: ~ + Inventory\Application\Port\Inbound\InventoryStockReceiptPort: + alias: Inventory\Infrastructure\Adapter\Procurement\InventoryStockReceiptAdapter + + Inventory\Infrastructure\Adapter\Procurement\InventoryPartDirectoryAdapter: ~ + Inventory\Application\Port\Inbound\InventoryPartDirectoryPort: + alias: Inventory\Infrastructure\Adapter\Procurement\InventoryPartDirectoryAdapter + + Inventory\Infrastructure\Adapter\Intervention\InventoryInterventionResourcesAdapter: ~ + Inventory\Application\Port\Inbound\InventoryInterventionResourcesPort: + alias: Inventory\Infrastructure\Adapter\Intervention\InventoryInterventionResourcesAdapter diff --git a/config/modules/maintenance.yaml b/config/modules/maintenance.yaml index b2e5eb8d3..5e667eb15 100644 --- a/config/modules/maintenance.yaml +++ b/config/modules/maintenance.yaml @@ -36,6 +36,9 @@ services: Maintenance\Application\Port\Outbound\Directory\MaintenanceEquipmentDirectoryPort: alias: Equipment\Infrastructure\Adapter\Maintenance\EquipmentMaintenanceDirectoryAdapter + Maintenance\Application\Port\Outbound\Directory\MaintenanceFacilityLifecyclePort: + alias: Facility\Infrastructure\Adapter\Maintenance\FacilityMaintenanceLifecycleAdapter + # Cross-module naming ports for the CSV export (adapters live in the owning module) Maintenance\Application\Port\Outbound\Naming\MaintenanceEquipmentNamingPort: alias: Equipment\Infrastructure\Adapter\Maintenance\EquipmentMaintenanceNamingAdapter @@ -64,6 +67,35 @@ services: Maintenance\Application\Port\Inbound\MaintenanceSchedulePort: alias: Maintenance\Application\Service\MaintenanceScheduleService + Maintenance\Application\Port\Outbound\Plan\MaintenancePlanStorePort: + alias: Maintenance\Infrastructure\Persistence\Doctrine\Repository\MaintenancePlanRepository + + Maintenance\Infrastructure\Persistence\Doctrine\Repository\MaintenancePlanRepository: + arguments: + $connection: '@doctrine.dbal.main_connection' + + Maintenance\Application\Port\Inbound\MaintenancePlanAuthorityPort: + alias: Maintenance\Application\Service\MaintenancePlanAuthorityService + + Maintenance\Application\Port\Inbound\MaintenanceOperationResultsPort: + alias: Maintenance\Application\Service\MaintenancePlanAuthorityService + + Maintenance\Application\Port\Inbound\MaintenanceOperationsDuePort: + alias: Maintenance\Infrastructure\Adapter\Equipment\MaintenanceOperationsDueAdapter + + Maintenance\Infrastructure\Adapter\Equipment\MaintenanceOperationsDueAdapter: + arguments: + $connection: '@doctrine.dbal.main_connection' + + Maintenance\Application\UseCase\Command\Plan\ManageMaintenancePlan\ManageMaintenancePlanHandler: + arguments: + $events: '@Shared\Infrastructure\Messaging\Outbox\TransactionalEventDispatcher' + $maxCampaignWorkItems: '%maintenance.max_campaign_work_items%' + tags: ['messenger.message_handler'] + + Maintenance\Application\UseCase\Query\Plan\ReadMaintenancePlans\ReadMaintenancePlansHandler: + tags: ['messenger.message_handler'] + # Adapter implementing the Inspection module's outbound synchronizer port Maintenance\Infrastructure\Adapter\Inspection\MaintenanceScheduleSynchronizerAdapter: ~ @@ -100,6 +132,7 @@ services: Maintenance\Application\UseCase\Command\Campaign\GenerateInspectionCampaign\GenerateInspectionCampaignHandler: arguments: $maxCampaignWorkItems: '%maintenance.max_campaign_work_items%' + $eventDispatcher: '@Shared\Infrastructure\Messaging\Outbox\MainTransactionEventDispatcher' tags: ['messenger.message_handler'] Maintenance\Application\UseCase\Command\Sweep\RecomputeMaintenanceSchedules\RecomputeMaintenanceSchedulesHandler: diff --git a/config/modules/maintenance_cost.yaml b/config/modules/maintenance_cost.yaml new file mode 100644 index 000000000..2a9d490c0 --- /dev/null +++ b/config/modules/maintenance_cost.yaml @@ -0,0 +1,83 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + MaintenanceCost\Presentation\: + resource: '../../src/MaintenanceCost/Presentation' + + MaintenanceCost\Application\Service\: + resource: '../../src/MaintenanceCost/Application/Service' + + MaintenanceCost\Domain\Service\: + resource: '../../src/MaintenanceCost/Domain/Service' + + MaintenanceCost\Infrastructure\Persistence\Doctrine\Repository\MaintenanceCostRepository: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + MaintenanceCost\Application\Port\Outbound\MaintenanceCostStorePort: + alias: MaintenanceCost\Infrastructure\Persistence\Doctrine\Repository\MaintenanceCostRepository + + MaintenanceCost\Infrastructure\Adapter\Publication\MaintenanceCostPublicationAdapter: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + MaintenanceCost\Application\Port\Inbound\MaintenanceCostPublicationPort: + alias: MaintenanceCost\Infrastructure\Adapter\Publication\MaintenanceCostPublicationAdapter + + MaintenanceCost\Infrastructure\Adapter\Reporting\MaintenanceCostReadAdapter: ~ + + MaintenanceCost\Application\Port\Inbound\MaintenanceCostReadPort: + alias: MaintenanceCost\Infrastructure\Adapter\Reporting\MaintenanceCostReadAdapter + + maintenance_cost.main_transaction_manager: + class: Shared\Infrastructure\Symfony\Adapter\Outbound\DoctrineTransactionManagerAdapter + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + MaintenanceCost\Application\UseCase\Query\Cost\GetMaintenanceCost\GetMaintenanceCostHandler: + tags: ['messenger.message_handler'] + + MaintenanceCost\Application\UseCase\Query\Reporting\ReadMaintenanceEconomicReport\ReadMaintenanceEconomicReportHandler: + tags: ['messenger.message_handler'] + + MaintenanceCost\Application\UseCase\Query\Reporting\ListMaintenanceEconomicDossiers\ListMaintenanceEconomicDossiersHandler: + tags: ['messenger.message_handler'] + + MaintenanceCost\Application\UseCase\Command\Cost\WriteMaintenanceCost\WriteMaintenanceCostHandler: + arguments: + $transactions: '@maintenance_cost.main_transaction_manager' + tags: ['messenger.message_handler'] + + MaintenanceCost\Infrastructure\Adapter\Currency\MaintenanceCurrencyAdapter: + arguments: + $connection: '@doctrine.dbal.main_connection' + + MaintenanceCost\Application\Port\Inbound\MaintenanceCurrencyPort: + alias: MaintenanceCost\Infrastructure\Adapter\Currency\MaintenanceCurrencyAdapter + + MaintenanceCost\Application\Port\Outbound\Currency\MaintenanceCurrencyStorePort: + alias: MaintenanceCost\Infrastructure\Adapter\Currency\MaintenanceCurrencyAdapter + + MaintenanceCost\Infrastructure\Adapter\Rate\MaintenanceRateAdapter: + arguments: + $connection: '@doctrine.dbal.main_connection' + + MaintenanceCost\Application\Port\Inbound\MaintenanceRatePort: + alias: MaintenanceCost\Infrastructure\Adapter\Rate\MaintenanceRateAdapter + + MaintenanceCost\Application\Port\Outbound\Rate\MaintenanceRateStorePort: + alias: MaintenanceCost\Infrastructure\Adapter\Rate\MaintenanceRateAdapter + + MaintenanceCost\Application\UseCase\Command\Currency\ConfigureMaintenanceCurrency\ConfigureMaintenanceCurrencyHandler: + tags: ['messenger.message_handler'] + + MaintenanceCost\Application\UseCase\Query\Currency\ReadMaintenanceCurrency\ReadMaintenanceCurrencyHandler: + tags: ['messenger.message_handler'] + + MaintenanceCost\Application\UseCase\Command\Rate\CreateMaintenanceRate\CreateMaintenanceRateHandler: + tags: ['messenger.message_handler'] + + MaintenanceCost\Application\UseCase\Query\Rate\ListMaintenanceRates\ListMaintenanceRatesHandler: + tags: ['messenger.message_handler'] diff --git a/config/modules/maintenance_export.yaml b/config/modules/maintenance_export.yaml new file mode 100644 index 000000000..4415e337a --- /dev/null +++ b/config/modules/maintenance_export.yaml @@ -0,0 +1,35 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + MaintenanceExport\Presentation\: + resource: '../../src/MaintenanceExport/Presentation' + + MaintenanceExport\Infrastructure\Adapter\: + resource: '../../src/MaintenanceExport/Infrastructure/Adapter' + + MaintenanceExport\Infrastructure\Adapter\Export\ExportIdentityValidationAdapter: + arguments: + $validators: !tagged_iterator maintenance_export.identity_validator + + MaintenanceExport\Application\Port\Outbound\MaintenanceExportIdentityPort: + alias: MaintenanceExport\Infrastructure\Adapter\Export\ExportIdentityValidationAdapter + + MaintenanceExport\Application\Port\Outbound\MaintenanceExportSourcePort: + alias: MaintenanceExport\Infrastructure\Adapter\Export\ExportSourceAdapter + + MaintenanceExport\Infrastructure\Persistence\Doctrine\Repository\MaintenanceExportRepository: + arguments: + $connection: '@doctrine.dbal.main_connection' + + MaintenanceExport\Application\Port\Outbound\MaintenanceExportRepositoryPort: + alias: MaintenanceExport\Infrastructure\Persistence\Doctrine\Repository\MaintenanceExportRepository + + MaintenanceExport\Application\UseCase\Command\ManageMaintenanceExport\ManageMaintenanceExportHandler: + arguments: + $events: '@Shared\Infrastructure\Messaging\Outbox\MainTransactionEventDispatcher' + tags: ['messenger.message_handler'] + + MaintenanceExport\Application\UseCase\Query\ReadMaintenanceExport\ReadMaintenanceExportHandler: + tags: ['messenger.message_handler'] diff --git a/config/modules/procurement.yaml b/config/modules/procurement.yaml new file mode 100644 index 000000000..1751914f6 --- /dev/null +++ b/config/modules/procurement.yaml @@ -0,0 +1,32 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + Procurement\Presentation\: + resource: '../../src/Procurement/Presentation' + + Procurement\Application\Service\: + resource: '../../src/Procurement/Application/Service' + + Procurement\Application\Port\Outbound\Persistence\ProcurementRepositoryPort: + alias: Procurement\Infrastructure\Persistence\Doctrine\Repository\ProcurementRepository + + Procurement\Infrastructure\Persistence\Doctrine\Repository\ProcurementRepository: + arguments: + $connection: '@doctrine.dbal.main_connection' + + Procurement\Application\UseCase\Command\ManageProcurement\ManageProcurementHandler: + arguments: + $events: '@Shared\Infrastructure\Messaging\Outbox\MainTransactionEventDispatcher' + tags: ['messenger.message_handler'] + + Procurement\Application\UseCase\Query\ReadProcurement\ReadProcurementHandler: + tags: ['messenger.message_handler'] + + Procurement\Application\Port\Inbound\ProcurementEconomicOverviewPort: + alias: Procurement\Infrastructure\Adapter\Reporting\ProcurementEconomicOverviewAdapter + + Procurement\Infrastructure\Adapter\Reporting\ProcurementEconomicOverviewAdapter: + arguments: + $connection: '@doctrine.dbal.main_connection' diff --git a/config/modules/service_request.yaml b/config/modules/service_request.yaml new file mode 100644 index 000000000..f453ececd --- /dev/null +++ b/config/modules/service_request.yaml @@ -0,0 +1,52 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + ServiceRequest\Presentation\: + resource: '../../src/ServiceRequest/Presentation' + + ServiceRequest\Application\Service\ServiceRequestAccessGuard: ~ + ServiceRequest\Application\Service\ServiceRequestTargetGuard: ~ + + ServiceRequest\Infrastructure\Persistence\Doctrine\Repository\ServiceRequestRepository: + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + ServiceRequest\Application\Port\Outbound\ServiceRequestRepositoryPort: + alias: ServiceRequest\Infrastructure\Persistence\Doctrine\Repository\ServiceRequestRepository + + ServiceRequest\Application\Port\Outbound\ServiceRequestEquipmentTargetPort: + alias: Equipment\Infrastructure\Adapter\ServiceRequest\ServiceRequestEquipmentTargetAdapter + + ServiceRequest\Application\Port\Outbound\ServiceRequestSiteTargetPort: + alias: Facility\Infrastructure\Adapter\ServiceRequest\ServiceRequestSiteTargetAdapter + + service_request.main_transaction_manager: + class: Shared\Infrastructure\Symfony\Adapter\Outbound\DoctrineTransactionManagerAdapter + arguments: + $entityManager: '@doctrine.orm.main_entity_manager' + + ServiceRequest\Application\UseCase\Command\CreateServiceRequest\CreateServiceRequestHandler: + arguments: + $transactions: '@service_request.main_transaction_manager' + $events: '@Shared\Infrastructure\Messaging\Outbox\MainTransactionEventDispatcher' + tags: ['messenger.message_handler'] + + ServiceRequest\Application\UseCase\Command\ChangeServiceRequest\ChangeServiceRequestHandler: + arguments: + $transactions: '@service_request.main_transaction_manager' + $events: '@Shared\Infrastructure\Messaging\Outbox\MainTransactionEventDispatcher' + tags: ['messenger.message_handler'] + + ServiceRequest\Application\UseCase\Command\ConvertServiceRequest\ConvertServiceRequestHandler: + arguments: + $transactions: '@service_request.main_transaction_manager' + $events: '@Shared\Infrastructure\Messaging\Outbox\MainTransactionEventDispatcher' + tags: ['messenger.message_handler'] + + ServiceRequest\Application\UseCase\Query\GetServiceRequest\GetServiceRequestHandler: + tags: ['messenger.message_handler'] + + ServiceRequest\Application\UseCase\Query\ListServiceRequests\ListServiceRequestsHandler: + tags: ['messenger.message_handler'] diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index c2a596b59..b0f43eab8 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -1,6 +1,6 @@ api_platform: - title: Fireguard API - description: Fire-safety compliance platform API — identity, organizations, facilities, equipment, inspections, interventions, maintenance and compliance + title: FireGuard API + description: Fire-equipment park and maintenance API — internal customers, sites, equipment, controls, interventions, parts, procurement and private maintenance costs version: 1.0.0 # Domain exception -> HTTP status, declaratively. # @@ -193,6 +193,8 @@ api_platform: Inspection\Domain\Exception\InspectionAlreadyClosedException: 409 Inspection\Domain\Exception\InspectionAlreadySubmittedException: 409 Inspection\Domain\Exception\InspectionNotFoundException: 404 + Inspection\Domain\Exception\EquipmentInspectionSummaryNotFoundException: 404 + Inspection\Domain\Exception\EquipmentInspectionSummaryAccessDeniedException: 403 Inspection\Domain\Exception\InspectionNotSubmittedException: 409 # Canonical inspection responses, whose mutations moved out of # InspectionResponseProcessor. Every wording is the one the processor @@ -211,6 +213,7 @@ api_platform: Intervention\Domain\Exception\InterventionBlockedException: 422 Intervention\Domain\Exception\InterventionConflictException: 409 Intervention\Domain\Exception\InterventionExportTooLargeException: 422 + Intervention\Application\Contract\Publication\InterventionFactsScopeTooLarge: 422 Intervention\Domain\Exception\InterventionNotFoundException: 404 Intervention\Domain\Exception\InterventionPreconditionFailedException: 412 Intervention\Domain\Exception\InterventionPreconditionRequiredException: 428 @@ -308,6 +311,12 @@ api_platform: - '%kernel.project_dir%/src/Workload/Presentation/Api/Resource' - '%kernel.project_dir%/src/Billing/Presentation/Api/Resource' - '%kernel.project_dir%/src/Automation/Presentation/Api/Resource' + - '%kernel.project_dir%/src/Customer/Presentation/Api/Resource' + - '%kernel.project_dir%/src/ServiceRequest/Presentation/Api/Resource' + - '%kernel.project_dir%/src/Inventory/Presentation/Api/Resource' + - '%kernel.project_dir%/src/Procurement/Presentation/Api/Resource' + - '%kernel.project_dir%/src/MaintenanceCost/Presentation/Api/Resource' + - '%kernel.project_dir%/src/MaintenanceExport/Presentation/Api/Resource' # Security schemes for OpenAPI (Swagger UI) # This is handled by api_platform.yaml security definitions in resources diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index d825487b3..dfe8563d5 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -218,6 +218,42 @@ doctrine: dir: '%kernel.project_dir%/src/Compliance/Infrastructure/Persistence/Doctrine/Record' prefix: 'Compliance\Infrastructure\Persistence\Doctrine\Record' alias: Compliance + Customer: + is_bundle: false + type: attribute + dir: '%kernel.project_dir%/src/Customer/Infrastructure/Persistence/Doctrine/Record' + prefix: 'Customer\Infrastructure\Persistence\Doctrine\Record' + alias: Customer + ServiceRequest: + is_bundle: false + type: attribute + dir: '%kernel.project_dir%/src/ServiceRequest/Infrastructure/Persistence/Doctrine/Record' + prefix: 'ServiceRequest\Infrastructure\Persistence\Doctrine\Record' + alias: ServiceRequest + Inventory: + is_bundle: false + type: attribute + dir: '%kernel.project_dir%/src/Inventory/Infrastructure/Persistence/Doctrine/Record' + prefix: 'Inventory\Infrastructure\Persistence\Doctrine\Record' + alias: Inventory + Procurement: + is_bundle: false + type: attribute + dir: '%kernel.project_dir%/src/Procurement/Infrastructure/Persistence/Doctrine/Record' + prefix: 'Procurement\Infrastructure\Persistence\Doctrine\Record' + alias: Procurement + MaintenanceCost: + is_bundle: false + type: attribute + dir: '%kernel.project_dir%/src/MaintenanceCost/Infrastructure/Persistence/Doctrine' + prefix: 'MaintenanceCost\Infrastructure\Persistence\Doctrine' + alias: MaintenanceCost + MaintenanceExport: + is_bundle: false + type: attribute + dir: '%kernel.project_dir%/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Record' + prefix: 'MaintenanceExport\Infrastructure\Persistence\Doctrine\Record' + alias: MaintenanceExport controller_resolver: auto_mapping: false diff --git a/config/packages/modules.yaml b/config/packages/modules.yaml index e9543deb3..e964c62b5 100644 --- a/config/packages/modules.yaml +++ b/config/packages/modules.yaml @@ -9,6 +9,7 @@ imports: - { resource: '../modules/session.yaml' } - { resource: '../modules/tenant.yaml' } - { resource: '../modules/organization.yaml' } + - { resource: '../modules/customer.yaml' } - { resource: '../modules/onboarding.yaml' } - { resource: '../modules/facility.yaml' } - { resource: '../modules/equipment.yaml' } @@ -30,7 +31,9 @@ imports: - { resource: '../modules/approval.yaml' } - { resource: '../modules/assistant.yaml' } - { resource: '../modules/calendar.yaml' } - - - + - { resource: '../modules/service_request.yaml' } + - { resource: '../modules/inventory.yaml' } + - { resource: '../modules/procurement.yaml' } + - { resource: '../modules/maintenance_cost.yaml' } + - { resource: '../modules/maintenance_export.yaml' } diff --git a/docs/README.md b/docs/README.md index 0a4cae6c3..01cc45f19 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,6 +50,12 @@ contracts from explanations and environment-specific operational information. - [Inspection](../src/Inspection/MODULE.md) - [Intervention](../src/Intervention/MODULE.md) - [Maintenance](../src/Maintenance/MODULE.md) +- [Customer](../src/Customer/MODULE.md) +- [ServiceRequest](../src/ServiceRequest/MODULE.md) +- [Inventory](../src/Inventory/MODULE.md) +- [Procurement](../src/Procurement/MODULE.md) +- [MaintenanceCost](../src/MaintenanceCost/MODULE.md) +- [MaintenanceExport](../src/MaintenanceExport/MODULE.md) - [Messaging](../src/Messaging/MODULE.md) - [Notification](../src/Notification/MODULE.md) - [OAuth](../src/OAuth/MODULE.md) diff --git a/migrations/main/Version20261006090000.php b/migrations/main/Version20261006090000.php new file mode 100644 index 000000000..a173ba0ba --- /dev/null +++ b/migrations/main/Version20261006090000.php @@ -0,0 +1,33 @@ +addSql("ALTER TABLE organizations ADD operating_profile VARCHAR(32) DEFAULT 'operator' NOT NULL"); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE organizations DROP operating_profile'); + } +} diff --git a/migrations/main/Version20261006100000.php b/migrations/main/Version20261006100000.php new file mode 100644 index 000000000..7a6637080 --- /dev/null +++ b/migrations/main/Version20261006100000.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE equipment ADD name VARCHAR(255) DEFAULT NULL, ADD asset_code VARCHAR(100) DEFAULT NULL, ADD criticality VARCHAR(16) DEFAULT NULL, ADD technical_properties JSON NOT NULL DEFAULT \'[]\', ADD predecessor_equipment_id VARCHAR(36) DEFAULT NULL, ADD successor_equipment_id VARCHAR(36) DEFAULT NULL'); + $this->addSql('CREATE UNIQUE INDEX uniq_equipment_organization_asset_code ON equipment (organization_id, asset_code)'); + $this->addSql('CREATE UNIQUE INDEX uniq_equipment_predecessor ON equipment (predecessor_equipment_id)'); + $this->addSql('CREATE UNIQUE INDEX uniq_equipment_successor ON equipment (successor_equipment_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX uniq_equipment_organization_asset_code'); + $this->addSql('DROP INDEX uniq_equipment_predecessor'); + $this->addSql('DROP INDEX uniq_equipment_successor'); + $this->addSql('ALTER TABLE equipment DROP name, DROP asset_code, DROP criticality, DROP technical_properties, DROP predecessor_equipment_id, DROP successor_equipment_id'); + } +} diff --git a/migrations/main/Version20261006100001.php b/migrations/main/Version20261006100001.php new file mode 100644 index 000000000..72dc6975d --- /dev/null +++ b/migrations/main/Version20261006100001.php @@ -0,0 +1,38 @@ +addSql('CREATE TABLE equipment_type_catalog (organization_id VARCHAR(36) NOT NULL, type_code VARCHAR(32) NOT NULL, label VARCHAR(100) NOT NULL, family VARCHAR(16) NOT NULL, archived BOOLEAN NOT NULL, revision INT NOT NULL, PRIMARY KEY(organization_id, type_code))'); + } + + /** Method down. */ + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE equipment_type_catalog'); + } + // #endregion +} diff --git a/migrations/main/Version20261006100002.php b/migrations/main/Version20261006100002.php new file mode 100644 index 000000000..da9a5098c --- /dev/null +++ b/migrations/main/Version20261006100002.php @@ -0,0 +1,61 @@ +addSql('CREATE TABLE equipment_replacement_receipts (organization_id VARCHAR(36) NOT NULL, client_operation_id VARCHAR(36) NOT NULL, predecessor_equipment_id VARCHAR(36) NOT NULL, successor_equipment_id VARCHAR(36) NOT NULL, payload_hash VARCHAR(64) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(organization_id, client_operation_id))'); + $this->addSql("COMMENT ON COLUMN equipment_replacement_receipts.created_at IS '(DC2Type:datetime_immutable)'"); + } + + /** + * Method down + * + * @access public + * + * @param Schema $schema the main schema + * + * @return void + */ + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE equipment_replacement_receipts'); + } + // #endregion +} diff --git a/migrations/main/Version20261006101000.php b/migrations/main/Version20261006101000.php new file mode 100644 index 000000000..f8cd435e2 --- /dev/null +++ b/migrations/main/Version20261006101000.php @@ -0,0 +1,29 @@ +addSql('CREATE TABLE customers (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, name VARCHAR(160) NOT NULL, code VARCHAR(80) DEFAULT NULL, email VARCHAR(254) DEFAULT NULL, phone VARCHAR(40) DEFAULT NULL, contacts JSON NOT NULL, archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, revision INT DEFAULT 1 NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX idx_customer_organization ON customers (organization_id)'); + $this->addSql('CREATE UNIQUE INDEX uniq_customer_organization_code ON customers (organization_id, code)'); + $this->addSql('ALTER TABLE facilities ADD customer_id VARCHAR(36) DEFAULT NULL'); + $this->addSql('CREATE INDEX idx_facility_customer ON facilities (customer_id)'); + $this->addSql("ALTER TABLE facilities ADD CONSTRAINT chk_facility_customer_root CHECK (customer_id IS NULL OR (type = 'site' AND parent_facility_id IS NULL))"); + } + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE facilities DROP CONSTRAINT chk_facility_customer_root'); + $this->addSql('DROP INDEX idx_facility_customer'); + $this->addSql('ALTER TABLE facilities DROP customer_id'); + $this->addSql('DROP TABLE customers'); + } +} diff --git a/migrations/main/Version20261006101001.php b/migrations/main/Version20261006101001.php new file mode 100644 index 000000000..07ced40dd --- /dev/null +++ b/migrations/main/Version20261006101001.php @@ -0,0 +1,40 @@ +addSql("COMMENT ON COLUMN customers.archived_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql("COMMENT ON COLUMN customers.created_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql("COMMENT ON COLUMN customers.updated_at IS '(DC2Type:datetime_immutable)'"); + } + + /** Method down. Restores the original metadata without rewriting any customer date. */ + public function down(Schema $schema): void + { + $this->addSql('COMMENT ON COLUMN customers.archived_at IS NULL'); + $this->addSql('COMMENT ON COLUMN customers.created_at IS NULL'); + $this->addSql('COMMENT ON COLUMN customers.updated_at IS NULL'); + } +} diff --git a/migrations/main/Version20261006102000.php b/migrations/main/Version20261006102000.php new file mode 100644 index 000000000..90c48f4bf --- /dev/null +++ b/migrations/main/Version20261006102000.php @@ -0,0 +1,38 @@ +addSql('CREATE TABLE maintenance_plans (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, equipment_id VARCHAR(36) NOT NULL, facility_id VARCHAR(36) DEFAULT NULL, equipment_type VARCHAR(64) NOT NULL, name VARCHAR(160) NOT NULL, operation_kind VARCHAR(16) NOT NULL, interval VARCHAR(32) NOT NULL, cadence_mode VARCHAR(16) NOT NULL, anchor_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, next_due_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, active BOOLEAN NOT NULL, legacy_schedule_id VARCHAR(36) DEFAULT NULL, last_completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_maintenance_plan_legacy_schedule ON maintenance_plans (legacy_schedule_id)'); + $this->addSql('CREATE INDEX idx_maintenance_plan_org_due ON maintenance_plans (organization_id, active, next_due_at)'); + $this->addSql('CREATE TABLE maintenance_occurrences (id VARCHAR(36) NOT NULL, plan_id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, due_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, status VARCHAR(16) NOT NULL, attempt INT NOT NULL, intervention_id VARCHAR(36) DEFAULT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, result_id VARCHAR(36) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql("CREATE UNIQUE INDEX uniq_maintenance_occurrence_open ON maintenance_occurrences (plan_id) WHERE status = 'open'"); + $this->addSql('CREATE INDEX idx_maintenance_occurrence_org_plan ON maintenance_occurrences (organization_id, plan_id)'); + $this->addSql('ALTER TABLE maintenance_occurrences ADD number INT DEFAULT NULL'); + $this->addSql('CREATE TABLE maintenance_engines (organization_id VARCHAR(36) NOT NULL, mode VARCHAR(16) NOT NULL, activated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(organization_id))'); + $this->addSql('CREATE TABLE maintenance_operation_receipts (id VARCHAR(36) NOT NULL, occurrence_id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, outcome VARCHAR(16) NOT NULL, performed_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE maintenance_operation_receipts'); + $this->addSql('DROP TABLE maintenance_engines'); + $this->addSql('DROP TABLE maintenance_occurrences'); + $this->addSql('DROP TABLE maintenance_plans'); + } +} diff --git a/migrations/main/Version20261006102001.php b/migrations/main/Version20261006102001.php new file mode 100644 index 000000000..0bc117612 --- /dev/null +++ b/migrations/main/Version20261006102001.php @@ -0,0 +1,27 @@ +addSql("ALTER TABLE maintenance_plans ADD calendar_timezone VARCHAR(64) NOT NULL DEFAULT 'UTC'"); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE maintenance_plans DROP calendar_timezone'); + } +} diff --git a/migrations/main/Version20261006102002.php b/migrations/main/Version20261006102002.php new file mode 100644 index 000000000..16369df20 --- /dev/null +++ b/migrations/main/Version20261006102002.php @@ -0,0 +1,43 @@ +> */ + private const array COLUMNS = [ + 'maintenance_plans' => ['anchor_at', 'next_due_at', 'last_completed_at', 'archived_at', 'created_at', 'updated_at'], + 'maintenance_occurrences' => ['due_at', 'completed_at', 'created_at', 'updated_at'], + 'maintenance_engines' => ['activated_at'], + 'maintenance_operation_receipts' => ['performed_at'], + ]; + + public function getDescription(): string + { + return 'Annotate immutable maintenance timestamps for stable Doctrine schema comparison.'; + } + + public function up(Schema $schema): void + { + foreach (self::COLUMNS as $table => $columns) { + foreach ($columns as $column) { + $this->addSql('COMMENT ON COLUMN ' . $table . '.' . $column . " IS '(DC2Type:datetime_immutable)'"); + } + } + } + + public function down(Schema $schema): void + { + foreach (self::COLUMNS as $table => $columns) { + foreach ($columns as $column) { + $this->addSql('COMMENT ON COLUMN ' . $table . '.' . $column . ' IS NULL'); + } + } + } +} diff --git a/migrations/main/Version20261006103000.php b/migrations/main/Version20261006103000.php new file mode 100644 index 000000000..81de35885 --- /dev/null +++ b/migrations/main/Version20261006103000.php @@ -0,0 +1,38 @@ +addSql('ALTER TABLE intervention_work_items ADD operation_id VARCHAR(36) DEFAULT NULL, ADD occurrence_id VARCHAR(36) DEFAULT NULL, ADD operation_kind VARCHAR(16) DEFAULT NULL, ADD execution_result JSON DEFAULT NULL'); + $this->addSql('ALTER TABLE interventions ADD closure_snapshot JSON DEFAULT NULL'); + } + + /** Method down. Removing the added facts loses their captured historical contents. */ + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE intervention_work_items DROP operation_id, DROP occurrence_id, DROP operation_kind, DROP execution_result'); + $this->addSql('ALTER TABLE interventions DROP closure_snapshot'); + } +} diff --git a/migrations/main/Version20261006110000.php b/migrations/main/Version20261006110000.php new file mode 100644 index 000000000..42ee6ce2c --- /dev/null +++ b/migrations/main/Version20261006110000.php @@ -0,0 +1,45 @@ + + */ +final class Version20261006110000 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add maintenance service requests and their idempotent conversion receipts.'; + } + + public function up(Schema $schema): void + { + $this->addSql("CREATE TABLE service_requests (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, equipment_id VARCHAR(36) DEFAULT NULL, site_id VARCHAR(36) DEFAULT NULL, target_snapshot JSONB NOT NULL, title VARCHAR(160) NOT NULL, description TEXT NOT NULL, priority VARCHAR(16) DEFAULT 'normal' NOT NULL, origin_inspection_id VARCHAR(36) DEFAULT NULL, origin_non_conformity_id VARCHAR(36) DEFAULT NULL, status VARCHAR(16) DEFAULT 'requested' NOT NULL, revision INT DEFAULT 1 NOT NULL, requested_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, qualified_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, rejected_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, cancelled_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, converted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, decision_reason TEXT DEFAULT NULL, qualification_note TEXT DEFAULT NULL, intervention_id VARCHAR(36) DEFAULT NULL, task_id VARCHAR(36) DEFAULT NULL, PRIMARY KEY(id), CONSTRAINT chk_service_request_target CHECK (equipment_id IS NOT NULL OR site_id IS NOT NULL), CONSTRAINT chk_service_request_revision CHECK (revision >= 1), CONSTRAINT chk_service_request_priority CHECK (priority IN ('low', 'normal', 'high', 'urgent')), CONSTRAINT chk_service_request_status CHECK (status IN ('requested', 'qualified', 'rejected', 'cancelled', 'converted')), CONSTRAINT chk_service_request_qualified_equipment CHECK (status NOT IN ('qualified', 'converted') OR equipment_id IS NOT NULL), CONSTRAINT chk_service_request_conversion CHECK (status <> 'converted' OR (intervention_id IS NOT NULL AND task_id IS NOT NULL AND converted_at IS NOT NULL)))"); + $this->addSql('CREATE INDEX idx_service_request_org_requested ON service_requests (organization_id, requested_at)'); + $this->addSql('CREATE INDEX idx_service_request_org_status ON service_requests (organization_id, status, requested_at)'); + $this->addSql('CREATE INDEX idx_service_request_org_equipment ON service_requests (organization_id, equipment_id)'); + $this->addSql('CREATE INDEX idx_service_request_org_site ON service_requests (organization_id, site_id)'); + foreach (['requested_at', 'updated_at', 'qualified_at', 'rejected_at', 'cancelled_at', 'converted_at'] as $column) { + $this->addSql('COMMENT ON COLUMN service_requests.' . $column . " IS '(DC2Type:datetime_immutable)'"); + } + $this->addSql('CREATE TABLE service_request_conversion_receipts (request_id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, client_operation_id VARCHAR(36) NOT NULL, payload_hash VARCHAR(64) NOT NULL, intervention_id VARCHAR(36) NOT NULL, task_id VARCHAR(36) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(request_id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_service_request_conversion_operation ON service_request_conversion_receipts (organization_id, client_operation_id)'); + $this->addSql("COMMENT ON COLUMN service_request_conversion_receipts.created_at IS '(DC2Type:datetime_immutable)'"); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE service_request_conversion_receipts'); + $this->addSql('DROP TABLE service_requests'); + } +} diff --git a/migrations/main/Version20261006111000.php b/migrations/main/Version20261006111000.php new file mode 100644 index 000000000..be845b5a3 --- /dev/null +++ b/migrations/main/Version20261006111000.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE inventory_parts (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, code VARCHAR(100) NOT NULL, label VARCHAR(255) NOT NULL, unit VARCHAR(32) NOT NULL, kind VARCHAR(16) NOT NULL, archived BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_inventory_parts_code ON inventory_parts (organization_id,code)'); + $this->addSql('CREATE TABLE inventory_warehouses (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, code VARCHAR(100) NOT NULL, label VARCHAR(255) NOT NULL, archived BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_inventory_warehouses_code ON inventory_warehouses (organization_id,code)'); + $this->addSql('CREATE TABLE inventory_balances (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, warehouse_id VARCHAR(36) NOT NULL, part_id VARCHAR(36) NOT NULL, quantity NUMERIC(24,6) NOT NULL, total_value NUMERIC(24,6), currency VARCHAR(3) NOT NULL, PRIMARY KEY(id), CONSTRAINT inventory_balance_nonnegative CHECK (quantity >= 0 AND (total_value IS NULL OR total_value >= 0)))'); + $this->addSql('CREATE UNIQUE INDEX uniq_inventory_balances_scope ON inventory_balances (organization_id,warehouse_id,part_id)'); + $this->addSql('CREATE TABLE inventory_movements (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, warehouse_id VARCHAR(36) NOT NULL, part_id VARCHAR(36) NOT NULL, kind VARCHAR(32) NOT NULL, quantity NUMERIC(24,6) NOT NULL, unit_cost NUMERIC(24,6), total_value NUMERIC(24,6), currency VARCHAR(3) NOT NULL, reason TEXT NOT NULL, actor_id VARCHAR(36) NOT NULL, occurred_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, intervention_id VARCHAR(36), work_item_id VARCHAR(36), equipment_id VARCHAR(36), correction_of VARCHAR(36), source_receipt_id VARCHAR(36), late BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql("COMMENT ON COLUMN inventory_movements.occurred_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql('CREATE INDEX idx_inventory_movements_intervention ON inventory_movements (organization_id,intervention_id)'); + $this->addSql('CREATE INDEX idx_inventory_movements_correction ON inventory_movements (organization_id,correction_of)'); + $this->addSql('CREATE TABLE inventory_declarations (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, warehouse_id VARCHAR(36) NOT NULL, part_id VARCHAR(36) NOT NULL, quantity NUMERIC(24,6) NOT NULL, intervention_id VARCHAR(36) NOT NULL, work_item_id VARCHAR(36), equipment_id VARCHAR(36), actor_id VARCHAR(36) NOT NULL, occurred_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, status VARCHAR(32) NOT NULL, reason VARCHAR(64), movement_id VARCHAR(36), late BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql("COMMENT ON COLUMN inventory_declarations.occurred_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql('CREATE INDEX idx_inventory_declarations_pending ON inventory_declarations (organization_id,intervention_id,status)'); + $this->addSql('CREATE TABLE inventory_operation_receipts (organization_id VARCHAR(36) NOT NULL, client_operation_id VARCHAR(36) NOT NULL, payload_hash VARCHAR(64) NOT NULL, response JSON NOT NULL, PRIMARY KEY(organization_id,client_operation_id))'); + } + public function down(Schema $schema):void { + $this->addSql('DROP TABLE inventory_operation_receipts'); + $this->addSql('DROP TABLE inventory_declarations'); + $this->addSql('DROP TABLE inventory_movements'); + $this->addSql('DROP TABLE inventory_balances'); + $this->addSql('DROP TABLE inventory_warehouses'); + $this->addSql('DROP TABLE inventory_parts'); + } +} diff --git a/migrations/main/Version20261006112000.php b/migrations/main/Version20261006112000.php new file mode 100644 index 000000000..dd52a5217 --- /dev/null +++ b/migrations/main/Version20261006112000.php @@ -0,0 +1,40 @@ +addSql('CREATE TABLE procurement_suppliers (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, name VARCHAR(160) NOT NULL, code VARCHAR(64) DEFAULT NULL, email VARCHAR(254) DEFAULT NULL, phone VARCHAR(40) DEFAULT NULL, contacts JSONB NOT NULL, archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, revision INT NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE procurement_orders (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, supplier_id VARCHAR(36) NOT NULL, name VARCHAR(160) NOT NULL, currency VARCHAR(3) NOT NULL, status VARCHAR(32) NOT NULL, lines JSONB NOT NULL, revision INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE procurement_receipts (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, order_id VARCHAR(36) NOT NULL, line_id VARCHAR(36) NOT NULL, kind VARCHAR(32) NOT NULL, quantity NUMERIC(24,6) NOT NULL, warehouse_id VARCHAR(36) DEFAULT NULL, unit_cost NUMERIC(24,6) DEFAULT NULL, currency VARCHAR(3) NOT NULL, received_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, actor_id VARCHAR(36) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, inventory_movement_id VARCHAR(36) DEFAULT NULL, equipment_ids JSONB NOT NULL, returned_quantity NUMERIC(24,6) NOT NULL, blocked_reason VARCHAR(160) DEFAULT NULL, revision INT NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE procurement_operations (organization_id VARCHAR(36) NOT NULL, client_operation_id VARCHAR(36) NOT NULL, kind VARCHAR(32) NOT NULL, fingerprint VARCHAR(64) NOT NULL, receipt_id VARCHAR(36) NOT NULL, declaration JSONB NOT NULL, PRIMARY KEY(organization_id, client_operation_id))'); + $this->addSql("COMMENT ON COLUMN procurement_suppliers.archived_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql("COMMENT ON COLUMN procurement_suppliers.created_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql("COMMENT ON COLUMN procurement_suppliers.updated_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql("COMMENT ON COLUMN procurement_orders.created_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql("COMMENT ON COLUMN procurement_orders.updated_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql("COMMENT ON COLUMN procurement_receipts.received_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql("COMMENT ON COLUMN procurement_receipts.created_at IS '(DC2Type:datetime_immutable)'"); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE procurement_operations'); + $this->addSql('DROP TABLE procurement_receipts'); + $this->addSql('DROP TABLE procurement_orders'); + $this->addSql('DROP TABLE procurement_suppliers'); + } +} diff --git a/migrations/main/Version20261006112001.php b/migrations/main/Version20261006112001.php new file mode 100644 index 000000000..ab45fc4bd --- /dev/null +++ b/migrations/main/Version20261006112001.php @@ -0,0 +1,31 @@ +addSql('CREATE INDEX idx_procurement_supplier_org_archive ON procurement_suppliers (organization_id, archived_at)'); + $this->addSql('CREATE INDEX idx_procurement_order_org_supplier_status ON procurement_orders (organization_id, supplier_id, status)'); + $this->addSql('CREATE INDEX idx_procurement_receipt_org_order ON procurement_receipts (organization_id, order_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX idx_procurement_receipt_org_order'); + $this->addSql('DROP INDEX idx_procurement_order_org_supplier_status'); + $this->addSql('DROP INDEX idx_procurement_supplier_org_archive'); + } +} diff --git a/migrations/main/Version20261006112002.php b/migrations/main/Version20261006112002.php new file mode 100644 index 000000000..5ad480cdb --- /dev/null +++ b/migrations/main/Version20261006112002.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE procurement_receipts ADD pending_return_quantity NUMERIC(24,6) DEFAULT 0.000000 NOT NULL'); + $this->addSql('CREATE TABLE procurement_returns (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, receipt_id VARCHAR(36) NOT NULL, client_operation_id VARCHAR(36) NOT NULL, quantity NUMERIC(24,6) NOT NULL, reason TEXT NOT NULL, actor_id VARCHAR(36) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, status VARCHAR(32) NOT NULL, inventory_movement_id VARCHAR(36) DEFAULT NULL, blocked_reason VARCHAR(160) DEFAULT NULL, reconciled_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, revision INT NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX idx_procurement_return_org_receipt ON procurement_returns (organization_id, receipt_id)'); + $this->addSql("COMMENT ON COLUMN procurement_returns.created_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql("COMMENT ON COLUMN procurement_returns.reconciled_at IS '(DC2Type:datetime_immutable)'"); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE procurement_returns'); + $this->addSql('ALTER TABLE procurement_receipts DROP pending_return_quantity'); + } +} diff --git a/migrations/main/Version20261006113000.php b/migrations/main/Version20261006113000.php new file mode 100644 index 000000000..8fc5f19f2 --- /dev/null +++ b/migrations/main/Version20261006113000.php @@ -0,0 +1,34 @@ +addSql('CREATE TABLE maintenance_cost_expenses (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, intervention_id VARCHAR(36) NOT NULL, work_item_id VARCHAR(36) DEFAULT NULL, client_id VARCHAR(64) NOT NULL, amount NUMERIC(24,6) NOT NULL, currency VARCHAR(3) NOT NULL, description TEXT NOT NULL, incurred_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, adjustment_of VARCHAR(36) DEFAULT NULL, created_by VARCHAR(36) NOT NULL, payload_hash VARCHAR(64) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_maintenance_expense_client ON maintenance_cost_expenses (organization_id, client_id)'); + $this->addSql('CREATE INDEX idx_maintenance_expense_intervention ON maintenance_cost_expenses (organization_id, intervention_id)'); + $this->addSql('CREATE TABLE maintenance_cost_planning (organization_id VARCHAR(36) NOT NULL, intervention_id VARCHAR(36) NOT NULL, planned_budget NUMERIC(24,6) DEFAULT NULL, estimated_minutes INT DEFAULT NULL, resources JSON NOT NULL, revision INT NOT NULL, PRIMARY KEY(organization_id, intervention_id))'); + $this->addSql('CREATE TABLE maintenance_cost_snapshots (organization_id VARCHAR(36) NOT NULL, intervention_id VARCHAR(36) NOT NULL, publication_id VARCHAR(36) NOT NULL, intervention_revision INT NOT NULL, captured_at VARCHAR(40) NOT NULL, version INT NOT NULL, currency VARCHAR(3) NOT NULL, total NUMERIC(24,6) DEFAULT NULL, known_total NUMERIC(24,6) NOT NULL, complete BOOLEAN NOT NULL, planned_budget NUMERIC(24,6) DEFAULT NULL, estimated_minutes INT DEFAULT NULL, planning_revision INT NOT NULL, planning_resources JSON NOT NULL, items JSON NOT NULL, PRIMARY KEY(organization_id, intervention_id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_maintenance_cost_publication ON maintenance_cost_snapshots (publication_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE maintenance_cost_snapshots'); + $this->addSql('DROP TABLE maintenance_cost_planning'); + $this->addSql('DROP TABLE maintenance_cost_expenses'); + } +} diff --git a/migrations/main/Version20261006113001.php b/migrations/main/Version20261006113001.php new file mode 100644 index 000000000..44f1e8fd7 --- /dev/null +++ b/migrations/main/Version20261006113001.php @@ -0,0 +1,32 @@ +addSql("CREATE TABLE maintenance_cost_currency_settings (organization_id VARCHAR(36) NOT NULL, currency VARCHAR(3) DEFAULT 'EUR' NOT NULL, locked BOOLEAN DEFAULT FALSE NOT NULL, PRIMARY KEY(organization_id))"); + $this->addSql('CREATE TABLE maintenance_cost_hourly_rates (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, member_id VARCHAR(36) NOT NULL, client_id VARCHAR(36) NOT NULL, hourly_amount NUMERIC(24, 6) NOT NULL, effective_from DATE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_maintenance_cost_rate_client ON maintenance_cost_hourly_rates (organization_id, client_id)'); + $this->addSql('CREATE UNIQUE INDEX uniq_maintenance_cost_rate_effective ON maintenance_cost_hourly_rates (organization_id, member_id, effective_from)'); + $this->addSql('CREATE INDEX idx_maintenance_cost_rate_member ON maintenance_cost_hourly_rates (organization_id, member_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE maintenance_cost_hourly_rates'); + $this->addSql('DROP TABLE maintenance_cost_currency_settings'); + } +} diff --git a/migrations/main/Version20261006113002.php b/migrations/main/Version20261006113002.php new file mode 100644 index 000000000..794ee1442 --- /dev/null +++ b/migrations/main/Version20261006113002.php @@ -0,0 +1,31 @@ +addSql("COMMENT ON COLUMN maintenance_cost_hourly_rates.effective_from IS '(DC2Type:date_immutable)'"); + $this->addSql("COMMENT ON COLUMN maintenance_cost_expenses.incurred_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql("COMMENT ON COLUMN maintenance_cost_expenses.created_at IS '(DC2Type:datetime_immutable)'"); + } + + public function down(Schema $schema): void + { + $this->addSql('COMMENT ON COLUMN maintenance_cost_hourly_rates.effective_from IS NULL'); + $this->addSql('COMMENT ON COLUMN maintenance_cost_expenses.incurred_at IS NULL'); + $this->addSql('COMMENT ON COLUMN maintenance_cost_expenses.created_at IS NULL'); + } +} diff --git a/migrations/main/Version20261007100000.php b/migrations/main/Version20261007100000.php new file mode 100644 index 000000000..df98cbe44 --- /dev/null +++ b/migrations/main/Version20261007100000.php @@ -0,0 +1,63 @@ +addSql('CREATE TABLE maintenance_export_documents (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, actor_id VARCHAR(36) NOT NULL, kind VARCHAR(32) NOT NULL, system VARCHAR(64) NOT NULL, include_internal_costs BOOLEAN NOT NULL, source_intervention_ids JSONB NOT NULL, original_export_id VARCHAR(36) DEFAULT NULL, adjustment_of VARCHAR(36) DEFAULT NULL, reason TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, rows JSONB NOT NULL, baseline JSONB NOT NULL, json_bytes TEXT NOT NULL, csv_bytes TEXT NOT NULL, json_sha256 VARCHAR(64) NOT NULL, csv_sha256 VARCHAR(64) NOT NULL, immutable_hash VARCHAR(64) NOT NULL, costs_complete BOOLEAN DEFAULT NULL, incomplete_cost_count INT DEFAULT NULL, revision INT NOT NULL, confirmation JSONB DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_maintenance_export_adjustment ON maintenance_export_documents (organization_id, adjustment_of)'); + $this->addSql('CREATE INDEX idx_maintenance_export_directory ON maintenance_export_documents (organization_id, created_at, id)'); + $this->addSql("COMMENT ON COLUMN maintenance_export_documents.created_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql('CREATE TABLE maintenance_export_operations (organization_id VARCHAR(36) NOT NULL, actor_id VARCHAR(36) NOT NULL, client_operation_id VARCHAR(36) NOT NULL, action VARCHAR(32) NOT NULL, fingerprint VARCHAR(64) NOT NULL, resource_id VARCHAR(36) NOT NULL, result JSONB NOT NULL, PRIMARY KEY(organization_id, actor_id, client_operation_id))'); + $this->addSql('CREATE TABLE maintenance_external_references (id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, system VARCHAR(64) NOT NULL, resource_type VARCHAR(32) NOT NULL, resource_id VARCHAR(36) NOT NULL, reference VARCHAR(200) NOT NULL, revision INT NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_maintenance_external_reference ON maintenance_external_references (organization_id, system, resource_type, resource_id)'); + $this->addSql("COMMENT ON COLUMN maintenance_external_references.updated_at IS '(DC2Type:datetime_immutable)'"); + } + + /** + * Method down + * + * @param Schema $schema current main schema + * + * @return void removes the new module and its retained history + */ + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE maintenance_external_references'); + $this->addSql('DROP TABLE maintenance_export_operations'); + $this->addSql('DROP TABLE maintenance_export_documents'); + } + // #endregion +} diff --git a/migrations/main/Version20261007150000.php b/migrations/main/Version20261007150000.php new file mode 100644 index 000000000..6a6a7035e --- /dev/null +++ b/migrations/main/Version20261007150000.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE maintenance_cost_snapshots ALTER total TYPE NUMERIC(38, 6), ALTER known_total TYPE NUMERIC(38, 6)'); + } + + public function down(Schema $schema): void + { + $this->abortIf(true, 'Published financial totals must not be truncated by reverting precision.'); + } +} diff --git a/openapi.json b/openapi.json index aee82966d..b73feab8f 100644 --- a/openapi.json +++ b/openapi.json @@ -1,8 +1,8 @@ { "openapi": "3.2.0", "info": { - "title": "Fireguard API", - "description": "Fire-safety compliance platform API \u2014 identity, organizations, facilities, equipment, inspections, interventions, maintenance and compliance", + "title": "FireGuard API", + "description": "Fire-equipment park and maintenance API \u2014 internal customers, sites, equipment, controls, interventions, parts, procurement and private maintenance costs", "version": "1.0.0" }, "servers": [ @@ -9664,20 +9664,20 @@ } } }, - "/api/direct-conversations": { + "/api/organizations/{organizationId}/customers": { "get": { - "operationId": "api_direct-conversations_get_collection", + "operationId": "list_customers", "tags": [ - "DirectConversation" + "Customer" ], "responses": { "200": { - "description": "DirectConversation collection", + "description": "Customer collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "DirectConversation.ConversationOutput.jsonld collection.", + "description": "Customer.CustomerOutput.jsonld-customer.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -9691,7 +9691,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/DirectConversation.ConversationOutput.jsonld" + "$ref": "#/components/schemas/Customer.CustomerOutput.jsonld-customer.read" } } } @@ -9723,75 +9723,88 @@ "links": {} } }, - "summary": "Retrieves the collection of DirectConversation resources.", - "description": "Retrieves the collection of DirectConversation resources.", + "summary": "Search internal customers", + "description": "Search internal customers", "parameters": [ { - "name": "page", + "name": "search", "in": "query", - "description": "The collection page number", + "description": "", "required": false, "deprecated": false, "schema": { - "type": "integer", - "default": 1 + "type": "string", + "maxLength": 160 }, "style": "form", "explode": true }, { - "name": "itemsPerPage", + "name": "archived", "in": "query", - "description": "The number of items per page", + "description": "", "required": false, "deprecated": false, "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "type": "boolean", + "default": false }, "style": "form", "explode": true }, { - "name": "organization", + "name": "page", "in": "query", - "description": "Organization IRI.", - "required": true, + "description": "", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "minimum": 1 }, "style": "form", "explode": true }, { - "name": "isArchived", + "name": "itemsPerPage", "in": "query", - "description": "Archived-flag filter.", + "description": "", "required": false, "deprecated": false, "schema": { - "type": "boolean" + "type": "integer", + "minimum": 1, + "maximum": 100 }, "style": "form", "explode": true + }, + { + "name": "organizationId", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] }, "post": { - "operationId": "api_direct-conversations_post", + "operationId": "create_customer", "tags": [ - "DirectConversation" + "Customer" ], "responses": { - "200": { - "description": "DirectConversation resource created", + "201": { + "description": "Customer resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/DirectConversation.ConversationOutput.jsonld" + "$ref": "#/components/schemas/Customer.CustomerOutput.jsonld-customer.read" } } }, @@ -9861,15 +9874,28 @@ "links": {} } }, - "summary": "Creates a DirectConversation resource.", - "description": "Creates a DirectConversation resource.", - "parameters": [], + "summary": "Create an internal customer", + "description": "Create an internal customer", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new DirectConversation resource", + "description": "The new Customer resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/DirectConversation.GetOrCreateDirectConversationInput" + "$ref": "#/components/schemas/Customer.CreateCustomerInput-customer.write" } } }, @@ -9877,19 +9903,19 @@ } } }, - "/api/auth/email-ownership": { + "/api/organizations/{organizationId}/customers/{id}": { "get": { - "operationId": "auth_email_ownership_get", + "operationId": "get_customer", "tags": [ - "EmailOwnership" + "Customer" ], "responses": { "200": { - "description": "EmailOwnership resource", + "description": "Customer resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EmailOwnership.EmailOwnershipOutput.jsonld-email_ownership.read" + "$ref": "#/components/schemas/Customer.CustomerOutput.jsonld-customer.read" } } } @@ -9937,24 +9963,174 @@ "links": {} } }, - "summary": "Read explicit Fireguard mailbox ownership proof", - "description": "Read explicit Fireguard mailbox ownership proof", - "parameters": [] + "summary": "Read an internal customer", + "description": "Read an internal customer", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "patch": { + "operationId": "patch_customer", + "tags": [ + "Customer" + ], + "responses": { + "200": { + "description": "Customer updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Customer.CustomerOutput.jsonld-customer.read" + } + } + }, + "links": {} + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Customer not found" + }, + "412": { + "description": "Stale revision" + }, + "428": { + "description": "If-Match required" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Update an internal customer", + "description": "Update an internal customer", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "example": "\"revision-1\"" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated Customer resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Customer.ChangeCustomerInput-customer.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/auth/email-ownership/confirm": { + "/api/organizations/{organizationId}/customers/{id}/archive": { "post": { - "operationId": "auth_email_ownership_confirm", + "operationId": "archive_customer", "tags": [ - "EmailOwnership" + "Customer" ], "responses": { "200": { - "description": "EmailOwnership resource created", + "description": "Customer resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EmailOwnership.EmailOwnershipOutput.jsonld-email_ownership.read" + "$ref": "#/components/schemas/Customer.CustomerOutput.jsonld-customer.read" } } }, @@ -10024,35 +10200,61 @@ "links": {} } }, - "summary": "Confirm current mailbox possession with a single-use email code", - "description": "Confirm current mailbox possession with a single-use email code", - "parameters": [], - "requestBody": { - "description": "The new EmailOwnership resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/EmailOwnership.ConfirmEmailOwnershipInput-email_ownership.write" - } - } + "summary": "Archive an internal customer", + "description": "Archive an internal customer", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "required": true - } + { + "name": "organizationId", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/auth/email-ownership/start": { + "/api/organizations/{organizationId}/customers/{id}/restore": { "post": { - "operationId": "auth_email_ownership_start", + "operationId": "restore_customer", "tags": [ - "EmailOwnership" + "Customer" ], "responses": { "200": { - "description": "EmailOwnership resource created", + "description": "Customer resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EmailOwnership.StartEmailOwnershipOutput.jsonld-email_ownership.read" + "$ref": "#/components/schemas/Customer.CustomerOutput.jsonld-customer.read" } } }, @@ -10122,25 +10324,62 @@ "links": {} } }, - "summary": "Send a code to the authenticated account current email", - "description": "Send a code to the authenticated account current email", - "parameters": [] + "summary": "Restore an internal customer", + "description": "Restore an internal customer", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/equipment": { + "/api/direct-conversations": { "get": { - "operationId": "api_equipment_get_collection", + "operationId": "api_direct-conversations_get_collection", "tags": [ - "Equipment" + "DirectConversation" ], "responses": { "200": { - "description": "Equipment collection", + "description": "DirectConversation collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Equipment.EquipmentOutput.jsonld collection.", + "description": "DirectConversation.ConversationOutput.jsonld collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -10154,7 +10393,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" + "$ref": "#/components/schemas/DirectConversation.ConversationOutput.jsonld" } } } @@ -10186,8 +10425,8 @@ "links": {} } }, - "summary": "Retrieves the collection of Equipment resources.", - "description": "Retrieves the collection of Equipment resources.", + "summary": "Retrieves the collection of DirectConversation resources.", + "description": "Retrieves the collection of DirectConversation resources.", "parameters": [ { "name": "page", @@ -10210,7 +10449,7 @@ "deprecated": false, "schema": { "type": "integer", - "default": 50, + "default": 30, "minimum": 0, "maximum": 100 }, @@ -10220,32 +10459,8 @@ { "name": "organization", "in": "query", - "description": "Organization IRI. Required when intervention is omitted.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "intervention", - "in": "query", - "description": "Intervention IRI. Also scopes the organization and defaults recordStatus to draft.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "facility", - "in": "query", - "description": "Facility IRI.", - "required": false, + "description": "Organization IRI.", + "required": true, "deprecated": false, "schema": { "type": "string" @@ -10254,17 +10469,13 @@ "explode": true }, { - "name": "recordStatus", + "name": "isArchived", "in": "query", - "description": "Lifecycle status of the representation.", + "description": "Archived-flag filter.", "required": false, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "draft", - "published" - ] + "type": "boolean" }, "style": "form", "explode": true @@ -10272,17 +10483,17 @@ ] }, "post": { - "operationId": "api_equipment_post", + "operationId": "api_direct-conversations_post", "tags": [ - "Equipment" + "DirectConversation" ], "responses": { - "201": { - "description": "Equipment resource created", + "200": { + "description": "DirectConversation resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" + "$ref": "#/components/schemas/DirectConversation.ConversationOutput.jsonld" } } }, @@ -10352,15 +10563,15 @@ "links": {} } }, - "summary": "Creates a Equipment resource.", - "description": "Creates a Equipment resource.", + "summary": "Creates a DirectConversation resource.", + "description": "Creates a DirectConversation resource.", "parameters": [], "requestBody": { - "description": "The new Equipment resource", + "description": "The new DirectConversation resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Equipment.CreateEquipmentInput" + "$ref": "#/components/schemas/DirectConversation.GetOrCreateDirectConversationInput" } } }, @@ -10368,19 +10579,19 @@ } } }, - "/api/equipment/{id}": { + "/api/auth/email-ownership": { "get": { - "operationId": "api_equipment_id_get", + "operationId": "auth_email_ownership_get", "tags": [ - "Equipment" + "EmailOwnership" ], "responses": { "200": { - "description": "Equipment resource", + "description": "EmailOwnership resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" + "$ref": "#/components/schemas/EmailOwnership.EmailOwnershipOutput.jsonld-email_ownership.read" } } } @@ -10428,35 +10639,24 @@ "links": {} } }, - "summary": "Retrieves a Equipment resource.", - "description": "Retrieves a Equipment resource.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Equipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - }, - "put": { - "operationId": "canonical_equipment_put", + "summary": "Read explicit Fireguard mailbox ownership proof", + "description": "Read explicit Fireguard mailbox ownership proof", + "parameters": [] + } + }, + "/api/auth/email-ownership/confirm": { + "post": { + "operationId": "auth_email_ownership_confirm", "tags": [ - "Equipment" + "EmailOwnership" ], "responses": { - "201": { - "description": "Equipment resource updated", + "200": { + "description": "EmailOwnership resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" + "$ref": "#/components/schemas/EmailOwnership.EmailOwnershipOutput.jsonld-email_ownership.read" } } }, @@ -10524,68 +10724,44 @@ } }, "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "Replaces the Equipment resource.", - "description": "Replaces the Equipment resource.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Equipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], + "summary": "Confirm current mailbox possession with a single-use email code", + "description": "Confirm current mailbox possession with a single-use email code", + "parameters": [], "requestBody": { - "description": "The updated Equipment resource", + "description": "The new EmailOwnership resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Equipment.CreateEquipmentInput" + "$ref": "#/components/schemas/EmailOwnership.ConfirmEmailOwnershipInput-email_ownership.write" } } }, "required": true } - }, - "delete": { - "operationId": "canonical_equipment_delete", + } + }, + "/api/auth/email-ownership/start": { + "post": { + "operationId": "auth_email_ownership_start", "tags": [ - "Equipment" + "EmailOwnership" ], "responses": { - "204": { - "description": "Equipment resource deleted" + "200": { + "description": "EmailOwnership resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EmailOwnership.StartEmailOwnershipOutput.jsonld-email_ownership.read" + } + } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -10605,89 +10781,12 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - } - }, - "summary": "Removes the Equipment resource.", - "description": "Removes the Equipment resource.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Equipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - }, - "patch": { - "operationId": "canonical_equipment_patch", - "tags": [ - "Equipment" - ], - "responses": { - "200": { - "description": "Equipment resource updated", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { @@ -10723,72 +10822,27 @@ } }, "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "Updates the Equipment resource.", - "description": "Updates the Equipment resource.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Equipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The updated Equipment resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Equipment.PatchCanonicalEquipmentInput.jsonMergePatch" - } - } - }, - "required": true - } + "summary": "Send a code to the authenticated account current email", + "description": "Send a code to the authenticated account current email", + "parameters": [] } }, - "/api/organizations/{organizationId}/equipment": { + "/api/equipment": { "get": { - "operationId": "equipment_list", + "operationId": "api_equipment_get_collection", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Equipment list retrieved", + "description": "Equipment collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "LegacyEquipment.EquipmentOutput.jsonld-equipment.read collection.", + "description": "Equipment.EquipmentOutput.jsonld collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -10802,7 +10856,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" } } } @@ -10812,28 +10866,31 @@ } } }, - "400": { - "description": "Invalid organization identifier" - }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "List equipment", - "description": "Lists equipment items for the target organization.", + "summary": "Retrieves the collection of Equipment resources.", + "description": "Retrieves the collection of Equipment resources.", "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "LegacyEquipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, { "name": "page", "in": "query", @@ -10855,67 +10912,17 @@ "deprecated": false, "schema": { "type": "integer", - "default": 30, + "default": 50, "minimum": 0, - "maximum": 200 - }, - "style": "form", - "explode": true - }, - { - "name": "includeDescendants", - "in": "query", - "description": "Include equipment assigned to published descendants of the facility filter.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false - }, - "style": "form", - "explode": true - }, - { - "name": "facilityId", - "in": "query", - "description": "Filter by facility identifier.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" - }, - "style": "form", - "explode": true - }, - { - "name": "type", - "in": "query", - "description": "Filter by equipment type.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "status", - "in": "query", - "description": "Filter by equipment status.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" + "maximum": 100 }, "style": "form", "explode": true }, { - "name": "brand", + "name": "organization", "in": "query", - "description": "Filter by exact equipment brand.", + "description": "Organization IRI. Required when intervention is omitted.", "required": false, "deprecated": false, "schema": { @@ -10925,9 +10932,9 @@ "explode": true }, { - "name": "model", + "name": "intervention", "in": "query", - "description": "Filter by exact equipment model.", + "description": "Intervention IRI. Also scopes the organization and defaults recordStatus to draft.", "required": false, "deprecated": false, "schema": { @@ -10937,9 +10944,9 @@ "explode": true }, { - "name": "subType", + "name": "facility", "in": "query", - "description": "Filter by exact equipment subtype.", + "description": "Facility IRI.", "required": false, "deprecated": false, "schema": { @@ -10949,18 +10956,16 @@ "explode": true }, { - "name": "maintenanceDueStatus", + "name": "recordStatus", "in": "query", - "description": "Filter by cross-module maintenance due status (`unscheduled`, `up_to_date`, `due_soon`, `overdue`).", + "description": "Lifecycle status of the representation.", "required": false, "deprecated": false, "schema": { "type": "string", "enum": [ - "unscheduled", - "up_to_date", - "due_soon", - "overdue" + "draft", + "published" ] }, "style": "form", @@ -10969,17 +10974,17 @@ ] }, "post": { - "operationId": "equipment_create", + "operationId": "api_equipment_post", "tags": [ "Equipment" ], "responses": { "201": { - "description": "Equipment created", + "description": "Equipment resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" } } }, @@ -11006,12 +11011,6 @@ }, "links": {} }, - "409": { - "description": "Serial number already exists in this organization" - }, - "403": { - "description": "Insufficient permissions" - }, "422": { "description": "An error occurred", "content": { @@ -11032,30 +11031,38 @@ } }, "links": {} - } - }, - "summary": "Create equipment", - "description": "Creates a new equipment item in the organization stock.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "LegacyEquipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "simple", - "explode": false + "links": {} } - ], + }, + "summary": "Creates a Equipment resource.", + "description": "Creates a Equipment resource.", + "parameters": [], "requestBody": { - "description": "The new LegacyEquipment resource", + "description": "The new Equipment resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyEquipment.CreateEquipmentInput-equipment.write" + "$ref": "#/components/schemas/Equipment.CreateEquipmentInput" } } }, @@ -11063,39 +11070,73 @@ } } }, - "/api/organizations/{organizationId}/equipment/export": { + "/api/equipment/{id}": { "get": { - "operationId": "equipment_export", + "operationId": "api_equipment_id_get", "tags": [ "Equipment" ], "responses": { "200": { - "description": "CSV export streamed successfully" - }, - "400": { - "description": "Missing organizationId URI parameter" + "description": "Equipment resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" + } + } + } }, "403": { - "description": "Authenticated but missing organization.equipment.read" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "The organization is outside the caller's scope" - }, - "422": { - "description": "Export exceeds the row cap" - }, - "204": { - "description": "EquipmentExport resource" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Export equipment (CSV)", - "description": "Streams a CSV export of every equipment item (Content-Disposition: attachment) for the given organization. Requires `organization.equipment.read` on the organization, resolved the same way the list endpoint resolves it \u2014 a resource-level ROLE_USER check alone does not grant access. The first six columns (type, subType, brand, model, serialNumber, locationLabel) are the import round-trip contract read back by the bulk CSV import; the remaining columns are read-only metadata. Bounded to 50000 matching rows \u2014 the request is rejected with 422 if the organization has more.", + "summary": "Retrieves a Equipment resource.", + "description": "Retrieves a Equipment resource.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "EquipmentExport identifier", + "description": "Equipment identifier", "required": true, "deprecated": false, "schema": { @@ -11104,36 +11145,87 @@ "style": "simple", "explode": false } - ], - "security": [ - { - "bearerAuth": [] - } ] - } - }, - "/api/organizations/{organizationId}/equipment/kpis": { - "get": { - "operationId": "equipment_get_kpis", + }, + "put": { + "operationId": "canonical_equipment_put", "tags": [ "Equipment" ], "responses": { - "200": { - "description": "Equipment KPIs retrieved", + "201": { + "description": "Equipment resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EquipmentKpi.EquipmentKpiOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" } } - } + }, + "links": {} }, "400": { - "description": "Invalid organization identifier" + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { "description": "Not found", @@ -11157,13 +11249,13 @@ "links": {} } }, - "summary": "Get equipment KPIs", - "description": "Returns the four headline counters for the organization equipment overview: `totalAssets` (every status), `compliant` (cross-module maintenance due status `up_to_date`), `dueSoon` (cross-module maintenance due status `due_soon`), and `openNonConformities`. The last counter is deliberately ORGANIZATION-WIDE, not equipment-scoped: non-conformities attach to inspections, not to equipment, so there is no reliable per-equipment open-non-conformity count (see `src/Equipment/MODULE.md`).", + "summary": "Replaces the Equipment resource.", + "description": "Replaces the Equipment resource.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "EquipmentKpi identifier", + "description": "Equipment identifier", "required": true, "deprecated": false, "schema": { @@ -11172,42 +11264,78 @@ "style": "simple", "explode": false } - ] - } - }, - "/api/organizations/{organizationId}/equipment/labels": { - "get": { - "operationId": "equipment_export_labels", + ], + "requestBody": { + "description": "The updated Equipment resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Equipment.CreateEquipmentInput" + } + } + }, + "required": true + } + }, + "delete": { + "operationId": "canonical_equipment_delete", "tags": [ "Equipment" ], "responses": { - "200": { - "description": "QR label sheet PDF" - }, - "400": { - "description": "Missing organizationId, both ids[] and facilityId provided, or empty ids[]" + "204": { + "description": "Equipment resource deleted" }, "403": { - "description": "Missing organization.equipment.read permission" - }, - "404": { - "description": "Organization outside the caller's scope" - }, - "422": { - "description": "Selection matches more than 500 labels" - }, - "204": { - "description": "EquipmentLabelSheet resource" - } - }, - "summary": "Export a printable QR label sheet as PDF", - "description": "Streams an A4 PDF of QR equipment labels laid out on an Avery L7159-compatible grid (63.5 x 33.9 mm, 3 columns x 8 rows, 24 labels per page). Each QR encodes the equipment IRI /api/equipment/{id}, the format the field scan resolves. Selection: ids[] (explicit equipment list) OR facilityId (all equipment of one facility) OR neither (the whole organization park); providing both is a 400. At most 500 labels per request \u2014 422 beyond. Requires organization.equipment.read, resolved with the module's standard 403/404 split. Not plan-gated: labels are operational field material, not a reporting deliverable.", - "parameters": [ + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Removes the Equipment resource.", + "description": "Removes the Equipment resource.", + "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "EquipmentLabelSheet identifier", + "description": "Equipment identifier", "required": true, "deprecated": false, "schema": { @@ -11215,58 +11343,154 @@ }, "style": "simple", "explode": false + } + ] + }, + "patch": { + "operationId": "canonical_equipment_patch", + "tags": [ + "Equipment" + ], + "responses": { + "200": { + "description": "Equipment resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" + } + } + }, + "links": {} }, - { - "name": "ids[]", - "in": "query", - "description": "Explicit equipment identifiers to print, one label each. Mutually exclusive with facilityId.", - "required": false, - "deprecated": false, - "schema": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } } }, - "style": "form", - "explode": true + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Updates the Equipment resource.", + "description": "Updates the Equipment resource.", + "parameters": [ { - "name": "facilityId", - "in": "query", - "description": "Print one label for every equipment item of this facility. Mutually exclusive with ids[].", - "required": false, + "name": "id", + "in": "path", + "description": "Equipment identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "uuid" + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ], - "security": [ - { - "bearerAuth": [] - } - ] + "requestBody": { + "description": "The updated Equipment resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Equipment.PatchCanonicalEquipmentInput.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/equipment/tags": { + "/api/organizations/{organizationId}/equipment": { "get": { - "operationId": "equipment_list_tags", + "operationId": "equipment_list", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Tag catalog retrieved", + "description": "Equipment list retrieved", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "EquipmentTag.TagOutput.jsonld-equipment.read collection.", + "description": "LegacyEquipment.EquipmentOutput.jsonld-equipment.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -11280,7 +11504,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/EquipmentTag.TagOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" } } } @@ -11297,13 +11521,13 @@ "description": "Insufficient permissions" } }, - "summary": "List tag catalog", - "description": "Lists all tags available in the organization catalog. Supports optional search for typeahead.", + "summary": "List equipment", + "description": "Lists equipment items for the target organization.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentTag identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -11335,15 +11559,71 @@ "type": "integer", "default": 30, "minimum": 0, - "maximum": 100 + "maximum": 200 }, "style": "form", "explode": true }, { - "name": "search", + "name": "family", "in": "query", - "description": "Filter tags by name (case-insensitive substring match).", + "description": "LegacyEquipment family", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "customerId", + "in": "query", + "description": "LegacyEquipment customerId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "includeDescendants", + "in": "query", + "description": "Include equipment assigned to published descendants of the facility filter.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "facilityId", + "in": "query", + "description": "Filter by facility identifier.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "type", + "in": "query", + "description": "Filter by equipment type.", "required": false, "deprecated": false, "schema": { @@ -11351,74 +11631,84 @@ }, "style": "form", "explode": true - } - ] - } - }, - "/api/organizations/{organizationId}/equipment/{equipmentId}": { - "get": { - "operationId": "equipment_get", - "tags": [ - "Equipment" - ], - "responses": { - "200": { - "description": "Equipment retrieved", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" - } - } - } }, - "400": { - "description": "Invalid identifier" + { + "name": "status", + "in": "query", + "description": "Filter by equipment status.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true }, - "403": { - "description": "Insufficient permissions" + { + "name": "brand", + "in": "query", + "description": "Filter by exact equipment brand.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true }, - "404": { - "description": "Equipment not found" - } - }, - "summary": "Get equipment", - "description": "Returns one equipment item by identifier.", - "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "LegacyEquipment identifier", - "required": true, + "name": "model", + "in": "query", + "description": "Filter by exact equipment model.", + "required": false, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true }, { - "name": "equipmentId", - "in": "path", - "description": "LegacyEquipment identifier", - "required": true, + "name": "subType", + "in": "query", + "description": "Filter by exact equipment subtype.", + "required": false, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true + }, + { + "name": "maintenanceDueStatus", + "in": "query", + "description": "Filter by maintenance due status (`unscheduled`, `up_to_date`, `due_soon`, `overdue`); `due` combines due-soon and overdue before pagination.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "unscheduled", + "up_to_date", + "due_soon", + "overdue", + "due" + ] + }, + "style": "form", + "explode": true } ] }, - "patch": { - "operationId": "equipment_update", + "post": { + "operationId": "equipment_create", "tags": [ "Equipment" ], "responses": { - "200": { - "description": "Equipment updated", + "201": { + "description": "Equipment created", "content": { "application/ld+json": { "schema": { @@ -11455,9 +11745,6 @@ "403": { "description": "Insufficient permissions" }, - "404": { - "description": "Equipment not found" - }, "422": { "description": "An error occurred", "content": { @@ -11480,8 +11767,8 @@ "links": {} } }, - "summary": "Patch equipment", - "description": "Partially updates equipment information.", + "summary": "Create equipment", + "description": "Creates a new equipment item in the organization stock.", "parameters": [ { "name": "organizationId", @@ -11494,26 +11781,14 @@ }, "style": "simple", "explode": false - }, - { - "name": "equipmentId", - "in": "path", - "description": "LegacyEquipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false } ], "requestBody": { - "description": "The updated LegacyEquipment resource", + "description": "The new LegacyEquipment resource", "content": { - "application/merge-patch+json": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyEquipment.UpdateEquipmentInput-equipment.write.jsonMergePatch" + "$ref": "#/components/schemas/LegacyEquipment.CreateEquipmentInput-equipment.write" } } }, @@ -11521,80 +11796,37 @@ } } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/assign": { - "post": { - "operationId": "equipment_assign_to_facility", + "/api/organizations/{organizationId}/equipment-summary": { + "get": { + "operationId": "organization_equipment_summary_get", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Equipment assigned", + "description": "Equipment totals retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/OrganizationEquipmentSummary.FacilityEquipmentSummaryOutput.jsonld" } } - }, - "links": {} + } }, "403": { - "description": "Insufficient permissions" + "description": "Missing equipment read permission" }, "404": { - "description": "Equipment not found" - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} + "description": "Organization or customer not found" } }, - "summary": "Assign to facility", - "description": "Assigns the equipment to a facility.", + "summary": "Get exact organization equipment totals", + "description": "Get exact organization equipment totals", "parameters": [ { "name": "organizationId", "in": "path", - "description": "LegacyEquipment identifier", + "description": "OrganizationEquipmentSummary identifier", "required": true, "deprecated": false, "schema": { @@ -11604,48 +11836,55 @@ "explode": false }, { - "name": "equipmentId", - "in": "path", - "description": "LegacyEquipment identifier", - "required": true, + "name": "family", + "in": "query", + "description": "OrganizationEquipmentSummary family", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The new LegacyEquipment resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/LegacyEquipment.AssignToFacilityInput-equipment.write" - } - } + "style": "form", + "explode": true }, - "required": true - } + { + "name": "customerId", + "in": "query", + "description": "OrganizationEquipmentSummary customerId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + } + ] } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/attachments": { + "/api/organizations/{organizationId}/equipment-types": { "get": { - "operationId": "equipment_attachments_list", + "operationId": "equipment_type_catalog_list", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Attachments retrieved", + "description": "Catalog including archived descriptors", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "EquipmentAttachment.AttachmentOutput.jsonld-equipment.read collection.", + "description": "EquipmentType.EquipmentTypeOutput.jsonld-equipment_type.read collection.", "allOf": [ { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" }, { "type": "object", @@ -11656,7 +11895,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/EquipmentAttachment.AttachmentOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/EquipmentType.EquipmentTypeOutput.jsonld-equipment_type.read" } } } @@ -11666,23 +11905,20 @@ } } }, - "400": { - "description": "Invalid identifier" - }, "403": { - "description": "Insufficient permissions" + "description": "Missing equipment-read permission" }, "404": { - "description": "Equipment not found" + "description": "Organization outside caller scope" } }, - "summary": "List attachments", - "description": "Lists all attachments for a given equipment item.", + "summary": "List organization equipment types, including archived types", + "description": "List organization equipment types, including archived types", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentAttachment identifier", + "description": "EquipmentType identifier", "required": true, "deprecated": false, "schema": { @@ -11690,119 +11926,85 @@ }, "style": "simple", "explode": false - }, - { - "name": "equipmentId", - "in": "path", - "description": "EquipmentAttachment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true } ] }, "post": { - "operationId": "equipment_add_attachment", + "operationId": "equipment_type_catalog_create", "tags": [ "Equipment" ], "responses": { "201": { - "description": "Attachment uploaded", + "description": "Active equipment type created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EquipmentAttachment.AttachmentOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/EquipmentType.EquipmentTypeOutput.jsonld-equipment_type.read" } } }, "links": {} }, - "400": { - "description": "Invalid input", + "403": { + "description": "Missing equipment-write permission" + }, + "404": { + "description": "Organization outside caller scope" + }, + "409": { + "description": "Type code already exists" + }, + "422": { + "description": "Invalid code, label or family", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Equipment not found" - }, - "422": { - "description": "An error occurred", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Add attachment", - "description": "Uploads a file attachment to the equipment (base64-encoded content in JSON).", + "summary": "Create an organization equipment type", + "description": "Create an organization equipment type", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentAttachment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "equipmentId", - "in": "path", - "description": "EquipmentAttachment identifier", + "description": "EquipmentType identifier", "required": true, "deprecated": false, "schema": { @@ -11813,11 +12015,11 @@ } ], "requestBody": { - "description": "The new EquipmentAttachment resource", + "description": "The new EquipmentType resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EquipmentAttachment.AddAttachmentInput-equipment.write" + "$ref": "#/components/schemas/EquipmentType.CreateEquipmentTypeInput-equipment_type.write" } } }, @@ -11825,107 +12027,37 @@ } } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/attachments/{attachmentId}": { - "delete": { - "operationId": "equipment_delete_attachment", - "tags": [ - "Equipment" - ], - "responses": { - "204": { - "description": "Attachment deleted" - }, - "400": { - "description": "Invalid identifier" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Equipment or attachment not found" - } - }, - "summary": "Delete attachment", - "description": "Deletes an attachment from the equipment.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "EquipmentAttachment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "equipmentId", - "in": "path", - "description": "EquipmentAttachment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "attachmentId", - "in": "path", - "description": "EquipmentAttachment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - } - }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/attachments/{attachmentId}/download": { + "/api/organizations/{organizationId}/equipment-types/{typeCode}": { "get": { - "operationId": "equipment_download_attachment", + "operationId": "equipment_type_catalog_get", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Attachment file content" + "description": "Equipment type descriptor", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EquipmentType.EquipmentTypeOutput.jsonld-equipment_type.read" + } + } + } }, "403": { - "description": "Insufficient permissions (requires organization.equipment.read)" + "description": "Missing equipment-read permission" }, "404": { - "description": "Organization, equipment, attachment, or its stored content not found" - }, - "204": { - "description": "EquipmentAttachmentContent resource" + "description": "Organization outside caller scope or unknown equipment type" } }, - "summary": "Download an equipment attachment", - "description": "Streams the stored file bytes with Content-Disposition: attachment and X-Content-Type-Options: nosniff. Gated by organization.equipment.read.", + "summary": "Read an organization equipment type", + "description": "Read an organization equipment type", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentAttachmentContent identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "equipmentId", - "in": "path", - "description": "EquipmentAttachmentContent identifier", + "description": "EquipmentType identifier", "required": true, "deprecated": false, "schema": { @@ -11935,9 +12067,9 @@ "explode": false }, { - "name": "attachmentId", + "name": "typeCode", "in": "path", - "description": "EquipmentAttachmentContent identifier", + "description": "EquipmentType identifier", "required": true, "deprecated": false, "schema": { @@ -11947,85 +12079,83 @@ "explode": false } ] - } - }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/commission": { - "post": { - "operationId": "equipment_commission", + }, + "patch": { + "operationId": "equipment_type_catalog_patch", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Equipment commissioned", + "description": "Equipment type updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/EquipmentType.EquipmentTypeOutput.jsonld-equipment_type.read" } } }, "links": {} }, - "400": { - "description": "Invalid identifier or equipment not assigned to a facility", + "403": { + "description": "Missing equipment-write permission" + }, + "404": { + "description": "Organization outside caller scope or unknown equipment type" + }, + "412": { + "description": "Observed descriptor revision is stale" + }, + "422": { + "description": "Invalid label, family or revision", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} }, - "409": { - "description": "Equipment already decommissioned" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Equipment not found" - }, - "422": { - "description": "An error occurred", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Commission equipment", - "description": "Marks the equipment as commissioned and operational.", + "summary": "Update or archive a type using its observed revision", + "description": "Update or archive a type using its observed revision", "parameters": [ { "name": "organizationId", "in": "path", - "description": "LegacyEquipment identifier", + "description": "EquipmentType identifier", "required": true, "deprecated": false, "schema": { @@ -12035,9 +12165,9 @@ "explode": false }, { - "name": "equipmentId", + "name": "typeCode", "in": "path", - "description": "LegacyEquipment identifier", + "description": "EquipmentType identifier", "required": true, "deprecated": false, "schema": { @@ -12047,86 +12177,52 @@ "explode": false } ], - "deprecated": true + "requestBody": { + "description": "The updated EquipmentType resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/EquipmentType.PatchEquipmentTypeInput-equipment_type.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/decommission": { - "post": { - "operationId": "equipment_decommission", + "/api/organizations/{organizationId}/equipment/export": { + "get": { + "operationId": "equipment_export", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Equipment decommissioned", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" - } - } - }, - "links": {} + "description": "CSV export streamed successfully" }, "400": { - "description": "Invalid identifier", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "409": { - "description": "Equipment already decommissioned" + "description": "Missing organizationId URI parameter" }, "403": { - "description": "Insufficient permissions" + "description": "Authenticated but missing organization.equipment.read" }, "404": { - "description": "Equipment not found" + "description": "The organization is outside the caller's scope" }, "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} + "description": "Export exceeds the row cap" + }, + "204": { + "description": "EquipmentExport resource" } }, - "summary": "Decommission equipment", - "description": "Permanently decommissions the equipment.", + "summary": "Export equipment (CSV)", + "description": "Streams a CSV export of every equipment item (Content-Disposition: attachment) for the given organization. Requires `organization.equipment.read` on the organization, resolved the same way the list endpoint resolves it \u2014 a resource-level ROLE_USER check alone does not grant access. The first six columns (type, subType, brand, model, serialNumber, locationLabel) are the import round-trip contract read back by the bulk CSV import; the remaining columns are read-only metadata. Bounded to 50000 matching rows \u2014 the request is rejected with 422 if the organization has more.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "LegacyEquipment identifier", + "description": "EquipmentExport identifier", "required": true, "deprecated": false, "schema": { @@ -12134,99 +12230,67 @@ }, "style": "simple", "explode": false - }, + } + ], + "security": [ { - "name": "equipmentId", - "in": "path", - "description": "LegacyEquipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/maintenance": { - "post": { - "operationId": "equipment_maintenance", + "/api/organizations/{organizationId}/equipment/kpis": { + "get": { + "operationId": "equipment_get_kpis", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Equipment put under maintenance", + "description": "Equipment KPIs retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/EquipmentKpi.EquipmentKpiOutput.jsonld-equipment.read" } } - }, - "links": {} + } }, "400": { - "description": "Invalid identifier or equipment not assigned to a facility", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "409": { - "description": "Equipment already decommissioned" + "description": "Invalid organization identifier" }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Equipment not found" - }, - "422": { - "description": "An error occurred", + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Put under maintenance", - "description": "Marks the equipment as under maintenance.", + "summary": "Get equipment KPIs", + "description": "Returns the four headline counters for the organization equipment overview: `totalAssets` (every status), `compliant` (cross-module maintenance due status `up_to_date`), `dueSoon` (cross-module maintenance due status `due_soon`), and `openNonConformities`. The last counter is deliberately ORGANIZATION-WIDE, not equipment-scoped: non-conformities attach to inspections, not to equipment, so there is no reliable per-equipment open-non-conformity count (see `src/Equipment/MODULE.md`).", "parameters": [ { "name": "organizationId", "in": "path", - "description": "LegacyEquipment identifier", + "description": "EquipmentKpi identifier", "required": true, "deprecated": false, "schema": { @@ -12234,11 +12298,43 @@ }, "style": "simple", "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/equipment/labels": { + "get": { + "operationId": "equipment_export_labels", + "tags": [ + "Equipment" + ], + "responses": { + "200": { + "description": "QR label sheet PDF" + }, + "400": { + "description": "Missing organizationId, both ids[] and facilityId provided, or empty ids[]" + }, + "403": { + "description": "Missing organization.equipment.read permission" + }, + "404": { + "description": "Organization outside the caller's scope" }, + "422": { + "description": "Selection matches more than 500 labels" + }, + "204": { + "description": "EquipmentLabelSheet resource" + } + }, + "summary": "Export a printable QR label sheet as PDF", + "description": "Streams an A4 PDF of QR equipment labels laid out on an Avery L7159-compatible grid (63.5 x 33.9 mm, 3 columns x 8 rows, 24 labels per page). Each QR encodes the equipment IRI /api/equipment/{id}, the format the field scan resolves. Selection: ids[] (explicit equipment list) OR facilityId (all equipment of one facility) OR neither (the whole organization park); providing both is a 400. At most 500 labels per request \u2014 422 beyond. Requires organization.equipment.read, resolved with the module's standard 403/404 split. Not plan-gated: labels are operational field material, not a reporting deliverable.", + "parameters": [ { - "name": "equipmentId", + "name": "organizationId", "in": "path", - "description": "LegacyEquipment identifier", + "description": "EquipmentLabelSheet identifier", "required": true, "deprecated": false, "schema": { @@ -12246,24 +12342,58 @@ }, "style": "simple", "explode": false + }, + { + "name": "ids[]", + "in": "query", + "description": "Explicit equipment identifiers to print, one label each. Mutually exclusive with facilityId.", + "required": false, + "deprecated": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "style": "form", + "explode": true + }, + { + "name": "facilityId", + "in": "query", + "description": "Print one label for every equipment item of this facility. Mutually exclusive with ids[].", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/maintenance-logs": { + "/api/organizations/{organizationId}/equipment/tags": { "get": { - "operationId": "equipment_list_maintenance_logs", + "operationId": "equipment_list_tags", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Maintenance logs retrieved", + "description": "Tag catalog retrieved", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "EquipmentMaintenanceLog.MaintenanceLogOutput.jsonld-equipment.read collection.", + "description": "EquipmentTag.TagOutput.jsonld-equipment.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -12277,7 +12407,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/EquipmentMaintenanceLog.MaintenanceLogOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/EquipmentTag.TagOutput.jsonld-equipment.read" } } } @@ -12288,34 +12418,19 @@ } }, "400": { - "description": "Invalid identifier" + "description": "Invalid organization identifier" }, "403": { "description": "Insufficient permissions" - }, - "404": { - "description": "Equipment not found" } }, - "summary": "List maintenance logs", - "description": "Lists the maintenance windows recorded for a given equipment item, ordered by start date descending.", + "summary": "List tag catalog", + "description": "Lists all tags available in the organization catalog. Supports optional search for typeahead.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentMaintenanceLog identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "equipmentId", - "in": "path", - "description": "EquipmentMaintenanceLog identifier", + "description": "EquipmentTag identifier", "required": true, "deprecated": false, "schema": { @@ -12345,88 +12460,57 @@ "deprecated": false, "schema": { "type": "integer", - "default": 20, + "default": 30, "minimum": 0, "maximum": 100 }, "style": "form", "explode": true + }, + { + "name": "search", + "in": "query", + "description": "Filter tags by name (case-insensitive substring match).", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true } ] } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/plan-position": { - "put": { - "operationId": "equipment_plan_position_set", + "/api/organizations/{organizationId}/equipment/{equipmentId}": { + "get": { + "operationId": "equipment_get", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Plan position set or cleared", + "description": "Equipment retrieved", "content": { "application/ld+json": { "schema": { "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" } } - }, - "links": {} + } }, "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Invalid identifier" }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Equipment or attachment not found" - }, - "409": { - "description": "Equipment not assigned to a facility, already decommissioned, or attachment is not a floor plan / not an ancestor" - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} + "description": "Equipment not found" } }, - "summary": "Set equipment plan position", - "description": "Pins the equipment at a point over a floor plan attachment belonging to its own facility or one of its ancestors. Submit \"attachmentId\", \"x\" and \"y\" (normalized 0-1 coordinates) together to set or replace the position, or all three null to clear it. The equipment must be assigned to a facility.", + "summary": "Get equipment", + "description": "Returns one equipment item by identifier.", "parameters": [ { "name": "organizationId", @@ -12452,50 +12536,84 @@ "style": "simple", "explode": false } - ], - "requestBody": { - "description": "The updated LegacyEquipment resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/LegacyEquipment.SetEquipmentPlanPositionInput-equipment.write" - } - } - }, - "required": true - } - } - }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/report": { - "get": { - "operationId": "equipment_export_report", + ] + }, + "patch": { + "operationId": "equipment_update", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Equipment sheet PDF" + "description": "Equipment updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + } + } + }, + "links": {} }, "400": { - "description": "Missing organizationId or equipmentId URI parameter" + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "409": { + "description": "Serial number already exists in this organization" }, "403": { - "description": "Missing organization.equipment.read, or plan not entitled (pro/max required)" + "description": "Insufficient permissions" }, "404": { - "description": "Organization outside the caller's scope, or equipment not found" + "description": "Equipment not found" }, - "204": { - "description": "EquipmentReportExport resource" + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} } }, - "summary": "Export an equipment sheet as PDF", - "description": "Streams a PDF sheet of one equipment item (identity, maintenance history, attachment index) with Content-Disposition: attachment. Requires organization.equipment.read, resolved with the module's standard 403/404 split, AND a pro/max plan \u2014 the same entitlement gate as the regulatory safety register export.", + "summary": "Patch equipment", + "description": "Partially updates equipment information.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentReportExport identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -12507,7 +12625,7 @@ { "name": "equipmentId", "in": "path", - "description": "EquipmentReportExport identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -12517,26 +12635,32 @@ "explode": false } ], - "security": [ - { - "bearerAuth": [] - } - ] + "requestBody": { + "description": "The updated LegacyEquipment resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/LegacyEquipment.UpdateEquipmentInput-equipment.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/tags": { + "/api/organizations/{organizationId}/equipment/{equipmentId}/assign": { "post": { - "operationId": "equipment_add_tag", + "operationId": "equipment_assign_to_facility", "tags": [ "Equipment" ], "responses": { - "201": { - "description": "Tag added", + "200": { + "description": "Equipment assigned", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EquipmentTag.TagOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" } } }, @@ -12591,13 +12715,13 @@ "links": {} } }, - "summary": "Add tag to equipment", - "description": "Adds a tag (created if not existing) to the equipment.", + "summary": "Assign to facility", + "description": "Assigns the equipment to a facility.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentTag identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -12609,7 +12733,7 @@ { "name": "equipmentId", "in": "path", - "description": "EquipmentTag identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -12620,11 +12744,11 @@ } ], "requestBody": { - "description": "The new EquipmentTag resource", + "description": "The new LegacyEquipment resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EquipmentTag.AddTagInput-equipment.write" + "$ref": "#/components/schemas/LegacyEquipment.AssignToFacilityInput-equipment.write" } } }, @@ -12632,15 +12756,42 @@ } } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/tags/{tagId}": { - "delete": { - "operationId": "equipment_remove_tag", + "/api/organizations/{organizationId}/equipment/{equipmentId}/attachments": { + "get": { + "operationId": "equipment_attachments_list", "tags": [ "Equipment" ], "responses": { - "204": { - "description": "Tag removed" + "200": { + "description": "Attachments retrieved", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "EquipmentAttachment.AttachmentOutput.jsonld-equipment.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EquipmentAttachment.AttachmentOutput.jsonld-equipment.read" + } + } + } + } + ] + } + } + } }, "400": { "description": "Invalid identifier" @@ -12649,16 +12800,16 @@ "description": "Insufficient permissions" }, "404": { - "description": "Equipment or tag not found" + "description": "Equipment not found" } }, - "summary": "Remove tag from equipment", - "description": "Removes a tag from the equipment.", + "summary": "List attachments", + "description": "Lists all attachments for a given equipment item.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentTag identifier", + "description": "EquipmentAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -12670,7 +12821,7 @@ { "name": "equipmentId", "in": "path", - "description": "EquipmentTag identifier", + "description": "EquipmentAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -12680,40 +12831,39 @@ "explode": false }, { - "name": "tagId", - "in": "path", - "description": "EquipmentTag identifier", - "required": true, + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 1 }, - "style": "simple", - "explode": false + "style": "form", + "explode": true } ] - } - }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/unassign": { + }, "post": { - "operationId": "equipment_unassign_from_facility", + "operationId": "equipment_add_attachment", "tags": [ "Equipment" ], "responses": { - "200": { - "description": "Equipment unassigned", + "201": { + "description": "Attachment uploaded", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/EquipmentAttachment.AttachmentOutput.jsonld-equipment.read" } } }, "links": {} }, "400": { - "description": "Invalid identifier", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -12761,13 +12911,13 @@ "links": {} } }, - "summary": "Unassign from facility", - "description": "Removes the equipment from its current facility.", + "summary": "Add attachment", + "description": "Uploads a file attachment to the equipment (base64-encoded content in JSON).", "parameters": [ { "name": "organizationId", "in": "path", - "description": "LegacyEquipment identifier", + "description": "EquipmentAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -12779,7 +12929,7 @@ { "name": "equipmentId", "in": "path", - "description": "LegacyEquipment identifier", + "description": "EquipmentAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -12788,63 +12938,47 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The new EquipmentAttachment resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EquipmentAttachment.AddAttachmentInput-equipment.write" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/equipment": { - "get": { - "operationId": "facility_equipment_list", + "/api/organizations/{organizationId}/equipment/{equipmentId}/attachments/{attachmentId}": { + "delete": { + "operationId": "equipment_delete_attachment", "tags": [ "Equipment" ], "responses": { - "200": { - "description": "Facility equipment list retrieved", - "content": { - "application/ld+json": { - "schema": { - "type": "object", - "description": "LegacyEquipment.EquipmentOutput.jsonld-equipment.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" - } - } - } - } - ] - } - } - } + "204": { + "description": "Attachment deleted" }, "400": { - "description": "Invalid organization or facility identifier" - }, - "404": { - "description": "Facility not found in the organization" + "description": "Invalid identifier" }, "403": { "description": "Insufficient permissions" + }, + "404": { + "description": "Equipment or attachment not found" } }, - "summary": "List facility equipment", - "description": "Lists directly assigned equipment, or the published facility subtree when includeDescendants is true. Search and pagination apply before the returned page.", + "summary": "Delete attachment", + "description": "Deletes an attachment from the equipment.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "LegacyEquipment identifier", + "description": "EquipmentAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -12854,9 +12988,9 @@ "explode": false }, { - "name": "facilityId", + "name": "equipmentId", "in": "path", - "description": "LegacyEquipment identifier", + "description": "EquipmentAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -12866,161 +13000,47 @@ "explode": false }, { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true - }, - { - "name": "includeDescendants", - "in": "query", - "description": "Include equipment assigned to published descendants of this facility.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false - }, - "style": "form", - "explode": true - }, - { - "name": "type", - "in": "query", - "description": "Filter by equipment type.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "status", - "in": "query", - "description": "Filter by equipment status.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "brand", - "in": "query", - "description": "Filter by exact equipment brand.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "model", - "in": "query", - "description": "Filter by exact equipment model.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "subType", - "in": "query", - "description": "Filter by exact equipment subtype.", - "required": false, + "name": "attachmentId", + "in": "path", + "description": "EquipmentAttachment identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true - }, - { - "name": "maintenanceDueStatus", - "in": "query", - "description": "Filter by cross-module maintenance due status (`unscheduled`, `up_to_date`, `due_soon`, `overdue`).", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "unscheduled", - "up_to_date", - "due_soon", - "overdue" - ] - }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/equipment-summary": { + "/api/organizations/{organizationId}/equipment/{equipmentId}/attachments/{attachmentId}/download": { "get": { - "operationId": "facility_equipment_summary_get", + "operationId": "equipment_download_attachment", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Facility equipment totals retrieved", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/FacilityEquipmentSummary.FacilityEquipmentSummaryOutput.jsonld" - } - } - } - }, - "400": { - "description": "Invalid scope or identifiers" + "description": "Attachment file content" }, "403": { - "description": "Missing organization.equipment.read permission" + "description": "Insufficient permissions (requires organization.equipment.read)" }, "404": { - "description": "Facility not found in the organization" + "description": "Organization, equipment, attachment, or its stored content not found" + }, + "204": { + "description": "EquipmentAttachmentContent resource" } }, - "summary": "Get exact facility equipment totals", - "description": "Counts published equipment in the same direct or subtree scope as the equipment collection, before pagination.", + "summary": "Download an equipment attachment", + "description": "Streams the stored file bytes with Content-Disposition: attachment and X-Content-Type-Options: nosniff. Gated by organization.equipment.read.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "FacilityEquipmentSummary identifier", + "description": "EquipmentAttachmentContent identifier", "required": true, "deprecated": false, "schema": { @@ -13030,9 +13050,9 @@ "explode": false }, { - "name": "facilityId", + "name": "equipmentId", "in": "path", - "description": "FacilityEquipmentSummary identifier", + "description": "EquipmentAttachmentContent identifier", "required": true, "deprecated": false, "schema": { @@ -13042,60 +13062,40 @@ "explode": false }, { - "name": "includeDescendants", - "in": "query", - "description": "FacilityEquipmentSummary includeDescendants", - "required": false, + "name": "attachmentId", + "in": "path", + "description": "EquipmentAttachmentContent identifier", + "required": true, "deprecated": false, "schema": { - "type": "boolean", - "default": true + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] } }, - "/api/facilities": { - "get": { - "operationId": "api_facilities_get_collection", + "/api/organizations/{organizationId}/equipment/{equipmentId}/commission": { + "post": { + "operationId": "equipment_commission", "tags": [ - "Facility" + "Equipment" ], "responses": { "200": { - "description": "Facility collection", + "description": "Equipment commissioned", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "Facility.FacilityOutput.jsonld collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" - } - } - } - } - ] + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" } } - } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid identifier or equipment not assigned to a facility", "content": { "application/ld+json": { "schema": { @@ -13114,113 +13114,89 @@ } }, "links": {} - } - }, - "summary": "Retrieves the collection of Facility resources.", - "description": "Retrieves the collection of Facility resources.", - "parameters": [ - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 50, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true + "409": { + "description": "Equipment already decommissioned" }, - { - "name": "includePath", - "in": "query", - "description": "Resolve ancestor breadcrumbs for this page in one organization-scoped read.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false - }, - "style": "form", - "explode": true + "403": { + "description": "Insufficient permissions" }, - { - "name": "organization", - "in": "query", - "description": "Organization IRI. Required when intervention is omitted.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true + "404": { + "description": "Equipment not found" }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Commission equipment", + "description": "Marks the equipment as commissioned and operational.", + "parameters": [ { - "name": "intervention", - "in": "query", - "description": "Intervention IRI. Also scopes the organization and defaults recordStatus to draft.", - "required": false, + "name": "organizationId", + "in": "path", + "description": "LegacyEquipment identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "recordStatus", - "in": "query", - "description": "Lifecycle status of the representation.", - "required": false, + "name": "equipmentId", + "in": "path", + "description": "LegacyEquipment identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "draft", - "published" - ] + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } - ] - }, + ], + "deprecated": true + } + }, + "/api/organizations/{organizationId}/equipment/{equipmentId}/decommission": { "post": { - "operationId": "api_facilities_post", + "operationId": "equipment_decommission", "tags": [ - "Facility" + "Equipment" ], "responses": { - "201": { - "description": "Facility resource created", + "200": { + "description": "Equipment decommissioned", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid identifier", "content": { "application/ld+json": { "schema": { @@ -13240,6 +13216,15 @@ }, "links": {} }, + "409": { + "description": "Equipment already decommissioned" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Equipment not found" + }, "422": { "description": "An error occurred", "content": { @@ -13260,9 +13245,58 @@ } }, "links": {} + } + }, + "summary": "Decommission equipment", + "description": "Permanently decommissions the equipment.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "LegacyEquipment identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "403": { - "description": "Forbidden", + { + "name": "equipmentId", + "in": "path", + "description": "LegacyEquipment identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/equipment/{equipmentId}/maintenance": { + "post": { + "operationId": "equipment_maintenance", + "tags": [ + "Equipment" + ], + "responses": { + "200": { + "description": "Equipment put under maintenance", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid identifier or equipment not assigned to a facility", "content": { "application/ld+json": { "schema": { @@ -13281,38 +13315,82 @@ } }, "links": {} + }, + "409": { + "description": "Equipment already decommissioned" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Equipment not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} } }, - "summary": "Creates a Facility resource.", - "description": "Creates a Facility resource.", - "parameters": [], - "requestBody": { - "description": "The new Facility resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Facility.CreateFacilityInput" - } - } + "summary": "Put under maintenance", + "description": "Marks the equipment as under maintenance.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "LegacyEquipment identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "required": true - } + { + "name": "equipmentId", + "in": "path", + "description": "LegacyEquipment identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/facilities/{facilityId}/attachments": { + "/api/organizations/{organizationId}/equipment/{equipmentId}/maintenance-logs": { "get": { - "operationId": "api_facilities_facilityIdattachments_get_collection", + "operationId": "equipment_list_maintenance_logs", "tags": [ - "Facility" + "Equipment" ], "responses": { "200": { - "description": "Attachments retrieved", + "description": "Maintenance logs retrieved", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read collection.", + "description": "EquipmentMaintenanceLog.MaintenanceLogOutput.jsonld-equipment.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -13326,7 +13404,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" + "$ref": "#/components/schemas/EquipmentMaintenanceLog.MaintenanceLogOutput.jsonld-equipment.read" } } } @@ -13336,20 +13414,35 @@ } } }, + "400": { + "description": "Invalid identifier" + }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Facility not found" + "description": "Equipment not found" } }, - "summary": "List facility attachments", - "description": "Lists all attachments for a given facility.", + "summary": "List maintenance logs", + "description": "Lists the maintenance windows recorded for a given equipment item, ordered by start date descending.", "parameters": [ { - "name": "facilityId", + "name": "organizationId", "in": "path", - "description": "FacilityAttachment identifier", + "description": "EquipmentMaintenanceLog identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "equipmentId", + "in": "path", + "description": "EquipmentMaintenanceLog identifier", "required": true, "deprecated": false, "schema": { @@ -13372,35 +13465,36 @@ "explode": true }, { - "name": "kind", + "name": "itemsPerPage", "in": "query", - "description": "Filter by attachment kind (\"document\" or \"floor_plan\").", + "description": "The number of items per page", "required": false, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "document", - "floor_plan" - ] + "type": "integer", + "default": 20, + "minimum": 0, + "maximum": 100 }, "style": "form", "explode": true } ] - }, - "post": { - "operationId": "api_facilities_facilityIdattachments_post", + } + }, + "/api/organizations/{organizationId}/equipment/{equipmentId}/plan-position": { + "put": { + "operationId": "equipment_plan_position_set", "tags": [ - "Facility" + "Equipment" ], "responses": { - "201": { - "description": "Attachment uploaded", + "200": { + "description": "Plan position set or cleared", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" } } }, @@ -13427,8 +13521,17 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Equipment or attachment not found" + }, + "409": { + "description": "Equipment not assigned to a facility, already decommissioned, or attachment is not a floor plan / not an ancestor" + }, "422": { - "description": "MIME type or size rejected", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -13447,21 +13550,27 @@ } }, "links": {} - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Facility not found" } }, - "summary": "Upload a facility attachment", - "description": "Uploads a multipart file attachment to a facility. An optional `kind` field (\"document\", the default, or \"floor_plan\") selects the attachment kind; a floor plan is restricted to image/png, image/jpeg, image/webp or image/svg+xml and has its pixel dimensions probed server-side.", + "summary": "Set equipment plan position", + "description": "Pins the equipment at a point over a floor plan attachment belonging to its own facility or one of its ancestors. Submit \"attachmentId\", \"x\" and \"y\" (normalized 0-1 coordinates) together to set or replace the position, or all three null to clear it. The equipment must be assigned to a facility.", "parameters": [ { - "name": "facilityId", + "name": "organizationId", "in": "path", - "description": "FacilityAttachment identifier", + "description": "LegacyEquipment identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "equipmentId", + "in": "path", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -13470,49 +13579,70 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The updated LegacyEquipment resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyEquipment.SetEquipmentPlanPositionInput-equipment.write" + } + } + }, + "required": true + } } }, - "/api/facilities/{id}": { - "get": { - "operationId": "api_facilities_id_get", + "/api/organizations/{organizationId}/equipment/{equipmentId}/replace": { + "post": { + "operationId": "equipment_replace", "tags": [ - "Facility" + "Equipment" ], "responses": { "200": { - "description": "Facility resource", + "description": "Replacement committed or replayed", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" + "$ref": "#/components/schemas/EquipmentReplacement.ReplaceEquipmentOutput.jsonld-equipment.read" } } - } + }, + "links": {} }, "403": { - "description": "Forbidden", + "description": "Missing equipment write permission" + }, + "404": { + "description": "Organization or equipment unavailable" + }, + "409": { + "description": "Replacement lifecycle or replay conflict" + }, + "422": { + "description": "Invalid successor or operation identity", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} }, - "404": { - "description": "Not found", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -13533,13 +13663,89 @@ "links": {} } }, - "summary": "Retrieves a Facility resource.", - "description": "Retrieves a Facility resource.", + "summary": "Replace equipment", + "description": "Retires a published asset and transfers its placement to an unused stock asset or atomically creates its successor. Replays return the original result.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Facility identifier", + "description": "EquipmentReplacement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "equipmentId", + "in": "path", + "description": "EquipmentReplacement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new EquipmentReplacement resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EquipmentReplacement.ReplaceEquipmentInput-equipment.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/equipment/{equipmentId}/report": { + "get": { + "operationId": "equipment_export_report", + "tags": [ + "Equipment" + ], + "responses": { + "200": { + "description": "Equipment sheet PDF" + }, + "400": { + "description": "Missing organizationId or equipmentId URI parameter" + }, + "403": { + "description": "Missing organization.equipment.read, or plan not entitled (pro/max required)" + }, + "404": { + "description": "Organization outside the caller's scope, or equipment not found" + }, + "204": { + "description": "EquipmentReportExport resource" + } + }, + "summary": "Export an equipment sheet as PDF", + "description": "Streams a PDF sheet of one equipment item (identity, maintenance history, attachment index) with Content-Disposition: attachment. Requires organization.equipment.read, resolved with the module's standard 403/404 split, AND a pro/max plan \u2014 the same entitlement gate as the regulatory safety register export.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "EquipmentReportExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "equipmentId", + "in": "path", + "description": "EquipmentReportExport identifier", "required": true, "deprecated": false, "schema": { @@ -13548,20 +13754,27 @@ "style": "simple", "explode": false } + ], + "security": [ + { + "bearerAuth": [] + } ] - }, - "put": { - "operationId": "canonical_facility_put", + } + }, + "/api/organizations/{organizationId}/equipment/{equipmentId}/tags": { + "post": { + "operationId": "equipment_add_tag", "tags": [ - "Facility" + "Equipment" ], "responses": { "201": { - "description": "Facility resource updated", + "description": "Tag added", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" + "$ref": "#/components/schemas/EquipmentTag.TagOutput.jsonld-equipment.read" } } }, @@ -13588,6 +13801,12 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Equipment not found" + }, "422": { "description": "An error occurred", "content": { @@ -13608,57 +13827,27 @@ } }, "links": {} - }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "Replaces the Facility resource.", - "description": "Replaces the Facility resource.", + "summary": "Add tag to equipment", + "description": "Adds a tag (created if not existing) to the equipment.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Facility identifier", + "description": "EquipmentTag identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "equipmentId", + "in": "path", + "description": "EquipmentTag identifier", "required": true, "deprecated": false, "schema": { @@ -13669,195 +13858,45 @@ } ], "requestBody": { - "description": "The updated Facility resource", + "description": "The new EquipmentTag resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Facility.CreateFacilityInput" + "$ref": "#/components/schemas/EquipmentTag.AddTagInput-equipment.write" } } }, "required": true } - }, + } + }, + "/api/organizations/{organizationId}/equipment/{equipmentId}/tags/{tagId}": { "delete": { - "operationId": "canonical_facility_delete", + "operationId": "equipment_remove_tag", "tags": [ - "Facility" + "Equipment" ], "responses": { "204": { - "description": "Facility resource deleted" - }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - } - }, - "summary": "Removes the Facility resource.", - "description": "Removes the Facility resource.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Facility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - }, - "patch": { - "operationId": "canonical_facility_patch", - "tags": [ - "Facility" - ], - "responses": { - "200": { - "description": "Facility resource updated", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" - } - } - }, - "links": {} + "description": "Tag removed" }, "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} + "description": "Invalid identifier" }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Insufficient permissions" }, "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Equipment or tag not found" } }, - "summary": "Updates the Facility resource.", - "description": "Updates the Facility resource.", + "summary": "Remove tag from equipment", + "description": "Removes a tag from the equipment.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Facility identifier", + "description": "EquipmentTag identifier", "required": true, "deprecated": false, "schema": { @@ -13865,52 +13904,11 @@ }, "style": "simple", "explode": false - } - ], - "requestBody": { - "description": "The updated Facility resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Facility.PatchCanonicalFacilityInput.jsonMergePatch" - } - } - }, - "required": true - } - } - }, - "/api/facility-attachments/{id}": { - "get": { - "operationId": "api_facility-attachments_id_get", - "tags": [ - "Facility" - ], - "responses": { - "200": { - "description": "Attachment retrieved", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" - } - } - } - }, - "403": { - "description": "Insufficient permissions" }, - "404": { - "description": "Attachment not found" - } - }, - "summary": "Get a facility attachment", - "description": "Get a facility attachment", - "parameters": [ { - "name": "id", + "name": "equipmentId", "in": "path", - "description": "FacilityAttachment identifier", + "description": "EquipmentTag identifier", "required": true, "deprecated": false, "schema": { @@ -13918,38 +13916,11 @@ }, "style": "simple", "explode": false - } - ] - }, - "delete": { - "operationId": "api_facility-attachments_id_delete", - "tags": [ - "Facility" - ], - "responses": { - "204": { - "description": "Attachment deleted" - }, - "428": { - "description": "If-Match header is required" - }, - "412": { - "description": "Attachment revision is stale" }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Attachment not found" - } - }, - "summary": "Delete a facility attachment", - "description": "Delete a facility attachment", - "parameters": [ { - "name": "id", + "name": "tagId", "in": "path", - "description": "FacilityAttachment identifier", + "description": "EquipmentTag identifier", "required": true, "deprecated": false, "schema": { @@ -13961,26 +13932,26 @@ ] } }, - "/api/facility-attachments/{id}/calibration": { - "put": { - "operationId": "facility_attachment_set_calibration", + "/api/organizations/{organizationId}/equipment/{equipmentId}/unassign": { + "post": { + "operationId": "equipment_unassign_from_facility", "tags": [ - "Facility" + "Equipment" ], "responses": { "200": { - "description": "Calibration saved", + "description": "Equipment unassigned", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityPlanCalibration.FacilityAttachmentOutput.jsonld-facility.read" + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" } } }, "links": {} }, "400": { - "description": "Invalid calibration", + "description": "Invalid identifier", "content": { "application/ld+json": { "schema": { @@ -14001,19 +13972,10 @@ "links": {} }, "403": { - "description": "Missing permission" + "description": "Insufficient permissions" }, "404": { - "description": "Attachment not found" - }, - "409": { - "description": "Attachment is not a floor plan" - }, - "412": { - "description": "Stale revision" - }, - "428": { - "description": "If-Match is required" + "description": "Equipment not found" }, "422": { "description": "An error occurred", @@ -14037,26 +13999,25 @@ "links": {} } }, - "summary": "Calibrate a floor plan in its building", - "description": "Sets a uniform metric scale, rotation and offset on the original floor plan image. Send calibration null to clear it. Requires organization.facilities.write and a matching attachment revision.", + "summary": "Unassign from facility", + "description": "Removes the equipment from its current facility.", "parameters": [ { - "name": "If-Match", - "in": "header", - "description": "", + "name": "organizationId", + "in": "path", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { "type": "string" }, "style": "simple", - "explode": false, - "example": "\"revision-1\"" + "explode": false }, { - "name": "id", + "name": "equipmentId", "in": "path", - "description": "FacilityPlanCalibration identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -14065,47 +14026,63 @@ "style": "simple", "explode": false } - ], - "requestBody": { - "description": "The updated FacilityPlanCalibration resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/FacilityPlanCalibration.SetFacilityAttachmentCalibrationInput-facility.write" - } - } - }, - "required": true - } + ] } }, - "/api/facility-attachments/{id}/download": { + "/api/organizations/{organizationId}/facilities/{facilityId}/equipment": { "get": { - "operationId": "download_facility_attachment", + "operationId": "facility_equipment_list", "tags": [ - "Facility" + "Equipment" ], "responses": { "200": { - "description": "Attachment file content" + "description": "Facility equipment list retrieved", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "LegacyEquipment.EquipmentOutput.jsonld-equipment.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + } + } + } + } + ] + } + } + } }, - "403": { - "description": "Insufficient permissions (requires organization.facilities.read)" + "400": { + "description": "Invalid organization or facility identifier" }, "404": { - "description": "Attachment (or its stored content) not found" + "description": "Facility not found in the organization" }, - "204": { - "description": "FacilityAttachmentContent resource" + "403": { + "description": "Insufficient permissions" } }, - "summary": "Download a facility attachment", - "description": "Streams the stored file bytes with Content-Disposition: attachment and X-Content-Type-Options: nosniff. A floor_plan attachment may be an SVG \u2014 the shared AttachmentDownloadResponder never serves it inline, so a browser downloads rather than executes it. Gated by organization.facilities.read.", + "summary": "List facility equipment", + "description": "Lists directly assigned equipment, or the published facility subtree when includeDescendants is true. Search and pagination apply before the returned page.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "FacilityAttachmentContent identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -14113,87 +14090,11 @@ }, "style": "simple", "explode": false - } - ] - } - }, - "/api/facility-attachments/{id}/primary": { - "post": { - "operationId": "facility_attachment_set_primary", - "tags": [ - "Facility" - ], - "responses": { - "200": { - "description": "Attachment promoted to primary plan", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" - } - } - }, - "links": {} - }, - "409": { - "description": "The attachment is not a floor plan" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Attachment not found" - }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} - } - }, - "summary": "Set the facility primary floor plan", - "description": "Promotes a `floor_plan` attachment to be the facility's primary plan, atomically clearing whichever attachment carried the flag before. A `document` attachment is refused with 409 \u2014 a POST verb-action route, mirroring `/facilities/{id}/archive` and `/facilities/{id}/move`.", - "parameters": [ { - "name": "id", + "name": "facilityId", "in": "path", - "description": "FacilityAttachment identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -14201,41 +14102,194 @@ }, "style": "simple", "explode": false - } - ] - } - }, - "/api/facility-models/{id}": { + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "family", + "in": "query", + "description": "LegacyEquipment family", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "customerId", + "in": "query", + "description": "LegacyEquipment customerId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "includeDescendants", + "in": "query", + "description": "Include equipment assigned to published descendants of this facility.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "type", + "in": "query", + "description": "Filter by equipment type.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "status", + "in": "query", + "description": "Filter by equipment status.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "brand", + "in": "query", + "description": "Filter by exact equipment brand.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "model", + "in": "query", + "description": "Filter by exact equipment model.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "subType", + "in": "query", + "description": "Filter by exact equipment subtype.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "maintenanceDueStatus", + "in": "query", + "description": "Filter by maintenance due status (`unscheduled`, `up_to_date`, `due_soon`, `overdue`); `due` combines due-soon and overdue before pagination.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "unscheduled", + "up_to_date", + "due_soon", + "overdue", + "due" + ] + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/facilities/{facilityId}/equipment-summary": { "get": { - "operationId": "get_facility_model", + "operationId": "facility_equipment_summary_get", "tags": [ - "Facility" + "Equipment" ], "responses": { "200": { - "description": "Model metadata with revision ETag", + "description": "Facility equipment totals retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" + "$ref": "#/components/schemas/FacilityEquipmentSummary.FacilityEquipmentSummaryOutput.jsonld" } } } }, + "400": { + "description": "Invalid scope or identifiers" + }, "403": { - "description": "Missing facilities read permission" + "description": "Missing organization.equipment.read permission" }, "404": { - "description": "Model outside organization scope or missing" + "description": "Facility not found in the organization" } }, - "summary": "Read model metadata, transformation and node associations", - "description": "Read model metadata, transformation and node associations", + "summary": "Get exact facility equipment totals", + "description": "Counts published equipment in the same direct or subtree scope as the equipment collection, before pagination.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "FacilityModel identifier", + "description": "FacilityEquipmentSummary identifier", "required": true, "deprecated": false, "schema": { @@ -14243,113 +14297,105 @@ }, "style": "simple", "explode": false - } - ] - }, - "delete": { - "operationId": "delete_facility_model", - "tags": [ - "Facility" - ], - "responses": { - "204": { - "description": "Model deleted" - }, - "403": { - "description": "Missing facilities write permission" }, - "404": { - "description": "Model outside organization scope or missing" - }, - "412": { - "description": "Stale revision" - }, - "428": { - "description": "If-Match required" - } - }, - "summary": "Delete a model and its immutable file", - "description": "Requires If-Match: \"revision-N\".", - "parameters": [ { - "name": "If-Match", - "in": "header", - "description": "", + "name": "facilityId", + "in": "path", + "description": "FacilityEquipmentSummary identifier", "required": true, "deprecated": false, "schema": { - "type": "string", - "pattern": "^\"revision-[0-9]+\"$" + "type": "string" }, "style": "simple", "explode": false }, { - "name": "id", - "in": "path", - "description": "FacilityModel identifier", - "required": true, + "name": "includeDescendants", + "in": "query", + "description": "FacilityEquipmentSummary includeDescendants", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "boolean", + "default": true }, - "style": "simple", - "explode": false + "style": "form", + "explode": true + }, + { + "name": "family", + "in": "query", + "description": "FacilityEquipmentSummary family", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "customerId", + "in": "query", + "description": "FacilityEquipmentSummary customerId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true } ] - }, - "patch": { - "operationId": "update_facility_model", + } + }, + "/api/organizations/{organizationId}/equipment/{equipmentId}/inspection-summary": { + "get": { + "operationId": "equipment_inspection_summary_get", "tags": [ - "Facility" + "EquipmentInspectionSummary" ], "responses": { "200": { - "description": "Settings saved", + "description": "EquipmentInspectionSummary resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" + "$ref": "#/components/schemas/EquipmentInspectionSummary.EquipmentInspectionSummaryOutput.jsonld" } } - }, - "links": {} + } }, "403": { - "description": "Missing permission" - }, - "404": { - "description": "Model outside scope or missing" - }, - "412": { - "description": "Stale revision" - }, - "428": { - "description": "If-Match required" - }, - "422": { - "description": "Invalid transformation or association", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} }, - "400": { - "description": "Invalid input", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -14370,26 +14416,25 @@ "links": {} } }, - "summary": "Update a model transformation and associations", - "description": "Requires If-Match: \"revision-N\" and a complete transform. Omitted or null bindings preserve stored associations; unavailable historical nodes remain until explicitly removed or reassigned. New associations must belong to the nearest building frame.", + "summary": "Retrieves a EquipmentInspectionSummary resource.", + "description": "Retrieves a EquipmentInspectionSummary resource.", "parameters": [ { - "name": "If-Match", - "in": "header", - "description": "", + "name": "organizationId", + "in": "path", + "description": "EquipmentInspectionSummary identifier", "required": true, "deprecated": false, "schema": { - "type": "string", - "pattern": "^\"revision-[0-9]+\"$" + "type": "string" }, "style": "simple", "explode": false }, { - "name": "id", + "name": "equipmentId", "in": "path", - "description": "FacilityModel identifier", + "description": "EquipmentInspectionSummary identifier", "required": true, "deprecated": false, "schema": { @@ -14398,146 +14443,48 @@ "style": "simple", "explode": false } - ], - "requestBody": { - "description": "The updated FacilityModel resource", - "content": { - "application/merge-patch+json": { - "schema": { - "type": "object", - "required": [ - "transform" - ], - "properties": { - "transform": { - "type": "object", - "required": [ - "scale", - "rotationDegrees", - "translation" - ], - "properties": { - "scale": { - "type": "number", - "exclusiveMinimum": 0 - }, - "rotationDegrees": { - "type": "number" - }, - "translation": { - "type": "object", - "required": [ - "x", - "y", - "z" - ], - "properties": { - "x": { - "type": "number" - }, - "y": { - "type": "number" - }, - "z": { - "type": "number" - } - } - } - } - }, - "bindings": { - "type": [ - "array", - "null" - ], - "items": { - "type": "object", - "required": [ - "nodeIndex", - "facilityId" - ], - "properties": { - "nodeIndex": { - "type": "integer", - "minimum": 0 - }, - "facilityId": { - "type": "string", - "format": "uuid" - } - } - } - }, - "removeBindingNodeIndices": { - "type": "array", - "uniqueItems": true, - "items": { - "type": "integer", - "minimum": 0 - }, - "description": "Explicitly remove stored node associations, including unavailable historical targets." - } - } - } - } - }, - "required": true - } + ] } }, - "/api/facility-models/{id}/activate": { - "post": { - "operationId": "activate_facility_model", + "/api/organizations/{organizationId}/equipment/{equipmentId}/open-work": { + "get": { + "operationId": "intervention_equipment_open_work_list", "tags": [ - "Facility" + "EquipmentOpenWork" ], "responses": { "200": { - "description": "Model activated", + "description": "EquipmentOpenWork collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" + "type": "object", + "description": "EquipmentOpenWork.EquipmentOpenWorkOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EquipmentOpenWork.EquipmentOpenWorkOutput.jsonld" + } + } + } + } + ] } } - }, - "links": {} + } }, "403": { - "description": "Missing facilities write permission" - }, - "404": { - "description": "Model outside organization scope or missing" - }, - "412": { - "description": "Stale revision" - }, - "428": { - "description": "If-Match required" - }, - "422": { - "description": "Association outside building subtree", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid input", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -14558,26 +14505,13 @@ "links": {} } }, - "summary": "Activate a model and atomically deactivate the previous model", - "description": "Requires If-Match: \"revision-N\".", + "summary": "Retrieves the collection of EquipmentOpenWork resources.", + "description": "Retrieves the collection of EquipmentOpenWork resources.", "parameters": [ { - "name": "If-Match", - "in": "header", - "description": "", - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "pattern": "^\"revision-[0-9]+\"$" - }, - "style": "simple", - "explode": false - }, - { - "name": "id", + "name": "organizationId", "in": "path", - "description": "FacilityModel identifier", + "description": "EquipmentOpenWork identifier", "required": true, "deprecated": false, "schema": { @@ -14585,45 +14519,11 @@ }, "style": "simple", "explode": false - } - ] - } - }, - "/api/facility-models/{id}/download": { - "get": { - "operationId": "download_facility_model", - "tags": [ - "Facility" - ], - "responses": { - "200": { - "description": "Immutable GLB file content", - "content": { - "model/gltf-binary": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "403": { - "description": "Missing facilities read permission" - }, - "404": { - "description": "Model outside scope or missing" }, - "204": { - "description": "FacilityModel resource" - } - }, - "summary": "Download the authenticated GLB file", - "description": "Download the authenticated GLB file", - "parameters": [ { - "name": "id", + "name": "equipmentId", "in": "path", - "description": "FacilityModel identifier", + "description": "EquipmentOpenWork identifier", "required": true, "deprecated": false, "schema": { @@ -14635,20 +14535,20 @@ ] } }, - "/api/organizations/{organizationId}/facilities": { + "/api/facilities": { "get": { - "operationId": "facility_list", + "operationId": "api_facilities_get_collection", "tags": [ "Facility" ], "responses": { "200": { - "description": "Facilities retrieved", + "description": "Facility collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "LegacyFacility.FacilityOutput.jsonld-facility.read collection.", + "description": "Facility.FacilityOutput.jsonld collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -14662,7 +14562,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" } } } @@ -14672,28 +14572,31 @@ } } }, - "400": { - "description": "Invalid organization identifier" - }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "List facilities", - "description": "Lists facilities for the target organization.", + "summary": "Retrieves the collection of Facility resources.", + "description": "Retrieves the collection of Facility resources.", "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, { "name": "page", "in": "query", @@ -14715,54 +14618,9 @@ "deprecated": false, "schema": { "type": "integer", - "default": 30, + "default": 50, "minimum": 0, - "maximum": 200 - }, - "style": "form", - "explode": true - }, - { - "name": "parentForType", - "in": "query", - "description": "Eligible active parents for creating this facility type. Published by default; interventionId also admits authorized drafts of that intervention. Mutually exclusive with parentForFacilityId.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ] - }, - "style": "form", - "explode": true - }, - { - "name": "parentForFacilityId", - "in": "query", - "description": "Eligible move destinations for this facility, excluding its subtree and invalid ancestry. Mutually exclusive with parentForType.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" - }, - "style": "form", - "explode": true - }, - { - "name": "interventionId", - "in": "query", - "description": "Parent preparation context, requiring parentForType or parentForFacilityId and intervention mutation access. Draft candidates belong only to this intervention; published candidates additionally require Facilities read.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" + "maximum": 100 }, "style": "form", "explode": true @@ -14781,117 +14639,59 @@ "explode": true }, { - "name": "includeArchived", + "name": "organization", "in": "query", - "description": "When true, archived facilities are included. Default: false.", + "description": "Organization IRI. Required when intervention is omitted.", "required": false, "deprecated": false, "schema": { - "type": "boolean", - "default": false + "type": "string" }, "style": "form", "explode": true }, { - "name": "type", + "name": "intervention", "in": "query", - "description": "Filter by facility type.", + "description": "Intervention IRI. Also scopes the organization and defaults recordStatus to draft.", "required": false, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ] + "type": "string" }, "style": "form", "explode": true }, { - "name": "status", + "name": "recordStatus", "in": "query", - "description": "Filter by facility status.", + "description": "Lifecycle status of the representation.", "required": false, "deprecated": false, "schema": { "type": "string", "enum": [ - "active", - "archived" + "draft", + "published" ] }, "style": "form", "explode": true - }, - { - "name": "parentFacilityId", - "in": "query", - "description": "Filter by direct parent facility identifier.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" - }, - "style": "form", - "explode": true - }, - { - "name": "rootsOnly", - "in": "query", - "description": "When true, only facilities without a parent are returned. Cannot be combined with parentFacilityId.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false - }, - "style": "form", - "explode": true - }, - { - "name": "code", - "in": "query", - "description": "Filter by exact facility code.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "hasCoordinates", - "in": "query", - "description": "When true, only facilities with both latitude and longitude set are returned. When false, only facilities missing coordinates are returned. Omit for no coordinate filtering.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean" - }, - "style": "form", - "explode": true } ] }, "post": { - "operationId": "facility_create", + "operationId": "api_facilities_post", "tags": [ "Facility" ], "responses": { "201": { - "description": "Facility created", + "description": "Facility resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" } } }, @@ -14918,15 +14718,6 @@ }, "links": {} }, - "409": { - "description": "Facility code already exists in this organization" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Parent facility not found" - }, "422": { "description": "An error occurred", "content": { @@ -14947,30 +14738,38 @@ } }, "links": {} - } - }, - "summary": "Create facility", - "description": "Creates a facility for the target organization.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "simple", - "explode": false + "links": {} } - ], + }, + "summary": "Creates a Facility resource.", + "description": "Creates a Facility resource.", + "parameters": [], "requestBody": { - "description": "The new LegacyFacility resource", + "description": "The new Facility resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.CreateFacilityInput-facility.write" + "$ref": "#/components/schemas/Facility.CreateFacilityInput" } } }, @@ -14978,104 +14777,57 @@ } } }, - "/api/organizations/{organizationId}/facilities/address-suggestions": { + "/api/facilities/{facilityId}/attachments": { "get": { - "operationId": "facility_suggest_addresses", + "operationId": "api_facilities_facilityIdattachments_get_collection", "tags": [ "Facility" ], "responses": { "200": { - "description": "Address matches in member with totalItems", + "description": "Attachments retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.SuggestAddressesOutput.jsonld-facility.read" + "type": "object", + "description": "FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" + } + } + } + } + ] } } } }, - "400": { - "description": "Missing or invalid search text" - }, - "403": { - "description": "Missing organization.facilities.write" - }, - "404": { - "description": "Organization outside caller scope" - }, - "429": { - "description": "User search budget exceeded" - }, - "503": { - "description": "Address provider unavailable" - } - }, - "summary": "Suggest postal addresses", - "description": "Returns up to five concrete international addresses. Requires organization.facilities.write. Thirty requests per minute per user; remote failures return 503, an empty member list means no match.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "q", - "in": "query", - "description": "LegacyFacility q", - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "minLength": 3, - "maxLength": 250 - }, - "style": "form", - "explode": true - } - ] - } - }, - "/api/organizations/{organizationId}/facilities/export": { - "get": { - "operationId": "facility_export", - "tags": [ - "Facility" - ], - "responses": { - "200": { - "description": "CSV export streamed successfully" - }, - "400": { - "description": "Invalid organization identifier or an invalid enum filter value" - }, "403": { - "description": "Authenticated but missing organization.facilities.read" + "description": "Insufficient permissions" }, "404": { - "description": "The organization is outside the caller's scope" - }, - "422": { - "description": "Export exceeds the row cap; narrow the filters and retry" - }, - "204": { - "description": "LegacyFacility resource" + "description": "Facility not found" } }, - "summary": "Export facilities (CSV)", - "description": "Streams a CSV export of facilities (Content-Disposition: attachment) for the given organization, using the same filter subset as the list endpoint. Requires `organization.facilities.read` on the organization, resolved the same way the list endpoint resolves it \u2014 a resource-level ROLE_USER check alone does not grant access. Bounded to 50000 matching rows \u2014 the request is rejected with 422 if the filters match more; narrow with a more specific filter and retry. The first seven CSV columns (type, name, code, address, latitude, longitude, parentCode) are the round-trip contract read back by the bulk CSV import.", + "summary": "List facility attachments", + "description": "Lists all attachments for a given facility.", "parameters": [ { - "name": "organizationId", + "name": "facilityId", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -15085,247 +14837,187 @@ "explode": false }, { - "name": "includeArchived", + "name": "page", "in": "query", - "description": "When true, archived facilities are included. Default: false.", + "description": "The collection page number", "required": false, "deprecated": false, "schema": { - "type": "boolean", - "default": false + "type": "integer", + "default": 1 }, "style": "form", "explode": true }, { - "name": "type", + "name": "kind", "in": "query", - "description": "Filter by facility type.", + "description": "Filter by attachment kind (\"document\" or \"floor_plan\").", "required": false, "deprecated": false, "schema": { "type": "string", "enum": [ - "site", - "building", - "floor", - "zone", - "area" + "document", + "floor_plan" ] }, "style": "form", "explode": true - }, - { - "name": "status", - "in": "query", - "description": "Filter by facility status.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "active", - "archived" - ] + } + ] + }, + "post": { + "operationId": "api_facilities_facilityIdattachments_post", + "tags": [ + "Facility" + ], + "responses": { + "201": { + "description": "Attachment uploaded", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "parentFacilityId", - "in": "query", - "description": "Filter by direct parent facility identifier.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "rootsOnly", - "in": "query", - "description": "When true, only facilities without a parent are returned. Cannot be combined with parentFacilityId.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false + "422": { + "description": "MIME type or size rejected", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "code", - "in": "query", - "description": "Filter by exact facility code.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true + "403": { + "description": "Insufficient permissions" }, + "404": { + "description": "Facility not found" + } + }, + "summary": "Upload a facility attachment", + "description": "Uploads a multipart file attachment to a facility. An optional `kind` field (\"document\", the default, or \"floor_plan\") selects the attachment kind; a floor plan is restricted to image/png, image/jpeg, image/webp or image/svg+xml and has its pixel dimensions probed server-side.", + "parameters": [ { - "name": "search", - "in": "query", - "description": "Text search across facility name/code.", - "required": false, + "name": "facilityId", + "in": "path", + "description": "FacilityAttachment identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true - }, - { - "name": "hasCoordinates", - "in": "query", - "description": "When true, only facilities with both latitude and longitude set are returned. When false, only facilities missing coordinates are returned. Omit for no coordinate filtering.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean" - }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] + "style": "simple", + "explode": false } ] } }, - "/api/organizations/{organizationId}/facilities/geocode": { + "/api/facilities/{id}": { "get": { - "operationId": "facility_geocode", + "operationId": "api_facilities_id_get", "tags": [ "Facility" ], "responses": { "200": { - "description": "Best-match coordinates for the address", + "description": "Facility resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.GeocodeAddressOutput.jsonld-facility.read" + "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" } } } }, - "400": { - "description": "Missing, empty, or too-long address" - }, "403": { - "description": "Authenticated but missing organization.facilities.write" - }, - "404": { - "description": "No coordinates found for the address (or the organization is outside the caller's scope)" - }, - "429": { - "description": "More than 30 geocoding requests in a minute" - } - }, - "summary": "Geocode an address", - "description": "Resolves a free-form postal address to WGS 84 coordinates as an aid to facility data entry. The lookup is proxied server-side through the configured geocoding provider (Nominatim by default) \u2014 the browser never calls the provider directly. Requires `organization.facilities.write` on the organization (resolved via resolveAccess): geocoding exists to FILL a facility's coordinates, so it is gated as a write-path aid, not a read. Rate limited to 30 requests per minute per user; the shared outbound channel is additionally throttled server-side to 1 request per second (Nominatim usage policy).", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "address", - "in": "query", - "description": "Free-form postal address to resolve (1 to 300 characters).", - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "maxLength": 300 - }, - "style": "form", - "explode": true - } - ] - } - }, - "/api/organizations/{organizationId}/facilities/{buildingId}/models": { - "get": { - "operationId": "list_facility_models", - "tags": [ - "Facility" - ], - "responses": { - "200": { - "description": "Building model collection", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "FacilityModel.FacilityModelOutput.jsonld-facility.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" - } - } - } - } - ] + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } - }, - "403": { - "description": "Missing facilities read permission" + }, + "links": {} }, "404": { - "description": "Building outside organization scope or missing" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "List a building active and draft models", - "description": "List a building active and draft models", + "summary": "Retrieves a Facility resource.", + "description": "Retrieves a Facility resource.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "FacilityModel identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "buildingId", + "name": "id", "in": "path", - "description": "FacilityModel identifier", + "description": "Facility identifier", "required": true, "deprecated": false, "schema": { @@ -15336,34 +15028,46 @@ } ] }, - "post": { - "operationId": "upload_facility_model", + "put": { + "operationId": "canonical_facility_put", "tags": [ "Facility" ], "responses": { "201": { - "description": "Draft model uploaded", + "description": "Facility resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" + "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" } } }, "links": {} }, - "403": { - "description": "Missing facilities write permission" - }, - "404": { - "description": "Building outside organization scope or missing" - }, - "409": { - "description": "Two models already exist" + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { - "description": "Invalid GLB, unsupported required extension or file over 10 MiB", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -15383,8 +15087,29 @@ }, "links": {} }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -15405,25 +15130,13 @@ "links": {} } }, - "summary": "Upload a self-contained GLB 2.0 model", - "description": "Upload a self-contained GLB 2.0 model", + "summary": "Replaces the Facility resource.", + "description": "Replaces the Facility resource.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "FacilityModel identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "buildingId", + "name": "id", "in": "path", - "description": "FacilityModel identifier", + "description": "Facility identifier", "required": true, "deprecated": false, "schema": { @@ -15434,72 +15147,76 @@ } ], "requestBody": { + "description": "The updated Facility resource", "content": { - "multipart/form-data": { + "application/ld+json": { "schema": { - "type": "object", - "required": [ - "file" - ], - "properties": { - "file": { - "type": "string", - "format": "binary" - } - } + "$ref": "#/components/schemas/Facility.CreateFacilityInput" } } }, "required": true } - } - }, - "/api/organizations/{organizationId}/facilities/{facilityId}": { - "get": { - "operationId": "facility_get", + }, + "delete": { + "operationId": "canonical_facility_delete", "tags": [ "Facility" ], "responses": { - "200": { - "description": "Facility retrieved", + "204": { + "description": "Facility resource deleted" + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } - }, - "400": { - "description": "Invalid identifier" - }, - "403": { - "description": "Insufficient permissions" + }, + "links": {} }, "404": { - "description": "Facility not found" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Get facility", - "description": "Returns one facility by identifier for the target organization.", + "summary": "Removes the Facility resource.", + "description": "Removes the Facility resource.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "facilityId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "Facility identifier", "required": true, "deprecated": false, "schema": { @@ -15511,17 +15228,17 @@ ] }, "patch": { - "operationId": "facility_update", + "operationId": "canonical_facility_patch", "tags": [ "Facility" ], "responses": { "200": { - "description": "Facility updated", + "description": "Facility resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" } } }, @@ -15548,15 +15265,6 @@ }, "links": {} }, - "409": { - "description": "Facility code already exists in this organization" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Facility not found" - }, "422": { "description": "An error occurred", "content": { @@ -15577,15 +15285,57 @@ } }, "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Patch facility", - "description": "Partially updates facility information.", + "summary": "Updates the Facility resource.", + "description": "Updates the Facility resource.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "Facility identifier", "required": true, "deprecated": false, "schema": { @@ -15593,11 +15343,52 @@ }, "style": "simple", "explode": false + } + ], + "requestBody": { + "description": "The updated Facility resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Facility.PatchCanonicalFacilityInput.jsonMergePatch" + } + } + }, + "required": true + } + } + }, + "/api/facility-attachments/{id}": { + "get": { + "operationId": "api_facility-attachments_id_get", + "tags": [ + "Facility" + ], + "responses": { + "200": { + "description": "Attachment retrieved", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" + } + } + } + }, + "403": { + "description": "Insufficient permissions" }, + "404": { + "description": "Attachment not found" + } + }, + "summary": "Get a facility attachment", + "description": "Get a facility attachment", + "parameters": [ { - "name": "facilityId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -15606,40 +15397,68 @@ "style": "simple", "explode": false } + ] + }, + "delete": { + "operationId": "api_facility-attachments_id_delete", + "tags": [ + "Facility" ], - "requestBody": { - "description": "The updated LegacyFacility resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/LegacyFacility.UpdateFacilityInput-facility.write.jsonMergePatch" - } - } + "responses": { + "204": { + "description": "Attachment deleted" }, - "required": true - } + "428": { + "description": "If-Match header is required" + }, + "412": { + "description": "Attachment revision is stale" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Attachment not found" + } + }, + "summary": "Delete a facility attachment", + "description": "Delete a facility attachment", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "FacilityAttachment identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/archive": { - "post": { - "operationId": "facility_archive", + "/api/facility-attachments/{id}/calibration": { + "put": { + "operationId": "facility_attachment_set_calibration", "tags": [ "Facility" ], "responses": { "200": { - "description": "Facility archived", + "description": "Calibration saved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + "$ref": "#/components/schemas/FacilityPlanCalibration.FacilityAttachmentOutput.jsonld-facility.read" } } }, "links": {} }, "400": { - "description": "Invalid identifier", + "description": "Invalid calibration", "content": { "application/ld+json": { "schema": { @@ -15660,10 +15479,19 @@ "links": {} }, "403": { - "description": "Insufficient permissions" + "description": "Missing permission" }, "404": { - "description": "Facility not found" + "description": "Attachment not found" + }, + "409": { + "description": "Attachment is not a floor plan" + }, + "412": { + "description": "Stale revision" + }, + "428": { + "description": "If-Match is required" }, "422": { "description": "An error occurred", @@ -15687,25 +15515,26 @@ "links": {} } }, - "summary": "Archive facility", - "description": "Archives one facility.", + "summary": "Calibrate a floor plan in its building", + "description": "Sets a uniform metric scale, rotation and offset on the original floor plan image. Send calibration null to clear it. Requires organization.facilities.write and a matching attachment revision.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", + "name": "If-Match", + "in": "header", + "description": "", "required": true, "deprecated": false, "schema": { "type": "string" }, "style": "simple", - "explode": false + "explode": false, + "example": "\"revision-1\"" }, { - "name": "facilityId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityPlanCalibration identifier", "required": true, "deprecated": false, "schema": { @@ -15715,55 +15544,46 @@ "explode": false } ], - "deprecated": true + "requestBody": { + "description": "The updated FacilityPlanCalibration resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/FacilityPlanCalibration.SetFacilityAttachmentCalibrationInput-facility.write" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/building-model": { + "/api/facility-attachments/{id}/download": { "get": { - "operationId": "facility_building_model_get", + "operationId": "download_facility_attachment", "tags": [ "Facility" ], "responses": { "200": { - "description": "Building model retrieved", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityBuildingModelOutput.jsonld-facility.read" - } - } - } + "description": "Attachment file content" }, "403": { - "description": "Insufficient permissions" + "description": "Insufficient permissions (requires organization.facilities.read)" }, "404": { - "description": "Facility not found" + "description": "Attachment (or its stored content) not found" }, - "409": { - "description": "Facility is not a building" + "204": { + "description": "FacilityAttachmentContent resource" } }, - "summary": "Get facility 3D building model", - "description": "Assembles, for a `building` facility, the ordered stack of floors a 3D viewer extrudes \u2014 each floor's outline and its rooms. A building with no floors, a floor with no primary plan, or a floor with no room are all valid \"200\" shapes, never errors.", + "summary": "Download a facility attachment", + "description": "Streams the stored file bytes with Content-Disposition: attachment and X-Content-Type-Options: nosniff. A floor_plan attachment may be an SVG \u2014 the shared AttachmentDownloadResponder never serves it inline, so a browser downloads rather than executes it. Gated by organization.facilities.read.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "facilityId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityAttachmentContent identifier", "required": true, "deprecated": false, "schema": { @@ -15775,72 +15595,83 @@ ] } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/children": { - "get": { - "operationId": "facility_list_children", + "/api/facility-attachments/{id}/primary": { + "post": { + "operationId": "facility_attachment_set_primary", "tags": [ "Facility" ], "responses": { "200": { - "description": "Facility children retrieved", + "description": "Attachment promoted to primary plan", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "LegacyFacility.FacilityOutput.jsonld-facility.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" - } - } - } - } - ] + "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" } } - } + }, + "links": {} }, - "400": { - "description": "Invalid identifier" + "409": { + "description": "The attachment is not a floor plan" }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Facility not found" + "description": "Attachment not found" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} } }, - "summary": "List facility children", - "description": "Lists direct children for one facility. This is the lazy tree expansion endpoint.", + "summary": "Set the facility primary floor plan", + "description": "Promotes a `floor_plan` attachment to be the facility's primary plan, atomically clearing whichever attachment carried the flag before. A `document` attachment is refused with 409 \u2014 a POST verb-action route, mirroring `/facilities/{id}/archive` and `/facilities/{id}/move`.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "facilityId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -15848,130 +15679,41 @@ }, "style": "simple", "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 200 - }, - "style": "form", - "explode": true - }, - { - "name": "includePath", - "in": "query", - "description": "Resolve ancestor breadcrumbs for this page in one organization-scoped read.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false - }, - "style": "form", - "explode": true - }, - { - "name": "includeArchived", - "in": "query", - "description": "When true, archived children are included.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false - }, - "style": "form", - "explode": true - }, - { - "name": "search", - "in": "query", - "description": "Text search across child facilities.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true } ] } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/descendants": { + "/api/facility-models/{id}": { "get": { - "operationId": "facility_list_descendants", + "operationId": "get_facility_model", "tags": [ "Facility" ], "responses": { "200": { - "description": "Facility descendants retrieved", + "description": "Model metadata with revision ETag", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "LegacyFacility.FacilityOutput.jsonld-facility.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" - } - } - } - } - ] + "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" } } } }, - "400": { - "description": "Invalid identifier" - }, "403": { - "description": "Insufficient permissions" + "description": "Missing facilities read permission" }, "404": { - "description": "Facility not found" + "description": "Model outside organization scope or missing" } }, - "summary": "List facility descendants", - "description": "Lists descendants for one facility. Set pagination=true to read bounded pages with search and a total count.", + "summary": "Read model metadata, transformation and node associations", + "description": "Read model metadata, transformation and node associations", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityModel identifier", "required": true, "deprecated": false, "schema": { @@ -15979,161 +15721,153 @@ }, "style": "simple", "explode": false + } + ] + }, + "delete": { + "operationId": "delete_facility_model", + "tags": [ + "Facility" + ], + "responses": { + "204": { + "description": "Model deleted" }, - { - "name": "facilityId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "403": { + "description": "Missing facilities write permission" }, - { - "name": "pagination", - "in": "query", - "description": "Enable or disable pagination", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean" - }, - "style": "form", - "explode": true + "404": { + "description": "Model outside organization scope or missing" }, - { - "name": "includePath", - "in": "query", - "description": "Resolve ancestor breadcrumbs for this page in one organization-scoped read.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false - }, - "style": "form", - "explode": true + "412": { + "description": "Stale revision" }, + "428": { + "description": "If-Match required" + } + }, + "summary": "Delete a model and its immutable file", + "description": "Requires If-Match: \"revision-N\".", + "parameters": [ { - "name": "includeArchived", - "in": "query", - "description": "When true, archived descendants are included.", - "required": false, + "name": "If-Match", + "in": "header", + "description": "", + "required": true, "deprecated": false, "schema": { - "type": "boolean", - "default": false + "type": "string", + "pattern": "^\"revision-[0-9]+\"$" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "search", - "in": "query", - "description": "Text search across descendant facilities.", - "required": false, + "name": "id", + "in": "path", + "description": "FacilityModel identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] - } - }, - "/api/organizations/{organizationId}/facilities/{facilityId}/duplicate": { - "post": { - "operationId": "facility_duplicate_subtree", + }, + "patch": { + "operationId": "update_facility_model", "tags": [ "Facility" ], "responses": { - "201": { - "description": "Facility subtree duplicated", + "200": { + "description": "Settings saved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" } } }, "links": {} }, - "400": { - "description": "Invalid input or target parent", + "403": { + "description": "Missing permission" + }, + "404": { + "description": "Model outside scope or missing" + }, + "412": { + "description": "Stale revision" + }, + "428": { + "description": "If-Match required" + }, + "422": { + "description": "Invalid transformation or association", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Facility not found" - }, - "409": { - "description": "Source facility is archived, or the organization plan quota would be exceeded" - }, - "422": { - "description": "Subtree exceeds the duplication size limit", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Duplicate facility subtree", - "description": "Duplicates a facility and its full subtree into a new, independent branch.", + "summary": "Update a model transformation and associations", + "description": "Requires If-Match: \"revision-N\" and a complete transform. Omitted or null bindings preserve stored associations; unavailable historical nodes remain until explicitly removed or reassigned. New associations must belong to the nearest building frame.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", + "name": "If-Match", + "in": "header", + "description": "", "required": true, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "pattern": "^\"revision-[0-9]+\"$" }, "style": "simple", "explode": false }, { - "name": "facilityId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityModel identifier", "required": true, "deprecated": false, "schema": { @@ -16144,11 +15878,84 @@ } ], "requestBody": { - "description": "The new LegacyFacility resource", + "description": "The updated FacilityModel resource", "content": { - "application/ld+json": { + "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.DuplicateFacilitySubtreeInput-facility.write" + "type": "object", + "required": [ + "transform" + ], + "properties": { + "transform": { + "type": "object", + "required": [ + "scale", + "rotationDegrees", + "translation" + ], + "properties": { + "scale": { + "type": "number", + "exclusiveMinimum": 0 + }, + "rotationDegrees": { + "type": "number" + }, + "translation": { + "type": "object", + "required": [ + "x", + "y", + "z" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + } + } + } + }, + "bindings": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "required": [ + "nodeIndex", + "facilityId" + ], + "properties": { + "nodeIndex": { + "type": "integer", + "minimum": 0 + }, + "facilityId": { + "type": "string", + "format": "uuid" + } + } + } + }, + "removeBindingNodeIndices": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "integer", + "minimum": 0 + }, + "description": "Explicitly remove stored node associations, including unavailable historical targets." + } + } } } }, @@ -16156,86 +15963,86 @@ } } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/move": { + "/api/facility-models/{id}/activate": { "post": { - "operationId": "facility_move", + "operationId": "activate_facility_model", "tags": [ "Facility" ], "responses": { "200": { - "description": "Facility moved", + "description": "Model activated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" } } }, "links": {} }, - "400": { - "description": "Invalid request", + "403": { + "description": "Missing facilities write permission" + }, + "404": { + "description": "Model outside organization scope or missing" + }, + "412": { + "description": "Stale revision" + }, + "428": { + "description": "If-Match required" + }, + "422": { + "description": "Association outside building subtree", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} }, - "422": { - "description": "Invalid hierarchy", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} - }, - "412": { - "description": "Stale revision" - }, - "428": { - "description": "If-Match is required" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Facility not found" } }, - "summary": "Move facility", - "description": "Moves a facility under a valid parent using its current revision.", + "summary": "Activate a model and atomically deactivate the previous model", + "description": "Requires If-Match: \"revision-N\".", "parameters": [ { "name": "If-Match", "in": "header", - "description": "The current facility revision, for example \"revision-1\".", + "description": "", "required": true, "deprecated": false, "schema": { @@ -16246,21 +16053,9 @@ "explode": false }, { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "facilityId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityModel identifier", "required": true, "deprecated": false, "schema": { @@ -16269,109 +16064,44 @@ "style": "simple", "explode": false } - ], - "requestBody": { - "description": "The new LegacyFacility resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/LegacyFacility.MoveFacilityInput-facility.write" - } - } - }, - "required": true - } + ] } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/plan-geometry": { - "put": { - "operationId": "facility_plan_geometry_set", + "/api/facility-models/{id}/download": { + "get": { + "operationId": "download_facility_model", "tags": [ "Facility" ], "responses": { "200": { - "description": "Plan geometry set or cleared", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid geometry shape, bounds, or point count", + "description": "Immutable GLB file content", "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { + "model/gltf-binary": { "schema": { - "$ref": "#/components/schemas/Error" + "type": "string", + "format": "binary" } } - }, - "links": {} + } }, "403": { - "description": "Insufficient permissions" + "description": "Missing facilities read permission" }, "404": { - "description": "Facility or attachment not found" - }, - "409": { - "description": "Attachment is not a floor plan, or does not belong to this facility or an ancestor" + "description": "Model outside scope or missing" }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} + "204": { + "description": "FacilityModel resource" } }, - "summary": "Set facility plan geometry", - "description": "Binds this facility (a spatial zone) to a polygon drawn over an ancestor floor plan attachment. Submit \"attachmentId\" and \"points\" (>=3 normalized 0-1 coordinates) together to set or replace the geometry, or both null to clear it.", + "summary": "Download the authenticated GLB file", + "description": "Download the authenticated GLB file", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "facilityId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityModel identifier", "required": true, "deprecated": false, "schema": { @@ -16380,52 +16110,55 @@ "style": "simple", "explode": false } - ], - "requestBody": { - "description": "The updated LegacyFacility resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/LegacyFacility.SetFacilityPlanGeometryInput-facility.write" - } - } - }, - "required": true - } + ] } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/plan-overlay": { + "/api/organizations/{organizationId}/facilities": { "get": { - "operationId": "facility_plan_overlay_get", + "operationId": "facility_list", "tags": [ "Facility" ], "responses": { "200": { - "description": "Plan overlay retrieved", + "description": "Facilities retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityPlanOverlayOutput.jsonld-facility.read" + "type": "object", + "description": "LegacyFacility.FacilityOutput.jsonld-facility.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + } + } + } + } + ] } } } }, "400": { - "description": "Invalid identifier" + "description": "Invalid organization identifier" }, "403": { "description": "Insufficient permissions" - }, - "404": { - "description": "Facility or attachment not found" - }, - "409": { - "description": "Attachment is not a floor plan, or does not belong to this facility or an ancestor" } }, - "summary": "Get facility plan overlay", - "description": "Returns one floor plan (explicit \"attachmentId\", or this facility's own primary plan when omitted) and every published self-or-descendant zone bound to it.", + "summary": "List facilities", + "description": "Lists facilities for the target organization.", "parameters": [ { "name": "organizationId", @@ -16440,21 +16173,69 @@ "explode": false }, { - "name": "facilityId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 1 }, - "style": "simple", - "explode": false + "style": "form", + "explode": true }, { - "name": "attachmentId", + "name": "itemsPerPage", "in": "query", - "description": "Floor plan attachment identifier. Defaults to this facility's primary plan when omitted.", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 200 + }, + "style": "form", + "explode": true + }, + { + "name": "customerId", + "in": "query", + "description": "Filter root sites assigned to this internal customer.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "parentForType", + "in": "query", + "description": "Eligible active parents for creating this facility type. Published by default; interventionId also admits authorized drafts of that intervention. Mutually exclusive with parentForFacilityId.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "parentForFacilityId", + "in": "query", + "description": "Eligible move destinations for this facility, excluding its subtree and invalid ancestry. Mutually exclusive with parentForType.", "required": false, "deprecated": false, "schema": { @@ -16463,19 +16244,141 @@ }, "style": "form", "explode": true + }, + { + "name": "interventionId", + "in": "query", + "description": "Parent preparation context, requiring parentForType or parentForFacilityId and intervention mutation access. Draft candidates belong only to this intervention; published candidates additionally require Facilities read.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "includePath", + "in": "query", + "description": "Resolve ancestor breadcrumbs for this page in one organization-scoped read.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "includeArchived", + "in": "query", + "description": "When true, archived facilities are included. Default: false.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "type", + "in": "query", + "description": "Filter by facility type.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "status", + "in": "query", + "description": "Filter by facility status.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "active", + "archived" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "parentFacilityId", + "in": "query", + "description": "Filter by direct parent facility identifier.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "rootsOnly", + "in": "query", + "description": "When true, only facilities without a parent are returned. Cannot be combined with parentFacilityId.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "code", + "in": "query", + "description": "Filter by exact facility code.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "hasCoordinates", + "in": "query", + "description": "When true, only facilities with both latitude and longitude set are returned. When false, only facilities missing coordinates are returned. Omit for no coordinate filtering.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean" + }, + "style": "form", + "explode": true } ] - } - }, - "/api/organizations/{organizationId}/facilities/{facilityId}/restore": { - "patch": { - "operationId": "facility_restore", + }, + "post": { + "operationId": "facility_create", "tags": [ "Facility" ], "responses": { - "200": { - "description": "Facility restored", + "201": { + "description": "Facility created", "content": { "application/ld+json": { "schema": { @@ -16486,7 +16389,7 @@ "links": {} }, "400": { - "description": "Invalid identifier or parent state", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -16506,11 +16409,14 @@ }, "links": {} }, + "409": { + "description": "Facility code already exists in this organization" + }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Facility not found" + "description": "Parent facility not found" }, "422": { "description": "An error occurred", @@ -16534,8 +16440,8 @@ "links": {} } }, - "summary": "Restore facility", - "description": "Restores an archived facility.", + "summary": "Create facility", + "description": "Creates a facility for the target organization.", "parameters": [ { "name": "organizationId", @@ -16548,76 +16454,61 @@ }, "style": "simple", "explode": false - }, - { - "name": "facilityId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false } - ] + ], + "requestBody": { + "description": "The new LegacyFacility resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyFacility.CreateFacilityInput-facility.write" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/facility-metadata-fields": { + "/api/organizations/{organizationId}/facilities/address-suggestions": { "get": { - "operationId": "facility_metadata_field_list", + "operationId": "facility_suggest_addresses", "tags": [ "Facility" ], "responses": { "200": { - "description": "Metadata field definitions retrieved", + "description": "Address matches in member with totalItems", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read" - } - } - } - } - ] + "$ref": "#/components/schemas/LegacyFacility.SuggestAddressesOutput.jsonld-facility.read" } } } }, "400": { - "description": "Invalid organization identifier" + "description": "Missing or invalid search text" }, "403": { - "description": "Insufficient permissions" + "description": "Missing organization.facilities.write" }, "404": { - "description": "Organization not found" + "description": "Organization outside caller scope" + }, + "429": { + "description": "User search budget exceeded" + }, + "503": { + "description": "Address provider unavailable" } }, - "summary": "List facility metadata field definitions", - "description": "Lists the organization's typed metadata field definitions. Doubles as the frontend's form-schema source.", + "summary": "Suggest postal addresses", + "description": "Returns up to five concrete international addresses. Requires organization.facilities.write. Thirty requests per minute per user; remote failures return 503, an empty member list means no match.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "FacilityMetadataField identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -16625,58 +16516,345 @@ }, "style": "simple", "explode": false + }, + { + "name": "q", + "in": "query", + "description": "LegacyFacility q", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "minLength": 3, + "maxLength": 250 + }, + "style": "form", + "explode": true } ] - }, - "post": { - "operationId": "facility_metadata_field_create", + } + }, + "/api/organizations/{organizationId}/facilities/export": { + "get": { + "operationId": "facility_export", "tags": [ "Facility" ], "responses": { - "201": { - "description": "Metadata field created", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read" - } - } - }, - "links": {} + "200": { + "description": "CSV export streamed successfully" }, "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "description": "Invalid organization identifier or an invalid enum filter value" + }, + "403": { + "description": "Authenticated but missing organization.facilities.read" + }, + "404": { + "description": "The organization is outside the caller's scope" + }, + "422": { + "description": "Export exceeds the row cap; narrow the filters and retry" + }, + "204": { + "description": "LegacyFacility resource" + } + }, + "summary": "Export facilities (CSV)", + "description": "Streams a CSV export of facilities (Content-Disposition: attachment) for the given organization, using the same filter subset as the list endpoint. Requires `organization.facilities.read` on the organization, resolved the same way the list endpoint resolves it \u2014 a resource-level ROLE_USER check alone does not grant access. Bounded to 50000 matching rows \u2014 the request is rejected with 422 if the filters match more; narrow with a more specific filter and retry. The first seven CSV columns (type, name, code, address, latitude, longitude, parentCode) are the round-trip contract read back by the bulk CSV import.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "includeArchived", + "in": "query", + "description": "When true, archived facilities are included. Default: false.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "type", + "in": "query", + "description": "Filter by facility type.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "status", + "in": "query", + "description": "Filter by facility status.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "active", + "archived" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "parentFacilityId", + "in": "query", + "description": "Filter by direct parent facility identifier.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "rootsOnly", + "in": "query", + "description": "When true, only facilities without a parent are returned. Cannot be combined with parentFacilityId.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "code", + "in": "query", + "description": "Filter by exact facility code.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "Text search across facility name/code.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "hasCoordinates", + "in": "query", + "description": "When true, only facilities with both latitude and longitude set are returned. When false, only facilities missing coordinates are returned. Omit for no coordinate filtering.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean" + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/organizations/{organizationId}/facilities/geocode": { + "get": { + "operationId": "facility_geocode", + "tags": [ + "Facility" + ], + "responses": { + "200": { + "description": "Best-match coordinates for the address", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyFacility.GeocodeAddressOutput.jsonld-facility.read" } - }, - "application/problem+json": { + } + } + }, + "400": { + "description": "Missing, empty, or too-long address" + }, + "403": { + "description": "Authenticated but missing organization.facilities.write" + }, + "404": { + "description": "No coordinates found for the address (or the organization is outside the caller's scope)" + }, + "429": { + "description": "More than 30 geocoding requests in a minute" + } + }, + "summary": "Geocode an address", + "description": "Resolves a free-form postal address to WGS 84 coordinates as an aid to facility data entry. The lookup is proxied server-side through the configured geocoding provider (Nominatim by default) \u2014 the browser never calls the provider directly. Requires `organization.facilities.write` on the organization (resolved via resolveAccess): geocoding exists to FILL a facility's coordinates, so it is gated as a write-path aid, not a read. Rate limited to 30 requests per minute per user; the shared outbound channel is additionally throttled server-side to 1 request per second (Nominatim usage policy).", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "address", + "in": "query", + "description": "Free-form postal address to resolve (1 to 300 characters).", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "maxLength": 300 + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/facilities/{buildingId}/models": { + "get": { + "operationId": "list_facility_models", + "tags": [ + "Facility" + ], + "responses": { + "200": { + "description": "Building model collection", + "content": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error" + "type": "object", + "description": "FacilityModel.FacilityModelOutput.jsonld-facility.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" + } + } + } + } + ] } - }, - "application/json": { + } + } + }, + "403": { + "description": "Missing facilities read permission" + }, + "404": { + "description": "Building outside organization scope or missing" + } + }, + "summary": "List a building active and draft models", + "description": "List a building active and draft models", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "FacilityModel identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "buildingId", + "in": "path", + "description": "FacilityModel identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "post": { + "operationId": "upload_facility_model", + "tags": [ + "Facility" + ], + "responses": { + "201": { + "description": "Draft model uploaded", + "content": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" } } }, "links": {} }, "403": { - "description": "Insufficient permissions" + "description": "Missing facilities write permission" }, "404": { - "description": "Organization not found" + "description": "Building outside organization scope or missing" }, "409": { - "description": "Metadata field key already exists for this organization" + "description": "Two models already exist" }, "422": { - "description": "Organization has reached the metadata field definition cap (50)", + "description": "Invalid GLB, unsupported required extension or file over 10 MiB", "content": { "application/ld+json": { "schema": { @@ -16695,15 +16873,48 @@ } }, "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Create a facility metadata field definition", - "description": "Creates one typed metadata field definition for the organization.", + "summary": "Upload a self-contained GLB 2.0 model", + "description": "Upload a self-contained GLB 2.0 model", "parameters": [ { "name": "organizationId", "in": "path", - "description": "FacilityMetadataField identifier", + "description": "FacilityModel identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "buildingId", + "in": "path", + "description": "FacilityModel identifier", "required": true, "deprecated": false, "schema": { @@ -16714,11 +16925,19 @@ } ], "requestBody": { - "description": "The new FacilityMetadataField resource", "content": { - "application/ld+json": { + "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/FacilityMetadataField.CreateFacilityMetadataFieldInput-facility.write" + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary" + } + } } } }, @@ -16726,30 +16945,40 @@ } } }, - "/api/organizations/{organizationId}/facility-metadata-fields/{id}": { - "delete": { - "operationId": "facility_metadata_field_delete", + "/api/organizations/{organizationId}/facilities/{facilityId}": { + "get": { + "operationId": "facility_get", "tags": [ "Facility" ], "responses": { - "204": { - "description": "Metadata field deleted" + "200": { + "description": "Facility retrieved", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + } + } + } + }, + "400": { + "description": "Invalid identifier" }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Metadata field not found" + "description": "Facility not found" } }, - "summary": "Delete a facility metadata field definition", - "description": "Deletes one metadata field definition. Existing facility metadata values for this key are left untouched.", + "summary": "Get facility", + "description": "Returns one facility by identifier for the target organization.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "FacilityMetadataField identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -16759,9 +16988,9 @@ "explode": false }, { - "name": "id", + "name": "facilityId", "in": "path", - "description": "FacilityMetadataField identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -16773,17 +17002,17 @@ ] }, "patch": { - "operationId": "facility_metadata_field_update", + "operationId": "facility_update", "tags": [ "Facility" ], "responses": { "200": { - "description": "Metadata field updated", + "description": "Facility updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read" + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" } } }, @@ -16810,11 +17039,14 @@ }, "links": {} }, + "409": { + "description": "Facility code already exists in this organization" + }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Metadata field not found" + "description": "Facility not found" }, "422": { "description": "An error occurred", @@ -16838,13 +17070,13 @@ "links": {} } }, - "summary": "Patch a facility metadata field definition", - "description": "Partially updates one metadata field definition.", + "summary": "Patch facility", + "description": "Partially updates facility information.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "FacilityMetadataField identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -16854,9 +17086,9 @@ "explode": false }, { - "name": "id", + "name": "facilityId", "in": "path", - "description": "FacilityMetadataField identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -16867,11 +17099,11 @@ } ], "requestBody": { - "description": "The updated FacilityMetadataField resource", + "description": "The updated LegacyFacility resource", "content": { "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/FacilityMetadataField.UpdateFacilityMetadataFieldInput-facility.write.jsonMergePatch" + "$ref": "#/components/schemas/LegacyFacility.UpdateFacilityInput-facility.write.jsonMergePatch" } } }, @@ -16879,25 +17111,26 @@ } } }, - "/api/organizations/{organizationId}/facility-tree": { - "get": { - "operationId": "getFacilityTree", + "/api/organizations/{organizationId}/facilities/{facilityId}/archive": { + "post": { + "operationId": "facility_archive", "tags": [ - "FacilityTree" + "Facility" ], "responses": { "200": { - "description": "FacilityTree resource", + "description": "Facility archived", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityTree.FacilityTreeOutput.jsonld" + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" } } - } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid identifier", "content": { "application/ld+json": { "schema": { @@ -16917,35 +17150,53 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, "404": { - "description": "Not found", + "description": "Facility not found" + }, + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Retrieves a FacilityTree resource.", - "description": "Retrieves a FacilityTree resource.", + "summary": "Archive facility", + "description": "Archives one facility.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "FacilityTree identifier", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -16954,70 +17205,81 @@ "style": "simple", "explode": false } - ] + ], + "deprecated": true } }, - "/api/health": { + "/api/organizations/{organizationId}/facilities/{facilityId}/building-model": { "get": { - "operationId": "health_check", + "operationId": "facility_building_model_get", "tags": [ - "Health" + "Facility" ], "responses": { "200": { - "description": "Application is healthy", + "description": "Building model retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Health.HealthOutput.jsonld-health.read_health.read.details" + "$ref": "#/components/schemas/LegacyFacility.FacilityBuildingModelOutput.jsonld-facility.read" } } } }, - "503": { - "description": "Application is unhealthy - critical dependency failure" + "403": { + "description": "Insufficient permissions" }, "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Facility not found" + }, + "409": { + "description": "Facility is not a building" } }, - "summary": "Health Check", - "description": "Returns the health status of the application and its dependencies (database, cache).", - "parameters": [] - } - }, - "/api/imports": { + "summary": "Get facility 3D building model", + "description": "Assembles, for a `building` facility, the ordered stack of floors a 3D viewer extrudes \u2014 each floor's outline and its rooms. A building with no floors, a floor with no primary plan, or a floor with no room are all valid \"200\" shapes, never errors.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/facilities/{facilityId}/children": { "get": { - "operationId": "api_imports_get_collection", + "operationId": "facility_list_children", "tags": [ - "ImportJob" + "Facility" ], "responses": { "200": { - "description": "ImportJob collection", + "description": "Facility children retrieved", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "ImportJob.ImportJobOutput.jsonld collection.", + "description": "LegacyFacility.FacilityOutput.jsonld-facility.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -17031,7 +17293,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" } } } @@ -17041,31 +17303,43 @@ } } }, + "400": { + "description": "Invalid identifier" + }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Insufficient permissions" + }, + "404": { + "description": "Facility not found" } }, - "summary": "Retrieves the collection of ImportJob resources.", - "description": "Retrieves the collection of ImportJob resources.", + "summary": "List facility children", + "description": "Lists direct children for one facility. This is the lazy tree expansion endpoint.", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "page", "in": "query", @@ -17089,27 +17363,168 @@ "type": "integer", "default": 30, "minimum": 0, - "maximum": 100 + "maximum": 200 }, "style": "form", "explode": true }, { - "name": "organization", + "name": "includePath", "in": "query", - "description": "Organization IRI.", + "description": "Resolve ancestor breadcrumbs for this page in one organization-scoped read.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "includeArchived", + "in": "query", + "description": "When true, archived children are included.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "Text search across child facilities.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/facilities/{facilityId}/descendants": { + "get": { + "operationId": "facility_list_descendants", + "tags": [ + "Facility" + ], + "responses": { + "200": { + "description": "Facility descendants retrieved", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "LegacyFacility.FacilityOutput.jsonld-facility.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Invalid identifier" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Facility not found" + } + }, + "summary": "List facility descendants", + "description": "Lists descendants for one facility. Set pagination=true to read bounded pages with search and a total count.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { "type": "string" }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "pagination", + "in": "query", + "description": "Enable or disable pagination", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean" + }, "style": "form", "explode": true }, { - "name": "kind", + "name": "includePath", "in": "query", - "description": "Resource kind filter (equipment|facility|member).", + "description": "Resolve ancestor breadcrumbs for this page in one organization-scoped read.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "includeArchived", + "in": "query", + "description": "When true, archived descendants are included.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "Text search across descendant facilities.", "required": false, "deprecated": false, "schema": { @@ -17119,26 +17534,28 @@ "explode": true } ] - }, + } + }, + "/api/organizations/{organizationId}/facilities/{facilityId}/duplicate": { "post": { - "operationId": "api_imports_post", + "operationId": "facility_duplicate_subtree", "tags": [ - "Import" + "Facility" ], "responses": { - "202": { - "description": "Import job accepted, processing enqueued", + "201": { + "description": "Facility subtree duplicated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid input or target parent", "content": { "application/ld+json": { "schema": { @@ -17158,8 +17575,17 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Facility not found" + }, + "409": { + "description": "Source facility is archived, or the organization plan quota would be exceeded" + }, "422": { - "description": "MIME type, extension or size rejected", + "description": "Subtree exceeds the duplication size limit", "content": { "application/ld+json": { "schema": { @@ -17178,82 +17604,69 @@ } }, "links": {} - }, - "403": { - "description": "Insufficient permissions" } }, - "summary": "Upload a bulk CSV import", - "description": "Uploads a CSV file to provision Equipment or Facility resources, or member invitations, in bulk; a row failure is reported, not fatal to the batch.", - "parameters": [], + "summary": "Duplicate facility subtree", + "description": "Duplicates a facility and its full subtree into a new, independent branch.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { + "description": "The new LegacyFacility resource", "content": { - "multipart/form-data": { + "application/ld+json": { "schema": { - "type": "object", - "properties": { - "organization": { - "type": "string", - "description": "Organization IRI." - }, - "kind": { - "type": "string", - "enum": [ - "equipment", - "facility", - "member" - ] - }, - "file": { - "type": "string", - "format": "binary" - }, - "dryRun": { - "type": "boolean", - "description": "Validate, resolve parent-by-code and project the plan quota without provisioning anything; the report is returned via GET /imports/{id}." - } - }, - "required": [ - "organization", - "kind", - "file" - ] + "$ref": "#/components/schemas/LegacyFacility.DuplicateFacilitySubtreeInput-facility.write" } } }, - "required": false + "required": true } } }, - "/api/imports/{id}/confirm": { + "/api/organizations/{organizationId}/facilities/{facilityId}/move": { "post": { - "operationId": "confirm_import_simulation", + "operationId": "facility_move", "tags": [ - "Import" + "Facility" ], "responses": { - "202": { - "description": "The single real import, newly enqueued or already confirmed", + "200": { + "description": "Facility moved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" } } }, "links": {} }, - "403": { - "description": "Write permission removed" - }, - "404": { - "description": "Simulation absent or outside membership scope" - }, - "409": { - "description": "Simulation incomplete, contains row failures, or retained file unavailable" - }, "400": { - "description": "Invalid input", + "description": "Invalid request", "content": { "application/ld+json": { "schema": { @@ -17274,7 +17687,7 @@ "links": {} }, "422": { - "description": "An error occurred", + "description": "Invalid hierarchy", "content": { "application/ld+json": { "schema": { @@ -17293,15 +17706,52 @@ } }, "links": {} + }, + "412": { + "description": "Stale revision" + }, + "428": { + "description": "If-Match is required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Facility not found" } }, - "summary": "Confirm a successful simulation once", - "description": "Reuses the retained file. Repeated confirmation returns the same real import. Rights, quotas and references are checked again during execution.", + "summary": "Move facility", + "description": "Moves a facility under a valid parent using its current revision.", "parameters": [ { - "name": "id", + "name": "If-Match", + "in": "header", + "description": "The current facility revision, for example \"revision-1\".", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "pattern": "^\"revision-[0-9]+\"$" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", "in": "path", - "description": "ImportJob identifier", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -17310,38 +17760,40 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The new LegacyFacility resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyFacility.MoveFacilityInput-facility.write" + } + } + }, + "required": true + } } }, - "/api/imports/{id}/resume": { - "post": { - "operationId": "resume_import_job", + "/api/organizations/{organizationId}/facilities/{facilityId}/plan-geometry": { + "put": { + "operationId": "facility_plan_geometry_set", "tags": [ - "Import" + "Facility" ], "responses": { - "202": { - "description": "Existing import enqueued; confirmed rows retained", + "200": { + "description": "Plan geometry set or cleared", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" } } }, "links": {} }, - "403": { - "description": "Missing write permission for this import kind" - }, - "404": { - "description": "Import absent or outside the active membership scope" - }, - "409": { - "description": "Import already completed or reserved by a live worker" - }, "400": { - "description": "Invalid input", + "description": "Invalid geometry shape, bounds, or point count", "content": { "application/ld+json": { "schema": { @@ -17361,6 +17813,15 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Facility or attachment not found" + }, + "409": { + "description": "Attachment is not a floor plan, or does not belong to this facility or an ancestor" + }, "422": { "description": "An error occurred", "content": { @@ -17383,13 +17844,25 @@ "links": {} } }, - "summary": "Resume the same import from its confirmed rows", - "description": "Resume the same import from its confirmed rows", + "summary": "Set facility plan geometry", + "description": "Binds this facility (a spatial zone) to a polygon drawn over an ancestor floor plan attachment. Submit \"attachmentId\" and \"points\" (>=3 normalized 0-1 coordinates) together to set or replace the geometry, or both null to clear it.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "ImportJob identifier", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -17398,40 +17871,57 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The updated LegacyFacility resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyFacility.SetFacilityPlanGeometryInput-facility.write" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/import-templates/{kind}": { + "/api/organizations/{organizationId}/facilities/{facilityId}/plan-overlay": { "get": { - "operationId": "get_import_template", + "operationId": "facility_plan_overlay_get", "tags": [ - "Import" + "Facility" ], "responses": { "200": { - "description": "Filename, CSV content and media type", + "description": "Plan overlay retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ImportTemplate.ImportTemplateOutput.jsonld" + "$ref": "#/components/schemas/LegacyFacility.FacilityPlanOverlayOutput.jsonld-facility.read" } } } }, + "400": { + "description": "Invalid identifier" + }, "403": { - "description": "Missing write permission for this kind" + "description": "Insufficient permissions" }, "404": { - "description": "Unknown kind or organization outside membership scope" + "description": "Facility or attachment not found" + }, + "409": { + "description": "Attachment is not a floor plan, or does not belong to this facility or an ancestor" } }, - "summary": "Download the CSV template for an authorized import kind", - "description": "Download the CSV template for an authorized import kind", + "summary": "Get facility plan overlay", + "description": "Returns one floor plan (explicit \"attachmentId\", or this facility's own primary plan when omitted) and every published self-or-descendant zone bound to it.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "ImportTemplate identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -17441,9 +17931,9 @@ "explode": false }, { - "name": "kind", + "name": "facilityId", "in": "path", - "description": "ImportTemplate identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -17451,29 +17941,43 @@ }, "style": "simple", "explode": false + }, + { + "name": "attachmentId", + "in": "query", + "description": "Floor plan attachment identifier. Defaults to this facility's primary plan when omitted.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true } ] } }, - "/api/imports/{id}": { - "get": { - "operationId": "api_imports_id_get", + "/api/organizations/{organizationId}/facilities/{facilityId}/restore": { + "patch": { + "operationId": "facility_restore", "tags": [ - "ImportJob" + "Facility" ], "responses": { "200": { - "description": "ImportJob resource", + "description": "Facility restored", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" } } - } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid identifier or parent state", "content": { "application/ld+json": { "schema": { @@ -17493,35 +17997,41 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, "404": { - "description": "Not found", + "description": "Facility not found" + }, + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Retrieves a ImportJob resource.", - "description": "Retrieves a ImportJob resource.", + "summary": "Restore facility", + "description": "Restores an archived facility.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "ImportJob identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -17531,60 +18041,103 @@ "explode": false }, { - "name": "reportPage", - "in": "query", - "description": "Report page, starting at one.", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "minimum": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "reportItemsPerPage", - "in": "query", - "description": "Report rows per page, from one to 100 (default 100).", - "required": false, + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] } }, - "/api/inbox": { + "/api/organizations/{organizationId}/facility-metadata-fields": { "get": { - "operationId": "inbox_get", + "operationId": "facility_metadata_field_list", "tags": [ - "Inbox" + "Facility" ], "responses": { "200": { - "description": "Inbox feed retrieved successfully", + "description": "Metadata field definitions retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Inbox.InboxOutput.jsonld-notification.inbox.read" + "type": "object", + "description": "FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read" + } + } + } + } + ] } } } }, "400": { - "description": "Invalid or conflicting cursor parameters" - }, - "401": { - "description": "Authentication required" + "description": "Invalid organization identifier" }, "403": { - "description": "Forbidden", + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization not found" + } + }, + "summary": "List facility metadata field definitions", + "description": "Lists the organization's typed metadata field definitions. Doubles as the frontend's form-schema source.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "FacilityMetadataField identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "post": { + "operationId": "facility_metadata_field_create", + "tags": [ + "Facility" + ], + "responses": { + "201": { + "description": "Metadata field created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -17604,113 +18157,131 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, "404": { - "description": "Not found", + "description": "Organization not found" + }, + "409": { + "description": "Metadata field key already exists for this organization" + }, + "422": { + "description": "Organization has reached the metadata field definition cap (50)", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Get the unified inbox feed", - "description": "Merges account notifications and, when an organization is selected, authorized Messaging mentions. Send nextPageCursor unchanged as cursor to continue the timestamp/source/id ordering without skipping ties. If complete is false, retry the current page; no nextPageCursor is issued while a source is unavailable. Legacy before/nextCursor remain supported but do not preserve timestamp ties. Do not combine before and cursor.", + "summary": "Create a facility metadata field definition", + "description": "Creates one typed metadata field definition for the organization.", "parameters": [ { - "name": "cursor", - "in": "query", - "description": "Opaque nextPageCursor from the previous response; preserves timestamp ties across all sources. Mutually exclusive with before.", - "required": false, + "name": "organizationId", + "in": "path", + "description": "FacilityMetadataField identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "maxLength": 2048 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new FacilityMetadataField resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/FacilityMetadataField.CreateFacilityMetadataFieldInput-facility.write" + } + } }, - { - "name": "organization", - "in": "query", - "description": "Filter by organization identifier. When omitted, items across all organizations (and account-level ones) are returned.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" - }, - "style": "form", - "explode": true + "required": true + } + } + }, + "/api/organizations/{organizationId}/facility-metadata-fields/{id}": { + "delete": { + "operationId": "facility_metadata_field_delete", + "tags": [ + "Facility" + ], + "responses": { + "204": { + "description": "Metadata field deleted" + }, + "403": { + "description": "Insufficient permissions" }, + "404": { + "description": "Metadata field not found" + } + }, + "summary": "Delete a facility metadata field definition", + "description": "Deletes one metadata field definition. Existing facility metadata values for this key are left untouched.", + "parameters": [ { - "name": "before", - "in": "query", - "description": "Cursor: only items that occurred strictly before this ISO-8601 instant are returned. Omit for the first page; pass the previous response's `nextCursor` to fetch the next page.", - "required": false, + "name": "organizationId", + "in": "path", + "description": "FacilityMetadataField identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "date-time" + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "limit", - "in": "query", - "description": "Maximum number of items to return (clamped server-side between 1 and 50).", - "required": false, + "name": "id", + "in": "path", + "description": "FacilityMetadataField identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 20 + "type": "string" }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] + "style": "simple", + "explode": false } ] - } - }, - "/api/inbox/unread-count": { - "get": { - "operationId": "inbox_unread_count", + }, + "patch": { + "operationId": "facility_metadata_field_update", "tags": [ - "Inbox" + "Facility" ], "responses": { "200": { - "description": "Unread inbox count returned successfully", + "description": "Metadata field updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Inbox.InboxUnreadCountOutput.jsonld-notification.inbox.unread_count" + "$ref": "#/components/schemas/FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read" } } - } - }, - "401": { - "description": "Authentication required" + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -17730,83 +18301,142 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, "404": { - "description": "Not found", + "description": "Metadata field not found" + }, + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Get the unified inbox unread count", - "description": "Returns the unread item count summed across every registered inbox source for the authenticated user (currently: their own notifications \u2014 the same figure `GET /notifications/unread-count` already exposes, but sourced through the unified inbox seam so it stays correct once Messaging registers mentions/direct messages/thread replies as additional sources). Intended for a persistent UI chrome badge, unlike the paginated `GET /inbox` feed.", + "summary": "Patch a facility metadata field definition", + "description": "Partially updates one metadata field definition.", "parameters": [ { - "name": "organization", - "in": "query", - "description": "Filter by organization identifier. When omitted, unread items across all organizations (and account-level ones) are counted.", - "required": false, + "name": "organizationId", + "in": "path", + "description": "FacilityMetadataField identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "uuid" + "type": "string" }, - "style": "form", - "explode": true - } - ], - "security": [ + "style": "simple", + "explode": false + }, { - "bearerAuth": [] + "name": "id", + "in": "path", + "description": "FacilityMetadataField identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } - ] + ], + "requestBody": { + "description": "The updated FacilityMetadataField resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/FacilityMetadataField.UpdateFacilityMetadataFieldInput-facility.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/inspection-attachments/{id}": { + "/api/organizations/{organizationId}/facility-tree": { "get": { - "operationId": "get_inspection_attachment", + "operationId": "getFacilityTree", "tags": [ - "Inspection" + "FacilityTree" ], "responses": { "200": { - "description": "Attachment retrieved", + "description": "FacilityTree resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" + "$ref": "#/components/schemas/FacilityTree.FacilityTreeOutput.jsonld" } } } }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "Attachment not found" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Get an inspection attachment", - "description": "Get an inspection attachment", + "summary": "Retrieves a FacilityTree resource.", + "description": "Retrieves a FacilityTree resource.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "InspectionAttachment identifier", + "description": "FacilityTree identifier", "required": true, "deprecated": false, "schema": { @@ -17816,61 +18446,69 @@ "explode": false } ] - }, - "delete": { - "operationId": "delete_inspection_attachment", + } + }, + "/api/health": { + "get": { + "operationId": "health_check", "tags": [ - "Inspection" + "Health" ], "responses": { - "204": { - "description": "Attachment deleted" - }, - "428": { - "description": "If-Match header is required" - }, - "412": { - "description": "Attachment revision is stale" + "200": { + "description": "Application is healthy", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Health.HealthOutput.jsonld-health.read_health.read.details" + } + } + } }, - "403": { - "description": "Insufficient permissions" + "503": { + "description": "Application is unhealthy - critical dependency failure" }, "404": { - "description": "Attachment not found" - } - }, - "summary": "Delete an inspection attachment", - "description": "Delete an inspection attachment", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "InspectionAttachment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "simple", - "explode": false + "links": {} } - ] + }, + "summary": "Health Check", + "description": "Returns the health status of the application and its dependencies (database, cache).", + "parameters": [] } }, - "/api/inspections": { + "/api/imports": { "get": { - "operationId": "api_inspections_get_collection", + "operationId": "api_imports_get_collection", "tags": [ - "Inspection" + "ImportJob" ], "responses": { "200": { - "description": "Inspection collection", + "description": "ImportJob collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Inspection.InspectionOutput.jsonld collection.", + "description": "ImportJob.ImportJobOutput.jsonld collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -17884,7 +18522,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" + "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" } } } @@ -17916,8 +18554,8 @@ "links": {} } }, - "summary": "Retrieves the collection of Inspection resources.", - "description": "Retrieves the collection of Inspection resources.", + "summary": "Retrieves the collection of ImportJob resources.", + "description": "Retrieves the collection of ImportJob resources.", "parameters": [ { "name": "page", @@ -17940,7 +18578,7 @@ "deprecated": false, "schema": { "type": "integer", - "default": 50, + "default": 30, "minimum": 0, "maximum": 100 }, @@ -17950,20 +18588,8 @@ { "name": "organization", "in": "query", - "description": "Organization IRI. Required when intervention is omitted.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "intervention", - "in": "query", - "description": "Intervention IRI. Also scopes the organization and defaults recordStatus to draft.", - "required": false, + "description": "Organization IRI.", + "required": true, "deprecated": false, "schema": { "type": "string" @@ -17972,9 +18598,9 @@ "explode": true }, { - "name": "equipment", + "name": "kind", "in": "query", - "description": "Equipment IRI.", + "description": "Resource kind filter (equipment|facility|member).", "required": false, "deprecated": false, "schema": { @@ -17982,37 +18608,21 @@ }, "style": "form", "explode": true - }, - { - "name": "recordStatus", - "in": "query", - "description": "Lifecycle status of the representation.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "draft", - "published" - ] - }, - "style": "form", - "explode": true } ] }, "post": { - "operationId": "api_inspections_post", + "operationId": "api_imports_post", "tags": [ - "Inspection" + "Import" ], "responses": { - "201": { - "description": "Inspection resource created", + "202": { + "description": "Import job accepted, processing enqueued", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" + "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" } } }, @@ -18040,7 +18650,7 @@ "links": {} }, "422": { - "description": "An error occurred", + "description": "MIME type, extension or size rejected", "content": { "application/ld+json": { "schema": { @@ -18061,62 +18671,80 @@ "links": {} }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Insufficient permissions" } }, - "summary": "Creates a Inspection resource.", - "description": "Creates a Inspection resource.", + "summary": "Upload a bulk CSV import", + "description": "Uploads a CSV file to provision Equipment or Facility resources, or member invitations, in bulk; a row failure is reported, not fatal to the batch.", "parameters": [], "requestBody": { - "description": "The new Inspection resource", "content": { - "application/ld+json": { + "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Inspection.CreateInspectionInput" + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization IRI." + }, + "kind": { + "type": "string", + "enum": [ + "equipment", + "facility", + "member" + ] + }, + "file": { + "type": "string", + "format": "binary" + }, + "dryRun": { + "type": "boolean", + "description": "Validate, resolve parent-by-code and project the plan quota without provisioning anything; the report is returned via GET /imports/{id}." + } + }, + "required": [ + "organization", + "kind", + "file" + ] } } }, - "required": true + "required": false } } }, - "/api/inspections/{id}": { - "get": { - "operationId": "api_inspections_id_get", + "/api/imports/{id}/confirm": { + "post": { + "operationId": "confirm_import_simulation", "tags": [ - "Inspection" + "Import" ], "responses": { - "200": { - "description": "Inspection resource", + "202": { + "description": "The single real import, newly enqueued or already confirmed", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" + "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" } } - } + }, + "links": {} }, "403": { - "description": "Forbidden", + "description": "Write permission removed" + }, + "404": { + "description": "Simulation absent or outside membership scope" + }, + "409": { + "description": "Simulation incomplete, contains row failures, or retained file unavailable" + }, + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -18136,35 +18764,35 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Retrieves a Inspection resource.", - "description": "Retrieves a Inspection resource.", + "summary": "Confirm a successful simulation once", + "description": "Reuses the retained file. Repeated confirmation returns the same real import. Rights, quotas and references are checked again during execution.", "parameters": [ { "name": "id", "in": "path", - "description": "Inspection identifier", + "description": "ImportJob identifier", "required": true, "deprecated": false, "schema": { @@ -18174,24 +18802,35 @@ "explode": false } ] - }, - "put": { - "operationId": "canonical_inspection_put", + } + }, + "/api/imports/{id}/resume": { + "post": { + "operationId": "resume_import_job", "tags": [ - "Inspection" + "Import" ], "responses": { - "201": { - "description": "Inspection resource updated", + "202": { + "description": "Existing import enqueued; confirmed rows retained", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" + "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" } } }, "links": {} }, + "403": { + "description": "Missing write permission for this import kind" + }, + "404": { + "description": "Import absent or outside the active membership scope" + }, + "409": { + "description": "Import already completed or reserved by a live worker" + }, "400": { "description": "Invalid input", "content": { @@ -18233,57 +18872,57 @@ } }, "links": {} - }, - "403": { - "description": "Forbidden", + } + }, + "summary": "Resume the same import from its confirmed rows", + "description": "Resume the same import from its confirmed rows", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ImportJob identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/import-templates/{kind}": { + "get": { + "operationId": "get_import_template", + "tags": [ + "Import" + ], + "responses": { + "200": { + "description": "Filename, CSV content and media type", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ImportTemplate.ImportTemplateOutput.jsonld" } } - }, - "links": {} + } + }, + "403": { + "description": "Missing write permission for this kind" }, "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Unknown kind or organization outside membership scope" } }, - "summary": "Replaces the Inspection resource.", - "description": "Replaces the Inspection resource.", + "summary": "Download the CSV template for an authorized import kind", + "description": "Download the CSV template for an authorized import kind", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Inspection identifier", + "description": "ImportTemplate identifier", "required": true, "deprecated": false, "schema": { @@ -18291,28 +18930,38 @@ }, "style": "simple", "explode": false - } - ], - "requestBody": { - "description": "The updated Inspection resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Inspection.CreateInspectionInput" - } - } }, - "required": true - } - }, - "delete": { - "operationId": "canonical_inspection_delete", + { + "name": "kind", + "in": "path", + "description": "ImportTemplate identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/imports/{id}": { + "get": { + "operationId": "api_imports_id_get", "tags": [ - "Inspection" + "ImportJob" ], "responses": { - "204": { - "description": "Inspection resource deleted" + "200": { + "description": "ImportJob resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" + } + } + } }, "403": { "description": "Forbidden", @@ -18357,13 +19006,13 @@ "links": {} } }, - "summary": "Removes the Inspection resource.", - "description": "Removes the Inspection resource.", + "summary": "Retrieves a ImportJob resource.", + "description": "Retrieves a ImportJob resource.", "parameters": [ { "name": "id", "in": "path", - "description": "Inspection identifier", + "description": "ImportJob identifier", "required": true, "deprecated": false, "schema": { @@ -18371,28 +19020,62 @@ }, "style": "simple", "explode": false + }, + { + "name": "reportPage", + "in": "query", + "description": "Report page, starting at one.", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "reportItemsPerPage", + "in": "query", + "description": "Report rows per page, from one to 100 (default 100).", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "style": "form", + "explode": true } ] - }, - "patch": { - "operationId": "canonical_inspection_patch", + } + }, + "/api/inbox": { + "get": { + "operationId": "inbox_get", "tags": [ - "Inspection" + "Inbox" ], "responses": { "200": { - "description": "Inspection resource updated", + "description": "Inbox feed retrieved successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" + "$ref": "#/components/schemas/Inbox.InboxOutput.jsonld-notification.inbox.read" } } - }, - "links": {} + } }, "400": { - "description": "Invalid input", + "description": "Invalid or conflicting cursor parameters" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -18412,26 +19095,110 @@ }, "links": {} }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} + } + }, + "summary": "Get the unified inbox feed", + "description": "Merges account notifications and, when an organization is selected, authorized Messaging mentions. Send nextPageCursor unchanged as cursor to continue the timestamp/source/id ordering without skipping ties. If complete is false, retry the current page; no nextPageCursor is issued while a source is unavailable. Legacy before/nextCursor remain supported but do not preserve timestamp ties. Do not combine before and cursor.", + "parameters": [ + { + "name": "cursor", + "in": "query", + "description": "Opaque nextPageCursor from the previous response; preserves timestamp ties across all sources. Mutually exclusive with before.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "maxLength": 2048 + }, + "style": "form", + "explode": true + }, + { + "name": "organization", + "in": "query", + "description": "Filter by organization identifier. When omitted, items across all organizations (and account-level ones) are returned.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "before", + "in": "query", + "description": "Cursor: only items that occurred strictly before this ISO-8601 instant are returned. Omit for the first page; pass the previous response's `nextCursor` to fetch the next page.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time" + }, + "style": "form", + "explode": true + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of items to return (clamped server-side between 1 and 50).", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 20 + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/inbox/unread-count": { + "get": { + "operationId": "inbox_unread_count", + "tags": [ + "Inbox" + ], + "responses": { + "200": { + "description": "Unread inbox count returned successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Inbox.InboxUnreadCountOutput.jsonld-notification.inbox.unread_count" + } + } + } + }, + "401": { + "description": "Authentication required" }, "403": { "description": "Forbidden", @@ -18476,68 +19243,43 @@ "links": {} } }, - "summary": "Updates the Inspection resource.", - "description": "Updates the Inspection resource.", + "summary": "Get the unified inbox unread count", + "description": "Returns the unread item count summed across every registered inbox source for the authenticated user (currently: their own notifications \u2014 the same figure `GET /notifications/unread-count` already exposes, but sourced through the unified inbox seam so it stays correct once Messaging registers mentions/direct messages/thread replies as additional sources). Intended for a persistent UI chrome badge, unlike the paginated `GET /inbox` feed.", "parameters": [ { - "name": "id", - "in": "path", - "description": "Inspection identifier", - "required": true, + "name": "organization", + "in": "query", + "description": "Filter by organization identifier. When omitted, unread items across all organizations (and account-level ones) are counted.", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true } ], - "requestBody": { - "description": "The updated Inspection resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Inspection.PatchCanonicalInspectionInput.jsonMergePatch" - } - } - }, - "required": true - } + "security": [ + { + "bearerAuth": [] + } + ] } }, - "/api/inspections/{inspectionId}/attachments": { + "/api/inspection-attachments/{id}": { "get": { - "operationId": "list_inspection_attachments", + "operationId": "get_inspection_attachment", "tags": [ "Inspection" ], "responses": { "200": { - "description": "Attachments retrieved", + "description": "Attachment retrieved", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" - } - } - } - } - ] + "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" } } } @@ -18546,14 +19288,53 @@ "description": "Insufficient permissions" }, "404": { - "description": "Inspection not found" + "description": "Attachment not found" } }, - "summary": "List inspection attachments", - "description": "Lists the inspection-level attachments of an inspection.", + "summary": "Get an inspection attachment", + "description": "Get an inspection attachment", "parameters": [ { - "name": "inspectionId", + "name": "id", + "in": "path", + "description": "InspectionAttachment identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "delete": { + "operationId": "delete_inspection_attachment", + "tags": [ + "Inspection" + ], + "responses": { + "204": { + "description": "Attachment deleted" + }, + "428": { + "description": "If-Match header is required" + }, + "412": { + "description": "Attachment revision is stale" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Attachment not found" + } + }, + "summary": "Delete an inspection attachment", + "description": "Delete an inspection attachment", + "parameters": [ + { + "name": "id", "in": "path", "description": "InspectionAttachment identifier", "required": true, @@ -18563,7 +19344,72 @@ }, "style": "simple", "explode": false + } + ] + } + }, + "/api/inspections": { + "get": { + "operationId": "api_inspections_get_collection", + "tags": [ + "Inspection" + ], + "responses": { + "200": { + "description": "Inspection collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Inspection.InspectionOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" + } + } + } + } + ] + } + } + } }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves the collection of Inspection resources.", + "description": "Retrieves the collection of Inspection resources.", + "parameters": [ { "name": "page", "in": "query", @@ -18576,21 +19422,88 @@ }, "style": "form", "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 50, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "organization", + "in": "query", + "description": "Organization IRI. Required when intervention is omitted.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "intervention", + "in": "query", + "description": "Intervention IRI. Also scopes the organization and defaults recordStatus to draft.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "equipment", + "in": "query", + "description": "Equipment IRI.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "recordStatus", + "in": "query", + "description": "Lifecycle status of the representation.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "draft", + "published" + ] + }, + "style": "form", + "explode": true } ] }, "post": { - "operationId": "add_inspection_attachment", + "operationId": "api_inspections_post", "tags": [ "Inspection" ], "responses": { "201": { - "description": "Attachment uploaded", + "description": "Inspection resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" + "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" } } }, @@ -18618,7 +19531,7 @@ "links": {} }, "422": { - "description": "MIME type or size rejected", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -18639,81 +19552,110 @@ "links": {} }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Inspection not found" - } - }, - "summary": "Upload an inspection attachment", - "description": "Uploads a multipart file attachment to an inspection.", - "parameters": [ - { - "name": "inspectionId", - "in": "path", - "description": "InspectionAttachment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "simple", - "explode": false + "links": {} } - ] + }, + "summary": "Creates a Inspection resource.", + "description": "Creates a Inspection resource.", + "parameters": [], + "requestBody": { + "description": "The new Inspection resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Inspection.CreateInspectionInput" + } + } + }, + "required": true + } } }, - "/api/non-conformities/{nonConformityId}/attachments": { + "/api/inspections/{id}": { "get": { - "operationId": "list_non_conformity_attachments", + "operationId": "api_inspections_id_get", "tags": [ "Inspection" ], "responses": { "200": { - "description": "Attachments retrieved", + "description": "Inspection resource", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" - } - } - } - } - ] + "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" } } } }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "Non-conformity not found" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "List non-conformity field-proof photos", - "description": "Lists the field-proof photo attachments of a non-conformity.", + "summary": "Retrieves a Inspection resource.", + "description": "Retrieves a Inspection resource.", "parameters": [ { - "name": "nonConformityId", + "name": "id", "in": "path", - "description": "InspectionAttachment identifier", + "description": "Inspection identifier", "required": true, "deprecated": false, "schema": { @@ -18721,34 +19663,21 @@ }, "style": "simple", "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true } ] }, - "post": { - "operationId": "add_non_conformity_attachment", + "put": { + "operationId": "canonical_inspection_put", "tags": [ "Inspection" ], "responses": { "201": { - "description": "Attachment uploaded", + "description": "Inspection resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" + "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" } } }, @@ -18776,7 +19705,7 @@ "links": {} }, "422": { - "description": "MIME type or size rejected", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -18797,19 +19726,55 @@ "links": {} }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "Non-conformity not found" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Upload a non-conformity field-proof photo", - "description": "Uploads a multipart file attachment (field-proof photo) to a non-conformity.", + "summary": "Replaces the Inspection resource.", + "description": "Replaces the Inspection resource.", "parameters": [ { - "name": "nonConformityId", + "name": "id", "in": "path", - "description": "InspectionAttachment identifier", + "description": "Inspection identifier", "required": true, "deprecated": false, "schema": { @@ -18818,57 +19783,78 @@ "style": "simple", "explode": false } - ] - } - }, - "/api/organizations/{organizationId}/facilities/{facilityId}/inspections": { - "get": { - "operationId": "facility_inspection_list", + ], + "requestBody": { + "description": "The updated Inspection resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Inspection.CreateInspectionInput" + } + } + }, + "required": true + } + }, + "delete": { + "operationId": "canonical_inspection_delete", "tags": [ "Inspection" ], "responses": { - "200": { - "description": "Facility inspection list", + "204": { + "description": "Inspection resource deleted" + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "LegacyInspection.InspectionOutput.jsonld-inspection.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LegacyInspection.InspectionOutput.jsonld-inspection.read" - } - } - } - } - ] + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } + }, + "links": {} }, - "403": { - "description": "Insufficient permissions" + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "List facility inspections", - "description": "Lists inspections directly linked to one facility.", + "summary": "Removes the Inspection resource.", + "description": "Removes the Inspection resource.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "LegacyInspection identifier", + "description": "Inspection identifier", "required": true, "deprecated": false, "schema": { @@ -18876,13 +19862,193 @@ }, "style": "simple", "explode": false + } + ] + }, + "patch": { + "operationId": "canonical_inspection_patch", + "tags": [ + "Inspection" + ], + "responses": { + "200": { + "description": "Inspection resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" + } + } + }, + "links": {} }, - { - "name": "facilityId", - "in": "path", - "description": "LegacyInspection identifier", - "required": true, - "deprecated": false, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Updates the Inspection resource.", + "description": "Updates the Inspection resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Inspection identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated Inspection resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Inspection.PatchCanonicalInspectionInput.jsonMergePatch" + } + } + }, + "required": true + } + } + }, + "/api/inspections/{inspectionId}/attachments": { + "get": { + "operationId": "list_inspection_attachments", + "tags": [ + "Inspection" + ], + "responses": { + "200": { + "description": "Attachments retrieved", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Inspection not found" + } + }, + "summary": "List inspection attachments", + "description": "Lists the inspection-level attachments of an inspection.", + "parameters": [ + { + "name": "inspectionId", + "in": "path", + "description": "InspectionAttachment identifier", + "required": true, + "deprecated": false, "schema": { "type": "string" }, @@ -18901,122 +20067,260 @@ }, "style": "form", "explode": true + } + ] + }, + "post": { + "operationId": "add_inspection_attachment", + "tags": [ + "Inspection" + ], + "responses": { + "201": { + "description": "Attachment uploaded", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" + } + } + }, + "links": {} }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "equipmentId", - "in": "query", - "description": "Filter by equipment", - "required": false, - "deprecated": false, - "schema": { - "type": "string" + "422": { + "description": "MIME type or size rejected", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } }, - "style": "form", - "explode": true + "links": {} + }, + "403": { + "description": "Insufficient permissions" }, + "404": { + "description": "Inspection not found" + } + }, + "summary": "Upload an inspection attachment", + "description": "Uploads a multipart file attachment to an inspection.", + "parameters": [ { - "name": "result", - "in": "query", - "description": "Filter by result (pass, fail, partial)", - "required": false, + "name": "inspectionId", + "in": "path", + "description": "InspectionAttachment identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false + } + ] + } + }, + "/api/non-conformities/{nonConformityId}/attachments": { + "get": { + "operationId": "list_non_conformity_attachments", + "tags": [ + "Inspection" + ], + "responses": { + "200": { + "description": "Attachments retrieved", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Insufficient permissions" }, + "404": { + "description": "Non-conformity not found" + } + }, + "summary": "List non-conformity field-proof photos", + "description": "Lists the field-proof photo attachments of a non-conformity.", + "parameters": [ { - "name": "status", - "in": "query", - "description": "Filter by status (draft, submitted, closed)", - "required": false, + "name": "nonConformityId", + "in": "path", + "description": "InspectionAttachment identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "performedAtFrom", + "name": "page", "in": "query", - "description": "Filter inspections performed on or after this instant.", + "description": "The collection page number", "required": false, "deprecated": false, "schema": { - "type": "string", - "format": "date-time" + "type": "integer", + "default": 1 }, "style": "form", "explode": true + } + ] + }, + "post": { + "operationId": "add_non_conformity_attachment", + "tags": [ + "Inspection" + ], + "responses": { + "201": { + "description": "Attachment uploaded", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" + } + } + }, + "links": {} }, - { - "name": "performedAtTo", - "in": "query", - "description": "Filter inspections performed on or before this instant.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time" + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "inspectorUserId", - "in": "query", - "description": "Filter by inspector user identifier.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" + "422": { + "description": "MIME type or size rejected", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } }, - "style": "form", - "explode": true + "links": {} + }, + "403": { + "description": "Insufficient permissions" }, + "404": { + "description": "Non-conformity not found" + } + }, + "summary": "Upload a non-conformity field-proof photo", + "description": "Uploads a multipart file attachment (field-proof photo) to a non-conformity.", + "parameters": [ { - "name": "checklistId", - "in": "query", - "description": "Filter by checklist identifier.", - "required": false, + "name": "nonConformityId", + "in": "path", + "description": "InspectionAttachment identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "uuid" + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] } }, - "/api/organizations/{organizationId}/inspections": { + "/api/organizations/{organizationId}/facilities/{facilityId}/inspections": { "get": { - "operationId": "inspection_list", + "operationId": "facility_inspection_list", "tags": [ "Inspection" ], "responses": { "200": { - "description": "Inspection list", + "description": "Facility inspection list", "content": { "application/ld+json": { "schema": { @@ -19049,8 +20353,8 @@ "description": "Insufficient permissions" } }, - "summary": "List inspections", - "description": "Lists all inspections for the organization.", + "summary": "List facility inspections", + "description": "Lists inspections directly linked to one facility.", "parameters": [ { "name": "organizationId", @@ -19064,6 +20368,18 @@ "style": "simple", "explode": false }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyInspection identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "page", "in": "query", @@ -19087,27 +20403,58 @@ "type": "integer", "default": 30, "minimum": 0, - "maximum": 200 + "maximum": 100 }, "style": "form", "explode": true }, { - "name": "equipmentId", + "name": "family", "in": "query", - "description": "Filter by equipment", + "description": "LegacyInspection family", "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] }, "style": "form", "explode": true }, { - "name": "facilityId", + "name": "customerId", "in": "query", - "description": "Filter by facility", + "description": "LegacyInspection customerId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "includeDescendants", + "in": "query", + "description": "LegacyInspection includeDescendants", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentId", + "in": "query", + "description": "Filter by equipment", "required": false, "deprecated": false, "schema": { @@ -19193,16 +20540,246 @@ "explode": true } ] - }, - "post": { - "operationId": "inspection_create", + } + }, + "/api/organizations/{organizationId}/inspections": { + "get": { + "operationId": "inspection_list", "tags": [ "Inspection" ], "responses": { - "201": { - "description": "Inspection created", - "content": { + "200": { + "description": "Inspection list", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "LegacyInspection.InspectionOutput.jsonld-inspection.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LegacyInspection.InspectionOutput.jsonld-inspection.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "List inspections", + "description": "Lists all inspections for the organization.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "LegacyInspection identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 200 + }, + "style": "form", + "explode": true + }, + { + "name": "family", + "in": "query", + "description": "LegacyInspection family", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "customerId", + "in": "query", + "description": "LegacyInspection customerId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "includeDescendants", + "in": "query", + "description": "LegacyInspection includeDescendants", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentId", + "in": "query", + "description": "Filter by equipment", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "facilityId", + "in": "query", + "description": "Filter by facility", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "result", + "in": "query", + "description": "Filter by result (pass, fail, partial)", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "status", + "in": "query", + "description": "Filter by status (draft, submitted, closed)", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "performedAtFrom", + "in": "query", + "description": "Filter inspections performed on or after this instant.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time" + }, + "style": "form", + "explode": true + }, + { + "name": "performedAtTo", + "in": "query", + "description": "Filter inspections performed on or before this instant.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time" + }, + "style": "form", + "explode": true + }, + { + "name": "inspectorUserId", + "in": "query", + "description": "Filter by inspector user identifier.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "checklistId", + "in": "query", + "description": "Filter by checklist identifier.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + } + ] + }, + "post": { + "operationId": "inspection_create", + "tags": [ + "Inspection" + ], + "responses": { + "201": { + "description": "Inspection created", + "content": { "application/ld+json": { "schema": { "$ref": "#/components/schemas/LegacyInspection.InspectionOutput.jsonld-inspection.read" @@ -20662,6 +22239,256 @@ ] } }, + "/api/organizations/{organizationId}/park-anomalies": { + "get": { + "operationId": "park_anomalies_list", + "tags": [ + "Inspection" + ], + "responses": { + "200": { + "description": "Scoped paginated anomaly queue", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "ParkAnomaly.NonConformityOutput.jsonld-inspection.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParkAnomaly.NonConformityOutput.jsonld-inspection.read" + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Invalid family or scope input" + }, + "403": { + "description": "Missing equipment or inspection read" + }, + "404": { + "description": "Unknown or hidden scope" + } + }, + "summary": "List unresolved published parc anomalies", + "description": "List unresolved published parc anomalies", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "ParkAnomaly identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "family", + "in": "query", + "description": "ParkAnomaly family", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "customerId", + "in": "query", + "description": "ParkAnomaly customerId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "facilityId", + "in": "query", + "description": "ParkAnomaly facilityId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "includeDescendants", + "in": "query", + "description": "ParkAnomaly includeDescendants", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": true + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/park-anomalies-summary": { + "get": { + "operationId": "park_anomalies_summary_get", + "tags": [ + "Inspection" + ], + "responses": { + "200": { + "description": "Counts by severity", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ParkAnomaly.ParkAnomaliesSummaryOutput.jsonld-inspection.read" + } + } + } + }, + "400": { + "description": "Invalid family or scope input" + }, + "403": { + "description": "Missing equipment or inspection read" + }, + "404": { + "description": "Unknown or hidden scope" + } + }, + "summary": "Count unresolved published parc anomalies", + "description": "Count unresolved published parc anomalies", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "ParkAnomaly identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "family", + "in": "query", + "description": "ParkAnomaly family", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "customerId", + "in": "query", + "description": "ParkAnomaly customerId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "facilityId", + "in": "query", + "description": "ParkAnomaly facilityId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "includeDescendants", + "in": "query", + "description": "ParkAnomaly includeDescendants", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": true + }, + "style": "form", + "explode": true + } + ] + } + }, "/api/inspection-responses": { "get": { "operationId": "api_inspection-responses_get_collection", @@ -26400,63 +28227,241 @@ } } }, - "/api/maintenance/schedules/export": { + "/api/organizations/{organizationId}/inventory-balances": { "get": { - "operationId": "maintenance_schedule_export", + "operationId": "inventory_balances_list", "tags": [ - "Maintenance" + "Inventory" ], "responses": { "200": { - "description": "CSV export streamed successfully" - }, - "400": { - "description": "Missing organization filter" + "description": "InventoryBalance collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "InventoryBalance.InventoryBalanceOutput.jsonld-inventory.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InventoryBalance.InventoryBalanceOutput.jsonld-inventory.read" + } + } + } + } + ] + } + } + } }, "403": { - "description": "Authenticated but missing organization.maintenance.read" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Read nonnegative stock balances", + "description": "Read nonnegative stock balances", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryBalance identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "404": { - "description": "The organization is outside the caller's scope" + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true }, - "422": { - "description": "Export exceeds the row cap; narrow the filters and retry" + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 30 + }, + "style": "form", + "explode": true }, - "204": { - "description": "MaintenanceSchedule resource" + { + "name": "warehouseId", + "in": "query", + "description": "InventoryBalance warehouseId", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "partId", + "in": "query", + "description": "InventoryBalance partId", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/inventory-consumptions": { + "get": { + "operationId": "inventory_consumptions_list", + "tags": [ + "Inventory" + ], + "responses": { + "200": { + "description": "InventoryConsumption collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "InventoryConsumption.InventoryConsumptionOutput.jsonld-inventory.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InventoryConsumption.InventoryConsumptionOutput.jsonld-inventory.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Export maintenance schedules (CSV)", - "description": "Streams a CSV export of maintenance schedules (Content-Disposition: attachment) for the given organization, using the same filters as the list endpoint (`facility`, `equipmentType`, `dueStatus`, `dueBefore`). Requires `organization.maintenance.read` on the organization, resolved the same way the list endpoint resolves it \u2014 a resource-level ROLE_USER check alone does not grant access. Bounded to 50000 matching rows \u2014 the request is rejected with 422 if the filters match more; narrow with a more specific filter and retry.", + "summary": "Declare complete physical consumptions, retaining shortages", + "description": "Declare complete physical consumptions, retaining shortages", "parameters": [ { - "name": "organization", - "in": "query", - "description": "Organization IRI.", + "name": "organizationId", + "in": "path", + "description": "InventoryConsumption identifier", "required": true, "deprecated": false, "schema": { "type": "string" }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, "style": "form", "explode": true }, { - "name": "facility", + "name": "itemsPerPage", "in": "query", - "description": "Facility IRI filter.", + "description": "The number of items per page", "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 30 }, "style": "form", "explode": true }, { - "name": "equipmentType", + "name": "warehouseId", "in": "query", - "description": "Equipment type filter.", + "description": "InventoryConsumption warehouseId", "required": false, "deprecated": false, "schema": { @@ -26466,9 +28471,9 @@ "explode": true }, { - "name": "dueStatus", + "name": "partId", "in": "query", - "description": "Due status filter (unscheduled|up_to_date|due_soon|overdue).", + "description": "InventoryConsumption partId", "required": false, "deprecated": false, "schema": { @@ -26478,9 +28483,9 @@ "explode": true }, { - "name": "dueBefore", + "name": "interventionId", "in": "query", - "description": "ISO-8601 upper bound on the next due date.", + "description": "InventoryConsumption interventionId", "required": false, "deprecated": false, "schema": { @@ -26488,28 +28493,33 @@ }, "style": "form", "explode": true - } - ], - "security": [ + }, { - "bearerAuth": [] + "name": "status", + "in": "query", + "description": "InventoryConsumption status", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true } ] - } - }, - "/api/maintenance/campaigns": { + }, "post": { - "operationId": "api_maintenancecampaigns_post", + "operationId": "inventory_consumptions_create", "tags": [ - "MaintenanceCampaign" + "InventoryConsumption" ], "responses": { "201": { - "description": "MaintenanceCampaign resource created", + "description": "InventoryConsumption resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/MaintenanceCampaign.GenerateInspectionCampaignOutput.jsonld" + "$ref": "#/components/schemas/InventoryConsumption.InventoryConsumptionOutput.jsonld-inventory.read" } } }, @@ -26579,15 +28589,28 @@ "links": {} } }, - "summary": "Creates a MaintenanceCampaign resource.", - "description": "Creates a MaintenanceCampaign resource.", - "parameters": [], + "summary": "Creates a InventoryConsumption resource.", + "description": "Creates a InventoryConsumption resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryConsumption identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new MaintenanceCampaign resource", + "description": "The new InventoryConsumption resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/MaintenanceCampaign.GenerateInspectionCampaignInput" + "$ref": "#/components/schemas/InventoryConsumption.DeclareInventoryConsumptionInput-inventory.write" } } }, @@ -26595,20 +28618,20 @@ } } }, - "/api/maintenance/schedules": { + "/api/organizations/{organizationId}/inventory-movements": { "get": { - "operationId": "api_maintenanceschedules_get_collection", + "operationId": "inventory_movements_list", "tags": [ - "MaintenanceSchedule" + "Inventory" ], "responses": { "200": { - "description": "MaintenanceSchedule collection", + "description": "InventoryMovement collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "MaintenanceSchedule.MaintenanceScheduleOutput.jsonld collection.", + "description": "InventoryMovement.InventoryMovementOutput.jsonld-inventory.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -26622,7 +28645,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/MaintenanceSchedule.MaintenanceScheduleOutput.jsonld" + "$ref": "#/components/schemas/InventoryMovement.InventoryMovementOutput.jsonld-inventory.read" } } } @@ -26654,9 +28677,21 @@ "links": {} } }, - "summary": "Retrieves the collection of MaintenanceSchedule resources.", - "description": "Retrieves the collection of MaintenanceSchedule resources.", + "summary": "Read immutable motivated stock movements", + "description": "Read immutable motivated stock movements", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryMovement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "page", "in": "query", @@ -26680,39 +28715,15 @@ "type": "integer", "default": 30, "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true - }, - { - "name": "organization", - "in": "query", - "description": "Organization IRI.", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "facility", - "in": "query", - "description": "Facility IRI filter.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" + "maximum": 30 }, "style": "form", "explode": true }, { - "name": "equipmentType", + "name": "warehouseId", "in": "query", - "description": "Equipment type filter.", + "description": "InventoryMovement warehouseId", "required": false, "deprecated": false, "schema": { @@ -26722,9 +28733,9 @@ "explode": true }, { - "name": "dueStatus", + "name": "partId", "in": "query", - "description": "Due status filter (unscheduled|up_to_date|due_soon|overdue).", + "description": "InventoryMovement partId", "required": false, "deprecated": false, "schema": { @@ -26734,9 +28745,9 @@ "explode": true }, { - "name": "dueBefore", + "name": "interventionId", "in": "query", - "description": "ISO-8601 upper bound on the next due date.", + "description": "InventoryMovement interventionId", "required": false, "deprecated": false, "schema": { @@ -26748,19 +28759,39 @@ ] } }, - "/api/maintenance/schedules/{id}": { + "/api/organizations/{organizationId}/inventory-parts": { "get": { - "operationId": "api_maintenanceschedules_id_get", + "operationId": "inventory_parts_list", "tags": [ - "MaintenanceSchedule" + "Inventory" ], "responses": { "200": { - "description": "MaintenanceSchedule resource", + "description": "InventoryPart collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/MaintenanceSchedule.MaintenanceScheduleOutput.jsonld" + "type": "object", + "description": "InventoryPart.InventoryPartOutput.jsonld-inventory.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InventoryPart.InventoryPartOutput.jsonld-inventory.read" + } + } + } + } + ] } } } @@ -26785,36 +28816,15 @@ } }, "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "Retrieves a MaintenanceSchedule resource.", - "description": "Retrieves a MaintenanceSchedule resource.", + "summary": "Manage quantitative part and consumable references", + "description": "Manage quantitative part and consumable references", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "MaintenanceSchedule identifier", + "description": "InventoryPart identifier", "required": true, "deprecated": false, "schema": { @@ -26822,21 +28832,77 @@ }, "style": "simple", "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 30 + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "InventoryPart search", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "archived", + "in": "query", + "description": "InventoryPart archived", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + }, + "style": "form", + "explode": true } ] }, - "patch": { - "operationId": "api_maintenanceschedules_id_patch", + "post": { + "operationId": "inventory_parts_create", "tags": [ - "MaintenanceSchedule" + "InventoryPart" ], "responses": { - "200": { - "description": "MaintenanceSchedule resource updated", + "201": { + "description": "InventoryPart resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/MaintenanceSchedule.MaintenanceScheduleOutput.jsonld" + "$ref": "#/components/schemas/InventoryPart.InventoryPartOutput.jsonld-inventory.read" } } }, @@ -26904,36 +28970,15 @@ } }, "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "Updates the MaintenanceSchedule resource.", - "description": "Updates the MaintenanceSchedule resource.", + "summary": "Creates a InventoryPart resource.", + "description": "Creates a InventoryPart resource.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "MaintenanceSchedule identifier", + "description": "InventoryPart identifier", "required": true, "deprecated": false, "schema": { @@ -26944,11 +28989,11 @@ } ], "requestBody": { - "description": "The updated MaintenanceSchedule resource", + "description": "The new InventoryPart resource", "content": { - "application/merge-patch+json": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/MaintenanceSchedule.UpdateMaintenanceScheduleInput.jsonMergePatch" + "$ref": "#/components/schemas/InventoryPart.CreateInventoryPartInput-inventory.write" } } }, @@ -26956,65 +29001,42 @@ } } }, - "/api/media": { - "post": { - "operationId": "api_media_post", + "/api/organizations/{organizationId}/inventory-warehouses": { + "get": { + "operationId": "inventory_warehouses_list", "tags": [ - "Media" + "Inventory" ], "responses": { - "201": { - "description": "Media resource created", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Media.AttachmentOutput.jsonld" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "422": { - "description": "An error occurred", + "200": { + "description": "InventoryWarehouse collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "type": "object", + "description": "InventoryWarehouse.InventoryWarehouseOutput.jsonld-inventory.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InventoryWarehouse.InventoryWarehouseOutput.jsonld-inventory.read" + } + } + } + } + ] } } - }, - "links": {} + } }, "403": { "description": "Forbidden", @@ -27038,51 +29060,98 @@ "links": {} } }, - "summary": "Creates a Media resource.", - "description": "Creates a Media resource.", - "parameters": [] - } - }, - "/api/media/{id}": { - "get": { - "operationId": "api_media_id_get", + "summary": "Manage warehouses", + "description": "Manage warehouses", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryWarehouse identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 30 + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "InventoryWarehouse search", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "archived", + "in": "query", + "description": "InventoryWarehouse archived", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + }, + "style": "form", + "explode": true + } + ] + }, + "post": { + "operationId": "inventory_warehouses_create", "tags": [ - "Media" + "InventoryWarehouse" ], "responses": { - "200": { - "description": "Media resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Media.AttachmentOutput.jsonld" - } - } - } - }, - "403": { - "description": "Forbidden", + "201": { + "description": "InventoryWarehouse resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/InventoryWarehouse.InventoryWarehouseOutput.jsonld-inventory.read" } } }, "links": {} }, - "404": { - "description": "Not found", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -27101,57 +29170,30 @@ } }, "links": {} - } - }, - "summary": "Retrieves a Media resource.", - "description": "Retrieves a Media resource.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Media identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - }, - "delete": { - "operationId": "api_media_id_delete", - "tags": [ - "Media" - ], - "responses": { - "204": { - "description": "Media resource deleted" }, - "403": { - "description": "Forbidden", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} }, - "404": { - "description": "Not found", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -27172,13 +29214,13 @@ "links": {} } }, - "summary": "Removes the Media resource.", - "description": "Removes the Media resource.", + "summary": "Creates a InventoryWarehouse resource.", + "description": "Creates a InventoryWarehouse resource.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Media identifier", + "description": "InventoryWarehouse identifier", "required": true, "deprecated": false, "schema": { @@ -27187,42 +29229,33 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The new InventoryWarehouse resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InventoryWarehouse.CreateInventoryWarehouseInput-inventory.write" + } + } + }, + "required": true + } } }, - "/api/conversations/{conversationId}/messages": { + "/api/organizations/{organizationId}/inventory-consumptions/{id}": { "get": { - "operationId": "api_conversations_conversationIdmessages_get_collection", + "operationId": "inventory_consumptions_get", "tags": [ - "Message" + "InventoryConsumption" ], "responses": { "200": { - "description": "Message collection", + "description": "InventoryConsumption resource", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "Message.MessageOutput.jsonld collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" - } - } - } - } - ] + "$ref": "#/components/schemas/InventoryConsumption.InventoryConsumptionOutput.jsonld-inventory.read" } } } @@ -27247,15 +29280,36 @@ } }, "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Retrieves the collection of Message resources.", - "description": "Retrieves the collection of Message resources.", + "summary": "Retrieves a InventoryConsumption resource.", + "description": "Retrieves a InventoryConsumption resource.", "parameters": [ { - "name": "conversationId", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "InventoryConsumption identifier", "required": true, "deprecated": false, "schema": { @@ -27265,47 +29319,33 @@ "explode": false }, { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, + "name": "id", + "in": "path", + "description": "InventoryConsumption identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] - }, + } + }, + "/api/organizations/{organizationId}/inventory-consumptions/{id}/reconcile": { "post": { - "operationId": "api_conversations_conversationIdmessages_post", + "operationId": "inventory_consumptions_reconcile", "tags": [ - "Message" + "InventoryConsumption" ], "responses": { - "201": { - "description": "Message resource created", + "200": { + "description": "InventoryConsumption resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + "$ref": "#/components/schemas/InventoryConsumption.InventoryConsumptionOutput.jsonld-inventory.read" } } }, @@ -27375,13 +29415,25 @@ "links": {} } }, - "summary": "Creates a Message resource.", - "description": "Creates a Message resource.", + "summary": "Creates a InventoryConsumption resource.", + "description": "Creates a InventoryConsumption resource.", "parameters": [ { - "name": "conversationId", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "InventoryConsumption identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "InventoryConsumption identifier", "required": true, "deprecated": false, "schema": { @@ -27392,11 +29444,11 @@ } ], "requestBody": { - "description": "The new Message resource", + "description": "The new InventoryConsumption resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.PostMessageInput" + "$ref": "#/components/schemas/InventoryConsumption.ReconcileInventoryConsumptionInput-inventory.write" } } }, @@ -27404,74 +29456,22 @@ } } }, - "/api/conversations/{conversationId}/messages/{clientId}": { - "put": { - "operationId": "put_message", + "/api/organizations/{organizationId}/inventory-movements/{id}": { + "get": { + "operationId": "inventory_movements_get", "tags": [ - "Message" + "InventoryMovement" ], "responses": { - "201": { - "description": "Message created", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" - } - } - }, - "links": {} - }, - "409": { - "description": "This client identifier was already used" - }, - "428": { - "description": "If-None-Match: * is required" - }, - "412": { - "description": "If-None-Match must be *" - }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "422": { - "description": "An error occurred", + "200": { + "description": "InventoryMovement resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/InventoryMovement.InventoryMovementOutput.jsonld-inventory.read" } } - }, - "links": {} + } }, "403": { "description": "Forbidden", @@ -27516,13 +29516,13 @@ "links": {} } }, - "summary": "Replaces the Message resource.", - "description": "Replaces the Message resource.", + "summary": "Retrieves a InventoryMovement resource.", + "description": "Retrieves a InventoryMovement resource.", "parameters": [ { - "name": "conversationId", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "InventoryMovement identifier", "required": true, "deprecated": false, "schema": { @@ -27532,9 +29532,9 @@ "explode": false }, { - "name": "clientId", + "name": "id", "in": "path", - "description": "Message identifier", + "description": "InventoryMovement identifier", "required": true, "deprecated": false, "schema": { @@ -27543,135 +29543,26 @@ "style": "simple", "explode": false } - ], - "requestBody": { - "description": "The updated Message resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Message.PostMessageInput" - } - } - }, - "required": true - } + ] } }, - "/api/conversations/{conversationId}/pinned-messages": { + "/api/organizations/{organizationId}/inventory-parts/{id}": { "get": { - "operationId": "list_pinned_messages", + "operationId": "inventory_parts_get", "tags": [ - "Message" + "InventoryPart" ], "responses": { "200": { - "description": "Message collection", + "description": "InventoryPart resource", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "Message.MessageOutput.jsonld collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" - } - } - } - } - ] + "$ref": "#/components/schemas/InventoryPart.InventoryPartOutput.jsonld-inventory.read" } } } }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - } - }, - "summary": "Retrieves the collection of Message resources.", - "description": "Retrieves the collection of Message resources.", - "parameters": [ - { - "name": "conversationId", - "in": "path", - "description": "Message identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true - } - ] - } - }, - "/api/messages/{id}": { - "delete": { - "operationId": "api_messages_id_delete", - "tags": [ - "Message" - ], - "responses": { - "204": { - "description": "Message resource deleted" - }, "403": { "description": "Forbidden", "content": { @@ -27715,13 +29606,25 @@ "links": {} } }, - "summary": "Removes the Message resource.", - "description": "Removes the Message resource.", + "summary": "Retrieves a InventoryPart resource.", + "description": "Retrieves a InventoryPart resource.", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryPart identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "id", "in": "path", - "description": "Message identifier", + "description": "InventoryPart identifier", "required": true, "deprecated": false, "schema": { @@ -27733,17 +29636,17 @@ ] }, "patch": { - "operationId": "api_messages_id_patch", + "operationId": "inventory_parts_patch", "tags": [ - "Message" + "InventoryPart" ], "responses": { "200": { - "description": "Message resource updated", + "description": "InventoryPart resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + "$ref": "#/components/schemas/InventoryPart.InventoryPartOutput.jsonld-inventory.read" } } }, @@ -27834,13 +29737,25 @@ "links": {} } }, - "summary": "Updates the Message resource.", - "description": "Updates the Message resource.", + "summary": "Updates the InventoryPart resource.", + "description": "Updates the InventoryPart resource.", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryPart identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "id", "in": "path", - "description": "Message identifier", + "description": "InventoryPart identifier", "required": true, "deprecated": false, "schema": { @@ -27851,11 +29766,11 @@ } ], "requestBody": { - "description": "The updated Message resource", + "description": "The updated InventoryPart resource", "content": { "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/Message.EditMessageInput.jsonMergePatch" + "$ref": "#/components/schemas/InventoryPart.PatchInventoryPartInput-inventory.write.jsonMergePatch" } } }, @@ -27863,26 +29778,25 @@ } } }, - "/api/messages/{id}/pin": { - "post": { - "operationId": "pin_message", + "/api/organizations/{organizationId}/inventory-warehouses/{id}": { + "get": { + "operationId": "inventory_warehouses_get", "tags": [ - "Message" + "InventoryWarehouse" ], "responses": { "200": { - "description": "Message resource created", + "description": "InventoryWarehouse resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + "$ref": "#/components/schemas/InventoryWarehouse.InventoryWarehouseOutput.jsonld-inventory.read" } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -27902,32 +29816,142 @@ }, "links": {} }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} - }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { + } + }, + "summary": "Retrieves a InventoryWarehouse resource.", + "description": "Retrieves a InventoryWarehouse resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryWarehouse identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "InventoryWarehouse identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "patch": { + "operationId": "inventory_warehouses_patch", + "tags": [ + "InventoryWarehouse" + ], + "responses": { + "200": { + "description": "InventoryWarehouse resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InventoryWarehouse.InventoryWarehouseOutput.jsonld-inventory.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { "$ref": "#/components/schemas/Error.jsonld" } }, @@ -27945,13 +29969,25 @@ "links": {} } }, - "summary": "Creates a Message resource.", - "description": "Creates a Message resource.", + "summary": "Updates the InventoryWarehouse resource.", + "description": "Updates the InventoryWarehouse resource.", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryWarehouse identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "id", "in": "path", - "description": "Message identifier", + "description": "InventoryWarehouse identifier", "required": true, "deprecated": false, "schema": { @@ -27960,19 +29996,40 @@ "style": "simple", "explode": false } - ] - }, - "delete": { - "operationId": "unpin_message", + ], + "requestBody": { + "description": "The updated InventoryWarehouse resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/InventoryWarehouse.PatchInventoryWarehouseInput-inventory.write.jsonMergePatch" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/inventory-corrections": { + "post": { + "operationId": "inventory_corrections_create", "tags": [ - "Message" + "Inventorycorrections" ], "responses": { - "204": { - "description": "Message resource deleted" + "201": { + "description": "Inventorycorrections resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Inventorycorrections.InventoryMovementOutput.jsonld-inventory.read" + } + } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -27992,8 +30049,29 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -28014,13 +30092,13 @@ "links": {} } }, - "summary": "Removes the Message resource.", - "description": "Removes the Message resource.", + "summary": "Creates a Inventorycorrections resource.", + "description": "Creates a Inventorycorrections resource.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "Inventorycorrections identifier", "required": true, "deprecated": false, "schema": { @@ -28029,22 +30107,33 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The new Inventorycorrections resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Inventorycorrections.CorrectInventoryStockInput-inventory.write" + } + } + }, + "required": true + } } }, - "/api/messages/{id}/reactions": { + "/api/organizations/{organizationId}/inventory-returns": { "post": { - "operationId": "add_reaction", + "operationId": "inventory_returns_create", "tags": [ - "Message" + "Inventoryreturns" ], "responses": { - "200": { - "description": "Message resource created", + "201": { + "description": "Inventoryreturns resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + "$ref": "#/components/schemas/Inventoryreturns.InventoryMovementOutput.jsonld-inventory.read" } } }, @@ -28114,13 +30203,13 @@ "links": {} } }, - "summary": "Creates a Message resource.", - "description": "Creates a Message resource.", + "summary": "Creates a Inventoryreturns resource.", + "description": "Creates a Inventoryreturns resource.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "Inventoryreturns identifier", "required": true, "deprecated": false, "schema": { @@ -28131,11 +30220,11 @@ } ], "requestBody": { - "description": "The new Message resource", + "description": "The new Inventoryreturns resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.AddReactionInput" + "$ref": "#/components/schemas/Inventoryreturns.ReturnInventoryConsumptionInput-inventory.write" } } }, @@ -28143,15 +30232,119 @@ } } }, - "/api/messages/{id}/reactions/{emoji}": { - "delete": { - "operationId": "remove_reaction", + "/api/maintenance/schedules/export": { + "get": { + "operationId": "maintenance_schedule_export", "tags": [ - "Message" + "Maintenance" ], "responses": { + "200": { + "description": "CSV export streamed successfully" + }, + "400": { + "description": "Missing organization filter" + }, + "403": { + "description": "Authenticated but missing organization.maintenance.read" + }, + "404": { + "description": "The organization is outside the caller's scope" + }, + "422": { + "description": "Export exceeds the row cap; narrow the filters and retry" + }, "204": { - "description": "Message resource deleted" + "description": "MaintenanceSchedule resource" + } + }, + "summary": "Export maintenance schedules (CSV)", + "description": "Streams a CSV export of maintenance schedules (Content-Disposition: attachment) for the given organization, using the same filters as the list endpoint (`facility`, `equipmentType`, `dueStatus`, `dueBefore`). Requires `organization.maintenance.read` on the organization, resolved the same way the list endpoint resolves it \u2014 a resource-level ROLE_USER check alone does not grant access. Bounded to 50000 matching rows \u2014 the request is rejected with 422 if the filters match more; narrow with a more specific filter and retry.", + "parameters": [ + { + "name": "organization", + "in": "query", + "description": "Organization IRI.", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "facility", + "in": "query", + "description": "Facility IRI filter.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentType", + "in": "query", + "description": "Equipment type filter.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "dueStatus", + "in": "query", + "description": "Due status filter (unscheduled|up_to_date|due_soon|overdue).", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "dueBefore", + "in": "query", + "description": "ISO-8601 upper bound on the next due date.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/organizations/{organizationId}/maintenance/engine": { + "get": { + "operationId": "maintenance_plan_engine", + "tags": [ + "Maintenance" + ], + "responses": { + "200": { + "description": "MaintenancePlan resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanEngineOutput.jsonld-maintenance_plan.read" + } + } + } }, "403": { "description": "Forbidden", @@ -28196,25 +30389,13 @@ "links": {} } }, - "summary": "Removes the Message resource.", - "description": "Removes the Message resource.", + "summary": "Read active preventive engine", + "description": "Read active preventive engine", "parameters": [ { - "name": "id", - "in": "path", - "description": "Message identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "emoji", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "MaintenancePlan identifier", "required": true, "deprecated": false, "schema": { @@ -28226,20 +30407,20 @@ ] } }, - "/api/messages/{id}/replies": { + "/api/organizations/{organizationId}/maintenance/plans": { "get": { - "operationId": "list_replies", + "operationId": "maintenance_plans_list", "tags": [ - "Message" + "Maintenance" ], "responses": { "200": { - "description": "Message collection", + "description": "MaintenancePlan collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Message.MessageOutput.jsonld collection.", + "description": "MaintenancePlan.MaintenancePlanOutput.jsonld-maintenance_plan.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -28253,7 +30434,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanOutput.jsonld-maintenance_plan.read" } } } @@ -28285,13 +30466,13 @@ "links": {} } }, - "summary": "Retrieves the collection of Message resources.", - "description": "Retrieves the collection of Message resources.", + "summary": "List independent equipment operations", + "description": "List independent equipment operations", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "MaintenancePlan identifier", "required": true, "deprecated": false, "schema": { @@ -28327,21 +30508,63 @@ }, "style": "form", "explode": true + }, + { + "name": "equipmentId", + "in": "query", + "description": "MaintenancePlan equipmentId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "operationKind", + "in": "query", + "description": "MaintenancePlan operationKind", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "control", + "maintenance" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "MaintenancePlan search", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "maxLength": 160 + }, + "style": "form", + "explode": true } ] }, "post": { - "operationId": "post_reply", + "operationId": "maintenance_plan_create", "tags": [ - "Message" + "Maintenance" ], "responses": { "201": { - "description": "Message resource created", + "description": "MaintenancePlan resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanOutput.jsonld-maintenance_plan.read" } } }, @@ -28411,13 +30634,13 @@ "links": {} } }, - "summary": "Creates a Message resource.", - "description": "Creates a Message resource.", + "summary": "Prepare an equipment control or maintenance plan", + "description": "Prepare an equipment control or maintenance plan", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "MaintenancePlan identifier", "required": true, "deprecated": false, "schema": { @@ -28428,11 +30651,11 @@ } ], "requestBody": { - "description": "The new Message resource", + "description": "The new MaintenancePlan resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.PostReplyInput" + "$ref": "#/components/schemas/MaintenancePlan.ChangeMaintenancePlanInput-maintenance_plan.write" } } }, @@ -28440,19 +30663,19 @@ } } }, - "/api/messages/{id}/save": { + "/api/organizations/{organizationId}/maintenance/plans/activate": { "post": { - "operationId": "save_message", + "operationId": "maintenance_plan_activate", "tags": [ - "Message" + "Maintenance" ], "responses": { "200": { - "description": "Message resource created", + "description": "MaintenancePlan resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanEngineOutput.jsonld-maintenance_plan.read" } } }, @@ -28522,13 +30745,13 @@ "links": {} } }, - "summary": "Creates a Message resource.", - "description": "Creates a Message resource.", + "summary": "Atomically hand over the preventive engine", + "description": "Atomically hand over the preventive engine", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "MaintenancePlan identifier", "required": true, "deprecated": false, "schema": { @@ -28538,18 +30761,28 @@ "explode": false } ] - }, - "delete": { - "operationId": "unsave_message", + } + }, + "/api/organizations/{organizationId}/maintenance/plans/prepare-legacy": { + "post": { + "operationId": "maintenance_plan_prepare_legacy", "tags": [ - "Message" + "Maintenance" ], "responses": { - "204": { - "description": "Message resource deleted" + "200": { + "description": "MaintenancePlan resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanEngineOutput.jsonld-maintenance_plan.read" + } + } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -28569,8 +30802,29 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -28591,13 +30845,13 @@ "links": {} } }, - "summary": "Removes the Message resource.", - "description": "Removes the Message resource.", + "summary": "Prepare historical plans without generating work", + "description": "Prepare historical plans without generating work", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "MaintenancePlan identifier", "required": true, "deprecated": false, "schema": { @@ -28609,39 +30863,19 @@ ] } }, - "/api/saved-messages": { + "/api/organizations/{organizationId}/maintenance/plans/{id}": { "get": { - "operationId": "list_saved_messages", + "operationId": "maintenance_plan_get", "tags": [ - "Message" + "Maintenance" ], "responses": { "200": { - "description": "Message collection", + "description": "MaintenancePlan resource", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "Message.MessageOutput.jsonld collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" - } - } - } - } - ] + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanOutput.jsonld-maintenance_plan.read" } } } @@ -28666,105 +30900,117 @@ } }, "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Retrieves the collection of Message resources.", - "description": "Retrieves the collection of Message resources.", + "summary": "Read an equipment operation", + "description": "Read an equipment operation", "parameters": [ { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, + "name": "organizationId", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "organization", - "in": "query", - "description": "Organization IRI.", + "name": "id", + "in": "path", + "description": "MaintenancePlan identifier", "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] - } - }, - "/api/conversations/{conversationId}/attachments": { - "get": { - "operationId": "list_conversation_attachments", + }, + "delete": { + "operationId": "maintenance_plan_archive", "tags": [ - "Messaging" + "Maintenance" ], "responses": { - "200": { - "description": "Attachments retrieved", + "204": { + "description": "MaintenancePlan resource deleted" + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "MessagingAttachment.MessageAttachmentOutput.jsonld collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MessagingAttachment.MessageAttachmentOutput.jsonld" - } - } - } - } - ] + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } - }, - "403": { - "description": "Insufficient permissions" + }, + "links": {} }, "404": { - "description": "Conversation not found" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "List a conversation's attachments (Files tab)", - "description": "Lists the attachments posted in a conversation, most recently uploaded first.", + "summary": "Archive an operation, retaining history", + "description": "Archive an operation, retaining history", "parameters": [ { - "name": "conversationId", + "name": "organizationId", "in": "path", - "description": "MessagingAttachment identifier", + "description": "MaintenancePlan identifier", "required": true, "deprecated": false, "schema": { @@ -28774,49 +31020,31 @@ "explode": false }, { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, + "name": "id", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] - } - }, - "/api/messages/{messageId}/attachments": { - "post": { - "operationId": "add_message_attachment", + }, + "patch": { + "operationId": "maintenance_plan_update", "tags": [ - "Messaging" + "Maintenance" ], "responses": { - "201": { - "description": "Attachment uploaded", + "200": { + "description": "MaintenancePlan resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/MessagingAttachment.MessageAttachmentOutput.jsonld" + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanOutput.jsonld-maintenance_plan.read" } } }, @@ -28844,7 +31072,7 @@ "links": {} }, "422": { - "description": "MIME type, size, or message state rejected", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -28865,19 +31093,55 @@ "links": {} }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "Message not found" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Upload a message attachment", - "description": "Uploads a multipart file attachment to a message.", + "summary": "Update an equipment operation", + "description": "Update an equipment operation", "parameters": [ { - "name": "messageId", + "name": "organizationId", "in": "path", - "description": "MessagingAttachment identifier", + "description": "MaintenancePlan identifier", "required": true, "deprecated": false, "schema": { @@ -28885,40 +31149,11 @@ }, "style": "simple", "explode": false - } - ] - } - }, - "/api/messaging-attachments/{id}": { - "delete": { - "operationId": "delete_message_attachment", - "tags": [ - "Messaging" - ], - "responses": { - "204": { - "description": "Attachment deleted" - }, - "428": { - "description": "If-Match header is required" }, - "412": { - "description": "Attachment revision is stale" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Attachment not found" - } - }, - "summary": "Delete a message attachment", - "description": "Delete a message attachment", - "parameters": [ { "name": "id", "in": "path", - "description": "MessagingAttachment identifier", + "description": "MaintenancePlan identifier", "required": true, "deprecated": false, "schema": { @@ -28927,83 +31162,79 @@ "style": "simple", "explode": false } - ] - } - }, - "/api/messaging-attachments/{id}/content": { - "get": { - "operationId": "download_message_attachment", - "tags": [ - "Messaging" ], - "responses": { - "200": { - "description": "Attachment file content" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Attachment (or its stored content) not found" + "requestBody": { + "description": "The updated MaintenancePlan resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/MaintenancePlan.ChangeMaintenancePlanInput-maintenance_plan.write.jsonMergePatch" + } + } }, - "204": { - "description": "MessagingAttachmentContent resource" - } - }, - "summary": "Download a message attachment", - "description": "Streams the stored file bytes with Content-Disposition: attachment. Gated by the same access rule as reading the owning conversation.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "MessagingAttachmentContent identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] + "required": true + } } }, - "/api/conversations/{conversationId}/links": { - "get": { - "operationId": "list_conversation_links", + "/api/organizations/{organizationId}/maintenance/plans/{id}/generate": { + "post": { + "operationId": "maintenance_plan_generate", "tags": [ - "MessagingLink" + "Maintenance" ], "responses": { "200": { - "description": "MessagingLink collection", + "description": "MaintenancePlan resource created", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "MessagingLink.MessagingLinkOutput.jsonld collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MessagingLink.MessagingLinkOutput.jsonld" - } - } - } - } - ] + "$ref": "#/components/schemas/MaintenancePlan.GenerateMaintenancePlanOutput.jsonld-maintenance_plan.read" } } - } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} }, "403": { "description": "Forbidden", @@ -29027,13 +31258,13 @@ "links": {} } }, - "summary": "Retrieves the collection of MessagingLink resources.", - "description": "Retrieves the collection of MessagingLink resources.", + "summary": "Reserve an occurrence and generate or replay its work", + "description": "Reserve an occurrence and generate or replay its work", "parameters": [ { - "name": "conversationId", + "name": "organizationId", "in": "path", - "description": "MessagingLink identifier", + "description": "MaintenancePlan identifier", "required": true, "deprecated": false, "schema": { @@ -29043,76 +31274,48 @@ "explode": false }, { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, + "name": "id", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } - ] + ], + "requestBody": { + "description": "The new MaintenancePlan resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenancePlan.GenerateMaintenancePlanInput-maintenance_plan.write" + } + } + }, + "required": true + } } }, - "/api/notification-types": { + "/api/organizations/{organizationId}/maintenance/plans/{id}/preview": { "get": { - "operationId": "notification_types_list", + "operationId": "maintenance_plan_preview", "tags": [ - "Notification" + "Maintenance" ], "responses": { "200": { - "description": "Notification types retrieved successfully", + "description": "MaintenancePlan resource", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "NotificationType.NotificationTypeOutput.jsonld-notification_type.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationType.NotificationTypeOutput.jsonld-notification_type.read" - } - } - } - } - ] + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanPreviewOutput.jsonld-maintenance_plan.read" } } } }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -29133,46 +31336,73 @@ } }, "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "List notification types", - "description": "Returns all known notification types with their categories. Useful for building type selector components on the frontend.", + "summary": "Preview three dates before activation", + "description": "Preview three dates before activation", "parameters": [ { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, + "name": "organizationId", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 1 + "type": "string" }, - "style": "form", - "explode": true - } - ], - "security": [ + "style": "simple", + "explode": false + }, { - "bearerAuth": [] + "name": "id", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] } }, - "/api/notifications": { + "/api/organizations/{organizationId}/maintenance-export-references": { "get": { - "operationId": "notifications_list", + "operationId": "maintenance_export_references", "tags": [ - "Notification" + "Maintenance export" ], "responses": { "200": { - "description": "Notifications retrieved successfully", + "description": "MaintenanceExport collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Notification.NotificationOutput.jsonld-notification.read collection.", + "description": "MaintenanceExport.MaintenanceExportReferenceOutput.jsonld-maintenance_export.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -29186,7 +31416,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Notification.NotificationOutput.jsonld-notification.read" + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportReferenceOutput.jsonld-maintenance_export.read" } } } @@ -29196,9 +31426,6 @@ } } }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -29221,9 +31448,21 @@ "links": {} } }, - "summary": "List notifications", - "description": "Returns a paginated collection of notifications for the authenticated user. Read notifications from low-value categories may be omitted from the default list after a retention delay.", + "summary": "Read scoped external identity mappings", + "description": "Read scoped external identity mappings", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "page", "in": "query", @@ -29245,7 +31484,7 @@ "deprecated": false, "schema": { "type": "integer", - "default": 20, + "default": 30, "minimum": 0, "maximum": 100 }, @@ -29253,45 +31492,38 @@ "explode": true }, { - "name": "unreadOnly", + "name": "system", "in": "query", - "description": "When true, returns only unread notifications.", + "description": "MaintenanceExport system", "required": false, "deprecated": false, "schema": { - "type": "boolean" + "type": "string" }, "style": "form", "explode": true }, { - "name": "type", + "name": "resourceType", "in": "query", - "description": "Filter by exact notification type (e.g. `organization.invitation`). See `GET /notification-types` for the full list.", + "description": "MaintenanceExport resourceType", "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "customer", + "site", + "equipment" + ] }, "style": "form", "explode": true }, { - "name": "category", + "name": "resourceId", "in": "query", - "description": "Filter by category prefix (e.g. `organization`, `system`). Ignored when `type` is also provided.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "organization", - "in": "query", - "description": "Filter by organization identifier. When omitted, notifications across all organizations are returned, including account-level notifications that have no organization.", + "description": "MaintenanceExport resourceId", "required": false, "deprecated": false, "schema": { @@ -29301,36 +31533,29 @@ "style": "form", "explode": true } - ], - "security": [ - { - "bearerAuth": [] - } ] } }, - "/api/notifications/preferences": { - "get": { - "operationId": "notification_preferences_get", + "/api/organizations/{organizationId}/maintenance-export-references/{resourceType}/{resourceId}": { + "patch": { + "operationId": "write_maintenance_export_reference", "tags": [ - "Notification" + "Maintenance export" ], "responses": { "200": { - "description": "Notification preferences retrieved successfully", + "description": "MaintenanceExport resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Notification.NotificationPreferencesOutput.jsonld-notification.preferences.read" + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportReferenceOutput.jsonld-maintenance_export.read" } } - } - }, - "401": { - "description": "Authentication required" + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -29350,56 +31575,29 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - } - }, - "summary": "Get notification preferences", - "description": "Returns the authenticated user's customized per-category delivery preferences. A category with no entry is enabled on every channel (email and Mercure); only explicit customizations are stored.", - "parameters": [], - "security": [ - { - "bearerAuth": [] - } - ] - }, - "patch": { - "operationId": "notification_preferences_update", - "tags": [ - "Notification" - ], - "responses": { - "200": { - "description": "Notification preferences updated successfully", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Notification.NotificationPreferencesOutput.jsonld-notification.preferences.read" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} }, - "400": { - "description": "Invalid request - validation failed", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -29419,29 +31617,117 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} + } + }, + "summary": "Set an external identity reference with an optimistic revision", + "description": "Set an external identity reference with an optimistic revision", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "resourceType", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "resourceId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated MaintenanceExport resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceExport.WriteMaintenanceExportReferenceInput-maintenance_export.write.jsonMergePatch" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/maintenance-export-sources": { + "get": { + "operationId": "maintenance_export_sources", + "tags": [ + "Maintenance export" + ], + "responses": { + "200": { + "description": "MaintenanceExport collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "MaintenanceExport.MaintenanceExportSourceOutput.jsonld-maintenance_export.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportSourceOutput.jsonld-maintenance_export.read" + } + } + } + } + ] + } + } + } }, "403": { "description": "Forbidden", @@ -29463,9 +31749,105 @@ } }, "links": {} + } + }, + "summary": "List published dossiers with explicit snapshot availability", + "description": "List published dossiers with explicit snapshot availability", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "404": { - "description": "Not found", + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "MaintenanceExport search", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/maintenance-exports": { + "get": { + "operationId": "maintenance_exports", + "tags": [ + "Maintenance export" + ], + "responses": { + "200": { + "description": "MaintenanceExport collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "MaintenanceExport.MaintenanceExportOutput.jsonld-maintenance_export.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportOutput.jsonld-maintenance_export.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -29486,48 +31868,80 @@ "links": {} } }, - "summary": "Update notification preferences", - "description": "Upserts one or more per-category delivery preferences for the authenticated user and returns the full customized set. This suppresses email/Mercure delivery only \u2014 matching notifications are still persisted and remain visible in the in-app list.", - "parameters": [], - "requestBody": { - "description": "The updated Notification resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Notification.UpdateNotificationPreferencesInput-notification.preferences.write.jsonMergePatch" - } - } + "summary": "List preserved ERP prestation exports", + "description": "List preserved ERP prestation exports", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "required": true - }, - "security": [ { - "bearerAuth": [] + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "system", + "in": "query", + "description": "MaintenanceExport system", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true } ] - } - }, - "/api/notifications/read-all": { - "patch": { - "operationId": "notification_mark_all_as_read", + }, + "post": { + "operationId": "create_maintenance_export", "tags": [ - "Notification" + "Maintenance export" ], "responses": { - "200": { - "description": "Notifications marked as read", + "201": { + "description": "MaintenanceExport resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Notification.MarkAllNotificationsAsReadOutput.jsonld-notification.mark_all_as_read" + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportOutput.jsonld-maintenance_export.read" } } }, "links": {} }, - "401": { - "description": "Authentication required" - }, "400": { "description": "Invalid input", "content": { @@ -29590,73 +32004,54 @@ } }, "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "Mark all notifications as read", - "description": "Marks every unread notification of the authenticated user as read. Idempotent: calling it again once everything is read affects zero notifications.", + "summary": "Preserve versioned CSV and JSON bytes atomically", + "description": "Preserve versioned CSV and JSON bytes atomically", "parameters": [ { - "name": "organization", - "in": "query", - "description": "Filter by organization identifier. When omitted, notifications across all organizations (and account-level ones) are marked as read.", - "required": false, + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "uuid" + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ], - "security": [ - { - "bearerAuth": [] - } - ] + "requestBody": { + "description": "The new MaintenanceExport resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceExport.CreateMaintenanceExportInput-maintenance_export.write" + } + } + }, + "required": true + } } }, - "/api/notifications/subscription": { + "/api/organizations/{organizationId}/maintenance-exports/{id}": { "get": { - "operationId": "notification_mercure_subscription", + "operationId": "maintenance_export", "tags": [ - "Notification" + "Maintenance export" ], "responses": { "200": { - "description": "Mercure subscription token returned successfully", + "description": "MaintenanceExport resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Notification.MercureSubscriptionOutput.jsonld-notification.mercure_subscription" + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportOutput.jsonld-maintenance_export.read" } } } }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -29700,38 +32095,56 @@ "links": {} } }, - "summary": "Get Mercure subscription token", - "description": "Returns a Mercure subscriber JWT and the private SSE topic for the authenticated user. The client must pass this token in the Authorization header (Bearer) when opening an EventSource connection to the Mercure hub.", - "parameters": [], - "security": [ + "summary": "Read retained export metadata", + "description": "Read retained export metadata", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] } }, - "/api/notifications/unread-count": { - "get": { - "operationId": "notification_unread_count", + "/api/organizations/{organizationId}/maintenance-exports/{id}/adjustments": { + "post": { + "operationId": "adjust_maintenance_export", "tags": [ - "Notification" + "Maintenance export" ], "responses": { - "200": { - "description": "Unread notification count returned successfully", + "201": { + "description": "MaintenanceExport resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Notification.UnreadNotificationsCountOutput.jsonld-notification.unread_count" + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportOutput.jsonld-maintenance_export.read" } } - } - }, - "401": { - "description": "Authentication required" + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -29751,74 +32164,26 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} - } - }, - "summary": "Get unread notifications count", - "description": "Returns the unread notification count for the authenticated user.", - "parameters": [ - { - "name": "organization", - "in": "query", - "description": "Filter by organization identifier. When omitted, unread notifications across all organizations (and account-level ones) are counted.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" - }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/notifications/{id}": { - "get": { - "operationId": "notification_get", - "tags": [ - "Notification" - ], - "responses": { - "200": { - "description": "Notification retrieved successfully", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Notification.NotificationOutput.jsonld-notification.read" - } - } - } - }, - "404": { - "description": "Notification not found" - }, - "401": { - "description": "Authentication required" }, "403": { "description": "Forbidden", @@ -29842,13 +32207,25 @@ "links": {} } }, - "summary": "Get notification", - "description": "Returns a notification owned by the authenticated user.", + "summary": "Append an adjustment without rewriting original files", + "description": "Append an adjustment without rewriting original files", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "id", "in": "path", - "description": "Notification identifier", + "description": "MaintenanceExport identifier", "required": true, "deprecated": false, "schema": { @@ -29858,37 +32235,37 @@ "explode": false } ], - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/notifications/{id}/read": { - "patch": { - "operationId": "notification_mark_as_read", + "requestBody": { + "description": "The new MaintenanceExport resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceExport.AdjustMaintenanceExportInput-maintenance_export.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/maintenance-exports/{id}/confirm": { + "post": { + "operationId": "confirm_maintenance_export", "tags": [ - "Notification" + "Maintenance export" ], "responses": { "200": { - "description": "Notification marked as read", + "description": "MaintenanceExport resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Notification.NotificationOutput.jsonld-notification.read" + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportOutput.jsonld-maintenance_export.read" } } }, "links": {} }, - "404": { - "description": "Notification not found" - }, - "401": { - "description": "Authentication required" - }, "400": { "description": "Invalid input", "content": { @@ -29953,13 +32330,25 @@ "links": {} } }, - "summary": "Mark notification as read", - "description": "Marks one notification as read for the authenticated user.", + "summary": "Explicitly acknowledge the external import", + "description": "Explicitly acknowledge the external import", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "id", "in": "path", - "description": "Notification identifier", + "description": "MaintenanceExport identifier", "required": true, "deprecated": false, "schema": { @@ -29969,39 +32358,49 @@ "explode": false } ], - "security": [ - { - "bearerAuth": [] - } - ] + "requestBody": { + "description": "The new MaintenanceExport resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceExport.ConfirmMaintenanceExportInput-maintenance_export.write" + } + } + }, + "required": true + } } }, - "/api/.well-known/jwks.json": { + "/api/organizations/{organizationId}/maintenance-exports/{id}/files/{format}": { "get": { - "operationId": "jwks", + "operationId": "maintenance_export_file", "tags": [ - "OAuth2" + "Maintenance export" ], "responses": { - "200": { - "description": "JWKS returned successfully", + "204": { + "description": "MaintenanceExport resource" + }, + "403": { + "description": "Forbidden", "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, "application/json": { "schema": { - "$ref": "#/components/schemas/Discovery.JwksOutput-token.read" + "$ref": "#/components/schemas/Error" } } }, - "links": { - "GetOpenIDConfig": { - "operationId": "openid_configuration", - "description": "Get the full OpenID Connect configuration" - }, - "IntrospectToken": { - "operationId": "introspect_token", - "description": "Introspect a token to validate it server-side" - } - } + "links": {} }, "404": { "description": "Not found", @@ -30025,126 +32424,110 @@ "links": {} } }, - "summary": "JSON Web Key Set", - "description": "Returns the public keys used to verify JWT signatures as defined in RFC 7517. Use these keys to validate access tokens issued by this server.", - "parameters": [] + "summary": "Download the exact preserved CSV or JSON bytes", + "description": "Download the exact preserved CSV or JSON bytes", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "format", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/.well-known/openid-configuration": { - "get": { - "operationId": "openid_configuration", + "/api/maintenance/campaigns": { + "post": { + "operationId": "api_maintenancecampaigns_post", "tags": [ - "OAuth2" + "MaintenanceCampaign" ], "responses": { - "200": { - "description": "OpenID Connect configuration returned successfully", + "201": { + "description": "MaintenanceCampaign resource created", "content": { - "application/json": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Discovery.OpenIdConfigurationOutput-token.read" + "$ref": "#/components/schemas/MaintenanceCampaign.GenerateInspectionCampaignOutput.jsonld" } } }, - "links": { - "GetJWKS": { - "operationId": "jwks", - "description": "Get the JSON Web Key Set for signature verification", - "parameters": { - "url": "$response.body#/jwks_uri" - } - }, - "IssueToken": { - "operationId": "token", - "description": "Issue an access token using the token endpoint", - "parameters": { - "url": "$response.body#/token_endpoint" - } - }, - "IntrospectToken": { - "operationId": "introspect_token", - "description": "Introspect a token using the introspection endpoint", - "parameters": { - "url": "$response.body#/introspection_endpoint" - } - }, - "RevokeToken": { - "operationId": "revoke_token", - "description": "Revoke a token using the revocation endpoint", - "parameters": { - "url": "$response.body#/revocation_endpoint" + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" } }, - "GetUserInfo": { - "operationId": "userinfo", - "description": "Get user information using the userinfo endpoint", - "parameters": { - "url": "$response.body#/userinfo_endpoint" + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" } }, - "EndSession": { - "operationId": "end_session", - "description": "Terminate the OpenID Connect session", - "parameters": { - "url": "$response.body#/end_session_endpoint" + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } + }, + "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} - } - }, - "summary": "OAuth2/OIDC Discovery", - "description": "Returns the OAuth2 Authorization Server Metadata document (RFC 8414) with optional OpenID Connect extensions when available.", - "parameters": [] - } - }, - "/api/oauth2/authorize": { - "get": { - "operationId": "authorize", - "tags": [ - "OAuth2" - ], - "responses": { - "302": { - "description": "Authorization code issued (redirect to redirect_uri)." }, "403": { - "description": "User consent required." - }, - "401": { - "description": "Authentication required." - }, - "400": { - "description": "Invalid authorization request." - }, - "429": { - "description": "Too many authorization requests" - }, - "204": { - "description": "OAuth2 resource" - }, - "404": { - "description": "Not found", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -30165,253 +32548,120 @@ "links": {} } }, - "summary": "Authorize Client", - "description": "Initiate the OAuth2 authorization code flow (PKCE required).", - "parameters": [ - { - "name": "response_type", - "in": "query", - "description": "OAuth2 response type (must be code)", - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "code" - ] - }, - "style": "form", - "explode": true - }, - { - "name": "client_id", - "in": "query", - "description": "OAuth2 client identifier (UUID)", - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" - }, - "style": "form", - "explode": true - }, - { - "name": "redirect_uri", - "in": "query", - "description": "Redirect URI registered for the client", - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "format": "uri" - }, - "style": "form", - "explode": true + "summary": "Creates a MaintenanceCampaign resource.", + "description": "Creates a MaintenanceCampaign resource.", + "parameters": [], + "requestBody": { + "description": "The new MaintenanceCampaign resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceCampaign.GenerateInspectionCampaignInput" + } + } }, - { - "name": "scope", - "in": "query", - "description": "Space-separated list of requested scopes", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "openid profile email" - }, - "style": "form", - "explode": true + "required": true + } + } + }, + "/api/organizations/{organizationId}/interventions/{id}/costs": { + "get": { + "operationId": "maintenance_cost_read", + "tags": [ + "MaintenanceCost" + ], + "responses": { + "200": { + "description": "Private internal costs", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceCost.MaintenanceCostOutput.jsonld-maintenance_cost.read" + } + } + } }, - { - "name": "state", - "in": "query", - "description": "Opaque state value returned to the client", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true + "403": { + "description": "Financial read permission missing" }, + "404": { + "description": "Intervention unavailable" + } + }, + "summary": "Read private current and frozen intervention costs", + "description": "Read private current and frozen intervention costs", + "parameters": [ { - "name": "code_challenge", - "in": "query", - "description": "PKCE code challenge", + "name": "organizationId", + "in": "path", + "description": "MaintenanceCost identifier", "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true - }, - { - "name": "code_challenge_method", - "in": "query", - "description": "PKCE code challenge method (S256 or plain, defaults to plain)", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "S256", - "plain" - ] - }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "nonce", - "in": "query", - "description": "OIDC nonce value (optional)", - "required": false, + "name": "id", + "in": "path", + "description": "MaintenanceCost identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true - }, - { - "name": "prompt", - "in": "query", - "description": "OIDC prompt values (space-separated)", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "login consent" - }, - "style": "form", - "explode": true - }, - { - "name": "max_age", - "in": "query", - "description": "Maximum authentication age in seconds", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "example": 3600 - }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] + "style": "simple", + "explode": false } ] } }, - "/api/oauth2/consent/check": { - "get": { - "operationId": "check_consent", + "/api/organizations/{organizationId}/interventions/{id}/costs/expenses": { + "post": { + "operationId": "maintenance_cost_expense", "tags": [ - "OAuth2" + "MaintenanceCost" ], "responses": { - "200": { - "description": "Consent check result", + "201": { + "description": "Expense retained or exact replay", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OAuth2.CheckConsentOutput.jsonld-consent.read" + "$ref": "#/components/schemas/MaintenanceCost.MaintenanceCostOutput.jsonld-maintenance_cost.read" } } }, - "links": { - "GetUserInfo": { - "operationId": "userinfo", - "description": "Get user information after consent check" - } - } - }, - "400": { - "description": "Missing required client_id parameter" - }, - "401": { - "description": "Missing or invalid access token" + "links": {} }, - "429": { - "description": "Too many consent checks" + "409": { + "description": "Stable declaration identity conflict" }, - "404": { - "description": "Not found", + "422": { + "description": "Invalid exact amount or source", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} - } - }, - "summary": "Check User Consent", - "description": "Check if the authenticated user has granted consent for a specific client and scopes.", - "parameters": [ - { - "name": "client_id", - "in": "query", - "description": "The OAuth2 client identifier requesting authorization", - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid", - "example": "01234567-89ab-cdef-0123-456789abcdef" - }, - "style": "form", - "explode": true - }, - { - "name": "scope", - "in": "query", - "description": "Space-separated list of requested OAuth2 scopes", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "openid profile email" - }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/oauth2/consent/grant": { - "post": { - "operationId": "grant_consent", - "tags": [ - "OAuth2" - ], - "responses": { - "302": { - "description": "Authorization completed (redirect to redirect_uri)." }, "400": { - "description": "Invalid consent request.", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -30431,90 +32681,98 @@ }, "links": {} }, - "401": { - "description": "Authentication required." - }, - "429": { - "description": "Too many consent submissions" - }, - "204": { - "description": "OAuth2 resource created", - "links": {} - }, - "422": { - "description": "An error occurred", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Grant User Consent", - "description": "Approve or deny a consent request and complete the authorization flow.", - "parameters": [], - "requestBody": { - "description": "The new OAuth2 resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.GrantConsentInput-consent.write" - } + "summary": "Append a motivated external expense or correction", + "description": "Append a motivated external expense or correction", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceCost identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.GrantConsentInput-consent.write" - } + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "MaintenanceCost identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" }, - "application/x-www-form-urlencoded": { + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new MaintenanceCost resource", + "content": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OAuth2.GrantConsentInput-consent.write" + "$ref": "#/components/schemas/MaintenanceCost.CreateMaintenanceExpenseInput-maintenance_cost.write" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/oauth2/logout": { - "get": { - "operationId": "end_session", + "/api/organizations/{organizationId}/interventions/{id}/costs/planning": { + "patch": { + "operationId": "maintenance_cost_planning", "tags": [ - "OAuth2" + "MaintenanceCost" ], "responses": { - "302": { - "description": "Redirect to the post_logout_redirect_uri." - }, "200": { - "description": "Logout completed without redirect." + "description": "Financial preparation updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceCost.MaintenanceCostOutput.jsonld-maintenance_cost.read" + } + } + }, + "links": {} }, - "400": { - "description": "Invalid logout request." + "409": { + "description": "Preparation already closed" }, - "204": { - "description": "OAuth2 resource" + "412": { + "description": "Stale planning revision" }, - "404": { - "description": "Not found", + "428": { + "description": "Planning revision required" + }, + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -30533,113 +32791,30 @@ } }, "links": {} - } - }, - "summary": "End Session", - "description": "Terminate the current OpenID Connect session and optionally redirect to a client post-logout URI.", - "parameters": [ - { - "name": "id_token_hint", - "in": "query", - "description": "ID token previously issued by this server (optional)", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "post_logout_redirect_uri", - "in": "query", - "description": "Client post-logout redirect URI (must be registered)", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uri" - }, - "style": "form", - "explode": true }, - { - "name": "client_id", - "in": "query", - "description": "OAuth2 client identifier (required when post_logout_redirect_uri is provided)", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" - }, - "style": "form", - "explode": true - }, - { - "name": "state", - "in": "query", - "description": "Opaque state value returned to the client after logout", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - } - ] - } - }, - "/api/oauth2/token": { - "post": { - "operationId": "token", - "tags": [ - "OAuth2" - ], - "responses": { - "200": { - "description": "Access token issued successfully", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OAuth2.TokenOutput.jsonld-token.read" - } - } - }, - "links": { - "IntrospectToken": { - "operationId": "introspect_token", - "description": "Introspect the issued token to get its metadata", - "parameters": { - "token": "$response.body#/access_token", - "token_type_hint": "access_token" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, - "RevokeToken": { - "operationId": "revoke_token", - "description": "Revoke the issued token when no longer needed", - "parameters": { - "token": "$response.body#/access_token", - "token_type_hint": "access_token" + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" } }, - "RevokeRefreshToken": { - "operationId": "revoke_token", - "description": "Revoke the refresh token when no longer needed", - "parameters": { - "token": "$response.body#/refresh_token", - "token_type_hint": "refresh_token" + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" } - }, - "GetUserInfo": { - "operationId": "userinfo", - "description": "Get user information using the access token (pass as Bearer header)" } - } + }, + "links": {} }, - "400": { - "description": "Invalid request - missing or invalid parameters", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -30659,53 +32834,75 @@ }, "links": {} }, - "401": { - "description": "Invalid client credentials or unauthorized grant type" - }, - "429": { - "description": "Too many token requests" - }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Issue Access Token", - "description": "Issue an access token using configured OAuth2 grant types (see discovery metadata).", - "parameters": [], - "requestBody": { - "description": "The new OAuth2 resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenInput-token.write" - } + "summary": "Prepare an internal budget and estimated resources", + "description": "Prepare an internal budget and estimated resources", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "example": "\"revision-0\"" }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenInput-token.write" - } + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceCost identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" }, - "application/x-www-form-urlencoded": { + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "MaintenanceCost identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated MaintenanceCost resource", + "content": { + "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/OAuth2.TokenInput-token.write" + "$ref": "#/components/schemas/MaintenanceCost.WriteCostPlanningInput-maintenance_cost.write.jsonMergePatch" } } }, @@ -30713,41 +32910,25 @@ } } }, - "/api/oauth2/token/introspect": { - "post": { - "operationId": "introspect_token", + "/api/organizations/{organizationId}/maintenance-cost/currency": { + "get": { + "operationId": "maintenance_cost_currency_read", "tags": [ - "OAuth2" + "MaintenanceCost" ], "responses": { "200": { - "description": "Token introspection result", + "description": "MaintenanceCostCurrency resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OAuth2.TokenIntrospectionOutput.jsonld-token.read" - } - } - }, - "links": { - "RevokeToken": { - "operationId": "revoke_token", - "description": "Revoke the token if it should be invalidated", - "parameters": { - "token": "$request.body#/token", - "token_type_hint": "$request.body#/token_type_hint" + "$ref": "#/components/schemas/MaintenanceCostCurrency.MaintenanceCurrencyOutput.jsonld-maintenance_cost_currency.read" } } } }, - "401": { - "description": "Invalid client credentials" - }, - "429": { - "description": "Too many introspection requests" - }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -30767,29 +32948,8 @@ }, "links": {} }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} - }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -30810,55 +32970,42 @@ "links": {} } }, - "summary": "Introspect Token", - "description": "Query the authorization server about the current state and metadata of a token as defined in RFC 7662.", - "parameters": [], - "requestBody": { - "description": "The new OAuth2 resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenIntrospectionInput-token.write" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenIntrospectionInput-token.write" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenIntrospectionInput-token.write" - } - } - }, - "required": true - }, - "security": [ + "summary": "Read the private organization cost currency", + "description": "Read the private organization cost currency", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "MaintenanceCostCurrency identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] - } - }, - "/api/oauth2/token/revoke": { - "post": { - "operationId": "revoke_token", + }, + "patch": { + "operationId": "maintenance_cost_currency_configure", "tags": [ - "OAuth2" + "MaintenanceCost" ], "responses": { "200": { - "description": "Token revoked successfully", - "links": { - "IssueNewToken": { - "operationId": "token", - "description": "Issue a new token after revoking the old one" + "description": "MaintenanceCostCurrency resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceCostCurrency.MaintenanceCurrencyOutput.jsonld-maintenance_cost_currency.read" + } } - } + }, + "links": {} }, "400": { - "description": "Invalid request - missing token parameter", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -30878,12 +33025,6 @@ }, "links": {} }, - "401": { - "description": "Invalid client credentials" - }, - "429": { - "description": "Too many revocation requests" - }, "422": { "description": "An error occurred", "content": { @@ -30925,67 +33066,6 @@ } }, "links": {} - } - }, - "summary": "Revoke Token", - "description": "Revoke an access token or refresh token as defined in RFC 7009.", - "parameters": [], - "requestBody": { - "description": "The new OAuth2 resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenRevocationInput" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenRevocationInput" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenRevocationInput" - } - } - }, - "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/oauth2/userinfo": { - "get": { - "operationId": "userinfo", - "tags": [ - "OAuth2" - ], - "responses": { - "200": { - "description": "User information retrieved successfully", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.UserInfoOutput.jsonld-token.read" - } - } - }, - "links": { - "CheckConsent": { - "operationId": "check_consent", - "description": "Check user consent status for a client" - } - } - }, - "401": { - "description": "Missing or invalid access token" - }, - "403": { - "description": "Access token lacks required scopes" }, "404": { "description": "Not found", @@ -31009,174 +33089,239 @@ "links": {} } }, - "summary": "Get User Information", - "description": "Retrieve claims about the authenticated user as defined by OpenID Connect Core 1.0.", - "parameters": [], - "security": [ + "summary": "Configure cost currency before its first financial fact", + "description": "Configure cost currency before its first financial fact", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "MaintenanceCostCurrency identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } - ] + ], + "requestBody": { + "description": "The updated MaintenanceCostCurrency resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceCostCurrency.ConfigureMaintenanceCurrencyInput-maintenance_cost_currency.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/otp/challenges": { - "post": { - "operationId": "createChallenge", + "/api/organizations/{organizationId}/maintenance-cost/dossiers": { + "get": { + "operationId": "maintenance_economic_dossiers", "tags": [ - "OTP" + "MaintenanceCost" ], "responses": { - "201": { - "description": "OTP challenge created successfully", + "200": { + "description": "Hydra minimal identity page", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Challenge.ChallengeOutput.jsonld" - } - } - }, - "links": { - "GetChallengeStatus": { - "operationId": "getChallengeStatus", - "description": "Check the challenge status", - "parameters": { - "token": "$response.body#/token" - } - }, - "VerifyChallenge": { - "operationId": "verifyChallenge", - "description": "Verify the OTP code", - "parameters": { - "token": "$response.body#/token" - } - }, - "ResendChallenge": { - "operationId": "resendChallenge", - "description": "Resend the OTP if canResendIn is 0", - "parameters": { - "token": "$response.body#/token" + "type": "object", + "description": "MaintenanceEconomic.MaintenanceEconomicDossierOutput.jsonld-maintenance_economic.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceEconomic.MaintenanceEconomicDossierOutput.jsonld-maintenance_economic.read" + } + } + } + } + ] } } } }, - "400": { - "description": "Invalid request parameters", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + "403": { + "description": "Dedicated financial read missing" + }, + "404": { + "description": "Organization outside scope" + } + }, + "summary": "Browse minimal private finance dossier identities", + "description": "Browse minimal private finance dossier identities", + "parameters": [ + { + "name": "search", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "maxLength": 160 }, - "links": {} + "style": "form", + "explode": true }, - "429": { - "description": "Rate limit exceeded" + { + "name": "from", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date" + }, + "style": "form", + "explode": true }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } + { + "name": "to", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date" }, - "links": {} + "style": "form", + "explode": true }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + { + "name": "siteId", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" }, - "links": {} - } - }, - "summary": "Create OTP challenge", - "description": "Creates a new OTP challenge and sends the code via the specified channel. Requires otp_challenges.create permission.", - "parameters": [], - "requestBody": { - "description": "The new Challenge resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Challenge.CreateChallengeInput" - } - } + "style": "form", + "explode": true }, - "required": true - }, - "security": [ { - "bearerAuth": [] + "name": "customerId", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentId", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "page", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceEconomic identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] } }, - "/api/otp/challenges/{token}": { + "/api/organizations/{organizationId}/maintenance-cost/rates": { "get": { - "operationId": "getChallengeStatus", + "operationId": "maintenance_cost_rates_list", "tags": [ - "OTP" + "MaintenanceCost" ], "responses": { "200": { - "description": "Challenge status retrieved", + "description": "MaintenanceCostRate collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Challenge.ChallengeOutput.jsonld" + "type": "object", + "description": "MaintenanceCostRate.MaintenanceRateOutput.jsonld-maintenance_cost_rate.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceCostRate.MaintenanceRateOutput.jsonld-maintenance_cost_rate.read" + } + } + } + } + ] } } - }, - "links": { - "VerifyChallenge": { - "operationId": "verifyChallenge", - "description": "Verify the OTP code if status is pending" - }, - "ResendChallenge": { - "operationId": "resendChallenge", - "description": "Resend the OTP if canResendIn is 0" - } } }, - "404": { - "description": "Challenge not found" - }, "403": { "description": "Forbidden", "content": { @@ -31199,13 +33344,13 @@ "links": {} } }, - "summary": "Get challenge status", - "description": "Returns the current status of an OTP challenge. Requires otp_challenges.read permission.", + "summary": "List private exact hourly rates", + "description": "List private exact hourly rates", "parameters": [ { - "name": "token", + "name": "organizationId", "in": "path", - "description": "Challenge identifier", + "description": "MaintenanceCostRate identifier", "required": true, "deprecated": false, "schema": { @@ -31213,39 +33358,67 @@ }, "style": "simple", "explode": false - } - ], - "security": [ + }, { - "bearerAuth": [] + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "memberId", + "in": "query", + "description": "MaintenanceCostRate memberId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true } ] - } - }, - "/api/otp/challenges/{token}/resend": { + }, "post": { - "operationId": "resendChallenge", + "operationId": "maintenance_cost_rates_create", "tags": [ - "OTP" + "MaintenanceCost" ], "responses": { - "200": { - "description": "OTP resent successfully", + "201": { + "description": "MaintenanceCostRate resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Challenge.ChallengeOutput.jsonld" + "$ref": "#/components/schemas/MaintenanceCostRate.MaintenanceRateOutput.jsonld-maintenance_cost_rate.read" } } }, "links": {} }, - "404": { - "description": "Challenge not found" - }, - "429": { - "description": "Resend cooldown not yet elapsed" - }, "400": { "description": "Invalid input", "content": { @@ -31310,13 +33483,13 @@ "links": {} } }, - "summary": "Resend OTP", - "description": "Resends the OTP code. Subject to cooldown period. Requires otp_challenges.resend permission.", + "summary": "Append an effective hourly rate using a stable client identity", + "description": "Append an effective hourly rate using a stable client identity", "parameters": [ { - "name": "token", + "name": "organizationId", "in": "path", - "description": "Challenge identifier", + "description": "MaintenanceCostRate identifier", "required": true, "deprecated": false, "schema": { @@ -31326,81 +33499,209 @@ "explode": false } ], - "security": [ - { - "bearerAuth": [] - } - ] + "requestBody": { + "description": "The new MaintenanceCostRate resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceCostRate.CreateMaintenanceRateInput-maintenance_cost_rate.write" + } + } + }, + "required": true + } } }, - "/api/otp/challenges/{token}/verify": { - "post": { - "operationId": "verifyChallenge", + "/api/organizations/{organizationId}/maintenance-cost/reports": { + "get": { + "operationId": "maintenance_economic_report", "tags": [ - "OTP" + "MaintenanceCost" ], "responses": { "200": { - "description": "OTP verified successfully", + "description": "Exact full-scope totals and row page", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Challenge.VerifyOtpOutput.jsonld" + "$ref": "#/components/schemas/MaintenanceEconomic.MaintenanceEconomicReportOutput.jsonld-maintenance_economic.read" } } + } + }, + "403": { + "description": "Dedicated financial read missing" + }, + "404": { + "description": "Organization outside scope" + }, + "422": { + "description": "Invalid or excessive window; narrow filters" + } + }, + "summary": "Read exact private maintenance economics with reconciled allocations", + "description": "Read exact private maintenance economics with reconciled allocations", + "parameters": [ + { + "name": "from", + "in": "query", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "format": "date" }, - "links": {} + "style": "form", + "explode": true }, - "400": { - "description": "Invalid OTP code", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + { + "name": "to", + "in": "query", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "format": "date" }, - "links": {} + "style": "form", + "explode": true }, - "404": { - "description": "Challenge not found" + { + "name": "groupBy", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "equipment", + "site", + "customer" + ] + }, + "style": "form", + "explode": true }, - "410": { - "description": "Challenge expired or max attempts reached" + { + "name": "siteId", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true }, - "429": { - "description": "Too many OTP verification attempts" + { + "name": "customerId", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true }, - "422": { - "description": "An error occurred", + { + "name": "equipmentId", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "page", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceEconomic identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/maintenance/schedules": { + "get": { + "operationId": "api_maintenanceschedules_get_collection", + "tags": [ + "MaintenanceSchedule" + ], + "responses": { + "200": { + "description": "MaintenanceSchedule collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "type": "object", + "description": "MaintenanceSchedule.MaintenanceScheduleOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceSchedule.MaintenanceScheduleOutput.jsonld" + } + } + } + } + ] } } - }, - "links": {} + } }, "403": { "description": "Forbidden", @@ -31424,61 +33725,115 @@ "links": {} } }, - "summary": "Verify challenge", - "description": "Verifies the OTP code for a challenge. Requires otp_challenges.verify permission.", + "summary": "Retrieves the collection of MaintenanceSchedule resources.", + "description": "Retrieves the collection of MaintenanceSchedule resources.", "parameters": [ { - "name": "token", - "in": "path", - "description": "Challenge identifier", - "required": true, + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 1 }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The new Challenge resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Challenge.VerifyOtpInput" - } - } + "style": "form", + "explode": true }, - "required": true - }, - "security": [ { - "bearerAuth": [] + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "organization", + "in": "query", + "description": "Organization IRI.", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "facility", + "in": "query", + "description": "Facility IRI filter.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentType", + "in": "query", + "description": "Equipment type filter.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "dueStatus", + "in": "query", + "description": "Due status filter (unscheduled|up_to_date|due_soon|overdue).", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "dueBefore", + "in": "query", + "description": "ISO-8601 upper bound on the next due date.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true } ] } }, - "/api/otp/channels": { + "/api/maintenance/schedules/{id}": { "get": { - "operationId": "listChannels", + "operationId": "api_maintenanceschedules_id_get", "tags": [ - "OTP" + "MaintenanceSchedule" ], "responses": { "200": { - "description": "List of available OTP channels", + "description": "MaintenanceSchedule resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OtpConfig.Config.ChannelOutput.jsonld" + "$ref": "#/components/schemas/MaintenanceSchedule.MaintenanceScheduleOutput.jsonld" } } - }, - "links": { - "CreateChallenge": { - "operationId": "createChallenge", - "description": "Create a new OTP challenge using one of these channels" - } } }, "403": { @@ -31524,62 +33879,42 @@ "links": {} } }, - "summary": "List OTP channels", - "description": "Returns all available OTP delivery channels. Requires otp_config.read permission.", - "parameters": [], - "security": [ + "summary": "Retrieves a MaintenanceSchedule resource.", + "description": "Retrieves a MaintenanceSchedule resource.", + "parameters": [ { - "bearerAuth": [] + "name": "id", + "in": "path", + "description": "MaintenanceSchedule identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] - } - }, - "/api/otp/purposes": { - "get": { - "operationId": "listPurposes", + }, + "patch": { + "operationId": "api_maintenanceschedules_id_patch", "tags": [ - "OTP" + "MaintenanceSchedule" ], "responses": { "200": { - "description": "List of available OTP purposes", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OtpConfig.PurposeOutput.jsonld" - } - } - }, - "links": { - "CreateChallenge": { - "operationId": "createChallenge", - "description": "Create a new OTP challenge using one of these purposes" - } - } - }, - "403": { - "description": "Forbidden", + "description": "MaintenanceSchedule resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/MaintenanceSchedule.MaintenanceScheduleOutput.jsonld" } } }, "links": {} }, - "404": { - "description": "Not found", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -31598,38 +33933,9 @@ } }, "links": {} - } - }, - "summary": "List OTP purposes", - "description": "Returns all available OTP purposes with their default TTL and max attempts configuration. Requires otp_config.read permission.", - "parameters": [], - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/otp/totp/confirm": { - "post": { - "operationId": "confirmTotp", - "tags": [ - "OTP" - ], - "responses": { - "200": { - "description": "TOTP activated successfully", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Totp.ConfirmTotpOutput.jsonld" - } - } - }, - "links": {} }, "422": { - "description": "Invalid or expired verification code", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -31649,17 +33955,8 @@ }, "links": {} }, - "404": { - "description": "No pending TOTP setup found; call setup first" - }, - "429": { - "description": "Too many confirmation attempts" - }, - "409": { - "description": "An active authenticator cannot be replaced by confirming a pending secret" - }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -31679,8 +33976,8 @@ }, "links": {} }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -31701,88 +33998,90 @@ "links": {} } }, - "summary": "Confirm TOTP", - "description": "Verifies a code from the authenticator app against the pending secret and, on success, activates TOTP for the user. An existing active factor cannot be replaced, including when a legacy pending secret exists. Requires otp_totp.confirm permission.", - "parameters": [], + "summary": "Updates the MaintenanceSchedule resource.", + "description": "Updates the MaintenanceSchedule resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "MaintenanceSchedule identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new Totp resource", + "description": "The updated MaintenanceSchedule resource", "content": { - "application/ld+json": { + "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/Totp.ConfirmTotpInput" + "$ref": "#/components/schemas/MaintenanceSchedule.UpdateMaintenanceScheduleInput.jsonMergePatch" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/otp/totp/disable": { + "/api/media": { "post": { - "operationId": "disableTotp", + "operationId": "api_media_post", "tags": [ - "OTP" + "Media" ], "responses": { - "200": { - "description": "TOTP disabled successfully", + "201": { + "description": "Media resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Totp.DisableTotpOutput.jsonld" + "$ref": "#/components/schemas/Media.AttachmentOutput.jsonld" } } }, "links": {} }, - "422": { - "description": "Invalid verification code", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} }, - "404": { - "description": "TOTP is not currently enabled for the user" - }, - "429": { - "description": "Too many disable attempts" - }, - "400": { - "description": "Invalid input", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, @@ -31810,58 +34109,30 @@ "links": {} } }, - "summary": "Disable TOTP", - "description": "Disables TOTP for the authenticated user. Requires a valid current TOTP code as proof of possession. Requires otp_totp.disable permission.", - "parameters": [], - "requestBody": { - "description": "The new Totp resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Totp.DisableTotpInput" - } - } - }, - "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + "summary": "Creates a Media resource.", + "description": "Creates a Media resource.", + "parameters": [] } }, - "/api/otp/totp/setup": { - "post": { - "operationId": "setupTotp", + "/api/media/{id}": { + "get": { + "operationId": "api_media_id_get", "tags": [ - "OTP" + "Media" ], "responses": { - "201": { - "description": "TOTP setup data returned", + "200": { + "description": "Media resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Totp.SetupTotpOutput.jsonld" + "$ref": "#/components/schemas/Media.AttachmentOutput.jsonld" } } - }, - "links": { - "ConfirmTotp": { - "operationId": "confirmTotp", - "description": "Confirm the pending secret with a code from the authenticator app" - } } }, - "401": { - "description": "User must be authenticated" - }, - "409": { - "description": "An active authenticator must first be disabled with its current code" - }, "403": { - "description": "Authenticator enrollment is unavailable for this account", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -31878,10 +34149,11 @@ "$ref": "#/components/schemas/Error" } } - } + }, + "links": {} }, - "400": { - "description": "Invalid input", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -31900,58 +34172,33 @@ } }, "links": {} - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} } }, - "summary": "Setup TOTP", - "description": "Generates a new TOTP secret and stores it server-side as a PENDING enrollment for the authenticated user. Returns the secret and a QR code URI for authenticator app setup. Calling this again replaces the pending secret during initial enrollment. Enrollment requires the account to remain active when its serialized mutation starts. An active factor must first be disabled with its current code; setup refuses replacement. Requires otp_totp.setup permission.", - "parameters": [], - "security": [ + "summary": "Retrieves a Media resource.", + "description": "Retrieves a Media resource.", + "parameters": [ { - "bearerAuth": [] + "name": "id", + "in": "path", + "description": "Media identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] - } - }, - "/api/onboarding/organization": { - "get": { - "operationId": "getOrganizationOnboarding", + }, + "delete": { + "operationId": "api_media_id_delete", "tags": [ - "Onboarding" + "Media" ], "responses": { - "200": { - "description": "Onboarding flow retrieved", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" - } - } - } - }, - "401": { - "description": "Authentication required" + "204": { + "description": "Media resource deleted" }, "403": { "description": "Forbidden", @@ -31996,73 +34243,60 @@ "links": {} } }, - "summary": "Get organization onboarding flow", - "description": "Returns persisted onboarding progression for organization setup and exposes actionable endpoints for each step.", - "parameters": [] + "summary": "Removes the Media resource.", + "description": "Removes the Media resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/onboarding/organization/dismiss": { - "post": { - "operationId": "dismissOrganizationOnboarding", + "/api/conversations/{conversationId}/messages": { + "get": { + "operationId": "api_conversations_conversationIdmessages_get_collection", "tags": [ - "Onboarding" + "Message" ], "responses": { "200": { - "description": "Onboarding activation dismissed", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" - } - } - }, - "links": {} - }, - "401": { - "description": "Authentication required" - }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "422": { - "description": "An error occurred", + "description": "Message collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "type": "object", + "description": "Message.MessageOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + } + } + } + } + ] } } - }, - "links": {} + } }, "403": { "description": "Forbidden", @@ -32086,32 +34320,68 @@ "links": {} } }, - "summary": "Dismiss organization onboarding activation", - "description": "Voluntarily hides the non-blocking activation flow without completing it. Progression is preserved and can be resumed later.", - "parameters": [] - } - }, - "/api/onboarding/organization/resume": { - "post": { - "operationId": "resumeOrganizationOnboarding", - "tags": [ - "Onboarding" + "summary": "Retrieves the collection of Message resources.", + "description": "Retrieves the collection of Message resources.", + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ] + }, + "post": { + "operationId": "api_conversations_conversationIdmessages_post", + "tags": [ + "Message" ], "responses": { - "200": { - "description": "Onboarding activation resumed", + "201": { + "description": "Message resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } }, "links": {} }, - "401": { - "description": "Authentication required" - }, "400": { "description": "Invalid input", "content": { @@ -32176,34 +34446,61 @@ "links": {} } }, - "summary": "Resume organization onboarding activation", - "description": "Clears a previous dismissal so the activation flow and setup checklist become visible again.", - "parameters": [] + "summary": "Creates a Message resource.", + "description": "Creates a Message resource.", + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new Message resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Message.PostMessageInput" + } + } + }, + "required": true + } } }, - "/api/onboarding/organization/rollback": { - "post": { - "operationId": "rollbackOrganizationOnboarding", + "/api/conversations/{conversationId}/messages/{clientId}": { + "put": { + "operationId": "put_message", "tags": [ - "Onboarding" + "Message" ], "responses": { - "200": { - "description": "Onboarding step rolled back", + "201": { + "description": "Message created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } }, "links": {} }, "409": { - "description": "No rollback action available" + "description": "This client identifier was already used" }, - "401": { - "description": "Authentication required" + "428": { + "description": "If-None-Match: * is required" + }, + "412": { + "description": "If-None-Match must be *" }, "400": { "description": "Invalid input", @@ -32267,39 +34564,109 @@ } }, "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Rollback last organization onboarding step", - "description": "Rolls back the last rollbackable onboarding step for the authenticated user.", - "parameters": [] + "summary": "Replaces the Message resource.", + "description": "Replaces the Message resource.", + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "clientId", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated Message resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Message.PostMessageInput" + } + } + }, + "required": true + } } }, - "/api/onboarding/organization/setup-operations": { - "post": { - "operationId": "prepareOrganizationSetup", + "/api/conversations/{conversationId}/pinned-messages": { + "get": { + "operationId": "list_pinned_messages", "tags": [ - "Onboarding" + "Message" ], "responses": { "200": { - "description": "Batch prepared or already prepared", + "description": "Message collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" + "type": "object", + "description": "Message.MessageOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + } + } + } + } + ] } } - }, - "links": {} - }, - "401": { - "description": "Authentication required" - }, - "409": { - "description": "Session, step, item or payload conflict" + } }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -32318,30 +34685,87 @@ } }, "links": {} + } + }, + "summary": "Retrieves the collection of Message resources.", + "description": "Retrieves the collection of Message resources.", + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "422": { - "description": "An error occurred", + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/messages/{id}": { + "delete": { + "operationId": "api_messages_id_delete", + "tags": [ + "Message" + ], + "responses": { + "204": { + "description": "Message resource deleted" + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -32362,43 +34786,40 @@ "links": {} } }, - "summary": "Prepare a durable setup batch", - "description": "Persists up to five item inputs for the current creator step. Repeating a key requires identical input. Owner resource POSTs consume the returned session and item keys atomically.", - "parameters": [], - "requestBody": { - "description": "The new OrganizationOnboarding resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.PrepareOrganizationSetupInput-onboarding.write" - } - } - }, - "required": true - } - } - }, - "/api/onboarding/organization/start": { - "post": { - "operationId": "startOrganizationOnboarding", + "summary": "Removes the Message resource.", + "description": "Removes the Message resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "patch": { + "operationId": "api_messages_id_patch", "tags": [ - "Onboarding" + "Message" ], "responses": { "200": { - "description": "Onboarding flow started", + "description": "Message resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } }, "links": {} }, - "401": { - "description": "Authentication required" - }, "400": { "description": "Invalid input", "content": { @@ -32461,17 +34882,51 @@ } }, "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Start organization onboarding flow", - "description": "Starts or resets the persisted organization onboarding session for the authenticated user.", - "parameters": [], + "summary": "Updates the Message resource.", + "description": "Updates the Message resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new OrganizationOnboarding resource", + "description": "The updated Message resource", "content": { - "application/ld+json": { + "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.StartOrganizationOnboardingInput-onboarding.write" + "$ref": "#/components/schemas/Message.EditMessageInput.jsonMergePatch" } } }, @@ -32479,26 +34934,26 @@ } } }, - "/api/onboarding/organization/steps/{stepKey}/execute": { + "/api/messages/{id}/pin": { "post": { - "operationId": "executeOrganizationOnboardingStep", + "operationId": "pin_message", "tags": [ - "Onboarding" + "Message" ], "responses": { "200": { - "description": "Onboarding step executed", + "description": "Message resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } }, "links": {} }, "400": { - "description": "Invalid step payload", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -32518,12 +34973,6 @@ }, "links": {} }, - "409": { - "description": "Step not available in current flow state" - }, - "401": { - "description": "Authentication required" - }, "422": { "description": "An error occurred", "content": { @@ -32567,13 +35016,13 @@ "links": {} } }, - "summary": "Execute organization onboarding step", - "description": "Executes the requested onboarding step and updates persisted flow state.", + "summary": "Creates a Message resource.", + "description": "Creates a Message resource.", "parameters": [ { - "name": "stepKey", + "name": "id", "in": "path", - "description": "OrganizationOnboarding identifier", + "description": "Message identifier", "required": true, "deprecated": false, "schema": { @@ -32582,40 +35031,98 @@ "style": "simple", "explode": false } + ] + }, + "delete": { + "operationId": "unpin_message", + "tags": [ + "Message" ], - "requestBody": { - "description": "The new OrganizationOnboarding resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.ExecuteOrganizationOnboardingStepInput-onboarding.write" + "responses": { + "204": { + "description": "Message resource deleted" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } } - } + }, + "links": {} }, - "required": true - } + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Removes the Message resource.", + "description": "Removes the Message resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/onboarding/organization/steps/{stepKey}/skip": { + "/api/messages/{id}/reactions": { "post": { - "operationId": "skipOrganizationOnboardingStep", + "operationId": "add_reaction", "tags": [ - "Onboarding" + "Message" ], "responses": { "200": { - "description": "Onboarding step skipped", + "description": "Message resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } }, "links": {} }, "400": { - "description": "Invalid or required step", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -32635,12 +35142,6 @@ }, "links": {} }, - "409": { - "description": "Step not available to skip in current flow state" - }, - "401": { - "description": "Authentication required" - }, "422": { "description": "An error occurred", "content": { @@ -32684,13 +35185,13 @@ "links": {} } }, - "summary": "Skip organization onboarding step", - "description": "Voluntarily skips a non-required onboarding step, allowing the flow to advance to completion without executing it.", + "summary": "Creates a Message resource.", + "description": "Creates a Message resource.", "parameters": [ { - "name": "stepKey", + "name": "id", "in": "path", - "description": "OrganizationOnboarding identifier", + "description": "Message identifier", "required": true, "deprecated": false, "schema": { @@ -32699,23 +35200,117 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The new Message resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Message.AddReactionInput" + } + } + }, + "required": true + } } }, - "/api/organizations": { + "/api/messages/{id}/reactions/{emoji}": { + "delete": { + "operationId": "remove_reaction", + "tags": [ + "Message" + ], + "responses": { + "204": { + "description": "Message resource deleted" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Removes the Message resource.", + "description": "Removes the Message resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "emoji", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/messages/{id}/replies": { "get": { - "operationId": "listUserOrganizations", + "operationId": "list_replies", "tags": [ - "Organization" + "Message" ], "responses": { "200": { - "description": "Organization collection", + "description": "Message collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Organization.OrganizationOutput.jsonld-Organization.read collection.", + "description": "Message.MessageOutput.jsonld collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -32729,7 +35324,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } } @@ -32761,9 +35356,21 @@ "links": {} } }, - "summary": "List user Organizations", - "description": "Lists Organizations where the authenticated user is a member.", + "summary": "Retrieves the collection of Message resources.", + "description": "Retrieves the collection of Message resources.", "parameters": [ + { + "name": "id", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "page", "in": "query", @@ -32791,46 +35398,26 @@ }, "style": "form", "explode": true - }, - { - "name": "status", - "in": "query", - "description": "Filter by organization status.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "active", - "suspended", - "archived" - ] - }, - "style": "form", - "explode": true } ] }, "post": { - "operationId": "createOrganization", + "operationId": "post_reply", "tags": [ - "Organization" + "Message" ], "responses": { "201": { - "description": "Organization created", + "description": "Message resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } }, "links": {} }, - "401": { - "description": "Authentication required" - }, "400": { "description": "Invalid input", "content": { @@ -32895,15 +35482,28 @@ "links": {} } }, - "summary": "Create Organization", - "description": "Creates a Organization and assigns the creator as owner.", - "parameters": [], + "summary": "Creates a Message resource.", + "description": "Creates a Message resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new Organization resource", + "description": "The new Message resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Organization.CreateOrganizationInput-Organization.write" + "$ref": "#/components/schemas/Message.PostReplyInput" } } }, @@ -32911,48 +35511,26 @@ } } }, - "/api/organizations/legal-types": { - "get": { - "operationId": "listOrganizationLegalTypes", + "/api/messages/{id}/save": { + "post": { + "operationId": "save_message", "tags": [ - "Organization" + "Message" ], "responses": { "200": { - "description": "Organization legal types retrieved", + "description": "Message resource created", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "OrganizationLegalType.OrganizationLegalTypeOptionOutput.jsonld-Organization.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OrganizationLegalType.OrganizationLegalTypeOptionOutput.jsonld-Organization.read" - } - } - } - } - ] + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } - } - }, - "401": { - "description": "Authentication required" + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -32971,67 +35549,30 @@ } }, "links": {} - } - }, - "summary": "List organization legal types", - "description": "Returns organization legal entity type values for the Legal profile settings tab select.", - "parameters": [ - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - } - ] - } - }, - "/api/organizations/{id}": { - "get": { - "operationId": "getOrganization", - "tags": [ - "Organization" - ], - "responses": { - "200": { - "description": "Organization resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" - } - } - } }, - "403": { - "description": "Forbidden", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} }, - "404": { - "description": "Not found", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -33052,13 +35593,13 @@ "links": {} } }, - "summary": "Get Organization", - "description": "Returns Organization details if the user has Organization.read permission in that Organization.", + "summary": "Creates a Message resource.", + "description": "Creates a Message resource.", "parameters": [ { "name": "id", "in": "path", - "description": "Organization identifier", + "description": "Message identifier", "required": true, "deprecated": false, "schema": { @@ -33070,72 +35611,16 @@ ] }, "delete": { - "operationId": "deleteOrganization", + "operationId": "unsave_message", "tags": [ - "Organization" + "Message" ], "responses": { "204": { - "description": "Organization archived" + "description": "Message resource deleted" }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization not found" - }, - "422": { - "description": "Missing or mismatched slug confirmation" - } - }, - "summary": "Archive Organization", - "description": "Archives the organization (reversible soft delete \u2014 NOT a permanent removal): it is hidden from the default listing and its owned data (facilities, equipment, inspections, interventions) is preserved rather than orphaned, and can be restored through the settings PATCH (isActive: true). Requires the organization.delete permission plus a danger-zone confirmation: the \"slug\" query parameter must exactly match the organization's current slug (case-insensitive, trimmed). A missing or mismatched confirmation is rejected with HTTP 422 and nothing is archived. Idempotent when already archived, provided the confirmation is still correct.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Organization identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "slug", - "in": "query", - "description": "Danger-zone confirmation: the organization's current slug, typed by the caller.", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - } - ] - }, - "patch": { - "operationId": "updateOrganizationSettings", - "tags": [ - "Organization" - ], - "responses": { - "200": { - "description": "Organization updated", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid request - validation failed", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -33155,44 +35640,35 @@ }, "links": {} }, - "409": { - "description": "Slug already in use" - }, - "403": { - "description": "Insufficient permissions" - }, "404": { - "description": "Organization not found" - }, - "422": { - "description": "An error occurred", + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Update Organization settings", - "description": "Updates organization settings \u2014 general & branding (name, slug, description, active status) plus the notifications and regional sections. Requires the organization.settings.write permission.", + "summary": "Removes the Message resource.", + "description": "Removes the Message resource.", "parameters": [ { "name": "id", "in": "path", - "description": "Organization identifier", + "description": "Message identifier", "required": true, "deprecated": false, "schema": { @@ -33201,40 +35677,48 @@ "style": "simple", "explode": false } - ], - "requestBody": { - "description": "The updated Organization resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Organization.UpdateOrganizationSettingsInput-Organization.write.jsonMergePatch" - } - } - }, - "required": true - } + ] } }, - "/api/organizations/{id}/plan": { - "patch": { - "operationId": "changeOrganizationPlan", + "/api/saved-messages": { + "get": { + "operationId": "list_saved_messages", "tags": [ - "Organization" + "Message" ], "responses": { "200": { - "description": "Organization plan updated", + "description": "Message collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" + "type": "object", + "description": "Message.MessageOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + } + } + } + } + ] } } - }, - "links": {} + } }, - "400": { - "description": "Invalid request - plan not selectable", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -33253,90 +35737,161 @@ } }, "links": {} - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization or plan not found" - }, - "409": { - "description": "Paid plan requires the billing checkout flow, or current usage exceeds the selected plan limits (confirm with acknowledgeOveruse)" - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} } }, - "summary": "Change Organization plan", - "description": "Assigns a subscription plan to the organization. Self-service \u2014 requires the organization.settings.write permission. When the target plan's caps sit below the current usage, the change is refused with HTTP 409 listing the exceeded resources; re-submit with acknowledgeOveruse: true to confirm the downgrade.", + "summary": "Retrieves the collection of Message resources.", + "description": "Retrieves the collection of Message resources.", "parameters": [ { - "name": "id", - "in": "path", - "description": "Organization identifier", + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "organization", + "in": "query", + "description": "Organization IRI.", "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true } - ], - "requestBody": { - "description": "The updated Organization resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Organization.ChangeOrganizationPlanInput-Organization.write.jsonMergePatch" - } - } - }, - "required": true - } + ] } }, - "/api/organizations/{id}/restore": { - "post": { - "operationId": "restoreOrganization", + "/api/conversations/{conversationId}/attachments": { + "get": { + "operationId": "list_conversation_attachments", "tags": [ - "Organization" + "Messaging" ], "responses": { "200": { - "description": "Organization restored \u2014 refreshed organization output returned", + "description": "Attachments retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" - } - } - }, - "links": {} + "type": "object", + "description": "MessagingAttachment.MessageAttachmentOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessagingAttachment.MessageAttachmentOutput.jsonld" + } + } + } + } + ] + } + } + } }, "403": { - "description": "Insufficient permissions \u2014 and, for an archived organization, held by every caller who is not a platform administrator" + "description": "Insufficient permissions" }, "404": { - "description": "Organization not found" + "description": "Conversation not found" + } + }, + "summary": "List a conversation's attachments (Files tab)", + "description": "Lists the attachments posted in a conversation, most recently uploaded first.", + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "MessagingAttachment identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/messages/{messageId}/attachments": { + "post": { + "operationId": "add_message_attachment", + "tags": [ + "Messaging" + ], + "responses": { + "201": { + "description": "Attachment uploaded", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MessagingAttachment.MessageAttachmentOutput.jsonld" + } + } + }, + "links": {} }, "400": { "description": "Invalid input", @@ -33360,7 +35915,7 @@ "links": {} }, "422": { - "description": "An error occurred", + "description": "MIME type, size, or message state rejected", "content": { "application/ld+json": { "schema": { @@ -33379,15 +35934,62 @@ } }, "links": {} + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Message not found" } }, - "summary": "Restore Organization", - "description": "Restores the organization to ACTIVE from SUSPENDED or ARCHIVED, as an explicit, dedicated action \u2014 coexists with (does not replace) the legacy isActive: true toggle on PATCH /organizations/{id}. Requires the organization.settings.write permission, the SAME permission the legacy toggle already requires \u2014 OR platform administrator privileges, which bypass it. The bypass is not a convenience: an ARCHIVED organization refuses organization.settings.write like every other write, so a platform administrator is the only caller who can reopen one. A SUSPENDED organization keeps its self-service path. Idempotent when already active.", + "summary": "Upload a message attachment", + "description": "Uploads a multipart file attachment to a message.", + "parameters": [ + { + "name": "messageId", + "in": "path", + "description": "MessagingAttachment identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/messaging-attachments/{id}": { + "delete": { + "operationId": "delete_message_attachment", + "tags": [ + "Messaging" + ], + "responses": { + "204": { + "description": "Attachment deleted" + }, + "428": { + "description": "If-Match header is required" + }, + "412": { + "description": "Attachment revision is stale" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Attachment not found" + } + }, + "summary": "Delete a message attachment", + "description": "Delete a message attachment", "parameters": [ { "name": "id", "in": "path", - "description": "Organization identifier", + "description": "MessagingAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -33399,83 +36001,110 @@ ] } }, - "/api/organizations/{id}/suspend": { - "post": { - "operationId": "suspendOrganization", + "/api/messaging-attachments/{id}/content": { + "get": { + "operationId": "download_message_attachment", "tags": [ - "Organization" + "Messaging" ], "responses": { "200": { - "description": "Organization suspended \u2014 refreshed organization output returned", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" - } - } - }, - "links": {} + "description": "Attachment file content" }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Organization not found" - }, - "409": { - "description": "Organization is archived \u2014 restore it first" + "description": "Attachment (or its stored content) not found" }, - "400": { - "description": "Invalid input", + "204": { + "description": "MessagingAttachmentContent resource" + } + }, + "summary": "Download a message attachment", + "description": "Streams the stored file bytes with Content-Disposition: attachment. Gated by the same access rule as reading the owning conversation.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "MessagingAttachmentContent identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/conversations/{conversationId}/links": { + "get": { + "operationId": "list_conversation_links", + "tags": [ + "MessagingLink" + ], + "responses": { + "200": { + "description": "MessagingLink collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "type": "object", + "description": "MessagingLink.MessagingLinkOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessagingLink.MessagingLinkOutput.jsonld" + } + } + } + } + ] } } - }, - "links": {} + } }, - "422": { - "description": "An error occurred", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Suspend Organization", - "description": "Suspends the organization as an explicit, dedicated action \u2014 coexists with (does not replace) the legacy isActive: false toggle on PATCH /organizations/{id}. Requires the organization.settings.write permission, the SAME permission the legacy toggle already requires. Idempotent when already suspended.", + "summary": "Retrieves the collection of MessagingLink resources.", + "description": "Retrieves the collection of MessagingLink resources.", "parameters": [ { - "name": "id", + "name": "conversationId", "in": "path", - "description": "Organization identifier", + "description": "MessagingLink identifier", "required": true, "deprecated": false, "schema": { @@ -33483,60 +36112,80 @@ }, "style": "simple", "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true } ] } }, - "/api/organizations/{id}/transfer-ownership": { - "post": { - "operationId": "transferOrganizationOwnership", + "/api/notification-types": { + "get": { + "operationId": "notification_types_list", "tags": [ - "Organization" + "Notification" ], "responses": { "200": { - "description": "Ownership transferred \u2014 refreshed organization output returned", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" - } - } - }, - "links": {} - }, - "403": { - "description": "Caller is not the organization's current owner" - }, - "404": { - "description": "Organization not found, or the target user is not an active member" - }, - "422": { - "description": "Missing or mismatched slug confirmation", + "description": "Notification types retrieved successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "type": "object", + "description": "NotificationType.NotificationTypeOutput.jsonld-notification_type.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationType.NotificationTypeOutput.jsonld-notification_type.read" + } + } + } + } + ] } } - }, - "links": {} + } }, - "409": { - "description": "Organization is archived, or the target user already owns the organization" + "401": { + "description": "Authentication required" }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -33557,49 +36206,44 @@ "links": {} } }, - "summary": "Transfer Organization ownership", - "description": "Transfers ownership of the organization to another active member. Only the organization's CURRENT owner may call this \u2014 independent of RBAC permissions, since no permission grants the right to give away someone else's ownership. Requires a danger-zone confirmation: \"slug\" must exactly match the organization's current slug (case-insensitive, trimmed), mirroring DELETE /organizations/{id}. The new owner automatically receives the organization's system \"admin\" role if they do not already hold it.", + "summary": "List notification types", + "description": "Returns all known notification types with their categories. Useful for building type selector components on the frontend.", "parameters": [ { - "name": "id", - "in": "path", - "description": "Organization identifier", - "required": true, + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 1 }, - "style": "simple", - "explode": false + "style": "form", + "explode": true } ], - "requestBody": { - "description": "The new Organization resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Organization.TransferOrganizationOwnershipInput-Organization.write" - } - } - }, - "required": true - } + "security": [ + { + "bearerAuth": [] + } + ] } }, - "/api/organizations/{organizationId}/audit-events": { + "/api/notifications": { "get": { - "operationId": "listOrganizationAuditEvents", + "operationId": "notifications_list", "tags": [ - "Organization" + "Notification" ], "responses": { "200": { - "description": "OrganizationAuditEvent collection", + "description": "Notifications retrieved successfully", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "OrganizationAuditEvent.OrganizationAuditEventOutput.jsonld-Organization.read collection.", + "description": "Notification.NotificationOutput.jsonld-notification.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -33613,7 +36257,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/OrganizationAuditEvent.OrganizationAuditEventOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Notification.NotificationOutput.jsonld-notification.read" } } } @@ -33623,6 +36267,9 @@ } } }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -33645,21 +36292,9 @@ "links": {} } }, - "summary": "List organization audit events", - "description": "Returns the organization's slice of the audit ledger, newest first, for the activity feed. Requires the `organization.audit.read` permission in the target organization (held by admins through `organization.*`). The payload is deliberately narrower than the platform-level audit API: actor email, IP address, user agent, and ledger chain internals are never exposed here, regardless of platform PII settings, and metadata keys carrying PII or request context are stripped.", + "summary": "List notifications", + "description": "Returns a paginated collection of notifications for the authenticated user. Read notifications from low-value categories may be omitted from the default list after a retention delay.", "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationAuditEvent identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, { "name": "page", "in": "query", @@ -33681,7 +36316,7 @@ "deprecated": false, "schema": { "type": "integer", - "default": 30, + "default": 20, "minimum": 0, "maximum": 100 }, @@ -33689,150 +36324,195 @@ "explode": true }, { - "name": "action", + "name": "unreadOnly", "in": "query", - "description": "Optional exact audit action filter (e.g. organization.member_added).", + "description": "When true, returns only unread notifications.", "required": false, "deprecated": false, "schema": { - "type": "string", - "example": "organization.member_added" + "type": "boolean" }, "style": "form", "explode": true }, { - "name": "from", + "name": "type", "in": "query", - "description": "Optional inclusive ISO 8601 lower bound on the occurrence datetime.", + "description": "Filter by exact notification type (e.g. `organization.invitation`). See `GET /notification-types` for the full list.", "required": false, "deprecated": false, "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" + "type": "string" }, "style": "form", "explode": true }, { - "name": "to", + "name": "category", "in": "query", - "description": "Optional inclusive ISO 8601 upper bound on the occurrence datetime.", + "description": "Filter by category prefix (e.g. `organization`, `system`). Ignored when `type` is also provided.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "organization", + "in": "query", + "description": "Filter by organization identifier. When omitted, notifications across all organizations are returned, including account-level notifications that have no organization.", "required": false, "deprecated": false, "schema": { "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" + "format": "uuid" }, "style": "form", "explode": true } + ], + "security": [ + { + "bearerAuth": [] + } ] } }, - "/api/organizations/{organizationId}/audit-events/export": { + "/api/notifications/preferences": { "get": { - "operationId": "exportOrganizationAuditEvents", + "operationId": "notification_preferences_get", "tags": [ - "Organization" + "Notification" ], "responses": { "200": { - "description": "CSV stream" + "description": "Notification preferences retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Notification.NotificationPreferencesOutput.jsonld-notification.preferences.read" + } + } + } }, - "400": { - "description": "Malformed date filter" + "401": { + "description": "Authentication required" }, "403": { - "description": "Member without the organization.audit.export permission" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "Organization not found, or the caller is not an active member" - }, - "422": { - "description": "The filters match more rows than the export cap" - }, - "204": { - "description": "OrganizationAuditEvent resource" - } - }, - "summary": "Export organization audit events (CSV)", - "description": "Streams the organization's slice of the audit ledger as CSV, using the same filters as the list endpoint. Requires the `organization.audit.export` permission, which is deliberately separate from `organization.audit.read`: reading keeps the data inside the product, exporting takes a file out. The organization is taken from the URI and is not a filter the caller can widen \u2014 unlike the platform-level `/audit-events/export`, which builds its criteria from the request and is therefore reserved to platform operators. The columns match what the read endpoint exposes: no actor email, no IP address, no user agent, no ledger chain internals.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationAuditEvent identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "action", - "in": "query", - "description": "Optional exact audit action filter (e.g. organization.member_added).", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "organization.member_added" - }, - "style": "form", - "explode": true - }, - { - "name": "from", - "in": "query", - "description": "Optional inclusive ISO 8601 lower bound on the occurrence datetime.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true - }, + "links": {} + } + }, + "summary": "Get notification preferences", + "description": "Returns the authenticated user's customized per-category delivery preferences. A category with no entry is enabled on every channel (email and Mercure); only explicit customizations are stored.", + "parameters": [], + "security": [ { - "name": "to", - "in": "query", - "description": "Optional inclusive ISO 8601 upper bound on the occurrence datetime.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" - }, - "style": "form", - "explode": true + "bearerAuth": [] } ] - } - }, - "/api/organizations/{organizationId}/dashboard": { - "get": { - "operationId": "getOrganizationDashboard", + }, + "patch": { + "operationId": "notification_preferences_update", "tags": [ - "Organization" + "Notification" ], "responses": { "200": { - "description": "OrganizationDashboard resource", + "description": "Notification preferences updated successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationDashboard.OrganizationDashboardOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Notification.NotificationPreferencesOutput.jsonld-notification.preferences.read" } } - } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} }, "403": { "description": "Forbidden", @@ -33877,253 +36557,177 @@ "links": {} } }, - "summary": "Get organization dashboard", - "description": "Returns lightweight organization-overview KPI data. `overview`, `alerts`, and non-`period*` health KPIs are current snapshots evaluated at `generatedAt`; `comparison` and `period*` health KPIs follow the requested period. `overview` is normalized for frontend consumption and exposes only stable keys and `summary` metric lists. `alerts` expose machine-readable `code`/`severity`/`count`, `health` exposes an iterable `metrics` list, and `comparison.metrics` exposes stable KPI cards for inspections, facilities, members, and equipment with `key`/`label`/`value` plus raw `current`/`previous`/`delta` values. Those cards represent period-over-period changes for inspections performed, facilities created, members joined, and equipment created. Legacy non-conformity opened and resolved comparison entries remain present for backward compatibility. `comparison.health.metrics` exposes percentage-based health deltas. Analytics filters are section-scoped: `facilityType` affects facilities overview and facility comparison cards, `equipmentType` and `equipmentStatus` affect equipment overview and equipment comparison cards, `inspection*` filters affect inspection KPIs and period comparisons, and `nonConformity*` filters affect non-conformity KPIs, alerts, and period comparisons. `overview.nonConformities.severityLow`/`severityMedium`/`severityHigh`/`severityCritical` are the sole exception: an org-wide, unfiltered current-snapshot breakdown by severity across every status, unaffected by `nonConformityStatus`/`nonConformitySeverity`. By default, the endpoint returns the latest 30-day window in UTC and compares it with the previous period. The maximum supported period is 366 days. Datetime bounds must be ISO 8601 strings with an explicit timezone offset, including optional microseconds. Access requires `organization.dashboard.read` plus the underlying read permissions for members, roles, facilities, equipment, and inspections. For chart or sparkline data, use the dedicated `/dashboard/trends/*` endpoints, which require `organization.inspection.read`.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationDashboard identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "from", - "in": "query", - "description": "Inclusive ISO 8601 datetime lower bound for the period-scoped parts of the dashboard, with an explicit timezone offset. Optional microseconds are preserved. If omitted, defaults to 30 days before \"to\" or now. Maximum supported period: 366 days.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" - }, - "style": "form", - "explode": true - }, - { - "name": "to", - "in": "query", - "description": "Inclusive ISO 8601 datetime upper bound for the period-scoped parts of the dashboard, with an explicit timezone offset. Optional microseconds are preserved. If omitted, defaults to the current datetime. Maximum supported period: 366 days.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" - }, - "style": "form", - "explode": true - }, - { - "name": "compare", - "in": "query", - "description": "Whether to include previous-period scalar comparison metrics for KPI cards. Defaults to true.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": true, - "example": true - }, - "style": "form", - "explode": true - }, - { - "name": "timezone", - "in": "query", - "description": "IANA timezone used for rendered period values. Defaults to UTC when omitted and not implied by the request bounds. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "Europe/Paris" - }, - "style": "form", - "explode": true + "summary": "Update notification preferences", + "description": "Upserts one or more per-category delivery preferences for the authenticated user and returns the full customized set. This suppresses email/Mercure delivery only \u2014 matching notifications are still persisted and remain visible in the in-app list.", + "parameters": [], + "requestBody": { + "description": "The updated Notification resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Notification.UpdateNotificationPreferencesInput-notification.preferences.write.jsonMergePatch" + } + } }, + "required": true + }, + "security": [ { - "name": "facilityType", - "in": "query", - "description": "Optional facility type filter applied to the facilities section only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ], - "example": "site" + "bearerAuth": [] + } + ] + } + }, + "/api/notifications/read-all": { + "patch": { + "operationId": "notification_mark_all_as_read", + "tags": [ + "Notification" + ], + "responses": { + "200": { + "description": "Notifications marked as read", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Notification.MarkAllNotificationsAsReadOutput.jsonld-notification.mark_all_as_read" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "equipmentType", - "in": "query", - "description": "Optional equipment type filter applied to the equipment section only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "fire_extinguisher", - "smoke_detector", - "heat_detector", - "sprinkler", - "fire_alarm_panel", - "hydrant", - "fire_door", - "emergency_lighting", - "access_control", - "camera", - "gas_detector", - "other" - ], - "example": "fire_extinguisher" - }, - "style": "form", - "explode": true + "401": { + "description": "Authentication required" }, - { - "name": "equipmentStatus", - "in": "query", - "description": "Optional equipment status filter applied to the equipment section only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "in_stock", - "operational", - "under_maintenance", - "decommissioned" - ], - "example": "operational" + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "inspectionStatus", - "in": "query", - "description": "Optional inspection status filter applied to inspection KPIs, period health, and inspection comparisons.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "draft", - "submitted", - "closed" - ], - "example": "closed" + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "inspectionResult", - "in": "query", - "description": "Optional inspection result filter applied to inspection KPIs, period health, and inspection comparisons.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "pass", - "fail", - "partial" - ], - "example": "pass" + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "inspectorType", - "in": "query", - "description": "Optional inspector type filter applied to inspection KPIs, period health, and inspection comparisons.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "user", - "external" - ], - "example": "user" + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true - }, + "links": {} + } + }, + "summary": "Mark all notifications as read", + "description": "Marks every unread notification of the authenticated user as read. Idempotent: calling it again once everything is read affects zero notifications.", + "parameters": [ { - "name": "nonConformityStatus", + "name": "organization", "in": "query", - "description": "Optional non-conformity status filter applied to non-conformity KPIs, alerts, period health, and non-conformity comparisons.", + "description": "Filter by organization identifier. When omitted, notifications across all organizations (and account-level ones) are marked as read.", "required": false, "deprecated": false, "schema": { "type": "string", - "enum": [ - "open", - "in_progress", - "done", - "waived" - ], - "example": "open" + "format": "uuid" }, "style": "form", "explode": true - }, + } + ], + "security": [ { - "name": "nonConformitySeverity", - "in": "query", - "description": "Optional non-conformity severity filter applied to non-conformity KPIs, alerts, period health, and non-conformity comparisons.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "low", - "medium", - "high", - "critical" - ], - "example": "critical" - }, - "style": "form", - "explode": true + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/dashboard/trends/equipment-created": { + "/api/notifications/subscription": { "get": { - "operationId": "getOrganizationDashboardEquipmentCreatedTrend", + "operationId": "notification_mercure_subscription", "tags": [ - "Organization" + "Notification" ], "responses": { "200": { - "description": "OrganizationDashboardTrend resource", + "description": "Mercure subscription token returned successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Notification.MercureSubscriptionOutput.jsonld-notification.mercure_subscription" } } } }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -34167,162 +36771,36 @@ "links": {} } }, - "summary": "Get equipment created trend", - "description": "Returns the equipment-created trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.equipment.read`.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationDashboardTrend identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, + "summary": "Get Mercure subscription token", + "description": "Returns a Mercure subscriber JWT and the private SSE topic for the authenticated user. The client must pass this token in the Authorization header (Bearer) when opening an EventSource connection to the Mercure hub.", + "parameters": [], + "security": [ { - "name": "from", - "in": "query", - "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" - }, - "style": "form", - "explode": true - }, - { - "name": "to", - "in": "query", - "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" - }, - "style": "form", - "explode": true - }, - { - "name": "compare", - "in": "query", - "description": "Whether to include previous-period comparison series. Defaults to true.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": true, - "example": true - }, - "style": "form", - "explode": true - }, - { - "name": "granularity", - "in": "query", - "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "day", - "week", - "month", - "auto" - ], - "default": "day", - "example": "week" - }, - "style": "form", - "explode": true - }, - { - "name": "timezone", - "in": "query", - "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "Europe/Paris" - }, - "style": "form", - "explode": true - }, - { - "name": "equipmentType", - "in": "query", - "description": "Optional equipment type filter applied to the equipment-created trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "fire_extinguisher", - "smoke_detector", - "heat_detector", - "sprinkler", - "fire_alarm_panel", - "hydrant", - "fire_door", - "emergency_lighting", - "access_control", - "camera", - "gas_detector", - "other" - ], - "example": "fire_extinguisher" - }, - "style": "form", - "explode": true - }, - { - "name": "equipmentStatus", - "in": "query", - "description": "Optional equipment status filter applied to the equipment-created trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "in_stock", - "operational", - "under_maintenance", - "decommissioned" - ], - "example": "operational" - }, - "style": "form", - "explode": true + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/dashboard/trends/facilities-created": { + "/api/notifications/unread-count": { "get": { - "operationId": "getOrganizationDashboardFacilitiesCreatedTrend", + "operationId": "notification_unread_count", "tags": [ - "Organization" + "Notification" ], "responses": { "200": { - "description": "OrganizationDashboardTrend resource", + "description": "Unread notification count returned successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Notification.UnreadNotificationsCountOutput.jsonld-notification.unread_count" } } } }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -34366,136 +36844,53 @@ "links": {} } }, - "summary": "Get facilities created trend", - "description": "Returns the facilities-created trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.facilities.read`.", + "summary": "Get unread notifications count", + "description": "Returns the unread notification count for the authenticated user.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationDashboardTrend identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "from", - "in": "query", - "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" - }, - "style": "form", - "explode": true - }, - { - "name": "to", - "in": "query", - "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" - }, - "style": "form", - "explode": true - }, - { - "name": "compare", - "in": "query", - "description": "Whether to include previous-period comparison series. Defaults to true.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": true, - "example": true - }, - "style": "form", - "explode": true - }, - { - "name": "granularity", - "in": "query", - "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "day", - "week", - "month", - "auto" - ], - "default": "day", - "example": "week" - }, - "style": "form", - "explode": true - }, - { - "name": "timezone", + "name": "organization", "in": "query", - "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", + "description": "Filter by organization identifier. When omitted, unread notifications across all organizations (and account-level ones) are counted.", "required": false, "deprecated": false, "schema": { "type": "string", - "example": "Europe/Paris" + "format": "uuid" }, "style": "form", "explode": true - }, + } + ], + "security": [ { - "name": "facilityType", - "in": "query", - "description": "Optional facility type filter applied to the facilities-created trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ], - "example": "site" - }, - "style": "form", - "explode": true + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/dashboard/trends/inspections": { + "/api/notifications/{id}": { "get": { - "operationId": "getOrganizationDashboardInspectionsTrend", + "operationId": "notification_get", "tags": [ - "Organization" + "Notification" ], "responses": { "200": { - "description": "OrganizationDashboardTrend resource", + "description": "Notification retrieved successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Notification.NotificationOutput.jsonld-notification.read" } } } }, + "404": { + "description": "Notification not found" + }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -34516,36 +36911,15 @@ } }, "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "Get inspections trend", - "description": "Returns the inspections-performed trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.inspection.read`.", + "summary": "Get notification", + "description": "Returns a notification owned by the authenticated user.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "OrganizationDashboardTrend identifier", + "description": "Notification identifier", "required": true, "deprecated": false, "schema": { @@ -34553,157 +36927,41 @@ }, "style": "simple", "explode": false - }, - { - "name": "from", - "in": "query", - "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" - }, - "style": "form", - "explode": true - }, - { - "name": "to", - "in": "query", - "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" - }, - "style": "form", - "explode": true - }, - { - "name": "compare", - "in": "query", - "description": "Whether to include previous-period comparison series. Defaults to true.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": true, - "example": true - }, - "style": "form", - "explode": true - }, - { - "name": "granularity", - "in": "query", - "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "day", - "week", - "month", - "auto" - ], - "default": "day", - "example": "week" - }, - "style": "form", - "explode": true - }, - { - "name": "timezone", - "in": "query", - "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "Europe/Paris" - }, - "style": "form", - "explode": true - }, - { - "name": "inspectionStatus", - "in": "query", - "description": "Optional inspection status filter applied to the inspection trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "draft", - "submitted", - "closed" - ], - "example": "closed" - }, - "style": "form", - "explode": true - }, - { - "name": "inspectionResult", - "in": "query", - "description": "Optional inspection result filter applied to the inspection trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "pass", - "fail", - "partial" - ], - "example": "pass" - }, - "style": "form", - "explode": true - }, + } + ], + "security": [ { - "name": "inspectorType", - "in": "query", - "description": "Optional inspector type filter applied to the inspection trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "user", - "external" - ], - "example": "user" - }, - "style": "form", - "explode": true + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/dashboard/trends/non-conformities-opened": { - "get": { - "operationId": "getOrganizationDashboardNonConformitiesOpenedTrend", + "/api/notifications/{id}/read": { + "patch": { + "operationId": "notification_mark_as_read", "tags": [ - "Organization" + "Notification" ], "responses": { "200": { - "description": "OrganizationDashboardTrend resource", + "description": "Notification marked as read", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Notification.NotificationOutput.jsonld-notification.read" } } - } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "404": { + "description": "Notification not found" + }, + "401": { + "description": "Authentication required" + }, + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -34723,8 +36981,29 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -34745,13 +37024,13 @@ "links": {} } }, - "summary": "Get non-conformities opened trend", - "description": "Returns the non-conformities-opened trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.inspection.read`.", + "summary": "Mark notification as read", + "description": "Marks one notification as read for the authenticated user.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "OrganizationDashboardTrend identifier", + "description": "Notification identifier", "required": true, "deprecated": false, "schema": { @@ -34759,155 +37038,132 @@ }, "style": "simple", "explode": false - }, + } + ], + "security": [ { - "name": "from", - "in": "query", - "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" + "bearerAuth": [] + } + ] + } + }, + "/api/.well-known/jwks.json": { + "get": { + "operationId": "jwks", + "tags": [ + "OAuth2" + ], + "responses": { + "200": { + "description": "JWKS returned successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Discovery.JwksOutput-token.read" + } + } }, - "style": "form", - "explode": true + "links": { + "GetOpenIDConfig": { + "operationId": "openid_configuration", + "description": "Get the full OpenID Connect configuration" + }, + "IntrospectToken": { + "operationId": "introspect_token", + "description": "Introspect a token to validate it server-side" + } + } }, - { - "name": "to", - "in": "query", - "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" - }, - "style": "form", - "explode": true - }, - { - "name": "compare", - "in": "query", - "description": "Whether to include previous-period comparison series. Defaults to true.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": true, - "example": true - }, - "style": "form", - "explode": true - }, - { - "name": "granularity", - "in": "query", - "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "day", - "week", - "month", - "auto" - ], - "default": "day", - "example": "week" - }, - "style": "form", - "explode": true - }, - { - "name": "timezone", - "in": "query", - "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "Europe/Paris" - }, - "style": "form", - "explode": true - }, - { - "name": "nonConformityStatus", - "in": "query", - "description": "Optional non-conformity status filter applied to the non-conformity trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "open", - "in_progress", - "done", - "waived" - ], - "example": "open" - }, - "style": "form", - "explode": true - }, - { - "name": "nonConformitySeverity", - "in": "query", - "description": "Optional non-conformity severity filter applied to the non-conformity trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "low", - "medium", - "high", - "critical" - ], - "example": "critical" - }, - "style": "form", - "explode": true - }, - { - "name": "metrics", - "in": "query", - "description": "Optional comma-separated list of additional non-conformity metric identifiers to combine into `seriesByMetric`, sharing this call's resolved period, timezone and granularity so a two-series (opened vs resolved) chart can render from one request instead of two independently-bucketed calls. Allowed values: non_conformities_opened, non_conformities_resolved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "non_conformities_opened,non_conformities_resolved" + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} } - ] + }, + "summary": "JSON Web Key Set", + "description": "Returns the public keys used to verify JWT signatures as defined in RFC 7517. Use these keys to validate access tokens issued by this server.", + "parameters": [] } }, - "/api/organizations/{organizationId}/dashboard/trends/non-conformities-resolved": { + "/api/.well-known/openid-configuration": { "get": { - "operationId": "getOrganizationDashboardNonConformitiesResolvedTrend", + "operationId": "openid_configuration", "tags": [ - "Organization" + "OAuth2" ], "responses": { "200": { - "description": "OrganizationDashboardTrend resource", + "description": "OpenID Connect configuration returned successfully", "content": { - "application/ld+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Discovery.OpenIdConfigurationOutput-token.read" + } + } + }, + "links": { + "GetJWKS": { + "operationId": "jwks", + "description": "Get the JSON Web Key Set for signature verification", + "parameters": { + "url": "$response.body#/jwks_uri" + } + }, + "IssueToken": { + "operationId": "token", + "description": "Issue an access token using the token endpoint", + "parameters": { + "url": "$response.body#/token_endpoint" + } + }, + "IntrospectToken": { + "operationId": "introspect_token", + "description": "Introspect a token using the introspection endpoint", + "parameters": { + "url": "$response.body#/introspection_endpoint" + } + }, + "RevokeToken": { + "operationId": "revoke_token", + "description": "Revoke a token using the revocation endpoint", + "parameters": { + "url": "$response.body#/revocation_endpoint" + } + }, + "GetUserInfo": { + "operationId": "userinfo", + "description": "Get user information using the userinfo endpoint", + "parameters": { + "url": "$response.body#/userinfo_endpoint" + } + }, + "EndSession": { + "operationId": "end_session", + "description": "Terminate the OpenID Connect session", + "parameters": { + "url": "$response.body#/end_session_endpoint" } } } }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -34926,6 +37182,37 @@ } }, "links": {} + } + }, + "summary": "OAuth2/OIDC Discovery", + "description": "Returns the OAuth2 Authorization Server Metadata document (RFC 8414) with optional OpenID Connect extensions when available.", + "parameters": [] + } + }, + "/api/oauth2/authorize": { + "get": { + "operationId": "authorize", + "tags": [ + "OAuth2" + ], + "responses": { + "302": { + "description": "Authorization code issued (redirect to redirect_uri)." + }, + "403": { + "description": "User consent required." + }, + "401": { + "description": "Authentication required." + }, + "400": { + "description": "Invalid authorization request." + }, + "429": { + "description": "Too many authorization requests" + }, + "204": { + "description": "OAuth2 resource" }, "404": { "description": "Not found", @@ -34949,197 +37236,253 @@ "links": {} } }, - "summary": "Get non-conformities resolved trend", - "description": "Returns the non-conformities-resolved trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.inspection.read`.", + "summary": "Authorize Client", + "description": "Initiate the OAuth2 authorization code flow (PKCE required).", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationDashboardTrend identifier", + "name": "response_type", + "in": "query", + "description": "OAuth2 response type (must be code)", "required": true, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "code" + ] }, - "style": "simple", - "explode": false + "style": "form", + "explode": true }, { - "name": "from", + "name": "client_id", "in": "query", - "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, + "description": "OAuth2 client identifier (UUID)", + "required": true, "deprecated": false, "schema": { "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" + "format": "uuid" }, "style": "form", "explode": true }, { - "name": "to", + "name": "redirect_uri", "in": "query", - "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, + "description": "Redirect URI registered for the client", + "required": true, "deprecated": false, "schema": { "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" + "format": "uri" }, "style": "form", "explode": true }, { - "name": "compare", + "name": "scope", "in": "query", - "description": "Whether to include previous-period comparison series. Defaults to true.", + "description": "Space-separated list of requested scopes", "required": false, "deprecated": false, "schema": { - "type": "boolean", - "default": true, - "example": true + "type": "string", + "example": "openid profile email" }, "style": "form", "explode": true }, { - "name": "granularity", + "name": "state", "in": "query", - "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", + "description": "Opaque state value returned to the client", "required": false, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "day", - "week", - "month", - "auto" - ], - "default": "day", - "example": "week" + "type": "string" }, "style": "form", "explode": true }, { - "name": "timezone", + "name": "code_challenge", "in": "query", - "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", - "required": false, + "description": "PKCE code challenge", + "required": true, "deprecated": false, "schema": { - "type": "string", - "example": "Europe/Paris" + "type": "string" }, "style": "form", "explode": true }, { - "name": "nonConformityStatus", + "name": "code_challenge_method", "in": "query", - "description": "Optional non-conformity status filter applied to the non-conformity trend only.", + "description": "PKCE code challenge method (S256 or plain, defaults to plain)", "required": false, "deprecated": false, "schema": { "type": "string", "enum": [ - "open", - "in_progress", - "done", - "waived" - ], - "example": "open" + "S256", + "plain" + ] }, "style": "form", "explode": true }, { - "name": "nonConformitySeverity", + "name": "nonce", "in": "query", - "description": "Optional non-conformity severity filter applied to the non-conformity trend only.", + "description": "OIDC nonce value (optional)", "required": false, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "low", - "medium", - "high", - "critical" - ], - "example": "critical" + "type": "string" }, "style": "form", "explode": true }, { - "name": "metrics", + "name": "prompt", "in": "query", - "description": "Optional comma-separated list of additional non-conformity metric identifiers to combine into `seriesByMetric`, sharing this call's resolved period, timezone and granularity so a two-series (opened vs resolved) chart can render from one request instead of two independently-bucketed calls. Allowed values: non_conformities_opened, non_conformities_resolved.", + "description": "OIDC prompt values (space-separated)", "required": false, "deprecated": false, "schema": { "type": "string", - "example": "non_conformities_opened,non_conformities_resolved" + "example": "login consent" }, "style": "form", "explode": true + }, + { + "name": "max_age", + "in": "query", + "description": "Maximum authentication age in seconds", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "example": 3600 + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/logo": { - "post": { - "operationId": "uploadOrganizationLogo", + "/api/oauth2/consent/check": { + "get": { + "operationId": "check_consent", "tags": [ - "Organization" + "OAuth2" ], "responses": { "200": { - "description": "Logo uploaded \u2014 organization output with updated logoUrl returned", + "description": "Consent check result", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OAuth2.CheckConsentOutput.jsonld-consent.read" } } }, - "links": {} + "links": { + "GetUserInfo": { + "operationId": "userinfo", + "description": "Get user information after consent check" + } + } }, - "422": { - "description": "Invalid file \u2014 missing, too large, or unsupported MIME type", + "400": { + "description": "Missing required client_id parameter" + }, + "401": { + "description": "Missing or invalid access token" + }, + "429": { + "description": "Too many consent checks" + }, + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} + } + }, + "summary": "Check User Consent", + "description": "Check if the authenticated user has granted consent for a specific client and scopes.", + "parameters": [ + { + "name": "client_id", + "in": "query", + "description": "The OAuth2 client identifier requesting authorization", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid", + "example": "01234567-89ab-cdef-0123-456789abcdef" + }, + "style": "form", + "explode": true }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization not found" + { + "name": "scope", + "in": "query", + "description": "Space-separated list of requested OAuth2 scopes", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "openid profile email" + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/oauth2/consent/grant": { + "post": { + "operationId": "grant_consent", + "tags": [ + "OAuth2" + ], + "responses": { + "302": { + "description": "Authorization completed (redirect to redirect_uri)." }, "400": { - "description": "Invalid input", + "description": "Invalid consent request.", "content": { "application/ld+json": { "schema": { @@ -35158,143 +37501,91 @@ } }, "links": {} - } - }, - "summary": "Upload Organization logo", - "description": "Uploads a logo image for the organization. Accepted formats: JPEG, PNG, WebP, GIF (max 5 MB). The image is scaled down to a single WebP variant. Requires the organization.settings.write permission.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "Organization identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + }, + "401": { + "description": "Authentication required." + }, + "429": { + "description": "Too many consent submissions" + }, + "204": { + "description": "OAuth2 resource created", + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } }, - "style": "simple", - "explode": false + "links": {} } - ], + }, + "summary": "Grant User Consent", + "description": "Approve or deny a consent request and complete the authorization flow.", + "parameters": [], "requestBody": { - "description": "Logo image file (JPEG, PNG, WebP or GIF, max 5 MB)", + "description": "The new OAuth2 resource", "content": { - "multipart/form-data": { + "application/ld+json": { "schema": { - "type": "object", - "required": [ - "logo" - ], - "properties": { - "logo": { - "type": "string", - "format": "binary", - "description": "Image file (JPEG, PNG, WebP or GIF), max 5 MB" - } - } + "$ref": "#/components/schemas/OAuth2.GrantConsentInput-consent.write" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuth2.GrantConsentInput-consent.write" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OAuth2.GrantConsentInput-consent.write" } } }, "required": true - } - }, - "delete": { - "operationId": "removeOrganizationLogo", - "tags": [ - "Organization" - ], - "responses": { - "204": { - "description": "Logo removed" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization not found" - }, - "409": { - "description": "Organization is archived \u2014 restore it first" - } }, - "summary": "Remove Organization logo", - "description": "Removes the organization logo. Requires the organization.settings.write permission (the same permission required to upload it). Idempotent when the organization has no logo.", - "parameters": [ + "security": [ { - "name": "organizationId", - "in": "path", - "description": "Organization identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/logo.webp": { + "/api/oauth2/logout": { "get": { - "operationId": "getOrganizationLogo", + "operationId": "end_session", "tags": [ - "Organization" + "OAuth2" ], "responses": { + "302": { + "description": "Redirect to the post_logout_redirect_uri." + }, "200": { - "description": "Logo image (WebP)" + "description": "Logout completed without redirect." }, - "404": { - "description": "Organization logo not found" + "400": { + "description": "Invalid logout request." }, "204": { - "description": "Organization resource" - } - }, - "summary": "Get Organization logo", - "description": "Streams the organization logo as WebP. Public endpoint.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "Organization identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - } - }, - "/api/organizations/{organizationId}/navigation-counters": { - "get": { - "operationId": "getOrganizationNavigationCounters", - "tags": [ - "Organization" - ], - "responses": { - "200": { - "description": "Navigation counters retrieved successfully", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationNavigationCounters.OrganizationNavigationCountersOutput.jsonld-Organization.read" - } - } - } - }, - "401": { - "description": "Authentication required" + "description": "OAuth2 resource" }, "404": { - "description": "Organization not found or the authenticated user is not an active member" - }, - "403": { - "description": "Forbidden", + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -35315,51 +37606,111 @@ "links": {} } }, - "summary": "Get organization sidebar navigation counters", - "description": "Returns lightweight badge counters for the frontend sidebar navigation: `openInterventions` (field interventions not yet `published`/`abandoned`) and `openNonConformities` (non-conformities currently `open` or `in_progress`). The caller only needs to be an ACTIVE member of the organization; each counter individually falls back to 0 (never a 403) when the caller lacks the underlying read permission (`organization.interventions.read` / `organization.inspection.read` respectively), mirroring how the organization dashboard degrades its own interventions section.", + "summary": "End Session", + "description": "Terminate the current OpenID Connect session and optionally redirect to a client post-logout URI.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationNavigationCounters identifier", - "required": true, + "name": "id_token_hint", + "in": "query", + "description": "ID token previously issued by this server (optional)", + "required": false, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false - } - ], - "security": [ + "style": "form", + "explode": true + }, { - "bearerAuth": [] + "name": "post_logout_redirect_uri", + "in": "query", + "description": "Client post-logout redirect URI (must be registered)", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uri" + }, + "style": "form", + "explode": true + }, + { + "name": "client_id", + "in": "query", + "description": "OAuth2 client identifier (required when post_logout_redirect_uri is provided)", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "state", + "in": "query", + "description": "Opaque state value returned to the client after logout", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true } ] } }, - "/api/organizations/{organizationId}/quota": { - "get": { - "operationId": "getOrganizationQuota", + "/api/oauth2/token": { + "post": { + "operationId": "token", "tags": [ - "Organization" + "OAuth2" ], "responses": { "200": { - "description": "Quota usage retrieved", + "description": "Access token issued successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Organization.OrganizationQuotaOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OAuth2.TokenOutput.jsonld-token.read" } } + }, + "links": { + "IntrospectToken": { + "operationId": "introspect_token", + "description": "Introspect the issued token to get its metadata", + "parameters": { + "token": "$response.body#/access_token", + "token_type_hint": "access_token" + } + }, + "RevokeToken": { + "operationId": "revoke_token", + "description": "Revoke the issued token when no longer needed", + "parameters": { + "token": "$response.body#/access_token", + "token_type_hint": "access_token" + } + }, + "RevokeRefreshToken": { + "operationId": "revoke_token", + "description": "Revoke the refresh token when no longer needed", + "parameters": { + "token": "$response.body#/refresh_token", + "token_type_hint": "refresh_token" + } + }, + "GetUserInfo": { + "operationId": "userinfo", + "description": "Get user information using the access token (pass as Bearer header)" + } } }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Not found", + "400": { + "description": "Invalid request - missing or invalid parameters", "content": { "application/ld+json": { "schema": { @@ -35378,123 +37729,93 @@ } }, "links": {} - } - }, - "summary": "Get Organization quota usage", - "description": "Returns the current usage and plan limit of each capped resource (members, facilities, equipment, inspections). Requires the organization.read permission.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "Organization identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - } - }, - "/api/organizations/{organizationId}/search": { - "get": { - "operationId": "searchOrganization", - "tags": [ - "Organization" - ], - "responses": { - "200": { - "description": "Search results retrieved", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationSearch.OrganizationSearchOutput.jsonld-Organization.read" - } - } - } - }, - "400": { - "description": "Missing q parameter, or q shorter than 2 / longer than 100 characters" }, "401": { - "description": "Authentication required" + "description": "Invalid client credentials or unauthorized grant type" }, - "404": { - "description": "Organization not found or the caller is not an active member \u2014 deliberately not 403, which would confirm it exists" + "429": { + "description": "Too many token requests" }, - "403": { - "description": "Forbidden", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Search across an organization", - "description": "Case-insensitive free-text search returning a flat `results` list, grouped by type in a stable order (`equipment`, `facility`, `intervention`, `inspection`, `non_conformity`), with at most 5 hits per type ordered by most recent update. Each hit carries `type`, `id`, `title` and optional `subtitle`/`extra`; the frontend builds the target route from `type` + `id` \u2014 the API ships no URL. Matched fields per type: equipment type/brand/model/serial number/location label; facility name/code/address; intervention name/number; inspection checklist reference code or inspection id; non-conformity description. The caller must be an ACTIVE member of the organization (a non-member gets 404, never a hint the organization exists); each type is then individually soft-gated on its read permission (`organization.equipment.read`, `organization.facilities.read`, `organization.interventions.read`, `organization.inspection.read` for both inspections and non-conformities) \u2014 a member without a permission simply gets no rows of that type, never an error.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationSearch identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "summary": "Issue Access Token", + "description": "Issue an access token using configured OAuth2 grant types (see discovery metadata).", + "parameters": [], + "requestBody": { + "description": "The new OAuth2 resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenInput-token.write" + } }, - "style": "simple", - "explode": false - }, - { - "name": "q", - "in": "query", - "description": "The free-text search term, 2 to 100 characters after trimming.", - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "minLength": 2, - "maxLength": 100 + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenInput-token.write" + } }, - "style": "form", - "explode": true - } - ] + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenInput-token.write" + } + } + }, + "required": true + } } }, - "/api/organizations/invitations/accept": { + "/api/oauth2/token/introspect": { "post": { - "operationId": "acceptOrganizationInvitation", + "operationId": "introspect_token", "tags": [ - "Organization Invitations" + "OAuth2" ], "responses": { - "201": { - "description": "OrganizationInvitation resource created", + "200": { + "description": "Token introspection result", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitation.OrganizationMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OAuth2.TokenIntrospectionOutput.jsonld-token.read" } } }, - "links": {} + "links": { + "RevokeToken": { + "operationId": "revoke_token", + "description": "Revoke the token if it should be invalidated", + "parameters": { + "token": "$request.body#/token", + "token_type_hint": "$request.body#/token_type_hint" + } + } + } + }, + "401": { + "description": "Invalid client credentials" + }, + "429": { + "description": "Too many introspection requests" }, "400": { "description": "Invalid input", @@ -35560,41 +37881,55 @@ "links": {} } }, - "summary": "Accept Organization invitation", - "description": "Accepts an invitation token and creates/updates organization membership for the authenticated user.", + "summary": "Introspect Token", + "description": "Query the authorization server about the current state and metadata of a token as defined in RFC 7662.", "parameters": [], "requestBody": { - "description": "The new OrganizationInvitation resource", + "description": "The new OAuth2 resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitation.AcceptOrganizationInvitationInput-Organization.write" + "$ref": "#/components/schemas/OAuth2.TokenIntrospectionInput-token.write" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenIntrospectionInput-token.write" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenIntrospectionInput-token.write" } } }, "required": true - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, - "/api/organizations/invitations/{token}/preview": { - "get": { - "operationId": "getOrganizationInvitationPreview", + "/api/oauth2/token/revoke": { + "post": { + "operationId": "revoke_token", "tags": [ - "Organization Invitations" + "OAuth2" ], "responses": { "200": { - "description": "OrganizationInvitation resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationPreviewOutput.jsonld-Organization.read" - } + "description": "Token revoked successfully", + "links": { + "IssueNewToken": { + "operationId": "token", + "description": "Issue a new token after revoking the old one" } } }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid request - missing token parameter", "content": { "application/ld+json": { "schema": { @@ -35614,8 +37949,35 @@ }, "links": {} }, - "404": { - "description": "Not found", + "401": { + "description": "Invalid client credentials" + }, + "429": { + "description": "Too many revocation requests" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -35636,63 +37998,68 @@ "links": {} } }, - "summary": "Preview Organization invitation", - "description": "Returns a minimal, public-safe preview of an invitation resolved by token (organization, inviter, invited email, status, expiry, granted role names).", - "parameters": [ - { - "name": "token", - "in": "path", - "description": "OrganizationInvitation identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "summary": "Revoke Token", + "description": "Revoke an access token or refresh token as defined in RFC 7009.", + "parameters": [], + "requestBody": { + "description": "The new OAuth2 resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenRevocationInput" + } }, - "style": "simple", - "explode": false + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenRevocationInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenRevocationInput" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/invitations": { + "/api/oauth2/userinfo": { "get": { - "operationId": "listOrganizationInvitations", + "operationId": "userinfo", "tags": [ - "Organization Invitations" + "OAuth2" ], "responses": { "200": { - "description": "OrganizationInvitation collection", + "description": "User information retrieved successfully", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" - } - } - } - } - ] + "$ref": "#/components/schemas/OAuth2.UserInfoOutput.jsonld-token.read" } } + }, + "links": { + "CheckConsent": { + "operationId": "check_consent", + "description": "Check user consent status for a client" + } } }, + "401": { + "description": "Missing or invalid access token" + }, "403": { - "description": "Forbidden", + "description": "Access token lacks required scopes" + }, + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -35713,70 +38080,58 @@ "links": {} } }, - "summary": "List Organization invitations", - "description": "Lists invitations created for an Organization.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationInvitation identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, + "summary": "Get User Information", + "description": "Retrieve claims about the authenticated user as defined by OpenID Connect Core 1.0.", + "parameters": [], + "security": [ { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true + "bearerAuth": [] } ] - }, + } + }, + "/api/otp/challenges": { "post": { - "operationId": "inviteOrganizationMember", + "operationId": "createChallenge", "tags": [ - "Organization Invitations" + "OTP" ], "responses": { "201": { - "description": "OrganizationInvitation resource created", + "description": "OTP challenge created successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Challenge.ChallengeOutput.jsonld" } } }, - "links": {} + "links": { + "GetChallengeStatus": { + "operationId": "getChallengeStatus", + "description": "Check the challenge status", + "parameters": { + "token": "$response.body#/token" + } + }, + "VerifyChallenge": { + "operationId": "verifyChallenge", + "description": "Verify the OTP code", + "parameters": { + "token": "$response.body#/token" + } + }, + "ResendChallenge": { + "operationId": "resendChallenge", + "description": "Resend the OTP if canResendIn is 0", + "parameters": { + "token": "$response.body#/token" + } + } + } }, "400": { - "description": "Invalid input", + "description": "Invalid request parameters", "content": { "application/ld+json": { "schema": { @@ -35796,6 +38151,9 @@ }, "links": {} }, + "429": { + "description": "Rate limit exceeded" + }, "422": { "description": "An error occurred", "content": { @@ -35839,13 +38197,86 @@ "links": {} } }, - "summary": "Invite Organization member", - "description": "Invites a user by email to join an Organization with one or more roles.", + "summary": "Create OTP challenge", + "description": "Creates a new OTP challenge and sends the code via the specified channel. Requires otp_challenges.create permission.", + "parameters": [], + "requestBody": { + "description": "The new Challenge resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Challenge.CreateChallengeInput" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/otp/challenges/{token}": { + "get": { + "operationId": "getChallengeStatus", + "tags": [ + "OTP" + ], + "responses": { + "200": { + "description": "Challenge status retrieved", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Challenge.ChallengeOutput.jsonld" + } + } + }, + "links": { + "VerifyChallenge": { + "operationId": "verifyChallenge", + "description": "Verify the OTP code if status is pending" + }, + "ResendChallenge": { + "operationId": "resendChallenge", + "description": "Resend the OTP if canResendIn is 0" + } + } + }, + "404": { + "description": "Challenge not found" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Get challenge status", + "description": "Returns the current status of an OTP challenge. Requires otp_challenges.read permission.", "parameters": [ { - "name": "organizationId", + "name": "token", "in": "path", - "description": "OrganizationInvitation identifier", + "description": "Challenge identifier", "required": true, "deprecated": false, "schema": { @@ -35855,37 +38286,37 @@ "explode": false } ], - "requestBody": { - "description": "The new OrganizationInvitation resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationInvitation.InviteOrganizationMemberInput-Organization.write" - } - } - }, - "required": true - } + "security": [ + { + "bearerAuth": [] + } + ] } }, - "/api/organizations/{organizationId}/invitations/{invitationId}/resend": { + "/api/otp/challenges/{token}/resend": { "post": { - "operationId": "resendOrganizationInvitation", + "operationId": "resendChallenge", "tags": [ - "Organization Invitations" + "OTP" ], "responses": { "200": { - "description": "OrganizationInvitation resource created", + "description": "OTP resent successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Challenge.ChallengeOutput.jsonld" } } }, "links": {} }, + "404": { + "description": "Challenge not found" + }, + "429": { + "description": "Resend cooldown not yet elapsed" + }, "400": { "description": "Invalid input", "content": { @@ -35950,13 +38381,13 @@ "links": {} } }, - "summary": "Resend Organization invitation", - "description": "Regenerates the token, resets the expiry and re-sends the invitation email, returning a fresh accept link.", + "summary": "Resend OTP", + "description": "Resends the OTP code. Subject to cooldown period. Requires otp_challenges.resend permission.", "parameters": [ { - "name": "organizationId", + "name": "token", "in": "path", - "description": "OrganizationInvitation identifier", + "description": "Challenge identifier", "required": true, "deprecated": false, "schema": { @@ -35964,42 +38395,35 @@ }, "style": "simple", "explode": false - }, + } + ], + "security": [ { - "name": "invitationId", - "in": "path", - "description": "OrganizationInvitation identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/invitations/{invitationId}/revoke": { + "/api/otp/challenges/{token}/verify": { "post": { - "operationId": "revokeOrganizationInvitation", + "operationId": "verifyChallenge", "tags": [ - "Organization Invitations" + "OTP" ], "responses": { "200": { - "description": "OrganizationInvitation resource created", + "description": "OTP verified successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Challenge.VerifyOtpOutput.jsonld" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid OTP code", "content": { "application/ld+json": { "schema": { @@ -36019,6 +38443,15 @@ }, "links": {} }, + "404": { + "description": "Challenge not found" + }, + "410": { + "description": "Challenge expired or max attempts reached" + }, + "429": { + "description": "Too many OTP verification attempts" + }, "422": { "description": "An error occurred", "content": { @@ -36062,13 +38495,13 @@ "links": {} } }, - "summary": "Revoke Organization invitation", - "description": "Revokes a pending invitation.", + "summary": "Verify challenge", + "description": "Verifies the OTP code for a challenge. Requires otp_challenges.verify permission.", "parameters": [ { - "name": "organizationId", + "name": "token", "in": "path", - "description": "OrganizationInvitation identifier", + "description": "Challenge identifier", "required": true, "deprecated": false, "schema": { @@ -36076,45 +38509,49 @@ }, "style": "simple", "explode": false + } + ], + "requestBody": { + "description": "The new Challenge resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Challenge.VerifyOtpInput" + } + } }, + "required": true + }, + "security": [ { - "name": "invitationId", - "in": "path", - "description": "OrganizationInvitation identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/me": { + "/api/otp/channels": { "get": { - "operationId": "getCurrentOrganizationMemberProfile", + "operationId": "listChannels", "tags": [ - "Organization Members" + "OTP" ], "responses": { "200": { - "description": "Current organization member profile retrieved successfully", + "description": "List of available OTP channels", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/CurrentOrganizationMemberProfile.CurrentOrganizationMemberProfileOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OtpConfig.Config.ChannelOutput.jsonld" } } + }, + "links": { + "CreateChallenge": { + "operationId": "createChallenge", + "description": "Create a new OTP challenge using one of these channels" + } } }, - "401": { - "description": "Authentication required" - }, - "404": { - "description": "Organization not found or the authenticated user is not an active member" - }, "403": { "description": "Forbidden", "content": { @@ -36135,70 +38572,9 @@ } }, "links": {} - } - }, - "summary": "Get current organization member profile", - "description": "Returns the authenticated active member profile in the target organization, including resolved roles and effective permissions.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "CurrentOrganizationMemberProfile identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/organizations/{organizationId}/members": { - "get": { - "operationId": "listOrganizationMembers", - "tags": [ - "Organization Members" - ], - "responses": { - "200": { - "description": "OrganizationMember collection", - "content": { - "application/ld+json": { - "schema": { - "type": "object", - "description": "OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" - } - } - } - } - ] - } - } - } }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -36219,70 +38595,41 @@ "links": {} } }, - "summary": "List Organization members", - "description": "Lists Organization members and assigned roles. Supports `search` (matched against the member's user identifier), `status` (`active`, `inactive`, or `all`), `roleId`, and `order[joinedAt|displayName]=asc|desc` (default `order[joinedAt]=asc`).", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, + "summary": "List OTP channels", + "description": "Returns all available OTP delivery channels. Requires otp_config.read permission.", + "parameters": [], + "security": [ { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true + "bearerAuth": [] } ] - }, - "post": { - "operationId": "addOrganizationMember", + } + }, + "/api/otp/purposes": { + "get": { + "operationId": "listPurposes", "tags": [ - "Organization Members" + "OTP" ], "responses": { - "201": { - "description": "OrganizationMember resource created", + "200": { + "description": "List of available OTP purposes", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OtpConfig.PurposeOutput.jsonld" } } }, - "links": {} + "links": { + "CreateChallenge": { + "operationId": "createChallenge", + "description": "Create a new OTP challenge using one of these purposes" + } + } }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -36302,29 +38649,8 @@ }, "links": {} }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} - }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -36345,90 +38671,80 @@ "links": {} } }, - "summary": "Add Organization member", - "description": "Adds an existing user to a Organization and assigns one or more roles.", - "parameters": [ + "summary": "List OTP purposes", + "description": "Returns all available OTP purposes with their default TTL and max attempts configuration. Requires otp_config.read permission.", + "parameters": [], + "security": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "bearerAuth": [] } - ], - "requestBody": { - "description": "The new OrganizationMember resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationMember.AddOrganizationMemberInput-Organization.write" - } - } - }, - "required": true - } + ] } }, - "/api/organizations/{organizationId}/members/batch-remove": { + "/api/otp/totp/confirm": { "post": { - "operationId": "batchRemoveOrganizationMembers", + "operationId": "confirmTotp", "tags": [ - "Organization Members" + "OTP" ], "responses": { "200": { - "description": "OrganizationMember resource created", + "description": "TOTP activated successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationMember.RemoveOrganizationMembersOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Totp.ConfirmTotpOutput.jsonld" } } }, "links": {} }, - "400": { - "description": "Invalid input", + "422": { + "description": "Invalid or expired verification code", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} }, - "422": { - "description": "An error occurred", + "404": { + "description": "No pending TOTP setup found; call setup first" + }, + "429": { + "description": "Too many confirmation attempts" + }, + "409": { + "description": "An active authenticator cannot be replaced by confirming a pending secret" + }, + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, @@ -36456,53 +38772,74 @@ "links": {} } }, - "summary": "Batch remove Organization members", - "description": "Removes several members in one request, reporting removed and failed IDs.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], + "summary": "Confirm TOTP", + "description": "Verifies a code from the authenticator app against the pending secret and, on success, activates TOTP for the user. An existing active factor cannot be replaced, including when a legacy pending secret exists. Requires otp_totp.confirm permission.", + "parameters": [], "requestBody": { - "description": "The new OrganizationMember resource", + "description": "The new Totp resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationMember.RemoveOrganizationMembersInput-Organization.write" + "$ref": "#/components/schemas/Totp.ConfirmTotpInput" } } }, "required": true - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, - "/api/organizations/{organizationId}/members/me": { - "delete": { - "operationId": "leaveOrganization", + "/api/otp/totp/disable": { + "post": { + "operationId": "disableTotp", "tags": [ - "Organization Members" + "OTP" ], "responses": { - "204": { - "description": "Membership deactivated" + "200": { + "description": "TOTP disabled successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Totp.DisableTotpOutput.jsonld" + } + } + }, + "links": {} + }, + "422": { + "description": "Invalid verification code", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} }, "404": { - "description": "Organization not found, or caller is not an active member" + "description": "TOTP is not currently enabled for the user" }, - "409": { - "description": "Caller is the organization's current owner, or is the last active administrator" + "429": { + "description": "Too many disable attempts" }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -36521,154 +38858,98 @@ } }, "links": {} - } - }, - "summary": "Leave Organization", - "description": "Deactivates the authenticated user's own membership (self-removal). The organization's current owner cannot leave \u2014 transfer ownership first via POST /organizations/{id}/transfer-ownership. Refused when leaving would strip the organization of its last active administrator.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - } - }, - "/api/organizations/{organizationId}/members/{memberId}": { - "get": { - "operationId": "getOrganizationMember", - "tags": [ - "Organization Members" - ], - "responses": { - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization not found, or member not found in this organization" }, - "200": { - "description": "OrganizationMember resource", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } - } - }, - "summary": "Get Organization member", - "description": "Resolves a single organization member by identifier.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "memberId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" }, - "style": "simple", - "explode": false - } - ] - }, - "delete": { - "operationId": "removeOrganizationMember", - "tags": [ - "Organization Members" - ], - "responses": { - "204": { - "description": "Member removed" - }, - "400": { - "description": "Invalid identifier" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization or member not found" + "links": {} } }, - "summary": "Remove Organization member", - "description": "Deactivates an organization member. The membership record is retained for audit purposes.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "summary": "Disable TOTP", + "description": "Disables TOTP for the authenticated user. Requires a valid current TOTP code as proof of possession. Requires otp_totp.disable permission.", + "parameters": [], + "requestBody": { + "description": "The new Totp resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Totp.DisableTotpInput" + } + } }, + "required": true + }, + "security": [ { - "name": "memberId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/members/{memberId}/reactivate": { + "/api/otp/totp/setup": { "post": { - "operationId": "reactivateOrganizationMember", + "operationId": "setupTotp", "tags": [ - "Organization Members" + "OTP" ], "responses": { - "200": { - "description": "Member reactivated", + "201": { + "description": "TOTP setup data returned", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Totp.SetupTotpOutput.jsonld" } } }, - "links": {} - }, - "403": { - "description": "Insufficient permissions" + "links": { + "ConfirmTotp": { + "operationId": "confirmTotp", + "description": "Confirm the pending secret with a code from the authenticator app" + } + } }, - "404": { - "description": "Organization not found, or member not found in this organization" + "401": { + "description": "User must be authenticated" }, "409": { - "description": "Organization is archived, member is already active, or the plan's member cap has been reached" + "description": "An active authenticator must first be disabled with its current code" + }, + "403": { + "description": "Authenticator enrollment is unavailable for this account", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "400": { "description": "Invalid input", @@ -36713,73 +38994,36 @@ "links": {} } }, - "summary": "Reactivate Organization member", - "description": "Reactivates a previously deactivated (removed) organization member. Subject to the same plan member cap as adding a new member.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, + "summary": "Setup TOTP", + "description": "Generates a new TOTP secret and stores it server-side as a PENDING enrollment for the authenticated user. Returns the secret and a QR code URI for authenticator app setup. Calling this again replaces the pending secret during initial enrollment. Enrollment requires the account to remain active when its serialized mutation starts. An active factor must first be disabled with its current code; setup refuses replacement. Requires otp_totp.setup permission.", + "parameters": [], + "security": [ { - "name": "memberId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/permissions": { + "/api/onboarding/organization": { "get": { - "operationId": "listOrganizationPermissions", + "operationId": "getOrganizationOnboarding", "tags": [ - "Organization Permissions" + "Onboarding" ], "responses": { "200": { - "description": "OrganizationPermission collection", + "description": "Onboarding flow retrieved", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "OrganizationPermission.OrganizationPermissionOutput.jsonld-Organization.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OrganizationPermission.OrganizationPermissionOutput.jsonld-Organization.read" - } - } - } - } - ] + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } } }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -36800,65 +39044,54 @@ } }, "links": {} - } - }, - "summary": "List available Organization permissions", - "description": "Lists all system-defined permissions that can be assigned to organization roles.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationPermission identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} } - ] + }, + "summary": "Get organization onboarding flow", + "description": "Returns persisted onboarding progression for organization setup and exposes actionable endpoints for each step.", + "parameters": [] } }, - "/api/organizations/{organizationId}/members/{memberId}/roles": { - "put": { - "operationId": "setOrganizationMemberRoles", + "/api/onboarding/organization/dismiss": { + "post": { + "operationId": "dismissOrganizationOnboarding", "tags": [ - "Organization Roles" + "Onboarding" ], "responses": { "200": { - "description": "Member roles replaced", + "description": "Onboarding activation dismissed", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationRole.OrganizationMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } }, "links": {} }, - "403": { - "description": "Insufficient permissions, or attempting to grant a permission the caller does not hold" - }, - "404": { - "description": "Organization, member, or one of the requested roles not found" - }, - "409": { - "description": "Would strip the organization of its last administrator" + "401": { + "description": "Authentication required" }, "400": { "description": "Invalid input", @@ -36901,65 +39134,55 @@ } }, "links": {} - } - }, - "summary": "Replace member roles", - "description": "Replaces the member's entire role set in one call. Roles being granted go through the privilege-escalation guard; roles being revoked go through the last-administrator lockout guard.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false }, - { - "name": "memberId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "simple", - "explode": false + "links": {} } - ], - "requestBody": { - "description": "The updated OrganizationRole resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationRole.SetOrganizationMemberRolesInput-Organization.write" - } - } - }, - "required": true - } - }, + }, + "summary": "Dismiss organization onboarding activation", + "description": "Voluntarily hides the non-blocking activation flow without completing it. Progression is preserved and can be resumed later.", + "parameters": [] + } + }, + "/api/onboarding/organization/resume": { "post": { - "operationId": "assignOrganizationRoleToMember", + "operationId": "resumeOrganizationOnboarding", "tags": [ - "Organization Roles" + "Onboarding" ], "responses": { - "201": { - "description": "OrganizationRole resource created", + "200": { + "description": "Onboarding activation resumed", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationRole.OrganizationMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } }, "links": {} }, + "401": { + "description": "Authentication required" + }, "400": { "description": "Invalid input", "content": { @@ -37024,145 +39247,76 @@ "links": {} } }, - "summary": "Assign role to member", - "description": "Assigns an existing Organization role to an existing Organization member.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "summary": "Resume organization onboarding activation", + "description": "Clears a previous dismissal so the activation flow and setup checklist become visible again.", + "parameters": [] + } + }, + "/api/onboarding/organization/rollback": { + "post": { + "operationId": "rollbackOrganizationOnboarding", + "tags": [ + "Onboarding" + ], + "responses": { + "200": { + "description": "Onboarding step rolled back", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" + } + } }, - "style": "simple", - "explode": false + "links": {} }, - { - "name": "memberId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The new OrganizationRole resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationRole.AssignOrganizationRoleInput-Organization.write" - } - } + "409": { + "description": "No rollback action available" }, - "required": true - } - } - }, - "/api/organizations/{organizationId}/members/{memberId}/roles/{roleId}": { - "delete": { - "operationId": "removeOrganizationRoleFromMember", - "tags": [ - "Organization Roles" - ], - "responses": { - "204": { - "description": "Role removed from member" + "401": { + "description": "Authentication required" }, "400": { - "description": "Invalid identifier" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization, member, or role not found" - } - }, - "summary": "Remove role from member", - "description": "Removes a role assignment from an organization member.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "memberId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "simple", - "explode": false + "links": {} }, - { - "name": "roleId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - } - }, - "/api/organizations/{organizationId}/roles": { - "get": { - "operationId": "listOrganizationRoles", - "tags": [ - "Organization Roles" - ], - "responses": { - "200": { - "description": "OrganizationRole collection", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" - } - } - } - } - ] + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" } } - } + }, + "links": {} }, "403": { "description": "Forbidden", @@ -37186,68 +39340,35 @@ "links": {} } }, - "summary": "List Organization roles", - "description": "Lists all roles defined for a Organization. Real pagination (`page`/`itemsPerPage`, default 30); `totalItems` reflects the count AFTER search filtering. Supports `search` (matched against the role name) and `order[name|isSystem|createdAt]=asc|desc` (default `order[name]=asc`).", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true - } - ] - }, + "summary": "Rollback last organization onboarding step", + "description": "Rolls back the last rollbackable onboarding step for the authenticated user.", + "parameters": [] + } + }, + "/api/onboarding/organization/setup-operations": { "post": { - "operationId": "createOrganizationRole", + "operationId": "prepareOrganizationSetup", "tags": [ - "Organization Roles" + "Onboarding" ], "responses": { - "201": { - "description": "OrganizationRole resource created", + "200": { + "description": "Batch prepared or already prepared", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } }, "links": {} }, + "401": { + "description": "Authentication required" + }, + "409": { + "description": "Session, step, item or payload conflict" + }, "400": { "description": "Invalid input", "content": { @@ -37312,28 +39433,15 @@ "links": {} } }, - "summary": "Create Organization role", - "description": "Creates a custom role inside a Organization with explicit permissions.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], + "summary": "Prepare a durable setup batch", + "description": "Persists up to five item inputs for the current creator step. Repeating a key requires identical input. Owner resource POSTs consume the returned session and item keys atomically.", + "parameters": [], "requestBody": { - "description": "The new OrganizationRole resource", + "description": "The new OrganizationOnboarding resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationRole.CreateOrganizationRoleInput-Organization.write" + "$ref": "#/components/schemas/OrganizationOnboarding.PrepareOrganizationSetupInput-onboarding.write" } } }, @@ -37341,126 +39449,29 @@ } } }, - "/api/organizations/{organizationId}/roles/{roleId}": { - "get": { - "operationId": "getOrganizationRole", - "tags": [ - "Organization Roles" - ], - "responses": { - "200": { - "description": "Role retrieved", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" - } - } - } - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization or role not found, or the role belongs to another organization" - } - }, - "summary": "Get Organization role", - "description": "Returns a single organization role, including memberCount (the number of ACTIVE members currently assigned). Requires the organization.roles.read permission.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "roleId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - }, - "delete": { - "operationId": "deleteOrganizationRole", - "tags": [ - "Organization Roles" - ], - "responses": { - "204": { - "description": "Role deleted" - }, - "400": { - "description": "Invalid identifier or system role" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization or role not found" - } - }, - "summary": "Delete Organization role", - "description": "Permanently deletes a custom role. All member role assignments for this role are removed. System roles cannot be deleted.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "roleId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - }, - "patch": { - "operationId": "updateOrganizationRole", + "/api/onboarding/organization/start": { + "post": { + "operationId": "startOrganizationOnboarding", "tags": [ - "Organization Roles" + "Onboarding" ], "responses": { "200": { - "description": "Role updated", + "description": "Onboarding flow started", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } }, "links": {} }, + "401": { + "description": "Authentication required" + }, "400": { - "description": "Invalid payload, or the role is a system role", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -37480,15 +39491,6 @@ }, "links": {} }, - "403": { - "description": "Insufficient permissions, or granting a permission the caller does not hold" - }, - "404": { - "description": "Organization or role not found, or the role belongs to another organization" - }, - "409": { - "description": "The change would leave the organization without an administrator" - }, "422": { "description": "An error occurred", "content": { @@ -37509,42 +39511,38 @@ } }, "links": {} - } - }, - "summary": "Update Organization role permissions", - "description": "Updates the permissions assigned to a custom organization role, and optionally renames it.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false }, - { - "name": "roleId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "simple", - "explode": false + "links": {} } - ], + }, + "summary": "Start organization onboarding flow", + "description": "Starts or resets the persisted organization onboarding session for the authenticated user.", + "parameters": [], "requestBody": { - "description": "The updated OrganizationRole resource", + "description": "The new OrganizationOnboarding resource", "content": { - "application/merge-patch+json": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationRole.UpdateOrganizationRoleInput-Organization.write.jsonMergePatch" + "$ref": "#/components/schemas/OrganizationOnboarding.StartOrganizationOnboardingInput-onboarding.write" } } }, @@ -37552,26 +39550,26 @@ } } }, - "/api/organizations/invitations/{invitationId}/accept": { + "/api/onboarding/organization/steps/{stepKey}/execute": { "post": { - "operationId": "organization_join_accept_invitation_by_id", + "operationId": "executeOrganizationOnboardingStep", "tags": [ - "Organization access" + "Onboarding" ], "responses": { "200": { - "description": "OrganizationJoin resource created", + "description": "Onboarding step executed", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinCompletedOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid step payload", "content": { "application/ld+json": { "schema": { @@ -37591,6 +39589,12 @@ }, "links": {} }, + "409": { + "description": "Step not available in current flow state" + }, + "401": { + "description": "Authentication required" + }, "422": { "description": "An error occurred", "content": { @@ -37634,13 +39638,13 @@ "links": {} } }, - "summary": "accept invitation by id", - "description": "accept invitation by id", + "summary": "Execute organization onboarding step", + "description": "Executes the requested onboarding step and updates persisted flow state.", "parameters": [ { - "name": "invitationId", + "name": "stepKey", "in": "path", - "description": "OrganizationJoin identifier", + "description": "OrganizationOnboarding identifier", "required": true, "deprecated": false, "schema": { @@ -37649,28 +39653,40 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The new OrganizationOnboarding resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationOnboarding.ExecuteOrganizationOnboardingStepInput-onboarding.write" + } + } + }, + "required": true + } } }, - "/api/organizations/join-options": { - "get": { - "operationId": "organization_join_join_options", + "/api/onboarding/organization/steps/{stepKey}/skip": { + "post": { + "operationId": "skipOrganizationOnboardingStep", "tags": [ - "Organization access" + "Onboarding" ], "responses": { "200": { - "description": "OrganizationJoin resource", + "description": "Onboarding step skipped", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinOptionsOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } - } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid or required step", "content": { "application/ld+json": { "schema": { @@ -37690,49 +39706,32 @@ }, "links": {} }, - "404": { - "description": "Not found", + "409": { + "description": "Step not available to skip in current flow state" + }, + "401": { + "description": "Authentication required" + }, + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} - } - }, - "summary": "join options", - "description": "join options", - "parameters": [] - } - }, - "/api/organizations/join-requests": { - "get": { - "operationId": "organization_join_my_requests", - "tags": [ - "Organization access" - ], - "responses": { - "200": { - "description": "OrganizationJoin resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestsOutput.jsonld-Organization.read" - } - } - } }, "403": { "description": "Forbidden", @@ -37754,9 +39753,65 @@ } }, "links": {} + } + }, + "summary": "Skip organization onboarding step", + "description": "Voluntarily skips a non-required onboarding step, allowing the flow to advance to completion without executing it.", + "parameters": [ + { + "name": "stepKey", + "in": "path", + "description": "OrganizationOnboarding identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations": { + "get": { + "operationId": "listUserOrganizations", + "tags": [ + "Organization" + ], + "responses": { + "200": { + "description": "Organization collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Organization.OrganizationOutput.jsonld-Organization.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" + } + } + } + } + ] + } + } + } }, - "404": { - "description": "Not found", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -37777,29 +39832,76 @@ "links": {} } }, - "summary": "my requests", - "description": "my requests", - "parameters": [] - } - }, - "/api/organizations/join-requests/{requestId}/cancel": { + "summary": "List user Organizations", + "description": "Lists Organizations where the authenticated user is a member.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "status", + "in": "query", + "description": "Filter by organization status.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "active", + "suspended", + "archived" + ] + }, + "style": "form", + "explode": true + } + ] + }, "post": { - "operationId": "organization_join_cancel_request", + "operationId": "createOrganization", "tags": [ - "Organization access" + "Organization" ], "responses": { - "200": { - "description": "OrganizationJoin resource created", + "201": { + "description": "Organization created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } }, "links": {} }, + "401": { + "description": "Authentication required" + }, "400": { "description": "Invalid input", "content": { @@ -37864,37 +39966,116 @@ "links": {} } }, - "summary": "cancel request", - "description": "cancel request", + "summary": "Create Organization", + "description": "Creates a Organization and assigns the creator as owner.", + "parameters": [], + "requestBody": { + "description": "The new Organization resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Organization.CreateOrganizationInput-Organization.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/legal-types": { + "get": { + "operationId": "listOrganizationLegalTypes", + "tags": [ + "Organization" + ], + "responses": { + "200": { + "description": "Organization legal types retrieved", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "OrganizationLegalType.OrganizationLegalTypeOptionOutput.jsonld-Organization.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationLegalType.OrganizationLegalTypeOptionOutput.jsonld-Organization.read" + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List organization legal types", + "description": "Returns organization legal entity type values for the Legal profile settings tab select.", "parameters": [ { - "name": "requestId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 1 }, - "style": "simple", - "explode": false + "style": "form", + "explode": true } ] } }, - "/api/organizations/{organizationId}/access-policy": { + "/api/organizations/{id}": { "get": { - "operationId": "organization_join_access_policy", + "operationId": "getOrganization", "tags": [ - "Organization access" + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource", + "description": "Organization resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationAccessPolicyOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } } @@ -37942,13 +40123,49 @@ "links": {} } }, - "summary": "access policy", - "description": "access policy", + "summary": "Get Organization", + "description": "Returns Organization details if the user has Organization.read permission in that Organization.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "OrganizationJoin identifier", + "description": "Organization identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "delete": { + "operationId": "deleteOrganization", + "tags": [ + "Organization" + ], + "responses": { + "204": { + "description": "Organization archived" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization not found" + }, + "422": { + "description": "Missing or mismatched slug confirmation" + } + }, + "summary": "Archive Organization", + "description": "Archives the organization (reversible soft delete \u2014 NOT a permanent removal): it is hidden from the default listing and its owned data (facilities, equipment, inspections, interventions) is preserved rather than orphaned, and can be restored through the settings PATCH (isActive: true). Requires the organization.delete permission plus a danger-zone confirmation: the \"slug\" query parameter must exactly match the organization's current slug (case-insensitive, trimmed). A missing or mismatched confirmation is rejected with HTTP 422 and nothing is archived. Idempotent when already archived, provided the confirmation is still correct.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organization identifier", "required": true, "deprecated": false, "schema": { @@ -37956,28 +40173,40 @@ }, "style": "simple", "explode": false + }, + { + "name": "slug", + "in": "query", + "description": "Danger-zone confirmation: the organization's current slug, typed by the caller.", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true } ] }, "patch": { - "operationId": "organization_join_update_access_policy", + "operationId": "updateOrganizationSettings", "tags": [ - "Organization access" + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource updated", + "description": "Organization updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationAccessPolicyOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid request - validation failed", "content": { "application/ld+json": { "schema": { @@ -37997,6 +40226,15 @@ }, "links": {} }, + "409": { + "description": "Slug already in use" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization not found" + }, "422": { "description": "An error occurred", "content": { @@ -38017,57 +40255,15 @@ } }, "links": {} - }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "update access policy", - "description": "update access policy", + "summary": "Update Organization settings", + "description": "Updates organization settings \u2014 general & branding (name, slug, description, active status) plus the notifications and regional sections. Requires the organization.settings.write permission.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "OrganizationJoin identifier", + "description": "Organization identifier", "required": true, "deprecated": false, "schema": { @@ -38078,11 +40274,11 @@ } ], "requestBody": { - "description": "The updated OrganizationJoin resource", + "description": "The updated Organization resource", "content": { "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationAccessPolicyInput-Organization.write.jsonMergePatch" + "$ref": "#/components/schemas/Organization.UpdateOrganizationSettingsInput-Organization.write.jsonMergePatch" } } }, @@ -38090,26 +40286,26 @@ } } }, - "/api/organizations/{organizationId}/domains": { - "post": { - "operationId": "organization_join_add_domain", + "/api/organizations/{id}/plan": { + "patch": { + "operationId": "changeOrganizationPlan", "tags": [ - "Organization access" + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource created", + "description": "Organization plan updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationDomainOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid request - plan not selectable", "content": { "application/ld+json": { "schema": { @@ -38129,6 +40325,15 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization or plan not found" + }, + "409": { + "description": "Paid plan requires the billing checkout flow, or current usage exceeds the selected plan limits (confirm with acknowledgeOveruse)" + }, "422": { "description": "An error occurred", "content": { @@ -38149,36 +40354,15 @@ } }, "links": {} - }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "add domain", - "description": "add domain", + "summary": "Change Organization plan", + "description": "Assigns a subscription plan to the organization. Self-service \u2014 requires the organization.settings.write permission. When the target plan's caps sit below the current usage, the change is refused with HTTP 409 listing the exceeded resources; re-submit with acknowledgeOveruse: true to confirm the downgrade.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "OrganizationJoin identifier", + "description": "Organization identifier", "required": true, "deprecated": false, "schema": { @@ -38189,11 +40373,11 @@ } ], "requestBody": { - "description": "The new OrganizationJoin resource", + "description": "The updated Organization resource", "content": { - "application/ld+json": { + "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationDomainInput-Organization.write" + "$ref": "#/components/schemas/Organization.ChangeOrganizationPlanInput-Organization.write.jsonMergePatch" } } }, @@ -38201,18 +40385,32 @@ } } }, - "/api/organizations/{organizationId}/domains/{domainId}": { - "delete": { - "operationId": "organization_join_remove_domain", + "/api/organizations/{id}/restore": { + "post": { + "operationId": "restoreOrganization", "tags": [ - "Organization access" + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource deleted" + "description": "Organization restored \u2014 refreshed organization output returned", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" + } + } + }, + "links": {} }, "403": { - "description": "Forbidden", + "description": "Insufficient permissions \u2014 and, for an archived organization, held by every caller who is not a platform administrator" + }, + "404": { + "description": "Organization not found" + }, + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -38232,47 +40430,35 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "remove domain", - "description": "remove domain", + "summary": "Restore Organization", + "description": "Restores the organization to ACTIVE from SUSPENDED or ARCHIVED, as an explicit, dedicated action \u2014 coexists with (does not replace) the legacy isActive: true toggle on PATCH /organizations/{id}. Requires the organization.settings.write permission, the SAME permission the legacy toggle already requires \u2014 OR platform administrator privileges, which bypass it. The bypass is not a convenience: an ARCHIVED organization refuses organization.settings.write like every other write, so a platform administrator is the only caller who can reopen one. A SUSPENDED organization keeps its self-service path. Idempotent when already active.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "domainId", + "name": "id", "in": "path", - "description": "OrganizationJoin identifier", + "description": "Organization identifier", "required": true, "deprecated": false, "schema": { @@ -38284,24 +40470,33 @@ ] } }, - "/api/organizations/{organizationId}/domains/{domainId}/verify": { + "/api/organizations/{id}/suspend": { "post": { - "operationId": "organization_join_verify_domain", + "operationId": "suspendOrganization", "tags": [ - "Organization access" + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource created", + "description": "Organization suspended \u2014 refreshed organization output returned", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationDomainOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization not found" + }, + "409": { + "description": "Organization is archived \u2014 restore it first" + }, "400": { "description": "Invalid input", "content": { @@ -38343,48 +40538,15 @@ } }, "links": {} - }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "verify domain", - "description": "verify domain", + "summary": "Suspend Organization", + "description": "Suspends the organization as an explicit, dedicated action \u2014 coexists with (does not replace) the legacy isActive: false toggle on PATCH /organizations/{id}. Requires the organization.settings.write permission, the SAME permission the legacy toggle already requires. Idempotent when already suspended.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "domainId", + "name": "id", "in": "path", - "description": "OrganizationJoin identifier", + "description": "Organization identifier", "required": true, "deprecated": false, "schema": { @@ -38396,47 +40558,32 @@ ] } }, - "/api/organizations/{organizationId}/join": { + "/api/organizations/{id}/transfer-ownership": { "post": { - "operationId": "organization_join_join", + "operationId": "transferOrganizationOwnership", "tags": [ - "Organization access" + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource created", + "description": "Ownership transferred \u2014 refreshed organization output returned", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinCompletedOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } }, "links": {} }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "403": { + "description": "Caller is not the organization's current owner" + }, + "404": { + "description": "Organization not found, or the target user is not an active member" }, "422": { - "description": "An error occurred", + "description": "Missing or mismatched slug confirmation", "content": { "application/ld+json": { "schema": { @@ -38456,8 +40603,11 @@ }, "links": {} }, - "403": { - "description": "Forbidden", + "409": { + "description": "Organization is archived, or the target user already owns the organization" + }, + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -38478,13 +40628,13 @@ "links": {} } }, - "summary": "join", - "description": "join", + "summary": "Transfer Organization ownership", + "description": "Transfers ownership of the organization to another active member. Only the organization's CURRENT owner may call this \u2014 independent of RBAC permissions, since no permission grants the right to give away someone else's ownership. Requires a danger-zone confirmation: \"slug\" must exactly match the organization's current slug (case-insensitive, trimmed), mirroring DELETE /organizations/{id}. The new owner automatically receives the organization's system \"admin\" role if they do not already hold it.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "OrganizationJoin identifier", + "description": "Organization identifier", "required": true, "deprecated": false, "schema": { @@ -38493,22 +40643,53 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The new Organization resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Organization.TransferOrganizationOwnershipInput-Organization.write" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/join-requests": { + "/api/organizations/{organizationId}/audit-events": { "get": { - "operationId": "organization_join_organization_requests", + "operationId": "listOrganizationAuditEvents", "tags": [ - "Organization access" + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource", + "description": "OrganizationAuditEvent collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestsOutput.jsonld-Organization.read" + "type": "object", + "description": "OrganizationAuditEvent.OrganizationAuditEventOutput.jsonld-Organization.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationAuditEvent.OrganizationAuditEventOutput.jsonld-Organization.read" + } + } + } + } + ] } } } @@ -38533,36 +40714,15 @@ } }, "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "organization requests", - "description": "organization requests", + "summary": "List organization audit events", + "description": "Returns the organization's slice of the audit ledger, newest first, for the activity feed. Requires the `organization.audit.read` permission in the target organization (held by admins through `organization.*`). The payload is deliberately narrower than the platform-level audit API: actor email, IP address, user agent, and ledger chain internals are never exposed here, regardless of platform PII settings, and metadata keys carrying PII or request context are stripped.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "OrganizationJoin identifier", + "description": "OrganizationAuditEvent identifier", "required": true, "deprecated": false, "schema": { @@ -38570,197 +40730,112 @@ }, "style": "simple", "explode": false - } - ] - }, - "post": { - "operationId": "organization_join_request", - "tags": [ - "Organization access" - ], - "responses": { - "200": { - "description": "OrganizationJoin resource created", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" - } - } + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 }, - "links": {} + "style": "form", + "explode": true }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 }, - "links": {} + "style": "form", + "explode": true }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } + { + "name": "action", + "in": "query", + "description": "Optional exact audit action filter (e.g. organization.member_added).", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "organization.member_added" }, - "links": {} + "style": "form", + "explode": true }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + { + "name": "from", + "in": "query", + "description": "Optional inclusive ISO 8601 lower bound on the occurrence datetime.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" }, - "links": {} - } - }, - "summary": "request", - "description": "request", - "parameters": [ + "style": "form", + "explode": true + }, { - "name": "organizationId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, + "name": "to", + "in": "query", + "description": "Optional inclusive ISO 8601 upper bound on the occurrence datetime.", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true } ] } }, - "/api/organizations/{organizationId}/join-requests/{requestId}/approve": { - "post": { - "operationId": "organization_join_approve_request", + "/api/organizations/{organizationId}/audit-events/export": { + "get": { + "operationId": "exportOrganizationAuditEvents", "tags": [ - "Organization access" + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource created", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" - } - } - }, - "links": {} + "description": "CSV stream" }, "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Malformed date filter" + }, + "403": { + "description": "Member without the organization.audit.export permission" + }, + "404": { + "description": "Organization not found, or the caller is not an active member" }, "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} + "description": "The filters match more rows than the export cap" }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "204": { + "description": "OrganizationAuditEvent resource" } }, - "summary": "approve request", - "description": "approve request", + "summary": "Export organization audit events (CSV)", + "description": "Streams the organization's slice of the audit ledger as CSV, using the same filters as the list endpoint. Requires the `organization.audit.export` permission, which is deliberately separate from `organization.audit.read`: reading keeps the data inside the product, exporting takes a file out. The organization is taken from the URI and is not a filter the caller can widen \u2014 unlike the platform-level `/audit-events/export`, which builds its criteria from the request and is therefore reserved to platform operators. The columns match what the read endpoint exposes: no actor email, no IP address, no user agent, no ledger chain internals.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "OrganizationJoin identifier", + "description": "OrganizationAuditEvent identifier", "required": true, "deprecated": false, "schema": { @@ -38770,51 +40845,68 @@ "explode": false }, { - "name": "requestId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, + "name": "action", + "in": "query", + "description": "Optional exact audit action filter (e.g. organization.member_added).", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "example": "organization.member_added" }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The new OrganizationJoin resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationJoin.ApproveOrganizationJoinRequestInput-Organization.write" - } - } + "style": "form", + "explode": true }, - "required": true - } + { + "name": "from", + "in": "query", + "description": "Optional inclusive ISO 8601 lower bound on the occurrence datetime.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" + }, + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "Optional inclusive ISO 8601 upper bound on the occurrence datetime.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" + }, + "style": "form", + "explode": true + } + ] } }, - "/api/organizations/{organizationId}/join-requests/{requestId}/reject": { - "post": { - "operationId": "organization_join_reject_request", + "/api/organizations/{organizationId}/dashboard": { + "get": { + "operationId": "getOrganizationDashboard", "tags": [ - "Organization access" + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource created", + "description": "OrganizationDashboard resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationDashboard.OrganizationDashboardOutput.jsonld-Organization.read" } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -38834,29 +40926,8 @@ }, "links": {} }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} - }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -38877,13 +40948,13 @@ "links": {} } }, - "summary": "reject request", - "description": "reject request", + "summary": "Get organization dashboard", + "description": "Returns lightweight organization-overview KPI data. `overview`, `alerts`, and non-`period*` health KPIs are current snapshots evaluated at `generatedAt`; `comparison` and `period*` health KPIs follow the requested period. `overview` is normalized for frontend consumption and exposes only stable keys and `summary` metric lists. `alerts` expose machine-readable `code`/`severity`/`count`, `health` exposes an iterable `metrics` list, and `comparison.metrics` exposes stable KPI cards for inspections, facilities, members, and equipment with `key`/`label`/`value` plus raw `current`/`previous`/`delta` values. Those cards represent period-over-period changes for inspections performed, facilities created, members joined, and equipment created. Legacy non-conformity opened and resolved comparison entries remain present for backward compatibility. `comparison.health.metrics` exposes percentage-based health deltas. Analytics filters are section-scoped: `facilityType` affects facilities overview and facility comparison cards, `equipmentType` and `equipmentStatus` affect equipment overview and equipment comparison cards, `inspection*` filters affect inspection KPIs and period comparisons, and `nonConformity*` filters affect non-conformity KPIs, alerts, and period comparisons. `overview.nonConformities.severityLow`/`severityMedium`/`severityHigh`/`severityCritical` are the sole exception: an org-wide, unfiltered current-snapshot breakdown by severity across every status, unaffected by `nonConformityStatus`/`nonConformitySeverity`. By default, the endpoint returns the latest 30-day window in UTC and compares it with the previous period. The maximum supported period is 366 days. Datetime bounds must be ISO 8601 strings with an explicit timezone offset, including optional microseconds. Access requires `organization.dashboard.read` plus the underlying read permissions for members, roles, facilities, equipment, and inspections. For chart or sparkline data, use the dedicated `/dashboard/trends/*` endpoints, which require `organization.inspection.read`.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "OrganizationJoin identifier", + "description": "OrganizationDashboard identifier", "required": true, "deprecated": false, "schema": { @@ -38893,45 +40964,239 @@ "explode": false }, { - "name": "requestId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, + "name": "from", + "in": "query", + "description": "Inclusive ISO 8601 datetime lower bound for the period-scoped parts of the dashboard, with an explicit timezone offset. Optional microseconds are preserved. If omitted, defaults to 30 days before \"to\" or now. Maximum supported period: 366 days.", + "required": false, "deprecated": false, "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" + }, + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "Inclusive ISO 8601 datetime upper bound for the period-scoped parts of the dashboard, with an explicit timezone offset. Optional microseconds are preserved. If omitted, defaults to the current datetime. Maximum supported period: 366 days.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" + }, + "style": "form", + "explode": true + }, + { + "name": "compare", + "in": "query", + "description": "Whether to include previous-period scalar comparison metrics for KPI cards. Defaults to true.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": true, + "example": true + }, + "style": "form", + "explode": true + }, + { + "name": "timezone", + "in": "query", + "description": "IANA timezone used for rendered period values. Defaults to UTC when omitted and not implied by the request bounds. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "Europe/Paris" + }, + "style": "form", + "explode": true + }, + { + "name": "facilityType", + "in": "query", + "description": "Optional facility type filter applied to the facilities section only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ], + "example": "site" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentType", + "in": "query", + "description": "Optional equipment type filter applied to the equipment section only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire_extinguisher", + "smoke_detector", + "heat_detector", + "sprinkler", + "fire_alarm_panel", + "hydrant", + "fire_door", + "emergency_lighting", + "access_control", + "camera", + "gas_detector", + "other" + ], + "example": "fire_extinguisher" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentStatus", + "in": "query", + "description": "Optional equipment status filter applied to the equipment section only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "in_stock", + "operational", + "under_maintenance", + "decommissioned" + ], + "example": "operational" + }, + "style": "form", + "explode": true + }, + { + "name": "inspectionStatus", + "in": "query", + "description": "Optional inspection status filter applied to inspection KPIs, period health, and inspection comparisons.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "draft", + "submitted", + "closed" + ], + "example": "closed" + }, + "style": "form", + "explode": true + }, + { + "name": "inspectionResult", + "in": "query", + "description": "Optional inspection result filter applied to inspection KPIs, period health, and inspection comparisons.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "pass", + "fail", + "partial" + ], + "example": "pass" + }, + "style": "form", + "explode": true + }, + { + "name": "inspectorType", + "in": "query", + "description": "Optional inspector type filter applied to inspection KPIs, period health, and inspection comparisons.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "user", + "external" + ], + "example": "user" + }, + "style": "form", + "explode": true + }, + { + "name": "nonConformityStatus", + "in": "query", + "description": "Optional non-conformity status filter applied to non-conformity KPIs, alerts, period health, and non-conformity comparisons.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "open", + "in_progress", + "done", + "waived" + ], + "example": "open" + }, + "style": "form", + "explode": true + }, + { + "name": "nonConformitySeverity", + "in": "query", + "description": "Optional non-conformity severity filter applied to non-conformity KPIs, alerts, period health, and non-conformity comparisons.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "example": "critical" + }, + "style": "form", + "explode": true } ] } }, - "/api/auth/password/reset/confirm": { - "post": { - "operationId": "confirm_password_reset", + "/api/organizations/{organizationId}/dashboard/trends/equipment-created": { + "get": { + "operationId": "getOrganizationDashboardEquipmentCreatedTrend", "tags": [ - "Password Reset" + "Organization" ], "responses": { "200": { - "description": "Password reset confirmed successfully. All sessions have been revoked.", + "description": "OrganizationDashboardTrend resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordReset.ConfirmPasswordResetOutput.jsonld-token.read" + "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" } } - }, - "links": { - "Login": { - "operationId": "login", - "description": "Login with the new password" - } } }, - "400": { - "description": "Invalid request - missing or invalid parameters", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -38951,160 +41216,186 @@ }, "links": {} }, - "401": { - "description": "Invalid or expired token/code" - }, - "429": { - "description": "Too many password reset confirmation attempts or maximum verification attempts exceeded" - }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Confirm Password Reset", - "description": "Confirm password reset using the token, code, and new password. All active sessions will be terminated.", - "parameters": [], - "requestBody": { - "description": "The new PasswordReset resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/PasswordReset.ConfirmPasswordResetInput-password_reset.write" - } - } + "summary": "Get equipment created trend", + "description": "Returns the equipment-created trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.equipment.read`.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationDashboardTrend identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "required": true - } - } - }, - "/api/auth/password/reset/request": { - "post": { - "operationId": "request_password_reset", - "tags": [ - "Password Reset" - ], - "responses": { - "200": { - "description": "Password reset request processed successfully. If the email exists, a code has been sent.", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/PasswordReset.RequestPasswordResetOutput.jsonld-token.read" - } - } + { + "name": "from", + "in": "query", + "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" }, - "links": { - "ConfirmReset": { - "operationId": "confirm_password_reset", - "description": "Confirm the password reset using the token and code" - } - } + "style": "form", + "explode": true }, - "400": { - "description": "Invalid request - missing or invalid email", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + { + "name": "to", + "in": "query", + "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" }, - "links": {} + "style": "form", + "explode": true }, - "429": { - "description": "Too many password reset requests" + { + "name": "compare", + "in": "query", + "description": "Whether to include previous-period comparison series. Defaults to true.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": true, + "example": true + }, + "style": "form", + "explode": true }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } + { + "name": "granularity", + "in": "query", + "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "auto" + ], + "default": "day", + "example": "week" }, - "links": {} - } - }, - "summary": "Request Password Reset", - "description": "Request a password reset by email. An OTP code will be sent to the user's email if the account exists.", - "parameters": [], - "requestBody": { - "description": "The new PasswordReset resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/PasswordReset.RequestPasswordResetInput-password_reset.write" - } - } + "style": "form", + "explode": true }, - "required": true - } + { + "name": "timezone", + "in": "query", + "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "Europe/Paris" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentType", + "in": "query", + "description": "Optional equipment type filter applied to the equipment-created trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire_extinguisher", + "smoke_detector", + "heat_detector", + "sprinkler", + "fire_alarm_panel", + "hydrant", + "fire_door", + "emergency_lighting", + "access_control", + "camera", + "gas_detector", + "other" + ], + "example": "fire_extinguisher" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentStatus", + "in": "query", + "description": "Optional equipment status filter applied to the equipment-created trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "in_stock", + "operational", + "under_maintenance", + "decommissioned" + ], + "example": "operational" + }, + "style": "form", + "explode": true + } + ] } }, - "/api/auth/password/reset/resend": { - "post": { - "operationId": "resend_password_reset", + "/api/organizations/{organizationId}/dashboard/trends/facilities-created": { + "get": { + "operationId": "getOrganizationDashboardFacilitiesCreatedTrend", "tags": [ - "Password Reset" + "Organization" ], "responses": { "200": { - "description": "Password reset code resent successfully.", + "description": "OrganizationDashboardTrend resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordReset.RequestPasswordResetOutput.jsonld-token.read" + "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" } } - }, - "links": { - "ConfirmReset": { - "operationId": "confirm_password_reset", - "description": "Confirm the password reset using the new token and code" - } } }, - "400": { - "description": "Invalid request - missing or invalid token", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -39125,69 +41416,159 @@ "links": {} }, "404": { - "description": "Reset challenge not found or no longer valid" - }, - "429": { - "description": "Resend cooldown not yet elapsed" - }, - "422": { - "description": "An error occurred", + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Resend Password Reset Code", - "description": "Resend the password reset OTP code using the existing challenge token.", - "parameters": [], - "requestBody": { - "description": "The new PasswordReset resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/PasswordReset.ResendPasswordResetInput-password_reset.write" - } - } - }, - "required": true - } + "summary": "Get facilities created trend", + "description": "Returns the facilities-created trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.facilities.read`.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationDashboardTrend identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "from", + "in": "query", + "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" + }, + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" + }, + "style": "form", + "explode": true + }, + { + "name": "compare", + "in": "query", + "description": "Whether to include previous-period comparison series. Defaults to true.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": true, + "example": true + }, + "style": "form", + "explode": true + }, + { + "name": "granularity", + "in": "query", + "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "auto" + ], + "default": "day", + "example": "week" + }, + "style": "form", + "explode": true + }, + { + "name": "timezone", + "in": "query", + "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "Europe/Paris" + }, + "style": "form", + "explode": true + }, + { + "name": "facilityType", + "in": "query", + "description": "Optional facility type filter applied to the facilities-created trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ], + "example": "site" + }, + "style": "form", + "explode": true + } + ] } }, - "/api/auth/password/setup": { - "post": { - "operationId": "request_initial_password", + "/api/organizations/{organizationId}/dashboard/trends/inspections": { + "get": { + "operationId": "getOrganizationDashboardInspectionsTrend", "tags": [ - "PasswordSetup" + "Organization" ], "responses": { "200": { - "description": "PasswordSetup resource created", + "description": "OrganizationDashboardTrend resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordSetup.RequestPasswordChangeOutput.jsonld-token.read" + "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -39207,26 +41588,190 @@ }, "links": {} }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} + } + }, + "summary": "Get inspections trend", + "description": "Returns the inspections-performed trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.inspection.read`.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationDashboardTrend identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "from", + "in": "query", + "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" + }, + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" + }, + "style": "form", + "explode": true + }, + { + "name": "compare", + "in": "query", + "description": "Whether to include previous-period comparison series. Defaults to true.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": true, + "example": true + }, + "style": "form", + "explode": true + }, + { + "name": "granularity", + "in": "query", + "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "auto" + ], + "default": "day", + "example": "week" + }, + "style": "form", + "explode": true + }, + { + "name": "timezone", + "in": "query", + "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "Europe/Paris" + }, + "style": "form", + "explode": true + }, + { + "name": "inspectionStatus", + "in": "query", + "description": "Optional inspection status filter applied to the inspection trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "draft", + "submitted", + "closed" + ], + "example": "closed" + }, + "style": "form", + "explode": true + }, + { + "name": "inspectionResult", + "in": "query", + "description": "Optional inspection result filter applied to the inspection trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "pass", + "fail", + "partial" + ], + "example": "pass" + }, + "style": "form", + "explode": true + }, + { + "name": "inspectorType", + "in": "query", + "description": "Optional inspector type filter applied to the inspection trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "user", + "external" + ], + "example": "user" + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/dashboard/trends/non-conformities-opened": { + "get": { + "operationId": "getOrganizationDashboardNonConformitiesOpenedTrend", + "tags": [ + "Organization" + ], + "responses": { + "200": { + "description": "OrganizationDashboardTrend resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" + } + } + } }, "403": { "description": "Forbidden", @@ -39248,33 +41793,9 @@ } }, "links": {} - } - }, - "summary": "Creates a PasswordSetup resource.", - "description": "Creates a PasswordSetup resource.", - "parameters": [] - } - }, - "/api/auth/password/setup/confirm": { - "post": { - "operationId": "confirm_initial_password", - "tags": [ - "PasswordSetup" - ], - "responses": { - "200": { - "description": "PasswordSetup resource created", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/PasswordSetup.ConfirmPasswordChangeOutput.jsonld-token.read" - } - } - }, - "links": {} }, - "400": { - "description": "Invalid input", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -39293,30 +41814,192 @@ } }, "links": {} + } + }, + "summary": "Get non-conformities opened trend", + "description": "Returns the non-conformities-opened trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.inspection.read`.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationDashboardTrend identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "422": { - "description": "An error occurred", + { + "name": "from", + "in": "query", + "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" + }, + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" + }, + "style": "form", + "explode": true + }, + { + "name": "compare", + "in": "query", + "description": "Whether to include previous-period comparison series. Defaults to true.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": true, + "example": true + }, + "style": "form", + "explode": true + }, + { + "name": "granularity", + "in": "query", + "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "auto" + ], + "default": "day", + "example": "week" + }, + "style": "form", + "explode": true + }, + { + "name": "timezone", + "in": "query", + "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "Europe/Paris" + }, + "style": "form", + "explode": true + }, + { + "name": "nonConformityStatus", + "in": "query", + "description": "Optional non-conformity status filter applied to the non-conformity trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "open", + "in_progress", + "done", + "waived" + ], + "example": "open" + }, + "style": "form", + "explode": true + }, + { + "name": "nonConformitySeverity", + "in": "query", + "description": "Optional non-conformity severity filter applied to the non-conformity trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "example": "critical" + }, + "style": "form", + "explode": true + }, + { + "name": "metrics", + "in": "query", + "description": "Optional comma-separated list of additional non-conformity metric identifiers to combine into `seriesByMetric`, sharing this call's resolved period, timezone and granularity so a two-series (opened vs resolved) chart can render from one request instead of two independently-bucketed calls. Allowed values: non_conformities_opened, non_conformities_resolved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "non_conformities_opened,non_conformities_resolved" + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/dashboard/trends/non-conformities-resolved": { + "get": { + "operationId": "getOrganizationDashboardNonConformitiesResolvedTrend", + "tags": [ + "Organization" + ], + "responses": { + "200": { + "description": "OrganizationDashboardTrend resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -39337,219 +42020,281 @@ "links": {} } }, - "summary": "Creates a PasswordSetup resource.", - "description": "Creates a PasswordSetup resource.", - "parameters": [], - "requestBody": { - "description": "The new PasswordSetup resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/PasswordSetup.ConfirmPasswordChangeInput-password_change.write" - } - } + "summary": "Get non-conformities resolved trend", + "description": "Returns the non-conformities-resolved trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.inspection.read`.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationDashboardTrend identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "required": true - } - } - }, - "/api/plans": { - "get": { - "operationId": "listPlans", - "tags": [ - "Plan" - ], - "responses": { - "200": { - "description": "Plans retrieved", - "content": { - "application/ld+json": { - "schema": { - "type": "object", - "description": "Plan.PlanOutput.jsonld-Plan.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" - } - } - } - } - ] - } - } - } + { + "name": "from", + "in": "query", + "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" + }, + "style": "form", + "explode": true }, - "401": { - "description": "Authentication required" + { + "name": "to", + "in": "query", + "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" + }, + "style": "form", + "explode": true }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + { + "name": "compare", + "in": "query", + "description": "Whether to include previous-period comparison series. Defaults to true.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": true, + "example": true }, - "links": {} + "style": "form", + "explode": true + }, + { + "name": "granularity", + "in": "query", + "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "auto" + ], + "default": "day", + "example": "week" + }, + "style": "form", + "explode": true + }, + { + "name": "timezone", + "in": "query", + "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "Europe/Paris" + }, + "style": "form", + "explode": true + }, + { + "name": "nonConformityStatus", + "in": "query", + "description": "Optional non-conformity status filter applied to the non-conformity trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "open", + "in_progress", + "done", + "waived" + ], + "example": "open" + }, + "style": "form", + "explode": true + }, + { + "name": "nonConformitySeverity", + "in": "query", + "description": "Optional non-conformity severity filter applied to the non-conformity trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "example": "critical" + }, + "style": "form", + "explode": true + }, + { + "name": "metrics", + "in": "query", + "description": "Optional comma-separated list of additional non-conformity metric identifiers to combine into `seriesByMetric`, sharing this call's resolved period, timezone and granularity so a two-series (opened vs resolved) chart can render from one request instead of two independently-bucketed calls. Allowed values: non_conformities_opened, non_conformities_resolved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "non_conformities_opened,non_conformities_resolved" + }, + "style": "form", + "explode": true } - }, - "summary": "List plans", - "description": "Lists subscription plans. Administrators see every plan; regular users see selectable plans only.", - "parameters": [] - }, + ] + } + }, + "/api/organizations/{organizationId}/logo": { "post": { - "operationId": "createPlan", + "operationId": "uploadOrganizationLogo", "tags": [ - "Plan" + "Organization" ], "responses": { - "201": { - "description": "Plan created", + "200": { + "description": "Logo uploaded \u2014 organization output with updated logoUrl returned", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } }, "links": {} }, - "400": { - "description": "Invalid request - validation failed", + "422": { + "description": "Invalid file \u2014 missing, too large, or unsupported MIME type", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} }, - "409": { - "description": "Plan key already in use" - }, "403": { - "description": "Administrator privileges required" + "description": "Insufficient permissions" }, - "422": { - "description": "An error occurred", + "404": { + "description": "Organization not found" + }, + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Create plan", - "description": "Creates a subscription plan. Requires platform administrator privileges.", - "parameters": [], + "summary": "Upload Organization logo", + "description": "Uploads a logo image for the organization. Accepted formats: JPEG, PNG, WebP, GIF (max 5 MB). The image is scaled down to a single WebP variant. Requires the organization.settings.write permission.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Organization identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new Plan resource", + "description": "Logo image file (JPEG, PNG, WebP or GIF, max 5 MB)", "content": { - "application/ld+json": { + "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Plan.CreatePlanInput-Plan.write" + "type": "object", + "required": [ + "logo" + ], + "properties": { + "logo": { + "type": "string", + "format": "binary", + "description": "Image file (JPEG, PNG, WebP or GIF), max 5 MB" + } + } } } }, "required": true } - } - }, - "/api/plans/{id}": { - "get": { - "operationId": "getPlan", + }, + "delete": { + "operationId": "removeOrganizationLogo", "tags": [ - "Plan" + "Organization" ], "responses": { - "200": { - "description": "Plan retrieved", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" - } - } - } + "204": { + "description": "Logo removed" + }, + "403": { + "description": "Insufficient permissions" }, "404": { - "description": "Plan not found" + "description": "Organization not found" }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "409": { + "description": "Organization is archived \u2014 restore it first" } }, - "summary": "Get plan", - "description": "Returns a single subscription plan by identifier.", + "summary": "Remove Organization logo", + "description": "Removes the organization logo. Requires the organization.settings.write permission (the same permission required to upload it). Idempotent when the organization has no logo.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Plan identifier", + "description": "Organization identifier", "required": true, "deprecated": false, "schema": { @@ -39559,33 +42304,32 @@ "explode": false } ] - }, - "delete": { - "operationId": "deletePlan", + } + }, + "/api/organizations/{organizationId}/logo.webp": { + "get": { + "operationId": "getOrganizationLogo", "tags": [ - "Plan" + "Organization" ], "responses": { - "204": { - "description": "Plan deleted" - }, - "409": { - "description": "The default plan cannot be deleted" - }, - "403": { - "description": "Administrator privileges required" + "200": { + "description": "Logo image (WebP)" }, "404": { - "description": "Plan not found" + "description": "Organization logo not found" + }, + "204": { + "description": "Organization resource" } }, - "summary": "Delete plan", - "description": "Deletes a subscription plan. The default plan cannot be deleted. Requires platform administrator privileges.", + "summary": "Get Organization logo", + "description": "Streams the organization logo as WebP. Public endpoint.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Plan identifier", + "description": "Organization identifier", "required": true, "deprecated": false, "schema": { @@ -39595,80 +42339,60 @@ "explode": false } ] - }, - "patch": { - "operationId": "updatePlan", + } + }, + "/api/organizations/{organizationId}/navigation-counters": { + "get": { + "operationId": "getOrganizationNavigationCounters", "tags": [ - "Plan" + "Organization" ], "responses": { "200": { - "description": "Plan updated", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid request - validation failed", + "description": "Navigation counters retrieved successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/OrganizationNavigationCounters.OrganizationNavigationCountersOutput.jsonld-Organization.read" } } - }, - "links": {} + } }, - "403": { - "description": "Administrator privileges required" + "401": { + "description": "Authentication required" }, "404": { - "description": "Plan not found" + "description": "Organization not found or the authenticated user is not an active member" }, - "422": { - "description": "An error occurred", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Update plan", - "description": "Updates a subscription plan. Requires platform administrator privileges.", + "summary": "Get organization sidebar navigation counters", + "description": "Returns lightweight badge counters for the frontend sidebar navigation: `openInterventions` (field interventions not yet `published`/`abandoned`) and `openNonConformities` (non-conformities currently `open` or `in_progress`). The caller only needs to be an ACTIVE member of the organization; each counter individually falls back to 0 (never a 403) when the caller lacks the underlying read permission (`organization.interventions.read` / `organization.inspection.read` respectively), mirroring how the organization dashboard degrades its own interventions section.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Plan identifier", + "description": "OrganizationNavigationCounters identifier", "required": true, "deprecated": false, "schema": { @@ -39678,58 +42402,35 @@ "explode": false } ], - "requestBody": { - "description": "The updated Plan resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Plan.UpdatePlanInput-Plan.write.jsonMergePatch" - } - } - }, - "required": true - } + "security": [ + { + "bearerAuth": [] + } + ] } }, - "/api/presence": { + "/api/organizations/{organizationId}/quota": { "get": { - "operationId": "messaging_get_presence", + "operationId": "getOrganizationQuota", "tags": [ - "Presence" + "Organization" ], "responses": { "200": { - "description": "Presence collection", + "description": "Quota usage retrieved", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "Presence.PresenceOutput.jsonld collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Presence.PresenceOutput.jsonld" - } - } - } - } - ] + "$ref": "#/components/schemas/Organization.OrganizationQuotaOutput.jsonld-Organization.read" } } } }, "403": { - "description": "Forbidden", + "description": "Insufficient permissions" + }, + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -39750,49 +42451,117 @@ "links": {} } }, - "summary": "Retrieves the collection of Presence resources.", - "description": "Retrieves the collection of Presence resources.", + "summary": "Get Organization quota usage", + "description": "Returns the current usage and plan limit of each capped resource (members, facilities, equipment, inspections). Requires the organization.read permission.", "parameters": [ { - "name": "organization", - "in": "query", - "description": "Organization IRI.", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "memberIds", - "in": "query", - "description": "Comma-separated organization member ids to check presence for (max 100). Required \u2014 there is no \"list all online members\" mode.", + "name": "organizationId", + "in": "path", + "description": "Organization identifier", "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/search": { + "get": { + "operationId": "searchOrganization", + "tags": [ + "Organization" + ], + "responses": { + "200": { + "description": "Search results retrieved", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationSearch.OrganizationSearchOutput.jsonld-Organization.read" + } + } + } + }, + "400": { + "description": "Missing q parameter, or q shorter than 2 / longer than 100 characters" + }, + "401": { + "description": "Authentication required" + }, + "404": { + "description": "Organization not found or the caller is not an active member \u2014 deliberately not 403, which would confirm it exists" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Search across an organization", + "description": "Case-insensitive free-text search returning a flat `results` list, grouped by type in a stable order (`equipment`, `facility`, `intervention`, `inspection`, `non_conformity`), with at most 5 hits per type ordered by most recent update. Each hit carries `type`, `id`, `title` and optional `subtitle`/`extra`; the frontend builds the target route from `type` + `id` \u2014 the API ships no URL. Matched fields per type: equipment type/brand/model/serial number/location label; facility name/code/address; intervention name/number; inspection checklist reference code or inspection id; non-conformity description. The caller must be an ACTIVE member of the organization (a non-member gets 404, never a hint the organization exists); each type is then individually soft-gated on its read permission (`organization.equipment.read`, `organization.facilities.read`, `organization.interventions.read`, `organization.inspection.read` for both inspections and non-conformities) \u2014 a member without a permission simply gets no rows of that type, never an error.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationSearch identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "q", + "in": "query", + "description": "The free-text search term, 2 to 100 characters after trimming.", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "minLength": 2, + "maxLength": 100 + }, + "style": "form", "explode": true } ] } }, - "/api/presence/ping": { + "/api/organizations/invitations/accept": { "post": { - "operationId": "messaging_ping_presence", + "operationId": "acceptOrganizationInvitation", "tags": [ - "Presence" + "Organization Invitations" ], "responses": { - "200": { - "description": "Presence resource created", + "201": { + "description": "OrganizationInvitation resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Presence.PingPresenceOutput.jsonld" + "$ref": "#/components/schemas/OrganizationInvitation.OrganizationMemberOutput.jsonld-Organization.read" } } }, @@ -39862,15 +42631,15 @@ "links": {} } }, - "summary": "Creates a Presence resource.", - "description": "Creates a Presence resource.", + "summary": "Accept Organization invitation", + "description": "Accepts an invitation token and creates/updates organization membership for the authenticated user.", "parameters": [], "requestBody": { - "description": "The new Presence resource", + "description": "The new OrganizationInvitation resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Presence.PingPresenceInput" + "$ref": "#/components/schemas/OrganizationInvitation.AcceptOrganizationInvitationInput-Organization.write" } } }, @@ -39878,19 +42647,19 @@ } } }, - "/api/presence/subscription": { + "/api/organizations/invitations/{token}/preview": { "get": { - "operationId": "messaging_presence_subscription", + "operationId": "getOrganizationInvitationPreview", "tags": [ - "Presence" + "Organization Invitations" ], "responses": { "200": { - "description": "Presence resource", + "description": "OrganizationInvitation resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Presence.PresenceSubscriptionOutput.jsonld-presence.read" + "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationPreviewOutput.jsonld-Organization.read" } } } @@ -39938,37 +42707,57 @@ "links": {} } }, - "summary": "Retrieves a Presence resource.", - "description": "Retrieves a Presence resource.", + "summary": "Preview Organization invitation", + "description": "Returns a minimal, public-safe preview of an invitation resolved by token (organization, inviter, invited email, status, expiry, granted role names).", "parameters": [ { - "name": "organization", - "in": "query", - "description": "Presence organization", + "name": "token", + "in": "path", + "description": "OrganizationInvitation identifier", "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] } }, - "/api/me/presence-preference": { + "/api/organizations/{organizationId}/invitations": { "get": { - "operationId": "user_get_presence_preference", + "operationId": "listOrganizationInvitations", "tags": [ - "PresencePreference" + "Organization Invitations" ], "responses": { "200": { - "description": "PresencePreference resource", + "description": "OrganizationInvitation collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PresencePreference.PresencePreferenceOutput.jsonld-presence-preference.read" + "type": "object", + "description": "OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" + } + } + } + } + ] } } } @@ -39993,45 +42782,65 @@ } }, "links": {} + } + }, + "summary": "List Organization invitations", + "description": "Lists invitations created for an Organization.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationInvitation identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 }, - "links": {} + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true } - }, - "summary": "Retrieves a PresencePreference resource.", - "description": "Retrieves a PresencePreference resource.", - "parameters": [] + ] }, - "patch": { - "operationId": "user_update_presence_preference", + "post": { + "operationId": "inviteOrganizationMember", "tags": [ - "PresencePreference" + "Organization Invitations" ], "responses": { - "200": { - "description": "PresencePreference resource updated", + "201": { + "description": "OrganizationInvitation resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PresencePreference.PresencePreferenceOutput.jsonld-presence-preference.read" + "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" } } }, @@ -40099,38 +42908,30 @@ } }, "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "Updates the PresencePreference resource.", - "description": "Updates the PresencePreference resource.", - "parameters": [], + "summary": "Invite Organization member", + "description": "Invites a user by email to join an Organization with one or more roles.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationInvitation identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The updated PresencePreference resource", + "description": "The new OrganizationInvitation resource", "content": { - "application/merge-patch+json": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PresencePreference.UpdatePresencePreferenceInput-presence-preference.write.jsonMergePatch" + "$ref": "#/components/schemas/OrganizationInvitation.InviteOrganizationMemberInput-Organization.write" } } }, @@ -40138,25 +42939,26 @@ } } }, - "/api/me/presence-preference/subscription": { - "get": { - "operationId": "user_presence_preference_subscription", + "/api/organizations/{organizationId}/invitations/{invitationId}/resend": { + "post": { + "operationId": "resendOrganizationInvitation", "tags": [ - "PresencePreference" + "Organization Invitations" ], "responses": { "200": { - "description": "PresencePreference resource", + "description": "OrganizationInvitation resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PresencePreference.PresencePreferenceSubscriptionOutput.jsonld-presence-preference.read" + "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" } } - } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -40176,8 +42978,29 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -40198,24 +43021,49 @@ "links": {} } }, - "summary": "Retrieves a PresencePreference resource.", - "description": "Retrieves a PresencePreference resource.", - "parameters": [] + "summary": "Resend Organization invitation", + "description": "Regenerates the token, resets the expiry and re-sends the invitation email, returning a fresh accept link.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationInvitation identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "invitationId", + "in": "path", + "description": "OrganizationInvitation identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/publications": { + "/api/organizations/{organizationId}/invitations/{invitationId}/revoke": { "post": { - "operationId": "publication_create", + "operationId": "revokeOrganizationInvitation", "tags": [ - "Publication" + "Organization Invitations" ], "responses": { - "202": { - "description": "Publication resource created", + "200": { + "description": "OrganizationInvitation resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Publication.PublicationOutput.jsonld" + "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" } } }, @@ -40285,62 +43133,61 @@ "links": {} } }, - "summary": "Creates a Publication resource.", - "description": "Creates a Publication resource.", - "parameters": [], - "requestBody": { - "description": "The new Publication resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Publication.CreatePublicationInput" - } - } + "summary": "Revoke Organization invitation", + "description": "Revokes a pending invitation.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationInvitation identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "required": true - } + { + "name": "invitationId", + "in": "path", + "description": "OrganizationInvitation identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/publications/{id}": { + "/api/organizations/{organizationId}/me": { "get": { - "operationId": "publication_get", + "operationId": "getCurrentOrganizationMemberProfile", "tags": [ - "Publication" + "Organization Members" ], "responses": { "200": { - "description": "Publication resource", + "description": "Current organization member profile retrieved successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Publication.PublicationOutput.jsonld" + "$ref": "#/components/schemas/CurrentOrganizationMemberProfile.CurrentOrganizationMemberProfileOutput.jsonld-Organization.read" } } } }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "401": { + "description": "Authentication required" }, "404": { - "description": "Not found", + "description": "Organization not found or the authenticated user is not an active member" + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -40361,13 +43208,13 @@ "links": {} } }, - "summary": "Retrieves a Publication resource.", - "description": "Retrieves a Publication resource.", + "summary": "Get current organization member profile", + "description": "Returns the authenticated active member profile in the target organization, including resolved roles and effective permissions.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Publication identifier", + "description": "CurrentOrganizationMemberProfile identifier", "required": true, "deprecated": false, "schema": { @@ -40376,34 +43223,53 @@ "style": "simple", "explode": false } + ], + "security": [ + { + "bearerAuth": [] + } ] } }, - "/api/auth/register": { - "post": { - "operationId": "register_user", + "/api/organizations/{organizationId}/members": { + "get": { + "operationId": "listOrganizationMembers", "tags": [ - "Registration" + "Organization Members" ], "responses": { - "201": { - "description": "Account created. A verification code has been sent to the email.", + "200": { + "description": "OrganizationMember collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Registration.RegisterOutput.jsonld-token.read" + "type": "object", + "description": "OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" + } + } + } + } + ] } } - }, - "links": { - "ConfirmRegistration": { - "operationId": "confirm_registration", - "description": "Verify the email using the token and code" - } } }, - "400": { - "description": "Invalid request - validation failed", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -40422,71 +43288,72 @@ } }, "links": {} - }, - "409": { - "description": "An account already exists with this email" - }, - "429": { - "description": "Too many registration requests" - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} } }, - "summary": "Register a new account", - "description": "Creates a pending-verification account and emails an OTP verification code.", - "parameters": [], - "requestBody": { - "description": "The new Registration resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Registration.RegisterInput-registration.write" - } - } + "summary": "List Organization members", + "description": "Lists Organization members and assigned roles. Supports `search` (matched against the member's user identifier), `status` (`active`, `inactive`, or `all`), `roleId`, and `order[joinedAt|displayName]=asc|desc` (default `order[joinedAt]=asc`).", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationMember identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "required": true - } - } - }, - "/api/auth/register/resend": { + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ] + }, "post": { - "operationId": "resend_registration", + "operationId": "addOrganizationMember", "tags": [ - "Registration" + "Organization Members" ], "responses": { - "200": { - "description": "A new verification code has been sent.", + "201": { + "description": "OrganizationMember resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Registration.RegisterOutput.jsonld-token.read" + "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid request - missing or invalid token", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -40506,12 +43373,6 @@ }, "links": {} }, - "404": { - "description": "Verification challenge not found or no longer valid" - }, - "429": { - "description": "Resend cooldown not yet elapsed" - }, "422": { "description": "An error occurred", "content": { @@ -40532,17 +43393,51 @@ } }, "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Resend the verification code", - "description": "Resends the email-verification OTP code using the existing challenge token.", - "parameters": [], + "summary": "Add Organization member", + "description": "Adds an existing user to a Organization and assigns one or more roles.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationMember identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new Registration resource", + "description": "The new OrganizationMember resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Registration.ResendRegistrationInput-registration.write" + "$ref": "#/components/schemas/OrganizationMember.AddOrganizationMemberInput-Organization.write" } } }, @@ -40550,31 +43445,26 @@ } } }, - "/api/auth/register/verify": { + "/api/organizations/{organizationId}/members/batch-remove": { "post": { - "operationId": "confirm_registration", + "operationId": "batchRemoveOrganizationMembers", "tags": [ - "Registration" + "Organization Members" ], "responses": { "200": { - "description": "Email verified. Access token returned and refresh token cookie set.", + "description": "OrganizationMember resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Registration.LoginOutput.jsonld-token.read" + "$ref": "#/components/schemas/OrganizationMember.RemoveOrganizationMembersOutput.jsonld-Organization.read" } } }, - "links": { - "RefreshToken": { - "operationId": "refresh", - "description": "Use the refresh token cookie to get a new access token" - } - } + "links": {} }, "400": { - "description": "Invalid request - missing or invalid parameters", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -40594,12 +43484,6 @@ }, "links": {} }, - "401": { - "description": "Invalid or expired token/code" - }, - "429": { - "description": "Maximum verification attempts exceeded" - }, "422": { "description": "An error occurred", "content": { @@ -40620,17 +43504,51 @@ } }, "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Verify email and log in", - "description": "Verifies the OTP code, activates the account, and returns an access token (and refresh token cookie) so the user is logged in automatically.", - "parameters": [], + "summary": "Batch remove Organization members", + "description": "Removes several members in one request, reporting removed and failed IDs.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationMember identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new Registration resource", + "description": "The new OrganizationMember resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Registration.ConfirmRegistrationInput-registration.write" + "$ref": "#/components/schemas/OrganizationMember.RemoveOrganizationMembersInput-Organization.write" } } }, @@ -40638,15 +43556,21 @@ } } }, - "/api/organizations/{organizationId}/compliance/export": { - "get": { - "operationId": "exportOrganizationSafetyRegister", + "/api/organizations/{organizationId}/members/me": { + "delete": { + "operationId": "leaveOrganization", "tags": [ - "SafetyRegisterExport" + "Organization Members" ], "responses": { "204": { - "description": "SafetyRegisterExport resource" + "description": "Membership deactivated" + }, + "404": { + "description": "Organization not found, or caller is not an active member" + }, + "409": { + "description": "Caller is the organization's current owner, or is the last active administrator" }, "403": { "description": "Forbidden", @@ -40668,36 +43592,117 @@ } }, "links": {} + } + }, + "summary": "Leave Organization", + "description": "Deactivates the authenticated user's own membership (self-removal). The organization's current owner cannot leave \u2014 transfer ownership first via POST /organizations/{id}/transfer-ownership. Refused when leaving would strip the organization of its last active administrator.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationMember identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/members/{memberId}": { + "get": { + "operationId": "getOrganizationMember", + "tags": [ + "Organization Members" + ], + "responses": { + "403": { + "description": "Insufficient permissions" }, "404": { - "description": "Not found", + "description": "Organization not found, or member not found in this organization" + }, + "200": { + "description": "OrganizationMember resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" } } + } + } + }, + "summary": "Get Organization member", + "description": "Resolves a single organization member by identifier.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationMember identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" }, - "links": {} + "style": "simple", + "explode": false + }, + { + "name": "memberId", + "in": "path", + "description": "OrganizationMember identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "delete": { + "operationId": "removeOrganizationMember", + "tags": [ + "Organization Members" + ], + "responses": { + "204": { + "description": "Member removed" + }, + "400": { + "description": "Invalid identifier" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization or member not found" } }, - "summary": "Retrieves a SafetyRegisterExport resource.", - "description": "Retrieves a SafetyRegisterExport resource.", + "summary": "Remove Organization member", + "description": "Deactivates an organization member. The membership record is retained for audit purposes.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "SafetyRegisterExport identifier", + "description": "OrganizationMember identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "memberId", + "in": "path", + "description": "OrganizationMember identifier", "required": true, "deprecated": false, "schema": { @@ -40709,18 +43714,35 @@ ] } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/compliance/export": { - "get": { - "operationId": "exportFacilitySafetyRegister", + "/api/organizations/{organizationId}/members/{memberId}/reactivate": { + "post": { + "operationId": "reactivateOrganizationMember", "tags": [ - "SafetyRegisterExport" + "Organization Members" ], "responses": { - "204": { - "description": "SafetyRegisterExport resource" + "200": { + "description": "Member reactivated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" + } + } + }, + "links": {} }, "403": { - "description": "Forbidden", + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization not found, or member not found in this organization" + }, + "409": { + "description": "Organization is archived, member is already active, or the plan's member cap has been reached" + }, + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -40740,35 +43762,35 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Retrieves a SafetyRegisterExport resource.", - "description": "Retrieves a SafetyRegisterExport resource.", + "summary": "Reactivate Organization member", + "description": "Reactivates a previously deactivated (removed) organization member. Subject to the same plan member cap as adding a new member.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "SafetyRegisterExport identifier", + "description": "OrganizationMember identifier", "required": true, "deprecated": false, "schema": { @@ -40778,9 +43800,9 @@ "explode": false }, { - "name": "facilityId", + "name": "memberId", "in": "path", - "description": "SafetyRegisterExport identifier", + "description": "OrganizationMember identifier", "required": true, "deprecated": false, "schema": { @@ -40792,20 +43814,20 @@ ] } }, - "/api/sessions": { + "/api/organizations/{organizationId}/permissions": { "get": { - "operationId": "session_list", + "operationId": "listOrganizationPermissions", "tags": [ - "Sessions" + "Organization Permissions" ], "responses": { "200": { - "description": "List of sessions retrieved successfully", + "description": "OrganizationPermission collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Session.SessionOutput.jsonld-session.read collection.", + "description": "OrganizationPermission.OrganizationPermissionOutput.jsonld-Organization.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -40819,7 +43841,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Session.SessionOutput.jsonld-session.read" + "$ref": "#/components/schemas/OrganizationPermission.OrganizationPermissionOutput.jsonld-Organization.read" } } } @@ -40829,68 +43851,85 @@ } } }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "List sessions", - "description": "Returns all active sessions for the authenticated user.", + "summary": "List available Organization permissions", + "description": "Lists all system-defined permissions that can be assigned to organization roles.", "parameters": [ { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, + "name": "organizationId", + "in": "path", + "description": "OrganizationPermission identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 1 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "itemsPerPage", + "name": "page", "in": "query", - "description": "The number of items per page", + "description": "The collection page number", "required": false, "deprecated": false, "schema": { "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "default": 1 }, "style": "form", "explode": true } - ], - "security": [ - { - "bearerAuth": [] - } ] } }, - "/api/sessions/revoke-all": { - "post": { - "operationId": "session_revoke_all", + "/api/organizations/{organizationId}/members/{memberId}/roles": { + "put": { + "operationId": "setOrganizationMemberRoles", "tags": [ - "Sessions" + "Organization Roles" ], "responses": { - "204": { - "description": "All sessions revoked successfully", + "200": { + "description": "Member roles replaced", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationRole.OrganizationMemberOutput.jsonld-Organization.read" + } + } + }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" + "description": "Insufficient permissions, or attempting to grant a permission the caller does not hold" + }, + "404": { + "description": "Organization, member, or one of the requested roles not found" + }, + "409": { + "description": "Would strip the organization of its last administrator" }, "400": { "description": "Invalid input", @@ -40935,39 +43974,62 @@ "links": {} } }, - "summary": "Revoke all sessions", - "description": "Revoke all sessions for the authenticated user (logout from all devices).", - "parameters": [], - "security": [ + "summary": "Replace member roles", + "description": "Replaces the member's entire role set in one call. Roles being granted go through the privilege-escalation guard; roles being revoked go through the last-administrator lockout guard.", + "parameters": [ { - "bearerAuth": [] - } - ] - } - }, - "/api/sessions/revoke-others": { - "post": { - "operationId": "session_revoke_others", - "tags": [ - "Sessions" - ], - "responses": { - "200": { - "description": "Other sessions revoked successfully", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Session.RevokeOtherSessionsOutput.jsonld-session.revoke_others" - } - } - }, - "links": {} + "name": "organizationId", + "in": "path", + "description": "OrganizationRole identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "401": { - "description": "Authentication required" + { + "name": "memberId", + "in": "path", + "description": "OrganizationRole identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated OrganizationRole resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationRole.SetOrganizationMemberRolesInput-Organization.write" + } + } }, - "403": { - "description": "Insufficient permissions" + "required": true + } + }, + "post": { + "operationId": "assignOrganizationRoleToMember", + "tags": [ + "Organization Roles" + ], + "responses": { + "201": { + "description": "OrganizationRole resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationRole.OrganizationMemberOutput.jsonld-Organization.read" + } + } + }, + "links": {} }, "400": { "description": "Invalid input", @@ -41010,52 +44072,36 @@ } }, "links": {} - } - }, - "summary": "Revoke other sessions", - "description": "Revokes every active session of the authenticated user except the one backing the current request, signing the user out on every other device while staying signed in here. Idempotent: calling it again once there is nothing else to revoke returns a zero count.", - "parameters": [], - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/sessions/{id}": { - "get": { - "operationId": "session_get", - "tags": [ - "Sessions" - ], - "responses": { - "200": { - "description": "Session retrieved successfully", + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Session.SessionOutput.jsonld-session.read" + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } - }, - "404": { - "description": "Session not found" - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" + }, + "links": {} } }, - "summary": "Get session", - "description": "Get details of a specific session.", + "summary": "Assign role to member", + "description": "Assigns an existing Organization role to an existing Organization member.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Session identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -41063,40 +44109,60 @@ }, "style": "simple", "explode": false - } - ], - "security": [ + }, { - "bearerAuth": [] + "name": "memberId", + "in": "path", + "description": "OrganizationRole identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } - ] - }, + ], + "requestBody": { + "description": "The new OrganizationRole resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationRole.AssignOrganizationRoleInput-Organization.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/members/{memberId}/roles/{roleId}": { "delete": { - "operationId": "session_revoke", + "operationId": "removeOrganizationRoleFromMember", "tags": [ - "Sessions" + "Organization Roles" ], "responses": { "204": { - "description": "Session revoked successfully" - }, - "404": { - "description": "Session not found" + "description": "Role removed from member" }, - "401": { - "description": "Authentication required" + "400": { + "description": "Invalid identifier" }, "403": { "description": "Insufficient permissions" + }, + "404": { + "description": "Organization, member, or role not found" } }, - "summary": "Revoke session", - "description": "Revoke a specific session. The user will be logged out from that device.", + "summary": "Remove role from member", + "description": "Removes a role assignment from an organization member.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Session identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -41104,29 +44170,48 @@ }, "style": "simple", "explode": false - } - ], - "security": [ + }, { - "bearerAuth": [] + "name": "memberId", + "in": "path", + "description": "OrganizationRole identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "roleId", + "in": "path", + "description": "OrganizationRole identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] } }, - "/api/organizations/{organizationId}/teams": { + "/api/organizations/{organizationId}/roles": { "get": { - "operationId": "listTeams", + "operationId": "listOrganizationRoles", "tags": [ - "Teams" + "Organization Roles" ], "responses": { "200": { - "description": "Team collection", + "description": "OrganizationRole collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Team.TeamOutput.jsonld-Organization.read collection.", + "description": "OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -41140,7 +44225,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" } } } @@ -41172,13 +44257,13 @@ "links": {} } }, - "summary": "List teams", - "description": "Lists all teams defined for an organization. Requires organization.teams.read.", + "summary": "List Organization roles", + "description": "Lists all roles defined for a Organization. Real pagination (`page`/`itemsPerPage`, default 30); `totalItems` reflects the count AFTER search filtering. Supports `search` (matched against the role name) and `order[name|isSystem|createdAt]=asc|desc` (default `order[name]=asc`).", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Team identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -41199,35 +44284,41 @@ }, "style": "form", "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true } ] }, "post": { - "operationId": "createTeam", + "operationId": "createOrganizationRole", "tags": [ - "Teams" + "Organization Roles" ], "responses": { "201": { - "description": "Team created", + "description": "OrganizationRole resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" } } }, "links": {} }, - "409": { - "description": "A team with this name already exists in the organization" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization not found" - }, "400": { "description": "Invalid input", "content": { @@ -41269,15 +44360,36 @@ } }, "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Create team", - "description": "Creates a team inside an organization. Requires organization.teams.write.", + "summary": "Create Organization role", + "description": "Creates a custom role inside a Organization with explicit permissions.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Team identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -41288,11 +44400,11 @@ } ], "requestBody": { - "description": "The new Team resource", + "description": "The new OrganizationRole resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Team.CreateTeamInput-Organization.write" + "$ref": "#/components/schemas/OrganizationRole.CreateOrganizationRoleInput-Organization.write" } } }, @@ -41300,55 +44412,37 @@ } } }, - "/api/organizations/{organizationId}/teams/{teamId}": { + "/api/organizations/{organizationId}/roles/{roleId}": { "get": { - "operationId": "getTeam", + "operationId": "getOrganizationRole", "tags": [ - "Teams" + "Organization Roles" ], "responses": { "200": { - "description": "Team retrieved", + "description": "Role retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" } } } }, - "404": { - "description": "Organization or team not found" - }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization or role not found, or the role belongs to another organization" } }, - "summary": "Get team", - "description": "Returns a single team by identifier. Requires organization.teams.read.", + "summary": "Get Organization role", + "description": "Returns a single organization role, including memberCount (the number of ACTIVE members currently assigned). Requires the organization.roles.read permission.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Team identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -41358,9 +44452,9 @@ "explode": false }, { - "name": "teamId", + "name": "roleId", "in": "path", - "description": "Team identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -41372,28 +44466,31 @@ ] }, "delete": { - "operationId": "deleteTeam", + "operationId": "deleteOrganizationRole", "tags": [ - "Teams" + "Organization Roles" ], "responses": { "204": { - "description": "Team deleted" + "description": "Role deleted" + }, + "400": { + "description": "Invalid identifier or system role" }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Organization or team not found" + "description": "Organization or role not found" } }, - "summary": "Delete team", - "description": "Permanently deletes a team. All team member assignments are removed. Requires organization.teams.manage.", + "summary": "Delete Organization role", + "description": "Permanently deletes a custom role. All member role assignments for this role are removed. System roles cannot be deleted.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Team identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -41403,9 +44500,9 @@ "explode": false }, { - "name": "teamId", + "name": "roleId", "in": "path", - "description": "Team identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -41417,24 +44514,24 @@ ] }, "patch": { - "operationId": "updateTeam", + "operationId": "updateOrganizationRole", "tags": [ - "Teams" + "Organization Roles" ], "responses": { "200": { - "description": "Team resource updated", + "description": "Role updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid payload, or the role is a system role", "content": { "application/ld+json": { "schema": { @@ -41454,6 +44551,15 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions, or granting a permission the caller does not hold" + }, + "404": { + "description": "Organization or role not found, or the role belongs to another organization" + }, + "409": { + "description": "The change would leave the organization without an administrator" + }, "422": { "description": "An error occurred", "content": { @@ -41474,9 +44580,69 @@ } }, "links": {} + } + }, + "summary": "Update Organization role permissions", + "description": "Updates the permissions assigned to a custom organization role, and optionally renames it.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationRole identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "403": { - "description": "Forbidden", + { + "name": "roleId", + "in": "path", + "description": "OrganizationRole identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated OrganizationRole resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationRole.UpdateOrganizationRoleInput-Organization.write.jsonMergePatch" + } + } + }, + "required": true + } + } + }, + "/api/organizations/invitations/{invitationId}/accept": { + "post": { + "operationId": "organization_join_accept_invitation_by_id", + "tags": [ + "Organization access" + ], + "responses": { + "200": { + "description": "OrganizationJoin resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinCompletedOutput.jsonld-Organization.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -41496,8 +44662,29 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -41518,25 +44705,13 @@ "links": {} } }, - "summary": "Update team", - "description": "Renames or redescribes a team. Requires organization.teams.write.", + "summary": "accept invitation by id", + "description": "accept invitation by id", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "Team identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "teamId", + "name": "invitationId", "in": "path", - "description": "Team identifier", + "description": "OrganizationJoin identifier", "required": true, "deprecated": false, "schema": { @@ -41545,53 +44720,22 @@ "style": "simple", "explode": false } - ], - "requestBody": { - "description": "The updated Team resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Team.UpdateTeamInput-Organization.write.jsonMergePatch" - } - } - }, - "required": true - } + ] } }, - "/api/organizations/{organizationId}/teams/{teamId}/members": { + "/api/organizations/join-options": { "get": { - "operationId": "listTeamMembers", + "operationId": "organization_join_join_options", "tags": [ - "Teams" + "Organization access" ], "responses": { "200": { - "description": "Team collection", + "description": "OrganizationJoin resource", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "Team.TeamMemberOutput.jsonld-Organization.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Team.TeamMemberOutput.jsonld-Organization.read" - } - } - } - } - ] + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinOptionsOutput.jsonld-Organization.read" } } } @@ -41616,69 +44760,53 @@ } }, "links": {} - } - }, - "summary": "List team members", - "description": "Lists members assigned to a team. Requires organization.teams.read.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "Team identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false }, - { - "name": "teamId", - "in": "path", - "description": "Team identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} } - ] - }, - "post": { - "operationId": "addTeamMember", + }, + "summary": "join options", + "description": "join options", + "parameters": [] + } + }, + "/api/organizations/join-requests": { + "get": { + "operationId": "organization_join_my_requests", "tags": [ - "Teams" + "Organization access" ], "responses": { - "201": { - "description": "Member added to the team", + "200": { + "description": "OrganizationJoin resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Team.TeamMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestsOutput.jsonld-Organization.read" } } - }, - "links": {} + } }, - "400": { - "description": "Member is not active in this organization, or already part of the team", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -41698,235 +44826,53 @@ }, "links": {} }, - "403": { - "description": "Insufficient permissions" - }, "404": { - "description": "Organization, team, or member not found" - }, - "422": { - "description": "An error occurred", + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Add team member", - "description": "Adds an active organization member to a team. Requires organization.teams.write.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "Team identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "teamId", - "in": "path", - "description": "Team identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The new Team resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Team.AddTeamMemberInput-Organization.write" - } - } - }, - "required": true - } - } - }, - "/api/organizations/{organizationId}/teams/{teamId}/members/{memberId}": { - "delete": { - "operationId": "removeTeamMember", - "tags": [ - "Teams" - ], - "responses": { - "204": { - "description": "Member removed from the team" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization, team, or membership not found" - } - }, - "summary": "Remove team member", - "description": "Removes a member from a team. Requires organization.teams.write.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "Team identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "teamId", - "in": "path", - "description": "Team identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "memberId", - "in": "path", - "description": "Team identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] + "summary": "my requests", + "description": "my requests", + "parameters": [] } }, - "/api/tenants": { - "get": { - "operationId": "tenant_list", - "tags": [ - "Tenants" - ], - "responses": { - "200": { - "description": "List of tenants retrieved successfully", - "content": { - "application/ld+json": { - "schema": { - "type": "object", - "description": "Tenant.TenantOutput.jsonld-tenant.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" - } - } - } - } - ] - } - } - } - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - } - }, - "summary": "List tenants", - "description": "Returns a list of all tenants. Requires tenants.read permission.", - "parameters": [ - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] - } - ] - }, + "/api/organizations/join-requests/{requestId}/cancel": { "post": { - "operationId": "tenant_create", + "operationId": "organization_join_cancel_request", "tags": [ - "Tenants" + "Organization access" ], "responses": { - "201": { - "description": "Tenant created successfully", + "200": { + "description": "OrganizationJoin resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid request - validation failed", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -41946,12 +44892,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, "422": { "description": "An error occurred", "content": { @@ -41972,63 +44912,36 @@ } }, "links": {} - } - }, - "summary": "Create tenant", - "description": "Create a new tenant with custom OAuth2 settings.", - "parameters": [], - "requestBody": { - "description": "The new Tenant resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Tenant.TenantInput-tenant.write" - } - } }, - "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/tenants/{id}": { - "get": { - "operationId": "tenant_get", - "tags": [ - "Tenants" - ], - "responses": { - "200": { - "description": "Tenant retrieved successfully", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } - }, - "404": { - "description": "Tenant not found" - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" + }, + "links": {} } }, - "summary": "Get tenant", - "description": "Get details of a specific tenant.", + "summary": "cancel request", + "description": "cancel request", "parameters": [ { - "name": "id", + "name": "requestId", "in": "path", - "description": "Tenant identifier", + "description": "OrganizationJoin identifier", "required": true, "deprecated": false, "schema": { @@ -42037,32 +44950,28 @@ "style": "simple", "explode": false } - ], - "security": [ - { - "bearerAuth": [] - } ] - }, - "put": { - "operationId": "tenant_replace", + } + }, + "/api/organizations/{organizationId}/access-policy": { + "get": { + "operationId": "organization_join_access_policy", "tags": [ - "Tenants" + "Organization access" ], "responses": { "200": { - "description": "Tenant replaced successfully", + "description": "OrganizationJoin resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationAccessPolicyOutput.jsonld-Organization.read" } } - }, - "links": {} + } }, - "400": { - "description": "Invalid request - validation failed", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -42083,95 +44992,34 @@ "links": {} }, "404": { - "description": "Tenant not found" - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, - "422": { - "description": "An error occurred", + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Replace tenant", - "description": "Replace a tenant by ID. Requires tenants.update permission.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Tenant identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The updated Tenant resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Tenant.TenantInput-tenant.write" - } - } - }, - "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "operationId": "tenant_delete", - "tags": [ - "Tenants" - ], - "responses": { - "204": { - "description": "Tenant deleted successfully" - }, - "404": { - "description": "Tenant not found" - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - } - }, - "summary": "Delete tenant", - "description": "Delete a tenant by ID. Requires tenants.delete permission.", + "summary": "access policy", + "description": "access policy", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Tenant identifier", + "description": "OrganizationJoin identifier", "required": true, "deprecated": false, "schema": { @@ -42180,32 +45028,27 @@ "style": "simple", "explode": false } - ], - "security": [ - { - "bearerAuth": [] - } ] }, "patch": { - "operationId": "tenant_update", + "operationId": "organization_join_update_access_policy", "tags": [ - "Tenants" + "Organization access" ], "responses": { "200": { - "description": "Tenant updated successfully", + "description": "OrganizationJoin resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationAccessPolicyOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid request - validation failed", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -42225,15 +45068,6 @@ }, "links": {} }, - "404": { - "description": "Tenant not found" - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, "422": { "description": "An error occurred", "content": { @@ -42254,71 +45088,9 @@ } }, "links": {} - } - }, - "summary": "Update tenant", - "description": "Update a tenant by ID. Requires tenants.update permission.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Tenant identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The updated Tenant resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Tenant.TenantInput-tenant.write.jsonMergePatch" - } - } - }, - "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/tenants/{id}/activate": { - "post": { - "operationId": "tenant_activate", - "tags": [ - "Tenants" - ], - "responses": { - "200": { - "description": "Tenant activated successfully", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" - } - } - }, - "links": {} - }, - "404": { - "description": "Tenant not found" - }, - "401": { - "description": "Authentication required" }, "403": { - "description": "Insufficient permissions" - }, - "400": { - "description": "Invalid input", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -42338,35 +45110,35 @@ }, "links": {} }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Activate tenant", - "description": "Activate a tenant by ID. Requires tenants.update permission.", + "summary": "update access policy", + "description": "update access policy", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Tenant identifier", + "description": "OrganizationJoin identifier", "required": true, "deprecated": false, "schema": { @@ -42376,40 +45148,37 @@ "explode": false } ], - "security": [ - { - "bearerAuth": [] - } - ] + "requestBody": { + "description": "The updated OrganizationJoin resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationJoin.OrganizationAccessPolicyInput-Organization.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/tenants/{id}/deactivate": { + "/api/organizations/{organizationId}/domains": { "post": { - "operationId": "tenant_deactivate", + "operationId": "organization_join_add_domain", "tags": [ - "Tenants" + "Organization access" ], "responses": { "200": { - "description": "Tenant deactivated successfully", + "description": "OrganizationJoin resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationDomainOutput.jsonld-Organization.read" } } }, "links": {} }, - "404": { - "description": "Tenant not found" - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, "400": { "description": "Invalid input", "content": { @@ -42451,15 +45220,36 @@ } }, "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Deactivate tenant", - "description": "Deactivate a tenant by ID. Requires tenants.update permission.", + "summary": "add domain", + "description": "add domain", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Tenant identifier", + "description": "OrganizationJoin identifier", "required": true, "deprecated": false, "schema": { @@ -42469,114 +45259,122 @@ "explode": false } ], - "security": [ - { - "bearerAuth": [] - } - ] + "requestBody": { + "description": "The new OrganizationJoin resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationJoin.OrganizationDomainInput-Organization.write" + } + } + }, + "required": true + } } }, - "/api/trusted-devices": { - "get": { - "operationId": "trusted_device_list", + "/api/organizations/{organizationId}/domains/{domainId}": { + "delete": { + "operationId": "organization_join_remove_domain", "tags": [ - "Trusted Devices" + "Organization access" ], "responses": { "200": { - "description": "List of trusted devices retrieved successfully", + "description": "OrganizationJoin resource deleted" + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "TrustedDevice.TrustedDeviceOutput.jsonld-trusted_device.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TrustedDevice.TrustedDeviceOutput.jsonld-trusted_device.read" - } - } - } - } - ] + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } - }, - "401": { - "description": "Authentication required" + }, + "links": {} }, - "403": { - "description": "Insufficient permissions" + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "List trusted devices", - "description": "List all trusted devices for the current user.", + "summary": "remove domain", + "description": "remove domain", "parameters": [ { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 1 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, + "name": "domainId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "type": "string" }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] + "style": "simple", + "explode": false } ] - }, + } + }, + "/api/organizations/{organizationId}/domains/{domainId}/verify": { "post": { - "operationId": "trusted_device_trust", + "operationId": "organization_join_verify_domain", "tags": [ - "Trusted Devices" + "Organization access" ], "responses": { - "201": { - "description": "Device trusted successfully", + "200": { + "description": "OrganizationJoin resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/TrustedDevice.TrustDeviceOutput.jsonld-trusted_device.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationDomainOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid request", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -42596,12 +45394,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, "422": { "description": "An error occurred", "content": { @@ -42622,35 +45414,77 @@ } }, "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Trust device", - "description": "Register the current device as trusted.", - "parameters": [], - "security": [ + "summary": "verify domain", + "description": "verify domain", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "domainId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] } }, - "/api/trusted-devices/revoke-all": { + "/api/organizations/{organizationId}/join": { "post": { - "operationId": "trusted_device_revoke_all", + "operationId": "organization_join_join", "tags": [ - "Trusted Devices" + "Organization access" ], "responses": { - "204": { - "description": "All devices revoked successfully", + "200": { + "description": "OrganizationJoin resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinCompletedOutput.jsonld-Organization.read" + } + } + }, "links": {} }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, "400": { "description": "Invalid input", "content": { @@ -42692,45 +45526,36 @@ } }, "links": {} - } - }, - "summary": "Revoke all trusted devices", - "description": "Revoke trust for all devices.", - "parameters": [], - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/trusted-devices/{id}": { - "delete": { - "operationId": "trusted_device_revoke", - "tags": [ - "Trusted Devices" - ], - "responses": { - "204": { - "description": "Device revoked successfully" - }, - "404": { - "description": "Device not found" - }, - "401": { - "description": "Authentication required" }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Revoke trusted device", - "description": "Revoke trust for a specific device.", + "summary": "join", + "description": "join", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "TrustedDevice identifier", + "description": "OrganizationJoin identifier", "required": true, "deprecated": false, "schema": { @@ -42739,37 +45564,26 @@ "style": "simple", "explode": false } - ], - "security": [ - { - "bearerAuth": [] - } ] } }, - "/api/me": { + "/api/organizations/{organizationId}/join-requests": { "get": { - "operationId": "user_get_current_profile", + "operationId": "organization_join_organization_requests", "tags": [ - "Users" + "Organization access" ], "responses": { "200": { - "description": "Current user profile retrieved successfully", + "description": "OrganizationJoin resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestsOutput.jsonld-Organization.read" } } } }, - "401": { - "description": "Authentication required" - }, - "404": { - "description": "Authenticated user not found" - }, "403": { "description": "Forbidden", "content": { @@ -42790,36 +45604,65 @@ } }, "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Get current user profile", - "description": "Returns the authenticated user profile with resolved global roles and permissions.", - "parameters": [], - "security": [ + "summary": "organization requests", + "description": "organization requests", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] }, - "patch": { - "operationId": "user_update_current_profile", + "post": { + "operationId": "organization_join_request", "tags": [ - "Users" + "Organization access" ], "responses": { "200": { - "description": "Current user profile updated successfully", + "description": "OrganizationJoin resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid request - validation failed", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -42839,15 +45682,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Authenticated user not found" - }, "422": { "description": "An error occurred", "content": { @@ -42868,49 +45702,88 @@ } }, "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Update current user profile", - "description": "Partially updates the authenticated user profile (first name, last name, preferred display language). Requires profile.update permission.", - "parameters": [], - "requestBody": { - "description": "The updated CurrentUserProfile resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileInput-user.write.jsonMergePatch" - } - } - }, - "required": true - }, - "security": [ + "summary": "request", + "description": "request", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] } }, - "/api/me/avatar": { - "put": { - "operationId": "user_upload_current_avatar", + "/api/organizations/{organizationId}/join-requests/{requestId}/approve": { + "post": { + "operationId": "organization_join_approve_request", "tags": [ - "Users" + "Organization access" ], "responses": { "200": { - "description": "Current user avatar uploaded successfully", + "description": "OrganizationJoin resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } }, "links": {} }, "422": { - "description": "Invalid file - missing, too large, or unsupported MIME type", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -42930,17 +45803,8 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Authenticated user not found" - }, - "400": { - "description": "Invalid input", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -42961,64 +45825,65 @@ "links": {} } }, - "summary": "Upload current user avatar", - "description": "Replaces the authenticated user avatar. Accepted formats: JPEG, PNG, WebP, GIF (max 5 MB). Requires profile.update permission.", - "parameters": [], + "summary": "approve request", + "description": "approve request", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "requestId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "Avatar image file (JPEG, PNG, WebP or GIF, max 5 MB)", + "description": "The new OrganizationJoin resource", "content": { - "multipart/form-data": { + "application/ld+json": { "schema": { - "type": "object", - "required": [ - "avatar" - ], - "properties": { - "avatar": { - "type": "string", - "format": "binary", - "description": "Image file (JPEG, PNG, WebP or GIF), max 5 MB" - } - } + "$ref": "#/components/schemas/OrganizationJoin.ApproveOrganizationJoinRequestInput-Organization.write" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/me/deactivate": { + "/api/organizations/{organizationId}/join-requests/{requestId}/reject": { "post": { - "operationId": "user_deactivate_current", + "operationId": "organization_join_reject_request", "tags": [ - "Users" + "Organization access" ], "responses": { "200": { - "description": "Account deactivated successfully \u2014 all sessions revoked", + "description": "OrganizationJoin resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" } } }, "links": {} }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Authenticated user not found" - }, "400": { "description": "Invalid input", "content": { @@ -43060,38 +45925,84 @@ } }, "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Deactivate current user account", - "description": "Deactivates the authenticated user own account and revokes all of their active sessions, signing them out everywhere. Idempotent. Organization memberships are left untouched. Reactivation is admin-only (POST /users/{id}/activate) \u2014 there is no self-service reactivation. Requires profile.update permission.", - "parameters": [], - "security": [ + "summary": "reject request", + "description": "reject request", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "requestId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] } }, - "/api/me/email-change": { + "/api/auth/password/reset/confirm": { "post": { - "operationId": "user_email_change_request", + "operationId": "confirm_password_reset", "tags": [ - "Users" + "Password Reset" ], "responses": { - "202": { - "description": "Confirmation link sent to the new address", + "200": { + "description": "Password reset confirmed successfully. All sessions have been revoked.", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EmailChange.RequestEmailChangeOutput.jsonld-email_change.read" + "$ref": "#/components/schemas/PasswordReset.ConfirmPasswordResetOutput.jsonld-token.read" } } }, - "links": {} + "links": { + "Login": { + "operationId": "login", + "description": "Login with the new password" + } + } }, "400": { - "description": "Invalid request - missing or malformed fields", + "description": "Invalid request - missing or invalid parameters", "content": { "application/ld+json": { "schema": { @@ -43112,13 +46023,13 @@ "links": {} }, "401": { - "description": "Authentication required" + "description": "Invalid or expired token/code" }, - "409": { - "description": "This email address cannot be used" + "429": { + "description": "Too many password reset confirmation attempts or maximum verification attempts exceeded" }, "422": { - "description": "Current password is incorrect", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -43137,66 +46048,49 @@ } }, "links": {} - }, - "429": { - "description": "Too many email change requests" - }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "Request email change", - "description": "Verifies the current password, then sends a confirmation link (1 h validity) to the new address and an alert to the current address. A new request replaces any pending one.", + "summary": "Confirm Password Reset", + "description": "Confirm password reset using the token, code, and new password. All active sessions will be terminated.", "parameters": [], "requestBody": { - "description": "The new EmailChange resource", + "description": "The new PasswordReset resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EmailChange.RequestEmailChangeInput-email_change.write" + "$ref": "#/components/schemas/PasswordReset.ConfirmPasswordResetInput-password_reset.write" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "operationId": "user_email_change_cancel", + } + } + }, + "/api/auth/password/reset/request": { + "post": { + "operationId": "request_password_reset", "tags": [ - "Users" + "Password Reset" ], "responses": { - "204": { - "description": "Pending request cancelled (or nothing was pending)" - }, - "401": { - "description": "Authentication required" + "200": { + "description": "Password reset request processed successfully. If the email exists, a code has been sent.", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PasswordReset.RequestPasswordResetOutput.jsonld-token.read" + } + } + }, + "links": { + "ConfirmReset": { + "operationId": "confirm_password_reset", + "description": "Confirm the password reset using the token and code" + } + } }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid request - missing or invalid email", "content": { "application/ld+json": { "schema": { @@ -43216,58 +46110,72 @@ }, "links": {} }, - "404": { - "description": "Not found", + "429": { + "description": "Too many password reset requests" + }, + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Cancel pending email change", - "description": "Cancels the pending email change request of the authenticated user. Idempotent: answers 204 whether or not a request was pending.", + "summary": "Request Password Reset", + "description": "Request a password reset by email. An OTP code will be sent to the user's email if the account exists.", "parameters": [], - "security": [ - { - "bearerAuth": [] - } - ] + "requestBody": { + "description": "The new PasswordReset resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PasswordReset.RequestPasswordResetInput-password_reset.write" + } + } + }, + "required": true + } } }, - "/api/me/email-change/confirm": { + "/api/auth/password/reset/resend": { "post": { - "operationId": "user_email_change_confirm", + "operationId": "resend_password_reset", "tags": [ - "Users" + "Password Reset" ], "responses": { "200": { - "description": "Email changed \u2014 all sessions have been revoked, sign in again with the new address", + "description": "Password reset code resent successfully.", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EmailChange.ConfirmEmailChangeOutput.jsonld-email_change.read" + "$ref": "#/components/schemas/PasswordReset.RequestPasswordResetOutput.jsonld-token.read" } } }, - "links": {} + "links": { + "ConfirmReset": { + "operationId": "confirm_password_reset", + "description": "Confirm the password reset using the new token and code" + } + } }, "400": { - "description": "Invalid, expired or already-used token", + "description": "Invalid request - missing or invalid token", "content": { "application/ld+json": { "schema": { @@ -43287,11 +46195,11 @@ }, "links": {} }, - "409": { - "description": "This email address cannot be used" + "404": { + "description": "Reset challenge not found or no longer valid" }, "429": { - "description": "Too many confirmation attempts" + "description": "Resend cooldown not yet elapsed" }, "422": { "description": "An error occurred", @@ -43315,15 +46223,15 @@ "links": {} } }, - "summary": "Confirm email change", - "description": "Public. Validates the confirmation token received at the new address, applies the change, and revokes every session and OAuth token of the user (the sign-in identifier changed). The token is single-use and expires after 1 hour.", + "summary": "Resend Password Reset Code", + "description": "Resend the password reset OTP code using the existing challenge token.", "parameters": [], "requestBody": { - "description": "The new EmailChange resource", + "description": "The new PasswordReset resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EmailChange.ConfirmEmailChangeInput-email_change.write" + "$ref": "#/components/schemas/PasswordReset.ResendPasswordResetInput-password_reset.write" } } }, @@ -43331,26 +46239,26 @@ } } }, - "/api/me/password/confirm": { + "/api/auth/password/setup": { "post": { - "operationId": "confirm_password_change", + "operationId": "request_initial_password", "tags": [ - "Users" + "PasswordSetup" ], "responses": { "200": { - "description": "Password changed successfully \u2014 all sessions have been revoked", + "description": "PasswordSetup resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordChange.ConfirmPasswordChangeOutput.jsonld-token.read" + "$ref": "#/components/schemas/PasswordSetup.RequestPasswordChangeOutput.jsonld-token.read" } } }, "links": {} }, "400": { - "description": "Invalid request - missing or invalid parameters", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -43370,12 +46278,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required, or invalid/expired token/code" - }, - "429": { - "description": "Too many confirmation attempts or maximum verification attempts exceeded" - }, "422": { "description": "An error occurred", "content": { @@ -43419,52 +46321,31 @@ "links": {} } }, - "summary": "Confirm password change", - "description": "Confirms the password change using the challenge token, the OTP code received by email, and the new password. All sessions and OAuth tokens are revoked afterwards.", - "parameters": [], - "requestBody": { - "description": "The new PasswordChange resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/PasswordChange.ConfirmPasswordChangeInput-password_change.write" - } - } - }, - "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + "summary": "Creates a PasswordSetup resource.", + "description": "Creates a PasswordSetup resource.", + "parameters": [] } }, - "/api/me/password/request": { + "/api/auth/password/setup/confirm": { "post": { - "operationId": "request_password_change", + "operationId": "confirm_initial_password", "tags": [ - "Users" + "PasswordSetup" ], "responses": { "200": { - "description": "Verification code sent \u2014 use the challenge token in the confirm step", + "description": "PasswordSetup resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordChange.RequestPasswordChangeOutput.jsonld-token.read" + "$ref": "#/components/schemas/PasswordSetup.ConfirmPasswordChangeOutput.jsonld-token.read" } } }, - "links": { - "ConfirmChange": { - "operationId": "confirm_password_change", - "description": "Confirm the password change using the token and code" - } - } + "links": {} }, "400": { - "description": "Invalid request - missing current password", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -43484,11 +46365,8 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "422": { - "description": "Current password is incorrect", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -43508,9 +46386,6 @@ }, "links": {} }, - "429": { - "description": "Too many password change requests" - }, "403": { "description": "Forbidden", "content": { @@ -43533,44 +46408,39 @@ "links": {} } }, - "summary": "Request password change", - "description": "Verifies the current password and sends an OTP code to the user's email. The returned challenge token is required for the confirm step.", + "summary": "Creates a PasswordSetup resource.", + "description": "Creates a PasswordSetup resource.", "parameters": [], "requestBody": { - "description": "The new PasswordChange resource", + "description": "The new PasswordSetup resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordChange.RequestPasswordChangeInput-password_change.write" + "$ref": "#/components/schemas/PasswordSetup.ConfirmPasswordChangeInput-password_change.write" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/users": { + "/api/plans": { "get": { - "operationId": "user_list", + "operationId": "listPlans", "tags": [ - "Users" + "Plan" ], "responses": { "200": { - "description": "List of users retrieved successfully", + "description": "Plans retrieved", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "User.UserOutput.jsonld-user.read collection.", + "description": "Plan.PlanOutput.jsonld-Plan.read collection.", "allOf": [ { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" }, { "type": "object", @@ -43581,7 +46451,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" } } } @@ -43595,59 +46465,43 @@ "description": "Authentication required" }, "403": { - "description": "Insufficient permissions" - } - }, - "summary": "List users", - "description": "Returns a paginated list of users. Requires users.read permission.", - "parameters": [ - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] - } - ] - }, - "post": { - "operationId": "user_create", - "tags": [ - "Users" - ], - "responses": { - "201": { - "description": "User created successfully", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List plans", + "description": "Lists subscription plans. Administrators see every plan; regular users see selectable plans only.", + "parameters": [] + }, + "post": { + "operationId": "createPlan", + "tags": [ + "Plan" + ], + "responses": { + "201": { + "description": "Plan created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" } } }, @@ -43675,13 +46529,10 @@ "links": {} }, "409": { - "description": "User already exists" - }, - "401": { - "description": "Authentication required" + "description": "Plan key already in use" }, "403": { - "description": "Insufficient permissions" + "description": "Administrator privileges required" }, "422": { "description": "An error occurred", @@ -43705,61 +46556,71 @@ "links": {} } }, - "summary": "Create user", - "description": "Creates a new user account. Requires users.create permission.", + "summary": "Create plan", + "description": "Creates a subscription plan. Requires platform administrator privileges.", "parameters": [], "requestBody": { - "description": "The new User resource", + "description": "The new Plan resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserInput-user.write" + "$ref": "#/components/schemas/Plan.CreatePlanInput-Plan.write" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/users/{id}": { + "/api/plans/{id}": { "get": { - "operationId": "user_get", + "operationId": "getPlan", "tags": [ - "Users" + "Plan" ], "responses": { "200": { - "description": "User retrieved successfully", + "description": "Plan retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" } } } }, "404": { - "description": "User not found" - }, - "401": { - "description": "Authentication required" + "description": "Plan not found" }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Get user", - "description": "Retrieve a user by ID. Requires users.read permission.", + "summary": "Get plan", + "description": "Returns a single subscription plan by identifier.", "parameters": [ { "name": "id", "in": "path", - "description": "User identifier", + "description": "Plan identifier", "required": true, "deprecated": false, "schema": { @@ -43768,25 +46629,56 @@ "style": "simple", "explode": false } + ] + }, + "delete": { + "operationId": "deletePlan", + "tags": [ + "Plan" ], - "security": [ + "responses": { + "204": { + "description": "Plan deleted" + }, + "409": { + "description": "The default plan cannot be deleted" + }, + "403": { + "description": "Administrator privileges required" + }, + "404": { + "description": "Plan not found" + } + }, + "summary": "Delete plan", + "description": "Deletes a subscription plan. The default plan cannot be deleted. Requires platform administrator privileges.", + "parameters": [ { - "bearerAuth": [] + "name": "id", + "in": "path", + "description": "Plan identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] }, - "put": { - "operationId": "user_replace", + "patch": { + "operationId": "updatePlan", "tags": [ - "Users" + "Plan" ], "responses": { "200": { - "description": "User replaced successfully", + "description": "Plan updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" } } }, @@ -43813,14 +46705,11 @@ }, "links": {} }, - "404": { - "description": "User not found" - }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" + "description": "Administrator privileges required" + }, + "404": { + "description": "Plan not found" }, "422": { "description": "An error occurred", @@ -43844,13 +46733,13 @@ "links": {} } }, - "summary": "Replace user", - "description": "Replace a user by ID. Requires users.update permission.", + "summary": "Update plan", + "description": "Updates a subscription plan. Requires platform administrator privileges.", "parameters": [ { "name": "id", "in": "path", - "description": "User identifier", + "description": "Plan identifier", "required": true, "deprecated": false, "schema": { @@ -43861,82 +46750,127 @@ } ], "requestBody": { - "description": "The updated User resource", + "description": "The updated Plan resource", "content": { - "application/ld+json": { + "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/User.UserInput-user.write" + "$ref": "#/components/schemas/Plan.UpdatePlanInput-Plan.write.jsonMergePatch" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "operationId": "user_delete", + } + } + }, + "/api/presence": { + "get": { + "operationId": "messaging_get_presence", "tags": [ - "Users" + "Presence" ], "responses": { - "204": { - "description": "User deleted successfully" - }, - "404": { - "description": "User not found" - }, - "401": { - "description": "Authentication required" + "200": { + "description": "Presence collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Presence.PresenceOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Presence.PresenceOutput.jsonld" + } + } + } + } + ] + } + } + } }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Delete user", - "description": "Delete a user by ID. Requires users.delete permission.", + "summary": "Retrieves the collection of Presence resources.", + "description": "Retrieves the collection of Presence resources.", "parameters": [ { - "name": "id", - "in": "path", - "description": "User identifier", + "name": "organization", + "in": "query", + "description": "Organization IRI.", "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false - } - ], - "security": [ + "style": "form", + "explode": true + }, { - "bearerAuth": [] + "name": "memberIds", + "in": "query", + "description": "Comma-separated organization member ids to check presence for (max 100). Required \u2014 there is no \"list all online members\" mode.", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true } ] - }, - "patch": { - "operationId": "user_update", + } + }, + "/api/presence/ping": { + "post": { + "operationId": "messaging_ping_presence", "tags": [ - "Users" + "Presence" ], "responses": { "200": { - "description": "User updated successfully", + "description": "Presence resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/Presence.PingPresenceOutput.jsonld" } } }, "links": {} }, "400": { - "description": "Invalid request - validation failed", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -43956,15 +46890,6 @@ }, "links": {} }, - "404": { - "description": "User not found" - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, "422": { "description": "An error occurred", "content": { @@ -43985,71 +46910,64 @@ } }, "links": {} - } - }, - "summary": "Update user (partial)", - "description": "Partially update a user by ID. Requires users.update permission.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "simple", - "explode": false + "links": {} } - ], + }, + "summary": "Creates a Presence resource.", + "description": "Creates a Presence resource.", + "parameters": [], "requestBody": { - "description": "The updated User resource", + "description": "The new Presence resource", "content": { - "application/merge-patch+json": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserInput-user.write.jsonMergePatch" + "$ref": "#/components/schemas/Presence.PingPresenceInput" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/users/{id}/activate": { - "post": { - "operationId": "user_activate", + "/api/presence/subscription": { + "get": { + "operationId": "messaging_presence_subscription", "tags": [ - "Users" + "Presence" ], "responses": { "200": { - "description": "User activated successfully", + "description": "Presence resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/Presence.PresenceSubscriptionOutput.jsonld-presence.read" } } - }, - "links": {} - }, - "404": { - "description": "User not found" - }, - "401": { - "description": "Authentication required" + } }, "403": { - "description": "Insufficient permissions" - }, - "400": { - "description": "Invalid input", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -44069,131 +46987,126 @@ }, "links": {} }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Activate user", - "description": "Activate a user account. Requires users.update permission.", + "summary": "Retrieves a Presence resource.", + "description": "Retrieves a Presence resource.", "parameters": [ { - "name": "id", - "in": "path", - "description": "User identifier", + "name": "organization", + "in": "query", + "description": "Presence organization", "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false - } - ], - "security": [ - { - "bearerAuth": [] + "style": "form", + "explode": true } ] } }, - "/api/users/{id}/avatar": { + "/api/me/presence-preference": { "get": { - "operationId": "user_get_avatar", + "operationId": "user_get_presence_preference", "tags": [ - "Users" + "PresencePreference" ], "responses": { "200": { - "description": "Avatar image (WebP)" - }, - "404": { - "description": "User or avatar not found" + "description": "PresencePreference resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PresencePreference.PresencePreferenceOutput.jsonld-presence-preference.read" + } + } + } }, - "204": { - "description": "User resource" - } - }, - "summary": "Get user avatar", - "description": "Streams the avatar as WebP. Optional ?size=N query parameter resolved to the nearest variant (256, 128, 64 or 32 px). Public endpoint.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - }, - "post": { - "operationId": "user_upload_avatar", - "tags": [ - "Users" - ], - "responses": { - "200": { - "description": "Avatar uploaded \u2014 user output with updated avatarUrl returned", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } }, "links": {} }, - "422": { - "description": "Invalid file \u2014 missing, too large, or unsupported MIME type", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a PresencePreference resource.", + "description": "Retrieves a PresencePreference resource.", + "parameters": [] + }, + "patch": { + "operationId": "user_update_presence_preference", + "tags": [ + "PresencePreference" + ], + "responses": { + "200": { + "description": "PresencePreference resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PresencePreference.PresencePreferenceOutput.jsonld-presence-preference.read" } } }, "links": {} - }, - "404": { - "description": "User not found" - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" }, "400": { "description": "Invalid input", @@ -44215,128 +47128,30 @@ } }, "links": {} - } - }, - "summary": "Upload user avatar", - "description": "Uploads an avatar image for a user. Accepted formats: JPEG, PNG, WebP, GIF (max 5 MB). The image is resized to four WebP variants (256, 128, 64, 32 px) with cover crop. Requires users.update permission.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "Avatar image file (JPEG, PNG, WebP or GIF, max 5 MB)", - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "required": [ - "avatar" - ], - "properties": { - "avatar": { - "type": "string", - "format": "binary", - "description": "Image file (JPEG, PNG, WebP or GIF), max 5 MB" - } - } - } - } - }, - "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/users/{id}/avatar/{size}.webp": { - "get": { - "operationId": "user_get_avatar_sized", - "tags": [ - "Users" - ], - "responses": { - "200": { - "description": "Avatar image (WebP)" - }, - "404": { - "description": "User or avatar not found" - }, - "204": { - "description": "User resource" - } - }, - "summary": "Get user avatar (sized)", - "description": "Streams the avatar WebP variant closest to the requested size (256, 128, 64 or 32 px). Public endpoint.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false }, - { - "name": "size", - "in": "path", - "description": "User identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - } - }, - "/api/users/{id}/deactivate": { - "post": { - "operationId": "user_deactivate", - "tags": [ - "Users" - ], - "responses": { - "200": { - "description": "User deactivated successfully", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} }, - "404": { - "description": "User not found" - }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" - }, - "400": { - "description": "Invalid input", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -44356,80 +47171,63 @@ }, "links": {} }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Deactivate user", - "description": "Deactivate a user account. Requires users.update permission.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "security": [ - { - "bearerAuth": [] - } - ] + "summary": "Updates the PresencePreference resource.", + "description": "Updates the PresencePreference resource.", + "parameters": [], + "requestBody": { + "description": "The updated PresencePreference resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/PresencePreference.UpdatePresencePreferenceInput-presence-preference.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/users/{id}/verify-email": { - "post": { - "operationId": "user_verify_email", + "/api/me/presence-preference/subscription": { + "get": { + "operationId": "user_presence_preference_subscription", "tags": [ - "Users" + "PresencePreference" ], "responses": { "200": { - "description": "User email verified successfully", + "description": "PresencePreference resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/PresencePreference.PresencePreferenceSubscriptionOutput.jsonld-presence-preference.read" } } - }, - "links": {} - }, - "404": { - "description": "User not found" - }, - "401": { - "description": "Authentication required" + } }, "403": { - "description": "Insufficient permissions" - }, - "400": { - "description": "Invalid input", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -44449,65 +47247,47 @@ }, "links": {} }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Verify user email", - "description": "Mark a user email as verified. Requires users.update permission.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "security": [ - { - "bearerAuth": [] - } - ] + "summary": "Retrieves a PresencePreference resource.", + "description": "Retrieves a PresencePreference resource.", + "parameters": [] } }, - "/api/organizations/{organizationId}/webhooks": { + "/api/organizations/{organizationId}/procurement/orders": { "get": { - "operationId": "listWebhookSubscriptions", + "operationId": "procurement_orders", "tags": [ - "Webhooks" + "Procurement" ], "responses": { "200": { - "description": "WebhookSubscription collection", + "description": "Procurement collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read collection.", + "description": "Procurement.PurchaseOrderOutput.jsonld-procurement.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -44521,7 +47301,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read" + "$ref": "#/components/schemas/Procurement.PurchaseOrderOutput.jsonld-procurement.read" } } } @@ -44553,13 +47333,13 @@ "links": {} } }, - "summary": "List webhook subscriptions", - "description": "Lists an organization's webhook subscriptions. Requires organization.webhooks.read.", + "summary": "List purchase orders", + "description": "List purchase orders", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WebhookSubscription identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -44595,31 +47375,81 @@ }, "style": "form", "explode": true + }, + { + "name": "status", + "in": "query", + "description": "Procurement status", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "draft", + "ordered", + "partial_received", + "received", + "cancelled" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "supplierId", + "in": "query", + "description": "Procurement supplierId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true } ] }, "post": { - "operationId": "createWebhookSubscription", + "operationId": "procurement_create_order", "tags": [ - "Webhooks" + "Procurement" ], "responses": { "201": { - "description": "Subscription created, secret revealed once", + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WebhookSubscription.WebhookSecretOutput.jsonld-Webhook.read" + "$ref": "#/components/schemas/Procurement.PurchaseOrderOutput.jsonld-procurement.read" } } }, "links": {} }, - "403": { - "description": "Insufficient permissions" + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { - "description": "Invalid URL, unknown event type, or subscription cap reached", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -44639,8 +47469,8 @@ }, "links": {} }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -44661,13 +47491,13 @@ "links": {} } }, - "summary": "Create webhook subscription", - "description": "Creates an outbound webhook subscription. Requires organization.webhooks.manage. The response includes the PLAINTEXT signing secret exactly once.", + "summary": "Create a purchase draft", + "description": "Create a purchase draft", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WebhookSubscription identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -44678,11 +47508,11 @@ } ], "requestBody": { - "description": "The new WebhookSubscription resource", + "description": "The new Procurement resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WebhookSubscription.CreateWebhookSubscriptionInput-Webhook.write" + "$ref": "#/components/schemas/Procurement.ChangePurchaseOrderInput-procurement.write" } } }, @@ -44690,26 +47520,23 @@ } } }, - "/api/organizations/{organizationId}/webhooks/{webhookId}": { + "/api/organizations/{organizationId}/procurement/orders/{id}": { "get": { - "operationId": "getWebhookSubscription", + "operationId": "procurement_order", "tags": [ - "Webhooks" + "Procurement" ], "responses": { "200": { - "description": "Subscription retrieved", + "description": "Procurement resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read" + "$ref": "#/components/schemas/Procurement.PurchaseOrderOutput.jsonld-procurement.read" } } } }, - "404": { - "description": "Organization or subscription not found" - }, "403": { "description": "Forbidden", "content": { @@ -44730,60 +47557,36 @@ } }, "links": {} - } - }, - "summary": "Get webhook subscription", - "description": "Returns a single webhook subscription. Requires organization.webhooks.read.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "WebhookSubscription identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "webhookId", - "in": "path", - "description": "WebhookSubscription identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - }, - "delete": { - "operationId": "deleteWebhookSubscription", - "tags": [ - "Webhooks" - ], - "responses": { - "204": { - "description": "Subscription deleted" - }, - "403": { - "description": "Insufficient permissions" }, "404": { - "description": "Organization or subscription not found" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Delete webhook subscription", - "description": "Permanently deletes a webhook subscription and its delivery log. Requires organization.webhooks.manage.", + "summary": "Read a purchase order", + "description": "Read a purchase order", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WebhookSubscription identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -44793,9 +47596,9 @@ "explode": false }, { - "name": "webhookId", + "name": "id", "in": "path", - "description": "WebhookSubscription identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -44807,17 +47610,17 @@ ] }, "patch": { - "operationId": "updateWebhookSubscription", + "operationId": "procurement_change_order", "tags": [ - "Webhooks" + "Procurement" ], "responses": { "200": { - "description": "WebhookSubscription resource updated", + "description": "Procurement resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read" + "$ref": "#/components/schemas/Procurement.PurchaseOrderOutput.jsonld-procurement.read" } } }, @@ -44908,13 +47711,13 @@ "links": {} } }, - "summary": "Update webhook subscription", - "description": "Partially updates a webhook subscription (url, eventTypes, isActive, description). Requires organization.webhooks.manage.", + "summary": "Edit a purchase draft", + "description": "Edit a purchase draft", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WebhookSubscription identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -44924,9 +47727,9 @@ "explode": false }, { - "name": "webhookId", + "name": "id", "in": "path", - "description": "WebhookSubscription identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -44937,11 +47740,11 @@ } ], "requestBody": { - "description": "The updated WebhookSubscription resource", + "description": "The updated Procurement resource", "content": { "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/WebhookSubscription.UpdateWebhookSubscriptionInput-Webhook.write.jsonMergePatch" + "$ref": "#/components/schemas/Procurement.ChangePurchaseOrderInput-procurement.write.jsonMergePatch" } } }, @@ -44949,42 +47752,65 @@ } } }, - "/api/organizations/{organizationId}/webhooks/{webhookId}/deliveries": { - "get": { - "operationId": "listWebhookDeliveries", + "/api/organizations/{organizationId}/procurement/orders/{id}/cancel-remaining": { + "post": { + "operationId": "procurement_cancel_remaining", "tags": [ - "Webhooks" + "Procurement" ], "responses": { "200": { - "description": "WebhookDelivery collection", + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "WebhookDelivery.WebhookDeliveryOutput.jsonld-Webhook.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WebhookDelivery.WebhookDeliveryOutput.jsonld-Webhook.read" - } - } - } - } - ] + "$ref": "#/components/schemas/Procurement.PurchaseOrderOutput.jsonld-procurement.read" } } - } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} }, "403": { "description": "Forbidden", @@ -45008,13 +47834,13 @@ "links": {} } }, - "summary": "List webhook deliveries", - "description": "Lists a subscription's delivery log. Requires organization.webhooks.read.", + "summary": "Cancel only quantities not yet received", + "description": "Cancel only quantities not yet received", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WebhookDelivery identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45024,9 +47850,9 @@ "explode": false }, { - "name": "webhookId", + "name": "id", "in": "path", - "description": "WebhookDelivery identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45034,71 +47860,28 @@ }, "style": "simple", "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true - }, - { - "name": "status", - "in": "query", - "description": "Delivery status filter (pending|delivered|failed).", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true } ] } }, - "/api/organizations/{organizationId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver": { + "/api/organizations/{organizationId}/procurement/orders/{id}/order": { "post": { - "operationId": "redeliverWebhook", + "operationId": "procurement_place_order", "tags": [ - "Webhooks" + "Procurement" ], "responses": { - "202": { - "description": "Redelivery enqueued", + "200": { + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WebhookDelivery.WebhookPingOutput.jsonld-Webhook.read" + "$ref": "#/components/schemas/Procurement.PurchaseOrderOutput.jsonld-procurement.read" } } }, "links": {} }, - "404": { - "description": "Organization, subscription, or delivery not found" - }, "400": { "description": "Invalid input", "content": { @@ -45163,13 +47946,13 @@ "links": {} } }, - "summary": "Redeliver a webhook delivery", - "description": "Re-enqueues a delivery (any status, including terminally failed) for another attempt. Requires organization.webhooks.manage.", + "summary": "Place a purchase order", + "description": "Place a purchase order", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WebhookDelivery identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45179,21 +47962,9 @@ "explode": false }, { - "name": "webhookId", - "in": "path", - "description": "WebhookDelivery identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "deliveryId", + "name": "id", "in": "path", - "description": "WebhookDelivery identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45205,65 +47976,42 @@ ] } }, - "/api/organizations/{organizationId}/webhooks/{webhookId}/ping": { - "post": { - "operationId": "pingWebhookSubscription", + "/api/organizations/{organizationId}/procurement/orders/{id}/receipts": { + "get": { + "operationId": "procurement_receipts", "tags": [ - "Webhooks" + "Procurement" ], "responses": { - "202": { - "description": "Test delivery enqueued", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/WebhookSubscription.WebhookPingOutput.jsonld-Webhook.read" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "422": { - "description": "An error occurred", + "200": { + "description": "Procurement collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "type": "object", + "description": "Procurement.ProcurementReceiptOutput.jsonld-procurement.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Procurement.ProcurementReceiptOutput.jsonld-procurement.read" + } + } + } + } + ] } } - }, - "links": {} + } }, "403": { "description": "Forbidden", @@ -45287,13 +48035,13 @@ "links": {} } }, - "summary": "Send a test delivery", - "description": "Enqueues a synthetic `webhook.ping` test delivery to this subscription. Requires organization.webhooks.manage.", + "summary": "List retained physical receipts", + "description": "List retained physical receipts", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WebhookSubscription identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45303,9 +48051,9 @@ "explode": false }, { - "name": "webhookId", + "name": "id", "in": "path", - "description": "WebhookSubscription identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45313,23 +48061,49 @@ }, "style": "simple", "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true } ] - } - }, - "/api/organizations/{organizationId}/webhooks/{webhookId}/rotate-secret": { + }, "post": { - "operationId": "rotateWebhookSecret", + "operationId": "procurement_receive", "tags": [ - "Webhooks" + "Procurement" ], "responses": { - "200": { - "description": "WebhookSubscription resource created", + "201": { + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WebhookSubscription.WebhookSecretOutput.jsonld-Webhook.read" + "$ref": "#/components/schemas/Procurement.ProcurementReceiptOutput.jsonld-procurement.read" } } }, @@ -45399,13 +48173,13 @@ "links": {} } }, - "summary": "Rotate webhook signing secret", - "description": "Generates a new signing secret, invalidating the previous one. Requires organization.webhooks.manage. The response includes the new PLAINTEXT secret exactly once.", + "summary": "Atomically record physical and stock receipt", + "description": "Atomically record physical and stock receipt", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WebhookSubscription identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45415,9 +48189,9 @@ "explode": false }, { - "name": "webhookId", + "name": "id", "in": "path", - "description": "WebhookSubscription identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45426,86 +48200,33 @@ "style": "simple", "explode": false } - ] - } - }, - "/api/webhooks/event-types": { - "get": { - "operationId": "listWebhookEventTypes", - "tags": [ - "Webhooks" ], - "responses": { - "200": { - "description": "WebhookEventType collection", - "content": { - "application/ld+json": { - "schema": { - "type": "object", - "description": "WebhookEventType.WebhookEventTypeOutput.jsonld-Webhook.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WebhookEventType.WebhookEventTypeOutput.jsonld-Webhook.read" - } - } - } - } - ] - } + "requestBody": { + "description": "The new Procurement resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Procurement.ReceivePurchaseOrderInput-procurement.write" } } }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - } - }, - "summary": "List webhook event types", - "description": "Returns the curated public event type allowlist a webhook subscription may register for.", - "parameters": [] + "required": true + } } }, - "/api/organizations/{organizationId}/workload": { + "/api/organizations/{organizationId}/procurement/receipts/{id}": { "get": { - "operationId": "workload_get", + "operationId": "procurement_receipt", "tags": [ - "Workload" + "Procurement" ], "responses": { "200": { - "description": "Workload resource", + "description": "Procurement resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Workload.WorkloadOutput.jsonld" + "$ref": "#/components/schemas/Procurement.ProcurementReceiptOutput.jsonld-procurement.read" } } } @@ -45553,13 +48274,13 @@ "links": {} } }, - "summary": "Retrieves a Workload resource.", - "description": "Retrieves a Workload resource.", + "summary": "Read a physical receipt", + "description": "Read a physical receipt", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Workload identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45569,112 +48290,33 @@ "explode": false }, { - "name": "from", - "in": "query", - "description": "Workload from", - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "format": "date" - }, - "style": "form", - "explode": true - }, - { - "name": "to", - "in": "query", - "description": "Workload to", + "name": "id", + "in": "path", + "description": "Procurement identifier", "required": true, "deprecated": false, - "schema": { - "type": "string", - "format": "date" - }, - "style": "form", - "explode": true - }, - { - "name": "member", - "in": "query", - "description": "Workload member", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "team", - "in": "query", - "description": "Workload team", - "required": false, - "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true - }, - { - "name": "overloaded", - "in": "query", - "description": "Workload overloaded", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean" - }, - "style": "form", - "explode": true - }, - { - "name": "page", - "in": "query", - "description": "One-based member page. Pagination never limits workload contributions.", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "minimum": 1, - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "pageSize", - "in": "query", - "description": "Members per page, after member, team and overload filters.", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 10 - }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] } }, - "/api/organizations/{organizationId}/workload/assessments": { + "/api/organizations/{organizationId}/procurement/receipts/{id}/individualize": { "post": { - "operationId": "workload_assess", + "operationId": "procurement_individualize", "tags": [ - "Workload" + "Procurement" ], "responses": { "200": { - "description": "Workload resource created", + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Workload.WorkloadAssessmentOutput.jsonld" + "$ref": "#/components/schemas/Procurement.ProcurementReceiptOutput.jsonld-procurement.read" } } }, @@ -45744,13 +48386,25 @@ "links": {} } }, - "summary": "Creates a Workload resource.", - "description": "Creates a Workload resource.", + "summary": "Create reserve equipment for received units", + "description": "Create reserve equipment for received units", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Workload identifier", + "description": "Procurement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45761,11 +48415,11 @@ } ], "requestBody": { - "description": "The new Workload resource", + "description": "The new Procurement resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Workload.WorkloadAssessmentInput" + "$ref": "#/components/schemas/Procurement.IndividualizeReceiptInput-procurement.write" } } }, @@ -45773,19 +48427,39 @@ } } }, - "/api/organizations/{organizationId}/workload/members/{memberId}/capacity": { + "/api/organizations/{organizationId}/procurement/receipts/{id}/returns": { "get": { - "operationId": "workload_capacity_get", + "operationId": "procurement_returns", "tags": [ - "WorkloadCapacity" + "Procurement" ], "responses": { "200": { - "description": "WorkloadCapacity resource", + "description": "Procurement collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityOutput.jsonld" + "type": "object", + "description": "Procurement.ProcurementReturnOutput.jsonld-procurement.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Procurement.ProcurementReturnOutput.jsonld-procurement.read" + } + } + } + } + ] } } } @@ -45810,36 +48484,15 @@ } }, "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "Retrieves a WorkloadCapacity resource.", - "description": "Retrieves a WorkloadCapacity resource.", + "summary": "List retained physical supplier returns", + "description": "List retained physical supplier returns", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45849,9 +48502,9 @@ "explode": false }, { - "name": "memberId", + "name": "id", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45859,21 +48512,49 @@ }, "style": "simple", "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true } ] }, "post": { - "operationId": "workload_capacity_save", + "operationId": "procurement_return", "tags": [ - "WorkloadCapacity" + "Procurement" ], "responses": { - "201": { - "description": "WorkloadCapacity resource created", + "200": { + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityChangeOutput.jsonld" + "$ref": "#/components/schemas/Procurement.ProcurementReceiptOutput.jsonld-procurement.read" } } }, @@ -45943,13 +48624,13 @@ "links": {} } }, - "summary": "Creates a WorkloadCapacity resource.", - "description": "Creates a WorkloadCapacity resource.", + "summary": "Record a motivated supply return", + "description": "Record a motivated supply return", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45959,9 +48640,9 @@ "explode": false }, { - "name": "memberId", + "name": "id", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45972,11 +48653,11 @@ } ], "requestBody": { - "description": "The new WorkloadCapacity resource", + "description": "The new Procurement resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityWeekInput" + "$ref": "#/components/schemas/Procurement.ReturnProcurementReceiptInput-procurement.write" } } }, @@ -45984,19 +48665,19 @@ } } }, - "/api/organizations/{organizationId}/workload/members/{memberId}/exceptions": { + "/api/organizations/{organizationId}/procurement/returns/{id}": { "get": { - "operationId": "workload_exceptions_get", + "operationId": "procurement_return_detail", "tags": [ - "WorkloadCapacity" + "Procurement" ], "responses": { "200": { - "description": "WorkloadCapacity resource", + "description": "Procurement resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityOutput.jsonld" + "$ref": "#/components/schemas/Procurement.ProcurementReturnOutput.jsonld-procurement.read" } } } @@ -46044,13 +48725,13 @@ "links": {} } }, - "summary": "Retrieves a WorkloadCapacity resource.", - "description": "Retrieves a WorkloadCapacity resource.", + "summary": "Read a retained physical return", + "description": "Read a retained physical return", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46060,9 +48741,9 @@ "explode": false }, { - "name": "memberId", + "name": "id", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46072,19 +48753,21 @@ "explode": false } ] - }, + } + }, + "/api/organizations/{organizationId}/procurement/returns/{id}/reconcile": { "post": { - "operationId": "workload_exception_create", + "operationId": "procurement_reconcile_return", "tags": [ - "WorkloadCapacity" + "Procurement" ], "responses": { - "201": { - "description": "WorkloadCapacity resource created", + "200": { + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityChangeOutput.jsonld" + "$ref": "#/components/schemas/Procurement.ProcurementReturnOutput.jsonld-procurement.read" } } }, @@ -46154,13 +48837,13 @@ "links": {} } }, - "summary": "Creates a WorkloadCapacity resource.", - "description": "Creates a WorkloadCapacity resource.", + "summary": "Explicitly reconcile a physical supply return", + "description": "Explicitly reconcile a physical supply return", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46170,9 +48853,9 @@ "explode": false }, { - "name": "memberId", + "name": "id", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46183,11 +48866,11 @@ } ], "requestBody": { - "description": "The new WorkloadCapacity resource", + "description": "The new Procurement resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityExceptionInput" + "$ref": "#/components/schemas/Procurement.IndividualizeReceiptInput-procurement.write" } } }, @@ -46195,39 +48878,45 @@ } } }, - "/api/organizations/{organizationId}/workload/members/{memberId}/exceptions/{exceptionId}": { - "delete": { - "operationId": "workload_exception_cancel", + "/api/organizations/{organizationId}/procurement/suppliers": { + "get": { + "operationId": "procurement_suppliers", "tags": [ - "WorkloadCapacity" + "Procurement" ], "responses": { - "204": { - "description": "WorkloadCapacity resource deleted" - }, - "403": { - "description": "Forbidden", + "200": { + "description": "Procurement collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "type": "object", + "description": "Procurement.SupplierOutput.jsonld-procurement.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Procurement.SupplierOutput.jsonld-procurement.read" + } + } + } + } + ] } } - }, - "links": {} + } }, - "404": { - "description": "Not found", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -46248,13 +48937,13 @@ "links": {} } }, - "summary": "Removes the WorkloadCapacity resource.", - "description": "Removes the WorkloadCapacity resource.", + "summary": "List suppliers", + "description": "List suppliers", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46264,21 +48953,152 @@ "explode": false }, { - "name": "memberId", - "in": "path", - "description": "WorkloadCapacity identifier", - "required": true, + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 1 }, - "style": "simple", - "explode": false + "style": "form", + "explode": true }, { - "name": "exceptionId", - "in": "path", - "description": "WorkloadCapacity identifier", + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "Procurement search", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "archived", + "in": "query", + "description": "false or omitted selects active suppliers; true selects archived suppliers; all selects both.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "false", + "true", + "all" + ] + }, + "style": "form", + "explode": true + } + ] + }, + "post": { + "operationId": "procurement_create_supplier", + "tags": [ + "Procurement" + ], + "responses": { + "201": { + "description": "Procurement resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Procurement.SupplierOutput.jsonld-procurement.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Create an internal supplier", + "description": "Create an internal supplier", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46287,22 +49107,33 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The new Procurement resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Procurement.ChangeSupplierInput-procurement.write" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/workload/settings": { + "/api/organizations/{organizationId}/procurement/suppliers/{id}": { "get": { - "operationId": "workload_settings_get", + "operationId": "procurement_supplier", "tags": [ - "WorkloadCapacity" + "Procurement" ], "responses": { "200": { - "description": "WorkloadCapacity resource", + "description": "Procurement resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityOutput.jsonld" + "$ref": "#/components/schemas/Procurement.SupplierOutput.jsonld-procurement.read" } } } @@ -46350,13 +49181,25 @@ "links": {} } }, - "summary": "Retrieves a WorkloadCapacity resource.", - "description": "Retrieves a WorkloadCapacity resource.", + "summary": "Read a supplier", + "description": "Read a supplier", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46367,18 +49210,18 @@ } ] }, - "post": { - "operationId": "workload_settings_save", + "patch": { + "operationId": "procurement_change_supplier", "tags": [ - "WorkloadCapacity" + "Procurement" ], "responses": { - "201": { - "description": "WorkloadCapacity resource created", + "200": { + "description": "Procurement resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityChangeOutput.jsonld" + "$ref": "#/components/schemas/Procurement.SupplierOutput.jsonld-procurement.read" } } }, @@ -46446,15 +49289,48 @@ } }, "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Creates a WorkloadCapacity resource.", - "description": "Creates a WorkloadCapacity resource.", + "summary": "Change a supplier", + "description": "Change a supplier", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46465,212 +49341,10573 @@ } ], "requestBody": { - "description": "The new WorkloadCapacity resource", + "description": "The updated Procurement resource", "content": { - "application/ld+json": { + "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityWeekInput" + "$ref": "#/components/schemas/Procurement.ChangeSupplierInput-procurement.write.jsonMergePatch" } } }, "required": true } } - } - }, - "components": { - "schemas": { - "AddressSuggestionOutput.jsonld-facility.read": { - "type": "object", - "properties": { - "displayName": { - "description": "Constructor.", - "type": "string" - }, - "latitude": { - "description": "Constructor.", - "type": "number" - }, - "longitude": { - "description": "Constructor.", - "type": "number" - }, - "street": { - "description": "Constructor.", - "type": "string" - }, - "city": { - "description": "Constructor.", - "type": "string" - }, - "region": { - "description": "Constructor.", - "type": "string" + }, + "/api/organizations/{organizationId}/procurement/suppliers/{id}/archive": { + "post": { + "operationId": "procurement_archive_supplier", + "tags": [ + "Procurement" + ], + "responses": { + "200": { + "description": "Procurement resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Procurement.SupplierOutput.jsonld-procurement.read" + } + } + }, + "links": {} }, - "postalCode": { - "description": "Constructor.", - "type": "string" + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, - "country": { - "description": "Constructor.", - "type": "string" + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} }, - "countryCode": { - "description": "Constructor.", - "type": "string" + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } - } - }, - "ApprovalActionType.ApprovalActionTypeOutput.jsonld-Approval.read": { - "allOf": [ + }, + "summary": "Archive a supplier retaining current purchases", + "description": "Archive a supplier retaining current purchases", + "parameters": [ { - "$ref": "#/components/schemas/HydraItemBaseSchema" + "name": "organizationId", + "in": "path", + "description": "Procurement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, { - "type": "object", - "properties": { - "value": { - "readOnly": true, - "description": "Property value.", - "default": "", - "type": "string" - }, - "label": { - "readOnly": true, - "description": "Property label.", - "default": "", - "type": "string" - } - } + "name": "id", + "in": "path", + "description": "Procurement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } + ] + } + }, + "/api/publications": { + "post": { + "operationId": "publication_create", + "tags": [ + "Publication" ], - "description": "Reference catalog of regulated action types the approval policy can gate." - }, - "ApprovalRequest.ApprovalRequestOutput.jsonld-Approval.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" + "responses": { + "202": { + "description": "Publication resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Publication.PublicationOutput.jsonld" + } + } + }, + "links": {} }, - { - "type": "object", - "properties": { - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", - "default": "", - "type": "string" + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } }, - "actionType": { - "readOnly": true, - "description": "Property actionType.", - "default": "", - "type": "string" + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } }, - "subjectId": { - "readOnly": true, - "description": "Property subjectId.", - "default": "", - "type": "string" + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } }, - "status": { - "readOnly": true, - "description": "Property status.", - "default": "", - "type": "string" + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } }, - "requestedByMemberId": { - "readOnly": true, - "description": "Property requestedByMemberId.", - "default": "", - "type": "string" + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } }, - "requestedByUserId": { - "readOnly": true, - "description": "Property requestedByUserId.", - "default": "", - "type": "string" + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Creates a Publication resource.", + "description": "Creates a Publication resource.", + "parameters": [], + "requestBody": { + "description": "The new Publication resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Publication.CreatePublicationInput" + } + } + }, + "required": true + } + } + }, + "/api/publications/{id}": { + "get": { + "operationId": "publication_get", + "tags": [ + "Publication" + ], + "responses": { + "200": { + "description": "Publication resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Publication.PublicationOutput.jsonld" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } }, - "decisionByMemberId": { - "readOnly": true, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a Publication resource.", + "description": "Retrieves a Publication resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Publication identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/auth/register": { + "post": { + "operationId": "register_user", + "tags": [ + "Registration" + ], + "responses": { + "201": { + "description": "Account created. A verification code has been sent to the email.", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Registration.RegisterOutput.jsonld-token.read" + } + } + }, + "links": { + "ConfirmRegistration": { + "operationId": "confirm_registration", + "description": "Verify the email using the token and code" + } + } + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "409": { + "description": "An account already exists with this email" + }, + "429": { + "description": "Too many registration requests" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Register a new account", + "description": "Creates a pending-verification account and emails an OTP verification code.", + "parameters": [], + "requestBody": { + "description": "The new Registration resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Registration.RegisterInput-registration.write" + } + } + }, + "required": true + } + } + }, + "/api/auth/register/resend": { + "post": { + "operationId": "resend_registration", + "tags": [ + "Registration" + ], + "responses": { + "200": { + "description": "A new verification code has been sent.", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Registration.RegisterOutput.jsonld-token.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - missing or invalid token", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Verification challenge not found or no longer valid" + }, + "429": { + "description": "Resend cooldown not yet elapsed" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Resend the verification code", + "description": "Resends the email-verification OTP code using the existing challenge token.", + "parameters": [], + "requestBody": { + "description": "The new Registration resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Registration.ResendRegistrationInput-registration.write" + } + } + }, + "required": true + } + } + }, + "/api/auth/register/verify": { + "post": { + "operationId": "confirm_registration", + "tags": [ + "Registration" + ], + "responses": { + "200": { + "description": "Email verified. Access token returned and refresh token cookie set.", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Registration.LoginOutput.jsonld-token.read" + } + } + }, + "links": { + "RefreshToken": { + "operationId": "refresh", + "description": "Use the refresh token cookie to get a new access token" + } + } + }, + "400": { + "description": "Invalid request - missing or invalid parameters", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Invalid or expired token/code" + }, + "429": { + "description": "Maximum verification attempts exceeded" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Verify email and log in", + "description": "Verifies the OTP code, activates the account, and returns an access token (and refresh token cookie) so the user is logged in automatically.", + "parameters": [], + "requestBody": { + "description": "The new Registration resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Registration.ConfirmRegistrationInput-registration.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/compliance/export": { + "get": { + "operationId": "exportOrganizationSafetyRegister", + "tags": [ + "SafetyRegisterExport" + ], + "responses": { + "204": { + "description": "SafetyRegisterExport resource" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a SafetyRegisterExport resource.", + "description": "Retrieves a SafetyRegisterExport resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "SafetyRegisterExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/facilities/{facilityId}/compliance/export": { + "get": { + "operationId": "exportFacilitySafetyRegister", + "tags": [ + "SafetyRegisterExport" + ], + "responses": { + "204": { + "description": "SafetyRegisterExport resource" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a SafetyRegisterExport resource.", + "description": "Retrieves a SafetyRegisterExport resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "SafetyRegisterExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "SafetyRegisterExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/service-requests": { + "get": { + "operationId": "list_service_requests", + "tags": [ + "ServiceRequest" + ], + "responses": { + "200": { + "description": "ServiceRequest collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "ServiceRequest.ServiceRequestOutput.jsonld-service_request.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "list an internal repair request", + "description": "list an internal repair request", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "status", + "in": "query", + "description": "ServiceRequest status", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "requested", + "qualified", + "rejected", + "cancelled", + "converted" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentId", + "in": "query", + "description": "ServiceRequest equipmentId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "siteId", + "in": "query", + "description": "ServiceRequest siteId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "ServiceRequest search", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "maxLength": 160 + }, + "style": "form", + "explode": true + } + ] + }, + "post": { + "operationId": "create_service_request", + "tags": [ + "ServiceRequest" + ], + "responses": { + "201": { + "description": "ServiceRequest resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "create an internal repair request", + "description": "create an internal repair request", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new ServiceRequest resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.CreateServiceRequestInput-service_request.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/service-requests/{id}": { + "get": { + "operationId": "get_service_request", + "tags": [ + "ServiceRequest" + ], + "responses": { + "200": { + "description": "ServiceRequest resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "get an internal repair request", + "description": "get an internal repair request", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "patch": { + "operationId": "patch_service_request", + "tags": [ + "ServiceRequest" + ], + "responses": { + "200": { + "description": "ServiceRequest resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "patch an internal repair request", + "description": "patch an internal repair request", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "example": "\"revision-1\"" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated ServiceRequest resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.UpdateServiceRequestInput-service_request.write.jsonMergePatch" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/service-requests/{id}/cancel": { + "post": { + "operationId": "cancel_service_request", + "tags": [ + "ServiceRequest" + ], + "responses": { + "200": { + "description": "ServiceRequest resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "cancel an internal repair request", + "description": "cancel an internal repair request", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "example": "\"revision-1\"" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new ServiceRequest resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.DecisionServiceRequestInput-service_request.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/service-requests/{id}/convert": { + "post": { + "operationId": "convert_service_request", + "tags": [ + "ServiceRequest" + ], + "responses": { + "200": { + "description": "ServiceRequest resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "convert an internal repair request", + "description": "convert an internal repair request", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "example": "\"revision-1\"" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new ServiceRequest resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ConvertServiceRequestInput-service_request.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/service-requests/{id}/qualify": { + "post": { + "operationId": "qualify_service_request", + "tags": [ + "ServiceRequest" + ], + "responses": { + "200": { + "description": "ServiceRequest resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "qualify an internal repair request", + "description": "qualify an internal repair request", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "example": "\"revision-1\"" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new ServiceRequest resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.QualifyServiceRequestInput-service_request.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/service-requests/{id}/reject": { + "post": { + "operationId": "reject_service_request", + "tags": [ + "ServiceRequest" + ], + "responses": { + "200": { + "description": "ServiceRequest resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "reject an internal repair request", + "description": "reject an internal repair request", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "example": "\"revision-1\"" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new ServiceRequest resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.DecisionServiceRequestInput-service_request.write" + } + } + }, + "required": true + } + } + }, + "/api/sessions": { + "get": { + "operationId": "session_list", + "tags": [ + "Sessions" + ], + "responses": { + "200": { + "description": "List of sessions retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Session.SessionOutput.jsonld-session.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session.SessionOutput.jsonld-session.read" + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "List sessions", + "description": "Returns all active sessions for the authenticated user.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/sessions/revoke-all": { + "post": { + "operationId": "session_revoke_all", + "tags": [ + "Sessions" + ], + "responses": { + "204": { + "description": "All sessions revoked successfully", + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Revoke all sessions", + "description": "Revoke all sessions for the authenticated user (logout from all devices).", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/sessions/revoke-others": { + "post": { + "operationId": "session_revoke_others", + "tags": [ + "Sessions" + ], + "responses": { + "200": { + "description": "Other sessions revoked successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Session.RevokeOtherSessionsOutput.jsonld-session.revoke_others" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Revoke other sessions", + "description": "Revokes every active session of the authenticated user except the one backing the current request, signing the user out on every other device while staying signed in here. Idempotent: calling it again once there is nothing else to revoke returns a zero count.", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/sessions/{id}": { + "get": { + "operationId": "session_get", + "tags": [ + "Sessions" + ], + "responses": { + "200": { + "description": "Session retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Session.SessionOutput.jsonld-session.read" + } + } + } + }, + "404": { + "description": "Session not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "Get session", + "description": "Get details of a specific session.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Session identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "session_revoke", + "tags": [ + "Sessions" + ], + "responses": { + "204": { + "description": "Session revoked successfully" + }, + "404": { + "description": "Session not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "Revoke session", + "description": "Revoke a specific session. The user will be logged out from that device.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Session identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/organizations/{organizationId}/teams": { + "get": { + "operationId": "listTeams", + "tags": [ + "Teams" + ], + "responses": { + "200": { + "description": "Team collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Team.TeamOutput.jsonld-Organization.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List teams", + "description": "Lists all teams defined for an organization. Requires organization.teams.read.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + } + ] + }, + "post": { + "operationId": "createTeam", + "tags": [ + "Teams" + ], + "responses": { + "201": { + "description": "Team created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + } + } + }, + "links": {} + }, + "409": { + "description": "A team with this name already exists in the organization" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization not found" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Create team", + "description": "Creates a team inside an organization. Requires organization.teams.write.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new Team resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Team.CreateTeamInput-Organization.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/teams/{teamId}": { + "get": { + "operationId": "getTeam", + "tags": [ + "Teams" + ], + "responses": { + "200": { + "description": "Team retrieved", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + } + } + } + }, + "404": { + "description": "Organization or team not found" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Get team", + "description": "Returns a single team by identifier. Requires organization.teams.read.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "teamId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "delete": { + "operationId": "deleteTeam", + "tags": [ + "Teams" + ], + "responses": { + "204": { + "description": "Team deleted" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization or team not found" + } + }, + "summary": "Delete team", + "description": "Permanently deletes a team. All team member assignments are removed. Requires organization.teams.manage.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "teamId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "patch": { + "operationId": "updateTeam", + "tags": [ + "Teams" + ], + "responses": { + "200": { + "description": "Team resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Update team", + "description": "Renames or redescribes a team. Requires organization.teams.write.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "teamId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated Team resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Team.UpdateTeamInput-Organization.write.jsonMergePatch" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/teams/{teamId}/members": { + "get": { + "operationId": "listTeamMembers", + "tags": [ + "Teams" + ], + "responses": { + "200": { + "description": "Team collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Team.TeamMemberOutput.jsonld-Organization.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Team.TeamMemberOutput.jsonld-Organization.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List team members", + "description": "Lists members assigned to a team. Requires organization.teams.read.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "teamId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + } + ] + }, + "post": { + "operationId": "addTeamMember", + "tags": [ + "Teams" + ], + "responses": { + "201": { + "description": "Member added to the team", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Team.TeamMemberOutput.jsonld-Organization.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Member is not active in this organization, or already part of the team", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization, team, or member not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Add team member", + "description": "Adds an active organization member to a team. Requires organization.teams.write.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "teamId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new Team resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Team.AddTeamMemberInput-Organization.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/teams/{teamId}/members/{memberId}": { + "delete": { + "operationId": "removeTeamMember", + "tags": [ + "Teams" + ], + "responses": { + "204": { + "description": "Member removed from the team" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization, team, or membership not found" + } + }, + "summary": "Remove team member", + "description": "Removes a member from a team. Requires organization.teams.write.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "teamId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "memberId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/tenants": { + "get": { + "operationId": "tenant_list", + "tags": [ + "Tenants" + ], + "responses": { + "200": { + "description": "List of tenants retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Tenant.TenantOutput.jsonld-tenant.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "List tenants", + "description": "Returns a list of all tenants. Requires tenants.read permission.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "tenant_create", + "tags": [ + "Tenants" + ], + "responses": { + "201": { + "description": "Tenant created successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Create tenant", + "description": "Create a new tenant with custom OAuth2 settings.", + "parameters": [], + "requestBody": { + "description": "The new Tenant resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantInput-tenant.write" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/tenants/{id}": { + "get": { + "operationId": "tenant_get", + "tags": [ + "Tenants" + ], + "responses": { + "200": { + "description": "Tenant retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + } + } + } + }, + "404": { + "description": "Tenant not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "Get tenant", + "description": "Get details of a specific tenant.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tenant identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "tenant_replace", + "tags": [ + "Tenants" + ], + "responses": { + "200": { + "description": "Tenant replaced successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Tenant not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Replace tenant", + "description": "Replace a tenant by ID. Requires tenants.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tenant identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated Tenant resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantInput-tenant.write" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "tenant_delete", + "tags": [ + "Tenants" + ], + "responses": { + "204": { + "description": "Tenant deleted successfully" + }, + "404": { + "description": "Tenant not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "Delete tenant", + "description": "Delete a tenant by ID. Requires tenants.delete permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tenant identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "operationId": "tenant_update", + "tags": [ + "Tenants" + ], + "responses": { + "200": { + "description": "Tenant updated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Tenant not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Update tenant", + "description": "Update a tenant by ID. Requires tenants.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tenant identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated Tenant resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantInput-tenant.write.jsonMergePatch" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/tenants/{id}/activate": { + "post": { + "operationId": "tenant_activate", + "tags": [ + "Tenants" + ], + "responses": { + "200": { + "description": "Tenant activated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + } + } + }, + "links": {} + }, + "404": { + "description": "Tenant not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Activate tenant", + "description": "Activate a tenant by ID. Requires tenants.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tenant identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/tenants/{id}/deactivate": { + "post": { + "operationId": "tenant_deactivate", + "tags": [ + "Tenants" + ], + "responses": { + "200": { + "description": "Tenant deactivated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + } + } + }, + "links": {} + }, + "404": { + "description": "Tenant not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Deactivate tenant", + "description": "Deactivate a tenant by ID. Requires tenants.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tenant identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/trusted-devices": { + "get": { + "operationId": "trusted_device_list", + "tags": [ + "Trusted Devices" + ], + "responses": { + "200": { + "description": "List of trusted devices retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "TrustedDevice.TrustedDeviceOutput.jsonld-trusted_device.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrustedDevice.TrustedDeviceOutput.jsonld-trusted_device.read" + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "List trusted devices", + "description": "List all trusted devices for the current user.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "trusted_device_trust", + "tags": [ + "Trusted Devices" + ], + "responses": { + "201": { + "description": "Device trusted successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/TrustedDevice.TrustDeviceOutput.jsonld-trusted_device.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Trust device", + "description": "Register the current device as trusted.", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/trusted-devices/revoke-all": { + "post": { + "operationId": "trusted_device_revoke_all", + "tags": [ + "Trusted Devices" + ], + "responses": { + "204": { + "description": "All devices revoked successfully", + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Revoke all trusted devices", + "description": "Revoke trust for all devices.", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/trusted-devices/{id}": { + "delete": { + "operationId": "trusted_device_revoke", + "tags": [ + "Trusted Devices" + ], + "responses": { + "204": { + "description": "Device revoked successfully" + }, + "404": { + "description": "Device not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "Revoke trusted device", + "description": "Revoke trust for a specific device.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "TrustedDevice identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/me": { + "get": { + "operationId": "user_get_current_profile", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Current user profile retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "404": { + "description": "Authenticated user not found" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Get current user profile", + "description": "Returns the authenticated user profile with resolved global roles and permissions.", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "operationId": "user_update_current_profile", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Current user profile updated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Authenticated user not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Update current user profile", + "description": "Partially updates the authenticated user profile (first name, last name, preferred display language). Requires profile.update permission.", + "parameters": [], + "requestBody": { + "description": "The updated CurrentUserProfile resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileInput-user.write.jsonMergePatch" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/me/avatar": { + "put": { + "operationId": "user_upload_current_avatar", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Current user avatar uploaded successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "422": { + "description": "Invalid file - missing, too large, or unsupported MIME type", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Authenticated user not found" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Upload current user avatar", + "description": "Replaces the authenticated user avatar. Accepted formats: JPEG, PNG, WebP, GIF (max 5 MB). Requires profile.update permission.", + "parameters": [], + "requestBody": { + "description": "Avatar image file (JPEG, PNG, WebP or GIF, max 5 MB)", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "avatar" + ], + "properties": { + "avatar": { + "type": "string", + "format": "binary", + "description": "Image file (JPEG, PNG, WebP or GIF), max 5 MB" + } + } + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/me/deactivate": { + "post": { + "operationId": "user_deactivate_current", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Account deactivated successfully \u2014 all sessions revoked", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Authenticated user not found" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Deactivate current user account", + "description": "Deactivates the authenticated user own account and revokes all of their active sessions, signing them out everywhere. Idempotent. Organization memberships are left untouched. Reactivation is admin-only (POST /users/{id}/activate) \u2014 there is no self-service reactivation. Requires profile.update permission.", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/me/email-change": { + "post": { + "operationId": "user_email_change_request", + "tags": [ + "Users" + ], + "responses": { + "202": { + "description": "Confirmation link sent to the new address", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EmailChange.RequestEmailChangeOutput.jsonld-email_change.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - missing or malformed fields", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "409": { + "description": "This email address cannot be used" + }, + "422": { + "description": "Current password is incorrect", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "429": { + "description": "Too many email change requests" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Request email change", + "description": "Verifies the current password, then sends a confirmation link (1 h validity) to the new address and an alert to the current address. A new request replaces any pending one.", + "parameters": [], + "requestBody": { + "description": "The new EmailChange resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EmailChange.RequestEmailChangeInput-email_change.write" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "user_email_change_cancel", + "tags": [ + "Users" + ], + "responses": { + "204": { + "description": "Pending request cancelled (or nothing was pending)" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Cancel pending email change", + "description": "Cancels the pending email change request of the authenticated user. Idempotent: answers 204 whether or not a request was pending.", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/me/email-change/confirm": { + "post": { + "operationId": "user_email_change_confirm", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Email changed \u2014 all sessions have been revoked, sign in again with the new address", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EmailChange.ConfirmEmailChangeOutput.jsonld-email_change.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid, expired or already-used token", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "409": { + "description": "This email address cannot be used" + }, + "429": { + "description": "Too many confirmation attempts" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Confirm email change", + "description": "Public. Validates the confirmation token received at the new address, applies the change, and revokes every session and OAuth token of the user (the sign-in identifier changed). The token is single-use and expires after 1 hour.", + "parameters": [], + "requestBody": { + "description": "The new EmailChange resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EmailChange.ConfirmEmailChangeInput-email_change.write" + } + } + }, + "required": true + } + } + }, + "/api/me/password/confirm": { + "post": { + "operationId": "confirm_password_change", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Password changed successfully \u2014 all sessions have been revoked", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PasswordChange.ConfirmPasswordChangeOutput.jsonld-token.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - missing or invalid parameters", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required, or invalid/expired token/code" + }, + "429": { + "description": "Too many confirmation attempts or maximum verification attempts exceeded" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Confirm password change", + "description": "Confirms the password change using the challenge token, the OTP code received by email, and the new password. All sessions and OAuth tokens are revoked afterwards.", + "parameters": [], + "requestBody": { + "description": "The new PasswordChange resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PasswordChange.ConfirmPasswordChangeInput-password_change.write" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/me/password/request": { + "post": { + "operationId": "request_password_change", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Verification code sent \u2014 use the challenge token in the confirm step", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PasswordChange.RequestPasswordChangeOutput.jsonld-token.read" + } + } + }, + "links": { + "ConfirmChange": { + "operationId": "confirm_password_change", + "description": "Confirm the password change using the token and code" + } + } + }, + "400": { + "description": "Invalid request - missing current password", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "422": { + "description": "Current password is incorrect", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "429": { + "description": "Too many password change requests" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Request password change", + "description": "Verifies the current password and sends an OTP code to the user's email. The returned challenge token is required for the confirm step.", + "parameters": [], + "requestBody": { + "description": "The new PasswordChange resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PasswordChange.RequestPasswordChangeInput-password_change.write" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/users": { + "get": { + "operationId": "user_list", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "List of users retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "User.UserOutput.jsonld-user.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "List users", + "description": "Returns a paginated list of users. Requires users.read permission.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "user_create", + "tags": [ + "Users" + ], + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "409": { + "description": "User already exists" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Create user", + "description": "Creates a new user account. Requires users.create permission.", + "parameters": [], + "requestBody": { + "description": "The new User resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserInput-user.write" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/users/{id}": { + "get": { + "operationId": "user_get", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + } + }, + "404": { + "description": "User not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "Get user", + "description": "Retrieve a user by ID. Requires users.read permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "user_replace", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User replaced successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "User not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Replace user", + "description": "Replace a user by ID. Requires users.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated User resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserInput-user.write" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "user_delete", + "tags": [ + "Users" + ], + "responses": { + "204": { + "description": "User deleted successfully" + }, + "404": { + "description": "User not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "Delete user", + "description": "Delete a user by ID. Requires users.delete permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "operationId": "user_update", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User updated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "User not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Update user (partial)", + "description": "Partially update a user by ID. Requires users.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated User resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/User.UserInput-user.write.jsonMergePatch" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/users/{id}/activate": { + "post": { + "operationId": "user_activate", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User activated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "404": { + "description": "User not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Activate user", + "description": "Activate a user account. Requires users.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/users/{id}/avatar": { + "get": { + "operationId": "user_get_avatar", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Avatar image (WebP)" + }, + "404": { + "description": "User or avatar not found" + }, + "204": { + "description": "User resource" + } + }, + "summary": "Get user avatar", + "description": "Streams the avatar as WebP. Optional ?size=N query parameter resolved to the nearest variant (256, 128, 64 or 32 px). Public endpoint.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "post": { + "operationId": "user_upload_avatar", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Avatar uploaded \u2014 user output with updated avatarUrl returned", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "422": { + "description": "Invalid file \u2014 missing, too large, or unsupported MIME type", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "404": { + "description": "User not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Upload user avatar", + "description": "Uploads an avatar image for a user. Accepted formats: JPEG, PNG, WebP, GIF (max 5 MB). The image is resized to four WebP variants (256, 128, 64, 32 px) with cover crop. Requires users.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "Avatar image file (JPEG, PNG, WebP or GIF, max 5 MB)", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "avatar" + ], + "properties": { + "avatar": { + "type": "string", + "format": "binary", + "description": "Image file (JPEG, PNG, WebP or GIF), max 5 MB" + } + } + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/users/{id}/avatar/{size}.webp": { + "get": { + "operationId": "user_get_avatar_sized", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Avatar image (WebP)" + }, + "404": { + "description": "User or avatar not found" + }, + "204": { + "description": "User resource" + } + }, + "summary": "Get user avatar (sized)", + "description": "Streams the avatar WebP variant closest to the requested size (256, 128, 64 or 32 px). Public endpoint.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "size", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/users/{id}/deactivate": { + "post": { + "operationId": "user_deactivate", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User deactivated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "404": { + "description": "User not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Deactivate user", + "description": "Deactivate a user account. Requires users.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/users/{id}/verify-email": { + "post": { + "operationId": "user_verify_email", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User email verified successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "404": { + "description": "User not found" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Verify user email", + "description": "Mark a user email as verified. Requires users.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/organizations/{organizationId}/webhooks": { + "get": { + "operationId": "listWebhookSubscriptions", + "tags": [ + "Webhooks" + ], + "responses": { + "200": { + "description": "WebhookSubscription collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List webhook subscriptions", + "description": "Lists an organization's webhook subscriptions. Requires organization.webhooks.read.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ] + }, + "post": { + "operationId": "createWebhookSubscription", + "tags": [ + "Webhooks" + ], + "responses": { + "201": { + "description": "Subscription created, secret revealed once", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WebhookSubscription.WebhookSecretOutput.jsonld-Webhook.read" + } + } + }, + "links": {} + }, + "403": { + "description": "Insufficient permissions" + }, + "422": { + "description": "Invalid URL, unknown event type, or subscription cap reached", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Create webhook subscription", + "description": "Creates an outbound webhook subscription. Requires organization.webhooks.manage. The response includes the PLAINTEXT signing secret exactly once.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new WebhookSubscription resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WebhookSubscription.CreateWebhookSubscriptionInput-Webhook.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/webhooks/{webhookId}": { + "get": { + "operationId": "getWebhookSubscription", + "tags": [ + "Webhooks" + ], + "responses": { + "200": { + "description": "Subscription retrieved", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read" + } + } + } + }, + "404": { + "description": "Organization or subscription not found" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Get webhook subscription", + "description": "Returns a single webhook subscription. Requires organization.webhooks.read.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "webhookId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "delete": { + "operationId": "deleteWebhookSubscription", + "tags": [ + "Webhooks" + ], + "responses": { + "204": { + "description": "Subscription deleted" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization or subscription not found" + } + }, + "summary": "Delete webhook subscription", + "description": "Permanently deletes a webhook subscription and its delivery log. Requires organization.webhooks.manage.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "webhookId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "patch": { + "operationId": "updateWebhookSubscription", + "tags": [ + "Webhooks" + ], + "responses": { + "200": { + "description": "WebhookSubscription resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Update webhook subscription", + "description": "Partially updates a webhook subscription (url, eventTypes, isActive, description). Requires organization.webhooks.manage.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "webhookId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated WebhookSubscription resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/WebhookSubscription.UpdateWebhookSubscriptionInput-Webhook.write.jsonMergePatch" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/webhooks/{webhookId}/deliveries": { + "get": { + "operationId": "listWebhookDeliveries", + "tags": [ + "Webhooks" + ], + "responses": { + "200": { + "description": "WebhookDelivery collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "WebhookDelivery.WebhookDeliveryOutput.jsonld-Webhook.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookDelivery.WebhookDeliveryOutput.jsonld-Webhook.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List webhook deliveries", + "description": "Lists a subscription's delivery log. Requires organization.webhooks.read.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookDelivery identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "webhookId", + "in": "path", + "description": "WebhookDelivery identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "status", + "in": "query", + "description": "Delivery status filter (pending|delivered|failed).", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver": { + "post": { + "operationId": "redeliverWebhook", + "tags": [ + "Webhooks" + ], + "responses": { + "202": { + "description": "Redelivery enqueued", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WebhookDelivery.WebhookPingOutput.jsonld-Webhook.read" + } + } + }, + "links": {} + }, + "404": { + "description": "Organization, subscription, or delivery not found" + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Redeliver a webhook delivery", + "description": "Re-enqueues a delivery (any status, including terminally failed) for another attempt. Requires organization.webhooks.manage.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookDelivery identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "webhookId", + "in": "path", + "description": "WebhookDelivery identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "deliveryId", + "in": "path", + "description": "WebhookDelivery identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/webhooks/{webhookId}/ping": { + "post": { + "operationId": "pingWebhookSubscription", + "tags": [ + "Webhooks" + ], + "responses": { + "202": { + "description": "Test delivery enqueued", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WebhookSubscription.WebhookPingOutput.jsonld-Webhook.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Send a test delivery", + "description": "Enqueues a synthetic `webhook.ping` test delivery to this subscription. Requires organization.webhooks.manage.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "webhookId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/webhooks/{webhookId}/rotate-secret": { + "post": { + "operationId": "rotateWebhookSecret", + "tags": [ + "Webhooks" + ], + "responses": { + "200": { + "description": "WebhookSubscription resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WebhookSubscription.WebhookSecretOutput.jsonld-Webhook.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Rotate webhook signing secret", + "description": "Generates a new signing secret, invalidating the previous one. Requires organization.webhooks.manage. The response includes the new PLAINTEXT secret exactly once.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "webhookId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/webhooks/event-types": { + "get": { + "operationId": "listWebhookEventTypes", + "tags": [ + "Webhooks" + ], + "responses": { + "200": { + "description": "WebhookEventType collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "WebhookEventType.WebhookEventTypeOutput.jsonld-Webhook.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookEventType.WebhookEventTypeOutput.jsonld-Webhook.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List webhook event types", + "description": "Returns the curated public event type allowlist a webhook subscription may register for.", + "parameters": [] + } + }, + "/api/organizations/{organizationId}/workload": { + "get": { + "operationId": "workload_get", + "tags": [ + "Workload" + ], + "responses": { + "200": { + "description": "Workload resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Workload.WorkloadOutput.jsonld" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a Workload resource.", + "description": "Retrieves a Workload resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Workload identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "from", + "in": "query", + "description": "Workload from", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "format": "date" + }, + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "Workload to", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "format": "date" + }, + "style": "form", + "explode": true + }, + { + "name": "member", + "in": "query", + "description": "Workload member", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "team", + "in": "query", + "description": "Workload team", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "overloaded", + "in": "query", + "description": "Workload overloaded", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean" + }, + "style": "form", + "explode": true + }, + { + "name": "page", + "in": "query", + "description": "One-based member page. Pagination never limits workload contributions.", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "pageSize", + "in": "query", + "description": "Members per page, after member, team and overload filters.", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 10 + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/workload/assessments": { + "post": { + "operationId": "workload_assess", + "tags": [ + "Workload" + ], + "responses": { + "200": { + "description": "Workload resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Workload.WorkloadAssessmentOutput.jsonld" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Creates a Workload resource.", + "description": "Creates a Workload resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Workload identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new Workload resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Workload.WorkloadAssessmentInput" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/workload/members/{memberId}/capacity": { + "get": { + "operationId": "workload_capacity_get", + "tags": [ + "WorkloadCapacity" + ], + "responses": { + "200": { + "description": "WorkloadCapacity resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityOutput.jsonld" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a WorkloadCapacity resource.", + "description": "Retrieves a WorkloadCapacity resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "memberId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "post": { + "operationId": "workload_capacity_save", + "tags": [ + "WorkloadCapacity" + ], + "responses": { + "201": { + "description": "WorkloadCapacity resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityChangeOutput.jsonld" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Creates a WorkloadCapacity resource.", + "description": "Creates a WorkloadCapacity resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "memberId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new WorkloadCapacity resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityWeekInput" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/workload/members/{memberId}/exceptions": { + "get": { + "operationId": "workload_exceptions_get", + "tags": [ + "WorkloadCapacity" + ], + "responses": { + "200": { + "description": "WorkloadCapacity resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityOutput.jsonld" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a WorkloadCapacity resource.", + "description": "Retrieves a WorkloadCapacity resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "memberId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "post": { + "operationId": "workload_exception_create", + "tags": [ + "WorkloadCapacity" + ], + "responses": { + "201": { + "description": "WorkloadCapacity resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityChangeOutput.jsonld" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Creates a WorkloadCapacity resource.", + "description": "Creates a WorkloadCapacity resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "memberId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new WorkloadCapacity resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityExceptionInput" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/workload/members/{memberId}/exceptions/{exceptionId}": { + "delete": { + "operationId": "workload_exception_cancel", + "tags": [ + "WorkloadCapacity" + ], + "responses": { + "204": { + "description": "WorkloadCapacity resource deleted" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Removes the WorkloadCapacity resource.", + "description": "Removes the WorkloadCapacity resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "memberId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "exceptionId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/workload/settings": { + "get": { + "operationId": "workload_settings_get", + "tags": [ + "WorkloadCapacity" + ], + "responses": { + "200": { + "description": "WorkloadCapacity resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityOutput.jsonld" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a WorkloadCapacity resource.", + "description": "Retrieves a WorkloadCapacity resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "post": { + "operationId": "workload_settings_save", + "tags": [ + "WorkloadCapacity" + ], + "responses": { + "201": { + "description": "WorkloadCapacity resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityChangeOutput.jsonld" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Creates a WorkloadCapacity resource.", + "description": "Creates a WorkloadCapacity resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new WorkloadCapacity resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityWeekInput" + } + } + }, + "required": true + } + } + } + }, + "components": { + "schemas": { + "AddressSuggestionOutput.jsonld-facility.read": { + "type": "object", + "properties": { + "displayName": { + "description": "Constructor.", + "type": "string" + }, + "latitude": { + "description": "Constructor.", + "type": "number" + }, + "longitude": { + "description": "Constructor.", + "type": "number" + }, + "street": { + "description": "Constructor.", + "type": "string" + }, + "city": { + "description": "Constructor.", + "type": "string" + }, + "region": { + "description": "Constructor.", + "type": "string" + }, + "postalCode": { + "description": "Constructor.", + "type": "string" + }, + "country": { + "description": "Constructor.", + "type": "string" + }, + "countryCode": { + "description": "Constructor.", + "type": "string" + } + } + }, + "ApprovalActionType.ApprovalActionTypeOutput.jsonld-Approval.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "value": { + "readOnly": true, + "description": "Property value.", + "default": "", + "type": "string" + }, + "label": { + "readOnly": true, + "description": "Property label.", + "default": "", + "type": "string" + } + } + } + ], + "description": "Reference catalog of regulated action types the approval policy can gate." + }, + "ApprovalRequest.ApprovalRequestOutput.jsonld-Approval.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "actionType": { + "readOnly": true, + "description": "Property actionType.", + "default": "", + "type": "string" + }, + "subjectId": { + "readOnly": true, + "description": "Property subjectId.", + "default": "", + "type": "string" + }, + "status": { + "readOnly": true, + "description": "Property status.", + "default": "", + "type": "string" + }, + "requestedByMemberId": { + "readOnly": true, + "description": "Property requestedByMemberId.", + "default": "", + "type": "string" + }, + "requestedByUserId": { + "readOnly": true, + "description": "Property requestedByUserId.", + "default": "", + "type": "string" + }, + "decisionByMemberId": { + "readOnly": true, "description": "Property decisionByMemberId.", "type": [ "string", "null" ] }, - "decisionByUserId": { - "readOnly": true, - "description": "Property decisionByUserId.", + "decisionByUserId": { + "readOnly": true, + "description": "Property decisionByUserId.", + "type": [ + "string", + "null" + ] + }, + "decisionNote": { + "readOnly": true, + "description": "Property decisionNote.", + "type": [ + "string", + "null" + ] + }, + "expiresAt": { + "readOnly": true, + "description": "Property expiresAt.", + "default": "", + "type": "string" + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt.", + "default": "", + "type": "string" + }, + "decidedAt": { + "readOnly": true, + "description": "Property decidedAt.", + "type": [ + "string", + "null" + ] + }, + "executedAt": { + "readOnly": true, + "description": "Property executedAt.", + "type": [ + "string", + "null" + ] + }, + "executionError": { + "readOnly": true, + "description": "Property executionError.", + "type": [ + "string", + "null" + ] + }, + "allowedActions": { + "description": "available decisions, revalidated by the command", + "type": "array", + "items": { + "type": "string" + } + }, + "decisionBlockReason": { + "description": "Property decisionBlockReason", + "type": [ + "string", + "null" + ] + } + } + } + ], + "description": "Organization-scoped four-eyes approval request queue." + }, + "ApprovalRequest.ApproveApprovalRequestInput-Approval.write": { + "type": "object", + "description": "Organization-scoped four-eyes approval request queue.", + "properties": { + "decisionNote": { + "maxLength": 2000, + "description": "Optional free-form decision note", + "type": [ + "string", + "null" + ] + } + } + }, + "ApprovalRequest.RejectApprovalRequestInput-Approval.write": { + "type": "object", + "description": "Organization-scoped four-eyes approval request queue.", + "properties": { + "decisionNote": { + "maxLength": 2000, + "description": "Optional free-form decision note", + "type": [ + "string", + "null" + ] + } + } + }, + "ApprovalRequest.WithdrawApprovalRequestInput-Approval.write": { + "type": "object", + "description": "Organization-scoped four-eyes approval request queue.", + "properties": { + "decisionNote": { + "maxLength": 2000, + "description": "Optional withdrawal reason", + "type": [ + "string", + "null" + ] + } + } + }, + "AssistantMessageOutput.jsonld-Assistant.read": { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "threadId": { + "readOnly": true, + "description": "Property threadId.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "role": { + "readOnly": true, + "description": "Property role.", + "default": "", + "type": "string" + }, + "body": { + "readOnly": true, + "description": "Property body.", + "default": "", + "type": "string" + }, + "status": { + "readOnly": true, + "description": "Property status.", + "default": "", + "type": "string" + }, + "errorCode": { + "readOnly": true, + "description": "Property errorCode.", + "type": [ + "string", + "null" + ] + }, + "tokenCount": { + "readOnly": true, + "description": "Property tokenCount.", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "completedAt": { + "readOnly": true, + "description": "Property completedAt.", + "type": [ + "string", + "null" + ] + }, + "attemptId": { + "readOnly": true, + "description": "Property attemptId", + "type": [ + "string", + "null" + ] + }, + "attemptNumber": { + "readOnly": true, + "description": "Property attemptNumber", + "default": 0, + "type": "integer" + }, + "attemptSequence": { + "readOnly": true, + "description": "Property attemptSequence", + "default": 0, + "type": "integer" + }, + "attemptExpiresAt": { + "readOnly": true, + "description": "Property attemptExpiresAt", + "type": [ + "string", + "null" + ] + }, + "canCancel": { + "readOnly": true, + "description": "Property canCancel", + "default": false, + "type": "boolean" + }, + "canRetry": { + "readOnly": true, + "description": "Property canRetry", + "default": false, + "type": "boolean" + } + } + }, + "AssistantThread.AskAssistantQuestionInput-Assistant.write": { + "type": "object", + "description": "Member-private AI-assistant chat threads.", + "required": [ + "body" + ], + "properties": { + "body": { + "maxLength": 8000, + "description": "The question body", + "default": "", + "type": "string" + }, + "temperature": { + "minimum": 0, + "maximum": 2, + "description": "Optional sampling temperature for this question only (0.0-2.0)", + "type": [ + "number", + "null" + ] + } + } + }, + "AssistantThread.AskAssistantQuestionOutput.jsonld-Assistant.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "threadId": { + "readOnly": true, + "description": "Property threadId.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "userMessage": { + "readOnly": true, + "description": "Property userMessage.", + "$ref": "#/components/schemas/AssistantMessageOutput.jsonld-Assistant.read" + }, + "assistantMessage": { + "readOnly": true, + "description": "Property assistantMessage.", + "$ref": "#/components/schemas/AssistantMessageOutput.jsonld-Assistant.read" + } + } + } + ], + "description": "Member-private AI-assistant chat threads." + }, + "AssistantThread.AssistantMessageOutput.jsonld-Assistant.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "threadId": { + "readOnly": true, + "description": "Property threadId.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "role": { + "readOnly": true, + "description": "Property role.", + "default": "", + "type": "string" + }, + "body": { + "readOnly": true, + "description": "Property body.", + "default": "", + "type": "string" + }, + "status": { + "readOnly": true, + "description": "Property status.", + "default": "", + "type": "string" + }, + "errorCode": { + "readOnly": true, + "description": "Property errorCode.", + "type": [ + "string", + "null" + ] + }, + "tokenCount": { + "readOnly": true, + "description": "Property tokenCount.", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "completedAt": { + "readOnly": true, + "description": "Property completedAt.", + "type": [ + "string", + "null" + ] + }, + "attemptId": { + "readOnly": true, + "description": "Property attemptId", + "type": [ + "string", + "null" + ] + }, + "attemptNumber": { + "readOnly": true, + "description": "Property attemptNumber", + "default": 0, + "type": "integer" + }, + "attemptSequence": { + "readOnly": true, + "description": "Property attemptSequence", + "default": 0, + "type": "integer" + }, + "attemptExpiresAt": { + "readOnly": true, + "description": "Property attemptExpiresAt", + "type": [ + "string", + "null" + ] + }, + "canCancel": { + "readOnly": true, + "description": "Property canCancel", + "default": false, + "type": "boolean" + }, + "canRetry": { + "readOnly": true, + "description": "Property canRetry", + "default": false, + "type": "boolean" + } + } + } + ], + "description": "Member-private AI-assistant chat threads." + }, + "AssistantThread.AssistantThreadDetailOutput.jsonld-Assistant.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "memberId": { + "readOnly": true, + "description": "Property memberId.", + "default": "", + "type": "string" + }, + "title": { + "readOnly": true, + "description": "Property title.", + "type": [ + "string", + "null" + ] + }, + "model": { + "readOnly": true, + "description": "Property model.", + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt.", + "default": "", + "type": "string" + }, + "lastMessageAt": { + "readOnly": true, + "description": "Property lastMessageAt.", + "type": [ + "string", + "null" + ] + }, + "messages": { + "readOnly": true, + "description": "Property messages.", + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantMessageOutput.jsonld-Assistant.read" + } + }, + "messagesPage": { + "readOnly": true, + "description": "Property messagesPage.", + "default": 1, + "type": "integer" + }, + "messagesItemsPerPage": { + "readOnly": true, + "description": "Property messagesItemsPerPage.", + "default": 50, + "type": "integer" + }, + "messagesTotal": { + "readOnly": true, + "description": "Property messagesTotal.", + "default": 0, + "type": "integer" + } + } + } + ], + "description": "Member-private AI-assistant chat threads." + }, + "AssistantThread.AssistantThreadOutput.jsonld-Assistant.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "memberId": { + "readOnly": true, + "description": "Property memberId.", + "default": "", + "type": "string" + }, + "title": { + "readOnly": true, + "description": "Property title.", + "type": [ + "string", + "null" + ] + }, + "model": { + "readOnly": true, + "description": "Property model.", + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt.", + "default": "", + "type": "string" + }, + "lastMessageAt": { + "readOnly": true, + "description": "Property lastMessageAt.", + "type": [ + "string", + "null" + ] + } + } + } + ], + "description": "Member-private AI-assistant chat threads." + }, + "AssistantThread.AssistantThreadSubscriptionOutput.jsonld-Assistant.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "token": { + "readOnly": true, + "description": "Property token.", + "default": "", + "type": "string" + }, + "topic": { + "readOnly": true, + "description": "Property topic.", + "default": "", + "type": "string" + } + } + } + ], + "description": "Member-private AI-assistant chat threads." + }, + "AssistantThread.ControlAssistantAttemptInput-Assistant.write": { + "type": "object", + "description": "Member-private AI-assistant chat threads.", + "required": [ + "attemptId" + ], + "properties": { + "attemptId": { + "format": "uuid", + "description": "Property attemptId", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + } + } + }, + "AssistantThread.StartAssistantThreadInput-Assistant.write": { + "type": "object", + "description": "Member-private AI-assistant chat threads.", + "properties": { + "title": { + "maxLength": 255, + "description": "Optional display title for the thread", + "type": [ + "string", + "null" + ] + }, + "model": { + "maxLength": 255, + "description": "Optional tenant-selected model override, validated against the operator-configured allowlist", + "type": [ + "string", + "null" + ] + } + } + }, + "AuditEvent.AuditEventOutput.jsonld-audit.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "required": [ + "id", + "action", + "actorType", + "occurredAt", + "recordedAt", + "chainId", + "sequence", + "eventHash" + ], + "properties": { + "id": { + "readOnly": true, + "description": "Audit event identifier (UUID).", + "default": "", + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string", + "format": "uuid" + }, + "action": { + "readOnly": true, + "description": "Audit action key.", + "default": "", + "example": "auth.login_success", + "type": "string" + }, + "actorType": { + "readOnly": true, + "description": "Actor type (user, client, system, anonymous).", + "default": "", + "example": "user", + "type": "string" + }, + "actorId": { + "readOnly": true, + "description": "Actor identifier (UUID or external ID).", + "example": "0c1b3f6a-8c2e-4f5d-9c0a-2c2b7b9f1c7a", + "type": "string", + "nullable": true + }, + "actorEmail": { + "readOnly": true, + "description": "Actor email (optional, depending on PII settings).", + "example": "user@example.com", + "type": "string", + "nullable": true + }, + "actorEmailHash": { + "readOnly": true, + "description": "Actor email hash (SHA-256).", + "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "type": "string", + "nullable": true + }, + "subjectType": { + "readOnly": true, + "description": "Subject type (token, user, client, etc.).", + "example": "token", + "type": "string", + "nullable": true + }, + "subjectId": { + "readOnly": true, + "description": "Subject identifier (UUID or token ID).", + "example": "f4d3b0f0-2b4c-4cdd-9d5a-2d2fd9b3c1a1", + "type": "string", + "nullable": true + }, + "clientId": { + "readOnly": true, + "description": "Client identifier (UUID).", + "example": "3e9d2c1a-72d4-4b18-9d4d-9a1234567890", + "type": "string", + "nullable": true + }, + "tenantId": { + "readOnly": true, + "description": "Tenant identifier (UUID).", + "example": "00000000-0000-4000-8000-000000000001", + "type": "string", + "nullable": true + }, + "ipAddress": { + "readOnly": true, + "description": "Client IP address (optional, depending on PII settings).", + "example": "203.0.113.5", + "type": "string", + "nullable": true + }, + "ipHash": { + "readOnly": true, + "description": "IP hash (SHA-256).", + "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "type": "string", + "nullable": true + }, + "userAgent": { + "readOnly": true, + "description": "User agent string (if available).", + "example": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", + "type": "string", + "nullable": true + }, + "metadata": { + "readOnly": true, + "description": "Event metadata payload (JSON).", + "example": { + "ip": "203.0.113.5", + "action": "login", + "success": true + }, + "type": "object", + "additionalProperties": true + }, + "occurredAt": { + "readOnly": true, + "description": "ISO 8601 datetime when the event occurred.", + "default": "", + "example": "2026-01-30T09:45:00+00:00", + "type": "string", + "format": "date-time" + }, + "recordedAt": { + "readOnly": true, + "description": "ISO 8601 datetime when the event was recorded.", + "default": "", + "example": "2026-01-30T09:45:01+00:00", + "type": "string", + "format": "date-time" + }, + "chainId": { + "readOnly": true, + "description": "Audit chain identifier.", + "default": "", + "example": "tenant:00000000-0000-4000-8000-000000000001", + "type": "string" + }, + "sequence": { + "readOnly": true, + "description": "Monotonic sequence number within the chain.", + "default": 0, + "example": 123, + "type": "integer" + }, + "prevHash": { + "readOnly": true, + "description": "Previous event hash in the chain.", + "example": "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce", + "type": "string", + "nullable": true + }, + "eventHash": { + "readOnly": true, + "description": "Hash of the event payload (SHA-256).", + "default": "", + "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "type": "string" + } + } + } + ], + "description": "Returns a paginated list of audit events." + }, + "Auth.LoginInput-token.write": { + "type": "object", + "description": "User authentication operations", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "format": "email", + "writeOnly": true, + "description": "Email address of the user attempting to authenticate", + "externalDocs": { + "url": "https://schema.org/email" + }, + "example": "john.doe@example.com", + "type": "string", + "minLength": 5, + "maxLength": 255, + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + }, + "password": { + "minLength": 8, + "maxLength": 128, + "writeOnly": true, + "description": "Password of the user (write-only, never returned in responses)", + "example": "SecureP@ssw0rd!", + "type": "string", + "format": "password" + }, + "remember_me": { + "writeOnly": true, + "description": "Extend session duration (30 days if true, default 1 day if false)", + "default": false, + "example": false, + "type": "boolean", + "nullable": false + } + } + }, + "Auth.LoginOutput.jsonld-token.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "access_token": { + "readOnly": true, + "description": "JWT access token for API authentication; absent while MFA is required", + "example": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "type": "string" + }, + "token_type": { + "readOnly": true, + "description": "Token type; absent while MFA is required", + "example": "Bearer", + "type": "string", + "enum": [ + "Bearer" + ], + "default": "Bearer" + }, + "expires_in": { + "readOnly": true, + "description": "Token lifetime in seconds; absent while MFA is required", + "example": 3600, + "type": "integer", + "minimum": 1 + }, + "scope": { + "readOnly": true, + "description": "Space-separated list of granted scopes", + "example": "openid profile email", + "type": "string", + "nullable": true + }, + "mfa_required": { + "readOnly": true, + "description": "If true, authentication is incomplete. Use mfa_token to verify code.", + "example": true, + "type": "boolean", + "nullable": true + }, + "mfa_token": { + "readOnly": true, + "description": "Temporary Pre-Auth Token (JWT) covering the partial authentication state.", + "example": "eyJ...", + "type": "string", + "nullable": true + }, + "challenge_token": { + "readOnly": true, + "description": "The OTP challenge token (reference).", + "example": "abc...", + "type": "string", + "nullable": true + }, + "mfa_method": { + "readOnly": true, + "description": "The delivery method for the MFA code (email, sms, totp).", + "example": "email", + "type": "string", + "enum": [ + "email", + "sms", + "totp" + ], + "nullable": true + }, + "mfa_destination": { + "readOnly": true, + "description": "The masked destination where the code was sent (e.g., \"j***e@e****e.com\" or \"+336****5678\").", + "example": "j***e@e****e.com", + "type": "string", + "nullable": true + }, + "mfa_resend_in": { + "readOnly": true, + "description": "Seconds until MFA code can be resent", + "example": 60, + "type": "integer", + "nullable": true + }, + "return_url": { + "description": "Validated local destination returned by a federated login.", + "type": [ + "string", + "null" + ] + }, + "new_account": { + "description": "Whether a federated login provisioned the Fireguard account.", + "type": [ + "boolean", + "null" + ] + } + } + } + ], + "description": "User authentication operations" + }, + "Auth.LogoutOutput.jsonld-token.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "readOnly": true, + "description": "Confirmation message indicating successful logout", + "default": "Logged out successfully", + "example": "Logged out successfully", + "type": "string" + } + } + } + ], + "description": "User authentication operations" + }, + "Auth.MfaResendInput": { + "type": "object", + "description": "User authentication operations", + "required": [ + "preAuthToken" + ], + "properties": { + "preAuthToken": { + "writeOnly": true, + "description": "The Pre-Auth Token received from the login response.", + "example": "eyJ...", + "type": "string" + } + } + }, + "Auth.MfaVerifyInput": { + "type": "object", + "description": "User authentication operations", + "required": [ + "preAuthToken", + "code" + ], + "properties": { + "preAuthToken": { + "writeOnly": true, + "description": "The Pre-Auth Token received from the login response.", + "example": "eyJ...", + "type": "string" + }, + "code": { + "writeOnly": true, + "description": "The OTP code received by the user.", + "example": "123456", + "type": "string" + } + } + }, + "AutomationAttempt.AutomationAttemptOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "description": "Method __construct", + "type": "string" + }, + "runId": { + "description": "Method __construct", + "type": "string" + }, + "organizationId": { + "description": "Method __construct", + "type": "string" + }, + "ruleKey": { + "description": "Method __construct", + "type": "string" + }, + "subjectId": { + "description": "Method __construct", + "type": "string" + }, + "attemptNumber": { + "description": "Method __construct", + "type": "integer" + }, + "status": { + "description": "Method __construct", + "type": "string" + }, + "createdAt": { + "description": "Method __construct", + "type": "string" + }, + "finishedAt": { + "description": "Method __construct", + "type": [ + "string", + "null" + ] + }, + "requestedBy": { + "description": "Method __construct", + "type": [ + "string", + "null" + ] + }, + "interventionId": { + "description": "Method __construct", + "type": [ + "string", + "null" + ] + }, + "errorCode": { + "description": "Method __construct", + "type": [ + "string", + "null" + ] + }, + "canRetry": { + "description": "Method __construct", + "type": "boolean" + } + } + } + ], + "description": "Resource AutomationResource. Separate organization history and management access." + }, + "AutomationAttempt.AutomationPolicyOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "description": "Method __construct", + "type": "string" + }, + "ruleKey": { + "description": "Method __construct", + "type": "string" + }, + "enabled": { + "description": "Method __construct", + "type": "boolean" + }, + "canManage": { + "description": "Method __construct", + "type": "boolean" + } + } + } + ], + "description": "Resource AutomationResource. Separate organization history and management access." + }, + "AutomationAttempt.RetryAutomationAttemptInput": { + "type": "object", + "description": "Resource AutomationResource. Separate organization history and management access.", + "required": [ + "attemptId" + ], + "properties": { + "attemptId": { + "format": "uuid", + "description": "Property attemptId", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + } + } + }, + "BillingPricing.PlanPricingOutput.jsonld-Billing.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "planKey": { + "readOnly": true, + "description": "Property planKey.", + "default": "", + "type": "string" + }, + "currency": { + "readOnly": true, + "description": "Property currency.", + "default": "", + "type": "string" + }, + "monthlyAmount": { + "readOnly": true, + "description": "Property monthlyAmount.", + "type": [ + "integer", + "null" + ] + }, + "yearlyAmount": { + "readOnly": true, + "description": "Property yearlyAmount.", + "type": [ + "integer", + "null" + ] + } + } + } + ], + "description": "Subscription plan pricing." + }, + "CalendarEvent.CalendarEventOutput.jsonld-Calendar.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "title": { + "readOnly": true, + "description": "Property title.", + "default": "", + "type": "string" + }, + "description": { + "readOnly": true, + "description": "Property description.", + "type": [ + "string", + "null" + ] + }, + "startsAt": { + "readOnly": true, + "description": "Property startsAt.", + "default": "", + "type": "string" + }, + "endsAt": { + "readOnly": true, + "description": "Property endsAt.", + "type": [ + "string", + "null" + ] + }, + "allDay": { + "readOnly": true, + "description": "Property allDay.", + "default": false, + "type": "boolean" + }, + "facilityId": { + "readOnly": true, + "description": "Property facilityId.", + "type": [ + "string", + "null" + ] + }, + "createdByMemberId": { + "readOnly": true, + "description": "Property createdByMemberId.", + "default": "", + "type": "string" + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt.", + "default": "", + "type": "string" + } + } + } + ], + "description": "Organization-scoped standalone calendar event management." + }, + "CalendarEvent.CreateCalendarEventInput-Calendar.write": { + "type": "object", + "description": "Organization-scoped standalone calendar event management.", + "required": [ + "title", + "startsAt" + ], + "properties": { + "title": { + "maxLength": 255, + "description": "Event title", + "default": "", + "example": "Fire drill", + "type": "string" + }, + "description": { + "maxLength": 5000, + "description": "Free-form description", + "type": [ + "string", + "null" + ] + }, + "startsAt": { + "description": "Event start, ISO 8601 with an explicit timezone offset", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "default": "", + "example": "2026-08-01T09:00:00+02:00", + "type": "string" + }, + "endsAt": { + "description": "Event end, ISO 8601 with an explicit timezone offset", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "example": "2026-08-01T11:00:00+02:00", + "type": [ + "string", + "null" + ] + }, + "allDay": { + "description": "Whether the event spans whole day(s)", + "default": false, + "type": "boolean" + }, + "facilityId": { + "description": "Associated facility identifier", + "type": [ + "string", + "null" + ] + } + } + }, + "CalendarEvent.UpdateCalendarEventInput-Calendar.write.jsonMergePatch": { + "type": "object", + "description": "Organization-scoped standalone calendar event management.", + "properties": { + "title": { + "minLength": 1, + "maxLength": 255, + "description": "Event title", + "type": [ + "string", + "null" + ] + }, + "description": { + "maxLength": 5000, + "description": "Free-form description", + "type": [ + "string", + "null" + ] + }, + "startsAt": { + "description": "Event start, ISO 8601 with an explicit timezone offset", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "type": [ + "string", + "null" + ] + }, + "endsAt": { + "description": "Event end, ISO 8601 with an explicit timezone offset", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "type": [ + "string", + "null" + ] + }, + "allDay": { + "description": "Whether the event spans whole day(s)", + "type": [ + "boolean", + "null" + ] + }, + "facilityId": { + "description": "Associated facility identifier", + "type": [ + "string", + "null" + ] + } + } + }, + "CalendarFeed.CalendarFeedOutput.jsonld-Calendar.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "from": { + "readOnly": true, + "description": "Property from.", + "default": "", + "type": "string" + }, + "to": { + "readOnly": true, + "description": "Property to.", + "default": "", + "type": "string" + }, + "items": { + "readOnly": true, + "description": "Property items.", + "type": "array", + "items": { + "$ref": "#/components/schemas/CalendarFeedItemOutput.jsonld-Calendar.read" + } + }, + "sources": { + "readOnly": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/CalendarFeedSourceOutput.jsonld-Calendar.read" + } + }, + "complete": { + "readOnly": true, + "description": "Property complete", + "default": true, + "type": "boolean" + } + } + } + ], + "description": "Unified organization calendar feed (standalone events, inspections, interventions, preventive-maintenance due dates)." + }, + "CalendarFeedItemOutput.jsonld-Calendar.read": { + "type": "object", + "properties": { + "sourceKey": { + "readOnly": true, + "description": "Property sourceKey.", + "default": "", + "type": "string" + }, + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "title": { + "readOnly": true, + "description": "Property title.", + "default": "", + "type": "string" + }, + "description": { + "readOnly": true, + "description": "Property description.", + "type": [ + "string", + "null" + ] + }, + "startsAt": { + "readOnly": true, + "description": "Property startsAt.", + "default": "", + "type": "string" + }, + "endsAt": { + "readOnly": true, + "description": "Property endsAt.", + "type": [ + "string", + "null" + ] + }, + "allDay": { + "readOnly": true, + "description": "Property allDay.", + "default": false, + "type": "boolean" + }, + "facilityId": { + "readOnly": true, + "description": "Property facilityId.", + "type": [ + "string", + "null" + ] + }, + "status": { + "readOnly": true, + "description": "Property status.", + "type": [ + "string", + "null" + ] + }, + "targetType": { + "readOnly": true, + "description": "Property targetType.", + "default": "", + "type": "string" + }, + "targetId": { + "readOnly": true, + "description": "Property targetId.", + "default": "", + "type": "string" + } + } + }, + "CalendarFeedSourceOutput.jsonld-Calendar.read": { + "type": "object", + "properties": { + "sourceKey": { + "description": "Property sourceKey", + "default": "", + "type": "string" + }, + "available": { + "description": "Property available", + "default": true, + "type": "boolean" + }, + "truncated": { + "description": "Property truncated", + "default": false, + "type": "boolean" + } + } + }, + "CalendarFeedToken.CalendarFeedTokenOutput.jsonld-Calendar.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "lastUsedAt": { + "readOnly": true, + "description": "Property lastUsedAt.", + "type": [ + "string", + "null" + ] + } + } + } + ], + "description": "Member-scoped iCal calendar feed subscription token." + }, + "CalendarFeedToken.CalendarFeedTokenSecretOutput.jsonld-Calendar.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "secret": { + "readOnly": true, + "description": "Property secret.", + "default": "", + "type": "string" + }, + "feedUrl": { + "readOnly": true, + "description": "Property feedUrl.", + "default": "", + "type": "string" + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "rotated": { + "readOnly": true, + "description": "Property rotated.", + "default": false, + "type": "boolean" + } + } + } + ], + "description": "Member-scoped iCal calendar feed subscription token." + }, + "CapacityConfigurationView.jsonld": { + "type": "object", + "properties": { + "weeks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CapacityWeekView.jsonld" + } + }, + "exceptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CapacityExceptionView.jsonld" + } + } + } + }, + "CapacityExceptionView.jsonld": { + "type": "object", + "properties": { + "id": { + "description": "stable resource identifier, retained across idempotent retries", + "type": "string" + }, + "memberId": { + "description": "organization member whose work or capacity is represented", + "type": "string" + }, + "startsOn": { + "description": "inclusive organization-local start date", + "type": "string" + }, + "endsOn": { + "description": "inclusive organization-local end date", + "type": "string" + }, + "minutes": { + "description": "duration in whole minutes", + "type": "integer" + } + } + }, + "CapacityWeekView.jsonld": { + "type": "object", + "properties": { + "id": { + "description": "stable resource identifier, retained across idempotent retries", + "type": "string" + }, + "scopeId": { + "description": "organization identifier for defaults, or member identifier for an individual override", + "type": "string" + }, + "effectiveOn": { + "description": "first local date on which this capacity version applies", + "type": "string" + }, + "minutes": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "Challenge.ChallengeOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "required": [ + "token", + "purpose", + "channel", + "maskedRecipient", + "status", + "expiresIn", + "attemptsRemaining", + "canResendIn" + ], + "properties": { + "token": { + "readOnly": true, + "description": "Challenge token to use in GET/verify/resend endpoints", + "example": "a1b2c3d4e5f6...", + "type": "string" + }, + "purpose": { + "readOnly": true, + "description": "The purpose of this OTP challenge", + "example": "login", + "type": "string" + }, + "channel": { + "readOnly": true, + "description": "The delivery channel used", + "example": "email", + "type": "string" + }, + "maskedRecipient": { + "readOnly": true, + "description": "Masked recipient for user-friendly display", + "example": "jo***@example.com", + "type": "string" + }, + "status": { + "readOnly": true, + "description": "Current status: pending, verified, expired, or failed", + "example": "pending", + "type": "string", + "enum": [ + "pending", + "verified", + "expired", + "failed" + ] + }, + "expiresIn": { + "readOnly": true, + "description": "Seconds remaining until the challenge expires", + "example": 285, + "type": "integer" + }, + "attemptsRemaining": { + "readOnly": true, + "description": "Number of verification attempts remaining", + "example": 4, + "type": "integer" + }, + "canResendIn": { + "readOnly": true, + "description": "Seconds until OTP can be resent (0 = can resend now)", + "example": 45, + "type": "integer" + } + } + } + ], + "description": "OTP challenge management for verification and 2FA." + }, + "Challenge.CreateChallengeInput": { + "type": "object", + "description": "OTP challenge management for verification and 2FA.", + "required": [ + "purpose", + "channel" + ], + "properties": { + "purpose": { + "enum": [ + "login", + "password_reset", + "email_verification", + "phone_verification", + "sensitive_operation", + "transaction_approval" + ], + "writeOnly": true, + "description": "The purpose of the OTP challenge. Use GET /api/otp/purposes to list available values.", + "example": "login", + "type": "string" + }, + "channel": { + "enum": [ + "email", + "sms", + "totp" + ], + "writeOnly": true, + "description": "The delivery channel for the OTP. Use GET /api/otp/channels to list available values.", + "example": "email", + "type": "string" + }, + "recipient": { + "writeOnly": true, + "description": "The recipient email or phone number. If not provided, user's default will be used.", + "example": "user@example.com", + "type": "string", + "nullable": true + }, + "ttlSeconds": { + "minimum": 60, + "maximum": 3600, + "writeOnly": true, + "description": "Custom time-to-live in seconds. Defaults to purpose-specific value.", + "example": 300, + "type": "integer", + "nullable": true + }, + "context": { + "writeOnly": true, + "description": "Optional context data (transaction ID, description, etc.)", + "example": { + "transactionId": "TXN-12345", + "description": "Confirm transfer of $100" + }, + "type": "object", + "nullable": true, + "additionalProperties": true + } + } + }, + "Challenge.VerifyOtpInput": { + "type": "object", + "description": "OTP challenge management for verification and 2FA.", + "required": [ + "code" + ], + "properties": { + "code": { + "writeOnly": true, + "description": "The OTP code to verify.", + "example": "123456", + "type": "string" + } + } + }, + "Challenge.VerifyOtpOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "required": [ + "success", + "attemptsRemaining" + ], + "properties": { + "success": { + "readOnly": true, + "description": "Whether verification was successful", + "example": true, + "type": "boolean" + }, + "attemptsRemaining": { + "readOnly": true, + "description": "Remaining verification attempts before the challenge is locked", + "example": 2, + "type": "integer" + }, + "error": { + "readOnly": true, + "description": "Error message when verification fails", + "example": "Invalid OTP code", + "type": "string", + "nullable": true + } + } + } + ], + "description": "OTP challenge management for verification and 2FA." + }, + "Channel.AddChannelParticipantInput": { + "type": "object", + "description": "Resource ChannelResource.\n\nNamed group channels \u2014 a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel \u2014 see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", + "required": [ + "memberId" + ], + "properties": { + "memberId": { + "format": "uuid", + "description": "Property memberId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "role": { + "maxLength": 50, + "description": "Property role.", + "type": [ + "string", + "null" + ] + } + } + }, + "Channel.BindChannelTeamInput.jsonMergePatch": { + "type": "object", + "description": "Resource ChannelResource.\n\nNamed group channels \u2014 a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel \u2014 see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", + "properties": { + "teamId": { + "format": "uuid", + "description": "Property teamId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + } + } + }, + "Channel.ChannelOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "description": "Property id.", + "default": "", + "type": "string" + }, + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" + }, + "name": { + "description": "Property name.", + "default": "", + "type": "string" + }, + "team": { + "description": "Property team.", + "type": [ + "string", + "null" + ] + }, + "createdByMember": { + "description": "Property createdByMember.", + "type": [ + "string", + "null" + ] + }, + "participantCount": { + "description": "Property participantCount.", + "default": 0, + "type": "integer" + }, + "isArchived": { + "description": "Property isArchived.", + "default": false, + "type": "boolean" + }, + "lastMessageAt": { + "description": "Property lastMessageAt.", + "type": [ + "string", + "null" + ] + }, + "messagesCount": { + "description": "Property messagesCount.", + "default": 0, + "type": "integer" + }, + "unreadCount": { + "description": "Property unreadCount.", + "default": 0, + "type": "integer" + }, + "createdAt": { + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "description": "Property updatedAt.", + "default": "", + "type": "string" + }, + "isFavorite": { + "description": "Property isFavorite.", + "default": false, + "type": "boolean" + }, + "parent": { + "description": "Property parent.", + "type": [ + "string", + "null" + ] + } + } + } + ], + "description": "Resource ChannelResource.\n\nNamed group channels \u2014 a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel \u2014 see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write." + }, + "Channel.ChannelParticipantOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "memberId": { + "description": "Property memberId.", + "default": "", + "type": "string" + }, + "role": { + "description": "Property role.", + "type": [ + "string", + "null" + ] + }, + "source": { + "description": "Property source.", + "default": "", + "type": "string" + }, + "addedAt": { + "description": "Property addedAt.", + "default": "", + "type": "string" + } + } + } + ], + "description": "Resource ChannelResource.\n\nNamed group channels \u2014 a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel \u2014 see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write." + }, + "Channel.CreateChannelInput": { + "type": "object", + "description": "Resource ChannelResource.\n\nNamed group channels \u2014 a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel \u2014 see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", + "required": [ + "organization", + "name" + ], + "properties": { + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" + }, + "name": { + "minLength": 2, + "maxLength": 80, + "description": "Property name.", + "default": "", + "type": "string" + } + } + }, + "Channel.SetChannelParentInput.jsonMergePatch": { + "type": "object", + "description": "Resource ChannelResource.\n\nNamed group channels \u2014 a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel \u2014 see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", + "properties": { + "parentChannelId": { + "format": "uuid", + "description": "Property parentChannelId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + } + } + }, + "Channel.UpdateChannelInput.jsonMergePatch": { + "type": "object", + "description": "Resource ChannelResource.\n\nNamed group channels \u2014 a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel \u2014 see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", + "properties": { + "name": { + "minLength": 2, + "maxLength": 80, + "description": "Property name.", + "type": [ + "string", + "null" + ] + }, + "isArchived": { + "description": "Property isArchived.", + "type": [ + "boolean", + "null" + ] + } + } + }, + "Checklist.ChecklistOutput.jsonld-inspection.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId", + "default": "", + "type": "string" + }, + "name": { + "readOnly": true, + "description": "Property name", + "default": "", + "type": "string" + }, + "referenceCode": { + "readOnly": true, + "description": "Property referenceCode", + "type": [ + "string", + "null" + ] + }, + "version": { + "readOnly": true, + "description": "Property version", + "default": "", + "type": "string" + }, + "status": { + "readOnly": true, + "description": "Property status", + "default": "", + "type": "string" + }, + "itemCount": { + "readOnly": true, + "description": "Property itemCount", + "default": 0, + "type": "integer" + }, + "items": { + "readOnly": true, + "description": "Property items", + "type": "array", + "items": { + "$ref": "#/components/schemas/ChecklistItemOutput.jsonld-inspection.read" + } + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt", + "default": "", + "type": "string" + }, + "previousChecklistId": { + "description": "Property previousChecklistId", + "type": [ + "string", + "null" + ] + }, + "canEditMetadata": { + "description": "Property canEditMetadata", + "default": false, + "type": "boolean" + }, + "canEditItems": { + "description": "Property canEditItems", + "default": false, + "type": "boolean" + }, + "canCreateRevision": { + "description": "Property canCreateRevision", + "default": false, + "type": "boolean" + } + } + } + ], + "description": "Versioned checklist templates for inspections." + }, + "Checklist.CreateChecklistInput-inspection.write": { + "type": "object", + "description": "Versioned checklist templates for inspections.", + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "maxLength": 255, + "description": "Checklist name", + "default": "", + "example": "Contr\u00f4le Extincteur", + "type": "string" + }, + "version": { + "maxLength": 50, + "description": "Version label", + "default": "", + "example": "1.0", + "type": "string" + }, + "referenceCode": { + "maxLength": 40, + "description": "Optional human-facing reference code, unique per organization", + "example": "CHK-EXT-Q", + "type": [ + "string", + "null" + ] + }, + "previousChecklistId": { + "format": "uuid", + "description": "Previous checklist in the same organization. Creates a distinct revision without changing existing inspections.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "items": { + "description": "Checklist items", + "type": "array", + "items": { + "$ref": "#/components/schemas/ChecklistItemInput-inspection.write" + } + } + } + }, + "Checklist.UpdateChecklistInput-inspection.write.jsonMergePatch": { + "type": "object", + "description": "Versioned checklist templates for inspections.", + "properties": { + "name": { + "maxLength": 255, + "description": "Checklist name", + "example": "Contr\u00f4le Extincteur", + "type": [ + "string", + "null" + ] + }, + "referenceCode": { + "maxLength": 40, + "description": "Optional human-facing reference code, unique per organization", + "example": "CHK-EXT-Q", + "type": [ + "string", + "null" + ] + }, + "items": { + "description": "Checklist items (full replacement list)", + "type": "array", + "items": { + "$ref": "#/components/schemas/ChecklistItemInput-inspection.write" + } + } + } + }, + "ChecklistItemInput-inspection.write": { + "type": "object", + "required": [ + "label" + ], + "properties": { + "label": { + "maxLength": 255, + "description": "Item label", + "default": "", + "example": "V\u00e9rifier la pression", + "type": "string" + }, + "description": { + "maxLength": 1000, + "description": "Optional item description", + "type": [ + "string", + "null" + ] + }, + "required": { + "description": "Whether the item is required", + "default": true, + "example": true, + "type": "boolean" + }, + "position": { + "minimum": 0, + "description": "Display position", + "default": 0, + "example": 0, + "type": "integer" + } + } + }, + "ChecklistItemOutput.jsonld-inspection.read": { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "label": { + "readOnly": true, + "description": "Property label.", + "default": "", + "type": "string" + }, + "position": { + "readOnly": true, + "description": "Property position.", + "default": 0, + "type": "integer" + }, + "required": { + "readOnly": true, + "description": "Property required.", + "default": true, + "type": "boolean" + }, + "description": { + "readOnly": true, + "description": "Property description.", + "type": [ + "string", + "null" + ] + } + } + }, + "Client.ClientInput-client.update.jsonMergePatch": { + "type": "object", + "description": "Updates name, redirect URIs, grant types, or scopes of an existing client.", + "properties": { + "client_name": { + "minLength": 3, + "maxLength": 100, + "writeOnly": true, + "description": "Human-readable name of the client application", + "example": "My Awesome App", + "type": "string" + }, + "redirect_uris": { + "writeOnly": true, + "description": "List of allowed redirect URIs for OAuth2 callbacks", + "example": [ + "https://app.example.com/callback" + ], + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "grant_types": { + "writeOnly": true, + "description": "List of allowed OAuth2 grant types", + "example": [ + "authorization_code", + "refresh_token" + ], + "type": "array", + "items": { + "type": "string", + "enum": [ + "authorization_code", + "client_credentials", + "refresh_token" + ] + } + }, + "scopes": { + "writeOnly": true, + "description": "List of allowed OAuth2 scopes", + "example": [ + "openid", + "profile", + "email" + ], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Client.ClientInput-client.write": { + "type": "object", + "description": "Register a new OAuth2 client application. The client secret is shown only once.", + "required": [ + "client_name", + "redirect_uris", + "grant_types", + "scopes" + ], + "properties": { + "client_name": { + "minLength": 3, + "maxLength": 100, + "writeOnly": true, + "description": "Human-readable name of the client application", + "example": "My Awesome App", + "type": "string" + }, + "redirect_uris": { + "writeOnly": true, + "description": "List of allowed redirect URIs for OAuth2 callbacks", + "example": [ + "https://app.example.com/callback" + ], + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "grant_types": { + "writeOnly": true, + "description": "List of allowed OAuth2 grant types", + "example": [ + "authorization_code", + "refresh_token" + ], + "type": "array", + "items": { + "type": "string", + "enum": [ + "authorization_code", + "client_credentials", + "refresh_token" + ] + } + }, + "scopes": { + "writeOnly": true, + "description": "List of allowed OAuth2 scopes", + "example": [ + "openid", + "profile", + "email" + ], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Client.ClientOutput.jsonld-client.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "required": [ + "client_id", + "is_active", + "created_at" + ], + "properties": { + "client_id": { + "readOnly": true, + "description": "Unique client identifier (UUID)", + "example": "01234567-89ab-cdef-0123-456789abcdef", + "type": "string", + "format": "uuid" + }, + "client_name": { + "readOnly": true, + "description": "Human-readable name of the client", + "example": "My Awesome App", + "type": "string" + }, + "redirect_uris": { + "readOnly": true, + "description": "List of registered redirect URIs", + "example": [ + "https://app.example.com/callback" + ], + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "grant_types": { + "readOnly": true, + "description": "List of allowed grant types", + "example": [ + "authorization_code" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "scopes": { + "readOnly": true, + "description": "List of allowed scopes", + "example": [ + "openid", + "profile" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "is_active": { + "readOnly": true, + "description": "Whether the client is currently active", + "default": true, + "example": true, + "type": "boolean" + }, + "created_at": { + "readOnly": true, + "description": "Creation timestamp (ISO 8601)", + "example": "2025-01-01T12:00:00+00:00", + "type": "string", + "format": "date-time" + } + } + } + ], + "description": "Get details of a specific OAuth2 client. Secret is not included." + }, + "Client.ClientOutput.jsonld-client.read_client.secret": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "required": [ + "client_id", + "is_active", + "created_at" + ], + "properties": { + "client_id": { + "readOnly": true, + "description": "Unique client identifier (UUID)", + "example": "01234567-89ab-cdef-0123-456789abcdef", + "type": "string", + "format": "uuid" + }, + "client_name": { + "readOnly": true, + "description": "Human-readable name of the client", + "example": "My Awesome App", + "type": "string" + }, + "client_secret": { + "readOnly": true, + "description": "Client secret (only shown once)", + "example": "your-client-secret", + "type": "string" + }, + "redirect_uris": { + "readOnly": true, + "description": "List of registered redirect URIs", + "example": [ + "https://app.example.com/callback" + ], + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "grant_types": { + "readOnly": true, + "description": "List of allowed grant types", + "example": [ + "authorization_code" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "scopes": { + "readOnly": true, + "description": "List of allowed scopes", + "example": [ + "openid", + "profile" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "is_active": { + "readOnly": true, + "description": "Whether the client is currently active", + "default": true, + "example": true, + "type": "boolean" + }, + "created_at": { + "readOnly": true, + "description": "Creation timestamp (ISO 8601)", + "example": "2025-01-01T12:00:00+00:00", + "type": "string", + "format": "date-time" + } + } + } + ], + "description": "Register a new OAuth2 client application. The client secret is shown only once." + }, + "ComplianceSummary.ComplianceSummaryOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "organizationId": { + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "facilityId": { + "description": "Property facilityId.", + "type": [ + "string", + "null" + ] + }, + "generatedAt": { + "description": "Property generatedAt.", + "default": "", + "type": "string" + }, + "dataEvaluatedAt": { + "description": "Oldest successful maintenance evaluation in the scope, distinct from generation time.", "type": [ "string", "null" ] }, - "decisionNote": { + "organizationStatus": { + "description": "Property organizationStatus.", + "default": "", + "type": "string" + }, + "totals": { + "description": "Property totals.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "facilities": { + "description": "Property facilities.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } + } + ], + "description": "Regulatory compliance register summary: organization rollup and per-facility breakdown, graded compliant/at_risk/non_compliant/not_applicable from Maintenance due-status, Inspection non-conformity, and Equipment inventory counts. Requires organization.compliance.read." + }, + "ConstraintViolation": { + "type": "object", + "description": "Unprocessable entity", + "properties": { + "status": { + "default": 422, + "type": "integer" + }, + "violations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "propertyPath": { + "type": "string", + "description": "The property path of the violation" + }, + "message": { + "type": "string", + "description": "The message associated with the violation" + }, + "code": { + "type": "string", + "description": "The code of the violation" + }, + "hint": { + "type": "string", + "description": "An extra hint to understand the violation" + }, + "payload": { + "type": "object", + "additionalProperties": true, + "description": "The serialized payload of the violation" + } + }, + "required": [ + "propertyPath", + "message" + ] + } + }, + "detail": { + "readOnly": true, + "type": "string" + }, + "type": { + "readOnly": true, + "type": "string" + }, + "title": { + "readOnly": true, + "type": [ + "string", + "null" + ] + }, + "instance": { + "readOnly": true, + "type": [ + "string", + "null" + ] + } + } + }, + "ConstraintViolation.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "status": { + "default": 422, + "type": "integer" + }, + "violations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "propertyPath": { + "type": "string", + "description": "The property path of the violation" + }, + "message": { + "type": "string", + "description": "The message associated with the violation" + }, + "code": { + "type": "string", + "description": "The code of the violation" + }, + "hint": { + "type": "string", + "description": "An extra hint to understand the violation" + }, + "payload": { + "type": "object", + "additionalProperties": true, + "description": "The serialized payload of the violation" + } + }, + "required": [ + "propertyPath", + "message" + ] + } + }, + "detail": { + "readOnly": true, + "type": "string" + }, + "description": { + "readOnly": true, + "type": "string" + }, + "type": { + "readOnly": true, + "type": "string" + }, + "title": { "readOnly": true, - "description": "Property decisionNote.", "type": [ "string", "null" ] }, - "expiresAt": { + "instance": { "readOnly": true, - "description": "Property expiresAt.", + "type": [ + "string", + "null" + ] + } + } + } + ], + "description": "Unprocessable entity" + }, + "Conversation.AcknowledgeDeliveryInput": { + "type": "object", + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", + "required": [ + "messageId" + ], + "properties": { + "messageId": { + "description": "Property messageId", + "default": "", + "type": "string" + } + } + }, + "Conversation.ArchiveConversationInput.jsonMergePatch": { + "type": "object", + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", + "properties": { + "isArchived": { + "description": "Property isArchived.", + "default": true, + "type": "boolean" + } + } + }, + "Conversation.ConversationActivityBucketOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "bucket": { + "description": "Property bucket.", + "default": "", + "type": "string" + }, + "count": { + "description": "Property count.", + "default": 0, + "type": "integer" + } + } + } + ], + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." + }, + "Conversation.ConversationOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "description": "Property id.", + "default": "", + "type": "string" + }, + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" + }, + "subjectType": { + "description": "Property subjectType.", + "default": "", + "type": "string" + }, + "subject": { + "description": "Property subject.", + "type": [ + "string", + "null" + ] + }, + "subjectLabel": { + "description": "Property subjectLabel.", + "type": [ + "string", + "null" + ] + }, + "visibility": { + "description": "Property visibility.", "default": "", "type": "string" }, + "lastMessageAt": { + "description": "Property lastMessageAt.", + "type": [ + "string", + "null" + ] + }, + "messagesCount": { + "description": "Property messagesCount.", + "default": 0, + "type": "integer" + }, + "isArchived": { + "description": "Property isArchived.", + "default": false, + "type": "boolean" + }, + "unreadCount": { + "description": "Property unreadCount.", + "default": 0, + "type": "integer" + }, "createdAt": { - "readOnly": true, "description": "Property createdAt.", "default": "", "type": "string" }, "updatedAt": { - "readOnly": true, "description": "Property updatedAt.", "default": "", "type": "string" }, - "decidedAt": { - "readOnly": true, - "description": "Property decidedAt.", + "isChannel": { + "description": "Property isChannel.", + "default": false, + "type": "boolean" + }, + "name": { + "description": "Property name.", "type": [ "string", "null" ] }, - "executedAt": { - "readOnly": true, - "description": "Property executedAt.", + "team": { + "description": "Property team.", "type": [ "string", "null" ] }, - "executionError": { - "readOnly": true, - "description": "Property executionError.", + "isFavorite": { + "description": "Property isFavorite.", + "default": false, + "type": "boolean" + }, + "parentConversationId": { + "description": "Property parentConversationId.", "type": [ "string", "null" ] }, - "allowedActions": { - "description": "available decisions, revalidated by the command", - "type": "array", - "items": { - "type": "string" - } - }, - "decisionBlockReason": { - "description": "Property decisionBlockReason", + "counterpartMember": { + "description": "Property counterpartMember.", "type": [ "string", "null" @@ -46679,186 +59916,159 @@ } } ], - "description": "Organization-scoped four-eyes approval request queue." - }, - "ApprovalRequest.ApproveApprovalRequestInput-Approval.write": { - "type": "object", - "description": "Organization-scoped four-eyes approval request queue.", - "properties": { - "decisionNote": { - "maxLength": 2000, - "description": "Optional free-form decision note", - "type": [ - "string", - "null" - ] - } - } + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." }, - "ApprovalRequest.RejectApprovalRequestInput-Approval.write": { - "type": "object", - "description": "Organization-scoped four-eyes approval request queue.", - "properties": { - "decisionNote": { - "maxLength": 2000, - "description": "Optional free-form decision note", - "type": [ - "string", - "null" - ] + "Conversation.ConversationReceiptsOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "receipts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConversationReceiptPositionOutput.jsonld" + } + } + } } - } + ], + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." }, - "ApprovalRequest.WithdrawApprovalRequestInput-Approval.write": { - "type": "object", - "description": "Organization-scoped four-eyes approval request queue.", - "properties": { - "decisionNote": { - "maxLength": 2000, - "description": "Optional withdrawal reason", - "type": [ - "string", - "null" - ] + "Conversation.ConversationSignalOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "accepted": { + "description": "Property accepted", + "default": true, + "type": "boolean" + } + } } - } + ], + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." }, - "AssistantMessageOutput.jsonld-Assistant.read": { + "Conversation.GetOrCreateConversationInput": { "type": "object", + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", + "required": [ + "organization", + "subjectType", + "subject" + ], "properties": { - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "threadId": { - "readOnly": true, - "description": "Property threadId.", - "default": "", - "type": "string" - }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", - "default": "", - "type": "string" - }, - "role": { - "readOnly": true, - "description": "Property role.", + "organization": { + "description": "Property organization.", "default": "", "type": "string" }, - "body": { - "readOnly": true, - "description": "Property body.", + "subjectType": { + "description": "Property subjectType.", "default": "", "type": "string" }, - "status": { - "readOnly": true, - "description": "Property status.", + "subject": { + "description": "Property subject.", "default": "", "type": "string" - }, - "errorCode": { - "readOnly": true, - "description": "Property errorCode.", - "type": [ - "string", - "null" - ] - }, - "tokenCount": { - "readOnly": true, - "description": "Property tokenCount.", + } + } + }, + "Conversation.MarkConversationReadInput.jsonMergePatch": { + "type": "object", + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", + "properties": { + "lastReadMessageId": { + "description": "Property lastReadMessageId.", "type": [ - "integer", + "string", "null" ] + } + } + }, + "Conversation.MessagingSubscriptionOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" }, - "createdAt": { - "readOnly": true, - "description": "Property createdAt.", + { + "type": "object", + "properties": { + "token": { + "readOnly": true, + "description": "Property token.", + "default": "", + "type": "string" + }, + "topic": { + "readOnly": true, + "description": "Property topic.", + "default": "", + "type": "string" + } + } + } + ], + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." + }, + "Conversation.PublishTypingInput": { + "type": "object", + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", + "properties": { + "active": { + "description": "Property active", + "default": false, + "type": "boolean" + } + } + }, + "ConversationReceiptPositionOutput.jsonld": { + "type": "object", + "properties": { + "memberId": { + "description": "Property memberId", "default": "", "type": "string" }, - "completedAt": { - "readOnly": true, - "description": "Property completedAt.", + "deliveredMessageId": { + "description": "Property deliveredMessageId", "type": [ "string", "null" ] }, - "attemptId": { - "readOnly": true, - "description": "Property attemptId", + "deliveredThroughAt": { + "description": "Property deliveredThroughAt", "type": [ "string", "null" ] }, - "attemptNumber": { - "readOnly": true, - "description": "Property attemptNumber", - "default": 0, - "type": "integer" - }, - "attemptSequence": { - "readOnly": true, - "description": "Property attemptSequence", - "default": 0, - "type": "integer" - }, - "attemptExpiresAt": { - "readOnly": true, - "description": "Property attemptExpiresAt", + "readMessageId": { + "description": "Property readMessageId", "type": [ "string", "null" ] }, - "canCancel": { - "readOnly": true, - "description": "Property canCancel", - "default": false, - "type": "boolean" - }, - "canRetry": { - "readOnly": true, - "description": "Property canRetry", - "default": false, - "type": "boolean" - } - } - }, - "AssistantThread.AskAssistantQuestionInput-Assistant.write": { - "type": "object", - "description": "Member-private AI-assistant chat threads.", - "required": [ - "body" - ], - "properties": { - "body": { - "maxLength": 8000, - "description": "The question body", - "default": "", - "type": "string" - }, - "temperature": { - "minimum": 0, - "maximum": 2, - "description": "Optional sampling temperature for this question only (0.0-2.0)", + "readThroughAt": { + "description": "Property readThroughAt", "type": [ - "number", + "string", "null" ] } } }, - "AssistantThread.AskAssistantQuestionOutput.jsonld-Assistant.read": { + "CurrentOrganizationMemberProfile.CurrentOrganizationMemberProfileOutput.jsonld-Organization.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -46866,9 +60076,9 @@ { "type": "object", "properties": { - "threadId": { + "id": { "readOnly": true, - "description": "Property threadId.", + "description": "Property id.", "default": "", "type": "string" }, @@ -46878,22 +60088,76 @@ "default": "", "type": "string" }, - "userMessage": { + "userId": { "readOnly": true, - "description": "Property userMessage.", - "$ref": "#/components/schemas/AssistantMessageOutput.jsonld-Assistant.read" + "description": "Property userId.", + "default": "", + "type": "string" }, - "assistantMessage": { + "isActive": { "readOnly": true, - "description": "Property assistantMessage.", - "$ref": "#/components/schemas/AssistantMessageOutput.jsonld-Assistant.read" + "description": "Property isActive.", + "default": true, + "type": "boolean" + }, + "joinedAt": { + "readOnly": true, + "description": "Property joinedAt.", + "default": "", + "type": "string" + }, + "roles": { + "readOnly": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationRoleOutput.jsonld-Organization.read" + } + }, + "permissions": { + "readOnly": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationPermissionOutput.jsonld-Organization.read" + } } } } ], - "description": "Member-private AI-assistant chat threads." + "description": "Resource CurrentOrganizationMemberProfileResource." }, - "AssistantThread.AssistantMessageOutput.jsonld-Assistant.read": { + "CurrentUserProfile.CurrentUserProfileInput-user.write.jsonMergePatch": { + "type": "object", + "description": "Resource CurrentUserProfileResource.", + "properties": { + "firstName": { + "maxLength": 100, + "writeOnly": true, + "description": "User first name.", + "example": "Jane", + "type": "string" + }, + "lastName": { + "maxLength": 100, + "writeOnly": true, + "description": "User last name.", + "example": "Doe", + "type": "string" + }, + "locale": { + "enum": [ + "system", + "en", + "fr", + "es" + ], + "writeOnly": true, + "description": "Preferred display language. Omit to leave unchanged; \"system\" follows the browser language.", + "example": "fr", + "type": "string" + } + } + }, + "CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -46904,115 +60168,247 @@ "id": { "readOnly": true, "description": "Property id.", - "default": "", - "type": "string" - }, - "threadId": { - "readOnly": true, - "description": "Property threadId.", - "default": "", - "type": "string" + "type": [ + "string", + "null" + ] }, - "organizationId": { + "username": { "readOnly": true, - "description": "Property organizationId.", - "default": "", - "type": "string" + "description": "Property username.", + "type": [ + "string", + "null" + ] }, - "role": { + "email": { "readOnly": true, - "description": "Property role.", - "default": "", - "type": "string" + "description": "Property email.", + "type": [ + "string", + "null" + ] }, - "body": { + "firstName": { "readOnly": true, - "description": "Property body.", - "default": "", - "type": "string" + "description": "Property firstName.", + "type": [ + "string", + "null" + ] }, - "status": { + "lastName": { "readOnly": true, - "description": "Property status.", - "default": "", - "type": "string" + "description": "Property lastName.", + "type": [ + "string", + "null" + ] }, - "errorCode": { + "avatarUrl": { "readOnly": true, - "description": "Property errorCode.", + "description": "Property avatarUrl.", "type": [ "string", "null" ] }, - "tokenCount": { + "status": { "readOnly": true, - "description": "Property tokenCount.", + "description": "Property status.", "type": [ - "integer", + "string", "null" ] }, - "createdAt": { + "emailVerified": { "readOnly": true, - "description": "Property createdAt.", - "default": "", - "type": "string" + "description": "Property emailVerified.", + "default": false, + "type": "boolean" }, - "completedAt": { + "tenantId": { "readOnly": true, - "description": "Property completedAt.", + "description": "Property tenantId.", "type": [ "string", "null" ] }, - "attemptId": { + "createdAt": { "readOnly": true, - "description": "Property attemptId", + "description": "Property createdAt.", "type": [ "string", "null" ] }, - "attemptNumber": { - "readOnly": true, - "description": "Property attemptNumber", - "default": 0, - "type": "integer" - }, - "attemptSequence": { - "readOnly": true, - "description": "Property attemptSequence", - "default": 0, - "type": "integer" - }, - "attemptExpiresAt": { + "lastLoginAt": { "readOnly": true, - "description": "Property attemptExpiresAt", + "description": "Property lastLoginAt.", "type": [ "string", "null" ] }, - "canCancel": { + "locale": { "readOnly": true, - "description": "Property canCancel", - "default": false, - "type": "boolean" + "description": "Preferred display language. \"system\" follows the browser language.", + "default": "system", + "example": "fr", + "type": "string", + "enum": [ + "system", + "en", + "fr", + "es" + ] }, - "canRetry": { + "totpEnabled": { "readOnly": true, - "description": "Property canRetry", + "description": "Whether the authenticated user has an active TOTP (authenticator app) enrollment.", "default": false, + "example": false, "type": "boolean" + }, + "roles": { + "readOnly": true, + "description": "Resolved global role names for the authenticated user.", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "readOnly": true, + "description": "Resolved global permission names for the authenticated user.", + "type": "array", + "items": { + "type": "string" + } + }, + "avatarUrls": { + "readOnly": true, + "description": "Avatar URLs by size in pixels. Null when no avatar is set or the avatar is hosted externally.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "uri" + }, + "nullable": true } } } ], - "description": "Member-private AI-assistant chat threads." + "description": "Resource CurrentUserProfileResource." }, - "AssistantThread.AssistantThreadDetailOutput.jsonld-Assistant.read": { + "Customer.ChangeCustomerInput-customer.write.jsonMergePatch": { + "type": "object", + "description": "Class CustomerResource. Internal customer management inside a single organization. @category Resource", + "properties": { + "name": { + "maxLength": 160, + "type": [ + "string", + "null" + ] + }, + "code": { + "maxLength": 80, + "type": [ + "string", + "null" + ] + }, + "email": { + "format": "email", + "maxLength": 254, + "externalDocs": { + "url": "https://schema.org/email" + }, + "type": [ + "string", + "null" + ] + }, + "phone": { + "maxLength": 40, + "type": [ + "string", + "null" + ] + }, + "contacts": { + "maxItems": 50, + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, + "Customer.CreateCustomerInput-customer.write": { + "type": "object", + "description": "Class CustomerResource. Internal customer management inside a single organization. @category Resource", + "required": [ + "name" + ], + "properties": { + "name": { + "maxLength": 160, + "default": "", + "type": "string" + }, + "code": { + "maxLength": 80, + "type": [ + "string", + "null" + ] + }, + "email": { + "format": "email", + "maxLength": 254, + "externalDocs": { + "url": "https://schema.org/email" + }, + "type": [ + "string", + "null" + ] + }, + "phone": { + "maxLength": 40, + "type": [ + "string", + "null" + ] + }, + "contacts": { + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, + "Customer.CustomerOutput.jsonld-customer.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -47021,91 +60417,71 @@ "type": "object", "properties": { "id": { - "readOnly": true, - "description": "Property id.", "default": "", "type": "string" }, "organizationId": { - "readOnly": true, - "description": "Property organizationId.", "default": "", "type": "string" }, - "memberId": { - "readOnly": true, - "description": "Property memberId.", + "name": { "default": "", "type": "string" }, - "title": { - "readOnly": true, - "description": "Property title.", + "code": { "type": [ "string", "null" ] }, - "model": { - "readOnly": true, - "description": "Property model.", + "email": { "type": [ "string", "null" ] }, - "createdAt": { - "readOnly": true, - "description": "Property createdAt.", - "default": "", - "type": "string" - }, - "updatedAt": { - "readOnly": true, - "description": "Property updatedAt.", - "default": "", - "type": "string" - }, - "lastMessageAt": { - "readOnly": true, - "description": "Property lastMessageAt.", + "phone": { "type": [ "string", "null" ] }, - "messages": { - "readOnly": true, - "description": "Property messages.", + "contacts": { "type": "array", "items": { - "$ref": "#/components/schemas/AssistantMessageOutput.jsonld-Assistant.read" + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } } }, - "messagesPage": { - "readOnly": true, - "description": "Property messagesPage.", - "default": 1, - "type": "integer" + "archivedAt": { + "type": [ + "string", + "null" + ] }, - "messagesItemsPerPage": { - "readOnly": true, - "description": "Property messagesItemsPerPage.", - "default": 50, - "type": "integer" + "createdAt": { + "default": "", + "type": "string" }, - "messagesTotal": { - "readOnly": true, - "description": "Property messagesTotal.", - "default": 0, + "updatedAt": { + "default": "", + "type": "string" + }, + "revision": { + "default": 1, "type": "integer" } } } ], - "description": "Member-private AI-assistant chat threads." + "description": "Class CustomerResource. Internal customer management inside a single organization. @category Resource" }, - "AssistantThread.AssistantThreadOutput.jsonld-Assistant.read": { + "DirectConversation.ConversationOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -47114,99 +60490,130 @@ "type": "object", "properties": { "id": { - "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", + "organization": { + "description": "Property organization.", "default": "", "type": "string" }, - "memberId": { - "readOnly": true, - "description": "Property memberId.", + "subjectType": { + "description": "Property subjectType.", "default": "", "type": "string" }, - "title": { - "readOnly": true, - "description": "Property title.", + "subject": { + "description": "Property subject.", + "type": [ + "string", + "null" + ] + }, + "subjectLabel": { + "description": "Property subjectLabel.", "type": [ "string", "null" ] }, - "model": { - "readOnly": true, - "description": "Property model.", + "visibility": { + "description": "Property visibility.", + "default": "", + "type": "string" + }, + "lastMessageAt": { + "description": "Property lastMessageAt.", "type": [ "string", "null" ] }, + "messagesCount": { + "description": "Property messagesCount.", + "default": 0, + "type": "integer" + }, + "isArchived": { + "description": "Property isArchived.", + "default": false, + "type": "boolean" + }, + "unreadCount": { + "description": "Property unreadCount.", + "default": 0, + "type": "integer" + }, "createdAt": { - "readOnly": true, "description": "Property createdAt.", "default": "", "type": "string" }, "updatedAt": { - "readOnly": true, "description": "Property updatedAt.", "default": "", "type": "string" }, - "lastMessageAt": { - "readOnly": true, - "description": "Property lastMessageAt.", + "isChannel": { + "description": "Property isChannel.", + "default": false, + "type": "boolean" + }, + "name": { + "description": "Property name.", "type": [ "string", "null" ] - } - } - } - ], - "description": "Member-private AI-assistant chat threads." - }, - "AssistantThread.AssistantThreadSubscriptionOutput.jsonld-Assistant.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "token": { - "readOnly": true, - "description": "Property token.", - "default": "", - "type": "string" }, - "topic": { - "readOnly": true, - "description": "Property topic.", - "default": "", - "type": "string" + "team": { + "description": "Property team.", + "type": [ + "string", + "null" + ] + }, + "isFavorite": { + "description": "Property isFavorite.", + "default": false, + "type": "boolean" + }, + "parentConversationId": { + "description": "Property parentConversationId.", + "type": [ + "string", + "null" + ] + }, + "counterpartMember": { + "description": "Property counterpartMember.", + "type": [ + "string", + "null" + ] } } } ], - "description": "Member-private AI-assistant chat threads." + "description": "Resource DirectConversationResource.\n\n1-to-1 private conversations between two organization members (L2.4) \u2014 a\n`Conversation` with `subjectType=DIRECT`, `visibility=PARTICIPANTS`, and\n`subjectId` set to the deterministic, order-independent pair key derived\nby `Messaging\\Domain\\Service\\DirectConversationKey`. Mirrors\n`ChannelResource`: a dedicated top-level creation endpoint PLUS its own\n\"list mine\" collection, while messages/read/subscription/favorite reuse\nthe EXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a direct\nconversation id IS a conversation id. It deliberately does NOT appear in\n`GET /api/conversations` (see `MessagingConversationRepository::list()`) \u2014\n`GET /api/direct-conversations` is the only list surface for a member's\ndirect conversations, scoped exactly like `GET /api/channels` (an INNER\nJOIN on `messaging_participants`), which is what preserves the privacy\ninvariant: a member can never discover a direct conversation they are not\na participant of, through either list." }, - "AssistantThread.ControlAssistantAttemptInput-Assistant.write": { + "DirectConversation.GetOrCreateDirectConversationInput": { "type": "object", - "description": "Member-private AI-assistant chat threads.", + "description": "Resource DirectConversationResource.\n\n1-to-1 private conversations between two organization members (L2.4) \u2014 a\n`Conversation` with `subjectType=DIRECT`, `visibility=PARTICIPANTS`, and\n`subjectId` set to the deterministic, order-independent pair key derived\nby `Messaging\\Domain\\Service\\DirectConversationKey`. Mirrors\n`ChannelResource`: a dedicated top-level creation endpoint PLUS its own\n\"list mine\" collection, while messages/read/subscription/favorite reuse\nthe EXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a direct\nconversation id IS a conversation id. It deliberately does NOT appear in\n`GET /api/conversations` (see `MessagingConversationRepository::list()`) \u2014\n`GET /api/direct-conversations` is the only list surface for a member's\ndirect conversations, scoped exactly like `GET /api/channels` (an INNER\nJOIN on `messaging_participants`), which is what preserves the privacy\ninvariant: a member can never discover a direct conversation they are not\na participant of, through either list.", "required": [ - "attemptId" + "organization", + "memberId" ], "properties": { - "attemptId": { + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" + }, + "memberId": { "format": "uuid", - "description": "Property attemptId", + "description": "Property memberId.", "externalDocs": { "url": "https://schema.org/identifier" }, @@ -47215,239 +60622,322 @@ } } }, - "AssistantThread.StartAssistantThreadInput-Assistant.write": { + "Discovery.JwksOutput-token.read": { "type": "object", - "description": "Member-private AI-assistant chat threads.", + "description": "OAuth2/OIDC discovery endpoints", + "required": [ + "keys" + ], "properties": { - "title": { - "maxLength": 255, - "description": "Optional display title for the thread", - "type": [ - "string", - "null" - ] - }, - "model": { - "maxLength": 255, - "description": "Optional tenant-selected model override, validated against the operator-configured allowlist", - "type": [ - "string", - "null" - ] + "keys": { + "readOnly": true, + "description": "Array of JSON Web Keys for signature verification", + "example": [ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "key-id-123" + } + ], + "type": "array", + "items": { + "type": "object", + "properties": { + "kty": { + "type": "string", + "description": "Key type (e.g., RSA)" + }, + "use": { + "type": "string", + "description": "Key usage (sig for signature)" + }, + "alg": { + "type": "string", + "description": "Algorithm (e.g., RS256)" + }, + "kid": { + "type": "string", + "description": "Key identifier" + }, + "n": { + "type": "string", + "description": "RSA modulus (Base64URL)" + }, + "e": { + "type": "string", + "description": "RSA exponent (Base64URL)" + } + } + } } } }, - "AuditEvent.AuditEventOutput.jsonld-audit.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" + "Discovery.OpenIdConfigurationOutput-token.read": { + "type": "object", + "description": "OAuth2/OIDC discovery endpoints", + "required": [ + "issuer", + "token_endpoint", + "userinfo_endpoint", + "jwks_uri" + ], + "properties": { + "issuer": { + "readOnly": true, + "description": "Issuer identifier URL of the authorization server", + "example": "https://auth.example.com", + "type": "string", + "format": "uri" }, - { - "type": "object", - "required": [ - "id", - "action", - "actorType", - "occurredAt", - "recordedAt", - "chainId", - "sequence", - "eventHash" + "authorization_endpoint": { + "readOnly": true, + "description": "URL of the authorization endpoint", + "example": "https://auth.example.com/oauth2/authorize", + "type": "string", + "format": "uri", + "nullable": true + }, + "token_endpoint": { + "readOnly": true, + "description": "URL of the token endpoint", + "example": "https://auth.example.com/api/oauth2/token", + "type": "string", + "format": "uri" + }, + "userinfo_endpoint": { + "readOnly": true, + "description": "URL of the UserInfo endpoint", + "example": "https://auth.example.com/api/oauth2/userinfo", + "type": "string", + "format": "uri" + }, + "jwks_uri": { + "readOnly": true, + "description": "URL of the JWKS endpoint", + "example": "https://auth.example.com/api/.well-known/jwks.json", + "type": "string", + "format": "uri" + }, + "revocation_endpoint": { + "readOnly": true, + "description": "URL of the token revocation endpoint", + "example": "https://auth.example.com/api/oauth2/token/revoke", + "type": "string", + "format": "uri", + "nullable": true + }, + "introspection_endpoint": { + "readOnly": true, + "description": "URL of the token introspection endpoint", + "example": "https://auth.example.com/api/oauth2/token/introspect", + "type": "string", + "format": "uri", + "nullable": true + }, + "end_session_endpoint": { + "readOnly": true, + "description": "URL of the end session (logout) endpoint", + "example": "https://auth.example.com/api/oauth2/logout", + "type": "string", + "format": "uri", + "nullable": true + }, + "scopes_supported": { + "readOnly": true, + "description": "List of supported OAuth2 scopes", + "example": [ + "openid", + "profile", + "email", + "offline_access" ], - "properties": { - "id": { - "readOnly": true, - "description": "Audit event identifier (UUID).", - "default": "", - "example": "550e8400-e29b-41d4-a716-446655440000", - "type": "string", - "format": "uuid" - }, - "action": { - "readOnly": true, - "description": "Audit action key.", - "default": "", - "example": "auth.login_success", - "type": "string" - }, - "actorType": { - "readOnly": true, - "description": "Actor type (user, client, system, anonymous).", - "default": "", - "example": "user", - "type": "string" - }, - "actorId": { - "readOnly": true, - "description": "Actor identifier (UUID or external ID).", - "example": "0c1b3f6a-8c2e-4f5d-9c0a-2c2b7b9f1c7a", - "type": "string", - "nullable": true - }, - "actorEmail": { - "readOnly": true, - "description": "Actor email (optional, depending on PII settings).", - "example": "user@example.com", - "type": "string", - "nullable": true - }, - "actorEmailHash": { - "readOnly": true, - "description": "Actor email hash (SHA-256).", - "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "type": "string", - "nullable": true - }, - "subjectType": { - "readOnly": true, - "description": "Subject type (token, user, client, etc.).", - "example": "token", - "type": "string", - "nullable": true - }, - "subjectId": { - "readOnly": true, - "description": "Subject identifier (UUID or token ID).", - "example": "f4d3b0f0-2b4c-4cdd-9d5a-2d2fd9b3c1a1", - "type": "string", - "nullable": true - }, - "clientId": { - "readOnly": true, - "description": "Client identifier (UUID).", - "example": "3e9d2c1a-72d4-4b18-9d4d-9a1234567890", - "type": "string", - "nullable": true - }, - "tenantId": { - "readOnly": true, - "description": "Tenant identifier (UUID).", - "example": "00000000-0000-4000-8000-000000000001", - "type": "string", - "nullable": true - }, - "ipAddress": { - "readOnly": true, - "description": "Client IP address (optional, depending on PII settings).", - "example": "203.0.113.5", - "type": "string", - "nullable": true - }, - "ipHash": { - "readOnly": true, - "description": "IP hash (SHA-256).", - "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "type": "string", - "nullable": true - }, - "userAgent": { - "readOnly": true, - "description": "User agent string (if available).", - "example": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", - "type": "string", - "nullable": true - }, - "metadata": { - "readOnly": true, - "description": "Event metadata payload (JSON).", - "example": { - "ip": "203.0.113.5", - "action": "login", - "success": true - }, - "type": "object", - "additionalProperties": true - }, - "occurredAt": { - "readOnly": true, - "description": "ISO 8601 datetime when the event occurred.", - "default": "", - "example": "2026-01-30T09:45:00+00:00", - "type": "string", - "format": "date-time" - }, - "recordedAt": { - "readOnly": true, - "description": "ISO 8601 datetime when the event was recorded.", - "default": "", - "example": "2026-01-30T09:45:01+00:00", - "type": "string", - "format": "date-time" - }, - "chainId": { - "readOnly": true, - "description": "Audit chain identifier.", - "default": "", - "example": "tenant:00000000-0000-4000-8000-000000000001", - "type": "string" - }, - "sequence": { - "readOnly": true, - "description": "Monotonic sequence number within the chain.", - "default": 0, - "example": 123, - "type": "integer" - }, - "prevHash": { - "readOnly": true, - "description": "Previous event hash in the chain.", - "example": "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce", - "type": "string", - "nullable": true + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "response_types_supported": { + "readOnly": true, + "description": "List of supported response types", + "example": [ + "code" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "grant_types_supported": { + "readOnly": true, + "description": "List of supported grant types", + "example": [ + "client_credentials", + "refresh_token", + "authorization_code" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "token_endpoint_auth_methods_supported": { + "readOnly": true, + "description": "Supported token endpoint authentication methods", + "example": [ + "client_secret_post" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "code_challenge_methods_supported": { + "readOnly": true, + "description": "Supported PKCE code challenge methods", + "example": [ + "S256", + "plain" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "prompt_values_supported": { + "readOnly": true, + "description": "Supported prompt values", + "example": [ + "none", + "login", + "consent", + "select_account" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "subject_types_supported": { + "readOnly": true, + "description": "Supported subject identifier types", + "example": [ + "public" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "id_token_signing_alg_values_supported": { + "readOnly": true, + "description": "Supported ID token signing algorithms", + "example": [ + "RS256" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "claims_supported": { + "readOnly": true, + "description": "Supported OpenID Connect claims", + "example": [ + "sub", + "name", + "given_name", + "family_name", + "preferred_username", + "picture", + "email", + "email_verified", + "auth_time" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + } + }, + "EmailChange.ConfirmEmailChangeInput-email_change.write": { + "type": "object", + "description": "Sign-in email address change operations (email confirmation protected)", + "required": [ + "token" + ], + "properties": { + "token": { + "maxLength": 128, + "writeOnly": true, + "description": "Confirmation token from the email sent to the new address", + "example": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "type": "string" + } + } + }, + "EmailChange.ConfirmEmailChangeOutput.jsonld-email_change.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "success": { + "description": "Whether the email address was changed successfully", + "example": true, + "type": "boolean" }, - "eventHash": { - "readOnly": true, - "description": "Hash of the event payload (SHA-256).", - "default": "", - "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "message": { + "description": "Informational message", + "example": "Your email address has been changed. Please sign in again with the new address.", "type": "string" } } } ], - "description": "Returns a paginated list of audit events." + "description": "Sign-in email address change operations (email confirmation protected)" }, - "Auth.LoginInput-token.write": { + "EmailChange.RequestEmailChangeInput-email_change.write": { "type": "object", - "description": "User authentication operations", + "description": "Sign-in email address change operations (email confirmation protected)", "required": [ - "email", - "password" + "newEmail", + "currentPassword" ], "properties": { - "email": { + "newEmail": { "format": "email", + "maxLength": 320, "writeOnly": true, - "description": "Email address of the user attempting to authenticate", + "description": "The new sign-in email address; a confirmation link is sent to it", "externalDocs": { "url": "https://schema.org/email" }, - "example": "john.doe@example.com", - "type": "string", - "minLength": 5, - "maxLength": 255, - "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + "example": "new-address@example.com", + "type": "string" }, - "password": { - "minLength": 8, - "maxLength": 128, + "currentPassword": { "writeOnly": true, - "description": "Password of the user (write-only, never returned in responses)", - "example": "SecureP@ssw0rd!", + "description": "Current password, verified before the confirmation email is sent", + "example": "CurrentP@ssw0rd!", "type": "string", "format": "password" - }, - "remember_me": { - "writeOnly": true, - "description": "Extend session duration (30 days if true, default 1 day if false)", - "default": false, - "example": false, - "type": "boolean", - "nullable": false } } }, - "Auth.LoginOutput.jsonld-token.read": { + "EmailChange.RequestEmailChangeOutput.jsonld-email_change.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -47455,163 +60945,49 @@ { "type": "object", "properties": { - "access_token": { - "readOnly": true, - "description": "JWT access token for API authentication; absent while MFA is required", - "example": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", - "type": "string" - }, - "token_type": { - "readOnly": true, - "description": "Token type; absent while MFA is required", - "example": "Bearer", - "type": "string", - "enum": [ - "Bearer" - ], - "default": "Bearer" - }, - "expires_in": { - "readOnly": true, - "description": "Token lifetime in seconds; absent while MFA is required", - "example": 3600, - "type": "integer", - "minimum": 1 - }, - "scope": { - "readOnly": true, - "description": "Space-separated list of granted scopes", - "example": "openid profile email", - "type": "string", - "nullable": true - }, - "mfa_required": { - "readOnly": true, - "description": "If true, authentication is incomplete. Use mfa_token to verify code.", + "success": { + "description": "Whether the email change request was accepted", "example": true, - "type": "boolean", - "nullable": true - }, - "mfa_token": { - "readOnly": true, - "description": "Temporary Pre-Auth Token (JWT) covering the partial authentication state.", - "example": "eyJ...", - "type": "string", - "nullable": true - }, - "challenge_token": { - "readOnly": true, - "description": "The OTP challenge token (reference).", - "example": "abc...", - "type": "string", - "nullable": true - }, - "mfa_method": { - "readOnly": true, - "description": "The delivery method for the MFA code (email, sms, totp).", - "example": "email", - "type": "string", - "enum": [ - "email", - "sms", - "totp" - ], - "nullable": true - }, - "mfa_destination": { - "readOnly": true, - "description": "The masked destination where the code was sent (e.g., \"j***e@e****e.com\" or \"+336****5678\").", - "example": "j***e@e****e.com", - "type": "string", - "nullable": true + "type": "boolean" }, - "mfa_resend_in": { - "readOnly": true, - "description": "Seconds until MFA code can be resent", - "example": 60, - "type": "integer", - "nullable": true + "message": { + "description": "Informational message", + "example": "A confirmation link has been sent to the new email address.", + "type": "string" }, - "return_url": { - "description": "Validated local destination returned by a federated login.", + "expiresAt": { + "description": "When the confirmation token expires", + "example": "2026-08-28T11:00:00+00:00", "type": [ "string", "null" - ] - }, - "new_account": { - "description": "Whether a federated login provisioned the Fireguard account.", - "type": [ - "boolean", - "null" - ] - } - } - } - ], - "description": "User authentication operations" - }, - "Auth.LogoutOutput.jsonld-token.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "readOnly": true, - "description": "Confirmation message indicating successful logout", - "default": "Logged out successfully", - "example": "Logged out successfully", - "type": "string" + ], + "format": "date-time" } } } ], - "description": "User authentication operations" - }, - "Auth.MfaResendInput": { - "type": "object", - "description": "User authentication operations", - "required": [ - "preAuthToken" - ], - "properties": { - "preAuthToken": { - "writeOnly": true, - "description": "The Pre-Auth Token received from the login response.", - "example": "eyJ...", - "type": "string" - } - } + "description": "Sign-in email address change operations (email confirmation protected)" }, - "Auth.MfaVerifyInput": { + "EmailOwnership.ConfirmEmailOwnershipInput-email_ownership.write": { "type": "object", - "description": "User authentication operations", + "description": "Resource EmailOwnershipResource.", "required": [ - "preAuthToken", + "challengeToken", "code" ], "properties": { - "preAuthToken": { - "writeOnly": true, - "description": "The Pre-Auth Token received from the login response.", - "example": "eyJ...", + "challengeToken": { + "default": "", "type": "string" }, "code": { - "writeOnly": true, - "description": "The OTP code received by the user.", - "example": "123456", + "default": "", "type": "string" } } }, - "AutomationAttempt.AutomationAttemptOutput.jsonld": { + "EmailOwnership.EmailOwnershipOutput.jsonld-email_ownership.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -47619,76 +60995,16 @@ { "type": "object", "properties": { - "id": { - "description": "Method __construct", - "type": "string" - }, - "runId": { - "description": "Method __construct", - "type": "string" - }, - "organizationId": { - "description": "Method __construct", - "type": "string" - }, - "ruleKey": { - "description": "Method __construct", - "type": "string" - }, - "subjectId": { - "description": "Method __construct", - "type": "string" - }, - "attemptNumber": { - "description": "Method __construct", - "type": "integer" - }, - "status": { - "description": "Method __construct", - "type": "string" - }, - "createdAt": { - "description": "Method __construct", - "type": "string" - }, - "finishedAt": { - "description": "Method __construct", - "type": [ - "string", - "null" - ] - }, - "requestedBy": { - "description": "Method __construct", - "type": [ - "string", - "null" - ] - }, - "interventionId": { - "description": "Method __construct", - "type": [ - "string", - "null" - ] - }, - "errorCode": { - "description": "Method __construct", - "type": [ - "string", - "null" - ] - }, - "canRetry": { - "description": "Method __construct", + "verified": { + "description": "current mailbox ownership proof", "type": "boolean" } } } ], - "description": "Resource AutomationResource. Separate organization history and management access." + "description": "Resource EmailOwnershipResource." }, - "AutomationAttempt.AutomationPolicyOutput.jsonld": { + "EmailOwnership.StartEmailOwnershipOutput.jsonld-email_ownership.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -47696,46 +61012,171 @@ { "type": "object", "properties": { - "id": { - "description": "Method __construct", - "type": "string" - }, - "ruleKey": { - "description": "Method __construct", + "challengeToken": { + "description": "single-use email challenge token", "type": "string" }, - "enabled": { - "description": "Method __construct", - "type": "boolean" - }, - "canManage": { - "description": "Method __construct", - "type": "boolean" + "canResendIn": { + "description": "seconds until resend", + "type": "integer" } } } ], - "description": "Resource AutomationResource. Separate organization history and management access." + "description": "Resource EmailOwnershipResource." }, - "AutomationAttempt.RetryAutomationAttemptInput": { + "Equipment.CreateEquipmentInput": { "type": "object", - "description": "Resource AutomationResource. Separate organization history and management access.", - "required": [ - "attemptId" - ], + "description": "Resource CanonicalEquipmentResource.", "properties": { - "attemptId": { + "name": { + "maxLength": 255, + "type": [ + "string", + "null" + ] + }, + "assetCode": { + "maxLength": 100, + "type": [ + "string", + "null" + ] + }, + "criticality": { + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": [ + "string", + "null" + ] + }, + "technicalProperties": { + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + }, + "clientId": { + "format": "uuid", + "description": "Client-generated UUID for idempotent offline creation", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "organization": { + "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", + "description": "Organization IRI, required on the canonical endpoint", + "type": [ + "string", + "null" + ] + }, + "intervention": { + "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", + "description": "Optional field intervention IRI", + "type": [ + "string", + "null" + ] + }, + "facility": { + "pattern": "^(/api/facilities/[0-9a-fA-F-]{36})$", + "description": "Optional facility IRI", + "type": [ + "string", + "null" + ] + }, + "onboardingSessionId": { "format": "uuid", - "description": "Property attemptId", "externalDocs": { "url": "https://schema.org/identifier" }, + "type": [ + "string", + "null" + ] + }, + "onboardingItemKey": { + "type": [ + "string", + "null" + ] + }, + "type": { + "maxLength": 32, + "description": "Equipment type", "default": "", + "example": "fire_extinguisher", "type": "string" + }, + "subType": { + "maxLength": 100, + "description": "Optional free-form sub-type", + "example": "CO2 6kg", + "type": [ + "string", + "null" + ] + }, + "brand": { + "maxLength": 100, + "description": "Optional brand name", + "example": "Sicli", + "type": [ + "string", + "null" + ] + }, + "model": { + "maxLength": 100, + "description": "Optional model name", + "example": "Pro 6", + "type": [ + "string", + "null" + ] + }, + "serialNumber": { + "maxLength": 100, + "description": "Optional serial number (unique per organization)", + "example": "SN-2024-001", + "type": [ + "string", + "null" + ] + }, + "locationLabel": { + "maxLength": 255, + "description": "Optional free-form location label", + "example": "Near staircase B", + "type": [ + "string", + "null" + ] } - } + }, + "required": [ + "type" + ] }, - "BillingPricing.PlanPricingOutput.jsonld-Billing.read": { + "Equipment.EquipmentOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -47743,47 +61184,65 @@ { "type": "object", "properties": { - "planKey": { - "readOnly": true, - "description": "Property planKey.", - "default": "", - "type": "string" + "name": { + "type": [ + "string", + "null" + ] }, - "currency": { - "readOnly": true, - "description": "Property currency.", - "default": "", - "type": "string" + "assetCode": { + "type": [ + "string", + "null" + ] + }, + "criticality": { + "type": [ + "string", + "null" + ] + }, + "technicalProperties": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } }, - "monthlyAmount": { - "readOnly": true, - "description": "Property monthlyAmount.", + "predecessorEquipmentId": { "type": [ - "integer", + "string", "null" ] }, - "yearlyAmount": { - "readOnly": true, - "description": "Property yearlyAmount.", + "successorEquipmentId": { "type": [ - "integer", + "string", "null" ] - } - } - } - ], - "description": "Subscription plan pricing." - }, - "CalendarEvent.CalendarEventOutput.jsonld-Calendar.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { + }, + "intervention": { + "description": "Property intervention.", + "type": [ + "string", + "null" + ] + }, + "recordStatus": { + "description": "Property recordStatus.", + "default": "published", + "type": "string" + }, + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" + }, "id": { "readOnly": true, "description": "Property id.", @@ -47796,54 +61255,150 @@ "default": "", "type": "string" }, - "title": { + "facilityId": { "readOnly": true, - "description": "Property title.", + "description": "Property facilityId.", + "type": [ + "string", + "null" + ] + }, + "type": { + "readOnly": true, + "description": "Property type.", "default": "", "type": "string" }, - "description": { + "subType": { "readOnly": true, - "description": "Property description.", + "description": "Property subType.", "type": [ "string", "null" ] }, - "startsAt": { + "brand": { "readOnly": true, - "description": "Property startsAt.", - "default": "", - "type": "string" + "description": "Property brand.", + "type": [ + "string", + "null" + ] }, - "endsAt": { + "model": { "readOnly": true, - "description": "Property endsAt.", + "description": "Property model.", "type": [ "string", "null" ] }, - "allDay": { + "serialNumber": { "readOnly": true, - "description": "Property allDay.", - "default": false, - "type": "boolean" + "description": "Property serialNumber.", + "type": [ + "string", + "null" + ] }, - "facilityId": { + "locationLabel": { "readOnly": true, - "description": "Property facilityId.", + "description": "Property locationLabel.", "type": [ "string", "null" ] }, - "createdByMemberId": { + "facilityName": { "readOnly": true, - "description": "Property createdByMemberId.", + "description": "Display name of the assigned facility", + "type": [ + "string", + "null" + ] + }, + "status": { + "readOnly": true, + "description": "Property status.", "default": "", "type": "string" }, + "installedAt": { + "readOnly": true, + "description": "Property installedAt.", + "type": [ + "string", + "null" + ] + }, + "commissionedAt": { + "readOnly": true, + "description": "Property commissionedAt.", + "type": [ + "string", + "null" + ] + }, + "tags": { + "readOnly": true, + "description": "Property tags.", + "type": "array", + "items": { + "$ref": "#/components/schemas/TagOutput.jsonld" + } + }, + "maintenanceDueStatus": { + "readOnly": true, + "description": "Property maintenanceDueStatus.", + "default": "unscheduled", + "type": "string" + }, + "controlDueStatus": { + "readOnly": true, + "description": "Due status of control operations, independent from service operations and anomalies.", + "default": "unscheduled", + "type": "string" + }, + "serviceDueStatus": { + "readOnly": true, + "description": "Due status of service operations, independent from periodic controls.", + "default": "unscheduled", + "type": "string" + }, + "controlNextDueAt": { + "readOnly": true, + "description": "Next control deadline in ISO 8601, when scheduled.", + "type": [ + "string", + "null" + ] + }, + "serviceNextDueAt": { + "readOnly": true, + "description": "Next service deadline in ISO 8601, when scheduled.", + "type": [ + "string", + "null" + ] + }, + "planPosition": { + "readOnly": true, + "description": "Position pinned on a floor plan attachment (detail read only)", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, "createdAt": { "readOnly": true, "description": "Property createdAt.", @@ -47859,710 +61414,270 @@ } } ], - "description": "Organization-scoped standalone calendar event management." - }, - "CalendarEvent.CreateCalendarEventInput-Calendar.write": { - "type": "object", - "description": "Organization-scoped standalone calendar event management.", - "required": [ - "title", - "startsAt" - ], - "properties": { - "title": { - "maxLength": 255, - "description": "Event title", - "default": "", - "example": "Fire drill", - "type": "string" - }, - "description": { - "maxLength": 5000, - "description": "Free-form description", - "type": [ - "string", - "null" - ] - }, - "startsAt": { - "description": "Event start, ISO 8601 with an explicit timezone offset", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, - "default": "", - "example": "2026-08-01T09:00:00+02:00", - "type": "string" - }, - "endsAt": { - "description": "Event end, ISO 8601 with an explicit timezone offset", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, - "example": "2026-08-01T11:00:00+02:00", - "type": [ - "string", - "null" - ] - }, - "allDay": { - "description": "Whether the event spans whole day(s)", - "default": false, - "type": "boolean" - }, - "facilityId": { - "description": "Associated facility identifier", - "type": [ - "string", - "null" - ] - } - } + "description": "Resource CanonicalEquipmentResource." }, - "CalendarEvent.UpdateCalendarEventInput-Calendar.write.jsonMergePatch": { + "Equipment.PatchCanonicalEquipmentInput.jsonMergePatch": { "type": "object", - "description": "Organization-scoped standalone calendar event management.", + "description": "Resource CanonicalEquipmentResource.", "properties": { - "title": { - "minLength": 1, + "name": { "maxLength": 255, - "description": "Event title", - "type": [ - "string", - "null" - ] - }, - "description": { - "maxLength": 5000, - "description": "Free-form description", - "type": [ - "string", - "null" - ] - }, - "startsAt": { - "description": "Event start, ISO 8601 with an explicit timezone offset", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, "type": [ "string", "null" ] }, - "endsAt": { - "description": "Event end, ISO 8601 with an explicit timezone offset", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, + "assetCode": { + "maxLength": 100, "type": [ "string", "null" ] }, - "allDay": { - "description": "Whether the event spans whole day(s)", - "type": [ - "boolean", - "null" - ] - }, - "facilityId": { - "description": "Associated facility identifier", + "criticality": { + "enum": [ + "low", + "medium", + "high", + "critical" + ], "type": [ "string", "null" ] - } - } - }, - "CalendarFeed.CalendarFeedOutput.jsonld-Calendar.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" }, - { - "type": "object", - "properties": { - "from": { - "readOnly": true, - "description": "Property from.", - "default": "", - "type": "string" - }, - "to": { - "readOnly": true, - "description": "Property to.", - "default": "", - "type": "string" - }, - "items": { - "readOnly": true, - "description": "Property items.", - "type": "array", - "items": { - "$ref": "#/components/schemas/CalendarFeedItemOutput.jsonld-Calendar.read" - } - }, - "sources": { - "readOnly": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/CalendarFeedSourceOutput.jsonld-Calendar.read" - } - }, - "complete": { - "readOnly": true, - "description": "Property complete", - "default": true, - "type": "boolean" + "technicalProperties": { + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] } } - } - ], - "description": "Unified organization calendar feed (standalone events, inspections, interventions, preventive-maintenance due dates)." - }, - "CalendarFeedItemOutput.jsonld-Calendar.read": { - "type": "object", - "properties": { - "sourceKey": { - "readOnly": true, - "description": "Property sourceKey.", - "default": "", - "type": "string" - }, - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "title": { - "readOnly": true, - "description": "Property title.", - "default": "", - "type": "string" }, - "description": { - "readOnly": true, - "description": "Property description.", + "type": { + "maxLength": 32, + "description": "Property type.", "type": [ "string", "null" ] }, - "startsAt": { - "readOnly": true, - "description": "Property startsAt.", - "default": "", - "type": "string" - }, - "endsAt": { - "readOnly": true, - "description": "Property endsAt.", + "subType": { + "maxLength": 100, + "description": "Property subType.", "type": [ "string", "null" ] }, - "allDay": { - "readOnly": true, - "description": "Property allDay.", - "default": false, - "type": "boolean" - }, - "facilityId": { - "readOnly": true, - "description": "Property facilityId.", + "brand": { + "maxLength": 100, + "description": "Property brand.", "type": [ "string", "null" ] }, - "status": { - "readOnly": true, - "description": "Property status.", + "model": { + "maxLength": 100, + "description": "Property model.", "type": [ "string", "null" ] }, - "targetType": { - "readOnly": true, - "description": "Property targetType.", - "default": "", - "type": "string" - }, - "targetId": { - "readOnly": true, - "description": "Property targetId.", - "default": "", - "type": "string" - } - } - }, - "CalendarFeedSourceOutput.jsonld-Calendar.read": { - "type": "object", - "properties": { - "sourceKey": { - "description": "Property sourceKey", - "default": "", - "type": "string" - }, - "available": { - "description": "Property available", - "default": true, - "type": "boolean" - }, - "truncated": { - "description": "Property truncated", - "default": false, - "type": "boolean" - } - } - }, - "CalendarFeedToken.CalendarFeedTokenOutput.jsonld-Calendar.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "createdAt": { - "readOnly": true, - "description": "Property createdAt.", - "default": "", - "type": "string" - }, - "lastUsedAt": { - "readOnly": true, - "description": "Property lastUsedAt.", - "type": [ - "string", - "null" - ] - } - } - } - ], - "description": "Member-scoped iCal calendar feed subscription token." - }, - "CalendarFeedToken.CalendarFeedTokenSecretOutput.jsonld-Calendar.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "secret": { - "readOnly": true, - "description": "Property secret.", - "default": "", - "type": "string" - }, - "feedUrl": { - "readOnly": true, - "description": "Property feedUrl.", - "default": "", - "type": "string" - }, - "createdAt": { - "readOnly": true, - "description": "Property createdAt.", - "default": "", - "type": "string" - }, - "rotated": { - "readOnly": true, - "description": "Property rotated.", - "default": false, - "type": "boolean" - } - } - } - ], - "description": "Member-scoped iCal calendar feed subscription token." - }, - "CapacityConfigurationView.jsonld": { - "type": "object", - "properties": { - "weeks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CapacityWeekView.jsonld" - } - }, - "exceptions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CapacityExceptionView.jsonld" - } - } - } - }, - "CapacityExceptionView.jsonld": { - "type": "object", - "properties": { - "id": { - "description": "stable resource identifier, retained across idempotent retries", - "type": "string" - }, - "memberId": { - "description": "organization member whose work or capacity is represented", - "type": "string" + "serialNumber": { + "maxLength": 100, + "description": "Property serialNumber.", + "type": [ + "string", + "null" + ] }, - "startsOn": { - "description": "inclusive organization-local start date", - "type": "string" + "locationLabel": { + "maxLength": 255, + "description": "Property locationLabel.", + "type": [ + "string", + "null" + ] }, - "endsOn": { - "description": "inclusive organization-local end date", - "type": "string" + "status": { + "enum": [ + "in_stock", + "operational", + "decommissioned", + "under_maintenance" + ], + "description": "Property status.", + "type": [ + "string", + "null" + ] }, - "minutes": { - "description": "duration in whole minutes", - "type": "integer" + "facility": { + "description": "Property facility.", + "type": [ + "string", + "null" + ] } } }, - "CapacityWeekView.jsonld": { + "EquipmentAttachment.AddAttachmentInput-equipment.write": { "type": "object", + "description": "File attachments linked to equipment items.", + "required": [ + "fileName", + "content", + "mimeType" + ], "properties": { - "id": { - "description": "stable resource identifier, retained across idempotent retries", + "fileName": { + "maxLength": 255, + "description": "Original file name", + "default": "", + "example": "maintenance_report.pdf", "type": "string" }, - "scopeId": { - "description": "organization identifier for defaults, or member identifier for an individual override", + "content": { + "description": "Base64-encoded file contents", + "default": "", "type": "string" }, - "effectiveOn": { - "description": "first local date on which this capacity version applies", + "mimeType": { + "maxLength": 100, + "description": "MIME type", + "default": "", + "example": "application/pdf", "type": "string" }, - "minutes": { - "type": "array", - "items": { - "type": "integer" - } + "label": { + "maxLength": 255, + "description": "Optional human-readable label", + "example": "Annual maintenance report 2024", + "type": [ + "string", + "null" + ] } } }, - "Challenge.ChallengeOutput.jsonld": { + "EquipmentAttachment.AttachmentOutput.jsonld-equipment.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, { "type": "object", - "required": [ - "token", - "purpose", - "channel", - "maskedRecipient", - "status", - "expiresIn", - "attemptsRemaining", - "canResendIn" - ], "properties": { - "token": { + "id": { "readOnly": true, - "description": "Challenge token to use in GET/verify/resend endpoints", - "example": "a1b2c3d4e5f6...", + "description": "Property id.", + "default": "", "type": "string" }, - "purpose": { + "equipmentId": { "readOnly": true, - "description": "The purpose of this OTP challenge", - "example": "login", + "description": "Property equipmentId.", + "default": "", "type": "string" }, - "channel": { + "fileName": { "readOnly": true, - "description": "The delivery channel used", - "example": "email", + "description": "Property fileName.", + "default": "", "type": "string" }, - "maskedRecipient": { + "mimeType": { "readOnly": true, - "description": "Masked recipient for user-friendly display", - "example": "jo***@example.com", + "description": "Property mimeType.", + "default": "", "type": "string" }, - "status": { + "size": { "readOnly": true, - "description": "Current status: pending, verified, expired, or failed", - "example": "pending", - "type": "string", - "enum": [ - "pending", - "verified", - "expired", - "failed" - ] + "description": "Property size.", + "default": 0, + "type": "integer" }, - "expiresIn": { + "label": { "readOnly": true, - "description": "Seconds remaining until the challenge expires", - "example": 285, - "type": "integer" + "description": "Property label.", + "type": [ + "string", + "null" + ] }, - "attemptsRemaining": { + "revision": { "readOnly": true, - "description": "Number of verification attempts remaining", - "example": 4, + "description": "Property revision.", + "default": 1, "type": "integer" }, - "canResendIn": { + "uploadedAt": { "readOnly": true, - "description": "Seconds until OTP can be resent (0 = can resend now)", - "example": 45, - "type": "integer" + "description": "Property uploadedAt.", + "default": "", + "type": "string" } } } ], - "description": "OTP challenge management for verification and 2FA." - }, - "Challenge.CreateChallengeInput": { - "type": "object", - "description": "OTP challenge management for verification and 2FA.", - "required": [ - "purpose", - "channel" - ], - "properties": { - "purpose": { - "enum": [ - "login", - "password_reset", - "email_verification", - "phone_verification", - "sensitive_operation", - "transaction_approval" - ], - "writeOnly": true, - "description": "The purpose of the OTP challenge. Use GET /api/otp/purposes to list available values.", - "example": "login", - "type": "string" - }, - "channel": { - "enum": [ - "email", - "sms", - "totp" - ], - "writeOnly": true, - "description": "The delivery channel for the OTP. Use GET /api/otp/channels to list available values.", - "example": "email", - "type": "string" - }, - "recipient": { - "writeOnly": true, - "description": "The recipient email or phone number. If not provided, user's default will be used.", - "example": "user@example.com", - "type": "string", - "nullable": true - }, - "ttlSeconds": { - "minimum": 60, - "maximum": 3600, - "writeOnly": true, - "description": "Custom time-to-live in seconds. Defaults to purpose-specific value.", - "example": 300, - "type": "integer", - "nullable": true - }, - "context": { - "writeOnly": true, - "description": "Optional context data (transaction ID, description, etc.)", - "example": { - "transactionId": "TXN-12345", - "description": "Confirm transfer of $100" - }, - "type": "object", - "nullable": true, - "additionalProperties": true - } - } - }, - "Challenge.VerifyOtpInput": { - "type": "object", - "description": "OTP challenge management for verification and 2FA.", - "required": [ - "code" - ], - "properties": { - "code": { - "writeOnly": true, - "description": "The OTP code to verify.", - "example": "123456", - "type": "string" - } - } + "description": "File attachments linked to equipment items." }, - "Challenge.VerifyOtpOutput.jsonld": { + "EquipmentInspectionSummary.EquipmentInspectionSummaryOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, { "type": "object", - "required": [ - "success", - "attemptsRemaining" - ], "properties": { - "success": { + "equipmentId": { "readOnly": true, - "description": "Whether verification was successful", - "example": true, - "type": "boolean" + "default": "", + "type": "string" }, - "attemptsRemaining": { + "openAnomalies": { "readOnly": true, - "description": "Remaining verification attempts before the challenge is locked", - "example": 2, + "default": 0, "type": "integer" }, - "error": { + "bySeverity": { "readOnly": true, - "description": "Error message when verification fails", - "example": "Invalid OTP code", - "type": "string", - "nullable": true - } - } - } - ], - "description": "OTP challenge management for verification and 2FA." - }, - "Channel.AddChannelParticipantInput": { - "type": "object", - "description": "Resource ChannelResource.\n\nNamed group channels \u2014 a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel \u2014 see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", - "required": [ - "memberId" - ], - "properties": { - "memberId": { - "format": "uuid", - "description": "Property memberId.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "default": "", - "type": "string" - }, - "role": { - "maxLength": 50, - "description": "Property role.", - "type": [ - "string", - "null" - ] - } - } - }, - "Channel.BindChannelTeamInput.jsonMergePatch": { - "type": "object", - "description": "Resource ChannelResource.\n\nNamed group channels \u2014 a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel \u2014 see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", - "properties": { - "teamId": { - "format": "uuid", - "description": "Property teamId.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - } - } - }, - "Channel.ChannelOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "id": { - "description": "Property id.", - "default": "", - "type": "string" - }, - "organization": { - "description": "Property organization.", - "default": "", - "type": "string" - }, - "name": { - "description": "Property name.", - "default": "", - "type": "string" - }, - "team": { - "description": "Property team.", - "type": [ - "string", - "null" - ] + "default": { + "low": 0, + "medium": 0, + "high": 0, + "critical": 0 + }, + "type": "object", + "additionalProperties": { + "type": "integer" + } }, - "createdByMember": { - "description": "Property createdByMember.", + "lastInspectionId": { + "readOnly": true, "type": [ "string", "null" ] }, - "participantCount": { - "description": "Property participantCount.", - "default": 0, - "type": "integer" - }, - "isArchived": { - "description": "Property isArchived.", - "default": false, - "type": "boolean" - }, - "lastMessageAt": { - "description": "Property lastMessageAt.", + "lastInspectionPerformedAt": { + "readOnly": true, "type": [ "string", "null" ] }, - "messagesCount": { - "description": "Property messagesCount.", - "default": 0, - "type": "integer" - }, - "unreadCount": { - "description": "Property unreadCount.", - "default": 0, - "type": "integer" - }, - "createdAt": { - "description": "Property createdAt.", - "default": "", - "type": "string" - }, - "updatedAt": { - "description": "Property updatedAt.", - "default": "", - "type": "string" - }, - "isFavorite": { - "description": "Property isFavorite.", - "default": false, - "type": "boolean" - }, - "parent": { - "description": "Property parent.", + "lastInspectionResult": { + "readOnly": true, "type": [ "string", "null" @@ -48571,9 +61686,9 @@ } } ], - "description": "Resource ChannelResource.\n\nNamed group channels \u2014 a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel \u2014 see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write." + "description": "Published controls and open findings for one equipment; requires both equipment and inspection read permissions." }, - "Channel.ChannelParticipantOutput.jsonld": { + "EquipmentKpi.EquipmentKpiOutput.jsonld-equipment.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -48581,95 +61696,36 @@ { "type": "object", "properties": { - "memberId": { - "description": "Property memberId.", - "default": "", - "type": "string" + "totalAssets": { + "readOnly": true, + "description": "Property totalAssets.", + "default": 0, + "type": "integer" }, - "role": { - "description": "Property role.", - "type": [ - "string", - "null" - ] + "compliant": { + "readOnly": true, + "description": "Property compliant.", + "default": 0, + "type": "integer" }, - "source": { - "description": "Property source.", - "default": "", - "type": "string" + "dueSoon": { + "readOnly": true, + "description": "Property dueSoon.", + "default": 0, + "type": "integer" }, - "addedAt": { - "description": "Property addedAt.", - "default": "", - "type": "string" + "openNonConformities": { + "readOnly": true, + "description": "Property openNonConformities.", + "default": 0, + "type": "integer" } } } ], - "description": "Resource ChannelResource.\n\nNamed group channels \u2014 a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel \u2014 see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write." - }, - "Channel.CreateChannelInput": { - "type": "object", - "description": "Resource ChannelResource.\n\nNamed group channels \u2014 a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel \u2014 see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", - "required": [ - "organization", - "name" - ], - "properties": { - "organization": { - "description": "Property organization.", - "default": "", - "type": "string" - }, - "name": { - "minLength": 2, - "maxLength": 80, - "description": "Property name.", - "default": "", - "type": "string" - } - } - }, - "Channel.SetChannelParentInput.jsonMergePatch": { - "type": "object", - "description": "Resource ChannelResource.\n\nNamed group channels \u2014 a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel \u2014 see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", - "properties": { - "parentChannelId": { - "format": "uuid", - "description": "Property parentChannelId.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - } - } - }, - "Channel.UpdateChannelInput.jsonMergePatch": { - "type": "object", - "description": "Resource ChannelResource.\n\nNamed group channels \u2014 a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel \u2014 see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", - "properties": { - "name": { - "minLength": 2, - "maxLength": 80, - "description": "Property name.", - "type": [ - "string", - "null" - ] - }, - "isArchived": { - "description": "Property isArchived.", - "type": [ - "boolean", - "null" - ] - } - } + "description": "Aggregate equipment KPI counters for the organization equipment overview page." }, - "Checklist.ChecklistOutput.jsonld-inspection.read": { + "EquipmentMaintenanceLog.MaintenanceLogOutput.jsonld-equipment.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -48679,535 +61735,410 @@ "properties": { "id": { "readOnly": true, - "description": "Property id", - "default": "", + "description": "Property id.", + "default": "", + "type": "string" + }, + "equipmentId": { + "readOnly": true, + "description": "Property equipmentId.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "startedAt": { + "readOnly": true, + "description": "Property startedAt.", + "default": "", + "type": "string" + }, + "completedAt": { + "readOnly": true, + "description": "Property completedAt.", + "type": [ + "string", + "null" + ] + }, + "source": { + "readOnly": true, + "description": "Property source.", + "default": "status_transition", "type": "string" }, - "organizationId": { + "interventionId": { "readOnly": true, - "description": "Property organizationId", - "default": "", - "type": "string" + "description": "Property interventionId.", + "type": [ + "string", + "null" + ] }, - "name": { + "interventionNumber": { "readOnly": true, - "description": "Property name", - "default": "", - "type": "string" + "description": "Property interventionNumber.", + "type": [ + "integer", + "null" + ] }, - "referenceCode": { + "workItemAction": { "readOnly": true, - "description": "Property referenceCode", + "description": "Property workItemAction.", "type": [ "string", "null" ] }, - "version": { + "actorId": { "readOnly": true, - "description": "Property version", - "default": "", - "type": "string" + "description": "Property actorId.", + "type": [ + "string", + "null" + ] }, - "status": { + "summary": { "readOnly": true, - "description": "Property status", + "description": "Property summary.", + "type": [ + "string", + "null" + ] + } + } + } + ], + "description": "Maintenance windows recorded for equipment items." + }, + "EquipmentOpenWork.EquipmentOpenWorkOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "interventionId": { + "description": "Property interventionId.", "default": "", "type": "string" }, - "itemCount": { - "readOnly": true, - "description": "Property itemCount", + "number": { + "description": "Property number.", "default": 0, "type": "integer" }, - "items": { - "readOnly": true, - "description": "Property items", - "type": "array", - "items": { - "$ref": "#/components/schemas/ChecklistItemOutput.jsonld-inspection.read" - } - }, - "createdAt": { - "readOnly": true, - "description": "Property createdAt", + "name": { + "description": "Property name.", "default": "", "type": "string" }, - "updatedAt": { - "readOnly": true, - "description": "Property updatedAt", + "status": { + "description": "Property status.", "default": "", "type": "string" }, - "previousChecklistId": { - "description": "Property previousChecklistId", - "type": [ - "string", - "null" - ] - }, - "canEditMetadata": { - "description": "Property canEditMetadata", - "default": false, - "type": "boolean" + "workItemId": { + "description": "Property workItemId.", + "default": "", + "type": "string" }, - "canEditItems": { - "description": "Property canEditItems", - "default": false, - "type": "boolean" + "action": { + "description": "Property action.", + "default": "", + "type": "string" }, - "canCreateRevision": { - "description": "Property canCreateRevision", - "default": false, - "type": "boolean" + "workItemStatus": { + "description": "Property workItemStatus.", + "default": "", + "type": "string" } } } ], - "description": "Versioned checklist templates for inspections." + "description": "Class EquipmentOpenWorkResource\n\nProvides a scoped lookup without changing equipment-read permissions." }, - "Checklist.CreateChecklistInput-inspection.write": { + "EquipmentReplacement.ReplaceEquipmentInput-equipment.write": { "type": "object", - "description": "Versioned checklist templates for inspections.", + "description": "Class EquipmentReplacementResource\n\nOrganization-scoped atomic equipment replacement, preserving the historical asset.", "required": [ - "name", - "version" + "clientOperationId" ], "properties": { - "name": { - "maxLength": 255, - "description": "Checklist name", - "default": "", - "example": "Contr\u00f4le Extincteur", - "type": "string" - }, - "version": { - "maxLength": 50, - "description": "Version label", - "default": "", - "example": "1.0", - "type": "string" - }, - "referenceCode": { - "maxLength": 40, - "description": "Optional human-facing reference code, unique per organization", - "example": "CHK-EXT-Q", - "type": [ - "string", - "null" - ] - }, - "previousChecklistId": { + "clientOperationId": { "format": "uuid", - "description": "Previous checklist in the same organization. Creates a distinct revision without changing existing inspections.", + "description": "Stable client-generated replacement operation UUID.", "externalDocs": { "url": "https://schema.org/identifier" }, - "type": [ - "string", - "null" - ] - }, - "items": { - "description": "Checklist items", - "type": "array", - "items": { - "$ref": "#/components/schemas/ChecklistItemInput-inspection.write" - } - } - } - }, - "Checklist.UpdateChecklistInput-inspection.write.jsonMergePatch": { - "type": "object", - "description": "Versioned checklist templates for inspections.", - "properties": { - "name": { - "maxLength": 255, - "description": "Checklist name", - "example": "Contr\u00f4le Extincteur", - "type": [ - "string", - "null" - ] - }, - "referenceCode": { - "maxLength": 40, - "description": "Optional human-facing reference code, unique per organization", - "example": "CHK-EXT-Q", - "type": [ - "string", - "null" - ] - }, - "items": { - "description": "Checklist items (full replacement list)", - "type": "array", - "items": { - "$ref": "#/components/schemas/ChecklistItemInput-inspection.write" - } - } - } - }, - "ChecklistItemInput-inspection.write": { - "type": "object", - "required": [ - "label" - ], - "properties": { - "label": { - "maxLength": 255, - "description": "Item label", "default": "", - "example": "V\u00e9rifier la pression", "type": "string" }, - "description": { - "maxLength": 1000, - "description": "Optional item description", + "successorEquipmentId": { + "format": "uuid", + "description": "Existing published equipment in stock, exclusive with successor.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "required": { - "description": "Whether the item is required", - "default": true, - "example": true, - "type": "boolean" - }, - "position": { - "minimum": 0, - "description": "Display position", - "default": 0, - "example": 0, - "type": "integer" - } - } - }, - "ChecklistItemOutput.jsonld-inspection.read": { - "type": "object", - "properties": { - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "label": { - "readOnly": true, - "description": "Property label.", - "default": "", - "type": "string" - }, - "position": { - "readOnly": true, - "description": "Property position.", - "default": 0, - "type": "integer" - }, - "required": { - "readOnly": true, - "description": "Property required.", - "default": true, - "type": "boolean" - }, - "description": { - "readOnly": true, - "description": "Property description.", - "type": [ - "string", - "null" + "successor": { + "description": "New equipment to create in the replacement transaction, exclusive with successorEquipmentId.", + "anyOf": [ + { + "$ref": "#/components/schemas/ReplacementSuccessorInput-equipment.write" + }, + { + "type": "null" + } ] } } }, - "Client.ClientInput-client.update.jsonMergePatch": { - "type": "object", - "description": "Updates name, redirect URIs, grant types, or scopes of an existing client.", - "properties": { - "client_name": { - "minLength": 3, - "maxLength": 100, - "writeOnly": true, - "description": "Human-readable name of the client application", - "example": "My Awesome App", - "type": "string" - }, - "redirect_uris": { - "writeOnly": true, - "description": "List of allowed redirect URIs for OAuth2 callbacks", - "example": [ - "https://app.example.com/callback" - ], - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "grant_types": { - "writeOnly": true, - "description": "List of allowed OAuth2 grant types", - "example": [ - "authorization_code", - "refresh_token" - ], - "type": "array", - "items": { - "type": "string", - "enum": [ - "authorization_code", - "client_credentials", - "refresh_token" - ] - } + "EquipmentReplacement.ReplaceEquipmentOutput.jsonld-equipment.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" }, - "scopes": { - "writeOnly": true, - "description": "List of allowed OAuth2 scopes", - "example": [ - "openid", - "profile", - "email" - ], - "type": "array", - "items": { - "type": "string" + { + "type": "object", + "properties": { + "predecessorEquipmentId": { + "description": "Method __construct", + "type": "string" + }, + "successorEquipmentId": { + "description": "Method __construct", + "type": "string" + }, + "clientOperationId": { + "description": "Method __construct", + "type": "string" + }, + "replayed": { + "description": "Method __construct", + "type": "boolean" + } } } - } + ], + "description": "Class EquipmentReplacementResource\n\nOrganization-scoped atomic equipment replacement, preserving the historical asset." }, - "Client.ClientInput-client.write": { + "EquipmentTag.AddTagInput-equipment.write": { "type": "object", - "description": "Register a new OAuth2 client application. The client secret is shown only once.", + "description": "Tags associated with equipment items.", "required": [ - "client_name", - "redirect_uris", - "grant_types", - "scopes" + "name" ], "properties": { - "client_name": { - "minLength": 3, + "name": { + "minLength": 1, "maxLength": 100, - "writeOnly": true, - "description": "Human-readable name of the client application", - "example": "My Awesome App", + "description": "Tag name (case-insensitive, normalized to lowercase)", + "default": "", + "example": "v\u00e9rification annuelle", "type": "string" - }, - "redirect_uris": { - "writeOnly": true, - "description": "List of allowed redirect URIs for OAuth2 callbacks", - "example": [ - "https://app.example.com/callback" - ], - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "grant_types": { - "writeOnly": true, - "description": "List of allowed OAuth2 grant types", - "example": [ - "authorization_code", - "refresh_token" - ], - "type": "array", - "items": { - "type": "string", - "enum": [ - "authorization_code", - "client_credentials", - "refresh_token" - ] - } - }, - "scopes": { - "writeOnly": true, - "description": "List of allowed OAuth2 scopes", - "example": [ - "openid", - "profile", - "email" - ], - "type": "array", - "items": { - "type": "string" - } } } }, - "Client.ClientOutput.jsonld-client.read": { + "EquipmentTag.TagOutput.jsonld-equipment.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, { "type": "object", - "required": [ - "client_id", - "is_active", - "created_at" - ], "properties": { - "client_id": { - "readOnly": true, - "description": "Unique client identifier (UUID)", - "example": "01234567-89ab-cdef-0123-456789abcdef", - "type": "string", - "format": "uuid" - }, - "client_name": { + "id": { "readOnly": true, - "description": "Human-readable name of the client", - "example": "My Awesome App", + "description": "Property id.", + "default": "", "type": "string" }, - "redirect_uris": { - "readOnly": true, - "description": "List of registered redirect URIs", - "example": [ - "https://app.example.com/callback" - ], - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "grant_types": { - "readOnly": true, - "description": "List of allowed grant types", - "example": [ - "authorization_code" - ], - "type": "array", - "items": { - "type": "string" - } - }, - "scopes": { - "readOnly": true, - "description": "List of allowed scopes", - "example": [ - "openid", - "profile" - ], - "type": "array", - "items": { - "type": "string" - } - }, - "is_active": { + "name": { "readOnly": true, - "description": "Whether the client is currently active", - "default": true, - "example": true, - "type": "boolean" + "description": "Property name.", + "default": "", + "type": "string" }, - "created_at": { + "organizationId": { "readOnly": true, - "description": "Creation timestamp (ISO 8601)", - "example": "2025-01-01T12:00:00+00:00", - "type": "string", - "format": "date-time" + "description": "Property organizationId.", + "default": "", + "type": "string" } } } ], - "description": "Get details of a specific OAuth2 client. Secret is not included." + "description": "Tags associated with equipment items." }, - "Client.ClientOutput.jsonld-client.read_client.secret": { + "EquipmentType.CreateEquipmentTypeInput-equipment_type.write": { + "type": "object", + "description": "Resource EquipmentTypeCatalogResource.\n\nOrganization catalogs preserve historical codes while allowing managed additions.", + "additionalProperties": false, + "required": [ + "value", + "label" + ], + "properties": { + "value": { + "maxLength": 32, + "description": "Property value.", + "default": "", + "type": "string" + }, + "label": { + "maxLength": 100, + "description": "Property label.", + "default": "", + "type": "string" + }, + "family": { + "enum": [ + "fire", + "safety", + "other" + ], + "description": "Property family.", + "default": "fire", + "type": "string" + } + } + }, + "EquipmentType.EquipmentTypeOutput.jsonld-equipment_type.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, { "type": "object", - "required": [ - "client_id", - "is_active", - "created_at" - ], "properties": { - "client_id": { - "readOnly": true, - "description": "Unique client identifier (UUID)", - "example": "01234567-89ab-cdef-0123-456789abcdef", - "type": "string", - "format": "uuid" - }, - "client_name": { - "readOnly": true, - "description": "Human-readable name of the client", - "example": "My Awesome App", + "value": { + "description": "Property value.", + "default": "", "type": "string" }, - "client_secret": { - "readOnly": true, - "description": "Client secret (only shown once)", - "example": "your-client-secret", + "label": { + "description": "Property label.", + "default": "", "type": "string" }, - "redirect_uris": { - "readOnly": true, - "description": "List of registered redirect URIs", - "example": [ - "https://app.example.com/callback" - ], - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "grant_types": { - "readOnly": true, - "description": "List of allowed grant types", - "example": [ - "authorization_code" - ], - "type": "array", - "items": { - "type": "string" - } - }, - "scopes": { - "readOnly": true, - "description": "List of allowed scopes", - "example": [ - "openid", - "profile" - ], - "type": "array", - "items": { - "type": "string" - } + "family": { + "description": "Property family.", + "default": "other", + "type": "string" }, - "is_active": { - "readOnly": true, - "description": "Whether the client is currently active", - "default": true, - "example": true, + "archived": { + "description": "Property archived.", + "default": false, "type": "boolean" }, - "created_at": { - "readOnly": true, - "description": "Creation timestamp (ISO 8601)", - "example": "2025-01-01T12:00:00+00:00", - "type": "string", - "format": "date-time" + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" } } } ], - "description": "Register a new OAuth2 client application. The client secret is shown only once." + "description": "Resource EquipmentTypeCatalogResource.\n\nOrganization catalogs preserve historical codes while allowing managed additions." }, - "ComplianceSummary.ComplianceSummaryOutput.jsonld": { + "EquipmentType.PatchEquipmentTypeInput-equipment_type.write.jsonMergePatch": { + "type": "object", + "description": "Resource EquipmentTypeCatalogResource.\n\nOrganization catalogs preserve historical codes while allowing managed additions.", + "additionalProperties": false, + "properties": { + "revision": { + "minimum": 0, + "exclusiveMinimum": 0, + "description": "Property revision.", + "default": 0, + "type": "integer" + }, + "label": { + "maxLength": 100, + "description": "Property label.", + "type": [ + "string", + "null" + ] + }, + "family": { + "enum": [ + "fire", + "safety", + "other" + ], + "description": "Property family.", + "type": [ + "string", + "null" + ] + }, + "archived": { + "description": "Property archived.", + "type": [ + "boolean", + "null" + ] + } + } + }, + "Error": { + "type": "object", + "description": "A representation of common errors.", + "properties": { + "title": { + "readOnly": true, + "description": "A short, human-readable summary of the problem.", + "type": [ + "string", + "null" + ] + }, + "detail": { + "readOnly": true, + "description": "A human-readable explanation specific to this occurrence of the problem.", + "type": [ + "string", + "null" + ] + }, + "status": { + "type": [ + "integer", + "null" + ], + "examples": [ + 404 + ], + "default": 400 + }, + "instance": { + "readOnly": true, + "description": "A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.", + "type": [ + "string", + "null" + ] + }, + "type": { + "readOnly": true, + "description": "A URI reference that identifies the problem type", + "type": "string" + } + } + }, + "Error.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -49215,146 +62146,238 @@ { "type": "object", "properties": { - "organizationId": { - "description": "Property organizationId.", - "default": "", - "type": "string" + "title": { + "readOnly": true, + "description": "A short, human-readable summary of the problem.", + "type": [ + "string", + "null" + ] }, - "facilityId": { - "description": "Property facilityId.", + "detail": { + "readOnly": true, + "description": "A human-readable explanation specific to this occurrence of the problem.", "type": [ "string", "null" ] }, - "generatedAt": { - "description": "Property generatedAt.", - "default": "", - "type": "string" + "status": { + "type": [ + "integer", + "null" + ], + "examples": [ + 404 + ], + "default": 400 }, - "dataEvaluatedAt": { - "description": "Oldest successful maintenance evaluation in the scope, distinct from generation time.", + "instance": { + "readOnly": true, + "description": "A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.", "type": [ "string", "null" ] }, - "organizationStatus": { - "description": "Property organizationStatus.", - "default": "", + "type": { + "readOnly": true, + "description": "A URI reference that identifies the problem type", "type": "string" }, - "totals": { - "description": "Property totals.", - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "integer" - }, - { - "type": "null" - } - ] - } - }, - "facilities": { - "description": "Property facilities.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "integer" - }, - { - "type": "string" - }, - { - "type": "null" - } - ] - } - } + "description": { + "readOnly": true, + "type": [ + "string", + "null" + ] } } } ], - "description": "Regulatory compliance register summary: organization rollup and per-facility breakdown, graded compliant/at_risk/non_compliant/not_applicable from Maintenance due-status, Inspection non-conformity, and Equipment inventory counts. Requires organization.compliance.read." + "description": "A representation of common errors." }, - "ConstraintViolation": { + "Facility.CreateFacilityInput": { "type": "object", - "description": "Unprocessable entity", + "description": "Resource CanonicalFacilityResource.", "properties": { - "status": { - "default": 422, - "type": "integer" + "clientId": { + "format": "uuid", + "description": "Client-generated UUID for idempotent offline creation", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] }, - "violations": { - "type": "array", - "items": { - "type": "object", - "properties": { - "propertyPath": { - "type": "string", - "description": "The property path of the violation" - }, - "message": { - "type": "string", - "description": "The message associated with the violation" - }, - "code": { - "type": "string", - "description": "The code of the violation" - }, - "hint": { - "type": "string", - "description": "An extra hint to understand the violation" - }, - "payload": { - "type": "object", - "additionalProperties": true, - "description": "The serialized payload of the violation" - } - }, - "required": [ - "propertyPath", - "message" - ] - } + "organization": { + "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", + "description": "Organization IRI, required on the canonical endpoint", + "type": [ + "string", + "null" + ] }, - "detail": { - "readOnly": true, - "type": "string" + "intervention": { + "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", + "description": "Optional field intervention IRI", + "type": [ + "string", + "null" + ] + }, + "onboardingSessionId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "onboardingItemKey": { + "type": [ + "string", + "null" + ] }, "type": { - "readOnly": true, + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ], + "description": "Facility type", + "default": "", + "example": "site", "type": "string" }, - "title": { - "readOnly": true, + "name": { + "minLength": 2, + "maxLength": 120, + "description": "Facility display name", + "default": "", + "example": "Headquarters", + "type": "string" + }, + "parentFacilityId": { + "format": "uuid", + "description": "Optional parent facility identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "example": "550e8400-e29b-41d4-a716-446655440001", "type": [ "string", "null" ] }, - "instance": { - "readOnly": true, + "code": { + "maxLength": 80, + "description": "Optional facility code", + "example": "SITE-PAR-001", + "type": [ + "string", + "null" + ] + }, + "address": { + "maxLength": 255, + "description": "Optional address", + "example": "10 rue de Rivoli, Paris", + "type": [ + "string", + "null" + ] + }, + "latitude": { + "minimum": -90, + "maximum": 90, + "description": "Optional latitude, required together with longitude", + "example": 48.8566, + "type": [ + "number", + "null" + ] + }, + "longitude": { + "minimum": -180, + "maximum": 180, + "description": "Optional longitude, required together with latitude", + "example": 2.3522, + "type": [ + "number", + "null" + ] + }, + "metadata": { + "description": "Optional free-form metadata", + "example": { + "country": "FR", + "timezone": "Europe/Paris" + }, + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + "levelIndex": { + "minimum": -100, + "maximum": 200, + "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", + "example": 0, + "type": [ + "integer", + "null" + ] + }, + "elevationMeters": { + "minimum": -10000, + "maximum": 10000, + "description": "Optional floor elevation in meters", + "example": -3, + "type": [ + "number", + "null" + ] + }, + "heightMeters": { + "minimum": 0, + "exclusiveMinimum": 0, + "maximum": 1000, + "description": "Optional floor height in meters", + "example": 3, + "type": [ + "number", + "null" + ] + }, + "customerId": { + "format": "uuid", + "description": "Internal customer identifier for a root site", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] } - } + }, + "required": [ + "type", + "name" + ] }, - "ConstraintViolation.jsonld": { + "Facility.FacilityOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -49362,123 +62385,377 @@ { "type": "object", "properties": { - "status": { - "default": 422, + "intervention": { + "description": "Property intervention.", + "type": [ + "string", + "null" + ] + }, + "recordStatus": { + "description": "Property recordStatus.", + "default": "published", + "type": "string" + }, + "revision": { + "description": "Property revision.", + "default": 1, "type": "integer" }, - "violations": { - "type": "array", - "items": { - "type": "object", - "properties": { - "propertyPath": { - "type": "string", - "description": "The property path of the violation" - }, - "message": { - "type": "string", - "description": "The message associated with the violation" - }, - "code": { - "type": "string", - "description": "The code of the violation" - }, - "hint": { - "type": "string", - "description": "An extra hint to understand the violation" - }, - "payload": { - "type": "object", - "additionalProperties": true, - "description": "The serialized payload of the violation" - } - }, - "required": [ - "propertyPath", - "message" - ] - } + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "parentFacilityId": { + "readOnly": true, + "description": "Property parentFacilityId.", + "type": [ + "string", + "null" + ] + }, + "hasChildren": { + "readOnly": true, + "description": "Property hasChildren.", + "default": false, + "type": "boolean" + }, + "equipmentCount": { + "readOnly": true, + "description": "Active equipment assigned to this facility", + "default": 0, + "type": "integer" }, - "detail": { + "type": { "readOnly": true, + "description": "Property type.", + "default": "", "type": "string" }, - "description": { + "name": { "readOnly": true, + "description": "Property name.", + "default": "", "type": "string" }, - "type": { + "code": { + "readOnly": true, + "description": "Property code.", + "type": [ + "string", + "null" + ] + }, + "status": { "readOnly": true, + "description": "Property status.", + "default": "", "type": "string" }, - "title": { + "address": { "readOnly": true, + "description": "Property address.", "type": [ "string", "null" ] }, - "instance": { + "latitude": { + "readOnly": true, + "description": "Property latitude.", + "type": [ + "number", + "null" + ] + }, + "longitude": { + "readOnly": true, + "description": "Property longitude.", + "type": [ + "number", + "null" + ] + }, + "metadata": { + "readOnly": true, + "description": "Property metadata.", + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + "levelIndex": { + "readOnly": true, + "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", + "type": [ + "integer", + "null" + ] + }, + "elevationMeters": { + "readOnly": true, + "description": "Property elevationMeters", + "type": [ + "number", + "null" + ] + }, + "heightMeters": { + "readOnly": true, + "description": "Property heightMeters", + "type": [ + "number", + "null" + ] + }, + "customerId": { + "description": "Optional internal customer identifier on root sites", + "type": [ + "string", + "null" + ] + }, + "planGeometry": { + "readOnly": true, + "description": "Optional spatial geometry bound to an ancestor floor plan (detail views only)", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + }, + { + "type": "string" + } + ] + } + }, + "geometryIssue": { "readOnly": true, + "description": "Property geometryIssue.", "type": [ "string", "null" + ], + "enum": [ + "invalid_geometry", + "plan_unavailable", + "outside_ancestry", + "other_plan", + null ] + }, + "hierarchyIssues": { + "readOnly": true, + "description": "Property hierarchyIssues.", + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt.", + "default": "", + "type": "string" + }, + "path": { + "readOnly": true, + "description": "Ancestor breadcrumb, root first, excluding this facility; opt-in on collections through includePath.", + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "name", + "type" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ] + } + } + } } } } ], - "description": "Unprocessable entity" - }, - "Conversation.AcknowledgeDeliveryInput": { - "type": "object", - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", - "required": [ - "messageId" - ], - "properties": { - "messageId": { - "description": "Property messageId", - "default": "", - "type": "string" - } - } + "description": "Resource CanonicalFacilityResource." }, - "Conversation.ArchiveConversationInput.jsonMergePatch": { + "Facility.PatchCanonicalFacilityInput.jsonMergePatch": { "type": "object", - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", + "description": "Resource CanonicalFacilityResource.", "properties": { - "isArchived": { - "description": "Property isArchived.", - "default": true, - "type": "boolean" - } - } - }, - "Conversation.ConversationActivityBucketOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" + "type": { + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ], + "description": "Property type.", + "type": [ + "string", + "null" + ] }, - { - "type": "object", - "properties": { - "bucket": { - "description": "Property bucket.", - "default": "", - "type": "string" - }, - "count": { - "description": "Property count.", - "default": 0, - "type": "integer" - } + "name": { + "minLength": 2, + "maxLength": 120, + "description": "Property name.", + "type": [ + "string", + "null" + ] + }, + "code": { + "maxLength": 80, + "description": "Property code.", + "type": [ + "string", + "null" + ] + }, + "customerId": { + "format": "uuid", + "description": "Property customerId. Optional internal customer identifier for a root site.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "address": { + "maxLength": 255, + "description": "Property address.", + "type": [ + "string", + "null" + ] + }, + "latitude": { + "minimum": -90, + "maximum": 90, + "description": "Property latitude.", + "type": [ + "number", + "null" + ] + }, + "longitude": { + "minimum": -180, + "maximum": 180, + "description": "Property longitude.", + "type": [ + "number", + "null" + ] + }, + "metadata": { + "description": "Property metadata.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] } + }, + "status": { + "enum": [ + "active", + "archived" + ], + "description": "Property status.", + "type": [ + "string", + "null" + ] + }, + "parent": { + "description": "Property parent.", + "type": [ + "string", + "null" + ] + }, + "levelIndex": { + "minimum": -100, + "maximum": 200, + "description": "Property levelIndex.", + "type": [ + "integer", + "null" + ] + }, + "elevationMeters": { + "minimum": -10000, + "maximum": 10000, + "description": "Property elevationMeters", + "type": [ + "number", + "null" + ] + }, + "heightMeters": { + "minimum": 0, + "exclusiveMinimum": 0, + "maximum": 1000, + "description": "Property heightMeters", + "type": [ + "number", + "null" + ] } - ], - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." + } }, - "Conversation.ConversationOutput.jsonld": { + "FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -49487,134 +62764,148 @@ "type": "object", "properties": { "id": { + "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organization": { - "description": "Property organization.", + "facilityId": { + "readOnly": true, + "description": "Property facilityId.", "default": "", "type": "string" }, - "subjectType": { - "description": "Property subjectType.", + "fileName": { + "readOnly": true, + "description": "Property fileName.", "default": "", "type": "string" }, - "subject": { - "description": "Property subject.", - "type": [ - "string", - "null" - ] - }, - "subjectLabel": { - "description": "Property subjectLabel.", - "type": [ - "string", - "null" - ] - }, - "visibility": { - "description": "Property visibility.", + "mimeType": { + "readOnly": true, + "description": "Property mimeType.", "default": "", "type": "string" }, - "lastMessageAt": { - "description": "Property lastMessageAt.", + "size": { + "readOnly": true, + "description": "Property size.", + "default": 0, + "type": "integer" + }, + "label": { + "readOnly": true, + "description": "Property label.", "type": [ "string", "null" ] }, - "messagesCount": { - "description": "Property messagesCount.", - "default": 0, - "type": "integer" - }, - "isArchived": { - "description": "Property isArchived.", - "default": false, - "type": "boolean" - }, - "unreadCount": { - "description": "Property unreadCount.", - "default": 0, + "revision": { + "readOnly": true, + "description": "Property revision.", + "default": 1, "type": "integer" }, - "createdAt": { - "description": "Property createdAt.", - "default": "", - "type": "string" - }, - "updatedAt": { - "description": "Property updatedAt.", - "default": "", + "kind": { + "readOnly": true, + "description": "Property kind.", + "default": "document", "type": "string" }, - "isChannel": { - "description": "Property isChannel.", + "isPrimaryPlan": { + "readOnly": true, + "description": "Property isPrimaryPlan.", "default": false, "type": "boolean" }, - "name": { - "description": "Property name.", + "imageWidth": { + "readOnly": true, + "description": "Property imageWidth.", "type": [ - "string", + "integer", "null" ] }, - "team": { - "description": "Property team.", + "imageHeight": { + "readOnly": true, + "description": "Property imageHeight.", "type": [ - "string", + "integer", "null" ] }, - "isFavorite": { - "description": "Property isFavorite.", - "default": false, - "type": "boolean" + "uploadedAt": { + "readOnly": true, + "description": "Property uploadedAt.", + "default": "", + "type": "string" }, - "parentConversationId": { - "description": "Property parentConversationId.", + "calibration": { + "readOnly": true, + "description": "Metric calibration of the original plan image.", + "type": [ + "object", + "null" + ], + "required": [ + "widthMeters", + "rotationDegrees", + "offsetXMeters", + "offsetZMeters" + ], + "properties": { + "widthMeters": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 100000 + }, + "rotationDegrees": { + "type": "number", + "minimum": -360, + "maximum": 360 + }, + "offsetXMeters": { + "type": "number", + "minimum": -100000, + "maximum": 100000 + }, + "offsetZMeters": { + "type": "number", + "minimum": -100000, + "maximum": 100000 + } + }, + "additionalProperties": false + }, + "calibrationBuildingId": { + "readOnly": true, + "description": "Property calibrationBuildingId", "type": [ "string", "null" - ] + ], + "format": "uuid" }, - "counterpartMember": { - "description": "Property counterpartMember.", + "calibrationIssue": { + "readOnly": true, + "description": "Property calibrationIssue", "type": [ "string", "null" + ], + "enum": [ + "building_changed", + "unverified_frame", + null ] } } } ], - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." - }, - "Conversation.ConversationReceiptsOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "receipts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ConversationReceiptPositionOutput.jsonld" - } - } - } - } - ], - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." + "description": "File attachments linked to a facility." }, - "Conversation.ConversationSignalOutput.jsonld": { + "FacilityEquipmentSummary.FacilityEquipmentSummaryOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -49622,122 +62913,144 @@ { "type": "object", "properties": { - "accepted": { - "description": "Property accepted", - "default": true, - "type": "boolean" + "scope": { + "readOnly": true, + "description": "Property scope.", + "default": "subtree", + "type": "string", + "enum": [ + "subtree", + "direct" + ] + }, + "totalItems": { + "readOnly": true, + "description": "Property totalItems.", + "default": 0, + "type": "integer", + "minimum": 0 + }, + "byStatus": { + "readOnly": true, + "description": "Property byStatus.", + "default": { + "in_stock": 0, + "operational": 0, + "under_maintenance": 0, + "decommissioned": 0 + }, + "type": "object", + "required": [ + "in_stock", + "operational", + "under_maintenance", + "decommissioned" + ], + "properties": { + "in_stock": { + "type": "integer", + "minimum": 0 + }, + "operational": { + "type": "integer", + "minimum": 0 + }, + "under_maintenance": { + "type": "integer", + "minimum": 0 + }, + "decommissioned": { + "type": "integer", + "minimum": 0 + } + } + }, + "needingAttentionCount": { + "readOnly": true, + "description": "Property needingAttentionCount.", + "default": 0, + "type": "integer", + "minimum": 0 } } } ], - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." + "description": "Resource FacilityEquipmentSummaryResource." }, - "Conversation.GetOrCreateConversationInput": { + "FacilityMetadataField.CreateFacilityMetadataFieldInput-facility.write": { "type": "object", - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", + "description": "Organization-defined typed schema for facility metadata.", "required": [ - "organization", - "subjectType", - "subject" + "key", + "label", + "fieldType" ], "properties": { - "organization": { - "description": "Property organization.", + "key": { + "minLength": 2, + "maxLength": 64, + "description": "Machine key, kebab-case or snake_case", "default": "", + "example": "surface-m2", "type": "string" }, - "subjectType": { - "description": "Property subjectType.", + "label": { + "minLength": 2, + "maxLength": 80, + "description": "Human-readable label", "default": "", + "example": "Surface (m\u00b2)", "type": "string" }, - "subject": { - "description": "Property subject.", + "fieldType": { + "enum": [ + "text", + "number", + "date", + "boolean", + "select" + ], + "description": "Field type", "default": "", + "example": "number", "type": "string" - } - } - }, - "Conversation.MarkConversationReadInput.jsonMergePatch": { - "type": "object", - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", - "properties": { - "lastReadMessageId": { - "description": "Property lastReadMessageId.", - "type": [ - "string", - "null" - ] - } - } - }, - "Conversation.MessagingSubscriptionOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" }, - { - "type": "object", - "properties": { - "token": { - "readOnly": true, - "description": "Property token.", - "default": "", - "type": "string" - }, - "topic": { - "readOnly": true, - "description": "Property topic.", - "default": "", - "type": "string" - } - } - } - ], - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." - }, - "Conversation.PublishTypingInput": { - "type": "object", - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", - "properties": { - "active": { - "description": "Property active", + "required": { + "description": "Whether the field is required on facility creation", "default": false, "type": "boolean" - } - } - }, - "ConversationReceiptPositionOutput.jsonld": { - "type": "object", - "properties": { - "memberId": { - "description": "Property memberId", - "default": "", - "type": "string" - }, - "deliveredMessageId": { - "description": "Property deliveredMessageId", - "type": [ - "string", - "null" - ] }, - "deliveredThroughAt": { - "description": "Property deliveredThroughAt", - "type": [ - "string", - "null" - ] + "options": { + "description": "Select options, required only when fieldType is \"select\"", + "example": [ + "ERP", + "IGH", + "Habitation" + ], + "type": "array", + "items": { + "type": "string" + } }, - "readMessageId": { - "description": "Property readMessageId", + "facilityType": { + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ], + "description": "Optional facility type scope; omitted or null applies to every type", + "example": "building", "type": [ "string", "null" ] }, - "readThroughAt": { - "description": "Property readThroughAt", + "unit": { + "maxLength": 16, + "description": "Optional unit label", + "example": "m\u00b2", "type": [ "string", "null" @@ -49745,7 +63058,7 @@ } } }, - "CurrentOrganizationMemberProfile.CurrentOrganizationMemberProfileOutput.jsonld-Organization.read": { + "FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -49755,86 +63068,131 @@ "properties": { "id": { "readOnly": true, - "description": "Property id.", + "description": "Property id", "default": "", "type": "string" }, - "organizationId": { + "key": { "readOnly": true, - "description": "Property organizationId.", + "description": "Property key", "default": "", "type": "string" }, - "userId": { + "label": { "readOnly": true, - "description": "Property userId.", + "description": "Property label", "default": "", "type": "string" }, - "isActive": { - "readOnly": true, - "description": "Property isActive.", - "default": true, - "type": "boolean" - }, - "joinedAt": { + "fieldType": { "readOnly": true, - "description": "Property joinedAt.", + "description": "Property fieldType", "default": "", "type": "string" }, - "roles": { + "options": { "readOnly": true, "type": "array", "items": { - "$ref": "#/components/schemas/OrganizationRoleOutput.jsonld-Organization.read" + "type": "string" } }, - "permissions": { + "facilityType": { "readOnly": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/OrganizationPermissionOutput.jsonld-Organization.read" - } + "description": "Property facilityType", + "type": [ + "string", + "null" + ] + }, + "required": { + "readOnly": true, + "description": "Property required", + "default": false, + "type": "boolean" + }, + "unit": { + "readOnly": true, + "description": "Property unit", + "type": [ + "string", + "null" + ] } } } ], - "description": "Resource CurrentOrganizationMemberProfileResource." + "description": "Organization-defined typed schema for facility metadata." }, - "CurrentUserProfile.CurrentUserProfileInput-user.write.jsonMergePatch": { + "FacilityMetadataField.UpdateFacilityMetadataFieldInput-facility.write.jsonMergePatch": { "type": "object", - "description": "Resource CurrentUserProfileResource.", + "description": "Organization-defined typed schema for facility metadata.", "properties": { - "firstName": { - "maxLength": 100, - "writeOnly": true, - "description": "User first name.", - "example": "Jane", - "type": "string" + "label": { + "minLength": 2, + "maxLength": 80, + "description": "Human-readable label (partial update)", + "type": [ + "string", + "null" + ] }, - "lastName": { - "maxLength": 100, - "writeOnly": true, - "description": "User last name.", - "example": "Doe", - "type": "string" + "fieldType": { + "enum": [ + "text", + "number", + "date", + "boolean", + "select" + ], + "description": "Field type (partial update)", + "type": [ + "string", + "null" + ] }, - "locale": { + "required": { + "description": "Whether the field is required on facility creation (partial update)", + "type": [ + "boolean", + "null" + ] + }, + "options": { + "description": "Select options (partial update)", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "facilityType": { "enum": [ - "system", - "en", - "fr", - "es" + "site", + "building", + "floor", + "zone", + "area" ], - "writeOnly": true, - "description": "Preferred display language. Omit to leave unchanged; \"system\" follows the browser language.", - "example": "fr", - "type": "string" + "description": "Optional facility type scope (partial update); null applies to every type", + "type": [ + "string", + "null" + ] + }, + "unit": { + "maxLength": 16, + "description": "Optional unit label (partial update)", + "type": [ + "string", + "null" + ] } } }, - "CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read": { + "FacilityModel.FacilityModelOutput.jsonld-facility.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -49843,143 +63201,134 @@ "type": "object", "properties": { "id": { - "readOnly": true, - "description": "Property id.", - "type": [ - "string", - "null" - ] - }, - "username": { - "readOnly": true, - "description": "Property username.", - "type": [ - "string", - "null" - ] - }, - "email": { - "readOnly": true, - "description": "Property email.", - "type": [ - "string", - "null" - ] - }, - "firstName": { - "readOnly": true, - "description": "Property firstName.", - "type": [ - "string", - "null" - ] + "description": "Method __construct.", + "type": "string" }, - "lastName": { - "readOnly": true, - "description": "Property lastName.", - "type": [ - "string", - "null" - ] + "organizationId": { + "description": "Method __construct.", + "type": "string" }, - "avatarUrl": { - "readOnly": true, - "description": "Property avatarUrl.", - "type": [ - "string", - "null" - ] + "buildingId": { + "description": "Method __construct.", + "type": "string" }, - "status": { - "readOnly": true, - "description": "Property status.", - "type": [ - "string", - "null" - ] + "fileName": { + "description": "Method __construct.", + "type": "string" }, - "emailVerified": { - "readOnly": true, - "description": "Property emailVerified.", - "default": false, - "type": "boolean" + "mimeType": { + "description": "Method __construct.", + "type": "string" }, - "tenantId": { - "readOnly": true, - "description": "Property tenantId.", - "type": [ - "string", - "null" - ] + "fileSize": { + "description": "Method __construct.", + "type": "integer" }, - "createdAt": { - "readOnly": true, - "description": "Property createdAt.", - "type": [ - "string", - "null" - ] + "nodeCount": { + "description": "Method __construct.", + "type": "integer" }, - "lastLoginAt": { - "readOnly": true, - "description": "Property lastLoginAt.", - "type": [ - "string", - "null" - ] + "nodes": { + "description": "Method __construct.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } }, - "locale": { - "readOnly": true, - "description": "Preferred display language. \"system\" follows the browser language.", - "default": "system", - "example": "fr", - "type": "string", - "enum": [ - "system", - "en", - "fr", - "es" - ] + "revision": { + "description": "Method __construct.", + "type": "integer" }, - "totpEnabled": { - "readOnly": true, - "description": "Whether the authenticated user has an active TOTP (authenticator app) enrollment.", - "default": false, - "example": false, + "active": { + "description": "Method __construct.", "type": "boolean" }, - "roles": { - "readOnly": true, - "description": "Resolved global role names for the authenticated user.", + "transform": { + "description": "Method __construct.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + { + "type": "number" + } + ] + } + }, + "bindings": { + "description": "Method __construct.", "type": "array", "items": { - "type": "string" + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } } }, - "permissions": { - "readOnly": true, - "description": "Resolved global permission names for the authenticated user.", + "downloadUrl": { + "description": "Method __construct.", + "type": "string" + }, + "createdAt": { + "description": "Method __construct.", + "type": "string" + }, + "updatedAt": { + "description": "Method __construct.", + "type": "string" + }, + "bindingIssues": { + "description": "Method __construct.", "type": "array", "items": { - "type": "string" + "type": "object", + "required": [ + "nodeIndex", + "code" + ], + "additionalProperties": false, + "properties": { + "nodeIndex": { + "type": "integer", + "minimum": 0 + }, + "code": { + "type": "string", + "enum": [ + "target_unavailable" + ] + } + } } - }, - "avatarUrls": { - "readOnly": true, - "description": "Avatar URLs by size in pixels. Null when no avatar is set or the avatar is hosted externally.", - "type": "object", - "additionalProperties": { - "type": "string", - "format": "uri" - }, - "nullable": true } } } ], - "description": "Resource CurrentUserProfileResource." + "description": "Immutable self-contained GLB 2.0 building models; maximum 10 MiB and two models per building." }, - "DirectConversation.ConversationOutput.jsonld": { + "FacilityPlanCalibration.FacilityAttachmentOutput.jsonld-facility.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -49988,403 +63337,281 @@ "type": "object", "properties": { "id": { + "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organization": { - "description": "Property organization.", + "facilityId": { + "readOnly": true, + "description": "Property facilityId.", "default": "", "type": "string" }, - "subjectType": { - "description": "Property subjectType.", + "fileName": { + "readOnly": true, + "description": "Property fileName.", "default": "", "type": "string" }, - "subject": { - "description": "Property subject.", - "type": [ - "string", - "null" - ] - }, - "subjectLabel": { - "description": "Property subjectLabel.", - "type": [ - "string", - "null" - ] - }, - "visibility": { - "description": "Property visibility.", + "mimeType": { + "readOnly": true, + "description": "Property mimeType.", "default": "", "type": "string" }, - "lastMessageAt": { - "description": "Property lastMessageAt.", + "size": { + "readOnly": true, + "description": "Property size.", + "default": 0, + "type": "integer" + }, + "label": { + "readOnly": true, + "description": "Property label.", "type": [ "string", "null" ] }, - "messagesCount": { - "description": "Property messagesCount.", - "default": 0, - "type": "integer" - }, - "isArchived": { - "description": "Property isArchived.", - "default": false, - "type": "boolean" - }, - "unreadCount": { - "description": "Property unreadCount.", - "default": 0, + "revision": { + "readOnly": true, + "description": "Property revision.", + "default": 1, "type": "integer" }, - "createdAt": { - "description": "Property createdAt.", - "default": "", - "type": "string" - }, - "updatedAt": { - "description": "Property updatedAt.", - "default": "", + "kind": { + "readOnly": true, + "description": "Property kind.", + "default": "document", "type": "string" }, - "isChannel": { - "description": "Property isChannel.", + "isPrimaryPlan": { + "readOnly": true, + "description": "Property isPrimaryPlan.", "default": false, "type": "boolean" }, - "name": { - "description": "Property name.", + "imageWidth": { + "readOnly": true, + "description": "Property imageWidth.", "type": [ - "string", + "integer", "null" ] }, - "team": { - "description": "Property team.", + "imageHeight": { + "readOnly": true, + "description": "Property imageHeight.", "type": [ - "string", + "integer", "null" ] }, - "isFavorite": { - "description": "Property isFavorite.", - "default": false, - "type": "boolean" + "uploadedAt": { + "readOnly": true, + "description": "Property uploadedAt.", + "default": "", + "type": "string" }, - "parentConversationId": { - "description": "Property parentConversationId.", + "calibration": { + "readOnly": true, + "description": "Metric calibration of the original plan image.", + "type": [ + "object", + "null" + ], + "required": [ + "widthMeters", + "rotationDegrees", + "offsetXMeters", + "offsetZMeters" + ], + "properties": { + "widthMeters": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 100000 + }, + "rotationDegrees": { + "type": "number", + "minimum": -360, + "maximum": 360 + }, + "offsetXMeters": { + "type": "number", + "minimum": -100000, + "maximum": 100000 + }, + "offsetZMeters": { + "type": "number", + "minimum": -100000, + "maximum": 100000 + } + }, + "additionalProperties": false + }, + "calibrationBuildingId": { + "readOnly": true, + "description": "Property calibrationBuildingId", "type": [ "string", "null" - ] + ], + "format": "uuid" }, - "counterpartMember": { - "description": "Property counterpartMember.", + "calibrationIssue": { + "readOnly": true, + "description": "Property calibrationIssue", "type": [ "string", "null" + ], + "enum": [ + "building_changed", + "unverified_frame", + null ] } } } ], - "description": "Resource DirectConversationResource.\n\n1-to-1 private conversations between two organization members (L2.4) \u2014 a\n`Conversation` with `subjectType=DIRECT`, `visibility=PARTICIPANTS`, and\n`subjectId` set to the deterministic, order-independent pair key derived\nby `Messaging\\Domain\\Service\\DirectConversationKey`. Mirrors\n`ChannelResource`: a dedicated top-level creation endpoint PLUS its own\n\"list mine\" collection, while messages/read/subscription/favorite reuse\nthe EXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a direct\nconversation id IS a conversation id. It deliberately does NOT appear in\n`GET /api/conversations` (see `MessagingConversationRepository::list()`) \u2014\n`GET /api/direct-conversations` is the only list surface for a member's\ndirect conversations, scoped exactly like `GET /api/channels` (an INNER\nJOIN on `messaging_participants`), which is what preserves the privacy\ninvariant: a member can never discover a direct conversation they are not\na participant of, through either list." - }, - "DirectConversation.GetOrCreateDirectConversationInput": { - "type": "object", - "description": "Resource DirectConversationResource.\n\n1-to-1 private conversations between two organization members (L2.4) \u2014 a\n`Conversation` with `subjectType=DIRECT`, `visibility=PARTICIPANTS`, and\n`subjectId` set to the deterministic, order-independent pair key derived\nby `Messaging\\Domain\\Service\\DirectConversationKey`. Mirrors\n`ChannelResource`: a dedicated top-level creation endpoint PLUS its own\n\"list mine\" collection, while messages/read/subscription/favorite reuse\nthe EXISTING `/api/conversations/{id}/...` endpoints unchanged \u2014 a direct\nconversation id IS a conversation id. It deliberately does NOT appear in\n`GET /api/conversations` (see `MessagingConversationRepository::list()`) \u2014\n`GET /api/direct-conversations` is the only list surface for a member's\ndirect conversations, scoped exactly like `GET /api/channels` (an INNER\nJOIN on `messaging_participants`), which is what preserves the privacy\ninvariant: a member can never discover a direct conversation they are not\na participant of, through either list.", - "required": [ - "organization", - "memberId" - ], - "properties": { - "organization": { - "description": "Property organization.", - "default": "", - "type": "string" - }, - "memberId": { - "format": "uuid", - "description": "Property memberId.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "default": "", - "type": "string" - } - } + "description": "Resource FacilityAttachmentCalibrationResource." }, - "Discovery.JwksOutput-token.read": { + "FacilityPlanCalibration.SetFacilityAttachmentCalibrationInput-facility.write": { "type": "object", - "description": "OAuth2/OIDC discovery endpoints", + "description": "Resource FacilityAttachmentCalibrationResource.", "required": [ - "keys" + "calibration" ], "properties": { - "keys": { - "readOnly": true, - "description": "Array of JSON Web Keys for signature verification", - "example": [ - { - "kty": "RSA", - "use": "sig", - "alg": "RS256", - "kid": "key-id-123" - } + "calibration": { + "description": "Calibration in metres, or null to remove the metric calibration.", + "type": [ + "object", + "null" ], - "type": "array", - "items": { - "type": "object", - "properties": { - "kty": { - "type": "string", - "description": "Key type (e.g., RSA)" - }, - "use": { - "type": "string", - "description": "Key usage (sig for signature)" - }, - "alg": { - "type": "string", - "description": "Algorithm (e.g., RS256)" - }, - "kid": { - "type": "string", - "description": "Key identifier" - }, - "n": { - "type": "string", - "description": "RSA modulus (Base64URL)" - }, - "e": { - "type": "string", - "description": "RSA exponent (Base64URL)" - } + "required": [ + "widthMeters", + "rotationDegrees", + "offsetXMeters", + "offsetZMeters" + ], + "properties": { + "widthMeters": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 100000 + }, + "rotationDegrees": { + "type": "number", + "minimum": -360, + "maximum": 360 + }, + "offsetXMeters": { + "type": "number", + "minimum": -100000, + "maximum": 100000 + }, + "offsetZMeters": { + "type": "number", + "minimum": -100000, + "maximum": 100000 } - } + }, + "additionalProperties": true } } }, - "Discovery.OpenIdConfigurationOutput-token.read": { - "type": "object", - "description": "OAuth2/OIDC discovery endpoints", - "required": [ - "issuer", - "token_endpoint", - "userinfo_endpoint", - "jwks_uri" - ], - "properties": { - "issuer": { - "readOnly": true, - "description": "Issuer identifier URL of the authorization server", - "example": "https://auth.example.com", - "type": "string", - "format": "uri" - }, - "authorization_endpoint": { - "readOnly": true, - "description": "URL of the authorization endpoint", - "example": "https://auth.example.com/oauth2/authorize", - "type": "string", - "format": "uri", - "nullable": true - }, - "token_endpoint": { - "readOnly": true, - "description": "URL of the token endpoint", - "example": "https://auth.example.com/api/oauth2/token", - "type": "string", - "format": "uri" - }, - "userinfo_endpoint": { - "readOnly": true, - "description": "URL of the UserInfo endpoint", - "example": "https://auth.example.com/api/oauth2/userinfo", - "type": "string", - "format": "uri" - }, - "jwks_uri": { - "readOnly": true, - "description": "URL of the JWKS endpoint", - "example": "https://auth.example.com/api/.well-known/jwks.json", - "type": "string", - "format": "uri" - }, - "revocation_endpoint": { - "readOnly": true, - "description": "URL of the token revocation endpoint", - "example": "https://auth.example.com/api/oauth2/token/revoke", - "type": "string", - "format": "uri", - "nullable": true - }, - "introspection_endpoint": { - "readOnly": true, - "description": "URL of the token introspection endpoint", - "example": "https://auth.example.com/api/oauth2/token/introspect", - "type": "string", - "format": "uri", - "nullable": true - }, - "end_session_endpoint": { - "readOnly": true, - "description": "URL of the end session (logout) endpoint", - "example": "https://auth.example.com/api/oauth2/logout", - "type": "string", - "format": "uri", - "nullable": true - }, - "scopes_supported": { - "readOnly": true, - "description": "List of supported OAuth2 scopes", - "example": [ - "openid", - "profile", - "email", - "offline_access" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "response_types_supported": { - "readOnly": true, - "description": "List of supported response types", - "example": [ - "code" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "grant_types_supported": { - "readOnly": true, - "description": "List of supported grant types", - "example": [ - "client_credentials", - "refresh_token", - "authorization_code" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "token_endpoint_auth_methods_supported": { - "readOnly": true, - "description": "Supported token endpoint authentication methods", - "example": [ - "client_secret_post" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "code_challenge_methods_supported": { - "readOnly": true, - "description": "Supported PKCE code challenge methods", - "example": [ - "S256", - "plain" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "prompt_values_supported": { - "readOnly": true, - "description": "Supported prompt values", - "example": [ - "none", - "login", - "consent", - "select_account" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "subject_types_supported": { - "readOnly": true, - "description": "Supported subject identifier types", - "example": [ - "public" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "id_token_signing_alg_values_supported": { - "readOnly": true, - "description": "Supported ID token signing algorithms", - "example": [ - "RS256" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true + "FacilityTree.FacilityTreeOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" }, - "claims_supported": { - "readOnly": true, - "description": "Supported OpenID Connect claims", - "example": [ - "sub", - "name", - "given_name", - "family_name", - "preferred_username", - "picture", - "email", - "email_verified", - "auth_time" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true + { + "type": "object", + "properties": { + "organizationId": { + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "generatedAt": { + "description": "Property generatedAt.", + "default": "", + "type": "string" + }, + "nodes": { + "description": "Property nodes.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + }, + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } } - } + ], + "description": "Enriched facility hierarchy (Site/Building/Floor/Zone) with per-node equipment count and compliance verdict/rate, batched by facility-id list. Requires organization.compliance.read." }, - "EmailChange.ConfirmEmailChangeInput-email_change.write": { + "FederatedAuth.FederatedCompleteInput": { "type": "object", - "description": "Sign-in email address change operations (email confirmation protected)", - "required": [ - "token" - ], + "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API.", "properties": { - "token": { + "code": { + "maxLength": 4096, + "description": "Property code", + "type": [ + "string", + "null" + ] + }, + "error": { "maxLength": 128, - "writeOnly": true, - "description": "Confirmation token from the email sent to the new address", - "example": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - "type": "string" + "description": "Property error", + "type": [ + "string", + "null" + ] + }, + "state": { + "maxLength": 256, + "description": "Property state", + "type": [ + "string", + "null" + ] } - } + }, + "required": [ + "state" + ] }, - "EmailChange.ConfirmEmailChangeOutput.jsonld-email_change.read": { + "FederatedAuth.FederatedConnectionsOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -50392,50 +63619,62 @@ { "type": "object", "properties": { - "success": { - "description": "Whether the email address was changed successfully", - "example": true, + "password_configured": { + "description": "Property passwordConfigured.", "type": "boolean" }, - "message": { - "description": "Informational message", - "example": "Your email address has been changed. Please sign in again with the new address.", + "last_sign_in_method": { + "description": "Property lastSignInMethod.", + "type": [ + "string", + "null" + ] + }, + "connections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FederatedConnectionOutput.jsonld" + } + } + } + } + ], + "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." + }, + "FederatedAuth.FederatedProviderOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "provider": { + "description": "Method __construct", "type": "string" + }, + "enabled": { + "description": "Method __construct", + "type": "boolean" } } } ], - "description": "Sign-in email address change operations (email confirmation protected)" + "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." }, - "EmailChange.RequestEmailChangeInput-email_change.write": { + "FederatedAuth.FederatedStartInput": { "type": "object", - "description": "Sign-in email address change operations (email confirmation protected)", - "required": [ - "newEmail", - "currentPassword" - ], + "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API.", "properties": { - "newEmail": { - "format": "email", - "maxLength": 320, - "writeOnly": true, - "description": "The new sign-in email address; a confirmation link is sent to it", - "externalDocs": { - "url": "https://schema.org/email" - }, - "example": "new-address@example.com", + "return_url": { + "maxLength": 500, + "description": "Property returnUrl", + "default": "/", "type": "string" - }, - "currentPassword": { - "writeOnly": true, - "description": "Current password, verified before the confirmation email is sent", - "example": "CurrentP@ssw0rd!", - "type": "string", - "format": "password" } } }, - "EmailChange.RequestEmailChangeOutput.jsonld-email_change.read": { + "FederatedAuth.FederatedStartOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -50443,212 +63682,347 @@ { "type": "object", "properties": { - "success": { - "description": "Whether the email change request was accepted", + "authorization_url": { + "description": "Method __construct", + "type": "string" + } + } + } + ], + "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." + }, + "FederatedAuth.LoginOutput.jsonld-token.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "access_token": { + "readOnly": true, + "description": "JWT access token for API authentication; absent while MFA is required", + "example": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "type": "string" + }, + "token_type": { + "readOnly": true, + "description": "Token type; absent while MFA is required", + "example": "Bearer", + "type": "string", + "enum": [ + "Bearer" + ], + "default": "Bearer" + }, + "expires_in": { + "readOnly": true, + "description": "Token lifetime in seconds; absent while MFA is required", + "example": 3600, + "type": "integer", + "minimum": 1 + }, + "scope": { + "readOnly": true, + "description": "Space-separated list of granted scopes", + "example": "openid profile email", + "type": "string", + "nullable": true + }, + "mfa_required": { + "readOnly": true, + "description": "If true, authentication is incomplete. Use mfa_token to verify code.", "example": true, - "type": "boolean" + "type": "boolean", + "nullable": true }, - "message": { - "description": "Informational message", - "example": "A confirmation link has been sent to the new email address.", - "type": "string" + "mfa_token": { + "readOnly": true, + "description": "Temporary Pre-Auth Token (JWT) covering the partial authentication state.", + "example": "eyJ...", + "type": "string", + "nullable": true }, - "expiresAt": { - "description": "When the confirmation token expires", - "example": "2026-08-28T11:00:00+00:00", + "challenge_token": { + "readOnly": true, + "description": "The OTP challenge token (reference).", + "example": "abc...", + "type": "string", + "nullable": true + }, + "mfa_method": { + "readOnly": true, + "description": "The delivery method for the MFA code (email, sms, totp).", + "example": "email", + "type": "string", + "enum": [ + "email", + "sms", + "totp" + ], + "nullable": true + }, + "mfa_destination": { + "readOnly": true, + "description": "The masked destination where the code was sent (e.g., \"j***e@e****e.com\" or \"+336****5678\").", + "example": "j***e@e****e.com", + "type": "string", + "nullable": true + }, + "mfa_resend_in": { + "readOnly": true, + "description": "Seconds until MFA code can be resent", + "example": 60, + "type": "integer", + "nullable": true + }, + "return_url": { + "description": "Validated local destination returned by a federated login.", "type": [ "string", "null" - ], - "format": "date-time" + ] + }, + "new_account": { + "description": "Whether a federated login provisioned the Fireguard account.", + "type": [ + "boolean", + "null" + ] } } } ], - "description": "Sign-in email address change operations (email confirmation protected)" + "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." }, - "EmailOwnership.ConfirmEmailOwnershipInput-email_ownership.write": { + "FederatedConnectionOutput.jsonld": { "type": "object", - "description": "Resource EmailOwnershipResource.", - "required": [ - "challengeToken", - "code" - ], "properties": { - "challengeToken": { - "default": "", + "provider": { + "description": "Property provider", "type": "string" }, - "code": { - "default": "", + "email": { + "description": "Property email", + "type": "string" + }, + "connected_at": { + "description": "Property connectedAt", + "type": "string" + }, + "last_used_at": { + "description": "Property lastUsedAt", "type": "string" } } }, - "EmailOwnership.EmailOwnershipOutput.jsonld-email_ownership.read": { + "Health.HealthOutput.jsonld-health.read_health.read.details": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, { "type": "object", + "required": [ + "status", + "timestamp", + "version" + ], "properties": { - "verified": { - "description": "current mailbox ownership proof", - "type": "boolean" + "status": { + "readOnly": true, + "description": "Overall health status of the service", + "default": "", + "example": "healthy", + "type": "string", + "enum": [ + "healthy", + "degraded", + "unhealthy" + ] + }, + "timestamp": { + "readOnly": true, + "description": "Current server timestamp (ISO 8601)", + "default": "", + "example": "2026-01-30T10:15:30+00:00", + "type": "string", + "format": "date-time" + }, + "database": { + "readOnly": true, + "description": "Database connectivity status (when details are enabled)", + "example": true, + "type": "boolean", + "nullable": true + }, + "cache": { + "readOnly": true, + "description": "Cache connectivity status (when details are enabled)", + "example": true, + "type": "boolean", + "nullable": true + }, + "version": { + "readOnly": true, + "description": "Running application version", + "default": "1.0.0", + "example": "1.0.0", + "type": "string" } } } ], - "description": "Resource EmailOwnershipResource." + "description": "Application health check" }, - "EmailOwnership.StartEmailOwnershipOutput.jsonld-email_ownership.read": { + "HydraCollectionBaseSchema": { "allOf": [ { - "$ref": "#/components/schemas/HydraItemBaseSchema" + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" }, { "type": "object", "properties": { - "challengeToken": { - "description": "single-use email challenge token", + "view": { + "type": "object", + "properties": { + "@id": { + "type": "string", + "format": "iri-reference" + }, + "@type": { + "type": "string" + }, + "first": { + "type": [ + "string", + "null" + ], + "format": "iri-reference" + }, + "last": { + "type": [ + "string", + "null" + ], + "format": "iri-reference" + }, + "previous": { + "type": [ + "string", + "null" + ], + "format": "iri-reference" + }, + "next": { + "type": [ + "string", + "null" + ], + "format": "iri-reference" + } + }, + "example": { + "@id": "string", + "@type": "string", + "first": "string", + "last": "string", + "previous": "string", + "next": "string" + } + } + } + } + ] + }, + "HydraCollectionBaseSchemaNoPagination": { + "type": "object", + "properties": { + "totalItems": { + "type": "integer", + "minimum": 0 + }, + "search": { + "type": "object", + "properties": { + "@type": { "type": "string" }, - "canResendIn": { - "description": "seconds until resend", - "type": "integer" + "template": { + "type": "string" + }, + "variableRepresentation": { + "type": "string" + }, + "mapping": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string" + }, + "variable": { + "type": "string" + }, + "property": { + "type": [ + "string", + "null" + ] + }, + "required": { + "type": "boolean" + } + } + } } } } - ], - "description": "Resource EmailOwnershipResource." + } }, - "Equipment.CreateEquipmentInput": { + "HydraItemBaseSchema": { "type": "object", - "description": "Resource CanonicalEquipmentResource.", "properties": { - "clientId": { - "format": "uuid", - "description": "Client-generated UUID for idempotent offline creation", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - }, - "organization": { - "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", - "description": "Organization IRI, required on the canonical endpoint", - "type": [ - "string", - "null" - ] - }, - "intervention": { - "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", - "description": "Optional field intervention IRI", - "type": [ - "string", - "null" - ] - }, - "facility": { - "pattern": "^(/api/facilities/[0-9a-fA-F-]{36})$", - "description": "Optional facility IRI", - "type": [ - "string", - "null" - ] - }, - "onboardingSessionId": { - "format": "uuid", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - }, - "onboardingItemKey": { - "type": [ - "string", - "null" + "@context": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "@vocab": { + "type": "string" + }, + "hydra": { + "type": "string", + "enum": [ + "http://www.w3.org/ns/hydra/core#" + ] + } + }, + "required": [ + "@vocab", + "hydra" + ], + "additionalProperties": true + } ] }, - "type": { - "enum": [ - "fire_extinguisher", - "smoke_detector", - "heat_detector", - "sprinkler", - "fire_alarm_panel", - "hydrant", - "fire_door", - "emergency_lighting", - "access_control", - "camera", - "gas_detector", - "other" - ], - "description": "Equipment type", - "default": "", - "example": "fire_extinguisher", + "@id": { "type": "string" }, - "subType": { - "maxLength": 100, - "description": "Optional free-form sub-type", - "example": "CO2 6kg", - "type": [ - "string", - "null" - ] - }, - "brand": { - "maxLength": 100, - "description": "Optional brand name", - "example": "Sicli", - "type": [ - "string", - "null" - ] - }, - "model": { - "maxLength": 100, - "description": "Optional model name", - "example": "Pro 6", - "type": [ - "string", - "null" - ] - }, - "serialNumber": { - "maxLength": 100, - "description": "Optional serial number (unique per organization)", - "example": "SN-2024-001", - "type": [ - "string", - "null" - ] - }, - "locationLabel": { - "maxLength": 255, - "description": "Optional free-form location label", - "example": "Near staircase B", - "type": [ - "string", - "null" - ] + "@type": { + "type": "string" } }, "required": [ - "type" + "@id", + "@type" ] }, - "Equipment.EquipmentOutput.jsonld": { + "ImportJob.ImportJobOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -50656,282 +64030,166 @@ { "type": "object", "properties": { - "intervention": { - "description": "Property intervention.", - "type": [ - "string", - "null" - ] - }, - "recordStatus": { - "description": "Property recordStatus.", - "default": "published", - "type": "string" - }, - "revision": { - "description": "Property revision.", - "default": 1, - "type": "integer" + "canResume": { + "description": "True only when the current member may resume and no live worker owns the job.", + "default": false, + "type": "boolean" }, "id": { - "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", + "organization": { + "description": "Property organization.", "default": "", "type": "string" }, - "facilityId": { - "readOnly": true, - "description": "Property facilityId.", - "type": [ - "string", - "null" - ] + "kind": { + "description": "Property kind.", + "default": "", + "type": "string" }, - "type": { - "readOnly": true, - "description": "Property type.", + "status": { + "description": "Property status.", "default": "", "type": "string" }, - "subType": { - "readOnly": true, - "description": "Property subType.", - "type": [ - "string", - "null" - ] + "originalFilename": { + "description": "Property originalFilename.", + "default": "", + "type": "string" }, - "brand": { - "readOnly": true, - "description": "Property brand.", - "type": [ - "string", - "null" - ] + "dryRun": { + "description": "Property dryRun.", + "default": false, + "type": "boolean" }, - "model": { - "readOnly": true, - "description": "Property model.", + "totalRows": { + "description": "Property totalRows.", "type": [ - "string", + "integer", "null" ] }, - "serialNumber": { - "readOnly": true, - "description": "Property serialNumber.", - "type": [ - "string", - "null" - ] + "processedRows": { + "description": "Property processedRows.", + "default": 0, + "type": "integer" }, - "locationLabel": { - "readOnly": true, - "description": "Property locationLabel.", - "type": [ - "string", - "null" - ] + "successfulRows": { + "description": "Property successfulRows.", + "default": 0, + "type": "integer" }, - "facilityName": { - "readOnly": true, - "description": "Display name of the assigned facility", + "failedRows": { + "description": "Property failedRows.", + "default": 0, + "type": "integer" + }, + "errorReport": { + "description": "Property errorReport.", + "type": "array", + "items": { + "$ref": "#/components/schemas/ImportRowErrorOutput.jsonld" + } + }, + "reportPage": { + "description": "Current report page, starting at one.", + "default": 1, + "type": "integer" + }, + "reportItemsPerPage": { + "description": "Maximum report rows returned per page.", + "default": 100, + "type": "integer" + }, + "reportTotal": { + "description": "Total confirmed report rows for this job.", + "default": 0, + "type": "integer" + }, + "reportHasNextPage": { + "description": "Whether a subsequent report page exists.", + "default": false, + "type": "boolean" + }, + "jobError": { + "description": "Property jobError.", "type": [ "string", "null" ] }, - "status": { - "readOnly": true, - "description": "Property status.", + "createdAt": { + "description": "Property createdAt.", "default": "", "type": "string" }, - "installedAt": { - "readOnly": true, - "description": "Property installedAt.", + "startedAt": { + "description": "Property startedAt.", "type": [ "string", "null" ] }, - "commissionedAt": { - "readOnly": true, - "description": "Property commissionedAt.", + "completedAt": { + "description": "Property completedAt.", "type": [ "string", "null" ] }, - "tags": { - "readOnly": true, - "description": "Property tags.", - "type": "array", - "items": { - "$ref": "#/components/schemas/TagOutput.jsonld" - } - }, - "maintenanceDueStatus": { - "readOnly": true, - "description": "Property maintenanceDueStatus.", - "default": "unscheduled", - "type": "string" - }, - "planPosition": { - "readOnly": true, - "description": "Position pinned on a floor plan attachment (detail read only)", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string" - } - ] - } - }, - "createdAt": { - "readOnly": true, - "description": "Property createdAt.", - "default": "", - "type": "string" - }, "updatedAt": { - "readOnly": true, "description": "Property updatedAt.", "default": "", "type": "string" + }, + "canConfirm": { + "description": "Property canConfirm", + "default": false, + "type": "boolean" + }, + "confirmedJobId": { + "description": "Property confirmedJobId", + "type": [ + "string", + "null" + ] } } } ], - "description": "Resource CanonicalEquipmentResource." + "description": "Bulk CSV import batches provisioning Equipment or Facility resources." }, - "Equipment.PatchCanonicalEquipmentInput.jsonMergePatch": { + "ImportRowErrorOutput.jsonld": { "type": "object", - "description": "Resource CanonicalEquipmentResource.", "properties": { - "type": { - "maxLength": 32, - "description": "Property type.", - "type": [ - "string", - "null" - ] - }, - "subType": { - "maxLength": 100, - "description": "Property subType.", - "type": [ - "string", - "null" - ] - }, - "brand": { - "maxLength": 100, - "description": "Property brand.", - "type": [ - "string", - "null" - ] - }, - "model": { - "maxLength": 100, - "description": "Property model.", - "type": [ - "string", - "null" - ] - }, - "serialNumber": { - "maxLength": 100, - "description": "Property serialNumber.", - "type": [ - "string", - "null" - ] - }, - "locationLabel": { - "maxLength": 255, - "description": "Property locationLabel.", - "type": [ - "string", - "null" - ] - }, - "status": { - "enum": [ - "in_stock", - "operational", - "decommissioned", - "under_maintenance" - ], - "description": "Property status.", - "type": [ - "string", - "null" - ] + "rowNumber": { + "description": "Property rowNumber.", + "default": 0, + "type": "integer" }, - "facility": { - "description": "Property facility.", + "column": { + "description": "Property column.", "type": [ "string", "null" ] - } - } - }, - "EquipmentAttachment.AddAttachmentInput-equipment.write": { - "type": "object", - "description": "File attachments linked to equipment items.", - "required": [ - "fileName", - "content", - "mimeType" - ], - "properties": { - "fileName": { - "maxLength": 255, - "description": "Original file name", - "default": "", - "example": "maintenance_report.pdf", - "type": "string" }, - "content": { - "description": "Base64-encoded file contents", + "code": { + "description": "Property code.", "default": "", "type": "string" }, - "mimeType": { - "maxLength": 100, - "description": "MIME type", + "message": { + "description": "Property message.", "default": "", - "example": "application/pdf", "type": "string" - }, - "label": { - "maxLength": 255, - "description": "Optional human-readable label", - "example": "Annual maintenance report 2024", - "type": [ - "string", - "null" - ] } } }, - "EquipmentAttachment.AttachmentOutput.jsonld-equipment.read": { + "ImportTemplate.ImportTemplateOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -50940,98 +64198,27 @@ "type": "object", "properties": { "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "equipmentId": { - "readOnly": true, - "description": "Property equipmentId.", - "default": "", + "description": "Method __construct", "type": "string" }, - "fileName": { - "readOnly": true, - "description": "Property fileName.", - "default": "", + "filename": { + "description": "Method __construct", "type": "string" }, - "mimeType": { - "readOnly": true, - "description": "Property mimeType.", - "default": "", + "content": { + "description": "Method __construct", "type": "string" }, - "size": { - "readOnly": true, - "description": "Property size.", - "default": 0, - "type": "integer" - }, - "label": { - "readOnly": true, - "description": "Property label.", - "type": [ - "string", - "null" - ] - }, - "revision": { - "readOnly": true, - "description": "Property revision.", - "default": 1, - "type": "integer" - }, - "uploadedAt": { - "readOnly": true, - "description": "Property uploadedAt.", - "default": "", + "mediaType": { + "description": "Method __construct", "type": "string" } } } ], - "description": "File attachments linked to equipment items." - }, - "EquipmentKpi.EquipmentKpiOutput.jsonld-equipment.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "totalAssets": { - "readOnly": true, - "description": "Property totalAssets.", - "default": 0, - "type": "integer" - }, - "compliant": { - "readOnly": true, - "description": "Property compliant.", - "default": 0, - "type": "integer" - }, - "dueSoon": { - "readOnly": true, - "description": "Property dueSoon.", - "default": 0, - "type": "integer" - }, - "openNonConformities": { - "readOnly": true, - "description": "Property openNonConformities.", - "default": 0, - "type": "integer" - } - } - } - ], - "description": "Aggregate equipment KPI counters for the organization equipment overview page." + "description": "Resource ImportTemplateResource. Organization-scoped server-owned CSV templates." }, - "EquipmentMaintenanceLog.MaintenanceLogOutput.jsonld-equipment.read": { + "Inbox.InboxOutput.jsonld-notification.inbox.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -51039,107 +64226,48 @@ { "type": "object", "properties": { - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "equipmentId": { - "readOnly": true, - "description": "Property equipmentId.", - "default": "", - "type": "string" - }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", - "default": "", - "type": "string" - }, - "startedAt": { - "readOnly": true, - "description": "Property startedAt.", - "default": "", - "type": "string" - }, - "completedAt": { - "readOnly": true, - "description": "Property completedAt.", - "type": [ - "string", - "null" - ] - }, - "source": { - "readOnly": true, - "description": "Property source.", - "default": "status_transition", - "type": "string" - }, - "interventionId": { - "readOnly": true, - "description": "Property interventionId.", - "type": [ - "string", - "null" - ] - }, - "interventionNumber": { + "items": { "readOnly": true, - "description": "Property interventionNumber.", - "type": [ - "integer", - "null" - ] + "description": "Property items.", + "type": "array", + "items": { + "$ref": "#/components/schemas/InboxItemOutput.jsonld-notification.inbox.read" + } }, - "workItemAction": { + "nextCursor": { "readOnly": true, - "description": "Property workItemAction.", + "description": "Property nextCursor.", "type": [ "string", "null" ] }, - "actorId": { + "nextPageCursor": { "readOnly": true, - "description": "Property actorId.", + "description": "Opaque composite cursor echoed unchanged as `cursor`; legacy nextCursor remains a date.", "type": [ "string", "null" ] }, - "summary": { + "complete": { "readOnly": true, - "description": "Property summary.", - "type": [ - "string", - "null" - ] + "description": "False when a source is unavailable; retry this page instead of advancing its cursor.", + "default": true, + "type": "boolean" + }, + "hasMore": { + "readOnly": true, + "description": "Property hasMore.", + "default": false, + "type": "boolean" } } } ], - "description": "Maintenance windows recorded for equipment items." - }, - "EquipmentTag.AddTagInput-equipment.write": { - "type": "object", - "description": "Tags associated with equipment items.", - "required": [ - "name" - ], - "properties": { - "name": { - "minLength": 1, - "maxLength": 100, - "description": "Tag name (case-insensitive, normalized to lowercase)", - "default": "", - "example": "v\u00e9rification annuelle", - "type": "string" - } - } + "description": "Unified feed of things needing the authenticated user's attention, merged from every registered source." }, - "EquipmentTag.TagOutput.jsonld-equipment.read": { + "Inbox.InboxUnreadCountOutput.jsonld-notification.inbox.unread_count": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -51147,136 +64275,105 @@ { "type": "object", "properties": { - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "name": { - "readOnly": true, - "description": "Property name.", - "default": "", - "type": "string" - }, - "organizationId": { + "unreadCount": { "readOnly": true, - "description": "Property organizationId.", - "default": "", - "type": "string" + "description": "Property unreadCount.", + "default": 0, + "type": "integer" } } } ], - "description": "Tags associated with equipment items." + "description": "Unified feed of things needing the authenticated user's attention, merged from every registered source." }, - "Error": { + "InboxItemOutput.jsonld-notification.inbox.read": { "type": "object", - "description": "A representation of common errors.", "properties": { - "title": { + "targetKind": { "readOnly": true, - "description": "A short, human-readable summary of the problem.", + "description": "Optional navigation discriminator supplied by the source, preserving targetType.", "type": [ "string", "null" ] }, - "detail": { + "sourceType": { "readOnly": true, - "description": "A human-readable explanation specific to this occurrence of the problem.", + "description": "Source-owned event type for presentation, when the source supplies one.", "type": [ "string", "null" ] }, - "status": { + "sourceKey": { + "readOnly": true, + "description": "Property sourceKey.", + "default": "", + "type": "string" + }, + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "kind": { + "readOnly": true, + "description": "Property kind.", + "default": "", + "type": "string" + }, + "title": { + "readOnly": true, + "description": "Property title.", + "default": "", + "type": "string" + }, + "snippet": { + "readOnly": true, + "description": "Property snippet.", "type": [ - "integer", + "string", "null" - ], - "examples": [ - 404 - ], - "default": 400 + ] }, - "instance": { + "occurredAt": { "readOnly": true, - "description": "A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.", + "description": "Property occurredAt.", + "default": "", + "type": "string" + }, + "isRead": { + "readOnly": true, + "description": "Property isRead.", + "default": false, + "type": "boolean" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", "type": [ "string", "null" ] }, - "type": { + "targetType": { "readOnly": true, - "description": "A URI reference that identifies the problem type", + "description": "Property targetType.", + "default": "", "type": "string" - } - } - }, - "Error.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" }, - { - "type": "object", - "properties": { - "title": { - "readOnly": true, - "description": "A short, human-readable summary of the problem.", - "type": [ - "string", - "null" - ] - }, - "detail": { - "readOnly": true, - "description": "A human-readable explanation specific to this occurrence of the problem.", - "type": [ - "string", - "null" - ] - }, - "status": { - "type": [ - "integer", - "null" - ], - "examples": [ - 404 - ], - "default": 400 - }, - "instance": { - "readOnly": true, - "description": "A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.", - "type": [ - "string", - "null" - ] - }, - "type": { - "readOnly": true, - "description": "A URI reference that identifies the problem type", - "type": "string" - }, - "description": { - "readOnly": true, - "type": [ - "string", - "null" - ] - } - } + "targetId": { + "readOnly": true, + "description": "Property targetId.", + "default": "", + "type": "string" } - ], - "description": "A representation of common errors." + } }, - "Facility.CreateFacilityInput": { + "Inspection.CreateInspectionInput": { "type": "object", - "description": "Resource CanonicalFacilityResource.", + "description": "Resource CanonicalInspectionResource.", "properties": { "clientId": { "format": "uuid", @@ -51305,145 +64402,313 @@ "null" ] }, - "onboardingSessionId": { + "equipmentId": { "format": "uuid", + "description": "Equipment identifier", "externalDocs": { "url": "https://schema.org/identifier" }, - "type": [ - "string", - "null" - ] + "default": "", + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" }, - "onboardingItemKey": { - "type": [ - "string", - "null" - ] + "result": { + "enum": [ + "pass", + "fail", + "partial" + ], + "description": "Inspection result", + "default": "", + "example": "pass", + "type": "string" }, - "type": { + "performedAt": { + "description": "Date the inspection was performed (ISO 8601)", + "default": "", + "example": "2024-06-15T10:00:00+02:00", + "type": "string" + }, + "inspectorType": { "enum": [ - "site", - "building", - "floor", - "zone", - "area" + "user", + "external" ], - "description": "Facility type", + "description": "Inspector type (user or external)", "default": "", - "example": "site", + "example": "user", "type": "string" }, - "name": { - "minLength": 2, - "maxLength": 120, - "description": "Facility display name", + "inspectorName": { + "maxLength": 255, + "description": "Inspector display name", "default": "", - "example": "Headquarters", + "example": "Jean Dupont", "type": "string" }, - "parentFacilityId": { + "facilityId": { "format": "uuid", - "description": "Optional parent facility identifier", + "description": "Optional facility identifier", "externalDocs": { "url": "https://schema.org/identifier" }, - "example": "550e8400-e29b-41d4-a716-446655440001", "type": [ "string", "null" ] }, - "code": { - "maxLength": 80, - "description": "Optional facility code", - "example": "SITE-PAR-001", + "checklistId": { + "format": "uuid", + "description": "Optional checklist identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "address": { + "inspectorUserId": { + "format": "uuid", + "description": "Optional user identifier (required for user type)", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "inspectorOrganizationName": { "maxLength": 255, - "description": "Optional address", - "example": "10 rue de Rivoli, Paris", + "description": "Optional external organization name", "type": [ "string", "null" ] }, - "latitude": { - "minimum": -90, - "maximum": 90, - "description": "Optional latitude, required together with longitude", - "example": 48.8566, + "notes": { + "maxLength": 5000, + "description": "Optional free-form notes", "type": [ - "number", + "string", "null" ] }, - "longitude": { - "minimum": -180, - "maximum": 180, - "description": "Optional longitude, required together with latitude", - "example": 2.3522, + "signature": { + "description": "Optional signature data", "type": [ - "number", + "string", "null" ] + } + }, + "required": [ + "equipmentId", + "result", + "performedAt", + "inspectorType", + "inspectorName" + ] + }, + "Inspection.InspectionOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" }, - "metadata": { - "description": "Optional free-form metadata", - "example": { - "country": "FR", - "timezone": "Europe/Paris" - }, + { "type": "object", - "additionalProperties": { - "type": [ - "string", - "null" - ] + "properties": { + "intervention": { + "description": "Property intervention.", + "type": [ + "string", + "null" + ] + }, + "recordStatus": { + "description": "Property recordStatus.", + "default": "published", + "type": "string" + }, + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" + }, + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "equipmentId": { + "readOnly": true, + "description": "Property equipmentId.", + "default": "", + "type": "string" + }, + "facilityId": { + "readOnly": true, + "description": "Property facilityId.", + "type": [ + "string", + "null" + ] + }, + "equipmentSerialNumber": { + "readOnly": true, + "description": "Serial number of the inspected equipment", + "type": [ + "string", + "null" + ] + }, + "facilityName": { + "readOnly": true, + "description": "Display name of the inspected facility", + "type": [ + "string", + "null" + ] + }, + "checklistName": { + "readOnly": true, + "description": "Display name of the followed checklist", + "type": [ + "string", + "null" + ] + }, + "result": { + "readOnly": true, + "description": "Property result.", + "default": "", + "type": "string" + }, + "status": { + "readOnly": true, + "description": "Property status.", + "default": "", + "type": "string" + }, + "performedAt": { + "readOnly": true, + "description": "Property performedAt.", + "default": "", + "type": "string" + }, + "inspector": { + "readOnly": true, + "description": "Property inspector.", + "anyOf": [ + { + "$ref": "#/components/schemas/InspectorOutput.jsonld" + }, + { + "type": "null" + } + ] + }, + "checklistId": { + "readOnly": true, + "description": "Property checklistId.", + "type": [ + "string", + "null" + ] + }, + "notes": { + "readOnly": true, + "description": "Property notes.", + "type": [ + "string", + "null" + ] + }, + "signature": { + "readOnly": true, + "description": "Property signature.", + "type": [ + "string", + "null" + ] + }, + "nonConformitiesCount": { + "readOnly": true, + "description": "Property nonConformitiesCount.", + "default": 0, + "type": "integer" + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt.", + "default": "", + "type": "string" + } } + } + ], + "description": "Resource CanonicalInspectionResource." + }, + "Inspection.PatchCanonicalInspectionInput.jsonMergePatch": { + "type": "object", + "description": "Resource CanonicalInspectionResource.", + "properties": { + "result": { + "enum": [ + "pass", + "fail", + "partial" + ], + "description": "Property result.", + "type": [ + "string", + "null" + ] }, - "levelIndex": { - "minimum": -100, - "maximum": 200, - "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", - "example": 0, + "status": { + "enum": [ + "draft", + "submitted", + "closed" + ], + "description": "Property status.", "type": [ - "integer", + "string", "null" ] }, - "elevationMeters": { - "minimum": -10000, - "maximum": 10000, - "description": "Optional floor elevation in meters", - "example": -3, + "notes": { + "maxLength": 5000, + "description": "Property notes.", "type": [ - "number", + "string", "null" ] }, - "heightMeters": { - "minimum": 0, - "exclusiveMinimum": 0, - "maximum": 1000, - "description": "Optional floor height in meters", - "example": 3, + "signature": { + "description": "Property signature.", "type": [ - "number", + "string", "null" ] } - }, - "required": [ - "type", - "name" - ] + } }, - "Facility.FacilityOutput.jsonld": { + "InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -51451,359 +64716,409 @@ { "type": "object", "properties": { - "intervention": { - "description": "Property intervention.", - "type": [ - "string", - "null" - ] - }, - "recordStatus": { - "description": "Property recordStatus.", - "default": "published", - "type": "string" - }, - "revision": { - "description": "Property revision.", - "default": 1, - "type": "integer" - }, "id": { "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organizationId": { + "inspectionId": { "readOnly": true, - "description": "Property organizationId.", + "description": "Property inspectionId.", "default": "", "type": "string" }, - "parentFacilityId": { + "nonConformityId": { "readOnly": true, - "description": "Property parentFacilityId.", + "description": "Property nonConformityId.", "type": [ "string", "null" ] }, - "hasChildren": { - "readOnly": true, - "description": "Property hasChildren.", - "default": false, - "type": "boolean" - }, - "equipmentCount": { - "readOnly": true, - "description": "Active equipment assigned to this facility", - "default": 0, - "type": "integer" - }, - "type": { + "fileName": { "readOnly": true, - "description": "Property type.", + "description": "Property fileName.", "default": "", "type": "string" }, - "name": { + "mimeType": { "readOnly": true, - "description": "Property name.", + "description": "Property mimeType.", "default": "", "type": "string" }, - "code": { - "readOnly": true, - "description": "Property code.", - "type": [ - "string", - "null" - ] - }, - "status": { + "size": { "readOnly": true, - "description": "Property status.", - "default": "", - "type": "string" + "description": "Property size.", + "default": 0, + "type": "integer" }, - "address": { + "label": { "readOnly": true, - "description": "Property address.", + "description": "Property label.", "type": [ "string", "null" ] }, - "latitude": { + "revision": { "readOnly": true, - "description": "Property latitude.", - "type": [ - "number", - "null" - ] + "description": "Property revision.", + "default": 1, + "type": "integer" }, - "longitude": { + "uploadedAt": { "readOnly": true, - "description": "Property longitude.", - "type": [ - "number", - "null" - ] + "description": "Property uploadedAt.", + "default": "", + "type": "string" + } + } + } + ], + "description": "File attachments linked to an inspection or a non-conformity." + }, + "InspectionResponse.CreateInspectionResponseInput": { + "type": "object", + "description": "Resource InspectionResponseResource.", + "required": [ + "organization", + "inspection", + "itemKey" + ], + "properties": { + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" + }, + "intervention": { + "description": "Property intervention.", + "type": [ + "string", + "null" + ] + }, + "inspection": { + "description": "Property inspection.", + "default": "", + "type": "string" + }, + "clientId": { + "format": "uuid", + "description": "Property clientId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "itemKey": { + "maxLength": 160, + "description": "Property itemKey.", + "default": "", + "type": "string" + }, + "value": { + "description": "Property value.", + "type": [ + "string", + "null" + ] + } + } + }, + "InspectionResponse.InspectionResponseOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "description": "Property id.", + "type": "string" }, - "metadata": { - "readOnly": true, - "description": "Property metadata.", - "type": "object", - "additionalProperties": { - "type": [ - "string", - "null" - ] - } + "organization": { + "description": "Property organization.", + "type": "string" }, - "levelIndex": { - "readOnly": true, - "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", + "intervention": { + "description": "Property intervention.", "type": [ - "integer", + "string", "null" ] }, - "elevationMeters": { - "readOnly": true, - "description": "Property elevationMeters", - "type": [ - "number", - "null" - ] + "inspection": { + "description": "Property inspection.", + "type": "string" }, - "heightMeters": { - "readOnly": true, - "description": "Property heightMeters", - "type": [ - "number", - "null" - ] + "recordStatus": { + "description": "Property recordStatus.", + "type": "string" }, - "planGeometry": { - "readOnly": true, - "description": "Optional spatial geometry bound to an ancestor floor plan (detail views only)", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "number" - } - } - }, - { - "type": "string" - } - ] - } + "revision": { + "description": "Property revision.", + "type": "integer" }, - "geometryIssue": { - "readOnly": true, - "description": "Property geometryIssue.", + "itemKey": { + "description": "Property itemKey.", + "type": "string" + }, + "value": { + "description": "Property value.", "type": [ "string", "null" - ], - "enum": [ - "invalid_geometry", - "plan_unavailable", - "outside_ancestry", - "other_plan", - null ] }, - "hierarchyIssues": { - "readOnly": true, - "description": "Property hierarchyIssues.", - "type": "array", - "items": { - "type": "string" - } - }, "createdAt": { - "readOnly": true, "description": "Property createdAt.", - "default": "", "type": "string" }, "updatedAt": { - "readOnly": true, "description": "Property updatedAt.", - "default": "", "type": "string" - }, - "path": { - "readOnly": true, - "description": "Ancestor breadcrumb, root first, excluding this facility; opt-in on collections through includePath.", - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name", - "type" - ], - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ] - } - } - } } } } ], - "description": "Resource CanonicalFacilityResource." + "description": "Resource InspectionResponseResource." }, - "Facility.PatchCanonicalFacilityInput.jsonMergePatch": { + "InspectionResponse.PatchInspectionResponseInput.jsonMergePatch": { + "type": "object", + "description": "Resource InspectionResponseResource.", + "properties": { + "value": { + "description": "Property value.", + "type": [ + "string", + "null" + ] + } + } + }, + "InspectorOutput.jsonld": { "type": "object", - "description": "Resource CanonicalFacilityResource.", "properties": { "type": { - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ], - "description": "Property type.", + "readOnly": true, + "description": "Property type", + "default": "", + "type": "string" + }, + "id": { + "readOnly": true, + "description": "Property id", "type": [ "string", "null" ] }, - "name": { - "minLength": 2, - "maxLength": 120, - "description": "Property name.", + "firstName": { + "readOnly": true, + "description": "Property firstName", "type": [ "string", "null" ] }, - "code": { - "maxLength": 80, - "description": "Property code.", + "lastName": { + "readOnly": true, + "description": "Property lastName", "type": [ "string", "null" ] }, - "address": { - "maxLength": 255, - "description": "Property address.", + "displayName": { + "readOnly": true, + "description": "Property displayName", + "default": "", + "type": "string" + }, + "avatarUrl": { + "readOnly": true, + "description": "Property avatarUrl", "type": [ "string", "null" ] }, - "latitude": { - "minimum": -90, - "maximum": 90, - "description": "Property latitude.", + "organizationName": { + "readOnly": true, + "description": "Property organizationName", "type": [ - "number", + "string", + "null" + ] + } + } + }, + "InspectorOutput.jsonld-inspection.read": { + "type": "object", + "properties": { + "type": { + "readOnly": true, + "description": "Property type", + "default": "", + "type": "string" + }, + "id": { + "readOnly": true, + "description": "Property id", + "type": [ + "string", "null" ] }, - "longitude": { - "minimum": -180, - "maximum": 180, - "description": "Property longitude.", + "firstName": { + "readOnly": true, + "description": "Property firstName", "type": [ - "number", + "string", "null" ] }, - "metadata": { - "description": "Property metadata.", + "lastName": { + "readOnly": true, + "description": "Property lastName", "type": [ - "object", + "string", "null" - ], - "additionalProperties": { - "type": [ - "string", - "null" - ] - } + ] }, - "status": { + "displayName": { + "readOnly": true, + "description": "Property displayName", + "default": "", + "type": "string" + }, + "avatarUrl": { + "readOnly": true, + "description": "Property avatarUrl", + "type": [ + "string", + "null" + ] + }, + "organizationName": { + "readOnly": true, + "description": "Property organizationName", + "type": [ + "string", + "null" + ] + } + } + }, + "Intervention.CreateInterventionInput": { + "type": "object", + "description": "Field intervention coordinating draft operational resources.", + "required": [ + "organization", + "name" + ], + "properties": { + "organization": { + "description": "Property organization.", + "default": "", + "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "type": { "enum": [ - "active", - "archived" + "site_setup", + "inventory", + "inspection_campaign", + "preventive_maintenance", + "corrective_maintenance" ], - "description": "Property status.", + "description": "Property type.", + "default": "site_setup", + "type": "string" + }, + "name": { + "maxLength": 160, + "description": "Property name.", + "default": "", + "type": "string" + }, + "description": { + "maxLength": 5000, + "description": "Property description.", + "type": [ + "string", + "null" + ] + }, + "site": { + "description": "Property site.", "type": [ "string", "null" ] }, - "parent": { - "description": "Property parent.", + "responsible": { + "description": "Property responsible.", + "type": [ + "string", + "null" + ] + }, + "participants": { + "description": "Property participants.", + "type": "array", + "items": { + "type": "string" + } + }, + "priority": { + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "description": "Property priority.", + "default": "normal", + "type": "string" + }, + "plannedStartAt": { + "description": "Property plannedStartAt.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, "type": [ "string", "null" ] }, - "levelIndex": { - "minimum": -100, - "maximum": 200, - "description": "Property levelIndex.", - "type": [ - "integer", - "null" - ] - }, - "elevationMeters": { - "minimum": -10000, - "maximum": 10000, - "description": "Property elevationMeters", + "dueAt": { + "description": "Property dueAt.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, "type": [ - "number", + "string", "null" ] }, - "heightMeters": { - "minimum": 0, - "exclusiveMinimum": 0, - "maximum": 1000, - "description": "Property heightMeters", - "type": [ - "number", - "null" - ] + "labelIds": { + "description": "Property labelIds.", + "type": "array", + "items": { + "type": "string" + } } } }, - "FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read": { + "Intervention.InterventionOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -51811,403 +65126,264 @@ { "type": "object", "properties": { + "closureSnapshot": { + "description": "Property closureSnapshot. Versioned dossier frozen by atomic publication.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, "id": { - "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "facilityId": { - "readOnly": true, - "description": "Property facilityId.", + "organization": { + "description": "Property organization.", "default": "", "type": "string" }, - "fileName": { - "readOnly": true, - "description": "Property fileName.", - "default": "", + "number": { + "description": "Property number.", + "default": 0, + "type": "integer" + }, + "type": { + "description": "Property type.", + "default": "site_setup", "type": "string" }, - "mimeType": { - "readOnly": true, - "description": "Property mimeType.", + "name": { + "description": "Property name.", "default": "", "type": "string" }, - "size": { - "readOnly": true, - "description": "Property size.", - "default": 0, - "type": "integer" - }, - "label": { - "readOnly": true, - "description": "Property label.", + "description": { + "description": "Property description.", "type": [ "string", "null" ] }, - "revision": { - "readOnly": true, - "description": "Property revision.", - "default": 1, - "type": "integer" - }, - "kind": { - "readOnly": true, - "description": "Property kind.", - "default": "document", + "status": { + "description": "Property status.", + "default": "draft", "type": "string" }, - "isPrimaryPlan": { - "readOnly": true, - "description": "Property isPrimaryPlan.", - "default": false, - "type": "boolean" - }, - "imageWidth": { - "readOnly": true, - "description": "Property imageWidth.", + "site": { + "description": "Property site.", "type": [ - "integer", + "string", "null" ] }, - "imageHeight": { - "readOnly": true, - "description": "Property imageHeight.", + "responsible": { + "description": "Property responsible.", "type": [ - "integer", + "string", "null" ] }, - "uploadedAt": { - "readOnly": true, - "description": "Property uploadedAt.", - "default": "", + "participants": { + "description": "Property participants.", + "type": "array", + "items": { + "type": "string" + } + }, + "priority": { + "description": "Property priority.", + "default": "normal", "type": "string" }, - "calibration": { - "readOnly": true, - "description": "Metric calibration of the original plan image.", + "plannedStartAt": { + "description": "Property plannedStartAt.", "type": [ - "object", + "string", "null" - ], - "required": [ - "widthMeters", - "rotationDegrees", - "offsetXMeters", - "offsetZMeters" - ], - "properties": { - "widthMeters": { - "type": "number", - "exclusiveMinimum": 0, - "maximum": 100000 - }, - "rotationDegrees": { - "type": "number", - "minimum": -360, - "maximum": 360 - }, - "offsetXMeters": { - "type": "number", - "minimum": -100000, - "maximum": 100000 - }, - "offsetZMeters": { - "type": "number", - "minimum": -100000, - "maximum": 100000 - } - }, - "additionalProperties": false + ] }, - "calibrationBuildingId": { - "readOnly": true, - "description": "Property calibrationBuildingId", + "dueAt": { + "description": "Property dueAt.", "type": [ "string", "null" - ], - "format": "uuid" + ] }, - "calibrationIssue": { - "readOnly": true, - "description": "Property calibrationIssue", + "reviewNote": { + "description": "Property reviewNote.", "type": [ "string", "null" - ], - "enum": [ - "building_changed", - "unverified_frame", - null ] - } - } - } - ], - "description": "File attachments linked to a facility." - }, - "FacilityEquipmentSummary.FacilityEquipmentSummaryOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "scope": { - "readOnly": true, - "description": "Property scope.", - "default": "subtree", - "type": "string", - "enum": [ - "subtree", - "direct" + }, + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" + }, + "recurrence": { + "description": "Property recurrence.", + "type": [ + "string", + "null" ] }, - "totalItems": { - "readOnly": true, - "description": "Property totalItems.", - "default": 0, - "type": "integer", - "minimum": 0 + "allowedTransitions": { + "description": "Property allowedTransitions.", + "type": "array", + "items": { + "type": "string" + } }, - "byStatus": { - "readOnly": true, - "description": "Property byStatus.", - "default": { - "in_stock": 0, - "operational": 0, - "under_maintenance": 0, - "decommissioned": 0 - }, - "type": "object", - "required": [ - "in_stock", - "operational", - "under_maintenance", - "decommissioned" - ], - "properties": { - "in_stock": { - "type": "integer", - "minimum": 0 - }, - "operational": { - "type": "integer", - "minimum": 0 - }, - "under_maintenance": { - "type": "integer", - "minimum": 0 + "allowedActions": { + "description": "Property allowedActions.", + "anyOf": [ + { + "$ref": "#/components/schemas/InterventionAllowedActionsOutput.jsonld" }, - "decommissioned": { - "type": "integer", - "minimum": 0 + { + "type": "null" } - } + ] }, - "needingAttentionCount": { - "readOnly": true, - "description": "Property needingAttentionCount.", + "facilitiesCount": { + "description": "Property facilitiesCount.", "default": 0, - "type": "integer", - "minimum": 0 + "type": "integer" + }, + "equipmentCount": { + "description": "Property equipmentCount.", + "default": 0, + "type": "integer" + }, + "inspectionsCount": { + "description": "Property inspectionsCount.", + "default": 0, + "type": "integer" + }, + "blockersCount": { + "description": "Property blockersCount.", + "default": 0, + "type": "integer" + }, + "workItemsCount": { + "description": "Property workItemsCount.", + "default": 0, + "type": "integer" + }, + "completedWorkItemsCount": { + "description": "Property completedWorkItemsCount.", + "default": 0, + "type": "integer" + }, + "proposedChangesCount": { + "description": "Property proposedChangesCount.", + "default": 0, + "type": "integer" + }, + "commentsCount": { + "description": "Property commentsCount.", + "default": 0, + "type": "integer" + }, + "hasSignature": { + "description": "Property hasSignature.", + "default": false, + "type": "boolean" + }, + "labels": { + "description": "Property labels.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "createdAt": { + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "description": "Property updatedAt.", + "default": "", + "type": "string" } } } ], - "description": "Resource FacilityEquipmentSummaryResource." + "description": "Field intervention coordinating draft operational resources." }, - "FacilityMetadataField.CreateFacilityMetadataFieldInput-facility.write": { + "Intervention.UpdateInterventionInput.jsonMergePatch": { "type": "object", - "description": "Organization-defined typed schema for facility metadata.", - "required": [ - "key", - "label", - "fieldType" - ], + "description": "Field intervention coordinating draft operational resources.", "properties": { - "key": { - "minLength": 2, + "workloadConfirmationToken": { "maxLength": 64, - "description": "Machine key, kebab-case or snake_case", - "default": "", - "example": "surface-m2", - "type": "string" - }, - "label": { - "minLength": 2, - "maxLength": 80, - "description": "Human-readable label", - "default": "", - "example": "Surface (m\u00b2)", - "type": "string" - }, - "fieldType": { - "enum": [ - "text", - "number", - "date", - "boolean", - "select" - ], - "description": "Field type", - "default": "", - "example": "number", - "type": "string" - }, - "required": { - "description": "Whether the field is required on facility creation", - "default": false, - "type": "boolean" - }, - "options": { - "description": "Select options, required only when fieldType is \"select\"", - "example": [ - "ERP", - "IGH", - "Habitation" - ], - "type": "array", - "items": { - "type": "string" - } + "description": "Property workloadConfirmationToken.", + "type": [ + "string", + "null" + ] }, - "facilityType": { - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ], - "description": "Optional facility type scope; omitted or null applies to every type", - "example": "building", + "name": { + "minLength": 1, + "maxLength": 160, + "description": "Property name.", "type": [ "string", "null" ] }, - "unit": { - "maxLength": 16, - "description": "Optional unit label", - "example": "m\u00b2", + "description": { + "maxLength": 5000, + "description": "Property description.", "type": [ "string", "null" ] - } - } - }, - "FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" }, - { - "type": "object", - "properties": { - "id": { - "readOnly": true, - "description": "Property id", - "default": "", - "type": "string" - }, - "key": { - "readOnly": true, - "description": "Property key", - "default": "", - "type": "string" - }, - "label": { - "readOnly": true, - "description": "Property label", - "default": "", - "type": "string" - }, - "fieldType": { - "readOnly": true, - "description": "Property fieldType", - "default": "", - "type": "string" - }, - "options": { - "readOnly": true, - "type": "array", - "items": { - "type": "string" - } - }, - "facilityType": { - "readOnly": true, - "description": "Property facilityType", - "type": [ - "string", - "null" - ] - }, - "required": { - "readOnly": true, - "description": "Property required", - "default": false, - "type": "boolean" - }, - "unit": { - "readOnly": true, - "description": "Property unit", - "type": [ - "string", - "null" - ] - } - } - } - ], - "description": "Organization-defined typed schema for facility metadata." - }, - "FacilityMetadataField.UpdateFacilityMetadataFieldInput-facility.write.jsonMergePatch": { - "type": "object", - "description": "Organization-defined typed schema for facility metadata.", - "properties": { - "label": { - "minLength": 2, - "maxLength": 80, - "description": "Human-readable label (partial update)", + "status": { + "enum": [ + "draft", + "planned", + "in_progress", + "submitted", + "changes_requested", + "published", + "abandoned" + ], + "description": "Property status.", "type": [ "string", "null" ] }, - "fieldType": { - "enum": [ - "text", - "number", - "date", - "boolean", - "select" - ], - "description": "Field type (partial update)", + "site": { + "description": "Property site.", "type": [ "string", "null" ] }, - "required": { - "description": "Whether the field is required on facility creation (partial update)", + "responsible": { + "description": "Property responsible.", "type": [ - "boolean", + "string", "null" ] }, - "options": { - "description": "Select options (partial update)", + "participants": { + "description": "Property participants.", "type": [ "array", "null" @@ -52216,23 +65392,75 @@ "type": "string" } }, - "facilityType": { + "priority": { "enum": [ - "site", - "building", - "floor", - "zone", - "area" + "low", + "normal", + "high", + "urgent" ], - "description": "Optional facility type scope (partial update); null applies to every type", + "description": "Property priority.", "type": [ "string", "null" ] }, - "unit": { - "maxLength": 16, - "description": "Optional unit label (partial update)", + "plannedStartAt": { + "description": "Property plannedStartAt.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "type": [ + "string", + "null" + ] + }, + "dueAt": { + "description": "Property dueAt.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "type": [ + "string", + "null" + ] + }, + "reviewNote": { + "maxLength": 4000, + "description": "Property reviewNote.", + "type": [ + "string", + "null" + ] + }, + "labelIds": { + "description": "Property labelIds.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + } + }, + "InterventionActivity.CreateInterventionCommentInput": { + "type": "object", + "description": "Resource InterventionActivityResource.\n\nThe intervention activity feed: member-authored comments and\nsystem-recorded lifecycle events (creation, status transitions).", + "required": [ + "body" + ], + "properties": { + "body": { + "maxLength": 20000, + "description": "Property body.", + "default": "", + "type": "string" + }, + "clientId": { + "maxLength": 64, + "description": "Property clientId.", "type": [ "string", "null" @@ -52240,7 +65468,7 @@ } } }, - "FacilityModel.FacilityModelOutput.jsonld-facility.read": { + "InterventionActivity.InterventionActivityOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -52249,134 +65477,177 @@ "type": "object", "properties": { "id": { - "description": "Method __construct.", - "type": "string" - }, - "organizationId": { - "description": "Method __construct.", + "description": "Property id.", + "default": "", "type": "string" }, - "buildingId": { - "description": "Method __construct.", + "intervention": { + "description": "Property intervention.", + "default": "", "type": "string" }, - "fileName": { - "description": "Method __construct.", + "kind": { + "description": "Property kind.", + "default": "system", "type": "string" }, - "mimeType": { - "description": "Method __construct.", + "event": { + "description": "Property event.", + "default": "created", "type": "string" }, - "fileSize": { - "description": "Method __construct.", - "type": "integer" - }, - "nodeCount": { - "description": "Method __construct.", - "type": "integer" - }, - "nodes": { - "description": "Method __construct.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - } - } - }, - "revision": { - "description": "Method __construct.", - "type": "integer" + "actor": { + "description": "Property actor.", + "type": [ + "string", + "null" + ] }, - "active": { - "description": "Method __construct.", - "type": "boolean" + "body": { + "description": "Property body.", + "type": [ + "string", + "null" + ] }, - "transform": { - "description": "Method __construct.", - "type": "object", + "payload": { + "description": "Property payload.", + "type": [ + "object", + "null" + ], "additionalProperties": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "type": "number" - } - }, - { - "type": "number" - } + "type": [ + "string", + "null" ] } }, - "bindings": { - "description": "Method __construct.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - } - } - }, - "downloadUrl": { - "description": "Method __construct.", - "type": "string" - }, "createdAt": { - "description": "Method __construct.", - "type": "string" - }, - "updatedAt": { - "description": "Method __construct.", + "description": "Property createdAt.", + "default": "", "type": "string" - }, - "bindingIssues": { - "description": "Method __construct.", - "type": "array", - "items": { - "type": "object", - "required": [ - "nodeIndex", - "code" - ], - "additionalProperties": false, - "properties": { - "nodeIndex": { - "type": "integer", - "minimum": 0 - }, - "code": { - "type": "string", - "enum": [ - "target_unavailable" - ] - } - } - } } } } ], - "description": "Immutable self-contained GLB 2.0 building models; maximum 10 MiB and two models per building." + "description": "Resource InterventionActivityResource.\n\nThe intervention activity feed: member-authored comments and\nsystem-recorded lifecycle events (creation, status transitions)." }, - "FacilityPlanCalibration.FacilityAttachmentOutput.jsonld-facility.read": { + "InterventionAllowedActionsOutput.jsonld": { + "type": "object", + "properties": { + "canEditDetails": { + "description": "Property canEditDetails.", + "default": false, + "type": "boolean" + }, + "canEditSite": { + "description": "Property canEditSite.", + "default": false, + "type": "boolean" + }, + "canEditResponsible": { + "description": "Property canEditResponsible.", + "default": false, + "type": "boolean" + }, + "canEditPlanning": { + "description": "Property canEditPlanning.", + "default": false, + "type": "boolean" + }, + "canMutateWorkItems": { + "description": "Property canMutateWorkItems.", + "default": false, + "type": "boolean" + }, + "canMutateChanges": { + "description": "Property canMutateChanges.", + "default": false, + "type": "boolean" + }, + "canAssignTeam": { + "description": "Property canAssignTeam.", + "default": false, + "type": "boolean" + }, + "canManageAttachments": { + "description": "Property canManageAttachments.", + "default": false, + "type": "boolean" + }, + "canSubmit": { + "description": "Property canSubmit.", + "default": false, + "type": "boolean" + }, + "canWithdraw": { + "description": "Property canWithdraw.", + "default": false, + "type": "boolean" + }, + "canDelete": { + "description": "Property canDelete.", + "default": false, + "type": "boolean" + }, + "canPublish": { + "description": "Property canPublish.", + "default": false, + "type": "boolean" + } + } + }, + "InterventionAssigneeOutput.jsonld": { + "type": "object", + "properties": { + "member": { + "readOnly": true, + "description": "Property member.", + "default": "", + "type": "string" + }, + "userId": { + "readOnly": true, + "description": "Property userId.", + "type": [ + "string", + "null" + ] + }, + "firstName": { + "readOnly": true, + "description": "Property firstName.", + "type": [ + "string", + "null" + ] + }, + "lastName": { + "readOnly": true, + "description": "Property lastName.", + "type": [ + "string", + "null" + ] + }, + "displayName": { + "readOnly": true, + "description": "Property displayName.", + "default": "", + "type": "string" + }, + "avatarUrl": { + "readOnly": true, + "description": "Property avatarUrl.", + "type": [ + "string", + "null" + ] + } + } + }, + "InterventionAttachment.InterventionAttachmentOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -52390,9 +65661,9 @@ "default": "", "type": "string" }, - "facilityId": { + "interventionId": { "readOnly": true, - "description": "Property facilityId.", + "description": "Property interventionId.", "default": "", "type": "string" }, @@ -52422,156 +65693,76 @@ "null" ] }, - "revision": { + "workItemId": { "readOnly": true, - "description": "Property revision.", - "default": 1, - "type": "integer" + "description": "Property workItemId.", + "type": [ + "string", + "null" + ] }, "kind": { "readOnly": true, "description": "Property kind.", - "default": "document", + "default": "file", "type": "string" }, - "isPrimaryPlan": { - "readOnly": true, - "description": "Property isPrimaryPlan.", - "default": false, - "type": "boolean" - }, - "imageWidth": { - "readOnly": true, - "description": "Property imageWidth.", - "type": [ - "integer", - "null" - ] - }, - "imageHeight": { + "revision": { "readOnly": true, - "description": "Property imageHeight.", - "type": [ - "integer", - "null" - ] + "description": "Property revision.", + "default": 1, + "type": "integer" }, "uploadedAt": { "readOnly": true, "description": "Property uploadedAt.", "default": "", "type": "string" - }, - "calibration": { - "readOnly": true, - "description": "Metric calibration of the original plan image.", - "type": [ - "object", - "null" - ], - "required": [ - "widthMeters", - "rotationDegrees", - "offsetXMeters", - "offsetZMeters" - ], - "properties": { - "widthMeters": { - "type": "number", - "exclusiveMinimum": 0, - "maximum": 100000 - }, - "rotationDegrees": { - "type": "number", - "minimum": -360, - "maximum": 360 - }, - "offsetXMeters": { - "type": "number", - "minimum": -100000, - "maximum": 100000 - }, - "offsetZMeters": { - "type": "number", - "minimum": -100000, - "maximum": 100000 - } - }, - "additionalProperties": false - }, - "calibrationBuildingId": { - "readOnly": true, - "description": "Property calibrationBuildingId", - "type": [ - "string", - "null" - ], - "format": "uuid" - }, - "calibrationIssue": { - "readOnly": true, - "description": "Property calibrationIssue", - "type": [ - "string", - "null" - ], - "enum": [ - "building_changed", - "unverified_frame", - null - ] } } } ], - "description": "Resource FacilityAttachmentCalibrationResource." + "description": "Resource InterventionAttachmentResource.\n\nGeneralized multipart file attachments on an intervention. Write access\nis phase-based (see `InterventionMediaProcessor`)." }, - "FacilityPlanCalibration.SetFacilityAttachmentCalibrationInput-facility.write": { + "InterventionChange.CreateInterventionChangeInput": { "type": "object", - "description": "Resource FacilityAttachmentCalibrationResource.", + "description": "Resource InterventionChangeResource.", "required": [ - "calibration" + "intervention", + "resource", + "patch" ], "properties": { - "calibration": { - "description": "Calibration in metres, or null to remove the metric calibration.", + "intervention": { + "description": "Property intervention.", + "default": "", + "type": "string" + }, + "workItem": { + "description": "Property workItem.", "type": [ - "object", + "string", "null" - ], - "required": [ - "widthMeters", - "rotationDegrees", - "offsetXMeters", - "offsetZMeters" - ], - "properties": { - "widthMeters": { - "type": "number", - "exclusiveMinimum": 0, - "maximum": 100000 - }, - "rotationDegrees": { - "type": "number", - "minimum": -360, - "maximum": 360 - }, - "offsetXMeters": { - "type": "number", - "minimum": -100000, - "maximum": 100000 - }, - "offsetZMeters": { - "type": "number", - "minimum": -100000, - "maximum": 100000 - } - }, - "additionalProperties": true + ] + }, + "resource": { + "description": "Property resource.", + "default": "", + "type": "string" + }, + "patch": { + "description": "Property patch.", + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } } } }, - "FacilityTree.FacilityTreeOutput.jsonld": { + "InterventionChange.InterventionChangeOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -52579,87 +65770,94 @@ { "type": "object", "properties": { - "organizationId": { - "description": "Property organizationId.", + "id": { + "description": "Property id.", "default": "", "type": "string" }, - "generatedAt": { - "description": "Property generatedAt.", + "intervention": { + "description": "Property intervention.", "default": "", "type": "string" }, - "nodes": { - "description": "Property nodes.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "array", - "items": { - "type": [ - "string", - "null" - ] - } - }, - { - "type": "number" - }, - { - "type": "integer" - }, - { - "type": "string" - }, - { - "type": "null" - } - ] - } + "workItem": { + "description": "Property workItem.", + "type": [ + "string", + "null" + ] + }, + "resource": { + "description": "Property resource.", + "default": "", + "type": "string" + }, + "patch": { + "description": "Property patch.", + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] } + }, + "status": { + "description": "Property status.", + "default": "proposed", + "type": "string" + }, + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" + }, + "createdAt": { + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "description": "Property updatedAt.", + "default": "", + "type": "string" } } } ], - "description": "Enriched facility hierarchy (Site/Building/Floor/Zone) with per-node equipment count and compliance verdict/rate, batched by facility-id list. Requires organization.compliance.read." + "description": "Resource InterventionChangeResource." }, - "FederatedAuth.FederatedCompleteInput": { + "InterventionChange.UpdateInterventionChangeInput.jsonMergePatch": { "type": "object", - "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API.", + "description": "Resource InterventionChangeResource.", "properties": { - "code": { - "maxLength": 4096, - "description": "Property code", - "type": [ - "string", - "null" - ] - }, - "error": { - "maxLength": 128, - "description": "Property error", + "patch": { + "description": "Property patch.", "type": [ - "string", + "object", "null" - ] + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] + } }, - "state": { - "maxLength": 256, - "description": "Property state", + "status": { + "enum": [ + "proposed", + "rejected" + ], + "description": "Property status.", "type": [ "string", "null" ] } - }, - "required": [ - "state" - ] + } }, - "FederatedAuth.FederatedConnectionsOutput.jsonld": { + "InterventionIssue.InterventionIssueOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -52667,29 +65865,64 @@ { "type": "object", "properties": { - "password_configured": { - "description": "Property passwordConfigured.", - "type": "boolean" + "severity": { + "description": "Property severity.", + "default": "", + "type": "string" }, - "last_sign_in_method": { - "description": "Property lastSignInMethod.", + "resource": { + "description": "Property resource.", + "default": "", + "type": "string" + }, + "field": { + "description": "Property field.", "type": [ "string", "null" ] }, - "connections": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FederatedConnectionOutput.jsonld" - } + "message": { + "description": "Property message.", + "default": "", + "type": "string" } } } ], - "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." + "description": "Resource InterventionIssueResource." }, - "FederatedAuth.FederatedProviderOutput.jsonld": { + "InterventionLabel.CreateInterventionLabelInput": { + "type": "object", + "description": "Resource InterventionLabelResource.\n\nOrganization-scoped intervention labels: small, reusable `{name, color}`\ntags interventions can be assigned to (`Intervention.labelIds`).", + "required": [ + "organization", + "name", + "color" + ], + "properties": { + "organization": { + "description": "Property organization.", + "default": "", + "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "name": { + "maxLength": 50, + "description": "Property name.", + "default": "", + "type": "string" + }, + "color": { + "pattern": "^(#[0-9a-fA-F]{6})$", + "description": "Property color.", + "default": "", + "example": "#3b82f6", + "type": "string" + } + } + }, + "InterventionLabel.InterventionLabelOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -52697,49 +65930,162 @@ { "type": "object", "properties": { - "provider": { - "description": "Method __construct", + "id": { + "description": "Property id.", + "default": "", "type": "string" }, - "enabled": { - "description": "Method __construct", - "type": "boolean" + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" + }, + "name": { + "description": "Property name.", + "default": "", + "type": "string" + }, + "color": { + "description": "Property color.", + "default": "", + "type": "string" + }, + "createdAt": { + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "description": "Property updatedAt.", + "default": "", + "type": "string" } } } ], - "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." + "description": "Resource InterventionLabelResource.\n\nOrganization-scoped intervention labels: small, reusable `{name, color}`\ntags interventions can be assigned to (`Intervention.labelIds`)." }, - "FederatedAuth.FederatedStartInput": { + "InterventionLabel.UpdateInterventionLabelInput.jsonMergePatch": { "type": "object", - "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API.", + "description": "Resource InterventionLabelResource.\n\nOrganization-scoped intervention labels: small, reusable `{name, color}`\ntags interventions can be assigned to (`Intervention.labelIds`).", "properties": { - "return_url": { - "maxLength": 500, - "description": "Property returnUrl", - "default": "/", - "type": "string" + "name": { + "minLength": 1, + "maxLength": 50, + "description": "Property name.", + "type": [ + "string", + "null" + ] + }, + "color": { + "pattern": "^(#[0-9a-fA-F]{6})$", + "description": "Property color.", + "example": "#3b82f6", + "type": [ + "string", + "null" + ] } } }, - "FederatedAuth.FederatedStartOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" + "InterventionRecurrence.CreateInterventionRecurrenceInput": { + "type": "object", + "description": "Resource InterventionRecurrenceResource.\n\nOrganization-scoped recurring intervention schedules: a rule (frequency +\ninterval + anchor date, in a fixed timezone) that periodically\nmaterializes an intervention template into a real intervention draft\nthrough the recurring sweep (`MaterializeDueRecurrencesHandler`).", + "required": [ + "organization", + "template", + "name", + "anchorDate", + "timezone" + ], + "properties": { + "organization": { + "description": "Property organization.", + "default": "", + "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", + "type": "string" }, - { - "type": "object", - "properties": { - "authorization_url": { - "description": "Method __construct", - "type": "string" - } - } + "template": { + "description": "Property template.", + "default": "", + "example": "/api/intervention-templates/550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "name": { + "maxLength": 160, + "description": "Property name.", + "default": "", + "type": "string" + }, + "site": { + "description": "Property site.", + "type": [ + "string", + "null" + ] + }, + "responsible": { + "description": "Property responsible.", + "type": [ + "string", + "null" + ] + }, + "frequency": { + "enum": [ + "weekly", + "monthly", + "quarterly", + "semiannual", + "annual" + ], + "description": "Property frequency.", + "default": "monthly", + "type": "string" + }, + "interval": { + "minimum": 1, + "maximum": 12, + "description": "Property interval.", + "default": 1, + "type": "integer" + }, + "anchorDate": { + "description": "Property anchorDate.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "default": "", + "example": "2026-01-31T00:00:00+00:00", + "type": "string" + }, + "timezone": { + "description": "Property timezone.", + "default": "UTC", + "example": "Europe/Paris", + "type": "string" + }, + "leadTimeDays": { + "minimum": 0, + "maximum": 90, + "description": "Property leadTimeDays.", + "default": 7, + "type": "integer" + }, + "endAt": { + "description": "Property endAt.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "type": [ + "string", + "null" + ] } - ], - "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." + } }, - "FederatedAuth.LoginOutput.jsonld-token.read": { + "InterventionRecurrence.InterventionRecurrenceOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -52747,330 +66093,349 @@ { "type": "object", "properties": { - "access_token": { - "readOnly": true, - "description": "JWT access token for API authentication; absent while MFA is required", - "example": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "id": { + "description": "Property id.", + "default": "", "type": "string" }, - "token_type": { - "readOnly": true, - "description": "Token type; absent while MFA is required", - "example": "Bearer", - "type": "string", - "enum": [ - "Bearer" - ], - "default": "Bearer" + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" }, - "expires_in": { - "readOnly": true, - "description": "Token lifetime in seconds; absent while MFA is required", - "example": 3600, - "type": "integer", - "minimum": 1 + "template": { + "description": "Property template.", + "default": "", + "type": "string" }, - "scope": { - "readOnly": true, - "description": "Space-separated list of granted scopes", - "example": "openid profile email", - "type": "string", - "nullable": true + "name": { + "description": "Property name.", + "default": "", + "type": "string" }, - "mfa_required": { - "readOnly": true, - "description": "If true, authentication is incomplete. Use mfa_token to verify code.", - "example": true, - "type": "boolean", - "nullable": true + "site": { + "description": "Property site.", + "type": [ + "string", + "null" + ] }, - "mfa_token": { - "readOnly": true, - "description": "Temporary Pre-Auth Token (JWT) covering the partial authentication state.", - "example": "eyJ...", - "type": "string", - "nullable": true + "responsible": { + "description": "Property responsible.", + "type": [ + "string", + "null" + ] }, - "challenge_token": { - "readOnly": true, - "description": "The OTP challenge token (reference).", - "example": "abc...", - "type": "string", - "nullable": true + "frequency": { + "description": "Property frequency.", + "default": "", + "type": "string" }, - "mfa_method": { - "readOnly": true, - "description": "The delivery method for the MFA code (email, sms, totp).", - "example": "email", - "type": "string", - "enum": [ - "email", - "sms", - "totp" - ], - "nullable": true + "interval": { + "description": "Property interval.", + "default": 1, + "type": "integer" }, - "mfa_destination": { - "readOnly": true, - "description": "The masked destination where the code was sent (e.g., \"j***e@e****e.com\" or \"+336****5678\").", - "example": "j***e@e****e.com", - "type": "string", - "nullable": true + "anchorDate": { + "description": "Property anchorDate.", + "default": "", + "type": "string" }, - "mfa_resend_in": { - "readOnly": true, - "description": "Seconds until MFA code can be resent", - "example": 60, - "type": "integer", - "nullable": true + "timezone": { + "description": "Property timezone.", + "default": "", + "type": "string" }, - "return_url": { - "description": "Validated local destination returned by a federated login.", + "leadTimeDays": { + "description": "Property leadTimeDays.", + "default": 7, + "type": "integer" + }, + "nextOccurrenceAt": { + "description": "Property nextOccurrenceAt.", + "default": "", + "type": "string" + }, + "lastMaterializedAt": { + "description": "Property lastMaterializedAt.", "type": [ "string", "null" ] }, - "new_account": { - "description": "Whether a federated login provisioned the Fireguard account.", + "isActive": { + "description": "Property isActive.", + "default": true, + "type": "boolean" + }, + "endAt": { + "description": "Property endAt.", "type": [ - "boolean", + "string", "null" ] + }, + "createdAt": { + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "description": "Property updatedAt.", + "default": "", + "type": "string" } } } - ], - "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." + ], + "description": "Resource InterventionRecurrenceResource.\n\nOrganization-scoped recurring intervention schedules: a rule (frequency +\ninterval + anchor date, in a fixed timezone) that periodically\nmaterializes an intervention template into a real intervention draft\nthrough the recurring sweep (`MaterializeDueRecurrencesHandler`)." + }, + "InterventionRecurrence.UpdateInterventionRecurrenceInput.jsonMergePatch": { + "type": "object", + "description": "Resource InterventionRecurrenceResource.\n\nOrganization-scoped recurring intervention schedules: a rule (frequency +\ninterval + anchor date, in a fixed timezone) that periodically\nmaterializes an intervention template into a real intervention draft\nthrough the recurring sweep (`MaterializeDueRecurrencesHandler`).", + "properties": { + "name": { + "minLength": 1, + "maxLength": 160, + "description": "Property name.", + "type": [ + "string", + "null" + ] + }, + "site": { + "description": "Property site.", + "type": [ + "string", + "null" + ] + }, + "responsible": { + "description": "Property responsible.", + "type": [ + "string", + "null" + ] + }, + "frequency": { + "enum": [ + "weekly", + "monthly", + "quarterly", + "semiannual", + "annual" + ], + "description": "Property frequency.", + "type": [ + "string", + "null" + ] + }, + "interval": { + "minimum": 1, + "maximum": 12, + "description": "Property interval.", + "type": [ + "integer", + "null" + ] + }, + "anchorDate": { + "description": "Property anchorDate.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "type": [ + "string", + "null" + ] + }, + "timezone": { + "description": "Property timezone.", + "type": [ + "string", + "null" + ] + }, + "leadTimeDays": { + "minimum": 0, + "maximum": 90, + "description": "Property leadTimeDays.", + "type": [ + "integer", + "null" + ] + }, + "endAt": { + "description": "Property endAt.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "type": [ + "string", + "null" + ] + }, + "isActive": { + "description": "Property isActive.", + "example": false, + "type": [ + "boolean", + "null" + ] + } + } }, - "FederatedConnectionOutput.jsonld": { + "InterventionResponsibleStatisticOutput.jsonld": { "type": "object", "properties": { - "provider": { - "description": "Property provider", + "memberId": { + "description": "Property memberId.", + "default": "", "type": "string" }, - "email": { - "description": "Property email", - "type": "string" + "displayName": { + "description": "Property displayName.", + "type": [ + "string", + "null" + ] }, - "connected_at": { - "description": "Property connectedAt", + "count": { + "description": "Property count.", + "default": 0, + "type": "integer" + } + } + }, + "InterventionSiteStatisticOutput.jsonld": { + "type": "object", + "properties": { + "siteId": { + "description": "Property siteId.", + "default": "", "type": "string" }, - "last_used_at": { - "description": "Property lastUsedAt", - "type": "string" + "siteName": { + "description": "Property siteName.", + "type": [ + "string", + "null" + ] + }, + "count": { + "description": "Property count.", + "default": 0, + "type": "integer" } } }, - "Health.HealthOutput.jsonld-health.read_health.read.details": { + "InterventionStatistics.InterventionStatisticsOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, { "type": "object", - "required": [ - "status", - "timestamp", - "version" - ], "properties": { - "status": { - "readOnly": true, - "description": "Overall health status of the service", - "default": "", - "example": "healthy", - "type": "string", - "enum": [ - "healthy", - "degraded", - "unhealthy" - ] - }, - "timestamp": { - "readOnly": true, - "description": "Current server timestamp (ISO 8601)", - "default": "", - "example": "2026-01-30T10:15:30+00:00", - "type": "string", - "format": "date-time" - }, - "database": { - "readOnly": true, - "description": "Database connectivity status (when details are enabled)", - "example": true, - "type": "boolean", - "nullable": true + "total": { + "description": "Property total.", + "default": 0, + "type": "integer" }, - "cache": { - "readOnly": true, - "description": "Cache connectivity status (when details are enabled)", - "example": true, - "type": "boolean", - "nullable": true + "byStatus": { + "description": "Property byStatus.", + "type": "object", + "additionalProperties": { + "type": "integer" + } }, - "version": { - "readOnly": true, - "description": "Running application version", - "default": "1.0.0", - "example": "1.0.0", - "type": "string" - } - } - } - ], - "description": "Application health check" - }, - "HydraCollectionBaseSchema": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" - }, - { - "type": "object", - "properties": { - "view": { + "byPriority": { + "description": "Property byPriority.", "type": "object", - "properties": { - "@id": { - "type": "string", - "format": "iri-reference" - }, - "@type": { - "type": "string" - }, - "first": { - "type": [ - "string", - "null" - ], - "format": "iri-reference" - }, - "last": { - "type": [ - "string", - "null" - ], - "format": "iri-reference" - }, - "previous": { - "type": [ - "string", - "null" - ], - "format": "iri-reference" - }, - "next": { - "type": [ - "string", - "null" - ], - "format": "iri-reference" - } - }, - "example": { - "@id": "string", - "@type": "string", - "first": "string", - "last": "string", - "previous": "string", - "next": "string" + "additionalProperties": { + "type": "integer" } - } - } - } - ] - }, - "HydraCollectionBaseSchemaNoPagination": { - "type": "object", - "properties": { - "totalItems": { - "type": "integer", - "minimum": 0 - }, - "search": { - "type": "object", - "properties": { - "@type": { - "type": "string" }, - "template": { - "type": "string" + "overdue": { + "description": "Property overdue.", + "default": 0, + "type": "integer" }, - "variableRepresentation": { - "type": "string" + "dueSoon": { + "description": "Property dueSoon.", + "default": 0, + "type": "integer" }, - "mapping": { + "bySite": { + "description": "Property bySite.", "type": "array", "items": { - "type": "object", - "properties": { - "@type": { - "type": "string" - }, - "variable": { - "type": "string" - }, - "property": { - "type": [ - "string", - "null" - ] - }, - "required": { - "type": "boolean" - } - } + "$ref": "#/components/schemas/InterventionSiteStatisticOutput.jsonld" + } + }, + "byResponsible": { + "description": "Property byResponsible.", + "type": "array", + "items": { + "$ref": "#/components/schemas/InterventionResponsibleStatisticOutput.jsonld" } + }, + "averagePublicationDays": { + "description": "Property averagePublicationDays.", + "type": [ + "number", + "null" + ] } } } - } + ], + "description": "Whole-organization intervention statistics for the list page KPI cards and the kanban column counters." }, - "HydraItemBaseSchema": { + "InterventionTargetOutput.jsonld": { "type": "object", "properties": { - "@context": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "@vocab": { - "type": "string" - }, - "hydra": { - "type": "string", - "enum": [ - "http://www.w3.org/ns/hydra/core#" - ] - } - }, - "required": [ - "@vocab", - "hydra" - ], - "additionalProperties": true - } - ] + "resource": { + "readOnly": true, + "description": "Property resource.", + "default": "", + "type": "string" }, - "@id": { + "kind": { + "readOnly": true, + "description": "Property kind.", + "default": "", "type": "string" }, - "@type": { + "label": { + "readOnly": true, + "description": "Property label.", + "default": "", "type": "string" } - }, + } + }, + "InterventionTeamAssignment.AssignInterventionTeamInput": { + "type": "object", + "description": "Resource InterventionTeamAssignmentResource.\n\nSnapshot-expands an organization team's active members into an\nintervention's participants list (requires\norganization.interventions.plan). Participants follow the schedule\nmutability guard {@see \\Intervention\\Domain\\Model\\Intervention\\Intervention}\nenforces, not a draft-only freeze: the assignment stays possible through\n`planned`, `in_progress` and `changes_requested`, and is refused only once\nthe intervention is `submitted` (frozen under review) or `published` /\n`abandoned` (immutable). Kept as a dedicated resource file to avoid growing\nthe large {@see InterventionResource}.", "required": [ - "@id", - "@type" - ] + "teamId" + ], + "properties": { + "teamId": { + "format": "uuid", + "description": "Property teamId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + } + } }, - "ImportJob.ImportJobOutput.jsonld": { + "InterventionTeamAssignment.InterventionOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -53078,10 +66443,18 @@ { "type": "object", "properties": { - "canResume": { - "description": "True only when the current member may resume and no live worker owns the job.", - "default": false, - "type": "boolean" + "closureSnapshot": { + "description": "Property closureSnapshot. Versioned dossier frozen by atomic publication.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] + } }, "id": { "description": "Property id.", @@ -53093,473 +66466,310 @@ "default": "", "type": "string" }, - "kind": { - "description": "Property kind.", + "number": { + "description": "Property number.", + "default": 0, + "type": "integer" + }, + "type": { + "description": "Property type.", + "default": "site_setup", + "type": "string" + }, + "name": { + "description": "Property name.", "default": "", "type": "string" }, + "description": { + "description": "Property description.", + "type": [ + "string", + "null" + ] + }, "status": { "description": "Property status.", - "default": "", + "default": "draft", "type": "string" }, - "originalFilename": { - "description": "Property originalFilename.", - "default": "", + "site": { + "description": "Property site.", + "type": [ + "string", + "null" + ] + }, + "responsible": { + "description": "Property responsible.", + "type": [ + "string", + "null" + ] + }, + "participants": { + "description": "Property participants.", + "type": "array", + "items": { + "type": "string" + } + }, + "priority": { + "description": "Property priority.", + "default": "normal", "type": "string" }, - "dryRun": { - "description": "Property dryRun.", - "default": false, - "type": "boolean" + "plannedStartAt": { + "description": "Property plannedStartAt.", + "type": [ + "string", + "null" + ] }, - "totalRows": { - "description": "Property totalRows.", + "dueAt": { + "description": "Property dueAt.", "type": [ - "integer", + "string", + "null" + ] + }, + "reviewNote": { + "description": "Property reviewNote.", + "type": [ + "string", + "null" + ] + }, + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" + }, + "recurrence": { + "description": "Property recurrence.", + "type": [ + "string", "null" ] }, - "processedRows": { - "description": "Property processedRows.", + "allowedTransitions": { + "description": "Property allowedTransitions.", + "type": "array", + "items": { + "type": "string" + } + }, + "allowedActions": { + "description": "Property allowedActions.", + "anyOf": [ + { + "$ref": "#/components/schemas/InterventionAllowedActionsOutput.jsonld" + }, + { + "type": "null" + } + ] + }, + "facilitiesCount": { + "description": "Property facilitiesCount.", + "default": 0, + "type": "integer" + }, + "equipmentCount": { + "description": "Property equipmentCount.", "default": 0, "type": "integer" }, - "successfulRows": { - "description": "Property successfulRows.", + "inspectionsCount": { + "description": "Property inspectionsCount.", "default": 0, "type": "integer" }, - "failedRows": { - "description": "Property failedRows.", + "blockersCount": { + "description": "Property blockersCount.", "default": 0, "type": "integer" }, - "errorReport": { - "description": "Property errorReport.", - "type": "array", - "items": { - "$ref": "#/components/schemas/ImportRowErrorOutput.jsonld" - } + "workItemsCount": { + "description": "Property workItemsCount.", + "default": 0, + "type": "integer" }, - "reportPage": { - "description": "Current report page, starting at one.", - "default": 1, + "completedWorkItemsCount": { + "description": "Property completedWorkItemsCount.", + "default": 0, "type": "integer" }, - "reportItemsPerPage": { - "description": "Maximum report rows returned per page.", - "default": 100, + "proposedChangesCount": { + "description": "Property proposedChangesCount.", + "default": 0, "type": "integer" }, - "reportTotal": { - "description": "Total confirmed report rows for this job.", + "commentsCount": { + "description": "Property commentsCount.", "default": 0, "type": "integer" }, - "reportHasNextPage": { - "description": "Whether a subsequent report page exists.", + "hasSignature": { + "description": "Property hasSignature.", "default": false, "type": "boolean" }, - "jobError": { - "description": "Property jobError.", - "type": [ - "string", - "null" - ] + "labels": { + "description": "Property labels.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } }, "createdAt": { "description": "Property createdAt.", "default": "", "type": "string" }, - "startedAt": { - "description": "Property startedAt.", - "type": [ - "string", - "null" - ] - }, - "completedAt": { - "description": "Property completedAt.", - "type": [ - "string", - "null" - ] - }, "updatedAt": { "description": "Property updatedAt.", "default": "", "type": "string" - }, - "canConfirm": { - "description": "Property canConfirm", - "default": false, - "type": "boolean" - }, - "confirmedJobId": { - "description": "Property confirmedJobId", - "type": [ - "string", - "null" - ] } } } ], - "description": "Bulk CSV import batches provisioning Equipment or Facility resources." + "description": "Resource InterventionTeamAssignmentResource.\n\nSnapshot-expands an organization team's active members into an\nintervention's participants list (requires\norganization.interventions.plan). Participants follow the schedule\nmutability guard {@see \\Intervention\\Domain\\Model\\Intervention\\Intervention}\nenforces, not a draft-only freeze: the assignment stays possible through\n`planned`, `in_progress` and `changes_requested`, and is refused only once\nthe intervention is `submitted` (frozen under review) or `published` /\n`abandoned` (immutable). Kept as a dedicated resource file to avoid growing\nthe large {@see InterventionResource}." }, - "ImportRowErrorOutput.jsonld": { + "InterventionTemplate.CreateInterventionTemplateInput": { "type": "object", + "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence \u2014 see the module changelog for later lots).", + "required": [ + "organization", + "name" + ], "properties": { - "rowNumber": { - "description": "Property rowNumber.", - "default": 0, - "type": "integer" - }, - "column": { - "description": "Property column.", - "type": [ - "string", - "null" - ] - }, - "code": { - "description": "Property code.", + "organization": { + "description": "Property organization.", "default": "", + "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", "type": "string" }, - "message": { - "description": "Property message.", + "name": { + "minLength": 2, + "maxLength": 160, + "description": "Property name.", "default": "", "type": "string" - } - } - }, - "ImportTemplate.ImportTemplateOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "id": { - "description": "Method __construct", - "type": "string" - }, - "filename": { - "description": "Method __construct", - "type": "string" - }, - "content": { - "description": "Method __construct", - "type": "string" - }, - "mediaType": { - "description": "Method __construct", - "type": "string" - } - } - } - ], - "description": "Resource ImportTemplateResource. Organization-scoped server-owned CSV templates." - }, - "Inbox.InboxOutput.jsonld-notification.inbox.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "items": { - "readOnly": true, - "description": "Property items.", - "type": "array", - "items": { - "$ref": "#/components/schemas/InboxItemOutput.jsonld-notification.inbox.read" - } - }, - "nextCursor": { - "readOnly": true, - "description": "Property nextCursor.", - "type": [ - "string", - "null" - ] - }, - "nextPageCursor": { - "readOnly": true, - "description": "Opaque composite cursor echoed unchanged as `cursor`; legacy nextCursor remains a date.", - "type": [ - "string", - "null" - ] - }, - "complete": { - "readOnly": true, - "description": "False when a source is unavailable; retry this page instead of advancing its cursor.", - "default": true, - "type": "boolean" - }, - "hasMore": { - "readOnly": true, - "description": "Property hasMore.", - "default": false, - "type": "boolean" - } - } - } - ], - "description": "Unified feed of things needing the authenticated user's attention, merged from every registered source." - }, - "Inbox.InboxUnreadCountOutput.jsonld-notification.inbox.unread_count": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "unreadCount": { - "readOnly": true, - "description": "Property unreadCount.", - "default": 0, - "type": "integer" - } - } - } - ], - "description": "Unified feed of things needing the authenticated user's attention, merged from every registered source." - }, - "InboxItemOutput.jsonld-notification.inbox.read": { - "type": "object", - "properties": { - "targetKind": { - "readOnly": true, - "description": "Optional navigation discriminator supplied by the source, preserving targetType.", - "type": [ - "string", - "null" - ] }, - "sourceType": { - "readOnly": true, - "description": "Source-owned event type for presentation, when the source supplies one.", + "description": { + "maxLength": 5000, + "description": "Property description.", "type": [ "string", "null" ] }, - "sourceKey": { - "readOnly": true, - "description": "Property sourceKey.", - "default": "", - "type": "string" - }, - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "kind": { - "readOnly": true, - "description": "Property kind.", - "default": "", + "type": { + "enum": [ + "site_setup", + "inventory", + "inspection_campaign", + "preventive_maintenance", + "corrective_maintenance" + ], + "description": "Property type.", + "default": "site_setup", "type": "string" }, - "title": { - "readOnly": true, - "description": "Property title.", - "default": "", + "priority": { + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "description": "Property priority.", + "default": "normal", "type": "string" }, - "snippet": { - "readOnly": true, - "description": "Property snippet.", + "defaultSite": { + "description": "Property defaultSite.", "type": [ "string", "null" ] }, - "occurredAt": { - "readOnly": true, - "description": "Property occurredAt.", - "default": "", - "type": "string" - }, - "isRead": { - "readOnly": true, - "description": "Property isRead.", - "default": false, - "type": "boolean" + "defaultResponsible": { + "description": "Property defaultResponsible.", + "type": [ + "string", + "null" + ] }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", + "duration": { + "description": "Property duration.", + "example": "P14D", "type": [ "string", "null" ] }, - "targetType": { - "readOnly": true, - "description": "Property targetType.", - "default": "", - "type": "string" + "labelIds": { + "description": "Property labelIds.", + "type": "array", + "items": { + "type": "string" + } }, - "targetId": { - "readOnly": true, - "description": "Property targetId.", - "default": "", - "type": "string" + "items": { + "description": "Property items.", + "type": "array", + "items": { + "$ref": "#/components/schemas/InterventionTemplateItemInput" + } } } }, - "Inspection.CreateInspectionInput": { + "InterventionTemplate.InstantiateInterventionTemplateInput": { "type": "object", - "description": "Resource CanonicalInspectionResource.", + "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence \u2014 see the module changelog for later lots).", "properties": { - "clientId": { - "format": "uuid", - "description": "Client-generated UUID for idempotent offline creation", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - }, - "organization": { - "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", - "description": "Organization IRI, required on the canonical endpoint", - "type": [ - "string", - "null" - ] - }, - "intervention": { - "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", - "description": "Optional field intervention IRI", + "name": { + "minLength": 2, + "maxLength": 160, + "description": "Property name.", "type": [ "string", "null" ] }, - "equipmentId": { - "format": "uuid", - "description": "Equipment identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "default": "", - "example": "550e8400-e29b-41d4-a716-446655440000", - "type": "string" - }, - "result": { - "enum": [ - "pass", - "fail", - "partial" - ], - "description": "Inspection result", - "default": "", - "example": "pass", - "type": "string" - }, - "performedAt": { - "description": "Date the inspection was performed (ISO 8601)", - "default": "", - "example": "2024-06-15T10:00:00+02:00", - "type": "string" - }, - "inspectorType": { - "enum": [ - "user", - "external" - ], - "description": "Inspector type (user or external)", - "default": "", - "example": "user", - "type": "string" - }, - "inspectorName": { - "maxLength": 255, - "description": "Inspector display name", - "default": "", - "example": "Jean Dupont", - "type": "string" - }, - "facilityId": { - "format": "uuid", - "description": "Optional facility identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, + "site": { + "description": "Property site.", "type": [ "string", "null" ] }, - "checklistId": { - "format": "uuid", - "description": "Optional checklist identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, + "responsible": { + "description": "Property responsible.", "type": [ "string", "null" ] }, - "inspectorUserId": { - "format": "uuid", - "description": "Optional user identifier (required for user type)", + "plannedStartAt": { + "description": "Property plannedStartAt.", "externalDocs": { - "url": "https://schema.org/identifier" + "url": "https://schema.org/DateTime" }, "type": [ "string", "null" ] - }, - "inspectorOrganizationName": { - "maxLength": 255, - "description": "Optional external organization name", - "type": [ - "string", - "null" - ] - }, - "notes": { - "maxLength": 5000, - "description": "Optional free-form notes", - "type": [ - "string", - "null" - ] - }, - "signature": { - "description": "Optional signature data", - "type": [ - "string", - "null" - ] } - }, - "required": [ - "equipmentId", - "result", - "performedAt", - "inspectorType", - "inspectorName" - ] + } }, - "Inspection.InspectionOutput.jsonld": { + "InterventionTemplate.InstantiateInterventionTemplateOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -53567,141 +66777,102 @@ { "type": "object", "properties": { - "intervention": { - "description": "Property intervention.", - "type": [ - "string", - "null" - ] - }, - "recordStatus": { - "description": "Property recordStatus.", - "default": "published", + "interventionId": { + "description": "Property interventionId.", + "default": "", "type": "string" }, - "revision": { - "description": "Property revision.", - "default": 1, + "number": { + "description": "Property number.", + "default": 0, "type": "integer" - }, + } + } + } + ], + "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence \u2014 see the module changelog for later lots)." + }, + "InterventionTemplate.InterventionTemplateOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { "id": { - "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", - "default": "", - "type": "string" - }, - "equipmentId": { - "readOnly": true, - "description": "Property equipmentId.", - "default": "", - "type": "string" - }, - "facilityId": { - "readOnly": true, - "description": "Property facilityId.", - "type": [ - "string", - "null" - ] - }, - "equipmentSerialNumber": { - "readOnly": true, - "description": "Serial number of the inspected equipment", - "type": [ - "string", - "null" - ] - }, - "facilityName": { - "readOnly": true, - "description": "Display name of the inspected facility", - "type": [ - "string", - "null" - ] + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" }, - "checklistName": { - "readOnly": true, - "description": "Display name of the followed checklist", + "name": { + "description": "Property name.", + "default": "", + "type": "string" + }, + "description": { + "description": "Property description.", "type": [ "string", "null" ] }, - "result": { - "readOnly": true, - "description": "Property result.", - "default": "", - "type": "string" - }, - "status": { - "readOnly": true, - "description": "Property status.", + "type": { + "description": "Property type.", "default": "", "type": "string" }, - "performedAt": { - "readOnly": true, - "description": "Property performedAt.", + "priority": { + "description": "Property priority.", "default": "", "type": "string" }, - "inspector": { - "readOnly": true, - "description": "Property inspector.", - "anyOf": [ - { - "$ref": "#/components/schemas/InspectorOutput.jsonld" - }, - { - "type": "null" - } - ] - }, - "checklistId": { - "readOnly": true, - "description": "Property checklistId.", + "defaultSite": { + "description": "Property defaultSite.", "type": [ "string", "null" ] }, - "notes": { - "readOnly": true, - "description": "Property notes.", + "defaultResponsible": { + "description": "Property defaultResponsible.", "type": [ "string", "null" ] }, - "signature": { - "readOnly": true, - "description": "Property signature.", + "duration": { + "description": "Property duration.", "type": [ "string", "null" ] }, - "nonConformitiesCount": { - "readOnly": true, - "description": "Property nonConformitiesCount.", - "default": 0, - "type": "integer" + "labelIds": { + "description": "Property labelIds.", + "type": "array", + "items": { + "type": "string" + } + }, + "items": { + "description": "Property items.", + "type": "array", + "items": { + "$ref": "#/components/schemas/InterventionTemplateItemOutput.jsonld" + } }, "createdAt": { - "readOnly": true, "description": "Property createdAt.", "default": "", "type": "string" }, "updatedAt": { - "readOnly": true, "description": "Property updatedAt.", "default": "", "type": "string" @@ -53709,169 +66880,193 @@ } } ], - "description": "Resource CanonicalInspectionResource." + "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence \u2014 see the module changelog for later lots)." }, - "Inspection.PatchCanonicalInspectionInput.jsonMergePatch": { + "InterventionTemplate.UpdateInterventionTemplateInput.jsonMergePatch": { "type": "object", - "description": "Resource CanonicalInspectionResource.", + "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence \u2014 see the module changelog for later lots).", "properties": { - "result": { + "name": { + "minLength": 2, + "maxLength": 160, + "description": "Property name.", + "type": [ + "string", + "null" + ] + }, + "description": { + "maxLength": 5000, + "description": "Property description.", + "type": [ + "string", + "null" + ] + }, + "type": { "enum": [ - "pass", - "fail", - "partial" + "site_setup", + "inventory", + "inspection_campaign", + "preventive_maintenance", + "corrective_maintenance" ], - "description": "Property result.", + "description": "Property type.", "type": [ "string", "null" ] }, - "status": { + "priority": { "enum": [ - "draft", - "submitted", - "closed" + "low", + "normal", + "high", + "urgent" ], - "description": "Property status.", + "description": "Property priority.", "type": [ "string", "null" ] }, - "notes": { - "maxLength": 5000, - "description": "Property notes.", + "defaultSite": { + "description": "Property defaultSite.", "type": [ "string", "null" ] }, - "signature": { - "description": "Property signature.", + "defaultResponsible": { + "description": "Property defaultResponsible.", "type": [ "string", "null" ] - } - } - }, - "InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" }, - { - "type": "object", - "properties": { - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "inspectionId": { - "readOnly": true, - "description": "Property inspectionId.", - "default": "", - "type": "string" - }, - "nonConformityId": { - "readOnly": true, - "description": "Property nonConformityId.", - "type": [ - "string", - "null" - ] - }, - "fileName": { - "readOnly": true, - "description": "Property fileName.", - "default": "", - "type": "string" - }, - "mimeType": { - "readOnly": true, - "description": "Property mimeType.", - "default": "", - "type": "string" - }, - "size": { - "readOnly": true, - "description": "Property size.", - "default": 0, - "type": "integer" - }, - "label": { - "readOnly": true, - "description": "Property label.", - "type": [ - "string", - "null" - ] - }, - "revision": { - "readOnly": true, - "description": "Property revision.", - "default": 1, - "type": "integer" - }, - "uploadedAt": { - "readOnly": true, - "description": "Property uploadedAt.", - "default": "", - "type": "string" - } + "duration": { + "description": "Property duration.", + "example": "P14D", + "type": [ + "string", + "null" + ] + }, + "labelIds": { + "description": "Property labelIds.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "items": { + "description": "Property items.", + "type": "array", + "items": { + "$ref": "#/components/schemas/InterventionTemplateItemInput" } } - ], - "description": "File attachments linked to an inspection or a non-conformity." + } }, - "InspectionResponse.CreateInspectionResponseInput": { + "InterventionTemplateItemInput": { "type": "object", - "description": "Resource InspectionResponseResource.", - "required": [ - "organization", - "inspection", - "itemKey" - ], "properties": { - "organization": { - "description": "Property organization.", + "estimatedMinutes": { + "minimum": 0, + "description": "Property estimatedMinutes.", + "type": [ + "integer", + "null" + ] + }, + "action": { + "maxLength": 60, + "description": "Property action.", "default": "", + "example": "inspection", "type": "string" }, - "intervention": { - "description": "Property intervention.", + "target": { + "description": "Property target.", "type": [ "string", "null" ] }, - "inspection": { - "description": "Property inspection.", - "default": "", - "type": "string" + "resultResource": { + "description": "Property resultResource.", + "type": [ + "string", + "null" + ] }, - "clientId": { - "format": "uuid", - "description": "Property clientId.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, + "required": { + "description": "Property required.", + "default": true, + "type": "boolean" + }, + "defaultAssignee": { + "description": "Property defaultAssignee.", + "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000/members/018f0b68-6758-7a12-8a1d-3f0d97f63c13", "type": [ "string", "null" ] + } + }, + "required": [ + "action" + ] + }, + "InterventionTemplateItemOutput.jsonld": { + "type": "object", + "properties": { + "estimatedMinutes": { + "description": "Property estimatedMinutes.", + "type": [ + "integer", + "null" + ] }, - "itemKey": { - "maxLength": 160, - "description": "Property itemKey.", + "id": { + "description": "Property id.", "default": "", "type": "string" }, - "value": { - "description": "Property value.", + "position": { + "description": "Property position.", + "default": 0, + "type": "integer" + }, + "action": { + "description": "Property action.", + "default": "", + "type": "string" + }, + "target": { + "description": "Property target.", + "type": [ + "string", + "null" + ] + }, + "resultResource": { + "description": "Property resultResource.", + "type": [ + "string", + "null" + ] + }, + "required": { + "description": "Property required.", + "default": true, + "type": "boolean" + }, + "defaultAssignee": { + "description": "Property defaultAssignee.", "type": [ "string", "null" @@ -53879,7 +67074,7 @@ } } }, - "InspectionResponse.InspectionResponseOutput.jsonld": { + "InterventionTime.TimeEntryOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -53888,283 +67083,268 @@ "type": "object", "properties": { "id": { - "description": "Property id.", - "type": "string" - }, - "organization": { - "description": "Property organization.", - "type": "string" - }, - "intervention": { - "description": "Property intervention.", - "type": [ - "string", - "null" - ] - }, - "inspection": { - "description": "Property inspection.", - "type": "string" - }, - "recordStatus": { - "description": "Property recordStatus.", - "type": "string" - }, - "revision": { - "description": "Property revision.", - "type": "integer" - }, - "itemKey": { - "description": "Property itemKey.", + "description": "stable resource identifier, retained across idempotent retries", "type": "string" }, - "value": { - "description": "Property value.", - "type": [ - "string", - "null" - ] - }, - "createdAt": { - "description": "Property createdAt.", + "entry": { + "description": "independent time-entry aggregate or view", + "$ref": "#/components/schemas/TimeEntryView.jsonld" + } + } + } + ], + "description": "InterventionTimeResource." + }, + "InterventionTime.TimeJournalOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "workItemId": { + "description": "intervention task associated with this contribution", "type": "string" }, - "updatedAt": { - "description": "Property updatedAt.", - "type": "string" + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimeEntryView.jsonld" + } } } } ], - "description": "Resource InspectionResponseResource." + "description": "InterventionTimeResource." }, - "InspectionResponse.PatchInspectionResponseInput.jsonMergePatch": { + "InterventionTime.WriteTimeEntryInput": { "type": "object", - "description": "Resource InspectionResponseResource.", + "description": "InterventionTimeResource.", "properties": { - "value": { - "description": "Property value.", + "id": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] - } - } - }, - "InspectorOutput.jsonld": { - "type": "object", - "properties": { - "type": { - "readOnly": true, - "description": "Property type", + }, + "memberId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "workedOn": { + "externalDocs": { + "url": "https://schema.org/Date" + }, "default": "", "type": "string" }, - "id": { - "readOnly": true, - "description": "Property id", + "minutes": { + "minimum": 1, + "maximum": 1440, + "default": 0, + "type": "integer" + }, + "note": { + "maxLength": 2000, "type": [ "string", "null" ] - }, - "firstName": { - "readOnly": true, - "description": "Property firstName", + } + }, + "required": [ + "workedOn" + ] + }, + "InterventionTime.WriteTimeEntryInput.jsonMergePatch": { + "type": "object", + "description": "InterventionTimeResource.", + "properties": { + "id": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "lastName": { - "readOnly": true, - "description": "Property lastName", + "memberId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "displayName": { - "readOnly": true, - "description": "Property displayName", + "workedOn": { + "externalDocs": { + "url": "https://schema.org/Date" + }, "default": "", "type": "string" }, - "avatarUrl": { - "readOnly": true, - "description": "Property avatarUrl", + "minutes": { + "minimum": 1, + "maximum": 1440, + "default": 0, + "type": "integer" + }, + "note": { + "maxLength": 2000, + "type": [ + "string", + "null" + ] + } + } + }, + "InterventionWorkItem.CreateInterventionWorkItemInput": { + "type": "object", + "description": "Resource InterventionWorkItemResource.", + "properties": { + "operationId": { + "format": "uuid", + "description": "Property operationId. Optional preventive plan identifier.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "organizationName": { - "readOnly": true, - "description": "Property organizationName", + "occurrenceId": { + "format": "uuid", + "description": "Property occurrenceId. Optional preventive occurrence identifier.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] - } - } - }, - "InspectorOutput.jsonld-inspection.read": { - "type": "object", - "properties": { - "type": { - "readOnly": true, - "description": "Property type", - "default": "", - "type": "string" }, - "id": { - "readOnly": true, - "description": "Property id", + "operationKind": { + "enum": [ + "control", + "maintenance" + ], + "description": "Property operationKind. Optional source operation kind.", "type": [ "string", "null" ] }, - "firstName": { - "readOnly": true, - "description": "Property firstName", + "workloadConfirmationToken": { + "maxLength": 64, + "description": "Property workloadConfirmationToken.", "type": [ "string", "null" ] }, - "lastName": { - "readOnly": true, - "description": "Property lastName", + "estimatedMinutes": { + "minimum": 0, + "description": "Property estimatedMinutes. Null means not estimated.", "type": [ - "string", + "integer", "null" ] }, - "displayName": { - "readOnly": true, - "description": "Property displayName", - "default": "", - "type": "string" - }, - "avatarUrl": { - "readOnly": true, - "description": "Property avatarUrl", + "workStartsOn": { + "description": "Property workStartsOn. Organization-local date.", + "externalDocs": { + "url": "https://schema.org/Date" + }, "type": [ "string", "null" ] }, - "organizationName": { - "readOnly": true, - "description": "Property organizationName", + "workEndsOn": { + "description": "Property workEndsOn. Organization-local date.", + "externalDocs": { + "url": "https://schema.org/Date" + }, "type": [ "string", "null" ] - } - } - }, - "Intervention.CreateInterventionInput": { - "type": "object", - "description": "Field intervention coordinating draft operational resources.", - "required": [ - "organization", - "name" - ], - "properties": { - "organization": { - "description": "Property organization.", + }, + "intervention": { + "description": "Property intervention.", "default": "", - "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", "type": "string" }, - "type": { + "action": { "enum": [ "site_setup", "inventory", - "inspection_campaign" + "inspection", + "maintenance", + "repair", + "replacement" ], - "description": "Property type.", - "default": "site_setup", - "type": "string" - }, - "name": { - "maxLength": 160, - "description": "Property name.", - "default": "", + "description": "Property action.", + "default": "inventory", "type": "string" }, - "description": { - "maxLength": 5000, - "description": "Property description.", + "target": { + "description": "Property target.", "type": [ "string", "null" ] }, - "site": { - "description": "Property site.", + "resultResource": { + "description": "Property resultResource.", "type": [ "string", "null" ] }, - "responsible": { - "description": "Property responsible.", + "assignee": { + "description": "Property assignee.", "type": [ "string", "null" ] }, - "participants": { - "description": "Property participants.", - "type": "array", - "items": { - "type": "string" - } - }, - "priority": { + "source": { "enum": [ - "low", - "normal", - "high", - "urgent" + "planned", + "discovered" ], - "description": "Property priority.", - "default": "normal", + "description": "Property source.", + "default": "planned", "type": "string" }, - "plannedStartAt": { - "description": "Property plannedStartAt.", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, - "type": [ - "string", - "null" - ] - }, - "dueAt": { - "description": "Property dueAt.", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, - "type": [ - "string", - "null" - ] - }, - "labelIds": { - "description": "Property labelIds.", - "type": "array", - "items": { - "type": "string" - } + "required": { + "description": "Property required.", + "default": true, + "type": "boolean" } - } + }, + "required": [ + "intervention" + ] }, - "Intervention.InterventionOutput.jsonld": { + "InterventionWorkItem.InterventionWorkItemOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -54172,175 +67352,178 @@ { "type": "object", "properties": { - "id": { - "description": "Property id.", - "default": "", - "type": "string" + "operationId": { + "description": "Property operationId. Source preventive plan identity.", + "type": [ + "string", + "null" + ] }, - "organization": { - "description": "Property organization.", - "default": "", - "type": "string" + "occurrenceId": { + "description": "Property occurrenceId. Source preventive occurrence identity.", + "type": [ + "string", + "null" + ] }, - "number": { - "description": "Property number.", + "operationKind": { + "description": "Property operationKind. Control or maintenance.", + "type": [ + "string", + "null" + ] + }, + "executionResult": { + "description": "Property executionResult. Factual result and server-owned review attribution.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + "spentMinutes": { + "description": "Property spentMinutes.", "default": 0, "type": "integer" }, - "type": { - "description": "Property type.", - "default": "site_setup", - "type": "string" - }, - "name": { - "description": "Property name.", - "default": "", - "type": "string" + "allowedActions": { + "description": "Property allowedActions.", + "default": { + "canLogTime": false, + "canManageTime": false, + "canReestimate": false, + "canReassign": false, + "canEditPlanning": false, + "canExecute": false + }, + "type": "object", + "additionalProperties": { + "type": "boolean" + } }, - "description": { - "description": "Property description.", + "estimatedMinutes": { + "description": "Property estimatedMinutes.", "type": [ - "string", + "integer", "null" ] }, - "status": { - "description": "Property status.", - "default": "draft", - "type": "string" + "remainingMinutes": { + "description": "Property remainingMinutes.", + "type": [ + "integer", + "null" + ] }, - "site": { - "description": "Property site.", + "workStartsOn": { + "description": "Property workStartsOn.", "type": [ "string", "null" ] }, - "responsible": { - "description": "Property responsible.", + "workEndsOn": { + "description": "Property workEndsOn.", "type": [ "string", "null" ] }, - "participants": { - "description": "Property participants.", - "type": "array", - "items": { - "type": "string" - } + "id": { + "description": "Property id.", + "default": "", + "type": "string" }, - "priority": { - "description": "Property priority.", - "default": "normal", + "intervention": { + "description": "Property intervention.", + "default": "", "type": "string" }, - "plannedStartAt": { - "description": "Property plannedStartAt.", + "action": { + "description": "Property action.", + "default": "", + "type": "string" + }, + "target": { + "description": "Property target.", "type": [ "string", "null" ] }, - "dueAt": { - "description": "Property dueAt.", - "type": [ - "string", - "null" + "targetSummary": { + "description": "Property targetSummary.", + "anyOf": [ + { + "$ref": "#/components/schemas/InterventionTargetOutput.jsonld" + }, + { + "type": "null" + } ] }, - "reviewNote": { - "description": "Property reviewNote.", + "resultResource": { + "description": "Property resultResource.", "type": [ "string", "null" ] }, - "revision": { - "description": "Property revision.", - "default": 1, - "type": "integer" - }, - "recurrence": { - "description": "Property recurrence.", + "assignee": { + "description": "Property assignee.", "type": [ "string", "null" ] }, - "allowedTransitions": { - "description": "Property allowedTransitions.", - "type": "array", - "items": { - "type": "string" - } - }, - "allowedActions": { - "description": "Property allowedActions.", + "assigneeProfile": { + "description": "Property assigneeProfile.", "anyOf": [ { - "$ref": "#/components/schemas/InterventionAllowedActionsOutput.jsonld" + "$ref": "#/components/schemas/InterventionAssigneeOutput.jsonld" }, { "type": "null" } ] }, - "facilitiesCount": { - "description": "Property facilitiesCount.", - "default": 0, - "type": "integer" - }, - "equipmentCount": { - "description": "Property equipmentCount.", - "default": 0, - "type": "integer" - }, - "inspectionsCount": { - "description": "Property inspectionsCount.", - "default": 0, - "type": "integer" + "source": { + "description": "Property source.", + "default": "planned", + "type": "string" }, - "blockersCount": { - "description": "Property blockersCount.", - "default": 0, - "type": "integer" + "status": { + "description": "Property status.", + "default": "planned", + "type": "string" }, - "workItemsCount": { - "description": "Property workItemsCount.", - "default": 0, - "type": "integer" + "required": { + "description": "Property required.", + "default": true, + "type": "boolean" }, - "completedWorkItemsCount": { - "description": "Property completedWorkItemsCount.", - "default": 0, - "type": "integer" + "skipReason": { + "description": "Property skipReason.", + "type": [ + "string", + "null" + ] }, - "proposedChangesCount": { - "description": "Property proposedChangesCount.", + "evidenceCount": { + "description": "Property evidenceCount.", "default": 0, "type": "integer" }, - "commentsCount": { - "description": "Property commentsCount.", - "default": 0, + "revision": { + "description": "Property revision.", + "default": 1, "type": "integer" }, - "hasSignature": { - "description": "Property hasSignature.", - "default": false, - "type": "boolean" - }, - "labels": { - "description": "Property labels.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, "createdAt": { "description": "Property createdAt.", "default": "", @@ -54354,146 +67537,99 @@ } } ], - "description": "Field intervention coordinating draft operational resources." + "description": "Resource InterventionWorkItemResource." }, - "Intervention.UpdateInterventionInput.jsonMergePatch": { + "InterventionWorkItem.UpdateInterventionWorkItemInput.jsonMergePatch": { "type": "object", - "description": "Field intervention coordinating draft operational resources.", + "description": "Resource InterventionWorkItemResource.", "properties": { - "workloadConfirmationToken": { - "maxLength": 64, - "description": "Property workloadConfirmationToken.", - "type": [ - "string", - "null" - ] - }, - "name": { - "minLength": 1, - "maxLength": 160, - "description": "Property name.", + "executionResult": { + "description": "Property executionResult. An execution fact staged for the normal review workflow.", "type": [ - "string", + "object", "null" - ] + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] + } }, - "description": { - "maxLength": 5000, - "description": "Property description.", + "workloadConfirmationToken": { + "maxLength": 64, + "description": "Property workloadConfirmationToken.", "type": [ "string", "null" ] }, - "status": { - "enum": [ - "draft", - "planned", - "in_progress", - "submitted", - "changes_requested", - "published", - "abandoned" - ], - "description": "Property status.", + "estimatedMinutes": { + "minimum": 0, + "description": "Property estimatedMinutes. Null means not estimated.", "type": [ - "string", + "integer", "null" ] }, - "site": { - "description": "Property site.", + "workStartsOn": { + "description": "Property workStartsOn. Organization-local date.", + "externalDocs": { + "url": "https://schema.org/Date" + }, "type": [ "string", "null" ] }, - "responsible": { - "description": "Property responsible.", + "workEndsOn": { + "description": "Property workEndsOn. Organization-local date.", + "externalDocs": { + "url": "https://schema.org/Date" + }, "type": [ "string", "null" ] }, - "participants": { - "description": "Property participants.", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "priority": { - "enum": [ - "low", - "normal", - "high", - "urgent" - ], - "description": "Property priority.", + "remainingMinutes": { + "minimum": 0, + "description": "Property remainingMinutes. Explicit re-estimation only.", "type": [ - "string", + "integer", "null" ] }, - "plannedStartAt": { - "description": "Property plannedStartAt.", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, + "resultResource": { + "description": "Property resultResource.", "type": [ "string", "null" ] }, - "dueAt": { - "description": "Property dueAt.", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, + "assignee": { + "description": "Property assignee.", "type": [ "string", "null" ] }, - "reviewNote": { - "maxLength": 4000, - "description": "Property reviewNote.", + "status": { + "enum": [ + "planned", + "in_progress", + "completed", + "skipped" + ], + "description": "Property status.", "type": [ "string", "null" ] }, - "labelIds": { - "description": "Property labelIds.", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - } - } - }, - "InterventionActivity.CreateInterventionCommentInput": { - "type": "object", - "description": "Resource InterventionActivityResource.\n\nThe intervention activity feed: member-authored comments and\nsystem-recorded lifecycle events (creation, status transitions).", - "required": [ - "body" - ], - "properties": { - "body": { - "maxLength": 20000, - "description": "Property body.", - "default": "", - "type": "string" - }, - "clientId": { - "maxLength": 64, - "description": "Property clientId.", + "skipReason": { + "maxLength": 2000, + "description": "Property skipReason.", "type": [ "string", "null" @@ -54501,7 +67637,7 @@ } } }, - "InterventionActivity.InterventionActivityOutput.jsonld": { + "InventoryBalance.InventoryBalanceOutput.jsonld-inventory.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -54510,177 +67646,112 @@ "type": "object", "properties": { "id": { - "description": "Property id.", "default": "", "type": "string" }, - "intervention": { - "description": "Property intervention.", + "partId": { "default": "", "type": "string" }, - "kind": { - "description": "Property kind.", - "default": "system", + "warehouseId": { + "default": "", "type": "string" }, - "event": { - "description": "Property event.", - "default": "created", + "quantity": { + "default": "", "type": "string" }, - "actor": { - "description": "Property actor.", - "type": [ - "string", - "null" - ] - }, - "body": { - "description": "Property body.", - "type": [ - "string", - "null" + "valuation": { + "anyOf": [ + { + "$ref": "#/components/schemas/InventoryValuationOutput.jsonld-inventory.read" + }, + { + "type": "null" + } ] - }, - "payload": { - "description": "Property payload.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": [ - "string", - "null" - ] - } - }, - "createdAt": { - "description": "Property createdAt.", - "default": "", - "type": "string" } } } ], - "description": "Resource InterventionActivityResource.\n\nThe intervention activity feed: member-authored comments and\nsystem-recorded lifecycle events (creation, status transitions)." + "description": "Read nonnegative stock balances. @category Resource" }, - "InterventionAllowedActionsOutput.jsonld": { + "InventoryConsumption.DeclareInventoryConsumptionInput-inventory.write": { "type": "object", + "description": "Declare complete physical consumptions, retaining shortages. @category Resource", + "additionalProperties": false, + "required": [ + "clientOperationId", + "partId", + "warehouseId", + "quantity", + "interventionId", + "occurredAt" + ], "properties": { - "canEditDetails": { - "description": "Property canEditDetails.", - "default": false, - "type": "boolean" - }, - "canEditSite": { - "description": "Property canEditSite.", - "default": false, - "type": "boolean" - }, - "canEditResponsible": { - "description": "Property canEditResponsible.", - "default": false, - "type": "boolean" - }, - "canEditPlanning": { - "description": "Property canEditPlanning.", - "default": false, - "type": "boolean" - }, - "canMutateWorkItems": { - "description": "Property canMutateWorkItems.", - "default": false, - "type": "boolean" - }, - "canMutateChanges": { - "description": "Property canMutateChanges.", - "default": false, - "type": "boolean" - }, - "canAssignTeam": { - "description": "Property canAssignTeam.", - "default": false, - "type": "boolean" - }, - "canManageAttachments": { - "description": "Property canManageAttachments.", - "default": false, - "type": "boolean" - }, - "canSubmit": { - "description": "Property canSubmit.", - "default": false, - "type": "boolean" + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" }, - "canWithdraw": { - "description": "Property canWithdraw.", - "default": false, - "type": "boolean" + "partId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" }, - "canDelete": { - "description": "Property canDelete.", - "default": false, - "type": "boolean" + "warehouseId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" }, - "canPublish": { - "description": "Property canPublish.", - "default": false, - "type": "boolean" - } - } - }, - "InterventionAssigneeOutput.jsonld": { - "type": "object", - "properties": { - "member": { - "readOnly": true, - "description": "Property member.", + "quantity": { "default": "", "type": "string" }, - "userId": { - "readOnly": true, - "description": "Property userId.", - "type": [ - "string", - "null" - ] + "interventionId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" }, - "firstName": { - "readOnly": true, - "description": "Property firstName.", + "workItemId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "lastName": { - "readOnly": true, - "description": "Property lastName.", + "equipmentId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "displayName": { - "readOnly": true, - "description": "Property displayName.", + "occurredAt": { "default": "", "type": "string" - }, - "avatarUrl": { - "readOnly": true, - "description": "Property avatarUrl.", - "type": [ - "string", - "null" - ] } } }, - "InterventionAttachment.InterventionAttachmentOutput.jsonld": { + "InventoryConsumption.InventoryConsumptionOutput.jsonld-inventory.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -54689,113 +67760,80 @@ "type": "object", "properties": { "id": { - "readOnly": true, - "description": "Property id.", "default": "", "type": "string" }, - "interventionId": { - "readOnly": true, - "description": "Property interventionId.", + "partId": { "default": "", "type": "string" }, - "fileName": { - "readOnly": true, - "description": "Property fileName.", + "warehouseId": { "default": "", "type": "string" }, - "mimeType": { - "readOnly": true, - "description": "Property mimeType.", + "quantity": { "default": "", "type": "string" }, - "size": { - "readOnly": true, - "description": "Property size.", - "default": 0, - "type": "integer" + "interventionId": { + "default": "", + "type": "string" }, - "label": { - "readOnly": true, - "description": "Property label.", + "workItemId": { "type": [ "string", "null" ] }, - "workItemId": { - "readOnly": true, - "description": "Property workItemId.", + "equipmentId": { "type": [ "string", "null" ] }, - "kind": { - "readOnly": true, - "description": "Property kind.", - "default": "file", + "actorId": { + "default": "", "type": "string" }, - "revision": { - "readOnly": true, - "description": "Property revision.", - "default": 1, - "type": "integer" + "occurredAt": { + "default": "", + "type": "string" }, - "uploadedAt": { - "readOnly": true, - "description": "Property uploadedAt.", + "status": { "default": "", "type": "string" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "movementId": { + "type": [ + "string", + "null" + ] + }, + "late": { + "default": false, + "type": "boolean" + }, + "replayed": { + "default": false, + "type": "boolean" } } } ], - "description": "Resource InterventionAttachmentResource.\n\nGeneralized multipart file attachments on an intervention. Write access\nis phase-based (see `InterventionMediaProcessor`)." + "description": "Declare complete physical consumptions, retaining shortages. @category Resource" }, - "InterventionChange.CreateInterventionChangeInput": { + "InventoryConsumption.ReconcileInventoryConsumptionInput-inventory.write": { "type": "object", - "description": "Resource InterventionChangeResource.", - "required": [ - "intervention", - "resource", - "patch" - ], - "properties": { - "intervention": { - "description": "Property intervention.", - "default": "", - "type": "string" - }, - "workItem": { - "description": "Property workItem.", - "type": [ - "string", - "null" - ] - }, - "resource": { - "description": "Property resource.", - "default": "", - "type": "string" - }, - "patch": { - "description": "Property patch.", - "type": "object", - "additionalProperties": { - "type": [ - "string", - "null" - ] - } - } - } + "description": "Declare complete physical consumptions, retaining shortages. @category Resource", + "additionalProperties": false }, - "InterventionChange.InterventionChangeOutput.jsonld": { + "InventoryMovement.InventoryMovementOutput.jsonld-inventory.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -54804,93 +67842,126 @@ "type": "object", "properties": { "id": { - "description": "Property id.", "default": "", "type": "string" }, - "intervention": { - "description": "Property intervention.", + "partId": { "default": "", "type": "string" }, - "workItem": { - "description": "Property workItem.", - "type": [ - "string", - "null" - ] - }, - "resource": { - "description": "Property resource.", + "warehouseId": { "default": "", "type": "string" }, - "patch": { - "description": "Property patch.", - "type": "object", - "additionalProperties": { - "type": [ - "string", - "null" - ] - } + "kind": { + "default": "", + "type": "string" }, - "status": { - "description": "Property status.", - "default": "proposed", + "quantity": { + "default": "", "type": "string" }, - "revision": { - "description": "Property revision.", - "default": 1, - "type": "integer" + "reason": { + "default": "", + "type": "string" }, - "createdAt": { - "description": "Property createdAt.", + "actorId": { "default": "", "type": "string" }, - "updatedAt": { - "description": "Property updatedAt.", + "occurredAt": { "default": "", "type": "string" + }, + "interventionId": { + "type": [ + "string", + "null" + ] + }, + "workItemId": { + "type": [ + "string", + "null" + ] + }, + "equipmentId": { + "type": [ + "string", + "null" + ] + }, + "correctionOf": { + "type": [ + "string", + "null" + ] + }, + "sourceReceiptId": { + "type": [ + "string", + "null" + ] + }, + "late": { + "default": false, + "type": "boolean" + }, + "replayed": { + "default": false, + "type": "boolean" + }, + "valuation": { + "anyOf": [ + { + "$ref": "#/components/schemas/InventoryValuationOutput.jsonld-inventory.read" + }, + { + "type": "null" + } + ] } } } ], - "description": "Resource InterventionChangeResource." + "description": "Read immutable motivated stock movements. @category Resource" }, - "InterventionChange.UpdateInterventionChangeInput.jsonMergePatch": { + "InventoryPart.CreateInventoryPartInput-inventory.write": { "type": "object", - "description": "Resource InterventionChangeResource.", + "description": "Manage quantitative part and consumable references. @category Resource", + "additionalProperties": false, + "required": [ + "code", + "label", + "unit" + ], "properties": { - "patch": { - "description": "Property patch.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": [ - "string", - "null" - ] - } + "code": { + "maxLength": 100, + "default": "", + "type": "string" }, - "status": { + "label": { + "maxLength": 255, + "default": "", + "type": "string" + }, + "unit": { + "maxLength": 32, + "default": "piece", + "type": "string" + }, + "kind": { "enum": [ - "proposed", - "rejected" + "part", + "consumable" ], - "description": "Property status.", - "type": [ - "string", - "null" - ] + "default": "part", + "type": "string" } } }, - "InterventionIssue.InterventionIssueOutput.jsonld": { + "InventoryPart.InventoryPartOutput.jsonld-inventory.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -54898,219 +67969,206 @@ { "type": "object", "properties": { - "severity": { - "description": "Property severity.", + "id": { "default": "", "type": "string" }, - "resource": { - "description": "Property resource.", + "code": { "default": "", "type": "string" }, - "field": { - "description": "Property field.", - "type": [ - "string", - "null" - ] + "label": { + "default": "", + "type": "string" }, - "message": { - "description": "Property message.", + "unit": { + "default": "", + "type": "string" + }, + "kind": { "default": "", "type": "string" + }, + "archived": { + "default": false, + "type": "boolean" } } } ], - "description": "Resource InterventionIssueResource." + "description": "Manage quantitative part and consumable references. @category Resource" }, - "InterventionLabel.CreateInterventionLabelInput": { + "InventoryPart.PatchInventoryPartInput-inventory.write.jsonMergePatch": { "type": "object", - "description": "Resource InterventionLabelResource.\n\nOrganization-scoped intervention labels: small, reusable `{name, color}`\ntags interventions can be assigned to (`Intervention.labelIds`).", - "required": [ - "organization", - "name", - "color" - ], + "description": "Manage quantitative part and consumable references. @category Resource", + "additionalProperties": false, "properties": { - "organization": { - "description": "Property organization.", + "label": { + "minLength": 1, + "maxLength": 255, + "type": [ + "string", + "null" + ] + }, + "unit": { + "minLength": 1, + "maxLength": 32, + "type": [ + "string", + "null" + ] + }, + "archived": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "InventoryValuationOutput.jsonld-inventory.read": { + "type": "object", + "properties": { + "unitCost": { + "type": [ + "string", + "null" + ] + }, + "totalValue": { + "type": [ + "string", + "null" + ] + }, + "currency": { "default": "", - "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", "type": "string" }, - "name": { - "maxLength": 50, - "description": "Property name.", + "incomplete": { + "default": false, + "type": "boolean" + } + } + }, + "InventoryWarehouse.CreateInventoryWarehouseInput-inventory.write": { + "type": "object", + "description": "Manage warehouses. @category Resource", + "additionalProperties": false, + "required": [ + "code", + "name" + ], + "properties": { + "code": { + "maxLength": 100, "default": "", "type": "string" }, - "color": { - "pattern": "^(#[0-9a-fA-F]{6})$", - "description": "Property color.", + "name": { + "maxLength": 255, "default": "", - "example": "#3b82f6", "type": "string" } } }, - "InterventionLabel.InterventionLabelOutput.jsonld": { + "InventoryWarehouse.InventoryWarehouseOutput.jsonld-inventory.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, - { - "type": "object", - "properties": { - "id": { - "description": "Property id.", - "default": "", - "type": "string" - }, - "organization": { - "description": "Property organization.", - "default": "", - "type": "string" - }, - "name": { - "description": "Property name.", + { + "type": "object", + "properties": { + "id": { "default": "", "type": "string" }, - "color": { - "description": "Property color.", + "code": { "default": "", "type": "string" }, - "createdAt": { - "description": "Property createdAt.", + "name": { "default": "", "type": "string" }, - "updatedAt": { - "description": "Property updatedAt.", - "default": "", - "type": "string" + "archived": { + "default": false, + "type": "boolean" } } } ], - "description": "Resource InterventionLabelResource.\n\nOrganization-scoped intervention labels: small, reusable `{name, color}`\ntags interventions can be assigned to (`Intervention.labelIds`)." + "description": "Manage warehouses. @category Resource" }, - "InterventionLabel.UpdateInterventionLabelInput.jsonMergePatch": { + "InventoryWarehouse.PatchInventoryWarehouseInput-inventory.write.jsonMergePatch": { "type": "object", - "description": "Resource InterventionLabelResource.\n\nOrganization-scoped intervention labels: small, reusable `{name, color}`\ntags interventions can be assigned to (`Intervention.labelIds`).", + "description": "Manage warehouses. @category Resource", + "additionalProperties": false, "properties": { "name": { "minLength": 1, - "maxLength": 50, - "description": "Property name.", + "maxLength": 255, "type": [ "string", "null" ] }, - "color": { - "pattern": "^(#[0-9a-fA-F]{6})$", - "description": "Property color.", - "example": "#3b82f6", + "archived": { "type": [ - "string", + "boolean", "null" ] } } }, - "InterventionRecurrence.CreateInterventionRecurrenceInput": { + "Inventorycorrections.CorrectInventoryStockInput-inventory.write": { "type": "object", - "description": "Resource InterventionRecurrenceResource.\n\nOrganization-scoped recurring intervention schedules: a rule (frequency +\ninterval + anchor date, in a fixed timezone) that periodically\nmaterializes an intervention template into a real intervention draft\nthrough the recurring sweep (`MaterializeDueRecurrencesHandler`).", + "description": "Motivated stock corrections. @category Resource", + "additionalProperties": false, "required": [ - "organization", - "template", - "name", - "anchorDate", - "timezone" + "clientOperationId", + "partId", + "warehouseId", + "quantity", + "reason" ], "properties": { - "organization": { - "description": "Property organization.", - "default": "", - "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", - "type": "string" - }, - "template": { - "description": "Property template.", + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "default": "", - "example": "/api/intervention-templates/550e8400-e29b-41d4-a716-446655440000", "type": "string" }, - "name": { - "maxLength": 160, - "description": "Property name.", + "partId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "default": "", "type": "string" }, - "site": { - "description": "Property site.", - "type": [ - "string", - "null" - ] - }, - "responsible": { - "description": "Property responsible.", - "type": [ - "string", - "null" - ] - }, - "frequency": { - "enum": [ - "weekly", - "monthly", - "quarterly", - "semiannual", - "annual" - ], - "description": "Property frequency.", - "default": "monthly", - "type": "string" - }, - "interval": { - "minimum": 1, - "maximum": 12, - "description": "Property interval.", - "default": 1, - "type": "integer" - }, - "anchorDate": { - "description": "Property anchorDate.", + "warehouseId": { + "format": "uuid", "externalDocs": { - "url": "https://schema.org/DateTime" + "url": "https://schema.org/identifier" }, "default": "", - "example": "2026-01-31T00:00:00+00:00", "type": "string" }, - "timezone": { - "description": "Property timezone.", - "default": "UTC", - "example": "Europe/Paris", + "quantity": { + "default": "", "type": "string" }, - "leadTimeDays": { - "minimum": 0, - "maximum": 90, - "description": "Property leadTimeDays.", - "default": 7, - "type": "integer" + "reason": { + "maxLength": 2000, + "default": "", + "type": "string" }, - "endAt": { - "description": "Property endAt.", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, + "unitCost": { "type": [ "string", "null" @@ -55118,7 +68176,7 @@ } } }, - "InterventionRecurrence.InterventionRecurrenceOutput.jsonld": { + "Inventorycorrections.InventoryMovementOutput.jsonld-inventory.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -55127,348 +68185,400 @@ "type": "object", "properties": { "id": { - "description": "Property id.", "default": "", "type": "string" }, - "organization": { - "description": "Property organization.", + "partId": { "default": "", "type": "string" }, - "template": { - "description": "Property template.", + "warehouseId": { "default": "", "type": "string" }, - "name": { - "description": "Property name.", + "kind": { "default": "", "type": "string" }, - "site": { - "description": "Property site.", + "quantity": { + "default": "", + "type": "string" + }, + "reason": { + "default": "", + "type": "string" + }, + "actorId": { + "default": "", + "type": "string" + }, + "occurredAt": { + "default": "", + "type": "string" + }, + "interventionId": { "type": [ "string", "null" ] }, - "responsible": { - "description": "Property responsible.", + "workItemId": { "type": [ "string", "null" ] }, - "frequency": { - "description": "Property frequency.", + "equipmentId": { + "type": [ + "string", + "null" + ] + }, + "correctionOf": { + "type": [ + "string", + "null" + ] + }, + "sourceReceiptId": { + "type": [ + "string", + "null" + ] + }, + "late": { + "default": false, + "type": "boolean" + }, + "replayed": { + "default": false, + "type": "boolean" + }, + "valuation": { + "anyOf": [ + { + "$ref": "#/components/schemas/InventoryValuationOutput.jsonld-inventory.read" + }, + { + "type": "null" + } + ] + } + } + } + ], + "description": "Motivated stock corrections. @category Resource" + }, + "Inventoryreturns.InventoryMovementOutput.jsonld-inventory.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { "default": "", "type": "string" }, - "interval": { - "description": "Property interval.", - "default": 1, - "type": "integer" + "partId": { + "default": "", + "type": "string" }, - "anchorDate": { - "description": "Property anchorDate.", + "warehouseId": { "default": "", "type": "string" }, - "timezone": { - "description": "Property timezone.", + "kind": { "default": "", "type": "string" }, - "leadTimeDays": { - "description": "Property leadTimeDays.", - "default": 7, - "type": "integer" + "quantity": { + "default": "", + "type": "string" }, - "nextOccurrenceAt": { - "description": "Property nextOccurrenceAt.", + "reason": { "default": "", "type": "string" }, - "lastMaterializedAt": { - "description": "Property lastMaterializedAt.", + "actorId": { + "default": "", + "type": "string" + }, + "occurredAt": { + "default": "", + "type": "string" + }, + "interventionId": { "type": [ "string", "null" ] }, - "isActive": { - "description": "Property isActive.", - "default": true, - "type": "boolean" + "workItemId": { + "type": [ + "string", + "null" + ] }, - "endAt": { - "description": "Property endAt.", + "equipmentId": { "type": [ "string", "null" ] }, - "createdAt": { - "description": "Property createdAt.", - "default": "", - "type": "string" + "correctionOf": { + "type": [ + "string", + "null" + ] }, - "updatedAt": { - "description": "Property updatedAt.", - "default": "", - "type": "string" + "sourceReceiptId": { + "type": [ + "string", + "null" + ] + }, + "late": { + "default": false, + "type": "boolean" + }, + "replayed": { + "default": false, + "type": "boolean" + }, + "valuation": { + "anyOf": [ + { + "$ref": "#/components/schemas/InventoryValuationOutput.jsonld-inventory.read" + }, + { + "type": "null" + } + ] } } } ], - "description": "Resource InterventionRecurrenceResource.\n\nOrganization-scoped recurring intervention schedules: a rule (frequency +\ninterval + anchor date, in a fixed timezone) that periodically\nmaterializes an intervention template into a real intervention draft\nthrough the recurring sweep (`MaterializeDueRecurrencesHandler`)." + "description": "Motivated stock returns. @category Resource" }, - "InterventionRecurrence.UpdateInterventionRecurrenceInput.jsonMergePatch": { + "Inventoryreturns.ReturnInventoryConsumptionInput-inventory.write": { "type": "object", - "description": "Resource InterventionRecurrenceResource.\n\nOrganization-scoped recurring intervention schedules: a rule (frequency +\ninterval + anchor date, in a fixed timezone) that periodically\nmaterializes an intervention template into a real intervention draft\nthrough the recurring sweep (`MaterializeDueRecurrencesHandler`).", + "description": "Motivated stock returns. @category Resource", + "additionalProperties": false, + "required": [ + "clientOperationId", + "consumptionId", + "quantity", + "reason" + ], "properties": { - "name": { - "minLength": 1, - "maxLength": 160, - "description": "Property name.", + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "consumptionId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "quantity": { + "default": "", + "type": "string" + }, + "reason": { + "maxLength": 2000, + "default": "", + "type": "string" + } + } + }, + "LegacyEquipment.AssignToFacilityInput-equipment.write": { + "type": "object", + "description": "Fire safety equipment and assets managed at organization level.", + "required": [ + "facilityId" + ], + "properties": { + "facilityId": { + "format": "uuid", + "description": "Target facility identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "example": "550e8400-e29b-41d4-a716-446655440001", + "type": "string" + }, + "installedAt": { + "description": "Optional installation date (ISO 8601)", + "example": "2024-01-15T09:00:00+00:00", "type": [ "string", "null" ] - }, - "site": { - "description": "Property site.", + } + } + }, + "LegacyEquipment.CreateEquipmentInput-equipment.write": { + "type": "object", + "description": "Fire safety equipment and assets managed at organization level.", + "properties": { + "name": { + "maxLength": 255, "type": [ "string", "null" ] }, - "responsible": { - "description": "Property responsible.", + "assetCode": { + "maxLength": 100, "type": [ "string", "null" ] }, - "frequency": { + "criticality": { "enum": [ - "weekly", - "monthly", - "quarterly", - "semiannual", - "annual" + "low", + "medium", + "high", + "critical" ], - "description": "Property frequency.", "type": [ "string", "null" ] }, - "interval": { - "minimum": 1, - "maximum": 12, - "description": "Property interval.", - "type": [ - "integer", - "null" - ] + "technicalProperties": { + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } }, - "anchorDate": { - "description": "Property anchorDate.", + "clientId": { + "format": "uuid", + "description": "Client-generated UUID for idempotent offline creation", "externalDocs": { - "url": "https://schema.org/DateTime" + "url": "https://schema.org/identifier" }, "type": [ "string", "null" ] }, - "timezone": { - "description": "Property timezone.", + "organization": { + "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", + "description": "Organization IRI, required on the canonical endpoint", "type": [ "string", "null" ] }, - "leadTimeDays": { - "minimum": 0, - "maximum": 90, - "description": "Property leadTimeDays.", + "intervention": { + "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", + "description": "Optional field intervention IRI", "type": [ - "integer", + "string", "null" ] }, - "endAt": { - "description": "Property endAt.", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, + "facility": { + "pattern": "^(/api/facilities/[0-9a-fA-F-]{36})$", + "description": "Optional facility IRI", "type": [ "string", "null" ] }, - "isActive": { - "description": "Property isActive.", - "example": false, + "onboardingSessionId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ - "boolean", + "string", "null" ] - } - } - }, - "InterventionResponsibleStatisticOutput.jsonld": { - "type": "object", - "properties": { - "memberId": { - "description": "Property memberId.", - "default": "", - "type": "string" }, - "displayName": { - "description": "Property displayName.", + "onboardingItemKey": { "type": [ "string", "null" ] }, - "count": { - "description": "Property count.", - "default": 0, - "type": "integer" - } - } - }, - "InterventionSiteStatisticOutput.jsonld": { - "type": "object", - "properties": { - "siteId": { - "description": "Property siteId.", + "type": { + "maxLength": 32, + "description": "Equipment type", "default": "", + "example": "fire_extinguisher", "type": "string" }, - "siteName": { - "description": "Property siteName.", + "subType": { + "maxLength": 100, + "description": "Optional free-form sub-type", + "example": "CO2 6kg", "type": [ "string", "null" ] }, - "count": { - "description": "Property count.", - "default": 0, - "type": "integer" - } - } - }, - "InterventionStatistics.InterventionStatisticsOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "total": { - "description": "Property total.", - "default": 0, - "type": "integer" - }, - "byStatus": { - "description": "Property byStatus.", - "type": "object", - "additionalProperties": { - "type": "integer" - } - }, - "byPriority": { - "description": "Property byPriority.", - "type": "object", - "additionalProperties": { - "type": "integer" - } - }, - "overdue": { - "description": "Property overdue.", - "default": 0, - "type": "integer" - }, - "dueSoon": { - "description": "Property dueSoon.", - "default": 0, - "type": "integer" - }, - "bySite": { - "description": "Property bySite.", - "type": "array", - "items": { - "$ref": "#/components/schemas/InterventionSiteStatisticOutput.jsonld" - } - }, - "byResponsible": { - "description": "Property byResponsible.", - "type": "array", - "items": { - "$ref": "#/components/schemas/InterventionResponsibleStatisticOutput.jsonld" - } - }, - "averagePublicationDays": { - "description": "Property averagePublicationDays.", - "type": [ - "number", - "null" - ] - } - } - } - ], - "description": "Whole-organization intervention statistics for the list page KPI cards and the kanban column counters." - }, - "InterventionTargetOutput.jsonld": { - "type": "object", - "properties": { - "resource": { - "readOnly": true, - "description": "Property resource.", - "default": "", - "type": "string" + "brand": { + "maxLength": 100, + "description": "Optional brand name", + "example": "Sicli", + "type": [ + "string", + "null" + ] }, - "kind": { - "readOnly": true, - "description": "Property kind.", - "default": "", - "type": "string" + "model": { + "maxLength": 100, + "description": "Optional model name", + "example": "Pro 6", + "type": [ + "string", + "null" + ] }, - "label": { - "readOnly": true, - "description": "Property label.", - "default": "", - "type": "string" + "serialNumber": { + "maxLength": 100, + "description": "Optional serial number (unique per organization)", + "example": "SN-2024-001", + "type": [ + "string", + "null" + ] + }, + "locationLabel": { + "maxLength": 255, + "description": "Optional free-form location label", + "example": "Near staircase B", + "type": [ + "string", + "null" + ] } - } - }, - "InterventionTeamAssignment.AssignInterventionTeamInput": { - "type": "object", - "description": "Resource InterventionTeamAssignmentResource.\n\nSnapshot-expands an organization team's active members into an\nintervention's participants list (requires\norganization.interventions.plan). Participants follow the schedule\nmutability guard {@see \\Intervention\\Domain\\Model\\Intervention\\Intervention}\nenforces, not a draft-only freeze: the assignment stays possible through\n`planned`, `in_progress` and `changes_requested`, and is refused only once\nthe intervention is `submitted` (frozen under review) or `published` /\n`abandoned` (immutable). Kept as a dedicated resource file to avoid growing\nthe large {@see InterventionResource}.", + }, "required": [ - "teamId" - ], - "properties": { - "teamId": { - "format": "uuid", - "description": "Property teamId.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "default": "", - "type": "string" - } - } + "type" + ] }, - "InterventionTeamAssignment.InterventionOutput.jsonld": { + "LegacyEquipment.EquipmentOutput.jsonld-equipment.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -55476,181 +68586,229 @@ { "type": "object", "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "assetCode": { + "type": [ + "string", + "null" + ] + }, + "criticality": { + "type": [ + "string", + "null" + ] + }, + "technicalProperties": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + }, + "predecessorEquipmentId": { + "type": [ + "string", + "null" + ] + }, + "successorEquipmentId": { + "type": [ + "string", + "null" + ] + }, + "intervention": { + "description": "Property intervention.", + "type": [ + "string", + "null" + ] + }, + "recordStatus": { + "description": "Property recordStatus.", + "default": "published", + "type": "string" + }, + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" + }, "id": { + "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organization": { - "description": "Property organization.", + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", "default": "", "type": "string" }, - "number": { - "description": "Property number.", - "default": 0, - "type": "integer" + "facilityId": { + "readOnly": true, + "description": "Property facilityId.", + "type": [ + "string", + "null" + ] }, "type": { + "readOnly": true, "description": "Property type.", - "default": "site_setup", - "type": "string" - }, - "name": { - "description": "Property name.", "default": "", "type": "string" }, - "description": { - "description": "Property description.", + "subType": { + "readOnly": true, + "description": "Property subType.", "type": [ "string", "null" ] }, - "status": { - "description": "Property status.", - "default": "draft", - "type": "string" - }, - "site": { - "description": "Property site.", + "brand": { + "readOnly": true, + "description": "Property brand.", "type": [ "string", "null" ] }, - "responsible": { - "description": "Property responsible.", + "model": { + "readOnly": true, + "description": "Property model.", "type": [ "string", "null" ] }, - "participants": { - "description": "Property participants.", - "type": "array", - "items": { - "type": "string" - } - }, - "priority": { - "description": "Property priority.", - "default": "normal", - "type": "string" - }, - "plannedStartAt": { - "description": "Property plannedStartAt.", + "serialNumber": { + "readOnly": true, + "description": "Property serialNumber.", "type": [ "string", "null" ] }, - "dueAt": { - "description": "Property dueAt.", + "locationLabel": { + "readOnly": true, + "description": "Property locationLabel.", "type": [ "string", "null" ] }, - "reviewNote": { - "description": "Property reviewNote.", + "facilityName": { + "readOnly": true, + "description": "Display name of the assigned facility", "type": [ "string", "null" ] }, - "revision": { - "description": "Property revision.", - "default": 1, - "type": "integer" + "status": { + "readOnly": true, + "description": "Property status.", + "default": "", + "type": "string" }, - "recurrence": { - "description": "Property recurrence.", + "installedAt": { + "readOnly": true, + "description": "Property installedAt.", "type": [ "string", "null" ] }, - "allowedTransitions": { - "description": "Property allowedTransitions.", + "commissionedAt": { + "readOnly": true, + "description": "Property commissionedAt.", + "type": [ + "string", + "null" + ] + }, + "tags": { + "readOnly": true, + "description": "Property tags.", "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/TagOutput.jsonld-equipment.read" } }, - "allowedActions": { - "description": "Property allowedActions.", - "anyOf": [ - { - "$ref": "#/components/schemas/InterventionAllowedActionsOutput.jsonld" - }, - { - "type": "null" - } - ] - }, - "facilitiesCount": { - "description": "Property facilitiesCount.", - "default": 0, - "type": "integer" - }, - "equipmentCount": { - "description": "Property equipmentCount.", - "default": 0, - "type": "integer" - }, - "inspectionsCount": { - "description": "Property inspectionsCount.", - "default": 0, - "type": "integer" - }, - "blockersCount": { - "description": "Property blockersCount.", - "default": 0, - "type": "integer" - }, - "workItemsCount": { - "description": "Property workItemsCount.", - "default": 0, - "type": "integer" + "maintenanceDueStatus": { + "readOnly": true, + "description": "Property maintenanceDueStatus.", + "default": "unscheduled", + "type": "string" }, - "completedWorkItemsCount": { - "description": "Property completedWorkItemsCount.", - "default": 0, - "type": "integer" + "controlDueStatus": { + "readOnly": true, + "description": "Due status of control operations, independent from service operations and anomalies.", + "default": "unscheduled", + "type": "string" }, - "proposedChangesCount": { - "description": "Property proposedChangesCount.", - "default": 0, - "type": "integer" + "serviceDueStatus": { + "readOnly": true, + "description": "Due status of service operations, independent from periodic controls.", + "default": "unscheduled", + "type": "string" }, - "commentsCount": { - "description": "Property commentsCount.", - "default": 0, - "type": "integer" + "controlNextDueAt": { + "readOnly": true, + "description": "Next control deadline in ISO 8601, when scheduled.", + "type": [ + "string", + "null" + ] }, - "hasSignature": { - "description": "Property hasSignature.", - "default": false, - "type": "boolean" + "serviceNextDueAt": { + "readOnly": true, + "description": "Next service deadline in ISO 8601, when scheduled.", + "type": [ + "string", + "null" + ] }, - "labels": { - "description": "Property labels.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "planPosition": { + "readOnly": true, + "description": "Position pinned on a floor plan attachment (detail read only)", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] } }, "createdAt": { + "readOnly": true, "description": "Property createdAt.", "default": "", "type": "string" }, "updatedAt": { + "readOnly": true, "description": "Property updatedAt.", "default": "", "type": "string" @@ -55658,136 +68816,728 @@ } } ], - "description": "Resource InterventionTeamAssignmentResource.\n\nSnapshot-expands an organization team's active members into an\nintervention's participants list (requires\norganization.interventions.plan). Participants follow the schedule\nmutability guard {@see \\Intervention\\Domain\\Model\\Intervention\\Intervention}\nenforces, not a draft-only freeze: the assignment stays possible through\n`planned`, `in_progress` and `changes_requested`, and is refused only once\nthe intervention is `submitted` (frozen under review) or `published` /\n`abandoned` (immutable). Kept as a dedicated resource file to avoid growing\nthe large {@see InterventionResource}." + "description": "Fire safety equipment and assets managed at organization level." }, - "InterventionTemplate.CreateInterventionTemplateInput": { + "LegacyEquipment.SetEquipmentPlanPositionInput-equipment.write": { "type": "object", - "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence \u2014 see the module changelog for later lots).", + "description": "Fire safety equipment and assets managed at organization level.", "required": [ - "organization", - "name" + "attachmentId", + "x", + "y" ], "properties": { + "attachmentId": { + "format": "uuid", + "description": "Floor plan attachment identifier the position is bound to. Required together with x/y; use null on all three to clear.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "example": "550e8400-e29b-41d4-a716-446655440001", + "type": [ + "string", + "null" + ] + }, + "x": { + "minimum": 0, + "maximum": 1, + "description": "Normalized x coordinate in [0, 1]. Required together with attachmentId/y; use null on all three to clear.", + "example": 0.42, + "type": [ + "number", + "null" + ] + }, + "y": { + "minimum": 0, + "maximum": 1, + "description": "Normalized y coordinate in [0, 1]. Required together with attachmentId/x; use null on all three to clear.", + "example": 0.17, + "type": [ + "number", + "null" + ] + } + } + }, + "LegacyEquipment.UpdateEquipmentInput-equipment.write.jsonMergePatch": { + "type": "object", + "description": "Fire safety equipment and assets managed at organization level.", + "properties": { + "name": { + "maxLength": 255, + "type": [ + "string", + "null" + ] + }, + "assetCode": { + "maxLength": 100, + "type": [ + "string", + "null" + ] + }, + "criticality": { + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": [ + "string", + "null" + ] + }, + "technicalProperties": { + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + }, + "type": { + "maxLength": 32, + "description": "Equipment type", + "default": "", + "example": "fire_extinguisher", + "type": "string" + }, + "subType": { + "maxLength": 100, + "description": "Optional free-form sub-type", + "example": "CO2 6kg", + "type": [ + "string", + "null" + ] + }, + "brand": { + "maxLength": 100, + "description": "Optional brand name", + "example": "Sicli", + "type": [ + "string", + "null" + ] + }, + "model": { + "maxLength": 100, + "description": "Optional model name", + "example": "Pro 6", + "type": [ + "string", + "null" + ] + }, + "serialNumber": { + "maxLength": 100, + "description": "Optional serial number (unique per organization)", + "example": "SN-2024-001", + "type": [ + "string", + "null" + ] + }, + "locationLabel": { + "maxLength": 255, + "description": "Optional free-form location label", + "example": "Near staircase B", + "type": [ + "string", + "null" + ] + } + } + }, + "LegacyFacility.CreateFacilityInput-facility.write": { + "type": "object", + "description": "Generic organizational facilities (site/building/floor/zone/area).", + "properties": { + "clientId": { + "format": "uuid", + "description": "Client-generated UUID for idempotent offline creation", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, "organization": { - "description": "Property organization.", + "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", + "description": "Organization IRI, required on the canonical endpoint", + "type": [ + "string", + "null" + ] + }, + "intervention": { + "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", + "description": "Optional field intervention IRI", + "type": [ + "string", + "null" + ] + }, + "onboardingSessionId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "onboardingItemKey": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ], + "description": "Facility type", "default": "", - "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", + "example": "site", "type": "string" }, "name": { - "minLength": 2, - "maxLength": 160, - "description": "Property name.", + "minLength": 2, + "maxLength": 120, + "description": "Facility display name", "default": "", + "example": "Headquarters", "type": "string" }, - "description": { - "maxLength": 5000, - "description": "Property description.", + "parentFacilityId": { + "format": "uuid", + "description": "Optional parent facility identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "example": "550e8400-e29b-41d4-a716-446655440001", "type": [ "string", "null" ] }, - "type": { - "enum": [ - "site_setup", - "inventory", - "inspection_campaign" - ], - "description": "Property type.", - "default": "site_setup", - "type": "string" - }, - "priority": { - "enum": [ - "low", - "normal", - "high", - "urgent" - ], - "description": "Property priority.", - "default": "normal", - "type": "string" - }, - "defaultSite": { - "description": "Property defaultSite.", + "code": { + "maxLength": 80, + "description": "Optional facility code", + "example": "SITE-PAR-001", "type": [ "string", "null" ] }, - "defaultResponsible": { - "description": "Property defaultResponsible.", + "address": { + "maxLength": 255, + "description": "Optional address", + "example": "10 rue de Rivoli, Paris", "type": [ "string", "null" ] }, - "duration": { - "description": "Property duration.", - "example": "P14D", + "latitude": { + "minimum": -90, + "maximum": 90, + "description": "Optional latitude, required together with longitude", + "example": 48.8566, "type": [ - "string", + "number", "null" ] }, - "labelIds": { - "description": "Property labelIds.", - "type": "array", - "items": { - "type": "string" + "longitude": { + "minimum": -180, + "maximum": 180, + "description": "Optional longitude, required together with latitude", + "example": 2.3522, + "type": [ + "number", + "null" + ] + }, + "metadata": { + "description": "Optional free-form metadata", + "example": { + "country": "FR", + "timezone": "Europe/Paris" + }, + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] } }, - "items": { - "description": "Property items.", - "type": "array", - "items": { - "$ref": "#/components/schemas/InterventionTemplateItemInput" + "levelIndex": { + "minimum": -100, + "maximum": 200, + "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", + "example": 0, + "type": [ + "integer", + "null" + ] + }, + "elevationMeters": { + "minimum": -10000, + "maximum": 10000, + "description": "Optional floor elevation in meters", + "example": -3, + "type": [ + "number", + "null" + ] + }, + "heightMeters": { + "minimum": 0, + "exclusiveMinimum": 0, + "maximum": 1000, + "description": "Optional floor height in meters", + "example": 3, + "type": [ + "number", + "null" + ] + }, + "customerId": { + "format": "uuid", + "description": "Internal customer identifier for a root site", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type", + "name" + ] + }, + "LegacyFacility.DuplicateFacilitySubtreeInput-facility.write": { + "type": "object", + "description": "Generic organizational facilities (site/building/floor/zone/area).", + "properties": { + "name": { + "minLength": 2, + "maxLength": 120, + "description": "Name for the copy's root. Defaults to \"{original} (copy)\" when omitted.", + "example": "Site A (copy)", + "type": [ + "string", + "null" + ] + }, + "parentFacilityId": { + "format": "uuid", + "description": "Target parent facility identifier for the copy's root. Defaults to the source facility's own parent when omitted.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "example": "550e8400-e29b-41d4-a716-446655440001", + "type": [ + "string", + "null" + ] + } + } + }, + "LegacyFacility.FacilityBuildingModelOutput.jsonld-facility.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "buildingId": { + "readOnly": true, + "description": "Property buildingId.", + "default": "", + "type": "string" + }, + "buildingName": { + "readOnly": true, + "description": "Property buildingName.", + "default": "", + "type": "string" + }, + "floors": { + "readOnly": true, + "description": "Property floors.", + "type": "array", + "items": { + "type": "object", + "required": [ + "facilityId", + "name", + "levelIndex", + "elevationMeters", + "heightMeters", + "status", + "hierarchyIssues", + "plan", + "outline", + "rooms", + "equipment", + "diagnostics" + ], + "properties": { + "facilityId": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "levelIndex": { + "type": [ + "integer", + "null" + ] + }, + "elevationMeters": { + "type": [ + "number", + "null" + ] + }, + "heightMeters": { + "type": [ + "number", + "null" + ] + }, + "status": { + "type": "string" + }, + "hierarchyIssues": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "missing_parent", + "invalid_parent_type", + "unpublished_parent", + "invalid_ancestor", + "cycle", + "depth_exceeded" + ] + } + }, + "plan": { + "type": [ + "object", + "null" + ], + "required": [ + "attachmentId", + "imageWidth", + "imageHeight", + "calibration", + "calibrationBuildingId", + "calibrationIssue" + ], + "properties": { + "attachmentId": { + "type": "string", + "format": "uuid" + }, + "imageWidth": { + "type": [ + "integer", + "null" + ] + }, + "imageHeight": { + "type": [ + "integer", + "null" + ] + }, + "calibrationBuildingId": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "calibrationIssue": { + "type": [ + "string", + "null" + ], + "enum": [ + "building_changed", + "unverified_frame", + null + ] + }, + "calibration": { + "type": [ + "object", + "null" + ], + "required": [ + "widthMeters", + "rotationDegrees", + "offsetXMeters", + "offsetZMeters" + ], + "properties": { + "widthMeters": { + "type": "number" + }, + "rotationDegrees": { + "type": "number" + }, + "offsetXMeters": { + "type": "number" + }, + "offsetZMeters": { + "type": "number" + } + } + } + } + }, + "outline": { + "type": [ + "object", + "null" + ], + "required": [ + "source", + "points" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "plan_geometry", + "rooms_bbox", + "image_rect" + ] + }, + "points": { + "type": "array", + "minItems": 3, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + } + } + }, + "rooms": { + "type": "array", + "items": { + "type": "object", + "required": [ + "facilityId", + "name", + "type", + "status", + "points" + ], + "properties": { + "facilityId": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "status": { + "type": "string" + }, + "points": { + "type": "array", + "minItems": 3, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + } + } + } + }, + "equipment": { + "type": "array", + "items": { + "type": "object", + "required": [ + "equipmentId", + "facilityId", + "type", + "serialNumber", + "locationLabel", + "status", + "position", + "placementIssue" + ], + "properties": { + "equipmentId": { + "type": "string", + "format": "uuid" + }, + "facilityId": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string" + }, + "serialNumber": { + "type": [ + "string", + "null" + ] + }, + "locationLabel": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "position": { + "type": [ + "object", + "null" + ], + "required": [ + "attachmentId", + "x", + "y" + ], + "properties": { + "attachmentId": { + "type": "string", + "format": "uuid" + }, + "x": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "y": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "placementIssue": { + "type": [ + "string", + "null" + ], + "enum": [ + "missing_plan", + "unplaced", + "other_plan", + "invalid_position", + "outside_ancestry", + null + ] + } + } + } + }, + "diagnostics": { + "type": "object", + "required": [ + "invalidGeometryCount", + "unpositionedEquipmentCount", + "geometryIssues" + ], + "properties": { + "invalidGeometryCount": { + "type": "integer", + "minimum": 0 + }, + "unpositionedEquipmentCount": { + "type": "integer", + "minimum": 0 + }, + "geometryIssues": { + "type": "array", + "items": { + "type": "object", + "required": [ + "facilityId", + "code" + ], + "properties": { + "facilityId": { + "type": "string", + "format": "uuid" + }, + "code": { + "type": "string", + "enum": [ + "invalid_geometry", + "plan_unavailable", + "outside_ancestry", + "other_plan" + ] + } + } + } + } + } + } + } + } + } } } - } - }, - "InterventionTemplate.InstantiateInterventionTemplateInput": { - "type": "object", - "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence \u2014 see the module changelog for later lots).", - "properties": { - "name": { - "minLength": 2, - "maxLength": 160, - "description": "Property name.", - "type": [ - "string", - "null" - ] - }, - "site": { - "description": "Property site.", - "type": [ - "string", - "null" - ] - }, - "responsible": { - "description": "Property responsible.", - "type": [ - "string", - "null" - ] - }, - "plannedStartAt": { - "description": "Property plannedStartAt.", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, - "type": [ - "string", - "null" - ] - } - } + ], + "description": "Generic organizational facilities (site/building/floor/zone/area)." }, - "InterventionTemplate.InstantiateInterventionTemplateOutput.jsonld": { + "LegacyFacility.FacilityOutput.jsonld-facility.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -55795,302 +69545,244 @@ { "type": "object", "properties": { - "interventionId": { - "description": "Property interventionId.", - "default": "", + "intervention": { + "description": "Property intervention.", + "type": [ + "string", + "null" + ] + }, + "recordStatus": { + "description": "Property recordStatus.", + "default": "published", "type": "string" }, - "number": { - "description": "Property number.", - "default": 0, + "revision": { + "description": "Property revision.", + "default": 1, "type": "integer" - } - } - } - ], - "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence \u2014 see the module changelog for later lots)." - }, - "InterventionTemplate.InterventionTemplateOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { + }, "id": { + "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organization": { - "description": "Property organization.", - "default": "", - "type": "string" - }, - "name": { - "description": "Property name.", + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", "default": "", "type": "string" }, - "description": { - "description": "Property description.", + "parentFacilityId": { + "readOnly": true, + "description": "Property parentFacilityId.", "type": [ "string", "null" ] }, + "hasChildren": { + "readOnly": true, + "description": "Property hasChildren.", + "default": false, + "type": "boolean" + }, + "equipmentCount": { + "readOnly": true, + "description": "Active equipment assigned to this facility", + "default": 0, + "type": "integer" + }, "type": { + "readOnly": true, "description": "Property type.", "default": "", "type": "string" }, - "priority": { - "description": "Property priority.", + "name": { + "readOnly": true, + "description": "Property name.", "default": "", "type": "string" }, - "defaultSite": { - "description": "Property defaultSite.", + "code": { + "readOnly": true, + "description": "Property code.", "type": [ "string", "null" ] }, - "defaultResponsible": { - "description": "Property defaultResponsible.", + "status": { + "readOnly": true, + "description": "Property status.", + "default": "", + "type": "string" + }, + "address": { + "readOnly": true, + "description": "Property address.", "type": [ "string", "null" ] }, - "duration": { - "description": "Property duration.", + "latitude": { + "readOnly": true, + "description": "Property latitude.", + "type": [ + "number", + "null" + ] + }, + "longitude": { + "readOnly": true, + "description": "Property longitude.", + "type": [ + "number", + "null" + ] + }, + "metadata": { + "readOnly": true, + "description": "Property metadata.", + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + "levelIndex": { + "readOnly": true, + "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", + "type": [ + "integer", + "null" + ] + }, + "elevationMeters": { + "readOnly": true, + "description": "Property elevationMeters", + "type": [ + "number", + "null" + ] + }, + "heightMeters": { + "readOnly": true, + "description": "Property heightMeters", + "type": [ + "number", + "null" + ] + }, + "customerId": { + "description": "Optional internal customer identifier on root sites", "type": [ "string", "null" ] }, - "labelIds": { - "description": "Property labelIds.", - "type": "array", - "items": { - "type": "string" + "planGeometry": { + "readOnly": true, + "description": "Optional spatial geometry bound to an ancestor floor plan (detail views only)", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + }, + { + "type": "string" + } + ] } }, - "items": { - "description": "Property items.", + "geometryIssue": { + "readOnly": true, + "description": "Property geometryIssue.", + "type": [ + "string", + "null" + ], + "enum": [ + "invalid_geometry", + "plan_unavailable", + "outside_ancestry", + "other_plan", + null + ] + }, + "hierarchyIssues": { + "readOnly": true, + "description": "Property hierarchyIssues.", "type": "array", "items": { - "$ref": "#/components/schemas/InterventionTemplateItemOutput.jsonld" + "type": "string" } }, "createdAt": { + "readOnly": true, "description": "Property createdAt.", "default": "", "type": "string" }, "updatedAt": { + "readOnly": true, "description": "Property updatedAt.", "default": "", "type": "string" + }, + "path": { + "readOnly": true, + "description": "Ancestor breadcrumb, root first, excluding this facility; opt-in on collections through includePath.", + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "name", + "type" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ] + } + } + } } } } ], - "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence \u2014 see the module changelog for later lots)." - }, - "InterventionTemplate.UpdateInterventionTemplateInput.jsonMergePatch": { - "type": "object", - "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence \u2014 see the module changelog for later lots).", - "properties": { - "name": { - "minLength": 2, - "maxLength": 160, - "description": "Property name.", - "type": [ - "string", - "null" - ] - }, - "description": { - "maxLength": 5000, - "description": "Property description.", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "site_setup", - "inventory", - "inspection_campaign" - ], - "description": "Property type.", - "type": [ - "string", - "null" - ] - }, - "priority": { - "enum": [ - "low", - "normal", - "high", - "urgent" - ], - "description": "Property priority.", - "type": [ - "string", - "null" - ] - }, - "defaultSite": { - "description": "Property defaultSite.", - "type": [ - "string", - "null" - ] - }, - "defaultResponsible": { - "description": "Property defaultResponsible.", - "type": [ - "string", - "null" - ] - }, - "duration": { - "description": "Property duration.", - "example": "P14D", - "type": [ - "string", - "null" - ] - }, - "labelIds": { - "description": "Property labelIds.", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "items": { - "description": "Property items.", - "type": "array", - "items": { - "$ref": "#/components/schemas/InterventionTemplateItemInput" - } - } - } - }, - "InterventionTemplateItemInput": { - "type": "object", - "properties": { - "estimatedMinutes": { - "minimum": 0, - "description": "Property estimatedMinutes.", - "type": [ - "integer", - "null" - ] - }, - "action": { - "maxLength": 60, - "description": "Property action.", - "default": "", - "example": "inspection", - "type": "string" - }, - "target": { - "description": "Property target.", - "type": [ - "string", - "null" - ] - }, - "resultResource": { - "description": "Property resultResource.", - "type": [ - "string", - "null" - ] - }, - "required": { - "description": "Property required.", - "default": true, - "type": "boolean" - }, - "defaultAssignee": { - "description": "Property defaultAssignee.", - "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000/members/018f0b68-6758-7a12-8a1d-3f0d97f63c13", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "action" - ] - }, - "InterventionTemplateItemOutput.jsonld": { - "type": "object", - "properties": { - "estimatedMinutes": { - "description": "Property estimatedMinutes.", - "type": [ - "integer", - "null" - ] - }, - "id": { - "description": "Property id.", - "default": "", - "type": "string" - }, - "position": { - "description": "Property position.", - "default": 0, - "type": "integer" - }, - "action": { - "description": "Property action.", - "default": "", - "type": "string" - }, - "target": { - "description": "Property target.", - "type": [ - "string", - "null" - ] - }, - "resultResource": { - "description": "Property resultResource.", - "type": [ - "string", - "null" - ] - }, - "required": { - "description": "Property required.", - "default": true, - "type": "boolean" - }, - "defaultAssignee": { - "description": "Property defaultAssignee.", - "type": [ - "string", - "null" - ] - } - } + "description": "Generic organizational facilities (site/building/floor/zone/area)." }, - "InterventionTime.TimeEntryOutput.jsonld": { + "LegacyFacility.FacilityPlanOverlayOutput.jsonld-facility.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -56098,20 +69790,99 @@ { "type": "object", "properties": { - "id": { - "description": "stable resource identifier, retained across idempotent retries", + "attachmentId": { + "readOnly": true, + "description": "Property attachmentId.", + "default": "", "type": "string" }, - "entry": { - "description": "independent time-entry aggregate or view", - "$ref": "#/components/schemas/TimeEntryView.jsonld" + "imageWidth": { + "readOnly": true, + "description": "Property imageWidth.", + "type": [ + "integer", + "null" + ] + }, + "imageHeight": { + "readOnly": true, + "description": "Property imageHeight.", + "type": [ + "integer", + "null" + ] + }, + "zones": { + "readOnly": true, + "description": "Property zones.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + }, + { + "type": "string" + } + ] + } + } + }, + "equipment": { + "readOnly": true, + "description": "Property equipment.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + }, + "geometryIssues": { + "readOnly": true, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "equipmentIssues": { + "readOnly": true, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } } ], - "description": "InterventionTimeResource." + "description": "Generic organizational facilities (site/building/floor/zone/area)." }, - "InterventionTime.TimeJournalOutput.jsonld": { + "LegacyFacility.GeocodeAddressOutput.jsonld-facility.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -56119,491 +69890,349 @@ { "type": "object", "properties": { - "workItemId": { - "description": "intervention task associated with this contribution", + "displayName": { + "readOnly": true, + "description": "Property displayName.", + "default": "", "type": "string" }, - "entries": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TimeEntryView.jsonld" - } + "latitude": { + "readOnly": true, + "description": "Property latitude.", + "default": 0, + "type": "number" + }, + "longitude": { + "readOnly": true, + "description": "Property longitude.", + "default": 0, + "type": "number" } } } ], - "description": "InterventionTimeResource." + "description": "Generic organizational facilities (site/building/floor/zone/area)." }, - "InterventionTime.WriteTimeEntryInput": { + "LegacyFacility.MoveFacilityInput-facility.write": { "type": "object", - "description": "InterventionTimeResource.", + "description": "Generic organizational facilities (site/building/floor/zone/area).", + "required": [ + "parentFacilityId" + ], "properties": { - "id": { - "format": "uuid", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - }, - "memberId": { + "parentFacilityId": { "format": "uuid", + "description": "Target parent facility identifier. Field is required; use null to detach from parent.", "externalDocs": { "url": "https://schema.org/identifier" }, - "type": [ - "string", - "null" - ] - }, - "workedOn": { - "externalDocs": { - "url": "https://schema.org/Date" - }, - "default": "", - "type": "string" - }, - "minutes": { - "minimum": 1, - "maximum": 1440, - "default": 0, - "type": "integer" - }, - "note": { - "maxLength": 2000, + "example": "550e8400-e29b-41d4-a716-446655440001", "type": [ "string", "null" ] } - }, - "required": [ - "workedOn" - ] + } }, - "InterventionTime.WriteTimeEntryInput.jsonMergePatch": { + "LegacyFacility.SetFacilityPlanGeometryInput-facility.write": { "type": "object", - "description": "InterventionTimeResource.", + "description": "Generic organizational facilities (site/building/floor/zone/area).", + "required": [ + "attachmentId", + "points" + ], "properties": { - "id": { - "format": "uuid", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - }, - "memberId": { + "attachmentId": { "format": "uuid", + "description": "Floor plan attachment identifier the geometry is bound to. Required together with points; use null on both to clear.", "externalDocs": { "url": "https://schema.org/identifier" }, + "example": "550e8400-e29b-41d4-a716-446655440001", "type": [ "string", "null" ] }, - "workedOn": { - "externalDocs": { - "url": "https://schema.org/Date" - }, - "default": "", - "type": "string" - }, - "minutes": { - "minimum": 1, - "maximum": 1440, - "default": 0, - "type": "integer" - }, - "note": { - "maxLength": 2000, + "points": { + "minItems": 3, + "description": "Polygon points in normalized 0-1 image coordinates, at least 3. Required together with attachmentId; use null on both to clear.", + "example": [ + [ + 0.1, + 0.1 + ], + [ + 0.4, + 0.1 + ], + [ + 0.4, + 0.4 + ] + ], "type": [ - "string", + "array", "null" - ] + ], + "items": { + "type": "array", + "items": { + "type": "number" + } + } } } }, - "InterventionWorkItem.CreateInterventionWorkItemInput": { + "LegacyFacility.SuggestAddressesOutput.jsonld-facility.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "member": { + "description": "Property member.", + "type": "array", + "items": { + "$ref": "#/components/schemas/AddressSuggestionOutput.jsonld-facility.read" + } + }, + "totalItems": { + "description": "Property totalItems.", + "default": 0, + "type": "integer" + } + } + } + ], + "description": "Generic organizational facilities (site/building/floor/zone/area)." + }, + "LegacyFacility.UpdateFacilityInput-facility.write.jsonMergePatch": { "type": "object", - "description": "Resource InterventionWorkItemResource.", + "description": "Generic organizational facilities (site/building/floor/zone/area).", "properties": { - "workloadConfirmationToken": { - "maxLength": 64, - "description": "Property workloadConfirmationToken.", + "type": { + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ], + "description": "Facility type (partial update)", + "example": "building", "type": [ "string", "null" ] }, - "estimatedMinutes": { - "minimum": 0, - "description": "Property estimatedMinutes. Null means not estimated.", + "name": { + "minLength": 2, + "maxLength": 120, + "description": "Facility display name (partial update)", + "example": "Warehouse A", "type": [ - "integer", + "string", "null" ] }, - "workStartsOn": { - "description": "Property workStartsOn. Organization-local date.", - "externalDocs": { - "url": "https://schema.org/Date" - }, + "code": { + "maxLength": 80, + "description": "Optional facility code", + "example": "BLD-WHS-A", "type": [ "string", "null" ] }, - "workEndsOn": { - "description": "Property workEndsOn. Organization-local date.", - "externalDocs": { - "url": "https://schema.org/Date" - }, + "address": { + "maxLength": 255, + "description": "Optional address", + "example": "12 avenue Victor Hugo, Lyon", "type": [ "string", "null" ] }, - "intervention": { - "description": "Property intervention.", - "default": "", - "type": "string" - }, - "action": { - "enum": [ - "site_setup", - "inventory", - "inspection" - ], - "description": "Property action.", - "default": "inventory", - "type": "string" - }, - "target": { - "description": "Property target.", + "latitude": { + "minimum": -90, + "maximum": 90, + "description": "Optional latitude, required together with longitude (partial update)", + "example": 45.764, "type": [ - "string", + "number", "null" ] }, - "resultResource": { - "description": "Property resultResource.", + "longitude": { + "minimum": -180, + "maximum": 180, + "description": "Optional longitude, required together with latitude (partial update)", + "example": 4.8357, "type": [ - "string", + "number", "null" ] }, - "assignee": { - "description": "Property assignee.", + "metadata": { + "description": "Optional free-form metadata", + "example": { + "surfaceM2": 4500 + }, + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + "levelIndex": { + "minimum": -100, + "maximum": 200, + "description": "Optional stacking order of the floor (partial update)", + "example": -1, "type": [ - "string", + "integer", "null" ] }, - "source": { - "enum": [ - "planned", - "discovered" - ], - "description": "Property source.", - "default": "planned", - "type": "string" - }, - "required": { - "description": "Property required.", - "default": true, - "type": "boolean" - } - }, - "required": [ - "intervention" - ] - }, - "InterventionWorkItem.InterventionWorkItemOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "spentMinutes": { - "description": "Property spentMinutes.", - "default": 0, - "type": "integer" - }, - "allowedActions": { - "description": "Property allowedActions.", - "default": { - "canLogTime": false, - "canManageTime": false, - "canReestimate": false, - "canReassign": false, - "canEditPlanning": false, - "canExecute": false - }, - "type": "object", - "additionalProperties": { - "type": "boolean" - } - }, - "estimatedMinutes": { - "description": "Property estimatedMinutes.", - "type": [ - "integer", - "null" - ] - }, - "remainingMinutes": { - "description": "Property remainingMinutes.", - "type": [ - "integer", - "null" - ] - }, - "workStartsOn": { - "description": "Property workStartsOn.", - "type": [ - "string", - "null" - ] - }, - "workEndsOn": { - "description": "Property workEndsOn.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Property id.", - "default": "", - "type": "string" - }, - "intervention": { - "description": "Property intervention.", - "default": "", - "type": "string" - }, - "action": { - "description": "Property action.", - "default": "", - "type": "string" - }, - "target": { - "description": "Property target.", - "type": [ - "string", - "null" - ] - }, - "targetSummary": { - "description": "Property targetSummary.", - "anyOf": [ - { - "$ref": "#/components/schemas/InterventionTargetOutput.jsonld" - }, - { - "type": "null" - } - ] - }, - "resultResource": { - "description": "Property resultResource.", - "type": [ - "string", - "null" - ] - }, - "assignee": { - "description": "Property assignee.", - "type": [ - "string", - "null" - ] - }, - "assigneeProfile": { - "description": "Property assigneeProfile.", - "anyOf": [ - { - "$ref": "#/components/schemas/InterventionAssigneeOutput.jsonld" - }, - { - "type": "null" - } - ] - }, - "source": { - "description": "Property source.", - "default": "planned", - "type": "string" - }, - "status": { - "description": "Property status.", - "default": "planned", - "type": "string" - }, - "required": { - "description": "Property required.", - "default": true, - "type": "boolean" - }, - "skipReason": { - "description": "Property skipReason.", - "type": [ - "string", - "null" - ] - }, - "evidenceCount": { - "description": "Property evidenceCount.", - "default": 0, - "type": "integer" - }, - "revision": { - "description": "Property revision.", - "default": 1, - "type": "integer" - }, - "createdAt": { - "description": "Property createdAt.", - "default": "", - "type": "string" - }, - "updatedAt": { - "description": "Property updatedAt.", - "default": "", - "type": "string" - } - } - } - ], - "description": "Resource InterventionWorkItemResource." - }, - "InterventionWorkItem.UpdateInterventionWorkItemInput.jsonMergePatch": { - "type": "object", - "description": "Resource InterventionWorkItemResource.", - "properties": { - "workloadConfirmationToken": { - "maxLength": 64, - "description": "Property workloadConfirmationToken.", + "elevationMeters": { + "minimum": -10000, + "maximum": 10000, + "description": "Optional floor elevation in meters", + "example": -3, "type": [ - "string", + "number", "null" ] }, - "estimatedMinutes": { + "heightMeters": { "minimum": 0, - "description": "Property estimatedMinutes. Null means not estimated.", + "exclusiveMinimum": 0, + "maximum": 1000, + "description": "Optional floor height in meters", + "example": 3, "type": [ - "integer", + "number", "null" ] }, - "workStartsOn": { - "description": "Property workStartsOn. Organization-local date.", + "customerId": { + "format": "uuid", + "description": "Internal customer identifier for a root site", "externalDocs": { - "url": "https://schema.org/Date" + "url": "https://schema.org/identifier" }, "type": [ "string", "null" ] - }, - "workEndsOn": { - "description": "Property workEndsOn. Organization-local date.", + } + } + }, + "LegacyInspection.CreateInspectionInput-inspection.write": { + "type": "object", + "description": "Inspection records for fire safety equipment.", + "properties": { + "clientId": { + "format": "uuid", + "description": "Client-generated UUID for idempotent offline creation", "externalDocs": { - "url": "https://schema.org/Date" + "url": "https://schema.org/identifier" }, "type": [ "string", "null" ] }, - "remainingMinutes": { - "minimum": 0, - "description": "Property remainingMinutes. Explicit re-estimation only.", - "type": [ - "integer", - "null" - ] - }, - "resultResource": { - "description": "Property resultResource.", + "organization": { + "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", + "description": "Organization IRI, required on the canonical endpoint", "type": [ "string", "null" ] }, - "assignee": { - "description": "Property assignee.", + "intervention": { + "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", + "description": "Optional field intervention IRI", "type": [ "string", "null" ] }, - "status": { + "equipmentId": { + "format": "uuid", + "description": "Equipment identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "result": { "enum": [ - "planned", - "in_progress", - "completed", - "skipped" + "pass", + "fail", + "partial" ], - "description": "Property status.", - "type": [ - "string", - "null" - ] + "description": "Inspection result", + "default": "", + "example": "pass", + "type": "string" }, - "skipReason": { - "maxLength": 2000, - "description": "Property skipReason.", + "performedAt": { + "description": "Date the inspection was performed (ISO 8601)", + "default": "", + "example": "2024-06-15T10:00:00+02:00", + "type": "string" + }, + "inspectorType": { + "enum": [ + "user", + "external" + ], + "description": "Inspector type (user or external)", + "default": "", + "example": "user", + "type": "string" + }, + "inspectorName": { + "maxLength": 255, + "description": "Inspector display name", + "default": "", + "example": "Jean Dupont", + "type": "string" + }, + "facilityId": { + "format": "uuid", + "description": "Optional facility identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] - } - } - }, - "LegacyEquipment.AssignToFacilityInput-equipment.write": { - "type": "object", - "description": "Fire safety equipment and assets managed at organization level.", - "required": [ - "facilityId" - ], - "properties": { - "facilityId": { + }, + "checklistId": { "format": "uuid", - "description": "Target facility identifier", + "description": "Optional checklist identifier", "externalDocs": { "url": "https://schema.org/identifier" }, - "default": "", - "example": "550e8400-e29b-41d4-a716-446655440001", - "type": "string" - }, - "installedAt": { - "description": "Optional installation date (ISO 8601)", - "example": "2024-01-15T09:00:00+00:00", "type": [ "string", "null" ] - } - } - }, - "LegacyEquipment.CreateEquipmentInput-equipment.write": { - "type": "object", - "description": "Fire safety equipment and assets managed at organization level.", - "properties": { - "clientId": { + }, + "inspectorUserId": { "format": "uuid", - "description": "Client-generated UUID for idempotent offline creation", + "description": "Optional user identifier (required for user type)", "externalDocs": { "url": "https://schema.org/identifier" }, @@ -56612,32 +70241,45 @@ "null" ] }, - "organization": { - "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", - "description": "Organization IRI, required on the canonical endpoint", + "inspectorOrganizationName": { + "maxLength": 255, + "description": "Optional external organization name", "type": [ "string", "null" ] }, - "intervention": { - "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", - "description": "Optional field intervention IRI", + "notes": { + "maxLength": 5000, + "description": "Optional free-form notes", "type": [ "string", "null" ] }, - "facility": { - "pattern": "^(/api/facilities/[0-9a-fA-F-]{36})$", - "description": "Optional facility IRI", + "signature": { + "description": "Optional signature data", "type": [ "string", "null" ] - }, - "onboardingSessionId": { + } + }, + "required": [ + "equipmentId", + "result", + "performedAt", + "inspectorType", + "inspectorName" + ] + }, + "LegacyInspection.EditInspectionInput-inspection.write.jsonMergePatch": { + "type": "object", + "description": "Inspection records for fire safety equipment.", + "properties": { + "equipmentId": { "format": "uuid", + "description": "Equipment identifier", "externalDocs": { "url": "https://schema.org/identifier" }, @@ -56646,83 +70288,67 @@ "null" ] }, - "onboardingItemKey": { + "facilityId": { + "format": "uuid", + "description": "Optional facility identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "type": { - "enum": [ - "fire_extinguisher", - "smoke_detector", - "heat_detector", - "sprinkler", - "fire_alarm_panel", - "hydrant", - "fire_door", - "emergency_lighting", - "access_control", - "camera", - "gas_detector", - "other" - ], - "description": "Equipment type", - "default": "", - "example": "fire_extinguisher", - "type": "string" - }, - "subType": { - "maxLength": 100, - "description": "Optional free-form sub-type", - "example": "CO2 6kg", + "checklistId": { + "format": "uuid", + "description": "Optional checklist identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "brand": { - "maxLength": 100, - "description": "Optional brand name", - "example": "Sicli", + "result": { + "enum": [ + "pass", + "fail", + "partial" + ], + "description": "Inspection result", + "example": "pass", "type": [ "string", "null" ] }, - "model": { - "maxLength": 100, - "description": "Optional model name", - "example": "Pro 6", + "performedAt": { + "description": "Date the inspection was performed (ISO 8601)", + "example": "2024-06-15T10:00:00+02:00", "type": [ "string", "null" ] }, - "serialNumber": { - "maxLength": 100, - "description": "Optional serial number (unique per organization)", - "example": "SN-2024-001", + "notes": { + "maxLength": 5000, + "description": "Optional free-form notes", "type": [ "string", "null" ] }, - "locationLabel": { - "maxLength": 255, - "description": "Optional free-form location label", - "example": "Near staircase B", + "signature": { + "description": "Optional signature data", "type": [ "string", "null" ] } - }, - "required": [ - "type" - ] + } }, - "LegacyEquipment.EquipmentOutput.jsonld-equipment.read": { + "LegacyInspection.InspectionOutput.jsonld-inspection.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -56759,67 +70385,49 @@ "default": "", "type": "string" }, - "facilityId": { - "readOnly": true, - "description": "Property facilityId.", - "type": [ - "string", - "null" - ] - }, - "type": { + "equipmentId": { "readOnly": true, - "description": "Property type.", + "description": "Property equipmentId.", "default": "", "type": "string" }, - "subType": { - "readOnly": true, - "description": "Property subType.", - "type": [ - "string", - "null" - ] - }, - "brand": { + "facilityId": { "readOnly": true, - "description": "Property brand.", + "description": "Property facilityId.", "type": [ "string", "null" ] }, - "model": { + "equipmentSerialNumber": { "readOnly": true, - "description": "Property model.", + "description": "Serial number of the inspected equipment", "type": [ "string", "null" ] }, - "serialNumber": { + "facilityName": { "readOnly": true, - "description": "Property serialNumber.", + "description": "Display name of the inspected facility", "type": [ "string", "null" ] }, - "locationLabel": { + "checklistName": { "readOnly": true, - "description": "Property locationLabel.", + "description": "Display name of the followed checklist", "type": [ "string", "null" ] }, - "facilityName": { + "result": { "readOnly": true, - "description": "Display name of the assigned facility", - "type": [ - "string", - "null" - ] + "description": "Property result.", + "default": "", + "type": "string" }, "status": { "readOnly": true, @@ -56827,53 +70435,53 @@ "default": "", "type": "string" }, - "installedAt": { + "performedAt": { "readOnly": true, - "description": "Property installedAt.", + "description": "Property performedAt.", + "default": "", + "type": "string" + }, + "inspector": { + "readOnly": true, + "description": "Property inspector.", + "anyOf": [ + { + "$ref": "#/components/schemas/InspectorOutput.jsonld-inspection.read" + }, + { + "type": "null" + } + ] + }, + "checklistId": { + "readOnly": true, + "description": "Property checklistId.", "type": [ "string", "null" ] }, - "commissionedAt": { + "notes": { "readOnly": true, - "description": "Property commissionedAt.", + "description": "Property notes.", "type": [ "string", "null" ] }, - "tags": { - "readOnly": true, - "description": "Property tags.", - "type": "array", - "items": { - "$ref": "#/components/schemas/TagOutput.jsonld-equipment.read" - } - }, - "maintenanceDueStatus": { - "readOnly": true, - "description": "Property maintenanceDueStatus.", - "default": "unscheduled", - "type": "string" - }, - "planPosition": { + "signature": { "readOnly": true, - "description": "Position pinned on a floor plan attachment (detail read only)", + "description": "Property signature.", "type": [ - "object", + "string", "null" - ], - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string" - } - ] - } + ] + }, + "nonConformitiesCount": { + "readOnly": true, + "description": "Property nonConformitiesCount.", + "default": 0, + "type": "integer" }, "createdAt": { "readOnly": true, @@ -56890,312 +70498,105 @@ } } ], - "description": "Fire safety equipment and assets managed at organization level." + "description": "Inspection records for fire safety equipment." }, - "LegacyEquipment.SetEquipmentPlanPositionInput-equipment.write": { + "MaintenanceCampaign.GenerateInspectionCampaignInput": { "type": "object", - "description": "Fire safety equipment and assets managed at organization level.", + "description": "Resource MaintenanceCampaignResource.\n\nGenerates an intervention draft (\"inspection campaign\") from an\norganization's due/overdue maintenance schedules.", "required": [ - "attachmentId", - "x", - "y" + "organization", + "dueBefore" ], "properties": { - "attachmentId": { - "format": "uuid", - "description": "Floor plan attachment identifier the position is bound to. Required together with x/y; use null on all three to clear.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "example": "550e8400-e29b-41d4-a716-446655440001", - "type": [ - "string", - "null" - ] - }, - "x": { - "minimum": 0, - "maximum": 1, - "description": "Normalized x coordinate in [0, 1]. Required together with attachmentId/y; use null on all three to clear.", - "example": 0.42, - "type": [ - "number", - "null" - ] - }, - "y": { - "minimum": 0, - "maximum": 1, - "description": "Normalized y coordinate in [0, 1]. Required together with attachmentId/x; use null on all three to clear.", - "example": 0.17, - "type": [ - "number", - "null" - ] - } - } - }, - "LegacyEquipment.UpdateEquipmentInput-equipment.write.jsonMergePatch": { - "type": "object", - "description": "Fire safety equipment and assets managed at organization level.", - "properties": { - "type": { - "enum": [ - "fire_extinguisher", - "smoke_detector", - "heat_detector", - "sprinkler", - "fire_alarm_panel", - "hydrant", - "fire_door", - "emergency_lighting", - "access_control", - "camera", - "gas_detector", - "other" - ], - "description": "Equipment type", - "default": "", - "example": "fire_extinguisher", - "type": "string" - }, - "subType": { - "maxLength": 100, - "description": "Optional free-form sub-type", - "example": "CO2 6kg", - "type": [ - "string", - "null" - ] - }, - "brand": { - "maxLength": 100, - "description": "Optional brand name", - "example": "Sicli", - "type": [ - "string", - "null" - ] - }, - "model": { - "maxLength": 100, - "description": "Optional model name", - "example": "Pro 6", - "type": [ - "string", - "null" - ] - }, - "serialNumber": { - "maxLength": 100, - "description": "Optional serial number (unique per organization)", - "example": "SN-2024-001", - "type": [ - "string", - "null" - ] - }, - "locationLabel": { - "maxLength": 255, - "description": "Optional free-form location label", - "example": "Near staircase B", - "type": [ - "string", - "null" - ] - } - } - }, - "LegacyFacility.CreateFacilityInput-facility.write": { - "type": "object", - "description": "Generic organizational facilities (site/building/floor/zone/area).", - "properties": { - "clientId": { - "format": "uuid", - "description": "Client-generated UUID for idempotent offline creation", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - }, "organization": { - "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", - "description": "Organization IRI, required on the canonical endpoint", - "type": [ - "string", - "null" - ] - }, - "intervention": { - "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", - "description": "Optional field intervention IRI", - "type": [ - "string", - "null" - ] - }, - "onboardingSessionId": { - "format": "uuid", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - }, - "onboardingItemKey": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ], - "description": "Facility type", + "description": "Property organization.", "default": "", - "example": "site", "type": "string" }, "name": { "minLength": 2, - "maxLength": 120, - "description": "Facility display name", + "maxLength": 160, + "description": "Property name.", "default": "", - "example": "Headquarters", "type": "string" }, - "parentFacilityId": { - "format": "uuid", - "description": "Optional parent facility identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "example": "550e8400-e29b-41d4-a716-446655440001", - "type": [ - "string", - "null" - ] - }, - "code": { - "maxLength": 80, - "description": "Optional facility code", - "example": "SITE-PAR-001", + "facility": { + "description": "Property facility.", "type": [ "string", "null" ] }, - "address": { - "maxLength": 255, - "description": "Optional address", - "example": "10 rue de Rivoli, Paris", + "equipmentType": { + "description": "Property equipmentType.", "type": [ "string", "null" ] }, - "latitude": { - "minimum": -90, - "maximum": 90, - "description": "Optional latitude, required together with longitude", - "example": 48.8566, - "type": [ - "number", - "null" - ] - }, - "longitude": { - "minimum": -180, - "maximum": 180, - "description": "Optional longitude, required together with latitude", - "example": 2.3522, - "type": [ - "number", - "null" - ] + "dueBefore": { + "description": "Property dueBefore.", + "default": "", + "type": "string" + } + } + }, + "MaintenanceCampaign.GenerateInspectionCampaignOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" }, - "metadata": { - "description": "Optional free-form metadata", - "example": { - "country": "FR", - "timezone": "Europe/Paris" - }, + { "type": "object", - "additionalProperties": { - "type": [ - "string", - "null" - ] + "properties": { + "interventionId": { + "description": "Property interventionId.", + "default": "", + "type": "string" + }, + "number": { + "description": "Property number.", + "default": 0, + "type": "integer" + }, + "workItemsCount": { + "description": "Property workItemsCount.", + "default": 0, + "type": "integer" + } } - }, - "levelIndex": { - "minimum": -100, - "maximum": 200, - "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", - "example": 0, - "type": [ - "integer", - "null" - ] - }, - "elevationMeters": { - "minimum": -10000, - "maximum": 10000, - "description": "Optional floor elevation in meters", - "example": -3, - "type": [ - "number", - "null" - ] - }, - "heightMeters": { - "minimum": 0, - "exclusiveMinimum": 0, - "maximum": 1000, - "description": "Optional floor height in meters", - "example": 3, - "type": [ - "number", - "null" - ] } - }, - "required": [ - "type", - "name" - ] + ], + "description": "Resource MaintenanceCampaignResource.\n\nGenerates an intervention draft (\"inspection campaign\") from an\norganization's due/overdue maintenance schedules." }, - "LegacyFacility.DuplicateFacilitySubtreeInput-facility.write": { + "MaintenanceCost.CreateMaintenanceExpenseInput-maintenance_cost.write": { "type": "object", - "description": "Generic organizational facilities (site/building/floor/zone/area).", + "description": "Class MaintenanceCostResource. Financial permissions are enforced by application use cases. @category Resource", + "additionalProperties": false, "properties": { - "name": { - "minLength": 2, - "maxLength": 120, - "description": "Name for the copy's root. Defaults to \"{original} (copy)\" when omitted.", - "example": "Site A (copy)", + "clientId": { + "default": "", + "type": "string" + }, + "amount": { + "default": "", + "type": "string" + }, + "description": { + "default": "", + "type": "string" + }, + "incurredAt": { + "default": "", + "type": "string" + }, + "workItemId": { "type": [ "string", "null" ] }, - "parentFacilityId": { - "format": "uuid", - "description": "Target parent facility identifier for the copy's root. Defaults to the source facility's own parent when omitted.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "example": "550e8400-e29b-41d4-a716-446655440001", + "adjustmentOf": { "type": [ "string", "null" @@ -57203,7 +70604,7 @@ } } }, - "LegacyFacility.FacilityBuildingModelOutput.jsonld-facility.read": { + "MaintenanceCost.MaintenanceCostOutput.jsonld-maintenance_cost.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -57211,370 +70612,130 @@ { "type": "object", "properties": { - "buildingId": { - "readOnly": true, - "description": "Property buildingId.", + "id": { "default": "", "type": "string" }, - "buildingName": { - "readOnly": true, - "description": "Property buildingName.", + "interventionId": { "default": "", "type": "string" }, - "floors": { - "readOnly": true, - "description": "Property floors.", - "type": "array", - "items": { - "type": "object", - "required": [ - "facilityId", - "name", - "levelIndex", - "elevationMeters", - "heightMeters", - "status", - "hierarchyIssues", - "plan", - "outline", - "rooms", - "equipment", - "diagnostics" - ], - "properties": { - "facilityId": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "levelIndex": { - "type": [ - "integer", - "null" - ] - }, - "elevationMeters": { - "type": [ - "number", - "null" - ] - }, - "heightMeters": { - "type": [ - "number", - "null" - ] - }, - "status": { - "type": "string" - }, - "hierarchyIssues": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "missing_parent", - "invalid_parent_type", - "unpublished_parent", - "invalid_ancestor", - "cycle", - "depth_exceeded" - ] - } - }, - "plan": { - "type": [ - "object", - "null" - ], - "required": [ - "attachmentId", - "imageWidth", - "imageHeight", - "calibration", - "calibrationBuildingId", - "calibrationIssue" - ], - "properties": { - "attachmentId": { - "type": "string", - "format": "uuid" - }, - "imageWidth": { - "type": [ - "integer", - "null" - ] - }, - "imageHeight": { - "type": [ - "integer", - "null" - ] - }, - "calibrationBuildingId": { - "type": [ - "string", - "null" - ], - "format": "uuid" - }, - "calibrationIssue": { - "type": [ - "string", - "null" - ], - "enum": [ - "building_changed", - "unverified_frame", - null - ] - }, - "calibration": { - "type": [ - "object", - "null" - ], - "required": [ - "widthMeters", - "rotationDegrees", - "offsetXMeters", - "offsetZMeters" - ], - "properties": { - "widthMeters": { - "type": "number" - }, - "rotationDegrees": { - "type": "number" - }, - "offsetXMeters": { - "type": "number" - }, - "offsetZMeters": { - "type": "number" - } - } - } - } - }, - "outline": { - "type": [ - "object", - "null" - ], - "required": [ - "source", - "points" - ], - "properties": { - "source": { - "type": "string", - "enum": [ - "plan_geometry", - "rooms_bbox", - "image_rect" - ] - }, - "points": { - "type": "array", - "minItems": 3, - "items": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "number", - "minimum": 0, - "maximum": 1 - } - } - } - } - }, - "rooms": { - "type": "array", - "items": { - "type": "object", - "required": [ - "facilityId", - "name", - "type", - "status", - "points" - ], - "properties": { - "facilityId": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "status": { - "type": "string" - }, - "points": { - "type": "array", - "minItems": 3, - "items": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "number", - "minimum": 0, - "maximum": 1 - } - } - } - } - } - }, - "equipment": { - "type": "array", - "items": { - "type": "object", - "required": [ - "equipmentId", - "facilityId", - "type", - "serialNumber", - "locationLabel", - "status", - "position", - "placementIssue" - ], - "properties": { - "equipmentId": { - "type": "string", - "format": "uuid" - }, - "facilityId": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string" - }, - "serialNumber": { - "type": [ - "string", - "null" - ] - }, - "locationLabel": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": "string" - }, - "position": { - "type": [ - "object", - "null" - ], - "required": [ - "attachmentId", - "x", - "y" - ], - "properties": { - "attachmentId": { - "type": "string", - "format": "uuid" - }, - "x": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "y": { - "type": "number", - "minimum": 0, - "maximum": 1 - } - } - }, - "placementIssue": { - "type": [ - "string", - "null" - ], - "enum": [ - "missing_plan", - "unplaced", - "other_plan", - "invalid_position", - "outside_ancestry", - null - ] - } - } - } - }, - "diagnostics": { - "type": "object", - "required": [ - "invalidGeometryCount", - "unpositionedEquipmentCount", - "geometryIssues" - ], - "properties": { - "invalidGeometryCount": { - "type": "integer", - "minimum": 0 - }, - "unpositionedEquipmentCount": { - "type": "integer", - "minimum": 0 - }, - "geometryIssues": { - "type": "array", - "items": { - "type": "object", - "required": [ - "facilityId", - "code" - ], - "properties": { - "facilityId": { - "type": "string", - "format": "uuid" - }, - "code": { - "type": "string", - "enum": [ - "invalid_geometry", - "plan_unavailable", - "outside_ancestry", - "other_plan" - ] - } - } - } - } + "organizationId": { + "default": "", + "type": "string" + }, + "currency": { + "default": "", + "type": "string" + }, + "plannedBudget": { + "type": [ + "string", + "null" + ] + }, + "estimatedMinutes": { + "type": [ + "integer", + "null" + ] + }, + "planningRevision": { + "default": 0, + "type": "integer" + }, + "planningEditable": { + "default": false, + "type": "boolean" + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" } - } + ] } } + }, + "current": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + "frozen": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] + } } } } ], - "description": "Generic organizational facilities (site/building/floor/zone/area)." + "description": "Class MaintenanceCostResource. Financial permissions are enforced by application use cases. @category Resource" }, - "LegacyFacility.FacilityOutput.jsonld-facility.read": { + "MaintenanceCost.WriteCostPlanningInput-maintenance_cost.write.jsonMergePatch": { + "type": "object", + "description": "Class MaintenanceCostResource. Financial permissions are enforced by application use cases. @category Resource", + "additionalProperties": false, + "properties": { + "plannedBudget": { + "type": [ + "string", + "null" + ] + }, + "estimatedMinutes": { + "type": [ + "integer", + "null" + ] + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, + "MaintenanceCostCurrency.ConfigureMaintenanceCurrencyInput-maintenance_cost_currency.write.jsonMergePatch": { + "type": "object", + "description": "Private currency settings shared by all internal maintenance finance.", + "additionalProperties": false, + "properties": { + "currency": { + "default": "", + "type": "string" + } + } + }, + "MaintenanceCostCurrency.MaintenanceCurrencyOutput.jsonld-maintenance_cost_currency.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -57582,108 +70743,260 @@ { "type": "object", "properties": { - "intervention": { - "description": "Property intervention.", - "type": [ - "string", - "null" - ] - }, - "recordStatus": { - "description": "Property recordStatus.", - "default": "published", + "organizationId": { + "default": "", "type": "string" }, - "revision": { - "description": "Property revision.", - "default": 1, - "type": "integer" + "currency": { + "default": "EUR", + "type": "string" }, + "locked": { + "default": false, + "type": "boolean" + } + } + } + ], + "description": "Private currency settings shared by all internal maintenance finance." + }, + "MaintenanceCostRate.CreateMaintenanceRateInput-maintenance_cost_rate.write": { + "type": "object", + "description": "Dedicated financial permissions protect append-only historical rate parameters.", + "additionalProperties": false, + "required": [ + "clientId", + "memberId", + "hourlyAmount", + "effectiveFrom" + ], + "properties": { + "clientId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "memberId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "hourlyAmount": { + "default": "", + "type": "string" + }, + "effectiveFrom": { + "externalDocs": { + "url": "https://schema.org/Date" + }, + "default": "", + "type": "string" + } + } + }, + "MaintenanceCostRate.MaintenanceRateOutput.jsonld-maintenance_cost_rate.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { "id": { - "readOnly": true, - "description": "Property id.", "default": "", "type": "string" }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", + "memberId": { "default": "", "type": "string" }, - "parentFacilityId": { - "readOnly": true, - "description": "Property parentFacilityId.", - "type": [ - "string", - "null" - ] + "hourlyAmount": { + "default": "", + "type": "string" }, - "hasChildren": { - "readOnly": true, - "description": "Property hasChildren.", + "currency": { + "default": "", + "type": "string" + }, + "effectiveFrom": { + "default": "", + "type": "string" + }, + "replayed": { "default": false, "type": "boolean" + } + } + } + ], + "description": "Dedicated financial permissions protect append-only historical rate parameters." + }, + "MaintenanceEconomic.MaintenanceEconomicDossierOutput.jsonld-maintenance_economic.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "default": "", + "type": "string" }, - "equipmentCount": { - "readOnly": true, - "description": "Active equipment assigned to this facility", + "number": { "default": 0, "type": "integer" }, - "type": { - "readOnly": true, - "description": "Property type.", + "name": { "default": "", "type": "string" }, - "name": { - "readOnly": true, - "description": "Property name.", + "type": { "default": "", "type": "string" }, - "code": { - "readOnly": true, - "description": "Property code.", - "type": [ - "string", - "null" - ] - }, "status": { - "readOnly": true, - "description": "Property status.", "default": "", "type": "string" }, - "address": { - "readOnly": true, - "description": "Property address.", + "publishedAt": { "type": [ "string", "null" ] }, - "latitude": { - "readOnly": true, - "description": "Property latitude.", + "site": { "type": [ - "number", + "object", "null" - ] + ], + "additionalProperties": { + "type": "string" + } }, - "longitude": { - "readOnly": true, - "description": "Property longitude.", + "customer": { "type": [ - "number", + "object", "null" - ] + ], + "additionalProperties": { + "type": "string" + } }, - "metadata": { - "readOnly": true, - "description": "Property metadata.", + "snapshotState": { + "default": "", + "type": "string" + }, + "identityComplete": { + "default": false, + "type": "boolean" + }, + "equipment": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } + } + ], + "description": "Class MaintenanceEconomicResource\n\nDedicated financial endpoints exclude ordinary operational and client reports." + }, + "MaintenanceEconomic.MaintenanceEconomicReportOutput.jsonld-maintenance_economic.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "default": "", + "type": "string" + }, + "organizationId": { + "default": "", + "type": "string" + }, + "from": { + "default": "", + "type": "string" + }, + "to": { + "default": "", + "type": "string" + }, + "groupBy": { + "default": "", + "type": "string" + }, + "currency": { + "default": "", + "type": "string" + }, + "page": { + "default": 1, + "type": "integer" + }, + "itemsPerPage": { + "default": 30, + "type": "integer" + }, + "totalItems": { + "default": 0, + "type": "integer" + }, + "interventionCount": { + "default": 0, + "type": "integer" + }, + "publishedInterventionCount": { + "default": 0, + "type": "integer" + }, + "liveInterventionCount": { + "default": 0, + "type": "integer" + }, + "missingSnapshotCount": { + "default": 0, + "type": "integer" + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + }, + "unallocated": { "type": "object", "additionalProperties": { "type": [ @@ -57692,127 +71005,170 @@ ] } }, - "levelIndex": { - "readOnly": true, - "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", - "type": [ - "integer", - "null" - ] + "current": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } }, - "elevationMeters": { - "readOnly": true, - "description": "Property elevationMeters", - "type": [ - "number", - "null" - ] + "frozen": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } }, - "heightMeters": { - "readOnly": true, - "description": "Property heightMeters", - "type": [ - "number", - "null" - ] + "planned": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } }, - "planGeometry": { - "readOnly": true, - "description": "Optional spatial geometry bound to an ancestor floor plan (detail views only)", - "type": [ - "object", - "null" - ], + "budget": { + "type": "object", "additionalProperties": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "number" - } - } - }, - { - "type": "string" - } + "type": [ + "string", + "null" ] } }, - "geometryIssue": { - "readOnly": true, - "description": "Property geometryIssue.", + "variance": { "type": [ "string", "null" - ], - "enum": [ - "invalid_geometry", - "plan_unavailable", - "outside_ancestry", - "other_plan", - null ] }, - "hierarchyIssues": { - "readOnly": true, - "description": "Property hierarchyIssues.", - "type": "array", - "items": { - "type": "string" + "reconciliation": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] } }, - "createdAt": { - "readOnly": true, - "description": "Property createdAt.", - "default": "", - "type": "string" - }, - "updatedAt": { - "readOnly": true, - "description": "Property updatedAt.", - "default": "", - "type": "string" + "procurement": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } }, - "path": { - "readOnly": true, - "description": "Ancestor breadcrumb, root first, excluding this facility; opt-in on collections through includePath.", + "dossiers": { "type": "array", "items": { "type": "object", - "required": [ - "id", - "name", - "type" - ], - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ] - } + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] } } } } } - ], - "description": "Generic organizational facilities (site/building/floor/zone/area)." + ], + "description": "Class MaintenanceEconomicResource\n\nDedicated financial endpoints exclude ordinary operational and client reports." + }, + "MaintenanceExport.AdjustMaintenanceExportInput-maintenance_export.write": { + "type": "object", + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication.", + "required": [ + "clientOperationId", + "reason" + ], + "properties": { + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "reason": { + "maxLength": 1000, + "default": "", + "type": "string" + } + } + }, + "MaintenanceExport.ConfirmMaintenanceExportInput-maintenance_export.write": { + "type": "object", + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication.", + "required": [ + "clientOperationId", + "externalImportReference" + ], + "properties": { + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "externalImportReference": { + "maxLength": 200, + "default": "", + "type": "string" + } + } + }, + "MaintenanceExport.CreateMaintenanceExportInput-maintenance_export.write": { + "type": "object", + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication.", + "required": [ + "clientOperationId", + "system" + ], + "properties": { + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "interventionIds": { + "minItems": 1, + "maxItems": 100, + "description": "published intervention identifiers", + "type": "array", + "items": { + "type": "string" + } + }, + "system": { + "maxLength": 40, + "default": "", + "type": "string" + }, + "includeInternalCosts": { + "default": false, + "type": "boolean" + } + } }, - "LegacyFacility.FacilityPlanOverlayOutput.jsonld-facility.read": { + "MaintenanceExport.MaintenanceExportOutput.jsonld-maintenance_export.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -57820,99 +71176,116 @@ { "type": "object", "properties": { - "attachmentId": { - "readOnly": true, - "description": "Property attachmentId.", - "default": "", + "id": { "type": "string" }, - "imageWidth": { - "readOnly": true, - "description": "Property imageWidth.", + "organizationId": { + "type": "string" + }, + "actorId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "schemaVersion": { + "type": "integer" + }, + "system": { + "type": "string" + }, + "includeInternalCosts": { + "type": "boolean" + }, + "sourceInterventionIds": { + "description": "retained structured metadata", + "type": "array", + "items": { + "type": "string" + } + }, + "originalExportId": { "type": [ - "integer", + "string", "null" ] }, - "imageHeight": { - "readOnly": true, - "description": "Property imageHeight.", + "adjustmentOf": { "type": [ - "integer", + "string", "null" ] }, - "zones": { - "readOnly": true, - "description": "Property zones.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "number" - } - } - }, - { - "type": "string" - } - ] - } + "reason": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "state": { + "type": "string" + }, + "confirmation": { + "description": "retained structured metadata", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] } }, - "equipment": { - "readOnly": true, - "description": "Property equipment.", - "type": "array", - "items": { + "rowCount": { + "type": "integer" + }, + "costsComplete": { + "type": [ + "boolean", + "null" + ] + }, + "incompleteCostCount": { + "type": [ + "integer", + "null" + ] + }, + "files": { + "description": "retained structured metadata", + "type": "object", + "additionalProperties": { "type": "object", "additionalProperties": { "anyOf": [ { - "type": "number" + "type": "integer" }, { "type": "string" - }, - { - "type": "null" } ] } } }, - "geometryIssues": { - "readOnly": true, - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "equipmentIssues": { - "readOnly": true, - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } + "replayed": { + "default": false, + "type": "boolean" } } } ], - "description": "Generic organizational facilities (site/building/floor/zone/area)." + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication." }, - "LegacyFacility.GeocodeAddressOutput.jsonld-facility.read": { + "MaintenanceExport.MaintenanceExportReferenceOutput.jsonld-maintenance_export.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -57920,101 +71293,40 @@ { "type": "object", "properties": { - "displayName": { - "readOnly": true, - "description": "Property displayName.", - "default": "", + "id": { "type": "string" }, - "latitude": { - "readOnly": true, - "description": "Property latitude.", - "default": 0, - "type": "number" + "organizationId": { + "type": "string" }, - "longitude": { - "readOnly": true, - "description": "Property longitude.", - "default": 0, - "type": "number" + "resourceType": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "system": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + }, + "replayed": { + "default": false, + "type": "boolean" } } } ], - "description": "Generic organizational facilities (site/building/floor/zone/area)." - }, - "LegacyFacility.MoveFacilityInput-facility.write": { - "type": "object", - "description": "Generic organizational facilities (site/building/floor/zone/area).", - "required": [ - "parentFacilityId" - ], - "properties": { - "parentFacilityId": { - "format": "uuid", - "description": "Target parent facility identifier. Field is required; use null to detach from parent.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "example": "550e8400-e29b-41d4-a716-446655440001", - "type": [ - "string", - "null" - ] - } - } - }, - "LegacyFacility.SetFacilityPlanGeometryInput-facility.write": { - "type": "object", - "description": "Generic organizational facilities (site/building/floor/zone/area).", - "required": [ - "attachmentId", - "points" - ], - "properties": { - "attachmentId": { - "format": "uuid", - "description": "Floor plan attachment identifier the geometry is bound to. Required together with points; use null on both to clear.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "example": "550e8400-e29b-41d4-a716-446655440001", - "type": [ - "string", - "null" - ] - }, - "points": { - "minItems": 3, - "description": "Polygon points in normalized 0-1 image coordinates, at least 3. Required together with attachmentId; use null on both to clear.", - "example": [ - [ - 0.1, - 0.1 - ], - [ - 0.4, - 0.1 - ], - [ - 0.4, - 0.4 - ] - ], - "type": [ - "array", - "null" - ], - "items": { - "type": "array", - "items": { - "type": "number" - } - } - } - } + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication." }, - "LegacyFacility.SuggestAddressesOutput.jsonld-facility.read": { + "MaintenanceExport.MaintenanceExportSourceOutput.jsonld-maintenance_export.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -58022,143 +71334,137 @@ { "type": "object", "properties": { - "member": { - "description": "Property member.", - "type": "array", - "items": { - "$ref": "#/components/schemas/AddressSuggestionOutput.jsonld-facility.read" - } + "id": { + "type": "string" }, - "totalItems": { - "description": "Property totalItems.", - "default": 0, + "number": { "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "publishedAt": { + "type": [ + "string", + "null" + ] + }, + "publicationId": { + "type": [ + "string", + "null" + ] + }, + "site": { + "description": "retained structured metadata", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "customer": { + "description": "retained structured metadata", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "snapshotState": { + "type": "string" + }, + "identityComplete": { + "type": "boolean" + }, + "ready": { + "type": "boolean" + }, + "blockedReason": { + "type": [ + "string", + "null" + ] } } } ], - "description": "Generic organizational facilities (site/building/floor/zone/area)." + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication." }, - "LegacyFacility.UpdateFacilityInput-facility.write.jsonMergePatch": { + "MaintenanceExport.WriteMaintenanceExportReferenceInput-maintenance_export.write.jsonMergePatch": { "type": "object", - "description": "Generic organizational facilities (site/building/floor/zone/area).", + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication.", "properties": { - "type": { - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ], - "description": "Facility type (partial update)", - "example": "building", - "type": [ - "string", - "null" - ] + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" }, - "name": { - "minLength": 2, - "maxLength": 120, - "description": "Facility display name (partial update)", - "example": "Warehouse A", - "type": [ - "string", - "null" - ] + "system": { + "maxLength": 40, + "default": "", + "type": "string" }, - "code": { - "maxLength": 80, - "description": "Optional facility code", - "example": "BLD-WHS-A", - "type": [ - "string", - "null" - ] + "reference": { + "maxLength": 200, + "default": "", + "type": "string" + } + } + }, + "MaintenanceOccurrenceOutput.jsonld-maintenance_plan.read": { + "type": "object", + "properties": { + "id": { + "default": "", + "type": "string" }, - "address": { - "maxLength": 255, - "description": "Optional address", - "example": "12 avenue Victor Hugo, Lyon", - "type": [ - "string", - "null" - ] + "dueAt": { + "default": "", + "type": "string" }, - "latitude": { - "minimum": -90, - "maximum": 90, - "description": "Optional latitude, required together with longitude (partial update)", - "example": 45.764, - "type": [ - "number", - "null" - ] + "attempt": { + "default": 0, + "type": "integer" }, - "longitude": { - "minimum": -180, - "maximum": 180, - "description": "Optional longitude, required together with latitude (partial update)", - "example": 4.8357, + "interventionId": { "type": [ - "number", + "string", "null" ] }, - "metadata": { - "description": "Optional free-form metadata", - "example": { - "surfaceM2": 4500 - }, - "type": "object", - "additionalProperties": { - "type": [ - "string", - "null" - ] - } - }, - "levelIndex": { - "minimum": -100, - "maximum": 200, - "description": "Optional stacking order of the floor (partial update)", - "example": -1, + "number": { "type": [ "integer", "null" ] }, - "elevationMeters": { - "minimum": -10000, - "maximum": 10000, - "description": "Optional floor elevation in meters", - "example": -3, - "type": [ - "number", - "null" - ] + "status": { + "default": "open", + "type": "string" }, - "heightMeters": { - "minimum": 0, - "exclusiveMinimum": 0, - "maximum": 1000, - "description": "Optional floor height in meters", - "example": 3, - "type": [ - "number", - "null" - ] + "retryAllowed": { + "default": false, + "type": "boolean" } } }, - "LegacyInspection.CreateInspectionInput-inspection.write": { + "MaintenancePlan.ChangeMaintenancePlanInput-maintenance_plan.write": { "type": "object", - "description": "Inspection records for fire safety equipment.", + "description": "Organization-scoped operations retain immutable occurrence and legacy identities.", "properties": { - "clientId": { + "equipmentId": { "format": "uuid", - "description": "Client-generated UUID for idempotent offline creation", "externalDocs": { "url": "https://schema.org/identifier" }, @@ -58167,138 +71473,76 @@ "null" ] }, - "organization": { - "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", - "description": "Organization IRI, required on the canonical endpoint", + "name": { + "minLength": 1, + "maxLength": 160, "type": [ "string", "null" ] }, - "intervention": { - "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", - "description": "Optional field intervention IRI", + "operationKind": { + "enum": [ + "control", + "maintenance" + ], "type": [ "string", "null" ] }, - "equipmentId": { - "format": "uuid", - "description": "Equipment identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "default": "", - "example": "550e8400-e29b-41d4-a716-446655440000", - "type": "string" - }, - "result": { - "enum": [ - "pass", - "fail", - "partial" - ], - "description": "Inspection result", - "default": "", - "example": "pass", - "type": "string" - }, - "performedAt": { - "description": "Date the inspection was performed (ISO 8601)", - "default": "", - "example": "2024-06-15T10:00:00+02:00", - "type": "string" - }, - "inspectorType": { - "enum": [ - "user", - "external" - ], - "description": "Inspector type (user or external)", - "default": "", - "example": "user", - "type": "string" - }, - "inspectorName": { - "maxLength": 255, - "description": "Inspector display name", - "default": "", - "example": "Jean Dupont", - "type": "string" - }, - "facilityId": { - "format": "uuid", - "description": "Optional facility identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, + "interval": { + "minLength": 3, + "maxLength": 32, "type": [ "string", "null" ] - }, - "checklistId": { - "format": "uuid", - "description": "Optional checklist identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, + }, + "anchorAt": { "type": [ "string", "null" ] }, - "inspectorUserId": { - "format": "uuid", - "description": "Optional user identifier (required for user type)", + "anchorOn": { "externalDocs": { - "url": "https://schema.org/identifier" + "url": "https://schema.org/Date" }, "type": [ "string", "null" ] }, - "inspectorOrganizationName": { - "maxLength": 255, - "description": "Optional external organization name", + "nextDueAt": { "type": [ "string", "null" ] }, - "notes": { - "maxLength": 5000, - "description": "Optional free-form notes", + "nextDueOn": { + "externalDocs": { + "url": "https://schema.org/Date" + }, "type": [ "string", "null" ] }, - "signature": { - "description": "Optional signature data", + "active": { "type": [ - "string", + "boolean", "null" ] } - }, - "required": [ - "equipmentId", - "result", - "performedAt", - "inspectorType", - "inspectorName" - ] + } }, - "LegacyInspection.EditInspectionInput-inspection.write.jsonMergePatch": { + "MaintenancePlan.ChangeMaintenancePlanInput-maintenance_plan.write.jsonMergePatch": { "type": "object", - "description": "Inspection records for fire safety equipment.", + "description": "Organization-scoped operations retain immutable occurrence and legacy identities.", "properties": { "equipmentId": { "format": "uuid", - "description": "Equipment identifier", "externalDocs": { "url": "https://schema.org/identifier" }, @@ -58307,59 +71551,81 @@ "null" ] }, - "facilityId": { - "format": "uuid", - "description": "Optional facility identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, + "name": { + "minLength": 1, + "maxLength": 160, "type": [ "string", "null" ] }, - "checklistId": { - "format": "uuid", - "description": "Optional checklist identifier", + "operationKind": { + "enum": [ + "control", + "maintenance" + ], + "type": [ + "string", + "null" + ] + }, + "interval": { + "minLength": 3, + "maxLength": 32, + "type": [ + "string", + "null" + ] + }, + "anchorAt": { + "type": [ + "string", + "null" + ] + }, + "anchorOn": { "externalDocs": { - "url": "https://schema.org/identifier" + "url": "https://schema.org/Date" }, "type": [ "string", "null" ] }, - "result": { - "enum": [ - "pass", - "fail", - "partial" - ], - "description": "Inspection result", - "example": "pass", + "nextDueAt": { "type": [ "string", "null" ] }, - "performedAt": { - "description": "Date the inspection was performed (ISO 8601)", - "example": "2024-06-15T10:00:00+02:00", + "nextDueOn": { + "externalDocs": { + "url": "https://schema.org/Date" + }, "type": [ "string", "null" ] }, - "notes": { - "maxLength": 5000, - "description": "Optional free-form notes", + "active": { "type": [ - "string", + "boolean", "null" ] + } + } + }, + "MaintenancePlan.GenerateMaintenancePlanInput-maintenance_plan.write": { + "type": "object", + "description": "Organization-scoped operations retain immutable occurrence and legacy identities.", + "properties": { + "retry": { + "default": false, + "type": "boolean" }, - "signature": { - "description": "Optional signature data", + "name": { + "minLength": 1, + "maxLength": 160, "type": [ "string", "null" @@ -58367,7 +71633,7 @@ } } }, - "LegacyInspection.InspectionOutput.jsonld-inspection.read": { + "MaintenancePlan.GenerateMaintenancePlanOutput.jsonld-maintenance_plan.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -58375,192 +71641,166 @@ { "type": "object", "properties": { - "intervention": { - "description": "Property intervention.", - "type": [ - "string", - "null" - ] + "occurrenceId": { + "default": "", + "type": "string" }, - "recordStatus": { - "description": "Property recordStatus.", - "default": "published", + "interventionId": { + "default": "", "type": "string" }, - "revision": { - "description": "Property revision.", - "default": 1, + "number": { + "default": 0, + "type": "integer" + }, + "workItemsCount": { + "default": 0, + "type": "integer" + }, + "replayed": { + "default": false, + "type": "boolean" + } + } + } + ], + "description": "Organization-scoped operations retain immutable occurrence and legacy identities." + }, + "MaintenancePlan.MaintenancePlanEngineOutput.jsonld-maintenance_plan.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "mode": { + "default": "legacy", + "type": "string" + }, + "preparedCount": { + "default": 0, "type": "integer" }, + "conflicts": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ], + "description": "Organization-scoped operations retain immutable occurrence and legacy identities." + }, + "MaintenancePlan.MaintenancePlanOutput.jsonld-maintenance_plan.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "calendarTimezone": { + "default": "UTC", + "type": "string" + }, "id": { - "readOnly": true, - "description": "Property id.", "default": "", "type": "string" }, "organizationId": { - "readOnly": true, - "description": "Property organizationId.", "default": "", "type": "string" }, "equipmentId": { - "readOnly": true, - "description": "Property equipmentId.", "default": "", "type": "string" }, "facilityId": { - "readOnly": true, - "description": "Property facilityId.", - "type": [ - "string", - "null" - ] - }, - "equipmentSerialNumber": { - "readOnly": true, - "description": "Serial number of the inspected equipment", - "type": [ - "string", - "null" - ] - }, - "facilityName": { - "readOnly": true, - "description": "Display name of the inspected facility", - "type": [ - "string", - "null" - ] - }, - "checklistName": { - "readOnly": true, - "description": "Display name of the followed checklist", "type": [ "string", "null" ] }, - "result": { - "readOnly": true, - "description": "Property result.", + "equipmentType": { "default": "", "type": "string" }, - "status": { - "readOnly": true, - "description": "Property status.", + "name": { "default": "", "type": "string" }, - "performedAt": { - "readOnly": true, - "description": "Property performedAt.", + "operationKind": { + "default": "control", + "type": "string" + }, + "interval": { "default": "", "type": "string" }, - "inspector": { - "readOnly": true, - "description": "Property inspector.", - "anyOf": [ - { - "$ref": "#/components/schemas/InspectorOutput.jsonld-inspection.read" - }, - { - "type": "null" - } + "cadenceMode": { + "default": "fixed", + "type": "string" + }, + "anchorAt": { + "type": [ + "string", + "null" ] }, - "checklistId": { - "readOnly": true, - "description": "Property checklistId.", + "nextDueAt": { "type": [ "string", "null" ] }, - "notes": { - "readOnly": true, - "description": "Property notes.", + "active": { + "default": false, + "type": "boolean" + }, + "legacyScheduleId": { "type": [ "string", "null" ] }, - "signature": { - "readOnly": true, - "description": "Property signature.", + "lastCompletedAt": { "type": [ "string", "null" ] }, - "nonConformitiesCount": { - "readOnly": true, - "description": "Property nonConformitiesCount.", - "default": 0, - "type": "integer" + "archivedAt": { + "type": [ + "string", + "null" + ] }, "createdAt": { - "readOnly": true, - "description": "Property createdAt.", "default": "", "type": "string" }, "updatedAt": { - "readOnly": true, - "description": "Property updatedAt.", "default": "", "type": "string" + }, + "openOccurrence": { + "anyOf": [ + { + "$ref": "#/components/schemas/MaintenanceOccurrenceOutput.jsonld-maintenance_plan.read" + }, + { + "type": "null" + } + ] } } } ], - "description": "Inspection records for fire safety equipment." - }, - "MaintenanceCampaign.GenerateInspectionCampaignInput": { - "type": "object", - "description": "Resource MaintenanceCampaignResource.\n\nGenerates an intervention draft (\"inspection campaign\") from an\norganization's due/overdue maintenance schedules.", - "required": [ - "organization", - "dueBefore" - ], - "properties": { - "organization": { - "description": "Property organization.", - "default": "", - "type": "string" - }, - "name": { - "minLength": 2, - "maxLength": 160, - "description": "Property name.", - "default": "", - "type": "string" - }, - "facility": { - "description": "Property facility.", - "type": [ - "string", - "null" - ] - }, - "equipmentType": { - "description": "Property equipmentType.", - "type": [ - "string", - "null" - ] - }, - "dueBefore": { - "description": "Property dueBefore.", - "default": "", - "type": "string" - } - } + "description": "Organization-scoped operations retain immutable occurrence and legacy identities." }, - "MaintenanceCampaign.GenerateInspectionCampaignOutput.jsonld": { + "MaintenancePlan.MaintenancePlanPreviewOutput.jsonld-maintenance_plan.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -58568,25 +71808,16 @@ { "type": "object", "properties": { - "interventionId": { - "description": "Property interventionId.", - "default": "", - "type": "string" - }, - "number": { - "description": "Property number.", - "default": 0, - "type": "integer" - }, - "workItemsCount": { - "description": "Property workItemsCount.", - "default": 0, - "type": "integer" + "dates": { + "type": "array", + "items": { + "type": "string" + } } } } ], - "description": "Resource MaintenanceCampaignResource.\n\nGenerates an intervention draft (\"inspection campaign\") from an\norganization's due/overdue maintenance schedules." + "description": "Organization-scoped operations retain immutable occurrence and legacy identities." }, "MaintenanceSchedule.MaintenanceScheduleOutput.jsonld": { "allOf": [ @@ -60330,6 +73561,15 @@ "example": "Fireguard Paris", "type": "string" }, + "operatingProfile": { + "enum": [ + "operator", + "service_provider" + ], + "description": "Operational profile; independent from access permissions", + "default": "operator", + "type": "string" + }, "slug": { "minLength": 3, "maxLength": 120, @@ -60366,6 +73606,12 @@ "default": "", "type": "string" }, + "operatingProfile": { + "readOnly": true, + "description": "Property operatingProfile.", + "default": "operator", + "type": "string" + }, "slug": { "readOnly": true, "description": "Property slug.", @@ -60607,6 +73853,17 @@ "null" ] }, + "operatingProfile": { + "enum": [ + "operator", + "service_provider" + ], + "description": "Operational profile; independent from access permissions", + "type": [ + "string", + "null" + ] + }, "slug": { "minLength": 3, "maxLength": 120, @@ -61457,10 +74714,189 @@ "mode": "none", "from": null, "to": null, - "metrics": [], - "health": { - "metrics": [] - } + "metrics": [], + "health": { + "metrics": [] + } + }, + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + }, + { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "trends": { + "readOnly": true, + "description": "Per-KPI running-total sparkline series, one point per day bucket\nacross the dashboard period (`period.from`..`period.to`). Exact for\nthe default \"ends near now\" window; an approximation anchored on\nthe current KPI total for explicitly historical windows.", + "default": { + "facilities": [], + "members": [], + "equipment": [], + "inspections": [] + }, + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } + } + }, + "recentInterventions": { + "readOnly": true, + "description": "The 5 most recently updated organization interventions across every\nstatus, ordered by `updatedAt` descending. Empty when the caller\nlacks `organization.interventions.read`.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } + } + ], + "description": "Organization dashboard analytics and KPIs. Supersedes the legacy /statistics widget endpoints." + }, + "OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "generatedAt": { + "readOnly": true, + "description": "Property generatedAt", + "default": "", + "type": "string" + }, + "metric": { + "readOnly": true, + "description": "Property metric", + "default": "", + "type": "string" + }, + "period": { + "readOnly": true, + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "summary": { + "readOnly": true, + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + }, + "series": { + "readOnly": true, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } + }, + "comparison": { + "readOnly": true, + "default": { + "mode": "none", + "from": null, + "to": null, + "summary": [], + "series": [] }, "type": "object", "additionalProperties": { @@ -61468,23 +74904,14 @@ { "type": "object", "additionalProperties": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "null" - } - ] + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" } - } + ] } }, { @@ -61493,17 +74920,11 @@ "type": "object", "additionalProperties": { "anyOf": [ - { - "type": "number" - }, { "type": "integer" }, { "type": "string" - }, - { - "type": "null" } ] } @@ -61518,15 +74939,9 @@ ] } }, - "trends": { + "seriesByMetric": { "readOnly": true, - "description": "Per-KPI running-total sparkline series, one point per day bucket\nacross the dashboard period (`period.from`..`period.to`). Exact for\nthe default \"ends near now\" window; an approximation anchored on\nthe current KPI total for explicitly historical windows.", - "default": { - "facilities": [], - "members": [], - "equipment": [], - "inspections": [] - }, + "description": "Per-metric series, keyed by metric identifier, populated only when the\n`metrics` filter requested more than the primary `metric` (e.g. the\ntwo-series non-conformities opened/resolved chart). Every entry shares\nthis payload's `period`, so the frontend can render several series on one\nchart without stitching independent calls together.", "type": "object", "additionalProperties": { "type": "array", @@ -61544,34 +74959,13 @@ } } } - }, - "recentInterventions": { - "readOnly": true, - "description": "The 5 most recently updated organization interventions across every\nstatus, ordered by `updatedAt` descending. Empty when the caller\nlacks `organization.interventions.read`.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - }, - { - "type": "null" - } - ] - } - } } } } ], - "description": "Organization dashboard analytics and KPIs. Supersedes the legacy /statistics widget endpoints." + "description": "Organization dashboard trend endpoints for chart-level requests with independent periods." }, - "OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read": { + "OrganizationEquipmentSummary.FacilityEquipmentSummaryOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -61579,134 +74973,69 @@ { "type": "object", "properties": { - "generatedAt": { - "readOnly": true, - "description": "Property generatedAt", - "default": "", - "type": "string" - }, - "metric": { - "readOnly": true, - "description": "Property metric", - "default": "", - "type": "string" - }, - "period": { - "readOnly": true, - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "summary": { + "scope": { "readOnly": true, - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "integer" - }, - { - "type": "string" - } - ] - } + "description": "Property scope.", + "default": "subtree", + "type": "string", + "enum": [ + "subtree", + "direct" + ] }, - "series": { + "totalItems": { "readOnly": true, - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - } - } + "description": "Property totalItems.", + "default": 0, + "type": "integer", + "minimum": 0 }, - "comparison": { + "byStatus": { "readOnly": true, + "description": "Property byStatus.", "default": { - "mode": "none", - "from": null, - "to": null, - "summary": [], - "series": [] + "in_stock": 0, + "operational": 0, + "under_maintenance": 0, + "decommissioned": 0 }, "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "integer" - } - ] - } - }, - { - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - } - } - }, - { - "type": "string" - }, - { - "type": "null" - } - ] + "required": [ + "in_stock", + "operational", + "under_maintenance", + "decommissioned" + ], + "properties": { + "in_stock": { + "type": "integer", + "minimum": 0 + }, + "operational": { + "type": "integer", + "minimum": 0 + }, + "under_maintenance": { + "type": "integer", + "minimum": 0 + }, + "decommissioned": { + "type": "integer", + "minimum": 0 + } } }, - "seriesByMetric": { + "needingAttentionCount": { "readOnly": true, - "description": "Per-metric series, keyed by metric identifier, populated only when the\n`metrics` filter requested more than the primary `metric` (e.g. the\ntwo-series non-conformities opened/resolved chart). Every entry shares\nthis payload's `period`, so the frontend can render several series on one\nchart without stitching independent calls together.", - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - } - } - } + "description": "Property needingAttentionCount.", + "default": 0, + "type": "integer", + "minimum": 0 } } } ], - "description": "Organization dashboard trend endpoints for chart-level requests with independent periods." + "description": "Organization parc summary within the caller's selected customer and family." }, "OrganizationInvitation.AcceptOrganizationInvitationInput-Organization.write": { "type": "object", @@ -63734,6 +77063,130 @@ ], "description": "OTP configuration discovery endpoints." }, + "ParkAnomaly.NonConformityOutput.jsonld-inspection.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id", + "default": "", + "type": "string" + }, + "inspectionId": { + "readOnly": true, + "description": "Property inspectionId", + "default": "", + "type": "string" + }, + "description": { + "readOnly": true, + "description": "Property description", + "default": "", + "type": "string" + }, + "severity": { + "readOnly": true, + "description": "Property severity", + "default": "", + "type": "string" + }, + "status": { + "readOnly": true, + "description": "Property status", + "default": "", + "type": "string" + }, + "dueAt": { + "readOnly": true, + "description": "Property dueAt", + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "readOnly": true, + "description": "Property resolvedAt", + "type": [ + "string", + "null" + ] + }, + "notes": { + "readOnly": true, + "description": "Property notes", + "type": [ + "string", + "null" + ] + }, + "equipmentId": { + "readOnly": true, + "description": "Identifier of the inspected equipment", + "type": [ + "string", + "null" + ] + }, + "equipmentSerialNumber": { + "readOnly": true, + "description": "Serial number of the inspected equipment", + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt", + "default": "", + "type": "string" + } + } + } + ], + "description": "Organization parc anomaly queue, backed by published inspection findings." + }, + "ParkAnomaly.ParkAnomaliesSummaryOutput.jsonld-inspection.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "openAnomalies": { + "default": 0, + "type": "integer" + }, + "bySeverity": { + "default": { + "low": 0, + "medium": 0, + "high": 0, + "critical": 0 + }, + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + } + ], + "description": "Organization parc anomaly queue, backed by published inspection findings." + }, "PasswordChange.ConfirmPasswordChangeInput-password_change.write": { "type": "object", "description": "Authenticated password change operations (OTP protected)", @@ -64418,109 +77871,453 @@ } } } - ], - "description": "Subscription plan catalog." + ], + "description": "Subscription plan catalog." + }, + "Plan.UpdatePlanInput-Plan.write.jsonMergePatch": { + "type": "object", + "description": "Subscription plan catalog.", + "properties": { + "name": { + "minLength": 2, + "maxLength": 120, + "description": "Plan display name", + "type": [ + "string", + "null" + ] + }, + "description": { + "maxLength": 2000, + "description": "Plan description", + "type": [ + "string", + "null" + ] + }, + "limits": { + "description": "Per-resource quantity caps (members, facilities, equipment, inspections); omit a resource for unlimited", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "integer" + } + }, + "isActive": { + "description": "Whether the plan can be selected", + "type": [ + "boolean", + "null" + ] + }, + "isDefault": { + "description": "Whether the plan is the catalog default", + "type": [ + "boolean", + "null" + ] + }, + "sortOrder": { + "description": "Display order", + "type": [ + "integer", + "null" + ] + } + } + }, + "PlanQuotaOutput.jsonld-Plan.read": { + "type": "object", + "properties": { + "resource": { + "readOnly": true, + "description": "Property resource.", + "default": "", + "type": "string" + }, + "label": { + "readOnly": true, + "description": "Property label.", + "default": "", + "type": "string" + }, + "limit": { + "readOnly": true, + "description": "Property limit.", + "type": [ + "integer", + "null" + ] + }, + "summary": { + "readOnly": true, + "description": "Property summary.", + "default": "", + "type": "string" + } + } + }, + "Presence.PingPresenceInput": { + "type": "object", + "description": "Resource PresenceResource.\n\nOnline presence (L2.7) \u2014 **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test \u2014 see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` \u2014 there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`).", + "required": [ + "organization" + ], + "properties": { + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" + } + } + }, + "Presence.PingPresenceOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "memberId": { + "readOnly": true, + "description": "Property memberId.", + "default": "", + "type": "string" + }, + "lastSeenAt": { + "readOnly": true, + "description": "Property lastSeenAt.", + "default": "", + "type": "string" + } + } + } + ], + "description": "Resource PresenceResource.\n\nOnline presence (L2.7) \u2014 **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test \u2014 see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` \u2014 there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`)." + }, + "Presence.PresenceOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "memberId": { + "description": "Property memberId.", + "default": "", + "type": "string" + }, + "online": { + "description": "Property online.", + "default": false, + "type": "boolean" + }, + "status": { + "description": "Property status", + "default": "offline", + "type": "string", + "enum": [ + "active", + "do_not_disturb", + "offline" + ] + }, + "lastSeenAt": { + "description": "Property lastSeenAt.", + "type": [ + "string", + "null" + ] + } + } + } + ], + "description": "Resource PresenceResource.\n\nOnline presence (L2.7) \u2014 **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test \u2014 see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` \u2014 there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`)." + }, + "Presence.PresenceSubscriptionOutput.jsonld-presence.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "topic": { + "description": "Property topic", + "default": "", + "type": "string" + }, + "token": { + "description": "Property token", + "default": "", + "type": "string" + }, + "expiresAt": { + "description": "Property expiresAt", + "default": "", + "type": "string" + } + } + } + ], + "description": "Resource PresenceResource.\n\nOnline presence (L2.7) \u2014 **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test \u2014 see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` \u2014 there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`)." + }, + "PresencePreference.PresencePreferenceOutput.jsonld-presence-preference.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "doNotDisturb": { + "description": "Property doNotDisturb", + "default": false, + "type": "boolean" + }, + "revision": { + "description": "Property revision", + "default": 0, + "type": "integer" + }, + "invisible": { + "description": "Property invisible", + "default": false, + "type": "boolean" + } + } + } + ], + "description": "Service PresencePreferenceResource." + }, + "PresencePreference.PresencePreferenceSubscriptionOutput.jsonld-presence-preference.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "topic": { + "description": "Property topic", + "default": "", + "type": "string" + }, + "token": { + "description": "Property token", + "default": "", + "type": "string" + }, + "expiresAt": { + "description": "Property expiresAt", + "default": "", + "type": "string" + } + } + } + ], + "description": "Service PresencePreferenceResource." + }, + "PresencePreference.UpdatePresencePreferenceInput-presence-preference.write.jsonMergePatch": { + "type": "object", + "description": "Service PresencePreferenceResource.", + "properties": { + "doNotDisturb": { + "description": "Property doNotDisturb", + "type": "boolean" + }, + "invisible": { + "description": "Property invisible", + "type": "boolean" + } + } + }, + "Procurement.ChangePurchaseOrderInput-procurement.write": { + "type": "object", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "supplierId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "lines": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, + "Procurement.ChangePurchaseOrderInput-procurement.write.jsonMergePatch": { + "type": "object", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "supplierId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "lines": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + } + } }, - "Plan.UpdatePlanInput-Plan.write.jsonMergePatch": { + "Procurement.ChangeSupplierInput-procurement.write": { "type": "object", - "description": "Subscription plan catalog.", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", "properties": { "name": { - "minLength": 2, - "maxLength": 120, - "description": "Plan display name", "type": [ "string", "null" ] }, - "description": { - "maxLength": 2000, - "description": "Plan description", + "code": { "type": [ "string", "null" ] }, - "limits": { - "description": "Per-resource quantity caps (members, facilities, equipment, inspections); omit a resource for unlimited", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "integer" - } - }, - "isActive": { - "description": "Whether the plan can be selected", + "email": { "type": [ - "boolean", + "string", "null" ] }, - "isDefault": { - "description": "Whether the plan is the catalog default", + "phone": { "type": [ - "boolean", + "string", "null" ] }, - "sortOrder": { - "description": "Display order", + "contacts": { "type": [ - "integer", + "array", "null" - ] + ], + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } } } }, - "PlanQuotaOutput.jsonld-Plan.read": { + "Procurement.ChangeSupplierInput-procurement.write.jsonMergePatch": { "type": "object", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", "properties": { - "resource": { - "readOnly": true, - "description": "Property resource.", - "default": "", - "type": "string" + "name": { + "type": [ + "string", + "null" + ] }, - "label": { - "readOnly": true, - "description": "Property label.", - "default": "", - "type": "string" + "code": { + "type": [ + "string", + "null" + ] }, - "limit": { - "readOnly": true, - "description": "Property limit.", + "email": { "type": [ - "integer", + "string", "null" ] }, - "summary": { - "readOnly": true, - "description": "Property summary.", - "default": "", - "type": "string" + "phone": { + "type": [ + "string", + "null" + ] + }, + "contacts": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } } } }, - "Presence.PingPresenceInput": { + "Procurement.IndividualizeReceiptInput-procurement.write": { "type": "object", - "description": "Resource PresenceResource.\n\nOnline presence (L2.7) \u2014 **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test \u2014 see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` \u2014 there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`).", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", "required": [ - "organization" + "clientOperationId" ], "properties": { - "organization": { - "description": "Property organization.", + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "default": "", "type": "string" } } }, - "Presence.PingPresenceOutput.jsonld": { + "Procurement.ProcurementReceiptOutput.jsonld-procurement.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -64528,53 +78325,78 @@ { "type": "object", "properties": { - "memberId": { - "readOnly": true, - "description": "Property memberId.", - "default": "", + "pendingReturnQuantity": { + "default": "0.000000", "type": "string" }, - "lastSeenAt": { - "readOnly": true, - "description": "Property lastSeenAt.", - "default": "", + "id": { "type": "string" - } - } - } - ], - "description": "Resource PresenceResource.\n\nOnline presence (L2.7) \u2014 **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test \u2014 see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` \u2014 there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`)." - }, - "Presence.PresenceOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "memberId": { - "description": "Property memberId.", - "default": "", + }, + "organizationId": { "type": "string" }, - "online": { - "description": "Property online.", - "default": false, - "type": "boolean" + "orderId": { + "type": "string" + }, + "lineId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "quantity": { + "type": "string" + }, + "warehouseId": { + "type": [ + "string", + "null" + ] + }, + "currency": { + "type": "string" + }, + "receivedAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "inventoryMovementId": { + "type": [ + "string", + "null" + ] + }, + "equipmentIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "returnedQuantity": { + "type": "string" }, "status": { - "description": "Property status", - "default": "offline", - "type": "string", - "enum": [ - "active", - "do_not_disturb", - "offline" + "type": "string" + }, + "blockedReason": { + "type": [ + "string", + "null" ] }, - "lastSeenAt": { - "description": "Property lastSeenAt.", + "revision": { + "type": "integer" + }, + "financialVisible": { + "type": "boolean" + }, + "replayed": { + "default": false, + "type": "boolean" + }, + "unitCost": { "type": [ "string", "null" @@ -64583,9 +78405,9 @@ } } ], - "description": "Resource PresenceResource.\n\nOnline presence (L2.7) \u2014 **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test \u2014 see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` \u2014 there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`)." + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP." }, - "Presence.PresenceSubscriptionOutput.jsonld-presence.read": { + "Procurement.ProcurementReturnOutput.jsonld-procurement.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -64593,27 +78415,61 @@ { "type": "object", "properties": { - "topic": { - "description": "Property topic", - "default": "", + "id": { "type": "string" }, - "token": { - "description": "Property token", - "default": "", + "organizationId": { "type": "string" }, - "expiresAt": { - "description": "Property expiresAt", - "default": "", + "receiptId": { + "type": "string" + }, + "clientOperationId": { + "type": "string" + }, + "quantity": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "string" + }, + "inventoryMovementId": { + "type": [ + "string", + "null" + ] + }, + "blockedReason": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { "type": "string" + }, + "reconciledAt": { + "type": [ + "string", + "null" + ] + }, + "revision": { + "type": "integer" + }, + "replayed": { + "default": false, + "type": "boolean" } } } ], - "description": "Resource PresenceResource.\n\nOnline presence (L2.7) \u2014 **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test \u2014 see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` \u2014 there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`)." + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP." }, - "PresencePreference.PresencePreferenceOutput.jsonld-presence-preference.read": { + "Procurement.PurchaseOrderOutput.jsonld-procurement.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -64621,27 +78477,141 @@ { "type": "object", "properties": { - "doNotDisturb": { - "description": "Property doNotDisturb", - "default": false, + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "supplierId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "status": { + "type": "string" + }, + "lines": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "financialVisible": { "type": "boolean" }, "revision": { - "description": "Property revision", - "default": 0, "type": "integer" }, - "invisible": { - "description": "Property invisible", + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "replayed": { "default": false, "type": "boolean" } } } ], - "description": "Service PresencePreferenceResource." + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP." }, - "PresencePreference.PresencePreferenceSubscriptionOutput.jsonld-presence-preference.read": { + "Procurement.ReceivePurchaseOrderInput-procurement.write": { + "type": "object", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", + "required": [ + "lineId", + "quantity", + "receivedAt", + "clientOperationId" + ], + "properties": { + "lineId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "warehouseId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "quantity": { + "default": "", + "type": "string" + }, + "receivedAt": { + "default": "", + "type": "string" + }, + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + } + } + }, + "Procurement.ReturnProcurementReceiptInput-procurement.write": { + "type": "object", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", + "required": [ + "quantity", + "reason", + "clientOperationId" + ], + "properties": { + "quantity": { + "default": "", + "type": "string" + }, + "reason": { + "default": "", + "type": "string" + }, + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + } + } + }, + "Procurement.SupplierOutput.jsonld-procurement.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -64649,39 +78619,78 @@ { "type": "object", "properties": { - "topic": { - "description": "Property topic", - "default": "", + "id": { "type": "string" }, - "token": { - "description": "Property token", - "default": "", + "organizationId": { "type": "string" }, - "expiresAt": { - "description": "Property expiresAt", - "default": "", + "name": { + "type": "string" + }, + "code": { + "type": [ + "string", + "null" + ] + }, + "email": { + "type": [ + "string", + "null" + ] + }, + "phone": { + "type": [ + "string", + "null" + ] + }, + "contacts": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "archivedAt": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { "type": "string" + }, + "revision": { + "type": "integer" + }, + "replayed": { + "default": false, + "type": "boolean" } } } ], - "description": "Service PresencePreferenceResource." - }, - "PresencePreference.UpdatePresencePreferenceInput-presence-preference.write.jsonMergePatch": { - "type": "object", - "description": "Service PresencePreferenceResource.", - "properties": { - "doNotDisturb": { - "description": "Property doNotDisturb", - "type": "boolean" - }, - "invisible": { - "description": "Property invisible", - "type": "boolean" - } - } + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP." }, "Publication.CreatePublicationInput": { "type": "object", @@ -65010,6 +79019,103 @@ } } }, + "ReplacementSuccessorInput-equipment.write": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "maxLength": 32, + "description": "Property type", + "default": "", + "type": "string" + }, + "subType": { + "maxLength": 100, + "description": "Property subType", + "type": [ + "string", + "null" + ] + }, + "brand": { + "maxLength": 100, + "description": "Property brand", + "type": [ + "string", + "null" + ] + }, + "model": { + "maxLength": 100, + "description": "Property model", + "type": [ + "string", + "null" + ] + }, + "serialNumber": { + "maxLength": 100, + "description": "Property serialNumber", + "type": [ + "string", + "null" + ] + }, + "locationLabel": { + "maxLength": 255, + "description": "Property locationLabel", + "type": [ + "string", + "null" + ] + }, + "name": { + "maxLength": 255, + "description": "Property name", + "type": [ + "string", + "null" + ] + }, + "assetCode": { + "maxLength": 100, + "description": "Property assetCode", + "type": [ + "string", + "null" + ] + }, + "criticality": { + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "description": "Property criticality", + "type": [ + "string", + "null" + ] + }, + "technicalProperties": { + "maxItems": 50, + "description": "Property technicalProperties", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, "Role.AddPermissionInput": { "type": "object", "description": "Role-Based Access Control (RBAC) role management. Roles group permissions together.", @@ -65273,6 +79379,370 @@ ], "description": "Dated archives of the regulatory \"registre de s\u00e9curit\u00e9\" PDF. Requires organization.compliance.export AND a pro/max plan." }, + "ServiceRequest.ConvertServiceRequestInput-service_request.write": { + "type": "object", + "description": "Class ServiceRequestResource. Organization-owned internal repair request workflow. @category Resource", + "required": [ + "clientOperationId" + ], + "properties": { + "clientOperationId": { + "format": "uuid", + "description": "Property clientOperationId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "existingInterventionId": { + "format": "uuid", + "description": "Property existingInterventionId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "existingTaskId": { + "format": "uuid", + "description": "Property existingTaskId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + } + } + }, + "ServiceRequest.CreateServiceRequestInput-service_request.write": { + "type": "object", + "description": "Class ServiceRequestResource. Organization-owned internal repair request workflow. @category Resource", + "properties": { + "equipmentId": { + "format": "uuid", + "description": "Property equipmentId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "siteId": { + "format": "uuid", + "description": "Property siteId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "title": { + "maxLength": 160, + "description": "Property title.", + "default": "", + "type": "string" + }, + "description": { + "maxLength": 10000, + "description": "Property description.", + "default": "", + "type": "string" + }, + "priority": { + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "description": "Property priority.", + "default": "normal", + "type": "string" + }, + "originInspectionId": { + "format": "uuid", + "description": "Property originInspectionId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "originNonConformityId": { + "format": "uuid", + "description": "Property originNonConformityId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "title", + "description" + ] + }, + "ServiceRequest.DecisionServiceRequestInput-service_request.write": { + "type": "object", + "description": "Class ServiceRequestResource. Organization-owned internal repair request workflow. @category Resource", + "required": [ + "reason" + ], + "properties": { + "reason": { + "maxLength": 2000, + "description": "Property reason.", + "default": "", + "type": "string" + } + } + }, + "ServiceRequest.QualifyServiceRequestInput-service_request.write": { + "type": "object", + "description": "Class ServiceRequestResource. Organization-owned internal repair request workflow. @category Resource", + "properties": { + "equipmentId": { + "format": "uuid", + "description": "Property equipmentId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "note": { + "maxLength": 10000, + "description": "Property note.", + "type": [ + "string", + "null" + ] + } + } + }, + "ServiceRequest.ServiceRequestOutput.jsonld-service_request.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "equipmentId": { + "description": "Property equipmentId.", + "type": [ + "string", + "null" + ] + }, + "siteId": { + "description": "Property siteId.", + "type": [ + "string", + "null" + ] + }, + "title": { + "description": "Property title.", + "default": "", + "type": "string" + }, + "description": { + "description": "Property description.", + "default": "", + "type": "string" + }, + "priority": { + "description": "Property priority.", + "default": "normal", + "type": "string" + }, + "status": { + "description": "Property status.", + "default": "requested", + "type": "string" + }, + "originInspectionId": { + "description": "Property originInspectionId.", + "type": [ + "string", + "null" + ] + }, + "originNonConformityId": { + "description": "Property originNonConformityId.", + "type": [ + "string", + "null" + ] + }, + "qualificationNote": { + "description": "Property qualificationNote.", + "type": [ + "string", + "null" + ] + }, + "decisionReason": { + "description": "Property decisionReason.", + "type": [ + "string", + "null" + ] + }, + "interventionId": { + "description": "Property interventionId.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Property taskId.", + "type": [ + "string", + "null" + ] + }, + "requestedAt": { + "description": "Property requestedAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "description": "Property updatedAt.", + "default": "", + "type": "string" + }, + "qualifiedAt": { + "description": "Property qualifiedAt.", + "type": [ + "string", + "null" + ] + }, + "rejectedAt": { + "description": "Property rejectedAt.", + "type": [ + "string", + "null" + ] + }, + "cancelledAt": { + "description": "Property cancelledAt.", + "type": [ + "string", + "null" + ] + }, + "convertedAt": { + "description": "Property convertedAt.", + "type": [ + "string", + "null" + ] + }, + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" + }, + "targetSnapshot": { + "default": { + "equipment": null, + "site": null, + "customer": null + }, + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + } + } + } + ], + "description": "Class ServiceRequestResource. Organization-owned internal repair request workflow. @category Resource" + }, + "ServiceRequest.UpdateServiceRequestInput-service_request.write.jsonMergePatch": { + "type": "object", + "description": "Class ServiceRequestResource. Organization-owned internal repair request workflow. @category Resource", + "properties": { + "title": { + "maxLength": 160, + "description": "Property title.", + "type": [ + "string", + "null" + ] + }, + "description": { + "maxLength": 10000, + "description": "Property description.", + "type": [ + "string", + "null" + ] + }, + "priority": { + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "description": "Property priority.", + "type": [ + "string", + "null" + ] + } + } + }, "Session.RevokeOtherSessionsOutput.jsonld-session.revoke_others": { "allOf": [ { @@ -67691,6 +82161,10 @@ "name": "Teams", "description": "Organization team management." }, + { + "name": "Customer", + "description": "Class CustomerResource. Internal customer management inside a single organization. @category Resource" + }, { "name": "Onboarding", "description": "Organization onboarding orchestration endpoints." @@ -67701,7 +82175,7 @@ }, { "name": "Equipment", - "description": "Resource FacilityEquipmentSummaryResource." + "description": "Organization parc summary within the caller's selected customer and family." }, { "name": "Media", @@ -67709,16 +82183,24 @@ }, { "name": "Inspection", - "description": "Organization-wide non-conformity statistics for the compliance KPI surfaces." + "description": "Organization parc anomaly queue, backed by published inspection findings." }, { "name": "Checklist", "description": "Versioned checklist templates for inspections." }, + { + "name": "EquipmentInspectionSummary", + "description": "Resource EquipmentInspectionSummaryResource." + }, { "name": "InspectionResponse", "description": "Resource InspectionResponseResource." }, + { + "name": "EquipmentOpenWork", + "description": "Class EquipmentOpenWorkResource\n\nProvides a scoped lookup without changing equipment-read permissions." + }, { "name": "InterventionActivity", "description": "Resource InterventionActivityResource.\n\nThe intervention activity feed: member-authored comments and\nsystem-recorded lifecycle events (creation, status transitions)." @@ -67804,11 +82286,11 @@ "description": "Resource MaintenanceCampaignResource.\n\nGenerates an intervention draft (\"inspection campaign\") from an\norganization's due/overdue maintenance schedules." }, { - "name": "MaintenanceSchedule", + "name": "Maintenance", "description": "Resource MaintenanceScheduleResource.\n\nOrganization-scoped preventive-maintenance schedules: one row per tracked\npiece of equipment, computed from the equipment's inspection history and\nthe organization's compliance periodicity (or a per-equipment override)." }, { - "name": "Maintenance", + "name": "MaintenanceSchedule", "description": "Resource MaintenanceScheduleResource.\n\nOrganization-scoped preventive-maintenance schedules: one row per tracked\npiece of equipment, computed from the equipment's inspection history and\nthe organization's compliance periodicity (or a per-equipment override)." }, { @@ -67883,6 +82365,50 @@ "name": "Calendar", "description": "Member-scoped iCal calendar feed subscription token." }, + { + "name": "ServiceRequest", + "description": "Class ServiceRequestResource. Organization-owned internal repair request workflow. @category Resource" + }, + { + "name": "Inventory", + "description": "Manage warehouses. @category Resource" + }, + { + "name": "InventoryConsumption", + "description": "Declare complete physical consumptions, retaining shortages. @category Resource" + }, + { + "name": "InventoryMovement", + "description": "Read immutable motivated stock movements. @category Resource" + }, + { + "name": "InventoryPart", + "description": "Manage quantitative part and consumable references. @category Resource" + }, + { + "name": "InventoryWarehouse", + "description": "Manage warehouses. @category Resource" + }, + { + "name": "Inventorycorrections", + "description": "Motivated stock corrections. @category Resource" + }, + { + "name": "Inventoryreturns", + "description": "Motivated stock returns. @category Resource" + }, + { + "name": "Procurement", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP." + }, + { + "name": "MaintenanceCost", + "description": "Class MaintenanceEconomicResource\n\nDedicated financial endpoints exclude ordinary operational and client reports." + }, + { + "name": "Maintenance export", + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication." + }, { "name": "Authentication", "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." diff --git a/src/Customer/Application/Contract/CustomerSnapshot.php b/src/Customer/Application/Contract/CustomerSnapshot.php new file mode 100644 index 000000000..2bb07fcc2 --- /dev/null +++ b/src/Customer/Application/Contract/CustomerSnapshot.php @@ -0,0 +1,18 @@ + $contacts + */ + public function __construct(public string $id, public string $name, public array $contacts, public ?DateTimeImmutable $archivedAt) + { + } +} diff --git a/src/Customer/Application/Contract/CustomerView.php b/src/Customer/Application/Contract/CustomerView.php new file mode 100644 index 000000000..5cbd0a14d --- /dev/null +++ b/src/Customer/Application/Contract/CustomerView.php @@ -0,0 +1,24 @@ + $contacts + */ + public function __construct(public string $id, public string $organizationId, public string $name, public ?string $code, public ?string $email, public ?string $phone, public array $contacts, public ?DateTimeImmutable $archivedAt, public DateTimeImmutable $createdAt, public DateTimeImmutable $updatedAt, public int $revision) + { + } + + public static function fromCustomer(Customer $customer): self + { + return new self($customer->id, $customer->organizationId, $customer->name, $customer->code, $customer->email, $customer->phone, $customer->contacts, $customer->archivedAt, $customer->createdAt, $customer->updatedAt, $customer->revision); + } +} diff --git a/src/Customer/Application/Port/Inbound/CustomerLookupPort.php b/src/Customer/Application/Port/Inbound/CustomerLookupPort.php new file mode 100644 index 000000000..e2998aa3b --- /dev/null +++ b/src/Customer/Application/Port/Inbound/CustomerLookupPort.php @@ -0,0 +1,13 @@ + + */ + public function list(string $organizationId, string $search, bool $archived, int $offset, int $limit): array; + + public function count(string $organizationId, string $search, bool $archived): int; +} diff --git a/src/Customer/Application/Service/CustomerAccessGuard.php b/src/Customer/Application/Service/CustomerAccessGuard.php new file mode 100644 index 000000000..55904055e --- /dev/null +++ b/src/Customer/Application/Service/CustomerAccessGuard.php @@ -0,0 +1,29 @@ +authorization->resolveAccess($actorId, $organizationId, $write ? 'organization.customers.manage' : 'organization.customers.read'); + if ($decision->isOutsideScope()) { + throw CustomerException::notFound(); + } + if (!$decision->isGranted()) { + throw CustomerException::denied(); + } + } +} diff --git a/src/Customer/Application/UseCase/Command/ChangeCustomer/ChangeCustomerCommand.php b/src/Customer/Application/UseCase/Command/ChangeCustomer/ChangeCustomerCommand.php new file mode 100644 index 000000000..a45a2eb98 --- /dev/null +++ b/src/Customer/Application/UseCase/Command/ChangeCustomer/ChangeCustomerCommand.php @@ -0,0 +1,18 @@ + $changes + */ + public function __construct(public string $actorId, public string $organizationId, public string $customerId, public string $action, public ?int $expectedRevision, public array $changes = []) + { + } +} diff --git a/src/Customer/Application/UseCase/Command/ChangeCustomer/ChangeCustomerHandler.php b/src/Customer/Application/UseCase/Command/ChangeCustomer/ChangeCustomerHandler.php new file mode 100644 index 000000000..247189c39 --- /dev/null +++ b/src/Customer/Application/UseCase/Command/ChangeCustomer/ChangeCustomerHandler.php @@ -0,0 +1,53 @@ +access->assertAccess($command->actorId, $command->organizationId, true); + + return $this->transactions->transactional(function () use ($command): ChangeCustomerResult { + $customer = $this->customers->find($command->customerId, $command->organizationId, true); + if (null === $customer) { + throw CustomerException::notFound(); + } + if (null === $command->expectedRevision) { + throw new CustomerException('customer_precondition_required', 'If-Match is required for this mutation.'); + } + if ($customer->revision !== $command->expectedRevision) { + throw CustomerException::stale(); + } + $changed = match ($command->action) { + 'patch' => $customer->change($command->changes, $this->clock->now()), + 'archive' => $customer->archive($this->clock->now()), + 'restore' => $customer->restore($this->clock->now()), + default => throw CustomerException::invalid('Unsupported customer action.'), + }; + if ($changed !== $customer) { + $this->customers->save($changed, $customer->revision); + $this->events->dispatch(new CustomerChangedEvent($changed->organizationId, $changed->id, match ($command->action) { + 'archive' => 'archived', 'restore' => 'restored', default => 'updated' + }, $changed->revision, $changed->updatedAt)); + } + + return new ChangeCustomerResult(CustomerView::fromCustomer($changed)); + }); + } +} diff --git a/src/Customer/Application/UseCase/Command/ChangeCustomer/ChangeCustomerResult.php b/src/Customer/Application/UseCase/Command/ChangeCustomer/ChangeCustomerResult.php new file mode 100644 index 000000000..de8029d97 --- /dev/null +++ b/src/Customer/Application/UseCase/Command/ChangeCustomer/ChangeCustomerResult.php @@ -0,0 +1,16 @@ + $contacts + */ + public function __construct(public string $actorId, public string $organizationId, public string $name, public ?string $code = null, public ?string $email = null, public ?string $phone = null, public array $contacts = []) + { + } +} diff --git a/src/Customer/Application/UseCase/Command/CreateCustomer/CreateCustomerHandler.php b/src/Customer/Application/UseCase/Command/CreateCustomer/CreateCustomerHandler.php new file mode 100644 index 000000000..4306f0fb7 --- /dev/null +++ b/src/Customer/Application/UseCase/Command/CreateCustomer/CreateCustomerHandler.php @@ -0,0 +1,34 @@ +access->assertAccess($command->actorId, $command->organizationId, true); + + return $this->transactions->transactional(function () use ($command): CreateCustomerResult { + $customer = Customer::create($this->ids->generate(), $command->organizationId, $command->name, $command->code, $command->email, $command->phone, $command->contacts, $this->clock->now()); + $this->customers->save($customer); + $this->events->dispatch(new CustomerChangedEvent($customer->organizationId, $customer->id, 'created', $customer->revision, $customer->updatedAt)); + + return new CreateCustomerResult(CustomerView::fromCustomer($customer)); + }); + } +} diff --git a/src/Customer/Application/UseCase/Command/CreateCustomer/CreateCustomerResult.php b/src/Customer/Application/UseCase/Command/CreateCustomer/CreateCustomerResult.php new file mode 100644 index 000000000..4e3497526 --- /dev/null +++ b/src/Customer/Application/UseCase/Command/CreateCustomer/CreateCustomerResult.php @@ -0,0 +1,16 @@ +access->assertAccess($query->actorId, $query->organizationId, false); + $customer = $this->customers->find($query->customerId, $query->organizationId); + if (null === $customer) { + throw CustomerException::notFound(); + } + + return new GetCustomerResult(CustomerView::fromCustomer($customer)); + } +} diff --git a/src/Customer/Application/UseCase/Query/GetCustomer/GetCustomerQuery.php b/src/Customer/Application/UseCase/Query/GetCustomer/GetCustomerQuery.php new file mode 100644 index 000000000..fa6e7b915 --- /dev/null +++ b/src/Customer/Application/UseCase/Query/GetCustomer/GetCustomerQuery.php @@ -0,0 +1,15 @@ +access->assertAccess($query->actorId, $query->organizationId, false); + if ($query->page < 1 || $query->itemsPerPage < 1 || $query->itemsPerPage > 100 || mb_strlen($query->search) > 160) { + throw CustomerException::invalid('Invalid customer pagination or search.'); + } + $search = trim($query->search); + $items = $this->customers->list($query->organizationId, $search, $query->archived, ($query->page - 1) * $query->itemsPerPage, $query->itemsPerPage); + + return new ListCustomersResult(array_map(CustomerView::fromCustomer(...), $items), $this->customers->count($query->organizationId, $search, $query->archived), $query->page, $query->itemsPerPage); + } +} diff --git a/src/Customer/Application/UseCase/Query/ListCustomers/ListCustomersQuery.php b/src/Customer/Application/UseCase/Query/ListCustomers/ListCustomersQuery.php new file mode 100644 index 000000000..57cd96b07 --- /dev/null +++ b/src/Customer/Application/UseCase/Query/ListCustomers/ListCustomersQuery.php @@ -0,0 +1,15 @@ + $items + */ + public function __construct(public array $items, public int $total, public int $page, public int $itemsPerPage) + { + } +} diff --git a/src/Customer/Domain/Event/CustomerChangedEvent.php b/src/Customer/Domain/Event/CustomerChangedEvent.php new file mode 100644 index 000000000..af18932c5 --- /dev/null +++ b/src/Customer/Domain/Event/CustomerChangedEvent.php @@ -0,0 +1,21 @@ + $contacts + */ + private function __construct( + public string $id, + public string $organizationId, + public string $name, + public ?string $code, + public ?string $email, + public ?string $phone, + public array $contacts, + public ?DateTimeImmutable $archivedAt, + public DateTimeImmutable $createdAt, + public DateTimeImmutable $updatedAt, + public int $revision, + ) { + } + + /** + * @param array $contacts + */ + public static function create(string $id, string $organizationId, string $name, ?string $code, ?string $email, ?string $phone, array $contacts, DateTimeImmutable $now): self + { + new Uuid($id); + new Uuid($organizationId); + + return new self($id, $organizationId, self::name($name), self::text($code, 80), self::email($email), self::text($phone, 40), self::contacts($contacts), null, $now, $now, 1); + } + + /** + * @param list $contacts + */ + public static function reconstitute(string $id, string $organizationId, string $name, ?string $code, ?string $email, ?string $phone, array $contacts, ?DateTimeImmutable $archivedAt, DateTimeImmutable $createdAt, DateTimeImmutable $updatedAt, int $revision): self + { + return new self($id, $organizationId, $name, $code, $email, $phone, $contacts, $archivedAt, $createdAt, $updatedAt, $revision); + } + + /** + * @param array $changes + */ + public function change(array $changes, DateTimeImmutable $now): self + { + $allowed = ['name', 'code', 'email', 'phone', 'contacts']; + foreach ($changes as $key => $value) { + if (!in_array($key, $allowed, true)) { + throw CustomerException::invalid('Unknown customer field.'); + } + if ('contacts' === $key) { + if (!is_array($value)) { + throw CustomerException::invalid('Contacts must be a list.'); + } + } elseif ('name' === $key && !is_string($value)) { + throw CustomerException::invalid('Customer name is required.'); + } elseif (null !== $value && !is_string($value)) { + throw CustomerException::invalid('Customer fields must be strings or null.'); + } + } + /** @var string $name */ $name = $changes['name'] ?? $this->name; + /** @var ?string $code */ $code = array_key_exists('code', $changes) ? $changes['code'] : $this->code; + /** @var ?string $email */ $email = array_key_exists('email', $changes) ? $changes['email'] : $this->email; + /** @var ?string $phone */ $phone = array_key_exists('phone', $changes) ? $changes['phone'] : $this->phone; + /** @var array $contacts */ $contacts = $changes['contacts'] ?? $this->contacts; + + return new self($this->id, $this->organizationId, self::name($name), self::text($code, 80), self::email($email), self::text($phone, 40), self::contacts($contacts), $this->archivedAt, $this->createdAt, $now, $this->revision + 1); + } + + public function archive(DateTimeImmutable $now): self + { + if (null !== $this->archivedAt) { + return $this; + } + + return new self($this->id, $this->organizationId, $this->name, $this->code, $this->email, $this->phone, $this->contacts, $now, $this->createdAt, $now, $this->revision + 1); + } + + public function restore(DateTimeImmutable $now): self + { + if (null === $this->archivedAt) { + return $this; + } + + return new self($this->id, $this->organizationId, $this->name, $this->code, $this->email, $this->phone, $this->contacts, null, $this->createdAt, $now, $this->revision + 1); + } + + private static function name(string $value): string + { + $value = trim($value); + if ('' === $value || mb_strlen($value) > 160) { + throw CustomerException::invalid('Customer name must contain 1 to 160 characters.'); + } + + return $value; + } + + private static function text(?string $value, int $max): ?string + { + $value = null === $value ? null : trim($value); + if (null !== $value && mb_strlen($value) > $max) { + throw CustomerException::invalid('Customer field is too long.'); + } + + return '' === $value ? null : $value; + } + + private static function email(?string $value): ?string + { + $value = self::text($value, 254); + if (null !== $value && false === filter_var($value, FILTER_VALIDATE_EMAIL)) { + throw CustomerException::invalid('Invalid customer email.'); + } + + return $value; + } + + /** + * @param array $contacts + * + * @return list + */ + private static function contacts(array $contacts): array + { + if (!array_is_list($contacts) || count($contacts) > 50) { + throw CustomerException::invalid('Contacts must be a list of at most 50 contacts.'); + } + $normalized = []; + foreach ($contacts as $contact) { + if (!is_array($contact) || !is_string($contact['name'] ?? null)) { + throw CustomerException::invalid('Each contact requires a name.'); + } + foreach ($contact as $key => $value) { + if (!in_array($key, ['name', 'email', 'phone', 'role'], true) || (null !== $value && !is_string($value))) { + throw CustomerException::invalid('Invalid contact field.'); + } + } + /** @var array{name:string,email?:?string,phone?:?string,role?:?string} $contact */ + $normalized[] = ['name' => self::name($contact['name']), 'email' => self::email($contact['email'] ?? null), 'phone' => self::text($contact['phone'] ?? null, 40), 'role' => self::text($contact['role'] ?? null, 80)]; + } + + return $normalized; + } +} diff --git a/src/Customer/Infrastructure/Adapter/Directory/CustomerLookupAdapter.php b/src/Customer/Infrastructure/Adapter/Directory/CustomerLookupAdapter.php new file mode 100644 index 000000000..191d0567b --- /dev/null +++ b/src/Customer/Infrastructure/Adapter/Directory/CustomerLookupAdapter.php @@ -0,0 +1,24 @@ +customers->find($customerId, $organizationId); + + return null === $customer ? null : new CustomerSnapshot($customer->id, $customer->name, $customer->contacts, $customer->archivedAt); + } +} diff --git a/src/Customer/Infrastructure/Adapter/Facility/FacilityCustomerValidationAdapter.php b/src/Customer/Infrastructure/Adapter/Facility/FacilityCustomerValidationAdapter.php new file mode 100644 index 000000000..a3362218f --- /dev/null +++ b/src/Customer/Infrastructure/Adapter/Facility/FacilityCustomerValidationAdapter.php @@ -0,0 +1,26 @@ +customers->find($customerId, $organizationId, $this->entityManager->getConnection()->isTransactionActive()); + if (null === $customer || null !== $customer->archivedAt) { + throw new FacilityCustomerUnavailable(); + } + } +} diff --git a/src/Customer/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportCustomerIdentityAdapter.php b/src/Customer/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportCustomerIdentityAdapter.php new file mode 100644 index 000000000..6e8c4045d --- /dev/null +++ b/src/Customer/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportCustomerIdentityAdapter.php @@ -0,0 +1,63 @@ +entityManager->getConnection()->fetchOne( + 'SELECT 1 FROM customers WHERE organization_id = :organization AND id = :resource LIMIT 1', + ['organization' => $organizationId, 'resource' => $resourceId], + ); + } + // #endregion +} diff --git a/src/Customer/Infrastructure/Persistence/Doctrine/Record/CustomerRecord.php b/src/Customer/Infrastructure/Persistence/Doctrine/Record/CustomerRecord.php new file mode 100644 index 000000000..c7e03049c --- /dev/null +++ b/src/Customer/Infrastructure/Persistence/Doctrine/Record/CustomerRecord.php @@ -0,0 +1,83 @@ + + */ + #[ORM\Column(type: 'json')] + public array $contacts = []; + + /** + * Property archivedAt. + */ + #[ORM\Column(name: 'archived_at', type: 'datetime_immutable', nullable: true)] + public ?DateTimeImmutable $archivedAt = null; + + /** + * Property createdAt. + */ + #[ORM\Column(name: 'created_at', type: 'datetime_immutable')] + public DateTimeImmutable $createdAt; + + /** + * Property updatedAt. + */ + #[ORM\Column(name: 'updated_at', type: 'datetime_immutable')] + public DateTimeImmutable $updatedAt; + + /** + * Property revision. + */ + #[ORM\Column(type: 'integer', options: ['default' => 1])] + public int $revision = 1; +} diff --git a/src/Customer/Infrastructure/Persistence/Doctrine/Repository/CustomerRepository.php b/src/Customer/Infrastructure/Persistence/Doctrine/Repository/CustomerRepository.php new file mode 100644 index 000000000..7e6151a8b --- /dev/null +++ b/src/Customer/Infrastructure/Persistence/Doctrine/Repository/CustomerRepository.php @@ -0,0 +1,88 @@ +entityManager->getConnection()->fetchAssociative('SELECT * FROM customers WHERE id = :id AND organization_id = :organization' . ($lock ? ' FOR UPDATE' : ''), ['id' => $id, 'organization' => $organizationId]); + + return false === $row ? null : $this->map($row); + } + + public function save(Customer $customer, ?int $expectedRevision = null): void + { + $connection = $this->entityManager->getConnection(); + $values = ['organization_id' => $customer->organizationId, 'name' => $customer->name, 'code' => $customer->code, 'email' => $customer->email, 'phone' => $customer->phone, 'contacts' => json_encode($customer->contacts, JSON_THROW_ON_ERROR), 'archived_at' => $customer->archivedAt?->format('Y-m-d H:i:s.u'), 'created_at' => $customer->createdAt->format('Y-m-d H:i:s.u'), 'updated_at' => $customer->updatedAt->format('Y-m-d H:i:s.u'), 'revision' => $customer->revision]; + + try { + if (null === $expectedRevision) { + $connection->insert('customers', ['id' => $customer->id] + $values); + } else { + $count = $connection->update('customers', $values, ['id' => $customer->id, 'organization_id' => $customer->organizationId, 'revision' => $expectedRevision]); + if (1 !== $count) { + throw CustomerException::stale(); + } + } + } catch (UniqueConstraintViolationException) { + throw CustomerException::codeConflict(); + } + } + + /** + * @return list + */ + public function list(string $organizationId, string $search, bool $archived, int $offset, int $limit): array + { + $sql = 'SELECT * FROM customers WHERE ' . $this->criteria($archived) . ' ORDER BY LOWER(name), id LIMIT :limit OFFSET :offset'; + $rows = $this->entityManager->getConnection()->fetchAllAssociative($sql, ['organization' => $organizationId, 'search' => '%' . $search . '%', 'limit' => $limit, 'offset' => $offset], ['limit' => \Doctrine\DBAL\ParameterType::INTEGER, 'offset' => \Doctrine\DBAL\ParameterType::INTEGER]); + + return array_map($this->map(...), $rows); + } + + public function count(string $organizationId, string $search, bool $archived): int + { + /** @var int|string $total */ + $total = $this->entityManager->getConnection()->fetchOne('SELECT COUNT(*) FROM customers WHERE ' . $this->criteria($archived), ['organization' => $organizationId, 'search' => '%' . $search . '%']); + + return (int) $total; + } + + private function criteria(bool $archived): string + { + return 'organization_id = :organization AND archived_at IS ' . ($archived ? 'NOT ' : '') . 'NULL AND (name ILIKE :search OR COALESCE(code, \'\') ILIKE :search OR COALESCE(email, \'\') ILIKE :search)'; + } + + /** + * @param array $row + */ + private function map(array $row): Customer + { + /** @var array{id:string,organization_id:string,name:string,code:?string,email:?string,phone:?string,contacts:string,archived_at:?string,created_at:string,updated_at:string,revision:int|string} $row */ + /** @var list $contacts */ + $contacts = json_decode((string) $row['contacts'], true, 512, JSON_THROW_ON_ERROR); + + return Customer::reconstitute((string) $row['id'], (string) $row['organization_id'], (string) $row['name'], null === $row['code'] ? null : (string) $row['code'], null === $row['email'] ? null : (string) $row['email'], null === $row['phone'] ? null : (string) $row['phone'], $contacts, null === $row['archived_at'] ? null : new DateTimeImmutable((string) $row['archived_at']), new DateTimeImmutable((string) $row['created_at']), new DateTimeImmutable((string) $row['updated_at']), (int) $row['revision']); + } +} diff --git a/src/Customer/MODULE.md b/src/Customer/MODULE.md new file mode 100644 index 000000000..041e0f297 --- /dev/null +++ b/src/Customer/MODULE.md @@ -0,0 +1,99 @@ +# Customer Module + +## Overview + +Customer owns internal client identities for an organization: contact details and +archival history. It belongs to `main`. An exploitant can manage sites without +customers; a service provider can assign a root site to one internal customer. +No customer creates a user, portal or cross-organization access. + +## API Endpoints + +All routes require an authenticated organization member. Unknown and foreign +customers return the same 404. An unentitled member receives 403. + +| Method | Route | Permission | Behavior | +| --- | --- | --- | --- | +| GET | `/api/organizations/{organizationId}/customers` | `organization.customers.read` | Search and paginate active or archived customers | +| GET | `/api/organizations/{organizationId}/customers/{id}` | `organization.customers.read` | Read retained identity, including archived customers | +| POST | `/api/organizations/{organizationId}/customers` | `organization.customers.manage` | Create (201) | +| PATCH | `/api/organizations/{organizationId}/customers/{id}` | `organization.customers.manage` | Merge patch (200) | +| POST | `/api/organizations/{organizationId}/customers/{id}/archive` | `organization.customers.manage` | Archive without deletion (200) | +| POST | `/api/organizations/{organizationId}/customers/{id}/restore` | `organization.customers.manage` | Restore (200) | + +Output: `id`, `organizationId`, `name`, nullable `code`/`email`/`phone`, `contacts`, +nullable `archivedAt`, `createdAt`, `updatedAt`, integer `revision`. Contacts contain +`name` and nullable `email`, `phone`, `role`; they have no independent identity. +Names contain 1–160 characters; codes at most 80, email 254, phone 40, contact role +80. At most 50 contacts are accepted. Optional empty strings normalize to null. +Codes are unique within an organization, including archived clients. + +Collection parameters: `search` (name/code/email, at most 160 characters), +`archived=false` (active only) or `true` (archived only), `page=1`, +`itemsPerPage=30` (1–100). Name and id define stable ordering. Items and total share +the same filters. PATCH preserves omitted fields and clears nullable fields sent +as null; names and contacts cannot be null. + +PATCH/archive/restore require `If-Match: "revision-N"`. Scope and permission checks +precede revision errors. Concurrent updates cannot overwrite another revision. +Archiving an already archived customer at its current revision does not write again. + +## Flows + +Writes resolve organization access, validate the domain identity and persist on +the explicitly wired main transaction. Mutations lock and reread the scoped row +before comparing the revision. No endpoint hard-deletes a customer. Significant +mutations enqueue `CustomerChangedEvent` after persistence, in the same main +transaction; archival replay emits no additional event. Events contain identifiers +and revision, without contact details. + +Facility stores optional `customerId` on root sites only. New assignments lock and +validate an active customer in the same organization. Existing archived links +remain readable and accept unrelated descriptive edits. Detaching uses null. +This field is unrelated to replay `clientId`. + +Intervention closure reads the published site's customer through the public lookup +port and copies the identity into its immutable closure snapshot. Later edits or +archival do not alter that snapshot. + +## Architecture + +Domain Customer enforces names, contact shapes and lifecycle. Application handlers +own permission decisions, pagination and mutations through ports. Infrastructure +persists PostgreSQL rows on main and exposes `CustomerLookupPort` plus the +Facility-owned validation port. Presentation translates API Platform contracts and +maps customer exceptions centrally to RFC 7807 responses. + +## Configuration + +Maintenance exports validate retained customer identities through the owner-hosted +`MaintenanceExportCustomerIdentityAdapter`, explicitly wired to main and tagged +`maintenance_export.identity_validator`. Archived customers remain valid historical +references; the lookup returns only a scoped boolean and never loads contacts. + +`config/modules/customer.yaml` registers repository, lookup and validation aliases, +bus handlers, presentation and the named `customer.main_transaction_manager`. +Every Doctrine consumer uses `doctrine.orm.main_entity_manager`. Main mapping and +API resource discovery include Customer; migration `Version20261006101000` +creates customers and optional root-site ownership. +`Version20261006101001` adds Doctrine's immutable date metadata to the existing +UTC `TIMESTAMP(0) WITHOUT TIME ZONE` columns, without changing stored date values. + +## Testing + +Unit tests cover normalization, null/omitted patches, retained archival history, +denial before persistence, locked revision checks and transport translation. +Functional PostgreSQL tests cover CRUD, collection filters, 403/404, 409/412/428, +root-site assignments, unknown/archived refusals and retained archived references. + +## Error Codes + +| Code | HTTP | Meaning | +| --- | --- | --- | +| `customer_not_found` | 404 | Unknown customer or hidden organization scope | +| `customer_access_denied` | 403 | Missing read/manage permission | +| `customer_invalid` | 422 | Invalid fields, contacts or pagination | +| `customer_code_conflict` | 409 | Organization code already used | +| `customer_revision_stale` | 412 | Revision moved or If-Match is malformed | +| `customer_precondition_required` | 428 | Missing If-Match after access checks | +| `facility_customer_invalid` | 422 | Customer assignment is unavailable or not on a root site | diff --git a/src/Customer/Presentation/Api/Dto/Input/ChangeCustomerInput.php b/src/Customer/Presentation/Api/Dto/Input/ChangeCustomerInput.php new file mode 100644 index 000000000..20cdf706f --- /dev/null +++ b/src/Customer/Presentation/Api/Dto/Input/ChangeCustomerInput.php @@ -0,0 +1,36 @@ + + */ + #[Groups(['customer:write'])] + #[Assert\Count(max: 50)] + public ?array $contacts = null; +} diff --git a/src/Customer/Presentation/Api/Dto/Input/CreateCustomerInput.php b/src/Customer/Presentation/Api/Dto/Input/CreateCustomerInput.php new file mode 100644 index 000000000..44c10cfbf --- /dev/null +++ b/src/Customer/Presentation/Api/Dto/Input/CreateCustomerInput.php @@ -0,0 +1,37 @@ + + */ + #[Groups(['customer:write'])] + #[Assert\Count(max: 50)] + public array $contacts = []; +} diff --git a/src/Customer/Presentation/Api/Dto/Output/CustomerOutput.php b/src/Customer/Presentation/Api/Dto/Output/CustomerOutput.php new file mode 100644 index 000000000..d414b8de4 --- /dev/null +++ b/src/Customer/Presentation/Api/Dto/Output/CustomerOutput.php @@ -0,0 +1,68 @@ + + */ + #[Groups(['customer:read'])] + public array $contacts = []; + + #[Groups(['customer:read'])] + public ?string $archivedAt = null; + + #[Groups(['customer:read'])] + public string $createdAt = ''; + + #[Groups(['customer:read'])] + public string $updatedAt = ''; + + #[Groups(['customer:read'])] + public int $revision = 1; + + public static function fromView(CustomerView $view): self + { + $output = new self(); + $output->id = $view->id; + $output->organizationId = $view->organizationId; + $output->name = $view->name; + $output->code = $view->code; + $output->email = $view->email; + $output->phone = $view->phone; + $output->contacts = $view->contacts; + $output->archivedAt = $view->archivedAt?->format(DATE_ATOM); + $output->createdAt = $view->createdAt->format(DATE_ATOM); + $output->updatedAt = $view->updatedAt->format(DATE_ATOM); + $output->revision = $view->revision; + + return $output; + } +} diff --git a/src/Customer/Presentation/Api/EventSubscriber/CustomerExceptionSubscriber.php b/src/Customer/Presentation/Api/EventSubscriber/CustomerExceptionSubscriber.php new file mode 100644 index 000000000..8c0e00982 --- /dev/null +++ b/src/Customer/Presentation/Api/EventSubscriber/CustomerExceptionSubscriber.php @@ -0,0 +1,39 @@ + + */ + public static function getSubscribedEvents(): array + { + return [KernelEvents::EXCEPTION => ['onException', 10]]; + } + + public function onException(ExceptionEvent $event): void + { + $error = $event->getThrowable(); + do { + if ($error instanceof CustomerException) { + $status = match ($error->reason) { + 'customer_not_found' => 404, 'customer_access_denied' => 403, 'customer_revision_stale' => 412, 'customer_precondition_required' => 428, 'customer_code_conflict' => 409, default => 422 + }; + $event->setResponse(new JsonResponse(['type' => '/errors/' . $error->reason, 'title' => 'Customer request refused', 'status' => $status, 'code' => $error->reason, 'detail' => $error->getMessage()], $status, ['Content-Type' => 'application/problem+json'])); + + return; + } + $error = $error->getPrevious(); + } while (null !== $error); + } +} diff --git a/src/Customer/Presentation/Api/Operation/CustomerOperations.php b/src/Customer/Presentation/Api/Operation/CustomerOperations.php new file mode 100644 index 000000000..d4521d9ce --- /dev/null +++ b/src/Customer/Presentation/Api/Operation/CustomerOperations.php @@ -0,0 +1,21 @@ + + */ +final readonly class CustomerProcessor implements ProcessorInterface +{ + public function __construct(private CommandBusPort $commands, private CurrentActorPort $actor, private RequestStack $requests) + { + } + + /** + * @param array $uriVariables @param array $context + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): CustomerOutput + { + $actorId = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $organizationId = $this->identifier($uriVariables, 'organizationId'); + if (CustomerOperations::CREATE === $operation->getName()) { + if (!$data instanceof CreateCustomerInput) { + throw CustomerException::invalid('Invalid customer input.'); + } + /** @var CreateCustomerResult $result */ + $result = $this->commands->dispatch(new CreateCustomerCommand($actorId, $organizationId, $data->name, $data->code, $data->email, $data->phone, $data->contacts)); + + return CustomerOutput::fromView($result->customer); + } + $action = match ($operation->getName()) { + CustomerOperations::ARCHIVE => 'archive', CustomerOperations::RESTORE => 'restore', default => 'patch' + }; + $changes = []; + if ('patch' === $action) { + if (!$data instanceof ChangeCustomerInput) { + throw CustomerException::invalid('Invalid customer input.'); + } + foreach ($this->requests->getCurrentRequest()?->getPayload()->all() ?? [] as $field => $value) { + if (!property_exists($data, $field)) { + throw CustomerException::invalid('Unknown customer field.'); + } + $changes[$field] = $data->{$field}; + } + } + $header = $this->requests->getCurrentRequest()?->headers->get('If-Match'); + $revision = null === $header ? null : (1 === preg_match('/^"revision-(\d+)"$/', $header, $matches) ? (int) $matches[1] : -1); + /** @var ChangeCustomerResult $result */ + $result = $this->commands->dispatch(new ChangeCustomerCommand($actorId, $organizationId, $this->identifier($uriVariables, 'id'), $action, $revision, $changes)); + + return CustomerOutput::fromView($result->customer); + } + + /** + * @param array $variables + */ + private function identifier(array $variables, string $key): string + { + $value = $variables[$key] ?? null; + + return is_string($value) ? $value : ''; + } +} diff --git a/src/Customer/Presentation/Api/Provider/CustomerProvider.php b/src/Customer/Presentation/Api/Provider/CustomerProvider.php new file mode 100644 index 000000000..cb43299c1 --- /dev/null +++ b/src/Customer/Presentation/Api/Provider/CustomerProvider.php @@ -0,0 +1,67 @@ + + */ +final readonly class CustomerProvider implements ProviderInterface +{ + public function __construct(private QueryBusPort $queries, private CurrentActorPort $actor, private RequestStack $requests) + { + } + + /** + * @param array $uriVariables @param array $context @return CustomerOutput|TraversablePaginator + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): CustomerOutput|TraversablePaginator + { + $actorId = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $organizationId = $this->identifier($uriVariables, 'organizationId'); + if (CustomerOperations::LIST === $operation->getName()) { + $request = $this->requests->getCurrentRequest(); + /** @var ListCustomersResult $result */ + $result = $this->queries->ask(new ListCustomersQuery($actorId, $organizationId, $request?->query->getString('search', '') ?? '', $request?->query->getBoolean('archived', false) ?? false, $request?->query->getInt('page', 1) ?? 1, $request?->query->getInt('itemsPerPage', 30) ?? 30)); + + return new TraversablePaginator(new ArrayIterator(array_map(CustomerOutput::fromView(...), $result->items)), (float) $result->page, (float) $result->itemsPerPage, (float) $result->total); + } + /** @var GetCustomerResult $result */ + $result = $this->queries->ask(new GetCustomerQuery($actorId, $organizationId, $this->identifier($uriVariables, 'id'))); + + return CustomerOutput::fromView($result->customer); + } + + /** + * @param array $variables + */ + private function identifier(array $variables, string $key): string + { + $value = $variables[$key] ?? null; + + return is_string($value) ? $value : ''; + } +} diff --git a/src/Customer/Presentation/Api/Resource/CustomerResource.php b/src/Customer/Presentation/Api/Resource/CustomerResource.php new file mode 100644 index 000000000..4df38dc83 --- /dev/null +++ b/src/Customer/Presentation/Api/Resource/CustomerResource.php @@ -0,0 +1,33 @@ + ['customer:read']], + denormalizationContext: ['groups' => ['customer:write']], + operations: [ + new GetCollection(name: CustomerOperations::LIST, uriTemplate: '/{organizationId}/customers', output: CustomerOutput::class, provider: CustomerProvider::class, openapi: new Operation(tags: ['Customer'], summary: 'Search internal customers', parameters: [new Parameter(name: 'search', in: 'query', schema: ['type' => 'string', 'maxLength' => 160]), new Parameter(name: 'archived', in: 'query', schema: ['type' => 'boolean', 'default' => false]), new Parameter(name: 'page', in: 'query', schema: ['type' => 'integer', 'minimum' => 1]), new Parameter(name: 'itemsPerPage', in: 'query', schema: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100])])), + new Get(name: CustomerOperations::GET, uriTemplate: '/{organizationId}/customers/{id}', output: CustomerOutput::class, provider: CustomerProvider::class, openapi: new Operation(tags: ['Customer'], summary: 'Read an internal customer')), + new Post(name: CustomerOperations::CREATE, uriTemplate: '/{organizationId}/customers', status: 201, read: false, input: CreateCustomerInput::class, output: CustomerOutput::class, processor: CustomerProcessor::class, openapi: new Operation(tags: ['Customer'], summary: 'Create an internal customer')), + new Patch(name: CustomerOperations::PATCH, uriTemplate: '/{organizationId}/customers/{id}', status: 200, read: false, input: ChangeCustomerInput::class, output: CustomerOutput::class, processor: CustomerProcessor::class, openapi: new Operation(tags: ['Customer'], summary: 'Update an internal customer', parameters: [new Parameter(name: 'If-Match', in: 'header', required: true, schema: ['type' => 'string', 'example' => '"revision-1"'])], responses: [200 => new Response(description: 'Customer updated'), 403 => new Response(description: 'Permission denied'), 404 => new Response(description: 'Customer not found'), 412 => new Response(description: 'Stale revision'), 428 => new Response(description: 'If-Match required')])), + new Post(name: CustomerOperations::ARCHIVE, uriTemplate: '/{organizationId}/customers/{id}/archive', status: 200, read: false, input: false, output: CustomerOutput::class, processor: CustomerProcessor::class, openapi: new Operation(tags: ['Customer'], summary: 'Archive an internal customer', parameters: [new Parameter(name: 'If-Match', in: 'header', required: true, schema: ['type' => 'string'])])), + new Post(name: CustomerOperations::RESTORE, uriTemplate: '/{organizationId}/customers/{id}/restore', status: 200, read: false, input: false, output: CustomerOutput::class, processor: CustomerProcessor::class, openapi: new Operation(tags: ['Customer'], summary: 'Restore an internal customer', parameters: [new Parameter(name: 'If-Match', in: 'header', required: true, schema: ['type' => 'string'])])), + ], +)] +final class CustomerResource +{ +} diff --git a/src/Equipment/Application/Contract/Equipment/EquipmentListCriteria.php b/src/Equipment/Application/Contract/Equipment/EquipmentListCriteria.php index 0b4c7a38e..6a5e78e41 100644 --- a/src/Equipment/Application/Contract/Equipment/EquipmentListCriteria.php +++ b/src/Equipment/Application/Contract/Equipment/EquipmentListCriteria.php @@ -25,6 +25,7 @@ * @param ?string $subType optional equipment subtype filter * @param ?string $search optional text search applied to equipment fields * @param ?list $facilityIds optional resolved subtree filter; an empty list matches nothing + * @param ?list $typeCodes optional family-resolved type literals; an empty list matches nothing * * @return void */ @@ -37,6 +38,7 @@ public function __construct( public ?string $subType = null, public ?string $search = null, public ?array $facilityIds = null, + public ?array $typeCodes = null, ) { } // #endregion diff --git a/src/Equipment/Application/Contract/EquipmentTypeCatalog/EquipmentTypeDescriptor.php b/src/Equipment/Application/Contract/EquipmentTypeCatalog/EquipmentTypeDescriptor.php new file mode 100644 index 000000000..73a25d64e --- /dev/null +++ b/src/Equipment/Application/Contract/EquipmentTypeCatalog/EquipmentTypeDescriptor.php @@ -0,0 +1,39 @@ +} $identityTemplate + */ + public function __construct(public string $organizationId, public string $actorId, public string $typeCode, public array $identityTemplate, public int $quantity) + { + } +} diff --git a/src/Equipment/Application/Contract/Procurement/EquipmentReserveReceiptResult.php b/src/Equipment/Application/Contract/Procurement/EquipmentReserveReceiptResult.php new file mode 100644 index 000000000..5624cc96a --- /dev/null +++ b/src/Equipment/Application/Contract/Procurement/EquipmentReserveReceiptResult.php @@ -0,0 +1,16 @@ + $equipmentIds + */ + public function __construct(public array $equipmentIds, public ?string $blockedReason = null) + { + } +} diff --git a/src/Equipment/Application/Contract/Replacement/EquipmentReplacementReceipt.php b/src/Equipment/Application/Contract/Replacement/EquipmentReplacementReceipt.php new file mode 100644 index 000000000..60fabeea8 --- /dev/null +++ b/src/Equipment/Application/Contract/Replacement/EquipmentReplacementReceipt.php @@ -0,0 +1,39 @@ + $technicalProperties declarative technical characteristics + * + * @return void + */ + public function __construct( + public string $type, + public ?string $subType = null, + public ?string $brand = null, + public ?string $model = null, + public ?string $serialNumber = null, + public ?string $locationLabel = null, + public ?string $name = null, + public ?string $assetCode = null, + public ?string $criticality = null, + public array $technicalProperties = [], + ) { + } + // #endregion +} diff --git a/src/Equipment/Application/Port/Inbound/EquipmentParkScopePort.php b/src/Equipment/Application/Port/Inbound/EquipmentParkScopePort.php new file mode 100644 index 000000000..332de9e40 --- /dev/null +++ b/src/Equipment/Application/Port/Inbound/EquipmentParkScopePort.php @@ -0,0 +1,26 @@ + + */ +interface EquipmentParkScopePort +{ + /** + * @since 1.0.0 + * + * @param list $equipmentIds candidate identifiers + * @param bool $includeDescendants whether an explicit facility includes published descendants; defaults to the legacy subtree scope + * + * @return list + */ + public function filterIds(string $organizationId, array $equipmentIds, ?string $family = null, ?string $customerId = null, ?string $facilityId = null, bool $includeDescendants = true): array; +} diff --git a/src/Equipment/Application/Port/Inbound/EquipmentReserveReceiptPort.php b/src/Equipment/Application/Port/Inbound/EquipmentReserveReceiptPort.php new file mode 100644 index 000000000..6e71d4a2e --- /dev/null +++ b/src/Equipment/Application/Port/Inbound/EquipmentReserveReceiptPort.php @@ -0,0 +1,15 @@ + $equipmentIds the identities to lock + * + * @return array available owner-scoped candidates keyed by identity + */ + public function lockEquipment(string $organizationId, array $equipmentIds): array; + + /** + * Method save + * + * Applies lifecycle/placement/link writes and persists the receipt atomically. + * + * @access public + * + * @param EquipmentReplacement $replacement the validated replacement + * @param EquipmentReplacementReceipt $receipt the durable operation result + * + * @return void + */ + public function save(EquipmentReplacement $replacement, EquipmentReplacementReceipt $receipt): void; + // #endregion +} diff --git a/src/Equipment/Application/Port/Outbound/EquipmentRepositoryPort.php b/src/Equipment/Application/Port/Outbound/EquipmentRepositoryPort.php index 7ea79da1a..b4cb4b1d3 100644 --- a/src/Equipment/Application/Port/Outbound/EquipmentRepositoryPort.php +++ b/src/Equipment/Application/Port/Outbound/EquipmentRepositoryPort.php @@ -20,6 +20,15 @@ */ interface EquipmentRepositoryPort { + /** + * @since 1.1.0 + * + * @param list $equipmentIds candidate identifiers + * + * @return list + */ + public function findPublishedIdsMatching(EquipmentOrganizationId $organizationId, EquipmentListCriteria $criteria, array $equipmentIds): array; + // #region Methods /** * Method save. diff --git a/src/Equipment/Application/Port/Outbound/EquipmentTypeCatalogPort.php b/src/Equipment/Application/Port/Outbound/EquipmentTypeCatalogPort.php new file mode 100644 index 000000000..c4948a19b --- /dev/null +++ b/src/Equipment/Application/Port/Outbound/EquipmentTypeCatalogPort.php @@ -0,0 +1,77 @@ + complete catalog, including archives + */ + public function list(string $organizationId): array; + + /** + * Method find. + * + * @access public + * + * @param string $organizationId owning organization + * @param string $typeCode stable type code + * + * @return EquipmentTypeDefinition|null descriptor or null when unknown + */ + public function find(string $organizationId, string $typeCode): ?EquipmentTypeDefinition; + + /** + * Method save. + * + * @access public + * + * @param string $organizationId owning organization + * @param EquipmentTypeDefinition $definition descriptor to persist + * @param int|null $expectedRevision null creates; a revision updates an existing descriptor + * + * @return void + * + * @throws EquipmentTypeCatalogException on duplicate or stale revision + */ + public function save(string $organizationId, EquipmentTypeDefinition $definition, ?int $expectedRevision): void; + + /** + * Method validateAvailableType. + * + * Unchanged historical codes are preserved. Creation and type changes require + * an active descriptor in this organization's catalog. + * + * @access public + * + * @param string $organizationId owning organization + * @param string $type candidate type code + * @param string|null $existingType current persisted code, null for creation + * + * @return void + * + * @throws EquipmentTypeCatalogException when unknown or archived + */ + public function validateAvailableType(string $organizationId, string $type, ?string $existingType = null): void; + // #endregion +} diff --git a/src/Equipment/Application/Port/Outbound/FacilityCustomerScopePort.php b/src/Equipment/Application/Port/Outbound/FacilityCustomerScopePort.php new file mode 100644 index 000000000..9e6d20425 --- /dev/null +++ b/src/Equipment/Application/Port/Outbound/FacilityCustomerScopePort.php @@ -0,0 +1,23 @@ + + */ +interface FacilityCustomerScopePort +{ + /** + * @since 1.0.0 + * + * @return list + */ + public function findPublishedIdsForCustomer(string $organizationId, string $customerId): array; +} diff --git a/src/Equipment/Application/Service/EquipmentReserveReceiptService.php b/src/Equipment/Application/Service/EquipmentReserveReceiptService.php new file mode 100644 index 000000000..0454b6a08 --- /dev/null +++ b/src/Equipment/Application/Service/EquipmentReserveReceiptService.php @@ -0,0 +1,69 @@ +catalog->find($organizationId, $typeCode); + + return null !== $type && !$type->archived; + } + + public function reserve(EquipmentReserveReceiptRequest $request): EquipmentReserveReceiptResult + { + new Uuid($request->organizationId); + new Uuid($request->actorId); + if ($request->quantity < 1 || $request->quantity > 100) { + throw new InvalidValueException('Reserve batches must contain between one and one hundred equipment.'); + } + $access = $this->authorization->resolveAccess($request->actorId, $request->organizationId, 'organization.equipment.write'); + if (!$access->isGranted()) { + return new EquipmentReserveReceiptResult([], blockedReason:'missing_equipment_permission'); + } + if (!$this->supportsType($request->organizationId, $request->typeCode)) { + return new EquipmentReserveReceiptResult([], blockedReason:'unavailable_type'); + } + $t = $request->identityTemplate; + $identity = EquipmentIdentity::fromValues($t['name'] ?? null, $t['assetCode'] ?? null, $t['criticality'] ?? null, $t['technicalProperties'] ?? []); + if ($request->quantity > 1 && (null !== ($t['assetCode'] ?? null) || null !== ($t['serialNumber'] ?? null))) { + throw new InvalidValueException('A shared batch template cannot repeat unique asset or serial identities.'); + } + + return $this->transactions->transactional(function () use ($request, $t, $identity): EquipmentReserveReceiptResult { + try { + $this->quota->assertCanAddMultiple($request->organizationId, OrganizationQuotaResource::EQUIPMENT, $request->quantity); + } catch (OrganizationQuotaExceededException) { + return new EquipmentReserveReceiptResult([], blockedReason:'quota_exceeded'); + } + $ids = []; + for ($unit = 0; $unit < $request->quantity; ++$unit) { + /** @var CreateEquipmentResult $result */ + $result = $this->commands->dispatch(new CreateEquipmentCommand($request->organizationId, $request->typeCode, subType:$t['subType'] ?? null, brand:$t['brand'] ?? null, model:$t['model'] ?? null, serialNumber:$t['serialNumber'] ?? null, name:$identity->name, assetCode:$identity->assetCode, criticality:$identity->criticality, technicalProperties:$identity->technicalProperties)); + $ids[] = $result->equipmentId; + } + + return new EquipmentReserveReceiptResult($ids); + }); + } +} diff --git a/src/Equipment/Application/Service/EquipmentSelectionScopeResolver.php b/src/Equipment/Application/Service/EquipmentSelectionScopeResolver.php new file mode 100644 index 000000000..3786d78be --- /dev/null +++ b/src/Equipment/Application/Service/EquipmentSelectionScopeResolver.php @@ -0,0 +1,75 @@ + + */ +final readonly class EquipmentSelectionScopeResolver +{ + /** + * @since 1.0.0 + */ + public function __construct(private EquipmentTypeCatalogPort $catalog, private CustomerLookupPort $customers, private FacilityCustomerScopePort $facilities) + { + } + + /** + * @since 1.0.0 + * + * @return ?list + */ + public function typesForFamily(string $organizationId, ?string $family): ?array + { + if (null === $family) { + return null; + } + if (!in_array($family, ['fire', 'safety', 'other'], true)) { + throw InvalidValueException::because('Invalid equipment family filter.'); + } + $types = []; + foreach ($this->catalog->list($organizationId) as $definition) { + if ($definition->family === $family) { + $types[] = $definition->value; + } + } + + return $types; + } + + /** + * @since 1.0.0 + * + * @param ?list $facilityIds an already selected subtree + * + * @return ?list + */ + public function customerFacilities(string $organizationId, ?string $customerId, ?array $facilityIds): ?array + { + if (null === $customerId) { + return $facilityIds; + } + if (null === $this->customers->find($customerId, $organizationId)) { + throw EquipmentNotFoundException::forCustomerScope($customerId); + } + $portfolio = $this->facilities->findPublishedIdsForCustomer($organizationId, $customerId); + + return null === $facilityIds ? $portfolio : array_values(array_intersect($facilityIds, $portfolio)); + } +} diff --git a/src/Equipment/Application/UseCase/Command/Equipment/AssignToFacility/AssignToFacilityHandler.php b/src/Equipment/Application/UseCase/Command/Equipment/AssignToFacility/AssignToFacilityHandler.php index 375e75a4f..26138ee9e 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/AssignToFacility/AssignToFacilityHandler.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/AssignToFacility/AssignToFacilityHandler.php @@ -120,6 +120,12 @@ private function assign(AssignToFacilityCommand $command): AssignToFacilityResul model: $equipment->model(), serialNumber: $equipment->serialNumber(), locationLabel: $equipment->locationLabel(), + name: $equipment->identity()->name, + assetCode: $equipment->identity()->assetCode, + criticality: $equipment->identity()->criticality, + technicalProperties: $equipment->identity()->technicalProperties, + predecessorEquipmentId: $equipment->predecessorEquipmentId(), + successorEquipmentId: $equipment->successorEquipmentId(), status: $equipment->status()->value, installedAt: $equipment->installedAt()?->format('c'), commissionedAt: $equipment->commissionedAt()?->format('c'), diff --git a/src/Equipment/Application/UseCase/Command/Equipment/AssignToFacility/AssignToFacilityResult.php b/src/Equipment/Application/UseCase/Command/Equipment/AssignToFacility/AssignToFacilityResult.php index c581bb108..df97af709 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/AssignToFacility/AssignToFacilityResult.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/AssignToFacility/AssignToFacilityResult.php @@ -24,6 +24,7 @@ * @since 1.0.0 * * @param list $tags + * @param list $technicalProperties descriptive properties */ public function __construct( public string $equipmentId, @@ -42,6 +43,12 @@ public function __construct( public DateTimeImmutable $createdAt, public DateTimeImmutable $updatedAt, public ?string $facilityName = null, + public ?string $name = null, + public ?string $assetCode = null, + public ?string $criticality = null, + public array $technicalProperties = [], + public ?string $predecessorEquipmentId = null, + public ?string $successorEquipmentId = null, ) { } // #endregion diff --git a/src/Equipment/Application/UseCase/Command/Equipment/CommissionEquipment/CommissionEquipmentHandler.php b/src/Equipment/Application/UseCase/Command/Equipment/CommissionEquipment/CommissionEquipmentHandler.php index dd42c21a8..e012622d3 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/CommissionEquipment/CommissionEquipmentHandler.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/CommissionEquipment/CommissionEquipmentHandler.php @@ -108,6 +108,12 @@ public function __invoke(CommissionEquipmentCommand $command): CommissionEquipme model: $equipment->model(), serialNumber: $equipment->serialNumber(), locationLabel: $equipment->locationLabel(), + name: $equipment->identity()->name, + assetCode: $equipment->identity()->assetCode, + criticality: $equipment->identity()->criticality, + technicalProperties: $equipment->identity()->technicalProperties, + predecessorEquipmentId: $equipment->predecessorEquipmentId(), + successorEquipmentId: $equipment->successorEquipmentId(), status: $equipment->status()->value, installedAt: $equipment->installedAt()?->format('c'), commissionedAt: $equipment->commissionedAt()?->format('c'), diff --git a/src/Equipment/Application/UseCase/Command/Equipment/CommissionEquipment/CommissionEquipmentResult.php b/src/Equipment/Application/UseCase/Command/Equipment/CommissionEquipment/CommissionEquipmentResult.php index 60e741b98..819aaa586 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/CommissionEquipment/CommissionEquipmentResult.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/CommissionEquipment/CommissionEquipmentResult.php @@ -24,6 +24,7 @@ * @since 1.0.0 * * @param list $tags + * @param list $technicalProperties descriptive properties */ public function __construct( public string $equipmentId, @@ -42,6 +43,12 @@ public function __construct( public DateTimeImmutable $createdAt, public DateTimeImmutable $updatedAt, public ?string $facilityName = null, + public ?string $name = null, + public ?string $assetCode = null, + public ?string $criticality = null, + public array $technicalProperties = [], + public ?string $predecessorEquipmentId = null, + public ?string $successorEquipmentId = null, ) { } // #endregion diff --git a/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentCommand.php b/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentCommand.php index 29efbdb7f..f1687dc63 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentCommand.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentCommand.php @@ -33,6 +33,7 @@ * @param ?string $resourceId the resource id value * @param bool $dryRun when true, validates and projects the quota without persisting * @param int $quotaProjectionOffset equipment already provisionally counted earlier in the same dry run + * @param list $technicalProperties descriptive properties */ public function __construct( public string $organizationId, @@ -49,6 +50,10 @@ public function __construct( public ?string $facilityId = null, public ?string $interventionId = null, public ?string $clientId = null, + public ?string $name = null, + public ?string $assetCode = null, + public ?string $criticality = null, + public array $technicalProperties = [], ) { } // #endregion diff --git a/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentHandler.php b/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentHandler.php index 5eae3c2e2..118545877 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentHandler.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentHandler.php @@ -7,7 +7,7 @@ use DateTimeImmutable; use Equipment\Application\Port\Outbound\{EquipmentRepositoryPort, FacilityNamingPort}; use Equipment\Domain\Model\Equipment\Equipment; -use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, EquipmentId, EquipmentOrganizationId, EquipmentType}; +use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, EquipmentId, EquipmentIdentity, EquipmentOrganizationId, EquipmentTypeCode}; use Intervention\Application\Port\Inbound\InterventionCreationPort; use LogicException; use Onboarding\Application\Contract\Setup\OrganizationSetupConflict; @@ -60,6 +60,7 @@ public function __construct( private ?OrganizationSetupPort $setup = null, private ?\Equipment\Application\Port\Outbound\FacilityValidationPort $facilityValidation = null, private ?InterventionCreationPort $creationContext = null, + private ?\Equipment\Application\Port\Outbound\EquipmentTypeCatalogPort $typeCatalog = null, ) { } // #endregion @@ -97,13 +98,14 @@ public function __invoke(CreateEquipmentCommand $command): CreateEquipmentResult $equipment = Equipment::create( id: $equipmentId, organizationId: $organizationId, - type: EquipmentType::from($command->type), + type: null === $this->typeCatalog ? \Equipment\Domain\ValueObject\EquipmentType::from($command->type) : EquipmentTypeCode::fromString($command->type), details: new EquipmentCatalogDetails( subType: $command->subType, brand: $command->brand, model: $command->model, serialNumber: $command->serialNumber, locationLabel: $command->locationLabel, + identity: EquipmentIdentity::fromValues($command->name, $command->assetCode, $command->criticality, $command->technicalProperties), ), ); } catch (InvalidValueException|ValueError $exception) { @@ -111,6 +113,7 @@ public function __invoke(CreateEquipmentCommand $command): CreateEquipmentResult } if ($command->dryRun) { + $this->typeCatalog?->validateAvailableType($command->organizationId, $command->type); // A dry run never enters the transaction that would take the quota's // advisory lock (see OrganizationQuotaPort::assertCanAdd): it projects // the cap instead, offsetting for rows already provisionally counted @@ -144,6 +147,7 @@ private function persist(CreateEquipmentCommand $command, Equipment $equipment): if (null !== $existing) { return $existing; } + $this->typeCatalog?->validateAvailableType($command->organizationId, $command->type); if (null !== $command->facilityId) { if (null === $this->facilityValidation) { throw new LogicException('Facility validation is unavailable.'); @@ -223,6 +227,12 @@ private function toResult(Equipment $equipment): CreateEquipmentResult model: $equipment->model(), serialNumber: $equipment->serialNumber(), locationLabel: $equipment->locationLabel(), + name: $equipment->identity()->name, + assetCode: $equipment->identity()->assetCode, + criticality: $equipment->identity()->criticality, + technicalProperties: $equipment->identity()->technicalProperties, + predecessorEquipmentId: $equipment->predecessorEquipmentId(), + successorEquipmentId: $equipment->successorEquipmentId(), status: $equipment->status()->value, installedAt: $equipment->installedAt()?->format('c'), commissionedAt: $equipment->commissionedAt()?->format('c'), diff --git a/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentResult.php b/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentResult.php index ce9e3155e..b8fbec1a2 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentResult.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentResult.php @@ -24,6 +24,7 @@ * @since 1.0.0 * * @param list $tags + * @param list $technicalProperties descriptive properties */ public function __construct( public string $equipmentId, @@ -45,6 +46,12 @@ public function __construct( public ?string $interventionId = null, public string $recordStatus = 'published', public int $revision = 1, + public ?string $name = null, + public ?string $assetCode = null, + public ?string $criticality = null, + public array $technicalProperties = [], + public ?string $predecessorEquipmentId = null, + public ?string $successorEquipmentId = null, ) { } // #endregion diff --git a/src/Equipment/Application/UseCase/Command/Equipment/DecommissionEquipment/DecommissionEquipmentHandler.php b/src/Equipment/Application/UseCase/Command/Equipment/DecommissionEquipment/DecommissionEquipmentHandler.php index bf71c4a80..3aa43cf53 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/DecommissionEquipment/DecommissionEquipmentHandler.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/DecommissionEquipment/DecommissionEquipmentHandler.php @@ -104,6 +104,12 @@ public function __invoke(DecommissionEquipmentCommand $command): DecommissionEqu model: $equipment->model(), serialNumber: $equipment->serialNumber(), locationLabel: $equipment->locationLabel(), + name: $equipment->identity()->name, + assetCode: $equipment->identity()->assetCode, + criticality: $equipment->identity()->criticality, + technicalProperties: $equipment->identity()->technicalProperties, + predecessorEquipmentId: $equipment->predecessorEquipmentId(), + successorEquipmentId: $equipment->successorEquipmentId(), status: $equipment->status()->value, installedAt: $equipment->installedAt()?->format('c'), commissionedAt: $equipment->commissionedAt()?->format('c'), diff --git a/src/Equipment/Application/UseCase/Command/Equipment/DecommissionEquipment/DecommissionEquipmentResult.php b/src/Equipment/Application/UseCase/Command/Equipment/DecommissionEquipment/DecommissionEquipmentResult.php index 75912613f..3fbd24845 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/DecommissionEquipment/DecommissionEquipmentResult.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/DecommissionEquipment/DecommissionEquipmentResult.php @@ -24,6 +24,7 @@ * @since 1.0.0 * * @param list $tags + * @param list $technicalProperties descriptive properties */ public function __construct( public string $equipmentId, @@ -42,6 +43,12 @@ public function __construct( public DateTimeImmutable $createdAt, public DateTimeImmutable $updatedAt, public ?string $facilityName = null, + public ?string $name = null, + public ?string $assetCode = null, + public ?string $criticality = null, + public array $technicalProperties = [], + public ?string $predecessorEquipmentId = null, + public ?string $successorEquipmentId = null, ) { } // #endregion diff --git a/src/Equipment/Application/UseCase/Command/Equipment/PatchCanonicalEquipment/PatchCanonicalEquipmentCommand.php b/src/Equipment/Application/UseCase/Command/Equipment/PatchCanonicalEquipment/PatchCanonicalEquipmentCommand.php index 3049e84eb..cab6ee707 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/PatchCanonicalEquipment/PatchCanonicalEquipmentCommand.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/PatchCanonicalEquipment/PatchCanonicalEquipmentCommand.php @@ -50,6 +50,7 @@ * @param ?string $locationLabel new location label, or null when explicitly cleared * @param bool $hasFacility whether the facility field was included in the request * @param ?string $facilityId new facility identifier, or null when explicitly unassigned + * @param list $technicalProperties descriptive properties * * @return void */ @@ -72,6 +73,14 @@ public function __construct( public ?string $locationLabel = null, public bool $hasFacility = false, public ?string $facilityId = null, + public bool $hasName = false, + public ?string $name = null, + public bool $hasAssetCode = false, + public ?string $assetCode = null, + public bool $hasCriticality = false, + public ?string $criticality = null, + public bool $hasTechnicalProperties = false, + public array $technicalProperties = [], ) { } // #endregion diff --git a/src/Equipment/Application/UseCase/Command/Equipment/PatchCanonicalEquipment/PatchCanonicalEquipmentHandler.php b/src/Equipment/Application/UseCase/Command/Equipment/PatchCanonicalEquipment/PatchCanonicalEquipmentHandler.php index 6853515a1..4d63e56bc 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/PatchCanonicalEquipment/PatchCanonicalEquipmentHandler.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/PatchCanonicalEquipment/PatchCanonicalEquipmentHandler.php @@ -60,6 +60,7 @@ public function __construct( private InterventionScopePort $interventions, private EventDispatcherPort $eventDispatcher, private TransactionManagerPort $transactionManager, + private ?\Equipment\Application\Port\Outbound\EquipmentTypeCatalogPort $typeCatalog = null, ) { } // #endregion @@ -92,6 +93,9 @@ function () use ($command, &$previousStatus): CanonicalEquipment { $patch->assertNonNullableFieldsArePresent(); $this->assertFacilityPatch($equipment, $patch); + if ($patch->hasType && null !== $patch->type) { + $this->typeCatalog?->validateAvailableType((string) $equipment->organizationId(), $patch->type, $equipment->type()); + } $previousStatus = $equipment->applyPatch($patch); @@ -189,6 +193,14 @@ private static function patch(PatchCanonicalEquipmentCommand $command): Canonica locationLabel: $command->locationLabel, hasFacility: $command->hasFacility, facilityId: $command->facilityId, + hasName: $command->hasName, + name: $command->name, + hasAssetCode: $command->hasAssetCode, + assetCode: $command->assetCode, + hasCriticality: $command->hasCriticality, + criticality: $command->criticality, + hasTechnicalProperties: $command->hasTechnicalProperties, + technicalProperties: $command->technicalProperties, ); } diff --git a/src/Equipment/Application/UseCase/Command/Equipment/PutUnderMaintenance/PutUnderMaintenanceHandler.php b/src/Equipment/Application/UseCase/Command/Equipment/PutUnderMaintenance/PutUnderMaintenanceHandler.php index 703696824..73d86eabf 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/PutUnderMaintenance/PutUnderMaintenanceHandler.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/PutUnderMaintenance/PutUnderMaintenanceHandler.php @@ -158,6 +158,12 @@ public function __invoke(PutUnderMaintenanceCommand $command): PutUnderMaintenan model: $equipment->model(), serialNumber: $equipment->serialNumber(), locationLabel: $equipment->locationLabel(), + name: $equipment->identity()->name, + assetCode: $equipment->identity()->assetCode, + criticality: $equipment->identity()->criticality, + technicalProperties: $equipment->identity()->technicalProperties, + predecessorEquipmentId: $equipment->predecessorEquipmentId(), + successorEquipmentId: $equipment->successorEquipmentId(), status: $equipment->status()->value, installedAt: $equipment->installedAt()?->format('c'), commissionedAt: $equipment->commissionedAt()?->format('c'), diff --git a/src/Equipment/Application/UseCase/Command/Equipment/PutUnderMaintenance/PutUnderMaintenanceResult.php b/src/Equipment/Application/UseCase/Command/Equipment/PutUnderMaintenance/PutUnderMaintenanceResult.php index 1de93bbc1..a9443b0d7 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/PutUnderMaintenance/PutUnderMaintenanceResult.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/PutUnderMaintenance/PutUnderMaintenanceResult.php @@ -24,6 +24,7 @@ * @since 1.0.0 * * @param list $tags + * @param list $technicalProperties descriptive properties */ public function __construct( public string $equipmentId, @@ -42,6 +43,12 @@ public function __construct( public DateTimeImmutable $createdAt, public DateTimeImmutable $updatedAt, public ?string $facilityName = null, + public ?string $name = null, + public ?string $assetCode = null, + public ?string $criticality = null, + public array $technicalProperties = [], + public ?string $predecessorEquipmentId = null, + public ?string $successorEquipmentId = null, ) { } // #endregion diff --git a/src/Equipment/Application/UseCase/Command/Equipment/RecordInterventionServiceHistory/RecordInterventionServiceHistoryHandler.php b/src/Equipment/Application/UseCase/Command/Equipment/RecordInterventionServiceHistory/RecordInterventionServiceHistoryHandler.php index e8e59e6b1..e22ea145b 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/RecordInterventionServiceHistory/RecordInterventionServiceHistoryHandler.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/RecordInterventionServiceHistory/RecordInterventionServiceHistoryHandler.php @@ -108,12 +108,13 @@ private function recordOne( id: $logId, equipmentId: EquipmentId::fromString($entry->equipmentId), organizationId: $organizationId, - occurredAt: $command->occurredAt, + occurredAt: $entry->performedAt ?? $command->occurredAt, intervention: new InterventionMaintenanceDetails( interventionId: $command->interventionId, interventionNumber: $interventionNumber, workItemAction: $entry->action, - actorId: $actorId, + actorId: $entry->authorId ?? $actorId, + summary: $entry->notes, ), ); diff --git a/src/Equipment/Application/UseCase/Command/Equipment/ReplaceEquipment/ReplaceEquipmentCommand.php b/src/Equipment/Application/UseCase/Command/Equipment/ReplaceEquipment/ReplaceEquipmentCommand.php new file mode 100644 index 000000000..4edb5b09d --- /dev/null +++ b/src/Equipment/Application/UseCase/Command/Equipment/ReplaceEquipment/ReplaceEquipmentCommand.php @@ -0,0 +1,42 @@ +successorEquipmentId) === (null === $command->successor)) { + throw EquipmentReplacementConflictException::because('Provide exactly one existing successor or new successor description.'); + } + $payloadHash = self::payloadHash($command); + + return $this->transactionManager->transactional(function () use ($command, $payloadHash): ReplaceEquipmentResult { + $receipt = $this->replacements->findReceiptForUpdate($command->organizationId, $command->clientOperationId); + if (null !== $receipt) { + if ($receipt->payloadHash !== $payloadHash) { + throw EquipmentReplacementConflictException::because('This client operation has already been used with another replacement payload.'); + } + + return new ReplaceEquipmentResult( + $receipt->predecessorEquipmentId, + $receipt->successorEquipmentId, + $receipt->clientOperationId, + true, + ); + } + + $ids = [$command->equipmentId]; + if (null !== $command->successorEquipmentId) { + $ids[] = $command->successorEquipmentId; + } + $candidates = $this->replacements->lockEquipment($command->organizationId, $ids); + $predecessor = $candidates[$command->equipmentId] ?? throw EquipmentNotFoundException::withId($command->equipmentId); + $predecessor->assertReplaceable(); + if (null !== $predecessor->facilityId) { + try { + $this->facilities->assertFacilityIsAssignable($predecessor->facilityId, $command->organizationId); + } catch (InvalidArgumentException $exception) { + throw EquipmentReplacementConflictException::because($exception->getMessage()); + } + } + $successorId = $command->successorEquipmentId; + if (null === $successorId) { + $successorId = $this->createSuccessor($command, $predecessor); + $candidates = $this->replacements->lockEquipment($command->organizationId, [$successorId]); + } + $successor = $candidates[$successorId] ?? throw EquipmentNotFoundException::withId($successorId); + $replacement = new EquipmentReplacement($predecessor, $successor); + $receipt = new EquipmentReplacementReceipt( + $command->organizationId, + $command->clientOperationId, + $predecessor->id, + $successor->id, + $payloadHash, + ); + $this->maintenance->syncForStatusTransition($predecessor->id, $command->organizationId, $predecessor->status, 'decommissioned'); + $this->replacements->save($replacement, $receipt); + + return new ReplaceEquipmentResult($predecessor->id, $successor->id, $command->clientOperationId, false); + }); + } + + /** + * Method createSuccessor + * + * Uses the ordinary creation use case inside the owning main transaction. + * + * @access private + * + * @param ReplaceEquipmentCommand $command the new asset fields + * @param ReplacementEquipment $predecessor the locked original asset + * + * @return string the created successor identifier + */ + private function createSuccessor(ReplaceEquipmentCommand $command, ReplacementEquipment $predecessor): string + { + $successor = $command->successor ?? throw new LogicException('New successor fields are unavailable.'); + $identity = EquipmentIdentity::fromValues($successor->name, $successor->assetCode, $successor->criticality, $successor->technicalProperties); + $result = $this->commandBus->dispatch(new CreateEquipmentCommand( + organizationId: $command->organizationId, + type: $successor->type, + subType: $successor->subType, + brand: $successor->brand, + model: $successor->model, + serialNumber: $successor->serialNumber, + locationLabel: $successor->locationLabel, + facilityId: $predecessor->facilityId, + name: $identity->name, + assetCode: $identity->assetCode, + criticality: $identity->criticality, + technicalProperties: $identity->technicalProperties, + )); + if (!$result instanceof CreateEquipmentResult) { + throw new LogicException('Equipment creation did not return its typed result.'); + } + + return $result->equipmentId; + } + + /** + * Method payloadHash + * + * Property order and an omitted optional unit do not change the replay identity. + * + * @access private + * + * @param ReplaceEquipmentCommand $command the requested replacement + * + * @return string the canonical request fingerprint + */ + private static function payloadHash(ReplaceEquipmentCommand $command): string + { + $successor = $command->successor; + $properties = null === $successor ? [] : $successor->technicalProperties; + $properties = array_map(static fn (array $property): array => [ + 'key' => $property['key'], 'value' => $property['value'], 'unit' => $property['unit'] ?? null, + ], $properties); + usort($properties, static fn (array $left, array $right): int => $left['key'] <=> $right['key']); + $description = null === $successor ? null : [ + 'type' => $successor->type, 'subType' => $successor->subType, 'brand' => $successor->brand, + 'model' => $successor->model, 'serialNumber' => $successor->serialNumber, + 'locationLabel' => $successor->locationLabel, 'name' => $successor->name, + 'assetCode' => $successor->assetCode, 'criticality' => $successor->criticality, 'technicalProperties' => $properties, + ]; + + return hash('sha256', json_encode([ + 'predecessorEquipmentId' => $command->equipmentId, + 'successorEquipmentId' => $command->successorEquipmentId, + 'successor' => $description, + ], JSON_THROW_ON_ERROR)); + } + // #endregion +} diff --git a/src/Equipment/Application/UseCase/Command/Equipment/ReplaceEquipment/ReplaceEquipmentResult.php b/src/Equipment/Application/UseCase/Command/Equipment/ReplaceEquipment/ReplaceEquipmentResult.php new file mode 100644 index 000000000..3c802a8c7 --- /dev/null +++ b/src/Equipment/Application/UseCase/Command/Equipment/ReplaceEquipment/ReplaceEquipmentResult.php @@ -0,0 +1,39 @@ +model(), serialNumber: $equipment->serialNumber(), locationLabel: $equipment->locationLabel(), + name: $equipment->identity()->name, + assetCode: $equipment->identity()->assetCode, + criticality: $equipment->identity()->criticality, + technicalProperties: $equipment->identity()->technicalProperties, + predecessorEquipmentId: $equipment->predecessorEquipmentId(), + successorEquipmentId: $equipment->successorEquipmentId(), status: $equipment->status()->value, installedAt: $equipment->installedAt()?->format('c'), commissionedAt: $equipment->commissionedAt()?->format('c'), diff --git a/src/Equipment/Application/UseCase/Command/Equipment/UnassignFromFacility/UnassignFromFacilityResult.php b/src/Equipment/Application/UseCase/Command/Equipment/UnassignFromFacility/UnassignFromFacilityResult.php index c83b1590c..3420ca090 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/UnassignFromFacility/UnassignFromFacilityResult.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/UnassignFromFacility/UnassignFromFacilityResult.php @@ -24,6 +24,7 @@ * @since 1.0.0 * * @param list $tags + * @param list $technicalProperties descriptive properties */ public function __construct( public string $equipmentId, @@ -42,6 +43,12 @@ public function __construct( public DateTimeImmutable $createdAt, public DateTimeImmutable $updatedAt, public ?string $facilityName = null, + public ?string $name = null, + public ?string $assetCode = null, + public ?string $criticality = null, + public array $technicalProperties = [], + public ?string $predecessorEquipmentId = null, + public ?string $successorEquipmentId = null, ) { } // #endregion diff --git a/src/Equipment/Application/UseCase/Command/Equipment/UpdateEquipment/UpdateEquipmentCommand.php b/src/Equipment/Application/UseCase/Command/Equipment/UpdateEquipment/UpdateEquipmentCommand.php index f4211959d..766e50597 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/UpdateEquipment/UpdateEquipmentCommand.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/UpdateEquipment/UpdateEquipmentCommand.php @@ -32,6 +32,7 @@ * @param ?string $model optional model value * @param ?string $serialNumber optional serial number * @param ?string $locationLabel optional location description + * @param list $technicalProperties descriptive properties * * @return void */ @@ -44,6 +45,14 @@ public function __construct( public ?string $model = null, public ?string $serialNumber = null, public ?string $locationLabel = null, + public ?string $name = null, + public ?string $assetCode = null, + public ?string $criticality = null, + public array $technicalProperties = [], + public bool $hasName = false, + public bool $hasAssetCode = false, + public bool $hasCriticality = false, + public bool $hasTechnicalProperties = false, ) { } // #endregion diff --git a/src/Equipment/Application/UseCase/Command/Equipment/UpdateEquipment/UpdateEquipmentHandler.php b/src/Equipment/Application/UseCase/Command/Equipment/UpdateEquipment/UpdateEquipmentHandler.php index e7668b0f2..ce06903fc 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/UpdateEquipment/UpdateEquipmentHandler.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/UpdateEquipment/UpdateEquipmentHandler.php @@ -7,7 +7,7 @@ use Equipment\Application\Port\Outbound\{EquipmentRepositoryPort, FacilityNamingPort, TagRepositoryPort}; use Equipment\Domain\Exception\EquipmentNotFoundException; use Equipment\Domain\Model\Equipment\Equipment; -use Equipment\Domain\ValueObject\{EquipmentId, EquipmentOrganizationId, EquipmentType}; +use Equipment\Domain\ValueObject\{EquipmentId, EquipmentIdentity, EquipmentOrganizationId, EquipmentTypeCode}; use Shared\Application\Message\CommandHandler; use Shared\Domain\Exception\InvalidValueException; use ValueError; @@ -42,6 +42,7 @@ public function __construct( private EquipmentRepositoryPort $equipmentRepository, private TagRepositoryPort $tagRepository, private FacilityNamingPort $facilityNaming, + private ?\Equipment\Application\Port\Outbound\EquipmentTypeCatalogPort $typeCatalog = null, ) { } // #endregion @@ -64,14 +65,22 @@ public function __invoke(UpdateEquipmentCommand $command): UpdateEquipmentResult } try { + $this->typeCatalog?->validateAvailableType($command->organizationId, $command->type, $equipment->type()->value); $equipment->update( - type: EquipmentType::from($command->type), + type: null === $this->typeCatalog ? \Equipment\Domain\ValueObject\EquipmentType::from($command->type) : EquipmentTypeCode::fromString($command->type), subType: $command->subType, brand: $command->brand, model: $command->model, serialNumber: $command->serialNumber, locationLabel: $command->locationLabel, ); + $identity = $equipment->identity(); + $equipment->updateIdentity(EquipmentIdentity::fromValues( + $command->hasName ? $command->name : $identity->name, + $command->hasAssetCode ? $command->assetCode : $identity->assetCode, + $command->hasCriticality ? $command->criticality : $identity->criticality, + $command->hasTechnicalProperties ? $command->technicalProperties : $identity->technicalProperties, + )); } catch (ValueError $exception) { throw InvalidValueException::because($exception->getMessage(), $exception); } @@ -88,6 +97,12 @@ public function __invoke(UpdateEquipmentCommand $command): UpdateEquipmentResult model: $equipment->model(), serialNumber: $equipment->serialNumber(), locationLabel: $equipment->locationLabel(), + name: $equipment->identity()->name, + assetCode: $equipment->identity()->assetCode, + criticality: $equipment->identity()->criticality, + technicalProperties: $equipment->identity()->technicalProperties, + predecessorEquipmentId: $equipment->predecessorEquipmentId(), + successorEquipmentId: $equipment->successorEquipmentId(), status: $equipment->status()->value, installedAt: $equipment->installedAt()?->format('c'), commissionedAt: $equipment->commissionedAt()?->format('c'), diff --git a/src/Equipment/Application/UseCase/Command/Equipment/UpdateEquipment/UpdateEquipmentResult.php b/src/Equipment/Application/UseCase/Command/Equipment/UpdateEquipment/UpdateEquipmentResult.php index e478ec92e..d6b3b7fd5 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/UpdateEquipment/UpdateEquipmentResult.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/UpdateEquipment/UpdateEquipmentResult.php @@ -24,6 +24,7 @@ * @since 1.0.0 * * @param list $tags + * @param list $technicalProperties descriptive properties */ public function __construct( public string $equipmentId, @@ -42,6 +43,12 @@ public function __construct( public DateTimeImmutable $createdAt, public DateTimeImmutable $updatedAt, public ?string $facilityName = null, + public ?string $name = null, + public ?string $assetCode = null, + public ?string $criticality = null, + public array $technicalProperties = [], + public ?string $predecessorEquipmentId = null, + public ?string $successorEquipmentId = null, ) { } // #endregion diff --git a/src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/CreateEquipmentType/CreateEquipmentTypeCommand.php b/src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/CreateEquipmentType/CreateEquipmentTypeCommand.php new file mode 100644 index 000000000..49311ff1f --- /dev/null +++ b/src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/CreateEquipmentType/CreateEquipmentTypeCommand.php @@ -0,0 +1,37 @@ +organizationId); + $type = EquipmentTypeDefinition::create($command->value, $command->label, $command->family); + $this->catalog->save($organizationId, $type, null); + + return new CreateEquipmentTypeResult(new EquipmentTypeDescriptor($type->value, $type->label, $type->family, $type->archived, $type->revision)); + } + // #endregion +} diff --git a/src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/CreateEquipmentType/CreateEquipmentTypeResult.php b/src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/CreateEquipmentType/CreateEquipmentTypeResult.php new file mode 100644 index 000000000..8f6bd2399 --- /dev/null +++ b/src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/CreateEquipmentType/CreateEquipmentTypeResult.php @@ -0,0 +1,31 @@ +organizationId); + $current = $this->catalog->find($organizationId, $command->value); + if (null === $current) { + throw new EquipmentTypeCatalogException('equipment_type_not_found', 'Equipment type not found.'); + } + if ($current->revision !== $command->revision) { + throw new EquipmentTypeCatalogException('equipment_type_revision_conflict', 'The equipment type changed. Reload before editing.'); + } + $type = $current->revise($command->label, $command->family, $command->archived); + $this->catalog->save($organizationId, $type, $command->revision); + + return new PatchEquipmentTypeResult(new EquipmentTypeDescriptor($type->value, $type->label, $type->family, $type->archived, $type->revision)); + } + // #endregion +} diff --git a/src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/PatchEquipmentType/PatchEquipmentTypeResult.php b/src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/PatchEquipmentType/PatchEquipmentTypeResult.php new file mode 100644 index 000000000..cedd5c70f --- /dev/null +++ b/src/Equipment/Application/UseCase/Command/EquipmentTypeCatalog/PatchEquipmentType/PatchEquipmentTypeResult.php @@ -0,0 +1,31 @@ +tagRepository->findByEquipmentId($equipmentId); - $dueStatuses = $this->maintenanceDueStatusPort->dueStatusesForEquipment( + $operationsDueByEquipment = $this->operationsDue?->forEquipment( (string) $equipment->organizationId(), [(string) $equipment->id()], - ); + ) ?? []; + $due = $operationsDueByEquipment[(string) $equipment->id()] ?? null; + $legacyStatuses = null === $this->operationsDue ? $this->maintenanceDueStatusPort->dueStatusesForEquipment( + (string) $equipment->organizationId(), + [(string) $equipment->id()], + ) : []; + $controlStatus = $due->controlDueStatus ?? ($legacyStatuses[(string) $equipment->id()] ?? 'unscheduled'); return new GetEquipmentResult( equipmentId: (string) $equipment->id(), @@ -80,6 +89,12 @@ public function __invoke(GetEquipmentQuery $query): GetEquipmentResult model: $equipment->model(), serialNumber: $equipment->serialNumber(), locationLabel: $equipment->locationLabel(), + name: $equipment->identity()->name, + assetCode: $equipment->identity()->assetCode, + criticality: $equipment->identity()->criticality, + technicalProperties: $equipment->identity()->technicalProperties, + predecessorEquipmentId: $equipment->predecessorEquipmentId(), + successorEquipmentId: $equipment->successorEquipmentId(), status: $equipment->status()->value, installedAt: $equipment->installedAt()?->format('c'), commissionedAt: $equipment->commissionedAt()?->format('c'), @@ -93,7 +108,11 @@ public function __invoke(GetEquipmentQuery $query): GetEquipmentResult ), createdAt: $equipment->createdAt(), updatedAt: $equipment->updatedAt(), - maintenanceDueStatus: $dueStatuses[(string) $equipment->id()] ?? 'unscheduled', + maintenanceDueStatus: $controlStatus, + controlDueStatus: $controlStatus, + serviceDueStatus: $due->serviceDueStatus ?? 'unscheduled', + controlNextDueAt: $due?->controlNextDueAt?->format('c'), + serviceNextDueAt: $due?->serviceNextDueAt?->format('c'), facilityName: null !== $equipment->facilityId() ? ($this->facilityNaming->findNamesByIds([(string) $equipment->facilityId()])[(string) $equipment->facilityId()] ?? null) : null, diff --git a/src/Equipment/Application/UseCase/Query/Equipment/GetEquipment/GetEquipmentResult.php b/src/Equipment/Application/UseCase/Query/Equipment/GetEquipment/GetEquipmentResult.php index 4cd34e651..6154bcc19 100644 --- a/src/Equipment/Application/UseCase/Query/Equipment/GetEquipment/GetEquipmentResult.php +++ b/src/Equipment/Application/UseCase/Query/Equipment/GetEquipment/GetEquipmentResult.php @@ -24,8 +24,13 @@ * @since 1.0.0 * * @param list $tags - * @param string $maintenanceDueStatus the maintenance due status value (`unscheduled`|`up_to_date`|`due_soon`|`overdue`), resolved cross-module from the Maintenance module; `unscheduled` when the equipment has no maintenance schedule + * @param string $maintenanceDueStatus compatibility control due status when the independent projection is unavailable * @param ?array{attachmentId: string, x: float, y: float} $planPosition the equipment's plan position, or null when unset + * @param list $technicalProperties descriptive properties + * @param ?string $controlDueStatus the independent control due status; null denotes an older projection + * @param string $serviceDueStatus the independent service due status + * @param ?string $controlNextDueAt the next control deadline in ISO 8601 + * @param ?string $serviceNextDueAt the next service deadline in ISO 8601 */ public function __construct( public string $equipmentId, @@ -46,6 +51,16 @@ public function __construct( public string $maintenanceDueStatus = 'unscheduled', public ?string $facilityName = null, public ?array $planPosition = null, + public ?string $name = null, + public ?string $assetCode = null, + public ?string $criticality = null, + public array $technicalProperties = [], + public ?string $predecessorEquipmentId = null, + public ?string $successorEquipmentId = null, + public ?string $controlDueStatus = null, + public string $serviceDueStatus = 'unscheduled', + public ?string $controlNextDueAt = null, + public ?string $serviceNextDueAt = null, ) { } // #endregion diff --git a/src/Equipment/Application/UseCase/Query/Equipment/GetFacilityEquipmentSummary/GetFacilityEquipmentSummaryHandler.php b/src/Equipment/Application/UseCase/Query/Equipment/GetFacilityEquipmentSummary/GetFacilityEquipmentSummaryHandler.php index 8089c1b04..33c9b9261 100644 --- a/src/Equipment/Application/UseCase/Query/Equipment/GetFacilityEquipmentSummary/GetFacilityEquipmentSummaryHandler.php +++ b/src/Equipment/Application/UseCase/Query/Equipment/GetFacilityEquipmentSummary/GetFacilityEquipmentSummaryHandler.php @@ -31,7 +31,7 @@ * @param EquipmentRepositoryPort $equipment aggregates the same predicates as the published collection * @param FacilitySubtreeScopePort $facilities resolves organization-scoped published facility identifiers */ - public function __construct(private EquipmentRepositoryPort $equipment, private FacilitySubtreeScopePort $facilities) + public function __construct(private EquipmentRepositoryPort $equipment, private FacilitySubtreeScopePort $facilities, private ?\Equipment\Application\Service\EquipmentSelectionScopeResolver $selectionScopes = null) { } // #endregion @@ -57,7 +57,8 @@ public function __invoke(GetFacilityEquipmentSummaryQuery $query): GetFacilityEq $counts = $this->equipment->countByStatusForCriteria($organizationId, new EquipmentListCriteria( facilityId: $query->includeDescendants ? null : (string) $facilityId, - facilityIds: $query->includeDescendants ? $facilityIds : null, + facilityIds: $this->selectionScopes?->customerFacilities($query->organizationId, $query->customerId, $query->includeDescendants ? $facilityIds : null) ?? ($query->includeDescendants ? $facilityIds : null), + typeCodes: $this->selectionScopes?->typesForFamily($query->organizationId, $query->family), )); $byStatus = [ 'in_stock' => $counts['in_stock'] ?? 0, diff --git a/src/Equipment/Application/UseCase/Query/Equipment/GetFacilityEquipmentSummary/GetFacilityEquipmentSummaryQuery.php b/src/Equipment/Application/UseCase/Query/Equipment/GetFacilityEquipmentSummary/GetFacilityEquipmentSummaryQuery.php index dfa50d946..aa7de3c01 100644 --- a/src/Equipment/Application/UseCase/Query/Equipment/GetFacilityEquipmentSummary/GetFacilityEquipmentSummaryQuery.php +++ b/src/Equipment/Application/UseCase/Query/Equipment/GetFacilityEquipmentSummary/GetFacilityEquipmentSummaryQuery.php @@ -26,7 +26,7 @@ * @param string $facilityId the published facility defining the scope * @param bool $includeDescendants whether published descendants share the scope */ - public function __construct(public string $organizationId, public string $facilityId, public bool $includeDescendants = true) + public function __construct(public string $organizationId, public string $facilityId, public bool $includeDescendants = true, public ?string $family = null, public ?string $customerId = null) { } // #endregion diff --git a/src/Equipment/Application/UseCase/Query/Equipment/GetOrganizationEquipmentSummary/GetOrganizationEquipmentSummaryHandler.php b/src/Equipment/Application/UseCase/Query/Equipment/GetOrganizationEquipmentSummary/GetOrganizationEquipmentSummaryHandler.php new file mode 100644 index 000000000..8dd073aec --- /dev/null +++ b/src/Equipment/Application/UseCase/Query/Equipment/GetOrganizationEquipmentSummary/GetOrganizationEquipmentSummaryHandler.php @@ -0,0 +1,49 @@ + + */ +final readonly class GetOrganizationEquipmentSummaryHandler implements QueryHandler +{ + /** + * @since 1.0.0 + */ + public function __construct(private EquipmentRepositoryPort $equipment, private EquipmentSelectionScopeResolver $scopes) + { + } + + /** + * @since 1.0.0 + */ + public function __invoke(GetOrganizationEquipmentSummaryQuery $query): GetFacilityEquipmentSummaryResult + { + $counts = $this->equipment->countByStatusForCriteria(EquipmentOrganizationId::fromString($query->organizationId), new EquipmentListCriteria( + facilityIds: $this->scopes->customerFacilities($query->organizationId, $query->customerId, null), + typeCodes: $this->scopes->typesForFamily($query->organizationId, $query->family), + )); + $byStatus = [ + 'in_stock' => $counts['in_stock'] ?? 0, 'operational' => $counts['operational'] ?? 0, + 'under_maintenance' => $counts['under_maintenance'] ?? 0, 'decommissioned' => $counts['decommissioned'] ?? 0, + ]; + + return new GetFacilityEquipmentSummaryResult(null === $query->customerId ? 'organization' : 'customer', array_sum($byStatus), $byStatus, $byStatus['under_maintenance'] + $byStatus['decommissioned']); + } +} diff --git a/src/Equipment/Application/UseCase/Query/Equipment/GetOrganizationEquipmentSummary/GetOrganizationEquipmentSummaryQuery.php b/src/Equipment/Application/UseCase/Query/Equipment/GetOrganizationEquipmentSummary/GetOrganizationEquipmentSummaryQuery.php new file mode 100644 index 000000000..709f23188 --- /dev/null +++ b/src/Equipment/Application/UseCase/Query/Equipment/GetOrganizationEquipmentSummary/GetOrganizationEquipmentSummaryQuery.php @@ -0,0 +1,25 @@ + + */ +final readonly class GetOrganizationEquipmentSummaryQuery implements QueryMessage +{ + /** + * @since 1.0.0 + */ + public function __construct(public string $organizationId, public ?string $family = null, public ?string $customerId = null) + { + } +} diff --git a/src/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsHandler.php b/src/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsHandler.php index 6f7aa1d51..42be03801 100644 --- a/src/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsHandler.php +++ b/src/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsHandler.php @@ -12,7 +12,9 @@ use Equipment\Domain\Model\Equipment\Equipment; use Equipment\Domain\Model\Tag\Tag; use Equipment\Domain\ValueObject\EquipmentFacilityId; -use Equipment\Domain\ValueObject\{EquipmentId, EquipmentOrganizationId, EquipmentStatus, EquipmentType}; +use Equipment\Domain\ValueObject\{EquipmentId, EquipmentOrganizationId, EquipmentStatus, EquipmentTypeCode}; +use Maintenance\Application\Contract\Plan\MaintenanceEquipmentOperationsDue; +use Maintenance\Application\Port\Inbound\MaintenanceOperationsDuePort; use Shared\Application\Contract\Pagination\PaginatedResult; use Shared\Application\Message\QueryHandler; use Shared\Domain\Exception\InvalidValueException; @@ -38,12 +40,12 @@ { // #region Constants /** - * The four Maintenance due status values, mirrored here without importing - * Maintenance's Domain layer — see `MaintenanceDueStatusPort`'s docblock. + * The four Maintenance due status values and the query-only `due` union, + * mirrored without importing Maintenance's Domain layer. * * @var list */ - private const array MAINTENANCE_DUE_STATUSES = ['unscheduled', 'up_to_date', 'due_soon', 'overdue']; + private const array MAINTENANCE_DUE_STATUSES = ['unscheduled', 'up_to_date', 'due_soon', 'overdue', 'due']; /** * Upper bound for the in-memory scan `listFilteredByDueStatus()` requires. @@ -66,6 +68,7 @@ * @param MaintenanceDueStatusPort $maintenanceDueStatusPort port used to apply or report maintenance due status * @param FacilityNamingPort $facilityNaming port used to resolve facility display names * @param FacilitySubtreeScopePort $facilitySubtree resolves scoped descendant identifiers + * @param ?MaintenanceOperationsDuePort $operationsDue independent control and service deadlines * * @return void */ @@ -75,6 +78,8 @@ public function __construct( private MaintenanceDueStatusPort $maintenanceDueStatusPort, private FacilityNamingPort $facilityNaming, private FacilitySubtreeScopePort $facilitySubtree, + private ?\Equipment\Application\Service\EquipmentSelectionScopeResolver $selectionScopes = null, + private ?MaintenanceOperationsDuePort $operationsDue = null, ) { } // #endregion @@ -91,7 +96,9 @@ public function __invoke(ListEquipmentsQuery $query): PaginatedResult { try { $organizationId = EquipmentOrganizationId::fromString($query->organizationId); - $type = null !== $query->type ? EquipmentType::from($query->type)->value : null; + $type = null !== $query->type + ? (null === $this->selectionScopes ? \Equipment\Domain\ValueObject\EquipmentType::from($query->type)->value : EquipmentTypeCode::fromString($query->type)->value) + : null; $status = null !== $query->status ? EquipmentStatus::from($query->status)->value : null; } catch (InvalidValueException|ValueError $exception) { throw InvalidValueException::because($exception->getMessage(), $exception); @@ -121,7 +128,8 @@ public function __invoke(ListEquipmentsQuery $query): PaginatedResult model: $query->model, subType: $query->subType, search: $query->search, - facilityIds: $facilityIds, + facilityIds: $this->selectionScopes?->customerFacilities($query->organizationId, $query->customerId, $facilityIds) ?? $facilityIds, + typeCodes: $this->selectionScopes?->typesForFamily($query->organizationId, $query->family), ); if (null !== $query->maintenanceDueStatus) { @@ -147,10 +155,12 @@ public function __invoke(ListEquipmentsQuery $query): PaginatedResult ); $tagsByEquipmentId = $this->tagRepository->findTagsByEquipmentIds($equipmentIds); - $dueStatusesByEquipmentId = $this->maintenanceDueStatusPort->dueStatusesForEquipment( + $rawEquipmentIds = array_map(static fn (EquipmentId $id): string => (string) $id, $equipmentIds); + $operationsDueByEquipmentId = $this->operationsDue?->forEquipment((string) $organizationId, $rawEquipmentIds) ?? []; + $dueStatusesByEquipmentId = null === $this->operationsDue ? $this->maintenanceDueStatusPort->dueStatusesForEquipment( (string) $organizationId, - array_map(static fn (EquipmentId $id): string => (string) $id, $equipmentIds), - ); + $rawEquipmentIds, + ) : array_map(static fn (MaintenanceEquipmentOperationsDue $due): string => $due->controlDueStatus, $operationsDueByEquipmentId); $facilityNames = $this->resolveFacilityNames($equipments); @@ -162,6 +172,7 @@ public function __invoke(ListEquipmentsQuery $query): PaginatedResult $tagsByEquipmentId[(string) $equipment->id()] ?? [], $dueStatusesByEquipmentId[(string) $equipment->id()] ?? 'unscheduled', null !== $facilityId ? ($facilityNames[$facilityId] ?? null) : null, + $operationsDueByEquipmentId[(string) $equipment->id()] ?? null, ); } @@ -204,14 +215,18 @@ private function listFilteredByDueStatus( 0, ); - $dueStatusesByEquipmentId = $this->maintenanceDueStatusPort->dueStatusesForEquipment( + $rawEquipmentIds = array_map(static fn ($equipment): string => (string) $equipment->id(), $candidates); + $operationsDueByEquipmentId = $this->operationsDue?->forEquipment((string) $organizationId, $rawEquipmentIds) ?? []; + $dueStatusesByEquipmentId = null === $this->operationsDue ? $this->maintenanceDueStatusPort->dueStatusesForEquipment( (string) $organizationId, - array_map(static fn ($equipment): string => (string) $equipment->id(), $candidates), - ); + $rawEquipmentIds, + ) : array_map(static fn (MaintenanceEquipmentOperationsDue $due): string => $due->controlDueStatus, $operationsDueByEquipmentId); $matched = array_values(array_filter( $candidates, - static fn ($equipment): bool => ($dueStatusesByEquipmentId[(string) $equipment->id()] ?? 'unscheduled') === $query->maintenanceDueStatus, + static fn ($equipment): bool => 'due' === $query->maintenanceDueStatus + ? in_array($dueStatusesByEquipmentId[(string) $equipment->id()] ?? 'unscheduled', ['due_soon', 'overdue'], true) + : ($dueStatusesByEquipmentId[(string) $equipment->id()] ?? 'unscheduled') === $query->maintenanceDueStatus, )); $total = count($matched); @@ -232,6 +247,7 @@ private function listFilteredByDueStatus( $tagsByEquipmentId[(string) $equipment->id()] ?? [], $dueStatusesByEquipmentId[(string) $equipment->id()] ?? 'unscheduled', null !== $facilityId ? ($facilityNames[$facilityId] ?? null) : null, + $operationsDueByEquipmentId[(string) $equipment->id()] ?? null, ); } @@ -281,9 +297,12 @@ private function resolveFacilityNames(array $equipments): array * @param list $tags the equipment's tags * @param string $maintenanceDueStatus the resolved maintenance due status value * @param ?string $facilityName the resolved facility display name, when assigned + * @param ?MaintenanceEquipmentOperationsDue $operationsDue independent operation deadlines */ - private function toResult(Equipment $equipment, array $tags, string $maintenanceDueStatus, ?string $facilityName = null): GetEquipmentResult + private function toResult(Equipment $equipment, array $tags, string $maintenanceDueStatus, ?string $facilityName = null, ?MaintenanceEquipmentOperationsDue $operationsDue = null): GetEquipmentResult { + $controlStatus = $operationsDue->controlDueStatus ?? $maintenanceDueStatus; + return new GetEquipmentResult( equipmentId: (string) $equipment->id(), organizationId: (string) $equipment->organizationId(), @@ -294,6 +313,12 @@ private function toResult(Equipment $equipment, array $tags, string $maintenance model: $equipment->model(), serialNumber: $equipment->serialNumber(), locationLabel: $equipment->locationLabel(), + name: $equipment->identity()->name, + assetCode: $equipment->identity()->assetCode, + criticality: $equipment->identity()->criticality, + technicalProperties: $equipment->identity()->technicalProperties, + predecessorEquipmentId: $equipment->predecessorEquipmentId(), + successorEquipmentId: $equipment->successorEquipmentId(), status: $equipment->status()->value, installedAt: $equipment->installedAt()?->format('c'), commissionedAt: $equipment->commissionedAt()?->format('c'), @@ -307,7 +332,11 @@ private function toResult(Equipment $equipment, array $tags, string $maintenance ), createdAt: $equipment->createdAt(), updatedAt: $equipment->updatedAt(), - maintenanceDueStatus: $maintenanceDueStatus, + maintenanceDueStatus: $controlStatus, + controlDueStatus: $controlStatus, + serviceDueStatus: $operationsDue->serviceDueStatus ?? 'unscheduled', + controlNextDueAt: $operationsDue?->controlNextDueAt?->format('c'), + serviceNextDueAt: $operationsDue?->serviceNextDueAt?->format('c'), facilityName: $facilityName, planPosition: $equipment->planPosition()?->toArray(), ); diff --git a/src/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsQuery.php b/src/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsQuery.php index f1380f765..46c8cb6f3 100644 --- a/src/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsQuery.php +++ b/src/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsQuery.php @@ -54,6 +54,8 @@ public function __construct( public Sorting $sorting = new Sorting('createdAt', SortDirection::ASC), public ?string $maintenanceDueStatus = null, public bool $includeDescendants = false, + public ?string $family = null, + public ?string $customerId = null, ) { } // #endregion diff --git a/src/Equipment/Application/UseCase/Query/EquipmentTypeCatalog/ListEquipmentTypes/ListEquipmentTypesHandler.php b/src/Equipment/Application/UseCase/Query/EquipmentTypeCatalog/ListEquipmentTypes/ListEquipmentTypesHandler.php new file mode 100644 index 000000000..7852b8ec0 --- /dev/null +++ b/src/Equipment/Application/UseCase/Query/EquipmentTypeCatalog/ListEquipmentTypes/ListEquipmentTypesHandler.php @@ -0,0 +1,55 @@ +organizationId); + $types = []; + foreach ($this->catalog->list($organizationId) as $type) { + $types[] = new EquipmentTypeDescriptor($type->value, $type->label, $type->family, $type->archived, $type->revision); + } + + return new ListEquipmentTypesResult($types); + } + // #endregion +} diff --git a/src/Equipment/Application/UseCase/Query/EquipmentTypeCatalog/ListEquipmentTypes/ListEquipmentTypesQuery.php b/src/Equipment/Application/UseCase/Query/EquipmentTypeCatalog/ListEquipmentTypes/ListEquipmentTypesQuery.php new file mode 100644 index 000000000..718f085b8 --- /dev/null +++ b/src/Equipment/Application/UseCase/Query/EquipmentTypeCatalog/ListEquipmentTypes/ListEquipmentTypesQuery.php @@ -0,0 +1,30 @@ + $types ordered descriptors + * + * @return void + */ + public function __construct(public array $types) + { + } + // #endregion +} diff --git a/src/Equipment/Domain/Exception/EquipmentAssetCodeAlreadyExistsException.php b/src/Equipment/Domain/Exception/EquipmentAssetCodeAlreadyExistsException.php new file mode 100644 index 000000000..57afd6935 --- /dev/null +++ b/src/Equipment/Domain/Exception/EquipmentAssetCodeAlreadyExistsException.php @@ -0,0 +1,26 @@ + + */ +final class EquipmentAssetCodeAlreadyExistsException extends RuntimeException +{ + /** + * @since 1.0.0 + */ + public static function withAssetCode(string $code): self + { + return new self('Equipment asset code already exists in this organization: ' . $code); + } +} diff --git a/src/Equipment/Domain/Exception/EquipmentNotFoundException.php b/src/Equipment/Domain/Exception/EquipmentNotFoundException.php index 94782bba3..9afc4271c 100644 --- a/src/Equipment/Domain/Exception/EquipmentNotFoundException.php +++ b/src/Equipment/Domain/Exception/EquipmentNotFoundException.php @@ -72,5 +72,13 @@ public static function forFacilityScope(string $facilityId): self { return new self(sprintf('Facility with ID "%s" not found.', $facilityId)); } + + /** + * @since 1.1.0 + */ + public static function forCustomerScope(string $customerId): self + { + return new self(sprintf('Customer with ID "%s" not found.', $customerId)); + } // #endregion } diff --git a/src/Equipment/Domain/Exception/EquipmentReplacementConflictException.php b/src/Equipment/Domain/Exception/EquipmentReplacementConflictException.php new file mode 100644 index 000000000..a95dca300 --- /dev/null +++ b/src/Equipment/Domain/Exception/EquipmentReplacementConflictException.php @@ -0,0 +1,33 @@ +commissionedAt, revision: $lifecycle->revision, updatedAt: $lifecycle->updatedAt, + identity: $metadata->details->identity ?? EquipmentIdentity::fromValues(), ); } @@ -417,11 +420,26 @@ public function updatedAt(): DateTimeImmutable return $this->updatedAt; } + /** + * @since 1.1.0 + */ + public function identity(): EquipmentIdentity + { + return $this->identity ?? EquipmentIdentity::fromValues(); + } + /** * Apply patch fields in their original validation and mutation order. */ private function applyFields(CanonicalEquipmentPatch $patch): void { + $identity = $this->identity(); + $this->identity = EquipmentIdentity::fromValues( + $patch->hasName ? $patch->name : $identity->name, + $patch->hasAssetCode ? $patch->assetCode : $identity->assetCode, + $patch->hasCriticality ? $patch->criticality : $identity->criticality, + $patch->hasTechnicalProperties ? $patch->technicalProperties : $identity->technicalProperties, + ); if ($patch->hasType && null !== $patch->type) { $this->type = $patch->type; } diff --git a/src/Equipment/Domain/Model/Equipment/Equipment.php b/src/Equipment/Domain/Model/Equipment/Equipment.php index df9731580..56c8b3131 100644 --- a/src/Equipment/Domain/Model/Equipment/Equipment.php +++ b/src/Equipment/Domain/Model/Equipment/Equipment.php @@ -10,9 +10,11 @@ EquipmentCatalogDetails, EquipmentFacilityId, EquipmentId, + EquipmentIdentity, EquipmentOrganizationId, EquipmentStatus, EquipmentType, + EquipmentTypeCode, PlanPosition, RestoredEquipmentAssignment }; @@ -59,7 +61,7 @@ final class Equipment private function __construct( private EquipmentId $id, private EquipmentOrganizationId $organizationId, - private EquipmentType $type, + private EquipmentType|EquipmentTypeCode $type, private EquipmentStatus $status, private DateTimeImmutable $createdAt, private DateTimeImmutable $updatedAt, @@ -72,6 +74,9 @@ private function __construct( private ?DateTimeImmutable $installedAt = null, private ?DateTimeImmutable $commissionedAt = null, private ?PlanPosition $planPosition = null, + private ?EquipmentIdentity $identity = null, + private ?string $predecessorEquipmentId = null, + private ?string $successorEquipmentId = null, ) { } // #endregion @@ -94,7 +99,7 @@ private function __construct( public static function create( EquipmentId $id, EquipmentOrganizationId $organizationId, - EquipmentType $type, + EquipmentType|EquipmentTypeCode $type, ?EquipmentCatalogDetails $details = null, ): self { $details ??= new EquipmentCatalogDetails(); @@ -115,6 +120,7 @@ public static function create( locationLabel: self::normalizeShortString($details->locationLabel, 'location label', 255), installedAt: null, commissionedAt: null, + identity: $details->identity ?? EquipmentIdentity::fromValues(), ); } @@ -138,11 +144,13 @@ public static function create( public static function reconstitute( EquipmentId $id, EquipmentOrganizationId $organizationId, - EquipmentType $type, + EquipmentType|EquipmentTypeCode $type, EquipmentCatalogDetails $details, RestoredEquipmentAssignment $assignment, DateTimeImmutable $createdAt, DateTimeImmutable $updatedAt, + ?string $predecessorEquipmentId = null, + ?string $successorEquipmentId = null, ): self { return new self( id: $id, @@ -160,6 +168,9 @@ public static function reconstitute( installedAt: $assignment->installedAt, commissionedAt: $assignment->commissionedAt, planPosition: $assignment->planPosition, + identity: $details->identity ?? EquipmentIdentity::fromValues(), + predecessorEquipmentId: $predecessorEquipmentId, + successorEquipmentId: $successorEquipmentId, ); } @@ -178,7 +189,7 @@ public static function reconstitute( * @param ?string $locationLabel the optional location label */ public function update( - EquipmentType $type, + EquipmentType|EquipmentTypeCode $type, ?string $subType = null, ?string $brand = null, ?string $model = null, @@ -391,7 +402,7 @@ public function facilityId(): ?EquipmentFacilityId * * @since 1.0.0 */ - public function type(): EquipmentType + public function type(): EquipmentType|EquipmentTypeCode { return $this->type; } @@ -506,6 +517,39 @@ public function updatedAt(): DateTimeImmutable return $this->updatedAt; } + /** + * @since 1.0.0 + */ + public function updateIdentity(EquipmentIdentity $identity): void + { + $this->identity = $identity; + $this->touch(); + } + + /** + * @since 1.0.0 + */ + public function identity(): EquipmentIdentity + { + return $this->identity ?? EquipmentIdentity::fromValues(); + } + + /** + * @since 1.0.0 + */ + public function predecessorEquipmentId(): ?string + { + return $this->predecessorEquipmentId; + } + + /** + * @since 1.0.0 + */ + public function successorEquipmentId(): ?string + { + return $this->successorEquipmentId; + } + /** * Method touch. * diff --git a/src/Equipment/Domain/Model/EquipmentTypeCatalog/EquipmentTypeDefinition.php b/src/Equipment/Domain/Model/EquipmentTypeCatalog/EquipmentTypeDefinition.php new file mode 100644 index 000000000..1e2d1f2cb --- /dev/null +++ b/src/Equipment/Domain/Model/EquipmentTypeCatalog/EquipmentTypeDefinition.php @@ -0,0 +1,115 @@ + 32 || '' === trim($label) || mb_strlen($label) > 100 || !in_array($family, ['fire', 'safety', 'other'], true) || $revision < 1) { + throw new EquipmentTypeCatalogException('equipment_type_invalid', 'Equipment type requires a code (max 32), label (max 100), valid family and positive revision.'); + } + } + // #endregion + + // #region Methods + /** + * Method create. + * + * New codes use stable lowercase tokens; restoration also supports historical codes. + * + * @access public + * + * @param string $value new code + * @param string $label display label + * @param string $family catalog family + * + * @return self new active descriptor + */ + public static function create(string $value, string $label, string $family): self + { + if (1 !== preg_match('/^[a-z][a-z0-9_]{0,31}$/D', $value)) { + throw new EquipmentTypeCatalogException('equipment_type_invalid', 'Equipment type code must be a lowercase token of at most 32 characters.'); + } + + return new self($value, trim($label), $family); + } + + /** + * Method revise. + * + * A real descriptor change increments its optimistic revision exactly once. + * + * @access public + * + * @param string|null $label replacement label, null preserves current + * @param string|null $family replacement family, null preserves current + * @param bool|null $archived replacement archived state, null preserves current + * + * @return self updated descriptor + */ + public function revise(?string $label, ?string $family, ?bool $archived): self + { + $nextLabel = null === $label ? $this->label : trim($label); + $nextFamily = $family ?? $this->family; + $nextArchived = $archived ?? $this->archived; + $changed = $nextLabel !== $this->label || $nextFamily !== $this->family || $nextArchived !== $this->archived; + + return new self($this->value, $nextLabel, $nextFamily, $nextArchived, $this->revision + ($changed ? 1 : 0)); + } + + /** + * Method assertAvailable. + * + * Existing assignments may preserve an archived code; new assignments cannot. + * + * @access public + * + * @param string|null $existingType current equipment type, when updating + * + * @return void + */ + public function assertAvailable(?string $existingType): void + { + if ($this->archived && $existingType !== $this->value) { + throw new EquipmentTypeCatalogException('equipment_type_archived', 'This equipment type is archived.'); + } + } + // #endregion +} diff --git a/src/Equipment/Domain/Model/Replacement/EquipmentReplacement.php b/src/Equipment/Domain/Model/Replacement/EquipmentReplacement.php new file mode 100644 index 000000000..b5f293b06 --- /dev/null +++ b/src/Equipment/Domain/Model/Replacement/EquipmentReplacement.php @@ -0,0 +1,59 @@ +assertReplaceable(); + if ($predecessor->id === $successor->id || $predecessor->organizationId !== $successor->organizationId) { + throw EquipmentReplacementConflictException::because('Replacement requires two distinct equipment items in the same organization.'); + } + if ('published' !== $successor->recordStatus || 'in_stock' !== $successor->status || null !== $successor->predecessorId || null !== $successor->successorId) { + throw EquipmentReplacementConflictException::because('The successor must be published, in stock and outside another replacement chain.'); + } + if ('in_stock' !== $predecessor->status && null === $predecessor->facilityId) { + throw EquipmentReplacementConflictException::because('An in-service predecessor must be assigned to a facility.'); + } + } + // #endregion + + // #region Methods + /** + * Method successorStatus + * + * @access public + * + * @return string the successor lifecycle after replacing the predecessor + */ + public function successorStatus(): string + { + return 'in_stock' === $this->predecessor->status ? 'in_stock' : 'operational'; + } + // #endregion +} diff --git a/src/Equipment/Domain/Model/Replacement/ReplacementEquipment.php b/src/Equipment/Domain/Model/Replacement/ReplacementEquipment.php new file mode 100644 index 000000000..8fd60c26d --- /dev/null +++ b/src/Equipment/Domain/Model/Replacement/ReplacementEquipment.php @@ -0,0 +1,65 @@ +recordStatus || 'decommissioned' === $this->status || null !== $this->successorId) { + throw EquipmentReplacementConflictException::because('The predecessor must be a published, active equipment without a successor.'); + } + } + // #endregion +} diff --git a/src/Equipment/Domain/ValueObject/CanonicalEquipmentPatch.php b/src/Equipment/Domain/ValueObject/CanonicalEquipmentPatch.php index 324e72907..aef537737 100644 --- a/src/Equipment/Domain/ValueObject/CanonicalEquipmentPatch.php +++ b/src/Equipment/Domain/ValueObject/CanonicalEquipmentPatch.php @@ -53,6 +53,7 @@ * @param ?string $locationLabel the requested location label * @param bool $hasFacility whether the `facility` key was present * @param ?string $facilityId the resolved facility identifier, null detaching it + * @param list $technicalProperties descriptive properties */ public function __construct( public bool $hasType = false, @@ -71,6 +72,14 @@ public function __construct( public ?string $locationLabel = null, public bool $hasFacility = false, public ?string $facilityId = null, + public bool $hasName = false, + public ?string $name = null, + public bool $hasAssetCode = false, + public ?string $assetCode = null, + public bool $hasCriticality = false, + public ?string $criticality = null, + public bool $hasTechnicalProperties = false, + public array $technicalProperties = [], ) { } // #endregion diff --git a/src/Equipment/Domain/ValueObject/EquipmentCatalogDetails.php b/src/Equipment/Domain/ValueObject/EquipmentCatalogDetails.php index efb6ec61f..0334ce30f 100644 --- a/src/Equipment/Domain/ValueObject/EquipmentCatalogDetails.php +++ b/src/Equipment/Domain/ValueObject/EquipmentCatalogDetails.php @@ -28,6 +28,7 @@ public function __construct( public ?string $model = null, public ?string $serialNumber = null, public ?string $locationLabel = null, + public ?EquipmentIdentity $identity = null, ) { } } diff --git a/src/Equipment/Domain/ValueObject/EquipmentIdentity.php b/src/Equipment/Domain/ValueObject/EquipmentIdentity.php new file mode 100644 index 000000000..96a585158 --- /dev/null +++ b/src/Equipment/Domain/ValueObject/EquipmentIdentity.php @@ -0,0 +1,93 @@ + + */ +final readonly class EquipmentIdentity +{ + /** + * @since 1.0.0 + * + * @param list $technicalProperties descriptive properties + */ + private function __construct( + public ?string $name, + public ?string $assetCode, + public ?string $criticality, + public array $technicalProperties, + ) { + } + + /** + * @since 1.0.0 + * + * @param array $technicalProperties declarative properties + */ + public static function fromValues(?string $name = null, ?string $assetCode = null, ?string $criticality = null, array $technicalProperties = []): self + { + if (null !== $criticality && !in_array($criticality, ['low', 'medium', 'high', 'critical'], true)) { + throw InvalidValueException::because('Equipment criticality is invalid.'); + } + if (!array_is_list($technicalProperties) || count($technicalProperties) > 50) { + throw InvalidValueException::because('Technical properties must be a list of at most 50 entries.'); + } + $properties = []; + $keys = []; + foreach ($technicalProperties as $property) { + if (!is_array($property) || !is_string($property['key'] ?? null) || !is_string($property['value'] ?? null) + || (null !== ($property['unit'] ?? null) && !is_string($property['unit']))) { + throw InvalidValueException::because('Technical properties require a string key, value and optional unit.'); + } + $key = self::text($property['key'], 64); + if (null === $key || in_array($key, $keys, true)) { + throw InvalidValueException::because('Technical property keys must be nonempty and unique.'); + } + $value = $property['value']; + if (mb_strlen($value) > 255) { + throw InvalidValueException::because('Technical property values must be at most 255 characters.'); + } + $keys[] = $key; + $unit = $property['unit'] ?? null; + if (null !== $unit && !is_string($unit)) { + throw InvalidValueException::because('Technical property unit must be a string or null.'); + } + $properties[] = ['key' => $key, 'value' => $value, 'unit' => self::text($unit, 32)]; + } + + return new self(self::text($name, 255), self::text($assetCode, 100), $criticality, $properties); + } + + /** + * @since 1.0.0 + */ + private static function text(?string $value, int $maximum): ?string + { + if (null === $value || '' === trim($value)) { + return null; + } + if (mb_strlen(trim($value)) > $maximum) { + throw InvalidValueException::because('Equipment identity text exceeds its maximum length.'); + } + + return trim($value); + } +} diff --git a/src/Equipment/Domain/ValueObject/EquipmentTypeCode.php b/src/Equipment/Domain/ValueObject/EquipmentTypeCode.php new file mode 100644 index 000000000..b592c746e --- /dev/null +++ b/src/Equipment/Domain/ValueObject/EquipmentTypeCode.php @@ -0,0 +1,45 @@ + + */ +final readonly class EquipmentTypeCode +{ + private function __construct(public string $value) + { + } + + /** + * @since 1.0.0 + */ + public static function fromString(string $value): EquipmentType|self + { + if ('' === trim($value) || mb_strlen($value) > 32) { + throw InvalidValueException::because('Equipment type must contain between 1 and 32 characters.'); + } + + return EquipmentType::tryFrom($value) ?? new self($value); + } + + /** + * @since 1.0.0 + */ + public function label(): string + { + return $this->value; + } +} diff --git a/src/Equipment/Infrastructure/Adapter/Inspection/EquipmentParkScopeAdapter.php b/src/Equipment/Infrastructure/Adapter/Inspection/EquipmentParkScopeAdapter.php new file mode 100644 index 000000000..70e94a5e2 --- /dev/null +++ b/src/Equipment/Infrastructure/Adapter/Inspection/EquipmentParkScopeAdapter.php @@ -0,0 +1,58 @@ + + */ +final readonly class EquipmentParkScopeAdapter implements EquipmentParkScopePort +{ + /** + * @since 1.0.0 + */ + public function __construct(private EquipmentRepositoryPort $equipment, private EquipmentSelectionScopeResolver $scopes, private FacilitySubtreeScopePort $facilities) + { + } + + /** + * @since 1.0.0 + * + * @param list $equipmentIds candidate identifiers + * @param bool $includeDescendants whether an explicit facility includes published descendants + * + * @return list + */ + public function filterIds(string $organizationId, array $equipmentIds, ?string $family = null, ?string $customerId = null, ?string $facilityId = null, bool $includeDescendants = true): array + { + $facilityIds = null; + if (null !== $facilityId) { + $facilityIds = $this->facilities->findPublishedSubtreeIds($organizationId, $facilityId); + if ([] === $facilityIds) { + throw EquipmentNotFoundException::forFacilityScope($facilityId); + } + if (!$includeDescendants) { + $facilityIds = [$facilityId]; + } + } + $criteria = new EquipmentListCriteria( + facilityIds: $this->scopes->customerFacilities($organizationId, $customerId, $facilityIds), + typeCodes: $this->scopes->typesForFamily($organizationId, $family), + ); + + return $this->equipment->findPublishedIdsMatching(EquipmentOrganizationId::fromString($organizationId), $criteria, $equipmentIds); + } +} diff --git a/src/Equipment/Infrastructure/Adapter/Inspection/EquipmentValidationAdapter.php b/src/Equipment/Infrastructure/Adapter/Inspection/EquipmentValidationAdapter.php index 2d907ec64..f95502c99 100644 --- a/src/Equipment/Infrastructure/Adapter/Inspection/EquipmentValidationAdapter.php +++ b/src/Equipment/Infrastructure/Adapter/Inspection/EquipmentValidationAdapter.php @@ -55,6 +55,21 @@ public function assertEquipmentExists(string $equipmentId, string $organizationI } } + /** + * {@inheritDoc} + */ + public function assertPublishedEquipmentExists(string $equipmentId, string $organizationId): void + { + try { + $equipment = $this->equipmentRepository->findPublishedById(EquipmentId::fromString($equipmentId)); + } catch (\Shared\Domain\Exception\InvalidValueException) { + $equipment = null; + } + if (null === $equipment || (string) $equipment->organizationId() !== $organizationId) { + throw new InvalidArgumentException(sprintf('Equipment with ID "%s" not found.', $equipmentId)); + } + } + /** * {@inheritDoc} */ diff --git a/src/Equipment/Infrastructure/Adapter/Intervention/EquipmentInterventionResourceAdapter.php b/src/Equipment/Infrastructure/Adapter/Intervention/EquipmentInterventionResourceAdapter.php index 20d9c59d1..c5ae69912 100644 --- a/src/Equipment/Infrastructure/Adapter/Intervention/EquipmentInterventionResourceAdapter.php +++ b/src/Equipment/Infrastructure/Adapter/Intervention/EquipmentInterventionResourceAdapter.php @@ -53,7 +53,7 @@ * * @var list */ - private const PATCHABLE_FIELDS = ['type', 'subType', 'brand', 'model', 'serialNumber', 'locationLabel', 'status', 'facility', 'planPosition']; + private const PATCHABLE_FIELDS = ['type', 'subType', 'brand', 'model', 'serialNumber', 'locationLabel', 'status', 'facility', 'planPosition', 'name', 'assetCode', 'criticality', 'technicalProperties']; /** * Constant STATUSES. @@ -104,6 +104,7 @@ public function __construct( private EquipmentMaintenanceLogSynchronizerPort $maintenanceLogSynchronizer, private \Equipment\Application\Port\Outbound\EquipmentFloorPlanValidationPort $floorPlans, ?FacilityLifecycleReferencePort $retainedReferences = null, + private ?\Equipment\Application\Port\Outbound\EquipmentTypeCatalogPort $typeCatalog = null, ) { $this->publicationFacilities = new EquipmentPublicationFacilityValidator($facilityValidation, $retainedReferences); } @@ -370,6 +371,7 @@ public function publishDrafts(string $interventionId): void ]); foreach ($records as $record) { + $this->typeCatalog?->validateAvailableType($this->organizationId($record->organization), $record->type); if (null !== $record->facilityId) { $this->publicationFacilities->assertReference($this->organizationId($record->organization), $record->facilityId, $record->status); } @@ -448,6 +450,7 @@ private function applyScalarFields(EquipmentRecord $record, array $patch): void if (!is_string($type) || '' === $type) { throw new InterventionConflictException('Equipment type cannot be empty.'); } + $this->typeCatalog?->validateAvailableType($this->organizationId($record->organization), $type, $record->type); $record->type = $type; } @@ -468,6 +471,33 @@ private function applyScalarFields(EquipmentRecord $record, array $patch): void } $record->status = $status; } + $identityFields = ['name', 'assetCode', 'criticality']; + foreach ($identityFields as $field) { + if (array_key_exists($field, $patch) && null !== $patch[$field] && !is_string($patch[$field])) { + throw new InterventionConflictException('Equipment identity fields must be strings or null.'); + } + } + if (array_key_exists('technicalProperties', $patch) && !is_array($patch['technicalProperties'])) { + throw new InterventionConflictException('Equipment technicalProperties must be a list.'); + } + $properties = $record->technicalProperties; + if (array_key_exists('technicalProperties', $patch)) { + $candidate = $patch['technicalProperties']; + if (!is_array($candidate)) { + throw new InterventionConflictException('Equipment technicalProperties must be a list.'); + } + $properties = $candidate; + } + $identity = \Equipment\Domain\ValueObject\EquipmentIdentity::fromValues( + self::identityText($patch, 'name', $record->name), + self::identityText($patch, 'assetCode', $record->assetCode), + self::identityText($patch, 'criticality', $record->criticality), + $properties, + ); + $record->name = $identity->name; + $record->assetCode = $identity->assetCode; + $record->criticality = $identity->criticality; + $record->technicalProperties = $identity->technicalProperties; } /** @@ -499,6 +529,24 @@ private function applyFacility(EquipmentRecord $record, string $organizationId, $record->facilityId = $facilityId; } + /** + * @since 1.1.0 + * + * @param array $patch submitted changes + */ + private static function identityText(array $patch, string $key, ?string $current): ?string + { + if (!array_key_exists($key, $patch)) { + return $current; + } + $value = $patch[$key]; + if (null !== $value && !is_string($value)) { + throw new InterventionConflictException('Equipment identity fields must be strings or null.'); + } + + return $value; + } + /** * @param array $patch */ diff --git a/src/Equipment/Infrastructure/Adapter/Intervention/EquipmentReplacementLookupAdapter.php b/src/Equipment/Infrastructure/Adapter/Intervention/EquipmentReplacementLookupAdapter.php new file mode 100644 index 000000000..003112146 --- /dev/null +++ b/src/Equipment/Infrastructure/Adapter/Intervention/EquipmentReplacementLookupAdapter.php @@ -0,0 +1,61 @@ +entityManager->getConnection()->fetchOne( + 'SELECT COUNT(*) FROM equipment predecessor INNER JOIN equipment successor ON successor.id = predecessor.successor_equipment_id AND successor.predecessor_equipment_id = predecessor.id WHERE predecessor.organization_id = :organization AND successor.organization_id = :organization AND predecessor.id = :predecessor AND successor.id = :successor AND predecessor.record_status = :published AND successor.record_status = :published AND predecessor.status = :retired', + [ + 'organization' => $organizationId, 'predecessor' => $originalEquipmentId, + 'successor' => $successorEquipmentId, 'published' => 'published', 'retired' => 'decommissioned', + ], + ); + + return is_numeric($count) && (int) $count > 0; + } + // #endregion +} diff --git a/src/Equipment/Infrastructure/Adapter/Intervention/InterventionEquipmentSnapshotAdapter.php b/src/Equipment/Infrastructure/Adapter/Intervention/InterventionEquipmentSnapshotAdapter.php new file mode 100644 index 000000000..87054d523 --- /dev/null +++ b/src/Equipment/Infrastructure/Adapter/Intervention/InterventionEquipmentSnapshotAdapter.php @@ -0,0 +1,125 @@ + $equipmentIds bounded asset identifiers + * + * @return array identities indexed by identifier + */ + public function snapshots(string $organizationId, array $equipmentIds): array + { + $equipmentIds = $this->bounded($equipmentIds); + if ([] === $equipmentIds) { + return []; + } + /** @var list $rows */ + $rows = $this->entityManager->getConnection()->fetchAllAssociative("SELECT id, name, asset_code, type, brand, model, serial_number, facility_id FROM equipment WHERE organization_id = :organization AND record_status = 'published' AND id IN (:ids) ORDER BY id", ['organization' => $organizationId, 'ids' => $equipmentIds], ['ids' => ArrayParameterType::STRING]); + $locations = []; + $result = []; + foreach ($rows as $row) { + $facilityId = $row['facility_id']; + if (null !== $facilityId && !array_key_exists($facilityId, $locations)) { + $locations[$facilityId] = $this->sites->find($organizationId, null, $facilityId); + } + $location = null === $facilityId ? null : $locations[$facilityId]; + $result[$row['id']] = new InterventionEquipmentSnapshot($row['id'], $row['name'], $row['asset_code'], $row['type'], $row['brand'], $row['model'], $row['serial_number'], $facilityId, null === $location ? null : ['id' => $location->id, 'name' => $location->name], $location?->customer); + } + + return $result; + } + + /** + * Method equipmentIdsInFacilities + * + * Refuses an oversized live target scope rather than scanning or truncating silently. + * + * @access public + * + * @param string $organizationId owning organization + * @param list $facilityIds bounded location identifiers + * + * @return list same-organization published asset identifiers + */ + public function equipmentIdsInFacilities(string $organizationId, array $facilityIds): array + { + $facilityIds = $this->bounded($facilityIds); + if ([] === $facilityIds) { + return []; + } + /** @var list $ids */ + $ids = $this->entityManager->getConnection()->fetchFirstColumn("SELECT id FROM equipment WHERE organization_id = :organization AND record_status = 'published' AND facility_id IN (:facilities) ORDER BY id LIMIT 10001", ['organization' => $organizationId, 'facilities' => $facilityIds], ['facilities' => ArrayParameterType::STRING]); + + return $this->bounded($ids); + } + + /** + * Method bounded + * + * Prevents unbounded reference queries while preserving unique source identity. + * + * @access private + * + * @param list $ids requested identifiers + * + * @return list unique bounded identifiers + */ + private function bounded(array $ids): array + { + $ids = array_values(array_unique($ids)); + if (count($ids) > 10000) { + throw new InterventionFactsScopeTooLarge('The equipment identity scope exceeds 10000 records; narrow the requested scope.'); + } + + return $ids; + } + // #endregion +} diff --git a/src/Equipment/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportEquipmentIdentityAdapter.php b/src/Equipment/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportEquipmentIdentityAdapter.php new file mode 100644 index 000000000..63b6c2816 --- /dev/null +++ b/src/Equipment/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportEquipmentIdentityAdapter.php @@ -0,0 +1,63 @@ +entityManager->getConnection()->fetchOne( + "SELECT 1 FROM equipment WHERE organization_id = :organization AND id = :resource AND record_status = 'published' LIMIT 1", + ['organization' => $organizationId, 'resource' => $resourceId], + ); + } + // #endregion +} diff --git a/src/Equipment/Infrastructure/Adapter/ServiceRequest/ServiceRequestEquipmentTargetAdapter.php b/src/Equipment/Infrastructure/Adapter/ServiceRequest/ServiceRequestEquipmentTargetAdapter.php new file mode 100644 index 000000000..a47ca99aa --- /dev/null +++ b/src/Equipment/Infrastructure/Adapter/ServiceRequest/ServiceRequestEquipmentTargetAdapter.php @@ -0,0 +1,30 @@ +entityManager->createQueryBuilder()->select('equipment')->from(EquipmentRecord::class, 'equipment')->where('equipment.id=:id')->andWhere('IDENTITY(equipment.organization)=:organization')->andWhere("equipment.recordStatus='published'")->setParameter('id', $equipmentId)->setParameter('organization', $organizationId)->getQuery()->setHint(Query::HINT_REFRESH, true); + if ($this->entityManager->getConnection()->isTransactionActive()) { + $query->setLockMode(LockMode::PESSIMISTIC_WRITE); + } + $record = $query->getOneOrNullResult(); + + return $record instanceof EquipmentRecord ? new ServiceRequestEquipmentTarget($record->id, $record->name, $record->assetCode, $record->status, $record->facilityId) : null; + } +} diff --git a/src/Equipment/Infrastructure/EventSubscriber/EquipmentChangeOutboxListener.php b/src/Equipment/Infrastructure/EventSubscriber/EquipmentChangeOutboxListener.php index d2bc8c371..3bbd8fb0a 100644 --- a/src/Equipment/Infrastructure/EventSubscriber/EquipmentChangeOutboxListener.php +++ b/src/Equipment/Infrastructure/EventSubscriber/EquipmentChangeOutboxListener.php @@ -94,7 +94,7 @@ public function postUpdate(PostUpdateEventArgs $args): void return; } $changes = $args->getObjectManager()->getUnitOfWork()->getEntityChangeSet($record); - if ([] !== array_intersect(['type', 'facilityId', 'status', 'recordStatus'], array_keys($changes))) { + if ([] !== array_intersect(['type', 'facilityId', 'status', 'recordStatus', 'name', 'assetCode', 'criticality', 'technicalProperties', 'predecessorEquipmentId', 'successorEquipmentId'], array_keys($changes))) { $this->enqueue($record, 'published' === ($changes['recordStatus'][0] ?? null)); } } diff --git a/src/Equipment/Infrastructure/Persistence/Doctrine/Mapper/CanonicalEquipmentMapper.php b/src/Equipment/Infrastructure/Persistence/Doctrine/Mapper/CanonicalEquipmentMapper.php index f209e94b7..da849d5ac 100644 --- a/src/Equipment/Infrastructure/Persistence/Doctrine/Mapper/CanonicalEquipmentMapper.php +++ b/src/Equipment/Infrastructure/Persistence/Doctrine/Mapper/CanonicalEquipmentMapper.php @@ -5,7 +5,7 @@ namespace Equipment\Infrastructure\Persistence\Doctrine\Mapper; use Equipment\Domain\Model\Equipment\{CanonicalEquipment, RestoredCanonicalEquipmentLifecycle, RestoredCanonicalEquipmentMetadata}; -use Equipment\Domain\ValueObject\EquipmentCatalogDetails; +use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, EquipmentIdentity}; use Equipment\Domain\ValueObject\{EquipmentId, EquipmentOrganizationId, EquipmentRecordStatus, EquipmentStatus}; use Equipment\Infrastructure\Persistence\Doctrine\Record\EquipmentRecord; use LogicException; @@ -54,6 +54,7 @@ public static function toDomain(EquipmentRecord $record): CanonicalEquipment model: $record->model, serialNumber: $record->serialNumber, locationLabel: $record->locationLabel, + identity: EquipmentIdentity::fromValues($record->name, $record->assetCode, $record->criticality, $record->technicalProperties), ), ), lifecycle: new RestoredCanonicalEquipmentLifecycle( @@ -91,6 +92,10 @@ public static function applyTo(CanonicalEquipment $equipment, EquipmentRecord $r $record->model = $equipment->model(); $record->serialNumber = $equipment->serialNumber(); $record->locationLabel = $equipment->locationLabel(); + $record->name = $equipment->identity()->name; + $record->assetCode = $equipment->identity()->assetCode; + $record->criticality = $equipment->identity()->criticality; + $record->technicalProperties = $equipment->identity()->technicalProperties; $record->status = $equipment->status()->value; $record->commissionedAt = $equipment->commissionedAt(); // Doctrine increments and compares the persisted version in the same UPDATE. diff --git a/src/Equipment/Infrastructure/Persistence/Doctrine/Mapper/EquipmentMapper.php b/src/Equipment/Infrastructure/Persistence/Doctrine/Mapper/EquipmentMapper.php index b54701886..3a0e2e619 100644 --- a/src/Equipment/Infrastructure/Persistence/Doctrine/Mapper/EquipmentMapper.php +++ b/src/Equipment/Infrastructure/Persistence/Doctrine/Mapper/EquipmentMapper.php @@ -8,9 +8,10 @@ use Equipment\Domain\ValueObject\{ EquipmentFacilityId, EquipmentId, + EquipmentIdentity, EquipmentOrganizationId, EquipmentStatus, - EquipmentType, + EquipmentTypeCode, PlanPosition }; use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, RestoredEquipmentAssignment}; @@ -49,13 +50,14 @@ public static function toDomain(EquipmentRecord $record): Equipment return Equipment::reconstitute( id: EquipmentId::fromString($record->id), organizationId: EquipmentOrganizationId::fromString($record->organization->id), - type: EquipmentType::from($record->type), + type: EquipmentTypeCode::fromString($record->type), details: new EquipmentCatalogDetails( subType: $record->subType, brand: $record->brand, model: $record->model, serialNumber: $record->serialNumber, locationLabel: $record->locationLabel, + identity: EquipmentIdentity::fromValues($record->name, $record->assetCode, $record->criticality, $record->technicalProperties), ), assignment: new RestoredEquipmentAssignment( status: EquipmentStatus::from($record->status), @@ -66,6 +68,8 @@ public static function toDomain(EquipmentRecord $record): Equipment ), createdAt: $record->createdAt, updatedAt: $record->updatedAt, + predecessorEquipmentId: $record->predecessorEquipmentId, + successorEquipmentId: $record->successorEquipmentId, ); } @@ -91,6 +95,10 @@ public static function toRecord(Equipment $equipment): EquipmentRecord $record->model = $equipment->model(); $record->serialNumber = $equipment->serialNumber(); $record->locationLabel = $equipment->locationLabel(); + $record->name = $equipment->identity()->name; + $record->assetCode = $equipment->identity()->assetCode; + $record->criticality = $equipment->identity()->criticality; + $record->technicalProperties = $equipment->identity()->technicalProperties; $record->status = $equipment->status()->value; $record->installedAt = $equipment->installedAt(); $record->commissionedAt = $equipment->commissionedAt(); diff --git a/src/Equipment/Infrastructure/Persistence/Doctrine/Record/EquipmentRecord.php b/src/Equipment/Infrastructure/Persistence/Doctrine/Record/EquipmentRecord.php index 735d5ccd7..bd7b5666b 100644 --- a/src/Equipment/Infrastructure/Persistence/Doctrine/Record/EquipmentRecord.php +++ b/src/Equipment/Infrastructure/Persistence/Doctrine/Record/EquipmentRecord.php @@ -29,6 +29,9 @@ #[ORM\Index(name: 'idx_equipment_organization_created_at', columns: ['organization_id', 'created_at'])] #[ORM\Index(name: 'idx_equipment_intervention_record_status', columns: ['intervention_id', 'record_status'])] #[ORM\UniqueConstraint(name: 'uniq_equipment_organization_serial', columns: ['organization_id', 'serial_number'])] +#[ORM\UniqueConstraint(name: 'uniq_equipment_organization_asset_code', columns: ['organization_id', 'asset_code'])] +#[ORM\UniqueConstraint(name: 'uniq_equipment_predecessor', columns: ['predecessor_equipment_id'])] +#[ORM\UniqueConstraint(name: 'uniq_equipment_successor', columns: ['successor_equipment_id'])] class EquipmentRecord { // #region Properties @@ -139,6 +142,44 @@ class EquipmentRecord #[ORM\Column(name: 'location_label', type: 'string', length: 255, nullable: true)] public ?string $locationLabel = null; + /** + * @since 1.1.0 + */ + #[ORM\Column(type: 'string', length: 255, nullable: true)] + public ?string $name = null; + + /** + * @since 1.1.0 + */ + #[ORM\Column(name: 'asset_code', type: 'string', length: 100, nullable: true)] + public ?string $assetCode = null; + + /** + * @since 1.1.0 + */ + #[ORM\Column(type: 'string', length: 16, nullable: true)] + public ?string $criticality = null; + + /** + * @since 1.1.0 + * + * @var list + */ + #[ORM\Column(name: 'technical_properties', type: 'json', options: ['default' => '[]'])] + public array $technicalProperties = []; + + /** + * @since 1.1.0 + */ + #[ORM\Column(name: 'predecessor_equipment_id', type: 'string', length: 36, nullable: true)] + public ?string $predecessorEquipmentId = null; + + /** + * @since 1.1.0 + */ + #[ORM\Column(name: 'successor_equipment_id', type: 'string', length: 36, nullable: true)] + public ?string $successorEquipmentId = null; + /** * Property status. * diff --git a/src/Equipment/Infrastructure/Persistence/Doctrine/Record/EquipmentReplacementReceiptRecord.php b/src/Equipment/Infrastructure/Persistence/Doctrine/Record/EquipmentReplacementReceiptRecord.php new file mode 100644 index 000000000..daf718090 --- /dev/null +++ b/src/Equipment/Infrastructure/Persistence/Doctrine/Record/EquipmentReplacementReceiptRecord.php @@ -0,0 +1,60 @@ +entityManager->flush(); + + try { + $this->entityManager->flush(); + } catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $error) { + if (str_contains(strtolower($error->getMessage()), 'uniq_equipment_organization_asset_code')) { + throw \Equipment\Domain\Exception\EquipmentAssetCodeAlreadyExistsException::withAssetCode($equipment->identity()->assetCode ?? ''); + } + + throw $error; + } } /** diff --git a/src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentReplacementRepository.php b/src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentReplacementRepository.php new file mode 100644 index 000000000..f21f70555 --- /dev/null +++ b/src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentReplacementRepository.php @@ -0,0 +1,159 @@ +entityManager->getConnection()->executeQuery( + 'SELECT pg_advisory_xact_lock(hashtextextended(:identity, 0))', + ['identity' => 'equipment-replacement:' . $organizationId . ':' . $clientOperationId], + ); + $record = $this->entityManager->find(EquipmentReplacementReceiptRecord::class, [ + 'organizationId' => $organizationId, 'clientOperationId' => $clientOperationId, + ]); + + return $record instanceof EquipmentReplacementReceiptRecord ? new EquipmentReplacementReceipt( + $record->organizationId, + $record->clientOperationId, + $record->predecessorEquipmentId, + $record->successorEquipmentId, + $record->payloadHash, + ) : null; + } + + /** + * Method lockEquipment + * + * @access public + * + * @param string $organizationId the owner + * @param list $equipmentIds the identities to lock + * + * @return array current owner-scoped candidates + */ + public function lockEquipment(string $organizationId, array $equipmentIds): array + { + $equipmentIds = array_unique($equipmentIds); + sort($equipmentIds); + $query = $this->entityManager->createQueryBuilder() + ->select('equipment')->from(EquipmentRecord::class, 'equipment') + ->where('IDENTITY(equipment.organization) = :organization') + ->andWhere('equipment.id IN (:ids)') + ->orderBy('equipment.id', 'ASC') + ->setParameter('organization', $organizationId) + ->setParameter('ids', $equipmentIds) + ->getQuery()->setLockMode(LockMode::PESSIMISTIC_WRITE) + ->setHint(Query::HINT_REFRESH, true); + + /** @var list $records */ + $records = $query->getResult(); + $candidates = []; + foreach ($records as $record) { + $candidates[$record->id] = new ReplacementEquipment( + $record->id, + $organizationId, + $record->recordStatus, + $record->status, + $record->facilityId, + $record->locationLabel, + $record->planPosition, + $record->predecessorEquipmentId, + $record->successorEquipmentId, + ); + } + + return $candidates; + } + + /** + * Method save + * + * @access public + * + * @param EquipmentReplacement $replacement the validated replacement + * @param EquipmentReplacementReceipt $receipt the durable replay record + * + * @return void + */ + public function save(EquipmentReplacement $replacement, EquipmentReplacementReceipt $receipt): void + { + $predecessor = $this->entityManager->find(EquipmentRecord::class, $replacement->predecessor->id); + $successor = $this->entityManager->find(EquipmentRecord::class, $replacement->successor->id); + if (!$predecessor instanceof EquipmentRecord || !$successor instanceof EquipmentRecord) { + throw new LogicException('Locked replacement equipment is unavailable.'); + } + $now = new DateTimeImmutable(); + $predecessor->status = 'decommissioned'; + $predecessor->successorEquipmentId = $successor->id; + $predecessor->updatedAt = $now; + $successor->predecessorEquipmentId = $predecessor->id; + $successor->facilityId = $predecessor->facilityId; + $successor->locationLabel = $predecessor->locationLabel; + $successor->planPosition = $predecessor->planPosition; + $successor->status = $replacement->successorStatus(); + $successor->updatedAt = $now; + if ('operational' === $successor->status) { + $successor->commissionedAt ??= $now; + $successor->installedAt ??= $now; + } + $record = new EquipmentReplacementReceiptRecord(); + $record->organizationId = $receipt->organizationId; + $record->clientOperationId = $receipt->clientOperationId; + $record->predecessorEquipmentId = $receipt->predecessorEquipmentId; + $record->successorEquipmentId = $receipt->successorEquipmentId; + $record->payloadHash = $receipt->payloadHash; + $record->createdAt = $now; + $this->entityManager->persist($record); + $this->entityManager->flush(); + } + // #endregion +} diff --git a/src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepository.php b/src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepository.php index 1cf08890c..60a779263 100644 --- a/src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepository.php +++ b/src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepository.php @@ -112,6 +112,10 @@ public function save(Equipment $equipment): void $existing->model = $record->model; $existing->serialNumber = $record->serialNumber; $existing->locationLabel = $record->locationLabel; + $existing->name = $record->name; + $existing->assetCode = $record->assetCode; + $existing->criticality = $record->criticality; + $existing->technicalProperties = $record->technicalProperties; $existing->status = $record->status; $existing->installedAt = $record->installedAt; $existing->commissionedAt = $record->commissionedAt; @@ -124,6 +128,9 @@ public function save(Equipment $equipment): void try { $this->entityManager->flush(); } catch (Throwable $exception) { + if ($exception instanceof UniqueConstraintViolationException && str_contains(strtolower($exception->getMessage()), 'uniq_equipment_organization_asset_code')) { + throw \Equipment\Domain\Exception\EquipmentAssetCodeAlreadyExistsException::withAssetCode($equipment->identity()->assetCode ?? ''); + } if ($this->isDuplicateSerialNumberViolation($exception)) { throw EquipmentSerialNumberAlreadyExistsException::withSerialNumber($equipment->serialNumber() ?? ''); } @@ -132,6 +139,26 @@ public function save(Equipment $equipment): void } } + /** + * @since 1.1.0 + * + * @param list $equipmentIds candidate identifiers + * + * @return list + */ + public function findPublishedIdsMatching(EquipmentOrganizationId $organizationId, EquipmentListCriteria $criteria, array $equipmentIds): array + { + if ([] === $equipmentIds) { + return []; + } + /** @var list $rows */ + $rows = $this->createListQueryBuilder($organizationId, $criteria)->select('e.id AS id') + ->andWhere('e.id IN (:candidates)')->setParameter('candidates', $equipmentIds) + ->orderBy('e.id', 'ASC')->getQuery()->getArrayResult(); + + return array_map(static fn (array $row): string => $row['id'], $rows); + } + /** * Method findById. * @@ -653,6 +680,13 @@ private function createListQueryBuilder( ->andWhere('e.type = :type') ->setParameter('type', $criteria->type); } + if (null !== $criteria->typeCodes) { + if ([] === $criteria->typeCodes) { + $queryBuilder->andWhere('1 = 0'); + } else { + $queryBuilder->andWhere('e.type IN (:typeCodes)')->setParameter('typeCodes', $criteria->typeCodes); + } + } if (null !== $criteria->status) { $queryBuilder @@ -683,6 +717,8 @@ private function createListQueryBuilder( 'search', $criteria->search, 'e.type', + 'e.name', + 'e.assetCode', 'e.subType', 'e.brand', 'e.model', diff --git a/src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentTypeCatalogRepository.php b/src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentTypeCatalogRepository.php new file mode 100644 index 000000000..523c86738 --- /dev/null +++ b/src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentTypeCatalogRepository.php @@ -0,0 +1,192 @@ + defaults, overrides and historical archived codes + */ + public function list(string $organizationId): array + { + $definitions = []; + foreach (EquipmentType::cases() as $type) { + $family = match ($type) { + EquipmentType::EMERGENCY_LIGHTING, EquipmentType::ACCESS_CONTROL, EquipmentType::CAMERA, EquipmentType::GAS_DETECTOR => 'safety', + EquipmentType::OTHER => 'other', + default => 'fire', + }; + $definitions[$type->value] = new EquipmentTypeDefinition($type->value, $type->label(), $family); + } + + $connection = $this->entityManager->getConnection(); + /** @var list $legacy */ + $legacy = $connection->fetchFirstColumn('SELECT DISTINCT type FROM equipment WHERE organization_id = :organization', ['organization' => $organizationId]); + foreach ($legacy as $value) { + $code = (string) $value; + if ('' !== trim($code) && !isset($definitions[$code])) { + $definitions[$code] = new EquipmentTypeDefinition($code, $code, 'other', true); + } + } + + /** @var list $rows */ + $rows = $connection->fetchAllAssociative('SELECT type_code, label, family, archived, revision FROM equipment_type_catalog WHERE organization_id = :organization', ['organization' => $organizationId]); + foreach ($rows as $row) { + $code = (string) $row['type_code']; + $definitions[$code] = new EquipmentTypeDefinition( + $code, + (string) $row['label'], + (string) $row['family'], + in_array($row['archived'], [true, 1, '1', 't', 'true'], true), + (int) $row['revision'], + ); + } + ksort($definitions); + + return array_values($definitions); + } + + /** + * Method find. + * + * @access public + * + * @param string $organizationId owning organization + * @param string $typeCode stable type code + * + * @return EquipmentTypeDefinition|null resolved descriptor + */ + public function find(string $organizationId, string $typeCode): ?EquipmentTypeDefinition + { + $connection = $this->entityManager->getConnection(); + /** @var array{type_code: string, label: string, family: string, archived: bool|int|string, revision: int|string}|false $row */ + $row = $connection->fetchAssociative('SELECT type_code, label, family, archived, revision FROM equipment_type_catalog WHERE organization_id = :organization AND type_code = :code', ['organization' => $organizationId, 'code' => $typeCode]); + if (false !== $row) { + return new EquipmentTypeDefinition( + $row['type_code'], + $row['label'], + $row['family'], + in_array($row['archived'], [true, 1, '1', 't', 'true'], true), + (int) $row['revision'], + ); + } + + $type = EquipmentType::tryFrom($typeCode); + if (null !== $type) { + $family = match ($type) { + EquipmentType::EMERGENCY_LIGHTING, EquipmentType::ACCESS_CONTROL, EquipmentType::CAMERA, EquipmentType::GAS_DETECTOR => 'safety', + EquipmentType::OTHER => 'other', + default => 'fire', + }; + + return new EquipmentTypeDefinition($type->value, $type->label(), $family); + } + + if ('' !== trim($typeCode) && false !== $connection->fetchOne('SELECT 1 FROM equipment WHERE organization_id = :organization AND type = :code LIMIT 1', ['organization' => $organizationId, 'code' => $typeCode])) { + return new EquipmentTypeDefinition($typeCode, $typeCode, 'other', true); + } + + return null; + } + + /** + * Method save. + * + * @access public + * + * @param string $organizationId owning organization + * @param EquipmentTypeDefinition $definition descriptor to store + * @param int|null $expectedRevision null for create + * + * @return void + */ + public function save(string $organizationId, EquipmentTypeDefinition $definition, ?int $expectedRevision): void + { + $connection = $this->entityManager->getConnection(); + $connection->transactional(function (Connection $connection) use ($organizationId, $definition, $expectedRevision): void { + $connection->executeStatement('SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))', ['key' => 'equipment-type-catalog:' . $organizationId]); + $current = $this->find($organizationId, $definition->value); + if (null === $expectedRevision && null !== $current) { + throw new EquipmentTypeCatalogException('equipment_type_exists', 'An equipment type with this code already exists.'); + } + if (null !== $expectedRevision && (null === $current || $current->revision !== $expectedRevision)) { + throw new EquipmentTypeCatalogException('equipment_type_revision_conflict', 'The equipment type changed. Reload before editing.'); + } + $connection->executeStatement( + 'INSERT INTO equipment_type_catalog (organization_id, type_code, label, family, archived, revision) VALUES (:organization, :code, :label, :family, :archived, :revision) ON CONFLICT (organization_id, type_code) DO UPDATE SET label = EXCLUDED.label, family = EXCLUDED.family, archived = EXCLUDED.archived, revision = EXCLUDED.revision', + ['organization' => $organizationId, 'code' => $definition->value, 'label' => $definition->label, 'family' => $definition->family, 'archived' => $definition->archived, 'revision' => $definition->revision], + ['archived' => \Doctrine\DBAL\ParameterType::BOOLEAN], + ); + }); + } + + /** + * Method validateAvailableType. + * + * @access public + * + * @param string $organizationId owning organization + * @param string $type candidate type + * @param string|null $existingType current persisted type + * + * @return void + */ + public function validateAvailableType(string $organizationId, string $type, ?string $existingType = null): void + { + if ($existingType === $type) { + return; + } + + $definition = $this->find($organizationId, $type); + if (null === $definition) { + throw new EquipmentTypeCatalogException('equipment_type_unknown', 'This equipment type is not in the organization catalog.'); + } + + $definition->assertAvailable($existingType); + } + // #endregion +} diff --git a/src/Equipment/MODULE.md b/src/Equipment/MODULE.md index 773b541dd..d70b10956 100644 --- a/src/Equipment/MODULE.md +++ b/src/Equipment/MODULE.md @@ -4,6 +4,17 @@ ## Overview +V3's public `InterventionEquipmentSnapshotPort` bridge supplies only published, +same-organization equipment identity to authorized operational and financial callers. +It preserves retired assets, archived catalog codes and the actual declared label, +asset reference, brand, model, serial and facility. The facility owner's public +hierarchy reader supplies root site and optional internal client `{id,name}`, including +archived published locations. No client contacts cross the bridge. Batches and live +facility scopes are bounded to 10000 records; missing, foreign or draft assets are +omitted and oversized scopes fail explicitly. Intervention and private cost owners +freeze these values in future snapshots; this reader never rewrites historical +snapshots or guesses missing legacy identities. + ### Atomic creation and exact place counts (2026-10-03) Creation, initial assignment, offline identity and intervention attachment use @@ -71,9 +82,63 @@ equipment answers 404. Storage/database failures still remove the attempt blob. | GET | `/api/organizations/{organizationId}/equipment/{equipmentId}/report` | Streams a PDF equipment sheet (identity, maintenance history, attachment index) — plan-gated, see below | | GET | `/api/organizations/{organizationId}/equipment/labels` | Streams a printable PDF sheet of QR equipment labels (Avery L7159 grid) — not plan-gated, see below | -Removed 2026-08-20: `GET /api/organizations/{organizationId}/equipment-types` and -`GET /api/organizations/{organizationId}/equipment-statuses` (unconsumed reference -catalogs; the frontend's localized typed registries are the source of these values). +`GET /api/organizations/{organizationId}/equipment-types` is the organization-owned +catalog for equipment families and custom types. It returns a collection of +`value`, `label`, `family` (`fire`, `safety`, `other`), `archived`, and `revision`. +The twelve historical codes retain their values; defaults have revision 1 and +changed descriptors have revision 2 or later. Unrecognized persisted historical +codes remain readable as archived descriptors. The status registry remains static. + +| Method | Path | Description | +| --- | --- | --- | +| GET / POST | `/api/organizations/{organizationId}/equipment-types` | Read descriptors / create a custom descriptor | +| GET / PATCH | `/api/organizations/{organizationId}/equipment-types/{typeCode}` | Read / revise label, family or archive status using the observed body revision | +| POST | `/api/organizations/{organizationId}/equipment/{equipmentId}/replace` | Atomically replace a published equipment with an existing reserve or a new successor | +| GET | `/api/organizations/{organizationId}/equipment-summary` | Exact published totals with family and customer scope, before pagination | + +Catalog reads require `organization.equipment.read`; administration and replacement +require `organization.equipment.write`. Unknown/foreign organization or asset scope +is hidden with 404; missing permission returns 403. Catalog codes are immutable. + +### Specialized parc identity and replacement + +Both organization and canonical equipment surfaces expose `name`, `assetCode`, +`criticality` (`low`, `medium`, `high`, `critical` or null), and `technicalProperties` +(at most 50 `{key, value, unit}` entries, unique nonempty keys). Asset codes are +optional, trimmed and unique within the organization. Empty identity text normalizes +to null. Canonical merge patches preserve omitted fields and can explicitly clear +optional identity text; the organization update also preserves omitted new identity. +These properties describe an asset and do not certify its condition or compliance. + +The organization and facility collections accept `family` and `customerId`. +Family includes archived types for historical reads. Customer scope is resolved from +published root sites and their published descendants through the Facility owner port; +foreign and unknown customer identifiers return the same 404. Facility summaries use +the same filters before pagination. Search covers equipment names and patrimonial codes. +Organization summaries use the same predicates and return `scope` organization/customer. +The `maintenanceDueStatus=due` query combines due-soon and overdue rows before +pagination; individual equipment outputs retain their actual due-state literal. +`EquipmentParkScopePort::filterIds` publishes scoped identifiers for peer modules; +an optional facility identifier includes its published subtree by default. Passing +`includeDescendants=false` selects only equipment directly assigned to that validated +facility, while retaining family and customer filters. This permits scoped +anomaly projections without exposing Equipment records or substituting organization totals. +Validated work-item service history can carry its actual performed date, author and +notes. Publication time and responsible member remain the fallback for historical +applied changes; work-item/change identities retain idempotent append semantics. + +Replacement accepts a stable UUID `clientOperationId` and exactly one of +`successorEquipmentId` or `successor` (ordinary creation identity fields, without +organization, facility, intervention or client-id overrides). One main transaction +locks candidates, creates a new successor with ordinary quota enforcement when +requested, retires the predecessor, establishes both history links and saves the +durable receipt. The successor inherits assignment, location and plan position and +enters service when replacing in-service equipment. Attachments, anomalies, dates, +QR identity and the predecessor's history remain attached to the original asset. +Repeated requests return the original identifiers without another creation or debit; +reusing the operation UUID with another payload returns 409. Output adds nullable +`predecessorEquipmentId` and `successorEquipmentId`. Complete reserve assets remain +individualized equipment, independently from future consumable stock quantities. An equipment may carry at most `Shared\Domain\Attachment\AttachmentConstraints::MAX_ATTACHMENTS_PER_PARENT` @@ -566,6 +631,28 @@ here through `Equipment\Infrastructure\Adapter\Facility\EquipmentPlanPositionAda ## Architecture +### Procurement reserve batches and repair requests + +Procurement individualizes physical equipment receipts through +`EquipmentReserveReceiptPort`. A batch contains one to one hundred units and +holds the normal Equipment quota lock before the first creation. Active catalog +validation and the ordinary creation use case apply to every unit. Missing +`organization.equipment.write`, an unavailable type or a quota cap return a +typed blocked outcome without creating orphan assets. Unexpected failures roll +back the complete main batch; Procurement retains its already received physical +receipt and owns the durable receipt-unit identities and replay mapping. + +Equipment reserve is individual `in_stock` equipment, distinct from Inventory's +quantitative parts. Shared batch templates cannot repeat unique asset codes or +serial numbers across multiple units. The resulting assets keep the normal +published equipment identity and are assigned to sites later. + +Repair requests read their target through the published ServiceRequest target +port. The Equipment adapter hides unknown, foreign and draft equipment. Inside +an active main transaction it locks and refreshes the Equipment row so retirement +and request conversion use current lifecycle state. ServiceRequest owns the +site hierarchy lock and the refusal of retired targets. + - Presentation: Api Platform resources, providers, processors, DTOs. - Application: Use cases (command/query), repository ports. - Domain: Equipment aggregate, Tag, EquipmentAttachment, value objects, domain exceptions. @@ -604,28 +691,30 @@ Cross-module contracts and lifecycle invariants: plan-overlay read. See `src/Facility/MODULE.md` for the full pairing, including the one file allowed to import both modules' Domain layers to satisfy the port's typed `@throws` contract. -- **Per-equipment maintenance due status (L2.10)**: `EquipmentOutput.maintenanceDueStatus` - (`GET .../equipment` and `GET .../equipment/{id}`) and the `maintenanceDueStatus` - list filter are resolved cross-module through the new - `Equipment\Application\Port\Outbound\MaintenanceDueStatusPort` (declared - here; Equipment references only this port, never Maintenance's Domain or - Infrastructure). Its adapter, - `Maintenance\Infrastructure\Adapter\Equipment\EquipmentMaintenanceDueStatusAdapter`, - is hosted in — and wired by — the Maintenance module (the module owning the - `maintenance_schedules` read model), mirroring the existing reverse - direction (`MaintenanceEquipmentDirectoryPort`, implemented here for - Maintenance). Batching: `GetEquipmentHandler` and `ListEquipmentsHandler` - each resolve the whole batch of equipment ids they need in ONE call to - `dueStatusesForEquipment()` — never per row. Equipment ids with no - maintenance schedule (never reconciled by the Maintenance sweep yet, or - genuinely untracked) come back as `unscheduled`, never absent, never null. +- **Independent control and service deadlines (V1, extending L2.10)**: + detail, organization collections and canonical collections expose + `controlDueStatus`, `serviceDueStatus`, `controlNextDueAt` and + `serviceNextDueAt`. Status values remain + `unscheduled`|`up_to_date`|`due_soon`|`overdue`; dates are ISO 8601 when a + deadline exists. Neither due status changes the operational condition or + resolves an anomaly. `maintenanceDueStatus` is a compatibility alias of + `controlDueStatus`, including on the filtered equipment collection. + `Maintenance\Application\Port\Inbound\MaintenanceOperationsDuePort` + publishes the organization-scoped bulk projection through its Application + contract; Equipment never reads Maintenance's Domain or persistence. + Get, List and the canonical collection call it once per batch, not per row. + When that optional owner port is unavailable, the existing + `MaintenanceDueStatusPort::dueStatusesForEquipment()` provides control + statuses and service remains `unscheduled`. The compatibility adapter and + new owner alias are wired by Maintenance in `config/modules/maintenance.yaml`. + Unscheduled or suspended equipment has no deadline. The `maintenanceDueStatus` filter cannot be pushed into the `equipment` table's SQL `WHERE` clause (the value lives in Maintenance, not here): `ListEquipmentsHandler::listFilteredByDueStatus()` instead loads every equipment matching the other filters unbounded (capped at `DUE_STATUS_FILTER_SCAN_LIMIT = 10_000`, a generous safety net given organizations are plan-quota bounded on equipment count — not a practical - limit), resolves due status for that whole candidate set in one batch call, + limit), resolves control due status for that whole candidate set in one batch call, filters in memory, then paginates with `array_slice()`. **Do not assume a per-equipment "Non-conformity" status exists.** It does not: non-conformities attach to _inspections_ (see `src/Inspection/MODULE.md`), never to equipment. @@ -857,6 +946,17 @@ names the write path has no reason to carry). ## Configuration +Maintenance exports validate published equipment identities through the owner-hosted +`MaintenanceExportEquipmentIdentityAdapter`, explicitly wired to main and tagged +`maintenance_export.identity_validator`. Retired equipment remains a valid historical +reference; drafts, missing assets and foreign identities return false. + +The main mappings include `EquipmentTypeCatalogRecord` and +`EquipmentReplacementReceiptRecord`. `EquipmentTypeCatalogPort` and +`EquipmentReplacementRepositoryPort` have explicitly main-wired repositories. +`EquipmentSelectionScopeResolver` composes the catalog, Customer lookup and +Facility customer scope ports. No identity or replacement migration touches auth. + - Service wiring: `config/modules/equipment.yaml` - `CanonicalEquipmentRepositoryPort` is aliased to `CanonicalEquipmentRepository`, wired to `main` explicitly. It is a **second** port over the `equipment` @@ -900,6 +1000,11 @@ Detailed cases and regression rationale are retained in the [Equipment testing r ## Error Codes +- `equipment_asset_code_conflict` → 409, patrimonial code already used in the organization. +- `equipment_replacement_conflict` → 409, incompatible lifecycle, successor or replay payload. +- `equipment_type_exists` → 409; `equipment_type_revision_conflict` → 412. +- `equipment_type_not_found` → 404; `equipment_type_invalid`, `equipment_type_unknown`, `equipment_type_archived` → 422. + - `EquipmentNotFoundException` → 404 - `EquipmentSerialNumberAlreadyExistsException` → 409 - `EquipmentAlreadyDecommissionedException` → 409 (processors map it via `ConflictHttpException`; diff --git a/src/Equipment/Presentation/Api/Controller/ExportEquipmentReportController.php b/src/Equipment/Presentation/Api/Controller/ExportEquipmentReportController.php index 909d96814..3f51c30a6 100644 --- a/src/Equipment/Presentation/Api/Controller/ExportEquipmentReportController.php +++ b/src/Equipment/Presentation/Api/Controller/ExportEquipmentReportController.php @@ -231,6 +231,12 @@ private function buildContext( ): array { return [ 'equipmentId' => $equipment->equipmentId, + 'name' => $equipment->name, + 'assetCode' => $equipment->assetCode, + 'criticality' => $equipment->criticality, + 'technicalProperties' => $equipment->technicalProperties, + 'predecessorEquipmentId' => $equipment->predecessorEquipmentId, + 'successorEquipmentId' => $equipment->successorEquipmentId, 'type' => $equipment->type, 'subType' => $equipment->subType, 'brand' => $equipment->brand, @@ -242,6 +248,10 @@ private function buildContext( 'installedAt' => $equipment->installedAt, 'commissionedAt' => $equipment->commissionedAt, 'maintenanceDueStatus' => $equipment->maintenanceDueStatus, + 'controlDueStatus' => $equipment->controlDueStatus ?? $equipment->maintenanceDueStatus, + 'serviceDueStatus' => $equipment->serviceDueStatus, + 'controlNextDueAt' => $equipment->controlNextDueAt, + 'serviceNextDueAt' => $equipment->serviceNextDueAt, 'tags' => array_map(static fn (array $tag): string => (string) $tag['name'], $equipment->tags), 'createdAt' => $equipment->createdAt->format('c'), 'updatedAt' => $equipment->updatedAt->format('c'), @@ -297,7 +307,7 @@ private function localizeContext(array $context, OrganizationDocumentBranding $b $generatedAt = $context['generatedAt'] ?? null; $context['generatedAtFormatted'] = $formatter->formatDateTime(is_string($generatedAt) ? $generatedAt : null); - foreach (['installedAt', 'commissionedAt'] as $dateKey) { + foreach (['installedAt', 'commissionedAt', 'controlNextDueAt', 'serviceNextDueAt'] as $dateKey) { $value = $context[$dateKey] ?? null; $context[$dateKey] = $formatter->formatDate(is_string($value) ? $value : null); } diff --git a/src/Equipment/Presentation/Api/Dto/Input/Equipment/CreateEquipmentInput.php b/src/Equipment/Presentation/Api/Dto/Input/Equipment/CreateEquipmentInput.php index fd2b9a780..0e0c01d93 100644 --- a/src/Equipment/Presentation/Api/Dto/Input/Equipment/CreateEquipmentInput.php +++ b/src/Equipment/Presentation/Api/Dto/Input/Equipment/CreateEquipmentInput.php @@ -5,7 +5,6 @@ namespace Equipment\Presentation\Api\Dto\Input\Equipment; use ApiPlatform\Metadata\ApiProperty; -use Equipment\Domain\ValueObject\EquipmentType; use Equipment\Presentation\Api\Serialization\EquipmentSerializationGroup; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; @@ -21,6 +20,36 @@ #[Assert\Expression('this.onboardingSessionId === null or (this.intervention === null and this.clientId === null)', message: 'Onboarding setup cannot target an intervention or offline resource.')] final class CreateEquipmentInput { + /** + * @since 1.1.0 + */ + #[Assert\Length(max: 255)] + #[Groups([EquipmentSerializationGroup::WRITE])] + public ?string $name = null; + + /** + * @since 1.1.0 + */ + #[Assert\Length(max: 100)] + #[Groups([EquipmentSerializationGroup::WRITE])] + public ?string $assetCode = null; + + /** + * @since 1.1.0 + */ + #[Assert\Choice(choices: ['low', 'medium', 'high', 'critical'])] + #[Groups([EquipmentSerializationGroup::WRITE])] + public ?string $criticality = null; + + /** + * @since 1.1.0 + * + * @var list + */ + #[Assert\Count(max: 50)] + #[Groups([EquipmentSerializationGroup::WRITE])] + public array $technicalProperties = []; + /** * Property clientId. * @@ -82,7 +111,7 @@ final class CreateEquipmentInput * @since 1.0.0 */ #[Assert\NotBlank(message: 'Equipment type is required.')] - #[Assert\Choice(callback: [EquipmentType::class, 'values'])] + #[Assert\Length(max: 32)] #[Groups([EquipmentSerializationGroup::WRITE])] #[ApiProperty(description: 'Equipment type', required: true, example: 'fire_extinguisher')] public string $type = ''; diff --git a/src/Equipment/Presentation/Api/Dto/Input/Equipment/PatchCanonicalEquipmentInput.php b/src/Equipment/Presentation/Api/Dto/Input/Equipment/PatchCanonicalEquipmentInput.php index 978e7af9e..0b48e0c32 100644 --- a/src/Equipment/Presentation/Api/Dto/Input/Equipment/PatchCanonicalEquipmentInput.php +++ b/src/Equipment/Presentation/Api/Dto/Input/Equipment/PatchCanonicalEquipmentInput.php @@ -16,6 +16,32 @@ */ final class PatchCanonicalEquipmentInput { + /** + * @since 1.1.0 + */ + #[Assert\Length(max: 255)] + public ?string $name = null; + + /** + * @since 1.1.0 + */ + #[Assert\Length(max: 100)] + public ?string $assetCode = null; + + /** + * @since 1.1.0 + */ + #[Assert\Choice(choices: ['low', 'medium', 'high', 'critical'])] + public ?string $criticality = null; + + /** + * @since 1.1.0 + * + * @var list + */ + #[Assert\Count(max: 50)] + public array $technicalProperties = []; + /** * Property type. * diff --git a/src/Equipment/Presentation/Api/Dto/Input/Equipment/ReplaceEquipmentInput.php b/src/Equipment/Presentation/Api/Dto/Input/Equipment/ReplaceEquipmentInput.php new file mode 100644 index 000000000..cd97a0941 --- /dev/null +++ b/src/Equipment/Presentation/Api/Dto/Input/Equipment/ReplaceEquipmentInput.php @@ -0,0 +1,48 @@ + + */ + #[Assert\Count(max: 50)] + #[Assert\All(constraints: [ + new Assert\Collection(fields: [ + 'key' => [new Assert\NotBlank(), new Assert\Type('string'), new Assert\Length(max: 64)], + 'value' => [new Assert\Type('string'), new Assert\Length(max: 255)], + 'unit' => new Assert\Optional([new Assert\Type('string'), new Assert\Length(max: 32)]), + ]), + ])] + #[Groups([EquipmentSerializationGroup::WRITE])] + public array $technicalProperties = []; + // #endregion +} diff --git a/src/Equipment/Presentation/Api/Dto/Input/Equipment/UpdateEquipmentInput.php b/src/Equipment/Presentation/Api/Dto/Input/Equipment/UpdateEquipmentInput.php index 1cb0a4511..9b3841836 100644 --- a/src/Equipment/Presentation/Api/Dto/Input/Equipment/UpdateEquipmentInput.php +++ b/src/Equipment/Presentation/Api/Dto/Input/Equipment/UpdateEquipmentInput.php @@ -5,7 +5,6 @@ namespace Equipment\Presentation\Api\Dto\Input\Equipment; use ApiPlatform\Metadata\ApiProperty; -use Equipment\Domain\ValueObject\EquipmentType; use Equipment\Presentation\Api\Serialization\EquipmentSerializationGroup; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; @@ -20,6 +19,36 @@ */ final class UpdateEquipmentInput { + /** + * @since 1.1.0 + */ + #[Assert\Length(max: 255)] + #[Groups([EquipmentSerializationGroup::WRITE])] + public ?string $name = null; + + /** + * @since 1.1.0 + */ + #[Assert\Length(max: 100)] + #[Groups([EquipmentSerializationGroup::WRITE])] + public ?string $assetCode = null; + + /** + * @since 1.1.0 + */ + #[Assert\Choice(choices: ['low', 'medium', 'high', 'critical'])] + #[Groups([EquipmentSerializationGroup::WRITE])] + public ?string $criticality = null; + + /** + * @since 1.1.0 + * + * @var list + */ + #[Assert\Count(max: 50)] + #[Groups([EquipmentSerializationGroup::WRITE])] + public array $technicalProperties = []; + // #region Properties /** * Property type. @@ -27,7 +56,7 @@ final class UpdateEquipmentInput * @since 1.0.0 */ #[Assert\NotBlank(message: 'Equipment type is required.')] - #[Assert\Choice(callback: [EquipmentType::class, 'values'])] + #[Assert\Length(max: 32)] #[Groups([EquipmentSerializationGroup::WRITE])] #[ApiProperty(description: 'Equipment type', required: true, example: 'fire_extinguisher')] public string $type = ''; diff --git a/src/Equipment/Presentation/Api/Dto/Input/EquipmentTypeCatalog/CreateEquipmentTypeInput.php b/src/Equipment/Presentation/Api/Dto/Input/EquipmentTypeCatalog/CreateEquipmentTypeInput.php new file mode 100644 index 000000000..d7943f9b8 --- /dev/null +++ b/src/Equipment/Presentation/Api/Dto/Input/EquipmentTypeCatalog/CreateEquipmentTypeInput.php @@ -0,0 +1,44 @@ + + */ + #[Groups([EquipmentSerializationGroup::READ])] + public array $technicalProperties = []; + + /** + * @since 1.1.0 + */ + #[Groups([EquipmentSerializationGroup::READ])] + public ?string $predecessorEquipmentId = null; + + /** + * @since 1.1.0 + */ + #[Groups([EquipmentSerializationGroup::READ])] + public ?string $successorEquipmentId = null; + /** * Property intervention. * @@ -179,11 +217,7 @@ final class EquipmentOutput /** * Property maintenanceDueStatus. * - * Resolved cross-module from the Maintenance module - * (`unscheduled`|`up_to_date`|`due_soon`|`overdue`); `unscheduled` when the - * equipment has no maintenance schedule. There is no per-equipment - * equivalent of a mockup "non-conformity" state: non-conformities attach - * to inspections, not to equipment (see `src/Equipment/MODULE.md`). + * Compatibility alias of controlDueStatus, independent from service deadlines and operational condition. * * @since 1.0.0 */ @@ -191,6 +225,38 @@ final class EquipmentOutput #[ApiProperty(readable: true, writable: false)] public string $maintenanceDueStatus = 'unscheduled'; + /** + * Property controlDueStatus + * + * Control operation status: unscheduled, up_to_date, due_soon or overdue. + */ + #[Groups([EquipmentSerializationGroup::READ])] + #[ApiProperty(readable: true, writable: false, description: 'Due status of control operations, independent from service operations and anomalies.')] + public string $controlDueStatus = 'unscheduled'; + + /** + * Property serviceDueStatus + * + * Service operation status: unscheduled, up_to_date, due_soon or overdue. + */ + #[Groups([EquipmentSerializationGroup::READ])] + #[ApiProperty(readable: true, writable: false, description: 'Due status of service operations, independent from periodic controls.')] + public string $serviceDueStatus = 'unscheduled'; + + /** + * Property controlNextDueAt + */ + #[Groups([EquipmentSerializationGroup::READ])] + #[ApiProperty(readable: true, writable: false, description: 'Next control deadline in ISO 8601, when scheduled.')] + public ?string $controlNextDueAt = null; + + /** + * Property serviceNextDueAt + */ + #[Groups([EquipmentSerializationGroup::READ])] + #[ApiProperty(readable: true, writable: false, description: 'Next service deadline in ISO 8601, when scheduled.')] + public ?string $serviceNextDueAt = null; + /** * Property planPosition. * diff --git a/src/Equipment/Presentation/Api/Dto/Output/Equipment/ReplaceEquipmentOutput.php b/src/Equipment/Presentation/Api/Dto/Output/Equipment/ReplaceEquipmentOutput.php new file mode 100644 index 000000000..a14b5d814 --- /dev/null +++ b/src/Equipment/Presentation/Api/Dto/Output/Equipment/ReplaceEquipmentOutput.php @@ -0,0 +1,44 @@ +value = $type->value; + $output->label = $type->label; + $output->family = $type->family; + $output->archived = $type->archived; + $output->revision = $type->revision; + + return $output; + } + // #endregion +} diff --git a/src/Equipment/Presentation/Api/EventSubscriber/EquipmentIdentityFailureSubscriber.php b/src/Equipment/Presentation/Api/EventSubscriber/EquipmentIdentityFailureSubscriber.php new file mode 100644 index 000000000..428d8212a --- /dev/null +++ b/src/Equipment/Presentation/Api/EventSubscriber/EquipmentIdentityFailureSubscriber.php @@ -0,0 +1,49 @@ + + */ +final readonly class EquipmentIdentityFailureSubscriber implements EventSubscriberInterface +{ + /** + * @since 1.0.0 @return array + */ + public static function getSubscribedEvents(): array + { + return [KernelEvents::EXCEPTION => ['onException', 15]]; + } + + /** + * @since 1.0.0 + */ + public function onException(ExceptionEvent $event): void + { + $error = $event->getThrowable(); + do { + if ($error instanceof EquipmentAssetCodeAlreadyExistsException) { + $event->setResponse(new JsonResponse([ + 'type' => '/errors/equipment_asset_code_conflict', 'title' => 'Asset code already used', + 'status' => 409, 'code' => 'equipment_asset_code_conflict', 'detail' => $error->getMessage(), + ], 409, ['Content-Type' => 'application/problem+json'])); + + return; + } + $error = $error->getPrevious(); + } while (null !== $error); + } +} diff --git a/src/Equipment/Presentation/Api/EventSubscriber/EquipmentReplacementFailureSubscriber.php b/src/Equipment/Presentation/Api/EventSubscriber/EquipmentReplacementFailureSubscriber.php new file mode 100644 index 000000000..ac7ad14b2 --- /dev/null +++ b/src/Equipment/Presentation/Api/EventSubscriber/EquipmentReplacementFailureSubscriber.php @@ -0,0 +1,82 @@ + the exception listener + */ + public static function getSubscribedEvents(): array + { + return [KernelEvents::EXCEPTION => ['onException', 12]]; + } + + /** + * Method onException + * + * @access public + * + * @param ExceptionEvent $event the current kernel exception + * + * @return void + */ + public function onException(ExceptionEvent $event): void + { + $request = $event->getRequest(); + if ('equipment_replace' !== $request->attributes->get('_api_operation_name')) { + return; + } + $error = $event->getThrowable(); + do { + $status = match (true) { + $error instanceof EquipmentReplacementConflictException => 409, + $error instanceof EquipmentSerialNumberAlreadyExistsException => 409, + $error instanceof OrganizationQuotaExceededException => 409, + $error instanceof EquipmentNotFoundException => 404, + $error instanceof InvalidValueException => 422, + default => null, + }; + if (null !== $status) { + $code = match (true) { + $error instanceof EquipmentNotFoundException => 'equipment_not_found', + $error instanceof EquipmentSerialNumberAlreadyExistsException => 'equipment_serial_number_conflict', + $error instanceof OrganizationQuotaExceededException => 'organization_quota_exceeded', + $error instanceof InvalidValueException => 'equipment_replacement_invalid', + default => 'equipment_replacement_conflict', + }; + $event->setResponse(new JsonResponse([ + 'type' => '/errors/' . $code, + 'title' => 404 === $status ? 'Equipment not found' : 'Replacement refused', + 'status' => $status, 'code' => $code, 'detail' => $error->getMessage(), + ], $status, ['Content-Type' => 'application/problem+json'])); + + return; + } + $error = $error->getPrevious(); + } while (null !== $error); + } + // #endregion +} diff --git a/src/Equipment/Presentation/Api/EventSubscriber/EquipmentTypeCatalogFailureSubscriber.php b/src/Equipment/Presentation/Api/EventSubscriber/EquipmentTypeCatalogFailureSubscriber.php new file mode 100644 index 000000000..51beb14b6 --- /dev/null +++ b/src/Equipment/Presentation/Api/EventSubscriber/EquipmentTypeCatalogFailureSubscriber.php @@ -0,0 +1,69 @@ + listeners + */ + public static function getSubscribedEvents(): array + { + return [KernelEvents::EXCEPTION => ['onException', 10]]; + } + + /** + * Method onException. + * + * @access public + * + * @param ExceptionEvent $event kernel exception event + * + * @return void + */ + public function onException(ExceptionEvent $event): void + { + $error = $event->getThrowable(); + do { + if ($error instanceof EquipmentTypeCatalogException) { + $status = match ($error->reason) { + 'equipment_type_not_found' => 404, + 'equipment_type_exists' => 409, + 'equipment_type_revision_conflict' => 412, + default => 422, + }; + $event->setResponse(new JsonResponse([ + 'type' => '/errors/' . $error->reason, + 'title' => 'Equipment type change refused', + 'status' => $status, + 'code' => $error->reason, + 'detail' => $error->getMessage(), + ], $status, ['Content-Type' => 'application/problem+json'])); + + return; + } + $error = $error->getPrevious(); + } while (null !== $error); + } + // #endregion +} diff --git a/src/Equipment/Presentation/Api/Factory/EquipmentOutputFactory.php b/src/Equipment/Presentation/Api/Factory/EquipmentOutputFactory.php index 40b91bfab..d475291ed 100644 --- a/src/Equipment/Presentation/Api/Factory/EquipmentOutputFactory.php +++ b/src/Equipment/Presentation/Api/Factory/EquipmentOutputFactory.php @@ -37,7 +37,7 @@ final class EquipmentOutputFactory * Method fromView. * * Maps a command or query result to the equipment output DTO. The - * query-only fields (`maintenanceDueStatus`, `planPosition`) keep their + * query-only fields (independent operation deadlines and planPosition) keep their * defaults on command results, which do not carry them. * * @since 1.0.0 @@ -59,6 +59,12 @@ public function fromView( $output->model = $view->model; $output->serialNumber = $view->serialNumber; $output->locationLabel = $view->locationLabel; + $output->name = $view->name; + $output->assetCode = $view->assetCode; + $output->criticality = $view->criticality; + $output->technicalProperties = $view->technicalProperties; + $output->predecessorEquipmentId = $view->predecessorEquipmentId; + $output->successorEquipmentId = $view->successorEquipmentId; $output->facilityName = $view->facilityName; $output->status = $view->status; $output->installedAt = $view->installedAt; @@ -68,7 +74,11 @@ public function fromView( $output->updatedAt = $view->updatedAt->format('c'); if ($view instanceof GetEquipmentResult) { - $output->maintenanceDueStatus = $view->maintenanceDueStatus; + $output->controlDueStatus = $view->controlDueStatus ?? $view->maintenanceDueStatus; + $output->maintenanceDueStatus = $output->controlDueStatus; + $output->serviceDueStatus = $view->serviceDueStatus; + $output->controlNextDueAt = $view->controlNextDueAt; + $output->serviceNextDueAt = $view->serviceNextDueAt; $output->planPosition = $view->planPosition; } diff --git a/src/Equipment/Presentation/Api/Operation/EquipmentReplacementOperations.php b/src/Equipment/Presentation/Api/Operation/EquipmentReplacementOperations.php new file mode 100644 index 000000000..6e225f02e --- /dev/null +++ b/src/Equipment/Presentation/Api/Operation/EquipmentReplacementOperations.php @@ -0,0 +1,22 @@ + */ - private const array PATCHABLE_FIELDS = ['type', 'status', 'subType', 'brand', 'model', 'serialNumber', 'locationLabel']; + private const array PATCHABLE_FIELDS = ['type', 'status', 'subType', 'brand', 'model', 'serialNumber', 'locationLabel', 'name', 'assetCode', 'criticality', 'technicalProperties']; // #endregion // #region Constructor @@ -177,6 +177,14 @@ public function process(mixed $data, Operation $operation, array $uriVariables = hasLocationLabel: $present['locationLabel'], locationLabel: $data->locationLabel, hasFacility: $hasFacility, + hasName: $present['name'], + name: $data->name, + hasAssetCode: $present['assetCode'], + assetCode: $data->assetCode, + hasCriticality: $present['criticality'], + criticality: $data->criticality, + hasTechnicalProperties: $present['technicalProperties'], + technicalProperties: $data->technicalProperties, facilityId: $hasFacility && null !== $data->facility ? ResourceIriParser::id($data->facility, 'facilities') : null, diff --git a/src/Equipment/Presentation/Api/Processor/Equipment/CreateEquipmentProcessor.php b/src/Equipment/Presentation/Api/Processor/Equipment/CreateEquipmentProcessor.php index 90b73b4cb..271ea7955 100644 --- a/src/Equipment/Presentation/Api/Processor/Equipment/CreateEquipmentProcessor.php +++ b/src/Equipment/Presentation/Api/Processor/Equipment/CreateEquipmentProcessor.php @@ -188,6 +188,10 @@ private function dispatchCreation( model: $data->model, serialNumber: $data->serialNumber, locationLabel: $data->locationLabel, + name: $data->name, + assetCode: $data->assetCode, + criticality: $data->criticality, + technicalProperties: $data->technicalProperties, resourceId: $resourceId, )); } catch (EquipmentSerialNumberAlreadyExistsException $exception) { diff --git a/src/Equipment/Presentation/Api/Processor/Equipment/ReplaceEquipmentProcessor.php b/src/Equipment/Presentation/Api/Processor/Equipment/ReplaceEquipmentProcessor.php new file mode 100644 index 000000000..afc02e3ec --- /dev/null +++ b/src/Equipment/Presentation/Api/Processor/Equipment/ReplaceEquipmentProcessor.php @@ -0,0 +1,112 @@ + + */ +final readonly class ReplaceEquipmentProcessor implements ProcessorInterface +{ + // #region Constructor + /** + * Method __construct + * + * @access public + * + * @param CommandBusPort $commandBus dispatches the replacement command + * @param OrganizationAuthorizationPort $authorization the permission owner + * @param Security $security the caller context + * + * @return void + */ + public function __construct( + private CommandBusPort $commandBus, + private OrganizationAuthorizationPort $authorization, + private Security $security, + ) { + } + // #endregion + + // #region Methods + /** + * Method process + * + * @access public + * + * @param mixed $data the deserialized replacement input + * @param Operation $operation the endpoint metadata + * @param array $uriVariables the owner and predecessor identities + * @param array $context the API Platform context + * + * @return ReplaceEquipmentOutput the durable operation result + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ReplaceEquipmentOutput + { + $user = $this->security->getUser(); + if (!$user instanceof AuthenticatedUser) { + throw new AccessDeniedHttpException('Authentication required.'); + } + $organizationId = $uriVariables['organizationId'] ?? null; + $equipmentId = $uriVariables['equipmentId'] ?? null; + if (!is_string($organizationId) || '' === $organizationId || !is_string($equipmentId) || '' === $equipmentId || !$data instanceof ReplaceEquipmentInput) { + throw new BadRequestHttpException('Organization, equipment and replacement input are required.'); + } + $organizationId = strtolower($organizationId); + $decision = $this->authorization->resolveAccess($user->getId(), $organizationId, 'organization.equipment.write'); + if ($decision->isOutsideScope()) { + throw new NotFoundHttpException('Organization not found.'); + } + if (!$decision->isGranted()) { + throw new AccessDeniedHttpException('Missing organization.equipment.write permission.'); + } + $newAsset = $data->successor; + $result = $this->commandBus->dispatch(new ReplaceEquipmentCommand( + organizationId: $organizationId, + equipmentId: strtolower($equipmentId), + clientOperationId: strtolower($data->clientOperationId), + successorEquipmentId: null === $data->successorEquipmentId ? null : strtolower($data->successorEquipmentId), + successor: null === $newAsset ? null : new EquipmentReplacementSuccessor( + $newAsset->type, + $newAsset->subType, + $newAsset->brand, + $newAsset->model, + $newAsset->serialNumber, + $newAsset->locationLabel, + $newAsset->name, + $newAsset->assetCode, + $newAsset->criticality, + $newAsset->technicalProperties, + ), + )); + if (!$result instanceof ReplaceEquipmentResult) { + throw new LogicException('Replacement did not return its typed result.'); + } + + return new ReplaceEquipmentOutput($result->predecessorEquipmentId, $result->successorEquipmentId, $result->clientOperationId, $result->replayed); + } + // #endregion +} diff --git a/src/Equipment/Presentation/Api/Processor/Equipment/UpdateEquipmentProcessor.php b/src/Equipment/Presentation/Api/Processor/Equipment/UpdateEquipmentProcessor.php index cee159b12..3d65ae87d 100644 --- a/src/Equipment/Presentation/Api/Processor/Equipment/UpdateEquipmentProcessor.php +++ b/src/Equipment/Presentation/Api/Processor/Equipment/UpdateEquipmentProcessor.php @@ -20,7 +20,10 @@ use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\{AccessDeniedHttpException, BadRequestHttpException, ConflictHttpException, NotFoundHttpException}; +use function array_key_exists; +use function is_array; use function is_string; +use function json_decode; /** * Processor UpdateEquipmentProcessor. @@ -56,6 +59,7 @@ public function __construct( private OrganizationAuthorizationPort $authorization, private Security $security, private EquipmentDetailOutputFactory $outputFactory, + private ?\Symfony\Component\HttpFoundation\RequestStack $requests = null, ) { } // #endregion @@ -100,6 +104,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables = } try { + $body = $this->requests?->getCurrentRequest()?->getContent(); + $decoded = null !== $body && '' !== $body ? json_decode($body, true) : []; + $fields = is_array($decoded) ? $decoded : []; /** @var UpdateEquipmentResult $result */ $result = $this->commandBus->dispatch(new UpdateEquipmentCommand( organizationId: $organizationId, @@ -110,6 +117,14 @@ public function process(mixed $data, Operation $operation, array $uriVariables = model: $data->model, serialNumber: $data->serialNumber, locationLabel: $data->locationLabel, + name: $data->name, + assetCode: $data->assetCode, + criticality: $data->criticality, + technicalProperties: $data->technicalProperties, + hasName: array_key_exists('name', $fields) || null !== $data->name, + hasAssetCode: array_key_exists('assetCode', $fields) || null !== $data->assetCode, + hasCriticality: array_key_exists('criticality', $fields) || null !== $data->criticality, + hasTechnicalProperties: array_key_exists('technicalProperties', $fields) || [] !== $data->technicalProperties, )); } catch (EquipmentNotFoundException $exception) { throw new NotFoundHttpException($exception->getMessage(), $exception); diff --git a/src/Equipment/Presentation/Api/Processor/EquipmentTypeCatalog/EquipmentTypeCatalogProcessor.php b/src/Equipment/Presentation/Api/Processor/EquipmentTypeCatalog/EquipmentTypeCatalogProcessor.php new file mode 100644 index 000000000..bd84cfdbf --- /dev/null +++ b/src/Equipment/Presentation/Api/Processor/EquipmentTypeCatalog/EquipmentTypeCatalogProcessor.php @@ -0,0 +1,84 @@ + + */ +final readonly class EquipmentTypeCatalogProcessor implements ProcessorInterface +{ + // #region Constructor + /** + * Method __construct. + * + * @access public + * + * @param CommandBusPort $commandBus catalog write dispatch + * @param EquipmentTypeCatalogAccess $access organization gate + * + * @return void + */ + public function __construct( + private CommandBusPort $commandBus, + private EquipmentTypeCatalogAccess $access, + ) { + } + // #endregion + + // #region Methods + /** + * Method process. + * + * @access public + * + * @param mixed $data validated catalog input + * @param Operation $operation current operation + * @param array $uriVariables organization and optional code + * @param array $context processor context + * + * @return EquipmentTypeOutput saved descriptor + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EquipmentTypeOutput + { + $organizationId = $this->access->organization($uriVariables, 'organization.equipment.write'); + if ($data instanceof CreateEquipmentTypeInput) { + /** @var CreateEquipmentTypeResult $result */ + $result = $this->commandBus->dispatch(new CreateEquipmentTypeCommand($organizationId, $data->value, $data->label, $data->family)); + } elseif ($data instanceof PatchEquipmentTypeInput && is_string($uriVariables['typeCode'] ?? null)) { + /** @var PatchEquipmentTypeResult $result */ + $result = $this->commandBus->dispatch(new PatchEquipmentTypeCommand( + $organizationId, + $uriVariables['typeCode'], + $data->revision, + $data->label, + $data->family, + $data->archived, + )); + } else { + throw new BadRequestHttpException('Invalid equipment type input.'); + } + + return EquipmentTypeOutput::fromDescriptor($result->type); + } + // #endregion +} diff --git a/src/Equipment/Presentation/Api/Provider/Equipment/CanonicalEquipmentProvider.php b/src/Equipment/Presentation/Api/Provider/Equipment/CanonicalEquipmentProvider.php index 947bbc3b8..87f302655 100644 --- a/src/Equipment/Presentation/Api/Provider/Equipment/CanonicalEquipmentProvider.php +++ b/src/Equipment/Presentation/Api/Provider/Equipment/CanonicalEquipmentProvider.php @@ -10,15 +10,19 @@ use ArrayIterator; use Auth\Infrastructure\Security\User\SecurityUser; use Doctrine\ORM\EntityManagerInterface; +use Equipment\Application\Port\Outbound\MaintenanceDueStatusPort; use Equipment\Infrastructure\Persistence\Doctrine\Record\EquipmentRecord; use Equipment\Presentation\Api\Dto\Output\Equipment\EquipmentOutput; use Intervention\Application\Service\InterventionResourceManager; +use Maintenance\Application\Contract\Plan\MaintenanceEquipmentOperationsDue; +use Maintenance\Application\Port\Inbound\MaintenanceOperationsDuePort; use Organization\Application\Port\Inbound\OrganizationAuthorizationPort; use Shared\Presentation\Api\Http\ResourceIriParser; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Exception\{AccessDeniedHttpException, BadRequestHttpException, NotFoundHttpException}; +use function array_map; use function is_array; use function is_numeric; use function is_string; @@ -57,6 +61,8 @@ * @param Security $security the security value * @param RequestStack $requestStack the request stack value * @param InterventionResourceManager $interventionResourceManager the intervention resource manager value + * @param ?MaintenanceOperationsDuePort $operationsDue independent operation deadlines + * @param ?MaintenanceDueStatusPort $maintenanceDueStatusPort compatibility fallback when the independent owner port is unavailable */ public function __construct( private EntityManagerInterface $entityManager, @@ -65,6 +71,8 @@ public function __construct( private RequestStack $requestStack, private InterventionResourceManager $interventionResourceManager, private \Equipment\Presentation\Api\Factory\EquipmentDetailOutputFactory $detail, + private ?MaintenanceOperationsDuePort $operationsDue = null, + private ?MaintenanceDueStatusPort $maintenanceDueStatusPort = null, ) { } @@ -163,9 +171,14 @@ private function provideCollection(Operation $operation, array $context): Traver ->setMaxResults($itemsPerPage) ->getQuery() ->getResult(); + $equipmentIds = array_map(static fn (EquipmentRecord $record): string => $record->id, $records); + $operationsDueByEquipment = $this->operationsDue?->forEquipment($organization, $equipmentIds) ?? []; + $legacyDueStatuses = null === $this->operationsDue + ? ($this->maintenanceDueStatusPort?->dueStatusesForEquipment($organization, $equipmentIds) ?? []) + : []; $output = []; foreach ($records as $record) { - $output[] = $this->map($record); + $output[] = $this->map($record, $operationsDueByEquipment[$record->id] ?? null, $legacyDueStatuses[$record->id] ?? 'unscheduled'); } return new TraversablePaginator(new ArrayIterator($output), (float) $page, (float) $itemsPerPage, (float) $total); @@ -261,10 +274,12 @@ private function assertRead(string $organizationId, string $notFoundMessage): vo * @since 1.0.0 * * @param EquipmentRecord $record the record value + * @param ?MaintenanceEquipmentOperationsDue $operationsDue the bulk-resolved operation statuses and dates + * @param string $controlDueStatus the compatibility control status * * @return EquipmentOutput the map result */ - private function map(EquipmentRecord $record): EquipmentOutput + private function map(EquipmentRecord $record, ?MaintenanceEquipmentOperationsDue $operationsDue = null, string $controlDueStatus = 'unscheduled'): EquipmentOutput { if (null === $record->organization) { throw new NotFoundHttpException('Equipment organization not found.'); @@ -282,9 +297,20 @@ private function map(EquipmentRecord $record): EquipmentOutput $output->model = $record->model; $output->serialNumber = $record->serialNumber; $output->locationLabel = $record->locationLabel; + $output->name = $record->name; + $output->assetCode = $record->assetCode; + $output->criticality = $record->criticality; + $output->technicalProperties = $record->technicalProperties; + $output->predecessorEquipmentId = $record->predecessorEquipmentId; + $output->successorEquipmentId = $record->successorEquipmentId; $output->status = $record->status; $output->installedAt = $record->installedAt?->format('c'); $output->commissionedAt = $record->commissionedAt?->format('c'); + $output->controlDueStatus = $operationsDue->controlDueStatus ?? $controlDueStatus; + $output->maintenanceDueStatus = $output->controlDueStatus; + $output->serviceDueStatus = $operationsDue->serviceDueStatus ?? 'unscheduled'; + $output->controlNextDueAt = $operationsDue?->controlNextDueAt?->format('c'); + $output->serviceNextDueAt = $operationsDue?->serviceNextDueAt?->format('c'); $output->createdAt = $record->createdAt->format('c'); $output->updatedAt = $record->updatedAt->format('c'); diff --git a/src/Equipment/Presentation/Api/Provider/Equipment/FacilityEquipmentSummaryProvider.php b/src/Equipment/Presentation/Api/Provider/Equipment/FacilityEquipmentSummaryProvider.php index 375df33c4..2db949eef 100644 --- a/src/Equipment/Presentation/Api/Provider/Equipment/FacilityEquipmentSummaryProvider.php +++ b/src/Equipment/Presentation/Api/Provider/Equipment/FacilityEquipmentSummaryProvider.php @@ -76,8 +76,17 @@ public function provide(Operation $operation, array $uriVariables = [], array $c throw new AccessDeniedHttpException('Missing organization.equipment.read permission.'); } $includeDescendants = $this->requests->getCurrentRequest()?->query->getBoolean('includeDescendants', true) ?? true; + $query = $this->requests->getCurrentRequest()?->query; + $family = $query?->get('family'); + $customerId = $query?->get('customerId'); /** @var GetFacilityEquipmentSummaryResult $result */ - $result = $this->queries->ask(new GetFacilityEquipmentSummaryQuery($organizationId, $facilityId, $includeDescendants)); + $result = $this->queries->ask(new GetFacilityEquipmentSummaryQuery( + $organizationId, + $facilityId, + $includeDescendants, + is_string($family) && '' !== $family ? $family : null, + is_string($customerId) && '' !== $customerId ? $customerId : null, + )); $output = new FacilityEquipmentSummaryOutput(); $output->scope = $result->scope; $output->totalItems = $result->totalItems; diff --git a/src/Equipment/Presentation/Api/Provider/Equipment/ListEquipmentsProvider.php b/src/Equipment/Presentation/Api/Provider/Equipment/ListEquipmentsProvider.php index 2e61e9874..4132f2485 100644 --- a/src/Equipment/Presentation/Api/Provider/Equipment/ListEquipmentsProvider.php +++ b/src/Equipment/Presentation/Api/Provider/Equipment/ListEquipmentsProvider.php @@ -135,6 +135,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c sorting: SortingExtractor::fromContext(['filters' => $filters], ['type', 'status', 'brand', 'model', 'createdAt', 'updatedAt'], 'createdAt'), maintenanceDueStatus: self::optionalFilter($maintenanceDueStatus), includeDescendants: $includeDescendants, + family: self::optionalFilter(\Shared\Presentation\Api\Http\OperationParameterReader::query($operation, $request)->get('family')), + customerId: self::optionalFilter(\Shared\Presentation\Api\Http\OperationParameterReader::query($operation, $request)->get('customerId')), )); } catch (EquipmentNotFoundException $exception) { throw new NotFoundHttpException($exception->getMessage(), $exception); diff --git a/src/Equipment/Presentation/Api/Provider/Equipment/OrganizationEquipmentSummaryProvider.php b/src/Equipment/Presentation/Api/Provider/Equipment/OrganizationEquipmentSummaryProvider.php new file mode 100644 index 000000000..5070d8b6a --- /dev/null +++ b/src/Equipment/Presentation/Api/Provider/Equipment/OrganizationEquipmentSummaryProvider.php @@ -0,0 +1,76 @@ + + * + * @implements ProviderInterface + */ +final readonly class OrganizationEquipmentSummaryProvider implements ProviderInterface +{ + /** + * @since 1.0.0 + */ + public function __construct(private QueryBusPort $queries, private OrganizationAuthorizationPort $authorization, private Security $security, private RequestStack $requests) + { + } + + /** + * @since 1.0.0 + * + * @param array $uriVariables route identifiers + * @param array $context provider context + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): FacilityEquipmentSummaryOutput + { + $user = $this->security->getUser(); + if (!$user instanceof AuthenticatedUser) { + throw new AccessDeniedHttpException('Authentication required.'); + } + $organizationId = $uriVariables['organizationId'] ?? null; + if (!is_string($organizationId) || '' === $organizationId) { + throw new BadRequestHttpException('OrganizationId is required.'); + } + $decision = $this->authorization->resolveAccess($user->getId(), $organizationId, 'organization.equipment.read'); + if ($decision->isOutsideScope()) { + throw new NotFoundHttpException('Organization not found.'); + } + if (!$decision->isGranted()) { + throw new AccessDeniedHttpException('Missing organization.equipment.read permission.'); + } + $parameters = $this->requests->getCurrentRequest()?->query; + $family = $parameters?->get('family'); + $customer = $parameters?->get('customerId'); + /** @var GetFacilityEquipmentSummaryResult $result */ + $result = $this->queries->ask(new GetOrganizationEquipmentSummaryQuery($organizationId, is_string($family) && '' !== $family ? $family : null, is_string($customer) && '' !== $customer ? $customer : null)); + $output = new FacilityEquipmentSummaryOutput(); + $output->scope = $result->scope; + $output->totalItems = $result->totalItems; + $output->byStatus = $result->byStatus; + $output->needingAttentionCount = $result->needingAttentionCount; + + return $output; + } +} diff --git a/src/Equipment/Presentation/Api/Provider/EquipmentTypeCatalog/EquipmentTypeCatalogProvider.php b/src/Equipment/Presentation/Api/Provider/EquipmentTypeCatalog/EquipmentTypeCatalogProvider.php new file mode 100644 index 000000000..ea6aa1943 --- /dev/null +++ b/src/Equipment/Presentation/Api/Provider/EquipmentTypeCatalog/EquipmentTypeCatalogProvider.php @@ -0,0 +1,79 @@ + + */ +final readonly class EquipmentTypeCatalogProvider implements ProviderInterface +{ + // #region Constructor + /** + * Method __construct. + * + * @access public + * + * @param QueryBusPort $queryBus catalog read dispatch + * @param EquipmentTypeCatalogAccess $access organization gate + * + * @return void + */ + public function __construct( + private QueryBusPort $queryBus, + private EquipmentTypeCatalogAccess $access, + ) { + } + // #endregion + + // #region Methods + /** + * Method provide. + * + * @access public + * + * @param Operation $operation current operation + * @param array $uriVariables scope and optional code + * @param array $context provider context + * + * @return list|EquipmentTypeOutput catalog or item + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|EquipmentTypeOutput + { + $organizationId = $this->access->organization($uriVariables, 'organization.equipment.read'); + /** @var ListEquipmentTypesResult $result */ + $result = $this->queryBus->ask(new ListEquipmentTypesQuery($organizationId)); + $typeCode = $uriVariables['typeCode'] ?? null; + $outputs = []; + foreach ($result->types as $type) { + $output = EquipmentTypeOutput::fromDescriptor($type); + if (is_string($typeCode) && $type->value === $typeCode) { + return $output; + } + $outputs[] = $output; + } + if (is_string($typeCode)) { + throw new NotFoundHttpException('Equipment type not found.'); + } + + return $outputs; + } + // #endregion +} diff --git a/src/Equipment/Presentation/Api/Resource/EquipmentReplacementResource.php b/src/Equipment/Presentation/Api/Resource/EquipmentReplacementResource.php new file mode 100644 index 000000000..ab34fc05d --- /dev/null +++ b/src/Equipment/Presentation/Api/Resource/EquipmentReplacementResource.php @@ -0,0 +1,53 @@ + [EquipmentSerializationGroup::WRITE]], + normalizationContext: ['groups' => [EquipmentSerializationGroup::READ]], + openapi: new Operation( + tags: ['Equipment'], + summary: 'Replace equipment', + description: 'Retires a published asset and transfers its placement to an unused stock asset or atomically creates its successor. Replays return the original result.', + responses: [ + 200 => new Response(description: 'Replacement committed or replayed'), + 403 => new Response(description: 'Missing equipment write permission'), + 404 => new Response(description: 'Organization or equipment unavailable'), + 409 => new Response(description: 'Replacement lifecycle or replay conflict'), + 422 => new Response(description: 'Invalid successor or operation identity'), + ], + ), + ), + ], +)] +final class EquipmentReplacementResource +{ +} diff --git a/src/Equipment/Presentation/Api/Resource/EquipmentResource.php b/src/Equipment/Presentation/Api/Resource/EquipmentResource.php index 2c65a6886..cfaba88ad 100644 --- a/src/Equipment/Presentation/Api/Resource/EquipmentResource.php +++ b/src/Equipment/Presentation/Api/Resource/EquipmentResource.php @@ -77,6 +77,8 @@ normalizationContext: ['groups' => [EquipmentSerializationGroup::READ]], security: self::SECURITY_ROLE_USER, parameters: [ + 'family' => new \ApiPlatform\Metadata\QueryParameter(schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'family', in: 'query', schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']])), + 'customerId' => new \ApiPlatform\Metadata\QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'customerId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid'])), 'includeDescendants' => new \ApiPlatform\Metadata\QueryParameter( schema: ['type' => 'boolean'], description: 'Include equipment assigned to published descendants of the facility filter.', @@ -176,7 +178,7 @@ ), ), 'maintenanceDueStatus' => new \ApiPlatform\Metadata\QueryParameter( - schema: ['type' => 'string', 'enum' => ['unscheduled', 'up_to_date', 'due_soon', 'overdue']], + schema: ['type' => 'string', 'enum' => ['unscheduled', 'up_to_date', 'due_soon', 'overdue', 'due']], description: self::MAINTENANCE_DUE_FILTER_DESCRIPTION, required: false, castToArray: false, @@ -187,7 +189,7 @@ in: 'query', required: false, description: self::MAINTENANCE_DUE_FILTER_DESCRIPTION, - schema: ['type' => 'string', 'enum' => ['unscheduled', 'up_to_date', 'due_soon', 'overdue']], + schema: ['type' => 'string', 'enum' => ['unscheduled', 'up_to_date', 'due_soon', 'overdue', 'due']], ), ), ], @@ -216,6 +218,8 @@ normalizationContext: ['groups' => [EquipmentSerializationGroup::READ]], security: self::SECURITY_ROLE_USER, parameters: [ + 'family' => new \ApiPlatform\Metadata\QueryParameter(schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'family', in: 'query', schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']])), + 'customerId' => new \ApiPlatform\Metadata\QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'customerId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid'])), 'includeDescendants' => new \ApiPlatform\Metadata\QueryParameter( schema: ['type' => 'boolean'], description: 'Include equipment assigned to published descendants of this facility.', @@ -300,7 +304,7 @@ ), ), 'maintenanceDueStatus' => new \ApiPlatform\Metadata\QueryParameter( - schema: ['type' => 'string', 'enum' => ['unscheduled', 'up_to_date', 'due_soon', 'overdue']], + schema: ['type' => 'string', 'enum' => ['unscheduled', 'up_to_date', 'due_soon', 'overdue', 'due']], description: self::MAINTENANCE_DUE_FILTER_DESCRIPTION, required: false, castToArray: false, @@ -311,7 +315,7 @@ in: 'query', required: false, description: self::MAINTENANCE_DUE_FILTER_DESCRIPTION, - schema: ['type' => 'string', 'enum' => ['unscheduled', 'up_to_date', 'due_soon', 'overdue']], + schema: ['type' => 'string', 'enum' => ['unscheduled', 'up_to_date', 'due_soon', 'overdue', 'due']], ), ), ], @@ -575,6 +579,6 @@ final class EquipmentResource /** * Constant MAINTENANCE_DUE_FILTER_DESCRIPTION */ - private const string MAINTENANCE_DUE_FILTER_DESCRIPTION = 'Filter by cross-module maintenance due status (`unscheduled`, `up_to_date`, `due_soon`, `overdue`).'; + private const string MAINTENANCE_DUE_FILTER_DESCRIPTION = 'Filter by maintenance due status (`unscheduled`, `up_to_date`, `due_soon`, `overdue`); `due` combines due-soon and overdue before pagination.'; // #endregion } diff --git a/src/Equipment/Presentation/Api/Resource/EquipmentTypeCatalogResource.php b/src/Equipment/Presentation/Api/Resource/EquipmentTypeCatalogResource.php new file mode 100644 index 000000000..4d2626c75 --- /dev/null +++ b/src/Equipment/Presentation/Api/Resource/EquipmentTypeCatalogResource.php @@ -0,0 +1,97 @@ + ['equipment_type:read']], + openapi: new Operation(tags: ['Equipment'], summary: 'List organization equipment types, including archived types', responses: [ + 200 => new Response(description: 'Catalog including archived descriptors'), + 403 => new Response(description: 'Missing equipment-read permission'), + 404 => new Response(description: 'Organization outside caller scope'), + ]), + ), + new Get( + name: EquipmentTypeCatalogOperations::GET, + uriTemplate: '/{organizationId}/equipment-types/{typeCode}', + input: false, + output: EquipmentTypeOutput::class, + provider: EquipmentTypeCatalogProvider::class, + security: "is_granted('ROLE_USER')", + normalizationContext: ['groups' => ['equipment_type:read']], + openapi: new Operation(tags: ['Equipment'], summary: 'Read an organization equipment type', responses: [ + 200 => new Response(description: 'Equipment type descriptor'), + 403 => new Response(description: 'Missing equipment-read permission'), + 404 => new Response(description: 'Organization outside caller scope or unknown equipment type'), + ]), + ), + new Post( + name: EquipmentTypeCatalogOperations::CREATE, + uriTemplate: '/{organizationId}/equipment-types', + status: 201, + read: false, + input: CreateEquipmentTypeInput::class, + output: EquipmentTypeOutput::class, + processor: EquipmentTypeCatalogProcessor::class, + security: "is_granted('ROLE_USER')", + denormalizationContext: ['groups' => ['equipment_type:write'], 'allow_extra_attributes' => false], + normalizationContext: ['groups' => ['equipment_type:read']], + openapi: new Operation(tags: ['Equipment'], summary: 'Create an organization equipment type', responses: [ + 201 => new Response(description: 'Active equipment type created'), + 403 => new Response(description: 'Missing equipment-write permission'), + 404 => new Response(description: 'Organization outside caller scope'), + 409 => new Response(description: 'Type code already exists'), + 422 => new Response(description: 'Invalid code, label or family'), + ]), + ), + new Patch( + name: EquipmentTypeCatalogOperations::PATCH, + uriTemplate: '/{organizationId}/equipment-types/{typeCode}', + status: 200, + read: false, + input: PatchEquipmentTypeInput::class, + output: EquipmentTypeOutput::class, + processor: EquipmentTypeCatalogProcessor::class, + security: "is_granted('ROLE_USER')", + denormalizationContext: ['groups' => ['equipment_type:write'], 'allow_extra_attributes' => false], + normalizationContext: ['groups' => ['equipment_type:read']], + openapi: new Operation(tags: ['Equipment'], summary: 'Update or archive a type using its observed revision', responses: [ + 200 => new Response(description: 'Equipment type updated'), + 403 => new Response(description: 'Missing equipment-write permission'), + 404 => new Response(description: 'Organization outside caller scope or unknown equipment type'), + 412 => new Response(description: 'Observed descriptor revision is stale'), + 422 => new Response(description: 'Invalid label, family or revision'), + ]), + ), + ], +)] +final class EquipmentTypeCatalogResource +{ +} diff --git a/src/Equipment/Presentation/Api/Resource/FacilityEquipmentSummaryResource.php b/src/Equipment/Presentation/Api/Resource/FacilityEquipmentSummaryResource.php index 36456b6bb..6faa3e0f5 100644 --- a/src/Equipment/Presentation/Api/Resource/FacilityEquipmentSummaryResource.php +++ b/src/Equipment/Presentation/Api/Resource/FacilityEquipmentSummaryResource.php @@ -33,7 +33,10 @@ castToNativeType: false, constraints: [], openApi: new Parameter(name: 'includeDescendants', in: 'query', schema: ['type' => 'boolean', 'default' => true]), - )], + ), + 'family' => new QueryParameter(schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'family', in: 'query', schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']])), + 'customerId' => new QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'customerId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid'])), + ], openapi: new Operation( tags: ['Equipment'], summary: 'Get exact facility equipment totals', diff --git a/src/Equipment/Presentation/Api/Resource/OrganizationEquipmentSummaryResource.php b/src/Equipment/Presentation/Api/Resource/OrganizationEquipmentSummaryResource.php new file mode 100644 index 000000000..cf366bae8 --- /dev/null +++ b/src/Equipment/Presentation/Api/Resource/OrganizationEquipmentSummaryResource.php @@ -0,0 +1,39 @@ + + */ +#[ApiResource(shortName: 'OrganizationEquipmentSummary', operations: [new Get( + name: self::GET_SUMMARY, + uriTemplate: '/organizations/{organizationId}/equipment-summary', + input: false, + output: FacilityEquipmentSummaryOutput::class, + provider: OrganizationEquipmentSummaryProvider::class, + security: "is_granted('ROLE_USER')", + parameters: [ + 'family' => new QueryParameter(schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'family', in: 'query', schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']])), + 'customerId' => new QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'customerId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid'])), + ], + openapi: new Operation(tags: ['Equipment'], summary: 'Get exact organization equipment totals', responses: [200 => new Response(description: 'Equipment totals retrieved'), 403 => new Response(description: 'Missing equipment read permission'), 404 => new Response(description: 'Organization or customer not found')]), +)])] +final class OrganizationEquipmentSummaryResource +{ + /** + * @since 1.0.0 + */ + public const string GET_SUMMARY = 'organization_equipment_summary_get'; +} diff --git a/src/Equipment/Presentation/Api/Service/EquipmentTypeCatalogAccess.php b/src/Equipment/Presentation/Api/Service/EquipmentTypeCatalogAccess.php new file mode 100644 index 000000000..8be884efe --- /dev/null +++ b/src/Equipment/Presentation/Api/Service/EquipmentTypeCatalogAccess.php @@ -0,0 +1,74 @@ + $uriVariables route identifiers + * @param string $permission required permission + * + * @return string authorized organization identifier + */ + public function organization(array $uriVariables, string $permission): string + { + $user = $this->security->getUser(); + if (!$user instanceof AuthenticatedUser) { + throw new AccessDeniedHttpException('Authentication required.'); + } + $organizationId = $uriVariables['organizationId'] ?? null; + if (!is_string($organizationId) || 1 !== preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD', $organizationId)) { + throw new BadRequestHttpException('A valid organizationId is required.'); + } + $decision = $this->authorization->resolveAccess($user->getId(), $organizationId, $permission); + if ($decision->isOutsideScope()) { + throw new NotFoundHttpException('Organization not found.'); + } + if (!$decision->isGranted()) { + throw new AccessDeniedHttpException('Missing ' . $permission . ' permission.'); + } + + return $organizationId; + } + // #endregion +} diff --git a/src/Facility/Application/Contract/Facility/FacilityListCriteria.php b/src/Facility/Application/Contract/Facility/FacilityListCriteria.php index 3154158b6..609ac833b 100644 --- a/src/Facility/Application/Contract/Facility/FacilityListCriteria.php +++ b/src/Facility/Application/Contract/Facility/FacilityListCriteria.php @@ -44,6 +44,7 @@ public function __construct( public ?array $eligibleParentIds = null, public ?string $parentInterventionId = null, public bool $includePublishedParents = true, + public ?string $customerId = null, ) { } // #endregion diff --git a/src/Facility/Application/Contract/FacilityCustomerUnavailable.php b/src/Facility/Application/Contract/FacilityCustomerUnavailable.php new file mode 100644 index 000000000..84e0ecfd4 --- /dev/null +++ b/src/Facility/Application/Contract/FacilityCustomerUnavailable.php @@ -0,0 +1,16 @@ +levelIndex, elevationMeters: $command->elevationMeters, heightMeters: $command->heightMeters, + customerId: $command->customerId, ), ); } catch (InvalidValueException|ValueError $exception) { @@ -151,6 +153,7 @@ public function __invoke(CreateFacilityCommand $command): CreateFacilityResult ); if ($command->dryRun) { + $this->assertCustomer($command); $this->assertHierarchy($command, $facility); // A dry run never enters the transaction that would take the quota's // advisory lock (see OrganizationQuotaPort::assertCanAdd): it projects @@ -275,6 +278,7 @@ private function persistFacility(CreateFacilityCommand $command, Facility $facil 'type' => $command->type, 'name' => $command->name, 'address' => $command->address, 'latitude' => $command->latitude, 'longitude' => $command->longitude, 'parentFacilityId' => $command->parentFacilityId, 'code' => $command->code, 'metadata' => $command->metadata, 'levelIndex' => $command->levelIndex, + ...(null === $command->customerId ? [] : ['customerId' => $command->customerId]), ...(null === $command->elevationMeters ? [] : ['elevationMeters' => $command->elevationMeters]), ...(null === $command->heightMeters ? [] : ['heightMeters' => $command->heightMeters]), ]); @@ -289,6 +293,7 @@ private function persistFacility(CreateFacilityCommand $command, Facility $facil } } + $this->assertCustomer($command); $this->assertHierarchy($command, $facility); $this->quota->assertCanAdd($command->organizationId, OrganizationQuotaResource::FACILITIES); $this->saveFacility($command, $facility); @@ -382,6 +387,7 @@ private function toResult(Facility $facility, bool $replayed = false): CreateFac levelIndex: $facility->levelIndex(), elevationMeters: $facility->elevationMeters(), heightMeters: $facility->heightMeters(), + customerId: $facility->customerId(), replayed: $replayed, interventionId: $context['interventionId'] ?? null, recordStatus: $context['recordStatus'] ?? 'published', @@ -516,5 +522,15 @@ private function isParentConstraintViolation(Throwable $exception): bool return false; } + + /** + * Method assertCustomer. New assignments require an active owner-scoped customer. + */ + private function assertCustomer(CreateFacilityCommand $command): void + { + if (null !== $command->customerId) { + ($this->customers ?? throw new \Facility\Application\Contract\FacilityCustomerUnavailable())->assertAssignable($command->customerId, $command->organizationId); + } + } // #endregion } diff --git a/src/Facility/Application/UseCase/Command/Facility/CreateFacility/CreateFacilityResult.php b/src/Facility/Application/UseCase/Command/Facility/CreateFacility/CreateFacilityResult.php index 09ed18157..1a6769d6c 100644 --- a/src/Facility/Application/UseCase/Command/Facility/CreateFacility/CreateFacilityResult.php +++ b/src/Facility/Application/UseCase/Command/Facility/CreateFacility/CreateFacilityResult.php @@ -61,6 +61,7 @@ public function __construct( public ?int $levelIndex = null, public ?float $elevationMeters = null, public ?float $heightMeters = null, + public ?string $customerId = null, public bool $replayed = false, public ?string $interventionId = null, public string $recordStatus = 'published', diff --git a/src/Facility/Application/UseCase/Command/Facility/PatchCanonicalFacility/PatchCanonicalFacilityCommand.php b/src/Facility/Application/UseCase/Command/Facility/PatchCanonicalFacility/PatchCanonicalFacilityCommand.php index ed28a4fcd..50ad99e2a 100644 --- a/src/Facility/Application/UseCase/Command/Facility/PatchCanonicalFacility/PatchCanonicalFacilityCommand.php +++ b/src/Facility/Application/UseCase/Command/Facility/PatchCanonicalFacility/PatchCanonicalFacilityCommand.php @@ -57,6 +57,8 @@ public function __construct( public ?float $elevationMeters = null, public bool $hasHeightMeters = false, public ?float $heightMeters = null, + public ?string $customerId = null, + public bool $hasCustomerId = false, ) { } // #endregion diff --git a/src/Facility/Application/UseCase/Command/Facility/PatchCanonicalFacility/PatchCanonicalFacilityHandler.php b/src/Facility/Application/UseCase/Command/Facility/PatchCanonicalFacility/PatchCanonicalFacilityHandler.php index fe46acab8..c36d2e759 100644 --- a/src/Facility/Application/UseCase/Command/Facility/PatchCanonicalFacility/PatchCanonicalFacilityHandler.php +++ b/src/Facility/Application/UseCase/Command/Facility/PatchCanonicalFacility/PatchCanonicalFacilityHandler.php @@ -76,6 +76,7 @@ public function __construct( #[Autowire('%facility.hierarchy.max_depth%')] private int $maxDepth = 8, private ?FacilityHierarchyPort $hierarchy = null, + private ?\Facility\Application\Port\Outbound\FacilityCustomerValidationPort $customers = null, ) { } // #endregion @@ -111,6 +112,9 @@ function () use ($command, &$change): CanonicalFacility { $facility->assertRevisionMatches($command->expectedRevision); $patch = self::patch($command); + if ($patch->hasCustomerId && null !== $patch->customerId && $patch->customerId !== $facility->customerId()) { + ($this->customers ?? throw new \Facility\Application\Contract\FacilityCustomerUnavailable())->assertAssignable($patch->customerId, (string) $facility->organizationId()); + } $patch->assertDescriptiveFieldsAreValid(); $this->assertMetadata($facility, $patch); @@ -403,6 +407,8 @@ private static function patch(PatchCanonicalFacilityCommand $command): Canonical elevationMeters: $command->elevationMeters, hasHeightMeters: $command->hasHeightMeters, heightMeters: $command->heightMeters, + customerId: $command->customerId, + hasCustomerId: $command->hasCustomerId, ); } diff --git a/src/Facility/Application/UseCase/Command/Facility/UpdateFacility/UpdateFacilityCommand.php b/src/Facility/Application/UseCase/Command/Facility/UpdateFacility/UpdateFacilityCommand.php index dbc49e953..7e0f24127 100644 --- a/src/Facility/Application/UseCase/Command/Facility/UpdateFacility/UpdateFacilityCommand.php +++ b/src/Facility/Application/UseCase/Command/Facility/UpdateFacility/UpdateFacilityCommand.php @@ -67,6 +67,8 @@ public function __construct( public ?float $elevationMeters = null, public bool $hasElevationMeters = false, public ?float $heightMeters = null, + public ?string $customerId = null, + public bool $hasCustomerId = false, public bool $hasHeightMeters = false, ) { } diff --git a/src/Facility/Application/UseCase/Command/Facility/UpdateFacility/UpdateFacilityHandler.php b/src/Facility/Application/UseCase/Command/Facility/UpdateFacility/UpdateFacilityHandler.php index 7c1afcffa..c70896aba 100644 --- a/src/Facility/Application/UseCase/Command/Facility/UpdateFacility/UpdateFacilityHandler.php +++ b/src/Facility/Application/UseCase/Command/Facility/UpdateFacility/UpdateFacilityHandler.php @@ -61,6 +61,7 @@ public function __construct( private ?FacilityHierarchyPort $hierarchy = null, private ?TransactionManagerPort $transactionManager = null, private ?CanonicalFacilityRepositoryPort $canonicalFacilities = null, + private ?\Facility\Application\Port\Outbound\FacilityCustomerValidationPort $customers = null, ) { } // #endregion @@ -118,6 +119,9 @@ private function update(UpdateFacilityCommand $command, ?FacilityUpdatedEvent &$ // actually differs, not merely which fields were present in the patch — // a PATCH that re-sends the current value must stay a no-op. $previous = clone $facility; + if ($command->hasCustomerId && null !== $command->customerId && $command->customerId !== $facility->customerId()) { + ($this->customers ?? throw new \Facility\Application\Contract\FacilityCustomerUnavailable())->assertAssignable($command->customerId, $command->organizationId); + } if ($command->hasType && null !== $command->type && $command->type !== $facility->type()->value && null !== $this->hierarchy) { $this->hierarchy->assertGraph($command->organizationId, [new FacilityHierarchyNode( @@ -191,6 +195,7 @@ private function update(UpdateFacilityCommand $command, ?FacilityUpdatedEvent &$ levelIndex: $facility->levelIndex(), elevationMeters: $facility->elevationMeters(), heightMeters: $facility->heightMeters(), + customerId: $facility->customerId(), ); } @@ -248,6 +253,9 @@ private function applyChanges(Facility $facility, UpdateFacilityCommand $command */ private function applyIdentityChanges(Facility $facility, UpdateFacilityCommand $command): void { + if ($command->hasCustomerId && null === $command->customerId) { + $facility->assignCustomer(null); + } if ($command->hasType) { if (null === $command->type) { throw InvalidValueException::because('Field "type" cannot be null when provided.'); @@ -267,6 +275,9 @@ private function applyIdentityChanges(Facility $facility, UpdateFacilityCommand if ($command->hasCode) { $facility->changeCode($command->code); } + if ($command->hasCustomerId && null !== $command->customerId) { + $facility->assignCustomer($command->customerId); + } } /** @@ -318,6 +329,9 @@ private function resolveCoordinates(UpdateFacilityCommand $command): ?FacilityCo private function changedFields(Facility $facility, Facility $previous): array { $changed = []; + if ($previous->customerId() !== $facility->customerId()) { + $changed[] = 'customerId'; + } if ($previous->type() !== $facility->type()) { $changed[] = 'type'; diff --git a/src/Facility/Application/UseCase/Command/Facility/UpdateFacility/UpdateFacilityResult.php b/src/Facility/Application/UseCase/Command/Facility/UpdateFacility/UpdateFacilityResult.php index 229508264..b2169929e 100644 --- a/src/Facility/Application/UseCase/Command/Facility/UpdateFacility/UpdateFacilityResult.php +++ b/src/Facility/Application/UseCase/Command/Facility/UpdateFacility/UpdateFacilityResult.php @@ -45,6 +45,7 @@ public function __construct( public ?int $levelIndex = null, public ?float $elevationMeters = null, public ?float $heightMeters = null, + public ?string $customerId = null, ) { } // #endregion diff --git a/src/Facility/Application/UseCase/Query/Facility/GetFacility/GetFacilityHandler.php b/src/Facility/Application/UseCase/Query/Facility/GetFacility/GetFacilityHandler.php index be3697cca..d421d880c 100644 --- a/src/Facility/Application/UseCase/Query/Facility/GetFacility/GetFacilityHandler.php +++ b/src/Facility/Application/UseCase/Query/Facility/GetFacility/GetFacilityHandler.php @@ -96,6 +96,7 @@ public function __invoke(GetFacilityQuery $query): GetFacilityResult levelIndex: $facility->levelIndex(), elevationMeters: $facility->elevationMeters(), heightMeters: $facility->heightMeters(), + customerId: $facility->customerId(), geometryIssue: $this->spatial->geometryIssue($context, (string) $facilityId, $geometry), hierarchyIssues: $this->hierarchy->issuesFor((string) $organizationId, [(string) $facilityId])[(string) $facilityId] ?? [], ); diff --git a/src/Facility/Application/UseCase/Query/Facility/GetFacility/GetFacilityResult.php b/src/Facility/Application/UseCase/Query/Facility/GetFacility/GetFacilityResult.php index f9837260b..7eec3ba5e 100644 --- a/src/Facility/Application/UseCase/Query/Facility/GetFacility/GetFacilityResult.php +++ b/src/Facility/Application/UseCase/Query/Facility/GetFacility/GetFacilityResult.php @@ -58,6 +58,7 @@ public function __construct( public ?int $levelIndex = null, public ?float $elevationMeters = null, public ?float $heightMeters = null, + public ?string $customerId = null, public ?string $geometryIssue = null, /** * @var list diff --git a/src/Facility/Application/UseCase/Query/Facility/GetFacilityChildren/GetFacilityChildrenHandler.php b/src/Facility/Application/UseCase/Query/Facility/GetFacilityChildren/GetFacilityChildrenHandler.php index 3b2655a39..e0bacf7d6 100644 --- a/src/Facility/Application/UseCase/Query/Facility/GetFacilityChildren/GetFacilityChildrenHandler.php +++ b/src/Facility/Application/UseCase/Query/Facility/GetFacilityChildren/GetFacilityChildrenHandler.php @@ -115,6 +115,7 @@ public function __invoke(GetFacilityChildrenQuery $query): PaginatedResult levelIndex: $child->levelIndex(), elevationMeters: $child->elevationMeters(), heightMeters: $child->heightMeters(), + customerId: $child->customerId(), recordStatus: $projectionContexts[(string) $child->id()]['recordStatus'] ?? 'published', interventionId: $projectionContexts[(string) $child->id()]['interventionId'] ?? null, revision: $projectionContexts[(string) $child->id()]['revision'] ?? 1, diff --git a/src/Facility/Application/UseCase/Query/Facility/GetFacilityDescendants/GetFacilityDescendantsHandler.php b/src/Facility/Application/UseCase/Query/Facility/GetFacilityDescendants/GetFacilityDescendantsHandler.php index 20becb7f6..884c0ee8c 100644 --- a/src/Facility/Application/UseCase/Query/Facility/GetFacilityDescendants/GetFacilityDescendantsHandler.php +++ b/src/Facility/Application/UseCase/Query/Facility/GetFacilityDescendants/GetFacilityDescendantsHandler.php @@ -132,6 +132,7 @@ public function __invoke(GetFacilityDescendantsQuery $query): GetFacilityDescend levelIndex: $facility->levelIndex(), elevationMeters: $facility->elevationMeters(), heightMeters: $facility->heightMeters(), + customerId: $facility->customerId(), recordStatus: $projectionContexts[(string) $facility->id()]['recordStatus'] ?? 'published', interventionId: $projectionContexts[(string) $facility->id()]['interventionId'] ?? null, revision: $projectionContexts[(string) $facility->id()]['revision'] ?? 1, diff --git a/src/Facility/Application/UseCase/Query/Facility/ListFacilities/ListFacilitiesHandler.php b/src/Facility/Application/UseCase/Query/Facility/ListFacilities/ListFacilitiesHandler.php index 635043771..9527a00e7 100644 --- a/src/Facility/Application/UseCase/Query/Facility/ListFacilities/ListFacilitiesHandler.php +++ b/src/Facility/Application/UseCase/Query/Facility/ListFacilities/ListFacilitiesHandler.php @@ -48,6 +48,7 @@ public function __construct( private FacilityRepositoryPort $facilityRepository, private FacilityEquipmentDependencyPort $equipmentDependency, private FacilityHierarchyPort $hierarchy, + private ?\Customer\Application\Port\Inbound\CustomerLookupPort $customers = null, ) { } // #endregion @@ -66,6 +67,9 @@ public function __construct( */ public function __invoke(ListFacilitiesQuery $query): PaginatedResult { + if (null !== $query->customerId && null === $this->customers?->find($query->customerId, $query->organizationId)) { + throw new \Facility\Domain\Exception\FacilityCustomerScopeNotFoundException(); + } [$organizationId, $criteria] = $this->criteria($query); $facilities = $this->facilityRepository->findByOrganizationId( @@ -161,6 +165,7 @@ private function criteria(ListFacilitiesQuery $query): array eligibleParentIds: $eligibleParentIds, parentInterventionId: $interventionId, includePublishedParents: $query->includePublishedParents, + customerId: $query->customerId, ); return [$organizationId, $criteria]; @@ -295,6 +300,7 @@ private function projectFacility(Facility $facility, array $childCounts, array $ levelIndex: $facility->levelIndex(), elevationMeters: $facility->elevationMeters(), heightMeters: $facility->heightMeters(), + customerId: $facility->customerId(), recordStatus: $projectionContexts[(string) $facility->id()]['recordStatus'] ?? 'published', interventionId: $projectionContexts[(string) $facility->id()]['interventionId'] ?? null, revision: $projectionContexts[(string) $facility->id()]['revision'] ?? 1, diff --git a/src/Facility/Application/UseCase/Query/Facility/ListFacilities/ListFacilitiesQuery.php b/src/Facility/Application/UseCase/Query/Facility/ListFacilities/ListFacilitiesQuery.php index 673235f5b..e8ab7b59c 100644 --- a/src/Facility/Application/UseCase/Query/Facility/ListFacilities/ListFacilitiesQuery.php +++ b/src/Facility/Application/UseCase/Query/Facility/ListFacilities/ListFacilitiesQuery.php @@ -62,6 +62,7 @@ public function __construct( public ?string $parentForFacilityId = null, public ?string $interventionId = null, public bool $includePublishedParents = true, + public ?string $customerId = null, ) { } // #endregion diff --git a/src/Facility/Domain/Exception/FacilityCustomerAssignmentException.php b/src/Facility/Domain/Exception/FacilityCustomerAssignmentException.php new file mode 100644 index 000000000..0a02c7ae3 --- /dev/null +++ b/src/Facility/Domain/Exception/FacilityCustomerAssignmentException.php @@ -0,0 +1,16 @@ +levelIndex), elevationMeters: $version->elevationMeters, heightMeters: $version->heightMeters, + customerId: $version->customerId, ); } @@ -148,6 +150,7 @@ public function applyPatch(CanonicalFacilityPatch $patch, ?CanonicalFacilityPare 'type' => $this->type, 'name' => $this->name, 'code' => $this->code, + 'customerId' => $this->customerId, 'address' => $this->address, 'latitude' => $this->latitude, 'longitude' => $this->longitude, @@ -385,6 +388,14 @@ public function code(): ?string return $this->code; } + /** + * Method customerId. Returns the root site's retained customer assignment. + */ + public function customerId(): ?string + { + return $this->customerId; + } + /** * Method address. * @@ -509,6 +520,13 @@ public function updatedAt(): DateTimeImmutable */ private function applyFields(CanonicalFacilityPatch $patch, ?CanonicalFacilityParent $parent): void { + $customerId = $patch->hasCustomerId ? $patch->customerId : $this->customerId; + $customerType = $patch->hasType && null !== $patch->type + ? (FacilityType::tryFrom($patch->type) ?? throw CanonicalFacilityValidationException::unsupportedValue('type', $patch->type)) + : $this->type; + $customerParent = $patch->hasParent ? $parent?->id : $this->parentFacilityId; + \Facility\Domain\ValueObject\FacilityCustomerReference::assertValid($customerId, $customerType, $customerParent); + $this->customerId = $customerId; $type = $patch->hasType && null !== $patch->type ? (FacilityType::tryFrom($patch->type) ?? throw CanonicalFacilityValidationException::unsupportedValue('type', $patch->type)) : $this->type; @@ -603,7 +621,7 @@ private function applyOtherFields(CanonicalFacilityPatch $patch, ?CanonicalFacil private function changedFields(array $previous): array { $changedFields = []; - foreach (['type', 'name', 'code', 'address', 'levelIndex', 'elevationMeters', 'heightMeters'] as $field) { + foreach (['type', 'name', 'code', 'customerId', 'address', 'levelIndex', 'elevationMeters', 'heightMeters'] as $field) { if ($previous[$field] !== $this->{$field}) { $changedFields[] = $field; } diff --git a/src/Facility/Domain/Model/Facility/CanonicalFacilityVersion.php b/src/Facility/Domain/Model/Facility/CanonicalFacilityVersion.php index 6f8ce2306..55a1ddd17 100644 --- a/src/Facility/Domain/Model/Facility/CanonicalFacilityVersion.php +++ b/src/Facility/Domain/Model/Facility/CanonicalFacilityVersion.php @@ -34,6 +34,7 @@ public function __construct( public ?int $levelIndex = null, public ?float $elevationMeters = null, public ?float $heightMeters = null, + public ?string $customerId = null, ) { } // #endregion diff --git a/src/Facility/Domain/Model/Facility/Facility.php b/src/Facility/Domain/Model/Facility/Facility.php index bb8d5d007..eb36e4ad3 100644 --- a/src/Facility/Domain/Model/Facility/Facility.php +++ b/src/Facility/Domain/Model/Facility/Facility.php @@ -78,6 +78,7 @@ private function __construct( private ?float $elevationMeters = null, private ?float $heightMeters = null, private ?array $unusablePlanGeometry = null, + private ?string $customerId = null, ) { } // #endregion @@ -107,6 +108,7 @@ public static function create( $now = new DateTimeImmutable(); $details ??= new FacilityDetails(); FacilityFloorMetrics::assertValid($type, $details->elevationMeters, $details->heightMeters); + \Facility\Domain\ValueObject\FacilityCustomerReference::assertValid($details->customerId, $type, $details->parentFacilityId?->__toString()); return new self( id: $id, @@ -125,6 +127,7 @@ public static function create( levelIndex: self::normalizeLevelIndex($details->levelIndex), elevationMeters: $details->elevationMeters, heightMeters: $details->heightMeters, + customerId: $details->customerId, ); } @@ -172,6 +175,7 @@ public static function reconstitute( coordinates: $details->coordinates, planGeometry: $geometryState?->planGeometry, unusablePlanGeometry: $geometryState?->unusablePlanGeometry, + customerId: $details->customerId, levelIndex: self::normalizeLevelIndex($details->levelIndex), elevationMeters: $details->elevationMeters, heightMeters: $details->heightMeters, @@ -196,6 +200,7 @@ public function rename(FacilityName $name): void */ public function changeType(FacilityType $type): void { + \Facility\Domain\ValueObject\FacilityCustomerReference::assertValid($this->customerId, $type, $this->parentFacilityId?->__toString()); $this->type = $type; if (FacilityType::FLOOR !== $type) { $this->elevationMeters = null; @@ -211,6 +216,7 @@ public function changeType(FacilityType $type): void */ public function moveTo(?FacilityId $parentFacilityId): void { + \Facility\Domain\ValueObject\FacilityCustomerReference::assertValid($this->customerId, $this->type, $parentFacilityId?->__toString()); $this->parentFacilityId = $parentFacilityId; $this->touch(); } @@ -417,6 +423,24 @@ public function code(): ?string return $this->code; } + /** + * Method customerId. Returns the optional customer assigned to this root site. + */ + public function customerId(): ?string + { + return $this->customerId; + } + + /** + * Method assignCustomer. Retains customer ownership only on root sites. + */ + public function assignCustomer(?string $customerId): void + { + \Facility\Domain\ValueObject\FacilityCustomerReference::assertValid($customerId, $this->type, $this->parentFacilityId?->__toString()); + $this->customerId = $customerId; + $this->touch(); + } + /** * Method status. * diff --git a/src/Facility/Domain/Model/Facility/FacilityDetails.php b/src/Facility/Domain/Model/Facility/FacilityDetails.php index 02da1adef..3c07e2d3a 100644 --- a/src/Facility/Domain/Model/Facility/FacilityDetails.php +++ b/src/Facility/Domain/Model/Facility/FacilityDetails.php @@ -23,6 +23,7 @@ public function __construct( public ?int $levelIndex = null, public ?float $elevationMeters = null, public ?float $heightMeters = null, + public ?string $customerId = null, ) { } } diff --git a/src/Facility/Domain/ValueObject/CanonicalFacilityPatch.php b/src/Facility/Domain/ValueObject/CanonicalFacilityPatch.php index acb50dc03..80c5f74d9 100644 --- a/src/Facility/Domain/ValueObject/CanonicalFacilityPatch.php +++ b/src/Facility/Domain/ValueObject/CanonicalFacilityPatch.php @@ -92,6 +92,8 @@ public function __construct( public ?float $elevationMeters = null, public bool $hasHeightMeters = false, public ?float $heightMeters = null, + public ?string $customerId = null, + public bool $hasCustomerId = false, ) { } // #endregion diff --git a/src/Facility/Domain/ValueObject/FacilityCustomerReference.php b/src/Facility/Domain/ValueObject/FacilityCustomerReference.php new file mode 100644 index 000000000..5337b4afb --- /dev/null +++ b/src/Facility/Domain/ValueObject/FacilityCustomerReference.php @@ -0,0 +1,23 @@ + + */ +final readonly class FacilityCustomerScopeAdapter implements FacilityCustomerScopePort +{ + /** + * @since 1.0.0 + */ + public function __construct(private EntityManagerInterface $entityManager) + { + } + + /** + * @since 1.0.0 + * + * @return list + */ + public function findPublishedIdsForCustomer(string $organizationId, string $customerId): array + { + $sql = <<<'SQL' + WITH RECURSIVE portfolio AS ( + SELECT id FROM facilities + WHERE organization_id = :organizationId AND record_status = 'published' + AND parent_facility_id IS NULL AND type = 'site' AND customer_id = :customerId + UNION + SELECT child.id FROM facilities child INNER JOIN portfolio ON child.parent_facility_id = portfolio.id + WHERE child.organization_id = :organizationId AND child.record_status = 'published' + ) + SELECT id FROM portfolio ORDER BY id + SQL; + + /** @var list */ + return $this->entityManager->getConnection()->executeQuery($sql, ['organizationId' => $organizationId, 'customerId' => $customerId])->fetchFirstColumn(); + } +} diff --git a/src/Facility/Infrastructure/Adapter/Intervention/FacilityInterventionResourceAdapter.php b/src/Facility/Infrastructure/Adapter/Intervention/FacilityInterventionResourceAdapter.php index 409fc30d1..d6022e0b8 100644 --- a/src/Facility/Infrastructure/Adapter/Intervention/FacilityInterventionResourceAdapter.php +++ b/src/Facility/Infrastructure/Adapter/Intervention/FacilityInterventionResourceAdapter.php @@ -62,6 +62,7 @@ public function __construct( int $maxDepth = 8, private ?\Facility\Application\Port\Inbound\FacilityHierarchyPort $hierarchy = null, ?\Facility\Application\Service\FacilityHierarchyPublicationContext $publicationContext = null, + private ?\Facility\Application\Port\Outbound\FacilityCustomerValidationPort $customers = null, ) { $this->patchApplier = new FacilityInterventionPatchApplier( $entityManager, @@ -73,6 +74,7 @@ public function __construct( $maxDepth, $hierarchy, $publicationContext, + $customers, ); } @@ -303,6 +305,10 @@ public function publishDrafts(string $interventionId): void 'recordStatus' => 'draft', ]); foreach ($records as $record) { + \Facility\Domain\ValueObject\FacilityCustomerReference::assertValid($record->customerId, \Facility\Domain\ValueObject\FacilityType::from($record->type), $record->parentFacility?->id); + if (null !== $record->customerId) { + ($this->customers ?? throw new \Facility\Application\Contract\FacilityCustomerUnavailable())->assertAssignable($record->customerId, $record->organizationId()); + } $record->recordStatus = 'published'; $record->updatedAt = new DateTimeImmutable(); } diff --git a/src/Facility/Infrastructure/Adapter/Intervention/InterventionEconomicScopeAdapter.php b/src/Facility/Infrastructure/Adapter/Intervention/InterventionEconomicScopeAdapter.php new file mode 100644 index 000000000..4c47b1943 --- /dev/null +++ b/src/Facility/Infrastructure/Adapter/Intervention/InterventionEconomicScopeAdapter.php @@ -0,0 +1,84 @@ + unique matching root and descendant identities + */ + public function facilityIds(string $organizationId, ?string $siteId, ?string $customerId): array + { + if (null === $siteId && null === $customerId) { + return []; + } + $rootFilters = ''; + $parameters = ['organization' => $organizationId]; + if (null !== $siteId) { + $rootFilters .= ' AND id = :site'; + $parameters['site'] = $siteId; + } + if (null !== $customerId) { + $rootFilters .= ' AND customer_id = :customer'; + $parameters['customer'] = $customerId; + } + /** @var list $ids */ + $ids = $this->entityManager->getConnection()->fetchFirstColumn("WITH RECURSIVE scope AS ( + SELECT id, ARRAY[id::text] AS path FROM facilities + WHERE organization_id = :organization AND record_status = 'published' AND type = 'site' AND parent_facility_id IS NULL" . $rootFilters . " + UNION ALL + SELECT f.id, s.path || f.id::text FROM facilities f JOIN scope s ON f.parent_facility_id = s.id + WHERE f.organization_id = :organization AND f.record_status = 'published' + AND NOT f.id::text = ANY(s.path) AND cardinality(s.path) < 64 + ) SELECT id FROM scope ORDER BY id LIMIT 10001", $parameters); + if (count($ids) > 10000) { + throw new InterventionFactsScopeTooLarge('The facility scope exceeds 10000 locations; narrow the requested scope.'); + } + + return $ids; + } + // #endregion +} diff --git a/src/Facility/Infrastructure/Adapter/Intervention/InterventionSiteCustomerSnapshotAdapter.php b/src/Facility/Infrastructure/Adapter/Intervention/InterventionSiteCustomerSnapshotAdapter.php new file mode 100644 index 000000000..bc33dfb33 --- /dev/null +++ b/src/Facility/Infrastructure/Adapter/Intervention/InterventionSiteCustomerSnapshotAdapter.php @@ -0,0 +1,60 @@ +>}} + */ + public function snapshot(string $organizationId, ?string $siteId): array + { + if (null === $siteId) { + return ['site' => null, 'customer' => null]; + } + /** @var array{id:string,name:string}|false $row */ + $row = $this->entityManager->getConnection()->fetchAssociative("SELECT id, name FROM facilities WHERE id = :id AND organization_id = :organization AND record_status = 'published'", ['id' => $siteId, 'organization' => $organizationId]); + if (false === $row) { + throw new FacilityCustomerUnavailable(); + } + /** @var array{customer_id:?string}|false $root */ + $root = $this->entityManager->getConnection()->fetchAssociative(<<<'SQL' + WITH RECURSIVE ancestry AS ( + SELECT id, type, parent_facility_id, customer_id, ARRAY[id::text] AS path + FROM facilities + WHERE id = :id AND organization_id = :organization AND record_status = 'published' + UNION ALL + SELECT p.id, p.type, p.parent_facility_id, p.customer_id, a.path || p.id::text + FROM facilities p JOIN ancestry a ON p.id = a.parent_facility_id + WHERE p.organization_id = :organization AND p.record_status = 'published' + AND NOT p.id::text = ANY(a.path) AND cardinality(a.path) < 64 + ) + SELECT customer_id FROM ancestry WHERE type = 'site' AND parent_facility_id IS NULL + SQL, ['id' => $siteId, 'organization' => $organizationId]); + $customerId = false === $root ? null : $root['customer_id']; + $customer = null === $customerId ? null : $this->customers->find($customerId, $organizationId); + if (null !== $customerId && null === $customer) { + throw new FacilityCustomerUnavailable(); + } + + return ['site' => ['id' => $row['id'], 'name' => $row['name']], 'customer' => null === $customer ? null : ['id' => $customer->id, 'name' => $customer->name, 'contacts' => $customer->contacts]]; + } +} diff --git a/src/Facility/Infrastructure/Adapter/Maintenance/FacilityMaintenanceLifecycleAdapter.php b/src/Facility/Infrastructure/Adapter/Maintenance/FacilityMaintenanceLifecycleAdapter.php new file mode 100644 index 000000000..511dbdfa4 --- /dev/null +++ b/src/Facility/Infrastructure/Adapter/Maintenance/FacilityMaintenanceLifecycleAdapter.php @@ -0,0 +1,32 @@ +entityManager->getConnection()->fetchOne( + "WITH RECURSIVE ancestry AS ( + SELECT id, parent_facility_id, status FROM facilities WHERE id = :id AND organization_id = :organization AND record_status = 'published' + UNION + SELECT f.id, f.parent_facility_id, f.status FROM facilities f JOIN ancestry a ON f.id = a.parent_facility_id WHERE f.organization_id = :organization + ) SELECT CASE WHEN NOT EXISTS (SELECT 1 FROM ancestry) OR EXISTS (SELECT 1 FROM ancestry WHERE status = 'archived') THEN 1 ELSE 0 END", + ['id' => $facilityId, 'organization' => $organizationId], + ); + } +} diff --git a/src/Facility/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportSiteIdentityAdapter.php b/src/Facility/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportSiteIdentityAdapter.php new file mode 100644 index 000000000..a7ac99a12 --- /dev/null +++ b/src/Facility/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportSiteIdentityAdapter.php @@ -0,0 +1,63 @@ +entityManager->getConnection()->fetchOne( + "SELECT 1 FROM facilities WHERE organization_id = :organization AND id = :resource AND type = 'site' AND parent_facility_id IS NULL AND record_status = 'published' LIMIT 1", + ['organization' => $organizationId, 'resource' => $resourceId], + ); + } + // #endregion +} diff --git a/src/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapter.php b/src/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapter.php new file mode 100644 index 000000000..c16121d3c --- /dev/null +++ b/src/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapter.php @@ -0,0 +1,88 @@ +hierarchy->lock($organizationId); + } + + public function find(string $organizationId, ?string $siteId, ?string $facilityId): ?ServiceRequestSiteTarget + { + $targetId = $facilityId ?? $siteId; + if (null === $targetId) { + return null; + } + $connection = $this->entityManager->getConnection(); + /** @var list $rows */ + $rows = $connection->fetchAllAssociative(<<<'SQL' + WITH RECURSIVE ancestry AS ( + SELECT id, name, type, parent_facility_id, customer_id, status, ARRAY[id::text] AS path + FROM facilities WHERE id = :target AND organization_id = :organization AND record_status = 'published' + UNION ALL + SELECT p.id, p.name, p.type, p.parent_facility_id, p.customer_id, p.status, a.path || p.id::text + FROM facilities p JOIN ancestry a ON p.id = a.parent_facility_id + WHERE p.organization_id = :organization AND p.record_status = 'published' + AND NOT p.id::text = ANY(a.path) AND cardinality(a.path) < 64 + ) + SELECT id, name, type, parent_facility_id, customer_id, status FROM ancestry + SQL, ['target' => $targetId, 'organization' => $organizationId]); + if ([] === $rows) { + return null; + } + $root = null; + $archived = false; + foreach ($rows as $row) { + $archived = $archived || 'archived' === $row['status']; + if ('site' === $row['type'] && null === $row['parent_facility_id']) { + $root = $row; + } + } + if (null === $root || (null !== $siteId && $siteId !== $root['id'])) { + return null; + } + if ($connection->isTransactionActive()) { + $ids = array_map(static fn (array $row): string => $row['id'], $rows); + $placeholders = []; + $parameters = ['organization' => $organizationId]; + foreach ($ids as $index => $id) { + $key = 'id' . $index; + $placeholders[] = ':' . $key; + $parameters[$key] = $id; + } + /** @var list $locked */ + $locked = $connection->fetchAllAssociative('SELECT id, status FROM facilities WHERE organization_id = :organization AND id IN (' . implode(', ', $placeholders) . ") AND record_status = 'published' ORDER BY id FOR SHARE", $parameters); + if (count($locked) !== count($rows)) { + return null; + } + foreach ($locked as $row) { + $archived = $archived || 'archived' === $row['status']; + } + } + $customer = null === $root['customer_id'] ? null : $this->customers->find($root['customer_id'], $organizationId); + if (null !== $root['customer_id'] && null === $customer) { + return null; + } + + return new ServiceRequestSiteTarget($root['id'], $root['name'], $archived, null === $customer ? null : ['id' => $customer->id, 'name' => $customer->name]); + } +} diff --git a/src/Facility/Infrastructure/Persistence/Doctrine/Mapper/CanonicalFacilityMapper.php b/src/Facility/Infrastructure/Persistence/Doctrine/Mapper/CanonicalFacilityMapper.php index 35b0e6d55..8a7a75418 100644 --- a/src/Facility/Infrastructure/Persistence/Doctrine/Mapper/CanonicalFacilityMapper.php +++ b/src/Facility/Infrastructure/Persistence/Doctrine/Mapper/CanonicalFacilityMapper.php @@ -58,7 +58,7 @@ public static function toDomain(FacilityRecord $record): CanonicalFacility $record->longitude, $record->metadata, ), - version: new CanonicalFacilityVersion(FacilityStatus::from($record->status), $record->revision, $record->updatedAt, $record->levelIndex, $record->elevationMeters, $record->heightMeters), + version: new CanonicalFacilityVersion(FacilityStatus::from($record->status), $record->revision, $record->updatedAt, $record->levelIndex, $record->elevationMeters, $record->heightMeters, $record->customerId), ); } @@ -83,6 +83,7 @@ public static function applyTo(CanonicalFacility $facility, FacilityRecord $reco $record->type = $facility->type()->value; $record->name = $facility->name(); $record->code = $facility->code(); + $record->customerId = $facility->customerId(); $record->address = $facility->address(); $record->latitude = $facility->latitude(); $record->longitude = $facility->longitude(); diff --git a/src/Facility/Infrastructure/Persistence/Doctrine/Mapper/FacilityMapper.php b/src/Facility/Infrastructure/Persistence/Doctrine/Mapper/FacilityMapper.php index 4f7c44fb2..249322860 100644 --- a/src/Facility/Infrastructure/Persistence/Doctrine/Mapper/FacilityMapper.php +++ b/src/Facility/Infrastructure/Persistence/Doctrine/Mapper/FacilityMapper.php @@ -69,6 +69,7 @@ public static function toDomain(FacilityRecord $record): Facility levelIndex: $record->levelIndex, elevationMeters: $record->elevationMeters, heightMeters: $record->heightMeters, + customerId: $record->customerId, ), geometryState: new FacilityGeometryState($planGeometry, null === $planGeometry ? $record->planGeometry : null), ); @@ -92,6 +93,7 @@ public static function toRecord(Facility $facility): FacilityRecord $record->type = $facility->type()->value; $record->name = (string) $facility->name(); $record->code = $facility->code(); + $record->customerId = $facility->customerId(); $record->status = $facility->status()->value; $record->address = $facility->address(); $record->latitude = $facility->coordinates()?->latitude(); diff --git a/src/Facility/Infrastructure/Persistence/Doctrine/Record/FacilityRecord.php b/src/Facility/Infrastructure/Persistence/Doctrine/Record/FacilityRecord.php index 506cfe606..f81a9553d 100644 --- a/src/Facility/Infrastructure/Persistence/Doctrine/Record/FacilityRecord.php +++ b/src/Facility/Infrastructure/Persistence/Doctrine/Record/FacilityRecord.php @@ -22,6 +22,7 @@ #[ORM\Table(name: 'facilities')] #[ORM\Index(name: 'idx_facility_organization', columns: ['organization_id'])] #[ORM\Index(name: 'idx_facility_parent', columns: ['parent_facility_id'])] +#[ORM\Index(name: 'idx_facility_customer', columns: ['customer_id'])] #[ORM\Index(name: 'idx_facility_type', columns: ['type'])] #[ORM\Index(name: 'idx_facility_status', columns: ['status'])] #[ORM\Index(name: 'idx_facility_organization_type', columns: ['organization_id', 'type'])] @@ -67,6 +68,12 @@ class FacilityRecord #[ORM\Column(name: 'client_id', type: 'string', length: 36, nullable: true, unique: true)] public ?string $clientId = null; + /** + * Property customerId. Optional internal customer assigned to a root site. + */ + #[ORM\Column(name: 'customer_id', type: 'string', length: 36, nullable: true)] + public ?string $customerId = null; + /** * Property recordStatus. * diff --git a/src/Facility/Infrastructure/Persistence/Doctrine/Repository/FacilityOrganizationQueryRepository.php b/src/Facility/Infrastructure/Persistence/Doctrine/Repository/FacilityOrganizationQueryRepository.php index 5efc8f2e8..d77f82d4a 100644 --- a/src/Facility/Infrastructure/Persistence/Doctrine/Repository/FacilityOrganizationQueryRepository.php +++ b/src/Facility/Infrastructure/Persistence/Doctrine/Repository/FacilityOrganizationQueryRepository.php @@ -406,6 +406,9 @@ protected function createListQueryBuilder( ->setParameter('organization', $organization); $this->applyRecordVisibility($queryBuilder, $criteria); + if (null !== $criteria->customerId) { + $queryBuilder->andWhere('f.customerId = :customerId')->setParameter('customerId', $criteria->customerId); + } if (null === $criteria->status && !$includeArchived) { $queryBuilder diff --git a/src/Facility/Infrastructure/Persistence/Doctrine/Repository/FacilityRepository.php b/src/Facility/Infrastructure/Persistence/Doctrine/Repository/FacilityRepository.php index f8ad24749..4806d3cf3 100644 --- a/src/Facility/Infrastructure/Persistence/Doctrine/Repository/FacilityRepository.php +++ b/src/Facility/Infrastructure/Persistence/Doctrine/Repository/FacilityRepository.php @@ -57,6 +57,7 @@ public function save(Facility $facility): void $existing->type = $record->type; $existing->name = $record->name; $existing->code = $record->code; + $existing->customerId = $record->customerId; $existing->status = $record->status; $existing->address = $record->address; $existing->latitude = $record->latitude; diff --git a/src/Facility/Infrastructure/Service/Intervention/FacilityInterventionPatchApplier.php b/src/Facility/Infrastructure/Service/Intervention/FacilityInterventionPatchApplier.php index d0cc48156..b797a7899 100644 --- a/src/Facility/Infrastructure/Service/Intervention/FacilityInterventionPatchApplier.php +++ b/src/Facility/Infrastructure/Service/Intervention/FacilityInterventionPatchApplier.php @@ -47,7 +47,7 @@ * * @var list */ - private const PATCHABLE_FIELDS = ['type', 'name', 'code', 'address', 'metadata', 'status', 'parent', 'latitude', 'longitude', 'planGeometry', 'elevationMeters', 'heightMeters']; + private const PATCHABLE_FIELDS = ['type', 'name', 'code', 'customerId', 'address', 'metadata', 'status', 'parent', 'latitude', 'longitude', 'planGeometry', 'elevationMeters', 'heightMeters']; /** * Constant STATUSES. @@ -101,6 +101,7 @@ public function __construct( private int $maxDepth, private ?\Facility\Application\Port\Inbound\FacilityHierarchyPort $hierarchy = null, private ?\Facility\Application\Service\FacilityHierarchyPublicationContext $publicationContext = null, + private ?\Facility\Application\Port\Outbound\FacilityCustomerValidationPort $customers = null, ) { } // #endregion @@ -138,6 +139,7 @@ public function apply(string $organizationId, string $resource, array $patch): v $this->applyPlanGeometry($record, $patch); $this->applyStatus($record, $patch); $this->applyParent($organizationId, $record, $patch); + $this->applyCustomer($organizationId, $record, $patch); // Restoring (archived -> active) is refused while the parent is archived, // mirroring the RestoreFacility use case and the canonical mutation processor. @@ -571,5 +573,21 @@ private function assertPatchFields(array $patch): void throw new FacilityPatchConflictException(sprintf('Unsupported facility patch fields: %s.', implode(', ', $unknown))); } } + + /** + * @param array $patch + */ + private function applyCustomer(string $organizationId, FacilityRecord $record, array $patch): void + { + $customerId = array_key_exists('customerId', $patch) ? $patch['customerId'] : $record->customerId; + if (null !== $customerId && !is_string($customerId)) { + throw new FacilityPatchConflictException('Customer identifier must be a string or null.'); + } + \Facility\Domain\ValueObject\FacilityCustomerReference::assertValid($customerId, FacilityType::from($record->type), $record->parentFacility?->id); + if (null !== $customerId && $customerId !== $record->customerId) { + ($this->customers ?? throw new \Facility\Application\Contract\FacilityCustomerUnavailable())->assertAssignable($customerId, $organizationId); + } + $record->customerId = $customerId; + } // #endregion } diff --git a/src/Facility/MODULE.md b/src/Facility/MODULE.md index 062167e33..bbc9a8dc6 100644 --- a/src/Facility/MODULE.md +++ b/src/Facility/MODULE.md @@ -8,6 +8,31 @@ committed creations; direct non-transactional callers retain synchronous dispatc ## Overview +The V3 economic-scope bridge resolves optional root-site and internal-client filters +for authorized Intervention source readers. It returns published root and descendant +identifiers, including archived locations, with organization fencing on every +recursive edge. Unknown or foreign filters return an empty scope. The depth is +bounded to 64 and the result to 10000 locations; oversized scopes require a narrower +filter rather than silently truncating an economic report. Published report filters +remain the Intervention owner's captured identities and do not call this live bridge. + +### Internal customer assignment + +Root SITE facilities may carry nullable `customerId`, independent from the +offline replay `clientId`. New assignments require an active internal customer in +the same organization through `FacilityCustomerValidationPort`; unknown, foreign +and archived clients produce the same `facility_customer_invalid` (422). +Only sites without a parent may carry this field. Both aggregates, canonical and +legacy writes, intervention proposals and draft publication enforce the invariant. +Retained archived customer links stay readable and accept unrelated edits. +Clearing the assignment uses explicit null. Duplicating a site starts unassigned. + +Legacy facility lists accept `customerId` to filter root sites, with identical +criteria for records and total; unknown and foreign customers return 404 after +facility access checks. Detail/list responses and offline snapshots include the +stored customer identifier. Intervention closure snapshots freeze the site's +identity and current customer name/contacts through owner-published ports. + ### Hierarchy consistency (2026-10-03) `FacilityHierarchyPort` is the shared creation/relation policy. SITE is the only @@ -980,6 +1005,11 @@ counts and ancestry the write path has no reason to carry). ## Configuration +Maintenance exports validate published root-site identities through the owner-hosted +`MaintenanceExportSiteIdentityAdapter`, explicitly wired to main and tagged +`maintenance_export.identity_validator`. Archived root sites remain valid historical +references; drafts, buildings, nested sites and foreign identities return false. + - Service wiring: `config/modules/facility.yaml` - `CanonicalFacilityRepositoryPort` is aliased to `CanonicalFacilityRepository`, wired to `main` explicitly. It is a diff --git a/src/Facility/Presentation/Api/Dto/Input/Facility/CreateFacilityInput.php b/src/Facility/Presentation/Api/Dto/Input/Facility/CreateFacilityInput.php index aad157568..d87a66d48 100644 --- a/src/Facility/Presentation/Api/Dto/Input/Facility/CreateFacilityInput.php +++ b/src/Facility/Presentation/Api/Dto/Input/Facility/CreateFacilityInput.php @@ -181,5 +181,13 @@ final class CreateFacilityInput #[Groups([FacilitySerializationGroup::WRITE])] #[ApiProperty(description: 'Optional floor height in meters', required: false, example: 3.0)] public ?float $heightMeters = null; + + /** + * Property customerId. Optional internal customer assigned only to root sites. + */ + #[Assert\Uuid] + #[Groups([FacilitySerializationGroup::WRITE])] + #[ApiProperty(description: 'Internal customer identifier for a root site', required: false)] + public ?string $customerId = null; // #endregion } diff --git a/src/Facility/Presentation/Api/Dto/Input/Facility/PatchCanonicalFacilityInput.php b/src/Facility/Presentation/Api/Dto/Input/Facility/PatchCanonicalFacilityInput.php index a1088cf7f..542f00da8 100644 --- a/src/Facility/Presentation/Api/Dto/Input/Facility/PatchCanonicalFacilityInput.php +++ b/src/Facility/Presentation/Api/Dto/Input/Facility/PatchCanonicalFacilityInput.php @@ -40,6 +40,12 @@ final class PatchCanonicalFacilityInput #[Assert\Length(max: 80)] public ?string $code = null; + /** + * Property customerId. Optional internal customer identifier for a root site. + */ + #[Assert\Uuid] + public ?string $customerId = null; + /** * Property address. * diff --git a/src/Facility/Presentation/Api/Dto/Input/Facility/UpdateFacilityInput.php b/src/Facility/Presentation/Api/Dto/Input/Facility/UpdateFacilityInput.php index 3d292ea0d..fe103d91d 100644 --- a/src/Facility/Presentation/Api/Dto/Input/Facility/UpdateFacilityInput.php +++ b/src/Facility/Presentation/Api/Dto/Input/Facility/UpdateFacilityInput.php @@ -123,5 +123,13 @@ final class UpdateFacilityInput #[Groups([FacilitySerializationGroup::WRITE])] #[ApiProperty(description: 'Optional floor height in meters', required: false, example: 3.0)] public ?float $heightMeters = null; + + /** + * Property customerId. Optional internal customer assigned only to root sites. + */ + #[Assert\Uuid] + #[Groups([FacilitySerializationGroup::WRITE])] + #[ApiProperty(description: 'Internal customer identifier for a root site', required: false)] + public ?string $customerId = null; // #endregion } diff --git a/src/Facility/Presentation/Api/Dto/Output/Facility/FacilityOutput.php b/src/Facility/Presentation/Api/Dto/Output/Facility/FacilityOutput.php index 58cd6e7b4..2d9a6178b 100644 --- a/src/Facility/Presentation/Api/Dto/Output/Facility/FacilityOutput.php +++ b/src/Facility/Presentation/Api/Dto/Output/Facility/FacilityOutput.php @@ -197,6 +197,13 @@ final class FacilityOutput #[ApiProperty(readable: true, writable: false)] public ?float $heightMeters = null; + /** + * Property customerId. Retained internal customer identifier, separate from replay clientId. + */ + #[Groups([FacilitySerializationGroup::READ])] + #[ApiProperty(description: 'Optional internal customer identifier on root sites')] + public ?string $customerId = null; + /** * Property planGeometry. * diff --git a/src/Facility/Presentation/Api/EventSubscriber/FacilityCustomerExceptionSubscriber.php b/src/Facility/Presentation/Api/EventSubscriber/FacilityCustomerExceptionSubscriber.php new file mode 100644 index 000000000..013119dee --- /dev/null +++ b/src/Facility/Presentation/Api/EventSubscriber/FacilityCustomerExceptionSubscriber.php @@ -0,0 +1,42 @@ + + */ + public static function getSubscribedEvents(): array + { + return [KernelEvents::EXCEPTION => ['onException', 15]]; + } + + public function onException(ExceptionEvent $event): void + { + $error = $event->getThrowable(); + do { + if ($error instanceof FacilityCustomerScopeNotFoundException) { + $event->setResponse(new JsonResponse(['type' => '/errors/facility_customer_not_found', 'title' => 'Customer not found', 'status' => 404, 'code' => 'facility_customer_not_found', 'detail' => $error->getMessage()], 404, ['Content-Type' => 'application/problem+json'])); + + return; + } + if ($error instanceof FacilityCustomerUnavailable || $error instanceof FacilityCustomerAssignmentException) { + $event->setResponse(new JsonResponse(['type' => '/errors/facility_customer_invalid', 'title' => 'Facility customer assignment refused', 'status' => 422, 'code' => 'facility_customer_invalid', 'detail' => $error->getMessage()], 422, ['Content-Type' => 'application/problem+json'])); + + return; + } + $error = $error->getPrevious(); + } while (null !== $error); + } +} diff --git a/src/Facility/Presentation/Api/Factory/FacilityDetailOutputFactory.php b/src/Facility/Presentation/Api/Factory/FacilityDetailOutputFactory.php index 0c2edffbe..60db269b4 100644 --- a/src/Facility/Presentation/Api/Factory/FacilityDetailOutputFactory.php +++ b/src/Facility/Presentation/Api/Factory/FacilityDetailOutputFactory.php @@ -69,6 +69,7 @@ public function read(string $organizationId, string $id): FacilityOutput $output->levelIndex = $result->levelIndex; $output->elevationMeters = $result->elevationMeters; $output->heightMeters = $result->heightMeters; + $output->customerId = $result->customerId; $output->planGeometry = $result->planGeometry; $output->geometryIssue = $result->geometryIssue; $output->hierarchyIssues = $result->hierarchyIssues; diff --git a/src/Facility/Presentation/Api/Processor/Facility/CanonicalFacilityMutationProcessor.php b/src/Facility/Presentation/Api/Processor/Facility/CanonicalFacilityMutationProcessor.php index 437b0619b..99543b720 100644 --- a/src/Facility/Presentation/Api/Processor/Facility/CanonicalFacilityMutationProcessor.php +++ b/src/Facility/Presentation/Api/Processor/Facility/CanonicalFacilityMutationProcessor.php @@ -80,7 +80,7 @@ * * @var list */ - private const array PATCHABLE_FIELDS = ['type', 'name', 'code', 'address', 'latitude', 'longitude', 'metadata', 'status', 'levelIndex', 'elevationMeters', 'heightMeters']; + private const array PATCHABLE_FIELDS = ['type', 'name', 'code', 'customerId', 'address', 'latitude', 'longitude', 'metadata', 'status', 'levelIndex', 'elevationMeters', 'heightMeters']; // #endregion // #region Constructor @@ -166,6 +166,8 @@ public function process(mixed $data, Operation $operation, array $uriVariables = hasName: $present['name'], name: $data->name, hasCode: $present['code'], + hasCustomerId: $present['customerId'], + customerId: $data->customerId, code: $data->code, hasAddress: $present['address'], address: $data->address, diff --git a/src/Facility/Presentation/Api/Processor/Facility/CreateFacilityProcessor.php b/src/Facility/Presentation/Api/Processor/Facility/CreateFacilityProcessor.php index fe66d7ab7..9d83ba2fb 100644 --- a/src/Facility/Presentation/Api/Processor/Facility/CreateFacilityProcessor.php +++ b/src/Facility/Presentation/Api/Processor/Facility/CreateFacilityProcessor.php @@ -161,6 +161,7 @@ private function processCreation(CreateFacilityInput $data, array $uriVariables) levelIndex: $data->levelIndex, elevationMeters: $data->elevationMeters, heightMeters: $data->heightMeters, + customerId: $data->customerId, )); } catch (FacilityCodeAlreadyExistsException $exception) { throw new ConflictHttpException($exception->getMessage(), $exception); @@ -189,6 +190,7 @@ private function processCreation(CreateFacilityInput $data, array $uriVariables) $output->levelIndex = $result->levelIndex; $output->elevationMeters = $result->elevationMeters; $output->heightMeters = $result->heightMeters; + $output->customerId = $result->customerId; $output->createdAt = $result->createdAt->format('c'); $output->updatedAt = $result->updatedAt->format('c'); $assignment = $result->replayed diff --git a/src/Facility/Presentation/Api/Processor/Facility/UpdateFacilityProcessor.php b/src/Facility/Presentation/Api/Processor/Facility/UpdateFacilityProcessor.php index 582de9142..9b998efc6 100644 --- a/src/Facility/Presentation/Api/Processor/Facility/UpdateFacilityProcessor.php +++ b/src/Facility/Presentation/Api/Processor/Facility/UpdateFacilityProcessor.php @@ -123,6 +123,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = && !array_key_exists('levelIndex', $payload) && !array_key_exists('elevationMeters', $payload) && !array_key_exists('heightMeters', $payload) + && !array_key_exists('customerId', $payload) ) { throw new BadRequestHttpException('At least one field must be provided for update.'); } @@ -152,6 +153,8 @@ public function process(mixed $data, Operation $operation, array $uriVariables = hasElevationMeters: array_key_exists('elevationMeters', $payload), heightMeters: $data->heightMeters, hasHeightMeters: array_key_exists('heightMeters', $payload), + customerId: $data->customerId, + hasCustomerId: array_key_exists('customerId', $payload), )); } catch (FacilityCodeAlreadyExistsException $exception) { throw new ConflictHttpException($exception->getMessage(), $exception); diff --git a/src/Facility/Presentation/Api/Provider/Facility/CanonicalFacilityProvider.php b/src/Facility/Presentation/Api/Provider/Facility/CanonicalFacilityProvider.php index 3830b49ea..6fd6e0b97 100644 --- a/src/Facility/Presentation/Api/Provider/Facility/CanonicalFacilityProvider.php +++ b/src/Facility/Presentation/Api/Provider/Facility/CanonicalFacilityProvider.php @@ -284,6 +284,7 @@ private function map(FacilityRecord $record, bool $includeGeometry): FacilityOut $output->levelIndex = $record->levelIndex; $output->elevationMeters = $record->elevationMeters; $output->heightMeters = $record->heightMeters; + $output->customerId = $record->customerId; if ($includeGeometry) { $output->planGeometry = $record->planGeometry; } diff --git a/src/Facility/Presentation/Api/Provider/Facility/ListFacilitiesProvider.php b/src/Facility/Presentation/Api/Provider/Facility/ListFacilitiesProvider.php index 4a06c1bd9..540598656 100644 --- a/src/Facility/Presentation/Api/Provider/Facility/ListFacilitiesProvider.php +++ b/src/Facility/Presentation/Api/Provider/Facility/ListFacilitiesProvider.php @@ -191,6 +191,7 @@ private function listQuery(Operation $operation, array $context, string $organiz parentForFacilityId: $this->optionalString($parentForFacilityId), interventionId: $this->optionalString($interventionId), includePublishedParents: $includePublishedParents, + customerId: $this->optionalString($query->get('customerId')), ); } @@ -271,6 +272,7 @@ private function mapResult(GetFacilityResult $facility): FacilityOutput $output->levelIndex = $facility->levelIndex; $output->elevationMeters = $facility->elevationMeters; $output->heightMeters = $facility->heightMeters; + $output->customerId = $facility->customerId; $output->createdAt = $facility->createdAt->format('c'); $output->updatedAt = $facility->updatedAt->format('c'); diff --git a/src/Facility/Presentation/Api/Provider/Facility/ListFacilityChildrenProvider.php b/src/Facility/Presentation/Api/Provider/Facility/ListFacilityChildrenProvider.php index d65038fca..71d05f5f0 100644 --- a/src/Facility/Presentation/Api/Provider/Facility/ListFacilityChildrenProvider.php +++ b/src/Facility/Presentation/Api/Provider/Facility/ListFacilityChildrenProvider.php @@ -247,6 +247,7 @@ private function mapResult(GetFacilityResult $facility): FacilityOutput $output->levelIndex = $facility->levelIndex; $output->elevationMeters = $facility->elevationMeters; $output->heightMeters = $facility->heightMeters; + $output->customerId = $facility->customerId; $output->createdAt = $facility->createdAt->format('c'); $output->updatedAt = $facility->updatedAt->format('c'); diff --git a/src/Facility/Presentation/Api/Provider/Facility/ListFacilityDescendantsProvider.php b/src/Facility/Presentation/Api/Provider/Facility/ListFacilityDescendantsProvider.php index e62fb53be..ab9ab37b5 100644 --- a/src/Facility/Presentation/Api/Provider/Facility/ListFacilityDescendantsProvider.php +++ b/src/Facility/Presentation/Api/Provider/Facility/ListFacilityDescendantsProvider.php @@ -254,6 +254,7 @@ private function mapResult(GetFacilityResult $facility): FacilityOutput $output->levelIndex = $facility->levelIndex; $output->elevationMeters = $facility->elevationMeters; $output->heightMeters = $facility->heightMeters; + $output->customerId = $facility->customerId; $output->createdAt = $facility->createdAt->format('c'); $output->updatedAt = $facility->updatedAt->format('c'); diff --git a/src/Facility/Presentation/Api/Resource/FacilityResource.php b/src/Facility/Presentation/Api/Resource/FacilityResource.php index c4d5a67d2..7d9c5a430 100644 --- a/src/Facility/Presentation/Api/Resource/FacilityResource.php +++ b/src/Facility/Presentation/Api/Resource/FacilityResource.php @@ -89,6 +89,15 @@ normalizationContext: ['groups' => [FacilitySerializationGroup::READ]], security: self::SECURITY_ROLE_USER, parameters: [ + 'customerId' => new \ApiPlatform\Metadata\QueryParameter( + schema: ['type' => 'string', 'format' => 'uuid'], + description: 'Filter root sites assigned to this internal customer.', + required: false, + castToArray: false, + castToNativeType: false, + constraints: [], + openApi: new Parameter(name: 'customerId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid']), + ), 'parentForType' => new \ApiPlatform\Metadata\QueryParameter( schema: ['type' => 'string', 'enum' => ['site', 'building', 'floor', 'zone', 'area']], description: 'Eligible active parents for creating this facility type. Published by default; interventionId also admits authorized drafts of that intervention. Mutually exclusive with parentForFacilityId.', diff --git a/src/Inspection/Application/Contract/Equipment/EquipmentInspectionSummary.php b/src/Inspection/Application/Contract/Equipment/EquipmentInspectionSummary.php new file mode 100644 index 000000000..23a3636e2 --- /dev/null +++ b/src/Inspection/Application/Contract/Equipment/EquipmentInspectionSummary.php @@ -0,0 +1,35 @@ + $equipmentIds optional resolved parc candidates; an empty list matches no inspections * * @return void */ @@ -27,6 +28,7 @@ public function __construct( public ?string $equipmentId = null, public ?string $facilityId = null, public ?string $checklistId = null, + public ?array $equipmentIds = null, ) { } // #endregion diff --git a/src/Inspection/Application/Contract/Park/ParkAnomaliesCounts.php b/src/Inspection/Application/Contract/Park/ParkAnomaliesCounts.php new file mode 100644 index 000000000..92aa955df --- /dev/null +++ b/src/Inspection/Application/Contract/Park/ParkAnomaliesCounts.php @@ -0,0 +1,33 @@ + 0, 'medium' => 0, 'high' => 0, 'critical' => 0], + ) { + } + // #endregion +} diff --git a/src/Inspection/Application/Contract/Park/ParkAnomalyEntry.php b/src/Inspection/Application/Contract/Park/ParkAnomalyEntry.php new file mode 100644 index 000000000..d5ab163df --- /dev/null +++ b/src/Inspection/Application/Contract/Park/ParkAnomalyEntry.php @@ -0,0 +1,53 @@ + distinct equipment identifiers in published inspections + */ + public function findPublishedEquipmentIds(string $organizationId): array; + // #region Methods /** * Method save. diff --git a/src/Inspection/Application/Port/Outbound/ParkAnomalyGatewayPort.php b/src/Inspection/Application/Port/Outbound/ParkAnomalyGatewayPort.php new file mode 100644 index 000000000..cc1e77371 --- /dev/null +++ b/src/Inspection/Application/Port/Outbound/ParkAnomalyGatewayPort.php @@ -0,0 +1,71 @@ + equipment referenced by published inspections with unresolved findings + */ + public function findCandidateEquipmentIds(string $organizationId): array; + + /** + * Method list. + * + * @access public + * + * @param string $organizationId owning organization + * @param list $equipmentIds already resolved published equipment scope + * @param Pagination $pagination applied after scoping + * @param Sorting $sorting whitelisted field and stable tie-breaker + * + * @return list page of unresolved findings + */ + public function list(string $organizationId, array $equipmentIds, Pagination $pagination, Sorting $sorting): array; + + /** + * Method count. + * + * @access public + * + * @param string $organizationId owning organization + * @param list $equipmentIds resolved equipment scope + * + * @return int total before pagination + */ + public function count(string $organizationId, array $equipmentIds): int; + + /** + * Method summary. + * + * @access public + * + * @param string $organizationId owning organization + * @param list $equipmentIds resolved equipment scope + * + * @return ParkAnomaliesCounts complete scoped counts + */ + public function summary(string $organizationId, array $equipmentIds): ParkAnomaliesCounts; + // #endregion +} diff --git a/src/Inspection/Application/UseCase/Query/Equipment/GetEquipmentInspectionSummary/GetEquipmentInspectionSummaryHandler.php b/src/Inspection/Application/UseCase/Query/Equipment/GetEquipmentInspectionSummary/GetEquipmentInspectionSummaryHandler.php new file mode 100644 index 000000000..627856565 --- /dev/null +++ b/src/Inspection/Application/UseCase/Query/Equipment/GetEquipmentInspectionSummary/GetEquipmentInspectionSummaryHandler.php @@ -0,0 +1,59 @@ +authorization->resolveAccess($query->userId, $query->organizationId, $permission); + if (OrganizationAccessDecision::OUTSIDE_SCOPE === $decision) { + throw new EquipmentInspectionSummaryNotFoundException('Equipment inspection summary not found.'); + } + if (!$decision->isGranted()) { + throw new EquipmentInspectionSummaryAccessDeniedException('Missing permission to read the equipment inspection summary.'); + } + } + + try { + $this->equipment->assertPublishedEquipmentExists($query->equipmentId, $query->organizationId); + } catch (InvalidArgumentException $exception) { + throw new EquipmentInspectionSummaryNotFoundException('Equipment inspection summary not found.', 0, $exception); + } + + return new GetEquipmentInspectionSummaryResult($this->summaries->find($query->organizationId, $query->equipmentId)); + } +} diff --git a/src/Inspection/Application/UseCase/Query/Equipment/GetEquipmentInspectionSummary/GetEquipmentInspectionSummaryQuery.php b/src/Inspection/Application/UseCase/Query/Equipment/GetEquipmentInspectionSummary/GetEquipmentInspectionSummaryQuery.php new file mode 100644 index 000000000..f0c7868b3 --- /dev/null +++ b/src/Inspection/Application/UseCase/Query/Equipment/GetEquipmentInspectionSummary/GetEquipmentInspectionSummaryQuery.php @@ -0,0 +1,26 @@ +includeDescendants ? null : $facilityId, $checklistId, $this->parkCandidates($query)), execution: new InspectionExecutionCriteria( $result, $status, @@ -200,5 +202,30 @@ private function mapResults(array $inspections): array return $results; } + + /** + * @since 1.1.0 + * + * @return ?list + */ + private function parkCandidates(ListInspectionsQuery $query): ?array + { + if (null === $query->family && null === $query->customerId && !$query->includeDescendants) { + return null; + } + if ($query->includeDescendants && null === $query->facilityId) { + throw InvalidValueException::because('includeDescendants requires a facilityId.'); + } + if (null === $this->parkScopes) { + throw new LogicException('Published parc scope is unavailable.'); + } + $candidates = $this->inspectionRepository->findPublishedEquipmentIds($query->organizationId); + // Validate explicit facility scope even when the requested collection remains direct. + if (null !== $query->facilityId && !$query->includeDescendants) { + $this->parkScopes->filterIds($query->organizationId, [], facilityId: $query->facilityId); + } + + return $this->parkScopes->filterIds($query->organizationId, $candidates, $query->family, $query->customerId, $query->includeDescendants ? $query->facilityId : null); + } // #endregion } diff --git a/src/Inspection/Application/UseCase/Query/Inspection/ListInspections/ListInspectionsQuery.php b/src/Inspection/Application/UseCase/Query/Inspection/ListInspections/ListInspectionsQuery.php index a55ec5b02..a4c3d1e44 100644 --- a/src/Inspection/Application/UseCase/Query/Inspection/ListInspections/ListInspectionsQuery.php +++ b/src/Inspection/Application/UseCase/Query/Inspection/ListInspections/ListInspectionsQuery.php @@ -54,6 +54,9 @@ public function __construct( public Pagination $pagination = new Pagination(), public ?string $search = null, public Sorting $sorting = new Sorting('createdAt', SortDirection::ASC), + public ?string $family = null, + public ?string $customerId = null, + public bool $includeDescendants = false, ) { } // #endregion diff --git a/src/Inspection/Application/UseCase/Query/Park/GetParkAnomaliesSummary/GetParkAnomaliesSummaryHandler.php b/src/Inspection/Application/UseCase/Query/Park/GetParkAnomaliesSummary/GetParkAnomaliesSummaryHandler.php new file mode 100644 index 000000000..7e77eb061 --- /dev/null +++ b/src/Inspection/Application/UseCase/Query/Park/GetParkAnomaliesSummary/GetParkAnomaliesSummaryHandler.php @@ -0,0 +1,65 @@ +organizationId); + $equipmentIds = $this->equipmentScope->filterIds( + $organizationId, + $this->anomalies->findCandidateEquipmentIds($organizationId), + $query->family, + $query->customerId, + $query->facilityId, + $query->includeDescendants, + ); + $counts = $this->anomalies->summary($organizationId, $equipmentIds); + + return new GetParkAnomaliesSummaryResult($counts->openAnomalies, $counts->bySeverity); + } + // #endregion +} diff --git a/src/Inspection/Application/UseCase/Query/Park/GetParkAnomaliesSummary/GetParkAnomaliesSummaryQuery.php b/src/Inspection/Application/UseCase/Query/Park/GetParkAnomaliesSummary/GetParkAnomaliesSummaryQuery.php new file mode 100644 index 000000000..5df0268a6 --- /dev/null +++ b/src/Inspection/Application/UseCase/Query/Park/GetParkAnomaliesSummary/GetParkAnomaliesSummaryQuery.php @@ -0,0 +1,41 @@ + page from the complete resolved scope + */ + public function __invoke(ListParkAnomaliesQuery $query): PaginatedResult + { + $organizationId = (string) InspectionOrganizationId::fromString($query->organizationId); + if ($query->pagination->offset < 0 || $query->pagination->limit < 1 || $query->pagination->limit > 100) { + throw InvalidValueException::because('Park anomaly pagination requires a non-negative offset and a limit from 1 to 100.'); + } + $equipmentIds = $this->equipmentScope->filterIds( + $organizationId, + $this->anomalies->findCandidateEquipmentIds($organizationId), + $query->family, + $query->customerId, + $query->facilityId, + $query->includeDescendants, + ); + $entries = $this->anomalies->list($organizationId, $equipmentIds, $query->pagination, $query->sorting); + $total = $this->anomalies->count($organizationId, $equipmentIds); + $pageIds = []; + foreach ($entries as $entry) { + $pageIds[$entry->equipmentId] = true; + } + $serialNumbers = [] === $pageIds ? [] : $this->equipmentNaming->findSerialNumbersByIds(array_keys($pageIds)); + $results = []; + foreach ($entries as $entry) { + $results[] = new OrganizationNonConformityResult( + nonConformityId: $entry->id, + inspectionId: $entry->inspectionId, + description: $entry->description, + severity: $entry->severity, + status: $entry->status, + dueAt: $entry->dueAt?->format('c'), + resolvedAt: $entry->resolvedAt?->format('c'), + notes: $entry->notes, + createdAt: $entry->createdAt, + updatedAt: $entry->updatedAt, + equipmentId: $entry->equipmentId, + equipmentSerialNumber: $serialNumbers[$entry->equipmentId] ?? null, + ); + } + + return new PaginatedResult( + items: $results, + total: $total, + limit: $query->pagination->limit, + offset: $query->pagination->offset, + ); + } + // #endregion +} diff --git a/src/Inspection/Application/UseCase/Query/Park/ListParkAnomalies/ListParkAnomaliesQuery.php b/src/Inspection/Application/UseCase/Query/Park/ListParkAnomalies/ListParkAnomaliesQuery.php new file mode 100644 index 000000000..1557c4330 --- /dev/null +++ b/src/Inspection/Application/UseCase/Query/Park/ListParkAnomalies/ListParkAnomaliesQuery.php @@ -0,0 +1,47 @@ + $organizationId, 'equipment' => $equipmentId]; + /** @var list $rows */ + $rows = $this->connection->fetchAllAssociative( + "SELECT n.severity, COUNT(*) AS count FROM non_conformities n INNER JOIN inspections i ON i.id = n.inspection_id WHERE i.organization_id = :organization AND i.equipment_id = :equipment AND i.record_status = 'published' AND n.status IN ('open', 'in_progress') GROUP BY n.severity", + $parameters, + ); + $severity = ['low' => 0, 'medium' => 0, 'high' => 0, 'critical' => 0]; + foreach ($rows as $row) { + $key = (string) $row['severity']; + if (isset($severity[$key])) { + $severity[$key] = (int) $row['count']; + } + } + /** @var array{id:string,performed_at:string,result:string}|false $last */ + $last = $this->connection->fetchAssociative( + "SELECT id, performed_at, result FROM inspections WHERE organization_id = :organization AND equipment_id = :equipment AND record_status = 'published' AND status = 'closed' ORDER BY performed_at DESC, id DESC LIMIT 1", + $parameters, + ); + + return new EquipmentInspectionSummary( + equipmentId: $equipmentId, + openAnomalies: array_sum($severity), + bySeverity: $severity, + lastInspectionId: false !== $last ? (string) $last['id'] : null, + lastInspectionPerformedAt: false !== $last ? new DateTimeImmutable((string) $last['performed_at'], new DateTimeZone('UTC'))->format('c') : null, + lastInspectionResult: false !== $last ? (string) $last['result'] : null, + ); + } +} diff --git a/src/Inspection/Infrastructure/Adapter/Intervention/InterventionInspectionResultAdapter.php b/src/Inspection/Infrastructure/Adapter/Intervention/InterventionInspectionResultAdapter.php new file mode 100644 index 000000000..c002ba5cd --- /dev/null +++ b/src/Inspection/Infrastructure/Adapter/Intervention/InterventionInspectionResultAdapter.php @@ -0,0 +1,60 @@ +entityManager->find(InspectionRecord::class, $inspectionId); + if (!$record instanceof InspectionRecord || $record->organizationId() !== $organizationId || $record->interventionId !== $interventionId) { + return null; + } + + return new InterventionInspectionResult( + id: $record->id, + equipmentId: $record->equipmentId, + result: $record->result, + status: $record->status, + performedAt: $record->performedAt, + authorId: $record->inspectorUserId, + notes: $record->notes, + ); + } + // #endregion +} diff --git a/src/Inspection/Infrastructure/Adapter/ServiceRequest/ServiceRequestOriginAdapter.php b/src/Inspection/Infrastructure/Adapter/ServiceRequest/ServiceRequestOriginAdapter.php new file mode 100644 index 000000000..72c0e55ad --- /dev/null +++ b/src/Inspection/Infrastructure/Adapter/ServiceRequest/ServiceRequestOriginAdapter.php @@ -0,0 +1,67 @@ +connection->fetchAssociative("SELECT id, equipment_id, facility_id FROM inspections WHERE id = :inspectionId AND organization_id = :organizationId AND record_status = 'published'", ['inspectionId' => $originInspectionId, 'organizationId' => $organizationId]) + : $this->connection->fetchAssociative("SELECT i.id, i.equipment_id, i.facility_id FROM non_conformities n INNER JOIN inspections i ON i.id = n.inspection_id WHERE n.id = :findingId AND i.organization_id = :organizationId AND i.record_status = 'published'", ['findingId' => $originNonConformityId, 'organizationId' => $organizationId]); + + if (false === $row || !isset($row['id'], $row['equipment_id']) || !is_string($row['id']) || !is_string($row['equipment_id'])) { + throw new ServiceRequestOriginUnavailable(); + } + if ((null !== $originInspectionId && $row['id'] !== $originInspectionId) || (null !== $equipmentId && $row['equipment_id'] !== $equipmentId)) { + throw new ServiceRequestOriginUnavailable(); + } + if (null !== $siteId) { + $facilityId = $row['facility_id'] ?? null; + if (!is_string($facilityId) || null === $this->sites->find($organizationId, $siteId, $facilityId)) { + throw new ServiceRequestOriginUnavailable(); + } + } + } +} diff --git a/src/Inspection/Infrastructure/Persistence/Doctrine/Repository/InspectionRepository.php b/src/Inspection/Infrastructure/Persistence/Doctrine/Repository/InspectionRepository.php index 00b840b0b..f56f4e7c6 100644 --- a/src/Inspection/Infrastructure/Persistence/Doctrine/Repository/InspectionRepository.php +++ b/src/Inspection/Infrastructure/Persistence/Doctrine/Repository/InspectionRepository.php @@ -240,6 +240,22 @@ public function findPublishedById(InspectionId $id): ?Inspection return InspectionMapper::toDomain($this->reinterpretRecordDateTimesFromStorage($record)); } + /** + * @since 1.1.0 + * + * @return list + */ + public function findPublishedEquipmentIds(string $organizationId): array + { + /** @var list $rows */ + $rows = $this->entityManager->createQueryBuilder()->select('DISTINCT i.equipmentId AS equipmentId') + ->from(InspectionRecord::class, 'i')->where('IDENTITY(i.organization) = :organization') + ->andWhere('i.recordStatus = :published')->setParameter('organization', $organizationId) + ->setParameter('published', 'published')->getQuery()->getArrayResult(); + + return array_map(static fn (array $row): string => $row['equipmentId'], $rows); + } + /** * Method findByOrganizationId. * @@ -755,6 +771,13 @@ private function createListQueryBuilder( ->andWhere('i.equipmentId = :equipmentId') ->setParameter('equipmentId', $criteria->subject->equipmentId); } + if (null !== $criteria->subject->equipmentIds) { + if ([] === $criteria->subject->equipmentIds) { + $queryBuilder->andWhere('1 = 0'); + } else { + $queryBuilder->andWhere('i.equipmentId IN (:parkEquipmentIds)')->setParameter('parkEquipmentIds', $criteria->subject->equipmentIds); + } + } if (null !== $criteria->subject->facilityId) { $queryBuilder diff --git a/src/Inspection/Infrastructure/Persistence/Doctrine/Repository/ParkAnomalyRepository.php b/src/Inspection/Infrastructure/Persistence/Doctrine/Repository/ParkAnomalyRepository.php new file mode 100644 index 000000000..6d92f080d --- /dev/null +++ b/src/Inspection/Infrastructure/Persistence/Doctrine/Repository/ParkAnomalyRepository.php @@ -0,0 +1,220 @@ +storageZone = new DateTimeZone($storageTimeZone); + } catch (Throwable $exception) { + throw new InvalidStorageTimeZoneException('Invalid inspection storage time zone.', previous: $exception); + } + } + // #endregion + + // #region Methods + /** + * Method findCandidateEquipmentIds. + * + * @access public + * + * @param string $organizationId owning organization + * + * @return list published inspection equipment with unresolved findings + */ + public function findCandidateEquipmentIds(string $organizationId): array + { + /** @var list $ids */ + $ids = $this->entityManager->getConnection()->fetchFirstColumn( + 'SELECT DISTINCT i.equipment_id' . self::BASE_SELECTION . ' ORDER BY i.equipment_id ASC', + $this->parameters($organizationId), + ['openStatuses' => ArrayParameterType::STRING], + ); + + return $ids; + } + + /** + * Method list. + * + * @access public + * + * @param string $organizationId owning organization + * @param list $equipmentIds resolved equipment scope + * @param Pagination $pagination SQL page bounds + * @param Sorting $sorting allowed primary sort + * + * @return list unresolved page + */ + public function list(string $organizationId, array $equipmentIds, Pagination $pagination, Sorting $sorting): array + { + if ([] === $equipmentIds) { + return []; + } + + $sort = match ($sorting->field) { + 'id' => 'nc.id', + 'severity' => "CASE nc.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END", + 'status' => 'nc.status', + 'dueAt' => 'nc.due_at', + 'updatedAt' => 'nc.updated_at', + default => 'nc.created_at', + }; + $direction = $sorting->direction->value; + /** @var list $rows */ + $rows = $this->entityManager->getConnection()->fetchAllAssociative( + 'SELECT nc.id, nc.inspection_id, i.equipment_id, nc.description, nc.severity, nc.status, nc.due_at, nc.resolved_at, nc.notes, nc.created_at, nc.updated_at' + . self::BASE_SELECTION . self::EQUIPMENT_SCOPE . ' ORDER BY ' . $sort . ' ' . $direction . ', nc.id DESC LIMIT :limit OFFSET :offset', + $this->parameters($organizationId) + ['equipmentIds' => $equipmentIds, 'limit' => $pagination->limit, 'offset' => $pagination->offset], + ['openStatuses' => ArrayParameterType::STRING, 'equipmentIds' => ArrayParameterType::STRING, 'limit' => ParameterType::INTEGER, 'offset' => ParameterType::INTEGER], + ); + $entries = []; + foreach ($rows as $row) { + $entries[] = new ParkAnomalyEntry( + id: $row['id'], + inspectionId: $row['inspection_id'], + equipmentId: $row['equipment_id'], + description: $row['description'], + severity: $row['severity'], + status: $row['status'], + dueAt: null === $row['due_at'] ? null : new DateTimeImmutable($row['due_at'], $this->storageZone), + resolvedAt: null === $row['resolved_at'] ? null : new DateTimeImmutable($row['resolved_at'], $this->storageZone), + notes: $row['notes'], + createdAt: new DateTimeImmutable($row['created_at'], $this->storageZone), + updatedAt: new DateTimeImmutable($row['updated_at'], $this->storageZone), + ); + } + + return $entries; + } + + /** + * Method count. + * + * @access public + * + * @param string $organizationId owning organization + * @param list $equipmentIds resolved equipment scope + * + * @return int total matching findings before pagination + */ + public function count(string $organizationId, array $equipmentIds): int + { + if ([] === $equipmentIds) { + return 0; + } + + /** @var int|string $count */ + $count = $this->entityManager->getConnection()->fetchOne( + 'SELECT COUNT(*)' . self::BASE_SELECTION . self::EQUIPMENT_SCOPE, + $this->parameters($organizationId) + ['equipmentIds' => $equipmentIds], + ['openStatuses' => ArrayParameterType::STRING, 'equipmentIds' => ArrayParameterType::STRING], + ); + + return (int) $count; + } + + /** + * Method summary. + * + * @access public + * + * @param string $organizationId owning organization + * @param list $equipmentIds resolved equipment scope + * + * @return ParkAnomaliesCounts zero-inclusive scoped totals + */ + public function summary(string $organizationId, array $equipmentIds): ParkAnomaliesCounts + { + if ([] === $equipmentIds) { + return new ParkAnomaliesCounts(0); + } + + /** @var array{total: int|string, low: int|string, medium: int|string, high: int|string, critical: int|string} $row */ + $row = $this->entityManager->getConnection()->fetchAssociative( + "SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE nc.severity = 'low') AS low, COUNT(*) FILTER (WHERE nc.severity = 'medium') AS medium, COUNT(*) FILTER (WHERE nc.severity = 'high') AS high, COUNT(*) FILTER (WHERE nc.severity = 'critical') AS critical" + . self::BASE_SELECTION . self::EQUIPMENT_SCOPE, + $this->parameters($organizationId) + ['equipmentIds' => $equipmentIds], + ['openStatuses' => ArrayParameterType::STRING, 'equipmentIds' => ArrayParameterType::STRING], + ); + + return new ParkAnomaliesCounts((int) $row['total'], [ + 'low' => (int) $row['low'], + 'medium' => (int) $row['medium'], + 'high' => (int) $row['high'], + 'critical' => (int) $row['critical'], + ]); + } + + /** + * Method parameters. + * + * @access private + * + * @param string $organizationId owning organization + * + * @return array{organizationId: string, published: string, openStatuses: list} shared predicates + */ + private function parameters(string $organizationId): array + { + return ['organizationId' => $organizationId, 'published' => 'published', 'openStatuses' => ['open', 'in_progress']]; + } + // #endregion +} diff --git a/src/Inspection/MODULE.md b/src/Inspection/MODULE.md index 95db4a028..291f6c4c8 100644 --- a/src/Inspection/MODULE.md +++ b/src/Inspection/MODULE.md @@ -4,6 +4,29 @@ ## Overview +### Equipment dossier and operation proof + +`GET /api/organizations/{organizationId}/equipment/{equipmentId}/inspection-summary` +requires both `organization.inspection.read` and `organization.equipment.read`. +It returns published inspection-owned facts for one published equipment: open and +in-progress findings, severity counts, and the most recent closed published +inspection's identifier, actual execution instant and result. Operational equipment +state, preventive deadlines and open findings remain independent. A passed control +never implicitly resolves another finding. No closed control is represented by +explicit null inspection fields, rather than a fabricated pass result. + +Unknown, foreign and private draft equipment are indistinguishable (404); missing +either read permission returns 403 before reading inspection facts. Counts and last +control exclude intervention-draft inspections and are restricted to the requested +organization/equipment. Actual execution date determines the latest control, +with identifier as a deterministic tie-breaker. + +`InterventionInspectionResultPort` exposes recorded inspection proof only when both +organization and intervention identifiers match. It preserves the real equipment, +result, lifecycle status, execution instant, inspector account and notes. Intervention +publication decides validity using that owner-published fact; it does not fabricate +an inspection result from a planned task. + Recording a non-conformity commits its row and domain event in the same main transaction. The durable event subsequently drives audit, webhook and automation consumers. A queue write failure rolls back creation; subscriber failures retry @@ -29,6 +52,41 @@ localized typed registries are the source of these values). ### Inspections +The organization and facility collections accept `family` (`fire`, `safety`, `other`), +`customerId` and `includeDescendants` (default false for legacy direct reads). +Parc scopes resolve published equipment identifiers through the Equipment public +scope port before the shared inspection list/count predicate is executed. A subtree +read follows current equipment assignment and retains each inspection's original +facility in its output. Explicit family/customer/subtree scopes require both +inspection and equipment read permissions; hidden organizations and unknown or +foreign explicit customer/facility scopes return 404, missing permission returns 403. + +### Parc anomaly queue + +| Method | Path | Description | +| --- | --- | --- | +| GET | `/api/organizations/{organizationId}/park-anomalies-summary` | Counts published unresolved findings in the selected parc | +| GET | `/api/organizations/{organizationId}/park-anomalies` | Paginates the exact same finding set | + +Both routes accept `family`, `customerId`, `facilityId` and `includeDescendants` +(default true). A facility selects its published subtree unless `includeDescendants=false` +restricts findings to equipment assigned directly to it. List, total and summary retain +the same direct/subtree, customer and family scope. The API imposes no default family. They require +`organization.inspection.read` and `organization.equipment.read`, distinguishing +403 entitlement denial from 404 hidden scopes. Their Inspection-owned queries +include only published inspections and findings `open` or `in_progress`. Equipment +ownership and family/customer/subtree assignment are resolved by +`EquipmentParkScopePort`, without joins to sibling persistence tables. + +The summary returns `openAnomalies` and zero-inclusive `bySeverity` buckets (`low`, +`medium`, `high`, `critical`). The Hydra collection exposes the existing +`NonConformityOutput`, with actual inspection/equipment identifiers and batched serial +numbers. List, total and summary use identical predicates before pagination; the +default sort is creation date descending with identifier tie-breaker. Permission +denial never becomes a zero-count response. + +### Inspection endpoint reference + | Method | Path | Description | | ------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | POST | `/api/organizations/{organizationId}/inspections` | Create inspection (starts as `draft`) | diff --git a/src/Inspection/Presentation/Api/Dto/Output/Equipment/EquipmentInspectionSummaryOutput.php b/src/Inspection/Presentation/Api/Dto/Output/Equipment/EquipmentInspectionSummaryOutput.php new file mode 100644 index 000000000..9b5f3e5a7 --- /dev/null +++ b/src/Inspection/Presentation/Api/Dto/Output/Equipment/EquipmentInspectionSummaryOutput.php @@ -0,0 +1,36 @@ + 0, 'medium' => 0, 'high' => 0, 'critical' => 0]; + + #[ApiProperty(readable: true, writable: false)] + public ?string $lastInspectionId = null; + + #[ApiProperty(readable: true, writable: false)] + public ?string $lastInspectionPerformedAt = null; + + #[ApiProperty(readable: true, writable: false)] + public ?string $lastInspectionResult = null; +} diff --git a/src/Inspection/Presentation/Api/Dto/Output/Park/ParkAnomaliesSummaryOutput.php b/src/Inspection/Presentation/Api/Dto/Output/Park/ParkAnomaliesSummaryOutput.php new file mode 100644 index 000000000..d8501f6e2 --- /dev/null +++ b/src/Inspection/Presentation/Api/Dto/Output/Park/ParkAnomaliesSummaryOutput.php @@ -0,0 +1,28 @@ + + */ +final class ParkAnomaliesSummaryOutput +{ + #[Groups([InspectionSerializationGroup::READ])] + public int $openAnomalies = 0; + + /** + * @var array{low:int,medium:int,high:int,critical:int} + */ + #[Groups([InspectionSerializationGroup::READ])] + public array $bySeverity = ['low' => 0, 'medium' => 0, 'high' => 0, 'critical' => 0]; +} diff --git a/src/Inspection/Presentation/Api/Operation/InspectionOperations.php b/src/Inspection/Presentation/Api/Operation/InspectionOperations.php index 1799810a6..4b042a110 100644 --- a/src/Inspection/Presentation/Api/Operation/InspectionOperations.php +++ b/src/Inspection/Presentation/Api/Operation/InspectionOperations.php @@ -14,6 +14,8 @@ final class InspectionOperations { // #region Constants + public const string GET_EQUIPMENT_INSPECTION_SUMMARY = 'equipment_inspection_summary_get'; + /** * Constant CREATE_INSPECTION * diff --git a/src/Inspection/Presentation/Api/Operation/ParkAnomaliesOperations.php b/src/Inspection/Presentation/Api/Operation/ParkAnomaliesOperations.php new file mode 100644 index 000000000..3d9140069 --- /dev/null +++ b/src/Inspection/Presentation/Api/Operation/ParkAnomaliesOperations.php @@ -0,0 +1,20 @@ + + */ +final class ParkAnomaliesOperations +{ + public const string SUMMARY = 'park_anomalies_summary_get'; + + public const string LIST = 'park_anomalies_list'; +} diff --git a/src/Inspection/Presentation/Api/Provider/Equipment/GetEquipmentInspectionSummaryProvider.php b/src/Inspection/Presentation/Api/Provider/Equipment/GetEquipmentInspectionSummaryProvider.php new file mode 100644 index 000000000..da0911a83 --- /dev/null +++ b/src/Inspection/Presentation/Api/Provider/Equipment/GetEquipmentInspectionSummaryProvider.php @@ -0,0 +1,69 @@ + + */ +final readonly class GetEquipmentInspectionSummaryProvider implements ProviderInterface +{ + /** + * Method __construct. + * + * @param QueryBusPort $queries the query bus + * @param Security $security the authenticated account + */ + public function __construct(private QueryBusPort $queries, private Security $security) + { + } + + /** + * Method provide. + * + * @param Operation $operation the requested operation + * @param array $uriVariables the scoped identifiers + * @param array $context the request context + * + * @return EquipmentInspectionSummaryOutput the authorized facts + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): EquipmentInspectionSummaryOutput + { + $user = $this->security->getUser(); + if (!$user instanceof AuthenticatedUser) { + throw new AccessDeniedHttpException('Authentication required.'); + } + $organizationId = $uriVariables['organizationId'] ?? null; + $equipmentId = $uriVariables['equipmentId'] ?? null; + if (!is_string($organizationId) || !is_string($equipmentId)) { + throw new BadRequestHttpException('Organization and equipment identifiers are required.'); + } + /** @var GetEquipmentInspectionSummaryResult $result */ + $result = $this->queries->ask(new GetEquipmentInspectionSummaryQuery($organizationId, $equipmentId, $user->getId())); + $output = new EquipmentInspectionSummaryOutput(); + $output->equipmentId = $result->summary->equipmentId; + $output->openAnomalies = $result->summary->openAnomalies; + $output->bySeverity = $result->summary->bySeverity; + $output->lastInspectionId = $result->summary->lastInspectionId; + $output->lastInspectionPerformedAt = $result->summary->lastInspectionPerformedAt; + $output->lastInspectionResult = $result->summary->lastInspectionResult; + + return $output; + } +} diff --git a/src/Inspection/Presentation/Api/Provider/Inspection/ListInspectionsProvider.php b/src/Inspection/Presentation/Api/Provider/Inspection/ListInspectionsProvider.php index f7e7fe668..069560d14 100644 --- a/src/Inspection/Presentation/Api/Provider/Inspection/ListInspectionsProvider.php +++ b/src/Inspection/Presentation/Api/Provider/Inspection/ListInspectionsProvider.php @@ -71,7 +71,19 @@ public function provide(Operation $operation, array $uriVariables = [], array $c throw new BadRequestHttpException('OrganizationId URI parameter is required.'); } - if (!$this->authorization->hasPermission($user->getId(), $organizationId, 'organization.inspection.read')) { + $requestQuery = $this->requestStack->getCurrentRequest()?->query; + $scoped = null !== self::optionalString($requestQuery?->get('family')) || null !== self::optionalString($requestQuery?->get('customerId')) || ($requestQuery?->getBoolean('includeDescendants', false) ?? false); + if ($scoped) { + foreach (['organization.inspection.read', 'organization.equipment.read'] as $permission) { + $decision = $this->authorization->resolveAccess($user->getId(), $organizationId, $permission); + if ($decision->isOutsideScope()) { + throw new \Symfony\Component\HttpKernel\Exception\NotFoundHttpException('Organization not found.'); + } + if (!$decision->isGranted()) { + throw new AccessDeniedHttpException('Missing ' . $permission . ' permission.'); + } + } + } elseif (!$this->authorization->hasPermission($user->getId(), $organizationId, 'organization.inspection.read')) { throw new AccessDeniedHttpException('Missing organization.inspection.read permission.'); } @@ -144,6 +156,9 @@ private function listQuery( pagination: new Pagination(offset: $offset, limit: $itemsPerPage), search: SearchExtractor::fromContext($context), sorting: SortingExtractor::fromContext($context, ['result', 'status', 'performedAt', 'createdAt'], 'createdAt'), + family: self::optionalString($params->get('family')), + customerId: self::optionalString($params->get('customerId')), + includeDescendants: $params->getBoolean('includeDescendants', false), ); } diff --git a/src/Inspection/Presentation/Api/Provider/Park/ParkAnomaliesProvider.php b/src/Inspection/Presentation/Api/Provider/Park/ParkAnomaliesProvider.php new file mode 100644 index 000000000..8fea7e1ed --- /dev/null +++ b/src/Inspection/Presentation/Api/Provider/Park/ParkAnomaliesProvider.php @@ -0,0 +1,114 @@ + + * + * @implements ProviderInterface + */ +final readonly class ParkAnomaliesProvider implements ProviderInterface +{ + /** + * @since 1.0.0 + */ + public function __construct(private QueryBusPort $queries, private ParkAnomaliesAccess $access, private RequestStack $requests) + { + } + + /** + * @since 1.0.0 + * + * @param array $uriVariables owner identifiers + * @param array $context provider context + * + * @return ParkAnomaliesSummaryOutput|TraversablePaginator + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ParkAnomaliesSummaryOutput|TraversablePaginator + { + $organizationId = $this->access->organization($uriVariables); + $parameters = $this->requests->getCurrentRequest()?->query; + $family = self::optional($parameters?->get('family')); + $customerId = self::optional($parameters?->get('customerId')); + $facilityId = self::optional($parameters?->get('facilityId')); + $includeDescendants = $parameters?->getBoolean('includeDescendants', true) ?? true; + if (ParkAnomaliesOperations::SUMMARY === $operation->getName()) { + /** @var GetParkAnomaliesSummaryResult $result */ + $result = $this->queries->ask(new GetParkAnomaliesSummaryQuery($organizationId, $family, $customerId, $facilityId, $includeDescendants)); + $output = new ParkAnomaliesSummaryOutput(); + $output->openAnomalies = $result->openAnomalies; + $output->bySeverity = $result->bySeverity; + + return $output; + } + $pageValue = $parameters?->get('page', '1'); + $sizeValue = $parameters?->get('itemsPerPage', '30'); + $page = max(1, is_numeric($pageValue) ? (int) $pageValue : 1); + $size = max(1, min(100, is_numeric($sizeValue) ? (int) $sizeValue : 30)); + /** @var PaginatedResult $result */ + $result = $this->queries->ask(new ListParkAnomaliesQuery($organizationId, $family, $customerId, $facilityId, new Pagination(($page - 1) * $size, $size), includeDescendants: $includeDescendants)); + $outputs = []; + foreach ($result->items as $row) { + $outputs[] = self::map($row); + } + + return new TraversablePaginator(new ArrayIterator($outputs), (float) $page, (float) $size, (float) $result->total); + } + + /** + * @since 1.0.0 + */ + private static function optional(mixed $value): ?string + { + return is_string($value) && '' !== $value ? $value : null; + } + + /** + * @since 1.0.0 + */ + private static function map(OrganizationNonConformityResult $row): NonConformityOutput + { + $output = new NonConformityOutput(); + $output->id = $row->nonConformityId; + $output->inspectionId = $row->inspectionId; + $output->description = $row->description; + $output->severity = $row->severity; + $output->status = $row->status; + $output->dueAt = $row->dueAt; + $output->resolvedAt = $row->resolvedAt; + $output->notes = $row->notes; + $output->equipmentId = $row->equipmentId; + $output->equipmentSerialNumber = $row->equipmentSerialNumber; + $output->createdAt = $row->createdAt->format('c'); + $output->updatedAt = $row->updatedAt->format('c'); + + return $output; + } +} diff --git a/src/Inspection/Presentation/Api/Resource/EquipmentInspectionSummaryResource.php b/src/Inspection/Presentation/Api/Resource/EquipmentInspectionSummaryResource.php new file mode 100644 index 000000000..679a5052a --- /dev/null +++ b/src/Inspection/Presentation/Api/Resource/EquipmentInspectionSummaryResource.php @@ -0,0 +1,32 @@ + [InspectionSerializationGroup::READ]], security: self::SECURITY_ROLE_USER, parameters: [ + 'family' => new \ApiPlatform\Metadata\QueryParameter(schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'family', in: 'query', schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']])), + 'customerId' => new \ApiPlatform\Metadata\QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'customerId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid'])), + 'includeDescendants' => new \ApiPlatform\Metadata\QueryParameter(schema: ['type' => 'boolean'], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'includeDescendants', in: 'query', schema: ['type' => 'boolean', 'default' => false])), 'equipmentId' => new \ApiPlatform\Metadata\QueryParameter( schema: ['type' => 'string'], description: self::EQUIPMENT_FILTER_DESCRIPTION, @@ -166,6 +169,9 @@ normalizationContext: ['groups' => [InspectionSerializationGroup::READ]], security: self::SECURITY_ROLE_USER, parameters: [ + 'family' => new \ApiPlatform\Metadata\QueryParameter(schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'family', in: 'query', schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']])), + 'customerId' => new \ApiPlatform\Metadata\QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'customerId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid'])), + 'includeDescendants' => new \ApiPlatform\Metadata\QueryParameter(schema: ['type' => 'boolean'], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'includeDescendants', in: 'query', schema: ['type' => 'boolean', 'default' => false])), 'equipmentId' => new \ApiPlatform\Metadata\QueryParameter( schema: ['type' => 'string'], description: self::EQUIPMENT_FILTER_DESCRIPTION, diff --git a/src/Inspection/Presentation/Api/Resource/ParkAnomaliesResource.php b/src/Inspection/Presentation/Api/Resource/ParkAnomaliesResource.php new file mode 100644 index 000000000..143f91116 --- /dev/null +++ b/src/Inspection/Presentation/Api/Resource/ParkAnomaliesResource.php @@ -0,0 +1,59 @@ + + */ +#[ApiResource(shortName: 'ParkAnomaly', normalizationContext: ['groups' => [InspectionSerializationGroup::READ]], security: "is_granted('ROLE_USER')", operations: [ + new Get( + name: ParkAnomaliesOperations::SUMMARY, + uriTemplate: '/organizations/{organizationId}/park-anomalies-summary', + input: false, + output: ParkAnomaliesSummaryOutput::class, + provider: ParkAnomaliesProvider::class, + parameters: [ + 'family' => new QueryParameter(schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'family', in: 'query', schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']])), + 'customerId' => new QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'customerId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid'])), + 'facilityId' => new QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'facilityId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid'])), + 'includeDescendants' => new QueryParameter(schema: ['type' => 'boolean', 'default' => true], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'includeDescendants', in: 'query', schema: ['type' => 'boolean', 'default' => true])), + ], + openapi: new Operation(tags: ['Inspection'], summary: 'Count unresolved published parc anomalies', responses: [200 => new Response(description: 'Counts by severity'), 400 => new Response(description: 'Invalid family or scope input'), 403 => new Response(description: 'Missing equipment or inspection read'), 404 => new Response(description: 'Unknown or hidden scope')]), + ), + new GetCollection( + name: ParkAnomaliesOperations::LIST, + uriTemplate: '/organizations/{organizationId}/park-anomalies', + input: false, + output: NonConformityOutput::class, + provider: ParkAnomaliesProvider::class, + paginationEnabled: true, + paginationClientItemsPerPage: true, + paginationMaximumItemsPerPage: 100, + paginationItemsPerPage: 30, + parameters: [ + 'family' => new QueryParameter(schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'family', in: 'query', schema: ['type' => 'string', 'enum' => ['fire', 'safety', 'other']])), + 'customerId' => new QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'customerId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid'])), + 'facilityId' => new QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'facilityId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid'])), + 'includeDescendants' => new QueryParameter(schema: ['type' => 'boolean', 'default' => true], required: false, castToArray: false, castToNativeType: false, constraints: [], openApi: new Parameter(name: 'includeDescendants', in: 'query', schema: ['type' => 'boolean', 'default' => true])), + ], + openapi: new Operation(tags: ['Inspection'], summary: 'List unresolved published parc anomalies', responses: [200 => new Response(description: 'Scoped paginated anomaly queue'), 400 => new Response(description: 'Invalid family or scope input'), 403 => new Response(description: 'Missing equipment or inspection read'), 404 => new Response(description: 'Unknown or hidden scope')]), + ), +])] +final class ParkAnomaliesResource +{ +} diff --git a/src/Inspection/Presentation/Api/Service/ParkAnomaliesAccess.php b/src/Inspection/Presentation/Api/Service/ParkAnomaliesAccess.php new file mode 100644 index 000000000..407a6157e --- /dev/null +++ b/src/Inspection/Presentation/Api/Service/ParkAnomaliesAccess.php @@ -0,0 +1,58 @@ + + */ +final readonly class ParkAnomaliesAccess +{ + /** + * @since 1.0.0 + */ + public function __construct(private Security $security, private OrganizationAuthorizationPort $authorization) + { + } + + /** + * @since 1.0.0 + * + * @param array $uriVariables owner scope + */ + public function organization(array $uriVariables): string + { + $user = $this->security->getUser(); + if (!$user instanceof AuthenticatedUser) { + throw new AccessDeniedHttpException('Authentication required.'); + } + $organizationId = $uriVariables['organizationId'] ?? null; + if (!is_string($organizationId) || '' === $organizationId) { + throw new BadRequestHttpException('OrganizationId is required.'); + } + foreach (['organization.inspection.read', 'organization.equipment.read'] as $permission) { + $decision = $this->authorization->resolveAccess($user->getId(), $organizationId, $permission); + if ($decision->isOutsideScope()) { + throw new NotFoundHttpException('Organization not found.'); + } + if (!$decision->isGranted()) { + throw new AccessDeniedHttpException('Missing ' . $permission . ' permission.'); + } + } + + return $organizationId; + } +} diff --git a/src/Intervention/Application/Contract/Cost/InterventionCostContext.php b/src/Intervention/Application/Contract/Cost/InterventionCostContext.php new file mode 100644 index 000000000..e22fe28b0 --- /dev/null +++ b/src/Intervention/Application/Contract/Cost/InterventionCostContext.php @@ -0,0 +1,16 @@ + $workItemIds + */ + public function __construct(public string $id, public string $organizationId, public string $status, public int $revision, public string $name, public ?string $siteId, public array $workItemIds) + { + } +} diff --git a/src/Intervention/Application/Contract/Cost/InterventionTimeCostFact.php b/src/Intervention/Application/Contract/Cost/InterventionTimeCostFact.php new file mode 100644 index 000000000..a8dbee878 --- /dev/null +++ b/src/Intervention/Application/Contract/Cost/InterventionTimeCostFact.php @@ -0,0 +1,15 @@ + $workItems task allocation facts + * + * @return void + */ + public function __construct(public string $id, public string $organizationId, public int $number, public string $name, public string $type, public string $status, public int $revision, public DateTimeImmutable $createdAt, public ?DateTimeImmutable $plannedStartAt, public ?DateTimeImmutable $dueAt, public ?DateTimeImmutable $publishedAt, public ?string $publicationId, public string $snapshotState, public ?int $snapshotVersion, public bool $identityComplete, public ?array $site, public ?array $customer, public array $workItems) + { + } + // #endregion +} diff --git a/src/Intervention/Application/Contract/Publication/InterventionEconomicContextPage.php b/src/Intervention/Application/Contract/Publication/InterventionEconomicContextPage.php new file mode 100644 index 000000000..b8af2c499 --- /dev/null +++ b/src/Intervention/Application/Contract/Publication/InterventionEconomicContextPage.php @@ -0,0 +1,35 @@ + $items bounded contexts + * @param int $totalItems exact matching intervention count + * @param int $page one-based page number + * @param int $itemsPerPage bounded requested size + * + * @return void + */ + public function __construct(public array $items, public int $totalItems, public int $page, public int $itemsPerPage) + { + } + // #endregion +} diff --git a/src/Intervention/Application/Contract/Publication/InterventionEquipmentSnapshot.php b/src/Intervention/Application/Contract/Publication/InterventionEquipmentSnapshot.php new file mode 100644 index 000000000..3e2f556aa --- /dev/null +++ b/src/Intervention/Application/Contract/Publication/InterventionEquipmentSnapshot.php @@ -0,0 +1,41 @@ + $workItems original task facts + * @param ?array $dossier retained operational dossier with minimized identity nodes + * + * @return void + */ + public function __construct(public string $id, public string $organizationId, public int $number, public string $name, public string $type, public string $status, public int $revision, public DateTimeImmutable $createdAt, public ?DateTimeImmutable $plannedStartAt, public ?DateTimeImmutable $dueAt, public ?DateTimeImmutable $publishedAt, public ?string $publicationId, public string $snapshotState, public ?int $snapshotVersion, public bool $identityComplete, public ?array $site, public ?array $customer, public array $workItems, public ?array $dossier) + { + } + // #endregion +} diff --git a/src/Intervention/Application/Contract/Publication/InterventionPublicationFactsPage.php b/src/Intervention/Application/Contract/Publication/InterventionPublicationFactsPage.php new file mode 100644 index 000000000..7075a851d --- /dev/null +++ b/src/Intervention/Application/Contract/Publication/InterventionPublicationFactsPage.php @@ -0,0 +1,35 @@ + $items bounded publication facts + * @param int $totalItems exact published intervention count + * @param int $page one-based page number + * @param int $itemsPerPage bounded requested size + * + * @return void + */ + public function __construct(public array $items, public int $totalItems, public int $page, public int $itemsPerPage) + { + } + // #endregion +} diff --git a/src/Intervention/Application/Contract/Publication/InterventionPublishedWorkFact.php b/src/Intervention/Application/Contract/Publication/InterventionPublishedWorkFact.php new file mode 100644 index 000000000..fed2c3fa3 --- /dev/null +++ b/src/Intervention/Application/Contract/Publication/InterventionPublishedWorkFact.php @@ -0,0 +1,49 @@ + $executionResult captured outcome and performed work + * @param bool $validated explicit validation state + * @param int $spentMinutes known uncancelled whole minutes + * @param int $evidenceCount known attached proofs + * @param ?DateTimeImmutable $createdAt captured creation time + * @param ?DateTimeImmutable $updatedAt captured modification time + * @param 'execution_result'|'published_inspection'|null $validationSource original validation evidence, without inventing missing result details + * + * @return void + */ + public function __construct(public string $id, public string $action, public string $status, public ?string $target, public ?string $resultResource, public ?string $equipmentId, public ?array $site, public ?array $customer, public ?InterventionEquipmentSnapshot $equipmentIdentity, public ?array $executionResult, public bool $validated, public int $spentMinutes, public int $evidenceCount, public ?DateTimeImmutable $createdAt, public ?DateTimeImmutable $updatedAt, public ?string $validationSource = null) + { + } + // #endregion +} diff --git a/src/Intervention/Application/Contract/Resource/InterventionEquipmentWork.php b/src/Intervention/Application/Contract/Resource/InterventionEquipmentWork.php new file mode 100644 index 000000000..dc7635e8a --- /dev/null +++ b/src/Intervention/Application/Contract/Resource/InterventionEquipmentWork.php @@ -0,0 +1,34 @@ + + */ + public function timeFacts(string $organizationId, string $interventionId): array; +} diff --git a/src/Intervention/Application/Port/Inbound/InterventionInventoryContextPort.php b/src/Intervention/Application/Port/Inbound/InterventionInventoryContextPort.php new file mode 100644 index 000000000..2b13d3b88 --- /dev/null +++ b/src/Intervention/Application/Port/Inbound/InterventionInventoryContextPort.php @@ -0,0 +1,15 @@ + $equipmentIds equipment whose legacy work is being migrated + * + * @return array> candidates by equipment identifier + */ + public function findOpenLegacyInspectionWork(string $organizationId, array $equipmentIds): array; + + /** + * Method attachOccurrence + * + * Must run in the caller's main transaction; repeated identical attachments are harmless. + * + * @access public + * + * @param string $organizationId owning organization + * @param string $workItemId legacy work item + * @param string $operationId preventive plan identity + * @param string $occurrenceId occurrence identity + * @param string $operationKind control or maintenance + * + * @return void + */ + public function attachOccurrence(string $organizationId, string $workItemId, string $operationId, string $occurrenceId, string $operationKind): void; +} diff --git a/src/Intervention/Application/Port/Inbound/InterventionPublicationFactsPort.php b/src/Intervention/Application/Port/Inbound/InterventionPublicationFactsPort.php new file mode 100644 index 000000000..73d9eb170 --- /dev/null +++ b/src/Intervention/Application/Port/Inbound/InterventionPublicationFactsPort.php @@ -0,0 +1,118 @@ + $interventionIds at most 100 unique identifiers + * + * @return list retained publication facts in stable identifier order + */ + public function publishedBatch(string $organizationId, array $interventionIds): array; + + /** + * Method publishedPage + * + * Includes legacy publications without dossiers and preserves one intervention per row. + * + * @access public + * + * @param string $organizationId authorized owning organization + * @param int $page one-based page + * @param int $itemsPerPage 1 to 100 + * @param ?string $search literal retained title or organization sequence + * + * @return InterventionPublicationFactsPage bounded published directory + */ + public function publishedPage(string $organizationId, int $page = 1, int $itemsPerPage = 50, ?string $search = null): InterventionPublicationFactsPage; + + /** + * Method economicContext + * + * Published identities are immutable; only unpublished work resolves current same-organization identities. + * + * @access public + * + * @param string $organizationId authorized owning organization + * @param string $interventionId requested work + * + * @return ?InterventionEconomicContext non-financial allocation source + */ + public function economicContext(string $organizationId, string $interventionId): ?InterventionEconomicContext; + + /** + * Method economicPage + * + * Filters published identities from their dossier and live identities through owning bridges. Dates are inclusive/exclusive. + * + * @access public + * + * @param string $organizationId authorized owning organization + * @param int $page one-based page + * @param int $itemsPerPage 1 to 100 + * @param ?string $search literal title or organization sequence search + * @param ?DateTimeImmutable $from inclusive source date + * @param ?DateTimeImmutable $to exclusive source date + * @param ?string $siteId optional root site + * @param ?string $customerId optional internal client + * @param ?string $equipmentId optional asset target + * @param list $financialInterventionIds at most 10000 finance-authorized additional matching sources; search and dates still apply + * + * @return InterventionEconomicContextPage bounded matching contexts with exact count + */ + public function economicPage(string $organizationId, int $page = 1, int $itemsPerPage = 50, ?string $search = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, ?string $siteId = null, ?string $customerId = null, ?string $equipmentId = null, array $financialInterventionIds = []): InterventionEconomicContextPage; + + /** + * Method economicWindow + * + * Uses completed publication time for published work and planned start or creation otherwise. More matches than the limit remain in totalItems. + * + * @access public + * + * @param string $organizationId authorized owning organization + * @param DateTimeImmutable $from inclusive source date + * @param DateTimeImmutable $to exclusive source date + * @param ?string $siteId optional root site + * @param ?string $customerId optional internal client + * @param ?string $equipmentId optional asset target + * @param int $limit 1 to 501 source contexts + * + * @return InterventionEconomicContextPage bounded report sources with exact count + */ + public function economicWindow(string $organizationId, DateTimeImmutable $from, DateTimeImmutable $to, ?string $siteId = null, ?string $customerId = null, ?string $equipmentId = null, int $limit = 501): InterventionEconomicContextPage; +} diff --git a/src/Intervention/Application/Port/Outbound/InterventionEconomicScopePort.php b/src/Intervention/Application/Port/Outbound/InterventionEconomicScopePort.php new file mode 100644 index 000000000..32398214b --- /dev/null +++ b/src/Intervention/Application/Port/Outbound/InterventionEconomicScopePort.php @@ -0,0 +1,30 @@ + root and descendant location identifiers + */ + public function facilityIds(string $organizationId, ?string $siteId, ?string $customerId): array; +} diff --git a/src/Intervention/Application/Port/Outbound/InterventionEquipmentReplacementPort.php b/src/Intervention/Application/Port/Outbound/InterventionEquipmentReplacementPort.php new file mode 100644 index 000000000..2345f9417 --- /dev/null +++ b/src/Intervention/Application/Port/Outbound/InterventionEquipmentReplacementPort.php @@ -0,0 +1,30 @@ + $equipmentIds at most 10000 unique identifiers + * + * @return array identities indexed by equipment identifier + */ + public function snapshots(string $organizationId, array $equipmentIds): array; + + /** + * Method equipmentIdsInFacilities + * + * Supplies a bounded same-organization target scope without exposing equipment persistence to the consumer. + * + * @access public + * + * @param string $organizationId owning organization + * @param list $facilityIds at most 10000 published location identifiers + * + * @return list at most 10000 published asset identifiers + */ + public function equipmentIdsInFacilities(string $organizationId, array $facilityIds): array; +} diff --git a/src/Intervention/Application/Port/Outbound/InterventionEquipmentWorkPort.php b/src/Intervention/Application/Port/Outbound/InterventionEquipmentWorkPort.php new file mode 100644 index 000000000..75c3717a1 --- /dev/null +++ b/src/Intervention/Application/Port/Outbound/InterventionEquipmentWorkPort.php @@ -0,0 +1,29 @@ + existing work ordered by planned creation + */ + public function findOpenWork(string $organizationId, string $equipmentId): array; +} diff --git a/src/Intervention/Application/Port/Outbound/InterventionInspectionResultPort.php b/src/Intervention/Application/Port/Outbound/InterventionInspectionResultPort.php new file mode 100644 index 000000000..f4bcc212a --- /dev/null +++ b/src/Intervention/Application/Port/Outbound/InterventionInspectionResultPort.php @@ -0,0 +1,32 @@ + + */ + public function issues(string $interventionId): array; +} diff --git a/src/Intervention/Application/Port/Outbound/InterventionSiteCustomerSnapshotPort.php b/src/Intervention/Application/Port/Outbound/InterventionSiteCustomerSnapshotPort.php new file mode 100644 index 000000000..5bed0906a --- /dev/null +++ b/src/Intervention/Application/Port/Outbound/InterventionSiteCustomerSnapshotPort.php @@ -0,0 +1,29 @@ +>}} + */ + public function snapshot(string $organizationId, ?string $siteId): array; +} diff --git a/src/Intervention/Application/Service/InterventionDraftFactory.php b/src/Intervention/Application/Service/InterventionDraftFactory.php index 8a7d04dbc..63ecb5214 100644 --- a/src/Intervention/Application/Service/InterventionDraftFactory.php +++ b/src/Intervention/Application/Service/InterventionDraftFactory.php @@ -173,6 +173,9 @@ private function createWorkItem(string $interventionId, string $actor, Intervent 'source' => 'planned', 'required' => $workItem->required, 'estimatedMinutes' => $workItem->estimatedMinutes, + 'operationId' => $workItem->operationId, + 'occurrenceId' => $workItem->occurrenceId, + 'operationKind' => $workItem->operationKind, ], )); } diff --git a/src/Intervention/Application/Service/InterventionIssueFinder.php b/src/Intervention/Application/Service/InterventionIssueFinder.php index bbdeeea3d..de373aef6 100644 --- a/src/Intervention/Application/Service/InterventionIssueFinder.php +++ b/src/Intervention/Application/Service/InterventionIssueFinder.php @@ -5,7 +5,7 @@ namespace Intervention\Application\Service; use Intervention\Application\Contract\Resource\{InterventionIssue, InterventionResourceSummary, InterventionValidationContext, InterventionWorkItemSummary}; -use Intervention\Application\Port\Outbound\{InterventionAttachmentRepositoryPort, InterventionResourceGatewayPort}; +use Intervention\Application\Port\Outbound\{InterventionAttachmentRepositoryPort, InterventionPublicationResourcesIssuesPort, InterventionResourceGatewayPort}; use function in_array; use function sprintf; @@ -33,6 +33,7 @@ public function __construct( private InterventionResourceGatewayPort $resources, private InterventionAttachmentRepositoryPort $attachments, + private InterventionPublicationResourcesIssuesPort $publicationResources, ) { } @@ -62,6 +63,7 @@ public function find( ...$this->siteSetupIssues($interventionId, $summary, $context), ...$this->workItemIssues($interventionId, $summary, $workItems, $context), ...$this->equipmentIssues($interventionId), + ...$this->publicationResources->issues($interventionId), ]; } @@ -97,7 +99,7 @@ private function workItemIssues( ): array { $issues = []; if (0 === $workItems->total) { - $severity = in_array($context?->type, ['inventory', 'inspection_campaign'], true) ? 'blocker' : 'warning'; + $severity = in_array($context?->type, ['inventory', 'inspection_campaign', 'preventive_maintenance', 'corrective_maintenance'], true) ? 'blocker' : 'warning'; $issues[] = new InterventionIssue($severity, 'intervention', $interventionId, null, 'No explicit work item has been prepared yet.'); } if ('inspection_campaign' === $context?->type && 0 === $summary->inspections) { diff --git a/src/Intervention/Application/UseCase/Query/Equipment/ListEquipmentOpenWork/ListEquipmentOpenWorkHandler.php b/src/Intervention/Application/UseCase/Query/Equipment/ListEquipmentOpenWork/ListEquipmentOpenWorkHandler.php new file mode 100644 index 000000000..8f275b669 --- /dev/null +++ b/src/Intervention/Application/UseCase/Query/Equipment/ListEquipmentOpenWork/ListEquipmentOpenWorkHandler.php @@ -0,0 +1,61 @@ +authorization->resolveAccess($query->userId, $query->organizationId, 'organization.interventions.read'); + if ($decision->isOutsideScope()) { + throw InterventionNotFoundException::withId($query->equipmentId); + } + if (!$decision->isGranted()) { + throw new InterventionAccessDeniedException('Missing organization.interventions.read permission.'); + } + if (!$this->resources->resourceBelongsToOrganization(InterventionResourceType::EQUIPMENT, $query->equipmentId, $query->organizationId)) { + throw InterventionNotFoundException::withId($query->equipmentId); + } + + return new ListEquipmentOpenWorkResult($this->work->findOpenWork($query->organizationId, $query->equipmentId)); + } +} diff --git a/src/Intervention/Application/UseCase/Query/Equipment/ListEquipmentOpenWork/ListEquipmentOpenWorkQuery.php b/src/Intervention/Application/UseCase/Query/Equipment/ListEquipmentOpenWork/ListEquipmentOpenWorkQuery.php new file mode 100644 index 000000000..b00c62733 --- /dev/null +++ b/src/Intervention/Application/UseCase/Query/Equipment/ListEquipmentOpenWork/ListEquipmentOpenWorkQuery.php @@ -0,0 +1,32 @@ + $items authorized open work + * + * @return void + */ + public function __construct(public array $items) + { + } +} diff --git a/src/Intervention/Domain/ValueObject/InterventionType.php b/src/Intervention/Domain/ValueObject/InterventionType.php index c010cf14f..6cdae1192 100644 --- a/src/Intervention/Domain/ValueObject/InterventionType.php +++ b/src/Intervention/Domain/ValueObject/InterventionType.php @@ -28,4 +28,10 @@ enum InterventionType: string * Case INSPECTION_CAMPAIGN */ case INSPECTION_CAMPAIGN = 'inspection_campaign'; + + /** Case PREVENTIVE_MAINTENANCE. */ + case PREVENTIVE_MAINTENANCE = 'preventive_maintenance'; + + /** Case CORRECTIVE_MAINTENANCE. */ + case CORRECTIVE_MAINTENANCE = 'corrective_maintenance'; } diff --git a/src/Intervention/Domain/ValueObject/WorkItemExecutionResult.php b/src/Intervention/Domain/ValueObject/WorkItemExecutionResult.php new file mode 100644 index 000000000..156fd2ae3 --- /dev/null +++ b/src/Intervention/Domain/ValueObject/WorkItemExecutionResult.php @@ -0,0 +1,146 @@ + $payload operation data supplied by an executor + * + * @return self the validated operation fact + */ + public static function fromPayload(array $payload): self + { + $equipmentId = $payload['equipmentId'] ?? null; + $performedAt = $payload['performedAt'] ?? null; + $outcome = $payload['outcome'] ?? null; + $workPerformed = $payload['workPerformed'] ?? null; + if (!is_string($equipmentId) || 1 !== preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $equipmentId)) { + throw new InterventionValidationException('The result must identify an equipment UUID.'); + } + if (!is_string($performedAt) || 1 !== preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/', $performedAt)) { + throw new InterventionValidationException('The actual execution date must include its timezone.'); + } + + try { + $date = new DateTimeImmutable($performedAt); + } catch (DateMalformedStringException) { + throw new InterventionValidationException('The actual execution date is invalid.'); + } + $errors = DateTimeImmutable::getLastErrors(); + if (false !== $errors && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) { + throw new InterventionValidationException('The actual execution date is invalid.'); + } + if (!is_string($outcome) || !in_array($outcome, ['successful', 'failed', 'performed'], true)) { + throw new InterventionValidationException('The operation outcome is invalid.'); + } + if (!is_string($workPerformed) || '' === trim($workPerformed) || mb_strlen($workPerformed) > 10000) { + throw new InterventionValidationException('Describe the executed work in at most 10000 characters.'); + } + + return new self($equipmentId, $date, $outcome, trim($workPerformed)); + } + + /** + * Method toArray + * + * Returns the factual payload without accepting caller-owned attribution or review state. + * + * @access public + * + * @return array{equipmentId:string, performedAt:string, outcome:string, workPerformed:string} + */ + public function toArray(): array + { + return ['equipmentId' => $this->equipmentId, 'performedAt' => $this->performedAt->format('c'), 'outcome' => $this->outcome, 'workPerformed' => $this->workPerformed]; + } + + /** + * Method assertCompletesAction + * + * A failed fact is retained but never completes repair, replacement or maintenance work. + * + * @access public + * + * @param string $action equipment work action + * @param string $targetEquipmentId prepared equipment identity + * + * @return void + */ + public function assertCompletesAction(string $action, string $targetEquipmentId): void + { + if ($this->equipmentId !== $targetEquipmentId || !in_array($action, ['maintenance', 'repair', 'replacement'], true) + || 'failed' === $this->outcome || ('maintenance' !== $action && 'successful' !== $this->outcome)) { + throw new InterventionValidationException('Completing this operation requires a successful result for its prepared equipment.'); + } + } + + /** + * Method assertAlreadyPerformed + * + * Distinguishes an actual execution fact from a scheduled future operation. + * + * @access public + * + * @param DateTimeImmutable $recordedAt server instant when the fact is being recorded or validated + * + * @return void + */ + public function assertAlreadyPerformed(DateTimeImmutable $recordedAt): void + { + if ($this->performedAt > $recordedAt) { + throw new InterventionValidationException('The actual execution date cannot be in the future.'); + } + } + // #endregion +} diff --git a/src/Intervention/Infrastructure/Adapter/Cost/InterventionCostSourceFactsAdapter.php b/src/Intervention/Infrastructure/Adapter/Cost/InterventionCostSourceFactsAdapter.php new file mode 100644 index 000000000..767bce5c7 --- /dev/null +++ b/src/Intervention/Infrastructure/Adapter/Cost/InterventionCostSourceFactsAdapter.php @@ -0,0 +1,60 @@ +entityManager->find(InterventionRecord::class, $interventionId, $lock ? LockMode::PESSIMISTIC_WRITE : null); + if (!$record instanceof InterventionRecord || $record->organization?->id !== $organizationId) { + return null; + } + if ($lock) { + $this->entityManager->refresh($record, LockMode::PESSIMISTIC_WRITE); + } + $items = $this->entityManager->getRepository(InterventionWorkItemRecord::class)->findBy(['intervention' => $record], null, 10001); + if (count($items) > 10000) { + throw new InterventionConflictException('Work items exceed the bounded finance source limit.'); + } + $itemIds = []; + foreach ($items as $item) { + $itemIds[] = $item->id; + } + + return new InterventionCostContext($record->id, $organizationId, $record->status, $record->revision, $record->name, $record->siteId, $itemIds); + } + + public function timeFacts(string $organizationId, string $interventionId): array + { + /** @var list $records */ + $records = $this->entityManager->createQueryBuilder()->select('t')->from(InterventionTimeEntryRecord::class, 't')->join('t.workItem', 'w')->join('w.intervention', 'i')->where('t.organizationId = :organization AND i.id = :intervention')->setParameter('organization', $organizationId)->setParameter('intervention', $interventionId)->orderBy('t.workedOn', 'ASC')->addOrderBy('t.id', 'ASC')->setMaxResults(10001)->getQuery()->getResult(); + if (count($records) > 10000) { + throw new InterventionConflictException('Time facts exceed the bounded finance source limit.'); + } + $facts = []; + foreach ($records as $record) { + if (null !== $record->workItem) { + $facts[] = new InterventionTimeCostFact($record->id, $record->workItem->id, $record->memberId, $record->workedOn, $record->minutes, $record->revision, $record->cancelled, $record->note, $record->updatedAt); + } + } + + return $facts; + } +} diff --git a/src/Intervention/Infrastructure/Adapter/Equipment/InterventionEquipmentWorkAdapter.php b/src/Intervention/Infrastructure/Adapter/Equipment/InterventionEquipmentWorkAdapter.php new file mode 100644 index 000000000..e8f73586c --- /dev/null +++ b/src/Intervention/Infrastructure/Adapter/Equipment/InterventionEquipmentWorkAdapter.php @@ -0,0 +1,71 @@ + existing work + */ + public function findOpenWork(string $organizationId, string $equipmentId): array + { + /** @var list $rows */ + $rows = $this->entityManager->createQueryBuilder() + ->select('i.id AS interventionId, i.number, i.name, i.status, w.id AS workItemId, w.action, w.status AS workItemStatus, w.target') + ->from(InterventionWorkItemRecord::class, 'w')->join('w.intervention', 'i') + ->where('IDENTITY(i.organization) = :organization AND i.status NOT IN (:closed) AND w.status <> :skipped') + ->andWhere('w.target = :target OR w.target LIKE :legacy') + ->setParameter('organization', $organizationId)->setParameter('closed', ['published', 'abandoned'])->setParameter('skipped', 'skipped') + ->setParameter('target', '/api/equipment/' . $equipmentId)->setParameter('legacy', '%' . $equipmentId . '%') + ->orderBy('i.createdAt', 'ASC')->addOrderBy('w.id', 'ASC')->getQuery()->getArrayResult(); + $items = []; + foreach ($rows as $row) { + $legacy = json_decode($row['target'], true); + if ('/api/equipment/' . $equipmentId !== $row['target'] && (!is_array($legacy) || $equipmentId !== ($legacy['equipmentId'] ?? null))) { + continue; + } + $items[] = new InterventionEquipmentWork($row['interventionId'], (int) $row['number'], $row['name'], $row['status'], $row['workItemId'], $row['action'], $row['workItemStatus']); + } + + return $items; + } +} diff --git a/src/Intervention/Infrastructure/Adapter/Equipment/InterventionServiceReportAdapter.php b/src/Intervention/Infrastructure/Adapter/Equipment/InterventionServiceReportAdapter.php index 19408bdae..5b88423df 100644 --- a/src/Intervention/Infrastructure/Adapter/Equipment/InterventionServiceReportAdapter.php +++ b/src/Intervention/Infrastructure/Adapter/Equipment/InterventionServiceReportAdapter.php @@ -7,10 +7,12 @@ use Doctrine\ORM\EntityManagerInterface; use Equipment\Application\Contract\Intervention\{InterventionServiceReport, ServicedEquipmentEntry}; use Equipment\Application\Port\Outbound\InterventionServiceReportPort; +use Intervention\Domain\ValueObject\WorkItemExecutionResult; use Intervention\Infrastructure\Persistence\Doctrine\Record\{InterventionChangeRecord, InterventionRecord, InterventionWorkItemRecord}; use Organization\Infrastructure\Persistence\Doctrine\Record\OrganizationRecord; use function array_key_exists; +use function is_string; use function preg_match; /** @@ -78,10 +80,23 @@ public function serviceReport(string $interventionId): ?InterventionServiceRepor ->getResult(); $equipment = []; + $reportedWorkItems = []; foreach ($changes as $change) { $entry = $this->toServicedEquipmentEntry($change); if (null !== $entry) { $equipment[] = $entry; + if (null !== $entry->workItemId) { + $reportedWorkItems[$entry->workItemId] = true; + } + } + } + if ('published' === $intervention->status) { + foreach ($this->entityManager->getRepository(InterventionWorkItemRecord::class)->findBy(['intervention' => $intervention, 'status' => 'completed', 'action' => ['maintenance', 'repair', 'replacement']], ['id' => 'ASC']) as $item) { + if (isset($reportedWorkItems[$item->id]) || 'validated' !== ($item->executionResult['state'] ?? null) || !is_string($item->executionResult['equipmentId'] ?? null)) { + continue; + } + $fact = WorkItemExecutionResult::fromPayload($item->executionResult); + $equipment[] = new ServicedEquipmentEntry($fact->equipmentId, $item->action, 'operation:' . $item->id, $item->id, performedAt: $fact->performedAt, notes: $fact->workPerformed, authorId: is_string($item->executionResult['authorId'] ?? null) ? $item->executionResult['authorId'] : null); } } @@ -113,12 +128,18 @@ private function toServicedEquipmentEntry(InterventionChangeRecord $change): ?Se $workItem = $change->workItem; $action = $workItem instanceof InterventionWorkItemRecord ? $workItem->action : $this->deriveAction($change->patch); + $fact = $workItem instanceof InterventionWorkItemRecord && null !== $workItem->executionResult && 'validated' === ($workItem->executionResult['state'] ?? null) && 'inspection' !== $workItem->action + ? WorkItemExecutionResult::fromPayload($workItem->executionResult) : null; + $authorId = $workItem?->executionResult['authorId'] ?? null; return new ServicedEquipmentEntry( equipmentId: $matches[1], action: $action, changeToken: $change->id, workItemId: $workItem?->id, + performedAt: $fact?->performedAt, + notes: $fact?->workPerformed, + authorId: null !== $fact && is_string($authorId) ? $authorId : null, ); } diff --git a/src/Intervention/Infrastructure/Adapter/Inventory/InterventionInventoryContextAdapter.php b/src/Intervention/Infrastructure/Adapter/Inventory/InterventionInventoryContextAdapter.php new file mode 100644 index 000000000..b8731fc8f --- /dev/null +++ b/src/Intervention/Infrastructure/Adapter/Inventory/InterventionInventoryContextAdapter.php @@ -0,0 +1,81 @@ +authorization->resolveAccess($actorId, $organizationId, 'organization.interventions.execute'); + if ($decision->isOutsideScope()) { + throw InterventionNotFoundException::withId($interventionId); + } + if (!$decision->isGranted()) { + throw new InterventionAccessDeniedException('Intervention execution permission is required.'); + } + if (!$this->entityManager->getConnection()->isTransactionActive()) { + throw new InterventionValidationException('Physical declarations require the main transaction.'); + } + $this->entityManager->getConnection()->executeQuery('SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))', ['key' => 'inventory-intervention:' . $organizationId . ':' . $interventionId]); + $intervention = $this->entityManager->find(InterventionRecord::class, $interventionId, LockMode::PESSIMISTIC_WRITE); + if (!$intervention instanceof InterventionRecord || $intervention->organization?->id !== $organizationId) { + throw InterventionNotFoundException::withId($interventionId); + } + $this->entityManager->refresh($intervention, LockMode::PESSIMISTIC_WRITE); + if (null !== $equipmentId && !$this->resources->resourceBelongsToOrganization(InterventionResourceType::EQUIPMENT, $equipmentId, $organizationId)) { + throw InterventionNotFoundException::withId($equipmentId); + } + if (null !== $workItemId) { + $item = $this->entityManager->find(InterventionWorkItemRecord::class, $workItemId); + if (!$item instanceof InterventionWorkItemRecord || $item->intervention?->id !== $interventionId) { + throw InterventionNotFoundException::withId($workItemId); + } + $target = null; + $targetValue = $item->target ?? ''; + if (1 === preg_match('#^/api/equipment/([^/]+)$#', $targetValue, $match)) { + $target = $match[1]; + } else { + $decoded = json_decode($targetValue, true); + if (is_array($decoded) && is_string($decoded['equipmentId'] ?? null)) { + $target = $decoded['equipmentId']; + } + } + if (null !== $equipmentId && $target !== $equipmentId) { + throw new InterventionValidationException('The physical declaration must match the task equipment.'); + } + $this->members->assertCanExecuteWorkItem($organizationId, $actorId, $intervention->responsibleId, $intervention->participants, $item->assigneeId); + } else { + $this->members->assertCanExecuteIntervention($organizationId, $actorId, $intervention->responsibleId, $intervention->participants); + } + + return new InventoryInterventionContext('published' === $intervention->status); + } + + public function existsInOrganization(string $organizationId, string $interventionId): bool + { + return null !== $this->entityManager->getRepository(InterventionRecord::class)->findOneBy(['id' => $interventionId, 'organization' => $organizationId]); + } +} diff --git a/src/Intervention/Infrastructure/Adapter/Inventory/InterventionPublicationResourcesIssuesAdapter.php b/src/Intervention/Infrastructure/Adapter/Inventory/InterventionPublicationResourcesIssuesAdapter.php new file mode 100644 index 000000000..4f15b7551 --- /dev/null +++ b/src/Intervention/Infrastructure/Adapter/Inventory/InterventionPublicationResourcesIssuesAdapter.php @@ -0,0 +1,33 @@ +entityManager->find(InterventionRecord::class, $interventionId); + if (!$intervention instanceof InterventionRecord || null === $intervention->organization) { + return []; + } + $issues = []; + foreach ($this->inventory->publicationBlockers($intervention->organization->id, $interventionId) as $reason) { + $issues[] = new InterventionIssue('blocker', 'intervention', $interventionId, 'resources', $reason); + } + + return $issues; + } +} diff --git a/src/Intervention/Infrastructure/Adapter/Maintenance/InterventionMaintenanceWorkAdapter.php b/src/Intervention/Infrastructure/Adapter/Maintenance/InterventionMaintenanceWorkAdapter.php new file mode 100644 index 000000000..bdb3d392f --- /dev/null +++ b/src/Intervention/Infrastructure/Adapter/Maintenance/InterventionMaintenanceWorkAdapter.php @@ -0,0 +1,173 @@ +entityManager->getConnection()->fetchOne('SELECT status FROM interventions WHERE id = :id AND organization_id = :organization', ['id' => $interventionId, 'organization' => $organizationId]); + + return is_string($value) ? $value : null; + } + + /** + * Method findOpenLegacyInspectionWork + * + * @access public + * + * @param string $organizationId owning organization + * @param list $equipmentIds prepared equipment scope + * + * @return array> work by equipment + */ + public function findOpenLegacyInspectionWork(string $organizationId, array $equipmentIds): array + { + if ([] === $equipmentIds) { + return []; + } + $query = $this->entityManager->createQueryBuilder()->select('w.id AS workItemId, w.target, i.id AS interventionId, i.number') + ->from(InterventionWorkItemRecord::class, 'w')->join('w.intervention', 'i') + ->where('IDENTITY(i.organization) = :organization AND i.status NOT IN (:closed) AND w.action = :action AND w.occurrenceId IS NULL AND w.status <> :skipped') + ->setParameter('organization', $organizationId)->setParameter('closed', ['published', 'abandoned']) + ->setParameter('action', 'inspection')->setParameter('skipped', 'skipped') + ->setParameter('targets', array_map(static fn (string $id): string => '/api/equipment/' . $id, $equipmentIds))->orderBy('w.id', 'ASC'); + $targetConditions = ['w.target IN (:targets)']; + foreach ($equipmentIds as $index => $equipmentId) { + $targetConditions[] = 'w.target LIKE :legacy' . $index; + $query->setParameter('legacy' . $index, '%' . $equipmentId . '%'); + } + $query->andWhere($query->expr()->orX(...$targetConditions)); + /** @var list $rows */ + $rows = $query->getQuery()->getArrayResult(); + $result = []; + foreach ($rows as $row) { + $equipmentId = $this->equipmentId($row['target']); + if (null !== $equipmentId && in_array($equipmentId, $equipmentIds, true)) { + $result[$equipmentId][] = new InterventionMaintenanceWork($row['interventionId'], (int) $row['number'], $row['workItemId']); + } + } + + return $result; + } + + /** + * Method attachOccurrence + * + * Locks the owning order and task and preserves submitted publication revisions. + * + * @access public + * + * @param string $organizationId owning organization + * @param string $workItemId prepared work identity + * @param string $operationId source plan + * @param string $occurrenceId source occurrence + * @param string $operationKind control or maintenance + * + * @return void + */ + public function attachOccurrence(string $organizationId, string $workItemId, string $operationId, string $occurrenceId, string $operationKind): void + { + if (!$this->entityManager->getConnection()->isTransactionActive()) { + throw new InterventionConflictException('Preventive handover requires the main transaction.'); + } + $item = $this->entityManager->find(InterventionWorkItemRecord::class, $workItemId); + if (!$item instanceof InterventionWorkItemRecord || !$item->intervention instanceof InterventionRecord || $item->intervention->organization?->id !== $organizationId) { + throw new InterventionConflictException('The preventive handover work is unavailable.'); + } + $order = $item->intervention; + $this->entityManager->lock($order, LockMode::PESSIMISTIC_WRITE); + $this->entityManager->refresh($order); + $this->entityManager->refresh($item, LockMode::PESSIMISTIC_WRITE); + if ($item->occurrenceId === $occurrenceId && $item->operationId === $operationId && $item->operationKind === $operationKind) { + return; + } + if (null !== $item->occurrenceId || in_array($order->status, ['submitted', 'published', 'abandoned'], true) + || !in_array($operationKind, ['control', 'maintenance'], true) || ('control' === $operationKind ? 'inspection' : 'maintenance') !== $item->action) { + throw new InterventionConflictException('The preventive handover conflicts with existing work.'); + } + $item->operationId = $operationId; + $item->occurrenceId = $occurrenceId; + $item->operationKind = $operationKind; + $equipmentId = $this->equipmentId($item->target); + if (null === $equipmentId) { + throw new InterventionConflictException('The legacy work does not identify its equipment.'); + } + $item->target = '/api/equipment/' . $equipmentId; + ++$item->revision; + ++$order->revision; + $item->updatedAt = $order->updatedAt = new DateTimeImmutable(); + $this->entityManager->flush(); + } + + /** + * Method equipmentId + * + * Supports deployed JSON targets during handover without treating arbitrary text as an equipment reference. + * + * @access private + * + * @param ?string $target historical JSON or canonical equipment IRI + * + * @return ?string exact referenced equipment identity + */ + private function equipmentId(?string $target): ?string + { + if (null === $target) { + return null; + } + if (1 === preg_match('#^/api/equipment/([^/]+)$#', $target, $match)) { + return $match[1]; + } + $value = json_decode($target, true); + + return is_array($value) && is_string($value['equipmentId'] ?? null) ? $value['equipmentId'] : null; + } +} diff --git a/src/Intervention/Infrastructure/Adapter/Publication/DoctrinePublicationAdapter.php b/src/Intervention/Infrastructure/Adapter/Publication/DoctrinePublicationAdapter.php index e7cfd141c..fa52f8f3d 100644 --- a/src/Intervention/Infrastructure/Adapter/Publication/DoctrinePublicationAdapter.php +++ b/src/Intervention/Infrastructure/Adapter/Publication/DoctrinePublicationAdapter.php @@ -14,6 +14,7 @@ use Intervention\Domain\Service\{InterventionChangePolicy, PublicationTransitionPolicy}; use Intervention\Domain\ValueObject\{InterventionChangeStatus, InterventionStatus, PublicationStatus}; use Intervention\Infrastructure\Persistence\Doctrine\Record\{InterventionChangeRecord, InterventionRecord, PublicationRecord}; +use MaintenanceCost\Application\Port\Inbound\MaintenanceCostPublicationPort; use Organization\Infrastructure\Persistence\Doctrine\Record\OrganizationRecord; use function array_filter; @@ -42,6 +43,9 @@ * @param InterventionDraftPublisher $draftPublisher the draft publisher value * @param PublicationTransitionPolicy $transitionPolicy the publication status transition policy value * @param InterventionChangePolicy $changePolicy the intervention change status policy value + * @param InterventionOperationPublicationAdapter $operations validates execution facts and acknowledges their source in the same transaction + * @param InterventionClosureSnapshotAdapter $snapshots captures the immutable closure dossier + * @param ?InterventionPublicationValidation $validation validates the final published resource graph */ public function __construct( private EntityManagerInterface $entityManager, @@ -49,6 +53,9 @@ public function __construct( private InterventionDraftPublisher $draftPublisher, private PublicationTransitionPolicy $transitionPolicy, private InterventionChangePolicy $changePolicy, + private InterventionOperationPublicationAdapter $operations, + private InterventionClosureSnapshotAdapter $snapshots, + private MaintenanceCostPublicationPort $costs, private ?InterventionPublicationValidation $validation = null, ) { } @@ -237,6 +244,13 @@ public function publish(string $publicationId): bool // completed this publication — no transition, no notification. return false; } + // Stock declarations acquire this resources fence before their work + // lock. Publication uses the same order to avoid a lock inversion. + $sourceOrganization = $publication->intervention->organization; + if (!$sourceOrganization instanceof OrganizationRecord) { + throw new InterventionConflictException('Intervention organization is unavailable.'); + } + $this->costs->assertReadyToPublish($sourceOrganization->id, $publication->intervention->id); $intervention = $this->entityManager->find(InterventionRecord::class, $publication->intervention->id, LockMode::PESSIMISTIC_WRITE); // @codeCoverageIgnoreStart // Unreachable: line 220 already dereferences $publication->intervention, @@ -256,13 +270,19 @@ public function publish(string $publicationId): bool throw new InterventionConflictException('Intervention organization is unavailable.'); } - $this->applyPublicationResources($intervention, $intervention->organization->id); + $organizationId = $intervention->organization->id; + $this->applyPublicationResources($intervention, $organizationId); + $this->operations->publish($intervention, $organizationId); + $this->entityManager->flush(); + $this->costs->freeze($organizationId, $intervention->id, $publication->id, $intervention->revision); + $publishedAt = new DateTimeImmutable(); + $intervention->closureSnapshot = $this->snapshots->capture($intervention, $organizationId, $publication->id, $publishedAt); $intervention->status = InterventionStatus::PUBLISHED->value; ++$intervention->revision; $intervention->updatedAt = new DateTimeImmutable(); $this->transitionPolicy->assertAllowed($currentPublicationStatus, PublicationStatus::COMPLETED); $publication->status = PublicationStatus::COMPLETED->value; - $publication->completedAt = new DateTimeImmutable(); + $publication->completedAt = $publishedAt; $this->entityManager->flush(); return true; diff --git a/src/Intervention/Infrastructure/Adapter/Publication/InterventionClosureSnapshotAdapter.php b/src/Intervention/Infrastructure/Adapter/Publication/InterventionClosureSnapshotAdapter.php new file mode 100644 index 000000000..be912e446 --- /dev/null +++ b/src/Intervention/Infrastructure/Adapter/Publication/InterventionClosureSnapshotAdapter.php @@ -0,0 +1,188 @@ + immutable versioned dossier + */ + public function capture(InterventionRecord $intervention, string $organizationId, ?string $publicationId = null, ?DateTimeImmutable $publishedAt = null): array + { + if (null !== $intervention->closureSnapshot) { + return $intervention->closureSnapshot; + } + $items = $this->entityManager->getRepository(InterventionWorkItemRecord::class)->findBy(['intervention' => $intervention], ['createdAt' => 'ASC', 'id' => 'ASC'], 10001); + $attachments = $this->entityManager->getRepository(InterventionAttachmentRecord::class)->findBy(['intervention' => $intervention], ['uploadedAt' => 'ASC', 'id' => 'ASC'], 10001); + $activities = $this->entityManager->getRepository(InterventionActivityRecord::class)->findBy(['intervention' => $intervention], ['createdAt' => 'ASC', 'id' => 'ASC'], 10001); + $changes = $this->entityManager->getRepository(InterventionChangeRecord::class)->findBy(['intervention' => $intervention], null, 10001); + /** @var list $times */ + $times = $this->entityManager->createQueryBuilder()->select('t')->from(InterventionTimeEntryRecord::class, 't')->join('t.workItem', 'w')->where('w.intervention = :intervention AND t.organizationId = :organization')->setParameter('intervention', $intervention)->setParameter('organization', $organizationId)->orderBy('t.id', 'ASC')->setMaxResults(10001)->getQuery()->getResult(); + foreach ([$items, $attachments, $activities, $changes, $times] as $records) { + if (count($records) > 10000) { + throw new InterventionFactsScopeTooLarge('A publication dossier exceeds 10000 records in one source; split the work before publication.'); + } + } + $equipmentIds = []; + foreach ($items as $item) { + foreach ([$item->target, $item->resultResource] as $target) { + $equipmentId = $this->facts->equipmentId($target); + if (null !== $equipmentId) { + $equipmentIds[] = $equipmentId; + } + } + } + $equipmentSnapshots = $this->equipment->snapshots($organizationId, array_values(array_unique($equipmentIds))); + $memberIds = [$intervention->responsibleId, ...$intervention->participants, ...array_map(static fn (InterventionWorkItemRecord $item): ?string => $item->assigneeId, $items), ...array_map(static fn (InterventionActivityRecord $activity): ?string => $activity->actorId, $activities)]; + $names = $this->members->displayNamesFor($organizationId, array_values(array_unique(array_filter($memberIds, is_string(...))))); + $identity = $this->sites->snapshot($organizationId, $intervention->siteId); + $site = ['site' => $identity['site'], 'customer' => null === $identity['customer'] ? null : ['id' => $identity['customer']['id'], 'name' => $identity['customer']['name']]]; + $capturedAt = ($publishedAt ?? new DateTimeImmutable())->format('c'); + $spentMinutes = []; + foreach ($times as $entry) { + if (!$entry->cancelled && null !== $entry->workItem) { + $spentMinutes[$entry->workItem->id] = ($spentMinutes[$entry->workItem->id] ?? 0) + $entry->minutes; + } + } + $evidenceCounts = []; + foreach ($attachments as $attachment) { + if (null !== $attachment->workItem) { + $evidenceCounts[$attachment->workItem->id] = ($evidenceCounts[$attachment->workItem->id] ?? 0) + 1; + } + } + $workItems = array_map(fn (InterventionWorkItemRecord $item): array => $this->workItem($item, $organizationId, $spentMinutes[$item->id] ?? 0, $evidenceCounts[$item->id] ?? 0, $equipmentSnapshots[$this->facts->equipmentId($item->target) ?? ''] ?? null, $equipmentSnapshots[$this->facts->equipmentId($item->resultResource) ?? ''] ?? null, $capturedAt), $items); + $evidence = array_map(static fn (InterventionAttachmentRecord $attachment): array => ['id' => $attachment->id, 'fileName' => $attachment->fileName, 'kind' => $attachment->kind, 'mimeType' => $attachment->mimeType, 'size' => $attachment->size, 'label' => $attachment->label, 'workItemId' => $attachment->workItem?->id, 'revision' => $attachment->revision, 'uploadedAt' => $attachment->uploadedAt->format('c')], $attachments); + $timeEntries = array_map(static fn (InterventionTimeEntryRecord $entry): array => ['id' => $entry->id, 'workItemId' => $entry->workItem?->id, 'memberId' => $entry->memberId, 'workedOn' => $entry->workedOn, 'minutes' => $entry->minutes, 'note' => $entry->note, 'cancelled' => $entry->cancelled, 'revision' => $entry->revision], $times); + $report = ['number' => $intervention->number, 'name' => $intervention->name, 'type' => $intervention->type, 'status' => 'published', 'priority' => $intervention->priority, 'siteName' => $site['site']['name'] ?? null, 'customerName' => $site['customer']['name'] ?? null, 'responsibleName' => $names[$intervention->responsibleId ?? ''] ?? null, 'participantNames' => array_values(array_filter(array_map(static fn (string $id): ?string => $names[$id] ?? null, $intervention->participants))), 'plannedStartAt' => $intervention->plannedStartAt?->format('c'), 'dueAt' => $intervention->dueAt?->format('c'), 'reviewNote' => $intervention->reviewNote, 'hasSignature' => count(array_filter($attachments, static fn (InterventionAttachmentRecord $item): bool => 'signature' === $item->kind)) > 0, 'labels' => array_map(static fn ($label): array => ['id' => $label->id, 'name' => $label->name, 'color' => $label->color], $intervention->labels->toArray()), 'generatedAt' => $capturedAt, 'reportMode' => 'snapshot', 'capturedAt' => $capturedAt, 'snapshotVersion' => 2, 'workItems' => [], 'issues' => [], 'proposedChangesCount' => 0, 'appliedChangesCount' => 0, 'rejectedChangesCount' => 0, 'attachments' => $evidence, 'activities' => [], 'timeEntries' => $timeEntries]; + $reportItems = []; + foreach ($items as $index => $item) { + $reportItems[] = [...$workItems[$index], 'assigneeName' => $names[$item->assigneeId ?? ''] ?? null]; + } + $report['workItems'] = $reportItems; + $changeCounts = ['proposedChangesCount' => 0, 'appliedChangesCount' => 0, 'rejectedChangesCount' => 0]; + foreach ($changes as $change) { + $key = $change->status . 'ChangesCount'; + $changeCounts[$key] = ($changeCounts[$key] ?? 0) + 1; + } + $report = [...$report, ...$changeCounts]; + $reportActivities = []; + foreach ($activities as $activity) { + $reportActivities[] = ['createdAt' => $activity->createdAt->format('c'), 'kind' => $activity->kind, 'event' => $activity->event, 'body' => $activity->body, 'payload' => $activity->payload, 'actorName' => $names[$activity->actorId ?? ''] ?? null]; + } + $report['activities'] = $reportActivities; + + return ['version' => 2, 'capturedAt' => $capturedAt, 'publishedAt' => $capturedAt, 'publicationId' => $publicationId, 'interventionId' => $intervention->id, 'revision' => $intervention->revision + 1, 'number' => $intervention->number, 'name' => $intervention->name, 'type' => $intervention->type, 'createdAt' => $intervention->createdAt->format('c'), 'plannedStartAt' => $intervention->plannedStartAt?->format('c'), 'dueAt' => $intervention->dueAt?->format('c'), ...$site, 'memberNames' => $names, 'workItems' => $workItems, 'attachments' => $evidence, 'timeEntries' => $timeEntries, 'report' => $report]; + } + + /** + * Method workItem + * + * Maps the dossier from loaded records and grouped evidence/time totals without per-task queries. + * + * @access private + * + * @param InterventionWorkItemRecord $item reviewed task + * @param string $organizationId owning organization + * @param int $spentMinutes known uncancelled time in whole minutes + * @param int $evidenceCount known attached proofs + * @param ?InterventionEquipmentSnapshot $equipment captured target asset identity + * @param ?InterventionEquipmentSnapshot $resultEquipment captured replacement successor identity + * @param string $capturedAt publication timestamp + * + * @return array frozen work item read shape + */ + private function workItem(InterventionWorkItemRecord $item, string $organizationId, int $spentMinutes, int $evidenceCount, ?InterventionEquipmentSnapshot $equipment, ?InterventionEquipmentSnapshot $resultEquipment, string $capturedAt): array + { + $execution = $item->executionResult; + if ('inspection' === $item->action && 'completed' === $item->status && null === $execution && null !== $item->resultResource && 1 === preg_match('#^/api/inspections/([^/]+)$#', $item->resultResource, $match) && null !== $item->intervention) { + $inspection = $this->inspections->find($organizationId, $item->intervention->id, $match[1]); + if (null !== $inspection && 'closed' === $inspection->status && $inspection->equipmentId === $this->facts->equipmentId($item->target)) { + $execution = ['equipmentId' => $inspection->equipmentId, 'performedAt' => $inspection->performedAt->format('c'), 'outcome' => 'performed', 'inspectionResult' => $inspection->result, 'workPerformed' => $inspection->notes ?? '', 'authorId' => $inspection->authorId, 'state' => 'validated', 'validatedAt' => $capturedAt]; + } + } + + return [ + 'id' => $item->id, + 'intervention' => '/api/interventions/' . $item->intervention?->id, + 'action' => $item->action, + 'target' => $item->target, + 'resultResource' => $item->resultResource, + 'assignee' => null === $item->assigneeId ? null : '/api/organizations/' . $organizationId . '/members/' . $item->assigneeId, + 'source' => $item->source, + 'status' => $item->status, + 'required' => $item->required, + 'skipReason' => $item->skipReason, + 'operationId' => $item->operationId, + 'occurrenceId' => $item->occurrenceId, + 'operationKind' => $item->operationKind, + 'executionResult' => $execution, + 'equipmentIdentity' => null === $equipment ? null : get_object_vars($equipment), + 'resultEquipmentIdentity' => null === $resultEquipment ? null : get_object_vars($resultEquipment), + 'site' => $equipment?->site, + 'customer' => $equipment?->customer, + 'estimatedMinutes' => $item->estimatedMinutes, + 'remainingMinutes' => $item->remainingMinutes, + 'workStartsOn' => $item->workStartsOn, + 'workEndsOn' => $item->workEndsOn, + 'spentMinutes' => $spentMinutes, + 'evidenceCount' => $evidenceCount, + 'revision' => $item->revision, + 'createdAt' => $item->createdAt->format('c'), + 'updatedAt' => $item->updatedAt->format('c'), + ]; + } +} diff --git a/src/Intervention/Infrastructure/Adapter/Publication/InterventionOperationPublicationAdapter.php b/src/Intervention/Infrastructure/Adapter/Publication/InterventionOperationPublicationAdapter.php new file mode 100644 index 000000000..dceb766ea --- /dev/null +++ b/src/Intervention/Infrastructure/Adapter/Publication/InterventionOperationPublicationAdapter.php @@ -0,0 +1,162 @@ +entityManager->getRepository(InterventionWorkItemRecord::class)->findBy(['intervention' => $intervention], ['id' => 'ASC']); + foreach ($items as $item) { + if ('completed' !== $item->status) { + continue; + } + if ('inspection' === $item->action && null !== $item->occurrenceId) { + $this->publishInspection($intervention, $item, $organizationId); + } elseif (in_array($item->action, ['maintenance', 'repair', 'replacement'], true)) { + $this->publishEquipmentOperation($intervention, $item, $organizationId); + } else { + continue; + } + ++$item->revision; + $item->updatedAt = new DateTimeImmutable(); + } + } + + /** + * Method publishInspection + * + * A closed adverse control is a performed control; it does not close its anomalies. + * + * @access private + * + * @param InterventionRecord $intervention owning intervention + * @param InterventionWorkItemRecord $item source-linked control task + * @param string $organizationId owning organization + * + * @return void + */ + private function publishInspection(InterventionRecord $intervention, InterventionWorkItemRecord $item, string $organizationId): void + { + if (null === $item->resultResource || 1 !== preg_match('#^/api/inspections/([^/]+)$#', $item->resultResource, $match)) { + throw new InterventionValidationException('A preventive control must identify its inspection result.'); + } + $result = $this->inspections->find($organizationId, $intervention->id, $match[1]); + $equipmentId = $this->equipmentId($item); + if (null === $result || $equipmentId !== $result->equipmentId || 'closed' !== $result->status || 'control' !== $item->operationKind) { + throw new InterventionValidationException('The control result must be closed and match the occurrence equipment.'); + } + $fact = new MaintenanceOperationResult($organizationId, $equipmentId, $item->occurrenceId ?? '', $result->id, $intervention->id, 'control', 'pass' === $result->result ? 'passed' : 'failed', $result->performedAt, operationId: $item->operationId); + $this->maintenance->validateResult($fact); + $this->maintenance->acknowledgeResult($fact); + $item->executionResult = ['equipmentId' => $equipmentId, 'performedAt' => $result->performedAt->format('c'), 'outcome' => 'performed', 'inspectionResult' => $result->result, 'workPerformed' => $result->notes ?? '', 'authorId' => $result->authorId, 'operationId' => $item->operationId, 'occurrenceId' => $item->occurrenceId, 'state' => 'validated', 'validatedAt' => new DateTimeImmutable()->format('c')]; + } + + /** + * Method publishEquipmentOperation + * + * Rejects missing facts and prevents unsuccessful repairs from satisfying an occurrence. + * + * @access private + * + * @param InterventionRecord $intervention owning intervention + * @param InterventionWorkItemRecord $item completed equipment operation + * @param string $organizationId owning organization + * + * @return void + */ + private function publishEquipmentOperation(InterventionRecord $intervention, InterventionWorkItemRecord $item, string $organizationId): void + { + if (null === $item->executionResult) { + throw new InterventionValidationException('Equipment operations require a recorded execution result.'); + } + $result = WorkItemExecutionResult::fromPayload($item->executionResult); + $result->assertAlreadyPerformed(new DateTimeImmutable()); + $result->assertCompletesAction($item->action, $this->equipmentId($item)); + if (!is_string($item->executionResult['authorId'] ?? null)) { + throw new InterventionValidationException('The completed operation has no successful scoped execution fact.'); + } + if (null !== $item->occurrenceId) { + if ('maintenance' !== $item->action || 'maintenance' !== $item->operationKind) { + throw new InterventionValidationException('A repair cannot satisfy a preventive maintenance occurrence.'); + } + $fact = new MaintenanceOperationResult($organizationId, $result->equipmentId, $item->occurrenceId, $item->id, $intervention->id, 'maintenance', 'passed', $result->performedAt, operationId: $item->operationId); + $this->maintenance->validateResult($fact); + $this->maintenance->acknowledgeResult($fact); + } + $item->executionResult['state'] = 'validated'; + if ('replacement' === $item->action && (null === $item->resultResource || 1 !== preg_match('#^/api/equipment/([^/]+)$#', $item->resultResource, $successor) + || !$this->replacements->isReplacement($organizationId, $result->equipmentId, $successor[1]))) { + throw new InterventionValidationException('The equipment owner must confirm the recorded replacement.'); + } + $item->executionResult['validatedAt'] = new DateTimeImmutable()->format('c'); + } + + /** + * Method equipmentId + * + * @access private + * + * @param InterventionWorkItemRecord $item equipment work item + * + * @return string stable target equipment identifier + */ + private function equipmentId(InterventionWorkItemRecord $item): string + { + if (null === $item->target || 1 !== preg_match('#^/api/equipment/([^/]+)$#', $item->target, $match)) { + throw new InterventionValidationException('The operation target must identify its equipment.'); + } + + return $match[1]; + } +} diff --git a/src/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapter.php b/src/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapter.php new file mode 100644 index 000000000..9a923b022 --- /dev/null +++ b/src/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapter.php @@ -0,0 +1,531 @@ +> 'publishedAt')::timestamptz AT TIME ZONE 'UTC', p.completed_at, (i.closure_snapshot::jsonb ->> 'capturedAt')::timestamptz AT TIME ZONE 'UTC') ELSE COALESCE(i.planned_start_at, i.created_at) END"; + + /** + * Constant TARGET_ID + * + * Reads canonical and historical JSON equipment targets without casting arbitrary target strings as JSON. + */ + private const string TARGET_ID = "COALESCE(substring(w.target from '^/api/equipment/([^/]+)$'), substring(w.target from '\"equipmentId\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"'))"; + // #endregion + + // #region Constructor + /** + * Method __construct + * + * Every owned persistence read names the main manager; sibling identities are resolved through public ports. + * + * @access public + * + * @param EntityManagerInterface $entityManager explicit main manager + * @param InterventionPublicationFactsMapper $mapper immutable source mapper + * @param InterventionEquipmentSnapshotPort $equipment owning asset identity bridge + * @param InterventionEconomicScopePort $scopes owning location scope bridge + * @param ServiceRequestSiteTargetPort $sites published root site identity bridge + * + * @return void + */ + public function __construct(private EntityManagerInterface $entityManager, private InterventionPublicationFactsMapper $mapper, private InterventionEquipmentSnapshotPort $equipment, private InterventionEconomicScopePort $scopes, private ServiceRequestSiteTargetPort $sites) + { + } + // #endregion + + // #region Methods + /** + * Method published + * + * @access public + * + * @param string $organizationId authorized owning organization + * @param string $interventionId requested work + * + * @return ?InterventionPublicationFacts original dossier or explicit missing-snapshot facts + */ + public function published(string $organizationId, string $interventionId): ?InterventionPublicationFacts + { + return $this->publishedBatch($organizationId, [$interventionId])[0] ?? null; + } + + /** + * Method publishedBatch + * + * @access public + * + * @param string $organizationId authorized owning organization + * @param list $interventionIds bounded unique source identities + * + * @return list organization-scoped published facts + */ + public function publishedBatch(string $organizationId, array $interventionIds): array + { + $interventionIds = array_values(array_unique($interventionIds)); + if (count($interventionIds) > 100) { + throw new InterventionFactsScopeTooLarge('A publication batch may contain at most 100 interventions.'); + } + if ([] === $interventionIds) { + return []; + } + $rows = $this->rows("i.organization_id = :organization AND i.status = 'published' AND i.id IN (:ids)", ['organization' => $organizationId, 'ids' => $interventionIds], ['ids' => ArrayParameterType::STRING], count($interventionIds), 0, 10000); + + $facts = array_map(fn (array $row): InterventionPublicationFacts => $this->publication($row), $rows); + usort($facts, static fn (InterventionPublicationFacts $left, InterventionPublicationFacts $right): int => strcmp($left->id, $right->id)); + + return $facts; + } + + /** + * Method publishedPage + * + * @access public + * + * @param string $organizationId authorized owning organization + * @param int $page one-based page + * @param int $itemsPerPage bounded size + * @param ?string $search literal retained title or organization sequence + * + * @return InterventionPublicationFactsPage exact published directory + */ + public function publishedPage(string $organizationId, int $page = 1, int $itemsPerPage = 50, ?string $search = null): InterventionPublicationFactsPage + { + $this->pagination($page, $itemsPerPage, 100); + $where = "i.organization_id = :organization AND i.status = 'published'"; + $parameters = ['organization' => $organizationId]; + if (null !== $search && '' !== $search) { + if (mb_strlen($search, 'UTF-8') > 160) { + throw new InvalidArgumentException('The source search may contain at most 160 characters.'); + } + $where .= " AND (COALESCE(i.closure_snapshot::jsonb ->> 'name', i.closure_snapshot::jsonb -> 'report' ->> 'name', i.name) ILIKE :search ESCAPE '!' OR i.number::text ILIKE :search ESCAPE '!')"; + $parameters['search'] = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $search) . '%'; + } + $rows = $this->rows($where, $parameters, [], $itemsPerPage, ($page - 1) * $itemsPerPage, 10000); + + return new InterventionPublicationFactsPage(array_map(fn (array $row): InterventionPublicationFacts => $this->publication($row), $rows), $this->total($where, $parameters, []), $page, $itemsPerPage); + } + + /** + * Method economicContext + * + * @access public + * + * @param string $organizationId authorized owning organization + * @param string $interventionId requested source + * + * @return ?InterventionEconomicContext immutable or explicit current allocation facts + */ + public function economicContext(string $organizationId, string $interventionId): ?InterventionEconomicContext + { + $rows = $this->rows('i.organization_id = :organization AND i.id = :id', ['organization' => $organizationId, 'id' => $interventionId], [], 1, 0); + + return [] === $rows ? null : $this->context($rows[0]); + } + + /** + * Method economicPage + * + * @access public + * + * @param string $organizationId authorized owning organization + * @param int $page one-based page + * @param int $itemsPerPage bounded size + * @param ?string $search literal source title or number + * @param ?DateTimeImmutable $from inclusive source date + * @param ?DateTimeImmutable $to exclusive source date + * @param ?string $siteId optional root site + * @param ?string $customerId optional internal client + * @param ?string $equipmentId optional asset + * @param list $financialInterventionIds bounded finance-authorized additional source identities + * + * @return InterventionEconomicContextPage exact matching source directory + */ + public function economicPage(string $organizationId, int $page = 1, int $itemsPerPage = 50, ?string $search = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, ?string $siteId = null, ?string $customerId = null, ?string $equipmentId = null, array $financialInterventionIds = []): InterventionEconomicContextPage + { + $this->pagination($page, $itemsPerPage, 100); + + return $this->economicSources($organizationId, $page, $itemsPerPage, $search, $from, $to, $siteId, $customerId, $equipmentId, $financialInterventionIds); + } + + /** + * Method economicWindow + * + * @access public + * + * @param string $organizationId authorized owning organization + * @param DateTimeImmutable $from inclusive source date + * @param DateTimeImmutable $to exclusive source date + * @param ?string $siteId optional root site + * @param ?string $customerId optional internal client + * @param ?string $equipmentId optional asset + * @param int $limit bounded report source count + * + * @return InterventionEconomicContextPage first bounded page and exact filtered count + */ + public function economicWindow(string $organizationId, DateTimeImmutable $from, DateTimeImmutable $to, ?string $siteId = null, ?string $customerId = null, ?string $equipmentId = null, int $limit = 501): InterventionEconomicContextPage + { + $this->pagination(1, $limit, 501); + + return $this->economicSources($organizationId, 1, $limit, null, $from, $to, $siteId, $customerId, $equipmentId); + } + + /** + * Method economicSources + * + * Applies the identical source predicate to count and page. Filters use immutable published identities and bounded owned live scopes. + * + * @access private + * + * @param string $organizationId owning organization + * @param int $page validated page + * @param int $size validated source limit + * @param ?string $search literal title or sequence + * @param ?DateTimeImmutable $from inclusive source date + * @param ?DateTimeImmutable $to exclusive source date + * @param ?string $siteId optional root site + * @param ?string $customerId optional client + * @param ?string $equipmentId optional equipment + * @param list $financialInterventionIds bounded additional matching sources + * + * @return InterventionEconomicContextPage bounded exact directory + */ + private function economicSources(string $organizationId, int $page, int $size, ?string $search, ?DateTimeImmutable $from, ?DateTimeImmutable $to, ?string $siteId, ?string $customerId, ?string $equipmentId, array $financialInterventionIds = []): InterventionEconomicContextPage + { + $financialInterventionIds = array_values(array_unique($financialInterventionIds)); + if (count($financialInterventionIds) > 10000) { + throw new InterventionFactsScopeTooLarge('The financial source scope exceeds 10000 intervention identifiers.'); + } + if (null !== $from && null !== $to && $from >= $to) { + throw new InvalidArgumentException('The source window must have an exclusive end after its start.'); + } + $filters = ['i.organization_id = :organization']; + $parameters = ['organization' => $organizationId]; + $types = []; + if (null !== $from) { + $filters[] = self::SOURCE_DATE . ' >= :from'; + $parameters['from'] = $from->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s.u'); + } + if (null !== $to) { + $filters[] = self::SOURCE_DATE . ' < :to'; + $parameters['to'] = $to->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s.u'); + } + if (null !== $search && '' !== $search) { + if (mb_strlen($search, 'UTF-8') > 160) { + throw new InvalidArgumentException('The source search may contain at most 160 characters.'); + } + $filters[] = "(CASE WHEN i.status = 'published' THEN COALESCE(i.closure_snapshot::jsonb ->> 'name', i.closure_snapshot::jsonb -> 'report' ->> 'name', i.name) ELSE i.name END ILIKE :search ESCAPE '!' OR i.number::text ILIKE :search ESCAPE '!')"; + $parameters['search'] = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $search) . '%'; + } + if (null !== $siteId || null !== $customerId || null !== $equipmentId) { + $identityFilter = $this->identityFilter($organizationId, $siteId, $customerId, $equipmentId, $parameters, $types); + if ([] !== $financialInterventionIds) { + $parameters['financialSources'] = $financialInterventionIds; + $types['financialSources'] = ArrayParameterType::STRING; + $identityFilter = '(' . $identityFilter . ' OR i.id IN (:financialSources))'; + } + $filters[] = $identityFilter; + } + $where = implode(' AND ', $filters); + $total = $this->total($where, $parameters, $types); + if (0 === $total) { + return new InterventionEconomicContextPage([], 0, $page, $size); + } + $rows = $this->rows($where, $parameters, $types, $size, ($page - 1) * $size); + $items = []; + $taskCount = 0; + foreach ($rows as $row) { + $context = $this->context($row); + $taskCount += count($context->workItems); + if ($taskCount > 20000) { + throw new InterventionFactsScopeTooLarge('The source page exceeds 20000 tasks; narrow the requested scope.'); + } + $items[] = $context; + } + + return new InterventionEconomicContextPage($items, $total, $page, $size); + } + + /** + * Method identityFilter + * + * Applies combined site/client/equipment filters to the same captured task rather than mixing unrelated tasks. + * + * @access private + * + * @param string $organizationId owning organization + * @param ?string $siteId root site filter + * @param ?string $customerId client filter + * @param ?string $equipmentId asset filter + * @param array $parameters accumulated parameters + * @param array $types accumulated array parameter types + * + * @return string scoped SQL predicate + */ + private function identityFilter(string $organizationId, ?string $siteId, ?string $customerId, ?string $equipmentId, array &$parameters, array &$types): string + { + $root = []; + $task = []; + if (null !== $siteId) { + $parameters['site'] = $siteId; + $root[] = "i.closure_snapshot::jsonb -> 'site' ->> 'id' = :site"; + $task[] = "COALESCE(j -> 'site' ->> 'id', j -> 'equipmentIdentity' -> 'site' ->> 'id') = :site"; + } + if (null !== $customerId) { + $parameters['customer'] = $customerId; + $root[] = "i.closure_snapshot::jsonb -> 'customer' ->> 'id' = :customer"; + $task[] = "COALESCE(j -> 'customer' ->> 'id', j -> 'equipmentIdentity' -> 'customer' ->> 'id') = :customer"; + } + if (null !== $equipmentId) { + $parameters['equipment'] = $equipmentId; + $task[] = "COALESCE(j -> 'equipmentIdentity' ->> 'id', substring(j ->> 'target' from '^/api/equipment/([^/]+)$'), substring(j ->> 'target' from '\"equipmentId\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"')) = :equipment"; + } + $published = "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(i.closure_snapshot::jsonb -> 'workItems', '[]'::jsonb)) j WHERE " . implode(' AND ', $task) . ')'; + if (null === $equipmentId && [] !== $root) { + $published = '(' . implode(' AND ', $root) . ') OR ' . $published; + } + $live = []; + $scope = null === $siteId && null === $customerId ? null : $this->scopes->facilityIds($organizationId, $siteId, $customerId); + $equipmentScope = null === $scope ? null : $this->equipment->equipmentIdsInFacilities($organizationId, $scope); + if (null !== $equipmentId) { + if (isset($this->equipment->snapshots($organizationId, [$equipmentId])[$equipmentId]) && (null === $scope || in_array($equipmentId, $equipmentScope ?? [], true))) { + $live[] = 'EXISTS (SELECT 1 FROM intervention_work_items w WHERE w.intervention_id = i.id AND ' . self::TARGET_ID . ' = :equipment)'; + } + } elseif (null !== $scope && [] !== $scope) { + $parameters['facilities'] = $scope; + $types['facilities'] = ArrayParameterType::STRING; + $live[] = 'i.site_id IN (:facilities)'; + if ([] !== $equipmentScope) { + $parameters['assets'] = $equipmentScope; + $types['assets'] = ArrayParameterType::STRING; + $live[] = 'EXISTS (SELECT 1 FROM intervention_work_items w WHERE w.intervention_id = i.id AND ' . self::TARGET_ID . ' IN (:assets))'; + } + } + + return "((i.status = 'published' AND (" . $published . ")) OR (i.status <> 'published' AND (" . ([] === $live ? 'FALSE' : implode(' OR ', $live)) . ')))'; + } + + /** + * Method rows + * + * @access private + * + * @param string $where scoped predicate + * @param array $parameters source parameters + * @param array $types array parameter types + * @param int $limit validated bound + * @param int $offset validated pagination offset + * @param int $maximumTasks task bound before transferring source dossiers + * + * @return list> owned scalar source rows + */ + private function rows(string $where, array $parameters, array $types, int $limit, int $offset, int $maximumTasks = 20000): array + { + $this->assertPageBounded($where, $parameters, $types, $limit, $offset, $maximumTasks); + + return $this->entityManager->getConnection()->fetchAllAssociative('SELECT i.*, p.id AS publication_id, p.completed_at AS published_at ' . self::SOURCE . ' WHERE ' . $where . ' ORDER BY i.created_at DESC, i.id ASC LIMIT ' . $limit . ' OFFSET ' . $offset, $parameters, $types); + } + + /** + * Method assertPageBounded + * + * Counts selected source records before transferring large JSON dossiers into PHP, so limits cannot be bypassed by a page of large publications. + * + * @access private + * + * @param string $where identical scoped page predicate + * @param array $parameters source parameters + * @param array $types array parameter types + * @param int $limit validated page bound + * @param int $offset validated page offset + * @param int $maximumTasks task bound before transferring source dossiers + * + * @return void + */ + private function assertPageBounded(string $where, array $parameters, array $types, int $limit, int $offset, int $maximumTasks): void + { + $selected = 'SELECT i.id, i.status, i.closure_snapshot ' . self::SOURCE . ' WHERE ' . $where . ' ORDER BY i.created_at DESC, i.id ASC LIMIT ' . $limit . ' OFFSET ' . $offset; + $snapshotTasks = "CASE WHEN jsonb_typeof(s.closure_snapshot::jsonb -> 'workItems') = 'array' THEN jsonb_array_length(s.closure_snapshot::jsonb -> 'workItems') ELSE 0 END"; + $snapshotProofs = "CASE WHEN jsonb_typeof(s.closure_snapshot::jsonb -> 'attachments') = 'array' THEN jsonb_array_length(s.closure_snapshot::jsonb -> 'attachments') ELSE 0 END"; + $snapshotTimes = "CASE WHEN jsonb_typeof(s.closure_snapshot::jsonb -> 'timeEntries') = 'array' THEN jsonb_array_length(s.closure_snapshot::jsonb -> 'timeEntries') ELSE 0 END"; + $snapshotActivities = "CASE WHEN jsonb_typeof(s.closure_snapshot::jsonb -> 'report' -> 'activities') = 'array' THEN jsonb_array_length(s.closure_snapshot::jsonb -> 'report' -> 'activities') ELSE 0 END"; + /** @var array{task_count:string|int,auxiliary_count:string|int,dossier_bytes:string|int}|false $counts */ + $counts = $this->entityManager->getConnection()->fetchAssociative('WITH selected AS (' . $selected . ') SELECT COALESCE(SUM(CASE WHEN s.status = \'published\' THEN ' . $snapshotTasks . ' ELSE (SELECT COUNT(*) FROM intervention_work_items w WHERE w.intervention_id = s.id) END), 0) AS task_count, COALESCE(SUM(' . $snapshotProofs . ' + ' . $snapshotTimes . ' + ' . $snapshotActivities . '), 0) AS auxiliary_count, COALESCE(SUM(octet_length(s.closure_snapshot::text)), 0) AS dossier_bytes FROM selected s', $parameters, $types); + if (false === $counts) { + throw new UnexpectedValueException('The selected operational source counts are unavailable.'); + } + if ((int) $counts['task_count'] > $maximumTasks || (int) $counts['auxiliary_count'] > 50000 || (int) $counts['dossier_bytes'] > 67108864) { + throw new InterventionFactsScopeTooLarge('The selected source exceeds its ' . $maximumTasks . '-task, 50000 auxiliary-record or 64 MiB dossier bound; narrow the requested scope.'); + } + } + + /** + * Method total + * + * @access private + * + * @param string $where identical page predicate + * @param array $parameters source parameters + * @param array $types array parameter types + * + * @return int exact filtered intervention count + */ + private function total(string $where, array $parameters, array $types): int + { + $total = $this->entityManager->getConnection()->fetchOne('SELECT COUNT(*) ' . self::SOURCE . ' WHERE ' . $where, $parameters, $types); + + return is_int($total) || is_string($total) ? (int) $total : throw new UnexpectedValueException('The scoped intervention count is unavailable.'); + } + + /** + * Method context + * + * @access private + * + * @param array $row owned scoped row + * + * @return InterventionEconomicContext actual immutable or current source + */ + private function context(array $row): InterventionEconomicContext + { + $snapshot = $this->mapper->snapshot($row['closure_snapshot']); + if ('published' === $row['status']) { + return $this->mapper->context($row, $snapshot); + } + $organizationId = is_string($row['organization_id']) ? $row['organization_id'] : throw new InvalidArgumentException('The source organization is invalid.'); + $interventionId = is_string($row['id']) ? $row['id'] : throw new InvalidArgumentException('The source identifier is invalid.'); + $siteId = is_string($row['site_id']) ? $row['site_id'] : null; + $site = null === $siteId ? null : $this->sites->find($organizationId, null, $siteId); + + return $this->mapper->context($row, null, $this->liveItems($organizationId, $interventionId), null === $site ? null : ['id' => $site->id, 'name' => $site->name], $site?->customer); + } + + /** + * Method publication + * + * @access private + * + * @param array $row owned scoped published row + * + * @return InterventionPublicationFacts exact retained dossier and typed facts + */ + private function publication(array $row): InterventionPublicationFacts + { + $snapshot = $this->mapper->snapshot($row['closure_snapshot']); + $context = $this->mapper->context($row, $snapshot); + + return new InterventionPublicationFacts($context->id, $context->organizationId, $context->number, $context->name, $context->type, $context->status, $context->revision, $context->createdAt, $context->plannedStartAt, $context->dueAt, $context->publishedAt, $context->publicationId, 'snapshot_missing' === $context->snapshotState ? 'snapshot_missing' : 'available', $context->snapshotVersion, $context->identityComplete, $context->site, $context->customer, $context->workItems, $snapshot); + } + + /** + * Method liveItems + * + * Aggregates owned time and proof counts without loading attachment blobs or joining foreign modules. + * + * @access private + * + * @param string $organizationId owning organization + * @param string $interventionId scoped source identifier + * + * @return list bounded current task facts + */ + private function liveItems(string $organizationId, string $interventionId): array + { + $rows = $this->entityManager->getConnection()->fetchAllAssociative('SELECT w.*, (SELECT COALESCE(SUM(t.minutes), 0) FROM intervention_time_entries t WHERE t.work_item_id = w.id AND t.organization_id = :organization AND NOT t.cancelled) AS spent_minutes, (SELECT COUNT(*) FROM intervention_attachments a WHERE a.work_item_id = w.id AND a.intervention_id = :intervention) AS evidence_count FROM intervention_work_items w WHERE w.intervention_id = :intervention ORDER BY w.id LIMIT 10001', ['organization' => $organizationId, 'intervention' => $interventionId]); + if (count($rows) > 10000) { + throw new InterventionFactsScopeTooLarge('The current source exceeds 10000 tasks; narrow the source scope.'); + } + $ids = []; + foreach ($rows as $row) { + $id = $this->mapper->equipmentId(is_string($row['target']) ? $row['target'] : null); + if (null !== $id) { + $ids[] = $id; + } + } + $equipment = $this->equipment->snapshots($organizationId, array_values(array_unique($ids))); + $result = []; + foreach ($rows as $row) { + $target = is_string($row['target']) ? $row['target'] : null; + $id = $this->mapper->equipmentId($target); + $execution = $row['execution_result']; + if (is_string($execution)) { + $execution = json_decode($execution, true); + } + $result[] = $this->mapper->workFact(['id' => $row['id'], 'action' => $row['action'], 'status' => $row['status'], 'target' => $target, 'resultResource' => $row['result_resource'], 'executionResult' => $execution, 'spentMinutes' => $row['spent_minutes'], 'evidenceCount' => $row['evidence_count'], 'createdAt' => $row['created_at'], 'updatedAt' => $row['updated_at']], null === $id ? null : ($equipment[$id] ?? null)); + } + + return $result; + } + + /** + * Method pagination + * + * @access private + * + * @param int $page one-based requested page + * @param int $size requested bound + * @param int $maximum allowed size + * + * @return void + */ + private function pagination(int $page, int $size, int $maximum): void + { + if ($page < 1 || $page > 1000000 || $size < 1 || $size > $maximum) { + throw new InvalidArgumentException('The source pagination is outside its bounded range.'); + } + } + // #endregion +} diff --git a/src/Intervention/Infrastructure/Adapter/ServiceRequest/ServiceRequestWorkAdapter.php b/src/Intervention/Infrastructure/Adapter/ServiceRequest/ServiceRequestWorkAdapter.php new file mode 100644 index 000000000..517a2e574 --- /dev/null +++ b/src/Intervention/Infrastructure/Adapter/ServiceRequest/ServiceRequestWorkAdapter.php @@ -0,0 +1,371 @@ +reserveSelection($request); + $key = $this->sourceKey($request); + $existing = $this->linkage($request); + if ($existing instanceof InterventionActivityRecord) { + return $this->replay($existing, $request); + } + if (null === $request->equipmentId) { + throw new InterventionValidationException('Qualify the equipment before creating corrective work.'); + } + if ([] === $this->equipment->filterIds($request->organizationId, [$request->equipmentId], facilityId: $request->siteId)) { + throw InterventionNotFoundException::withId($request->equipmentId); + } + $created = null === $request->existingInterventionId; + $order = $created ? $this->createIntervention($request) : $this->existingIntervention($request); + $taskId = $this->findTask($order, $request)->id ?? $this->createTask($order, $request); + $this->activities->append(new InterventionActivityAppendRequest( + $order->id, + $request->organizationId, + $this->members->assertActiveMemberForUser($request->organizationId, $request->actorId), + new InterventionActivityContent('system', 'service_request_linked', null, [ + 'requestId' => $request->requestId, + 'equipmentId' => $request->equipmentId, + 'taskId' => $taskId, + 'created' => $created, + 'selectedInterventionId' => $request->existingInterventionId, + 'selectedTaskId' => $request->existingTaskId, + ]), + $key, + )); + + return new ServiceRequestWorkLink($order->id, $taskId, $created); + } + + /** + * Method reserveSelection + * + * Reserves source identity and an existing parent before the caller takes facility or equipment locks. + * It records no work or activities and permits unchanged linkage replays after publication. + * + * @access public + * + * @param ServiceRequestWorkRequest $request immutable request and explicit intervention selection + * + * @return void no return value + */ + public function reserveSelection(ServiceRequestWorkRequest $request): void + { + $decision = $this->authorization->resolveAccess($request->actorId, $request->organizationId, 'organization.interventions.plan'); + if ($decision->isOutsideScope()) { + throw InterventionNotFoundException::withId($request->requestId); + } + if (!$decision->isGranted()) { + throw new InterventionAccessDeniedException('Intervention planning permission is required to convert a service request.'); + } + $connection = $this->entityManager->getConnection(); + if (!$connection->isTransactionActive()) { + throw new InterventionConflictException('Service request conversion requires the main transaction.'); + } + $key = $this->sourceKey($request); + $connection->executeQuery('SELECT pg_advisory_xact_lock(hashtextextended(:identity, 0))', ['identity' => $key]); + $existing = $this->linkage($request); + if ($existing instanceof InterventionActivityRecord) { + $this->replay($existing, $request); + + return; + } + if (null !== $request->existingTaskId && null === $request->existingInterventionId) { + throw new InterventionValidationException('Select the intervention owning the repair task.'); + } + if (null !== $request->existingInterventionId) { + $this->existingIntervention($request); + } + } + + /** + * Method sourceKey + * + * Keeps one journal and transaction lock identity per qualified request across client operation retries. + * + * @access private + * + * @param ServiceRequestWorkRequest $request owning organization and stable request identity + * + * @return string unique source journal key + */ + private function sourceKey(ServiceRequestWorkRequest $request): string + { + return hash('sha256', 'intervention.service_request:' . $request->organizationId . ':' . $request->requestId); + } + + /** + * Method linkage + * + * Reads the existing immutable linkage after its source identity has been reserved. + * + * @access private + * + * @param ServiceRequestWorkRequest $request owning organization and request identity + * + * @return ?InterventionActivityRecord persisted linkage when already converted + */ + private function linkage(ServiceRequestWorkRequest $request): ?InterventionActivityRecord + { + return $this->entityManager->getRepository(InterventionActivityRecord::class)->findOneBy(['clientId' => $this->sourceKey($request), 'organizationId' => $request->organizationId]); + } + + /** + * Method replay + * + * Returns the original linkage even after closure without changing task facts or appending activities. + * + * @access private + * + * @param InterventionActivityRecord $activity persisted request linkage + * @param ServiceRequestWorkRequest $request replayed selection + * + * @return ServiceRequestWorkLink original durable work identity + */ + private function replay(InterventionActivityRecord $activity, ServiceRequestWorkRequest $request): ServiceRequestWorkLink + { + $payload = $activity->payload; + if ('service_request_linked' !== $activity->event || !$activity->intervention instanceof InterventionRecord + || !is_string($payload['taskId'] ?? null) || !is_bool($payload['created'] ?? null) + || ($payload['requestId'] ?? null) !== $request->requestId || ($payload['equipmentId'] ?? null) !== $request->equipmentId + || ($payload['selectedInterventionId'] ?? null) !== $request->existingInterventionId || ($payload['selectedTaskId'] ?? null) !== $request->existingTaskId) { + throw new InterventionConflictException('The service request already identifies different corrective work.'); + } + $task = $this->entityManager->find(InterventionWorkItemRecord::class, $payload['taskId']); + if (!$task instanceof InterventionWorkItemRecord || $task->intervention?->id !== $activity->intervention->id) { + throw new InterventionConflictException('The original corrective work is no longer available.'); + } + + return new ServiceRequestWorkLink($activity->intervention->id, $task->id, $payload['created']); + } + + /** + * Method createIntervention + * + * Creates a numbered draft using the same invariants and activity path as HTTP writes. + * + * @access private + * + * @param ServiceRequestWorkRequest $request qualified equipment and work content + * + * @return InterventionRecord freshly persisted corrective intervention + */ + private function createIntervention(ServiceRequestWorkRequest $request): InterventionRecord + { + $view = $this->workflow->mutate(new InterventionWorkflowMutation('intervention', 'create', $request->actorId, null, [ + 'organizationId' => $request->organizationId, + 'type' => 'corrective_maintenance', + 'name' => $request->title, + 'description' => $request->description, + 'priority' => 'normal', + 'siteId' => $request->siteId, + ])); + $id = $view?->data['id'] ?? null; + $record = is_string($id) ? $this->entityManager->find(InterventionRecord::class, $id) : null; + if (!$record instanceof InterventionRecord) { + throw new InterventionConflictException('The workflow did not create corrective work.'); + } + + return $record; + } + + /** + * Method existingIntervention + * + * Locks and refreshes the parent before selecting or adding any task. + * + * @access private + * + * @param ServiceRequestWorkRequest $request explicit owning intervention selection + * + * @return InterventionRecord mutable corrective intervention within the same organization + */ + private function existingIntervention(ServiceRequestWorkRequest $request): InterventionRecord + { + $record = $this->entityManager->find(InterventionRecord::class, $request->existingInterventionId); + if (!$record instanceof InterventionRecord || $record->organization?->id !== $request->organizationId) { + throw InterventionNotFoundException::withId($request->existingInterventionId ?? 'unknown'); + } + $this->entityManager->refresh($record, LockMode::PESSIMISTIC_WRITE); + if ('corrective_maintenance' !== $record->type || in_array($record->status, ['submitted', 'published', 'abandoned'], true)) { + throw new InterventionConflictException('Select an open corrective intervention for this repair.'); + } + if (null !== $request->siteId && null !== $record->siteId && $request->siteId !== $record->siteId) { + throw new InterventionConflictException('The selected intervention belongs to another site.'); + } + + return $record; + } + + /** + * Method findTask + * + * Resolves a real open repair without modifying its result, evidence, revision or assignment. + * + * @access private + * + * @param InterventionRecord $order locked intervention + * @param ServiceRequestWorkRequest $request qualified equipment and optional task choice + * + * @return ?InterventionWorkItemRecord the selected repair, or null when a new task is required + */ + private function findTask(InterventionRecord $order, ServiceRequestWorkRequest $request): ?InterventionWorkItemRecord + { + if (null !== $request->existingTaskId) { + $task = $this->entityManager->find(InterventionWorkItemRecord::class, $request->existingTaskId); + if (!$task instanceof InterventionWorkItemRecord || $task->intervention?->id !== $order->id) { + throw InterventionNotFoundException::withId($request->existingTaskId); + } + $this->entityManager->refresh($task, LockMode::PESSIMISTIC_WRITE); + if (!$this->isCompatibleTask($task, $request)) { + throw new InterventionConflictException('Select an open repair task for the qualified equipment.'); + } + + return $task; + } + /** @var list $candidates */ + $candidates = $this->entityManager->getRepository(InterventionWorkItemRecord::class)->findBy(['intervention' => $order, 'action' => 'repair', 'status' => ['planned', 'in_progress']]); + $matching = []; + foreach ($candidates as $candidate) { + $this->entityManager->refresh($candidate, LockMode::PESSIMISTIC_WRITE); + if ($this->isCompatibleTask($candidate, $request)) { + $matching[] = $candidate; + } + } + if (count($matching) > 1) { + throw new InterventionConflictException('Several open repairs match this equipment. Select the intended task.'); + } + + return $matching[0] ?? null; + } + + /** + * Method isCompatibleTask + * + * Supports canonical equipment targets and retained historical JSON targets without rewriting them. + * + * @access private + * + * @param InterventionWorkItemRecord $task candidate repair + * @param ServiceRequestWorkRequest $request qualified equipment identity + * + * @return bool whether this task is still open for the same equipment + */ + private function isCompatibleTask(InterventionWorkItemRecord $task, ServiceRequestWorkRequest $request): bool + { + $target = json_decode($task->target ?? '', true); + $sameEquipment = $task->target === '/api/equipment/' . $request->equipmentId || (is_array($target) && ($target['equipmentId'] ?? null) === $request->equipmentId); + + return 'repair' === $task->action && in_array($task->status, ['planned', 'in_progress'], true) && $sameEquipment; + } + + /** + * Method createTask + * + * Adds prepared or discovered repair work through the existing execution and planning policy. + * + * @access private + * + * @param InterventionRecord $order owning mutable corrective intervention + * @param ServiceRequestWorkRequest $request qualified equipment identity and actor + * + * @return string persisted repair task identifier + */ + private function createTask(InterventionRecord $order, ServiceRequestWorkRequest $request): string + { + if ('draft' !== $order->status && !$this->authorization->hasPermission($request->actorId, $request->organizationId, 'organization.interventions.execute')) { + throw new InterventionAccessDeniedException('Intervention execution permission is required to add discovered repair work.'); + } + $view = $this->workflow->mutate(new InterventionWorkflowMutation('work_item', 'create', $request->actorId, null, [ + 'interventionId' => $order->id, + 'action' => 'repair', + 'target' => '/api/equipment/' . $request->equipmentId, + 'source' => 'draft' === $order->status ? 'planned' : 'discovered', + 'required' => true, + ])); + $id = $view?->data['id'] ?? null; + if (!is_string($id)) { + throw new InterventionConflictException('The workflow did not create the repair task.'); + } + + return $id; + } + // #endregion +} diff --git a/src/Intervention/Infrastructure/DataFixtures/InterventionFixtures.php b/src/Intervention/Infrastructure/DataFixtures/InterventionFixtures.php index 0a0cb8d55..669b5fb88 100644 --- a/src/Intervention/Infrastructure/DataFixtures/InterventionFixtures.php +++ b/src/Intervention/Infrastructure/DataFixtures/InterventionFixtures.php @@ -22,13 +22,14 @@ use function array_values; use function count; use function explode; +use function max; use function sprintf; /** * Class InterventionFixtures * * Seeds the intervention workspace end to end: labels, reusable templates, - * twelve interventions spanning **every** {@see InterventionStatus} case with + * fourteen interventions spanning **every** {@see InterventionStatus} case with * their work items, proposed/applied changes, publication attempts, activity * feed and attachments, plus the recurrence schedules that materialize new * drafts. @@ -293,7 +294,7 @@ final class InterventionFixtures extends Fixture implements DependentFixtureInte /** * Constant INTERVENTION_SEEDS. * - * The twelve seeded interventions, ordered by number, covering every + * The hand-authored interventions, ordered by number, covering every * status, every type and every priority. * * `statusPath` is the comma-separated transition chain from `draft` to the @@ -571,6 +572,46 @@ final class InterventionFixtures extends Fixture implements DependentFixtureInte 'plannedStartOffsetDays' => 15, 'dueOffsetDays' => 40, ], + [ + 'reference' => 'intervention-seed-extinguisher-maintenance', + 'id' => 'a349acb1-d4b1-4c0e-a3eb-334fa025d011', + 'number' => 53, + 'type' => 'preventive_maintenance', + 'name' => 'Paris HQ extinguisher preventive maintenance', + 'description' => 'Clean and service the Zone A extinguisher, independently of its annual inspection.', + 'status' => 'planned', + 'priority' => 'normal', + 'siteReference' => FacilityFixtures::SITE_REFERENCE, + 'responsibleReference' => OrganizationFixtures::PARIS_TECHNICIAN_MEMBER_REFERENCE, + 'participantReferences' => OrganizationFixtures::SAFETY_MANAGER_MEMBER_REFERENCE, + 'labelReferences' => self::LABEL_FIELD_WORK_REFERENCE, + 'statusPath' => 'planned', + 'reviewNote' => '', + 'revision' => 2, + 'createdAt' => '2026-04-18T08:00:00+00:00', + 'plannedStartOffsetDays' => 2, + 'dueOffsetDays' => 9, + ], + [ + 'reference' => 'intervention-seed-fire-door-repair', + 'id' => 'a349acb1-d4b1-4c0e-a3eb-334fa025d012', + 'number' => 54, + 'type' => 'corrective_maintenance', + 'name' => 'Storage room fire door closer repair', + 'description' => 'Repair the closer identified during the fire door survey, then verify that the door closes fully.', + 'status' => 'in_progress', + 'priority' => 'high', + 'siteReference' => FacilityFixtures::BUILDING_REFERENCE, + 'responsibleReference' => OrganizationFixtures::FIELD_TECHNICIAN_MEMBER_REFERENCE, + 'participantReferences' => OrganizationFixtures::PARIS_TECHNICIAN_MEMBER_REFERENCE, + 'labelReferences' => self::LABEL_FIELD_WORK_REFERENCE . ',' . self::LABEL_QUICK_WIN_REFERENCE, + 'statusPath' => 'planned,in_progress', + 'reviewNote' => '', + 'revision' => 3, + 'createdAt' => '2026-04-19T08:00:00+00:00', + 'plannedStartOffsetDays' => -1, + 'dueOffsetDays' => 3, + ], ]; /** @@ -627,6 +668,8 @@ final class InterventionFixtures extends Fixture implements DependentFixtureInte ['interventionNumber' => '11', 'action' => 'inventory', 'target' => 'List the units due for recharge', 'equipmentReference' => '', 'status' => 'planned', 'source' => 'planned', 'required' => 'yes', 'assigneeReference' => OrganizationFixtures::PARIS_TECHNICIAN_MEMBER_REFERENCE, 'skipReason' => ''], ['interventionNumber' => '12', 'action' => 'site_setup', 'target' => 'Position the detector heads', 'equipmentReference' => '', 'status' => 'planned', 'source' => 'planned', 'required' => 'yes', 'assigneeReference' => OrganizationFixtures::REGIONAL_COORDINATOR_MEMBER_REFERENCE, 'skipReason' => ''], ['interventionNumber' => '12', 'action' => 'inventory', 'target' => 'Register the new detectors', 'equipmentReference' => '', 'status' => 'planned', 'source' => 'planned', 'required' => 'yes', 'assigneeReference' => OrganizationFixtures::WAREHOUSE_LEAD_MEMBER_REFERENCE, 'skipReason' => ''], + ['interventionNumber' => '53', 'action' => 'maintenance', 'target' => 'Service the Zone A extinguisher', 'equipmentReference' => EquipmentFixtures::EXTINGUISHER_REFERENCE, 'status' => 'planned', 'source' => 'planned', 'required' => 'yes', 'assigneeReference' => OrganizationFixtures::PARIS_TECHNICIAN_MEMBER_REFERENCE, 'skipReason' => ''], + ['interventionNumber' => '54', 'action' => 'repair', 'target' => 'Repair and verify the storage room fire door closer', 'equipmentReference' => EquipmentFixtures::BUILDING_FIRE_DOOR_REFERENCE, 'status' => 'in_progress', 'source' => 'planned', 'required' => 'yes', 'assigneeReference' => OrganizationFixtures::FIELD_TECHNICIAN_MEMBER_REFERENCE, 'skipReason' => ''], ]; /** @@ -799,10 +842,10 @@ final class InterventionFixtures extends Fixture implements DependentFixtureInte /** * Constant BULK_INTERVENTION_COUNT. * - * On top of the twelve hand-authored {@see self::INTERVENTION_SEEDS}, a + * On top of the hand-authored {@see self::INTERVENTION_SEEDS}, a * generated pool so the intervention board — list, board and calendar * views alike — clears 50 rows and its pagination has more than one page. - * Deliberately simpler than the hand-authored dozen: one work item, no + * Deliberately simpler than the hand-authored examples: one work item, no * changes, no publication, no comments. Volume, not narrative. * * @since 1.2.0 @@ -811,6 +854,11 @@ final class InterventionFixtures extends Fixture implements DependentFixtureInte */ public const int BULK_INTERVENTION_COUNT = 40; + /** + * Preserve the numbers of the historical generated pool when adding examples. + */ + private const int BULK_INTERVENTION_FIRST_NUMBER = 13; + /** * Constant COMMISSIONING_INSPECTION_TARGET * @@ -946,7 +994,10 @@ public function load(ObjectManager $manager): void // next runtime creation collides with the unique (organization, number). $counter = new InterventionNumberCounterRecord(); $counter->organizationId = $organization->id; - $counter->lastNumber = count(self::INTERVENTION_SEEDS) + self::BULK_INTERVENTION_COUNT; + $counter->lastNumber = self::BULK_INTERVENTION_FIRST_NUMBER + self::BULK_INTERVENTION_COUNT - 1; + foreach (self::INTERVENTION_SEEDS as $seed) { + $counter->lastNumber = max($counter->lastNumber, $seed['number']); + } $manager->persist($counter); $manager->flush(); @@ -1147,6 +1198,10 @@ private function loadWorkItems(ObjectManager $manager, array $interventions): vo $workItem->resultResource = '' === $seed['equipmentReference'] ? null : $this->equipmentIri($seed['equipmentReference']); + if ('maintenance' === $seed['action'] || 'repair' === $seed['action']) { + $workItem->target = $this->equipmentIri($seed['equipmentReference']); + $workItem->resultResource = null; + } $workItem->assigneeId = $this->memberId($seed['assigneeReference']); $workItem->source = $seed['source']; $workItem->status = $seed['status']; @@ -1262,7 +1317,7 @@ private function loadComments(ObjectManager $manager, array $interventions): voi * Method loadBulkInterventions. * * Generates {@see self::BULK_INTERVENTION_COUNT} plain interventions past - * the twelve hand-authored ones, cycling status/type/priority/responsible + * the original twelve hand-authored ones, cycling status/type/priority/responsible * so the pool stays varied without hand-typing forty more rows. See the * class docblock for why the seed tables above stay flat scalar rows — * the same reasoning applies to keeping this generator's constants flat. @@ -1288,7 +1343,7 @@ private function loadBulkInterventions(ObjectManager $manager, OrganizationRecor } $labelReferences = array_values($labels); - $startingNumber = count(self::INTERVENTION_SEEDS) + 1; + $startingNumber = self::BULK_INTERVENTION_FIRST_NUMBER; for ($i = 0; $i < self::BULK_INTERVENTION_COUNT; ++$i) { $number = $startingNumber + $i; diff --git a/src/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionPublicationFactsMapper.php b/src/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionPublicationFactsMapper.php new file mode 100644 index 000000000..08e113a91 --- /dev/null +++ b/src/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionPublicationFactsMapper.php @@ -0,0 +1,317 @@ + $row owned intervention source row + * @param ?array $snapshot decoded supported dossier + * @param list $liveItems bounded current task facts + * @param ?array{id:string,name:string} $liveSite unpublished root site identity + * @param ?array{id:string,name:string} $liveCustomer unpublished internal client identity + * + * @return InterventionEconomicContext non-financial source context + */ + public function context(array $row, ?array $snapshot, array $liveItems = [], ?array $liveSite = null, ?array $liveCustomer = null): InterventionEconomicContext + { + $published = 'published' === $row['status']; + $report = is_array($snapshot['report'] ?? null) ? $snapshot['report'] : []; + $items = $published ? (null === $snapshot ? [] : $this->snapshotItems($snapshot)) : $liveItems; + $complete = !$published || null !== $snapshot; + if (null !== ($row['site_id'] ?? null) && null === ($published ? $this->identity($snapshot['site'] ?? null) : $liveSite)) { + $complete = false; + } + foreach ($items as $item) { + if (null !== $item->equipmentId && (null === $item->equipmentIdentity || (null !== $item->equipmentIdentity->facilityId && null === $item->equipmentIdentity->site))) { + $complete = false; + } + } + $state = !$published ? 'live' : (null === $snapshot ? 'snapshot_missing' : 'available'); + + return new InterventionEconomicContext( + $this->requiredString($row['id']), + $this->requiredString($row['organization_id']), + $this->integer($snapshot['number'] ?? $report['number'] ?? $row['number']), + $this->requiredString($snapshot['name'] ?? $report['name'] ?? $row['name']), + $this->requiredString($snapshot['type'] ?? $report['type'] ?? $row['type']), + $this->requiredString($row['status']), + $this->integer($snapshot['revision'] ?? $row['revision']), + $this->date($snapshot['createdAt'] ?? $row['created_at']) ?? throw new UnexpectedValueException('The owned intervention creation time is missing.'), + $this->date($published ? ($snapshot['plannedStartAt'] ?? $report['plannedStartAt'] ?? null) : $row['planned_start_at']), + $this->date($published ? ($snapshot['dueAt'] ?? $report['dueAt'] ?? null) : $row['due_at']), + $published ? $this->date($snapshot['publishedAt'] ?? $row['published_at'] ?? $snapshot['capturedAt'] ?? null) : null, + $published ? $this->nullableString($snapshot['publicationId'] ?? $row['publication_id'] ?? null) : null, + $state, + is_int($snapshot['version'] ?? null) ? $snapshot['version'] : null, + $complete, + $published ? $this->identity($snapshot['site'] ?? null) : $liveSite, + $published ? $this->identity($snapshot['customer'] ?? null) : $liveCustomer, + $items, + ); + } + + /** + * Method snapshot + * + * Accepts supported original versions and refuses to invent facts for absent or unsupported historical dossiers. + * + * @access public + * + * @param mixed $value owned JSON column + * + * @return ?array supported retained dossier + */ + public function snapshot(mixed $value): ?array + { + if (is_string($value)) { + $value = json_decode($value, true); + } + if (!is_array($value) || !in_array($value['version'] ?? null, [1, 2], true)) { + return null; + } + + /** @var array $value */ + /** @var array $minimized */ + $minimized = $this->minimizeIdentities($value); + + return $minimized; + } + + /** + * Method workFact + * + * Maps task identity without deriving validation from scheduling or completion alone. + * + * @access public + * + * @param array $item owned current or captured task shape + * @param ?InterventionEquipmentSnapshot $equipment original or live owner-supplied identity + * @param ?array{id:string,name:string} $site explicit task site identity + * @param ?array{id:string,name:string} $customer explicit task client identity + * @param bool $published whether this task belongs to a supported immutable published dossier + * + * @return InterventionPublishedWorkFact task allocation and execution facts + */ + public function workFact(array $item, ?InterventionEquipmentSnapshot $equipment = null, ?array $site = null, ?array $customer = null, bool $published = false): InterventionPublishedWorkFact + { + $target = $this->nullableString($item['target'] ?? null); + $execution = is_array($item['executionResult'] ?? null) ? $item['executionResult'] : null; + $resultResource = $this->nullableString($item['resultResource'] ?? null); + $validationSource = 'validated' === ($execution['state'] ?? null) ? 'execution_result' : null; + if (null === $validationSource && $published && 'inspection' === $item['action'] && 'completed' === $item['status'] && null !== $resultResource && 1 === preg_match('#^/api/inspections/[^/]+$#', $resultResource)) { + $validationSource = 'published_inspection'; + } + + /** @var ?array $execution */ + return new InterventionPublishedWorkFact( + $this->requiredString($item['id']), + $this->requiredString($item['action']), + $this->requiredString($item['status']), + $target, + $resultResource, + $equipment->id ?? $this->equipmentId($target), + $equipment->site ?? $site, + $equipment->customer ?? $customer, + $equipment, + $execution, + null !== $validationSource, + $this->integer($item['spentMinutes'] ?? 0), + $this->integer($item['evidenceCount'] ?? 0), + $this->date($item['createdAt'] ?? null), + $this->date($item['updatedAt'] ?? null), + $validationSource, + ); + } + + /** + * Method equipmentId + * + * Preserves both the historical offline JSON target and the canonical equipment IRI. + * + * @access public + * + * @param ?string $target retained target + * + * @return ?string identified asset without live lookup + */ + public function equipmentId(?string $target): ?string + { + if (null === $target) { + return null; + } + if (1 === preg_match('#^/api/equipment/([^/]+)$#', $target, $match)) { + return $match[1]; + } + $value = json_decode($target, true); + + return is_array($value) && is_string($value['equipmentId'] ?? null) ? $value['equipmentId'] : null; + } + + /** + * Method identity + * + * Exposes only stable identifier and name even if an old dossier contains additional contact fields. + * + * @access public + * + * @param mixed $value retained identity + * + * @return ?array{id:string,name:string} minimal valid identity + */ + public function identity(mixed $value): ?array + { + return is_array($value) && is_string($value['id'] ?? null) && is_string($value['name'] ?? null) ? ['id' => $value['id'], 'name' => $value['name']] : null; + } + + /** + * Method snapshotItems + * + * A version-one asset target remains identifiable but lacks complete captured equipment and per-task location identity. + * + * @access private + * + * @param array $snapshot retained dossier + * + * @return list immutable task facts + */ + private function snapshotItems(array $snapshot): array + { + $items = $snapshot['workItems'] ?? []; + if (!is_array($items)) { + return []; + } + if (count($items) > 10000) { + throw new InterventionFactsScopeTooLarge('The published dossier exceeds 10000 tasks; narrow the source scope.'); + } + $result = []; + foreach ($items as $item) { + if (!is_array($item) || !is_string($item['id'] ?? null) || !is_string($item['action'] ?? null) || !is_string($item['status'] ?? null)) { + continue; + } + $identity = $item['equipmentIdentity'] ?? null; + $equipment = null; + if (is_array($identity) && is_string($identity['id'] ?? null) && is_string($identity['type'] ?? null)) { + $equipment = new InterventionEquipmentSnapshot($identity['id'], $this->nullableString($identity['name'] ?? null), $this->nullableString($identity['assetReference'] ?? null), $identity['type'], $this->nullableString($identity['brand'] ?? null), $this->nullableString($identity['model'] ?? null), $this->nullableString($identity['serialNumber'] ?? null), $this->nullableString($identity['facilityId'] ?? null), $this->identity($identity['site'] ?? null), $this->identity($identity['customer'] ?? null)); + } + /** @var array $item */ + $result[] = $this->workFact($item, $equipment, $this->identity($item['site'] ?? null), $this->identity($item['customer'] ?? null), true); + } + + return $result; + } + + /** + * Method date + * + * Reads actual retained timestamps; missing fields stay null. + * + * @access private + * + * @param mixed $value retained time + * + * @return ?DateTimeImmutable parsed source time + */ + private function date(mixed $value): ?DateTimeImmutable + { + return is_string($value) && '' !== $value ? new DateTimeImmutable($value, new DateTimeZone('UTC')) : null; + } + + /** + * Method minimizeIdentities + * + * Preserves operational facts while removing legacy contact payloads from every site/client identity node, without altering stored JSON. + * + * @access private + * + * @param array $source retained JSON object or list + * + * @return array retained operational facts with minimal identity nodes + */ + private function minimizeIdentities(array $source): array + { + foreach ($source as $key => $value) { + if ('customer' === $key || 'site' === $key) { + $source[$key] = $this->identity($value); + } elseif (is_array($value)) { + $source[$key] = $this->minimizeIdentities($value); + } + } + + return $source; + } + + /** + * Method nullableString + * + * @access private + * + * @param mixed $value owned field + * + * @return ?string actual scalar value + */ + private function nullableString(mixed $value): ?string + { + return is_string($value) ? $value : null; + } + + /** + * Method requiredString + * + * Refuses corrupt required fields instead of constructing a misleading source identity. + * + * @access private + * + * @param mixed $value owned required field + * + * @return string validated scalar + */ + private function requiredString(mixed $value): string + { + return is_string($value) ? $value : throw new UnexpectedValueException('A required intervention source field is not a string.'); + } + + /** + * Method integer + * + * @access private + * + * @param mixed $value database integer or JSON integer + * + * @return int normalized integer + */ + private function integer(mixed $value): int + { + return is_int($value) || is_string($value) ? (int) $value : 0; + } + // #endregion +} diff --git a/src/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionViewMapper.php b/src/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionViewMapper.php index 4538faabc..55dc95d8f 100644 --- a/src/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionViewMapper.php +++ b/src/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionViewMapper.php @@ -137,7 +137,7 @@ public function interventionView(InterventionRecord $intervention, ?Intervention $inspectionsCount = $metrics->inspections; $blockersCount = $metrics->resourceBlockers; $blockersCount += 'site_setup' === $intervention->type && 0 === $metrics->facilities ? 1 : 0; - $blockersCount += in_array($intervention->type, ['inventory', 'inspection_campaign'], true) && 0 === $metrics->workItems ? 1 : 0; + $blockersCount += in_array($intervention->type, ['inventory', 'inspection_campaign', 'preventive_maintenance', 'corrective_maintenance'], true) && 0 === $metrics->workItems ? 1 : 0; $blockersCount += $metrics->requiredIncomplete > 0 ? 1 : 0; $workItemsCount = $metrics->workItems; $completedWorkItemsCount = $metrics->completedWorkItems; @@ -162,6 +162,7 @@ public function interventionView(InterventionRecord $intervention, ?Intervention 'plannedStartAt' => $intervention->plannedStartAt?->format('c'), 'dueAt' => $intervention->dueAt?->format('c'), 'reviewNote' => $intervention->reviewNote, + 'closureSnapshot' => null === $metrics ? $intervention->closureSnapshot : null, 'revision' => $intervention->revision, 'recurrence' => null === $recurrenceId ? null : '/api/intervention-recurrences/' . $recurrenceId, 'facilitiesCount' => $facilitiesCount, @@ -203,6 +204,10 @@ public function workItemView(InterventionWorkItemRecord $record): InterventionWo 'id' => $record->id, 'intervention' => self::INTERVENTION_IRI_PREFIX . $intervention->id, 'action' => $record->action, + 'operationId' => $record->operationId, + 'occurrenceId' => $record->occurrenceId, + 'operationKind' => $record->operationKind, + 'executionResult' => $record->executionResult, 'target' => $record->target, 'resultResource' => $record->resultResource, 'assignee' => null === $record->assigneeId ? null : self::ORGANIZATION_IRI_PREFIX . $organizationId . self::MEMBER_IRI_SEGMENT . $record->assigneeId, diff --git a/src/Intervention/Infrastructure/Persistence/Doctrine/Record/InterventionRecord.php b/src/Intervention/Infrastructure/Persistence/Doctrine/Record/InterventionRecord.php index 533097f34..18fd7547e 100644 --- a/src/Intervention/Infrastructure/Persistence/Doctrine/Record/InterventionRecord.php +++ b/src/Intervention/Infrastructure/Persistence/Doctrine/Record/InterventionRecord.php @@ -25,6 +25,14 @@ #[ORM\UniqueConstraint(name: 'uniq_intervention_org_number', columns: ['organization_id', 'number'])] class InterventionRecord { + /** + * Property closureSnapshot. Immutable versioned dossier captured in publication. + * + * @var array|null + */ + #[ORM\Column(type: 'json', nullable: true)] + public ?array $closureSnapshot = null; + /** * Property id. * diff --git a/src/Intervention/Infrastructure/Persistence/Doctrine/Record/InterventionWorkItemRecord.php b/src/Intervention/Infrastructure/Persistence/Doctrine/Record/InterventionWorkItemRecord.php index ce2db030e..824ec28c3 100644 --- a/src/Intervention/Infrastructure/Persistence/Doctrine/Record/InterventionWorkItemRecord.php +++ b/src/Intervention/Infrastructure/Persistence/Doctrine/Record/InterventionWorkItemRecord.php @@ -21,6 +21,32 @@ #[ORM\Index(name: 'idx_intervention_work_item_assignee', columns: ['assignee_id'])] class InterventionWorkItemRecord { + /** + * Property operationId. Stable preventive plan identity. + */ + #[ORM\Column(type: 'string', length: 36, nullable: true)] + public ?string $operationId = null; + + /** + * Property occurrenceId. Stable occurrence identity; never inferred from dates. + */ + #[ORM\Column(type: 'string', length: 36, nullable: true)] + public ?string $occurrenceId = null; + + /** + * Property operationKind. Control or maintenance. + */ + #[ORM\Column(type: 'string', length: 16, nullable: true)] + public ?string $operationKind = null; + + /** + * Property executionResult. Staged fact becomes validated only in publication. + * + * @var array|null + */ + #[ORM\Column(type: 'json', nullable: true)] + public ?array $executionResult = null; + /** * Property estimatedMinutes. * diff --git a/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowMutationSupport.php b/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowMutationSupport.php index 3276a1970..d94ae67b6 100644 --- a/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowMutationSupport.php +++ b/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowMutationSupport.php @@ -63,6 +63,23 @@ public function __construct( private ?\Facility\Application\Port\Inbound\FacilityHierarchyPort $hierarchy = null, ) { } + + /** + * Method equipmentBelongsToOrganization + * + * Checks equipment ownership through its owner-published resource contract. + * + * @access public + * + * @param string $equipmentId equipment operation target + * @param InterventionRecord $intervention owning intervention + * + * @return bool whether the equipment belongs to the same organization + */ + public function equipmentBelongsToOrganization(string $equipmentId, InterventionRecord $intervention): bool + { + return $this->resources->resourceBelongsToOrganization(InterventionResourceType::EQUIPMENT, $equipmentId, $this->organizationId($intervention)); + } // #endregion /** diff --git a/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkItemWriter.php b/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkItemWriter.php index 03dfa5e6b..e6beeffe2 100644 --- a/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkItemWriter.php +++ b/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkItemWriter.php @@ -18,7 +18,7 @@ }; use Intervention\Domain\Service\InterventionWorkItemTransitionPolicy; use Intervention\Domain\ValueObject\InterventionWorkItemStatus; -use Intervention\Domain\ValueObject\{WorkItemEffort, WorkItemPeriod}; +use Intervention\Domain\ValueObject\{WorkItemEffort, WorkItemExecutionResult, WorkItemPeriod}; use Intervention\Infrastructure\Persistence\Doctrine\Record\{ InterventionRecord, InterventionWorkItemRecord @@ -28,9 +28,12 @@ use function array_diff; use function array_intersect; +use function array_intersect_key; use function array_key_exists; use function array_keys; use function in_array; +use function is_array; +use function preg_match; use function trim; /** @@ -96,6 +99,7 @@ public function mutateWorkItem(InterventionWorkflowMutation $mutation, array &$n $previousAssigneeId = $record->assigneeId; $previousWorkItemStatus = $record->status; $interventionAutoStarted = $this->applyWorkItemPatch($record, $intervention, $mutation->payload, $previousWorkItemStatus); + $this->applyExecutionResult($record, $intervention, $mutation); $now = new DateTimeImmutable(); ++$record->revision; $record->updatedAt = $now; @@ -391,6 +395,19 @@ private function createWorkItem(InterventionWorkflowMutation $mutation, array &$ $record->action = InterventionWorkflowPayload::requiredString($mutation->payload, 'action'); $record->target = InterventionWorkflowPayload::nullableString($mutation->payload, 'target'); $record->resultResource = InterventionWorkflowPayload::nullableString($mutation->payload, 'resultResource'); + $record->operationId = InterventionWorkflowPayload::nullableString($mutation->payload, 'operationId'); + $record->occurrenceId = InterventionWorkflowPayload::nullableString($mutation->payload, 'occurrenceId'); + $record->operationKind = InterventionWorkflowPayload::nullableString($mutation->payload, 'operationKind'); + $hasSource = null !== $record->operationId || null !== $record->occurrenceId || null !== $record->operationKind; + if ($hasSource && (null === $record->operationId || null === $record->occurrenceId || !in_array($record->operationKind, ['control', 'maintenance'], true))) { + throw new InterventionValidationException('A preventive source requires its operation, occurrence and kind.'); + } + if ($hasSource && (('control' === $record->operationKind && 'inspection' !== $record->action) || ('maintenance' === $record->operationKind && 'maintenance' !== $record->action))) { + throw new InterventionValidationException('The work item action must match its preventive operation kind.'); + } + if ($hasSource || in_array($record->action, ['maintenance', 'repair', 'replacement'], true)) { + $this->assertEquipmentTarget($record, $intervention); + } $this->runtime->support->assertFacilityTarget($record->target, $intervention); $this->runtime->support->assertFacilityTarget($record->resultResource, $intervention); $record->assigneeId = $assigneeId; @@ -414,5 +431,92 @@ private function createWorkItem(InterventionWorkflowMutation $mutation, array &$ return $this->runtime->views->workItemView($record); } + + /** + * Method applyExecutionResult + * + * Stages executor-attributed facts and keeps unsuccessful repairs open. + * + * @access private + * + * @param InterventionWorkItemRecord $record locked work item + * @param InterventionRecord $intervention owning intervention + * @param InterventionWorkflowMutation $mutation authenticated execution mutation + * + * @return void + */ + private function applyExecutionResult(InterventionWorkItemRecord $record, InterventionRecord $intervention, InterventionWorkflowMutation $mutation): void + { + $operational = in_array($record->action, ['maintenance', 'repair', 'replacement'], true); + if (array_key_exists('executionResult', $mutation->payload)) { + if (!$operational || 'draft' === $intervention->status) { + throw new InterventionValidationException('Execution results belong to equipment operations after preparation.'); + } + $payload = $mutation->payload['executionResult']; + if (null === $payload) { + if (null !== $record->executionResult) { + throw new InterventionValidationException('Recorded attempts are retained. Reopen the task and record a corrected attempt.'); + } + $record->executionResult = null; + } elseif (is_array($payload)) { + $result = WorkItemExecutionResult::fromPayload(InterventionWorkflowPayload::patch($payload)); + $result->assertAlreadyPerformed(new DateTimeImmutable()); + $equipmentId = $this->assertEquipmentTarget($record, $intervention); + if ($result->equipmentId !== $equipmentId) { + throw new InterventionValidationException('The result must concern the prepared equipment.'); + } + $authorId = $this->runtime->memberPolicy->findMemberId($this->runtime->support->organizationId($intervention), $mutation->userId); + if (null === $authorId) { + throw new InterventionValidationException('The executor must be an organization member.'); + } + $previous = $record->executionResult; + $fact = $result->toArray(); + if (null === $previous || array_intersect_key($previous, $fact) !== $fact) { + $history = is_array($previous['history'] ?? null) ? $previous['history'] : []; + if (null !== $previous) { + unset($previous['history']); + $history[] = $previous; + } + $record->executionResult = [...$result->toArray(), 'authorId' => $authorId, 'operationId' => $record->operationId, 'occurrenceId' => $record->occurrenceId, 'state' => 'staged', 'validatedAt' => null, 'history' => $history]; + $activityResult = $record->executionResult; + unset($activityResult['history']); + $this->runtime->activities->append(new InterventionActivityAppendRequest($intervention->id, $this->runtime->support->organizationId($intervention), $authorId, new InterventionActivityContent('system', 'work_item_result_recorded', null, ['workItemId' => $record->id, 'result' => $activityResult]))); + } + } else { + throw new InterventionValidationException('The execution result must be an object.'); + } + } + if ($operational && 'completed' === $record->status) { + if (null === $record->executionResult) { + throw new InterventionValidationException('Completing this operation requires a successful execution result.'); + } + WorkItemExecutionResult::fromPayload($record->executionResult)->assertCompletesAction($record->action, $this->assertEquipmentTarget($record, $intervention)); + if ('replacement' === $record->action && null === $record->resultResource) { + throw new InterventionValidationException('A completed replacement must identify its successor equipment.'); + } + } + } + + /** + * Method assertEquipmentTarget + * + * Requires a real equipment target belonging to the intervention organization. + * + * @access private + * + * @param InterventionWorkItemRecord $record equipment operation + * @param InterventionRecord $intervention owning intervention + * + * @return string scoped equipment identifier + */ + private function assertEquipmentTarget(InterventionWorkItemRecord $record, InterventionRecord $intervention): string + { + if (null === $record->target || 1 !== preg_match('#^/api/equipment/([^/]+)$#', $record->target, $match) + || !$this->runtime->support->equipmentBelongsToOrganization($match[1], $intervention)) { + throw new InterventionValidationException('An equipment operation requires an equipment in the intervention organization.'); + } + + return $match[1]; + } // #endregion } diff --git a/src/Intervention/MODULE.md b/src/Intervention/MODULE.md index 9189ad846..214585846 100644 --- a/src/Intervention/MODULE.md +++ b/src/Intervention/MODULE.md @@ -4,6 +4,88 @@ ## Overview +V3 exposes `InterventionPublicationFactsPort` to authorized economic and retained-export +use cases. Its organization-scoped published reads include explicit `snapshot_missing` +rows for historical work without a supported dossier. No historical identity is rebuilt +from current client, site or equipment records. Version-one dossiers remain readable; +their missing equipment identity is marked incomplete. New publications capture version +2 with the original publication identifier/time, intervention identity and schedule, full +declared equipment identity, replacement successor identity and each task's root site and +internal client `{id,name}`. Closed control facts are captured through the inspection +owner even when they have no preventive occurrence. Stored dossiers are never upgraded +or rewritten on read; contact fields in legacy identity nodes are minimized at the public +read boundary. + +Published directories and batches are bounded to 100 interventions. Economic directories +use at most 100 rows per page; a report source window returns at most 501 rows and an exact +filtered total so its caller can refuse scopes above 500. The window is inclusive at the +start and exclusive at the end. Published work uses original publication evidence time; +unpublished work uses planned start or creation. Literal title/number search and optional +site, internal client and equipment filters apply on the server. Published filters use +the retained dossier; current filters use bounded owning-module bridges. Assets that are +retired, archived catalog codes and archived published sites remain readable. More than +10000 tasks in a dossier, 20000 tasks or 50000 proof/time/activity records in a source +page, or 10000 locations/assets in a live +scope requires a narrower source request instead of silently truncating facts. Financial +and export permissions remain the consuming use case's responsibility; this port returns +operational identity and facts, without amounts or contact lookups. + +An authorized finance directory may supply up to 10000 additional verified intervention +identifiers for direct financial allocations without operational tasks. These identifiers +join the operational target predicate as a union before exact counting and pagination; +organization, literal search and source-date restrictions still apply to every row. +The source reader neither queries financial persistence nor infers allocations from those +identifiers. Report-window reads keep their original source-window semantics. + +Before transferring source JSON into PHP, a scalar query checks the selected page's +combined counts and encoded size. Published directories/batches additionally cap tasks +at 10000; every source read caps retained dossier bytes at 64 MiB. Export generation may +impose a smaller output bound. These failures preserve every source record and instruct +the caller to choose a smaller page, batch or report scope. + +Legacy published inspections whose completed task retains a canonical inspection result +are validated by the atomic publication guarantee (`validationSource=published_inspection`). +Their missing performed-date/result details remain unknown. Other task results require +their explicit captured validated state; current completion alone never validates a result. + +V2 extends operational work through public resource ports. Qualified ServiceRequest conversion requires the canonical `.plan` entitlement; adding discovered work after preparation also requires `.execute` and the existing participant policy. A main-transaction advisory lock and stable `service_request_linked` receipt bind one request to a corrective intervention/repair task. Linking reuses one unambiguous open repair without modifying recorded attempts; submitted, published or abandoned work refuses a new link, while replay returns its original identifiers. + +Inventory physical declarations share an intervention fence with publication before either locks the work record. Unresolved received declarations appear as non-financial blockers in issues, transition availability and final publication. Published interventions may receive late physical facts while their dossier remains immutable. MaintenanceCost consumes public non-financial time/source facts and captures its private financial snapshot in the same main publication transaction. Neither its amounts nor its rates are embedded in ordinary intervention outputs, closure snapshots or customer PDFs. + +FireGuard's specialized maintenance work reuses the existing intervention workflow. +Types `preventive_maintenance` and `corrective_maintenance` supplement the historical +types; work-item actions `maintenance`, `repair` and `replacement` supplement +`site_setup`, `inventory` and `inspection`. + +Equipment-operation tasks record `executionResult` through the existing revisioned +PATCH and offline replay path: `equipmentId`, actual timezone-qualified `performedAt`, +`outcome` (`successful`, `failed`, `performed`) and non-empty `workPerformed` (10000 +characters maximum). Author, preventive source and validation state are server-owned. +Earlier attempts are retained in `history` and in the activity feed. An unsuccessful +repair or maintenance stays open; a completed repair/replacement requires success. + +Optional create-time `operationId`, `occurrenceId` and `operationKind` (`control` or +`maintenance`) identify a complete preventive source. Publication acknowledges only +that exact equipment/occurrence on the same main transaction. Closed adverse controls +count as performed while their non-conformities remain open. A successful repair does +not acquit an unrelated control or maintenance occurrence. + +Publication also freezes `closureSnapshot` version 2 with equipment/site/customer identity, +work-item results, attachment metadata, member names and the time entries known at +closure. The independent time journal remains editable under its existing policy. +Published reports use this snapshot; drafts use live data explicitly. Older published +interventions without snapshots are labelled as historical live reports rather than +presented as frozen dossiers. The PDF export retains the existing READ authorization. + +Validated maintenance, repair and replacement tasks also feed the equipment owner's +maintenance log, including their actual date, executor and executed-work description. +A task already represented by an applied equipment proposal is reported once. + +Preventive handover finds both historical JSON equipment targets and canonical IRIs, +rejects ambiguous mappings and normalizes attached targets under the main transaction. +Already submitted work requires explicit resolution before handover; no pending +publication revision is silently altered. + Publication guards validate the final facility graph, including drafts and proposals from the same publication, under the organization relation lock. The scoped publication context permits intermediate writes only for covered, @@ -57,6 +139,10 @@ Main goals: ## API Endpoints +| Method | Endpoint | Behavior | +| --- | --- | --- | +| GET | `/organizations/{organizationId}/equipment/{equipmentId}/open-work` | Existing non-abandoned/non-published equipment work, including completed work awaiting publication. Requires `organization.interventions.read`; foreign equipment or organizations return 404 and missing entitlement returns 403. Returns intervention identity/number/name/status plus task identity/action/status. | + All paths are prefixed with `/api`. Interventions are organization-scoped through the required `organization` query parameter on the collection (not a nested URI). Every operation requires `ROLE_USER`; finer-grained membership/permission checks diff --git a/src/Intervention/Presentation/Api/Controller/ExportInterventionReportController.php b/src/Intervention/Presentation/Api/Controller/ExportInterventionReportController.php index edfc63c3f..692a3e4db 100644 --- a/src/Intervention/Presentation/Api/Controller/ExportInterventionReportController.php +++ b/src/Intervention/Presentation/Api/Controller/ExportInterventionReportController.php @@ -133,59 +133,65 @@ public function __invoke(Request $request): Response $organizationId = $interventionResult->view->organizationId; - /** @var ListInterventionWorkflowResult $workItemsResult */ - $workItemsResult = $this->queryBus->ask(new ListInterventionWorkflowQuery( - userId: $user->getId(), - resource: 'work_item', - scopeId: $id, - filters: [], - page: 1, - itemsPerPage: self::MAX_ITEMS_PER_PAGE, - )); - - /** @var ListInterventionWorkflowResult $changesResult */ - $changesResult = $this->queryBus->ask(new ListInterventionWorkflowQuery( - userId: $user->getId(), - resource: 'change', - scopeId: $id, - filters: [], - page: 1, - itemsPerPage: self::MAX_ITEMS_PER_PAGE, - )); - - /** @var ListInterventionIssuesResult $issuesResult */ - $issuesResult = $this->queryBus->ask(new ListInterventionIssuesQuery( - userId: $user->getId(), - interventionId: $id, - )); - - /** @var ListInterventionAttachmentsResult $attachmentsResult */ - $attachmentsResult = $this->queryBus->ask(new ListInterventionAttachmentsQuery( - userId: $user->getId(), - interventionId: $id, - )); - - /** @var ListInterventionActivitiesResult $activitiesResult */ - $activitiesResult = $this->queryBus->ask(new ListInterventionActivitiesQuery( - userId: $user->getId(), - interventionId: $id, - page: 1, - itemsPerPage: self::MAX_ITEMS_PER_PAGE, - )); + $snapshot = $interventionResult->view->data['closureSnapshot'] ?? null; + if (is_array($snapshot) && is_array($snapshot['report'] ?? null)) { + /** @var array $context */ + $context = $snapshot['report']; + } else { + + /** @var ListInterventionWorkflowResult $workItemsResult */ + $workItemsResult = $this->queryBus->ask(new ListInterventionWorkflowQuery( + userId: $user->getId(), + resource: 'work_item', + scopeId: $id, + filters: [], + page: 1, + itemsPerPage: self::MAX_ITEMS_PER_PAGE, + )); + + /** @var ListInterventionWorkflowResult $changesResult */ + $changesResult = $this->queryBus->ask(new ListInterventionWorkflowQuery( + userId: $user->getId(), + resource: 'change', + scopeId: $id, + filters: [], + page: 1, + itemsPerPage: self::MAX_ITEMS_PER_PAGE, + )); + + /** @var ListInterventionIssuesResult $issuesResult */ + $issuesResult = $this->queryBus->ask(new ListInterventionIssuesQuery( + userId: $user->getId(), + interventionId: $id, + )); + + /** @var ListInterventionAttachmentsResult $attachmentsResult */ + $attachmentsResult = $this->queryBus->ask(new ListInterventionAttachmentsQuery( + userId: $user->getId(), + interventionId: $id, + )); + + /** @var ListInterventionActivitiesResult $activitiesResult */ + $activitiesResult = $this->queryBus->ask(new ListInterventionActivitiesQuery( + userId: $user->getId(), + interventionId: $id, + page: 1, + itemsPerPage: self::MAX_ITEMS_PER_PAGE, + )); + $context = $this->buildContext( + organizationId: $organizationId, + intervention: $interventionResult->view, + workItems: $workItemsResult->page->items, + changes: $changesResult->page->items, + issues: $issuesResult->issues, + attachments: $attachmentsResult->attachments, + activities: $activitiesResult->page->items, + ); + } } catch (Throwable $exception) { throw $this->mapWorkflowException($exception); } - $context = $this->buildContext( - organizationId: $organizationId, - intervention: $interventionResult->view, - workItems: $workItemsResult->page->items, - changes: $changesResult->page->items, - issues: $issuesResult->issues, - attachments: $attachmentsResult->attachments, - activities: $activitiesResult->page->items, - ); - $context = $this->localizeContext($context, $this->branding->getDocumentBranding($organizationId)); $pdf = $this->renderer->render($context); @@ -278,6 +284,8 @@ private function buildContext( 'hasSignature' => $data['hasSignature'], 'labels' => $data['labels'], 'generatedAt' => new DateTimeImmutable()->format('c'), + 'reportMode' => 'published' === ($data['status'] ?? null) ? 'legacy_live' : 'live', + 'customerName' => null, 'workItems' => self::mapWorkItems($workItems, $memberNames), 'issues' => array_map(static fn (InterventionIssue $issue): array => [ 'severity' => $issue->severity, @@ -331,6 +339,8 @@ private static function mapWorkItems(array $workItems, array $memberNames): arra 'required' => $workItem->data['required'], 'skipReason' => $workItem->data['skipReason'], 'evidenceCount' => $workItem->data['evidenceCount'], + 'executionResult' => $workItem->data['executionResult'] ?? null, + 'spentMinutes' => $workItem->data['spentMinutes'] ?? 0, ]; }, $workItems); } diff --git a/src/Intervention/Presentation/Api/Dto/Input/CreateInterventionInput.php b/src/Intervention/Presentation/Api/Dto/Input/CreateInterventionInput.php index 517152fdd..08abb1966 100644 --- a/src/Intervention/Presentation/Api/Dto/Input/CreateInterventionInput.php +++ b/src/Intervention/Presentation/Api/Dto/Input/CreateInterventionInput.php @@ -32,7 +32,7 @@ final class CreateInterventionInput * * @since 1.0.0 */ - #[Assert\Choice(choices: ['site_setup', 'inventory', 'inspection_campaign'])] + #[Assert\Choice(choices: ['site_setup', 'inventory', 'inspection_campaign', 'preventive_maintenance', 'corrective_maintenance'])] public string $type = 'site_setup'; /** diff --git a/src/Intervention/Presentation/Api/Dto/Input/CreateInterventionTemplateInput.php b/src/Intervention/Presentation/Api/Dto/Input/CreateInterventionTemplateInput.php index dda2521af..77f38864a 100644 --- a/src/Intervention/Presentation/Api/Dto/Input/CreateInterventionTemplateInput.php +++ b/src/Intervention/Presentation/Api/Dto/Input/CreateInterventionTemplateInput.php @@ -49,7 +49,7 @@ final class CreateInterventionTemplateInput * * @since 1.0.0 */ - #[Assert\Choice(choices: ['site_setup', 'inventory', 'inspection_campaign'])] + #[Assert\Choice(choices: ['site_setup', 'inventory', 'inspection_campaign', 'preventive_maintenance', 'corrective_maintenance'])] public string $type = 'site_setup'; /** diff --git a/src/Intervention/Presentation/Api/Dto/Input/CreateInterventionWorkItemInput.php b/src/Intervention/Presentation/Api/Dto/Input/CreateInterventionWorkItemInput.php index d1ae39066..1e324f882 100644 --- a/src/Intervention/Presentation/Api/Dto/Input/CreateInterventionWorkItemInput.php +++ b/src/Intervention/Presentation/Api/Dto/Input/CreateInterventionWorkItemInput.php @@ -16,6 +16,24 @@ */ final class CreateInterventionWorkItemInput { + /** + * Property operationId. Optional preventive plan identifier. + */ + #[Assert\Uuid] + public ?string $operationId = null; + + /** + * Property occurrenceId. Optional preventive occurrence identifier. + */ + #[Assert\Uuid] + public ?string $occurrenceId = null; + + /** + * Property operationKind. Optional source operation kind. + */ + #[Assert\Choice(choices: ['control', 'maintenance'])] + public ?string $operationKind = null; + /** * Property workloadConfirmationToken. * @@ -61,7 +79,7 @@ final class CreateInterventionWorkItemInput * * @since 1.0.0 */ - #[Assert\Choice(choices: ['site_setup', 'inventory', 'inspection'])] + #[Assert\Choice(choices: ['site_setup', 'inventory', 'inspection', 'maintenance', 'repair', 'replacement'])] public string $action = 'inventory'; /** diff --git a/src/Intervention/Presentation/Api/Dto/Input/UpdateInterventionTemplateInput.php b/src/Intervention/Presentation/Api/Dto/Input/UpdateInterventionTemplateInput.php index e10112f98..92ec6a932 100644 --- a/src/Intervention/Presentation/Api/Dto/Input/UpdateInterventionTemplateInput.php +++ b/src/Intervention/Presentation/Api/Dto/Input/UpdateInterventionTemplateInput.php @@ -39,7 +39,7 @@ final class UpdateInterventionTemplateInput * * @since 1.0.0 */ - #[Assert\Choice(choices: ['site_setup', 'inventory', 'inspection_campaign'])] + #[Assert\Choice(choices: ['site_setup', 'inventory', 'inspection_campaign', 'preventive_maintenance', 'corrective_maintenance'])] public ?string $type = null; /** diff --git a/src/Intervention/Presentation/Api/Dto/Input/UpdateInterventionWorkItemInput.php b/src/Intervention/Presentation/Api/Dto/Input/UpdateInterventionWorkItemInput.php index 8214ec21f..9cecd7705 100644 --- a/src/Intervention/Presentation/Api/Dto/Input/UpdateInterventionWorkItemInput.php +++ b/src/Intervention/Presentation/Api/Dto/Input/UpdateInterventionWorkItemInput.php @@ -16,6 +16,13 @@ */ final class UpdateInterventionWorkItemInput { + /** + * Property executionResult. An execution fact staged for the normal review workflow. + * + * @var array|null + */ + public ?array $executionResult = null; + /** * Property workloadConfirmationToken. * diff --git a/src/Intervention/Presentation/Api/Dto/Output/EquipmentOpenWorkOutput.php b/src/Intervention/Presentation/Api/Dto/Output/EquipmentOpenWorkOutput.php new file mode 100644 index 000000000..2045ab2e3 --- /dev/null +++ b/src/Intervention/Presentation/Api/Dto/Output/EquipmentOpenWorkOutput.php @@ -0,0 +1,50 @@ +|null + */ + public ?array $closureSnapshot = null; + /** * Property id. * diff --git a/src/Intervention/Presentation/Api/Dto/Output/InterventionWorkItemOutput.php b/src/Intervention/Presentation/Api/Dto/Output/InterventionWorkItemOutput.php index 4b354333b..8ea5d39e3 100644 --- a/src/Intervention/Presentation/Api/Dto/Output/InterventionWorkItemOutput.php +++ b/src/Intervention/Presentation/Api/Dto/Output/InterventionWorkItemOutput.php @@ -16,6 +16,28 @@ */ final class InterventionWorkItemOutput { + /** + * Property operationId. Source preventive plan identity. + */ + public ?string $operationId = null; + + /** + * Property occurrenceId. Source preventive occurrence identity. + */ + public ?string $occurrenceId = null; + + /** + * Property operationKind. Control or maintenance. + */ + public ?string $operationKind = null; + + /** + * Property executionResult. Factual result and server-owned review attribution. + * + * @var array|null + */ + public ?array $executionResult = null; + /** * Property spentMinutes. * diff --git a/src/Intervention/Presentation/Api/Factory/InterventionOutputFactory.php b/src/Intervention/Presentation/Api/Factory/InterventionOutputFactory.php index 4d3919f41..a66e6deeb 100644 --- a/src/Intervention/Presentation/Api/Factory/InterventionOutputFactory.php +++ b/src/Intervention/Presentation/Api/Factory/InterventionOutputFactory.php @@ -14,6 +14,7 @@ use Shared\Presentation\Api\Http\ResourceIriParser; use function array_map; +use function is_array; /** * Factory InterventionOutputFactory. @@ -76,6 +77,9 @@ public function fromView(InterventionWorkflowView $view): InterventionOutput $output->plannedStartAt = $this->nullableString($data, 'plannedStartAt'); $output->dueAt = $this->nullableString($data, 'dueAt'); $output->reviewNote = $this->nullableString($data, 'reviewNote'); + /** @var array|null $snapshot */ + $snapshot = is_array($data['closureSnapshot'] ?? null) ? $data['closureSnapshot'] : null; + $output->closureSnapshot = $snapshot; $output->revision = $this->integer($data, 'revision'); // Absent from the list view's data by design — see InterventionOutput. $output->recurrence = $this->nullableString($data, 'recurrence'); diff --git a/src/Intervention/Presentation/Api/Factory/InterventionWorkItemOutputFactory.php b/src/Intervention/Presentation/Api/Factory/InterventionWorkItemOutputFactory.php index 1b11af571..84b7e0850 100644 --- a/src/Intervention/Presentation/Api/Factory/InterventionWorkItemOutputFactory.php +++ b/src/Intervention/Presentation/Api/Factory/InterventionWorkItemOutputFactory.php @@ -17,6 +17,7 @@ use User\Application\UseCase\Query\User\GetUser\{GetUserQuery, GetUserResult}; use function array_key_exists; +use function is_array; use function sprintf; use function str_starts_with; use function trim; @@ -102,6 +103,12 @@ public function fromView(InterventionWorkflowView $view, ?string $userId = null) $output->id = $this->string($data, 'id'); $output->intervention = $this->string($data, 'intervention'); $output->action = $this->string($data, 'action'); + $output->operationId = $this->nullableString($data, 'operationId'); + $output->occurrenceId = $this->nullableString($data, 'occurrenceId'); + $output->operationKind = $this->nullableString($data, 'operationKind'); + /** @var array|null $executionResult */ + $executionResult = is_array($data['executionResult'] ?? null) ? $data['executionResult'] : null; + $output->executionResult = $executionResult; $output->target = $this->nullableString($data, 'target'); $output->targetSummary = $this->resolveTarget($output->target, $view->organizationId); $output->resultResource = $this->nullableString($data, 'resultResource'); diff --git a/src/Intervention/Presentation/Api/Operation/InterventionOperations.php b/src/Intervention/Presentation/Api/Operation/InterventionOperations.php index ccc5e2b4c..4c804709e 100644 --- a/src/Intervention/Presentation/Api/Operation/InterventionOperations.php +++ b/src/Intervention/Presentation/Api/Operation/InterventionOperations.php @@ -14,6 +14,11 @@ */ final class InterventionOperations { + /** + * Constant LIST_EQUIPMENT_OPEN_WORK. + */ + public const string LIST_EQUIPMENT_OPEN_WORK = 'intervention_equipment_open_work_list'; + // #region Constants // Intervention /** diff --git a/src/Intervention/Presentation/Api/Processor/InterventionWorkItemProcessor.php b/src/Intervention/Presentation/Api/Processor/InterventionWorkItemProcessor.php index 82b3228a9..1fdc1fdcf 100644 --- a/src/Intervention/Presentation/Api/Processor/InterventionWorkItemProcessor.php +++ b/src/Intervention/Presentation/Api/Processor/InterventionWorkItemProcessor.php @@ -112,6 +112,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables = 'workloadConfirmationToken' => $data->workloadConfirmationToken, 'workStartsOn' => $data->workStartsOn, 'workEndsOn' => $data->workEndsOn, + 'operationId' => $data->operationId, + 'occurrenceId' => $data->occurrenceId, + 'operationKind' => $data->operationKind, ]; } elseif ($data instanceof UpdateInterventionWorkItemInput) { $payload = $this->updatePayload($data); @@ -148,7 +151,7 @@ private function updatePayload(UpdateInterventionWorkItemInput $input): array { $fields = $this->mergePatchFields->all(); $payload = []; - foreach (['resultResource', 'status', 'skipReason', 'estimatedMinutes', 'remainingMinutes', 'workStartsOn', 'workEndsOn', 'workloadConfirmationToken'] as $field) { + foreach (['resultResource', 'status', 'skipReason', 'estimatedMinutes', 'remainingMinutes', 'workStartsOn', 'workEndsOn', 'workloadConfirmationToken', 'executionResult'] as $field) { if (array_key_exists($field, $fields)) { $payload[$field] = $input->{$field}; } diff --git a/src/Intervention/Presentation/Api/Provider/EquipmentOpenWorkProvider.php b/src/Intervention/Presentation/Api/Provider/EquipmentOpenWorkProvider.php new file mode 100644 index 000000000..cf9d033ed --- /dev/null +++ b/src/Intervention/Presentation/Api/Provider/EquipmentOpenWorkProvider.php @@ -0,0 +1,92 @@ + + */ +final readonly class EquipmentOpenWorkProvider implements ProviderInterface +{ + use InterventionWorkflowExceptionMapperTrait; + + /** + * Method __construct + * + * @access public + * + * @param QueryBusPort $queries application read entrypoint + * @param Security $security authenticated account reader + * + * @return void + */ + public function __construct(private QueryBusPort $queries, private Security $security) + { + } + + /** + * Method provide + * + * @access public + * + * @param Operation $operation API operation + * @param array $uriVariables selected organization and equipment + * @param array $context serialization context + * + * @return list authorized open work + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + $user = $this->security->getUser(); + if (!$user instanceof AuthenticatedUser) { + throw new AccessDeniedHttpException('Authentication required.'); + } + $organizationId = $uriVariables['organizationId'] ?? null; + $equipmentId = $uriVariables['equipmentId'] ?? null; + if (!is_string($organizationId) || !is_string($equipmentId)) { + throw new BadRequestHttpException('Organization and equipment are required.'); + } + + try { + /** @var ListEquipmentOpenWorkResult $result */ + $result = $this->queries->ask(new ListEquipmentOpenWorkQuery($user->getId(), $organizationId, $equipmentId)); + } catch (Throwable $exception) { + throw $this->mapWorkflowException($exception); + } + + return array_map(static function (InterventionEquipmentWork $item): EquipmentOpenWorkOutput { + $output = new EquipmentOpenWorkOutput(); + $output->interventionId = $item->interventionId; + $output->number = $item->number; + $output->name = $item->name; + $output->status = $item->status; + $output->workItemId = $item->workItemId; + $output->action = $item->action; + $output->workItemStatus = $item->workItemStatus; + + return $output; + }, $result->items); + } +} diff --git a/src/Intervention/Presentation/Api/Provider/InterventionProvider.php b/src/Intervention/Presentation/Api/Provider/InterventionProvider.php index 2d8ea03e7..21f7c2deb 100644 --- a/src/Intervention/Presentation/Api/Provider/InterventionProvider.php +++ b/src/Intervention/Presentation/Api/Provider/InterventionProvider.php @@ -182,7 +182,7 @@ private function enumFilters(InputBag $query): array // Reject an unknown enum value up front: the gateway's IN() filter would // otherwise return a silently empty collection instead of a client error. $enumGuards = [ - 'type' => [InterventionType::tryFrom(...), 'The type filter must be one of: site_setup, inventory, inspection_campaign.'], + 'type' => [InterventionType::tryFrom(...), 'The type filter must be one of: site_setup, inventory, inspection_campaign, preventive_maintenance, corrective_maintenance.'], 'status' => [InterventionStatus::tryFrom(...), 'The status filter must be a known intervention status.'], 'priority' => [InterventionPriority::tryFrom(...), 'The priority filter must be one of: low, normal, high, urgent.'], ]; diff --git a/src/Intervention/Presentation/Api/Resource/EquipmentOpenWorkResource.php b/src/Intervention/Presentation/Api/Resource/EquipmentOpenWorkResource.php new file mode 100644 index 000000000..1e1735345 --- /dev/null +++ b/src/Intervention/Presentation/Api/Resource/EquipmentOpenWorkResource.php @@ -0,0 +1,22 @@ +query->all(); $enumGuards = [ - 'type' => [InterventionType::tryFrom(...), 'The type filter must be one of: site_setup, inventory, inspection_campaign.'], + 'type' => [InterventionType::tryFrom(...), 'The type filter must be one of: site_setup, inventory, inspection_campaign, preventive_maintenance, corrective_maintenance.'], 'status' => [InterventionStatus::tryFrom(...), 'The status filter must be a known intervention status.'], 'priority' => [InterventionPriority::tryFrom(...), 'The priority filter must be one of: low, normal, high, urgent.'], ]; diff --git a/src/Inventory/Application/Contract/Directory/InventoryPartDescriptor.php b/src/Inventory/Application/Contract/Directory/InventoryPartDescriptor.php new file mode 100644 index 000000000..adbbb471c --- /dev/null +++ b/src/Inventory/Application/Contract/Directory/InventoryPartDescriptor.php @@ -0,0 +1,13 @@ + $response + */ + public function __construct(public string $organizationId, public string $clientOperationId, public string $payloadHash, public array $response) + { + } +} diff --git a/src/Inventory/Application/Contract/Stock/InventoryPublicationBlockedException.php b/src/Inventory/Application/Contract/Stock/InventoryPublicationBlockedException.php new file mode 100644 index 000000000..142288a5b --- /dev/null +++ b/src/Inventory/Application/Contract/Stock/InventoryPublicationBlockedException.php @@ -0,0 +1,12 @@ + stable read-side publication reasons, without acquiring a lock + */ + public function publicationBlockers(string $organizationId, string $interventionId): array; + + /** + * Acquires the shared intervention lock; unresolved declarations refuse publication. Caller owns main transaction. + */ + public function assertReadyToPublish(string $organizationId, string $interventionId): void; + + /** + * @return list immutable quantities and values; unknown amounts stay null + */ + public function costFacts(string $organizationId, string $interventionId): array; + + /** + * Method economicInterventionIds + * + * Callers authorize finance first. Reads only organization-owned material target identifiers, including pending declarations. + * + * @access public + * + * @param string $organizationId authorized owning organization + * @param list $equipmentIds at most 10000 candidate equipment identities + * + * @return list at most 10000 distinct intervention identifiers; an oversized scope fails explicitly + */ + public function economicInterventionIds(string $organizationId, array $equipmentIds): array; +} diff --git a/src/Inventory/Application/Port/Inbound/InventoryPartDirectoryPort.php b/src/Inventory/Application/Port/Inbound/InventoryPartDirectoryPort.php new file mode 100644 index 000000000..21c5d3ff1 --- /dev/null +++ b/src/Inventory/Application/Port/Inbound/InventoryPartDirectoryPort.php @@ -0,0 +1,20 @@ + $partIds at most one hundred scoped identities + * + * @return array current nonfinancial descriptors + */ + public function describeMany(string $organizationId, array $partIds): array; + + public function existsActive(string $organizationId, string $partId): bool; + + public function warehouseExistsActive(string $organizationId, string $warehouseId): bool; +} diff --git a/src/Inventory/Application/Port/Inbound/InventoryStockReceiptPort.php b/src/Inventory/Application/Port/Inbound/InventoryStockReceiptPort.php new file mode 100644 index 000000000..691957ab5 --- /dev/null +++ b/src/Inventory/Application/Port/Inbound/InventoryStockReceiptPort.php @@ -0,0 +1,26 @@ + $ids bounded scoped references + * + * @return array matching owned references, including archives + */ + public function referencesByIds(string $type, string $org, array $ids): array; + + public function balanceForUpdate(string $org, string $warehouseId, string $partId): ?StockBalance; + + public function saveBalance(StockBalance $balance): void; + + public function saveMovement(StockMovement $movement): void; + + public function movement(string $org, string $id): ?StockMovement; + + public function linkedQuantity(string $org, string $movementId): string; + + public function linkedValue(string $org, string $movementId): ?string; + + public function saveDeclaration(ConsumptionDeclaration $declaration): void; + + public function declaration(string $org, string $id, bool $lock = false): ?ConsumptionDeclaration; + + public function hasPendingDeclarations(string $org, string $interventionId): bool; + + /** + * @return list + */ + public function interventionMovements(string $org, string $interventionId): array; + + /** + * Method economicInterventionIds + * + * Reads only owned direct-material references, never operational or equipment persistence. + * + * @access public + * + * @param string $org authorized owning organization + * @param list $equipmentIds at most 10000 equipment identifiers + * + * @return list distinct intervention identifiers, refusing more than 10000 matches + */ + public function economicInterventionIds(string $org, array $equipmentIds): array; + + /** + * @param array $filters the exact collection filters + * + * @return list projected rows + */ + public function list(string $type, string $org, array $filters, int $limit, int $offset): array; + + /** + * @param array $filters + */ + public function count(string $type, string $org, array $filters): int; +} diff --git a/src/Inventory/Application/UseCase/Command/ApplyInventoryStock/ApplyInventoryStockCommand.php b/src/Inventory/Application/UseCase/Command/ApplyInventoryStock/ApplyInventoryStockCommand.php new file mode 100644 index 000000000..6078fe03e --- /dev/null +++ b/src/Inventory/Application/UseCase/Command/ApplyInventoryStock/ApplyInventoryStockCommand.php @@ -0,0 +1,16 @@ +canonical($command); + new Uuid($command->organizationId); + new Uuid($command->actorId); + if (!in_array($command->kind, ['receipt', 'receipt_return', 'consumption', 'return', 'correction', 'reconcile'], true)) { + throw new InvalidArgumentException('Unknown inventory operation.'); + } + if ('reconcile' === $command->kind) { + return $this->transactions->transactional(fn (): ApplyInventoryStockResult => $this->reconcile($command)); + } + if (null === $command->clientOperationId) { + throw new InvalidArgumentException('A stable clientOperationId is required.'); + } + new Uuid($command->clientOperationId); + $quantity = new StockQuantity($command->quantity ?? '', 'correction' === $command->kind)->value; + $hash = hash('sha256', json_encode(['kind' => $command->kind, 'partId' => $command->partId, 'warehouseId' => $command->warehouseId, 'quantity' => $quantity, 'occurredAt' => $command->occurredAt?->format('c'), 'interventionId' => $command->interventionId, 'workItemId' => $command->workItemId, 'equipmentId' => $command->equipmentId, 'reason' => $command->reason, 'unitCost' => null === $command->unitCost ? null : $this->amount($command->unitCost), 'currency' => $command->currency, 'sourceReceiptId' => $command->sourceReceiptId, 'originalId' => $command->originalId], JSON_THROW_ON_ERROR)); + + return $this->transactions->transactional(function () use ($command, $quantity, $hash): ApplyInventoryStockResult { + $receipt = $this->store->operationForUpdate($command->organizationId, $command->clientOperationId ?? throw new LogicException('Missing operation identity.')); + if (null !== $receipt) { + if ($receipt->payloadHash !== $hash) { + throw new InventoryConflictException('clientOperationId was already used for another declaration.'); + } + if ('consumption' === $command->kind) { + $this->interventions->validate($command->organizationId, $command->interventionId ?? throw new InvalidArgumentException('An intervention is required.'), $command->workItemId, $command->equipmentId, $command->actorId); + } elseif ('return' === $command->kind) { + $declaration = $this->store->declaration($command->organizationId, $command->originalId ?? throw new InvalidArgumentException('An original consumption is required.')) ?? throw new InventoryNotFoundException('Consumption declaration not found.'); + $this->interventions->validate($command->organizationId, $declaration->interventionId, $declaration->workItemId, $declaration->equipmentId, $command->actorId); + } + + return $this->replay($command, $receipt); + } + $result = match($command->kind) { + 'receipt' => $this->receive($command, $quantity), + 'consumption' => $this->consume($command, $quantity), + 'return','receipt_return' => $this->returnStock($command, $quantity), + default => $this->correct($command, $quantity), + }; + if (null !== $result->receipt?->blockedReason) { + return $result; + } + $this->store->saveOperation(new InventoryOperationReceipt($command->organizationId, $command->clientOperationId ?? throw new LogicException('Missing operation identity.'), $hash, $this->snapshot($result))); + + return $result; + }); + } + + private function consume(ApplyInventoryStockCommand $command, string $quantity): ApplyInventoryStockResult + { + $org = $command->organizationId; + $intervention = $command->interventionId ?? throw new InvalidArgumentException('An interventionId is required.'); + foreach ([$intervention, $command->workItemId, $command->equipmentId] as $id) { + if (null !== $id) { + new Uuid($id); + } + } + $context = $this->interventions->validate($org, $intervention, $command->workItemId, $command->equipmentId, $command->actorId); + [$part,$warehouse] = $this->references($org, $command->partId, $command->warehouseId); + $occurred = $command->occurredAt ?? throw new InvalidArgumentException('An occurredAt date is required.'); + $declaration = new ConsumptionDeclaration($this->ids->generate(), $org, $part->id, $warehouse->id, $quantity, $intervention, $command->workItemId, $command->equipmentId, $command->actorId, $occurred, 'received_pending', null, null, $context->published); + // Persist the complete physical fact before its resolution. Insufficient stock returns normally. + $this->store->saveDeclaration($declaration); + + return new ApplyInventoryStockResult(declaration:$this->resolve($declaration, $part, $warehouse, $context->published)); + } + + private function canonical(ApplyInventoryStockCommand $c): ApplyInventoryStockCommand + { + return new ApplyInventoryStockCommand(strtolower($c->organizationId), strtolower($c->actorId), $c->kind, null === $c->clientOperationId ? null : strtolower($c->clientOperationId), null === $c->partId ? null : strtolower($c->partId), null === $c->warehouseId ? null : strtolower($c->warehouseId), $c->quantity, $c->occurredAt, null === $c->interventionId ? null : strtolower($c->interventionId), null === $c->workItemId ? null : strtolower($c->workItemId), null === $c->equipmentId ? null : strtolower($c->equipmentId), $c->reason, $c->unitCost, $c->currency, null === $c->sourceReceiptId ? null : strtolower($c->sourceReceiptId), null === $c->originalId ? null : strtolower($c->originalId)); + } + + private function resolve(ConsumptionDeclaration $declaration, InventoryReference $part, InventoryReference $warehouse, bool $late): ConsumptionDeclaration + { + $currency = $this->currency->lock($declaration->organizationId); + $balance = $this->store->balanceForUpdate($declaration->organizationId, $warehouse->id, $part->id); + $reason = match(true) { + $part->archived || $warehouse->archived => 'archived_reference',null === $balance => 'missing_balance',DecimalAmount::fromString($balance->quantity)->compareTo(DecimalAmount::fromString($declaration->quantity)) < 0 => 'insufficient_stock',default => null + }; + if (null !== $reason) { + $pending = $this->resolvedDeclaration($declaration, 'received_pending', $reason, null, $late); + $this->store->saveDeclaration($pending); + + return $pending; + } + if (null === $balance) { + throw new LogicException('Missing locked balance.'); + } + if ($currency !== $balance->currency) { + throw new InventoryConflictException('Inventory balance currency does not match the organization.'); + } + $issue = new StockValuation($balance->quantity, $balance->totalValue)->issue($declaration->quantity); + $movement = new StockMovement($this->ids->generate(), $declaration->organizationId, $part->id, $warehouse->id, 'consumption', $this->negative($declaration->quantity), $issue->unitCost, null === $issue->totalValue ? null : $this->negative($issue->totalValue), $currency, 'Declared intervention consumption', $declaration->actorId, $declaration->occurredAt, $declaration->interventionId, $declaration->workItemId, $declaration->equipmentId, null, null, $late); + $this->store->saveBalance(new StockBalance($balance->id, $balance->organizationId, $balance->partId, $balance->warehouseId, $issue->balance->quantity, $issue->balance->totalValue, $currency)); + $this->store->saveMovement($movement); + $confirmed = $this->resolvedDeclaration($declaration, 'confirmed', null, $movement->id, $late); + $this->store->saveDeclaration($confirmed); + + return $confirmed; + } + + private function reconcile(ApplyInventoryStockCommand $command): ApplyInventoryStockResult + { + $id = $command->originalId ?? throw new InvalidArgumentException('A declarationId is required.'); + new Uuid($id); + // Parent scope is read before locking declaration: advisory parent -> declaration -> balance, same order as publication. + $candidate = $this->store->declaration($command->organizationId, $id); + if (null === $candidate) { + throw new InventoryNotFoundException('Consumption declaration not found.'); + } + $context = $this->interventions->validate($command->organizationId, $candidate->interventionId, $candidate->workItemId, $candidate->equipmentId, $command->actorId); + $declaration = $this->store->declaration($command->organizationId, $id, true) ?? throw new InventoryNotFoundException('Consumption declaration not found.'); + if ('confirmed' === $declaration->status) { + return new ApplyInventoryStockResult(declaration:$declaration, replayed:true); + } + [$part,$warehouse] = $this->references($command->organizationId, $declaration->partId, $declaration->warehouseId); + + return new ApplyInventoryStockResult(declaration:$this->resolve($declaration, $part, $warehouse, $context->published || $declaration->late)); + } + + private function receive(ApplyInventoryStockCommand $command, string $quantity): ApplyInventoryStockResult + { + [$part,$warehouse] = $this->references($command->organizationId, $command->partId, $command->warehouseId); + if ($part->archived || $warehouse->archived) { + throw new InventoryConflictException('Archived inventory references cannot receive stock.'); + } + $currency = $this->currency->lock($command->organizationId); + if ($command->currency !== $currency) { + throw new InventoryConflictException('Receipt currency must match the organization currency.'); + } + $unit = null === $command->unitCost ? null : $this->amount($command->unitCost); + $value = null === $unit ? null : DecimalAmount::fromString($unit)->multiply(DecimalAmount::fromString($quantity))->toString(); + $balance = $this->store->balanceForUpdate($command->organizationId, $warehouse->id, $part->id); + $valuation = new StockValuation(null === $balance ? '0.000000' : $balance->quantity, null === $balance ? '0.000000' : $balance->totalValue)->receive($quantity, $value); + $movement = new StockMovement($this->ids->generate(), $command->organizationId, $part->id, $warehouse->id, 'receipt', $quantity, $unit, $value, $currency, 'Procurement receipt', $command->actorId, new DateTimeImmutable(), sourceReceiptId:$command->sourceReceiptId); + $this->store->saveBalance(new StockBalance(null === $balance ? $this->ids->generate() : $balance->id, $command->organizationId, $part->id, $warehouse->id, $valuation->quantity, $valuation->totalValue, $currency)); + $this->store->saveMovement($movement); + + return new ApplyInventoryStockResult(movement:$movement, receipt:new InventoryReceiptResult($movement->id, $quantity, $unit, $value)); + } + + private function returnStock(ApplyInventoryStockCommand $command, string $quantity): ApplyInventoryStockResult + { + $reason = $this->reason($command->reason); + $originalId = $command->originalId ?? throw new InvalidArgumentException('The original declaration or movement is required.'); + new Uuid($originalId); + $org = $command->organizationId; + $late = false; + if ('return' === $command->kind) { + $declaration = $this->store->declaration($org, $originalId); + if (null === $declaration) { + throw new InventoryNotFoundException('Consumption declaration not found.'); + } + $context = $this->interventions->validate($org, $declaration->interventionId, $declaration->workItemId, $declaration->equipmentId, $command->actorId); + $late = $context->published; + if (null === $declaration->movementId) { + throw new InventoryConflictException('An unresolved declaration cannot be returned to stock.'); + } + $originalId = $declaration->movementId; + } + $original = $this->store->movement($org, $originalId) ?? throw new InventoryNotFoundException('Stock movement not found.'); + if (('return' === $command->kind && 'consumption' !== $original->kind) || ('receipt_return' === $command->kind && 'receipt' !== $original->kind)) { + throw new InventoryConflictException('The referenced movement is not returnable.'); + } + [$part,$warehouse] = $this->references($org, $original->partId, $original->warehouseId); + $currency = $this->currency->lock($org); + $balance = $this->store->balanceForUpdate($org, $warehouse->id, $part->id) ?? throw new InventoryConflictException('Missing inventory balance.'); + $returned = DecimalAmount::fromString($this->store->linkedQuantity($org, $originalId)); + $originalQuantity = DecimalAmount::fromString($original->quantity); + if ($originalQuantity->isNegative()) { + $originalQuantity = DecimalAmount::zero()->subtract($originalQuantity); + } + $cumulativeQuantity = $returned->add(DecimalAmount::fromString($quantity)); + if ($cumulativeQuantity->compareTo($originalQuantity) > 0) { + throw new InventoryConflictException('Returns cannot exceed the original quantity.'); + } + if ('receipt_return' === $command->kind && DecimalAmount::fromString($balance->quantity)->compareTo(DecimalAmount::fromString($quantity)) < 0) { + return new ApplyInventoryStockResult(receipt:new InventoryReceiptResult('', $quantity, null, null, blockedReason:'stock_insufficient')); + } + $unit = $original->unitCost; + $originalValue = null === $original->totalValue ? null : DecimalAmount::fromString($original->totalValue); + if (null !== $originalValue && $originalValue->isNegative()) { + $originalValue = DecimalAmount::zero()->subtract($originalValue); + } + // Allocate from the remaining original value so rounding cannot overcredit partial returns. + $lastReturn = 0 === $cumulativeQuantity->compareTo($originalQuantity); + $linkedValue = $this->store->linkedValue($org, $originalId); + $value = null; + if ('return' === $command->kind && null !== $originalValue && null !== $linkedValue) { + $remainingValue = $originalValue->subtract(DecimalAmount::fromString($linkedValue)); + if ($remainingValue->isNegative()) { + throw new InventoryConflictException('Original stock valuation is inconsistent.'); + } + $value = $lastReturn ? $remainingValue->toString() : $remainingValue->multiplyAndDivide(DecimalAmount::fromString($quantity), $originalQuantity->subtract($returned))->toString(); + } + if ('return' === $command->kind && null !== $value && DecimalAmount::fromString($value)->isNegative()) { + throw new InventoryConflictException('Original stock valuation is inconsistent.'); + } + $sign = 'return' === $command->kind ? 1 : -1; + $valuation = new StockValuation($balance->quantity, $balance->totalValue); + if (1 === $sign) { + $next = $valuation->receive($quantity, $value); + } else { + $issue = $valuation->issue($quantity); + $next = $issue->balance; + $value = $issue->totalValue; + $unit = $issue->unitCost; + } + $movement = new StockMovement($this->ids->generate(), $org, $part->id, $warehouse->id, $command->kind, 1 === $sign ? $quantity : $this->negative($quantity), $unit, null === $value ? null : (1 === $sign ? $value : $this->negative($value)), $currency, $reason, $command->actorId, new DateTimeImmutable(), $original->interventionId, $original->workItemId, $original->equipmentId, $originalId, $original->sourceReceiptId, $late); + $this->store->saveBalance(new StockBalance($balance->id, $org, $part->id, $warehouse->id, $next->quantity, $next->totalValue, $currency)); + $this->store->saveMovement($movement); + + return new ApplyInventoryStockResult(movement:$movement, receipt:new InventoryReceiptResult($movement->id, $quantity, $unit, $value)); + } + + private function correct(ApplyInventoryStockCommand $command, string $quantity): ApplyInventoryStockResult + { + $reason = $this->reason($command->reason); + [$part,$warehouse] = $this->references($command->organizationId, $command->partId, $command->warehouseId); + $currency = $this->currency->lock($command->organizationId); + $balance = $this->store->balanceForUpdate($command->organizationId, $warehouse->id, $part->id); + $valuation = new StockValuation(null === $balance ? '0.000000' : $balance->quantity, null === $balance ? '0.000000' : $balance->totalValue); + $delta = DecimalAmount::fromString($quantity); + $value = null; + $unit = null; + if ($delta->isNegative()) { + $issue = $valuation->issue(DecimalAmount::zero()->subtract($delta)->toString()); + $next = $issue->balance; + $unit = $issue->unitCost; + $value = null === $issue->totalValue ? null : $this->negative($issue->totalValue); + } else { + $unit = null === $command->unitCost ? null : $this->amount($command->unitCost); + $value = null === $unit ? null : DecimalAmount::fromString($unit)->multiply($delta)->toString(); + $next = $valuation->receive($quantity, $value); + } + $movement = new StockMovement($this->ids->generate(), $command->organizationId, $part->id, $warehouse->id, 'correction', $quantity, $unit, $value, $currency, $reason, $command->actorId, $command->occurredAt ?? new DateTimeImmutable()); + $this->store->saveBalance(new StockBalance(null === $balance ? $this->ids->generate() : $balance->id, $command->organizationId, $part->id, $warehouse->id, $next->quantity, $next->totalValue, $currency)); + $this->store->saveMovement($movement); + + return new ApplyInventoryStockResult(movement:$movement); + } + + /** + * @return array{InventoryReference,InventoryReference} + */ + private function references(string $org, ?string $partId, ?string $warehouseId): array + { + if (null === $partId || null === $warehouseId) { + throw new InvalidArgumentException('Part and warehouse identifiers are required.'); + }new Uuid($partId); + new Uuid($warehouseId); + $part = $this->store->reference('parts', $org, $partId, true) ?? throw new InventoryNotFoundException('Inventory reference not found.'); + $warehouse = $this->store->reference('warehouses', $org, $warehouseId, true) ?? throw new InventoryNotFoundException('Inventory reference not found.'); + + return [$part, $warehouse]; + } + + private function amount(string $value): string + { + $amount = DecimalAmount::fromString($value); + if ($amount->isNegative()) { + throw new InvalidArgumentException('Unit cost cannot be negative.'); + } + + return $amount->toString(); + } + + private function negative(string $value): string + { + return DecimalAmount::zero()->subtract(DecimalAmount::fromString($value))->toString(); + } + + private function reason(?string $value): string + { + $value = trim($value ?? ''); + if ('' === $value || mb_strlen($value) > 2000) { + throw new InvalidArgumentException('A reason between 1 and 2000 characters is required.'); + } + + return $value; + } + + private function resolvedDeclaration(ConsumptionDeclaration $d, string $status, ?string $reason, ?string $movement, bool $late): ConsumptionDeclaration + { + return new ConsumptionDeclaration($d->id, $d->organizationId, $d->partId, $d->warehouseId, $d->quantity, $d->interventionId, $d->workItemId, $d->equipmentId, $d->actorId, $d->occurredAt, $status, $reason, $movement, $late); + } + + /** + * @return array + */ + private function snapshot(ApplyInventoryStockResult $result): array + { + if (null !== $result->declaration) { + $d = $result->declaration; + + return ['declaration' => ['id' => $d->id, 'organizationId' => $d->organizationId, 'partId' => $d->partId, 'warehouseId' => $d->warehouseId, 'quantity' => $d->quantity, 'interventionId' => $d->interventionId, 'workItemId' => $d->workItemId, 'equipmentId' => $d->equipmentId, 'actorId' => $d->actorId, 'occurredAt' => $d->occurredAt->format('c'), 'status' => $d->status, 'reason' => $d->reason, 'movementId' => $d->movementId, 'late' => $d->late]]; + } + $m = $result->movement ?? throw new LogicException('Missing stock movement response.'); + + return ['movementId' => $m->id, 'receipt' => null === $result->receipt ? null : ['movementId' => $result->receipt->movementId, 'quantity' => $result->receipt->quantity, 'unitCost' => $result->receipt->unitCost, 'totalValue' => $result->receipt->totalValue]]; + } + + private function replay(ApplyInventoryStockCommand $command, InventoryOperationReceipt $receipt): ApplyInventoryStockResult + { + $data = $receipt->response; + if (isset($data['declaration']) && is_array($data['declaration'])) { + $d = $data['declaration']; + + return new ApplyInventoryStockResult(declaration:new ConsumptionDeclaration($this->s($d, 'id'), $command->organizationId, $this->s($d, 'partId'), $this->s($d, 'warehouseId'), $this->s($d, 'quantity'), $this->s($d, 'interventionId'), $this->n($d, 'workItemId'), $this->n($d, 'equipmentId'), $this->s($d, 'actorId'), new DateTimeImmutable($this->s($d, 'occurredAt')), $this->s($d, 'status'), $this->n($d, 'reason'), $this->n($d, 'movementId'), true === ($d['late'] ?? false)), replayed:true); + } + $movement = $this->store->movement($command->organizationId, $this->s($data, 'movementId')) ?? throw new LogicException('Immutable replay movement unavailable.'); + $r = $data['receipt'] ?? null; + + return new ApplyInventoryStockResult(movement:$movement, receipt:is_array($r) ? new InventoryReceiptResult($this->s($r, 'movementId'), $this->s($r, 'quantity'), $this->n($r, 'unitCost'), $this->n($r, 'totalValue'), true) : null, replayed:true); + } + + /** + * @param array $data + */ + private function s(array $data, string $key): string + { + if (!isset($data[$key]) || !is_string($data[$key])) { + throw new LogicException('Malformed inventory replay snapshot.'); + } + + return $data[$key]; + } + + /** + * @param array $data + */ + private function n(array $data, string $key): ?string + { + return null === ($data[$key] ?? null) ? null : $this->s($data, $key); + } +} diff --git a/src/Inventory/Application/UseCase/Command/ApplyInventoryStock/ApplyInventoryStockResult.php b/src/Inventory/Application/UseCase/Command/ApplyInventoryStock/ApplyInventoryStockResult.php new file mode 100644 index 000000000..320db2c58 --- /dev/null +++ b/src/Inventory/Application/UseCase/Command/ApplyInventoryStock/ApplyInventoryStockResult.php @@ -0,0 +1,16 @@ +organizationId); + if (!in_array($command->type, ['parts', 'warehouses'], true)) { + throw new InvalidArgumentException('Invalid inventory reference type.'); + } + + return $this->transactions->transactional(function () use ($command): ManageInventoryReferenceResult { + $existing = null; + if (null !== $command->id) { + new Uuid($command->id); + $existing = $this->store->reference($command->type, $command->organizationId, $command->id, true); + if (null === $existing) { + throw new InventoryNotFoundException('Inventory reference not found.'); + } + } + $reference = null === $existing + ? new InventoryReference($this->ids->generate(), $command->organizationId, trim($command->code ?? ''), trim($command->label ?? ''), 'parts' === $command->type ? $command->unit ?? 'piece' : null, 'parts' === $command->type ? $command->kind ?? 'part' : null, $command->archived ?? false) + : new InventoryReference($existing->id, $command->organizationId, $existing->code, trim($command->label ?? $existing->label), 'parts' === $command->type ? $command->unit ?? $existing->unit : null, $existing->kind, $command->archived ?? $existing->archived); + $this->store->saveReference($command->type, $reference); + + return new ManageInventoryReferenceResult($reference); + }); + } +} diff --git a/src/Inventory/Application/UseCase/Command/ManageInventoryReference/ManageInventoryReferenceResult.php b/src/Inventory/Application/UseCase/Command/ManageInventoryReference/ManageInventoryReferenceResult.php new file mode 100644 index 000000000..b3851b2a3 --- /dev/null +++ b/src/Inventory/Application/UseCase/Command/ManageInventoryReference/ManageInventoryReferenceResult.php @@ -0,0 +1,15 @@ +organizationId); + if (isset($query->filters['archived']) && !in_array($query->filters['archived'], ['true', 'false'], true)) { + throw new InvalidArgumentException('archived must be true or false.'); + } + if (isset($query->filters['search']) && mb_strlen($query->filters['search']) > 255) { + throw new InvalidArgumentException('Search must contain at most 255 characters.'); + } + if (isset($query->filters['status']) && !in_array($query->filters['status'], ['confirmed', 'received_pending'], true)) { + throw new InvalidArgumentException('Unknown consumption status.'); + } + if ($query->page < 1 || $query->itemsPerPage < 1 || $query->itemsPerPage > 100) { + throw new InvalidArgumentException('Inventory pagination must be between 1 and 100.'); + } + foreach (['warehouseId' => 'warehouses', 'partId' => 'parts'] as $key => $type) { + if (isset($query->filters[$key])) { + new Uuid($query->filters[$key]); + if (null === $this->store->reference($type, $query->organizationId, $query->filters[$key])) { + throw new InventoryNotFoundException('Inventory reference not found.'); + } + } + } + if (null !== $query->id) { + new Uuid($query->id); + $item = match($query->type) { + 'parts','warehouses' => $this->store->reference($query->type, $query->organizationId, $query->id),'consumptions' => $this->store->declaration($query->organizationId, $query->id),'movements' => $this->store->movement($query->organizationId, $query->id),default => throw new InvalidArgumentException('Unsupported inventory detail.') + }; + if (null === $item) { + throw new InventoryNotFoundException('Inventory item not found.'); + } + + return new ListInventoryResult([$item], 1); + } + if (isset($query->filters['interventionId'])) { + new Uuid($query->filters['interventionId']); + if (!$this->interventions->existsInOrganization($query->organizationId, $query->filters['interventionId'])) { + throw new InventoryNotFoundException('Intervention not found.'); + } + } + + return new ListInventoryResult($this->store->list($query->type, $query->organizationId, $query->filters, $query->itemsPerPage, ($query->page - 1) * $query->itemsPerPage), $this->store->count($query->type, $query->organizationId, $query->filters)); + } +} diff --git a/src/Inventory/Application/UseCase/Query/ListInventory/ListInventoryQuery.php b/src/Inventory/Application/UseCase/Query/ListInventory/ListInventoryQuery.php new file mode 100644 index 000000000..4a75455bc --- /dev/null +++ b/src/Inventory/Application/UseCase/Query/ListInventory/ListInventoryQuery.php @@ -0,0 +1,18 @@ + $filters + */ + public function __construct(public string $organizationId, public string $type, public array $filters = [], public int $page = 1, public int $itemsPerPage = 30, public ?string $id = null) + { + } +} diff --git a/src/Inventory/Application/UseCase/Query/ListInventory/ListInventoryResult.php b/src/Inventory/Application/UseCase/Query/ListInventory/ListInventoryResult.php new file mode 100644 index 000000000..7cf72f018 --- /dev/null +++ b/src/Inventory/Application/UseCase/Query/ListInventory/ListInventoryResult.php @@ -0,0 +1,18 @@ + $items + */ + public function __construct(public array $items, public int $totalItems) + { + } +} diff --git a/src/Inventory/Domain/Exception/InventoryConflictException.php b/src/Inventory/Domain/Exception/InventoryConflictException.php new file mode 100644 index 000000000..0c2292c89 --- /dev/null +++ b/src/Inventory/Domain/Exception/InventoryConflictException.php @@ -0,0 +1,12 @@ + 100 || '' === trim($label) || mb_strlen($label) > 255 || (null !== $unit && ('' === trim($unit) || mb_strlen($unit) > 32)) || (null !== $kind && !in_array($kind, ['part', 'consumable'], true))) { + throw new InvalidArgumentException('Invalid inventory reference.'); + } + } +} diff --git a/src/Inventory/Domain/Model/Stock/StockBalance.php b/src/Inventory/Domain/Model/Stock/StockBalance.php new file mode 100644 index 000000000..56ca3fc99 --- /dev/null +++ b/src/Inventory/Domain/Model/Stock/StockBalance.php @@ -0,0 +1,13 @@ +isNegative() || (null !== $totalValue && DecimalAmount::fromString($totalValue)->isNegative())) { + throw new InventoryConflictException('Stock quantity and total value must remain nonnegative.'); + } + } + + public function receive(string $quantity, ?string $value): self + { + $quantity = new StockQuantity($quantity)->value; + if (null !== $value && DecimalAmount::fromString($value)->isNegative()) { + throw new InventoryConflictException('Receipt valuation cannot be negative.'); + } + $known = '0.000000' === $this->quantity || null !== $this->totalValue; + + return new self(DecimalAmount::fromString($this->quantity)->add(DecimalAmount::fromString($quantity))->toString(), $known && null !== $value ? DecimalAmount::fromString($this->totalValue ?? '0.000000')->add(DecimalAmount::fromString($value))->toString() : null); + } + + public function issue(string $quantity): StockIssue + { + $quantity = new StockQuantity($quantity)->value; + if (DecimalAmount::fromString($this->quantity)->compareTo(DecimalAmount::fromString($quantity)) < 0) { + throw new InventoryConflictException('Insufficient stock; no partial issue is allowed.'); + } + $value = null; + if (null !== $this->totalValue) { + $value = 0 === DecimalAmount::fromString($this->quantity)->compareTo(DecimalAmount::fromString($quantity)) ? $this->totalValue : DecimalAmount::fromString($this->totalValue)->multiplyAndDivide(DecimalAmount::fromString($quantity), DecimalAmount::fromString($this->quantity))->toString(); + if (DecimalAmount::fromString($value)->compareTo(DecimalAmount::fromString($this->totalValue)) > 0) { + $value = $this->totalValue; + } + } + $remaining = DecimalAmount::fromString($this->quantity)->subtract(DecimalAmount::fromString($quantity))->toString(); + + return new StockIssue(new self($remaining, '0.000000' === $remaining ? '0.000000' : (null === $value ? null : DecimalAmount::fromString($this->totalValue ?? '0.000000')->subtract(DecimalAmount::fromString($value))->toString())), $value, null === $value ? null : DecimalAmount::fromString($value)->divide(DecimalAmount::fromString($quantity))->toString()); + } +} diff --git a/src/Inventory/Domain/ValueObject/StockQuantity.php b/src/Inventory/Domain/ValueObject/StockQuantity.php new file mode 100644 index 000000000..63c2dfd39 --- /dev/null +++ b/src/Inventory/Domain/ValueObject/StockQuantity.php @@ -0,0 +1,30 @@ + '']; + $normalized = $whole . '.' . str_pad($fraction, 6, '0'); + if ('0.000000' === $normalized || '-0.000000' === $normalized) { + throw new InvalidArgumentException('Quantity must be nonzero.'); + } + $this->value = $normalized; + } +} diff --git a/src/Inventory/Infrastructure/Adapter/Intervention/InventoryInterventionResourcesAdapter.php b/src/Inventory/Infrastructure/Adapter/Intervention/InventoryInterventionResourcesAdapter.php new file mode 100644 index 000000000..2d47b4a84 --- /dev/null +++ b/src/Inventory/Infrastructure/Adapter/Intervention/InventoryInterventionResourcesAdapter.php @@ -0,0 +1,70 @@ +store->hasPendingDeclarations($organizationId, $interventionId) ? ['inventory_pending_declarations'] : []; + } + + public function assertReadyToPublish(string $organizationId, string $interventionId): void + { + $this->store->lockIntervention($organizationId, $interventionId); + if ([] !== $this->publicationBlockers($organizationId, $interventionId)) { + throw new InventoryPublicationBlockedException('Received inventory declarations must be reconciled before publication.'); + } + } + + public function costFacts(string $organizationId, string $interventionId): array + { + $facts = []; + foreach ($this->store->interventionMovements($organizationId, $interventionId) as $m) { + $facts[] = new InventoryCostFact($m->id, $m->partId, DecimalAmount::zero()->subtract(DecimalAmount::fromString($m->quantity))->toString(), $m->unitCost, null === $m->totalValue ? null : DecimalAmount::zero()->subtract(DecimalAmount::fromString($m->totalValue))->toString(), $m->currency, $m->occurredAt, $m->workItemId, $m->correctionOf, $m->equipmentId); + } + foreach ($this->store->list('consumptions', $organizationId, ['interventionId' => $interventionId, 'status' => 'received_pending'], 10001, 0) as $d) { + if ($d instanceof ConsumptionDeclaration) { + $facts[] = new InventoryCostFact($d->id, $d->partId, $d->quantity, null, null, $this->currency->forOrganization($organizationId), $d->occurredAt, $d->workItemId, null, $d->equipmentId); + } + } + if (count($facts) > 10000) { + throw new InventoryPublicationBlockedException('Inventory facts exceed the bounded publication limit.'); + } + + return $facts; + } + + /** + * Method economicInterventionIds + * + * Publishes bounded material target references to authorized private finance consumers. + * + * @access public + * + * @param string $organizationId authorized owning organization + * @param list $equipmentIds bounded equipment scope + * + * @return list candidate intervention identities + */ + public function economicInterventionIds(string $organizationId, array $equipmentIds): array + { + return $this->store->economicInterventionIds($organizationId, $equipmentIds); + } +} diff --git a/src/Inventory/Infrastructure/Adapter/Procurement/InventoryPartDirectoryAdapter.php b/src/Inventory/Infrastructure/Adapter/Procurement/InventoryPartDirectoryAdapter.php new file mode 100644 index 000000000..caf02a6fb --- /dev/null +++ b/src/Inventory/Infrastructure/Adapter/Procurement/InventoryPartDirectoryAdapter.php @@ -0,0 +1,46 @@ + 100) { + throw new InvalidArgumentException('Part descriptor batches contain at most one hundred identities.'); + } + $descriptors = []; + foreach ($this->store->referencesByIds('parts', $organizationId, $partIds) as $id => $part) { + $descriptors[$id] = new \Inventory\Application\Contract\Directory\InventoryPartDescriptor($part->id, $part->code, $part->label, $part->unit ?? 'piece', $part->archived); + } + + return $descriptors; + } + + public function existsActive(string $organizationId, string $partId): bool + { + $part = $this->store->reference('parts', $organizationId, $partId); + + return null !== $part && !$part->archived; + } + + public function warehouseExistsActive(string $organizationId, string $warehouseId): bool + { + $w = $this->store->reference('warehouses', $organizationId, $warehouseId); + + return null !== $w && !$w->archived; + } +} diff --git a/src/Inventory/Infrastructure/Adapter/Procurement/InventoryStockReceiptAdapter.php b/src/Inventory/Infrastructure/Adapter/Procurement/InventoryStockReceiptAdapter.php new file mode 100644 index 000000000..f8c474f10 --- /dev/null +++ b/src/Inventory/Infrastructure/Adapter/Procurement/InventoryStockReceiptAdapter.php @@ -0,0 +1,45 @@ +commands->dispatch(new ApplyInventoryStockCommand($r->organizationId, $r->actorId, 'receipt', $r->clientOperationId, $r->partId, $r->warehouseId, $r->quantity, unitCost:$r->unitCost, currency:$r->currency, sourceReceiptId:$r->sourceReceiptId)); + + return $result->receipt ?? throw new LogicException('Inventory receipt result unavailable.'); + } + + public function reverse(InventoryReceiptReturnRequest $r): InventoryReceiptResult + { + $result = $this->tryReverse($r); + if (null !== $result->blockedReason) { + throw new \Inventory\Domain\Exception\InventoryConflictException('Insufficient stock for the full supplier return.'); + } + + return $result; + } + + public function tryReverse(InventoryReceiptReturnRequest $r): InventoryReceiptResult + { + /** @var ApplyInventoryStockResult $result */ + $result = $this->commands->dispatch(new ApplyInventoryStockCommand($r->organizationId, $r->actorId, 'receipt_return', $r->clientOperationId, quantity:$r->quantity, reason:$r->reason, originalId:$r->movementId)); + + return $result->receipt ?? throw new LogicException('Inventory return result unavailable.'); + } +} diff --git a/src/Inventory/Infrastructure/Persistence/Doctrine/Record/InventoryBalanceRecord.php b/src/Inventory/Infrastructure/Persistence/Doctrine/Record/InventoryBalanceRecord.php new file mode 100644 index 000000000..67aaf73a6 --- /dev/null +++ b/src/Inventory/Infrastructure/Persistence/Doctrine/Record/InventoryBalanceRecord.php @@ -0,0 +1,36 @@ + + */ + #[ORM\Column(name:'response', type:'json')] + public array $response; +} diff --git a/src/Inventory/Infrastructure/Persistence/Doctrine/Record/InventoryPartRecord.php b/src/Inventory/Infrastructure/Persistence/Doctrine/Record/InventoryPartRecord.php new file mode 100644 index 000000000..ee1bda16e --- /dev/null +++ b/src/Inventory/Infrastructure/Persistence/Doctrine/Record/InventoryPartRecord.php @@ -0,0 +1,36 @@ +storageZone = new DateTimeZone($storageTimeZone); + } + + public function operationForUpdate(string $org, string $operationId): ?InventoryOperationReceipt + { + $this->lock('inventory-operation:' . $org . ':' . $operationId); + $row = $this->connection()->fetchAssociative('SELECT * FROM inventory_operation_receipts WHERE organization_id=:org AND client_operation_id=:op', ['org' => $org, 'op' => $operationId]); + if (false === $row) { + return null; + } + $response = json_decode(self::s($row, 'response'), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($response)) { + throw new LogicException('Invalid stored operation response.'); + } + + /** @var array $response */ + return new InventoryOperationReceipt($org, $operationId, self::s($row, 'payload_hash'), $response); + } + + public function saveOperation(InventoryOperationReceipt $receipt): void + { + $this->connection()->insert('inventory_operation_receipts', ['organization_id' => $receipt->organizationId, 'client_operation_id' => $receipt->clientOperationId, 'payload_hash' => $receipt->payloadHash, 'response' => json_encode($receipt->response, JSON_THROW_ON_ERROR)]); + } + + public function lockIntervention(string $org, string $interventionId): void + { + $this->lock('inventory-intervention:' . $org . ':' . $interventionId); + } + + public function reference(string $type, string $org, string $id, bool $lock = false): ?InventoryReference + { + $table = $this->table($type); + $row = $this->connection()->fetchAssociative('SELECT * FROM ' . $table . ' WHERE organization_id=:org AND id=:id' . ($lock ? ' FOR UPDATE' : ''), ['org' => $org, 'id' => $id]); + + return false === $row ? null : $this->referenceRow($type, $row); + } + + public function saveReference(string $type, InventoryReference $reference): void + { + $data = ['organization_id' => $reference->organizationId, 'code' => $reference->code, 'label' => $reference->label, 'archived' => $reference->archived]; + if ('parts' === $type) { + $data['unit'] = $reference->unit; + $data['kind'] = $reference->kind; + } + + try { + $this->connection()->executeStatement('INSERT INTO ' . $this->table($type) . ' (id,' . implode(',', array_keys($data)) . ') VALUES (:id,' . implode(',', array_map(static fn (string $k): string => ':' . $k, array_keys($data))) . ') ON CONFLICT (id) DO UPDATE SET label=EXCLUDED.label,archived=EXCLUDED.archived' . ('parts' === $type ? ',unit=EXCLUDED.unit' : ''), ['id' => $reference->id] + $data, ['archived' => ParameterType::BOOLEAN]); + } catch (UniqueConstraintViolationException $e) { + throw new InventoryConflictException('Inventory reference code already exists.', 0, $e); + } + } + + public function referencesByIds(string $type, string $org, array $ids): array + { + if ([] === $ids) { + return []; + } + if (count($ids) > 100) { + throw new InvalidArgumentException('Reference batches contain at most one hundred identities.'); + } + $rows = $this->connection()->fetchAllAssociative('SELECT * FROM ' . $this->table($type) . ' WHERE organization_id=:org AND id IN (:ids)', ['org' => $org, 'ids' => array_values(array_unique($ids))], ['ids' => ArrayParameterType::STRING]); + $references = []; + foreach ($rows as $row) { + $item = $this->referenceRow($type, $row); + $references[$item->id] = $item; + } + + return $references; + } + + public function balanceForUpdate(string $org, string $warehouseId, string $partId): ?StockBalance + { + $this->lock('inventory-balance:' . $org . ':' . $warehouseId . ':' . $partId); + $row = $this->connection()->fetchAssociative('SELECT * FROM inventory_balances WHERE organization_id=:org AND warehouse_id=:warehouse AND part_id=:part FOR UPDATE', ['org' => $org, 'warehouse' => $warehouseId, 'part' => $partId]); + + return false === $row ? null : $this->balanceRow($row); + } + + public function saveBalance(StockBalance $balance): void + { + $this->connection()->executeStatement('INSERT INTO inventory_balances(id,organization_id,warehouse_id,part_id,quantity,total_value,currency) VALUES (:id,:org,:warehouse,:part,:quantity,:value,:currency) ON CONFLICT (organization_id,warehouse_id,part_id) DO UPDATE SET quantity=EXCLUDED.quantity,total_value=EXCLUDED.total_value', ['id' => $balance->id, 'org' => $balance->organizationId, 'warehouse' => $balance->warehouseId, 'part' => $balance->partId, 'quantity' => $balance->quantity, 'value' => $balance->totalValue, 'currency' => $balance->currency]); + } + + public function saveMovement(StockMovement $movement): void + { + $this->connection()->insert('inventory_movements', ['id' => $movement->id, 'organization_id' => $movement->organizationId, 'warehouse_id' => $movement->warehouseId, 'part_id' => $movement->partId, 'kind' => $movement->kind, 'quantity' => $movement->quantity, 'unit_cost' => $movement->unitCost, 'total_value' => $movement->totalValue, 'currency' => $movement->currency, 'reason' => $movement->reason, 'actor_id' => $movement->actorId, 'occurred_at' => $movement->occurredAt->setTimezone($this->storageZone)->format('Y-m-d H:i:s'), 'intervention_id' => $movement->interventionId, 'work_item_id' => $movement->workItemId, 'equipment_id' => $movement->equipmentId, 'correction_of' => $movement->correctionOf, 'source_receipt_id' => $movement->sourceReceiptId, 'late' => $movement->late], ['late' => ParameterType::BOOLEAN]); + } + + public function movement(string $org, string $id): ?StockMovement + { + $row = $this->connection()->fetchAssociative('SELECT * FROM inventory_movements WHERE organization_id=:org AND id=:id', ['org' => $org, 'id' => $id]); + + return false === $row ? null : $this->movementRow($row); + } + + public function linkedQuantity(string $org, string $movementId): string + { + $value = $this->connection()->fetchOne('SELECT COALESCE(SUM(ABS(quantity)),0) FROM inventory_movements WHERE organization_id=:org AND correction_of=:id', ['org' => $org, 'id' => $movementId]); + if (!is_string($value) && !is_int($value)) { + throw new LogicException('Invalid linked quantity.'); + } + + return DecimalAmount::fromString((string) $value)->toString(); + } + + public function linkedValue(string $org, string $movementId): ?string + { + $row = $this->connection()->fetchAssociative('SELECT COALESCE(SUM(ABS(total_value)),0) AS amount,COUNT(*) FILTER (WHERE total_value IS NULL) AS unknown FROM inventory_movements WHERE organization_id=:org AND correction_of=:id', ['org' => $org, 'id' => $movementId]); + if (false === $row) { + throw new LogicException('Invalid linked movement aggregate.'); + } + + $unknown = $row['unknown'] ?? null; + if (!is_int($unknown) && !is_string($unknown)) { + throw new LogicException('Invalid linked valuation count.'); + } + + return 0 !== (int) $unknown ? null : DecimalAmount::fromString(self::s($row, 'amount'))->toString(); + } + + public function saveDeclaration(ConsumptionDeclaration $declaration): void + { + $this->connection()->executeStatement('INSERT INTO inventory_declarations(id,organization_id,warehouse_id,part_id,quantity,intervention_id,work_item_id,equipment_id,actor_id,occurred_at,status,reason,movement_id,late) VALUES (:id,:org,:warehouse,:part,:quantity,:intervention,:work,:equipment,:actor,:occurred,:status,:reason,:movement,:late) ON CONFLICT (id) DO UPDATE SET status=EXCLUDED.status,reason=EXCLUDED.reason,movement_id=EXCLUDED.movement_id,late=EXCLUDED.late', ['id' => $declaration->id, 'org' => $declaration->organizationId, 'warehouse' => $declaration->warehouseId, 'part' => $declaration->partId, 'quantity' => $declaration->quantity, 'intervention' => $declaration->interventionId, 'work' => $declaration->workItemId, 'equipment' => $declaration->equipmentId, 'actor' => $declaration->actorId, 'occurred' => $declaration->occurredAt->setTimezone($this->storageZone)->format('Y-m-d H:i:s'), 'status' => $declaration->status, 'reason' => $declaration->reason, 'movement' => $declaration->movementId, 'late' => $declaration->late], ['late' => ParameterType::BOOLEAN]); + } + + public function declaration(string $org, string $id, bool $lock = false): ?ConsumptionDeclaration + { + $row = $this->connection()->fetchAssociative('SELECT * FROM inventory_declarations WHERE organization_id=:org AND id=:id' . ($lock ? ' FOR UPDATE' : ''), ['org' => $org, 'id' => $id]); + + return false === $row ? null : $this->declarationRow($row); + } + + public function hasPendingDeclarations(string $org, string $interventionId): bool + { + $count = $this->connection()->fetchOne("SELECT COUNT(*) FROM inventory_declarations WHERE organization_id=:org AND intervention_id=:intervention AND status='received_pending'", ['org' => $org, 'intervention' => $interventionId]); + if (!is_string($count) && !is_int($count)) { + throw new LogicException('Invalid declaration count.'); + } + + return (int) $count > 0; + } + + public function interventionMovements(string $org, string $interventionId): array + { + $rows = $this->connection()->fetchAllAssociative('SELECT * FROM inventory_movements WHERE organization_id=:org AND intervention_id=:intervention ORDER BY occurred_at,id LIMIT 10001', ['org' => $org, 'intervention' => $interventionId]); + if (count($rows) > 10000) { + throw new InventoryConflictException('Intervention inventory facts exceed the bounded publication limit.'); + } + + return array_map($this->movementRow(...), $rows); + } + + public function list(string $type, string $org, array $filters, int $limit, int $offset): array + { + [$where,$params] = $this->selection($type, $org, $filters); + $rows = $this->connection()->fetchAllAssociative('SELECT * FROM ' . $this->table($type) . ' WHERE ' . $where . ' ORDER BY id ASC LIMIT :limit OFFSET :offset', $params + ['limit' => $limit, 'offset' => $offset], ['limit' => ParameterType::INTEGER, 'offset' => ParameterType::INTEGER]); + + return array_map(fn (array $r): InventoryReference|StockBalance|StockMovement|ConsumptionDeclaration => match($type) { + 'parts','warehouses' => $this->referenceRow($type, $r),'balances' => $this->balanceRow($r),'movements' => $this->movementRow($r),'consumptions' => $this->declarationRow($r),default => throw new LogicException('Unknown inventory projection.') + }, $rows); + } + + public function count(string $type, string $org, array $filters): int + { + [$where,$params] = $this->selection($type, $org, $filters); + + $count = $this->connection()->fetchOne('SELECT COUNT(*) FROM ' . $this->table($type) . ' WHERE ' . $where, $params); + if (!is_string($count) && !is_int($count)) { + throw new LogicException('Invalid inventory count.'); + } + + return (int) $count; + } + + /** + * Method economicInterventionIds + * + * Counts no sibling records and returns no valuations or quantities. + * + * @access public + * + * @param string $org authorized organization + * @param list $equipmentIds bounded equipment scope + * + * @return list distinct candidate intervention identifiers + */ + public function economicInterventionIds(string $org, array $equipmentIds): array + { + $equipmentIds = array_values(array_unique($equipmentIds)); + if (count($equipmentIds) > 10000) { + throw new InventoryConflictException('The financial equipment scope exceeds 10000 identities; narrow its target filters.'); + } + if ([] === $equipmentIds) { + return []; + } + /** @var list $ids */ + $ids = $this->connection()->fetchFirstColumn("SELECT intervention_id FROM inventory_movements WHERE organization_id = :org AND intervention_id IS NOT NULL AND equipment_id IN (:equipment) UNION SELECT intervention_id FROM inventory_declarations WHERE organization_id = :org AND status = 'received_pending' AND equipment_id IN (:equipment) ORDER BY intervention_id LIMIT 10001", ['org' => $org, 'equipment' => $equipmentIds], ['equipment' => ArrayParameterType::STRING]); + if (count($ids) > 10000) { + throw new InventoryConflictException('The financial material scope exceeds 10000 interventions; narrow its target filters.'); + } + + return $ids; + } + + private function connection(): Connection + { + return $this->entityManager->getConnection(); + } + + private function lock(string $identity): void + { + if (!$this->connection()->isTransactionActive()) { + throw new LogicException('Inventory write requires a main transaction.'); + } + $this->connection()->executeQuery('SELECT pg_advisory_xact_lock(hashtextextended(:identity,0))', ['identity' => $identity]); + } + + private function table(string $type): string + { + return match($type) { + 'parts' => 'inventory_parts','warehouses' => 'inventory_warehouses','balances' => 'inventory_balances','movements' => 'inventory_movements','consumptions' => 'inventory_declarations',default => throw new LogicException('Unknown inventory collection.') + }; + } + + /** + * @param array $filters the exact collection filters + * + * @return array{string,array} SQL predicate and bound parameters + */ + private function selection(string $type, string $org, array $filters): array + { + $where = 'organization_id=:org'; + $params = ['org' => $org]; + if (in_array($type, ['parts', 'warehouses'], true)) { + if (isset($filters['search'])) { + $where .= " AND (code ILIKE :search ESCAPE '!' OR label ILIKE :search ESCAPE '!')"; + $params['search'] = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $filters['search']) . '%'; + } + if (isset($filters['archived'])) { + $where .= ' AND archived = CAST(:archived AS BOOLEAN)'; + $params['archived'] = $filters['archived']; + } + } + foreach (['warehouseId' => 'warehouse_id', 'partId' => 'part_id', 'interventionId' => 'intervention_id', 'status' => 'status'] as $key => $column) { + if (isset($filters[$key])) { + $where .= ' AND ' . $column . '=:' . $key; + $params[$key] = $filters[$key]; + } + } + + return [$where, $params]; + } + + /** + * @param array $row + */ + private function referenceRow(string $type, array $row): InventoryReference + { + return new InventoryReference(self::s($row, 'id'), self::s($row, 'organization_id'), self::s($row, 'code'), self::s($row, 'label'), 'parts' === $type ? self::s($row, 'unit') : null, 'parts' === $type ? self::s($row, 'kind') : null, (bool) $row['archived']); + } + + /** + * @param array $row + */ + private function balanceRow(array $row): StockBalance + { + return new StockBalance(self::s($row, 'id'), self::s($row, 'organization_id'), self::s($row, 'part_id'), self::s($row, 'warehouse_id'), self::s($row, 'quantity'), self::n($row, 'total_value'), self::s($row, 'currency')); + } + + /** + * @param array $row + */ + private function movementRow(array $row): StockMovement + { + return new StockMovement(self::s($row, 'id'), self::s($row, 'organization_id'), self::s($row, 'part_id'), self::s($row, 'warehouse_id'), self::s($row, 'kind'), self::s($row, 'quantity'), self::n($row, 'unit_cost'), self::n($row, 'total_value'), self::s($row, 'currency'), self::s($row, 'reason'), self::s($row, 'actor_id'), new DateTimeImmutable(self::s($row, 'occurred_at'), $this->storageZone), self::n($row, 'intervention_id'), self::n($row, 'work_item_id'), self::n($row, 'equipment_id'), self::n($row, 'correction_of'), self::n($row, 'source_receipt_id'), (bool) $row['late']); + } + + /** + * @param array $row + */ + private function declarationRow(array $row): ConsumptionDeclaration + { + return new ConsumptionDeclaration(self::s($row, 'id'), self::s($row, 'organization_id'), self::s($row, 'part_id'), self::s($row, 'warehouse_id'), self::s($row, 'quantity'), self::s($row, 'intervention_id'), self::n($row, 'work_item_id'), self::n($row, 'equipment_id'), self::s($row, 'actor_id'), new DateTimeImmutable(self::s($row, 'occurred_at'), $this->storageZone), self::s($row, 'status'), self::n($row, 'reason'), self::n($row, 'movement_id'), (bool) $row['late']); + } + + /** + * @param array $r + */ + private static function s(array $r, string $key): string + { + if (!isset($r[$key]) || !is_string($r[$key])) { + throw new LogicException('Malformed inventory persisted field ' . $key); + } + + return $r[$key]; + } + + /** + * @param array $r + */ + private static function n(array $r, string $key): ?string + { + return null === ($r[$key] ?? null) ? null : self::s($r, $key); + } +} diff --git a/src/Inventory/MODULE.md b/src/Inventory/MODULE.md new file mode 100644 index 000000000..33d3c7f59 --- /dev/null +++ b/src/Inventory/MODULE.md @@ -0,0 +1,70 @@ +# Inventory Module + +## Overview + +Inventory owns quantitative parts, consumables, warehouses, balances and their immutable movements in `main`. It does not own individual equipment in reserve: Equipment owns those assets, their catalog, quota and history. Quantities and internal values are exact decimal strings with six fractional digits. Labels and units are descriptive. + +## API endpoints + +All routes are prefixed by `/api/organizations/{organizationId}`. + +| Route | Methods | Permission | +| --- | --- | --- | +| `/inventory-parts`, `/inventory-parts/{id}` | list/create, get/patch | inventory.read / inventory.manage | +| `/inventory-warehouses`, `/inventory-warehouses/{id}` | list/create, get/patch | inventory.read / inventory.manage | +| `/inventory-balances` | list | inventory.read | +| `/inventory-movements`, `/inventory-movements/{id}` | list, get | inventory.read | +| `/inventory-consumptions`, `/inventory-consumptions/{id}` | list/declare, get | inventory.read / inventory.consume and interventions.execute | +| `/inventory-consumptions/{id}/reconcile` | post with empty body | inventory.manage and interventions.execute | +| `/inventory-returns` | post | inventory.consume and interventions.execute | +| `/inventory-corrections` | post | inventory.manage and maintenance_cost.manage | + +Permission names carry the `organization.` prefix. Collections use Hydra pagination, `page` and `itemsPerPage` (maximum 100). Parts and warehouses accept `search` and optional `archived=true|false`; absent archived includes history. Balances accept warehouseId and partId. Movements additionally accept interventionId; consumptions also accept status. + +Part input is code, label, unit and kind (`part|consumable`). Warehouse input is code and name. Codes and part kinds are immutable; label/name, unit and archived are patchable. Detail reads retain archived references. + +Consumption input is stable UUID clientOperationId, partId, warehouseId, positive decimal quantity, interventionId, optional workItemId/equipmentId, and an ISO occurrence with explicit offset. The output distinguishes `confirmed` and `received_pending` with reason `insufficient_stock|archived_reference|missing_balance`, a nullable movementId, late and replayed. Returns carry consumptionId, quantity and a required reason. Corrections carry signed quantity and reason; optional unitCost never silently becomes zero. + +Balances and movements attach `valuation` only when the caller holds maintenance_cost.read. Its totalValue and unitCost are nullable, currency is fixed per organization, and incomplete remains true for unknown values. Ordinary quantity reads carry no internal values. + +## Flows + +A valid physical consumption is saved before resolution in the same main transaction. It debits the complete requested quantity and its CUMP value or remains received_pending without any stock movement. No partial issue or negative balance is possible. Archived references and absent balance books also retain the declaration. Reconciliation uses the declaration's original quantity and work context. + +Each operation has an organization-scoped stable identity and payload hash. A conflicting body returns 409. Replaying consumption returns its original response snapshot even if the declaration has since been reconciled; detail reads return its current status. Confirmed receipts, returns and corrections replay their immutable movement. + +InventoryStockReceiptPort receives procurement quantities or reverses a linked receipt. tryReverse returns stock_insufficient without changing stock or poisoning the caller's transaction; Procurement retains the physical return and may retry. Unconfirmed supplier returns are not registered as completed inventory operations. Confirmed returns are bounded by both the original received quantity and available stock. + +Returned intervention parts cannot exceed their original consumption. Every correction/return has a reason and preserves original movements. Each partial return allocates the remaining original value proportionally to the remaining original quantity, so rounding cannot credit more than the original value. The last return receives its exact residual; unknown original or prior return values remain unknown. Unknown receipt prices make a nonempty balance incomplete; only emptying that balance establishes known zero before new priced receipts. + +## Architecture + +Handlers inject the inventory store, main transaction manager and published Intervention/MaintenanceCost ports. The repository only queries Inventory tables. Organization authorization occurs at the HTTP boundary; Intervention validates work ownership, equipment scope and execution. Procurement and intervention costs call public Application ports/contracts. + +All stock paths acquire the organization currency lock before the balance lock. Consumption and publication share the `inventory-intervention:{organizationId}:{interventionId}` advisory fence before the parent intervention lock. Publication refuses received unresolved declarations and captures immutable facts. Late declarations and returns remain current cost facts with linked adjustments; they never mutate an existing dossier. + +The public intervention resource port also supplies finance-authorized direct-equipment candidate intervention identifiers from owned movements and pending declarations. Both the equipment input and distinct intervention output are bounded to 10000 identities, with an explicit refusal of oversized scopes. No quantities, valuations or sibling persistence cross this directory projection; MaintenanceCost verifies actual current/captured allocations before selecting a financial dossier. + +CUMP allocates total balance value proportionally with one final half-up rounding at six places, retaining the residual. The last issue uses the entire remaining value. Returns are linked to their source movement. Missing price is represented by null, rather than a fabricated zero. + +## Configuration + +`config/modules/inventory.yaml` wires every service and explicitly uses the main entity manager and transaction manager. Doctrine maps `Inventory\\Infrastructure\\Persistence\\Doctrine\\Record` only under main. Routes register the Inventory API Resource directory. DATABASE_STORAGE_TIMEZONE controls persisted wall-clock timestamps; reads and writes convert through that zone. + +Migration `Version20261006111000` adds the scoped reference catalog, balances, immutable movement ledger, consumption declarations and operation receipts. Balances have an organization/warehouse/part unique key and a nonnegative database constraint. No auth/main joins are used. + +## Testing + +Domain tests cover weighted average allocation, fractional quantities, last-issue residuals and incomplete values. Handler tests cover shortages, missing books, late facts, original-snapshot replay and exact partial returns across tiny, fractional, mixed and unknown valuations. PostgreSQL functional tests cover reconciliation, returns with tiny rounding residuals and replay, money permissions, archived history, scoped 404s, unchanged published dossiers and search/pagination parity. A two-session PostgreSQL integration test disputes the final part and proves one confirmed issue plus one intact pending declaration. + +Use `make test-db` once to build the isolated template, then run the focused Inventory suites. Source/test PHPStan, container lint, both Deptrac configurations, OpenAPI and main schema gates are required. + +## Error codes + +| Code | Status | Meaning | +| --- | --- | --- | +| inventory_not_found | 404 | Unknown or foreign inventory reference/detail | +| inventory_conflict | 409 | Reused operation payload, duplicate catalog code, invalid return bound or publication blocker | +| inventory_invalid | 422 | Invalid exact quantity, monetary value, reason, reference properties or filters | + +Authentication and missing contextual permission fail closed with 401/403. Outside organizations use the same 404 as unknown organizations. Publication callers receive the public InventoryPublicationBlockedException contract, rather than an Inventory Domain type. diff --git a/src/Inventory/Presentation/Api/Dto/Input/CorrectInventoryStockInput.php b/src/Inventory/Presentation/Api/Dto/Input/CorrectInventoryStockInput.php new file mode 100644 index 000000000..bde215b2c --- /dev/null +++ b/src/Inventory/Presentation/Api/Dto/Input/CorrectInventoryStockInput.php @@ -0,0 +1,39 @@ + + */ + public static function getSubscribedEvents(): array + { + return [KernelEvents::EXCEPTION => ['onException', 12]]; + } + + public function onException(ExceptionEvent $event): void + { + $operation = $event->getRequest()->attributes->get('_api_operation_name', ''); + $inventory = is_string($operation) && str_starts_with($operation, 'inventory_'); + $error = $event->getThrowable(); + do { + $status = match(true) { + $error instanceof InventoryNotFoundException => 404,$error instanceof InventoryConflictException || $error instanceof InventoryPublicationBlockedException => 409,$inventory && ($error instanceof InvalidArgumentException || $error instanceof InvalidValueException) => 422,default => null + }; + if (null !== $status) { + $code = match($status) { + 404 => 'inventory_not_found',409 => 'inventory_conflict',default => 'inventory_invalid' + }; + $event->setResponse(new JsonResponse(['type' => '/errors/' . $code, 'title' => 'Inventory operation refused', 'status' => $status, 'code' => $code, 'detail' => $error->getMessage()], $status, ['Content-Type' => 'application/problem+json'])); + + return; + } + $error = $error->getPrevious(); + } while (null !== $error); + } +} diff --git a/src/Inventory/Presentation/Api/Operation/InventoryOperations.php b/src/Inventory/Presentation/Api/Operation/InventoryOperations.php new file mode 100644 index 000000000..d02443aee --- /dev/null +++ b/src/Inventory/Presentation/Api/Operation/InventoryOperations.php @@ -0,0 +1,43 @@ + + */ +final readonly class InventoryProcessor implements ProcessorInterface +{ + public function __construct(private CommandBusPort $commands, private InventoryAccess $access, private InventoryOutputFactory $outputs) + { + } + + /** + * @param mixed $data validated input + * @param array $uriVariables route identifiers + * @param array $context processor context + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): InventoryPartOutput|InventoryWarehouseOutput|InventoryBalanceOutput|InventoryMovementOutput|InventoryConsumptionOutput + { + $consume = $data instanceof DeclareInventoryConsumptionInput || $data instanceof ReturnInventoryConsumptionInput; + $org = $this->access->organization($uriVariables, $consume ? 'organization.inventory.consume' : 'organization.inventory.manage'); + $actor = $this->access->actor(); + $id = $uriVariables['id'] ?? null; + if (null !== $id && !is_string($id)) { + throw new BadRequestHttpException('Invalid inventory identifier.'); + } + if ($data instanceof CreateInventoryPartInput || $data instanceof PatchInventoryPartInput || $data instanceof CreateInventoryWarehouseInput || $data instanceof PatchInventoryWarehouseInput) { + $command = match(true) { + $data instanceof CreateInventoryPartInput => new ManageInventoryReferenceCommand($org, 'parts', code:$data->code, label:$data->label, unit:$data->unit, kind:$data->kind), + $data instanceof PatchInventoryPartInput => new ManageInventoryReferenceCommand($org, 'parts', id:$id, label:$data->label, unit:$data->unit, archived:$data->archived), + $data instanceof CreateInventoryWarehouseInput => new ManageInventoryReferenceCommand($org, 'warehouses', code:$data->code, label:$data->name), + default => new ManageInventoryReferenceCommand($org, 'warehouses', id:$id, label:$data->name, archived:$data->archived), + }; + /** @var ManageInventoryReferenceResult $result */ + $result = $this->commands->dispatch($command); + + return $this->outputs->output($result->reference); + } + if ($data instanceof CorrectInventoryStockInput) { + $this->access->requirePermission($org, 'organization.maintenance_cost.manage'); + $command = new ApplyInventoryStockCommand($org, $actor, 'correction', $data->clientOperationId, $data->partId, $data->warehouseId, $data->quantity, reason:$data->reason, unitCost:$data->unitCost); + } elseif ($data instanceof DeclareInventoryConsumptionInput) { + $this->access->requirePermission($org, 'organization.interventions.execute'); + $command = new ApplyInventoryStockCommand($org, $actor, 'consumption', $data->clientOperationId, $data->partId, $data->warehouseId, $data->quantity, $this->date($data->occurredAt), $data->interventionId, $data->workItemId, $data->equipmentId); + } elseif ($data instanceof ReturnInventoryConsumptionInput) { + $this->access->requirePermission($org, 'organization.interventions.execute'); + $command = new ApplyInventoryStockCommand($org, $actor, 'return', $data->clientOperationId, quantity:$data->quantity, reason:$data->reason, originalId:$data->consumptionId); + } elseif ($data instanceof ReconcileInventoryConsumptionInput && null !== $id) { + $this->access->requirePermission($org, 'organization.interventions.execute'); + $command = new ApplyInventoryStockCommand($org, $actor, 'reconcile', originalId:$id); + } else { + throw new BadRequestHttpException('Invalid inventory input.'); + } + /** @var ApplyInventoryStockResult $result */ + $result = $this->commands->dispatch($command); + + return $this->outputs->output($result->declaration ?? $result->movement ?? throw new BadRequestHttpException('Inventory result unavailable.'), $this->access->financial($org), $result->replayed); + } + + private function date(string $value): DateTimeImmutable + { + try { + $date = new DateTimeImmutable($value); + } catch (DateMalformedStringException $error) { + throw new InvalidArgumentException('Invalid physical occurrence date.', previous:$error); + } + $errors = DateTimeImmutable::getLastErrors(); + if (false !== $errors && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) { + throw new InvalidArgumentException('Invalid physical occurrence date.'); + } + + return $date; + } +} diff --git a/src/Inventory/Presentation/Api/Provider/InventoryProvider.php b/src/Inventory/Presentation/Api/Provider/InventoryProvider.php new file mode 100644 index 000000000..1028ce426 --- /dev/null +++ b/src/Inventory/Presentation/Api/Provider/InventoryProvider.php @@ -0,0 +1,74 @@ + + */ +final readonly class InventoryProvider implements ProviderInterface +{ + public function __construct(private QueryBusPort $queries, private InventoryAccess $access, private InventoryOutputFactory $outputs, private RequestStack $requests) + { + } + + /** + * @param array $uriVariables the route scope + * @param array $context the provider context + * + * @return InventoryPartOutput|InventoryWarehouseOutput|InventoryBalanceOutput|InventoryMovementOutput|InventoryConsumptionOutput|TraversablePaginator inventory projection + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object + { + $org = $this->access->organization($uriVariables, 'organization.inventory.read'); + $name = $operation->getName() ?? ''; + $type = explode('_', $name)[1] ?? ''; + $request = $this->requests->getCurrentRequest(); + $filters = []; + $allowed = match($type) { + 'parts','warehouses' => ['search', 'archived'],'balances' => ['warehouseId', 'partId'],'movements' => ['warehouseId', 'partId', 'interventionId'],'consumptions' => ['warehouseId', 'partId', 'interventionId', 'status'],default => throw new BadRequestHttpException('Invalid inventory collection.') + }; + foreach ($allowed as $key) { + $value = $request?->query->get($key); + if (null !== $value) { + if ('' === $value) { + throw new BadRequestHttpException('Invalid inventory filter.'); + }$filters[$key] = $value; + } + } + $page = max(1, $request?->query->getInt('page', 1) ?? 1); + $size = $request?->query->getInt('itemsPerPage', 30) ?? 30; + $id = $uriVariables['id'] ?? null; + if (null !== $id && !is_string($id)) { + throw new BadRequestHttpException('Invalid inventory identifier.'); + } + /** @var ListInventoryResult $result */ + $result = $this->queries->ask(new ListInventoryQuery($org, $type, $filters, $page, $size, $id)); + $finance = $this->access->financial($org); + $items = array_map(fn ($item) => $this->outputs->output($item, $finance), $result->items); + if (null !== $id) { + return $items[0]; + } + + return new TraversablePaginator(new ArrayIterator($items), (float) $page, (float) $size, (float) $result->totalItems); + } +} diff --git a/src/Inventory/Presentation/Api/Resource/InventoryBalanceResource.php b/src/Inventory/Presentation/Api/Resource/InventoryBalanceResource.php new file mode 100644 index 000000000..9a0b2117a --- /dev/null +++ b/src/Inventory/Presentation/Api/Resource/InventoryBalanceResource.php @@ -0,0 +1,19 @@ + ['inventory:read'], 'skip_null_values' => true], parameters:['warehouseId' => new QueryParameter(schema:['type' => 'string']), 'partId' => new QueryParameter(schema:['type' => 'string'])], openapi:new Operation(tags:['Inventory'], summary:'Read nonnegative stock balances')), +])] +final class InventoryBalanceResource +{ +} diff --git a/src/Inventory/Presentation/Api/Resource/InventoryConsumptionResource.php b/src/Inventory/Presentation/Api/Resource/InventoryConsumptionResource.php new file mode 100644 index 000000000..747da3595 --- /dev/null +++ b/src/Inventory/Presentation/Api/Resource/InventoryConsumptionResource.php @@ -0,0 +1,24 @@ + ['inventory:read'], 'skip_null_values' => true], parameters:['warehouseId' => new QueryParameter(schema:['type' => 'string']), 'partId' => new QueryParameter(schema:['type' => 'string']), 'interventionId' => new QueryParameter(schema:['type' => 'string']), 'status' => new QueryParameter(schema:['type' => 'string'])], openapi:new Operation(tags:['Inventory'], summary:'Declare complete physical consumptions, retaining shortages')), + new Get(name:InventoryOperations::CONSUMPTIONS_GET, uriTemplate:'/{organizationId}/inventory-consumptions/{id}', input:false, output:InventoryConsumptionOutput::class, provider:InventoryProvider::class, security:"is_granted('ROLE_USER')", normalizationContext:['groups' => ['inventory:read'], 'skip_null_values' => true]), + new Post(name:InventoryOperations::CONSUMPTIONS_CREATE, uriTemplate:'/{organizationId}/inventory-consumptions', read:false, input:DeclareInventoryConsumptionInput::class, output:InventoryConsumptionOutput::class, processor:InventoryProcessor::class, security:"is_granted('ROLE_USER')", normalizationContext:['groups' => ['inventory:read'], 'skip_null_values' => true], denormalizationContext:['groups' => ['inventory:write'], 'allow_extra_attributes' => false]), + new Post(name:InventoryOperations::CONSUMPTIONS_RECONCILE, uriTemplate:'/{organizationId}/inventory-consumptions/{id}/reconcile', read:false, status:200, input:ReconcileInventoryConsumptionInput::class, output:InventoryConsumptionOutput::class, processor:InventoryProcessor::class, security:"is_granted('ROLE_USER')", normalizationContext:['groups' => ['inventory:read'], 'skip_null_values' => true], denormalizationContext:['groups' => ['inventory:write'], 'allow_extra_attributes' => false]), +])] +final class InventoryConsumptionResource +{ +} diff --git a/src/Inventory/Presentation/Api/Resource/InventoryMovementResource.php b/src/Inventory/Presentation/Api/Resource/InventoryMovementResource.php new file mode 100644 index 000000000..c6b627743 --- /dev/null +++ b/src/Inventory/Presentation/Api/Resource/InventoryMovementResource.php @@ -0,0 +1,20 @@ + ['inventory:read'], 'skip_null_values' => true], parameters:['warehouseId' => new QueryParameter(schema:['type' => 'string']), 'partId' => new QueryParameter(schema:['type' => 'string']), 'interventionId' => new QueryParameter(schema:['type' => 'string'])], openapi:new Operation(tags:['Inventory'], summary:'Read immutable motivated stock movements')), + new Get(name:InventoryOperations::MOVEMENTS_GET, uriTemplate:'/{organizationId}/inventory-movements/{id}', input:false, output:InventoryMovementOutput::class, provider:InventoryProvider::class, security:"is_granted('ROLE_USER')", normalizationContext:['groups' => ['inventory:read'], 'skip_null_values' => true]), +])] +final class InventoryMovementResource +{ +} diff --git a/src/Inventory/Presentation/Api/Resource/InventoryPartResource.php b/src/Inventory/Presentation/Api/Resource/InventoryPartResource.php new file mode 100644 index 000000000..c419fc642 --- /dev/null +++ b/src/Inventory/Presentation/Api/Resource/InventoryPartResource.php @@ -0,0 +1,25 @@ + ['inventory:read'], 'skip_null_values' => true], parameters:['search' => new QueryParameter(schema:['type' => 'string']), 'archived' => new QueryParameter(schema:['type' => 'string', 'enum' => ['true', 'false']])], openapi:new Operation(tags:['Inventory'], summary:'Manage quantitative part and consumable references')), + new Get(name:InventoryOperations::PARTS_GET, uriTemplate:'/{organizationId}/inventory-parts/{id}', input:false, output:InventoryPartOutput::class, provider:InventoryProvider::class, security:"is_granted('ROLE_USER')", normalizationContext:['groups' => ['inventory:read'], 'skip_null_values' => true]), + new Post(name:InventoryOperations::PARTS_CREATE, uriTemplate:'/{organizationId}/inventory-parts', read:false, input:CreateInventoryPartInput::class, output:InventoryPartOutput::class, processor:InventoryProcessor::class, security:"is_granted('ROLE_USER')", normalizationContext:['groups' => ['inventory:read'], 'skip_null_values' => true], denormalizationContext:['groups' => ['inventory:write'], 'allow_extra_attributes' => false]), + new Patch(name:InventoryOperations::PARTS_PATCH, uriTemplate:'/{organizationId}/inventory-parts/{id}', read:false, input:PatchInventoryPartInput::class, output:InventoryPartOutput::class, processor:InventoryProcessor::class, security:"is_granted('ROLE_USER')", normalizationContext:['groups' => ['inventory:read'], 'skip_null_values' => true], denormalizationContext:['groups' => ['inventory:write'], 'allow_extra_attributes' => false]), +])] +final class InventoryPartResource +{ +} diff --git a/src/Inventory/Presentation/Api/Resource/InventoryWarehouseResource.php b/src/Inventory/Presentation/Api/Resource/InventoryWarehouseResource.php new file mode 100644 index 000000000..275d847bb --- /dev/null +++ b/src/Inventory/Presentation/Api/Resource/InventoryWarehouseResource.php @@ -0,0 +1,25 @@ + ['inventory:read'], 'skip_null_values' => true], parameters:['search' => new QueryParameter(schema:['type' => 'string']), 'archived' => new QueryParameter(schema:['type' => 'string', 'enum' => ['true', 'false']])], openapi:new Operation(tags:['Inventory'], summary:'Manage warehouses')), + new Get(name:InventoryOperations::WAREHOUSES_GET, uriTemplate:'/{organizationId}/inventory-warehouses/{id}', input:false, output:InventoryWarehouseOutput::class, provider:InventoryProvider::class, security:"is_granted('ROLE_USER')", normalizationContext:['groups' => ['inventory:read'], 'skip_null_values' => true]), + new Post(name:InventoryOperations::WAREHOUSES_CREATE, uriTemplate:'/{organizationId}/inventory-warehouses', read:false, input:CreateInventoryWarehouseInput::class, output:InventoryWarehouseOutput::class, processor:InventoryProcessor::class, security:"is_granted('ROLE_USER')", normalizationContext:['groups' => ['inventory:read'], 'skip_null_values' => true], denormalizationContext:['groups' => ['inventory:write'], 'allow_extra_attributes' => false]), + new Patch(name:InventoryOperations::WAREHOUSES_PATCH, uriTemplate:'/{organizationId}/inventory-warehouses/{id}', read:false, input:PatchInventoryWarehouseInput::class, output:InventoryWarehouseOutput::class, processor:InventoryProcessor::class, security:"is_granted('ROLE_USER')", normalizationContext:['groups' => ['inventory:read'], 'skip_null_values' => true], denormalizationContext:['groups' => ['inventory:write'], 'allow_extra_attributes' => false]), +])] +final class InventoryWarehouseResource +{ +} diff --git a/src/Inventory/Presentation/Api/Resource/InventorycorrectionsResource.php b/src/Inventory/Presentation/Api/Resource/InventorycorrectionsResource.php new file mode 100644 index 000000000..9ee4b9184 --- /dev/null +++ b/src/Inventory/Presentation/Api/Resource/InventorycorrectionsResource.php @@ -0,0 +1,17 @@ + ['inventory:read'], 'skip_null_values' => true], denormalizationContext:['groups' => ['inventory:write'], 'allow_extra_attributes' => false])])] +final class InventorycorrectionsResource +{ +} diff --git a/src/Inventory/Presentation/Api/Resource/InventoryreturnsResource.php b/src/Inventory/Presentation/Api/Resource/InventoryreturnsResource.php new file mode 100644 index 000000000..cbb477dfd --- /dev/null +++ b/src/Inventory/Presentation/Api/Resource/InventoryreturnsResource.php @@ -0,0 +1,17 @@ + ['inventory:read'], 'skip_null_values' => true], denormalizationContext:['groups' => ['inventory:write'], 'allow_extra_attributes' => false])])] +final class InventoryreturnsResource +{ +} diff --git a/src/Inventory/Presentation/Api/Service/InventoryAccess.php b/src/Inventory/Presentation/Api/Service/InventoryAccess.php new file mode 100644 index 000000000..f1fb91f51 --- /dev/null +++ b/src/Inventory/Presentation/Api/Service/InventoryAccess.php @@ -0,0 +1,61 @@ +security->getUser(); + if (!$user instanceof AuthenticatedUser) { + throw new AccessDeniedHttpException('Authentication required.'); + } + + return $user->getId(); + } + + /** + * @param array $uriVariables + */ + public function organization(array $uriVariables, string $permission): string + { + $org = $uriVariables['organizationId'] ?? null; + if (!is_string($org) || 1 !== preg_match('/^[0-9a-f-]{36}$/iD', $org)) { + throw new BadRequestHttpException('A valid organizationId is required.'); + } + $decision = $this->authorization->resolveAccess($this->actor(), $org, $permission); + if ($decision->isOutsideScope()) { + throw new NotFoundHttpException('Organization not found.'); + } + if (!$decision->isGranted()) { + throw new AccessDeniedHttpException('Missing ' . $permission . ' permission.'); + } + + return $org; + } + + public function financial(string $org): bool + { + return $this->authorization->resolveAccess($this->actor(), $org, 'organization.maintenance_cost.read')->isGranted(); + } + + public function requirePermission(string $org, string $permission): void + { + $this->organization(['organizationId' => $org], $permission); + } +} diff --git a/src/Inventory/Presentation/Api/Service/InventoryOutputFactory.php b/src/Inventory/Presentation/Api/Service/InventoryOutputFactory.php new file mode 100644 index 000000000..dfad456ec --- /dev/null +++ b/src/Inventory/Presentation/Api/Service/InventoryOutputFactory.php @@ -0,0 +1,83 @@ +kind) { + $o = new InventoryWarehouseOutput(); + $o->name = $item->label; + } else { + $o = new InventoryPartOutput(); + $o->label = $item->label; + $o->unit = $item->unit ?? 'piece'; + $o->kind = $item->kind; + } + $o->id = $item->id; + $o->code = $item->code; + $o->archived = $item->archived; + + return $o; + } + if ($item instanceof StockBalance) { + $o = new InventoryBalanceOutput(); + $o->id = $item->id; + $o->partId = $item->partId; + $o->warehouseId = $item->warehouseId; + $o->quantity = $item->quantity; + if ($financial) { + $o->valuation = $this->valuation(null === $item->totalValue || '0.000000' === $item->quantity ? null : DecimalAmount::fromString($item->totalValue)->divide(DecimalAmount::fromString($item->quantity))->toString(), $item->totalValue, $item->currency); + } + + return $o; + } + if ($item instanceof ConsumptionDeclaration) { + $o = new InventoryConsumptionOutput(); + $o->status = $item->status; + $o->movementId = $item->movementId; + } else { + $o = new InventoryMovementOutput(); + $o->kind = $item->kind; + $o->correctionOf = $item->correctionOf; + $o->sourceReceiptId = $item->sourceReceiptId; + if ($financial) { + $o->valuation = $this->valuation($item->unitCost, $item->totalValue, $item->currency); + } + } + $o->id = $item->id; + $o->partId = $item->partId; + $o->warehouseId = $item->warehouseId; + $o->quantity = $item->quantity; + $o->reason = $item->reason; + $o->actorId = $item->actorId; + $o->occurredAt = $item->occurredAt->format('c'); + $o->interventionId = $item->interventionId; + $o->workItemId = $item->workItemId; + $o->equipmentId = $item->equipmentId; + $o->late = $item->late; + $o->replayed = $replayed; + + return $o; + } + + private function valuation(?string $unit, ?string $total, string $currency): InventoryValuationOutput + { + $v = new InventoryValuationOutput(); + $v->unitCost = $unit; + $v->totalValue = $total; + $v->currency = $currency; + $v->incomplete = null === $total; + + return $v; + } +} diff --git a/src/Maintenance/Application/Contract/Plan/MaintenanceEquipmentOperationsDue.php b/src/Maintenance/Application/Contract/Plan/MaintenanceEquipmentOperationsDue.php new file mode 100644 index 000000000..60641e240 --- /dev/null +++ b/src/Maintenance/Application/Contract/Plan/MaintenanceEquipmentOperationsDue.php @@ -0,0 +1,20 @@ + $equipmentIds + * + * @return array + */ + public function forEquipment(string $organizationId, array $equipmentIds): array; +} diff --git a/src/Maintenance/Application/Port/Inbound/MaintenancePlanAuthorityPort.php b/src/Maintenance/Application/Port/Inbound/MaintenancePlanAuthorityPort.php new file mode 100644 index 000000000..8c4d61c95 --- /dev/null +++ b/src/Maintenance/Application/Port/Inbound/MaintenancePlanAuthorityPort.php @@ -0,0 +1,20 @@ + + */ + public function list(string $organizationId, int $limit, int $offset, ?string $equipmentId = null, ?string $operationKind = null, ?string $search = null, bool $includeArchived = false): array; + + public function count(string $organizationId, ?string $equipmentId = null, ?string $operationKind = null, ?string $search = null): int; + + public function save(MaintenancePlanState $plan): void; + + public function openOccurrence(string $organizationId, string $planId): ?MaintenanceOccurrenceState; + + public function findOccurrence(string $organizationId, string $id): ?MaintenanceOccurrenceState; + + public function saveOccurrence(MaintenanceOccurrenceState $occurrence): void; + + public function hasReceipt(string $resultId, string $occurrenceId): bool; + + public function saveReceipt(MaintenanceOperationResult $result): void; +} diff --git a/src/Maintenance/Application/Service/MaintenancePlanAuthorityService.php b/src/Maintenance/Application/Service/MaintenancePlanAuthorityService.php new file mode 100644 index 000000000..4fe81128e --- /dev/null +++ b/src/Maintenance/Application/Service/MaintenancePlanAuthorityService.php @@ -0,0 +1,54 @@ +plans->engineMode($organizationId); + } + + public function refreshControlProjection(string $organizationId, string $equipmentId): void + { + $this->commands->dispatch(new ManageMaintenancePlanCommand('refresh_projection', $organizationId, null, equipmentId: $equipmentId)); + } + + public function generateCampaign(string $organizationId, string $actorUserId, string $name, ?string $facilityId, ?string $equipmentType, DateTimeImmutable $dueBefore): GenerateInspectionCampaignResult + { + /** @var ManageMaintenancePlanResult $result */ + $result = $this->commands->dispatch(new ManageMaintenancePlanCommand('campaign', $organizationId, $actorUserId, name: $name, facilityId: $facilityId, equipmentType: $equipmentType, dueBefore: $dueBefore)); + + return new GenerateInspectionCampaignResult($result->interventionId ?? '', $result->number ?? 0, $result->workItemsCount); + } + + public function setLegacyOverride(string $organizationId, string $scheduleId, ?string $interval, string $actorUserId): void + { + $this->commands->dispatch(new ManageMaintenancePlanCommand('set_legacy_override', $organizationId, $actorUserId, planId: $scheduleId, interval: $interval)); + } + + public function validateResult(MaintenanceOperationResult $result): void + { + $this->commands->dispatch(new ManageMaintenancePlanCommand('validate_result', $result->organizationId, null, operationResult: $result)); + } + + public function acknowledgeResult(MaintenanceOperationResult $result): void + { + $this->commands->dispatch(new ManageMaintenancePlanCommand('acknowledge_result', $result->organizationId, null, operationResult: $result)); + } +} diff --git a/src/Maintenance/Application/Service/MaintenanceScheduleService.php b/src/Maintenance/Application/Service/MaintenanceScheduleService.php index 22758902e..46ef389ad 100644 --- a/src/Maintenance/Application/Service/MaintenanceScheduleService.php +++ b/src/Maintenance/Application/Service/MaintenanceScheduleService.php @@ -52,6 +52,7 @@ public function __construct( private ClockPort $clock, private MaintenanceScheduleLockPort $locks, private \Maintenance\Application\Port\Outbound\Schedule\MaintenanceInspectionHistoryPort $inspectionHistory, + private ?\Maintenance\Application\Port\Inbound\MaintenancePlanAuthorityPort $planAuthority = null, ) { } @@ -126,6 +127,11 @@ private function refreshLockedPage(array $page): void } foreach ($page as $equipment) { if (!isset($seen[$equipment->equipmentId])) { + if ($this->planAuthority?->usesPlans($equipment->organizationId)) { + $this->planAuthority->refreshControlProjection($equipment->organizationId, $equipment->equipmentId); + + continue; + } $this->schedules->removeByOrganizationAndEquipment($equipment->organizationId, $equipment->equipmentId); } } @@ -148,6 +154,13 @@ private function refreshLockedPage(array $page): void */ private function refreshOrganizationEquipment(string $organizationId, array $equipmentGroup, ?MaintenanceCompliancePolicy $compliance, DateTimeImmutable $now): array { + if ($this->planAuthority?->usesPlans($organizationId)) { + foreach ($equipmentGroup as $equipment) { + $this->planAuthority->refreshControlProjection($organizationId, $equipment->equipmentId); + } + + return []; + } $equipmentIds = array_map(static fn (TrackableEquipment $equipment): string => $equipment->equipmentId, $equipmentGroup); $existing = $this->schedules->findForEquipment($organizationId, $equipmentIds); $history = $this->inspectionHistory->latestClosedAtForEquipment($organizationId, $equipmentIds); @@ -202,6 +215,11 @@ private function refreshOrganizationEquipment(string $organizationId, array $equ */ private function recompute(string $organizationId, string $equipmentId, ?DateTimeImmutable $closedAt = null): void { + if ($this->planAuthority?->usesPlans($organizationId)) { + $this->planAuthority->refreshControlProjection($organizationId, $equipmentId); + + return; + } $equipment = $this->directory->findEquipment($equipmentId); if (null === $equipment || $equipment->organizationId !== $organizationId || 'decommissioned' === $equipment->status) { $this->schedules->removeByOrganizationAndEquipment($organizationId, $equipmentId); diff --git a/src/Maintenance/Application/UseCase/Command/Campaign/GenerateInspectionCampaign/GenerateInspectionCampaignHandler.php b/src/Maintenance/Application/UseCase/Command/Campaign/GenerateInspectionCampaign/GenerateInspectionCampaignHandler.php index e79db6755..4156d4c9d 100644 --- a/src/Maintenance/Application/UseCase/Command/Campaign/GenerateInspectionCampaign/GenerateInspectionCampaignHandler.php +++ b/src/Maintenance/Application/UseCase/Command/Campaign/GenerateInspectionCampaign/GenerateInspectionCampaignHandler.php @@ -59,6 +59,8 @@ public function __construct( private OrganizationAuthorizationPort $authorization, private EventDispatcherPort $eventDispatcher, private int $maxCampaignWorkItems = 25, + private ?\Maintenance\Application\Port\Outbound\Plan\MaintenancePlanStorePort $plans = null, + private ?\Maintenance\Application\Port\Inbound\MaintenancePlanAuthorityPort $planAuthority = null, ) { } @@ -72,6 +74,20 @@ public function __construct( * @return GenerateInspectionCampaignResult the command result */ public function __invoke(GenerateInspectionCampaignCommand $command): GenerateInspectionCampaignResult + { + if (null === $this->plans) { + return $this->generateLegacy($command); + } + + return $this->plans->synchronized($command->organizationId, fn (): GenerateInspectionCampaignResult => $this->planAuthority?->usesPlans($command->organizationId) + ? $this->planAuthority->generateCampaign($command->organizationId, $command->actorUserId, $command->name, $command->facilityId, $command->equipmentType, $command->dueBefore) + : $this->generateLegacy($command)); + } + + /** + * Generates historical campaigns while the legacy authority still owns the organization. + */ + private function generateLegacy(GenerateInspectionCampaignCommand $command): GenerateInspectionCampaignResult { if (!$this->authorization->isMemberOf($command->actorUserId, $command->organizationId)) { throw MaintenanceNotFoundException::forOrganizationScope($command->organizationId); diff --git a/src/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanCommand.php b/src/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanCommand.php new file mode 100644 index 000000000..e8c4f3920 --- /dev/null +++ b/src/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanCommand.php @@ -0,0 +1,35 @@ +action, ['validate_result', 'acknowledge_result', 'refresh_projection'], true); + if (!$system) { + $this->authorize($command->organizationId, $command->actorUserId, 'organization.maintenance.manage'); + } + if (in_array($command->action, ['generate', 'campaign', 'activate'], true)) { + $this->authorize($command->organizationId, $command->actorUserId, 'organization.interventions.plan'); + } + + return $this->plans->synchronized($command->organizationId, fn (): ManageMaintenancePlanResult => match ($command->action) { + 'create' => $this->create($command), + 'update', 'archive' => $this->update($command), + 'set_legacy_override' => $this->legacyOverride($command), + 'prepare_legacy' => $this->prepareLegacy($command->organizationId), + 'activate' => $this->activate($command->organizationId), + 'generate' => $this->generate($command), + 'campaign' => $this->campaign($command), + 'validate_result', 'acknowledge_result' => $this->result($command), + 'refresh_projection' => $this->projection($command->organizationId, $command->equipmentId ?? ''), + default => throw new MaintenanceValidationException('Unknown maintenance plan action.'), + }); + } + + private function authorize(string $organizationId, ?string $actorUserId, string $permission): void + { + if (null === $actorUserId) { + throw new MaintenanceAccessDeniedException('An authenticated actor is required.'); + } + $decision = $this->authorization->resolveAccess($actorUserId, $organizationId, $permission); + if ($decision->isOutsideScope()) { + throw MaintenanceNotFoundException::forOrganizationScope($organizationId); + } + if (!$decision->isGranted()) { + throw new MaintenanceAccessDeniedException('Missing permission: ' . $permission); + } + } + + private function create(ManageMaintenancePlanCommand $command): ManageMaintenancePlanResult + { + $equipment = $this->equipment->findEquipment($command->equipmentId ?? ''); + if (null === $equipment || $equipment->organizationId !== $command->organizationId) { + throw MaintenanceNotFoundException::withId($command->equipmentId ?? ''); + } + if ((null === $command->anchorAt && null === $command->anchorOn) || null === $command->interval || null === $command->name || null === $command->operationKind) { + throw new MaintenanceValidationException('Equipment, name, kind, interval and calendar anchor are required.'); + } + $now = $this->clock->now(); + $regionalContext = null === $this->regional ? null : $this->regional->context($command->organizationId); + $timezone = null === $regionalContext ? 'UTC' : $regionalContext->timezone; + $zone = new DateTimeZone($timezone); + $anchor = $this->calendarDate($command->anchorAt, $command->anchorOn, $zone) ?? throw new MaintenanceValidationException('A first calendar date is required.'); + $next = $this->calendarDate($command->nextDueAt, $command->nextDueOn, $zone); + $model = MaintenancePlan::create($this->ids->generate(), $command->organizationId, $equipment->equipmentId, $command->name, MaintenanceOperationKind::from($command->operationKind), PlanCadence::fromString($command->interval), $anchor, $now); + $plan = new MaintenancePlanState($model->id, $model->organizationId, $model->equipmentId, $equipment->facilityId, $equipment->equipmentType, $model->name, $model->kind->value, $model->cadence->value, 'fixed', $model->anchorAt, $next ?? $model->nextDueAt(), $command->active ?? false, null, null, null, $now, $now, $timezone); + $this->model($plan); + $this->plans->save($plan); + $this->projection($plan->organizationId, $plan->equipmentId); + + return $this->details($plan); + } + + private function update(ManageMaintenancePlanCommand $command): ManageMaintenancePlanResult + { + $plan = $this->requirePlan($command->organizationId, $command->planId); + $open = $this->plans->openOccurrence($plan->organizationId, $plan->id); + if ('archive' === $command->action) { + if (null !== $open && null !== $open->interventionId && !in_array($this->work->status($plan->organizationId, $open->interventionId), ['abandoned', 'published'], true)) { + throw new MaintenanceValidationException('Resolve or abandon the open work before archiving its plan.'); + } + $plan->active = false; + $plan->archivedAt = $this->clock->now(); + } else { + if (null !== $plan->archivedAt) { + throw new MaintenanceValidationException('An archived plan cannot be edited.'); + } + if (null !== $open && (null !== $command->interval || null !== $command->anchorAt || null !== $command->nextDueAt || null !== $command->anchorOn || null !== $command->nextDueOn)) { + throw new MaintenanceValidationException('An open occurrence retains its cadence and original due date.'); + } + $plan->name = $command->name ?? $plan->name; + $plan->interval = $command->interval ?? $plan->interval; + $plan->anchorAt = $this->calendarDate($command->anchorAt, $command->anchorOn, new DateTimeZone($plan->calendarTimezone)) ?? $plan->anchorAt; + $plan->nextDueAt = $this->calendarDate($command->nextDueAt, $command->nextDueOn, new DateTimeZone($plan->calendarTimezone)) ?? $plan->nextDueAt; + $plan->active = $command->active ?? $plan->active; + $this->model($plan); + if (null !== $plan->legacyScheduleId && null !== $command->interval) { + $source = $this->schedules->findById($plan->legacyScheduleId) ?? throw MaintenanceNotFoundException::withId($plan->legacyScheduleId); + if (null !== $plan->lastCompletedAt && null === $command->nextDueAt && null === $command->nextDueOn) { + $plan->nextDueAt = PlanCadence::legacyFromString($plan->interval)->addTo($plan->lastCompletedAt); + } + $this->schedules->save(new MaintenanceScheduleSnapshot($source->id, $source->organizationId, $source->equipmentId, $source->facilityId, $source->equipmentType, $plan->interval, $source->lastInspectionClosedAt, $source->nextDueAt, $source->dueStatus, $source->lastRemindedAt, $source->remindedFor, $this->clock->now())); + } + } + $plan->updatedAt = $this->clock->now(); + $this->plans->save($plan); + $this->projection($plan->organizationId, $plan->equipmentId); + + return $this->details($plan); + } + + private function prepareLegacy(string $organizationId): ManageMaintenancePlanResult + { + if ('plans' === $this->plans->engineMode($organizationId)) { + return new ManageMaintenancePlanResult(mode: 'plans', preparedCount: $this->plans->count($organizationId)); + } + $policy = $this->compliance->compliancePolicy($organizationId); + $page = 1; + $prepared = 0; + do { + $schedules = $this->schedules->list($organizationId, null, null, null, null, $page++, 200); + foreach ($schedules->items as $schedule) { + $interval = $schedule->intervalOverride ?? $policy->periodicityFor($schedule->equipmentType); + if (null === $interval) { + continue; + } + PlanCadence::legacyFromString($interval); + $now = $this->clock->now(); + $existing = $this->plans->findByLegacySchedule($organizationId, $schedule->id); + $plan = new MaintenancePlanState(null !== $existing ? $existing->id : $this->ids->generate(), $organizationId, $schedule->equipmentId, $schedule->facilityId, $schedule->equipmentType, null !== $existing ? $existing->name : 'Periodic control', 'control', $interval, 'legacy', $schedule->nextDueAt ?? $existing?->anchorAt, $schedule->nextDueAt ?? $existing?->nextDueAt, false, $schedule->id, $schedule->lastInspectionClosedAt, $existing?->archivedAt, null !== $existing ? $existing->createdAt : $now, $now); + $this->plans->save($plan); + ++$prepared; + } + } while (200 === count($schedules->items)); + $offset = 0; + do { + $plans = $this->plans->list($organizationId, 200, $offset, includeArchived: true); + foreach ($plans as $plan) { + if (null === $plan->legacyScheduleId) { + continue; + } + $schedule = $this->schedules->findById($plan->legacyScheduleId); + if (null === $schedule || null === ($schedule->intervalOverride ?? $policy->periodicityFor($schedule->equipmentType))) { + $plan->active = false; + $plan->archivedAt ??= $this->clock->now(); + $plan->updatedAt = $this->clock->now(); + $this->plans->save($plan); + } + } + $offset += 200; + } while (200 === count($plans)); + + return new ManageMaintenancePlanResult(mode: 'legacy', preparedCount: $prepared); + } + + private function legacyOverride(ManageMaintenancePlanCommand $command): ManageMaintenancePlanResult + { + $plan = $this->plans->findByLegacySchedule($command->organizationId, $command->planId ?? '') ?? throw new MaintenanceValidationException('This control projection has no historical plan; edit its operation instead.'); + if (null !== $this->plans->openOccurrence($plan->organizationId, $plan->id)) { + throw new MaintenanceValidationException('An open occurrence retains its cadence; resolve it before changing the historical override.'); + } + $schedule = $this->schedules->findById($plan->legacyScheduleId ?? '') ?? throw MaintenanceNotFoundException::withId($command->planId ?? ''); + $effective = $command->interval ?? $this->compliance->compliancePolicy($plan->organizationId)->periodicityFor($plan->equipmentType); + if (null !== $effective) { + $plan->interval = PlanCadence::legacyFromString($effective)->value; + $plan->nextDueAt = null === $plan->lastCompletedAt ? null : PlanCadence::legacyFromString($effective)->addTo($plan->lastCompletedAt); + } + $plan->active = null !== $effective; + $plan->updatedAt = $this->clock->now(); + $this->plans->save($plan); + $this->schedules->save(new MaintenanceScheduleSnapshot($schedule->id, $schedule->organizationId, $schedule->equipmentId, $schedule->facilityId, $schedule->equipmentType, $command->interval, $schedule->lastInspectionClosedAt, $schedule->nextDueAt, $schedule->dueStatus, $schedule->lastRemindedAt, $schedule->remindedFor, $this->clock->now())); + $this->projection($plan->organizationId, $plan->equipmentId); + $this->events->dispatch(new \Maintenance\Domain\Event\Schedule\MaintenanceScheduleOverriddenEvent($plan->organizationId, $schedule->id, $plan->equipmentId, $command->interval, $command->actorUserId ?? '')); + + return $this->details($plan); + } + + private function activate(string $organizationId): ManageMaintenancePlanResult + { + if ('plans' === $this->plans->engineMode($organizationId)) { + return new ManageMaintenancePlanResult(mode: 'plans', preparedCount: $this->plans->count($organizationId)); + } + $prepared = $this->prepareLegacy($organizationId)->preparedCount; + $offset = 0; + do { + $page = $this->plans->list($organizationId, 200, $offset); + $equipmentIds = array_map(static fn (MaintenancePlanState $plan): string => $plan->equipmentId, $page); + $candidates = $this->work->findOpenLegacyInspectionWork($organizationId, $equipmentIds); + foreach ($page as $plan) { + if (null !== $plan->archivedAt) { + continue; + } + if (null === $plan->legacyScheduleId) { + if ('control' === $plan->operationKind && [] !== ($candidates[$plan->equipmentId] ?? [])) { + $schedule = $this->schedules->findByOrganizationAndEquipment($organizationId, $plan->equipmentId); + if (null === $schedule || null === $this->plans->findByLegacySchedule($organizationId, $schedule->id)) { + throw new MaintenanceValidationException('Existing control work cannot be mapped safely to the new operation; resolve it before activation.'); + } + } + + continue; + } + $matches = $candidates[$plan->equipmentId] ?? []; + if (count($matches) > 1) { + throw new MaintenanceValidationException('Ambiguous legacy work for equipment ' . $plan->equipmentId . '; resolve duplicate open controls before activation.'); + } + if (1 === count($matches)) { + $match = $matches[0]; + if ('submitted' === $this->work->status($organizationId, $match->interventionId)) { + throw new MaintenanceValidationException('Complete the submitted legacy intervention before activating plans.'); + } + $occurrence = new MaintenanceOccurrenceState($this->ids->generate(), $plan->id, $organizationId, $plan->nextDueAt ?? $this->clock->now(), 'open', 1, $match->interventionId, null, null, $this->clock->now(), $this->clock->now(), $match->number); + $this->plans->saveOccurrence($occurrence); + $this->work->attachOccurrence($organizationId, $match->workItemId, $plan->id, $occurrence->id, 'control'); + } + $plan->active = true; + $plan->updatedAt = $this->clock->now(); + $this->plans->save($plan); + } + $offset += 200; + } while (200 === count($page)); + $this->plans->activateEngine($organizationId, $this->clock->now()); + $offset = 0; + do { + $page = $this->plans->list($organizationId, 200, $offset); + foreach ($page as $plan) { + $this->projection($organizationId, $plan->equipmentId); + } + $offset += 200; + } while (200 === count($page)); + + return new ManageMaintenancePlanResult(mode: 'plans', preparedCount: $prepared); + } + + private function generate(ManageMaintenancePlanCommand $command): ManageMaintenancePlanResult + { + $plan = $this->requirePlan($command->organizationId, $command->planId); + if ('plans' !== $this->plans->engineMode($command->organizationId)) { + throw new MaintenanceValidationException('Activate the prepared plans engine before generating work.'); + } + $this->assertTrackable($plan); + $open = $this->plans->openOccurrence($plan->organizationId, $plan->id); + if (null !== $open && null !== $open->interventionId && !$command->retry) { + return new ManageMaintenancePlanResult(occurrenceId: $open->id, interventionId: $open->interventionId, number: $open->number, workItemsCount: 1, replayed: true, mode: 'plans'); + } + if ($command->retry && (null === $open || !$this->canRetry($open))) { + throw new MaintenanceValidationException('Retry requires an abandoned or unsuccessful current attempt.'); + } + + return $this->generatePlans([$plan], $command->name ?? $plan->name, $command->actorUserId, $command->retry); + } + + private function campaign(ManageMaintenancePlanCommand $command): ManageMaintenancePlanResult + { + if ('plans' !== $this->plans->engineMode($command->organizationId)) { + throw new MaintenanceValidationException('The organization still uses historical schedules.'); + } + $selected = []; + $offset = 0; + $before = $command->dueBefore ?? $this->clock->now(); + do { + $page = $this->plans->list($command->organizationId, 200, $offset, operationKind: 'control'); + foreach ($page as $plan) { + if (!$this->trackable($plan, false) || (null !== $command->facilityId && $plan->facilityId !== $command->facilityId) || (null !== $command->equipmentType && $plan->equipmentType !== $command->equipmentType) || (null !== $plan->nextDueAt && $plan->nextDueAt > $before) || null !== $this->plans->openOccurrence($plan->organizationId, $plan->id)) { + continue; + } + $selected[] = $plan; + if (count($selected) > $this->maxCampaignWorkItems) { + throw new MaintenanceValidationException('Campaign exceeds the synchronous work-item limit; narrow the filters.'); + } + } + $offset += 200; + } while (200 === count($page)); + if ([] === $selected) { + throw new MaintenanceValidationException('No unassigned control occurrences match the campaign.'); + } + foreach ($selected as $plan) { + $this->plans->save($plan); + } + + return $this->generatePlans($selected, $command->name ?? 'Control campaign', $command->actorUserId, false); + } + + /** + * @param list $plans + */ + private function generatePlans(array $plans, string $name, ?string $actorUserId, bool $retry): ManageMaintenancePlanResult + { + $occurrences = []; + $items = []; + foreach ($plans as $plan) { + $open = $this->plans->openOccurrence($plan->organizationId, $plan->id); + $occurrence = $open ?? new MaintenanceOccurrenceState($this->ids->generate(), $plan->id, $plan->organizationId, $plan->nextDueAt ?? $this->clock->now(), 'open', 0, null, null, null, $this->clock->now(), $this->clock->now()); + $this->plans->saveOccurrence($occurrence); + $occurrences[] = $occurrence; + $items[] = new InterventionDraftWorkItem(action: 'control' === $plan->operationKind ? 'inspection' : 'maintenance', target: '/api/equipment/' . $plan->equipmentId, operationId: $plan->id, occurrenceId: $occurrence->id, operationKind: $plan->operationKind); + } + $first = $plans[0]; + $draft = $this->drafts->create(new CreateInterventionDraftRequest(organizationId: $first->organizationId, type: 'control' === $first->operationKind ? 'inspection_campaign' : 'preventive_maintenance', name: $name, origin: 'maintenance:plan', workItems: $items, actorUserId: $actorUserId)); + foreach ($occurrences as $occurrence) { + $model = $this->occurrenceModel($occurrence); + $retry ? $model->retryAttempt($draft->interventionId) : $model->beginAttempt($draft->interventionId); + $occurrence->attempt = $model->attempt(); + $occurrence->interventionId = $model->interventionId(); + $occurrence->resultId = $model->resultId(); + $occurrence->number = $draft->number; + $occurrence->updatedAt = $this->clock->now(); + $this->plans->saveOccurrence($occurrence); + } + $this->events->dispatch(new MaintenanceCampaignGeneratedEvent($first->organizationId, $draft->interventionId, $draft->workItemsCount, $actorUserId ?? '')); + + return new ManageMaintenancePlanResult(mode: 'plans', occurrenceId: $occurrences[0]->id, interventionId: $draft->interventionId, number: $draft->number, workItemsCount: $draft->workItemsCount); + } + + private function result(ManageMaintenancePlanCommand $command): ManageMaintenancePlanResult + { + $result = $command->operationResult ?? throw new MaintenanceValidationException('A validated operation result is required.'); + $occurrence = $this->plans->findOccurrence($result->organizationId, $result->occurrenceId) ?? throw MaintenanceNotFoundException::withId($result->occurrenceId); + $plan = $this->requirePlan($result->organizationId, $occurrence->planId); + if ($plan->equipmentId !== $result->equipmentId || (null !== $result->operationId && $plan->id !== $result->operationId) || $plan->operationKind !== $result->operationKind || $occurrence->interventionId !== $result->interventionId || !in_array($result->outcome, ['passed', 'failed'], true)) { + throw new MaintenanceValidationException('The result does not identify the current equipment operation and attempt.'); + } + if ($this->plans->hasReceipt($result->resultId, $occurrence->id)) { + return $this->details($plan); + } + if ('open' !== $occurrence->status || null !== $occurrence->resultId) { + throw new MaintenanceValidationException('This occurrence attempt already has a validated result.'); + } + if ($result->performedAt > $this->clock->now()) { + throw new MaintenanceValidationException('The result date must belong to this attempt and cannot be in the future.'); + } + if ('validate_result' === $command->action) { + return $this->details($plan); + } + $model = $this->occurrenceModel($occurrence); + $completed = $model->validateResult(MaintenanceOperationKind::from($plan->operationKind), 'passed' === $result->outcome, $result->resultId, $this->clock->now()); + $occurrence->status = $model->state(); + $occurrence->resultId = $model->resultId(); + $occurrence->completedAt = $model->completedAt(); + $occurrence->updatedAt = $this->clock->now(); + $this->plans->saveOccurrence($occurrence); + $this->plans->saveReceipt($result); + if ($completed) { + $calendar = $this->model($plan); + $calendar->complete(null === $plan->lastCompletedAt ? $result->performedAt : max($plan->lastCompletedAt, $result->performedAt)); + $plan->nextDueAt = $calendar->nextDueAt(); + $plan->lastCompletedAt = null === $plan->lastCompletedAt ? $result->performedAt : max($plan->lastCompletedAt, $result->performedAt); + $plan->updatedAt = $this->clock->now(); + $this->plans->save($plan); + $this->projection($plan->organizationId, $plan->equipmentId); + } + + return $this->details($plan); + } + + private function projection(string $organizationId, string $equipmentId): ManageMaintenancePlanResult + { + if ('plans' !== $this->plans->engineMode($organizationId)) { + return new ManageMaintenancePlanResult(); + } + $equipment = $this->equipment->findEquipment($equipmentId); + if (null === $equipment || $equipment->organizationId !== $organizationId) { + $existing = $this->schedules->findByOrganizationAndEquipment($organizationId, $equipmentId); + if (null !== $existing) { + $this->schedules->save(new MaintenanceScheduleSnapshot($existing->id, $organizationId, $equipmentId, $existing->facilityId, $existing->equipmentType, $existing->intervalOverride, $existing->lastInspectionClosedAt, null, 'unscheduled', $existing->lastRemindedAt, $existing->remindedFor, $this->clock->now())); + } + + return new ManageMaintenancePlanResult(mode: 'plans'); + } + $existing = $this->schedules->findByOrganizationAndEquipment($organizationId, $equipmentId); + $policy = $this->compliance->compliancePolicy($organizationId); + $next = null; + $last = null; + $tracked = false; + $missingDueDate = false; + $offset = 0; + do { + $page = $this->plans->list($organizationId, 200, $offset, $equipmentId, 'control'); + foreach ($page as $plan) { + if (!$plan->active || !$this->refreshLegacyPolicy($plan)) { + continue; + } + $tracked = true; + if (null === $plan->nextDueAt) { + $missingDueDate = true; + } + if (null !== $plan->nextDueAt && (null === $next || $plan->nextDueAt < $next)) { + $next = $plan->nextDueAt; + } + if (null !== $plan->lastCompletedAt && (null === $last || $plan->lastCompletedAt > $last)) { + $last = $plan->lastCompletedAt; + } + } + $offset += 200; + } while (200 === count($page)); + if ($missingDueDate) { + $next = null; + } + if ('decommissioned' === $equipment->status || $this->facilities->isArchived($equipment->facilityId, $organizationId)) { + $tracked = false; + $next = null; + } + $rules = new MaintenanceScheduleRecomputePolicy(); + $dueStatus = $rules->computeDueStatus($next, $tracked ? PeriodicityInterval::fromString('P1Y') : null, $this->clock->now(), $policy->reminderWindowDays); + $remindedFor = $existing?->remindedFor; + $lastRemindedAt = $existing?->lastRemindedAt; + if ($rules->shouldResetRemindedFor($existing?->nextDueAt, $next)) { + $remindedFor = null; + } + if ($tracked && null !== $next && null === $remindedFor && in_array($dueStatus, [MaintenanceDueStatus::DUE_SOON, MaintenanceDueStatus::OVERDUE], true)) { + $this->events->dispatch(new MaintenanceReminderRequestedEvent($organizationId, $equipmentId, $equipment->facilityId, $next, MaintenanceDueStatus::OVERDUE === $dueStatus)); + $remindedFor = $next; + $lastRemindedAt = $this->clock->now(); + } + $utc = new DateTimeZone('UTC'); + $this->schedules->save(new MaintenanceScheduleSnapshot($existing?->id, $organizationId, $equipmentId, $equipment->facilityId, $equipment->equipmentType, $existing?->intervalOverride, $last?->setTimezone($utc), $next?->setTimezone($utc), $dueStatus->value, $lastRemindedAt?->setTimezone($utc), $remindedFor?->setTimezone($utc), $this->clock->now())); + + return new ManageMaintenancePlanResult(mode: 'plans'); + } + + private function assertTrackable(MaintenancePlanState $plan): void + { + if (!$this->trackable($plan)) { + throw new MaintenanceValidationException('The plan is inactive or its equipment/site is no longer trackable.'); + } + } + + /** + * Method trackable + * + * Resolves current equipment scope before evaluating its historical cadence source. + * Campaign scans defer saves so refreshing dates cannot reorder their paged input. + * + * @access private + * + * @param MaintenancePlanState $plan the candidate operation + * @param bool $savePolicy whether a changed historical cadence is saved immediately + * + * @return bool whether the current operation is eligible for generation + */ + private function trackable(MaintenancePlanState $plan, bool $savePolicy = true): bool + { + $equipment = $this->equipment->findEquipment($plan->equipmentId); + if (!$plan->active || null !== $plan->archivedAt || null === $equipment || $equipment->organizationId !== $plan->organizationId || 'decommissioned' === $equipment->status || $this->facilities->isArchived($equipment->facilityId, $plan->organizationId)) { + return false; + } + $plan->facilityId = $equipment->facilityId; + $plan->equipmentType = $equipment->equipmentType; + + return $this->refreshLegacyPolicy($plan, $savePolicy); + } + + /** + * Historical operations retain their override/default source after the engine handover. + */ + private function refreshLegacyPolicy(MaintenancePlanState $plan, bool $save = true): bool + { + if (null === $plan->legacyScheduleId) { + return true; + } + $schedule = $this->schedules->findById($plan->legacyScheduleId); + if (null === $schedule) { + return false; + } + $interval = $schedule->intervalOverride ?? $this->compliance->compliancePolicy($plan->organizationId)->periodicityFor($plan->equipmentType); + if (null === $interval) { + return false; + } + if ($interval !== $plan->interval && null === $this->plans->openOccurrence($plan->organizationId, $plan->id)) { + $cadence = PlanCadence::legacyFromString($interval); + $plan->interval = $cadence->value; + if (null !== $plan->lastCompletedAt) { + $plan->nextDueAt = $cadence->addTo($plan->lastCompletedAt); + } + $plan->updatedAt = $this->clock->now(); + if ($save) { + $this->plans->save($plan); + } + } + + return true; + } + + private function requirePlan(string $organizationId, ?string $id): MaintenancePlanState + { + return $this->plans->find($organizationId, $id ?? '') ?? throw MaintenanceNotFoundException::withId($id ?? ''); + } + + private function calendarDate(?DateTimeImmutable $instant, ?string $date, DateTimeZone $timezone): ?DateTimeImmutable + { + if (null !== $date && null !== $instant) { + throw new MaintenanceValidationException('Supply either a calendar date or an instant, not both.'); + } + if (null === $date) { + return $instant?->setTimezone($timezone); + } + $parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $date, $timezone); + if (false === $parsed || false !== DateTimeImmutable::getLastErrors() || $parsed->format('Y-m-d') !== $date) { + throw new MaintenanceValidationException('A calendar date must be a real YYYY-MM-DD date.'); + } + + return $parsed; + } + + private function model(MaintenancePlanState $plan): MaintenancePlan + { + if (mb_strlen(trim($plan->name)) > 160) { + throw new MaintenanceValidationException('A plan name cannot exceed 160 characters.'); + } + + return MaintenancePlan::reconstitute($plan->id, $plan->organizationId, $plan->equipmentId, $plan->name, MaintenanceOperationKind::from($plan->operationKind), 'legacy' === $plan->cadenceMode ? PlanCadence::legacyFromString($plan->interval) : PlanCadence::fromString($plan->interval), $plan->anchorAt, $plan->nextDueAt, $plan->createdAt, 'legacy' === $plan->cadenceMode, null !== $plan->archivedAt); + } + + private function occurrenceModel(MaintenanceOccurrenceState $occurrence): MaintenanceOccurrence + { + return MaintenanceOccurrence::reconstitute($occurrence->id, $occurrence->planId, $occurrence->dueAt, $occurrence->createdAt, $occurrence->attempt, $occurrence->interventionId, $occurrence->completedAt, $occurrence->resultId); + } + + private function canRetry(MaintenanceOccurrenceState $occurrence): bool + { + return 'open' === $occurrence->status && null !== $occurrence->interventionId && (null !== $occurrence->resultId || in_array($this->work->status($occurrence->organizationId, $occurrence->interventionId), ['abandoned', 'published'], true)); + } + + private function details(MaintenancePlanState $plan): ManageMaintenancePlanResult + { + $open = $this->plans->openOccurrence($plan->organizationId, $plan->id); + + return new ManageMaintenancePlanResult(details: new MaintenancePlanDetails($plan, $open, null !== $open && $this->canRetry($open)), mode: $this->plans->engineMode($plan->organizationId)); + } +} diff --git a/src/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanResult.php b/src/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanResult.php new file mode 100644 index 000000000..45f6b647e --- /dev/null +++ b/src/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanResult.php @@ -0,0 +1,24 @@ +scheduleId); } + if ($this->planAuthority?->usesPlans($schedule->organizationId)) { + $this->planAuthority->setLegacyOverride($schedule->organizationId, $schedule->id, $command->intervalOverride, $command->userId); + $updated = $this->schedules->findById($schedule->id) ?? throw MaintenanceNotFoundException::withId($schedule->id); + + return new SetMaintenanceScheduleOverrideResult($updated); + } $compliancePolicy = $this->compliancePolicy->compliancePolicy($schedule->organizationId); try { diff --git a/src/Maintenance/Application/UseCase/Command/Sweep/RecomputeMaintenanceSchedules/RecomputeMaintenanceSchedulesHandler.php b/src/Maintenance/Application/UseCase/Command/Sweep/RecomputeMaintenanceSchedules/RecomputeMaintenanceSchedulesHandler.php index b1cf7c520..d13ae9ae8 100644 --- a/src/Maintenance/Application/UseCase/Command/Sweep/RecomputeMaintenanceSchedules/RecomputeMaintenanceSchedulesHandler.php +++ b/src/Maintenance/Application/UseCase/Command/Sweep/RecomputeMaintenanceSchedules/RecomputeMaintenanceSchedulesHandler.php @@ -75,6 +75,7 @@ public function __construct( private \Maintenance\Application\Service\MaintenanceScheduleService $synchronizer, private \Maintenance\Application\Port\Outbound\Schedule\MaintenanceScheduleLockPort $locks, private EventDispatcherPort $reminders, + private ?\Maintenance\Application\Port\Inbound\MaintenancePlanAuthorityPort $planAuthority = null, ) { } // #endregion @@ -180,6 +181,9 @@ private function recomputeLockedSchedules(array $items): void $policies = $this->compliancePolicy->compliancePolicies(array_keys($groups)); $updates = []; foreach ($groups as $organizationId => $equipmentIds) { + if ($this->planAuthority?->usesPlans($organizationId)) { + continue; + } $current = $this->schedules->findForEquipment($organizationId, $equipmentIds); $compliance = $policies[$organizationId] ?? $this->compliancePolicy->compliancePolicy($organizationId); foreach ($current as $schedule) { diff --git a/src/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansHandler.php b/src/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansHandler.php new file mode 100644 index 000000000..96a315ccd --- /dev/null +++ b/src/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansHandler.php @@ -0,0 +1,64 @@ +authorization->resolveAccess($query->actorUserId, $query->organizationId, 'organization.maintenance.read'); + if ($decision->isOutsideScope()) { + throw MaintenanceNotFoundException::forOrganizationScope($query->organizationId); + } + if (!$decision->isGranted()) { + throw new MaintenanceAccessDeniedException('Missing permission: organization.maintenance.read'); + } + if (null !== $query->operationKind && !in_array($query->operationKind, ['control', 'maintenance'], true)) { + throw new MaintenanceValidationException('Unknown maintenance operation kind.'); + } + $mode = $this->plans->engineMode($query->organizationId); + if ('engine' === $query->action) { + return new ReadMaintenancePlansResult(mode: $mode, preparedCount: $this->plans->count($query->organizationId)); + } + if (null !== $query->planId) { + $plan = $this->plans->find($query->organizationId, $query->planId) ?? throw MaintenanceNotFoundException::withId($query->planId); + $model = MaintenancePlan::reconstitute($plan->id, $plan->organizationId, $plan->equipmentId, $plan->name, MaintenanceOperationKind::from($plan->operationKind), 'legacy' === $plan->cadenceMode ? PlanCadence::legacyFromString($plan->interval) : PlanCadence::fromString($plan->interval), $plan->anchorAt, $plan->nextDueAt, $plan->createdAt, 'legacy' === $plan->cadenceMode, null !== $plan->archivedAt); + + return new ReadMaintenancePlansResult(items: [$this->details($plan)], total: 1, dates: 'preview' === $query->action ? $model->preview() : [], mode: $mode); + } + $size = max(1, min(100, $query->itemsPerPage)); + $offset = (max(1, $query->page) - 1) * $size; + $items = $this->plans->list($query->organizationId, $size, $offset, $query->equipmentId, $query->operationKind, $query->search); + + return new ReadMaintenancePlansResult(items: array_map($this->details(...), $items), total: $this->plans->count($query->organizationId, $query->equipmentId, $query->operationKind, $query->search), mode: $mode); + } + + private function details(MaintenancePlanState $plan): MaintenancePlanDetails + { + $open = $this->plans->openOccurrence($plan->organizationId, $plan->id); + $retryAllowed = null !== $open && 'open' === $open->status && null !== $open->interventionId && (null !== $open->resultId || in_array($this->work->status($plan->organizationId, $open->interventionId), ['abandoned', 'published'], true)); + + return new MaintenancePlanDetails($plan, $open, $retryAllowed); + } +} diff --git a/src/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansQuery.php b/src/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansQuery.php new file mode 100644 index 000000000..ce815ecfe --- /dev/null +++ b/src/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansQuery.php @@ -0,0 +1,24 @@ + $items + * @param list $dates + */ + public function __construct(public array $items = [], public int $total = 0, public array $dates = [], public string $mode = 'legacy', public int $preparedCount = 0) + { + } +} diff --git a/src/Maintenance/Domain/Model/MaintenanceOccurrence.php b/src/Maintenance/Domain/Model/MaintenanceOccurrence.php new file mode 100644 index 000000000..29186d183 --- /dev/null +++ b/src/Maintenance/Domain/Model/MaintenanceOccurrence.php @@ -0,0 +1,286 @@ +interventionId === $interventionId) { + return; + } + if (null !== $this->completedAt || null !== $this->interventionId) { + throw InvalidValueException::because('An existing occurrence attempt needs an explicit retry.'); + } + + $this->interventionId = $interventionId; + $this->attempt = 1; + } + + /** + * Method retryAttempt + * + * Starts another explicit attempt after the caller verifies the old work is + * cancelled or unsuccessful. The original occurrence and due date survive. + * + * @access public + * + * @param string $interventionId the new intervention UUID + * + * @return void + */ + public function retryAttempt(string $interventionId): void + { + new Uuid($interventionId); + + if (null !== $this->completedAt || null === $this->interventionId) { + throw InvalidValueException::because('Only an attempted open occurrence can be retried.'); + } + if ($this->interventionId === $interventionId) { + return; + } + + ++$this->attempt; + $this->interventionId = $interventionId; + $this->resultId = null; + } + + /** + * Method validateResult + * + * Completes a validated control regardless of defects; failed maintenance + * stays open. The same result can be replayed without changing completion. + * + * @access public + * + * @param MaintenanceOperationKind $kind the owning plan's operation kind + * @param bool $successful whether the executed work succeeded + * @param string $resultId the validated result UUID + * @param DateTimeImmutable $validatedAt the validation instant + * + * @return bool whether the occurrence is completed + */ + public function validateResult( + MaintenanceOperationKind $kind, + bool $successful, + string $resultId, + DateTimeImmutable $validatedAt, + ): bool { + new Uuid($resultId); + + if ($this->resultId === $resultId) { + return null !== $this->completedAt; + } + if (null === $this->interventionId || null !== $this->resultId || null !== $this->completedAt || $validatedAt < $this->createdAt) { + throw InvalidValueException::because('A result must validate the current occurrence attempt exactly once.'); + } + + $this->resultId = $resultId; + if ($kind->completesOccurrence($successful)) { + $this->completedAt = $validatedAt; + } + + return null !== $this->completedAt; + } + + /** + * Method state + * + * Exposes the persisted occurrence lifecycle. + * + * @access public + * + * @return string open or completed + */ + public function state(): string + { + return null === $this->completedAt ? 'open' : 'completed'; + } + + /** + * Method attempt + * + * Returns the explicit attempt number, zero before work is generated. + * + * @access public + * + * @return int the attempt number + */ + public function attempt(): int + { + return $this->attempt; + } + + /** + * Method interventionId + * + * Returns the latest work identifier without implying completion. + * + * @access public + * + * @return ?string the intervention UUID + */ + public function interventionId(): ?string + { + return $this->interventionId; + } + + /** + * Method completedAt + * + * Returns the validated completion instant. + * + * @access public + * + * @return ?DateTimeImmutable null while the occurrence remains open + */ + public function completedAt(): ?DateTimeImmutable + { + return $this->completedAt; + } + + /** + * Method resultId + * + * Identifies the current attempt's validated result. + * + * @access public + * + * @return ?string the result UUID + */ + public function resultId(): ?string + { + return $this->resultId; + } + // #endregion +} diff --git a/src/Maintenance/Domain/Model/MaintenancePlan.php b/src/Maintenance/Domain/Model/MaintenancePlan.php new file mode 100644 index 000000000..d89a2a820 --- /dev/null +++ b/src/Maintenance/Domain/Model/MaintenancePlan.php @@ -0,0 +1,260 @@ + 200) { + throw InvalidValueException::because('A maintenance plan needs a name of at most 200 characters.'); + } + + if ($legacy !== $cadence->legacy || (!$legacy && (null === $anchorAt || null === $nextDueAt))) { + throw InvalidValueException::because('A fixed plan needs an anchor and next due date; its calculation mode must match its cadence.'); + } + + if (null !== $anchorAt && null !== $nextDueAt && !$legacy && $nextDueAt < $anchorAt) { + throw InvalidValueException::because('A fixed plan cannot be due before its original anchor.'); + } + + if (null !== $anchorAt && null !== $nextDueAt && !$legacy && $cadence->preview($anchorAt, $nextDueAt, 1)[0]->format('U.u') !== $nextDueAt->format('U.u')) { + throw InvalidValueException::because('A fixed plan must be due on a calendar slot derived from its original anchor.'); + } + } + // #endregion + + // #region Methods + /** + * Method create + * + * Starts a new independent operation with an explicit first due date. + * + * @access public + * + * @param string $id the plan UUID + * @param string $organizationId the owning organization UUID + * @param string $equipmentId the equipment UUID + * @param string $name the operation's display name + * @param MaintenanceOperationKind $kind the independently scheduled operation + * @param PlanCadence $cadence the explicit interval + * @param ?DateTimeImmutable $firstDueAt the first due date; null is reserved for legacy plans + * @param DateTimeImmutable $now the creation instant + * @param bool $legacy whether the cadence preserves historical sliding arithmetic + * + * @return self the new plan + */ + public static function create( + string $id, + string $organizationId, + string $equipmentId, + string $name, + MaintenanceOperationKind $kind, + PlanCadence $cadence, + ?DateTimeImmutable $firstDueAt, + DateTimeImmutable $now, + bool $legacy = false, + ): self { + return new self($id, $organizationId, $equipmentId, trim($name), $kind, $cadence, $firstDueAt, $firstDueAt, $now, $legacy, false); + } + + /** + * Method reconstitute + * + * Restores persisted state only when its next date belongs to the anchored + * calendar, preserving the original anchor after a month-end clamp. + * + * @access public + * + * @param string $id the plan UUID + * @param string $organizationId the owning organization UUID + * @param string $equipmentId the equipment UUID + * @param string $name the operation's display name + * @param MaintenanceOperationKind $kind the independently scheduled operation + * @param PlanCadence $cadence the explicit interval + * @param ?DateTimeImmutable $firstDueAt the original calendar anchor + * @param ?DateTimeImmutable $nextDueAt the stored due date + * @param DateTimeImmutable $createdAt the original creation instant + * @param bool $legacy whether the plan preserves historical sliding arithmetic + * @param bool $archived whether generation is stopped + * + * @return self the restored plan + */ + public static function reconstitute( + string $id, + string $organizationId, + string $equipmentId, + string $name, + MaintenanceOperationKind $kind, + PlanCadence $cadence, + ?DateTimeImmutable $firstDueAt, + ?DateTimeImmutable $nextDueAt, + DateTimeImmutable $createdAt, + bool $legacy, + bool $archived, + ): self { + return new self($id, $organizationId, $equipmentId, $name, $kind, $cadence, $firstDueAt, $nextDueAt, $createdAt, $legacy, $archived); + } + + /** + * Method nextDueAt + * + * Returns the stored due date without recalculating from unrelated inspections. + * + * @access public + * + * @return ?DateTimeImmutable the next due date + */ + public function nextDueAt(): ?DateTimeImmutable + { + return $this->nextDueAt; + } + + /** + * Method isArchived + * + * Exposes whether the plan has been stopped while retaining its history. + * + * @access public + * + * @return bool whether the plan is archived + */ + public function isArchived(): bool + { + return $this->archived; + } + + /** + * Method preview + * + * Previews dates from the stored due date; unscheduled legacy rows stay empty. + * + * @access public + * + * @param int $count the requested number of dates + * + * @return list the upcoming calendar slots + */ + public function preview(int $count = 3): array + { + if (null === $this->nextDueAt) { + return []; + } + + return $this->cadence->preview($this->anchorAt ?? $this->nextDueAt, $this->nextDueAt, $count); + } + + /** + * Method complete + * + * Advances a validated occurrence exactly once; callers retain occurrence + * completion receipts for idempotency. Early completion also advances its slot. + * + * @access public + * + * @param DateTimeImmutable $validatedAt the validated result instant + * + * @return void + */ + public function complete(DateTimeImmutable $validatedAt): void + { + if ($this->legacy) { + $this->nextDueAt = $this->cadence->addTo($validatedAt); + + return; + } + + if (null === $this->anchorAt || null === $this->nextDueAt) { + throw InvalidValueException::because('A fixed maintenance plan needs an explicit calendar.'); + } + + $this->nextDueAt = $this->cadence->nextAfter($this->anchorAt, max($this->nextDueAt, $validatedAt)); + } + + /** + * Method archive + * + * Stops future generation without deleting historical occurrences. + * + * @access public + * + * @return void + */ + public function archive(): void + { + $this->archived = true; + } + + /** + * Method canGenerate + * + * Applies archive and lifecycle suspension before any occurrence reservation. + * + * @access public + * + * @param bool $siteArchived whether the site's generation is suspended + * @param bool $equipmentRetired whether the equipment has left service + * + * @return bool whether generation is allowed + */ + public function canGenerate(bool $siteArchived, bool $equipmentRetired): bool + { + return !$this->archived && !$siteArchived && !$equipmentRetired && null !== $this->nextDueAt; + } + // #endregion +} diff --git a/src/Maintenance/Domain/ValueObject/MaintenanceOperationKind.php b/src/Maintenance/Domain/ValueObject/MaintenanceOperationKind.php new file mode 100644 index 000000000..c6ffa0d0e --- /dev/null +++ b/src/Maintenance/Domain/ValueObject/MaintenanceOperationKind.php @@ -0,0 +1,55 @@ + the stable operation codes + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Method completesOccurrence + * + * A validated control is completed even when it identifies a defect. + * + * @access public + * + * @param bool $successful whether the executed operation succeeded + * + * @return bool whether its occurrence can be completed + */ + public function completesOccurrence(bool $successful): bool + { + return self::CONTROL === $this || $successful; + } + // #endregion +} diff --git a/src/Maintenance/Domain/ValueObject/PlanCadence.php b/src/Maintenance/Domain/ValueObject/PlanCadence.php new file mode 100644 index 000000000..6a72d37b3 --- /dev/null +++ b/src/Maintenance/Domain/ValueObject/PlanCadence.php @@ -0,0 +1,240 @@ + 3650, + 'W' => 520, + 'M' => 120, + 'Y' => 10, + }; + + if ($step > $limit) { + throw InvalidValueException::because('A plan cadence must not exceed ten years.'); + } + + return new self($value, false, $step, $unit); + } + + /** + * Method legacyFromString + * + * Keeps the interval grammar and bounds of the historical inspection schedule. + * + * @access public + * + * @param string $value the historical ISO duration + * + * @return self the validated sliding cadence + */ + public static function legacyFromString(string $value): self + { + PeriodicityInterval::fromString($value); + + return new self($value, true, 0, ''); + } + + /** + * Method addTo + * + * Calculates the first date after an anchor; repeated fixed calculations must + * use dateAt with the original anchor to preserve its day. + * + * @access public + * + * @param DateTimeImmutable $date the original fixed anchor or latest legacy completion + * + * @return DateTimeImmutable the following date + */ + public function addTo(DateTimeImmutable $date): DateTimeImmutable + { + return $this->dateAt($date, 1); + } + + /** + * Method dateAt + * + * Finds a numbered date from an unchanged anchor, with month-end clamping. + * + * @access public + * + * @param DateTimeImmutable $anchor the first due date + * @param int $index the non-negative cadence index, bounded to 100000 + * + * @return DateTimeImmutable the due date at that index + */ + public function dateAt(DateTimeImmutable $anchor, int $index): DateTimeImmutable + { + if ($index < 0 || $index > 100000) { + throw InvalidValueException::because('A cadence index must be between zero and 100000.'); + } + + if ($this->legacy) { + $date = $anchor; + $interval = new DateInterval($this->value); + for ($offset = 0; $offset < $index; ++$offset) { + $date = $date->add($interval); + } + + return $date; + } + + $offset = $this->step * $index; + + if ('D' === $this->unit || 'W' === $this->unit) { + return $anchor->add(new DateInterval(sprintf('P%dD', 'W' === $this->unit ? $offset * 7 : $offset))); + } + + $year = (int) $anchor->format('Y'); + $month = (int) $anchor->format('n'); + if ('Y' === $this->unit) { + $year += $offset; + } else { + $months = $year * 12 + $month - 1 + $offset; + $year = intdiv($months, 12); + $month = $months % 12 + 1; + } + + $firstOfMonth = $anchor->setDate($year, $month, 1); + + return $firstOfMonth->setDate($year, $month, min((int) $anchor->format('j'), (int) $firstOfMonth->format('t'))); + } + + /** + * Method nextAfter + * + * Skips missed fixed slots without producing retroactive occurrences. + * Historical schedules instead slide from the supplied completion date. + * + * @access public + * + * @param DateTimeImmutable $anchor the original first due date + * @param DateTimeImmutable $date the instant to advance beyond + * + * @return DateTimeImmutable the next due date strictly after the instant + */ + public function nextAfter(DateTimeImmutable $anchor, DateTimeImmutable $date): DateTimeImmutable + { + if ($this->legacy) { + return $date->add(new DateInterval($this->value)); + } + + if ($date < $anchor) { + return $anchor; + } + + $inAnchorZone = $date->setTimezone($anchor->getTimezone()); + $elapsed = match ($this->unit) { + 'D' => (int) $anchor->diff($inAnchorZone)->days, + 'W' => intdiv((int) $anchor->diff($inAnchorZone)->days, 7), + 'M' => ((int) $inAnchorZone->format('Y') - (int) $anchor->format('Y')) * 12 + (int) $inAnchorZone->format('n') - (int) $anchor->format('n'), + 'Y' => (int) $inAnchorZone->format('Y') - (int) $anchor->format('Y'), + default => throw InvalidValueException::because('A fixed cadence needs a calendar unit.'), + }; + $index = max(0, intdiv($elapsed, $this->step)); + $next = $this->dateAt($anchor, $index); + + while ($next <= $date) { + $next = $this->dateAt($anchor, ++$index); + } + + return $next; + } + + /** + * Method preview + * + * Includes the first slot on or after the requested date. A legacy preview + * begins at the stored due date and slides each following slot. + * + * @access public + * + * @param DateTimeImmutable $anchor the original first due date + * @param DateTimeImmutable $from the current due date or lower preview bound + * @param int $count the number of dates, from one to 100 + * + * @return list the preview dates + */ + public function preview(DateTimeImmutable $anchor, DateTimeImmutable $from, int $count = 3): array + { + if ($count < 1 || $count > 100) { + throw InvalidValueException::because('A cadence preview must contain between one and 100 dates.'); + } + + $dates = []; + $date = $this->legacy ? $from : $this->nextAfter($anchor, $from->modify('-1 microsecond')); + for ($index = 0; $index < $count; ++$index) { + $dates[] = $date; + $date = $this->nextAfter($anchor, $date); + } + + return $dates; + } + // #endregion +} diff --git a/src/Maintenance/Infrastructure/Adapter/Equipment/MaintenanceOperationsDueAdapter.php b/src/Maintenance/Infrastructure/Adapter/Equipment/MaintenanceOperationsDueAdapter.php new file mode 100644 index 000000000..bccffe276 --- /dev/null +++ b/src/Maintenance/Infrastructure/Adapter/Equipment/MaintenanceOperationsDueAdapter.php @@ -0,0 +1,120 @@ +connection->fetchOne('SELECT mode FROM maintenance_engines WHERE organization_id = :org', ['org' => $organizationId]); + $mode = is_string($modeValue) ? $modeValue : 'legacy'; + $policy = $this->compliance->compliancePolicy($organizationId); + $result = []; + $facilitySuspension = []; + foreach (array_chunk(array_unique($equipmentIds), 200) as $chunk) { + $current = []; + foreach ($this->equipment->findEquipmentByIds($chunk) as $equipment) { + if ($equipment->organizationId !== $organizationId) { + continue; + } + $facilityKey = $equipment->facilityId ?? ''; + $facilitySuspension[$facilityKey] ??= $this->facilities->isArchived($equipment->facilityId, $organizationId); + $current[$equipment->equipmentId] = 'decommissioned' !== $equipment->status && !$facilitySuspension[$facilityKey]; + $result[$equipment->equipmentId] = new MaintenanceEquipmentOperationsDue('unscheduled', 'unscheduled', null, null, $mode); + } + if ([] === $current) { + continue; + } + $parameters = ['org' => $organizationId, 'ids' => array_keys($current)]; + $types = ['ids' => ArrayParameterType::STRING]; + if ('legacy' === $mode) { + /** @var list $rows */ + $rows = $this->connection->fetchAllAssociative('SELECT equipment_id, due_status, next_due_at FROM maintenance_schedules WHERE organization_id = :org AND equipment_id IN (:ids)', $parameters, $types); + foreach ($rows as $row) { + if ($current[$row['equipment_id']]) { + $result[$row['equipment_id']] = new MaintenanceEquipmentOperationsDue($row['due_status'], 'unscheduled', $this->date($row['next_due_at']), null, $mode); + } + } + + continue; + } + /** @var list $rows */ + $rows = $this->connection->fetchAllAssociative('SELECT p.equipment_id, p.operation_kind, p.next_due_at, p.equipment_type, p.legacy_schedule_id, s.interval_override FROM maintenance_plans p LEFT JOIN maintenance_schedules s ON s.id = p.legacy_schedule_id AND s.organization_id = p.organization_id WHERE p.organization_id = :org AND p.equipment_id IN (:ids) AND p.active = TRUE AND p.archived_at IS NULL', $parameters, $types); + $groups = []; + foreach ($rows as $row) { + if (!$current[$row['equipment_id']] || (null !== $row['legacy_schedule_id'] && null === ($row['interval_override'] ?? $policy->periodicityFor($row['equipment_type'])))) { + continue; + } + $groups[$row['equipment_id']][$row['operation_kind']][] = $this->date($row['next_due_at']); + } + foreach ($groups as $equipmentId => $kinds) { + [$controlStatus, $controlDate] = $this->aggregate($kinds['control'] ?? [], $policy->reminderWindowDays); + [$serviceStatus, $serviceDate] = $this->aggregate($kinds['maintenance'] ?? [], $policy->reminderWindowDays); + $result[$equipmentId] = new MaintenanceEquipmentOperationsDue($controlStatus, $serviceStatus, $controlDate, $serviceDate, $mode); + } + } + + return $result; + } + + /** + * @param list $dates + * + * @return array{string, ?DateTimeImmutable} + */ + private function aggregate(array $dates, int $reminderDays): array + { + if ([] === $dates) { + return ['unscheduled', null]; + } + $first = null; + foreach ($dates as $date) { + if (null === $date) { + return ['overdue', null]; + } + if (null === $first || $date < $first) { + $first = $date; + } + } + $now = $this->clock->now(); + if ($now > $first) { + return ['overdue', $first]; + } + + return [$now >= $first->modify('-' . $reminderDays . ' days') ? 'due_soon' : 'up_to_date', $first]; + } + + private function date(?string $date): ?DateTimeImmutable + { + return null === $date ? null : new DateTimeImmutable($date, new DateTimeZone('UTC')); + } +} diff --git a/src/Maintenance/Infrastructure/Persistence/Doctrine/Lock/MaintenanceScheduleLockAdapter.php b/src/Maintenance/Infrastructure/Persistence/Doctrine/Lock/MaintenanceScheduleLockAdapter.php index fbacd8c20..179bff3cc 100644 --- a/src/Maintenance/Infrastructure/Persistence/Doctrine/Lock/MaintenanceScheduleLockAdapter.php +++ b/src/Maintenance/Infrastructure/Persistence/Doctrine/Lock/MaintenanceScheduleLockAdapter.php @@ -7,7 +7,10 @@ use Doctrine\DBAL\Connection; use Maintenance\Application\Port\Outbound\Schedule\MaintenanceScheduleLockPort; +use function array_map; +use function array_unique; use function implode; +use function sort; use function usort; /** Transaction-scoped lock also protects the first insert, before a row exists. */ @@ -49,6 +52,7 @@ public function __construct(private Connection $connection) public function synchronized(string $organizationId, string $equipmentId, callable $work): mixed { return $this->connection->transactional(function () use ($organizationId, $equipmentId, $work): mixed { + $this->connection->executeStatement('SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))', ['key' => 'maintenance.engine.' . $organizationId]); $this->connection->executeStatement('SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))', [ 'key' => 'maintenance.schedule.' . $organizationId . '.' . $equipmentId, ]); @@ -70,6 +74,11 @@ public function synchronizedBatch(array $scopes, callable $work): mixed usort($scopes, static fn (array $left, array $right): int => [$left['organizationId'], $left['equipmentId']] <=> [$right['organizationId'], $right['equipmentId']]); return $this->connection->transactional(function () use ($scopes, $work): mixed { + $organizations = array_unique(array_map(static fn (array $scope): string => $scope['organizationId'], $scopes)); + sort($organizations); + foreach ($organizations as $organizationId) { + $this->connection->executeStatement('SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))', ['key' => 'maintenance.engine.' . $organizationId]); + } $values = []; $parameters = []; foreach ($scopes as $index => $scope) { diff --git a/src/Maintenance/Infrastructure/Persistence/Doctrine/Record/MaintenanceEngineRecord.php b/src/Maintenance/Infrastructure/Persistence/Doctrine/Record/MaintenanceEngineRecord.php new file mode 100644 index 000000000..b8e0de8ad --- /dev/null +++ b/src/Maintenance/Infrastructure/Persistence/Doctrine/Record/MaintenanceEngineRecord.php @@ -0,0 +1,25 @@ + "((status)::text = 'open'::text)"])] +#[ORM\Index(name: 'idx_maintenance_occurrence_org_plan', columns: ['organization_id', 'plan_id'])] +class MaintenanceOccurrenceRecord +{ + #[ORM\Column(type: 'integer', nullable: true)] + public ?int $number = null; + + #[ORM\Id] + #[ORM\Column(name: 'id', type: 'string', length: 36)] + public string $id; + + #[ORM\Column(name: 'plan_id', type: 'string', length: 36)] + public string $planId; + + #[ORM\Column(name: 'organization_id', type: 'string', length: 36)] + public string $organizationId; + + #[ORM\Column(name: 'due_at', type: 'datetime_immutable')] + public DateTimeImmutable $dueAt; + + #[ORM\Column(name: 'status', type: 'string', length: 16)] + public string $status; + + #[ORM\Column(name: 'attempt', type: 'integer')] + public int $attempt; + + #[ORM\Column(name: 'intervention_id', type: 'string', length: 36, nullable: true)] + public ?string $interventionId = null; + + #[ORM\Column(name: 'completed_at', type: 'datetime_immutable', nullable: true)] + public ?DateTimeImmutable $completedAt = null; + + #[ORM\Column(name: 'result_id', type: 'string', length: 36, nullable: true)] + public ?string $resultId = null; + + #[ORM\Column(name: 'created_at', type: 'datetime_immutable')] + public DateTimeImmutable $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'datetime_immutable')] + public DateTimeImmutable $updatedAt; +} diff --git a/src/Maintenance/Infrastructure/Persistence/Doctrine/Record/MaintenanceOperationReceiptRecord.php b/src/Maintenance/Infrastructure/Persistence/Doctrine/Record/MaintenanceOperationReceiptRecord.php new file mode 100644 index 000000000..26797161b --- /dev/null +++ b/src/Maintenance/Infrastructure/Persistence/Doctrine/Record/MaintenanceOperationReceiptRecord.php @@ -0,0 +1,31 @@ + 'UTC'])] + public string $calendarTimezone = 'UTC'; + + #[ORM\Id] + #[ORM\Column(name: 'id', type: 'string', length: 36)] + public string $id; + + #[ORM\Column(name: 'organization_id', type: 'string', length: 36)] + public string $organizationId; + + #[ORM\Column(name: 'equipment_id', type: 'string', length: 36)] + public string $equipmentId; + + #[ORM\Column(name: 'facility_id', type: 'string', length: 36, nullable: true)] + public ?string $facilityId = null; + + #[ORM\Column(name: 'equipment_type', type: 'string', length: 64)] + public string $equipmentType; + + #[ORM\Column(name: 'name', type: 'string', length: 160)] + public string $name; + + #[ORM\Column(name: 'operation_kind', type: 'string', length: 16)] + public string $operationKind; + + #[ORM\Column(name: 'interval', type: 'string', length: 32)] + public string $interval; + + #[ORM\Column(name: 'cadence_mode', type: 'string', length: 16)] + public string $cadenceMode; + + #[ORM\Column(name: 'anchor_at', type: 'datetime_immutable', nullable: true)] + public ?DateTimeImmutable $anchorAt = null; + + #[ORM\Column(name: 'next_due_at', type: 'datetime_immutable', nullable: true)] + public ?DateTimeImmutable $nextDueAt = null; + + #[ORM\Column(name: 'active', type: 'boolean')] + public bool $active; + + #[ORM\Column(name: 'legacy_schedule_id', type: 'string', length: 36, nullable: true)] + public ?string $legacyScheduleId = null; + + #[ORM\Column(name: 'last_completed_at', type: 'datetime_immutable', nullable: true)] + public ?DateTimeImmutable $lastCompletedAt = null; + + #[ORM\Column(name: 'archived_at', type: 'datetime_immutable', nullable: true)] + public ?DateTimeImmutable $archivedAt = null; + + #[ORM\Column(name: 'created_at', type: 'datetime_immutable')] + public DateTimeImmutable $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'datetime_immutable')] + public DateTimeImmutable $updatedAt; +} diff --git a/src/Maintenance/Infrastructure/Persistence/Doctrine/Repository/MaintenancePlanRepository.php b/src/Maintenance/Infrastructure/Persistence/Doctrine/Repository/MaintenancePlanRepository.php new file mode 100644 index 000000000..239cc70e5 --- /dev/null +++ b/src/Maintenance/Infrastructure/Persistence/Doctrine/Repository/MaintenancePlanRepository.php @@ -0,0 +1,196 @@ +connection->transactional(function () use ($organizationId, $work): mixed { + $this->connection->executeStatement('SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))', ['key' => 'maintenance.engine.' . $organizationId]); + + return $work(); + }); + } + + public function engineMode(string $organizationId): string + { + $mode = $this->connection->fetchOne('SELECT mode FROM maintenance_engines WHERE organization_id = :org', ['org' => $organizationId]); + + return is_string($mode) ? $mode : 'legacy'; + } + + public function activateEngine(string $organizationId, DateTimeImmutable $now): void + { + $this->connection->executeStatement("INSERT INTO maintenance_engines (organization_id, mode, activated_at) VALUES (:org, 'plans', :now) ON CONFLICT (organization_id) DO NOTHING", ['org' => $organizationId, 'now' => $this->date($now)]); + } + + public function find(string $organizationId, string $id): ?MaintenancePlanState + { + /** @var array|false $row */ + $row = $this->connection->fetchAssociative('SELECT * FROM maintenance_plans WHERE id = :id AND organization_id = :org', ['id' => $id, 'org' => $organizationId]); + + return false === $row ? null : $this->plan($row); + } + + public function findByLegacySchedule(string $organizationId, string $scheduleId): ?MaintenancePlanState + { + /** @var array|false $row */ + $row = $this->connection->fetchAssociative('SELECT * FROM maintenance_plans WHERE legacy_schedule_id = :id AND organization_id = :org', ['id' => $scheduleId, 'org' => $organizationId]); + + return false === $row ? null : $this->plan($row); + } + + public function list(string $organizationId, int $limit, int $offset, ?string $equipmentId = null, ?string $operationKind = null, ?string $search = null, bool $includeArchived = false): array + { + [$where, $parameters] = $this->criteria($organizationId, $equipmentId, $operationKind, $search, $includeArchived); + /** @var list> $rows */ + $rows = $this->connection->fetchAllAssociative('SELECT * FROM maintenance_plans WHERE ' . $where . ' ORDER BY next_due_at ASC NULLS LAST, id ASC LIMIT ' . $limit . ' OFFSET ' . $offset, $parameters); + + return array_map($this->plan(...), $rows); + } + + public function count(string $organizationId, ?string $equipmentId = null, ?string $operationKind = null, ?string $search = null): int + { + [$where, $parameters] = $this->criteria($organizationId, $equipmentId, $operationKind, $search); + + /** @var int|numeric-string $count */ + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM maintenance_plans WHERE ' . $where, $parameters); + + return (int) $count; + } + + public function save(MaintenancePlanState $plan): void + { + $this->upsert('maintenance_plans', $this->snapshot($plan)); + } + + public function openOccurrence(string $organizationId, string $planId): ?MaintenanceOccurrenceState + { + /** @var array|false $row */ + $row = $this->connection->fetchAssociative("SELECT * FROM maintenance_occurrences WHERE organization_id = :org AND plan_id = :plan AND status = 'open'", ['org' => $organizationId, 'plan' => $planId]); + + return false === $row ? null : $this->occurrence($row); + } + + public function findOccurrence(string $organizationId, string $id): ?MaintenanceOccurrenceState + { + /** @var array|false $row */ + $row = $this->connection->fetchAssociative('SELECT * FROM maintenance_occurrences WHERE organization_id = :org AND id = :id', ['org' => $organizationId, 'id' => $id]); + + return false === $row ? null : $this->occurrence($row); + } + + public function saveOccurrence(MaintenanceOccurrenceState $occurrence): void + { + $this->upsert('maintenance_occurrences', $this->snapshot($occurrence)); + } + + public function hasReceipt(string $resultId, string $occurrenceId): bool + { + return false !== $this->connection->fetchOne('SELECT id FROM maintenance_operation_receipts WHERE id = :id AND occurrence_id = :occurrence', ['id' => $resultId, 'occurrence' => $occurrenceId]); + } + + public function saveReceipt(MaintenanceOperationResult $result): void + { + $this->connection->insert('maintenance_operation_receipts', ['id' => $result->resultId, 'occurrence_id' => $result->occurrenceId, 'organization_id' => $result->organizationId, 'outcome' => $result->outcome, 'performed_at' => $this->date($result->performedAt)]); + } + + /** + * @return array{string, array} + */ + private function criteria(string $organizationId, ?string $equipmentId, ?string $operationKind, ?string $search, bool $includeArchived = false): array + { + $where = 'organization_id = :org' . ($includeArchived ? '' : ' AND archived_at IS NULL'); + $parameters = ['org' => $organizationId]; + foreach (['equipment_id' => $equipmentId, 'operation_kind' => $operationKind] as $column => $value) { + if (null !== $value) { + $where .= ' AND ' . $column . ' = :' . $column; + $parameters[$column] = $value; + } + } + if (null !== $search && '' !== $search) { + $where .= ' AND LOWER(name) LIKE :search'; + $parameters['search'] = '%' . strtolower($search) . '%'; + } + + return [$where, $parameters]; + } + + /** + * @return array + */ + private function snapshot(MaintenancePlanState|MaintenanceOccurrenceState $state): array + { + $data = []; + foreach (get_object_vars($state) as $property => $value) { + $column = strtolower((string) preg_replace('/[A-Z]/', '_$0', $property)); + $data[$column] = $value instanceof DateTimeImmutable ? $this->date($value) : $value; + } + + return $data; + } + + /** + * @param array $data + */ + private function upsert(string $table, array $data): void + { + $columns = array_keys($data); + $updates = array_map(static fn (string $column): string => $column . ' = EXCLUDED.' . $column, $columns); + $types = []; + foreach ($data as $column => $value) { + if ('active' === $column) { + $types[$column] = \Doctrine\DBAL\ParameterType::BOOLEAN; + } + } + $this->connection->executeStatement('INSERT INTO ' . $table . ' (' . implode(', ', $columns) . ') VALUES (' . implode(', ', array_map(static fn (string $column): string => ':' . $column, $columns)) . ') ON CONFLICT (id) DO UPDATE SET ' . implode(', ', $updates), $data, $types); + } + + private function date(DateTimeImmutable $date): string + { + return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'); + } + + private function parsed(bool|int|string|null $date): ?DateTimeImmutable + { + return null === $date ? null : new DateTimeImmutable((string) $date, new DateTimeZone('UTC')); + } + + /** + * @param array $row + */ + private function plan(array $row): MaintenancePlanState + { + return new MaintenancePlanState((string) $row['id'], (string) $row['organization_id'], (string) $row['equipment_id'], null === $row['facility_id'] ? null : (string) $row['facility_id'], (string) $row['equipment_type'], (string) $row['name'], (string) $row['operation_kind'], (string) $row['interval'], (string) $row['cadence_mode'], $this->parsed($row['anchor_at'])?->setTimezone(new DateTimeZone((string) $row['calendar_timezone'])), $this->parsed($row['next_due_at'])?->setTimezone(new DateTimeZone((string) $row['calendar_timezone'])), (bool) $row['active'], null === $row['legacy_schedule_id'] ? null : (string) $row['legacy_schedule_id'], $this->parsed($row['last_completed_at']), $this->parsed($row['archived_at']), new DateTimeImmutable((string) $row['created_at'], new DateTimeZone('UTC')), new DateTimeImmutable((string) $row['updated_at'], new DateTimeZone('UTC')), (string) $row['calendar_timezone']); + } + + /** + * @param array $row + */ + private function occurrence(array $row): MaintenanceOccurrenceState + { + return new MaintenanceOccurrenceState((string) $row['id'], (string) $row['plan_id'], (string) $row['organization_id'], new DateTimeImmutable((string) $row['due_at'], new DateTimeZone('UTC')), (string) $row['status'], (int) $row['attempt'], null === $row['intervention_id'] ? null : (string) $row['intervention_id'], $this->parsed($row['completed_at']), null === $row['result_id'] ? null : (string) $row['result_id'], new DateTimeImmutable((string) $row['created_at'], new DateTimeZone('UTC')), new DateTimeImmutable((string) $row['updated_at'], new DateTimeZone('UTC')), null === $row['number'] ? null : (int) $row['number']); + } +} diff --git a/src/Maintenance/MODULE.md b/src/Maintenance/MODULE.md index 8cb2c8dee..e405ef963 100644 --- a/src/Maintenance/MODULE.md +++ b/src/Maintenance/MODULE.md @@ -4,11 +4,12 @@ ## Overview -Maintenance provides preventive-maintenance scheduling for fire-safety -equipment: one schedule row per tracked piece of equipment, recomputed from -the equipment's inspection history and the organization's compliance policy -(periodicity per equipment type + reminder window), with an optional -per-equipment periodicity override. +Maintenance owns independent preventive operations for fire-safety equipment. +Each organization explicitly chooses one persisted engine authority: historical +inspection schedules (`legacy`, the compatible default), or equipment plans +(`plans`). A plan owns one control or maintenance operation, its original calendar, +and stable occurrences. Historical schedule rows remain the compatible **control** +projection; servicing operations never contribute to compliance statistics. Main goals: @@ -23,6 +24,38 @@ Main goals: ## API Endpoints +The organization-scoped plan base is +`/api/organizations/{organizationId}/maintenance`. All operations require +`ROLE_USER`, then application permission/scope checks (403 for an unentitled +member, 404 outside the organization). Existing schedule routes below remain available. + +| Method | Path relative to the plan base | Behavior | Permission | +| --- | --- | --- | --- | +| GET | `/plans` | Bounded page; equipmentId, operationKind, search, page and itemsPerPage filters | maintenance.read | +| POST | `/plans` | Prepare a control or maintenance operation; active defaults to false | maintenance.manage | +| GET / PATCH | `/plans/{id}` | Read/edit configuration; equipment and kind are immutable | maintenance.read / maintenance.manage | +| DELETE | `/plans/{id}` | Archive with 204, retaining readable history; resolve live work first | maintenance.manage | +| GET | `/plans/{id}/preview` | Pure preview of three anchored dates | maintenance.read | +| POST | `/plans/{id}/generate` | Atomically reserve occurrence, seed work and enqueue event; replay returns existing work | maintenance.manage + interventions.plan | +| POST | `/plans/prepare-legacy` | Prepare non-generating historical plans and exact source mapping | maintenance.manage | +| POST | `/plans/activate` | Locked, atomic handover; refuse ambiguous or submitted legacy work | maintenance.manage + interventions.plan | +| GET | `/engine` | Read authority and configured plan count, without mutation | maintenance.read | + +Permission names above have the `organization.` prefix. New fixed plans require +equipmentId, name, operationKind (`control` or `maintenance`), interval (`PnD`, +`PnW`, `PnM` or `PnY`, bounded to ten years) and a first calendar anchor. +Use `anchorOn` and `nextDueOn` (`YYYY-MM-DD`) for date pickers. Explicit-offset +`anchorAt` and `nextDueAt` are alternatives, not simultaneous values. The plan +freezes the organization's IANA timezone. UTC persistence is rehydrated into +that original calendar, preserving month-end clamping and DST transitions. +An explicit next due date must be an actual slot of the anchored cadence. +No default frequency is inferred for new operations. + +Generate accepts optional name and `retry: true`. An open occurrence with +existing work is replayed; retry is explicit and permitted only after abandonment, +a skipped published task, or unsuccessful servicing. Its original occurrence id +and due date remain unchanged. Outputs expose the server's retryAllowed decision. + | Method | Path | Description | Permission | | ------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | GET | `/api/maintenance/schedules` | List schedules (filters: `organization` _(required)_, `facility`, `equipmentType`, `dueStatus`, `dueBefore`; 30/page, client page size) | `organization.maintenance.read` | @@ -49,6 +82,51 @@ single justified exemption). ## Flows +### Independent operations and safe handover + +Prepare legacy plans while `legacy` still owns all old entrypoints. Preparation +copies overrides, known completion/due dates and uniquely links legacyScheduleId; +uninitialized dates stay null unless an explicit first date has been configured. +Activation refreshes these sources while holding the organization transaction +lock shared by historical campaigns, schedule updates and workers. It attaches +each uniquely matching open historical inspection task to its occurrence. More +than one candidate, frozen submitted work or an unmappable control blocks the +entire transaction. Only after mapping succeeds does the authority become plans. +Preparing again and activating preserve the archival decision of an existing +historical candidate; an archived operation cannot regain generation authority. + +Old campaign and override endpoints then delegate to the plan engine. The hourly +sweep refreshes the control projection and lifecycle suspension, without running +the historical recomputation/reminder engine for activated organizations. Missing +or retired equipment never causes plan/occurrence history to be deleted. + +For fixed calendars, dates advance from the original anchor and clamp to the last +day of a shorter month; missed slots are skipped after a validated completion. +Legacy calendars retain their original sliding DateTime interval calculation and +override/default source. There is one open occurrence per plan, enforced in PostgreSQL. +Fixed-calendar occurrence due dates are returned in the plan's frozen timezone, +including after a UTC database reload. An open occurrence still freezes cadence +and calendar fields while permitting a name-only edit. + +Plan campaigns resolve current equipment ownership, facility, type and lifecycle, +then the current historical override/default cadence, before applying scope and +due-date filters. Ineligible operations are skipped. Cadence updates are saved +after the bounded page scan so reordered due dates cannot skip candidates. + +MaintenanceOperationsDuePort publishes a bulk owner-provided read contract for +Equipment and reporting: controlDueStatus/controlNextDueAt are independent from +serviceDueStatus/serviceNextDueAt. Historical maintenanceDueStatus remains a control +alias for compatibility. Missing initial dates on active operations are overdue; +archived sites and retired equipment expose unscheduled deadlines without deleting +their plans. Reads never initialize plans, mutate calendars or switch engine authority. + +Intervention publication calls MaintenanceOperationResultsPort validateResult and +acknowledgeResult on the same main transaction as the publication. Source equipment, +operation, occurrence, intervention and result identity must match. Control results +complete the occurrence even when adverse; defects remain owned by Inspection. +Failed servicing keeps the occurrence due. Receipts make acknowledgment replay safe. +Standalone inspection closure never acquits an unrelated operation in the plans engine. + ### Inspection closes (event-driven hot path) Inspection closure triggers maintenance synchronization through the published contract. The diagram distinguishes the inspection outcome from the documented best-effort synchronization effect. @@ -322,6 +400,13 @@ automatically — no backfill migration is needed. ## Configuration - Service wiring: `config/modules/maintenance.yaml` +- Plan repository and authority locks: explicit `doctrine.dbal.main_connection`; + plan-generated events use `TransactionalEventDispatcher` on that same connection. +- Facility lifecycle suspension uses the owner-provided ancestry adapter on main; + organization calendar context uses the published workforce directory port. +- Additive main migrations `Version20261006102000`, `Version20261006102001` + and `Version20261006102002` (immutable UTC datetime annotations); + authority defaults to legacy until an explicit successful activation. - Doctrine mapping (main entity manager): `config/packages/doctrine.yaml` - Messenger routing: `config/packages/messenger.yaml` (`RecomputeMaintenanceSchedulesCommand` → `async`; the schedule itself is consumed from the auto-registered `scheduler_maintenance` @@ -333,6 +418,11 @@ automatically — no backfill migration is needed. ## Testing +- New operation coverage: Unit/Maintenance Domain, plan command/query handlers, + plan processor/provider; Functional/Api/MaintenancePlanApiTest; and the PostgreSQL + MaintenancePlanRepositoryTest with two real worker connections, the native Doctrine + outbox sender, rollback visibility and the partial unique open-occurrence constraint. + - Unit: `tests/Unit/Maintenance` — including (L2.2) `Infrastructure/Adapter/Assistant/MaintenanceAssistantContextProviderAdapterTest.php` (permission gate, empty-fragment when nothing is due, soonest-first diff --git a/src/Maintenance/Presentation/Api/Dto/Input/ChangeMaintenancePlanInput.php b/src/Maintenance/Presentation/Api/Dto/Input/ChangeMaintenancePlanInput.php new file mode 100644 index 000000000..bda3e143c --- /dev/null +++ b/src/Maintenance/Presentation/Api/Dto/Input/ChangeMaintenancePlanInput.php @@ -0,0 +1,45 @@ + + */ + #[Groups(['maintenance_plan:read'])] + public array $conflicts = []; +} diff --git a/src/Maintenance/Presentation/Api/Dto/Output/MaintenancePlanOutput.php b/src/Maintenance/Presentation/Api/Dto/Output/MaintenancePlanOutput.php new file mode 100644 index 000000000..9e8c4d195 --- /dev/null +++ b/src/Maintenance/Presentation/Api/Dto/Output/MaintenancePlanOutput.php @@ -0,0 +1,70 @@ + + */ + #[Groups(['maintenance_plan:read'])] + public array $dates = []; +} diff --git a/src/Maintenance/Presentation/Api/EventSubscriber/MaintenancePlanExceptionSubscriber.php b/src/Maintenance/Presentation/Api/EventSubscriber/MaintenancePlanExceptionSubscriber.php new file mode 100644 index 000000000..7e7c9889a --- /dev/null +++ b/src/Maintenance/Presentation/Api/EventSubscriber/MaintenancePlanExceptionSubscriber.php @@ -0,0 +1,48 @@ + ['onKernelException', 10]]; + } + + public function onKernelException(ExceptionEvent $event): void + { + $original = $event->getThrowable(); + if ($original instanceof HttpExceptionInterface || !str_contains($event->getRequest()->getPathInfo(), '/maintenance/')) { + return; + } + $current = $original; + do { + $mapped = match (true) { + $current instanceof MaintenanceNotFoundException => new NotFoundHttpException($current->getMessage(), $original), + $current instanceof MaintenanceAccessDeniedException => new AccessDeniedHttpException($current->getMessage(), $original), + $current instanceof MaintenanceValidationException, $current instanceof InvalidValueException, $current instanceof ValueError => new UnprocessableEntityHttpException($current->getMessage(), $original), + default => null, + }; + if (null !== $mapped) { + $event->setThrowable($mapped); + + return; + } + $current = $current->getPrevious(); + } while ($current instanceof Throwable); + } +} diff --git a/src/Maintenance/Presentation/Api/Factory/MaintenancePlanOutputFactory.php b/src/Maintenance/Presentation/Api/Factory/MaintenancePlanOutputFactory.php new file mode 100644 index 000000000..c7526678c --- /dev/null +++ b/src/Maintenance/Presentation/Api/Factory/MaintenancePlanOutputFactory.php @@ -0,0 +1,41 @@ +plan) as $field => $value) { + $output->{$field} = $value instanceof DateTimeImmutable ? $value->format(DATE_ATOM) : $value; + } + if (null !== $details->openOccurrence) { + $open = $details->openOccurrence; + $occurrence = new MaintenanceOccurrenceOutput(); + $occurrence->id = $open->id; + $dueAt = 'fixed' === $details->plan->cadenceMode ? $open->dueAt->setTimezone(new DateTimeZone($details->plan->calendarTimezone)) : $open->dueAt; + $occurrence->dueAt = $dueAt->format(DATE_ATOM); + $occurrence->status = $open->status; + $occurrence->attempt = $open->attempt; + $occurrence->interventionId = $open->interventionId; + $occurrence->number = $open->number; + $occurrence->retryAllowed = $details->retryAllowed; + $output->openOccurrence = $occurrence; + } + + return $output; + } +} diff --git a/src/Maintenance/Presentation/Api/Operation/MaintenancePlanOperations.php b/src/Maintenance/Presentation/Api/Operation/MaintenancePlanOperations.php new file mode 100644 index 000000000..bdd65821a --- /dev/null +++ b/src/Maintenance/Presentation/Api/Operation/MaintenancePlanOperations.php @@ -0,0 +1,29 @@ + */ +final readonly class MaintenancePlanProcessor implements ProcessorInterface +{ + public function __construct(private CommandBusPort $commands, private CurrentActorPort $actor, private MaintenancePlanOutputFactory $outputs) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?object + { + $actor = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $organizationId = $uriVariables['organizationId'] ?? null; + $planId = $uriVariables['id'] ?? null; + if (!is_string($organizationId) || (null !== $planId && !is_string($planId))) { + throw new BadRequestHttpException('Invalid maintenance route identifiers.'); + } + $action = match ($operation->getName()) { + MaintenancePlanOperations::CREATE => 'create', + MaintenancePlanOperations::UPDATE => 'update', + MaintenancePlanOperations::ARCHIVE => 'archive', + MaintenancePlanOperations::GENERATE => 'generate', + MaintenancePlanOperations::PREPARE => 'prepare_legacy', + MaintenancePlanOperations::ACTIVATE => 'activate', + default => throw new BadRequestHttpException('Unsupported maintenance operation.'), + }; + $change = $data instanceof ChangeMaintenancePlanInput ? $data : null; + $generate = $data instanceof GenerateMaintenancePlanInput ? $data : null; + if (('create' === $action || 'update' === $action) && null === $change) { + throw new BadRequestHttpException('A plan request is required.'); + } + if ('update' === $action && (null !== $change?->equipmentId || null !== $change?->operationKind)) { + throw new BadRequestHttpException('Equipment and operation kind are immutable; prepare a separate plan.'); + } + /** @var ManageMaintenancePlanResult $result */ + $result = $this->commands->dispatch(new ManageMaintenancePlanCommand( + $action, + $organizationId, + $actor, + planId: $planId, + equipmentId: $change?->equipmentId, + name: null !== $change ? $change->name : (null !== $generate ? $generate->name : null), + operationKind: $change?->operationKind, + interval: $change?->interval, + anchorAt: $this->date($change?->anchorAt), + nextDueAt: $this->date($change?->nextDueAt), + active: $change?->active, + retry: null !== $generate ? $generate->retry : false, + anchorOn: $change?->anchorOn, + nextDueOn: $change?->nextDueOn, + )); + if ('archive' === $action) { + return null; + } + if ('prepare_legacy' === $action || 'activate' === $action) { + $output = new MaintenancePlanEngineOutput(); + $output->mode = $result->mode; + $output->preparedCount = $result->preparedCount; + + return $output; + } + if ('generate' === $action) { + $output = new GenerateMaintenancePlanOutput(); + $output->occurrenceId = $result->occurrenceId ?? ''; + $output->interventionId = $result->interventionId ?? ''; + $output->number = $result->number ?? 0; + $output->workItemsCount = $result->workItemsCount; + $output->replayed = $result->replayed; + + return $output; + } + + return null === $result->details ? null : $this->outputs->fromDetails($result->details); + } + + private function date(?string $value): ?DateTimeImmutable + { + if (null === $value) { + return null; + } + if (1 !== preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/D', $value)) { + throw new BadRequestHttpException('Calendar dates must be ISO date-times with an explicit offset.'); + } + + try { + $date = new DateTimeImmutable($value); + } catch (Exception $exception) { + throw new BadRequestHttpException('Invalid calendar date.', $exception); + } + if (false !== DateTimeImmutable::getLastErrors()) { + throw new BadRequestHttpException('Invalid calendar date.'); + } + + return $date; + } +} diff --git a/src/Maintenance/Presentation/Api/Provider/MaintenancePlanProvider.php b/src/Maintenance/Presentation/Api/Provider/MaintenancePlanProvider.php new file mode 100644 index 000000000..4d572a50d --- /dev/null +++ b/src/Maintenance/Presentation/Api/Provider/MaintenancePlanProvider.php @@ -0,0 +1,82 @@ + */ +final readonly class MaintenancePlanProvider implements ProviderInterface +{ + public function __construct(private QueryBusPort $queries, private CurrentActorPort $actor, private MaintenancePlanOutputFactory $outputs, private RequestStack $requests) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object + { + $actor = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $organizationId = $uriVariables['organizationId'] ?? null; + $planId = $uriVariables['id'] ?? null; + if (!is_string($organizationId) || (null !== $planId && !is_string($planId))) { + throw new BadRequestHttpException('Invalid maintenance route identifiers.'); + } + $request = $this->requests->getCurrentRequest(); + $page = max(1, $request?->query->getInt('page', 1) ?? 1); + $size = max(1, min(100, $request?->query->getInt('itemsPerPage', 30) ?? 30)); + $action = match ($operation->getName()) { + MaintenancePlanOperations::ENGINE => 'engine', + MaintenancePlanOperations::PREVIEW => 'preview', + MaintenancePlanOperations::GET => 'detail', + default => 'list', + }; + /** @var ReadMaintenancePlansResult $result */ + $result = $this->queries->ask(new ReadMaintenancePlansQuery( + $organizationId, + $actor, + $action, + $planId, + $page, + $size, + $request?->query->get('equipmentId'), + $request?->query->get('operationKind'), + $request?->query->get('search'), + )); + if ('engine' === $action) { + $output = new MaintenancePlanEngineOutput(); + $output->mode = $result->mode; + $output->preparedCount = $result->preparedCount; + + return $output; + } + if ('preview' === $action) { + $output = new MaintenancePlanPreviewOutput(); + $output->dates = array_map(static fn (DateTimeImmutable $date): string => $date->format(DATE_ATOM), $result->dates); + + return $output; + } + $items = array_map(fn (MaintenancePlanDetails $details): object => $this->outputs->fromDetails($details), $result->items); + + return 'detail' === $action ? $items[0] : new TraversablePaginator(new ArrayIterator($items), $page, $size, $result->total); + } +} diff --git a/src/Maintenance/Presentation/Api/Resource/MaintenancePlanResource.php b/src/Maintenance/Presentation/Api/Resource/MaintenancePlanResource.php new file mode 100644 index 000000000..19a06b669 --- /dev/null +++ b/src/Maintenance/Presentation/Api/Resource/MaintenancePlanResource.php @@ -0,0 +1,41 @@ + ['maintenance_plan:read']], + denormalizationContext: ['groups' => ['maintenance_plan:write']], + operations: [ + new GetCollection(name: MaintenancePlanOperations::LIST, uriTemplate: '/organizations/{organizationId}/maintenance/plans', output: MaintenancePlanOutput::class, provider: MaintenancePlanProvider::class, paginationEnabled: true, paginationClientItemsPerPage: true, paginationItemsPerPage: 30, paginationMaximumItemsPerPage: 100, parameters: [ + 'equipmentId' => new QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], castToArray: false), + 'operationKind' => new QueryParameter(schema: ['type' => 'string', 'enum' => ['control', 'maintenance']], castToArray: false), + 'search' => new QueryParameter(schema: ['type' => 'string', 'maxLength' => 160], castToArray: false), + ], openapi: new Operation(tags: ['Maintenance'], summary: 'List independent equipment operations')), + new Get(name: MaintenancePlanOperations::ENGINE, uriTemplate: '/organizations/{organizationId}/maintenance/engine', output: MaintenancePlanEngineOutput::class, provider: MaintenancePlanProvider::class, openapi: new Operation(tags: ['Maintenance'], summary: 'Read active preventive engine')), + new Post(name: MaintenancePlanOperations::PREPARE, uriTemplate: '/organizations/{organizationId}/maintenance/plans/prepare-legacy', input: false, output: MaintenancePlanEngineOutput::class, read: false, status: 200, processor: MaintenancePlanProcessor::class, openapi: new Operation(tags: ['Maintenance'], summary: 'Prepare historical plans without generating work')), + new Post(name: MaintenancePlanOperations::ACTIVATE, uriTemplate: '/organizations/{organizationId}/maintenance/plans/activate', input: false, output: MaintenancePlanEngineOutput::class, read: false, status: 200, processor: MaintenancePlanProcessor::class, openapi: new Operation(tags: ['Maintenance'], summary: 'Atomically hand over the preventive engine')), + new Post(name: MaintenancePlanOperations::CREATE, uriTemplate: '/organizations/{organizationId}/maintenance/plans', input: ChangeMaintenancePlanInput::class, output: MaintenancePlanOutput::class, read: false, status: 201, processor: MaintenancePlanProcessor::class, openapi: new Operation(tags: ['Maintenance'], summary: 'Prepare an equipment control or maintenance plan')), + new Get(name: MaintenancePlanOperations::GET, uriTemplate: '/organizations/{organizationId}/maintenance/plans/{id}', requirements: ['id' => self::UUID_PATTERN], output: MaintenancePlanOutput::class, provider: MaintenancePlanProvider::class, openapi: new Operation(tags: ['Maintenance'], summary: 'Read an equipment operation')), + new Patch(name: MaintenancePlanOperations::UPDATE, uriTemplate: '/organizations/{organizationId}/maintenance/plans/{id}', requirements: ['id' => self::UUID_PATTERN], input: ChangeMaintenancePlanInput::class, output: MaintenancePlanOutput::class, read: false, status: 200, processor: MaintenancePlanProcessor::class, openapi: new Operation(tags: ['Maintenance'], summary: 'Update an equipment operation')), + new Delete(name: MaintenancePlanOperations::ARCHIVE, uriTemplate: '/organizations/{organizationId}/maintenance/plans/{id}', requirements: ['id' => self::UUID_PATTERN], input: false, output: false, read: false, status: 204, processor: MaintenancePlanProcessor::class, openapi: new Operation(tags: ['Maintenance'], summary: 'Archive an operation, retaining history')), + new Get(name: MaintenancePlanOperations::PREVIEW, uriTemplate: '/organizations/{organizationId}/maintenance/plans/{id}/preview', requirements: ['id' => self::UUID_PATTERN], output: MaintenancePlanPreviewOutput::class, provider: MaintenancePlanProvider::class, openapi: new Operation(tags: ['Maintenance'], summary: 'Preview three dates before activation')), + new Post(name: MaintenancePlanOperations::GENERATE, uriTemplate: '/organizations/{organizationId}/maintenance/plans/{id}/generate', requirements: ['id' => self::UUID_PATTERN], input: GenerateMaintenancePlanInput::class, output: GenerateMaintenancePlanOutput::class, read: false, status: 200, processor: MaintenancePlanProcessor::class, openapi: new Operation(tags: ['Maintenance'], summary: 'Reserve an occurrence and generate or replay its work')), + ], +)] +final class MaintenancePlanResource +{ + private const string UUID_PATTERN = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'; +} diff --git a/src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostItem.php b/src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostItem.php new file mode 100644 index 000000000..5d6100f08 --- /dev/null +++ b/src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostItem.php @@ -0,0 +1,24 @@ + $this->id, 'kind' => $this->kind, 'workItemId' => $this->workItemId, 'sourceId' => $this->sourceId, 'sourceRevision' => $this->sourceRevision, 'amount' => $this->amount, 'currency' => $this->currency, 'description' => $this->description, 'occurredAt' => $this->occurredAt, 'correctionOf' => $this->correctionOf, 'hourlyAmount' => $this->hourlyAmount, 'rateId' => $this->rateId, 'equipmentId' => $this->equipmentId, 'allocation' => $this->allocation]; + } +} diff --git a/src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostPlanning.php b/src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostPlanning.php new file mode 100644 index 000000000..36f463c7e --- /dev/null +++ b/src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostPlanning.php @@ -0,0 +1,16 @@ + $resources + */ + public function __construct(public ?string $plannedBudget = null, public ?int $estimatedMinutes = null, public array $resources = [], public int $revision = 0) + { + } +} diff --git a/src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostSnapshot.php b/src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostSnapshot.php new file mode 100644 index 000000000..09ec8dcc1 --- /dev/null +++ b/src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostSnapshot.php @@ -0,0 +1,13 @@ + $items + */ + public function __construct(public ?string $total, public string $knownTotal, public bool $complete, public array $items) + { + } +} diff --git a/src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostView.php b/src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostView.php new file mode 100644 index 000000000..225e70971 --- /dev/null +++ b/src/MaintenanceCost/Application/Contract/Cost/MaintenanceCostView.php @@ -0,0 +1,13 @@ + $this->total, 'knownTotal' => $this->knownTotal, 'complete' => $this->complete, 'contributionCount' => $this->contributionCount, 'unknownCount' => $this->unknownCount]; + } + // #endregion +} diff --git a/src/MaintenanceCost/Application/Contract/Reporting/MaintenanceEconomicReport.php b/src/MaintenanceCost/Application/Contract/Reporting/MaintenanceEconomicReport.php new file mode 100644 index 000000000..f7ccfa2c9 --- /dev/null +++ b/src/MaintenanceCost/Application/Contract/Reporting/MaintenanceEconomicReport.php @@ -0,0 +1,26 @@ + $rows paginated allocated destinations + * @param list $dossiers minimal retained source labels, bounded to 500 + * @param array{sourceCurrent:MaintenanceEconomicAmount,excludedCurrent:MaintenanceEconomicAmount,sourceFrozen:MaintenanceEconomicAmount,excludedFrozen:MaintenanceEconomicAmount,sourcePlanned:MaintenanceEconomicAmount,excludedPlanned:MaintenanceEconomicAmount,reconciled:bool} $reconciliation original dossier totals versus selected and excluded targets + */ + public function __construct(public string $organizationId, public string $from, public string $to, public string $groupBy, public string $currency, public int $page, public int $itemsPerPage, public int $totalItems, public int $interventionCount, public int $publishedInterventionCount, public int $liveInterventionCount, public int $missingSnapshotCount, public array $rows, public MaintenanceEconomicRow $unallocated, public MaintenanceEconomicAmount $current, public MaintenanceEconomicAmount $frozen, public MaintenanceEconomicAmount $planned, public MaintenanceEconomicAmount $budget, public ?string $variance, public array $reconciliation, public ProcurementEconomicOverview $procurement, public array $dossiers) + { + } +} diff --git a/src/MaintenanceCost/Application/Contract/Reporting/MaintenanceEconomicRow.php b/src/MaintenanceCost/Application/Contract/Reporting/MaintenanceEconomicRow.php new file mode 100644 index 000000000..9cfb43662 --- /dev/null +++ b/src/MaintenanceCost/Application/Contract/Reporting/MaintenanceEconomicRow.php @@ -0,0 +1,30 @@ + $interventionIds distinct source dossiers + */ + public function __construct(public ?string $id, public ?string $name, public string $identityState, public bool $allocationComplete, public MaintenanceEconomicAmount $current, public MaintenanceEconomicAmount $frozen, public MaintenanceEconomicAmount $planned, public MaintenanceEconomicAmount $budget, public ?string $variance, public array $interventionIds) + { + } + + /** + * @return array{id:?string,name:?string,identityState:string,allocationComplete:bool,current:array{total:?string,knownTotal:string,complete:bool,contributionCount:int,unknownCount:int},frozen:array{total:?string,knownTotal:string,complete:bool,contributionCount:int,unknownCount:int},planned:array{total:?string,knownTotal:string,complete:bool,contributionCount:int,unknownCount:int},budget:array{total:?string,knownTotal:string,complete:bool,contributionCount:int,unknownCount:int},variance:?string,interventionIds:list} + */ + public function toArray(): array + { + return ['id' => $this->id, 'name' => $this->name, 'identityState' => $this->identityState, 'allocationComplete' => $this->allocationComplete, 'current' => $this->current->toArray(), 'frozen' => $this->frozen->toArray(), 'planned' => $this->planned->toArray(), 'budget' => $this->budget->toArray(), 'variance' => $this->variance, 'interventionIds' => $this->interventionIds]; + } +} diff --git a/src/MaintenanceCost/Application/Port/Inbound/MaintenanceCostPublicationPort.php b/src/MaintenanceCost/Application/Port/Inbound/MaintenanceCostPublicationPort.php new file mode 100644 index 000000000..8d58e1d77 --- /dev/null +++ b/src/MaintenanceCost/Application/Port/Inbound/MaintenanceCostPublicationPort.php @@ -0,0 +1,13 @@ + + */ + public function expenses(string $organizationId, string $interventionId): array; + + public function expenseByClientId(string $organizationId, string $clientId): ?MaintenanceExpense; + + public function lockExpenseIdentity(string $organizationId, string $clientId): void; + + public function expense(string $organizationId, string $id): ?MaintenanceExpense; + + public function saveExpense(MaintenanceExpense $expense): void; + + public function snapshot(string $organizationId, string $interventionId): ?MaintenanceCostSnapshot; + + public function saveSnapshot(string $organizationId, string $interventionId, MaintenanceCostSnapshot $snapshot): void; + + /** + * Method economicInterventionIds + * + * Published allocations retain captured targets even after equipment moves or retirement. + * + * @access public + * + * @param string $organizationId authorized organization + * @param ?string $siteId optional captured site filter + * @param ?string $customerId optional captured client filter + * @param ?string $equipmentId optional captured equipment filter + * + * @return list at most 10000 candidate intervention identifiers, refusing oversized scopes + */ + public function economicInterventionIds(string $organizationId, ?string $siteId, ?string $customerId, ?string $equipmentId): array; +} diff --git a/src/MaintenanceCost/Application/Port/Outbound/Rate/MaintenanceRateStorePort.php b/src/MaintenanceCost/Application/Port/Outbound/Rate/MaintenanceRateStorePort.php new file mode 100644 index 000000000..2b85f9562 --- /dev/null +++ b/src/MaintenanceCost/Application/Port/Outbound/Rate/MaintenanceRateStorePort.php @@ -0,0 +1,33 @@ + + */ + public function list(string $organizationId, int $limit, int $offset, ?string $memberId = null): array; + + public function count(string $organizationId, ?string $memberId = null): int; +} diff --git a/src/MaintenanceCost/Application/Service/MaintenanceCostAccessGuard.php b/src/MaintenanceCost/Application/Service/MaintenanceCostAccessGuard.php new file mode 100644 index 000000000..a0a3b41ea --- /dev/null +++ b/src/MaintenanceCost/Application/Service/MaintenanceCostAccessGuard.php @@ -0,0 +1,34 @@ +authorization->resolveAccess($actorId, $organizationId, $write ? 'organization.maintenance_cost.manage' : 'organization.maintenance_cost.read'); + if ($decision->isOutsideScope()) { + throw MaintenanceCostException::notFound(); + } + if (!$decision->isGranted()) { + throw MaintenanceCostException::denied(); + } + } + + public function canManage(string $actorId, string $organizationId): bool + { + return $this->authorization->resolveAccess($actorId, $organizationId, 'organization.maintenance_cost.manage')->isGranted(); + } +} diff --git a/src/MaintenanceCost/Application/Service/MaintenanceCostAllocationResolver.php b/src/MaintenanceCost/Application/Service/MaintenanceCostAllocationResolver.php new file mode 100644 index 000000000..5ab7b4bb2 --- /dev/null +++ b/src/MaintenanceCost/Application/Service/MaintenanceCostAllocationResolver.php @@ -0,0 +1,145 @@ + $items current contributions + * @param array $originals captured sources keyed by kind and source + * + * @return list enriched current contributions + */ + public function resolve(string $organizationId, string $interventionId, array $items, array $originals): array + { + $context = $this->work->economicContext($organizationId, $interventionId); + if (null !== $context && ($context->organizationId !== $organizationId || $context->id !== $interventionId)) { + throw MaintenanceCostException::conflict('Allocation identity is outside the authorized cost source.'); + } + $tasks = []; + foreach ($context->workItems ?? [] as $task) { + $tasks[$task->id] = $task; + } + $capturedById = []; + foreach ($originals as $captured) { + $capturedById[$captured->id] = $captured; + } + $latePublication = null === $context?->publicationId ? null : 'publication:' . $context->publicationId; + $capturedSources = []; + $equipmentIds = []; + foreach ($items as $item) { + $original = $originals[$item->kind . ':' . $item->sourceId] ?? (null === $item->correctionOf ? null : ($capturedById[$item->correctionOf] ?? null)); + if (null !== $original) { + $capturedSources[$item->id] = true; + } + $task = null === $item->workItemId ? null : ($tasks[$item->workItemId] ?? null); + $id = $item->equipmentId ?? $task?->equipmentId; + $useCapturedTask = null !== $task && (null === $id || $task->equipmentId === $id) && 'available' === $context?->snapshotState; + $canUseLiveIdentity = 'live' === $context?->snapshotState || (null === $original && null !== $latePublication && $item->correctionOf === $latePublication); + if (null !== $id && null === $original?->allocation && !$useCapturedTask && $canUseLiveIdentity) { + $equipmentIds[$id] = true; + } + } + $identities = [] === $equipmentIds ? [] : $this->equipment->snapshots($organizationId, array_keys($equipmentIds)); + $resolved = []; + foreach ($items as $item) { + $original = $originals[$item->kind . ':' . $item->sourceId] ?? (null === $item->correctionOf ? null : ($capturedById[$item->correctionOf] ?? null)); + $allocation = $original?->allocation; + $task = null === $item->workItemId ? null : ($tasks[$item->workItemId] ?? null); + $equipmentId = $item->equipmentId ?? $task?->equipmentId; + if (null === $allocation) { + $identity = null === $equipmentId ? null : ($identities[$equipmentId] ?? null); + $useCapturedTask = null !== $task && (null === $equipmentId || $task->equipmentId === $equipmentId) && 'available' === $context?->snapshotState; + $capturedIdentity = $useCapturedTask ? $task->equipmentIdentity : null; + $historicSource = 'live' !== $context?->snapshotState && (null !== $original || null === $latePublication || $item->correctionOf !== $latePublication); + // Only facts anchored to this publication may acquire a later live identity. + $equipmentIdentity = $capturedIdentity ?? ($useCapturedTask || $historicSource ? null : $identity); + $allocation = [ + 'identityState' => null !== $capturedIdentity ? 'captured' : (null !== $equipmentIdentity || (null !== $task && 'live' === $context?->snapshotState) ? 'live' : 'incomplete'), + 'equipment' => null === $equipmentId ? null : ['id' => $equipmentId, 'name' => $equipmentIdentity?->name, 'assetReference' => $equipmentIdentity?->assetReference], + 'site' => $useCapturedTask ? $task->site : $equipmentIdentity?->site, + 'customer' => $useCapturedTask ? $task->customer : $equipmentIdentity?->customer, + ]; + if (null === $equipmentId && null !== $task) { + $allocation['site'] = $task->site; + $allocation['customer'] = $task->customer; + } + } + $resolved[] = new MaintenanceCostItem($item->id, $item->kind, $item->workItemId, $item->sourceId, $item->sourceRevision, $item->amount, $item->currency, $item->description, $item->occurredAt, $item->correctionOf, $item->hourlyAmount, $item->rateId, $equipmentId, $allocation); + } + + return $this->inheritCurrentCorrections($resolved, $capturedSources); + } + + /** + * Method inheritCurrentCorrections + * + * Corrections of later facts retain the source allocation without changing frozen + * contributions. Cyclic correction references retain each item's own identity. + * + * @access private + * + * @param list $items current contributions with resolved identities + * @param array $capturedSources sources already anchored to a frozen contribution + * + * @return list current contributions with source correction identities + */ + private function inheritCurrentCorrections(array $items, array $capturedSources): array + { + $byId = []; + foreach ($items as $item) { + $byId[$item->id] = $item; + } + $sources = []; + $resolved = []; + foreach ($items as $item) { + $path = []; + $source = $item; + while (true) { + if (array_key_exists($source->id, $sources)) { + $source = $sources[$source->id]; + + break; + } + if (isset($path[$source->id])) { + $source = null; + + break; + } + $path[$source->id] = true; + if (isset($capturedSources[$source->id]) || null === $source->correctionOf || !isset($byId[$source->correctionOf])) { + break; + } + $source = $byId[$source->correctionOf]; + } + foreach (array_keys($path) as $id) { + $sources[$id] = $source; + } + $resolved[] = null === $source || $source->id === $item->id ? $item : new MaintenanceCostItem($item->id, $item->kind, $item->workItemId, $item->sourceId, $item->sourceRevision, $item->amount, $item->currency, $item->description, $item->occurredAt, $item->correctionOf, $item->hourlyAmount, $item->rateId, $source->equipmentId, $source->allocation); + } + + return $resolved; + } +} diff --git a/src/MaintenanceCost/Application/Service/MaintenanceCostProjection.php b/src/MaintenanceCost/Application/Service/MaintenanceCostProjection.php new file mode 100644 index 000000000..656a478fe --- /dev/null +++ b/src/MaintenanceCost/Application/Service/MaintenanceCostProjection.php @@ -0,0 +1,79 @@ +work->context($organizationId, $interventionId); + if (null === $context) { + throw MaintenanceCostException::notFound(); + } + $currency = $this->currencies->forOrganization($organizationId); + $frozen = $this->store->snapshot($organizationId, $interventionId); + + return new MaintenanceCostView($interventionId, $organizationId, $currency, $this->store->planning($organizationId, $interventionId), $this->current($organizationId, $interventionId, $currency, $frozen), $frozen, in_array($context->status, ['draft', 'planned', 'in_progress', 'changes_requested'], true)); + } + + public function current(string $organizationId, string $interventionId, string $currency, ?MaintenanceCostSnapshot $frozen): MaintenanceCostTotals + { + $items = []; + $originals = []; + foreach ($frozen?->totals->items ?? [] as $item) { + $originals[$item->kind . ':' . $item->sourceId] = $item; + } + foreach ($this->work->timeFacts($organizationId, $interventionId) as $fact) { + $original = $originals['time:' . $fact->id] ?? null; + $rate = $this->rates->forMember($organizationId, $fact->memberId, $fact->workedOn); + if (null !== $rate && $rate->currency !== $currency) { + throw MaintenanceCostException::conflict('The organization currency changed while reading hourly rates.'); + } + // A new rate never reprices the identical fact captured at publication. + $preserveRate = null !== $original && null !== $original->hourlyAmount && $original->occurredAt === $fact->workedOn; + $hourlyAmount = $preserveRate ? $original->hourlyAmount : $rate?->hourlyAmount; + $rateId = $preserveRate ? $original->rateId : $rate?->id; + $amount = $fact->cancelled ? '0.000000' : (null === $hourlyAmount ? null : $this->calculator->timeAmount($hourlyAmount, $fact->minutes)); + $correctionOf = null !== $original && ($original->sourceRevision !== $fact->revision || $original->amount !== $amount) ? $original->id : (null === $original && null !== $frozen ? 'publication:' . $frozen->publicationId : null); + $items[] = new MaintenanceCostItem('time:' . $fact->id . ':' . $fact->revision, 'time', $fact->workItemId, $fact->id, $fact->revision, $amount, $currency, $fact->note ?? 'Recorded work time', $fact->workedOn, $correctionOf, $hourlyAmount, $rateId); + } + foreach ($this->inventory->costFacts($organizationId, $interventionId) as $fact) { + if ($fact->currency !== $currency) { + throw MaintenanceCostException::conflict('A material valuation uses another organization currency.'); + } + $correctionOf = null !== $fact->correctionOf ? 'material:' . $fact->correctionOf : (!isset($originals['material:' . $fact->factId]) && null !== $frozen ? 'publication:' . $frozen->publicationId : null); + $items[] = new MaintenanceCostItem('material:' . $fact->factId, 'material', $fact->workItemId, $fact->factId, null, $fact->exactAmount, $currency, 'Material ' . $fact->partId . ' × ' . $fact->quantity, $fact->movementAt->format('c'), $correctionOf, null, null, $fact->equipmentId); + } + foreach ($this->store->expenses($organizationId, $interventionId) as $expense) { + if ($expense->currency !== $currency) { + throw MaintenanceCostException::conflict('An expense uses another organization currency.'); + } + $correctionOf = null !== $expense->adjustmentOf ? 'expense:' . $expense->adjustmentOf : (!isset($originals['expense:' . $expense->id]) && null !== $frozen ? 'publication:' . $frozen->publicationId : null); + $items[] = new MaintenanceCostItem('expense:' . $expense->id, 'expense', $expense->workItemId, $expense->id, null, $expense->amount, $currency, $expense->description, $expense->incurredAt->format('c'), $correctionOf); + } + if (null !== $this->allocations) { + $items = $this->allocations->resolve($organizationId, $interventionId, $items, $originals); + } + $totals = $this->calculator->total(array_map(static fn (MaintenanceCostItem $item): ?string => $item->amount, $items)); + + return new MaintenanceCostTotals($totals['total'], $totals['knownTotal'], $totals['complete'], $items); + } +} diff --git a/src/MaintenanceCost/Application/Service/MaintenanceEconomicAccumulator.php b/src/MaintenanceCost/Application/Service/MaintenanceEconomicAccumulator.php new file mode 100644 index 000000000..768cd5552 --- /dev/null +++ b/src/MaintenanceCost/Application/Service/MaintenanceEconomicAccumulator.php @@ -0,0 +1,48 @@ +count; + if (null === $amount) { + ++$this->unknown; + } else { + if (!is_numeric($amount)) { + throw MaintenanceCostException::conflict('A financial source is not an exact numeric value.'); + } + $this->knownTotal = bcadd($this->knownTotal, $amount, 6); + } + } + + public function amount(): MaintenanceEconomicAmount + { + return new MaintenanceEconomicAmount(0 === $this->unknown ? $this->knownTotal : null, $this->knownTotal, 0 === $this->unknown, $this->count, $this->unknown); + } +} diff --git a/src/MaintenanceCost/Application/Service/MaintenanceEconomicAggregator.php b/src/MaintenanceCost/Application/Service/MaintenanceEconomicAggregator.php new file mode 100644 index 000000000..d4881f814 --- /dev/null +++ b/src/MaintenanceCost/Application/Service/MaintenanceEconomicAggregator.php @@ -0,0 +1,247 @@ + $contexts bounded source dossiers + * @param array $views scoped private facts keyed by intervention + */ + public function report(ReadMaintenanceEconomicReportQuery $query, string $currency, array $contexts, array $views, ProcurementEconomicOverview $procurement): MaintenanceEconomicReport + { + $eligibleContexts = []; + foreach ($contexts as $context) { + $view = $views[$context->id] ?? throw MaintenanceCostException::notFound(); + if ($view->organizationId !== $query->organizationId || $context->organizationId !== $query->organizationId || $view->currency !== $currency) { + throw MaintenanceCostException::conflict('Economic sources do not share one authorized organization currency.'); + } + if ($this->eligible($query, $context, $view)) { + $eligibleContexts[] = $context; + } + } + $contexts = $eligibleContexts; + $buckets = []; + $unallocated = new MaintenanceEconomicBucket(null, null); + $selected = ['current' => new MaintenanceEconomicAccumulator(), 'frozen' => new MaintenanceEconomicAccumulator(), 'planned' => new MaintenanceEconomicAccumulator(), 'budget' => new MaintenanceEconomicAccumulator()]; + $source = ['current' => new MaintenanceEconomicAccumulator(), 'frozen' => new MaintenanceEconomicAccumulator(), 'planned' => new MaintenanceEconomicAccumulator()]; + $excluded = ['current' => new MaintenanceEconomicAccumulator(), 'frozen' => new MaintenanceEconomicAccumulator(), 'planned' => new MaintenanceEconomicAccumulator()]; + $published = 0; + $missing = 0; + $factCount = 0; + foreach ($contexts as $context) { + $view = $views[$context->id] ?? throw MaintenanceCostException::notFound(); + if ($view->organizationId !== $query->organizationId || $context->organizationId !== $query->organizationId || $view->currency !== $currency) { + throw MaintenanceCostException::conflict('Economic sources do not share one authorized organization currency.'); + } + $isPublished = 'published' === $context->status; + $published += $isPublished ? 1 : 0; + $missing += $isPublished && ('snapshot_missing' === $context->snapshotState || null === $view->frozen) ? 1 : 0; + foreach (['current' => $view->current->items, 'frozen' => $view->frozen?->totals->items ?? []] as $kind => $items) { + foreach ($items as $item) { + if ($item->currency !== $currency) { + throw MaintenanceCostException::conflict('A contribution uses another organization currency.'); + } + $allocation = $this->allocation($item, $context); + $this->retain($kind, $item->amount, $allocation, $context->id, $query, $buckets, $unallocated, $selected, $source, $excluded); + $this->bounded(++$factCount); + } + } + if ($isPublished && null === $view->frozen) { + $this->retain('frozen', null, $this->emptyAllocation(), $context->id, $query, $buckets, $unallocated, $selected, $source, $excluded); + } + $planning = $isPublished && null !== $view->frozen?->planning ? $view->frozen->planning : $view->planning; + $unallocated->budget->add($planning->plannedBudget); + $selected['budget']->add($planning->plannedBudget); + $unallocated->source($context->id, $isPublished ? 'captured' : 'live', false, null); + if ([] === $planning->resources) { + $this->retain('planned', $planning->plannedBudget, $this->emptyAllocation(), $context->id, $query, $buckets, $unallocated, $selected, $source, $excluded); + } else { + foreach ($planning->resources as $resource) { + $item = new MaintenanceCostItem('planning', 'planning', $resource['workItemId'], 'planning', null, $resource['amount'], $currency, $resource['description'], ''); + $this->retain('planned', $resource['amount'], $this->allocation($item, $context), $context->id, $query, $buckets, $unallocated, $selected, $source, $excluded); + $this->bounded(++$factCount); + } + } + } + $rows = []; + foreach ($buckets as $bucket) { + $rows[] = $bucket->row(); + } + usort($rows, static fn ($a, $b): int => ($a->name ?? $a->id ?? '') <=> ($b->name ?? $b->id ?? '')); + $current = $selected['current']->amount(); + $planned = $selected['planned']->amount(); + $reconciled = true; + foreach (['current', 'frozen', 'planned'] as $kind) { + $includedAmount = $selected[$kind]->amount(); + $excludedAmount = $excluded[$kind]->amount(); + $sourceAmount = $source[$kind]->amount(); + $sum = bcadd($includedAmount->knownTotal, $excludedAmount->knownTotal, 6); + $reconciled = $reconciled && 0 === bccomp($sum, $sourceAmount->knownTotal, 6) + && $includedAmount->contributionCount + $excludedAmount->contributionCount === $sourceAmount->contributionCount + && $includedAmount->unknownCount + $excludedAmount->unknownCount === $sourceAmount->unknownCount; + } + + return new MaintenanceEconomicReport($query->organizationId, $query->from, $query->to, $query->groupBy, $currency, $query->page, $query->itemsPerPage, count($rows), count($contexts), $published, count($contexts) - $published, $missing, array_slice($rows, ($query->page - 1) * $query->itemsPerPage, $query->itemsPerPage), $unallocated->row(), $current, $selected['frozen']->amount(), $planned, $selected['budget']->amount(), $current->complete && $planned->complete && $planned->contributionCount > 0 ? bcsub($current->knownTotal, $planned->knownTotal, 6) : null, ['sourceCurrent' => $source['current']->amount(), 'excludedCurrent' => $excluded['current']->amount(), 'sourceFrozen' => $source['frozen']->amount(), 'excludedFrozen' => $excluded['frozen']->amount(), 'sourcePlanned' => $source['planned']->amount(), 'excludedPlanned' => $excluded['planned']->amount(), 'reconciled' => $reconciled], $procurement, array_map(static fn (InterventionEconomicContext $context): array => ['id' => $context->id, 'number' => $context->number, 'name' => $context->name, 'status' => $context->status, 'snapshotState' => $context->snapshotState], $contexts)); + } + + /** + * @return array{identityState:string,equipment:?array{id:string,name:?string,assetReference:?string},site:?array{id:string,name:string},customer:?array{id:string,name:string}} + */ + private function allocation(MaintenanceCostItem $item, InterventionEconomicContext $context): array + { + if (null !== $item->allocation) { + return $item->allocation; + } + $task = null; + foreach ($context->workItems as $candidate) { + if (null !== $item->workItemId && $candidate->id === $item->workItemId) { + $task = $candidate; + + break; + } + if (null === $item->workItemId && null !== $item->equipmentId && $candidate->equipmentId === $item->equipmentId) { + if (null !== $task) { + $task = null; + + break; + } + $task = $candidate; + } + } + $id = $item->equipmentId ?? $task?->equipmentId; + $identity = $task?->equipmentIdentity; + + return ['identityState' => 'live' === $context->snapshotState ? 'live' : (null !== $identity ? 'captured' : 'incomplete'), 'equipment' => null === $id ? null : ['id' => $id, 'name' => $identity?->name, 'assetReference' => $identity?->assetReference], 'site' => $task?->site, 'customer' => $task?->customer]; + } + + /** + * @param array{identityState:string,equipment:?array{id:string,name:?string,assetReference:?string},site:?array{id:string,name:string},customer:?array{id:string,name:string}} $allocation + * @param array $buckets + * @param array $selected + * @param array $source + * @param array $excluded + */ + private function retain(string $kind, ?string $amount, array $allocation, string $interventionId, ReadMaintenanceEconomicReportQuery $query, array &$buckets, MaintenanceEconomicBucket $unallocated, array $selected, array $source, array $excluded): void + { + $source[$kind]->add($amount); + $identity = match ($query->groupBy) { + 'equipment' => $allocation['equipment'], + 'site' => $allocation['site'], + 'customer' => $allocation['customer'], + default => throw MaintenanceCostException::invalid('Unknown economic grouping.'), + }; + $hasAllocation = null !== $allocation['equipment'] || null !== $allocation['site'] || null !== $allocation['customer']; + $matches = $this->matches($query, $allocation); + if ($hasAllocation && false === $matches) { + $excluded[$kind]->add($amount); + + return; + } + if (null === $matches) { + $identity = null; + } + $selected[$kind]->add($amount); + if (null === $identity) { + $bucket = $unallocated; + } else { + $bucket = $buckets[$identity['id']] ??= new MaintenanceEconomicBucket($identity['id'], $identity['name']); + } + $target = match ($kind) { + 'current' => $bucket->current, + 'frozen' => $bucket->frozen, + 'planned' => $bucket->planned, + default => throw MaintenanceCostException::invalid('Unknown economic contribution.'), + }; + $target->add($amount); + $bucket->source($interventionId, $allocation['identityState'], null !== $identity && 'incomplete' !== $allocation['identityState'], $identity['name'] ?? null); + } + + /** + * @return array{identityState:string,equipment:null,site:null,customer:null} + */ + private function emptyAllocation(): array + { + return ['identityState' => 'incomplete', 'equipment' => null, 'site' => null, 'customer' => null]; + } + + private function bounded(int $count): void + { + if ($count > 50000) { + throw MaintenanceCostException::invalid('The report exceeds 50000 contributions; narrow its dates or target filters.'); + } + } + + /** + * @param array{identityState:string,equipment:?array{id:string,name:?string,assetReference:?string},site:?array{id:string,name:string},customer:?array{id:string,name:string}} $allocation + * + * @return ?bool true matching target, false known different target, null unresolved scope + */ + private function matches(ReadMaintenanceEconomicReportQuery $query, array $allocation): ?bool + { + $unknown = false; + foreach (['equipment' => $query->equipmentId, 'site' => $query->siteId, 'customer' => $query->customerId] as $key => $filter) { + if (null === $filter) { + continue; + } + $actual = $allocation[$key]['id'] ?? null; + if (null === $actual) { + $unknown = true; + } elseif ($actual !== $filter) { + return false; + } + } + + return $unknown ? null : true; + } + + private function eligible(ReadMaintenanceEconomicReportQuery $query, InterventionEconomicContext $context, MaintenanceCostView $view): bool + { + if (null === $query->equipmentId && null === $query->siteId && null === $query->customerId) { + return true; + } + foreach ([...$view->current->items, ...($view->frozen?->totals->items ?? [])] as $item) { + $allocation = $this->allocation($item, $context); + $hasTarget = null !== $allocation['equipment'] || null !== $allocation['site'] || null !== $allocation['customer']; + if ($hasTarget && false !== $this->matches($query, $allocation)) { + return true; + } + } + foreach ($context->workItems as $task) { + $item = new MaintenanceCostItem('target', 'target', $task->id, 'target', null, null, $view->currency, '', ''); + $allocation = $this->allocation($item, $context); + $hasTarget = null !== $allocation['equipment'] || null !== $allocation['site'] || null !== $allocation['customer']; + if ($hasTarget && false !== $this->matches($query, $allocation)) { + return true; + } + } + $root = ['identityState' => 'live' === $context->snapshotState ? 'live' : 'captured', 'equipment' => null, 'site' => $context->site, 'customer' => $context->customer]; + + return null === $query->equipmentId && true === $this->matches($query, $root); + } +} diff --git a/src/MaintenanceCost/Application/Service/MaintenanceEconomicBucket.php b/src/MaintenanceCost/Application/Service/MaintenanceEconomicBucket.php new file mode 100644 index 000000000..ff041b149 --- /dev/null +++ b/src/MaintenanceCost/Application/Service/MaintenanceEconomicBucket.php @@ -0,0 +1,70 @@ + + */ + private array $interventions = []; + + private string $state = ''; + + private bool $allocationComplete = true; + + public function __construct(private ?string $id, private ?string $name) + { + $this->current = new MaintenanceEconomicAccumulator(); + $this->frozen = new MaintenanceEconomicAccumulator(); + $this->planned = new MaintenanceEconomicAccumulator(); + $this->budget = new MaintenanceEconomicAccumulator(); + } + + public function source(string $interventionId, string $state, bool $complete, ?string $name): void + { + $this->interventions[$interventionId] = true; + $this->allocationComplete = $this->allocationComplete && $complete; + if ('' === $this->state) { + $this->state = $state; + } elseif ($state !== $this->state) { + $this->state = 'mixed'; + } + if (null !== $this->name && null !== $name && $this->name !== $name) { + $this->state = 'mixed'; + } elseif (null === $this->name) { + $this->name = $name; + } + } + + public function row(): MaintenanceEconomicRow + { + $current = $this->current->amount(); + $planned = $this->planned->amount(); + $variance = $current->complete && $planned->complete && $planned->contributionCount > 0 ? bcsub($current->knownTotal, $planned->knownTotal, 6) : null; + + return new MaintenanceEconomicRow($this->id, $this->name, null === $this->id ? 'unallocated' : ('' === $this->state ? 'incomplete' : $this->state), null !== $this->id && $this->allocationComplete, $current, $this->frozen->amount(), $planned, $this->budget->amount(), $variance, array_keys($this->interventions)); + } +} diff --git a/src/MaintenanceCost/Application/Service/MaintenanceEconomicDirectory.php b/src/MaintenanceCost/Application/Service/MaintenanceEconomicDirectory.php new file mode 100644 index 000000000..92ff98724 --- /dev/null +++ b/src/MaintenanceCost/Application/Service/MaintenanceEconomicDirectory.php @@ -0,0 +1,161 @@ + bounded verified additional source identifiers + */ + public function matchingIds(string $organizationId, ?string $siteId, ?string $customerId, ?string $equipmentId): array + { + if (null === $siteId && null === $customerId && null === $equipmentId) { + return []; + } + $equipmentIds = null === $equipmentId ? [] : [$equipmentId]; + if (null !== $siteId || null !== $customerId) { + $facilities = $this->scopes->facilityIds($organizationId, $siteId, $customerId); + $scope = [] === $facilities ? [] : $this->equipment->equipmentIdsInFacilities($organizationId, $facilities); + $equipmentIds = null === $equipmentId ? $scope : (in_array($equipmentId, $scope, true) ? [$equipmentId] : []); + } + $candidates = array_values(array_unique([...$this->store->economicInterventionIds($organizationId, $siteId, $customerId, $equipmentId), ...$this->inventory->economicInterventionIds($organizationId, $equipmentIds)])); + if (count($candidates) > 10000) { + throw MaintenanceCostException::invalid('The financial directory scope exceeds 10000 interventions; narrow its target filters.'); + } + $matches = []; + $factCount = 0; + foreach ($candidates as $id) { + $view = $this->costs->view($organizationId, $id); + $this->assertScope($organizationId, $id, $view); + $items = [...$view->current->items, ...($view->frozen?->totals->items ?? [])]; + $factCount += count($items); + if ($factCount > 50000) { + throw MaintenanceCostException::invalid('The financial directory scope exceeds 50000 contributions; narrow its target filters.'); + } + foreach ($items as $item) { + $allocation = $item->allocation; + if (null === $allocation) { + continue; + } + $matchesTarget = true; + foreach (['site' => $siteId, 'customer' => $customerId, 'equipment' => $equipmentId] as $target => $identifier) { + if (null !== $identifier && ($allocation[$target]['id'] ?? null) !== $identifier) { + $matchesTarget = false; + } + } + if ($matchesTarget) { + $matches[$id] = true; + + break; + } + } + } + + return array_keys($matches); + } + + /** + * Method equipment + * + * Transfers only minimal allocation labels; private amounts and source bodies stay inside the projection. + * + * @access public + * + * @param string $organizationId finance-authorized organization + * @param string $interventionId selected scoped intervention + * + * @return list minimal current and frozen equipment identities + */ + public function equipment(string $organizationId, string $interventionId): array + { + $view = $this->costs->view($organizationId, $interventionId); + $this->assertScope($organizationId, $interventionId, $view); + $equipment = []; + foreach ([...$view->current->items, ...($view->frozen?->totals->items ?? [])] as $item) { + $allocation = $item->allocation; + if (null !== $allocation && null !== $allocation['equipment']) { + $identity = $allocation['equipment']; + $key = $identity['id'] . ':' . ($allocation['site']['id'] ?? '') . ':' . ($allocation['customer']['id'] ?? ''); + $equipment[$key] ??= [...$identity, 'site' => $allocation['site'], 'customer' => $allocation['customer']]; + } + } + + return array_values($equipment); + } + + /** + * Method assertScope + * + * Fails closed if a projection bridge returns another source identity. + * + * @access private + * + * @param string $organizationId authorized organization + * @param string $interventionId requested source + * @param MaintenanceCostView $view returned projection + * + * @return void + */ + private function assertScope(string $organizationId, string $interventionId, MaintenanceCostView $view): void + { + if ($view->organizationId !== $organizationId || $view->interventionId !== $interventionId) { + throw MaintenanceCostException::conflict('The financial directory source is outside the authorized organization.'); + } + } + // #endregion +} diff --git a/src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostCommand.php b/src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostCommand.php new file mode 100644 index 000000000..7099e761c --- /dev/null +++ b/src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostCommand.php @@ -0,0 +1,18 @@ + $values + */ + public function __construct(public string $actorId, public string $organizationId, public string $interventionId, public string $action, public array $values, public ?int $expectedRevision = null) + { + } +} diff --git a/src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostHandler.php b/src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostHandler.php new file mode 100644 index 000000000..de69354b2 --- /dev/null +++ b/src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostHandler.php @@ -0,0 +1,244 @@ +access->assertAccess($command->actorId, $command->organizationId, true); + // Mutations return the complete private projection, so its dedicated + // read entitlement is required independently from management rights. + $this->access->assertAccess($command->actorId, $command->organizationId, false); + + return $this->transactions->transactional(function () use ($command): WriteMaintenanceCostResult { + $context = $this->work->context($command->organizationId, $command->interventionId, true) ?? throw MaintenanceCostException::notFound(); + if ('planning' === $command->action) { + $this->planning($command, $context); + } elseif ('expense' === $command->action) { + $this->expense($command, $context); + } else { + throw MaintenanceCostException::invalid('Unknown cost mutation.'); + } + + return new WriteMaintenanceCostResult($this->projection->view($command->organizationId, $command->interventionId)); + }); + } + + private function planning(WriteMaintenanceCostCommand $command, InterventionCostContext $context): void + { + if (in_array($context->status, ['submitted', 'published', 'abandoned'], true)) { + throw MaintenanceCostException::conflict('Financial preparation is closed for this intervention.'); + } + if (null === $command->expectedRevision) { + throw MaintenanceCostException::preconditionRequired(); + } + $current = $this->store->planning($command->organizationId, $command->interventionId); + if ($current->revision !== $command->expectedRevision) { + throw MaintenanceCostException::stale(); + } + foreach ($command->values as $key => $value) { + if (!in_array($key, ['plannedBudget', 'estimatedMinutes', 'resources'], true)) { + throw MaintenanceCostException::invalid('Unknown planning field.'); + } + } + $budget = array_key_exists('plannedBudget', $command->values) ? $this->nullableAmount($command->values['plannedBudget']) : $current->plannedBudget; + $minutes = array_key_exists('estimatedMinutes', $command->values) ? $this->nullableMinutes($command->values['estimatedMinutes']) : $current->estimatedMinutes; + $resources = array_key_exists('resources', $command->values) ? $this->resources($command->values['resources'], $context) : $current->resources; + $this->store->savePlanning($command->organizationId, $command->interventionId, new MaintenanceCostPlanning($budget, $minutes, $resources, $current->revision + 1)); + } + + private function expense(WriteMaintenanceCostCommand $command, InterventionCostContext $context): void + { + foreach ($command->values as $key => $value) { + if (!in_array($key, ['clientId', 'amount', 'description', 'incurredAt', 'workItemId', 'adjustmentOf'], true)) { + throw MaintenanceCostException::invalid('Unknown expense field.'); + } + } + $clientId = $this->string($command->values['clientId'] ?? null, 64); + if (1 === preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-7][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iD', $clientId)) { + $clientId = strtolower($clientId); + } + $this->store->lockExpenseIdentity($command->organizationId, $clientId); + $description = $this->string($command->values['description'] ?? null, 2000); + $rawAmount = $this->string($command->values['amount'] ?? null, 26); + $amount = $this->amount($rawAmount, true); + $date = $this->string($command->values['incurredAt'] ?? null, 40); + if (1 !== preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/D', $date)) { + throw MaintenanceCostException::invalid('The expense date must identify its timezone.'); + } + + try { + $incurredAt = new DateTimeImmutable($date); + } catch (DateMalformedStringException) { + throw MaintenanceCostException::invalid('The expense date is invalid.'); + } + if ($incurredAt->format('Y-m-d\TH:i:s') !== substr($date, 0, 19)) { + throw MaintenanceCostException::invalid('The expense date is not a valid calendar date.'); + } + $incurredAt = $incurredAt->setTimezone(new DateTimeZone('UTC')); + if ($incurredAt > $this->clock->now()) { + throw MaintenanceCostException::invalid('An actual expense cannot be dated in the future.'); + } + $workItemId = $this->nullableId($command->values['workItemId'] ?? null); + if (null !== $workItemId && !in_array($workItemId, $context->workItemIds, true)) { + throw MaintenanceCostException::notFound(); + } + $adjustmentOf = $this->nullableId($command->values['adjustmentOf'] ?? null); + if (null !== $adjustmentOf) { + $original = $this->store->expense($command->organizationId, $adjustmentOf); + if (null === $original || $original->interventionId !== $command->interventionId || null !== $original->adjustmentOf) { + throw MaintenanceCostException::notFound(); + } + } elseif (DecimalAmount::fromString($amount)->isNegative()) { + throw MaintenanceCostException::invalid('A negative expense requires its original expense and a correction reason.'); + } + $payloadHash = hash('sha256', json_encode([$command->interventionId, $workItemId, $amount, $description, $incurredAt->format('c'), $adjustmentOf], JSON_THROW_ON_ERROR)); + $existing = $this->store->expenseByClientId($command->organizationId, $clientId); + if (null !== $existing) { + if ($existing->payloadHash !== $payloadHash || $existing->createdBy !== $command->actorId) { + throw MaintenanceCostException::conflict('This expense identifier is already used by another declaration.'); + } + + return; + } + $currency = $this->currencies->lock($command->organizationId); + $this->store->saveExpense(new MaintenanceExpense($this->uuids->generate(), $command->organizationId, $command->interventionId, $workItemId, $clientId, $amount, $currency, $description, $incurredAt, $adjustmentOf, $command->actorId, $payloadHash, $this->clock->now())); + } + + /** + * @return list + */ + private function resources(mixed $input, InterventionCostContext $context): array + { + if (!is_array($input) || count($input) > 1000) { + throw MaintenanceCostException::invalid('At most one thousand estimated resources may be prepared.'); + } + $result = []; + foreach ($input as $resource) { + if (!is_array($resource)) { + throw MaintenanceCostException::invalid('Estimated resources must be structured objects.'); + } + foreach ($resource as $key => $value) { + if (!in_array($key, ['workItemId', 'kind', 'description', 'quantity', 'unitCost', 'estimatedMinutes', 'amount'], true)) { + throw MaintenanceCostException::invalid('Unknown estimated resource field.'); + } + } + $workItemId = $this->nullableId($resource['workItemId'] ?? null); + if (null !== $workItemId && !in_array($workItemId, $context->workItemIds, true)) { + throw MaintenanceCostException::notFound(); + } + $kind = $this->string($resource['kind'] ?? null, 16); + if (!in_array($kind, ['time', 'material', 'external'], true)) { + throw MaintenanceCostException::invalid('Unknown estimated resource kind.'); + } + $quantity = $this->nullableAmount($resource['quantity'] ?? null); + $unitCost = $this->nullableAmount($resource['unitCost'] ?? null); + $minutes = $this->nullableMinutes($resource['estimatedMinutes'] ?? null); + $amount = $this->nullableAmount($resource['amount'] ?? null); + if ('time' === $kind) { + if (null !== $quantity) { + throw MaintenanceCostException::invalid('A time estimate cannot include a material quantity.'); + } + $amount = null !== $unitCost && null !== $minutes ? $this->calculator->timeAmount($unitCost, $minutes) : null; + } elseif ('material' === $kind) { + if (null !== $minutes) { + throw MaintenanceCostException::invalid('A material estimate cannot include work minutes.'); + } + $amount = null !== $unitCost && null !== $quantity ? DecimalAmount::fromString($unitCost)->multiply(DecimalAmount::fromString($quantity))->toString() : null; + } elseif (null !== $quantity || null !== $unitCost || null !== $minutes) { + throw MaintenanceCostException::invalid('An external estimate uses its explicit amount without quantity, rate or minutes.'); + } + $result[] = ['workItemId' => $workItemId, 'kind' => $kind, 'description' => $this->string($resource['description'] ?? null, 1000), 'quantity' => $quantity, 'unitCost' => $unitCost, 'estimatedMinutes' => $minutes, 'amount' => $amount]; + } + + return $result; + } + + private function nullableAmount(mixed $value): ?string + { + return null === $value ? null : $this->amount($this->string($value, 26)); + } + + private function amount(string $value, bool $signed = false): string + { + try { + $amount = DecimalAmount::fromString($value); + } catch (InvalidValueException $error) { + throw MaintenanceCostException::invalid($error->getMessage()); + } + if (!$signed && $amount->isNegative()) { + throw MaintenanceCostException::invalid('A planned amount or quantity cannot be negative.'); + } + + return $amount->toString(); + } + + private function nullableMinutes(mixed $value): ?int + { + if (null !== $value && (!is_int($value) || $value < 0 || $value > 100000000)) { + throw MaintenanceCostException::invalid('Estimated minutes must be a bounded non-negative integer.'); + } + + return $value; + } + + private function nullableId(mixed $value): ?string + { + if (null === $value) { + return null; + } + $id = $this->string($value, 36); + new Uuid($id); + + return $id; + } + + private function string(mixed $value, int $maximum): string + { + if (!is_string($value) || '' === trim($value) || mb_strlen(trim($value)) > $maximum) { + throw MaintenanceCostException::invalid('A non-empty bounded string is required.'); + } + + return trim($value); + } +} diff --git a/src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostResult.php b/src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostResult.php new file mode 100644 index 000000000..effcff55d --- /dev/null +++ b/src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostResult.php @@ -0,0 +1,16 @@ +access->assertAccess($command->actorUserId, $command->organizationId, true); + + return $this->store->synchronized($command->organizationId, function () use ($command): ConfigureMaintenanceCurrencyResult { + $current = $this->store->read($command->organizationId); + $configured = new MaintenanceCurrency($current->currency, $current->locked)->configure($command->currency); + $snapshot = new MaintenanceCurrencySnapshot($command->organizationId, $configured->currency, $configured->locked); + $this->store->save($snapshot); + + return new ConfigureMaintenanceCurrencyResult($snapshot); + }); + } +} diff --git a/src/MaintenanceCost/Application/UseCase/Command/Currency/ConfigureMaintenanceCurrency/ConfigureMaintenanceCurrencyResult.php b/src/MaintenanceCost/Application/UseCase/Command/Currency/ConfigureMaintenanceCurrency/ConfigureMaintenanceCurrencyResult.php new file mode 100644 index 000000000..fb8363e80 --- /dev/null +++ b/src/MaintenanceCost/Application/UseCase/Command/Currency/ConfigureMaintenanceCurrency/ConfigureMaintenanceCurrencyResult.php @@ -0,0 +1,16 @@ +access->assertAccess($command->actorUserId, $command->organizationId, true); + $rate = new MaintenanceHourlyRate(strtolower($command->memberId), $command->hourlyAmount, $command->effectiveFrom, strtolower($command->clientId)); + $member = $this->workforce->profiles($command->organizationId, [$rate->memberId]); + if (!isset($member[$rate->memberId])) { + throw MaintenanceCostException::notFound(); + } + + return $this->store->synchronized($command->organizationId, function () use ($command, $rate): CreateMaintenanceRateResult { + $existing = $this->store->findByClientId($command->organizationId, $rate->clientId); + if (null !== $existing) { + if ($existing->memberId !== $rate->memberId || $existing->hourlyAmount !== $rate->hourlyAmount || $existing->effectiveFrom !== $rate->effectiveFrom) { + throw MaintenanceCostException::conflict('Rate client identifier was already used for different parameters.'); + } + + return new CreateMaintenanceRateResult($existing, true); + } + if (null !== $this->store->findByEffectiveDate($command->organizationId, $rate->memberId, $rate->effectiveFrom)) { + throw MaintenanceCostException::conflict('An hourly rate already exists for this member and effective date.'); + } + $snapshot = new MaintenanceRateSnapshot($this->identifiers->generate(), $rate->memberId, $rate->hourlyAmount, $this->currency->forOrganization($command->organizationId), $rate->effectiveFrom); + $this->store->append($command->organizationId, $rate->clientId, $snapshot); + + return new CreateMaintenanceRateResult($snapshot, false); + }); + } +} diff --git a/src/MaintenanceCost/Application/UseCase/Command/Rate/CreateMaintenanceRate/CreateMaintenanceRateResult.php b/src/MaintenanceCost/Application/UseCase/Command/Rate/CreateMaintenanceRate/CreateMaintenanceRateResult.php new file mode 100644 index 000000000..ef97b14be --- /dev/null +++ b/src/MaintenanceCost/Application/UseCase/Command/Rate/CreateMaintenanceRate/CreateMaintenanceRateResult.php @@ -0,0 +1,16 @@ +access->assertAccess($query->actorId, $query->organizationId, false); + + $view = $this->projection->view($query->organizationId, $query->interventionId); + + return new GetMaintenanceCostResult(new MaintenanceCostView($view->interventionId, $view->organizationId, $view->currency, $view->planning, $view->current, $view->frozen, $view->planningEditable && $this->access->canManage($query->actorId, $query->organizationId))); + } +} diff --git a/src/MaintenanceCost/Application/UseCase/Query/Cost/GetMaintenanceCost/GetMaintenanceCostQuery.php b/src/MaintenanceCost/Application/UseCase/Query/Cost/GetMaintenanceCost/GetMaintenanceCostQuery.php new file mode 100644 index 000000000..384329929 --- /dev/null +++ b/src/MaintenanceCost/Application/UseCase/Query/Cost/GetMaintenanceCost/GetMaintenanceCostQuery.php @@ -0,0 +1,15 @@ +access->assertAccess($query->actorUserId, $query->organizationId, false); + + return new ReadMaintenanceCurrencyResult($this->store->read($query->organizationId)); + } +} diff --git a/src/MaintenanceCost/Application/UseCase/Query/Currency/ReadMaintenanceCurrency/ReadMaintenanceCurrencyQuery.php b/src/MaintenanceCost/Application/UseCase/Query/Currency/ReadMaintenanceCurrency/ReadMaintenanceCurrencyQuery.php new file mode 100644 index 000000000..31ff27a53 --- /dev/null +++ b/src/MaintenanceCost/Application/UseCase/Query/Currency/ReadMaintenanceCurrency/ReadMaintenanceCurrencyQuery.php @@ -0,0 +1,15 @@ +access->assertAccess($query->actorUserId, $query->organizationId, false); + $memberId = null === $query->memberId ? null : strtolower($query->memberId); + if (null !== $memberId && (1 !== preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $memberId) || !isset($this->workforce->profiles($query->organizationId, [$memberId])[$memberId]))) { + throw MaintenanceCostException::notFound(); + } + if ($query->page < 1 || $query->itemsPerPage < 1 || $query->itemsPerPage > 100 || $query->page > 1000000) { + throw MaintenanceCostException::invalid('Rate pagination must be a positive bounded page and 1 to 100 items.'); + } + + return new ListMaintenanceRatesResult($this->store->list($query->organizationId, $query->itemsPerPage, ($query->page - 1) * $query->itemsPerPage, $memberId), $this->store->count($query->organizationId, $memberId), $query->page, $query->itemsPerPage); + } +} diff --git a/src/MaintenanceCost/Application/UseCase/Query/Rate/ListMaintenanceRates/ListMaintenanceRatesQuery.php b/src/MaintenanceCost/Application/UseCase/Query/Rate/ListMaintenanceRates/ListMaintenanceRatesQuery.php new file mode 100644 index 000000000..0eb7df8ed --- /dev/null +++ b/src/MaintenanceCost/Application/UseCase/Query/Rate/ListMaintenanceRates/ListMaintenanceRatesQuery.php @@ -0,0 +1,15 @@ + $items + */ + public function __construct(public array $items, public int $total, public int $page, public int $itemsPerPage) + { + } +} diff --git a/src/MaintenanceCost/Application/UseCase/Query/Reporting/ListMaintenanceEconomicDossiers/ListMaintenanceEconomicDossiersHandler.php b/src/MaintenanceCost/Application/UseCase/Query/Reporting/ListMaintenanceEconomicDossiers/ListMaintenanceEconomicDossiersHandler.php new file mode 100644 index 000000000..6b94afba9 --- /dev/null +++ b/src/MaintenanceCost/Application/UseCase/Query/Reporting/ListMaintenanceEconomicDossiers/ListMaintenanceEconomicDossiersHandler.php @@ -0,0 +1,51 @@ +access->assertAccess($query->actorId, $query->organizationId, false); + if ($query->page < 1 || $query->page > 100000 || $query->itemsPerPage < 1 || $query->itemsPerPage > 100 || (null !== $query->search && mb_strlen($query->search) > 160) || (null === $query->from) !== (null === $query->to)) { + throw MaintenanceCostException::invalid('Use valid pagination, a search of at most 160 characters and either both dates or neither.'); + } + $window = null !== $query->from && null !== $query->to ? MaintenanceEconomicWindow::fromDates($query->from, $query->to) : null; + foreach ([$query->siteId, $query->customerId, $query->equipmentId] as $identifier) { + if (null !== $identifier) { + new Uuid($identifier); + } + } + + $financialIds = $this->directory->matchingIds($query->organizationId, $query->siteId, $query->customerId, $query->equipmentId); + $page = $this->work->economicPage($query->organizationId, $query->page, $query->itemsPerPage, $query->search, $window?->from, $window?->to, $query->siteId, $query->customerId, $query->equipmentId, $financialIds); + $equipment = []; + foreach ($page->items as $context) { + $equipment[$context->id] = $this->directory->equipment($query->organizationId, $context->id); + } + + return new ListMaintenanceEconomicDossiersResult($page, $equipment); + } +} diff --git a/src/MaintenanceCost/Application/UseCase/Query/Reporting/ListMaintenanceEconomicDossiers/ListMaintenanceEconomicDossiersQuery.php b/src/MaintenanceCost/Application/UseCase/Query/Reporting/ListMaintenanceEconomicDossiers/ListMaintenanceEconomicDossiersQuery.php new file mode 100644 index 000000000..499cb8331 --- /dev/null +++ b/src/MaintenanceCost/Application/UseCase/Query/Reporting/ListMaintenanceEconomicDossiers/ListMaintenanceEconomicDossiersQuery.php @@ -0,0 +1,21 @@ +> $equipment minimal financial identities indexed by intervention + */ + public function __construct(public InterventionEconomicContextPage $page, public array $equipment = []) + { + } +} diff --git a/src/MaintenanceCost/Application/UseCase/Query/Reporting/ReadMaintenanceEconomicReport/ReadMaintenanceEconomicReportHandler.php b/src/MaintenanceCost/Application/UseCase/Query/Reporting/ReadMaintenanceEconomicReport/ReadMaintenanceEconomicReportHandler.php new file mode 100644 index 000000000..1f054e4e7 --- /dev/null +++ b/src/MaintenanceCost/Application/UseCase/Query/Reporting/ReadMaintenanceEconomicReport/ReadMaintenanceEconomicReportHandler.php @@ -0,0 +1,76 @@ +access->assertAccess($query->actorId, $query->organizationId, false); + $window = MaintenanceEconomicWindow::fromDates($query->from, $query->to); + if (!in_array($query->groupBy, ['equipment', 'site', 'customer'], true) || $query->page < 1 || $query->page > 100000 || $query->itemsPerPage < 1 || $query->itemsPerPage > 100) { + throw MaintenanceCostException::invalid('Use equipment, site or customer grouping and a valid page of at most 100 rows.'); + } + foreach ([$query->siteId, $query->customerId, $query->equipmentId] as $identifier) { + if (null !== $identifier) { + new Uuid($identifier); + } + } + // Financial direct-material targets need not exist among operational tasks. + $contexts = $this->work->economicWindow($query->organizationId, $window->from, $window->to, null, null, null, 501); + if ($contexts->totalItems > 500) { + throw MaintenanceCostException::invalid('The report exceeds 500 interventions; narrow its date window.'); + } + if (count($contexts->items) !== $contexts->totalItems) { + throw MaintenanceCostException::conflict('The bounded source window is incomplete; no partial economic total can be returned.'); + } + $views = []; + $factCount = 0; + foreach ($contexts->items as $context) { + $view = $this->costs->view($query->organizationId, $context->id); + $factCount += count($view->current->items) + count($view->frozen?->totals->items ?? []) + count($view->planning->resources); + if ($factCount > 50000) { + throw MaintenanceCostException::invalid('The report exceeds 50000 contributions; narrow its dates or target filters.'); + } + $views[$context->id] = $view; + } + + try { + $procurement = $this->procurement->overview($query->organizationId, $window->from, $window->to); + } catch (ProcurementEconomicOverviewUnavailable $error) { + throw MaintenanceCostException::invalid($error->getMessage()); + } + $currency = $this->currencies->forOrganization($query->organizationId); + if ($procurement->currency !== $currency) { + throw MaintenanceCostException::conflict('Procurement uses another organization currency.'); + } + + return new ReadMaintenanceEconomicReportResult($this->aggregator->report($query, $currency, $contexts->items, $views, $procurement)); + } +} diff --git a/src/MaintenanceCost/Application/UseCase/Query/Reporting/ReadMaintenanceEconomicReport/ReadMaintenanceEconomicReportQuery.php b/src/MaintenanceCost/Application/UseCase/Query/Reporting/ReadMaintenanceEconomicReport/ReadMaintenanceEconomicReportQuery.php new file mode 100644 index 000000000..aa751abde --- /dev/null +++ b/src/MaintenanceCost/Application/UseCase/Query/Reporting/ReadMaintenanceEconomicReport/ReadMaintenanceEconomicReportQuery.php @@ -0,0 +1,21 @@ +locked); + if ($this->locked && $currency !== $this->currency) { + throw MaintenanceCostException::conflict('Organization currency is locked after its first financial fact.'); + } + + return $next; + } +} diff --git a/src/MaintenanceCost/Domain/Model/Rate/MaintenanceHourlyRate.php b/src/MaintenanceCost/Domain/Model/Rate/MaintenanceHourlyRate.php new file mode 100644 index 000000000..476e1ca65 --- /dev/null +++ b/src/MaintenanceCost/Domain/Model/Rate/MaintenanceHourlyRate.php @@ -0,0 +1,41 @@ +isNegative()) { + throw MaintenanceCostException::invalid('Hourly amount must be non-negative.'); + } + $this->hourlyAmount = $amount->toString(); + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $effectiveFrom); + if (false === $date || $date->format('Y-m-d') !== $effectiveFrom) { + throw MaintenanceCostException::invalid('Rate effective date must be a valid YYYY-MM-DD date.'); + } + } +} diff --git a/src/MaintenanceCost/Domain/Service/MaintenanceCostCalculator.php b/src/MaintenanceCost/Domain/Service/MaintenanceCostCalculator.php new file mode 100644 index 000000000..c42d486b3 --- /dev/null +++ b/src/MaintenanceCost/Domain/Service/MaintenanceCostCalculator.php @@ -0,0 +1,44 @@ +multiplyInt($minutes)->divideInt(60)->toString(); + } + + /** + * @param list $amounts + * + * @return array{total:?string,knownTotal:string,complete:bool} + */ + public function total(array $amounts): array + { + $known = '0.000000'; + $complete = true; + foreach ($amounts as $amount) { + if (null === $amount) { + $complete = false; + } else { + if (1 !== preg_match('/^-?\d+(?:\.\d{1,6})?$/D', $amount) || !is_numeric($amount)) { + throw MaintenanceCostException::invalid('A calculated cost must remain an exact decimal string.'); + } + $known = bcadd($known, $amount, 6); + } + } + + return ['total' => $complete ? $known : null, 'knownTotal' => $known, 'complete' => $complete]; + } +} diff --git a/src/MaintenanceCost/Domain/ValueObject/MaintenanceEconomicWindow.php b/src/MaintenanceCost/Domain/ValueObject/MaintenanceEconomicWindow.php new file mode 100644 index 000000000..6c3b865b3 --- /dev/null +++ b/src/MaintenanceCost/Domain/ValueObject/MaintenanceEconomicWindow.php @@ -0,0 +1,75 @@ +diff($last)->days >= 366) { + throw MaintenanceCostException::invalid('Use an ordered report window of at most 366 calendar days.'); + } + + return new self($start, $last->modify('+1 day')); + } + + /** + * Method date + * + * @access private + * + * @param string $value strict calendar date + * + * @return DateTimeImmutable UTC day without normalization of invalid dates + */ + private static function date(string $value): DateTimeImmutable + { + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value, new DateTimeZone('UTC')); + if (false === $date || $date->format('Y-m-d') !== $value) { + throw MaintenanceCostException::invalid('Report dates must use valid YYYY-MM-DD calendar dates.'); + } + + return $date; + } + // #endregion +} diff --git a/src/MaintenanceCost/Infrastructure/Adapter/Currency/MaintenanceCurrencyAdapter.php b/src/MaintenanceCost/Infrastructure/Adapter/Currency/MaintenanceCurrencyAdapter.php new file mode 100644 index 000000000..868766760 --- /dev/null +++ b/src/MaintenanceCost/Infrastructure/Adapter/Currency/MaintenanceCurrencyAdapter.php @@ -0,0 +1,70 @@ +read($organizationId)->currency; + } + + public function read(string $organizationId): MaintenanceCurrencySnapshot + { + /** @var array{currency:string,locked:bool}|false $row */ + $row = $this->connection->fetchAssociative('SELECT currency, locked FROM maintenance_cost_currency_settings WHERE organization_id = :org', ['org' => $organizationId]); + + return new MaintenanceCurrencySnapshot($organizationId, false === $row ? 'EUR' : $row['currency'], false !== $row && $row['locked']); + } + + public function synchronized(string $organizationId, callable $work): mixed + { + return $this->connection->transactional(function () use ($organizationId, $work): mixed { + $this->scopeLock($organizationId); + + return $work(); + }); + } + + public function lock(string $organizationId): string + { + $this->requireTransaction(); + $this->scopeLock($organizationId); + $this->connection->executeStatement("INSERT INTO maintenance_cost_currency_settings (organization_id, currency, locked) VALUES (:org, 'EUR', TRUE) ON CONFLICT (organization_id) DO UPDATE SET locked = TRUE", ['org' => $organizationId]); + + return $this->forOrganization($organizationId); + } + + public function save(MaintenanceCurrencySnapshot $currency): void + { + $this->requireTransaction(); + $this->connection->executeStatement('INSERT INTO maintenance_cost_currency_settings (organization_id, currency, locked) VALUES (:org, :currency, :locked) ON CONFLICT (organization_id) DO UPDATE SET currency = EXCLUDED.currency, locked = EXCLUDED.locked', [ + 'org' => $currency->organizationId, 'currency' => $currency->currency, 'locked' => $currency->locked, + ], ['locked' => \Doctrine\DBAL\ParameterType::BOOLEAN]); + } + + private function scopeLock(string $organizationId): void + { + $this->connection->executeStatement('SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))', ['key' => 'maintenance_cost.currency.' . $organizationId]); + } + + private function requireTransaction(): void + { + if (!$this->connection->isTransactionActive()) { + throw new LogicException('Currency writes require the active main transaction.'); + } + } +} diff --git a/src/MaintenanceCost/Infrastructure/Adapter/Publication/MaintenanceCostPublicationAdapter.php b/src/MaintenanceCost/Infrastructure/Adapter/Publication/MaintenanceCostPublicationAdapter.php new file mode 100644 index 000000000..5491a2845 --- /dev/null +++ b/src/MaintenanceCost/Infrastructure/Adapter/Publication/MaintenanceCostPublicationAdapter.php @@ -0,0 +1,55 @@ +entityManager->getConnection()->isTransactionActive()) { + throw MaintenanceCostException::conflict('Publication resources require the owning main transaction.'); + } + $this->inventory->assertReadyToPublish($organizationId, $interventionId); + } + + public function freeze(string $organizationId, string $interventionId, string $publicationId, int $interventionRevision): void + { + $this->assertReadyToPublish($organizationId, $interventionId); + $context = $this->work->context($organizationId, $interventionId, true); + if (null === $context || 'submitted' !== $context->status || $context->revision !== $interventionRevision) { + throw MaintenanceCostException::conflict('The intervention changed before its cost snapshot was captured.'); + } + if (null !== $this->store->snapshot($organizationId, $interventionId)) { + return; + } + $currency = $this->currencies->lock($organizationId); + $totals = $this->projection->current($organizationId, $interventionId, $currency, null); + $capturedItems = []; + foreach ($totals->items as $item) { + $allocation = $item->allocation; + if (null !== $allocation && 'live' === $allocation['identityState']) { + $allocation['identityState'] = 'captured'; + } + $capturedItems[] = new MaintenanceCostItem($item->id, $item->kind, $item->workItemId, $item->sourceId, $item->sourceRevision, $item->amount, $item->currency, $item->description, $item->occurredAt, $item->correctionOf, $item->hourlyAmount, $item->rateId, $item->equipmentId, $allocation); + } + $totals = new MaintenanceCostTotals($totals->total, $totals->knownTotal, $totals->complete, $capturedItems); + $this->store->saveSnapshot($organizationId, $interventionId, new MaintenanceCostSnapshot(1, $this->clock->now()->format('c'), $publicationId, $interventionRevision, $currency, $totals, $this->store->planning($organizationId, $interventionId))); + } +} diff --git a/src/MaintenanceCost/Infrastructure/Adapter/Rate/MaintenanceRateAdapter.php b/src/MaintenanceCost/Infrastructure/Adapter/Rate/MaintenanceRateAdapter.php new file mode 100644 index 000000000..8c6708ac4 --- /dev/null +++ b/src/MaintenanceCost/Infrastructure/Adapter/Rate/MaintenanceRateAdapter.php @@ -0,0 +1,115 @@ +connection->transactional(function () use ($organizationId, $work): mixed { + $this->connection->executeStatement('SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))', ['key' => 'maintenance_cost.currency.' . $organizationId]); + + return $work(); + }); + } + + public function forMember(string $organizationId, string $memberId, string $workedOn): ?MaintenanceRateSnapshot + { + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $workedOn); + if (false === $date || $date->format('Y-m-d') !== $workedOn) { + throw MaintenanceCostException::invalid('Worked-on date must be a valid YYYY-MM-DD date.'); + } + + return $this->find(self::SELECT . 'WHERE rates.organization_id = :org AND rates.member_id = :member AND rates.effective_from <= :worked ORDER BY rates.effective_from DESC, rates.id ASC LIMIT 1', ['org' => $organizationId, 'member' => $memberId, 'worked' => $workedOn]); + } + + public function findByClientId(string $organizationId, string $clientId): ?MaintenanceRateSnapshot + { + return $this->find(self::SELECT . 'WHERE rates.organization_id = :org AND rates.client_id = :client', ['org' => $organizationId, 'client' => $clientId]); + } + + public function findByEffectiveDate(string $organizationId, string $memberId, string $effectiveFrom): ?MaintenanceRateSnapshot + { + return $this->find(self::SELECT . 'WHERE rates.organization_id = :org AND rates.member_id = :member AND rates.effective_from = :date', ['org' => $organizationId, 'member' => $memberId, 'date' => $effectiveFrom]); + } + + public function append(string $organizationId, string $clientId, MaintenanceRateSnapshot $rate): void + { + if (!$this->connection->isTransactionActive()) { + throw new LogicException('Appending a rate requires the active main transaction.'); + } + $this->connection->insert('maintenance_cost_hourly_rates', [ + 'id' => $rate->id, 'organization_id' => $organizationId, 'member_id' => $rate->memberId, + 'client_id' => $clientId, 'hourly_amount' => $rate->hourlyAmount, 'effective_from' => $rate->effectiveFrom, + ]); + } + + public function list(string $organizationId, int $limit, int $offset, ?string $memberId = null): array + { + $parameters = ['org' => $organizationId]; + $where = 'rates.organization_id = :org'; + if (null !== $memberId) { + $where .= ' AND rates.member_id = :member'; + $parameters['member'] = $memberId; + } + /** @var list> $rows */ + $rows = $this->connection->fetchAllAssociative(self::SELECT . 'WHERE ' . $where . ' ORDER BY rates.effective_from DESC, rates.id ASC LIMIT ' . max(1, min(100, $limit)) . ' OFFSET ' . max(0, $offset), $parameters); + + return array_map($this->snapshot(...), $rows); + } + + public function count(string $organizationId, ?string $memberId = null): int + { + $parameters = ['org' => $organizationId]; + $where = 'organization_id = :org'; + if (null !== $memberId) { + $where .= ' AND member_id = :member'; + $parameters['member'] = $memberId; + } + /** @var int|numeric-string $count */ + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM maintenance_cost_hourly_rates WHERE ' . $where, $parameters); + + return (int) $count; + } + + /** + * @param array $parameters + */ + private function find(string $sql, array $parameters): ?MaintenanceRateSnapshot + { + /** @var array|false $row */ + $row = $this->connection->fetchAssociative($sql, $parameters); + + return false === $row ? null : $this->snapshot($row); + } + + /** + * @param array $row + */ + private function snapshot(array $row): MaintenanceRateSnapshot + { + /** @var array{id:string,member_id:string,hourly_amount:string,currency:string,effective_from:string} $row */ + return new MaintenanceRateSnapshot($row['id'], $row['member_id'], $row['hourly_amount'], $row['currency'], $row['effective_from']); + } +} diff --git a/src/MaintenanceCost/Infrastructure/Adapter/Reporting/MaintenanceCostReadAdapter.php b/src/MaintenanceCost/Infrastructure/Adapter/Reporting/MaintenanceCostReadAdapter.php new file mode 100644 index 000000000..43f7421e1 --- /dev/null +++ b/src/MaintenanceCost/Infrastructure/Adapter/Reporting/MaintenanceCostReadAdapter.php @@ -0,0 +1,49 @@ +projection->view($organizationId, $interventionId); + } + // #endregion +} diff --git a/src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Currency/MaintenanceCurrencyRecord.php b/src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Currency/MaintenanceCurrencyRecord.php new file mode 100644 index 000000000..2979d3cea --- /dev/null +++ b/src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Currency/MaintenanceCurrencyRecord.php @@ -0,0 +1,23 @@ + 'EUR'])] + public string $currency = 'EUR'; + + #[ORM\Column(type: 'boolean', options: ['default' => false])] + public bool $locked = false; +} diff --git a/src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Rate/MaintenanceRateRecord.php b/src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Rate/MaintenanceRateRecord.php new file mode 100644 index 000000000..087fe70ed --- /dev/null +++ b/src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Rate/MaintenanceRateRecord.php @@ -0,0 +1,36 @@ + + */ + #[ORM\Column(type: 'json')] + public array $resources = []; + + #[ORM\Column(type: 'integer')] + public int $revision = 0; +} diff --git a/src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Record/MaintenanceCostSnapshotRecord.php b/src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Record/MaintenanceCostSnapshotRecord.php new file mode 100644 index 000000000..58d0cca75 --- /dev/null +++ b/src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Record/MaintenanceCostSnapshotRecord.php @@ -0,0 +1,67 @@ + + */ + #[ORM\Column(name: 'planning_resources', type: 'json')] + public array $planningResources = []; + + /** + * @var list + */ + #[ORM\Column(type: 'json')] + public array $items = []; +} diff --git a/src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Record/MaintenanceExpenseRecord.php b/src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Record/MaintenanceExpenseRecord.php new file mode 100644 index 000000000..cfcfd9736 --- /dev/null +++ b/src/MaintenanceCost/Infrastructure/Persistence/Doctrine/Record/MaintenanceExpenseRecord.php @@ -0,0 +1,56 @@ +entityManager->find(MaintenanceCostPlanningRecord::class, ['organizationId' => $organizationId, 'interventionId' => $interventionId]); + + return $record instanceof MaintenanceCostPlanningRecord ? new MaintenanceCostPlanning($record->plannedBudget, $record->estimatedMinutes, $record->resources, $record->revision) : new MaintenanceCostPlanning(); + } + + public function savePlanning(string $organizationId, string $interventionId, MaintenanceCostPlanning $planning): void + { + $record = $this->entityManager->find(MaintenanceCostPlanningRecord::class, ['organizationId' => $organizationId, 'interventionId' => $interventionId]); + if (!$record instanceof MaintenanceCostPlanningRecord) { + $record = new MaintenanceCostPlanningRecord(); + $record->organizationId = $organizationId; + $record->interventionId = $interventionId; + $this->entityManager->persist($record); + } + $record->plannedBudget = $planning->plannedBudget; + $record->estimatedMinutes = $planning->estimatedMinutes; + $record->resources = $planning->resources; + $record->revision = $planning->revision; + $this->entityManager->flush(); + } + + public function expenses(string $organizationId, string $interventionId): array + { + $records = $this->entityManager->getRepository(MaintenanceExpenseRecord::class)->findBy(['organizationId' => $organizationId, 'interventionId' => $interventionId], ['incurredAt' => 'ASC', 'id' => 'ASC'], 10001); + if (count($records) > 10000) { + throw MaintenanceCostException::conflict('Cost facts exceed the bounded calculation size.'); + } + $expenses = []; + foreach ($records as $record) { + $expenses[] = $this->expenseView($record); + } + + return $expenses; + } + + public function expenseByClientId(string $organizationId, string $clientId): ?MaintenanceExpense + { + $record = $this->entityManager->getRepository(MaintenanceExpenseRecord::class)->findOneBy(['organizationId' => $organizationId, 'clientId' => $clientId]); + + return $record instanceof MaintenanceExpenseRecord ? $this->expenseView($record) : null; + } + + public function lockExpenseIdentity(string $organizationId, string $clientId): void + { + if (!$this->entityManager->getConnection()->isTransactionActive()) { + throw MaintenanceCostException::conflict('Expense declarations require the main transaction.'); + } + $this->entityManager->getConnection()->executeQuery('SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))', ['key' => 'maintenance-expense:' . $organizationId . ':' . $clientId]); + } + + public function expense(string $organizationId, string $id): ?MaintenanceExpense + { + $record = $this->entityManager->getRepository(MaintenanceExpenseRecord::class)->findOneBy(['organizationId' => $organizationId, 'id' => $id]); + + return $record instanceof MaintenanceExpenseRecord ? $this->expenseView($record) : null; + } + + public function saveExpense(MaintenanceExpense $expense): void + { + $record = new MaintenanceExpenseRecord(); + foreach (['id', 'organizationId', 'interventionId', 'workItemId', 'clientId', 'amount', 'currency', 'description', 'incurredAt', 'adjustmentOf', 'createdBy', 'payloadHash', 'createdAt'] as $property) { + $record->{$property} = $expense->{$property}; + } + $this->entityManager->persist($record); + $this->entityManager->flush(); + } + + public function snapshot(string $organizationId, string $interventionId): ?MaintenanceCostSnapshot + { + $record = $this->entityManager->find(MaintenanceCostSnapshotRecord::class, ['organizationId' => $organizationId, 'interventionId' => $interventionId]); + if (!$record instanceof MaintenanceCostSnapshotRecord) { + return null; + } + $items = []; + foreach ($record->items as $item) { + $items[] = new MaintenanceCostItem($item['id'], $item['kind'], $item['workItemId'], $item['sourceId'], $item['sourceRevision'], $item['amount'], $item['currency'], $item['description'], $item['occurredAt'], $item['correctionOf'], $item['hourlyAmount'], $item['rateId'], $item['equipmentId'] ?? null, $item['allocation'] ?? null); + } + + return new MaintenanceCostSnapshot($record->version, $record->capturedAt, $record->publicationId, $record->interventionRevision, $record->currency, new MaintenanceCostTotals($record->total, $record->knownTotal, $record->complete, $items), new MaintenanceCostPlanning($record->plannedBudget, $record->estimatedMinutes, $record->planningResources, $record->planningRevision)); + } + + public function saveSnapshot(string $organizationId, string $interventionId, MaintenanceCostSnapshot $snapshot): void + { + $existing = $this->snapshot($organizationId, $interventionId); + if (null !== $existing) { + if ($existing->publicationId !== $snapshot->publicationId) { + throw MaintenanceCostException::conflict('A published cost snapshot cannot be replaced.'); + } + + return; + } + $record = new MaintenanceCostSnapshotRecord(); + $record->organizationId = $organizationId; + $record->interventionId = $interventionId; + $record->publicationId = $snapshot->publicationId; + $record->interventionRevision = $snapshot->interventionRevision; + $record->capturedAt = $snapshot->capturedAt; + $record->version = $snapshot->version; + $record->currency = $snapshot->currency; + $record->total = $snapshot->totals->total; + $record->knownTotal = $snapshot->totals->knownTotal; + $record->complete = $snapshot->totals->complete; + $planning = $snapshot->planning ?? new MaintenanceCostPlanning(); + $record->plannedBudget = $planning->plannedBudget; + $record->estimatedMinutes = $planning->estimatedMinutes; + $record->planningResources = $planning->resources; + $record->planningRevision = $planning->revision; + foreach ($snapshot->totals->items as $item) { + $record->items[] = $item->toArray(); + } + $this->entityManager->persist($record); + $this->entityManager->flush(); + } + + /** + * Method economicInterventionIds + * + * Applies all target filters to one captured contribution before returning bounded owned identities. + * + * @access public + * + * @param string $organizationId authorized organization + * @param ?string $siteId captured site filter + * @param ?string $customerId captured client filter + * @param ?string $equipmentId captured equipment filter + * + * @return list candidate intervention identifiers + */ + public function economicInterventionIds(string $organizationId, ?string $siteId, ?string $customerId, ?string $equipmentId): array + { + $targets = []; + $parameters = ['organization' => $organizationId]; + foreach (['site' => $siteId, 'customer' => $customerId, 'equipment' => $equipmentId] as $target => $identifier) { + if (null !== $identifier) { + $targets[] = "j -> 'allocation' -> '" . $target . "' ->> 'id' = :" . $target; + $parameters[$target] = $identifier; + } + } + if ([] === $targets) { + return []; + } + /** @var list $ids */ + $ids = $this->entityManager->getConnection()->fetchFirstColumn('SELECT intervention_id FROM maintenance_cost_snapshots WHERE organization_id = :organization AND EXISTS (SELECT 1 FROM jsonb_array_elements(items::jsonb) j WHERE ' . implode(' AND ', $targets) . ') ORDER BY intervention_id LIMIT 10001', $parameters); + if (count($ids) > 10000) { + throw MaintenanceCostException::invalid('The captured financial scope exceeds 10000 interventions; narrow its target filters.'); + } + + return $ids; + } + + private function expenseView(MaintenanceExpenseRecord $record): MaintenanceExpense + { + return new MaintenanceExpense($record->id, $record->organizationId, $record->interventionId, $record->workItemId, $record->clientId, $record->amount, $record->currency, $record->description, $record->incurredAt, $record->adjustmentOf, $record->createdBy, $record->payloadHash, $record->createdAt); + } +} diff --git a/src/MaintenanceCost/MODULE.md b/src/MaintenanceCost/MODULE.md new file mode 100644 index 000000000..41bd51d1c --- /dev/null +++ b/src/MaintenanceCost/MODULE.md @@ -0,0 +1,67 @@ +# MaintenanceCost + +## Overview + +MaintenanceCost owns private internal maintenance finance. It records exact six-decimal amounts, planning budgets, append-only external expenses, organization currency and effective hourly rates. Currency is organization-wide and becomes immutable after the first stock or cost fact. + +Intervention remains owner of operational work and its independent time journal. Inventory remains owner of physical movements and material valuations. MaintenanceCost consumes their published Application ports and contracts; it never reads their persistence records. Unknown rates or material prices remain explicit incomplete values, never zeroes. + +The ordinary intervention dossier, closure snapshot, client reports and PDFs do not contain financial amounts. Dedicated `organization.maintenance_cost.read` and `.manage` permissions protect the finance endpoints. Main database transactions coordinate immutable private snapshots with intervention publication. Current realized costs read later append-only expenses, corrected time and late material facts; those facts never rewrite the published cost snapshot. + +Public currency and rate ports support Procurement and Inventory with one exact currency. Commercial quotes, invoicing, taxes and payments remain in the external ERP; Billing retains SaaS subscription responsibility. + +## API endpoints + +- `GET /api/organizations/{organizationId}/interventions/{id}/costs`: exact current contributions, incomplete flags and the separate frozen snapshot. Source corrections reference their original item; declarations received after publication reference its private publication identity. +- `PATCH .../costs/planning`: partial budget, minutes and estimated resources under `If-Match: "revision-N"`, independently from the operational revision. Preparation closes upon submission. Resource kind determines the calculation: material uses quantity × unit cost, time uses hourly amount × minutes / 60, external uses its explicit amount. Non-null fields belonging to another kind are rejected. Missing calculation inputs leave the amount unknown rather than retaining an earlier computed amount. +- `POST .../costs/expenses`: stable `clientId`, literal decimal amount, actual timezone-qualified date, description and optional work item. A correction appends a signed delta referencing `adjustmentOf`; original expenses are never edited. Replaying the identical actor/payload retains one fact. +- `GET/PATCH /api/organizations/{organizationId}/maintenance-cost/currency`: private settings; a currency change after locking returns 409, identical replay remains accepted. +- `GET/POST /api/organizations/{organizationId}/maintenance-cost/rates`: append-only effective rates by member; stable client UUID and uniqueness per organization/member/date prevent replacement on replay. Each source valuation captures its applied rate and currency. + +Publication acquires the Inventory intervention fence before the operational work lock. Final pending-declaration validation and private snapshot capture run on the same `main` transaction as resources, results, ordinary dossier and publication status. A later failure rolls back every capture. Financial values never enter ordinary issue messages or publication reports. + +- `GET /api/organizations/{organizationId}/maintenance-cost/reports`: private equipment, root site or internal customer aggregates. Required inclusive UTC calendar `from`/`to` dates span at most 366 days. Optional `siteId`, `customerId` and `equipmentId` filters retain matching targets and expose other allocated amounts in reconciliation. Pages contain at most 100 allocated rows; full-scope totals and the separate unallocated row do not change between pages. +- `GET /api/organizations/{organizationId}/maintenance-cost/dossiers`: a server-paginated minimal finance directory with title/number search, optional paired dates and target filters. Its equipment identities and filters include direct material allocations without operational tasks, from both current and captured financial facts. Financial readers can select minimal site/client/equipment identities and open private costs without ordinary Intervention, Facility, Customer or Equipment read permissions. No amounts, contacts, documents, proof bodies or operational dossier cross this endpoint. + +## Flows + +Reporting selects interventions by completed publication date, or planned start/creation for work that remains unpublished. These dates describe the work dossier, not the occurrence date of each time or material fact. The organization date window is bounded to 500 candidate interventions before applying financial target filters, because direct material references need not occur among operational tasks. An oversized candidate window requires narrower dates; target filters cannot lift that cap. At most 50000 combined financial contributions are calculated. No truncated total is returned. The operational finance directory independently remains paginated when a report window is too large. + +Each financial contribution is allocated once. Time and targeted expenses follow their work item. Material keeps its direct equipment identity even without a work item. Financial filters first retain matching source dossiers from current/frozen contribution identities and captured operational targets, then separate known different targets from the selected amounts. Missing target scope stays explicitly unallocated rather than being silently treated as a different site or client. Global expenses and budgets remain unallocated within the matching dossiers. All retained amounts reconcile against the selected source dossiers, with excluded targets shown separately; they are not claimed as attributable to a selected asset. + +The planned amount sums estimated resources when present; otherwise it uses the global budget as an unallocated baseline. The separate budget column never adds the budget to its resource estimate. Missing estimates or prices remain unknown, with a known subtotal. A variance is produced only when current and planned costs are complete and a planning baseline exists. + +The procurement overview is organization-wide within the order-creation date window, independent of equipment/site/client filters. It reports active commitments, gross physical receipts, receivable remainder, motivated returns and pending individualization. Receipts and returns on the selected orders include their full retained history; this basis is explicit in the response. Supplier contacts never enter the overview. + +New private publication contributions capture minimal equipment/site/client identity through owning public ports. Corrections to the same source preserve its captured identity. Existing financial JSON without allocation remains readable. Legacy allocations are derived only from an unambiguous captured task map; absent historical identities remain incomplete or unallocated, including a published dossier with no private financial snapshot. Current park identities are allowed only for unpublished work or a proven new fact anchored to the same private publication identity; its later corrections follow that source allocation. Reading an aggregate never replaces a published snapshot. + +The directory adds verified financial target identities to the owning operational query before its exact count, search/date filters and pagination. Captured allocations remain selectable after a move or retirement. Candidate material references alone cannot establish a historic site or client. Filtered directory discovery is bounded to 10000 distinct financial candidate interventions and 50000 combined current/frozen contributions; oversized scopes fail explicitly. Unfiltered pages read financial identities only for their at most 100 selected dossiers. These directory bounds are independent of the report's 500-intervention date-window cap. + +## Architecture + +MaintenanceCostReadPort publishes the existing typed private cost view to trusted application callers such as ERP export. Those callers must authorize organization scope and financial read before invoking it. Report and directory handlers apply the same explicit guard before querying public Intervention, Inventory and Procurement ports. MaintenanceEconomicDirectory discovers only bounded identity candidates through owner-published ports and verifies their private allocations. The module never reads sibling persistence records. + +MaintenanceEconomicAggregator sums exact decimal strings and retains null valuations, one allocation destination and a separate reconciliation for filtered targets. Amount arithmetic uses BCMath; large aggregate values remain exact beyond an individual amount's storage precision. MaintenanceCostAllocationResolver enriches current contributions through organization-scoped equipment and operational identity contracts. The publication adapter captures those identities in its owning atomic transaction. + +## Configuration + +`config/modules/maintenance_cost.yaml` registers the financial query handlers, trusted read adapter, public currency/rate ports and the failure subscriber. Every persistence consumer explicitly uses the main entity manager or connection. Organization financial read/manage permissions are independent of operational, inventory and procurement permissions. + +Allocation fields extend existing JSON snapshots additively and do not require a new table or rewrite of stored publications. Ordinary reports and PDFs continue to omit every internal amount. + +Additive main migration 20261007150000 widens only frozen `total` and `knownTotal` to NUMERIC(38,6), so summing valid individual eighteen-digit expenses or computed time costs cannot overflow a published aggregate. Individual expense, budget and hourly-rate input validation remains unchanged. + +## Testing + +Unit tests cover single allocation across multiple equipment targets, direct materials, missing historical identity, exact sums beyond eighteen digits, unknown prices, resource/budget separation, current versus frozen costs, compound target filters, full-scope pagination, reconciliation and strict leap/calendar bounds. Handler and provider tests cover finance authorization before source reads and refusal of partial or oversized source windows. + +Functional PostgreSQL tests exercise finance-only readers, ordinary operational denial, minimal server search, direct materials without tasks in directory names and target filters, exact filtered pages, captured targets after equipment moves, unknown historic publications, late expenses without snapshot replacement, organization 404, finance 403 and date/group/page 422. Procurement's owning reader separately proves partial receipts, returns, cancellation, unknown unit costs, exact sums, 500/501 limits and organization isolation. + +## Error codes + +- 403: financial read/manage entitlement missing within an active organization. +- 404: organization or intervention outside the caller's scope. +- 409: source currency mismatch, immutable snapshot conflict, or a source window that would return partial totals. +- 412: stale financial planning revision. +- 422: invalid exact value, report dates, grouping, filters or excessive bounded calculation. +- 428: missing planning revision. diff --git a/src/MaintenanceCost/Presentation/Api/Dto/Input/Cost/CreateMaintenanceExpenseInput.php b/src/MaintenanceCost/Presentation/Api/Dto/Input/Cost/CreateMaintenanceExpenseInput.php new file mode 100644 index 000000000..b963d1aa3 --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Dto/Input/Cost/CreateMaintenanceExpenseInput.php @@ -0,0 +1,29 @@ +> + */ + #[Groups(['maintenance_cost:write'])] + public array $resources = []; +} diff --git a/src/MaintenanceCost/Presentation/Api/Dto/Input/Currency/ConfigureMaintenanceCurrencyInput.php b/src/MaintenanceCost/Presentation/Api/Dto/Input/Currency/ConfigureMaintenanceCurrencyInput.php new file mode 100644 index 000000000..1537e7c93 --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Dto/Input/Currency/ConfigureMaintenanceCurrencyInput.php @@ -0,0 +1,17 @@ + + */ + #[Groups(['maintenance_cost:read'])] + public array $resources = []; + + /** + * @var array + */ + #[Groups(['maintenance_cost:read'])] + public array $current = []; + + /** + * @var array|null + */ + #[Groups(['maintenance_cost:read'])] + public ?array $frozen = null; + + public static function fromView(MaintenanceCostView $view): self + { + $output = new self(); + $output->id = $view->interventionId; + $output->interventionId = $view->interventionId; + $output->organizationId = $view->organizationId; + $output->currency = $view->currency; + $output->plannedBudget = $view->planning->plannedBudget; + $output->estimatedMinutes = $view->planning->estimatedMinutes; + $output->planningRevision = $view->planning->revision; + $output->planningEditable = $view->planningEditable; + $output->resources = $view->planning->resources; + $output->current = ['total' => $view->current->total, 'knownTotal' => $view->current->knownTotal, 'complete' => $view->current->complete, 'items' => array_map(static fn (MaintenanceCostItem $item): array => $item->toArray(), $view->current->items)]; + if (null !== $view->frozen) { + $snapshot = $view->frozen; + $output->frozen = ['version' => $snapshot->version, 'capturedAt' => $snapshot->capturedAt, 'publicationId' => $snapshot->publicationId, 'interventionRevision' => $snapshot->interventionRevision, 'currency' => $snapshot->currency, 'total' => $snapshot->totals->total, 'knownTotal' => $snapshot->totals->knownTotal, 'complete' => $snapshot->totals->complete, 'plannedBudget' => $snapshot->planning?->plannedBudget, 'estimatedMinutes' => $snapshot->planning?->estimatedMinutes, 'resources' => $snapshot->planning->resources ?? [], 'items' => array_map(static fn (MaintenanceCostItem $item): array => $item->toArray(), $snapshot->totals->items)]; + } + + return $output; + } +} diff --git a/src/MaintenanceCost/Presentation/Api/Dto/Output/Currency/MaintenanceCurrencyOutput.php b/src/MaintenanceCost/Presentation/Api/Dto/Output/Currency/MaintenanceCurrencyOutput.php new file mode 100644 index 000000000..359fa8546 --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Dto/Output/Currency/MaintenanceCurrencyOutput.php @@ -0,0 +1,31 @@ +organizationId = $snapshot->organizationId; + $output->currency = $snapshot->currency; + $output->locked = $snapshot->locked; + + return $output; + } +} diff --git a/src/MaintenanceCost/Presentation/Api/Dto/Output/Rate/MaintenanceRateOutput.php b/src/MaintenanceCost/Presentation/Api/Dto/Output/Rate/MaintenanceRateOutput.php new file mode 100644 index 000000000..227950af1 --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Dto/Output/Rate/MaintenanceRateOutput.php @@ -0,0 +1,43 @@ +id = $snapshot->id; + $output->memberId = $snapshot->memberId; + $output->hourlyAmount = $snapshot->hourlyAmount; + $output->currency = $snapshot->currency; + $output->effectiveFrom = $snapshot->effectiveFrom; + $output->replayed = $replayed; + + return $output; + } +} diff --git a/src/MaintenanceCost/Presentation/Api/Dto/Output/Reporting/MaintenanceEconomicDossierOutput.php b/src/MaintenanceCost/Presentation/Api/Dto/Output/Reporting/MaintenanceEconomicDossierOutput.php new file mode 100644 index 000000000..33de15e76 --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Dto/Output/Reporting/MaintenanceEconomicDossierOutput.php @@ -0,0 +1,91 @@ + + */ + #[Groups(['maintenance_economic:read'])] + public array $equipment = []; + + /** + * @param list $financialEquipment minimal authorized financial identities + */ + public static function fromContext(InterventionEconomicContext $context, array $financialEquipment = []): self + { + $output = new self(); + foreach (['id', 'number', 'name', 'type', 'status', 'site', 'customer', 'snapshotState', 'identityComplete'] as $field) { + $output->{$field} = $context->{$field}; + } + $output->publishedAt = $context->publishedAt?->format('c'); + $equipment = []; + foreach ($context->workItems as $work) { + if (null !== $work->equipmentId) { + $equipment[$work->equipmentId] = ['id' => $work->equipmentId, 'name' => $work->equipmentIdentity?->name, 'assetReference' => $work->equipmentIdentity?->assetReference, 'site' => $work->site, 'customer' => $work->customer]; + } + } + foreach ($financialEquipment as $identity) { + $key = $identity['id']; + if (!isset($equipment[$key])) { + $equipment[$key] = $identity; + } elseif (($equipment[$key]['site']['id'] ?? null) !== ($identity['site']['id'] ?? null) || ($equipment[$key]['customer']['id'] ?? null) !== ($identity['customer']['id'] ?? null)) { + $equipment[$key . ':' . ($identity['site']['id'] ?? '') . ':' . ($identity['customer']['id'] ?? '')] = $identity; + } + } + $output->equipment = array_values($equipment); + + return $output; + } +} diff --git a/src/MaintenanceCost/Presentation/Api/Dto/Output/Reporting/MaintenanceEconomicReportOutput.php b/src/MaintenanceCost/Presentation/Api/Dto/Output/Reporting/MaintenanceEconomicReportOutput.php new file mode 100644 index 000000000..4b6e4ce08 --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Dto/Output/Reporting/MaintenanceEconomicReportOutput.php @@ -0,0 +1,144 @@ +> + */ + #[Groups(['maintenance_economic:read'])] + public array $rows = []; + + /** + * @var array + */ + #[Groups(['maintenance_economic:read'])] + public array $unallocated = []; + + /** + * @var array + */ + #[Groups(['maintenance_economic:read'])] + public array $current = []; + + /** + * @var array + */ + #[Groups(['maintenance_economic:read'])] + public array $frozen = []; + + /** + * @var array + */ + #[Groups(['maintenance_economic:read'])] + public array $planned = []; + + /** + * @var array + */ + #[Groups(['maintenance_economic:read'])] + public array $budget = []; + + #[Groups(['maintenance_economic:read'])] + public ?string $variance = null; + + /** + * @var array + */ + #[Groups(['maintenance_economic:read'])] + public array $reconciliation = []; + + /** + * @var array + */ + #[Groups(['maintenance_economic:read'])] + public array $procurement = []; + + /** + * @var list + */ + #[Groups(['maintenance_economic:read'])] + public array $dossiers = []; + + public static function fromReport(MaintenanceEconomicReport $report): self + { + $output = new self(); + $output->id = $report->organizationId; + foreach (['organizationId', 'from', 'to', 'groupBy', 'currency', 'page', 'itemsPerPage', 'totalItems', 'interventionCount', 'publishedInterventionCount', 'liveInterventionCount', 'missingSnapshotCount', 'variance'] as $field) { + $output->{$field} = $report->{$field}; + } + $output->rows = array_map(static fn (MaintenanceEconomicRow $row): array => $row->toArray(), $report->rows); + $output->dossiers = $report->dossiers; + $output->unallocated = $report->unallocated->toArray(); + foreach (['current', 'frozen', 'planned', 'budget'] as $field) { + $output->{$field} = $report->{$field}->toArray(); + } + foreach ($report->reconciliation as $key => $value) { + $output->reconciliation[$key] = $value instanceof MaintenanceEconomicAmount ? $value->toArray() : $value; + } + $overview = $report->procurement; + $output->procurement = ['organizationId' => $overview->organizationId, 'currency' => $overview->currency, 'from' => $overview->from, 'to' => $overview->to, 'basis' => $overview->basis, 'receiptScope' => $overview->receiptScope, 'orderCount' => $overview->orderCount, 'receiptCount' => $overview->receiptCount, 'pendingIndividualizationCount' => $overview->pendingIndividualizationCount]; + foreach (['ordered', 'received', 'outstanding', 'returned'] as $field) { + /** @var ProcurementEconomicAmount $amount */ + $amount = $overview->{$field}; + $output->procurement[$field] = ['total' => $amount->total, 'knownTotal' => $amount->knownTotal, 'complete' => $amount->complete]; + } + + return $output; + } +} diff --git a/src/MaintenanceCost/Presentation/Api/EventSubscriber/MaintenanceCostFailureSubscriber.php b/src/MaintenanceCost/Presentation/Api/EventSubscriber/MaintenanceCostFailureSubscriber.php new file mode 100644 index 000000000..b29206cb6 --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/EventSubscriber/MaintenanceCostFailureSubscriber.php @@ -0,0 +1,52 @@ + + */ + public static function getSubscribedEvents(): array + { + return [KernelEvents::EXCEPTION => ['onException', 11]]; + } + + public function onException(ExceptionEvent $event): void + { + $operation = $event->getRequest()->attributes->get('_api_operation_name', ''); + $costOperation = is_string($operation) && (str_starts_with($operation, 'maintenance_cost_') || str_starts_with($operation, 'maintenance_economic_')); + $error = $event->getThrowable(); + do { + $reason = $error instanceof MaintenanceCostException ? $error->reason : ($costOperation && ($error instanceof InvalidValueException || $error instanceof NotNormalizableValueException) ? 'maintenance_cost_invalid' : null); + if (null !== $reason) { + $status = match ($reason) { + 'maintenance_cost_not_found' => 404, + 'maintenance_cost_access_denied' => 403, + 'maintenance_cost_conflict' => 409, + 'maintenance_cost_revision_stale' => 412, + 'maintenance_cost_precondition_required' => 428, + default => 422, + }; + $event->setResponse(new JsonResponse(['type' => '/errors/' . $reason, 'title' => 'Maintenance cost operation refused', 'status' => $status, 'code' => $reason, 'detail' => $error->getMessage()], $status, ['Content-Type' => 'application/problem+json'])); + + return; + } + $error = $error->getPrevious(); + } while (null !== $error); + } +} diff --git a/src/MaintenanceCost/Presentation/Api/Operation/Cost/MaintenanceCostOperations.php b/src/MaintenanceCost/Presentation/Api/Operation/Cost/MaintenanceCostOperations.php new file mode 100644 index 000000000..129174880 --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Operation/Cost/MaintenanceCostOperations.php @@ -0,0 +1,15 @@ + + */ +final readonly class MaintenanceCostProcessor implements ProcessorInterface +{ + public function __construct(private CommandBusPort $commands, private CurrentActorPort $actor, private RequestStack $requests) + { + } + + /** + * @param array $uriVariables @param array $context + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): MaintenanceCostOutput + { + $actorId = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $organizationId = is_string($uriVariables['organizationId'] ?? null) ? $uriVariables['organizationId'] : ''; + $interventionId = is_string($uriVariables['id'] ?? null) ? $uriVariables['id'] : ''; + $request = $this->requests->getCurrentRequest(); + $header = $request?->headers->get('If-Match'); + $revision = null === $header ? null : (1 === preg_match('/^"revision-(\d+)"$/D', $header, $matches) ? (int) $matches[1] : -1); + /** @var WriteMaintenanceCostResult $result */ + $result = $this->commands->dispatch(new WriteMaintenanceCostCommand($actorId, $organizationId, $interventionId, MaintenanceCostOperations::PLANNING === $operation->getName() ? 'planning' : 'expense', $request?->getPayload()->all() ?? [], $revision)); + + return MaintenanceCostOutput::fromView($result->view); + } +} diff --git a/src/MaintenanceCost/Presentation/Api/Processor/Currency/ConfigureMaintenanceCurrencyProcessor.php b/src/MaintenanceCost/Presentation/Api/Processor/Currency/ConfigureMaintenanceCurrencyProcessor.php new file mode 100644 index 000000000..b1e5e4b52 --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Processor/Currency/ConfigureMaintenanceCurrencyProcessor.php @@ -0,0 +1,38 @@ + */ +final readonly class ConfigureMaintenanceCurrencyProcessor implements ProcessorInterface +{ + public function __construct(private CommandBusPort $commands, private CurrentActorPort $actor) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): MaintenanceCurrencyOutput + { + $actor = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $organizationId = $uriVariables['organizationId'] ?? null; + if (!$data instanceof ConfigureMaintenanceCurrencyInput || !is_string($organizationId)) { + throw MaintenanceCostException::invalid('Invalid currency configuration request.'); + } + /** @var ConfigureMaintenanceCurrencyResult $result */ + $result = $this->commands->dispatch(new ConfigureMaintenanceCurrencyCommand($organizationId, $actor, $data->currency)); + + return MaintenanceCurrencyOutput::fromSnapshot($result->currency); + } +} diff --git a/src/MaintenanceCost/Presentation/Api/Processor/Rate/CreateMaintenanceRateProcessor.php b/src/MaintenanceCost/Presentation/Api/Processor/Rate/CreateMaintenanceRateProcessor.php new file mode 100644 index 000000000..3eec7a57c --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Processor/Rate/CreateMaintenanceRateProcessor.php @@ -0,0 +1,38 @@ + */ +final readonly class CreateMaintenanceRateProcessor implements ProcessorInterface +{ + public function __construct(private CommandBusPort $commands, private CurrentActorPort $actor) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): MaintenanceRateOutput + { + $actor = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $organizationId = $uriVariables['organizationId'] ?? null; + if (!$data instanceof CreateMaintenanceRateInput || !is_string($organizationId)) { + throw MaintenanceCostException::invalid('Invalid hourly rate request.'); + } + /** @var CreateMaintenanceRateResult $result */ + $result = $this->commands->dispatch(new CreateMaintenanceRateCommand($organizationId, $actor, $data->memberId, $data->hourlyAmount, $data->effectiveFrom, $data->clientId)); + + return MaintenanceRateOutput::fromSnapshot($result->rate, $result->replayed); + } +} diff --git a/src/MaintenanceCost/Presentation/Api/Provider/Cost/MaintenanceCostProvider.php b/src/MaintenanceCost/Presentation/Api/Provider/Cost/MaintenanceCostProvider.php new file mode 100644 index 000000000..c4950533c --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Provider/Cost/MaintenanceCostProvider.php @@ -0,0 +1,43 @@ + + */ +final readonly class MaintenanceCostProvider implements ProviderInterface +{ + public function __construct(private QueryBusPort $queries, private CurrentActorPort $actor) + { + } + + /** + * @param array $uriVariables @param array $context + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): MaintenanceCostOutput + { + $actorId = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $organizationId = is_string($uriVariables['organizationId'] ?? null) ? $uriVariables['organizationId'] : ''; + $interventionId = is_string($uriVariables['id'] ?? null) ? $uriVariables['id'] : ''; + /** @var GetMaintenanceCostResult $result */ + $result = $this->queries->ask(new GetMaintenanceCostQuery($actorId, $organizationId, $interventionId)); + + return MaintenanceCostOutput::fromView($result->view); + } +} diff --git a/src/MaintenanceCost/Presentation/Api/Provider/Currency/MaintenanceCurrencyProvider.php b/src/MaintenanceCost/Presentation/Api/Provider/Currency/MaintenanceCurrencyProvider.php new file mode 100644 index 000000000..523fd745f --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Provider/Currency/MaintenanceCurrencyProvider.php @@ -0,0 +1,37 @@ + */ +final readonly class MaintenanceCurrencyProvider implements ProviderInterface +{ + public function __construct(private QueryBusPort $queries, private CurrentActorPort $actor) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): MaintenanceCurrencyOutput + { + $actor = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $organizationId = $uriVariables['organizationId'] ?? null; + if (!is_string($organizationId)) { + throw MaintenanceCostException::notFound(); + } + /** @var ReadMaintenanceCurrencyResult $result */ + $result = $this->queries->ask(new ReadMaintenanceCurrencyQuery($organizationId, $actor)); + + return MaintenanceCurrencyOutput::fromSnapshot($result->currency); + } +} diff --git a/src/MaintenanceCost/Presentation/Api/Provider/Rate/MaintenanceRateProvider.php b/src/MaintenanceCost/Presentation/Api/Provider/Rate/MaintenanceRateProvider.php new file mode 100644 index 000000000..a031ede9f --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Provider/Rate/MaintenanceRateProvider.php @@ -0,0 +1,46 @@ + */ +final readonly class MaintenanceRateProvider implements ProviderInterface +{ + public function __construct(private QueryBusPort $queries, private CurrentActorPort $actor, private RequestStack $requests) + { + } + + /** + * @return TraversablePaginator + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator + { + $actor = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $organizationId = $uriVariables['organizationId'] ?? null; + if (!is_string($organizationId)) { + throw MaintenanceCostException::notFound(); + } + $request = $this->requests->getCurrentRequest(); + $member = $request?->query->getString('memberId', '') ?? ''; + /** @var ListMaintenanceRatesResult $result */ + $result = $this->queries->ask(new ListMaintenanceRatesQuery($organizationId, $actor, '' === $member ? null : $member, $request?->query->getInt('page', 1) ?? 1, $request?->query->getInt('itemsPerPage', 30) ?? 30)); + + return new TraversablePaginator(new ArrayIterator(array_map(static fn ($rate): MaintenanceRateOutput => MaintenanceRateOutput::fromSnapshot($rate), $result->items)), (float) $result->page, (float) $result->itemsPerPage, (float) $result->total); + } +} diff --git a/src/MaintenanceCost/Presentation/Api/Provider/Reporting/MaintenanceEconomicProvider.php b/src/MaintenanceCost/Presentation/Api/Provider/Reporting/MaintenanceEconomicProvider.php new file mode 100644 index 000000000..e1eb2f9d6 --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Provider/Reporting/MaintenanceEconomicProvider.php @@ -0,0 +1,74 @@ + + */ +final readonly class MaintenanceEconomicProvider implements ProviderInterface +{ + public function __construct(private QueryBusPort $queries, private CurrentActorPort $actor, private RequestStack $requests) + { + } + + /** + * @param array $uriVariables + * @param array $context + * + * @return MaintenanceEconomicReportOutput|TraversablePaginator + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): MaintenanceEconomicReportOutput|TraversablePaginator + { + $actor = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $organizationId = is_string($uriVariables['organizationId'] ?? null) ? $uriVariables['organizationId'] : ''; + $request = $this->requests->getCurrentRequest(); + $page = $request?->query->getInt('page', 1) ?? 1; + $limit = $request?->query->getInt('itemsPerPage', 30) ?? 30; + $siteId = $this->filter('siteId'); + $customerId = $this->filter('customerId'); + $equipmentId = $this->filter('equipmentId'); + if (MaintenanceEconomicOperations::DOSSIERS === $operation->getName()) { + /** @var ListMaintenanceEconomicDossiersResult $result */ + $result = $this->queries->ask(new ListMaintenanceEconomicDossiersQuery($actor, $organizationId, $page, $limit, $this->filter('search'), $this->filter('from'), $this->filter('to'), $siteId, $customerId, $equipmentId)); + $directory = $result->page; + + return new TraversablePaginator(new ArrayIterator(array_map(static fn (InterventionEconomicContext $context): MaintenanceEconomicDossierOutput => MaintenanceEconomicDossierOutput::fromContext($context, $result->equipment[$context->id] ?? []), $directory->items)), (float) $directory->page, (float) $directory->itemsPerPage, (float) $directory->totalItems); + } + /** @var ReadMaintenanceEconomicReportResult $result */ + $result = $this->queries->ask(new ReadMaintenanceEconomicReportQuery($actor, $organizationId, $this->filter('from') ?? '', $this->filter('to') ?? '', $this->filter('groupBy') ?? 'equipment', $siteId, $customerId, $equipmentId, $page, $limit)); + + return MaintenanceEconomicReportOutput::fromReport($result->report); + } + + private function filter(string $name): ?string + { + $value = $this->requests->getCurrentRequest()?->query->getString($name, '') ?? ''; + + return '' === $value ? null : $value; + } +} diff --git a/src/MaintenanceCost/Presentation/Api/Resource/Cost/MaintenanceCostResource.php b/src/MaintenanceCost/Presentation/Api/Resource/Cost/MaintenanceCostResource.php new file mode 100644 index 000000000..e112bf26f --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Resource/Cost/MaintenanceCostResource.php @@ -0,0 +1,29 @@ + ['maintenance_cost:read']], + denormalizationContext: ['groups' => ['maintenance_cost:write'], 'allow_extra_attributes' => false], + operations: [ + new Get(name: MaintenanceCostOperations::READ, uriTemplate: '/organizations/{organizationId}/interventions/{id}/costs', output: MaintenanceCostOutput::class, provider: MaintenanceCostProvider::class, openapi: new Operation(tags: ['MaintenanceCost'], summary: 'Read private current and frozen intervention costs', responses: [200 => new Response(description: 'Private internal costs'), 403 => new Response(description: 'Financial read permission missing'), 404 => new Response(description: 'Intervention unavailable')])), + new Patch(name: MaintenanceCostOperations::PLANNING, uriTemplate: '/organizations/{organizationId}/interventions/{id}/costs/planning', read: false, status: 200, input: WriteCostPlanningInput::class, output: MaintenanceCostOutput::class, processor: MaintenanceCostProcessor::class, openapi: new Operation(tags: ['MaintenanceCost'], summary: 'Prepare an internal budget and estimated resources', parameters: [new Parameter(name: 'If-Match', in: 'header', required: true, schema: ['type' => 'string', 'example' => '"revision-0"'])], responses: [200 => new Response(description: 'Financial preparation updated'), 409 => new Response(description: 'Preparation already closed'), 412 => new Response(description: 'Stale planning revision'), 428 => new Response(description: 'Planning revision required')])), + new Post(name: MaintenanceCostOperations::EXPENSE, uriTemplate: '/organizations/{organizationId}/interventions/{id}/costs/expenses', read: false, status: 201, input: CreateMaintenanceExpenseInput::class, output: MaintenanceCostOutput::class, processor: MaintenanceCostProcessor::class, openapi: new Operation(tags: ['MaintenanceCost'], summary: 'Append a motivated external expense or correction', responses: [201 => new Response(description: 'Expense retained or exact replay'), 409 => new Response(description: 'Stable declaration identity conflict'), 422 => new Response(description: 'Invalid exact amount or source')])), + ], +)] +final class MaintenanceCostResource +{ +} diff --git a/src/MaintenanceCost/Presentation/Api/Resource/Currency/MaintenanceCurrencyResource.php b/src/MaintenanceCost/Presentation/Api/Resource/Currency/MaintenanceCurrencyResource.php new file mode 100644 index 000000000..4b8534806 --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Resource/Currency/MaintenanceCurrencyResource.php @@ -0,0 +1,28 @@ + ['maintenance_cost_currency:read']], + denormalizationContext: ['groups' => ['maintenance_cost_currency:write'], 'allow_extra_attributes' => false], + operations: [ + new Get(name: MaintenanceCurrencyOperations::READ, uriTemplate: '/organizations/{organizationId}/maintenance-cost/currency', output: MaintenanceCurrencyOutput::class, provider: MaintenanceCurrencyProvider::class, openapi: new Operation(tags: ['MaintenanceCost'], summary: 'Read the private organization cost currency')), + new Patch(name: MaintenanceCurrencyOperations::CONFIGURE, uriTemplate: '/organizations/{organizationId}/maintenance-cost/currency', input: ConfigureMaintenanceCurrencyInput::class, output: MaintenanceCurrencyOutput::class, read: false, status: 200, processor: ConfigureMaintenanceCurrencyProcessor::class, openapi: new Operation(tags: ['MaintenanceCost'], summary: 'Configure cost currency before its first financial fact')), + ], +)] +final class MaintenanceCurrencyResource +{ +} diff --git a/src/MaintenanceCost/Presentation/Api/Resource/Rate/MaintenanceRateResource.php b/src/MaintenanceCost/Presentation/Api/Resource/Rate/MaintenanceRateResource.php new file mode 100644 index 000000000..6ef037015 --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Resource/Rate/MaintenanceRateResource.php @@ -0,0 +1,30 @@ + ['maintenance_cost_rate:read']], + denormalizationContext: ['groups' => ['maintenance_cost_rate:write'], 'allow_extra_attributes' => false], + operations: [ + new GetCollection(name: MaintenanceRateOperations::LIST, uriTemplate: '/organizations/{organizationId}/maintenance-cost/rates', output: MaintenanceRateOutput::class, provider: MaintenanceRateProvider::class, paginationEnabled: true, paginationClientItemsPerPage: true, paginationItemsPerPage: 30, paginationMaximumItemsPerPage: 100, parameters: [ + 'memberId' => new QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], castToArray: false), + ], openapi: new Operation(tags: ['MaintenanceCost'], summary: 'List private exact hourly rates')), + new Post(name: MaintenanceRateOperations::CREATE, uriTemplate: '/organizations/{organizationId}/maintenance-cost/rates', input: CreateMaintenanceRateInput::class, output: MaintenanceRateOutput::class, read: false, status: 201, processor: CreateMaintenanceRateProcessor::class, openapi: new Operation(tags: ['MaintenanceCost'], summary: 'Append an effective hourly rate using a stable client identity')), + ], +)] +final class MaintenanceRateResource +{ +} diff --git a/src/MaintenanceCost/Presentation/Api/Resource/Reporting/MaintenanceEconomicResource.php b/src/MaintenanceCost/Presentation/Api/Resource/Reporting/MaintenanceEconomicResource.php new file mode 100644 index 000000000..5590dd278 --- /dev/null +++ b/src/MaintenanceCost/Presentation/Api/Resource/Reporting/MaintenanceEconomicResource.php @@ -0,0 +1,31 @@ + ['maintenance_economic:read']], + operations: [ + new Get(name: MaintenanceEconomicOperations::REPORT, uriTemplate: '/organizations/{organizationId}/maintenance-cost/reports', output: MaintenanceEconomicReportOutput::class, provider: MaintenanceEconomicProvider::class, openapi: new Operation(tags: ['MaintenanceCost'], summary: 'Read exact private maintenance economics with reconciled allocations', parameters: [new Parameter(name: 'from', in: 'query', required: true, schema: ['type' => 'string', 'format' => 'date']), new Parameter(name: 'to', in: 'query', required: true, schema: ['type' => 'string', 'format' => 'date']), new Parameter(name: 'groupBy', in: 'query', schema: ['type' => 'string', 'enum' => ['equipment', 'site', 'customer']]), new Parameter(name: 'siteId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid']), new Parameter(name: 'customerId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid']), new Parameter(name: 'equipmentId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid']), new Parameter(name: 'page', in: 'query', schema: ['type' => 'integer', 'minimum' => 1]), new Parameter(name: 'itemsPerPage', in: 'query', schema: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100])], responses: [200 => new Response(description: 'Exact full-scope totals and row page'), 403 => new Response(description: 'Dedicated financial read missing'), 404 => new Response(description: 'Organization outside scope'), 422 => new Response(description: 'Invalid or excessive window; narrow filters')])), + new GetCollection(name: MaintenanceEconomicOperations::DOSSIERS, uriTemplate: '/organizations/{organizationId}/maintenance-cost/dossiers', output: MaintenanceEconomicDossierOutput::class, provider: MaintenanceEconomicProvider::class, openapi: new Operation(tags: ['MaintenanceCost'], summary: 'Browse minimal private finance dossier identities', parameters: [new Parameter(name: 'search', in: 'query', schema: ['type' => 'string', 'maxLength' => 160]), new Parameter(name: 'from', in: 'query', schema: ['type' => 'string', 'format' => 'date']), new Parameter(name: 'to', in: 'query', schema: ['type' => 'string', 'format' => 'date']), new Parameter(name: 'siteId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid']), new Parameter(name: 'customerId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid']), new Parameter(name: 'equipmentId', in: 'query', schema: ['type' => 'string', 'format' => 'uuid']), new Parameter(name: 'page', in: 'query', schema: ['type' => 'integer', 'minimum' => 1]), new Parameter(name: 'itemsPerPage', in: 'query', schema: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100])], responses: [200 => new Response(description: 'Hydra minimal identity page'), 403 => new Response(description: 'Dedicated financial read missing'), 404 => new Response(description: 'Organization outside scope')])), + ], +)] +final class MaintenanceEconomicResource +{ +} diff --git a/src/MaintenanceExport/Application/Contract/ExportDocumentSummary.php b/src/MaintenanceExport/Application/Contract/ExportDocumentSummary.php new file mode 100644 index 000000000..f7b373afb --- /dev/null +++ b/src/MaintenanceExport/Application/Contract/ExportDocumentSummary.php @@ -0,0 +1,86 @@ + $sourceInterventionIds retained source selection + * @param string|null $originalExportId adjustment chain origin + * @param string|null $adjustmentOf preceding retained artifact + * @param string|null $reason adjustment motivation + * @param DateTimeImmutable $createdAt generation instant + * @param int $revision confirmation revision + * @param array|null $confirmation explicit external import acknowledgement + * @param int $rowCount saved output row count + * @param bool|null $costsComplete financial completeness, absent for operational exports + * @param int|null $incompleteCostCount unknown financial contribution count + * @param string $jsonSha256 saved JSON fingerprint + * @param int $jsonSize saved UTF-8 JSON byte count + * @param string $csvSha256 saved CSV fingerprint + * @param int $csvSize saved UTF-8 CSV byte count + * + * @return void + */ + public function __construct( + public string $id, + public string $organizationId, + public string $actorId, + public string $kind, + public string $system, + public bool $includeInternalCosts, + public array $sourceInterventionIds, + public ?string $originalExportId, + public ?string $adjustmentOf, + public ?string $reason, + public DateTimeImmutable $createdAt, + public int $revision, + public ?array $confirmation, + public int $rowCount, + public ?bool $costsComplete, + public ?int $incompleteCostCount, + public string $jsonSha256, + public int $jsonSize, + public string $csvSha256, + public int $csvSize, + ) { + } + // #endregion + + // #region Methods + /** + * Method projection + * + * @access public + * + * @return array existing public metadata contract without file contents + */ + public function projection(): array + { + return ['id' => $this->id, 'organizationId' => $this->organizationId, 'actorId' => $this->actorId, 'kind' => $this->kind, 'schemaVersion' => 1, 'system' => $this->system, 'includeInternalCosts' => $this->includeInternalCosts, 'sourceInterventionIds' => $this->sourceInterventionIds, 'originalExportId' => $this->originalExportId, 'adjustmentOf' => $this->adjustmentOf, 'reason' => $this->reason, 'createdAt' => $this->createdAt->format('c'), 'revision' => $this->revision, 'state' => null === $this->confirmation ? 'generated' : 'import_confirmed', 'confirmation' => $this->confirmation, 'rowCount' => $this->rowCount, 'costsComplete' => $this->costsComplete, 'incompleteCostCount' => $this->incompleteCostCount, 'files' => ['json' => ['mediaType' => 'application/json', 'sha256' => $this->jsonSha256, 'size' => $this->jsonSize], 'csv' => ['mediaType' => 'text/csv', 'sha256' => $this->csvSha256, 'size' => $this->csvSize]]]; + } + // #endregion +} diff --git a/src/MaintenanceExport/Application/Contract/ExportOperation.php b/src/MaintenanceExport/Application/Contract/ExportOperation.php new file mode 100644 index 000000000..c3fa931e9 --- /dev/null +++ b/src/MaintenanceExport/Application/Contract/ExportOperation.php @@ -0,0 +1,27 @@ + $result original response metadata + * + * @return void + */ + public function __construct(public string $organizationId, public string $actorId, public string $clientOperationId, public string $action, public string $fingerprint, public string $resourceId, public array $result) + { + } + // #endregion +} diff --git a/src/MaintenanceExport/Application/Contract/ExportSourceState.php b/src/MaintenanceExport/Application/Contract/ExportSourceState.php new file mode 100644 index 000000000..33aa7b4db --- /dev/null +++ b/src/MaintenanceExport/Application/Contract/ExportSourceState.php @@ -0,0 +1,27 @@ +> $baseline current facts keyed by stable logical source + * + * @return void + */ + public function __construct(public array $baseline, public ?bool $costsComplete, public ?int $incompleteCostCount) + { + } + // #endregion +} diff --git a/src/MaintenanceExport/Application/Port/Outbound/MaintenanceExportIdentityPort.php b/src/MaintenanceExport/Application/Port/Outbound/MaintenanceExportIdentityPort.php new file mode 100644 index 000000000..c4cf2d88a --- /dev/null +++ b/src/MaintenanceExport/Application/Port/Outbound/MaintenanceExportIdentityPort.php @@ -0,0 +1,27 @@ + ordered metadata page + */ + public function documentSummaries(string $organizationId, bool $includeFinancial, ?string $system, int $offset, int $limit): array; + + /** + * Method countDocuments + * + * @return int visible server page total + */ + public function countDocuments(string $organizationId, bool $includeFinancial, ?string $system): int; + + /** + * Method operation + * + * @return ExportOperation|null original actor-scoped receipt + */ + public function operation(string $organizationId, string $actorId, string $clientOperationId): ?ExportOperation; + + /** + * Method saveOperation + * + * @return void unique canonical operation receipt + */ + public function saveOperation(ExportOperation $operation): void; + + /** + * Method reference + * + * @return ExternalReference|null exact same-organization system mapping + */ + public function reference(string $organizationId, string $system, string $resourceType, string $resourceId): ?ExternalReference; + + /** + * Method saveReference + * + * @return void optimistic changes within owning transaction + */ + public function saveReference(ExternalReference $reference): void; + + /** + * Method references + * + * @return list bounded reference directory + */ + public function references(string $organizationId, ?string $system, ?string $resourceType, ?string $resourceId, int $offset, int $limit): array; + + /** + * Method countReferences + * + * @return int filtered directory total + */ + public function countReferences(string $organizationId, ?string $system, ?string $resourceType, ?string $resourceId): int; + // #endregion +} diff --git a/src/MaintenanceExport/Application/Port/Outbound/MaintenanceExportSourcePort.php b/src/MaintenanceExport/Application/Port/Outbound/MaintenanceExportSourcePort.php new file mode 100644 index 000000000..e18730ebd --- /dev/null +++ b/src/MaintenanceExport/Application/Port/Outbound/MaintenanceExportSourcePort.php @@ -0,0 +1,28 @@ + $interventionIds maximum100 published dossiers + * @param bool $current use current validated corrections only for a new adjustment + * + * @return ExportSourceState stable source identities and exact amounts + */ + public function capture(string $organizationId, array $interventionIds, string $system, bool $includeInternalCosts, bool $current): ExportSourceState; + // #endregion +} diff --git a/src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportCommand.php b/src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportCommand.php new file mode 100644 index 000000000..35e9b2233 --- /dev/null +++ b/src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportCommand.php @@ -0,0 +1,29 @@ + $payload validated transport declarations + * + * @return void + */ + public function __construct(public string $actorId, public string $organizationId, public string $action, public ?string $id, public ?int $expectedRevision, public array $payload) + { + } + // #endregion +} diff --git a/src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportHandler.php b/src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportHandler.php new file mode 100644 index 000000000..6239f8eb5 --- /dev/null +++ b/src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportHandler.php @@ -0,0 +1,249 @@ +organizationId); + $actor = ExportIdentity::uuid($command->actorId); + $this->authorize($actor, $org, 'organization.maintenance_exports.read'); + $this->authorize($actor, $org, 'confirm' === $command->action ? 'organization.maintenance_exports.confirm' : 'organization.maintenance_exports.manage'); + $payload = $this->canonicalPayload($command); + $operationId = $payload['clientOperationId']; + $id = null === $command->id ? null : ExportIdentity::uuid($command->id); + $fingerprint = hash('sha256', ExportArtifact::json(['action' => $command->action, 'id' => $id, 'payload' => $payload])); + + return $this->repository->synchronized($org, function () use ($command, $org, $actor, $payload, $operationId, $id, $fingerprint): ManageMaintenanceExportResult { + $receipt = $this->repository->operation($org, $actor, $operationId); + if (null !== $receipt) { + if ($receipt->fingerprint !== $fingerprint || $receipt->action !== $command->action) { + throw MaintenanceExportException::conflict('The operation identifier belongs to another declaration.'); + } + if (true === ($receipt->result['includeInternalCosts'] ?? false)) { + $this->authorize($actor, $org, 'organization.maintenance_cost.read'); + } + + return new ManageMaintenanceExportResult('reference' === $command->action ? 'reference' : 'export', $receipt->result, true); + } + $now = $this->clock->now()->setTimezone(new DateTimeZone('UTC')); + if ('reference' === $command->action) { + $resourceType = $payload['resourceType']; + $resourceId = $payload['resourceId']; + $system = $payload['system']; + if (!$this->identities->exists($org, $resourceType, $resourceId)) { + throw MaintenanceExportException::notFound(); + } + $existing = $this->repository->reference($org, $system, $resourceType, $resourceId); + if (null === $command->expectedRevision) { + throw MaintenanceExportException::revisionRequired(); + } + if ($command->expectedRevision !== ($existing->revision ?? 0)) { + throw MaintenanceExportException::stale(); + } + $reference = new ExternalReference($existing->id ?? $this->ids->generate(), $org, $system, $resourceType, $resourceId, $payload['reference'], ($existing->revision ?? 0) + 1, $now); + $this->repository->saveReference($reference); + $result = $reference->projection(); + $resourceId = $reference->id; + } elseif ('confirm' === $command->action) { + $document = $this->document($org, $id); + $this->financialAccess($actor, $org, $document->includeInternalCosts); + $expected = $command->expectedRevision ?? throw MaintenanceExportException::revisionRequired(); + $document->confirm($expected, $operationId, $payload['externalImportReference'], $actor, $now); + $this->repository->saveDocument($document); + $result = $document->projection(); + $resourceId = $document->id; + } else { + $previous = 'adjustment' === $command->action ? $this->document($org, $id) : null; + if (null !== $previous) { + $this->financialAccess($actor, $org, $previous->includeInternalCosts); + $previous->assertRevision($command->expectedRevision); + if ($this->repository->hasAdjustment($org, $previous->id)) { + throw MaintenanceExportException::conflict('Adjust the latest export in the chain.'); + } + } + $financial = $previous->includeInternalCosts ?? $payload['includeInternalCosts']; + $this->financialAccess($actor, $org, $financial); + $sourceIds = $previous->sourceInterventionIds ?? $payload['interventionIds']; + $system = $previous->system ?? $payload['system']; + $facts = $this->sources->capture($org, $sourceIds, $system, $financial, null !== $previous); + $newId = $this->ids->generate(); + $rows = null === $previous ? ExportRows::initial($facts->baseline) : ExportRows::adjustment($previous->baseline, $facts->baseline, $newId); + $baseline = $facts->baseline; + // Subsequent corrections must refer to the rows actually introduced by this adjustment. + foreach ($baseline as $key => $fact) { + if (null !== $previous && isset($previous->baseline[$key])) { + $baseline[$key]['id'] = $previous->baseline[$key]['id']; + } + } + foreach ($rows as $row) { + if ('add' !== ($row['change'] ?? null)) { + continue; + } + $key = $row['logicalSourceKey'] ?? null; + if (is_string($key) && isset($baseline[$key])) { + $baseline[$key]['id'] = $row['id']; + } + } + $kind = null === $previous ? 'initial' : 'adjustment'; + $original = null === $previous ? null : ($previous->originalExportId ?? $previous->id); + $reason = null === $previous ? null : $payload['reason']; + $metadata = ['schema' => 'fireguard.maintenance-prestations', 'schemaVersion' => 1, 'exportId' => $newId, 'organizationId' => $org, 'system' => $system, 'kind' => $kind, 'originalExportId' => $original, 'adjustmentOf' => $previous?->id, 'reason' => $reason, 'generatedAt' => $now->format('c'), 'actorId' => $actor, 'sourceInterventionIds' => $sourceIds, 'includeInternalCosts' => $financial, 'rows' => $rows]; + if ($financial) { + $metadata['costsComplete'] = $facts->costsComplete; + $metadata['incompleteCostCount'] = $facts->incompleteCostCount; + } + $document = new ExportDocument($newId, $org, $actor, $kind, $system, $financial, $sourceIds, $original, $previous?->id, $reason, $now, $rows, $baseline, ExportArtifact::json($metadata), ExportArtifact::csv($rows, $financial), $facts->costsComplete, $facts->incompleteCostCount); + $this->repository->saveDocument($document); + $result = $document->projection(); + $resourceId = $document->id; + } + $this->repository->saveOperation(new ExportOperation($org, $actor, $operationId, $command->action, $fingerprint, $resourceId, $result)); + $this->events->dispatch(new MaintenanceExportChangedEvent($org, $resourceId, $command->action, $now)); + + return new ManageMaintenanceExportResult('reference' === $command->action ? 'reference' : 'export', $result); + }); + } + + /** + * Method canonicalPayload + * + * @return array{clientOperationId:string,interventionIds:list,system:string,includeInternalCosts:bool,resourceType:string,resourceId:string,reference:string,reason:string,externalImportReference:string} hashable canonical intent + */ + private function canonicalPayload(ManageMaintenanceExportCommand $command): array + { + $payload = $command->payload; + $operation = $this->text($payload, 'clientOperationId'); + $result = ['clientOperationId' => ExportIdentity::uuid($operation), 'interventionIds' => [], 'system' => '', 'includeInternalCosts' => false, 'resourceType' => '', 'resourceId' => '', 'reference' => '', 'reason' => '', 'externalImportReference' => '']; + if ('create' === $command->action) { + $ids = $payload['interventionIds'] ?? null; + if (!is_array($ids) || count($ids) < 1 || count($ids) > 100) { + throw MaintenanceExportException::invalid('Select between one and 100 published interventions.'); + } + $canonical = []; + foreach ($ids as $source) { + if (!is_string($source)) { + throw MaintenanceExportException::invalid('Invalid intervention identifier.'); + } $uuid = ExportIdentity::uuid($source); + $canonical[$uuid] = $uuid; + } + if (count($canonical) !== count($ids)) { + throw MaintenanceExportException::invalid('Duplicate intervention identifiers are not allowed.'); + } + $canonical = array_values($canonical); + sort($canonical); + $financial = $payload['includeInternalCosts'] ?? false; + if (!is_bool($financial)) { + throw MaintenanceExportException::invalid('includeInternalCosts must be a boolean.'); + } + $result = [...$result, 'interventionIds' => $canonical, 'system' => ExportIdentity::system($this->text($payload, 'system')), 'includeInternalCosts' => $financial]; + } elseif ('adjustment' === $command->action) { + $result['reason'] = ExportIdentity::text($this->text($payload, 'reason'), 1000); + } elseif ('confirm' === $command->action) { + $result['externalImportReference'] = ExportIdentity::text($this->text($payload, 'externalImportReference'), 200); + } elseif ('reference' === $command->action) { + $result = [...$result, 'resourceType' => ExportIdentity::resourceType($this->text($payload, 'resourceType')), 'resourceId' => ExportIdentity::uuid($this->text($payload, 'resourceId')), 'system' => ExportIdentity::system($this->text($payload, 'system')), 'reference' => ExportIdentity::text($this->text($payload, 'reference'), 200)]; + } else { + throw MaintenanceExportException::invalid('Unknown export operation.'); + } + ksort($result); + + return $result; + } + + /** + * Method text + * + * @param array $payload declaration + * + * @return string supplied string + */ + private function text(array $payload, string $field): string + { + return is_string($payload[$field] ?? null) ? $payload[$field] : throw MaintenanceExportException::invalid('Missing export field: ' . $field . '.'); + } + + /** + * Method document + * + * @return ExportDocument scoped artifact + */ + private function document(string $organizationId, ?string $id): ExportDocument + { + return null === $id ? throw MaintenanceExportException::notFound() : ($this->repository->document($organizationId, $id) ?? throw MaintenanceExportException::notFound()); + } + + /** + * Method authorize + * + * @return void fail-closed access + */ + private function authorize(string $actorId, string $organizationId, string $permission): void + { + $decision = $this->authorization->resolveAccess($actorId, $organizationId, $permission); + if ($decision->isOutsideScope()) { + throw MaintenanceExportException::notFound(); + } + if (!$decision->isGranted()) { + throw MaintenanceExportException::denied(); + } + } + + /** + * Method financialAccess + * + * @return void independently gated private cost visibility + */ + private function financialAccess(string $actorId, string $organizationId, bool $required): void + { + if ($required) { + $this->authorize($actorId, $organizationId, 'organization.maintenance_cost.read'); + } + } + // #endregion +} diff --git a/src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportResult.php b/src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportResult.php new file mode 100644 index 000000000..a8c67f9f8 --- /dev/null +++ b/src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportResult.php @@ -0,0 +1,29 @@ + $data authorized metadata + * + * @return void + */ + public function __construct(public string $kind, public array $data, public bool $replayed = false) + { + } + // #endregion +} diff --git a/src/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportHandler.php b/src/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportHandler.php new file mode 100644 index 000000000..54ae290f7 --- /dev/null +++ b/src/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportHandler.php @@ -0,0 +1,110 @@ +organizationId); + $actor = ExportIdentity::uuid($query->actorId); + $decision = $this->authorization->resolveAccess($actor, $org, 'organization.maintenance_exports.read'); + if ($decision->isOutsideScope()) { + throw MaintenanceExportException::notFound(); + } + if (!$decision->isGranted()) { + throw MaintenanceExportException::denied(); + } + $financial = $this->authorization->resolveAccess($actor, $org, 'organization.maintenance_cost.read')->isGranted(); + if ($query->page < 1 || $query->itemsPerPage < 1 || $query->itemsPerPage > 100 || $query->page > 10000) { + throw MaintenanceExportException::invalid('Invalid export pagination.'); + } + $system = null === $query->system || '' === $query->system ? null : ExportIdentity::system($query->system); + if ('sources' === $query->action) { + if (null !== $query->search && mb_strlen($query->search, 'UTF-8') > 160) { + throw MaintenanceExportException::invalid('Source searches may contain at most 160 characters.'); + } + $page = $this->publications->publishedPage($org, $query->page, $query->itemsPerPage, $query->search); + $items = []; + foreach ($page->items as $source) { + $validated = 0; + foreach ($source->workItems as $work) { + if ($work->validated) { + ++$validated; + } + } + $blocked = 'available' !== $source->snapshotState || null === $source->publicationId ? 'snapshot_missing' : (0 === $validated ? 'no_validated_work' : null); + $items[] = ['id' => $source->id, 'number' => $source->number, 'name' => $source->name, 'type' => $source->type, 'publishedAt' => $source->publishedAt?->format('c'), 'publicationId' => $source->publicationId, 'site' => $source->site, 'customer' => $source->customer, 'snapshotState' => $source->snapshotState, 'identityComplete' => $source->identityComplete, 'ready' => null === $blocked, 'blockedReason' => $blocked]; + } + + return new ReadMaintenanceExportResult('source', $items, $page->totalItems, $page->page, $page->itemsPerPage, true); + } + if ('references' === $query->action) { + $type = null === $query->resourceType || '' === $query->resourceType ? null : ExportIdentity::resourceType($query->resourceType); + $id = null === $query->resourceId || '' === $query->resourceId ? null : ExportIdentity::uuid($query->resourceId); + $items = array_map(static fn ($reference): array => $reference->projection(), $this->repository->references($org, $system, $type, $id, ($query->page - 1) * $query->itemsPerPage, $query->itemsPerPage)); + + return new ReadMaintenanceExportResult('reference', $items, $this->repository->countReferences($org, $system, $type, $id), $query->page, $query->itemsPerPage, true); + } + if ('exports' === $query->action) { + $items = array_map(static fn ($summary): array => $summary->projection(), $this->repository->documentSummaries($org, $financial, $system, ($query->page - 1) * $query->itemsPerPage, $query->itemsPerPage)); + + return new ReadMaintenanceExportResult('export', $items, $this->repository->countDocuments($org, $financial, $system), $query->page, $query->itemsPerPage, true); + } + $id = null === $query->id ? throw MaintenanceExportException::notFound() : ExportIdentity::uuid($query->id); + $document = $this->repository->document($org, $id) ?? throw MaintenanceExportException::notFound(); + if ($document->includeInternalCosts && !$financial) { + throw MaintenanceExportException::denied(); + } + if ('file' === $query->action) { + if (!in_array($query->format, ['json', 'csv'], true)) { + throw MaintenanceExportException::invalid('Unknown retained export format.'); + } + $bytes = 'json' === $query->format ? $document->jsonBytes : $document->csvBytes; + + return new ReadMaintenanceExportResult('file', [], bytes:$bytes, mediaType:'json' === $query->format ? 'application/json' : 'text/csv', fileName:'fireguard-maintenance-' . $document->id . '.' . $query->format, sha256:hash('sha256', $bytes)); + } + if ('export' !== $query->action) { + throw MaintenanceExportException::invalid('Unknown export read.'); + } + + return new ReadMaintenanceExportResult('export', [$document->projection()]); + } + // #endregion +} diff --git a/src/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportQuery.php b/src/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportQuery.php new file mode 100644 index 000000000..c91809929 --- /dev/null +++ b/src/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportQuery.php @@ -0,0 +1,27 @@ +> $items authorized metadata + * + * @return void + */ + public function __construct(public string $kind, public array $items, public int $total = 0, public int $page = 1, public int $itemsPerPage = 30, public bool $collection = false, public ?string $bytes = null, public ?string $mediaType = null, public ?string $fileName = null, public ?string $sha256 = null) + { + } + // #endregion +} diff --git a/src/MaintenanceExport/Domain/Event/MaintenanceExportChangedEvent.php b/src/MaintenanceExport/Domain/Event/MaintenanceExportChangedEvent.php new file mode 100644 index 000000000..111663a12 --- /dev/null +++ b/src/MaintenanceExport/Domain/Event/MaintenanceExportChangedEvent.php @@ -0,0 +1,27 @@ + $sourceInterventionIds published source identifiers + * @param list> $rows immutable exported row set + * @param array> $baseline current fact state at generation, used only to calculate later deltas + * @param array|null $confirmation explicit ERP receipt, independent from generation + */ + public function __construct( + public readonly string $id, + public readonly string $organizationId, + public readonly string $actorId, + public readonly string $kind, + public readonly string $system, + public readonly bool $includeInternalCosts, + public readonly array $sourceInterventionIds, + public readonly ?string $originalExportId, + public readonly ?string $adjustmentOf, + public readonly ?string $reason, + public readonly DateTimeImmutable $createdAt, + public readonly array $rows, + public readonly array $baseline, + public readonly string $jsonBytes, + public readonly string $csvBytes, + public readonly ?bool $costsComplete = null, + public readonly ?int $incompleteCostCount = null, + private int $revision = 1, + private ?array $confirmation = null, + ) { + ExportIdentity::uuid($id); + ExportIdentity::uuid($organizationId); + ExportIdentity::uuid($actorId); + ExportIdentity::system($system); + if (!in_array($kind, ['initial', 'adjustment'], true) || count($rows) > ExportArtifact::MAX_ROWS || $revision < 1 || strlen($jsonBytes) > ExportArtifact::MAX_BYTES || strlen($csvBytes) > ExportArtifact::MAX_BYTES) { + throw MaintenanceExportException::invalid('Invalid retained export.'); + } + if ('adjustment' === $kind && (null === $originalExportId || null === $adjustmentOf || null === $reason)) { + throw MaintenanceExportException::invalid('An adjustment must retain its original and preceding export.'); + } + } + // #endregion + + // #region Methods + /** + * Method revision + * + * @return int confirmation revision + */ + public function revision(): int + { + return $this->revision; + } + + /** + * Method confirmation + * + * @return array|null explicit external import receipt + */ + public function confirmation(): ?array + { + return $this->confirmation; + } + + /** + * Method assertRevision + * + * @param int|null $expected If-Match revision + * + * @return void + */ + public function assertRevision(?int $expected): void + { + if (null === $expected) { + throw MaintenanceExportException::revisionRequired(); + } + if ($expected !== $this->revision) { + throw MaintenanceExportException::stale(); + } + } + + /** + * Method confirm + * + * @return void records import acknowledgement without changing any artifact + */ + public function confirm(int $expected, string $clientOperationId, string $reference, string $actorId, DateTimeImmutable $at): void + { + $this->assertRevision($expected); + if (null !== $this->confirmation) { + throw MaintenanceExportException::conflict('The export already has an import acknowledgement.'); + } + $this->confirmation = ['clientOperationId' => ExportIdentity::uuid($clientOperationId), 'externalImportReference' => ExportIdentity::text($reference, 200), 'actorId' => ExportIdentity::uuid($actorId), 'confirmedAt' => $at->format('c')]; + ++$this->revision; + } + + /** + * Method projection + * + * @return array public metadata, artifacts obtained only through an authorized download + */ + public function projection(): array + { + return ['id' => $this->id, 'organizationId' => $this->organizationId, 'actorId' => $this->actorId, 'kind' => $this->kind, 'schemaVersion' => 1, 'system' => $this->system, 'includeInternalCosts' => $this->includeInternalCosts, 'sourceInterventionIds' => $this->sourceInterventionIds, 'originalExportId' => $this->originalExportId, 'adjustmentOf' => $this->adjustmentOf, 'reason' => $this->reason, 'createdAt' => $this->createdAt->format('c'), 'revision' => $this->revision, 'state' => null === $this->confirmation ? 'generated' : 'import_confirmed', 'confirmation' => $this->confirmation, 'rowCount' => count($this->rows), 'costsComplete' => $this->costsComplete, 'incompleteCostCount' => $this->incompleteCostCount, 'files' => ['json' => ['mediaType' => 'application/json', 'sha256' => hash('sha256', $this->jsonBytes), 'size' => strlen($this->jsonBytes)], 'csv' => ['mediaType' => 'text/csv', 'sha256' => hash('sha256', $this->csvBytes), 'size' => strlen($this->csvBytes)]]]; + } + // #endregion +} diff --git a/src/MaintenanceExport/Domain/Model/ExternalReference.php b/src/MaintenanceExport/Domain/Model/ExternalReference.php new file mode 100644 index 000000000..249981b4d --- /dev/null +++ b/src/MaintenanceExport/Domain/Model/ExternalReference.php @@ -0,0 +1,50 @@ + no contacts or financial data + */ + public function projection(): array + { + return ['id' => $this->id, 'organizationId' => $this->organizationId, 'system' => $this->system, 'resourceType' => $this->resourceType, 'resourceId' => $this->resourceId, 'reference' => $this->reference, 'revision' => $this->revision, 'updatedAt' => $this->updatedAt->format('c')]; + } + // #endregion +} diff --git a/src/MaintenanceExport/Domain/ValueObject/ExportArtifact.php b/src/MaintenanceExport/Domain/ValueObject/ExportArtifact.php new file mode 100644 index 000000000..1b5cb5e34 --- /dev/null +++ b/src/MaintenanceExport/Domain/ValueObject/ExportArtifact.php @@ -0,0 +1,147 @@ + $data frozen document + * + * @return string canonical bytes + */ + public static function json(array $data): string + { + $encoded = json_encode(self::canonical($data), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; + if (strlen($encoded) > self::MAX_BYTES) { + throw MaintenanceExportException::invalid('Export artifact exceeds the size limit.'); + } + + return $encoded; + } + + /** + * Method csv + * + * @param list> $rows frozen rows + * @param bool $financial include dedicated private columns + * + * @return string retained CSV bytes + */ + public static function csv(array $rows, bool $financial): string + { + if (count($rows) > self::MAX_ROWS) { + throw MaintenanceExportException::invalid('Too many export rows.'); + } + $columns = ['id', 'kind', 'change', 'correctionOf', 'interventionId', 'publicationId', 'publicationRevision', 'workItemId', 'sourceId', 'sourceRevision', 'equipmentId', 'equipmentName', 'assetReference', 'siteId', 'siteName', 'customerId', 'customerName', 'equipmentReference', 'siteReference', 'customerReference', 'action', 'performedOn', 'outcome', 'minutes', 'evidenceCount']; + if ($financial) { + $columns = [...$columns, 'amount', 'currency', 'costComplete']; + } + $lines = [implode(',', $columns)]; + foreach ($rows as $row) { + $cells = []; + foreach ($columns as $column) { + $value = $row[$column] ?? null; + $cell = is_bool($value) ? ($value ? 'true' : 'false') : (is_string($value) || is_int($value) ? (string) $value : ''); + $numeric = in_array($column, ['minutes', 'amount', 'sourceRevision', 'publicationRevision', 'evidenceCount'], true) && 1 === preg_match('/^-?[0-9]+(?:\.[0-9]+)?$/', $cell); + if (!$numeric && 1 === preg_match('/^[\x00-\x20]*[=+\-@]/', $cell)) { + $cell = "'" . $cell; + } + $cells[] = '"' . str_replace('"', '""', $cell) . '"'; + } + $lines[] = implode(',', $cells); + } + $bytes = implode("\r\n", $lines) . "\r\n"; + if (strlen($bytes) > self::MAX_BYTES) { + throw MaintenanceExportException::invalid('Export artifact exceeds the size limit.'); + } + + return $bytes; + } + + /** + * Method rowId + * + * @param string $key immutable source key + * + * @return string deterministic stable UUID + */ + public static function rowId(string $key): string + { + $hex = hash('sha256', $key); + + return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-5' . substr($hex, 13, 3) . '-8' . substr($hex, 17, 3) . '-' . substr($hex, 20, 12); + } + + /** + * Method canonical + * + * @param mixed $value exact JSON value + * + * @return mixed sorted associative maps + */ + private static function canonical(mixed $value): mixed + { + if (is_float($value)) { + throw MaintenanceExportException::invalid('Export amounts must use exact decimal strings.'); + } + if (is_array($value)) { + if (!array_is_list($value)) { + ksort($value); + } + foreach ($value as $key => $item) { + $value[$key] = self::canonical($item); + } + } + + return $value; + } + // #endregion +} diff --git a/src/MaintenanceExport/Domain/ValueObject/ExportIdentity.php b/src/MaintenanceExport/Domain/ValueObject/ExportIdentity.php new file mode 100644 index 000000000..cf65be6d1 --- /dev/null +++ b/src/MaintenanceExport/Domain/ValueObject/ExportIdentity.php @@ -0,0 +1,86 @@ +value); + } + + /** + * Method system + * + * @param string $value ERP system identifier + * + * @return string stable explicit system code + */ + public static function system(string $value): string + { + $value = trim($value); + if (1 !== preg_match('/^[A-Za-z0-9][A-Za-z0-9_.-]{0,39}$/', $value)) { + throw MaintenanceExportException::invalid('Invalid ERP system code.'); + } + + return $value; + } + + /** + * Method resourceType + * + * @return string bounded reference resource type + */ + public static function resourceType(string $value): string + { + if (!in_array($value, ['customer', 'site', 'equipment'], true)) { + throw MaintenanceExportException::invalid('Invalid export reference type.'); + } + + return $value; + } + + /** + * Method text + * + * @param string $value declaration text + * @param int $max maximum UTF-8 characters + * + * @return string retained text + */ + public static function text(string $value, int $max): string + { + $value = trim($value); + if ('' === $value || mb_strlen($value, 'UTF-8') > $max || 1 === preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $value)) { + throw MaintenanceExportException::invalid('Invalid export declaration text.'); + } + + return $value; + } + // #endregion +} diff --git a/src/MaintenanceExport/Domain/ValueObject/ExportRows.php b/src/MaintenanceExport/Domain/ValueObject/ExportRows.php new file mode 100644 index 000000000..e966ca810 --- /dev/null +++ b/src/MaintenanceExport/Domain/ValueObject/ExportRows.php @@ -0,0 +1,110 @@ +> $baseline frozen source rows + * + * @return list> stable ordered export rows + */ + public static function initial(array $baseline): array + { + $keys = array_keys($baseline); + sort($keys); + $rows = []; + foreach ($keys as $key) { + $rows[] = [...$baseline[$key], 'change' => 'add', 'correctionOf' => null]; + } + self::assertBounded($rows); + + return $rows; + } + + /** + * Method adjustment + * + * @param array> $before preceding captured facts + * @param array> $after new facts + * @param string $adjustmentId stable new artifact identity + * + * @return list> explicit compensation and replacement + */ + public static function adjustment(array $before, array $after, string $adjustmentId): array + { + $keys = array_unique([...array_keys($before), ...array_keys($after)]); + sort($keys); + $rows = []; + foreach ($keys as $key) { + $old = $before[$key] ?? null; + $new = $after[$key] ?? null; + $oldComparable = $old; + $newComparable = $new; + if (null !== $oldComparable) { + unset($oldComparable['id']); + } + if (null !== $newComparable) { + unset($newComparable['id']); + } + if (null !== $oldComparable && null !== $newComparable && ExportArtifact::json($oldComparable) === ExportArtifact::json($newComparable)) { + continue; + } + if (null !== $old) { + $reversal = [...$old, 'id' => ExportArtifact::rowId($adjustmentId . '|reverse|' . $key), 'change' => 'reverse', 'correctionOf' => $old['id']]; + if (is_int($old['minutes'] ?? null)) { + $reversal['minutes'] = -$old['minutes']; + } + if (is_string($old['amount'] ?? null)) { + $reversal['amount'] = DecimalAmount::zero()->subtract(DecimalAmount::fromString($old['amount']))->toString(); + } + $rows[] = $reversal; + } + if (null !== $new) { + $rows[] = [...$new, 'id' => ExportArtifact::rowId($adjustmentId . '|add|' . $key), 'change' => 'add', 'correctionOf' => $old['id'] ?? null]; + } + } + if ([] === $rows) { + throw MaintenanceExportException::conflict('No validated source correction is available for adjustment.'); + } + self::assertBounded($rows); + + return $rows; + } + + /** + * Method assertBounded + * + * @param list> $rows synchronous row work + * + * @return void + */ + private static function assertBounded(array $rows): void + { + if ([] === $rows || count($rows) > ExportArtifact::MAX_ROWS) { + throw MaintenanceExportException::invalid('An export requires between one and 10000 rows.'); + } + } + // #endregion +} diff --git a/src/MaintenanceExport/Infrastructure/Adapter/Export/ExportIdentityValidationAdapter.php b/src/MaintenanceExport/Infrastructure/Adapter/Export/ExportIdentityValidationAdapter.php new file mode 100644 index 000000000..e50745442 --- /dev/null +++ b/src/MaintenanceExport/Infrastructure/Adapter/Export/ExportIdentityValidationAdapter.php @@ -0,0 +1,47 @@ + $validators owner-hosted scoped validators + * + * @return void + */ + public function __construct(private iterable $validators) + { + } + // #endregion + + // #region Methods + /** + * Method exists + * + * @return bool uniformly scoped identity result + */ + public function exists(string $organizationId, string $resourceType, string $resourceId): bool + { + foreach ($this->validators as $validator) { + if ($validator->exists($organizationId, $resourceType, $resourceId)) { + return true; + } + } + + return false; + } + // #endregion +} diff --git a/src/MaintenanceExport/Infrastructure/Adapter/Export/ExportSourceAdapter.php b/src/MaintenanceExport/Infrastructure/Adapter/Export/ExportSourceAdapter.php new file mode 100644 index 000000000..3d852b860 --- /dev/null +++ b/src/MaintenanceExport/Infrastructure/Adapter/Export/ExportSourceAdapter.php @@ -0,0 +1,233 @@ + $interventionIds scoped published identifiers + * + * @return ExportSourceState preserved source projection + */ + public function capture(string $organizationId, array $interventionIds, string $system, bool $includeInternalCosts, bool $current): ExportSourceState + { + foreach ($interventionIds as $id) { + $context = $this->times->context($organizationId, $id, true); + if (null === $context || 'published' !== $context->status) { + throw MaintenanceExportException::notFound(); + } + } + $publications = $this->publications->publishedBatch($organizationId, $interventionIds); + if (count($publications) !== count($interventionIds)) { + throw MaintenanceExportException::notFound(); + } + $baseline = []; + $sourceBytes = 0; + $incomplete = 0; + foreach ($publications as $publication) { + if ('available' !== $publication->snapshotState || null === $publication->dossier || null === $publication->publicationId) { + throw MaintenanceExportException::snapshotMissing(); + } + $validatedWork = 0; + foreach ($publication->workItems as $work) { + if ($work->validated) { + ++$validatedWork; + } + } + if (0 === $validatedWork) { + throw MaintenanceExportException::invalid('A selected publication has no validated prestation.'); + } + $timeRows = $this->timeRows($organizationId, $publication, $current); + $workIdentities = []; + foreach ($publication->workItems as $work) { + if (!$work->validated && !$includeInternalCosts) { + continue; + } + $workIdentities[$work->id] = $this->workRow($organizationId, $publication, $work, $system); + if (!$work->validated) { + continue; + } + $row = $workIdentities[$work->id]; + $workTimes = $timeRows[$work->id] ?? []; + $row['timeFacts'] = $workTimes; + $minutes = 0; + foreach ($workTimes as $time) { + if (!$time['cancelled']) { + $minutes += $time['minutes']; + } + } + $row['minutes'] = $minutes; + $key = 'work:' . $publication->id . ':' . $work->id; + $row['logicalSourceKey'] = $key; + $row['id'] = ExportArtifact::rowId('work|' . $organizationId . '|' . $publication->publicationId . '|' . $work->id); + $baseline[$key] = $row; + $sourceBytes += strlen(ExportArtifact::json($row)); + if ($sourceBytes > ExportArtifact::MAX_BYTES) { + throw MaintenanceExportException::invalid('Export source facts exceed the artifact size limit.'); + } + if (count($baseline) > ExportArtifact::MAX_ROWS) { + throw MaintenanceExportException::invalid('Too many export source rows.'); + } + } + if (!$includeInternalCosts) { + continue; + } + $costs = $this->costs->view($organizationId, $publication->id); + if (!$current && null === $costs->frozen) { + throw MaintenanceExportException::snapshotMissing(); + } + if (!$current && ($costs->frozen ?? throw MaintenanceExportException::snapshotMissing())->publicationId !== $publication->publicationId) { + throw MaintenanceExportException::conflict('The retained financial snapshot belongs to another publication.'); + } + $totals = $current ? $costs->current : ($costs->frozen ?? throw MaintenanceExportException::snapshotMissing())->totals; + foreach ($totals->items as $item) { + $identity = $workIdentities[$item->workItemId ?? ''] ?? $this->baseRow($organizationId, $publication, null, $system); + unset($identity['id']); + $row = [...$identity, 'kind' => $item->kind, 'sourceId' => $item->sourceId, 'sourceRevision' => $item->sourceRevision, 'workItemId' => $item->workItemId, 'performedOn' => $item->occurredAt, 'amount' => $item->amount, 'currency' => $item->currency, 'costComplete' => null !== $item->amount, 'correctionOfSource' => $item->correctionOf, 'description' => $item->description, 'minutes' => null, 'timeFacts' => []]; + if (null !== $item->allocation || (null === $item->workItemId && null !== $item->equipmentId)) { + $row['equipmentId'] = $item->equipmentId ?? $item->allocation['equipment']['id'] ?? null; + $row['equipmentName'] = $item->allocation['equipment']['name'] ?? null; + $row['assetReference'] = $item->allocation['equipment']['assetReference'] ?? null; + $row['equipmentIdentity'] = $item->allocation['equipment'] ?? null; + $row['equipmentIdentityState'] = $item->allocation['identityState'] ?? 'incomplete'; + $row['identityComplete'] = 'captured' === $row['equipmentIdentityState']; + $row['equipmentReference'] = $this->reference($organizationId, $system, 'equipment', $row['equipmentId']); + $row['siteId'] = $item->allocation['site']['id'] ?? null; + $row['siteName'] = $item->allocation['site']['name'] ?? null; + $row['customerId'] = $item->allocation['customer']['id'] ?? null; + $row['customerName'] = $item->allocation['customer']['name'] ?? null; + $row['siteReference'] = $this->reference($organizationId, $system, 'site', $row['siteId']); + $row['customerReference'] = $this->reference($organizationId, $system, 'customer', $row['customerId']); + } + if (null === $item->amount) { + ++$incomplete; + } + $key = 'cost:' . $publication->id . ':' . $item->kind . ':' . $item->sourceId; + $row['logicalSourceKey'] = $key; + $row['id'] = ExportArtifact::rowId('cost|' . $organizationId . '|' . $publication->publicationId . '|' . $item->kind . '|' . $item->sourceId); + $baseline[$key] = $row; + $sourceBytes += strlen(ExportArtifact::json($row)); + if ($sourceBytes > ExportArtifact::MAX_BYTES) { + throw MaintenanceExportException::invalid('Export source facts exceed the artifact size limit.'); + } + if (count($baseline) > ExportArtifact::MAX_ROWS) { + throw MaintenanceExportException::invalid('Too many export source rows.'); + } + } + } + ksort($baseline); + + return new ExportSourceState($baseline, $includeInternalCosts ? 0 === $incomplete : null, $includeInternalCosts ? $incomplete : null); + } + + /** + * Method timeRows + * + * @return array> current or preserved time journal + */ + private function timeRows(string $organizationId, InterventionPublicationFacts $publication, bool $current): array + { + $rows = []; + if ($current) { + foreach ($this->times->timeFacts($organizationId, $publication->id) as $time) { + $rows[$time->workItemId][$time->id] = ['id' => $time->id, 'memberId' => $time->memberId, 'workedOn' => $time->workedOn, 'minutes' => $time->minutes, 'revision' => $time->revision, 'cancelled' => $time->cancelled]; + } + } else { + $facts = $publication->dossier['timeEntries'] ?? []; + if (!is_array($facts)) { + throw MaintenanceExportException::snapshotMissing(); + } + foreach ($facts as $time) { + if (!is_array($time) || !is_string($time['id'] ?? null) || !is_string($time['workItemId'] ?? null) || !is_string($time['memberId'] ?? null) || !is_string($time['workedOn'] ?? null) || !is_int($time['minutes'] ?? null) || !is_int($time['revision'] ?? null) || !is_bool($time['cancelled'] ?? null)) { + throw MaintenanceExportException::snapshotMissing(); + } + $rows[$time['workItemId']][$time['id']] = ['id' => $time['id'], 'memberId' => $time['memberId'], 'workedOn' => $time['workedOn'], 'minutes' => $time['minutes'], 'revision' => $time['revision'], 'cancelled' => $time['cancelled']]; + } + } + foreach ($rows as $key => $group) { + ksort($group); + $rows[$key] = array_values($group); + } + + return $rows; + } + + /** + * Method workRow + * + * @return array frozen equipment and execution identity + */ + private function workRow(string $organizationId, InterventionPublicationFacts $publication, InterventionPublishedWorkFact $work, string $system): array + { + $row = $this->baseRow($organizationId, $publication, $work, $system); + $execution = $work->executionResult ?? []; + + return [...$row, 'kind' => 'prestation', 'sourceId' => $work->id, 'sourceRevision' => $publication->revision, 'action' => $work->action, 'performedOn' => is_string($execution['performedAt'] ?? null) ? $execution['performedAt'] : null, 'resultRecordedAt' => $work->updatedAt?->format('c'), 'outcome' => is_string($execution['outcome'] ?? null) ? $execution['outcome'] : null, 'executionResult' => $work->executionResult, 'validationSource' => $work->validationSource, 'target' => $work->target, 'resultResource' => $work->resultResource, 'evidenceCount' => $work->evidenceCount]; + } + + /** + * Method baseRow + * + * @return array historical context with explicit unknown equipment identity + */ + private function baseRow(string $organizationId, InterventionPublicationFacts $publication, ?InterventionPublishedWorkFact $work, string $system): array + { + $site = null === $work ? $publication->site : $work->site; + $customer = null === $work ? $publication->customer : $work->customer; + $equipment = $work?->equipmentIdentity; + $equipmentId = $work?->equipmentId; + + return ['interventionId' => $publication->id, 'interventionNumber' => $publication->number, 'interventionName' => $publication->name, 'publicationId' => $publication->publicationId, 'publicationRevision' => $publication->revision, 'workItemId' => $work?->id, 'equipmentId' => $equipmentId, 'equipmentName' => $equipment?->name, 'assetReference' => $equipment?->assetReference, 'equipmentIdentity' => null === $equipment ? null : get_object_vars($equipment), 'identityComplete' => $publication->identityComplete, 'siteId' => $site['id'] ?? null, 'siteName' => $site['name'] ?? null, 'customerId' => $customer['id'] ?? null, 'customerName' => $customer['name'] ?? null, 'equipmentReference' => $this->reference($organizationId, $system, 'equipment', $equipmentId), 'siteReference' => $this->reference($organizationId, $system, 'site', $site['id'] ?? null), 'customerReference' => $this->reference($organizationId, $system, 'customer', $customer['id'] ?? null)]; + } + + /** + * Method reference + * + * @return string|null explicitly configured external mapping at generation + */ + private function reference(string $organizationId, string $system, string $type, ?string $id): ?string + { + return null === $id ? null : $this->repository->reference($organizationId, $system, $type, $id)?->reference; + } + // #endregion +} diff --git a/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Record/MaintenanceExportDocumentRecord.php b/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Record/MaintenanceExportDocumentRecord.php new file mode 100644 index 000000000..95d61a31a --- /dev/null +++ b/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Record/MaintenanceExportDocumentRecord.php @@ -0,0 +1,165 @@ + preserved source identities + */ + #[ORM\Column(name: 'source_intervention_ids', type: 'json', options: ['jsonb' => true])] + public array $sourceInterventionIds; + + /** + * Property originalExportId + */ + #[ORM\Column(name: 'original_export_id', length: 36, nullable: true)] + public ?string $originalExportId = null; + + /** + * Property adjustmentOf + */ + #[ORM\Column(name: 'adjustment_of', length: 36, nullable: true)] + public ?string $adjustmentOf = null; + + /** + * Property reason + */ + #[ORM\Column(type: 'text', nullable: true)] + public ?string $reason = null; + + /** + * Property createdAt + */ + #[ORM\Column(name: 'created_at', type: 'datetime_immutable')] + public DateTimeImmutable $createdAt; + + /** + * Property rows + * + * @var list> immutable exported rows + */ + #[ORM\Column(type: 'json', options: ['jsonb' => true])] + public array $rows; + + /** + * Property baseline + * + * @var array> source state for later deltas + */ + #[ORM\Column(type: 'json', options: ['jsonb' => true])] + public array $baseline; + + /** + * Property jsonBytes + */ + #[ORM\Column(name: 'json_bytes', type: 'text')] + public string $jsonBytes; + + /** + * Property csvBytes + */ + #[ORM\Column(name: 'csv_bytes', type: 'text')] + public string $csvBytes; + + /** + * Property jsonSha256 + */ + #[ORM\Column(name: 'json_sha256', length: 64)] + public string $jsonSha256; + + /** + * Property csvSha256 + */ + #[ORM\Column(name: 'csv_sha256', length: 64)] + public string $csvSha256; + + /** + * Property immutableHash + */ + #[ORM\Column(name: 'immutable_hash', length: 64)] + public string $immutableHash; + + /** + * Property costsComplete + */ + #[ORM\Column(name: 'costs_complete', type: 'boolean', nullable: true)] + public ?bool $costsComplete = null; + + /** + * Property incompleteCostCount + */ + #[ORM\Column(name: 'incomplete_cost_count', type: 'integer', nullable: true)] + public ?int $incompleteCostCount = null; + + /** + * Property revision + */ + #[ORM\Column(type: 'integer')] + public int $revision; + + /** + * Property confirmation + * + * @var array|null explicit import acknowledgement + */ + #[ORM\Column(type: 'json', nullable: true, options: ['jsonb' => true])] + public ?array $confirmation = null; + // #endregion +} diff --git a/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Record/MaintenanceExportOperationRecord.php b/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Record/MaintenanceExportOperationRecord.php new file mode 100644 index 000000000..5d094b454 --- /dev/null +++ b/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Record/MaintenanceExportOperationRecord.php @@ -0,0 +1,68 @@ + original response retained across later changes + */ + #[ORM\Column(type: 'json', options: ['jsonb' => true])] + public array $result; + // #endregion +} diff --git a/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Record/MaintenanceExternalReferenceRecord.php b/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Record/MaintenanceExternalReferenceRecord.php new file mode 100644 index 000000000..636394c63 --- /dev/null +++ b/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Record/MaintenanceExternalReferenceRecord.php @@ -0,0 +1,72 @@ +connection->transactional(function () use ($organizationId, $work): mixed { + $this->lock($organizationId); + + return $work(); + }); + } + + /** + * Method document + * + * @param string $organizationId owning organization + * @param string $id document identity + * + * @return ExportDocument|null scoped preserved artifact + */ + public function document(string $organizationId, string $id): ?ExportDocument + { + $row = $this->connection->fetchAssociative('SELECT * FROM maintenance_export_documents WHERE organization_id = :org AND id = :id', ['org' => $organizationId, 'id' => $id]); + + return false === $row ? null : $this->documentRow($row); + } + + /** + * Method saveDocument + * + * @param ExportDocument $document immutable artifact with optional new confirmation + * + * @return void updates only confirmation metadata for an existing document + */ + public function saveDocument(ExportDocument $document): void + { + $this->lock($document->organizationId); + $existing = $this->document($document->organizationId, $document->id); + $immutable = $this->immutableData($document); + if (null === $existing) { + try { + $this->connection->insert('maintenance_export_documents', $immutable + ['immutable_hash' => $this->immutableHash($document), 'revision' => $document->revision(), 'confirmation' => null === $document->confirmation() ? null : $this->json($document->confirmation())], ['include_internal_costs' => ParameterType::BOOLEAN, 'costs_complete' => null === $document->costsComplete ? ParameterType::NULL : ParameterType::BOOLEAN]); + } catch (UniqueConstraintViolationException) { + throw MaintenanceExportException::conflict('The export identity or preceding adjustment is already retained.'); + } + + return; + } + if (!hash_equals($this->immutableHash($existing), $this->immutableHash($document))) { + throw MaintenanceExportException::conflict('Preserved export facts and artifacts cannot be changed.'); + } + if ($existing->revision() === $document->revision() && $this->json($existing->confirmation()) === $this->json($document->confirmation())) { + return; + } + if (null !== $existing->confirmation()) { + throw MaintenanceExportException::conflict('An import acknowledgement cannot be replaced.'); + } + if ($document->revision() !== $existing->revision() + 1 || null === $document->confirmation()) { + throw MaintenanceExportException::stale(); + } + $updated = $this->connection->executeStatement('UPDATE maintenance_export_documents SET revision = :revision, confirmation = CAST(:confirmation AS JSONB) WHERE organization_id = :org AND id = :id AND revision = :expected AND confirmation IS NULL', ['revision' => $document->revision(), 'confirmation' => $this->json($document->confirmation()), 'org' => $document->organizationId, 'id' => $document->id, 'expected' => $existing->revision()], ['revision' => ParameterType::INTEGER, 'expected' => ParameterType::INTEGER]); + if (1 !== $updated) { + throw MaintenanceExportException::stale(); + } + } + + /** + * Method hasAdjustment + * + * @param string $organizationId owning organization + * @param string $id preceding export + * + * @return bool one retained direct adjustment exists + */ + public function hasAdjustment(string $organizationId, string $id): bool + { + return $this->number($this->connection->fetchOne('SELECT COUNT(*) FROM maintenance_export_documents WHERE organization_id = :org AND adjustment_of = :id', ['org' => $organizationId, 'id' => $id])) > 0; + } + + /** + * Method documentSummaries + * + * @param string $organizationId owning organization + * @param bool $includeFinancial financial read permission + * @param string|null $system explicit ERP filter + * @param int $offset server offset + * @param int $limit bounded page size + * + * @return list metadata page without artifact or source-fact hydration + */ + public function documentSummaries(string $organizationId, bool $includeFinancial, ?string $system, int $offset, int $limit): array + { + [$where, $params] = $this->documentSelection($organizationId, $includeFinancial, $system); + $rows = $this->connection->fetchAllAssociative('SELECT id, organization_id, actor_id, kind, system, include_internal_costs, source_intervention_ids, original_export_id, adjustment_of, reason, created_at, revision, confirmation, costs_complete, incomplete_cost_count, json_sha256, csv_sha256, jsonb_array_length(rows) AS row_count, octet_length(json_bytes) AS json_size, octet_length(csv_bytes) AS csv_size FROM maintenance_export_documents WHERE ' . $where . ' ORDER BY created_at DESC, id DESC LIMIT :limit OFFSET :offset', $params + ['limit' => $limit, 'offset' => $offset], ['limit' => ParameterType::INTEGER, 'offset' => ParameterType::INTEGER]); + + return array_map($this->documentSummaryRow(...), $rows); + } + + /** + * Method countDocuments + * + * @param string $organizationId owning organization + * @param bool $includeFinancial financial read permission + * @param string|null $system explicit ERP filter + * + * @return int same selection total + */ + public function countDocuments(string $organizationId, bool $includeFinancial, ?string $system): int + { + [$where, $params] = $this->documentSelection($organizationId, $includeFinancial, $system); + + return $this->number($this->connection->fetchOne('SELECT COUNT(*) FROM maintenance_export_documents WHERE ' . $where, $params)); + } + + /** + * Method operation + * + * @param string $organizationId owning organization + * @param string $actorId signed actor + * @param string $clientOperationId stable replay key + * + * @return ExportOperation|null original response receipt + */ + public function operation(string $organizationId, string $actorId, string $clientOperationId): ?ExportOperation + { + $row = $this->connection->fetchAssociative('SELECT * FROM maintenance_export_operations WHERE organization_id = :org AND actor_id = :actor AND client_operation_id = :operation', ['org' => $organizationId, 'actor' => $actorId, 'operation' => $clientOperationId]); + + return false === $row ? null : new ExportOperation($this->string($row, 'organization_id'), $this->string($row, 'actor_id'), $this->string($row, 'client_operation_id'), $this->string($row, 'action'), $this->string($row, 'fingerprint'), $this->string($row, 'resource_id'), $this->object($this->string($row, 'result'))); + } + + /** + * Method saveOperation + * + * @param ExportOperation $operation immutable accepted response + * + * @return void retains one receipt per organization, actor and client operation + */ + public function saveOperation(ExportOperation $operation): void + { + $this->lock($operation->organizationId); + $existing = $this->operation($operation->organizationId, $operation->actorId, $operation->clientOperationId); + if (null !== $existing) { + if ($existing->action === $operation->action && $existing->fingerprint === $operation->fingerprint && $existing->resourceId === $operation->resourceId && $this->json($existing->result) === $this->json($operation->result)) { + return; + } + + throw MaintenanceExportException::conflict('The operation key belongs to a different retained declaration.'); + } + + try { + $this->connection->insert('maintenance_export_operations', ['organization_id' => $operation->organizationId, 'actor_id' => $operation->actorId, 'client_operation_id' => $operation->clientOperationId, 'action' => $operation->action, 'fingerprint' => $operation->fingerprint, 'resource_id' => $operation->resourceId, 'result' => $this->json($operation->result)]); + } catch (UniqueConstraintViolationException) { + throw MaintenanceExportException::conflict('The operation receipt already exists.'); + } + } + + /** + * Method reference + * + * @param string $organizationId owning organization + * @param string $system ERP system + * @param string $resourceType published resource kind + * @param string $resourceId organization-owned identity + * + * @return ExternalReference|null exact mapping + */ + public function reference(string $organizationId, string $system, string $resourceType, string $resourceId): ?ExternalReference + { + $row = $this->connection->fetchAssociative('SELECT * FROM maintenance_external_references WHERE organization_id = :org AND system = :system AND resource_type = :type AND resource_id = :resource', ['org' => $organizationId, 'system' => $system, 'type' => $resourceType, 'resource' => $resourceId]); + + return false === $row ? null : $this->referenceRow($row); + } + + /** + * Method saveReference + * + * @param ExternalReference $reference exact target with next revision + * + * @return void persists optimistic reference changes without touching documents + */ + public function saveReference(ExternalReference $reference): void + { + $this->lock($reference->organizationId); + $existing = $this->reference($reference->organizationId, $reference->system, $reference->resourceType, $reference->resourceId); + if (null === $existing) { + if (1 !== $reference->revision) { + throw MaintenanceExportException::stale(); + } + + try { + $this->connection->insert('maintenance_external_references', ['id' => $reference->id, 'organization_id' => $reference->organizationId, 'system' => $reference->system, 'resource_type' => $reference->resourceType, 'resource_id' => $reference->resourceId, 'reference' => $reference->reference, 'revision' => $reference->revision, 'updated_at' => $this->time($reference->updatedAt)]); + } catch (UniqueConstraintViolationException) { + throw MaintenanceExportException::conflict('The external reference identity already exists.'); + } + + return; + } + if ($existing->id !== $reference->id) { + throw MaintenanceExportException::conflict('An external reference keeps its original identity.'); + } + if ($existing->revision === $reference->revision && $existing->reference === $reference->reference && $this->time($existing->updatedAt) === $this->time($reference->updatedAt)) { + return; + } + if ($reference->revision !== $existing->revision + 1) { + throw MaintenanceExportException::stale(); + } + $updated = $this->connection->executeStatement('UPDATE maintenance_external_references SET reference = :reference, revision = :revision, updated_at = :at WHERE organization_id = :org AND id = :id AND revision = :expected', ['reference' => $reference->reference, 'revision' => $reference->revision, 'at' => $this->time($reference->updatedAt), 'org' => $reference->organizationId, 'id' => $reference->id, 'expected' => $existing->revision], ['revision' => ParameterType::INTEGER, 'expected' => ParameterType::INTEGER]); + if (1 !== $updated) { + throw MaintenanceExportException::stale(); + } + } + + /** + * Method references + * + * @param string $organizationId owning organization + * @param string|null $system ERP filter + * @param string|null $resourceType resource kind filter + * @param string|null $resourceId resource identity filter + * @param int $offset server offset + * @param int $limit bounded page size + * + * @return list same-scope mapping page + */ + public function references(string $organizationId, ?string $system, ?string $resourceType, ?string $resourceId, int $offset, int $limit): array + { + [$where, $params] = $this->referenceSelection($organizationId, $system, $resourceType, $resourceId); + $rows = $this->connection->fetchAllAssociative('SELECT * FROM maintenance_external_references WHERE ' . $where . ' ORDER BY system, resource_type, resource_id, id LIMIT :limit OFFSET :offset', $params + ['limit' => $limit, 'offset' => $offset], ['limit' => ParameterType::INTEGER, 'offset' => ParameterType::INTEGER]); + + return array_map($this->referenceRow(...), $rows); + } + + /** + * Method countReferences + * + * @param string $organizationId owning organization + * @param string|null $system ERP filter + * @param string|null $resourceType resource kind filter + * @param string|null $resourceId resource identity filter + * + * @return int same selection total + */ + public function countReferences(string $organizationId, ?string $system, ?string $resourceType, ?string $resourceId): int + { + [$where, $params] = $this->referenceSelection($organizationId, $system, $resourceType, $resourceId); + + return $this->number($this->connection->fetchOne('SELECT COUNT(*) FROM maintenance_external_references WHERE ' . $where, $params)); + } + + /** + * Method lock + * + * @param string $organizationId organization owning all transaction writes + * + * @return void requires an ambient main transaction + */ + private function lock(string $organizationId): void + { + if (!$this->connection->isTransactionActive()) { + throw new LogicException('Maintenance export writes require a main transaction.'); + } + $this->connection->executeQuery('SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))', ['key' => 'maintenance_export.' . $organizationId]); + } + + /** + * Method immutableData + * + * @param ExportDocument $document preserved document + * + * @return array immutable SQL values only + */ + private function immutableData(ExportDocument $document): array + { + return ['id' => $document->id, 'organization_id' => $document->organizationId, 'actor_id' => $document->actorId, 'kind' => $document->kind, 'system' => $document->system, 'include_internal_costs' => $document->includeInternalCosts, 'source_intervention_ids' => $this->json($document->sourceInterventionIds), 'original_export_id' => $document->originalExportId, 'adjustment_of' => $document->adjustmentOf, 'reason' => $document->reason, 'created_at' => $this->time($document->createdAt), 'rows' => $this->json($document->rows), 'baseline' => $this->json($document->baseline), 'json_bytes' => $document->jsonBytes, 'csv_bytes' => $document->csvBytes, 'json_sha256' => hash('sha256', $document->jsonBytes), 'csv_sha256' => hash('sha256', $document->csvBytes), 'costs_complete' => $document->costsComplete, 'incomplete_cost_count' => $document->incompleteCostCount]; + } + + /** + * Method immutableHash + * + * @param ExportDocument $document preserved document + * + * @return string canonical fingerprint unaffected by JSONB object-key ordering + */ + private function immutableHash(ExportDocument $document): string + { + return hash('sha256', $this->json($this->immutableData($document))); + } + + /** + * Method documentRow + * + * @param array $row same-organization SQL row + * + * @return ExportDocument preserved artifact after integrity verification + */ + private function documentRow(array $row): ExportDocument + { + $sources = $this->sourceIdentities($row); + $rows = json_decode($this->string($row, 'rows'), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($rows) || !array_is_list($rows)) { + throw new LogicException('Invalid retained export row set.'); + } + $exportedRows = []; + foreach ($rows as $exportedRow) { + $exportedRows[] = $this->stringKeys($exportedRow); + } + $baseline = []; + foreach ($this->object($this->string($row, 'baseline')) as $key => $value) { + $baseline[$key] = $this->stringKeys($value); + } + $document = new ExportDocument($this->string($row, 'id'), $this->string($row, 'organization_id'), $this->string($row, 'actor_id'), $this->string($row, 'kind'), $this->string($row, 'system'), $this->boolean($row['include_internal_costs'] ?? null), $sources, $this->nullableString($row, 'original_export_id'), $this->nullableString($row, 'adjustment_of'), $this->nullableString($row, 'reason'), new DateTimeImmutable($this->string($row, 'created_at'), new DateTimeZone('UTC')), $exportedRows, $baseline, $this->string($row, 'json_bytes'), $this->string($row, 'csv_bytes'), null === ($row['costs_complete'] ?? null) ? null : $this->boolean($row['costs_complete']), null === ($row['incomplete_cost_count'] ?? null) ? null : $this->number($row['incomplete_cost_count']), $this->number($row['revision'] ?? null), null === ($row['confirmation'] ?? null) ? null : $this->object($this->string($row, 'confirmation'))); + if (!hash_equals($this->string($row, 'json_sha256'), hash('sha256', $document->jsonBytes)) || !hash_equals($this->string($row, 'csv_sha256'), hash('sha256', $document->csvBytes)) || !hash_equals($this->string($row, 'immutable_hash'), $this->immutableHash($document))) { + throw new LogicException('Retained export integrity verification failed.'); + } + + return $document; + } + + /** + * Method documentSummaryRow + * + * @access private + * + * @param array $row projected organization-scoped metadata + * + * @return ExportDocumentSummary saved counts and hashes without rehydrating an artifact + */ + private function documentSummaryRow(array $row): ExportDocumentSummary + { + return new ExportDocumentSummary($this->string($row, 'id'), $this->string($row, 'organization_id'), $this->string($row, 'actor_id'), $this->string($row, 'kind'), $this->string($row, 'system'), $this->boolean($row['include_internal_costs'] ?? null), $this->sourceIdentities($row), $this->nullableString($row, 'original_export_id'), $this->nullableString($row, 'adjustment_of'), $this->nullableString($row, 'reason'), new DateTimeImmutable($this->string($row, 'created_at'), new DateTimeZone('UTC')), $this->number($row['revision'] ?? null), null === ($row['confirmation'] ?? null) ? null : $this->object($this->string($row, 'confirmation')), $this->number($row['row_count'] ?? null), null === ($row['costs_complete'] ?? null) ? null : $this->boolean($row['costs_complete']), null === ($row['incomplete_cost_count'] ?? null) ? null : $this->number($row['incomplete_cost_count']), $this->string($row, 'json_sha256'), $this->number($row['json_size'] ?? null), $this->string($row, 'csv_sha256'), $this->number($row['csv_size'] ?? null)); + } + + /** + * Method sourceIdentities + * + * @access private + * + * @param array $row retained source-selection metadata + * + * @return list ordered published source identities + */ + private function sourceIdentities(array $row): array + { + $sourceIds = json_decode($this->string($row, 'source_intervention_ids'), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($sourceIds) || !array_is_list($sourceIds)) { + throw new LogicException('Invalid retained export source identities.'); + } + $sources = []; + foreach ($sourceIds as $sourceId) { + if (!is_string($sourceId)) { + throw new LogicException('Invalid retained export source identity.'); + } + $sources[] = $sourceId; + } + + return $sources; + } + + /** + * Method referenceRow + * + * @param array $row scoped SQL mapping + * + * @return ExternalReference exact optimistic mapping + */ + private function referenceRow(array $row): ExternalReference + { + return new ExternalReference($this->string($row, 'id'), $this->string($row, 'organization_id'), $this->string($row, 'system'), $this->string($row, 'resource_type'), $this->string($row, 'resource_id'), $this->string($row, 'reference'), $this->number($row['revision'] ?? null), new DateTimeImmutable($this->string($row, 'updated_at'), new DateTimeZone('UTC'))); + } + + /** + * Method documentSelection + * + * @param string $org owning organization + * @param bool $financial applicable financial permission + * @param string|null $system optional ERP filter + * + * @return array{string,array} bound document predicate + */ + private function documentSelection(string $org, bool $financial, ?string $system): array + { + $where = 'organization_id = :org' . ($financial ? '' : ' AND include_internal_costs = FALSE'); + $params = ['org' => $org]; + if (null !== $system) { + $where .= ' AND system = :system'; + $params['system'] = $system; + } + + return [$where, $params]; + } + + /** + * Method referenceSelection + * + * @param string $org owning organization + * @param string|null $system ERP filter + * @param string|null $type resource kind filter + * @param string|null $resource resource identity filter + * + * @return array{string,array} exact bound mapping predicate + */ + private function referenceSelection(string $org, ?string $system, ?string $type, ?string $resource): array + { + $where = 'organization_id = :org'; + $params = ['org' => $org]; + foreach (['system' => $system, 'resource_type' => $type, 'resource_id' => $resource] as $key => $value) { + if (null !== $value) { + $where .= ' AND ' . $key . ' = :' . $key; + $params[$key] = $value; + } + } + + return [$where, $params]; + } + + /** + * Method time + * + * @param DateTimeImmutable $date source instant + * + * @return string UTC storage instant + */ + private function time(DateTimeImmutable $date): string + { + return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'); + } + + /** + * Method json + * + * @param mixed $value preserved JSON value + * + * @return string canonical object ordering with exact list ordering + */ + private function json(mixed $value): string + { + return json_encode($this->canonical($value), JSON_THROW_ON_ERROR); + } + + /** + * Method canonical + * + * @param mixed $value JSON-compatible value + * + * @return mixed recursively stable object ordering + */ + private function canonical(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + if (!array_is_list($value)) { + ksort($value, SORT_STRING); + } + foreach ($value as $key => $child) { + $value[$key] = $this->canonical($child); + } + + return $value; + } + + /** + * Method object + * + * @param string $json stored JSON mapping + * + * @return array decoded string-keyed mapping + */ + private function object(string $json): array + { + return $this->stringKeys(json_decode($json, true, 512, JSON_THROW_ON_ERROR)); + } + + /** + * Method stringKeys + * + * @param mixed $value stored mapping candidate + * + * @return array validated string keys + */ + private function stringKeys(mixed $value): array + { + if (!is_array($value)) { + throw new LogicException('Invalid retained export JSON mapping.'); + } + $result = []; + foreach ($value as $key => $child) { + if (!is_string($key)) { + throw new LogicException('Invalid retained export JSON mapping key.'); + } + $result[$key] = $child; + } + + return $result; + } + + /** + * Method string + * + * @param array $row SQL row + * @param string $key expected text column + * + * @return string validated stored text + */ + private function string(array $row, string $key): string + { + $value = $row[$key] ?? null; + if (!is_string($value)) { + throw new LogicException('Invalid retained export text field ' . $key . '.'); + } + + return $value; + } + + /** + * Method nullableString + * + * @param array $row SQL row + * @param string $key nullable text column + * + * @return string|null validated nullable text + */ + private function nullableString(array $row, string $key): ?string + { + return null === ($row[$key] ?? null) ? null : $this->string($row, $key); + } + + /** + * Method number + * + * @param mixed $value integer column or aggregate + * + * @return int validated nonnegative integer + */ + private function number(mixed $value): int + { + if (is_int($value)) { + return $value; + } + if (is_string($value) && 1 === preg_match('/^\d+$/D', $value)) { + return (int) $value; + } + + throw new LogicException('Invalid retained export integer field.'); + } + + /** + * Method boolean + * + * @param mixed $value PostgreSQL boolean field + * + * @return bool validated driver boolean representation + */ + private function boolean(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + return match ($value) { + 1, '1', 't', 'true' => true, + 0, '0', 'f', 'false' => false, + default => throw new LogicException('Invalid retained export boolean field.'), + }; + } + // #endregion +} diff --git a/src/MaintenanceExport/MODULE.md b/src/MaintenanceExport/MODULE.md new file mode 100644 index 000000000..a20979d9c --- /dev/null +++ b/src/MaintenanceExport/MODULE.md @@ -0,0 +1,70 @@ +# MaintenanceExport + +## Overview + +MaintenanceExport owns retained ERP prestation exports and organization-internal external identity mappings. FireGuard retains its fire-park specialization; quotations, selling prices, taxes, invoicing and payments belong to the commercial ERP. Billing continues to own the FireGuard SaaS subscription. + +Each generated export preserves schema-version-1 JSON and CSV bytes, SHA-256 fingerprints and the source fact baseline. Downloads always read these saved bytes. Names, references, independent time corrections and later internal costs cannot rewrite an original artifact or the published operational dossier. + +## API endpoints + +All routes require authentication and use `/api/organizations/{organizationId}`. + +| Method | Path | Behavior | +|---|---|---| +| GET / POST | `/maintenance-exports` | Paginate retained exports or generate from 1–100 published intervention IDs. | +| GET | `/maintenance-exports/{id}` | Read retained metadata, version, checksums and import state. | +| GET | `/maintenance-exports/{id}/files/{format}` | Download the exact saved JSON or CSV bytes. | +| POST | `/maintenance-exports/{id}/adjustments` | Append a motivated adjustment to the latest export in a chain. | +| POST | `/maintenance-exports/{id}/confirm` | Record an explicit external import acknowledgement. | +| GET | `/maintenance-export-sources` | Paginate published dossiers, including unavailable historical snapshots. | +| GET | `/maintenance-export-references` | Paginate external references by system, resourceType and resourceId. | +| PATCH | `/maintenance-export-references/{resourceType}/{resourceId}` | Upsert a customer, root site or equipment external reference. | + +Collections use Hydra pagination, default 30 and maximum 100; source search is literal and limited to 160 Unicode characters. Export collections filter by explicit ERP `system`. Sources expose `snapshotState`, `identityComplete`, `ready` and `blockedReason` (`snapshot_missing` or `no_validated_work`), with minimal client/site identity and no contacts. + +Creation accepts `clientOperationId`, `interventionIds`, `system` and `includeInternalCosts` (false by default). Adjustments accept `clientOperationId` and `reason`; confirmation accepts `clientOperationId` and `externalImportReference`. Existing-resource actions require `If-Match: "revision-N"`. Reference creation uses revision zero; later changes use the returned mapping revision. Reference and import identifiers allow 200 Unicode characters; adjustment reasons allow 1000. + +## Flows + +Generation locks the scoped published interventions, reads their retained operational dossiers, captures external mappings, renders both formats and inserts the operation receipt in one main transaction. Every selected dossier must contain a validated prestation. A missing or unsupported historical snapshot blocks generation; the module never substitutes live historic names, client/site identity, results or dates. Retained version-one inspection dossiers can expose a validated published inspection reference while their uncaptured actual result/date and asset identity remain explicitly unknown. + +An initial financial export additionally requires a private financial snapshot belonging to the same publication. Unknown amounts remain null, with `costsComplete=false` and an explicit incomplete contribution count; they are never replaced with zero. Normal exports omit amount/currency columns and JSON financial facts. Individual target identity, including an explicit absent customer, never inherits another task's primary client. Financial contributions retain their own captured allocation even when their task was skipped or unvalidated; only validated tasks become prestation rows. Older contributions without an allocation retain their task's frozen identity. Direct material consumption uses its own owner-supplied allocation, with an explicit incomplete identity when unavailable. + +Rows carry stable source, work-item, publication and source-revision identifiers. JSON preserves exact decimal strings. CSV uses RFC4180 quoting and protects user text from spreadsheet formulas; numeric amounts and minute corrections retain their exact numeric representation. JSON remains the canonical unmodified text representation for ERP integrations. + +A genuine correction produces compensating `reverse` rows and `add` rows linked to the preceding exported row through `correctionOf`. The new artifact retains `originalExportId`, `adjustmentOf` and a reason. Unchanged facts create no adjustment. A unique parent relationship prevents divergent adjustment forks; corrections continue from the latest artifact. Initial bytes and the original dossier remain unchanged. Each new adjustment is imported and confirmed independently. + +Generation and import confirmation are separate states (`generated` and `import_confirmed`). Confirmation is a manual external receipt, not an automatic assertion that the ERP imported a file. There is no delete or artifact-overwrite endpoint. UUIDs are normalized to lowercase. An actor/organization/operation receipt replays before stale revision checks, returning its original response metadata; reuse for another declaration conflicts without repeating effects. + +## Architecture + +Command and query handlers are the only application entry points. Domain models enforce preserved artifacts, optimistic confirmation, character limits, bounded rows and compensation semantics. Main PostgreSQL persistence verifies saved checksums and immutable fingerprints, serializes writes with an organization advisory lock and permits only confirmation/revision updates on existing documents. Archive collections read a lightweight summary through the repository port: PostgreSQL projects metadata, row count, stored hashes and UTF-8 byte lengths without transferring artifact bytes, rows or correction baselines. The API metadata contract is shared with verified full document reads; individual reads, downloads, adjustments and writes still rehydrate and verify the complete retained artifact. + +Public Intervention ports provide bounded immutable publication facts and the independent time journal. MaintenanceCost provides private frozen/current cost facts. Customer, Facility and Equipment host scoped retained-identity validators, combined through `maintenance_export.identity_validator`; the export module never reads sibling records. External mappings belong to this module and do not alter owner identities or client contacts. API Platform providers return saved-file Responses without serializer transformation. + +## Configuration + +`config/modules/maintenance_export.yaml` registers the main connection, tagged identity composite, public owner-port source adapter, processors/providers, central exception subscriber and bus handlers. Events use the main transactional outbox and carry identifiers only. The additive main migration `Version20261007100000` creates documents, immutable operation receipts and external reference mappings. Artifact text is retained as exact UTF-8; JSONB source data is canonically compared to avoid ordering-induced false corrections. Dates are UTC. + +`organization.maintenance_exports.read` authorizes reads. Generation, adjustments and references also require `.manage`; confirmation additionally requires `.confirm`. Manage alone does not grant read. Every read or mutation of a financial export independently requires `organization.maintenance_cost.read`, including replay and download. Collections omit private exports from readers without this financial permission. + +Synchronous generation is bounded to 100 selected dossiers, 10000 output rows and 10 MiB per artifact. Owner publication ports impose their own aggregate task, proof/time and encoded-dossier caps. Exceeding a bound requires a narrower explicit selection, with no silent truncation. + +## Testing + +Domain tests cover canonical Unicode JSON, exact decimal strings, formula-safe CSV, stable identifiers, character limits, compensation, unchanged JSONB facts and immutable confirmations. Handler/transport tests prove permission ordering, no-event rejection, original receipt replay and unchanged download bytes. + +PostgreSQL tests use independent sessions for organization locks, operation uniqueness, immutable byte/fact enforcement, rollback and reference CAS. Archive tests compare lightweight and verified metadata, inspect executed SQL result columns and page through 31 retained near-limit artifacts with bounded PHP memory. Functional API tests cover frozen identities, independent time correction, an adjustment chain, preserved originals, explicit import acknowledgement, missing snapshots, private unknown costs, financial denial and organization isolation. Mixed-target CSV/JSON regressions retain expense and time identities and external references for a skipped task through a compensating adjustment. Source adapter regressions also cover null per-task client/site and direct consumption outside the primary dossier target. + +Run targeted PHPUnit, PHPStan and scoped formatting before root container, architecture, PostgreSQL schema and OpenAPI gates. Test setup uses the migrated isolated test templates only. + +## Error codes + +- 400: malformed transport or unsupported serialization. +- 403: active scoped member missing export, confirmation or independent financial read permission. +- 404: organization or retained resource outside the caller's scope, unknown source or owner identity. +- 409: incompatible operation replay, overwritten acknowledgement, immutable artifact mutation, adjustment fork or absent genuine correction. +- 412: stale or malformed revision. +- 422: invalid identity/text/exact source, missing historical publication/financial snapshot, no validated prestation or exceeded explicit source/row/byte bound. +- 428: missing If-Match on an existing-resource mutation or mapping creation. diff --git a/src/MaintenanceExport/Presentation/Api/Dto/Input/AdjustMaintenanceExportInput.php b/src/MaintenanceExport/Presentation/Api/Dto/Input/AdjustMaintenanceExportInput.php new file mode 100644 index 000000000..3abd0b5bb --- /dev/null +++ b/src/MaintenanceExport/Presentation/Api/Dto/Input/AdjustMaintenanceExportInput.php @@ -0,0 +1,30 @@ + published intervention identifiers + */ + #[Groups(['maintenance_export:write'])] + #[Assert\Count(min: 1, max: 100)] + #[Assert\All([new Assert\Uuid()])] + public array $interventionIds = []; + + #[Groups(['maintenance_export:write'])] + #[Assert\NotBlank()] + #[Assert\Length(max: 40)] + public string $system = ''; + + #[Groups(['maintenance_export:write'])] + public bool $includeInternalCosts = false; + + // #endregion +} diff --git a/src/MaintenanceExport/Presentation/Api/Dto/Input/WriteMaintenanceExportReferenceInput.php b/src/MaintenanceExport/Presentation/Api/Dto/Input/WriteMaintenanceExportReferenceInput.php new file mode 100644 index 000000000..f8db632c0 --- /dev/null +++ b/src/MaintenanceExport/Presentation/Api/Dto/Input/WriteMaintenanceExportReferenceInput.php @@ -0,0 +1,35 @@ + retained structured metadata + */ + #[Groups(['maintenance_export:read'])] + public array $sourceInterventionIds; + + #[Groups(['maintenance_export:read'])] + public ?string $originalExportId; + + #[Groups(['maintenance_export:read'])] + public ?string $adjustmentOf; + + #[Groups(['maintenance_export:read'])] + public ?string $reason; + + #[Groups(['maintenance_export:read'])] + public string $createdAt; + + #[Groups(['maintenance_export:read'])] + public int $revision; + + #[Groups(['maintenance_export:read'])] + public string $state; + + /** + * @var array|null retained structured metadata + */ + #[Groups(['maintenance_export:read'])] + public ?array $confirmation; + + #[Groups(['maintenance_export:read'])] + public int $rowCount; + + #[Groups(['maintenance_export:read'])] + public ?bool $costsComplete; + + #[Groups(['maintenance_export:read'])] + public ?int $incompleteCostCount; + + /** + * @var array retained structured metadata + */ + #[Groups(['maintenance_export:read'])] + public array $files; + + #[Groups(['maintenance_export:read'])] + public bool $replayed = false; + + // #endregion + + // #region Methods + /** + * Method fromProjection + * + * @param array $data authorized read projection + * + * @return self transport output + */ + public static function fromProjection(array $data, bool $replayed = false): self + { + /** + * @var array{id:string,organizationId:string,actorId:string,kind:string,schemaVersion:int,system:string,includeInternalCosts:bool,sourceInterventionIds:list,originalExportId:string|null,adjustmentOf:string|null,reason:string|null,createdAt:string,revision:int,state:string,confirmation:array|null,rowCount:int,costsComplete:bool|null,incompleteCostCount:int|null,files:array} $data + */ + $output = new self(); + $output->id = $data['id']; + $output->organizationId = $data['organizationId']; + $output->actorId = $data['actorId']; + $output->kind = $data['kind']; + $output->schemaVersion = $data['schemaVersion']; + $output->system = $data['system']; + $output->includeInternalCosts = $data['includeInternalCosts']; + $output->sourceInterventionIds = $data['sourceInterventionIds']; + $output->originalExportId = $data['originalExportId']; + $output->adjustmentOf = $data['adjustmentOf']; + $output->reason = $data['reason']; + $output->createdAt = $data['createdAt']; + $output->revision = $data['revision']; + $output->state = $data['state']; + $output->confirmation = $data['confirmation']; + $output->rowCount = $data['rowCount']; + $output->costsComplete = $data['costsComplete']; + $output->incompleteCostCount = $data['incompleteCostCount']; + $output->files = $data['files']; + $output->replayed = $replayed; + + return $output; + } + // #endregion +} diff --git a/src/MaintenanceExport/Presentation/Api/Dto/Output/MaintenanceExportReferenceOutput.php b/src/MaintenanceExport/Presentation/Api/Dto/Output/MaintenanceExportReferenceOutput.php new file mode 100644 index 000000000..d963231b8 --- /dev/null +++ b/src/MaintenanceExport/Presentation/Api/Dto/Output/MaintenanceExportReferenceOutput.php @@ -0,0 +1,76 @@ + $data authorized read projection + * + * @return self transport output + */ + public static function fromProjection(array $data, bool $replayed = false): self + { + /** + * @var array{id:string,organizationId:string,resourceType:string,resourceId:string,system:string,reference:string,revision:int,updatedAt:string} $data + */ + $output = new self(); + $output->id = $data['id']; + $output->organizationId = $data['organizationId']; + $output->resourceType = $data['resourceType']; + $output->resourceId = $data['resourceId']; + $output->system = $data['system']; + $output->reference = $data['reference']; + $output->revision = $data['revision']; + $output->updatedAt = $data['updatedAt']; + $output->replayed = $replayed; + + return $output; + } + // #endregion +} diff --git a/src/MaintenanceExport/Presentation/Api/Dto/Output/MaintenanceExportSourceOutput.php b/src/MaintenanceExport/Presentation/Api/Dto/Output/MaintenanceExportSourceOutput.php new file mode 100644 index 000000000..ce28e2679 --- /dev/null +++ b/src/MaintenanceExport/Presentation/Api/Dto/Output/MaintenanceExportSourceOutput.php @@ -0,0 +1,94 @@ + $data authorized read projection + * + * @return self transport output + */ + public static function fromProjection(array $data, bool $replayed = false): self + { + /** + * @var array{id:string,number:int,name:string,type:string,publishedAt:string|null,publicationId:string|null,site:array{id:string,name:string}|null,customer:array{id:string,name:string}|null,snapshotState:string,identityComplete:bool,ready:bool,blockedReason:string|null} $data + */ + $output = new self(); + $output->id = $data['id']; + $output->number = $data['number']; + $output->name = $data['name']; + $output->type = $data['type']; + $output->publishedAt = $data['publishedAt']; + $output->publicationId = $data['publicationId']; + $output->site = $data['site']; + $output->customer = $data['customer']; + $output->snapshotState = $data['snapshotState']; + $output->identityComplete = $data['identityComplete']; + $output->ready = $data['ready']; + $output->blockedReason = $data['blockedReason']; + + return $output; + } + // #endregion +} diff --git a/src/MaintenanceExport/Presentation/Api/EventSubscriber/MaintenanceExportExceptionSubscriber.php b/src/MaintenanceExport/Presentation/Api/EventSubscriber/MaintenanceExportExceptionSubscriber.php new file mode 100644 index 000000000..86ec8cba3 --- /dev/null +++ b/src/MaintenanceExport/Presentation/Api/EventSubscriber/MaintenanceExportExceptionSubscriber.php @@ -0,0 +1,67 @@ + kernel error mapping + */ + public static function getSubscribedEvents(): array + { + return [KernelEvents::EXCEPTION => ['onKernelException', 10]]; + } + + /** + * Method onKernelException + * + * @return void + */ + public function onKernelException(ExceptionEvent $event): void + { + $original = $event->getThrowable(); + if ($original instanceof HttpExceptionInterface || !str_contains($event->getRequest()->getPathInfo(), '/maintenance-export')) { + return; + } + $current = $original; + do { + if ($current instanceof MaintenanceExportException) { + $status = match($current->reason) { + 'not_found' => 404,'denied' => 403,'stale' => 412,'revision_required' => 428,'conflict' => 409,default => 422 + }; + $event->setThrowable(new HttpException($status, $current->getMessage(), $original)); + + return; + } + if ($current instanceof InvalidValueException || $current instanceof ValueError) { + $event->setThrowable(new HttpException(422, $current->getMessage(), $original)); + + return; + } + $current = $current->getPrevious(); + } while ($current instanceof Throwable); + } + // #endregion +} diff --git a/src/MaintenanceExport/Presentation/Api/Operation/MaintenanceExportOperations.php b/src/MaintenanceExport/Presentation/Api/Operation/MaintenanceExportOperations.php new file mode 100644 index 000000000..cf7aebde0 --- /dev/null +++ b/src/MaintenanceExport/Presentation/Api/Operation/MaintenanceExportOperations.php @@ -0,0 +1,34 @@ + + */ +final readonly class MaintenanceExportProcessor implements ProcessorInterface +{ + // #region Constructor + /** + * Method __construct + * + * @return void + */ + public function __construct(private CommandBusPort $commands, private CurrentActorPort $actor, private RequestStack $requests) + { + } + // #endregion + + // #region Methods + /** + * Method process + * + * @param array $uriVariables scoped route + * @param array $context serializer context + * + * @return MaintenanceExportOutput|MaintenanceExportReferenceOutput authorized metadata + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): MaintenanceExportOutput|MaintenanceExportReferenceOutput + { + $actor = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $action = match($operation->getName()) { + MaintenanceExportOperations::CREATE => 'create',MaintenanceExportOperations::ADJUST => 'adjustment',MaintenanceExportOperations::CONFIRM => 'confirm',MaintenanceExportOperations::WRITE_REFERENCE => 'reference',default => throw MaintenanceExportException::invalid('Unknown export mutation.') + }; + if (!is_object($data)) { + throw MaintenanceExportException::invalid('An export input is required.'); + } + $request = $this->requests->getCurrentRequest(); + $payload = []; + foreach ($request?->getPayload()->all() ?? [] as $field => $value) { + if (!property_exists($data, $field)) { + throw MaintenanceExportException::invalid('Unknown export input field.'); + } + $payload[$field] = $data->{$field}; + } + if ('reference' === $action) { + $payload['resourceType'] = $this->string($uriVariables, 'resourceType'); + $payload['resourceId'] = $this->string($uriVariables, 'resourceId'); + } + $header = $request?->headers->get('If-Match'); + $revision = null === $header ? null : (1 === preg_match('/^"revision-(\d+)"$/', $header, $matches) ? (int) $matches[1] : -1); + /** + * @var ManageMaintenanceExportResult $result + */ + $result = $this->commands->dispatch(new ManageMaintenanceExportCommand($actor, $this->string($uriVariables, 'organizationId'), $action, 'reference' === $action ? $payload['resourceId'] : (is_string($uriVariables['id'] ?? null) ? $uriVariables['id'] : null), $revision, $payload)); + + return 'reference' === $result->kind ? MaintenanceExportReferenceOutput::fromProjection($result->data, $result->replayed) : MaintenanceExportOutput::fromProjection($result->data, $result->replayed); + } + + /** + * Method string + * + * @param array $data route variables + * + * @return string scoped route primitive + */ + private function string(array $data, string $field): string + { + return is_string($data[$field] ?? null) ? $data[$field] : ''; + } + // #endregion +} diff --git a/src/MaintenanceExport/Presentation/Api/Provider/MaintenanceExportProvider.php b/src/MaintenanceExport/Presentation/Api/Provider/MaintenanceExportProvider.php new file mode 100644 index 000000000..eb2540ad4 --- /dev/null +++ b/src/MaintenanceExport/Presentation/Api/Provider/MaintenanceExportProvider.php @@ -0,0 +1,91 @@ + + */ +final readonly class MaintenanceExportProvider implements ProviderInterface +{ + // #region Constructor + /** + * Method __construct + * + * @return void + */ + public function __construct(private QueryBusPort $queries, private CurrentActorPort $actor, private RequestStack $requests) + { + } + // #endregion + + // #region Methods + /** + * Method provide + * + * @param array $uriVariables route scope + * @param array $context serializer context + * + * @return MaintenanceExportOutput|MaintenanceExportReferenceOutput|MaintenanceExportSourceOutput|Response|TraversablePaginator authorized data or immutable bytes + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): MaintenanceExportOutput|MaintenanceExportReferenceOutput|MaintenanceExportSourceOutput|Response|TraversablePaginator + { + $actor = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $action = match($operation->getName()) { + MaintenanceExportOperations::EXPORTS => 'exports',MaintenanceExportOperations::EXPORT => 'export',MaintenanceExportOperations::SOURCES => 'sources',MaintenanceExportOperations::REFERENCES => 'references',MaintenanceExportOperations::FILE => 'file',default => throw MaintenanceExportException::invalid('Unknown export read.') + }; + $request = $this->requests->getCurrentRequest(); + /** + * @var ReadMaintenanceExportResult $result + */ + $result = $this->queries->ask(new ReadMaintenanceExportQuery($actor, $this->string($uriVariables, 'organizationId') ?? '', $action, $this->string($uriVariables, 'id'), $this->string($uriVariables, 'format'), $request?->query->getString('system'), $request?->query->getString('resourceType'), $request?->query->getString('resourceId'), $request?->query->getInt('page', 1) ?? 1, $request?->query->getInt('itemsPerPage', 30) ?? 30, $request?->query->getString('search'))); + if ('file' === $result->kind) { + return new Response($result->bytes, 200, ['Content-Type' => $result->mediaType . '; charset=utf-8', 'Content-Disposition' => 'attachment; filename="' . $result->fileName . '"', 'ETag' => '"sha256-' . $result->sha256 . '"', 'Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']); + } + $items = []; + foreach ($result->items as $item) { + $items[] = match($result->kind) { + 'source' => MaintenanceExportSourceOutput::fromProjection($item),'reference' => MaintenanceExportReferenceOutput::fromProjection($item),default => MaintenanceExportOutput::fromProjection($item) + }; + } + if ($result->collection) { + return new TraversablePaginator(new ArrayIterator($items), (float) $result->page, (float) $result->itemsPerPage, (float) $result->total); + } + + return $items[0] ?? throw MaintenanceExportException::notFound(); + } + + /** + * Method string + * + * @param array $data route variables + * + * @return string|null optional scoped primitive + */ + private function string(array $data, string $field): ?string + { + return is_string($data[$field] ?? null) ? $data[$field] : null; + } + // #endregion +} diff --git a/src/MaintenanceExport/Presentation/Api/Resource/MaintenanceExportResource.php b/src/MaintenanceExport/Presentation/Api/Resource/MaintenanceExportResource.php new file mode 100644 index 000000000..75d4c335c --- /dev/null +++ b/src/MaintenanceExport/Presentation/Api/Resource/MaintenanceExportResource.php @@ -0,0 +1,40 @@ + ['maintenance_export:read'], 'skip_null_values' => false], + denormalizationContext:['groups' => ['maintenance_export:write']], + operations:[ + new GetCollection(name:MaintenanceExportOperations::EXPORTS, uriTemplate:'/organizations/{organizationId}/maintenance-exports', output:MaintenanceExportOutput::class, provider:MaintenanceExportProvider::class, paginationEnabled:true, paginationClientItemsPerPage:true, paginationItemsPerPage:30, paginationMaximumItemsPerPage:100, parameters:['system' => new QueryParameter(schema:['type' => 'string'], castToArray:false)], openapi:new Operation(tags:['Maintenance export'], summary:'List preserved ERP prestation exports')), + new Post(name:MaintenanceExportOperations::CREATE, uriTemplate:'/organizations/{organizationId}/maintenance-exports', input:CreateMaintenanceExportInput::class, output:MaintenanceExportOutput::class, processor:MaintenanceExportProcessor::class, read:false, status:201, openapi:new Operation(tags:['Maintenance export'], summary:'Preserve versioned CSV and JSON bytes atomically')), + new Get(name:MaintenanceExportOperations::EXPORT, uriTemplate:'/organizations/{organizationId}/maintenance-exports/{id}', output:MaintenanceExportOutput::class, provider:MaintenanceExportProvider::class, openapi:new Operation(tags:['Maintenance export'], summary:'Read retained export metadata')), + new Post(name:MaintenanceExportOperations::ADJUST, uriTemplate:'/organizations/{organizationId}/maintenance-exports/{id}/adjustments', input:AdjustMaintenanceExportInput::class, output:MaintenanceExportOutput::class, processor:MaintenanceExportProcessor::class, read:false, status:201, openapi:new Operation(tags:['Maintenance export'], summary:'Append an adjustment without rewriting original files')), + new Post(name:MaintenanceExportOperations::CONFIRM, uriTemplate:'/organizations/{organizationId}/maintenance-exports/{id}/confirm', input:ConfirmMaintenanceExportInput::class, output:MaintenanceExportOutput::class, processor:MaintenanceExportProcessor::class, read:false, status:200, openapi:new Operation(tags:['Maintenance export'], summary:'Explicitly acknowledge the external import')), + new Get(name:MaintenanceExportOperations::FILE, uriTemplate:'/organizations/{organizationId}/maintenance-exports/{id}/files/{format}', output:false, provider:MaintenanceExportProvider::class, serialize:false, openapi:new Operation(tags:['Maintenance export'], summary:'Download the exact preserved CSV or JSON bytes')), + new GetCollection(name:MaintenanceExportOperations::SOURCES, uriTemplate:'/organizations/{organizationId}/maintenance-export-sources', output:MaintenanceExportSourceOutput::class, provider:MaintenanceExportProvider::class, paginationEnabled:true, paginationClientItemsPerPage:true, paginationItemsPerPage:30, paginationMaximumItemsPerPage:100, parameters:['search' => new QueryParameter(schema:['type' => 'string'], castToArray:false)], openapi:new Operation(tags:['Maintenance export'], summary:'List published dossiers with explicit snapshot availability')), + new GetCollection(name:MaintenanceExportOperations::REFERENCES, uriTemplate:'/organizations/{organizationId}/maintenance-export-references', output:MaintenanceExportReferenceOutput::class, provider:MaintenanceExportProvider::class, paginationEnabled:true, paginationClientItemsPerPage:true, paginationItemsPerPage:30, paginationMaximumItemsPerPage:100, parameters:['system' => new QueryParameter(schema:['type' => 'string'], castToArray:false), 'resourceType' => new QueryParameter(schema:['type' => 'string', 'enum' => ['customer', 'site', 'equipment']], castToArray:false), 'resourceId' => new QueryParameter(schema:['type' => 'string', 'format' => 'uuid'], castToArray:false)], openapi:new Operation(tags:['Maintenance export'], summary:'Read scoped external identity mappings')), + new Patch(name:MaintenanceExportOperations::WRITE_REFERENCE, uriTemplate:'/organizations/{organizationId}/maintenance-export-references/{resourceType}/{resourceId}', input:WriteMaintenanceExportReferenceInput::class, output:MaintenanceExportReferenceOutput::class, processor:MaintenanceExportProcessor::class, read:false, status:200, openapi:new Operation(tags:['Maintenance export'], summary:'Set an external identity reference with an optimistic revision')), + ], +)] +final class MaintenanceExportResource +{ +} diff --git a/src/Onboarding/Application/Service/OrganizationSetupService.php b/src/Onboarding/Application/Service/OrganizationSetupService.php index 04b579912..26a03871a 100644 --- a/src/Onboarding/Application/Service/OrganizationSetupService.php +++ b/src/Onboarding/Application/Service/OrganizationSetupService.php @@ -41,10 +41,10 @@ * Constant FIELDS */ private const array FIELDS = [ - 'create_organization' => ['name' => '', 'slug' => null], + 'create_organization' => ['name' => '', 'slug' => null, 'operatingProfile' => 'operator'], 'invite_members' => ['email' => '', 'roleIds' => []], - 'create_first_facility' => ['type' => '', 'name' => '', 'address' => null, 'latitude' => null, 'longitude' => null, 'code' => null, 'parentFacilityId' => null, 'metadata' => [], 'levelIndex' => null], - 'create_first_equipment' => ['type' => '', 'subType' => null, 'brand' => null, 'model' => null, 'serialNumber' => null, 'locationLabel' => null, 'facility' => null], + 'create_first_facility' => ['type' => '', 'name' => '', 'address' => null, 'latitude' => null, 'longitude' => null, 'code' => null, 'parentFacilityId' => null, 'metadata' => [], 'levelIndex' => null, 'customerId' => null], + 'create_first_equipment' => ['type' => '', 'subType' => null, 'brand' => null, 'model' => null, 'serialNumber' => null, 'locationLabel' => null, 'facility' => null, 'name' => null, 'assetCode' => null, 'criticality' => null, 'technicalProperties' => []], ]; /** @@ -233,6 +233,9 @@ private function normalize(string $stepKey, array $payload): array throw OrganizationSetupConflict::because('Unsupported setup input.'); } $normalized = [...$defaults, ...$payload]; + if ('create_organization' === $stepKey && !in_array($normalized['operatingProfile'], ['operator', 'service_provider'], true)) { + throw OrganizationSetupConflict::because('Invalid organization operating profile.'); + } if ('invite_members' === $stepKey) { $normalized['roleIds'] ??= []; if (!is_string($normalized['email']) || !is_array($normalized['roleIds'])) { diff --git a/src/Onboarding/MODULE.md b/src/Onboarding/MODULE.md index 0f5e52504..4d067ea18 100644 --- a/src/Onboarding/MODULE.md +++ b/src/Onboarding/MODULE.md @@ -122,6 +122,17 @@ Every state projection includes nullable `accessibleOrganizationId`, independent ## Durable setup recovery +### Operating profile in organization setup + +Prepared `create_organization` inputs include `operatingProfile`, restricted to +`operator` and `service_provider`. Omission normalizes to `operator`, so pre-profile +receipts remain replayable with the explicit default. The chosen profile remains part +of the prepared payload: creation and replay must submit the same normalized profile. +A completed item key cannot be reused to change this choice, and a missing provider +profile on replay returns `onboarding_setup_conflict` rather than silently substituting +operator defaults. A settings change after creation uses Organization's existing +permission-controlled PATCH and does not rewrite the onboarding receipt. + `POST /api/onboarding/organization/setup-operations` prepares `{sessionId, stepKey, items:[{itemKey,payload}]}` and returns the onboarding state. `sessionId` and `setupOperations` are returned by every flow projection; operations carry `stepKey`, `itemKey`, the bounded whitelisted payload, nullable `resourceId`, and `prepared` / `completed` status. Inputs contain no tokens. This authenticated projection is not an SSR transfer payload. Preparation replaces omitted pending items of the current step, preserves completed results, and refuses changed payloads for existing keys. It permits one organization/equipment and up to five invitations/facilities per session step. Reset starts a different session, invalidating previous receipts. Preparing a batch alone creates no domain resource and consumes no quota. diff --git a/src/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationCommand.php b/src/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationCommand.php index 809745bf3..a6c7d6cc2 100644 --- a/src/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationCommand.php +++ b/src/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationCommand.php @@ -35,6 +35,7 @@ public function __construct( public string $ownerUserId, public ?string $slug = null, public ?OrganizationSetupContext $setupContext = null, + public string $operatingProfile = 'operator', ) { } // #endregion diff --git a/src/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationHandler.php b/src/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationHandler.php index d304599cc..4521cc591 100644 --- a/src/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationHandler.php +++ b/src/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationHandler.php @@ -135,6 +135,7 @@ public function __invoke(CreateOrganizationCommand $command): CreateOrganization name: new OrganizationName($command->name), ownerUserId: $command->ownerUserId, options: new OrganizationCreationOptions( + operatingProfile: \Organization\Domain\ValueObject\OrganizationOperatingProfile::fromString($command->operatingProfile), slug: null !== $normalizedSlug ? new OrganizationSlug($normalizedSlug) : null, planId: $defaultPlan?->id(), ), @@ -218,7 +219,7 @@ public function __invoke(CreateOrganizationCommand $command): CreateOrganization private function createWithinTransaction(CreateOrganizationCommand $command, Organization $organization, OrganizationRole $ownerRole, OrganizationRole $memberRole, OrganizationMember $ownerMember, bool &$replayed): CreateOrganizationResult { if (null !== $command->setupContext) { - $operation = ($this->setup ?? throw OrganizationSetupConflict::because(self::SETUP_JOURNAL_UNAVAILABLE_MESSAGE))->begin($command->setupContext, 'create_organization', null, ['name' => $command->name, 'slug' => $command->slug]); + $operation = ($this->setup ?? throw OrganizationSetupConflict::because(self::SETUP_JOURNAL_UNAVAILABLE_MESSAGE))->begin($command->setupContext, 'create_organization', null, ['name' => $command->name, 'slug' => $command->slug, 'operatingProfile' => $command->operatingProfile]); if (null !== $operation->resourceId) { $existing = $this->organizationRepository->findById(OrganizationId::fromString($operation->resourceId)); if (null === $existing || $existing->ownerUserId() !== $command->ownerUserId) { @@ -231,6 +232,7 @@ private function createWithinTransaction(CreateOrganizationCommand $command, Org ownerMemberId: $operation->resultIds['ownerMemberId'], ownerRoleId: $operation->resultIds['ownerRoleId'], name: (string) $existing->name(), + operatingProfile: $existing->operatingProfile()->value, slug: (string) $existing->slug(), ownerUserId: $existing->ownerUserId(), createdByUserId: $existing->createdByUserId(), @@ -267,6 +269,7 @@ private function createWithinTransaction(CreateOrganizationCommand $command, Org ownerMemberId: (string) $ownerMemberId, ownerRoleId: (string) $ownerRoleId, name: (string) $organization->name(), + operatingProfile: $organization->operatingProfile()->value, slug: (string) $organization->slug(), ownerUserId: $organization->ownerUserId(), createdByUserId: $organization->createdByUserId(), diff --git a/src/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationResult.php b/src/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationResult.php index 8c9ea3412..6cbf29c9e 100644 --- a/src/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationResult.php +++ b/src/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationResult.php @@ -48,6 +48,7 @@ public function __construct( public string $status, public DateTimeImmutable $createdAt, public DateTimeImmutable $updatedAt, + public string $operatingProfile = 'operator', ) { } // #endregion diff --git a/src/Organization/Application/UseCase/Command/Organization/UpdateOrganizationSettings/UpdateOrganizationSettingsCommand.php b/src/Organization/Application/UseCase/Command/Organization/UpdateOrganizationSettings/UpdateOrganizationSettingsCommand.php index 7de8de175..8852b3034 100644 --- a/src/Organization/Application/UseCase/Command/Organization/UpdateOrganizationSettings/UpdateOrganizationSettingsCommand.php +++ b/src/Organization/Application/UseCase/Command/Organization/UpdateOrganizationSettings/UpdateOrganizationSettingsCommand.php @@ -71,6 +71,7 @@ public function __construct( public ?string $vatNumber = null, public ?array $registeredAddress = null, public ?string $privacyContactEmail = null, + public ?string $operatingProfile = null, ) { } // #endregion diff --git a/src/Organization/Application/UseCase/Command/Organization/UpdateOrganizationSettings/UpdateOrganizationSettingsHandler.php b/src/Organization/Application/UseCase/Command/Organization/UpdateOrganizationSettings/UpdateOrganizationSettingsHandler.php index aaabc47c3..39c8a17d9 100644 --- a/src/Organization/Application/UseCase/Command/Organization/UpdateOrganizationSettings/UpdateOrganizationSettingsHandler.php +++ b/src/Organization/Application/UseCase/Command/Organization/UpdateOrganizationSettings/UpdateOrganizationSettingsHandler.php @@ -130,6 +130,10 @@ public function __invoke(UpdateOrganizationSettingsCommand $command): UpdateOrga private function applyGeneralChanges(Organization $organization, UpdateOrganizationSettingsCommand $command): array { $fields = []; + if (null !== $command->operatingProfile) { + $organization->changeOperatingProfile(\Organization\Domain\ValueObject\OrganizationOperatingProfile::fromString($command->operatingProfile)); + $fields[] = 'operatingProfile'; + } if (null !== $command->name) { $organization->rename(new OrganizationName($command->name)); $fields[] = 'name'; diff --git a/src/Organization/Application/UseCase/Query/Organization/GetOrganization/GetOrganizationHandler.php b/src/Organization/Application/UseCase/Query/Organization/GetOrganization/GetOrganizationHandler.php index 948d0aaf1..732084a4e 100644 --- a/src/Organization/Application/UseCase/Query/Organization/GetOrganization/GetOrganizationHandler.php +++ b/src/Organization/Application/UseCase/Query/Organization/GetOrganization/GetOrganizationHandler.php @@ -84,6 +84,7 @@ public function __invoke(GetOrganizationQuery $query): GetOrganizationResult return new GetOrganizationResult( id: (string) $organization->id(), name: (string) $organization->name(), + operatingProfile: $organization->operatingProfile()->value, slug: (string) $organization->slug(), ownerUserId: $organization->ownerUserId(), createdByUserId: $organization->createdByUserId(), diff --git a/src/Organization/Application/UseCase/Query/Organization/GetOrganization/GetOrganizationResult.php b/src/Organization/Application/UseCase/Query/Organization/GetOrganization/GetOrganizationResult.php index 57552732a..b87d0d14f 100644 --- a/src/Organization/Application/UseCase/Query/Organization/GetOrganization/GetOrganizationResult.php +++ b/src/Organization/Application/UseCase/Query/Organization/GetOrganization/GetOrganizationResult.php @@ -77,6 +77,7 @@ public function __construct( public ?array $roles = null, public ?array $registeredAddress = null, public ?string $privacyContactEmail = null, + public string $operatingProfile = 'operator', ) { } // #endregion diff --git a/src/Organization/Application/UseCase/Query/Organization/ListUserOrganizations/ListUserOrganizationsHandler.php b/src/Organization/Application/UseCase/Query/Organization/ListUserOrganizations/ListUserOrganizationsHandler.php index dcfb9e8d2..48fc7e588 100644 --- a/src/Organization/Application/UseCase/Query/Organization/ListUserOrganizations/ListUserOrganizationsHandler.php +++ b/src/Organization/Application/UseCase/Query/Organization/ListUserOrganizations/ListUserOrganizationsHandler.php @@ -168,6 +168,7 @@ private function organizationResult( return new GetOrganizationResult( id: $organizationId, name: (string) $organization->name(), + operatingProfile: $organization->operatingProfile()->value, slug: (string) $organization->slug(), ownerUserId: $organization->ownerUserId(), createdByUserId: $organization->createdByUserId(), diff --git a/src/Organization/Domain/Catalog/OrganizationPermissionCatalog.php b/src/Organization/Domain/Catalog/OrganizationPermissionCatalog.php index 17e9980a3..22f9df829 100644 --- a/src/Organization/Domain/Catalog/OrganizationPermissionCatalog.php +++ b/src/Organization/Domain/Catalog/OrganizationPermissionCatalog.php @@ -223,6 +223,10 @@ public static function definitions(): array ['name' => 'organization.teams.write', 'description' => 'Manage organization teams (create, update, add/remove members)'], ['name' => 'organization.teams.manage', 'description' => 'Delete organization teams'], + // Internal customer management + ['name' => 'organization.customers.read', 'description' => 'View internal customers and their operational contacts'], + ['name' => 'organization.customers.manage', 'description' => 'Manage internal customers, contacts and archival'], + // Facility management ['name' => 'organization.facilities.read', 'description' => 'View organization facilities'], ['name' => 'organization.facilities.write', 'description' => 'Manage organization facilities (create, update, archive, move, attachments)'], @@ -253,6 +257,21 @@ public static function definitions(): array ['name' => 'organization.maintenance.read', 'description' => 'View organization preventive-maintenance schedules'], ['name' => 'organization.maintenance.manage', 'description' => 'Manage preventive-maintenance schedule overrides and generate inspection campaigns'], + // Repair requests and internal maintenance resources + ['name' => 'organization.service_requests.read', 'description' => 'View internal maintenance requests'], + ['name' => 'organization.service_requests.create', 'description' => 'Declare internal maintenance requests'], + ['name' => 'organization.service_requests.manage', 'description' => 'Qualify maintenance requests and explicitly connect corrective work'], + ['name' => 'organization.inventory.read', 'description' => 'View parts, warehouses and stock quantities'], + ['name' => 'organization.inventory.manage', 'description' => 'Manage parts, warehouses and motivated stock reconciliation'], + ['name' => 'organization.inventory.consume', 'description' => 'Declare physical material use and linked returns during authorized work'], + ['name' => 'organization.procurement.read', 'description' => 'View internal suppliers, orders and receipts'], + ['name' => 'organization.procurement.manage', 'description' => 'Manage internal procurement and equipment individualization'], + ['name' => 'organization.maintenance_cost.read', 'description' => 'View private internal maintenance costs, rates and valuations'], + ['name' => 'organization.maintenance_cost.manage', 'description' => 'Configure internal currency, rates, budgets and motivated cost corrections'], + ['name' => 'organization.maintenance_exports.read', 'description' => 'Read retained maintenance exports and external reference mappings'], + ['name' => 'organization.maintenance_exports.manage', 'description' => 'Generate immutable validated maintenance exports and linked adjustments'], + ['name' => 'organization.maintenance_exports.confirm', 'description' => 'Record explicit ERP import confirmations'], + // Messaging ['name' => 'organization.messaging.read', 'description' => 'View organization conversations and messages'], ['name' => 'organization.messaging.write', 'description' => 'Post, edit, and delete own messages in organization conversations'], diff --git a/src/Organization/Domain/Catalog/OrganizationSystemRoleCatalog.php b/src/Organization/Domain/Catalog/OrganizationSystemRoleCatalog.php index f5bbe7641..e0834fce2 100644 --- a/src/Organization/Domain/Catalog/OrganizationSystemRoleCatalog.php +++ b/src/Organization/Domain/Catalog/OrganizationSystemRoleCatalog.php @@ -50,6 +50,7 @@ public static function permissionsFor(string $roleName): array 'organization.members.read', 'organization.roles.read', 'organization.facilities.read', + 'organization.customers.read', 'organization.equipment.read', 'organization.inspection.read', 'organization.interventions.read', diff --git a/src/Organization/Domain/Model/Organization/Organization.php b/src/Organization/Domain/Model/Organization/Organization.php index 3987b58ac..5f57173da 100644 --- a/src/Organization/Domain/Model/Organization/Organization.php +++ b/src/Organization/Domain/Model/Organization/Organization.php @@ -16,6 +16,7 @@ OrganizationLegalType, OrganizationName, OrganizationNotificationSettings, + OrganizationOperatingProfile, OrganizationRegionalSettings, OrganizationRegisteredAddress, OrganizationRegistrationNumber, @@ -100,6 +101,7 @@ private function __construct( private ?OrganizationVatNumber $vatNumber = null, private ?OrganizationRegisteredAddress $registeredAddress = null, private ?Email $privacyContactEmail = null, + private OrganizationOperatingProfile $operatingProfile = OrganizationOperatingProfile::OPERATOR, ) { $this->settings = $settings ?? OrganizationSettings::default(); } @@ -141,6 +143,7 @@ public static function create( description: $options->description, logoUrl: $options->logoUrl, settings: $options->settings, + operatingProfile: $options->operatingProfile, planId: $options->planId, ); } @@ -181,6 +184,7 @@ public static function reconstitute( description: $profile->description, logoUrl: $profile->logoUrl, settings: $profile->settings, + operatingProfile: $profile->operatingProfile, planId: $profile->planId, country: $legal->country, legalType: $legal->legalType, @@ -220,6 +224,27 @@ public function name(): OrganizationName return $this->name; } + /** + * Method operatingProfile. + * + * @return OrganizationOperatingProfile the organization's operational defaults + */ + public function operatingProfile(): OrganizationOperatingProfile + { + return $this->operatingProfile; + } + + /** + * Method changeOperatingProfile. + * + * @param OrganizationOperatingProfile $profile the requested operational defaults + */ + public function changeOperatingProfile(OrganizationOperatingProfile $profile): void + { + $this->operatingProfile = $profile; + $this->updatedAt = new DateTimeImmutable(); + } + /** * Method slug. * diff --git a/src/Organization/Domain/Model/Organization/OrganizationCreationOptions.php b/src/Organization/Domain/Model/Organization/OrganizationCreationOptions.php index 7f0c5cd98..d2e09156e 100644 --- a/src/Organization/Domain/Model/Organization/OrganizationCreationOptions.php +++ b/src/Organization/Domain/Model/Organization/OrganizationCreationOptions.php @@ -4,7 +4,7 @@ namespace Organization\Domain\Model\Organization; -use Organization\Domain\ValueObject\{OrganizationSettings, OrganizationSlug, PlanId}; +use Organization\Domain\ValueObject\{OrganizationOperatingProfile, OrganizationSettings, OrganizationSlug, PlanId}; /** * Optional values supplied when creating an organization. @@ -40,6 +40,7 @@ public function __construct( public ?string $logoUrl = null, public ?OrganizationSettings $settings = null, public ?PlanId $planId = null, + public OrganizationOperatingProfile $operatingProfile = OrganizationOperatingProfile::OPERATOR, ) { } // #endregion diff --git a/src/Organization/Domain/Model/Organization/RestoredOrganizationProfile.php b/src/Organization/Domain/Model/Organization/RestoredOrganizationProfile.php index 10e8a2840..6cecd5aec 100644 --- a/src/Organization/Domain/Model/Organization/RestoredOrganizationProfile.php +++ b/src/Organization/Domain/Model/Organization/RestoredOrganizationProfile.php @@ -4,7 +4,7 @@ namespace Organization\Domain\Model\Organization; -use Organization\Domain\ValueObject\{OrganizationSettings, PlanId}; +use Organization\Domain\ValueObject\{OrganizationOperatingProfile, OrganizationSettings, PlanId}; /** Optional persisted presentation, preferences, and plan assignment. */ final readonly class RestoredOrganizationProfile @@ -29,6 +29,7 @@ public function __construct( public ?string $logoUrl = null, public ?OrganizationSettings $settings = null, public ?PlanId $planId = null, + public OrganizationOperatingProfile $operatingProfile = OrganizationOperatingProfile::OPERATOR, ) { } // #endregion diff --git a/src/Organization/Domain/ValueObject/OrganizationOperatingProfile.php b/src/Organization/Domain/ValueObject/OrganizationOperatingProfile.php new file mode 100644 index 000000000..a742f1029 --- /dev/null +++ b/src/Organization/Domain/ValueObject/OrganizationOperatingProfile.php @@ -0,0 +1,32 @@ +operatingProfile), description: $record->description, logoUrl: $record->logoUrl, settings: OrganizationSettings::fromArray($record->settings), @@ -99,6 +101,7 @@ public static function toRecord(Organization $organization): OrganizationRecord $record = new OrganizationRecord(); $record->id = (string) $organization->id(); $record->name = (string) $organization->name(); + $record->operatingProfile = $organization->operatingProfile()->value; $record->slug = (string) $organization->slug(); $record->ownerUserId = $organization->ownerUserId(); $record->createdByUserId = $organization->createdByUserId(); diff --git a/src/Organization/Infrastructure/Persistence/Doctrine/Record/OrganizationRecord.php b/src/Organization/Infrastructure/Persistence/Doctrine/Record/OrganizationRecord.php index b3f69d4d0..9f250df11 100644 --- a/src/Organization/Infrastructure/Persistence/Doctrine/Record/OrganizationRecord.php +++ b/src/Organization/Infrastructure/Persistence/Doctrine/Record/OrganizationRecord.php @@ -48,6 +48,14 @@ class OrganizationRecord #[ORM\Column(type: 'string', length: 120)] public string $name; + /** + * Property operatingProfile. + * + * Operational defaults are independent from membership and permissions. + */ + #[ORM\Column(name: 'operating_profile', type: 'string', length: 32, options: ['default' => 'operator'])] + public string $operatingProfile = 'operator'; + /** * Property slug. * diff --git a/src/Organization/Infrastructure/Persistence/Doctrine/Repository/OrganizationRepository.php b/src/Organization/Infrastructure/Persistence/Doctrine/Repository/OrganizationRepository.php index fda61a451..205b6ba83 100644 --- a/src/Organization/Infrastructure/Persistence/Doctrine/Repository/OrganizationRepository.php +++ b/src/Organization/Infrastructure/Persistence/Doctrine/Repository/OrganizationRepository.php @@ -107,6 +107,7 @@ public function save(Organization $organization): void if ($existing instanceof OrganizationRecord) { $existing->name = $record->name; + $existing->operatingProfile = $record->operatingProfile; $existing->slug = $record->slug; $existing->ownerUserId = $record->ownerUserId; $existing->createdByUserId = $record->createdByUserId; diff --git a/src/Organization/MODULE.md b/src/Organization/MODULE.md index 48b46a033..9f4ced6e5 100644 --- a/src/Organization/MODULE.md +++ b/src/Organization/MODULE.md @@ -7,6 +7,20 @@ Organization manages Organizations and member-level RBAC inside each Organization. It is isolated from authentication storage and persisted in the dedicated main database. +### Operating profile + +`operatingProfile` identifies operational defaults: `operator` for a site operator, +`service_provider` for a contractor managing customer sites. Creation defaults to +`operator`, including legacy organizations migrated into the new contract. Create, +detail, membership-list and settings-update outputs expose the persisted value. +PATCH accepts either value; omission or null preserves the current profile. + +Changing the profile requires `organization.settings.write`. The profile grants no +permissions and does not alter ownership, membership roles, subscription limits or +the existing organization authorization rules. Unsupported values are rejected +before persistence. PostgreSQL functional coverage verifies creation, partial updates, +read projections, permission denial and unchanged role assignments. + Imported invitations defer email delivery to the main outbox in the creation transaction. The delivery rechecks the current token, pending state and expiry, and deduplicates by invitation plus token hash. Other invitation callers keep diff --git a/src/Organization/Presentation/Api/Dto/Input/Organization/CreateOrganizationInput.php b/src/Organization/Presentation/Api/Dto/Input/Organization/CreateOrganizationInput.php index d88d30e44..73519a50a 100644 --- a/src/Organization/Presentation/Api/Dto/Input/Organization/CreateOrganizationInput.php +++ b/src/Organization/Presentation/Api/Dto/Input/Organization/CreateOrganizationInput.php @@ -45,6 +45,16 @@ final class CreateOrganizationInput #[ApiProperty(description: 'Organization display name', required: true, example: 'Fireguard Paris')] public string $name = ''; + /** + * Property operatingProfile. + * + * Selects operator or service-provider onboarding defaults. + */ + #[Assert\Choice(choices: ['operator', 'service_provider'])] + #[Groups([OrganizationSerializationGroup::WRITE])] + #[ApiProperty(description: 'Operational profile; independent from access permissions', required: false)] + public string $operatingProfile = 'operator'; + /** * Property slug. * diff --git a/src/Organization/Presentation/Api/Dto/Input/Organization/UpdateOrganizationSettingsInput.php b/src/Organization/Presentation/Api/Dto/Input/Organization/UpdateOrganizationSettingsInput.php index a47246dc2..fd743e660 100644 --- a/src/Organization/Presentation/Api/Dto/Input/Organization/UpdateOrganizationSettingsInput.php +++ b/src/Organization/Presentation/Api/Dto/Input/Organization/UpdateOrganizationSettingsInput.php @@ -33,6 +33,16 @@ final class UpdateOrganizationSettingsInput #[ApiProperty(description: 'Organization display name', required: false, example: 'Fireguard Paris')] public ?string $name = null; + /** + * Property operatingProfile. + * + * Null or omission preserves the organization's operational profile. + */ + #[Assert\Choice(choices: ['operator', 'service_provider'])] + #[Groups([OrganizationSerializationGroup::WRITE])] + #[ApiProperty(description: 'Operational profile; independent from access permissions', required: false)] + public ?string $operatingProfile = null; + /** * Property slug. * diff --git a/src/Organization/Presentation/Api/Dto/Output/Organization/OrganizationOutput.php b/src/Organization/Presentation/Api/Dto/Output/Organization/OrganizationOutput.php index 29c69db01..4f72f75a9 100644 --- a/src/Organization/Presentation/Api/Dto/Output/Organization/OrganizationOutput.php +++ b/src/Organization/Presentation/Api/Dto/Output/Organization/OrganizationOutput.php @@ -37,6 +37,15 @@ final class OrganizationOutput #[ApiProperty(readable: true, writable: false)] public string $name = ''; + /** + * Property operatingProfile. + * + * Operational defaults for the organization workspace. + */ + #[Groups([OrganizationSerializationGroup::READ])] + #[ApiProperty(readable: true, writable: false)] + public string $operatingProfile = 'operator'; + /** * Property slug. * diff --git a/src/Organization/Presentation/Api/Processor/Organization/CreateOrganizationProcessor.php b/src/Organization/Presentation/Api/Processor/Organization/CreateOrganizationProcessor.php index b6bf323e3..ba5874c01 100644 --- a/src/Organization/Presentation/Api/Processor/Organization/CreateOrganizationProcessor.php +++ b/src/Organization/Presentation/Api/Processor/Organization/CreateOrganizationProcessor.php @@ -72,6 +72,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $result = $this->commandBus->dispatch(new CreateOrganizationCommand( setupContext: OrganizationSetupContext::fromOptional($user->getId(), $data->onboardingSessionId, $data->onboardingItemKey), name: $data->name, + operatingProfile: $data->operatingProfile, ownerUserId: $user->getId(), slug: $data->slug, )); @@ -79,6 +80,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $output = new OrganizationOutput(); $output->id = $result->organizationId; $output->name = $result->name; + $output->operatingProfile = $result->operatingProfile; $output->slug = $result->slug; $output->ownerUserId = $result->ownerUserId; $output->createdByUserId = $result->createdByUserId; diff --git a/src/Organization/Presentation/Api/Processor/Organization/UpdateOrganizationSettingsProcessor.php b/src/Organization/Presentation/Api/Processor/Organization/UpdateOrganizationSettingsProcessor.php index cd58c1a7d..c48e89a06 100644 --- a/src/Organization/Presentation/Api/Processor/Organization/UpdateOrganizationSettingsProcessor.php +++ b/src/Organization/Presentation/Api/Processor/Organization/UpdateOrganizationSettingsProcessor.php @@ -157,6 +157,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $result = $this->commandBus->dispatch(new UpdateOrganizationSettingsCommand( organizationId: $organizationId, name: $data->name, + operatingProfile: $data->operatingProfile, slug: $data->slug, description: $data->description, isActive: $data->isActive, diff --git a/src/Organization/Presentation/Api/Provider/Organization/ListUserOrganizationsProvider.php b/src/Organization/Presentation/Api/Provider/Organization/ListUserOrganizationsProvider.php index 5047d9a4b..3b8873fef 100644 --- a/src/Organization/Presentation/Api/Provider/Organization/ListUserOrganizationsProvider.php +++ b/src/Organization/Presentation/Api/Provider/Organization/ListUserOrganizationsProvider.php @@ -104,6 +104,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $output = new OrganizationOutput(); $output->id = $organization->id; $output->name = $organization->name; + $output->operatingProfile = $organization->operatingProfile; $output->slug = $organization->slug; $output->ownerUserId = $organization->ownerUserId; $output->createdByUserId = $organization->createdByUserId; diff --git a/src/Organization/Presentation/Api/Trait/OrganizationOutputMapperTrait.php b/src/Organization/Presentation/Api/Trait/OrganizationOutputMapperTrait.php index ff371c69d..cc4869e42 100644 --- a/src/Organization/Presentation/Api/Trait/OrganizationOutputMapperTrait.php +++ b/src/Organization/Presentation/Api/Trait/OrganizationOutputMapperTrait.php @@ -44,6 +44,7 @@ private function buildOrganizationOutput(GetOrganizationResult $result): Organiz $output = new OrganizationOutput(); $output->id = $result->id; $output->name = $result->name; + $output->operatingProfile = $result->operatingProfile; $output->slug = $result->slug; $output->ownerUserId = $result->ownerUserId; $output->createdByUserId = $result->createdByUserId; diff --git a/src/Procurement/Application/Contract/ProcurementOperationState.php b/src/Procurement/Application/Contract/ProcurementOperationState.php new file mode 100644 index 000000000..e24710f01 --- /dev/null +++ b/src/Procurement/Application/Contract/ProcurementOperationState.php @@ -0,0 +1,16 @@ + $declaration + */ + public function __construct(public string $organizationId, public string $clientOperationId, public string $kind, public string $fingerprint, public string $receiptId, public array $declaration = []) + { + } +} diff --git a/src/Procurement/Application/Contract/ProcurementReceiptState.php b/src/Procurement/Application/Contract/ProcurementReceiptState.php new file mode 100644 index 000000000..e798ee4b2 --- /dev/null +++ b/src/Procurement/Application/Contract/ProcurementReceiptState.php @@ -0,0 +1,45 @@ + $equipmentIds + */ + public function __construct( + public readonly string $id, + public readonly string $organizationId, + public readonly string $orderId, + public readonly string $lineId, + public readonly string $kind, + public readonly string $quantity, + public readonly ?string $warehouseId, + public readonly ?string $unitCost, + public readonly string $currency, + public readonly DateTimeImmutable $receivedAt, + public readonly string $actorId, + public readonly DateTimeImmutable $createdAt, + public ?string $inventoryMovementId = null, + public array $equipmentIds = [], + public string $returnedQuantity = '0.000000', + public ?string $blockedReason = null, + public int $revision = 1, + public string $pendingReturnQuantity = '0.000000', + ) { + } + + public function status(): string + { + if ($this->quantity === $this->returnedQuantity) { + return 'returned'; + } + + return 'part' === $this->kind ? 'stock_received' : ([] === $this->equipmentIds ? 'awaiting_individualization' : 'individualized'); + } +} diff --git a/src/Procurement/Application/Contract/ProcurementReturnState.php b/src/Procurement/Application/Contract/ProcurementReturnState.php new file mode 100644 index 000000000..e506e60bd --- /dev/null +++ b/src/Procurement/Application/Contract/ProcurementReturnState.php @@ -0,0 +1,28 @@ + + */ + public function returns(string $organizationId, string $receiptId, int $offset, int $limit): array; + + public function countReturns(string $organizationId, string $receiptId): int; + + public function operation(string $organizationId, string $clientOperationId): ?ProcurementOperationState; + + public function saveOperation(ProcurementOperationState $operation): void; + + /** + * @return list + */ + public function suppliers(string $organizationId, string $search, ?bool $archived, int $offset, int $limit): array; + + public function countSuppliers(string $organizationId, string $search, ?bool $archived): int; + + /** + * @return list + */ + public function orders(string $organizationId, ?string $status, ?string $supplierId, int $offset, int $limit): array; + + public function countOrders(string $organizationId, ?string $status, ?string $supplierId): int; + + /** + * @return list + */ + public function receipts(string $organizationId, string $orderId, int $offset, int $limit): array; + + public function countReceipts(string $organizationId, string $orderId): int; +} diff --git a/src/Procurement/Application/Service/ProcurementProjection.php b/src/Procurement/Application/Service/ProcurementProjection.php new file mode 100644 index 000000000..f024ca8ea --- /dev/null +++ b/src/Procurement/Application/Service/ProcurementProjection.php @@ -0,0 +1,76 @@ + + */ + public function supplier(Supplier $supplier): array + { + return ['id' => $supplier->id, 'organizationId' => $supplier->organizationId, 'name' => $supplier->name(), 'code' => $supplier->code(), 'email' => $supplier->email(), 'phone' => $supplier->phone(), 'contacts' => $supplier->contacts(), 'archivedAt' => $supplier->archivedAt()?->format(DATE_ATOM), 'createdAt' => $supplier->createdAt->format(DATE_ATOM), 'updatedAt' => $supplier->updatedAt()->format(DATE_ATOM), 'revision' => $supplier->revision()]; + } + + /** + * @return array + */ + public function order(PurchaseOrder $order, bool $financialVisible): array + { + $lines = []; + foreach ($order->lines() as $line) { + $lines[] = $this->line($line, $financialVisible); + } + + return ['id' => $order->id, 'organizationId' => $order->organizationId, 'supplierId' => $order->supplierId(), 'name' => $order->name(), 'currency' => $order->currency(), 'status' => $order->status()->value, 'lines' => $lines, 'financialVisible' => $financialVisible, 'revision' => $order->revision(), 'createdAt' => $order->createdAt->format(DATE_ATOM), 'updatedAt' => $order->updatedAt()->format(DATE_ATOM)]; + } + + /** + * @return array + */ + public function receipt(ProcurementReceiptState $receipt, bool $financialVisible): array + { + $data = ['id' => $receipt->id, 'organizationId' => $receipt->organizationId, 'orderId' => $receipt->orderId, 'lineId' => $receipt->lineId, 'kind' => $receipt->kind, 'quantity' => $receipt->quantity, 'warehouseId' => $receipt->warehouseId, 'currency' => $receipt->currency, 'receivedAt' => $receipt->receivedAt->format(DATE_ATOM), 'createdAt' => $receipt->createdAt->format(DATE_ATOM), 'inventoryMovementId' => $receipt->inventoryMovementId, 'equipmentIds' => $receipt->equipmentIds, 'returnedQuantity' => $receipt->returnedQuantity, 'status' => $receipt->status(), 'blockedReason' => $receipt->blockedReason, 'revision' => $receipt->revision, 'financialVisible' => $financialVisible]; + if ($financialVisible) { + $data['unitCost'] = $receipt->unitCost; + } + $data['pendingReturnQuantity'] = $receipt->pendingReturnQuantity; + + return $data; + } + + /** + * @return array + */ + public function returnDeclaration(ProcurementReturnState $return): array + { + return ['id' => $return->id, 'organizationId' => $return->organizationId, 'receiptId' => $return->receiptId, 'clientOperationId' => $return->clientOperationId, 'quantity' => $return->quantity, 'reason' => $return->reason, 'status' => $return->status, 'inventoryMovementId' => $return->inventoryMovementId, 'blockedReason' => $return->blockedReason, 'createdAt' => $return->createdAt->format(DATE_ATOM), 'reconciledAt' => $return->reconciledAt?->format(DATE_ATOM), 'revision' => $return->revision]; + } + + /** + * @return array + */ + private function line(ProcurementLine $line, bool $financialVisible): array + { + $data = ['id' => $line->id, 'kind' => $line->kind, 'partId' => $line->partId, 'typeCode' => $line->typeCode, 'identityTemplate' => $line->identityTemplate, 'quantity' => $line->quantity, 'receivedQuantity' => $line->receivedQuantity, 'returnedQuantity' => $line->returnedQuantity, 'remainingQuantity' => $line->remainingQuantity()]; + if ('part' === $line->kind) { + $data['partCode'] = $line->partCode; + $data['partLabel'] = $line->partLabel; + $data['partUnit'] = $line->partUnit; + } + if ($financialVisible) { + $data['unitCost'] = $line->unitCost; + } + + return $data; + } +} diff --git a/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementCommand.php b/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementCommand.php new file mode 100644 index 000000000..fe776a0fb --- /dev/null +++ b/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementCommand.php @@ -0,0 +1,18 @@ + $payload + */ + public function __construct(public string $actorId, public string $organizationId, public string $action, public ?string $id = null, public ?int $expectedRevision = null, public array $payload = []) + { + } +} diff --git a/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandler.php b/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandler.php new file mode 100644 index 000000000..c102ace75 --- /dev/null +++ b/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandler.php @@ -0,0 +1,584 @@ +actorId), strtolower($command->organizationId), $command->action, null === $command->id ? null : strtolower($command->id), $command->expectedRevision, $command->payload); + $this->access($command, 'organization.procurement.manage'); + if ('individualize' === $command->action) { + $this->access($command, 'organization.equipment.write'); + } + $finance = $this->authorization->hasPermission($command->actorId, $command->organizationId, 'organization.maintenance_cost.read'); + + return $this->repository->synchronized($command->organizationId, function () use ($command, $finance): ManageProcurementResult { + $result = match ($command->action) { + 'create_supplier', 'change_supplier', 'archive_supplier' => $this->supplier($command), + 'create_order', 'change_order', 'order', 'cancel_remaining' => $this->order($command, $finance), + 'receive' => $this->receive($command, $finance), + 'individualize' => $this->individualize($command, $finance), + 'return' => $this->returnReceipt($command, $finance), + 'reconcile_return' => $this->reconcileReturn($command, $finance), + default => throw ProcurementException::invalid('Unknown procurement mutation.'), + }; + if (!$result->replayed) { + $resourceId = $result->data['id'] ?? null; + if (is_string($resourceId)) { + $this->events->dispatch(new ProcurementChangedEvent($command->organizationId, $resourceId, $command->action, $this->clock->now())); + } + } + + return $result; + }); + } + + private function supplier(ManageProcurementCommand $command): ManageProcurementResult + { + $this->fields($command->payload, ['name', 'code', 'email', 'phone', 'contacts']); + $now = $this->clock->now(); + if ('create_supplier' === $command->action) { + $supplier = Supplier::create($this->ids->generate(), $command->organizationId, $this->text($command->payload, 'name'), $this->optionalText($command->payload, 'code'), $this->optionalText($command->payload, 'email'), $this->optionalText($command->payload, 'phone'), $this->contacts($command->payload['contacts'] ?? []), $now); + } else { + $supplier = $this->repository->supplier($command->organizationId, $command->id ?? '') ?? throw ProcurementException::notFound(); + $revision = $this->revision($command); + if ('archive_supplier' === $command->action) { + $supplier->archive($revision, $now); + } else { + $supplier->change($revision, $this->textOr($command->payload, 'name', $supplier->name()), $this->optionalOr($command->payload, 'code', $supplier->code()), $this->optionalOr($command->payload, 'email', $supplier->email()), $this->optionalOr($command->payload, 'phone', $supplier->phone()), $this->contacts($command->payload['contacts'] ?? $supplier->contacts()), $now); + } + } + $this->repository->saveSupplier($supplier); + + return new ManageProcurementResult('supplier', $this->projection->supplier($supplier)); + } + + private function order(ManageProcurementCommand $command, bool $finance): ManageProcurementResult + { + $this->fields($command->payload, ['name', 'supplierId', 'lines']); + $now = $this->clock->now(); + if ('create_order' === $command->action) { + $supplierId = $this->uuid($this->text($command->payload, 'supplierId')); + $this->activeSupplier($command->organizationId, $supplierId); + $order = PurchaseOrder::create($this->ids->generate(), $command->organizationId, $supplierId, $this->currencies->lock($command->organizationId), $this->text($command->payload, 'name'), $this->lines($command, $command->payload['lines'] ?? [], []), $now); + } else { + $order = $this->repository->order($command->organizationId, $command->id ?? '') ?? throw ProcurementException::notFound(); + $revision = $this->revision($command); + if ('order' === $command->action) { + $this->activeSupplier($command->organizationId, $order->supplierId()); + $order->order($revision, $now); + } elseif ('cancel_remaining' === $command->action) { + $order->cancelRemaining($revision, $now); + } else { + $supplierId = $this->uuid($this->textOr($command->payload, 'supplierId', $order->supplierId())); + $this->activeSupplier($command->organizationId, $supplierId); + $lines = array_key_exists('lines', $command->payload) ? $this->lines($command, $command->payload['lines'], $order->lines()) : $order->lines(); + $order->changeDraft($revision, $supplierId, $order->currency(), $this->textOr($command->payload, 'name', $order->name()), $lines, $now); + } + } + $this->repository->saveOrder($order); + + return new ManageProcurementResult('order', $this->projection->order($order, $finance)); + } + + private function receive(ManageProcurementCommand $command, bool $finance): ManageProcurementResult + { + $this->fields($command->payload, ['lineId', 'warehouseId', 'quantity', 'receivedAt', 'clientOperationId']); + $order = $this->repository->order($command->organizationId, $command->id ?? '') ?? throw ProcurementException::notFound(); + $lineId = $this->uuid($this->text($command->payload, 'lineId')); + $line = $this->lineById($order, $lineId); + if ('part' === $line->kind) { + $this->access($command, 'organization.inventory.manage'); + } + $quantity = $this->quantity($command->payload['quantity'] ?? null); + $warehouseId = $this->optionalText($command->payload, 'warehouseId'); + $warehouseId = null === $warehouseId ? null : $this->uuid($warehouseId); + $receivedAt = $this->date($this->text($command->payload, 'receivedAt')); + if ($receivedAt > $this->clock->now()) { + throw ProcurementException::invalid('A physical receipt cannot occur in the future.'); + } + $operationId = $this->operationId($command); + $fingerprint = $this->fingerprint(['orderId' => $order->id, 'lineId' => $lineId, 'quantity' => $quantity, 'warehouseId' => $warehouseId, 'receivedAt' => $receivedAt->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s.uP')]); + $replay = $this->replay($command, $operationId, 'receive', $fingerprint, $finance); + if (null !== $replay) { + return $replay; + } + if ('equipment_to_individualize' === $line->kind && (null !== $warehouseId || !DecimalAmount::fromString($quantity)->isInteger() || DecimalAmount::fromString($quantity)->compareTo(DecimalAmount::fromInt(100)) > 0)) { + throw ProcurementException::invalid('Equipment receipts contain at most 100 units and no quantitative warehouse stock.'); + } + if ('part' === $line->kind && (null === $warehouseId || !$this->parts->warehouseExistsActive($command->organizationId, $this->uuid($warehouseId)))) { + throw ProcurementException::notFound(); + } + $order->recordReceipt($this->revision($command), $lineId, $quantity, $this->clock->now()); + $receipt = new ProcurementReceiptState($this->ids->generate(), $command->organizationId, $order->id, $lineId, $line->kind, $quantity, $warehouseId, $line->unitCost, $order->currency(), $receivedAt, $command->actorId, $this->clock->now()); + if ('part' === $line->kind) { + $currency = $this->currencies->lock($command->organizationId); + if ($currency !== $order->currency()) { + throw ProcurementException::conflict('The organization currency differs from the order currency.'); + } + $stock = $this->stock->receive(new InventoryReceiptRequest(organizationId: $command->organizationId, partId: $line->partId ?? throw ProcurementException::invalid('Missing part.'), warehouseId: $warehouseId ?? throw ProcurementException::invalid('Missing warehouse.'), quantity: $quantity, unitCost: $line->unitCost, currency: $currency, clientOperationId: $operationId, sourceReceiptId: $receipt->id, actorId: $command->actorId)); + $receipt->inventoryMovementId = $stock->movementId; + } + $this->repository->saveOrder($order); + $this->repository->saveReceipt($receipt); + $this->repository->saveOperation(new ProcurementOperationState($command->organizationId, $operationId, 'receive', $fingerprint, $receipt->id, $command->payload + ['actorId' => $command->actorId])); + + return new ManageProcurementResult('receipt', $this->projection->receipt($receipt, $finance)); + } + + private function individualize(ManageProcurementCommand $command, bool $finance): ManageProcurementResult + { + $this->fields($command->payload, ['clientOperationId']); + $receipt = $this->repository->receipt($command->organizationId, $command->id ?? '') ?? throw ProcurementException::notFound(); + $operationId = $this->operationId($command); + $fingerprint = $this->fingerprint(['receiptId' => $receipt->id]); + $replay = $this->replay($command, $operationId, 'individualize', $fingerprint, $finance); + if (null !== $replay) { + return $replay; + } + $this->receiptRevision($command, $receipt); + if ('equipment_to_individualize' !== $receipt->kind) { + throw ProcurementException::conflict('Only an equipment receipt can be individualized.'); + } + $remaining = DecimalAmount::fromString($receipt->quantity)->subtract(DecimalAmount::fromString($receipt->returnedQuantity)); + if ([] === $receipt->equipmentIds && !$remaining->isZero()) { + $order = $this->repository->order($command->organizationId, $receipt->orderId) ?? throw ProcurementException::notFound(); + $line = $this->lineById($order, $receipt->lineId); + $result = $this->equipment->reserve(new EquipmentReserveReceiptRequest($command->organizationId, $command->actorId, $line->typeCode ?? throw ProcurementException::invalid('Missing equipment type.'), $this->equipmentTemplate($line->identityTemplate), $remaining->toInt())); + $receipt->blockedReason = $result->blockedReason; + if (null === $result->blockedReason) { + if (count($result->equipmentIds) !== $remaining->toInt()) { + throw ProcurementException::conflict('The equipment bridge returned an incomplete batch.'); + } + $receipt->equipmentIds = $result->equipmentIds; + } + ++$receipt->revision; + $this->repository->saveReceipt($receipt); + } + $this->repository->saveOperation(new ProcurementOperationState($command->organizationId, $operationId, 'individualize', $fingerprint, $receipt->id, $command->payload + ['actorId' => $command->actorId, 'blockedReason' => $receipt->blockedReason])); + + return new ManageProcurementResult('receipt', $this->projection->receipt($receipt, $finance)); + } + + private function returnReceipt(ManageProcurementCommand $command, bool $finance): ManageProcurementResult + { + $this->fields($command->payload, ['quantity', 'reason', 'clientOperationId']); + $receipt = $this->repository->receipt($command->organizationId, $command->id ?? '') ?? throw ProcurementException::notFound(); + $quantity = $this->quantity($command->payload['quantity'] ?? null); + if ('part' === $receipt->kind) { + $this->access($command, 'organization.inventory.manage'); + } + $reason = trim($this->text($command->payload, 'reason')); + if ('' === $reason || strlen($reason) > 2000) { + throw ProcurementException::invalid('A motivated return reason is required.'); + } + $operationId = $this->operationId($command); + $fingerprint = $this->fingerprint(['receiptId' => $receipt->id, 'quantity' => $quantity, 'reason' => $reason]); + $replay = $this->replay($command, $operationId, 'return', $fingerprint, $finance); + if (null !== $replay) { + return $replay; + } + $this->receiptRevision($command, $receipt); + if (DecimalAmount::fromString($quantity)->compareTo(DecimalAmount::fromString($receipt->quantity)->subtract(DecimalAmount::fromString($receipt->returnedQuantity))) > 0) { + throw ProcurementException::conflict('The return exceeds the remaining received quantity.'); + } + if ([] !== $receipt->equipmentIds) { + throw ProcurementException::conflict('Individualized equipment must be handled in its retained park lifecycle.'); + } + $order = $this->repository->order($command->organizationId, $receipt->orderId) ?? throw ProcurementException::notFound(); + $return = new ProcurementReturnState($this->ids->generate(), $command->organizationId, $receipt->id, $operationId, $quantity, $reason, $command->actorId, $this->clock->now()); + $this->repository->saveReturn($return); + if ('part' === $receipt->kind) { + $this->currencies->lock($command->organizationId); + $movement = $this->stock->tryReverse(new InventoryReceiptReturnRequest(organizationId: $command->organizationId, movementId: $receipt->inventoryMovementId ?? throw ProcurementException::conflict('No stock receipt to reverse.'), quantity: $quantity, reason: $reason, clientOperationId: $operationId, actorId: $command->actorId)); + $return->blockedReason = $movement->blockedReason; + if (null === $movement->blockedReason) { + $return->inventoryMovementId = $movement->movementId; + } + } + if (null === $return->blockedReason) { + $return->status = 'confirmed'; + $return->reconciledAt = $this->clock->now(); + } else { + $receipt->pendingReturnQuantity = DecimalAmount::fromString($receipt->pendingReturnQuantity)->add(DecimalAmount::fromString($quantity))->toString(); + } + $order->recordReturn($order->revision(), $receipt->lineId, $quantity, $this->clock->now()); + $receipt->returnedQuantity = DecimalAmount::fromString($receipt->returnedQuantity)->add(DecimalAmount::fromString($quantity))->toString(); + if ($receipt->returnedQuantity === $receipt->quantity) { + $receipt->blockedReason = null; + } + ++$receipt->revision; + $this->repository->saveOrder($order); + $this->repository->saveReturn($return); + $this->repository->saveReceipt($receipt); + $this->repository->saveOperation(new ProcurementOperationState($command->organizationId, $operationId, 'return', $fingerprint, $receipt->id, $command->payload + ['actorId' => $command->actorId, 'returnId' => $return->id])); + + return new ManageProcurementResult('receipt', $this->projection->receipt($receipt, $finance)); + } + + private function reconcileReturn(ManageProcurementCommand $command, bool $finance): ManageProcurementResult + { + $this->fields($command->payload, ['clientOperationId']); + $return = $this->repository->returnDeclaration($command->organizationId, $command->id ?? '') ?? throw ProcurementException::notFound(); + $receipt = $this->repository->receipt($command->organizationId, $return->receiptId) ?? throw ProcurementException::notFound(); + if ('part' === $receipt->kind) { + $this->access($command, 'organization.inventory.manage'); + } + $operationId = $this->operationId($command); + $fingerprint = $this->fingerprint(['returnId' => $return->id]); + $replay = $this->replay($command, $operationId, 'reconcile_return', $fingerprint, $finance); + if (null !== $replay) { + return $replay; + } + if ($return->revision !== $this->revision($command)) { + throw ProcurementException::stale(); + } + if ('confirmed' !== $return->status) { + $this->currencies->lock($command->organizationId); + $movement = $this->stock->tryReverse(new InventoryReceiptReturnRequest($command->organizationId, $receipt->inventoryMovementId ?? throw ProcurementException::conflict('No stock receipt to reverse.'), $return->quantity, $return->reason, $return->clientOperationId, $return->actorId)); + $return->blockedReason = $movement->blockedReason; + if (null === $movement->blockedReason) { + $return->status = 'confirmed'; + $return->inventoryMovementId = $movement->movementId; + $return->reconciledAt = $this->clock->now(); + $receipt->pendingReturnQuantity = DecimalAmount::fromString($receipt->pendingReturnQuantity)->subtract(DecimalAmount::fromString($return->quantity))->toString(); + ++$receipt->revision; + $this->repository->saveReceipt($receipt); + } + ++$return->revision; + $this->repository->saveReturn($return); + } + $this->repository->saveOperation(new ProcurementOperationState($command->organizationId, $operationId, 'reconcile_return', $fingerprint, $receipt->id, $command->payload + ['actorId' => $command->actorId, 'returnId' => $return->id, 'blockedReason' => $return->blockedReason])); + + return new ManageProcurementResult('return', $this->projection->returnDeclaration($return)); + } + + /** + * @param list $existing + * + * @return list + */ + private function lines(ManageProcurementCommand $command, mixed $input, array $existing): array + { + if (!is_array($input) || !array_is_list($input) || count($input) > 100) { + throw ProcurementException::invalid('Order lines must be a bounded list.'); + } + $known = []; + foreach ($existing as $line) { + $known[$line->id] = $line; + } + $partIds = []; + foreach ($input as $row) { + if (is_array($row) && 'part' === ($row['kind'] ?? null) && is_string($row['partId'] ?? null)) { + $partIds[] = $this->uuid($row['partId']); + } + } + $descriptors = $this->parts->describeMany($command->organizationId, array_values(array_unique($partIds))); + $lines = []; + foreach ($input as $fields) { + if (!is_array($fields)) { + throw ProcurementException::invalid('An order line must be an object.'); + } + /** @var array $fields */ + $this->fields($fields, ['id', 'kind', 'partId', 'typeCode', 'identityTemplate', 'quantity', 'unitCost']); + $id = array_key_exists('id', $fields) ? $this->uuid($this->text($fields, 'id')) : $this->ids->generate(); + $kind = $this->text($fields, 'kind'); + $partId = $this->optionalText($fields, 'partId'); + $partId = null === $partId ? null : $this->uuid($partId); + $typeCode = $this->optionalText($fields, 'typeCode'); + $descriptor = null === $partId ? null : ($descriptors[$partId] ?? null); + if ('part' === $kind && (null === $descriptor || $descriptor->archived)) { + throw ProcurementException::notFound(); + } + if ('equipment_to_individualize' === $kind && (null === $typeCode || !$this->equipment->supportsType($command->organizationId, $typeCode))) { + throw ProcurementException::notFound(); + } + if (array_key_exists('unitCost', $fields)) { + $this->access($command, 'organization.maintenance_cost.manage'); + $unitCost = $this->optionalText($fields, 'unitCost'); + } else { + $unitCost = $known[$id]->unitCost ?? null; + } + $identity = $fields['identityTemplate'] ?? []; + if (!is_array($identity) || (!empty($identity) && array_is_list($identity))) { + throw ProcurementException::invalid('Equipment identity template must be an object.'); + } + /** @var array $identity */ + if ('equipment_to_individualize' === $kind) { + $identity = $this->equipmentTemplate($identity); + } + $lines[] = ProcurementLine::create($id, $kind, $partId, $typeCode, $identity, $this->quantity($fields['quantity'] ?? null), $unitCost, $descriptor?->code, $descriptor?->label, $descriptor?->unit); + } + + return $lines; + } + + private function access(ManageProcurementCommand $command, string $permission): void + { + $decision = $this->authorization->resolveAccess($command->actorId, $command->organizationId, $permission); + if (OrganizationAccessDecision::GRANTED !== $decision) { + throw OrganizationAccessDecision::OUTSIDE_SCOPE === $decision ? ProcurementException::notFound() : ProcurementException::denied(); + } + } + + private function activeSupplier(string $organizationId, string $supplierId): void + { + $supplier = $this->repository->supplier($organizationId, $supplierId) ?? throw ProcurementException::notFound(); + if (!$supplier->isActive()) { + throw ProcurementException::conflict('An archived supplier cannot be selected for a new or changed order.'); + } + } + + private function revision(ManageProcurementCommand $command): int + { + return $command->expectedRevision ?? throw ProcurementException::revisionRequired(); + } + + private function receiptRevision(ManageProcurementCommand $command, ProcurementReceiptState $receipt): void + { + if ($this->revision($command) !== $receipt->revision) { + throw ProcurementException::stale(); + } + } + + private function lineById(PurchaseOrder $order, string $id): ProcurementLine + { + foreach ($order->lines() as $line) { + if ($line->id === $id) { + return $line; + } + } + + throw ProcurementException::notFound(); + } + + private function operationId(ManageProcurementCommand $command): string + { + return $this->uuid($this->text($command->payload, 'clientOperationId')); + } + + private function replay(ManageProcurementCommand $command, string $operationId, string $kind, string $fingerprint, bool $finance): ?ManageProcurementResult + { + $operation = $this->repository->operation($command->organizationId, $operationId); + if (null === $operation) { + return null; + } + if ($operation->kind !== $kind || $operation->fingerprint !== $fingerprint) { + throw ProcurementException::conflict('The offline operation identifier already belongs to a different declaration.'); + } + if ('reconcile_return' === $kind) { + $returnId = $operation->declaration['returnId'] ?? null; + $return = is_string($returnId) ? $this->repository->returnDeclaration($command->organizationId, $returnId) : null; + + return new ManageProcurementResult('return', $this->projection->returnDeclaration($return ?? throw ProcurementException::conflict('The retained return declaration is missing.')), true); + } + $receipt = $this->repository->receipt($command->organizationId, $operation->receiptId) ?? throw ProcurementException::conflict('The saved physical operation has no receipt.'); + + return new ManageProcurementResult('receipt', $this->projection->receipt($receipt, $finance), true); + } + + /** + * @param array $values + */ + private function fingerprint(array $values): string + { + return hash('sha256', json_encode($values, JSON_THROW_ON_ERROR)); + } + + /** + * @param array $payload + * @param list $allowed + */ + private function fields(array $payload, array $allowed): void + { + foreach ($payload as $field => $value) { + if (!in_array($field, $allowed, true)) { + throw ProcurementException::invalid('Unknown procurement field: ' . $field); + } + } + } + + /** + * @param array $payload + */ + private function text(array $payload, string $field): string + { + $value = $payload[$field] ?? null; + if (!is_string($value)) { + throw ProcurementException::invalid('Field ' . $field . ' must be a string.'); + } + + return $value; + } + + /** + * @param array $payload + */ + private function optionalText(array $payload, string $field): ?string + { + if (!array_key_exists($field, $payload) || null === $payload[$field]) { + return null; + } + + return $this->text($payload, $field); + } + + /** + * @param array $payload + */ + private function textOr(array $payload, string $field, string $fallback): string + { + return array_key_exists($field, $payload) ? $this->text($payload, $field) : $fallback; + } + + /** + * @param array $payload + */ + private function optionalOr(array $payload, string $field, ?string $fallback): ?string + { + return array_key_exists($field, $payload) ? $this->optionalText($payload, $field) : $fallback; + } + + private function quantity(mixed $quantity): string + { + if (!is_string($quantity)) { + throw ProcurementException::invalid('Quantity must be an exact decimal string.'); + } + $value = DecimalAmount::fromString($quantity); + if ($value->isNegative() || $value->isZero() || $value->compareTo(DecimalAmount::fromInt(100000)) > 0) { + throw ProcurementException::invalid('Quantity must be positive and at most 100000.'); + } + + return $value->toString(); + } + + private function uuid(string $id): string + { + return strtolower(new Uuid($id)->value); + } + + private function date(string $value): DateTimeImmutable + { + if (1 !== preg_match('/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$/', $value)) { + throw ProcurementException::invalid('An explicit-offset receivedAt timestamp is required.'); + } + + try { + $date = new DateTimeImmutable($value); + } catch (DateMalformedStringException) { + throw ProcurementException::invalid('The receivedAt date is invalid.'); + } + $errors = DateTimeImmutable::getLastErrors(); + if (false !== $errors && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) { + throw ProcurementException::invalid('The receivedAt date is invalid.'); + } + + return $date; + } + + /** + * @return list + */ + private function contacts(mixed $input): array + { + if (!is_array($input) || !array_is_list($input)) { + throw ProcurementException::invalid('Supplier contacts must be a list.'); + } + $contacts = []; + foreach ($input as $row) { + if (!is_array($row)) { + throw ProcurementException::invalid('A supplier contact must be an object.'); + } + /** @var array $row */ + $this->fields($row, ['name', 'email', 'phone', 'role']); + $contacts[] = ['name' => $this->text($row, 'name'), 'email' => $this->optionalText($row, 'email'), 'phone' => $this->optionalText($row, 'phone'), 'role' => $this->optionalText($row, 'role')]; + } + + return $contacts; + } + + /** + * @param array $input + * + * @return array{name?:?string,brand?:?string,model?:?string,subType?:?string,serialNumber?:?string,assetCode?:?string,criticality?:?string,technicalProperties?:list} + */ + private function equipmentTemplate(array $input): array + { + $this->fields($input, ['name', 'brand', 'model', 'subType', 'serialNumber', 'assetCode', 'criticality', 'technicalProperties']); + $output = ['name' => $this->optionalText($input, 'name'), 'brand' => $this->optionalText($input, 'brand'), 'model' => $this->optionalText($input, 'model'), 'subType' => $this->optionalText($input, 'subType'), 'serialNumber' => $this->optionalText($input, 'serialNumber'), 'assetCode' => $this->optionalText($input, 'assetCode'), 'criticality' => $this->optionalText($input, 'criticality')]; + if (array_key_exists('technicalProperties', $input)) { + $properties = $input['technicalProperties']; + if (!is_array($properties) || !array_is_list($properties)) { + throw ProcurementException::invalid('Equipment technical properties must be a list.'); + } + $rows = []; + foreach ($properties as $property) { + if (!is_array($property)) { + throw ProcurementException::invalid('Equipment properties must be objects.'); + } + /** @var array $property */ + $this->fields($property, ['key', 'value', 'unit']); + $rows[] = ['key' => $this->text($property, 'key'), 'value' => $this->text($property, 'value'), 'unit' => $this->optionalText($property, 'unit')]; + } + $output['technicalProperties'] = $rows; + } + + return $output; + } +} diff --git a/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementResult.php b/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementResult.php new file mode 100644 index 000000000..17318af9e --- /dev/null +++ b/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementResult.php @@ -0,0 +1,18 @@ + $data + */ + public function __construct(public string $kind, public array $data, public bool $replayed = false) + { + } +} diff --git a/src/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementHandler.php b/src/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementHandler.php new file mode 100644 index 000000000..349deb92c --- /dev/null +++ b/src/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementHandler.php @@ -0,0 +1,100 @@ +actorId), strtolower($query->organizationId), $query->action, null === $query->id ? null : strtolower($query->id), $query->search, $query->archived, $query->status, null === $query->supplierId ? null : strtolower($query->supplierId), $query->page, $query->itemsPerPage); + $decision = $this->authorization->resolveAccess($query->actorId, $query->organizationId, 'organization.procurement.read'); + if (OrganizationAccessDecision::GRANTED !== $decision) { + throw OrganizationAccessDecision::OUTSIDE_SCOPE === $decision ? ProcurementException::notFound() : ProcurementException::denied(); + } + $finance = $this->authorization->hasPermission($query->actorId, $query->organizationId, 'organization.maintenance_cost.read'); + $page = max(1, $query->page); + $limit = min(100, max(1, $query->itemsPerPage)); + $offset = ($page - 1) * $limit; + $items = []; + $total = 1; + switch ($query->action) { + case 'suppliers': + foreach ($this->repository->suppliers($query->organizationId, $query->search, $query->archived, $offset, $limit) as $supplier) { + $items[] = $this->projection->supplier($supplier); + } + $total = $this->repository->countSuppliers($query->organizationId, $query->search, $query->archived); + $kind = 'supplier'; + + break; + case 'supplier': + $items[] = $this->projection->supplier($this->repository->supplier($query->organizationId, $query->id ?? '') ?? throw ProcurementException::notFound()); + $kind = 'supplier'; + + break; + case 'orders': + foreach ($this->repository->orders($query->organizationId, $query->status, $query->supplierId, $offset, $limit) as $order) { + $items[] = $this->projection->order($order, $finance); + } + $total = $this->repository->countOrders($query->organizationId, $query->status, $query->supplierId); + $kind = 'order'; + + break; + case 'order': + $items[] = $this->projection->order($this->repository->order($query->organizationId, $query->id ?? '') ?? throw ProcurementException::notFound(), $finance); + $kind = 'order'; + + break; + case 'receipts': + $order = $this->repository->order($query->organizationId, $query->id ?? '') ?? throw ProcurementException::notFound(); + foreach ($this->repository->receipts($query->organizationId, $order->id, $offset, $limit) as $receipt) { + $items[] = $this->projection->receipt($receipt, $finance); + } + $total = $this->repository->countReceipts($query->organizationId, $order->id); + $kind = 'receipt'; + + break; + case 'receipt': + $items[] = $this->projection->receipt($this->repository->receipt($query->organizationId, $query->id ?? '') ?? throw ProcurementException::notFound(), $finance); + $kind = 'receipt'; + + break; + case 'returns': + $receipt = $this->repository->receipt($query->organizationId, $query->id ?? '') ?? throw ProcurementException::notFound(); + foreach ($this->repository->returns($query->organizationId, $receipt->id, $offset, $limit) as $return) { + $items[] = $this->projection->returnDeclaration($return); + } + $total = $this->repository->countReturns($query->organizationId, $receipt->id); + $kind = 'return'; + + break; + case 'return': + $items[] = $this->projection->returnDeclaration($this->repository->returnDeclaration($query->organizationId, $query->id ?? '') ?? throw ProcurementException::notFound()); + $kind = 'return'; + + break; + default: + throw ProcurementException::invalid('Unknown procurement read.'); + } + + return new ReadProcurementResult($kind, $items, !in_array($query->action, ['supplier', 'order', 'receipt', 'return'], true), $total, $page, $limit); + } +} diff --git a/src/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementQuery.php b/src/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementQuery.php new file mode 100644 index 000000000..36187e06b --- /dev/null +++ b/src/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementQuery.php @@ -0,0 +1,15 @@ +> $items + */ + public function __construct(public string $kind, public array $items, public bool $collection, public int $total = 1, public int $page = 1, public int $itemsPerPage = 30) + { + } +} diff --git a/src/Procurement/Domain/Event/ProcurementChangedEvent.php b/src/Procurement/Domain/Event/ProcurementChangedEvent.php new file mode 100644 index 000000000..ddeed2dd1 --- /dev/null +++ b/src/Procurement/Domain/Event/ProcurementChangedEvent.php @@ -0,0 +1,15 @@ + + */ + private array $lines; + // #endregion + + // #region Constructor + /** + * Method __construct + * + * Restores an order only when its quantities agree with its lifecycle. + * + * @access private + * + * @param string $id the stable order UUID + * @param string $organizationId the owning organization UUID + * @param string $supplierId the supplier UUID + * @param string $currency the uppercase organization currency + * @param string $name the order's display name + * @param list $lines the immutable line snapshots + * @param PurchaseOrderStatus $status the gross reception lifecycle + * @param int $revision the positive resource revision + * @param DateTimeImmutable $createdAt the creation instant + * @param DateTimeImmutable $updatedAt the latest update instant + * + * @return void + */ + private function __construct( + public readonly string $id, + public readonly string $organizationId, + private string $supplierId, + private string $currency, + private string $name, + array $lines, + private PurchaseOrderStatus $status, + private int $revision, + public readonly DateTimeImmutable $createdAt, + private DateTimeImmutable $updatedAt, + ) { + new Uuid($id); + new Uuid($organizationId); + new Uuid($supplierId); + if ($revision < 1 || $updatedAt < $createdAt) { + throw ProcurementException::invalid('Purchase-order revision and historical timestamps are inconsistent.'); + } + + $this->currency = self::normalizeCurrency($currency); + $this->name = self::normalizeName($name); + $this->lines = self::validateLines($lines); + $this->assertRestoredStatus(); + } + // #endregion + + // #region Methods + /** + * Method create + * + * Creates a draft which can be completed before it is ordered. + * + * @access public + * + * @param string $id the order UUID + * @param string $organizationId the owning organization UUID + * @param string $supplierId the supplier UUID + * @param string $currency the organization currency + * @param string $name the display name + * @param list $lines the draft lines + * @param DateTimeImmutable $now the creation instant + * + * @return self the draft order + */ + public static function create(string $id, string $organizationId, string $supplierId, string $currency, string $name, array $lines, DateTimeImmutable $now): self + { + return new self($id, $organizationId, $supplierId, $currency, $name, $lines, PurchaseOrderStatus::DRAFT, 1, $now, $now); + } + + /** + * Method reconstitute + * + * Restores retained reception and return history without changing identities. + * + * @access public + * + * @param string $id the order UUID + * @param string $organizationId the owning organization UUID + * @param string $supplierId the supplier UUID + * @param string $currency the organization currency + * @param string $name the display name + * @param list $lines the persisted line snapshots + * @param PurchaseOrderStatus $status the persisted lifecycle + * @param int $revision the persisted resource revision + * @param DateTimeImmutable $createdAt the creation instant + * @param DateTimeImmutable $updatedAt the latest update instant + * + * @return self the restored order + */ + public static function reconstitute(string $id, string $organizationId, string $supplierId, string $currency, string $name, array $lines, PurchaseOrderStatus $status, int $revision, DateTimeImmutable $createdAt, DateTimeImmutable $updatedAt): self + { + return new self($id, $organizationId, $supplierId, $currency, $name, $lines, $status, $revision, $createdAt, $updatedAt); + } + + /** + * Method changeDraft + * + * Validates all replacement values before updating an editable draft. + * + * @access public + * + * @param int $expectedRevision the reviewed resource revision + * @param string $supplierId the replacement supplier UUID + * @param string $currency the organization currency + * @param string $name the replacement display name + * @param list $lines the replacement draft lines + * @param DateTimeImmutable $now the update instant + * + * @return void + */ + public function changeDraft(int $expectedRevision, string $supplierId, string $currency, string $name, array $lines, DateTimeImmutable $now): void + { + $this->assertRevision($expectedRevision); + $this->assertTime($now); + if (PurchaseOrderStatus::DRAFT !== $this->status) { + throw ProcurementException::conflict('Only a draft purchase order can be changed.'); + } + + new Uuid($supplierId); + $currency = self::normalizeCurrency($currency); + $name = self::normalizeName($name); + $lines = self::validateLines($lines); + foreach ($lines as $line) { + if ('0.000000' !== $line->receivedQuantity || '0.000000' !== $line->returnedQuantity) { + throw ProcurementException::invalid('Draft lines cannot contain receipt or return history.'); + } + } + + $this->supplierId = $supplierId; + $this->currency = $currency; + $this->name = $name; + $this->lines = $lines; + $this->touch($now); + } + + /** + * Method order + * + * Freezes commercial line identity before the first physical reception. + * + * @access public + * + * @param int $expectedRevision the reviewed resource revision + * @param DateTimeImmutable $now the order instant + * + * @return void + */ + public function order(int $expectedRevision, DateTimeImmutable $now): void + { + $this->assertRevision($expectedRevision); + $this->assertTime($now); + if (PurchaseOrderStatus::DRAFT !== $this->status || [] === $this->lines) { + throw ProcurementException::conflict('Ordering requires a nonempty draft.'); + } + + $this->status = PurchaseOrderStatus::ORDERED; + $this->touch($now); + } + + /** + * Method cancelRemaining + * + * Stops outstanding delivery while keeping received and returned quantities. + * + * @access public + * + * @param int $expectedRevision the reviewed resource revision + * @param DateTimeImmutable $now the cancellation instant + * + * @return void + */ + public function cancelRemaining(int $expectedRevision, DateTimeImmutable $now): void + { + $this->assertRevision($expectedRevision); + $this->assertTime($now); + if (PurchaseOrderStatus::CANCELLED === $this->status) { + return; + } + if (PurchaseOrderStatus::RECEIVED === $this->status) { + throw ProcurementException::conflict('A fully received order has no remaining delivery to cancel.'); + } + + $this->status = PurchaseOrderStatus::CANCELLED; + $this->touch($now); + } + + /** + * Method recordReceipt + * + * Adds a bounded quantity to one line; handlers atomically save the associated + * stock movement or individual equipment creations and an idempotency receipt. + * + * @access public + * + * @param int $expectedRevision the reviewed resource revision + * @param string $lineId the ordered line UUID + * @param string $quantity the positive exact received quantity + * @param DateTimeImmutable $now the reception instant + * + * @return void + */ + public function recordReceipt(int $expectedRevision, string $lineId, string $quantity, DateTimeImmutable $now): void + { + $this->assertRevision($expectedRevision); + $this->assertTime($now); + if (!$this->status->allowsReceipt()) { + throw ProcurementException::conflict('This order cannot receive new quantities.'); + } + + $index = $this->lineIndex($lineId); + $line = $this->lines[$index]->receive($quantity); + $lines = $this->lines; + $lines[$index] = $line; + $this->lines = array_values($lines); + $this->status = $this->receivedStatus(); + $this->touch($now); + } + + /** + * Method recordReturn + * + * Returns only previously received units. A return never reopens delivery or + * erases the gross reception history. + * + * @access public + * + * @param int $expectedRevision the reviewed resource revision + * @param string $lineId the line UUID + * @param string $quantity the positive exact returned quantity + * @param DateTimeImmutable $now the supplier-return instant + * + * @return void + */ + public function recordReturn(int $expectedRevision, string $lineId, string $quantity, DateTimeImmutable $now): void + { + $this->assertRevision($expectedRevision); + $this->assertTime($now); + if (!in_array($this->status, [PurchaseOrderStatus::PARTIAL_RECEIVED, PurchaseOrderStatus::RECEIVED, PurchaseOrderStatus::CANCELLED], true)) { + throw ProcurementException::conflict('An order must retain received goods before recording a return.'); + } + + $index = $this->lineIndex($lineId); + $line = $this->lines[$index]->returnReceived($quantity); + $lines = $this->lines; + $lines[$index] = $line; + $this->lines = array_values($lines); + $this->touch($now); + } + + /** + * Method supplierId + * + * @access public + * + * @return string the supplier UUID + */ + public function supplierId(): string + { + return $this->supplierId; + } + + /** + * Method currency + * + * @access public + * + * @return string the uppercase currency + */ + public function currency(): string + { + return $this->currency; + } + + /** + * Method name + * + * @access public + * + * @return string the order display name + */ + public function name(): string + { + return $this->name; + } + + /** + * Method lines + * + * @access public + * + * @return list the immutable line snapshots + */ + public function lines(): array + { + return $this->lines; + } + + /** + * Method status + * + * @access public + * + * @return PurchaseOrderStatus the current reception lifecycle + */ + public function status(): PurchaseOrderStatus + { + return $this->status; + } + + /** + * Method revision + * + * @access public + * + * @return int the resource revision + */ + public function revision(): int + { + return $this->revision; + } + + /** + * Method updatedAt + * + * @access public + * + * @return DateTimeImmutable the latest mutation instant + */ + public function updatedAt(): DateTimeImmutable + { + return $this->updatedAt; + } + + /** + * Method cancelledRemaining + * + * @access public + * + * @return bool whether further deliveries have been explicitly cancelled + */ + public function cancelledRemaining(): bool + { + return PurchaseOrderStatus::CANCELLED === $this->status; + } + + /** + * Method lineIndex + * + * @access private + * + * @param string $lineId the retained line UUID + * + * @return int its current list index + */ + private function lineIndex(string $lineId): int + { + foreach ($this->lines as $index => $line) { + if ($line->id === $lineId) { + return $index; + } + } + + throw ProcurementException::invalid('The order does not contain this line.'); + } + + /** + * Method receivedStatus + * + * @access private + * + * @return PurchaseOrderStatus the lifecycle derived from gross receipts + */ + private function receivedStatus(): PurchaseOrderStatus + { + $received = DecimalAmount::zero(); + $remaining = DecimalAmount::zero(); + foreach ($this->lines as $line) { + $received = $received->add(DecimalAmount::fromString($line->receivedQuantity)); + $remaining = $remaining->add(DecimalAmount::fromString($line->remainingQuantity())); + } + + if ($received->isZero()) { + return PurchaseOrderStatus::ORDERED; + } + + return $remaining->isZero() ? PurchaseOrderStatus::RECEIVED : PurchaseOrderStatus::PARTIAL_RECEIVED; + } + + /** + * Method assertRestoredStatus + * + * @access private + * + * @return void + */ + private function assertRestoredStatus(): void + { + if (PurchaseOrderStatus::DRAFT === $this->status) { + foreach ($this->lines as $line) { + if ('0.000000' !== $line->receivedQuantity || '0.000000' !== $line->returnedQuantity) { + throw ProcurementException::invalid('Draft lines cannot contain receipt or return history.'); + } + } + + return; + } + if (PurchaseOrderStatus::CANCELLED === $this->status) { + return; + } + if ([] === $this->lines || $this->receivedStatus() !== $this->status) { + throw ProcurementException::invalid('Purchase-order status must agree with retained receipt quantities.'); + } + } + + /** + * Method validateLines + * + * @access private + * + * @param array $lines the raw candidate line snapshots + * + * @return list the bounded list with unique identities + */ + private static function validateLines(array $lines): array + { + if (!array_is_list($lines) || count($lines) > 500) { + throw ProcurementException::invalid('A purchase order needs a list of at most 500 lines.'); + } + + $identities = []; + $validated = []; + foreach ($lines as $line) { + if (!$line instanceof ProcurementLine || isset($identities[$line->id])) { + throw ProcurementException::invalid('Purchase-order lines must have unique validated identities.'); + } + $identities[$line->id] = true; + $validated[] = $line; + } + + return $validated; + } + + /** + * Method normalizeCurrency + * + * @access private + * + * @param string $currency the declared organization currency + * + * @return string the validated currency + */ + private static function normalizeCurrency(string $currency): string + { + if (1 !== preg_match('/^[A-Z]{3}$/D', $currency)) { + throw ProcurementException::invalid('A purchase-order currency must contain three uppercase letters.'); + } + + return $currency; + } + + /** + * Method normalizeName + * + * @access private + * + * @param string $name the raw display name + * + * @return string the normalized display name + */ + private static function normalizeName(string $name): string + { + $name = trim($name); + if ('' === $name || mb_strlen($name) > 160) { + throw ProcurementException::invalid('A purchase-order name must contain 1 to 160 characters.'); + } + + return $name; + } + + /** + * Method assertRevision + * + * @access private + * + * @param int $expectedRevision the reviewed revision + * + * @return void + */ + private function assertRevision(int $expectedRevision): void + { + if ($expectedRevision !== $this->revision) { + throw ProcurementException::stale(); + } + } + + /** + * Method assertTime + * + * @access private + * + * @param DateTimeImmutable $now the mutation instant + * + * @return void + */ + private function assertTime(DateTimeImmutable $now): void + { + if ($now < $this->updatedAt) { + throw ProcurementException::invalid('A purchase-order update cannot precede its previous update.'); + } + } + + /** + * Method touch + * + * @access private + * + * @param DateTimeImmutable $now the validated mutation instant + * + * @return void + */ + private function touch(DateTimeImmutable $now): void + { + $this->updatedAt = $now; + ++$this->revision; + } + // #endregion +} diff --git a/src/Procurement/Domain/Model/Supplier.php b/src/Procurement/Domain/Model/Supplier.php new file mode 100644 index 000000000..0abb48fdc --- /dev/null +++ b/src/Procurement/Domain/Model/Supplier.php @@ -0,0 +1,452 @@ + + */ + private array $contacts; + // #endregion + + // #region Constructor + /** + * Method __construct + * + * Restores validated identity and contact data with an optimistic revision. + * + * @access private + * + * @param string $id the stable supplier UUID + * @param string $organizationId the owning organization UUID + * @param string $name the display name + * @param ?string $code the organization's external supplier reference + * @param ?string $email the main supplier email + * @param ?string $phone the main supplier telephone + * @param array $contacts the declarative contact records + * @param ?DateTimeImmutable $archivedAt the archival instant + * @param DateTimeImmutable $createdAt the creation instant + * @param DateTimeImmutable $updatedAt the latest update instant + * @param int $revision the positive resource revision + * + * @return void + */ + private function __construct( + public readonly string $id, + public readonly string $organizationId, + private string $name, + private ?string $code, + private ?string $email, + private ?string $phone, + array $contacts, + private ?DateTimeImmutable $archivedAt, + public readonly DateTimeImmutable $createdAt, + private DateTimeImmutable $updatedAt, + private int $revision, + ) { + new Uuid($id); + new Uuid($organizationId); + if ($revision < 1 || $updatedAt < $createdAt || (null !== $archivedAt && ($archivedAt < $createdAt || $archivedAt > $updatedAt))) { + throw ProcurementException::invalid('Supplier revision and historical timestamps are inconsistent.'); + } + + $this->name = self::normalizeName($name); + $this->code = self::normalizeText($code, 80); + $this->email = self::normalizeEmail($email); + $this->phone = self::normalizeText($phone, 40); + $this->contacts = self::normalizeContacts($contacts); + } + // #endregion + + // #region Methods + /** + * Method create + * + * Creates an active supplier scoped to one organization. + * + * @access public + * + * @param string $id the supplier UUID + * @param string $organizationId the organization UUID + * @param string $name the supplier name + * @param ?string $code the external supplier reference + * @param ?string $email the main email + * @param ?string $phone the main telephone + * @param array $contacts the supplier's contacts + * @param DateTimeImmutable $now the creation instant + * + * @return self the active supplier + */ + public static function create(string $id, string $organizationId, string $name, ?string $code, ?string $email, ?string $phone, array $contacts, DateTimeImmutable $now): self + { + return new self($id, $organizationId, $name, $code, $email, $phone, $contacts, null, $now, $now, 1); + } + + /** + * Method reconstitute + * + * Restores a supplier without bypassing contact or lifecycle invariants. + * + * @access public + * + * @param string $id the supplier UUID + * @param string $organizationId the organization UUID + * @param string $name the supplier name + * @param ?string $code the external supplier reference + * @param ?string $email the main email + * @param ?string $phone the main telephone + * @param array $contacts the supplier's contacts + * @param ?DateTimeImmutable $archivedAt the archival instant + * @param DateTimeImmutable $createdAt the creation instant + * @param DateTimeImmutable $updatedAt the latest update instant + * @param int $revision the positive resource revision + * + * @return self the restored supplier + */ + public static function reconstitute(string $id, string $organizationId, string $name, ?string $code, ?string $email, ?string $phone, array $contacts, ?DateTimeImmutable $archivedAt, DateTimeImmutable $createdAt, DateTimeImmutable $updatedAt, int $revision): self + { + return new self($id, $organizationId, $name, $code, $email, $phone, $contacts, $archivedAt, $createdAt, $updatedAt, $revision); + } + + /** + * Method change + * + * Validates every field before changing active supplier state. + * + * @access public + * + * @param int $expectedRevision the last reviewed resource revision + * @param string $name the replacement display name + * @param ?string $code the replacement external reference + * @param ?string $email the replacement main email + * @param ?string $phone the replacement main telephone + * @param array $contacts the replacement contacts + * @param DateTimeImmutable $now the update instant + * + * @return void + */ + public function change(int $expectedRevision, string $name, ?string $code, ?string $email, ?string $phone, array $contacts, DateTimeImmutable $now): void + { + $this->assertRevision($expectedRevision); + $this->assertTime($now); + if (!$this->isActive()) { + throw ProcurementException::conflict('An archived supplier cannot be changed.'); + } + + $name = self::normalizeName($name); + $code = self::normalizeText($code, 80); + $email = self::normalizeEmail($email); + $phone = self::normalizeText($phone, 40); + $contacts = self::normalizeContacts($contacts); + + $this->name = $name; + $this->code = $code; + $this->email = $email; + $this->phone = $phone; + $this->contacts = $contacts; + $this->touch($now); + } + + /** + * Method archive + * + * Stops future supplier edits without deleting its purchasing history. + * + * @access public + * + * @param int $expectedRevision the last reviewed revision + * @param DateTimeImmutable $now the archival instant + * + * @return void + */ + public function archive(int $expectedRevision, DateTimeImmutable $now): void + { + $this->assertRevision($expectedRevision); + $this->assertTime($now); + if (null !== $this->archivedAt) { + return; + } + + $this->archivedAt = $now; + $this->touch($now); + } + + /** + * Method name + * + * @access public + * + * @return string the display name + */ + public function name(): string + { + return $this->name; + } + + /** + * Method code + * + * @access public + * + * @return ?string the external supplier reference + */ + public function code(): ?string + { + return $this->code; + } + + /** + * Method email + * + * @access public + * + * @return ?string the main supplier email + */ + public function email(): ?string + { + return $this->email; + } + + /** + * Method phone + * + * @access public + * + * @return ?string the main supplier telephone + */ + public function phone(): ?string + { + return $this->phone; + } + + /** + * Method contacts + * + * @access public + * + * @return list the normalized contacts + */ + public function contacts(): array + { + return $this->contacts; + } + + /** + * Method archivedAt + * + * @access public + * + * @return ?DateTimeImmutable the archival instant + */ + public function archivedAt(): ?DateTimeImmutable + { + return $this->archivedAt; + } + + /** + * Method isActive + * + * @access public + * + * @return bool whether new purchasing activity can use the supplier + */ + public function isActive(): bool + { + return null === $this->archivedAt; + } + + /** + * Method revision + * + * @access public + * + * @return int the current resource revision + */ + public function revision(): int + { + return $this->revision; + } + + /** + * Method updatedAt + * + * @access public + * + * @return DateTimeImmutable the latest mutation instant + */ + public function updatedAt(): DateTimeImmutable + { + return $this->updatedAt; + } + + /** + * Method assertRevision + * + * @access private + * + * @param int $expectedRevision the reviewed revision + * + * @return void + */ + private function assertRevision(int $expectedRevision): void + { + if ($expectedRevision !== $this->revision) { + throw ProcurementException::stale(); + } + } + + /** + * Method assertTime + * + * @access private + * + * @param DateTimeImmutable $now the mutation instant + * + * @return void + */ + private function assertTime(DateTimeImmutable $now): void + { + if ($now < $this->updatedAt) { + throw ProcurementException::invalid('A supplier update cannot precede its previous update.'); + } + } + + /** + * Method touch + * + * @access private + * + * @param DateTimeImmutable $now the validated mutation instant + * + * @return void + */ + private function touch(DateTimeImmutable $now): void + { + $this->updatedAt = $now; + ++$this->revision; + } + + /** + * Method normalizeName + * + * @access private + * + * @param string $value the raw display name + * + * @return string the required normalized name + */ + private static function normalizeName(string $value): string + { + $value = trim($value); + if ('' === $value || mb_strlen($value) > 160) { + throw ProcurementException::invalid('Supplier and contact names must contain 1 to 160 characters.'); + } + + return $value; + } + + /** + * Method normalizeText + * + * @access private + * + * @param ?string $value the nullable raw text + * @param int $maximum the maximum character count + * + * @return ?string the normalized text + */ + private static function normalizeText(?string $value, int $maximum): ?string + { + $value = null === $value ? null : trim($value); + if (null !== $value && mb_strlen($value) > $maximum) { + throw ProcurementException::invalid('Supplier text exceeds its maximum length.'); + } + + return '' === $value ? null : $value; + } + + /** + * Method normalizeEmail + * + * @access private + * + * @param ?string $value the nullable email + * + * @return ?string the normalized email + */ + private static function normalizeEmail(?string $value): ?string + { + $value = self::normalizeText($value, 254); + if (null !== $value && false === filter_var($value, FILTER_VALIDATE_EMAIL)) { + throw ProcurementException::invalid('Invalid supplier email.'); + } + + return $value; + } + + /** + * Method normalizeContacts + * + * @access private + * + * @param array $contacts the raw contact list + * + * @return list the validated contacts + */ + private static function normalizeContacts(array $contacts): array + { + if (!array_is_list($contacts) || count($contacts) > 50) { + throw ProcurementException::invalid('Supplier contacts must be a list of at most 50 contacts.'); + } + + $normalized = []; + foreach ($contacts as $contact) { + if (!is_array($contact) || !is_string($contact['name'] ?? null)) { + throw ProcurementException::invalid('Each supplier contact requires a name.'); + } + foreach ($contact as $key => $value) { + if (!in_array($key, ['name', 'email', 'phone', 'role'], true) || (null !== $value && !is_string($value))) { + throw ProcurementException::invalid('Invalid supplier contact field.'); + } + } + /** @var array{name:string,email?:?string,phone?:?string,role?:?string} $contact */ + $normalized[] = [ + 'name' => self::normalizeName($contact['name']), + 'email' => self::normalizeEmail($contact['email'] ?? null), + 'phone' => self::normalizeText($contact['phone'] ?? null, 40), + 'role' => self::normalizeText($contact['role'] ?? null, 80), + ]; + } + + return $normalized; + } + // #endregion +} diff --git a/src/Procurement/Domain/ValueObject/ProcurementLine.php b/src/Procurement/Domain/ValueObject/ProcurementLine.php new file mode 100644 index 000000000..671fb1202 --- /dev/null +++ b/src/Procurement/Domain/ValueObject/ProcurementLine.php @@ -0,0 +1,292 @@ + $identityTemplate the bounded declarative identity template + * @param string $quantity the exact ordered quantity with six fractional digits + * @param ?string $unitCost the exact nonnegative unit amount with six fractional digits, or unknown + * @param string $receivedQuantity the exact retained gross received quantity + * @param string $returnedQuantity the exact retained quantity returned to the supplier + * + * @return void + */ + private function __construct( + public string $id, + public string $kind, + public ?string $partId, + public ?string $typeCode, + public array $identityTemplate, + public string $quantity, + public ?string $unitCost, + public string $receivedQuantity, + public string $returnedQuantity, + public ?string $partCode = null, + public ?string $partLabel = null, + public ?string $partUnit = null, + ) { + new Uuid($id); + if (!in_array($kind, ['part', 'equipment_to_individualize'], true)) { + throw ProcurementException::invalid('Unknown procurement line kind.'); + } + $ordered = DecimalAmount::fromString($quantity); + $received = DecimalAmount::fromString($receivedQuantity); + $returned = DecimalAmount::fromString($returnedQuantity); + if ($ordered->isNegative() || $ordered->isZero() || $ordered->compareTo(DecimalAmount::fromInt(100000)) > 0 || $received->isNegative() || $received->compareTo($ordered) > 0 || $returned->isNegative() || $returned->compareTo($received) > 0) { + throw ProcurementException::invalid('An ordered line needs a positive quantity up to 100000 and bounded receipt and return quantities.'); + } + + if ('part' === $kind) { + if (null === $partId || null !== $typeCode || [] !== $identityTemplate) { + throw ProcurementException::invalid('A part line needs only an article identity.'); + } + new Uuid($partId); + } elseif (null !== $partId || null === $typeCode || '' === $typeCode || mb_strlen($typeCode) > 32) { + throw ProcurementException::invalid('An equipment line needs a type code and cannot also refer to a consumable article.'); + } + if ('equipment_to_individualize' === $kind && (!$ordered->isInteger() || !$received->isInteger() || !$returned->isInteger())) { + throw ProcurementException::invalid('Individually tracked equipment quantities must be whole units.'); + } + + self::validateTemplate($identityTemplate); + foreach ([[$partCode, 100], [$partLabel, 255], [$partUnit, 32]] as [$value, $maximum]) { + if (null !== $value && ('' === trim($value) || mb_strlen($value) > $maximum)) { + throw ProcurementException::invalid('The retained article identity exceeds its allowed length.'); + } + } + } + // #endregion + + // #region Methods + /** + * Method create + * + * Creates a line with no reception or return history. + * + * @access public + * + * @param string $id the stable line UUID + * @param string $kind part or equipment_to_individualize + * @param ?string $partId the article UUID + * @param ?string $typeCode the declared equipment type + * @param array $identityTemplate the declarative template + * @param string $quantity the positive exact ordered quantity + * @param ?string $unitCost the exact unit amount, or unknown + * + * @return self the draft line + */ + public static function create(string $id, string $kind, ?string $partId, ?string $typeCode, array $identityTemplate, string $quantity, ?string $unitCost, ?string $partCode = null, ?string $partLabel = null, ?string $partUnit = null): self + { + return self::reconstitute($id, $kind, $partId, $typeCode, $identityTemplate, $quantity, $unitCost, '0.000000', '0.000000', $partCode, $partLabel, $partUnit); + } + + /** + * Method reconstitute + * + * Restores receipt and return totals with all quantity invariants enforced. + * + * @access public + * + * @param string $id the stable line UUID + * @param string $kind part or equipment_to_individualize + * @param ?string $partId the article UUID + * @param ?string $typeCode the declared equipment type + * @param array $identityTemplate the declarative template + * @param string $quantity the positive exact ordered quantity + * @param ?string $unitCost the exact unit amount, or unknown + * @param string $receivedQuantity the retained exact gross received quantity + * @param string $returnedQuantity the retained exact returned quantity + * + * @return self the validated line snapshot + */ + public static function reconstitute(string $id, string $kind, ?string $partId, ?string $typeCode, array $identityTemplate, string $quantity, ?string $unitCost, string $receivedQuantity, string $returnedQuantity, ?string $partCode = null, ?string $partLabel = null, ?string $partUnit = null): self + { + $cost = null === $unitCost ? null : DecimalAmount::fromString($unitCost); + if (null !== $cost && $cost->isNegative()) { + throw ProcurementException::invalid('A procurement unit cost cannot be negative.'); + } + + return new self($id, $kind, $partId, null === $typeCode ? null : trim($typeCode), $identityTemplate, DecimalAmount::fromString($quantity)->toString(), $cost?->toString(), DecimalAmount::fromString($receivedQuantity)->toString(), DecimalAmount::fromString($returnedQuantity)->toString(), $partCode, $partLabel, $partUnit); + } + + /** + * Method receive + * + * Returns a new snapshot only after validating the full requested reception. + * + * @access public + * + * @param string $quantity the positive exact quantity to receive + * + * @return self the updated line + */ + public function receive(string $quantity): self + { + $received = DecimalAmount::fromString($quantity); + if ($received->isNegative() || $received->isZero() || $received->compareTo(DecimalAmount::fromString($this->remainingQuantity())) > 0) { + throw ProcurementException::invalid('A receipt quantity must be positive and cannot exceed the remaining delivery.'); + } + + return new self($this->id, $this->kind, $this->partId, $this->typeCode, $this->identityTemplate, $this->quantity, $this->unitCost, DecimalAmount::fromString($this->receivedQuantity)->add($received)->toString(), $this->returnedQuantity, $this->partCode, $this->partLabel, $this->partUnit); + } + + /** + * Method returnReceived + * + * Keeps gross receipts intact while bounding cumulative supplier returns. + * + * @access public + * + * @param string $quantity the positive exact quantity to return + * + * @return self the updated line + */ + public function returnReceived(string $quantity): self + { + $returned = DecimalAmount::fromString($quantity); + if ($returned->isNegative() || $returned->isZero() || $returned->compareTo(DecimalAmount::fromString($this->returnableQuantity())) > 0) { + throw ProcurementException::invalid('A return quantity must be positive and cannot exceed received units not already returned.'); + } + + return new self($this->id, $this->kind, $this->partId, $this->typeCode, $this->identityTemplate, $this->quantity, $this->unitCost, $this->receivedQuantity, DecimalAmount::fromString($this->returnedQuantity)->add($returned)->toString(), $this->partCode, $this->partLabel, $this->partUnit); + } + + /** + * Method remainingQuantity + * + * @access public + * + * @return string the exact gross quantity still awaiting delivery + */ + public function remainingQuantity(): string + { + return DecimalAmount::fromString($this->quantity)->subtract(DecimalAmount::fromString($this->receivedQuantity))->toString(); + } + + /** + * Method returnableQuantity + * + * @access public + * + * @return string the exact received quantity not previously returned + */ + public function returnableQuantity(): string + { + return DecimalAmount::fromString($this->receivedQuantity)->subtract(DecimalAmount::fromString($this->returnedQuantity))->toString(); + } + + /** + * Method validateTemplate + * + * The template is declarative JSON; individual identities are validated through + * the Equipment public contract when the goods are actually received. + * + * @access private + * + * @param array $template the raw identity template + * + * @return void + */ + private static function validateTemplate(array $template): void + { + if (([] !== $template && array_is_list($template)) || count($template) > 50) { + throw ProcurementException::invalid('An equipment identity template must be a bounded object.'); + } + foreach ($template as $key => $value) { + if (!is_string($key) || '' === trim($key) || mb_strlen($key) > 64) { + throw ProcurementException::invalid('Identity template field names must contain 1 to 64 characters.'); + } + } + + $nodes = 0; + self::validateTemplateValue($template, 0, $nodes); + } + + /** + * Method validateTemplateValue + * + * Rejects objects, nonfinite numbers and unbounded payloads without interpreting + * or executing declarative identity properties. + * + * @access private + * + * @param mixed $value the candidate JSON value + * @param int $depth the current nesting depth + * @param int $nodes the number of already visited values + * + * @return void + */ + private static function validateTemplateValue(mixed $value, int $depth, int &$nodes): void + { + if (++$nodes > 500 || $depth > 4) { + throw ProcurementException::invalid('An identity template exceeds its size or depth bound.'); + } + if (null === $value || is_bool($value) || is_int($value)) { + return; + } + if (is_string($value)) { + if (mb_strlen($value) > 2000) { + throw ProcurementException::invalid('An identity template value exceeds its maximum length.'); + } + + return; + } + if (is_float($value)) { + if (!is_finite($value)) { + throw ProcurementException::invalid('Identity template numbers must be finite.'); + } + + return; + } + if (is_array($value)) { + foreach ($value as $key => $item) { + if (is_string($key) && mb_strlen($key) > 64) { + throw ProcurementException::invalid('An identity template field name exceeds its maximum length.'); + } + self::validateTemplateValue($item, $depth + 1, $nodes); + } + + return; + } + + throw ProcurementException::invalid('Identity templates must contain declarative JSON values.'); + } + // #endregion +} diff --git a/src/Procurement/Domain/ValueObject/PurchaseOrderStatus.php b/src/Procurement/Domain/ValueObject/PurchaseOrderStatus.php new file mode 100644 index 000000000..e8df9d0f6 --- /dev/null +++ b/src/Procurement/Domain/ValueObject/PurchaseOrderStatus.php @@ -0,0 +1,60 @@ + the stable lifecycle codes + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Method allowsReceipt + * + * Cancelled remaining quantities cannot silently become receivable again. + * + * @access public + * + * @return bool whether new quantities may be received + */ + public function allowsReceipt(): bool + { + return self::ORDERED === $this || self::PARTIAL_RECEIVED === $this; + } + // #endregion +} diff --git a/src/Procurement/Infrastructure/Adapter/Reporting/ProcurementEconomicOverviewAdapter.php b/src/Procurement/Infrastructure/Adapter/Reporting/ProcurementEconomicOverviewAdapter.php new file mode 100644 index 000000000..103155495 --- /dev/null +++ b/src/Procurement/Infrastructure/Adapter/Reporting/ProcurementEconomicOverviewAdapter.php @@ -0,0 +1,147 @@ += $to) { + throw new ProcurementEconomicOverviewUnavailable('invalid_period', 'The procurement reporting period needs an exclusive end after its start.'); + } + $currency = $this->currencies->forOrganization($organizationId); + $utc = new DateTimeZone('UTC'); + /** @var array{order_count:int|string,order_currency_count:int|string,order_currency:?string,receipt_count:int|string,receipt_currency_count:int|string,receipt_currency:?string,pending_individualization_count:int|string,ordered_known:numeric-string,ordered_unknown:int|string,outstanding_known:numeric-string,outstanding_unknown:int|string,received_known:numeric-string,received_unknown:int|string,returned_known:numeric-string,returned_unknown:int|string} $row */ + $row = $this->connection->fetchAssociative(<<<'SQL' + WITH selected_orders AS MATERIALIZED ( + SELECT id, currency, status, lines + FROM procurement_orders + WHERE organization_id = :organization + AND status <> 'draft' AND created_at >= :from AND created_at < :to + ORDER BY created_at ASC, id ASC + LIMIT 501 + ), order_metrics AS ( + SELECT COUNT(*) AS order_count, COUNT(DISTINCT currency) AS order_currency_count, + MIN(currency) AS order_currency + FROM selected_orders + ), line_values AS ( + SELECT CASE WHEN o.status = 'cancelled' THEN (l.value->>'receivedQuantity')::numeric + ELSE (l.value->>'quantity')::numeric END AS committed, + CASE WHEN o.status = 'cancelled' THEN 0::numeric + ELSE (l.value->>'quantity')::numeric - (l.value->>'receivedQuantity')::numeric END AS remaining, + (l.value->>'unitCost')::numeric AS unit_cost + FROM selected_orders o CROSS JOIN LATERAL jsonb_array_elements(o.lines) AS l(value) + ), line_metrics AS ( + SELECT COALESCE(SUM(ROUND(committed * unit_cost, 6)), 0)::text AS ordered_known, + COUNT(*) FILTER (WHERE committed > 0 AND unit_cost IS NULL) AS ordered_unknown, + COALESCE(SUM(ROUND(remaining * unit_cost, 6)), 0)::text AS outstanding_known, + COUNT(*) FILTER (WHERE remaining > 0 AND unit_cost IS NULL) AS outstanding_unknown + FROM line_values + ), receipt_metrics AS ( + SELECT COUNT(*) AS receipt_count, COUNT(DISTINCT r.currency) AS receipt_currency_count, + MIN(r.currency) AS receipt_currency, + COUNT(*) FILTER (WHERE r.kind = 'equipment_to_individualize' + AND jsonb_array_length(r.equipment_ids) = 0 AND r.quantity > r.returned_quantity) AS pending_individualization_count, + COALESCE(SUM(ROUND(r.quantity * r.unit_cost, 6)), 0)::text AS received_known, + COUNT(*) FILTER (WHERE r.quantity > 0 AND r.unit_cost IS NULL) AS received_unknown, + COALESCE(SUM(ROUND(r.returned_quantity * r.unit_cost, 6)), 0)::text AS returned_known, + COUNT(*) FILTER (WHERE r.returned_quantity > 0 AND r.unit_cost IS NULL) AS returned_unknown + FROM procurement_receipts r JOIN selected_orders o ON o.id = r.order_id + WHERE r.organization_id = :organization + ) + SELECT * FROM order_metrics CROSS JOIN line_metrics CROSS JOIN receipt_metrics + SQL, ['organization' => $organizationId, 'from' => $from->setTimezone($utc), 'to' => $to->setTimezone($utc)], ['from' => Types::DATETIME_IMMUTABLE, 'to' => Types::DATETIME_IMMUTABLE]); + if ((int) $row['order_count'] > 500) { + throw new ProcurementEconomicOverviewUnavailable('order_limit', 'The procurement overview exceeds 500 orders; narrow the reporting period.'); + } + if ((int) $row['order_currency_count'] > 1 || (null !== $row['order_currency'] && $row['order_currency'] !== $currency) + || (int) $row['receipt_currency_count'] > 1 || (null !== $row['receipt_currency'] && $row['receipt_currency'] !== $currency)) { + throw new ProcurementEconomicOverviewUnavailable('mixed_currency', 'Purchase economics must use the configured organization currency.'); + } + + return new ProcurementEconomicOverview( + $organizationId, + $currency, + $from->format('c'), + $to->format('c'), + (int) $row['order_count'], + (int) $row['receipt_count'], + (int) $row['pending_individualization_count'], + $this->amount($row['ordered_known'], (int) $row['ordered_unknown']), + $this->amount($row['received_known'], (int) $row['received_unknown']), + $this->amount($row['outstanding_known'], (int) $row['outstanding_unknown']), + $this->amount($row['returned_known'], (int) $row['returned_unknown']), + ); + } + + /** + * Method amount + * + * A zero quantity never makes a subtotal incomplete merely because its historical price was unknown. + * + * @access private + * + * @param numeric-string $known exact PostgreSQL numeric subtotal + * @param int $unknown number of positive quantities with no price + * + * @return ProcurementEconomicAmount canonical six-place total or explicitly incomplete subtotal + */ + private function amount(string $known, int $unknown): ProcurementEconomicAmount + { + $subtotal = bcadd($known, '0', 6); + + return new ProcurementEconomicAmount(0 === $unknown ? $subtotal : null, $subtotal, 0 === $unknown); + } + // #endregion +} diff --git a/src/Procurement/Infrastructure/Persistence/Doctrine/Record/ProcurementOperationRecord.php b/src/Procurement/Infrastructure/Persistence/Doctrine/Record/ProcurementOperationRecord.php new file mode 100644 index 000000000..b0e5c3cd9 --- /dev/null +++ b/src/Procurement/Infrastructure/Persistence/Doctrine/Record/ProcurementOperationRecord.php @@ -0,0 +1,36 @@ + + */ + #[ORM\Column(name: 'declaration', type: 'json', options: ['jsonb' => true])] + public array $declaration; +} diff --git a/src/Procurement/Infrastructure/Persistence/Doctrine/Record/ProcurementReceiptRecord.php b/src/Procurement/Infrastructure/Persistence/Doctrine/Record/ProcurementReceiptRecord.php new file mode 100644 index 000000000..a84d4f302 --- /dev/null +++ b/src/Procurement/Infrastructure/Persistence/Doctrine/Record/ProcurementReceiptRecord.php @@ -0,0 +1,73 @@ + '0.000000'])] + public string $pendingReturnQuantity = '0.000000'; + + #[ORM\Id] + #[ORM\Column(name: 'id', type: 'string', length: 36)] + public string $id; + + #[ORM\Column(name: 'organization_id', type: 'string', length: 36)] + public string $organizationId; + + #[ORM\Column(name: 'order_id', type: 'string', length: 36)] + public string $orderId; + + #[ORM\Column(name: 'line_id', type: 'string', length: 36)] + public string $lineId; + + #[ORM\Column(name: 'kind', type: 'string', length: 32)] + public string $kind; + + #[ORM\Column(name: 'quantity', type: 'decimal', precision: 24, scale: 6)] + public string $quantity; + + #[ORM\Column(name: 'warehouse_id', type: 'string', length: 36, nullable: true)] + public ?string $warehouseId = null; + + #[ORM\Column(name: 'unit_cost', type: 'decimal', precision: 24, scale: 6, nullable: true)] + public ?string $unitCost = null; + + #[ORM\Column(name: 'currency', type: 'string', length: 3)] + public string $currency; + + #[ORM\Column(name: 'received_at', type: 'datetime_immutable')] + public DateTimeImmutable $receivedAt; + + #[ORM\Column(name: 'actor_id', type: 'string', length: 36)] + public string $actorId; + + #[ORM\Column(name: 'created_at', type: 'datetime_immutable')] + public DateTimeImmutable $createdAt; + + #[ORM\Column(name: 'inventory_movement_id', type: 'string', length: 36, nullable: true)] + public ?string $inventoryMovementId = null; + + /** + * @var array + */ + #[ORM\Column(name: 'equipment_ids', type: 'json', options: ['jsonb' => true])] + public array $equipmentIds; + + #[ORM\Column(name: 'returned_quantity', type: 'decimal', precision: 24, scale: 6)] + public string $returnedQuantity; + + #[ORM\Column(name: 'blocked_reason', type: 'string', length: 160, nullable: true)] + public ?string $blockedReason = null; + + #[ORM\Column(name: 'revision', type: 'integer')] + public int $revision; +} diff --git a/src/Procurement/Infrastructure/Persistence/Doctrine/Record/ProcurementReturnRecord.php b/src/Procurement/Infrastructure/Persistence/Doctrine/Record/ProcurementReturnRecord.php new file mode 100644 index 000000000..e8444032f --- /dev/null +++ b/src/Procurement/Infrastructure/Persistence/Doctrine/Record/ProcurementReturnRecord.php @@ -0,0 +1,55 @@ + + */ + #[ORM\Column(name: 'lines', type: 'json', options: ['jsonb' => true])] + public array $lines; + + #[ORM\Column(name: 'revision', type: 'integer')] + public int $revision; + + #[ORM\Column(name: 'created_at', type: 'datetime_immutable')] + public DateTimeImmutable $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'datetime_immutable')] + public DateTimeImmutable $updatedAt; +} diff --git a/src/Procurement/Infrastructure/Persistence/Doctrine/Record/SupplierRecord.php b/src/Procurement/Infrastructure/Persistence/Doctrine/Record/SupplierRecord.php new file mode 100644 index 000000000..cafa95141 --- /dev/null +++ b/src/Procurement/Infrastructure/Persistence/Doctrine/Record/SupplierRecord.php @@ -0,0 +1,52 @@ + + */ + #[ORM\Column(name: 'contacts', type: 'json', options: ['jsonb' => true])] + public array $contacts; + + #[ORM\Column(name: 'archived_at', type: 'datetime_immutable', nullable: true)] + public ?DateTimeImmutable $archivedAt = null; + + #[ORM\Column(name: 'created_at', type: 'datetime_immutable')] + public DateTimeImmutable $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'datetime_immutable')] + public DateTimeImmutable $updatedAt; + + #[ORM\Column(name: 'revision', type: 'integer')] + public int $revision; +} diff --git a/src/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepository.php b/src/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepository.php new file mode 100644 index 000000000..653c2b3d4 --- /dev/null +++ b/src/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepository.php @@ -0,0 +1,313 @@ +connection->transactional(function () use ($organizationId, $work): mixed { + $this->connection->executeQuery('SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))', ['key' => 'procurement.' . $organizationId]); + + return $work(); + }); + } + + public function supplier(string $organizationId, string $id): ?Supplier + { + $row = $this->one('procurement_suppliers', $organizationId, $id); + + return null === $row ? null : $this->supplierFromRow($row); + } + + public function saveSupplier(Supplier $supplier): void + { + $this->upsert('procurement_suppliers', ['id' => $supplier->id, 'organization_id' => $supplier->organizationId, 'name' => $supplier->name(), 'code' => $supplier->code(), 'email' => $supplier->email(), 'phone' => $supplier->phone(), 'contacts' => $this->json($supplier->contacts()), 'archived_at' => $this->time($supplier->archivedAt()), 'created_at' => $this->time($supplier->createdAt), 'updated_at' => $this->time($supplier->updatedAt()), 'revision' => $supplier->revision()]); + } + + public function order(string $organizationId, string $id): ?PurchaseOrder + { + $row = $this->one('procurement_orders', $organizationId, $id); + + return null === $row ? null : $this->orderFromRow($row); + } + + public function saveOrder(PurchaseOrder $order): void + { + $lines = []; + foreach ($order->lines() as $line) { + $lines[] = get_object_vars($line); + } + $this->upsert('procurement_orders', ['id' => $order->id, 'organization_id' => $order->organizationId, 'supplier_id' => $order->supplierId(), 'name' => $order->name(), 'currency' => $order->currency(), 'status' => $order->status()->value, 'lines' => $this->json($lines), 'revision' => $order->revision(), 'created_at' => $this->time($order->createdAt), 'updated_at' => $this->time($order->updatedAt())]); + } + + public function receipt(string $organizationId, string $id): ?ProcurementReceiptState + { + $row = $this->one('procurement_receipts', $organizationId, $id); + + return null === $row ? null : $this->receiptFromRow($row); + } + + public function saveReceipt(ProcurementReceiptState $receipt): void + { + $this->upsert('procurement_receipts', ['id' => $receipt->id, 'organization_id' => $receipt->organizationId, 'order_id' => $receipt->orderId, 'line_id' => $receipt->lineId, 'kind' => $receipt->kind, 'quantity' => $receipt->quantity, 'warehouse_id' => $receipt->warehouseId, 'unit_cost' => $receipt->unitCost, 'currency' => $receipt->currency, 'received_at' => $this->time($receipt->receivedAt), 'actor_id' => $receipt->actorId, 'created_at' => $this->time($receipt->createdAt), 'inventory_movement_id' => $receipt->inventoryMovementId, 'equipment_ids' => $this->json($receipt->equipmentIds), 'returned_quantity' => $receipt->returnedQuantity, 'pending_return_quantity' => $receipt->pendingReturnQuantity, 'blocked_reason' => $receipt->blockedReason, 'revision' => $receipt->revision]); + } + + public function returnDeclaration(string $organizationId, string $id): ?ProcurementReturnState + { + $row = $this->one('procurement_returns', $organizationId, $id); + + return null === $row ? null : $this->returnFromRow($row); + } + + public function saveReturn(ProcurementReturnState $return): void + { + $this->upsert('procurement_returns', ['id' => $return->id, 'organization_id' => $return->organizationId, 'receipt_id' => $return->receiptId, 'client_operation_id' => $return->clientOperationId, 'quantity' => $return->quantity, 'reason' => $return->reason, 'actor_id' => $return->actorId, 'created_at' => $this->time($return->createdAt), 'status' => $return->status, 'inventory_movement_id' => $return->inventoryMovementId, 'blocked_reason' => $return->blockedReason, 'reconciled_at' => $this->time($return->reconciledAt), 'revision' => $return->revision]); + } + + public function returns(string $organizationId, string $receiptId, int $offset, int $limit): array + { + return array_map($this->returnFromRow(...), $this->page('procurement_returns', 'organization_id = :org AND receipt_id = :receipt', ['org' => $organizationId, 'receipt' => $receiptId], $offset, $limit, 'created_at, id')); + } + + public function countReturns(string $organizationId, string $receiptId): int + { + /** @var int|string $count */ + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM procurement_returns WHERE organization_id = :org AND receipt_id = :receipt', ['org' => $organizationId, 'receipt' => $receiptId]); + + return (int) $count; + } + + public function operation(string $organizationId, string $clientOperationId): ?ProcurementOperationState + { + $row = $this->connection->fetchAssociative('SELECT * FROM procurement_operations WHERE organization_id = :org AND client_operation_id = :operation', ['org' => $organizationId, 'operation' => $clientOperationId]); + if (false === $row) { + return null; + } + /** @var array{organization_id:string,client_operation_id:string,kind:string,fingerprint:string,receipt_id:string,declaration:string} $row */ + /** @var array $declaration */ + $declaration = json_decode($row['declaration'], true, 512, JSON_THROW_ON_ERROR); + + return new ProcurementOperationState($row['organization_id'], $row['client_operation_id'], $row['kind'], $row['fingerprint'], $row['receipt_id'], $declaration); + } + + public function saveOperation(ProcurementOperationState $operation): void + { + $existing = $this->operation($operation->organizationId, $operation->clientOperationId); + if (null !== $existing) { + if ($existing->kind !== $operation->kind || $existing->fingerprint !== $operation->fingerprint || $existing->receiptId !== $operation->receiptId) { + throw ProcurementException::conflict('The physical declaration key cannot be reused.'); + } + + return; + } + $this->connection->insert('procurement_operations', ['organization_id' => $operation->organizationId, 'client_operation_id' => $operation->clientOperationId, 'kind' => $operation->kind, 'fingerprint' => $operation->fingerprint, 'receipt_id' => $operation->receiptId, 'declaration' => $this->json($operation->declaration)]); + } + + public function suppliers(string $organizationId, string $search, ?bool $archived, int $offset, int $limit): array + { + $criteria = $this->supplierCriteria($archived); + $rows = $this->page('procurement_suppliers', $criteria, ['org' => $organizationId, 'search' => '%' . $search . '%'], $offset, $limit, 'LOWER(name), id'); + + return array_map($this->supplierFromRow(...), $rows); + } + + public function countSuppliers(string $organizationId, string $search, ?bool $archived): int + { + /** @var int|string $count */ + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM procurement_suppliers WHERE ' . $this->supplierCriteria($archived), ['org' => $organizationId, 'search' => '%' . $search . '%']); + + return (int) $count; + } + + public function orders(string $organizationId, ?string $status, ?string $supplierId, int $offset, int $limit): array + { + [$criteria, $parameters] = $this->orderCriteria($organizationId, $status, $supplierId); + + return array_map($this->orderFromRow(...), $this->page('procurement_orders', $criteria, $parameters, $offset, $limit, 'created_at DESC, id')); + } + + public function countOrders(string $organizationId, ?string $status, ?string $supplierId): int + { + [$criteria, $parameters] = $this->orderCriteria($organizationId, $status, $supplierId); + + /** @var int|string $count */ + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM procurement_orders WHERE ' . $criteria, $parameters); + + return (int) $count; + } + + public function receipts(string $organizationId, string $orderId, int $offset, int $limit): array + { + return array_map($this->receiptFromRow(...), $this->page('procurement_receipts', 'organization_id = :org AND order_id = :order', ['org' => $organizationId, 'order' => $orderId], $offset, $limit, 'created_at, id')); + } + + public function countReceipts(string $organizationId, string $orderId): int + { + /** @var int|string $count */ + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM procurement_receipts WHERE organization_id = :org AND order_id = :order', ['org' => $organizationId, 'order' => $orderId]); + + return (int) $count; + } + + /** + * @return array|null + */ + private function one(string $table, string $organizationId, string $id): ?array + { + $row = $this->connection->fetchAssociative('SELECT * FROM ' . $table . ' WHERE organization_id = :org AND id = :id', ['org' => $organizationId, 'id' => $id]); + + return false === $row ? null : $row; + } + + /** + * @param array $values + */ + private function upsert(string $table, array $values): void + { + $names = array_keys($values); + $updates = []; + foreach ($names as $name) { + if (!in_array($name, ['id', 'organization_id', 'created_at'], true) && ('procurement_receipts' !== $table || in_array($name, ['inventory_movement_id', 'equipment_ids', 'returned_quantity', 'pending_return_quantity', 'blocked_reason', 'revision'], true)) && ('procurement_returns' !== $table || in_array($name, ['status', 'inventory_movement_id', 'blocked_reason', 'reconciled_at', 'revision'], true))) { + $updates[] = $name . ' = EXCLUDED.' . $name; + } + } + $affected = $this->connection->executeStatement('INSERT INTO ' . $table . ' (' . implode(', ', $names) . ') VALUES (' . implode(', ', array_map(static fn (string $name): string => ':' . $name, $names)) . ') ON CONFLICT (id) DO UPDATE SET ' . implode(', ', $updates) . ' WHERE ' . $table . '.organization_id = EXCLUDED.organization_id', $values); + if (1 !== $affected) { + throw ProcurementException::notFound(); + } + } + + /** + * @param array $parameters + * + * @return list> + */ + private function page(string $table, string $criteria, array $parameters, int $offset, int $limit, string $sort): array + { + return $this->connection->fetchAllAssociative('SELECT * FROM ' . $table . ' WHERE ' . $criteria . ' ORDER BY ' . $sort . ' LIMIT :limit OFFSET :offset', $parameters + ['limit' => $limit, 'offset' => $offset], ['limit' => ParameterType::INTEGER, 'offset' => ParameterType::INTEGER]); + } + + private function supplierCriteria(?bool $archived): string + { + return 'organization_id = :org' . (null === $archived ? '' : ' AND archived_at IS ' . ($archived ? 'NOT ' : '') . 'NULL') . " AND (name ILIKE :search OR COALESCE(code, '') ILIKE :search)"; + } + + /** + * @return array{string,array} + */ + private function orderCriteria(string $organizationId, ?string $status, ?string $supplierId): array + { + $criteria = 'organization_id = :org'; + $parameters = ['org' => $organizationId]; + if (null !== $status) { + $criteria .= ' AND status = :status'; + $parameters['status'] = $status; + } + if (null !== $supplierId) { + $criteria .= ' AND supplier_id = :supplier'; + $parameters['supplier'] = $supplierId; + } + + return [$criteria, $parameters]; + } + + /** + * @param array $row + */ + private function supplierFromRow(array $row): Supplier + { + /** @var array{id:string,organization_id:string,name:string,code:?string,email:?string,phone:?string,contacts:string,archived_at:?string,created_at:string,updated_at:string,revision:int|string} $row */ + /** @var list $contacts */ + $contacts = json_decode($row['contacts'], true, 512, JSON_THROW_ON_ERROR); + + return Supplier::reconstitute($row['id'], $row['organization_id'], $row['name'], $row['code'], $row['email'], $row['phone'], $contacts, $this->dateOrNull($row['archived_at']), $this->date($row['created_at']), $this->date($row['updated_at']), (int) $row['revision']); + } + + /** + * @param array $row + */ + private function orderFromRow(array $row): PurchaseOrder + { + /** @var array{id:string,organization_id:string,supplier_id:string,currency:string,name:string,lines:string,status:string,revision:int|string,created_at:string,updated_at:string} $row */ + /** @var list,quantity:string,unitCost:?string,receivedQuantity:string,returnedQuantity:string,partCode?:?string,partLabel?:?string,partUnit?:?string}> $stored */ + $stored = json_decode($row['lines'], true, 512, JSON_THROW_ON_ERROR); + $lines = []; + foreach ($stored as $line) { + $lines[] = ProcurementLine::reconstitute($line['id'], $line['kind'], $line['partId'], $line['typeCode'], $line['identityTemplate'], $line['quantity'], $line['unitCost'], $line['receivedQuantity'], $line['returnedQuantity'], $line['partCode'] ?? null, $line['partLabel'] ?? null, $line['partUnit'] ?? null); + } + + return PurchaseOrder::reconstitute($row['id'], $row['organization_id'], $row['supplier_id'], $row['currency'], $row['name'], $lines, PurchaseOrderStatus::from($row['status']), (int) $row['revision'], $this->date($row['created_at']), $this->date($row['updated_at'])); + } + + /** + * @param array $row + */ + private function receiptFromRow(array $row): ProcurementReceiptState + { + /** @var array{id:string,organization_id:string,order_id:string,line_id:string,kind:string,quantity:string,warehouse_id:?string,unit_cost:?string,currency:string,received_at:string,actor_id:string,created_at:string,inventory_movement_id:?string,equipment_ids:string,returned_quantity:string,pending_return_quantity:string,blocked_reason:?string,revision:int|string} $row */ + /** @var list $equipmentIds */ + $equipmentIds = json_decode($row['equipment_ids'], true, 512, JSON_THROW_ON_ERROR); + + return new ProcurementReceiptState($row['id'], $row['organization_id'], $row['order_id'], $row['line_id'], $row['kind'], $row['quantity'], $row['warehouse_id'], $row['unit_cost'], $row['currency'], $this->date($row['received_at']), $row['actor_id'], $this->date($row['created_at']), $row['inventory_movement_id'], $equipmentIds, $row['returned_quantity'], $row['blocked_reason'], (int) $row['revision'], $row['pending_return_quantity']); + } + + /** + * @param array $row + */ + private function returnFromRow(array $row): ProcurementReturnState + { + /** @var array{id:string,organization_id:string,receipt_id:string,client_operation_id:string,quantity:string,reason:string,actor_id:string,created_at:string,status:string,inventory_movement_id:?string,blocked_reason:?string,reconciled_at:?string,revision:int|string} $row */ + + return new ProcurementReturnState($row['id'], $row['organization_id'], $row['receipt_id'], $row['client_operation_id'], $row['quantity'], $row['reason'], $row['actor_id'], $this->date($row['created_at']), $row['status'], $row['inventory_movement_id'], $row['blocked_reason'], $this->dateOrNull($row['reconciled_at']), (int) $row['revision']); + } + + private function time(?DateTimeImmutable $time): ?string + { + return $time?->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'); + } + + private function date(string $value): DateTimeImmutable + { + return new DateTimeImmutable($value, new DateTimeZone('UTC')); + } + + private function dateOrNull(?string $value): ?DateTimeImmutable + { + return null === $value ? null : $this->date($value); + } + + /** + * @param array $value + */ + private function json(array $value): string + { + return json_encode($value, JSON_THROW_ON_ERROR); + } +} diff --git a/src/Procurement/MODULE.md b/src/Procurement/MODULE.md new file mode 100644 index 000000000..b60125199 --- /dev/null +++ b/src/Procurement/MODULE.md @@ -0,0 +1,84 @@ +# Procurement + +## Overview + +Procurement owns organization-internal suppliers, purchase orders, physical receipts and motivated supply returns. It supports maintenance resources; commercial quotations, taxes, customer invoicing and payments remain in the ERP. Billing continues to own the FireGuard SaaS subscription. + +Part and consumable quantities are exact decimal strings with six fractional digits. Equipment quantities must be whole units. Unit costs are nonnegative exact strings or explicitly unknown; no floating point amount is accepted. The organization currency is resolved and locked through MaintenanceCost. + +A part receipt creates quantitative inventory. An equipment receipt instead remains awaiting individualization, without creating a quantitative stock row. Individualization creates reserve assets through Equipment, preserving its catalogue, permissions, identity constraints and quota checks. + +ProcurementEconomicOverviewPort exposes private purchase economics to a reporting caller that has already verified organization scope and financial read permission. Its inclusive-start, exclusive-end period selects at most 500 non-draft orders by createdAt; receipt and return values include the entire physical history of those selected orders, including receipts after the reporting window. A single main-database statement preserves a consistent view. Ordered value means the effective commitment after cancellation, received value is gross delivery, outstanding value excludes cancelled remainder and is never reopened by returns, and returned value retains physical returns even while stock reconciliation is pending. Unknown prices produce a known subtotal and an explicitly incomplete total. Oversized selections or currencies differing from the configured organization currency are refused; no supplier contacts enter the report. + +## API endpoints + +All routes are under `/api/organizations/{organizationId}/procurement` and require authentication. + +| Method | Path | Behavior | +|---|---|---| +| GET / POST | `/suppliers` | List or create internal suppliers and contacts. | +| GET / PATCH | `/suppliers/{id}` | Read or change a supplier. | +| POST | `/suppliers/{id}/archive` | Archive without deleting current purchases. | +| GET / POST | `/orders` | List or prepare purchase drafts. | +| GET / PATCH | `/orders/{id}` | Read or edit a draft. | +| POST | `/orders/{id}/order` | Freeze ordered lines for delivery. | +| POST | `/orders/{id}/cancel-remaining` | Cancel only quantities not yet received. | +| GET / POST | `/orders/{id}/receipts` | List receipts or record a physical delivery. | +| GET | `/receipts/{id}` | Read retained delivery evidence. | +| POST | `/receipts/{id}/individualize` | Create a bounded reserve-equipment batch. | +| POST | `/receipts/{id}/returns` | Retain a motivated physical return and attempt stock confirmation. | +| GET | `/receipts/{id}/returns` | List retained returns, including those awaiting reconciliation. | +| GET | `/returns/{id}` | Read a physical return and its reconciliation status. | +| POST | `/returns/{id}/reconcile` | Explicitly retry stock confirmation. | + +Collections use Hydra pagination, default 30 and maximum 100. Supplier filters include search and archived: omission or false selects active suppliers, true selects archived suppliers, and explicit all selects both with the same scoped count and pagination. Order filters include status and supplierId. All resource references are resolved in the same organization. + +Mutations of an existing resource require `If-Match: "revision-N"`. Receipt creation uses the order revision; individualization and return declaration use the receipt revision; return reconciliation uses the return revision. A matching saved clientOperationId replays before the stale revision check. UUID representations are normalized to lowercase. + +## Flows + +A draft becomes ordered, then partial_received or received. Ordered lines cannot be edited. Cancelling remaining quantities preserves all receipts and returns. Supplier archival prevents new selections while already ordered purchases can still be received. + +Part code, label and unit are captured through one scoped catalog lookup during draft preparation. The ordered line keeps this identity when catalog labels or units later change; these nonfinancial fields remain visible to a quantity receiver without inventory or cost read permissions. + +Each physical delivery names a stable line UUID, exact quantity, receivedAt with an explicit offset, and clientOperationId. Organization locking, remaining-quantity reservation, durable receipt, inventory confirmation and the event outbox share the main transaction. Replaying a declaration does not create another movement or asset. Reusing its operation UUID for another declaration is a conflict. + +Equipment deliveries contain at most 100 units per receipt. A quota failure or an unavailable type keeps the physical receipt awaiting_individualization, without partial equipment creation. Every attempt is saved, including blocked attempts; another attempt requires a new operation UUID. A transport retry retains the original UUID. Individualized receipts preserve their equipment IDs and are excluded from quantitative piece stock. + +A physical supply return preserves its quantity and reason even when available computer stock cannot confirm the debit. returnedQuantity counts the physical fact; pendingReturnQuantity separately counts stock awaiting reconciliation. A typed blocked inventory result creates no debit, negative balance or partial movement. Explicit reconciliation retries the original inventory operation key and, when successful, updates the stock movement, pending quantity, return status and outbox atomically. Returns do not reopen the order's remaining delivery quantity. Equipment already individualized is handled through its park lifecycle. + +## Architecture + +Presentation translates DTOs into CommandBusPort and QueryBusPort messages. ManageProcurementHandler and ReadProcurementHandler are the sole mutation and read entry points. Domain models enforce order lifecycle, exact quantities, line separation, contact validity and optimistic revisions. + +ProcurementRepository uses an explicitly named main DBAL connection. It scopes every lookup and serializes mutations with a transaction-scoped organization advisory lock. Physical receipt and return identity fields are immutable on repository updates. Stable operation declarations and motivated return evidence are retained. + +Cross-module access uses published Application contracts and ports: InventoryStockReceiptPort, InventoryPartDirectoryPort, EquipmentReserveReceiptPort, MaintenanceCurrencyPort and OrganizationAuthorizationPort. The module never reads sibling persistence records during production execution. + +## Configuration + +`config/modules/procurement.yaml` binds the repository to main and registers providers, processors, the exception subscriber and message handlers. Events use MainTransactionEventDispatcher. + +The module requires `organization.procurement.read/manage`. Part receipts, supply returns and reconciliation additionally require `organization.inventory.manage`; individualization requires `organization.equipment.write`. Cost visibility independently requires `organization.maintenance_cost.read`; explicitly writing or clearing a unitCost requires `organization.maintenance_cost.manage`. Hidden prices are omitted from projections and DTOs; an authorized unknown cost remains null. + +Additive main migrations 20261006112000–20261006112002 add suppliers, orders, physical receipts, immutable operation keys, returns and scoped indexes. UTC timestamps carry datetime_immutable type comments. Decimal columns use NUMERIC(24,6). + +## Testing + +Domain tests cover supplier contacts/archive, order transitions, fractional consumables, integer equipment, bounded receipts and returns. Handler and transport tests cover authority, isolated financial visibility, exact decimals, optimistic revision, operation replay and UUID aliases. + +PostgreSQL integration tests use independent connections for organization locks, the last quantity, retained declarations, transaction rollback and outbox visibility. Functional API tests exercise real inventory receipt/return bridges and real quota-backed equipment individualization, including partial delivery, archived suppliers, hidden costs, replay, cancellation and organization denial. + +Run targeted Procurement tests, PHPStan, scoped formatting, architecture checks and container lint. Root API gates additionally validate migrations, Doctrine schemas and OpenAPI freshness. + +## Error codes + +- 400: malformed transport. +- 403: active organization member missing an applicable procurement, stock, equipment or financial permission. +- 404: organization outside scope or a resource/reference outside the authorized organization. +- 409: invalid lifecycle, quantity exceeding retained bounds, or an operation UUID reused for a different declaration. +- 412: stale or malformed resource revision. +- 422: invalid exact amount, quantity, contact, line kind, template or timestamp. +- 428: missing If-Match on a new mutation of an existing resource. + +Blocked quota individualization and computer-stock shortage are accepted physical records with a visible pending status, not partially confirmed stock or equipment. diff --git a/src/Procurement/Presentation/Api/Dto/Input/ChangePurchaseOrderInput.php b/src/Procurement/Presentation/Api/Dto/Input/ChangePurchaseOrderInput.php new file mode 100644 index 000000000..6a827c6bd --- /dev/null +++ b/src/Procurement/Presentation/Api/Dto/Input/ChangePurchaseOrderInput.php @@ -0,0 +1,25 @@ +>|null + */ + #[Groups(['procurement:write'])] + public ?array $lines = null; +} diff --git a/src/Procurement/Presentation/Api/Dto/Input/ChangeSupplierInput.php b/src/Procurement/Presentation/Api/Dto/Input/ChangeSupplierInput.php new file mode 100644 index 000000000..16fd6aecd --- /dev/null +++ b/src/Procurement/Presentation/Api/Dto/Input/ChangeSupplierInput.php @@ -0,0 +1,29 @@ +>|null + */ + #[Groups(['procurement:write'])] + public ?array $contacts = null; +} diff --git a/src/Procurement/Presentation/Api/Dto/Input/IndividualizeReceiptInput.php b/src/Procurement/Presentation/Api/Dto/Input/IndividualizeReceiptInput.php new file mode 100644 index 000000000..a1a4dad9c --- /dev/null +++ b/src/Procurement/Presentation/Api/Dto/Input/IndividualizeReceiptInput.php @@ -0,0 +1,17 @@ + + */ + #[Groups(['procurement:read'])] + public array $equipmentIds; + + #[Groups(['procurement:read'])] + public string $returnedQuantity; + + #[Groups(['procurement:read'])] + public string $status; + + #[Groups(['procurement:read'])] + public ?string $blockedReason; + + #[Groups(['procurement:read'])] + public int $revision; + + #[Groups(['procurement:read'])] + public bool $financialVisible; + + #[Groups(['procurement:read'])] + public bool $replayed = false; + + #[Groups(['procurement:read'])] + public ?string $unitCost; + + /** + * @param array $data + */ + public static function fromProjection(array $data, bool $replayed = false): self + { + /** @var array{id:string,organizationId:string,orderId:string,lineId:string,kind:string,quantity:string,warehouseId:string|null,currency:string,receivedAt:string,createdAt:string,inventoryMovementId:string|null,equipmentIds:list,returnedQuantity:string,status:string,blockedReason:string|null,revision:int,financialVisible:bool,unitCost?:string|null,pendingReturnQuantity?:string} $data */ + $output = new self(); + $output->pendingReturnQuantity = $data['pendingReturnQuantity'] ?? '0.000000'; + $output->id = $data['id']; + $output->organizationId = $data['organizationId']; + $output->orderId = $data['orderId']; + $output->lineId = $data['lineId']; + $output->kind = $data['kind']; + $output->quantity = $data['quantity']; + $output->warehouseId = $data['warehouseId']; + $output->currency = $data['currency']; + $output->receivedAt = $data['receivedAt']; + $output->createdAt = $data['createdAt']; + $output->inventoryMovementId = $data['inventoryMovementId']; + $output->equipmentIds = $data['equipmentIds']; + $output->returnedQuantity = $data['returnedQuantity']; + $output->status = $data['status']; + $output->blockedReason = $data['blockedReason']; + $output->revision = $data['revision']; + $output->financialVisible = $data['financialVisible']; + if (array_key_exists('unitCost', $data)) { + $output->unitCost = is_string($data['unitCost']) ? $data['unitCost'] : null; + } + $output->replayed = $replayed; + + return $output; + } +} diff --git a/src/Procurement/Presentation/Api/Dto/Output/ProcurementReturnOutput.php b/src/Procurement/Presentation/Api/Dto/Output/ProcurementReturnOutput.php new file mode 100644 index 000000000..ef902097f --- /dev/null +++ b/src/Procurement/Presentation/Api/Dto/Output/ProcurementReturnOutput.php @@ -0,0 +1,76 @@ + $data + */ + public static function fromProjection(array $data, bool $replayed = false): self + { + /** @var array{id:string,organizationId:string,receiptId:string,clientOperationId:string,quantity:string,reason:string,status:string,inventoryMovementId:string|null,blockedReason:string|null,createdAt:string,reconciledAt:string|null,revision:int} $data */ + $output = new self(); + $output->id = $data['id']; + $output->organizationId = $data['organizationId']; + $output->receiptId = $data['receiptId']; + $output->clientOperationId = $data['clientOperationId']; + $output->quantity = $data['quantity']; + $output->reason = $data['reason']; + $output->status = $data['status']; + $output->inventoryMovementId = $data['inventoryMovementId']; + $output->blockedReason = $data['blockedReason']; + $output->createdAt = $data['createdAt']; + $output->reconciledAt = $data['reconciledAt']; + $output->revision = $data['revision']; + $output->replayed = $replayed; + + return $output; + } +} diff --git a/src/Procurement/Presentation/Api/Dto/Output/PurchaseOrderOutput.php b/src/Procurement/Presentation/Api/Dto/Output/PurchaseOrderOutput.php new file mode 100644 index 000000000..1c062b773 --- /dev/null +++ b/src/Procurement/Presentation/Api/Dto/Output/PurchaseOrderOutput.php @@ -0,0 +1,75 @@ +>|list + */ + #[Groups(['procurement:read'])] + public array $lines; + + #[Groups(['procurement:read'])] + public bool $financialVisible; + + #[Groups(['procurement:read'])] + public int $revision; + + #[Groups(['procurement:read'])] + public string $createdAt; + + #[Groups(['procurement:read'])] + public string $updatedAt; + + #[Groups(['procurement:read'])] + public bool $replayed = false; + + /** + * @param array $data + */ + public static function fromProjection(array $data, bool $replayed = false): self + { + /** @var array{id:string,organizationId:string,supplierId:string,name:string,currency:string,status:string,lines:list>|list,financialVisible:bool,revision:int,createdAt:string,updatedAt:string} $data */ + $output = new self(); + $output->id = $data['id']; + $output->organizationId = $data['organizationId']; + $output->supplierId = $data['supplierId']; + $output->name = $data['name']; + $output->currency = $data['currency']; + $output->status = $data['status']; + $output->lines = $data['lines']; + $output->financialVisible = $data['financialVisible']; + $output->revision = $data['revision']; + $output->createdAt = $data['createdAt']; + $output->updatedAt = $data['updatedAt']; + $output->replayed = $replayed; + + return $output; + } +} diff --git a/src/Procurement/Presentation/Api/Dto/Output/SupplierOutput.php b/src/Procurement/Presentation/Api/Dto/Output/SupplierOutput.php new file mode 100644 index 000000000..e2e4a27bd --- /dev/null +++ b/src/Procurement/Presentation/Api/Dto/Output/SupplierOutput.php @@ -0,0 +1,75 @@ +>|list + */ + #[Groups(['procurement:read'])] + public array $contacts; + + #[Groups(['procurement:read'])] + public ?string $archivedAt; + + #[Groups(['procurement:read'])] + public string $createdAt; + + #[Groups(['procurement:read'])] + public string $updatedAt; + + #[Groups(['procurement:read'])] + public int $revision; + + #[Groups(['procurement:read'])] + public bool $replayed = false; + + /** + * @param array $data + */ + public static function fromProjection(array $data, bool $replayed = false): self + { + /** @var array{id:string,organizationId:string,name:string,code:string|null,email:string|null,phone:string|null,contacts:list>|list,archivedAt:string|null,createdAt:string,updatedAt:string,revision:int} $data */ + $output = new self(); + $output->id = $data['id']; + $output->organizationId = $data['organizationId']; + $output->name = $data['name']; + $output->code = $data['code']; + $output->email = $data['email']; + $output->phone = $data['phone']; + $output->contacts = $data['contacts']; + $output->archivedAt = $data['archivedAt']; + $output->createdAt = $data['createdAt']; + $output->updatedAt = $data['updatedAt']; + $output->revision = $data['revision']; + $output->replayed = $replayed; + + return $output; + } +} diff --git a/src/Procurement/Presentation/Api/EventSubscriber/ProcurementExceptionSubscriber.php b/src/Procurement/Presentation/Api/EventSubscriber/ProcurementExceptionSubscriber.php new file mode 100644 index 000000000..12d45a915 --- /dev/null +++ b/src/Procurement/Presentation/Api/EventSubscriber/ProcurementExceptionSubscriber.php @@ -0,0 +1,50 @@ + ['onKernelException', 10]]; + } + + public function onKernelException(ExceptionEvent $event): void + { + $original = $event->getThrowable(); + if ($original instanceof HttpExceptionInterface || !str_contains($event->getRequest()->getPathInfo(), '/procurement/')) { + return; + } + $current = $original; + do { + if ($current instanceof ProcurementException) { + $status = match ($current->errorCode) { + 'not_found' => 404, 'denied' => 403, 'stale' => 412, 'revision_required' => 428, 'conflict' => 409, default => 422, + }; + $event->setThrowable(new HttpException($status, $current->getMessage(), $original)); + + return; + } + if ($current instanceof InvalidValueException || $current instanceof ValueError) { + $event->setThrowable(new HttpException(422, $current->getMessage(), $original)); + + return; + } + $current = $current->getPrevious(); + } while ($current instanceof Throwable); + } +} diff --git a/src/Procurement/Presentation/Api/Operation/ProcurementOperations.php b/src/Procurement/Presentation/Api/Operation/ProcurementOperations.php new file mode 100644 index 000000000..134a6a633 --- /dev/null +++ b/src/Procurement/Presentation/Api/Operation/ProcurementOperations.php @@ -0,0 +1,47 @@ + + */ +final readonly class ProcurementProcessor implements ProcessorInterface +{ + public function __construct(private CommandBusPort $commands, private CurrentActorPort $actor, private RequestStack $requests) + { + } + + /** + * @param array $uriVariables @param array $context + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SupplierOutput|PurchaseOrderOutput|ProcurementReceiptOutput|ProcurementReturnOutput + { + $actorId = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $action = match ($operation->getName()) { + ProcurementOperations::CREATE_SUPPLIER => 'create_supplier', + ProcurementOperations::CHANGE_SUPPLIER => 'change_supplier', + ProcurementOperations::ARCHIVE_SUPPLIER => 'archive_supplier', + ProcurementOperations::CREATE_ORDER => 'create_order', + ProcurementOperations::CHANGE_ORDER => 'change_order', + ProcurementOperations::PLACE_ORDER => 'order', + ProcurementOperations::CANCEL_REMAINING => 'cancel_remaining', + ProcurementOperations::RECEIVE => 'receive', + ProcurementOperations::INDIVIDUALIZE => 'individualize', + ProcurementOperations::RETURN => 'return', + ProcurementOperations::RECONCILE_RETURN => 'reconcile_return', + default => throw ProcurementException::invalid('Unknown procurement HTTP mutation.'), + }; + $payload = []; + $request = $this->requests->getCurrentRequest(); + if (!in_array($action, ['archive_supplier', 'order', 'cancel_remaining'], true)) { + if (!is_object($data)) { + throw ProcurementException::invalid('A procurement input is required.'); + } + foreach ($request?->getPayload()->all() ?? [] as $field => $value) { + if (!property_exists($data, $field)) { + throw ProcurementException::invalid('Unknown procurement field.'); + } + $payload[$field] = $data->{$field}; + } + } + $header = $request?->headers->get('If-Match'); + $revision = null === $header ? null : (1 === preg_match('/^"revision-(\\d+)"$/', $header, $matches) ? (int) $matches[1] : -1); + /** @var ManageProcurementResult $result */ + $result = $this->commands->dispatch(new ManageProcurementCommand($actorId, $this->identifier($uriVariables, 'organizationId') ?? '', $action, $this->identifier($uriVariables, 'id'), $revision, $payload)); + + return match ($result->kind) { + 'supplier' => SupplierOutput::fromProjection($result->data, $result->replayed), + 'order' => PurchaseOrderOutput::fromProjection($result->data, $result->replayed), + 'receipt' => ProcurementReceiptOutput::fromProjection($result->data, $result->replayed), + 'return' => ProcurementReturnOutput::fromProjection($result->data, $result->replayed), + default => throw ProcurementException::invalid('Invalid procurement result.'), + }; + } + + /** + * @param array $variables + */ + private function identifier(array $variables, string $field): ?string + { + return is_string($variables[$field] ?? null) ? $variables[$field] : null; + } +} diff --git a/src/Procurement/Presentation/Api/Provider/ProcurementProvider.php b/src/Procurement/Presentation/Api/Provider/ProcurementProvider.php new file mode 100644 index 000000000..57b676f5c --- /dev/null +++ b/src/Procurement/Presentation/Api/Provider/ProcurementProvider.php @@ -0,0 +1,76 @@ + + */ +final readonly class ProcurementProvider implements ProviderInterface +{ + public function __construct(private QueryBusPort $queries, private CurrentActorPort $actor, private RequestStack $requests) + { + } + + /** + * @param array $uriVariables + * @param array $context + * + * @return SupplierOutput|PurchaseOrderOutput|ProcurementReceiptOutput|ProcurementReturnOutput|TraversablePaginator + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): SupplierOutput|PurchaseOrderOutput|ProcurementReceiptOutput|ProcurementReturnOutput|TraversablePaginator + { + $actorId = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $action = match ($operation->getName()) { + ProcurementOperations::SUPPLIERS => 'suppliers', ProcurementOperations::SUPPLIER => 'supplier', + ProcurementOperations::ORDERS => 'orders', ProcurementOperations::ORDER => 'order', + ProcurementOperations::RECEIPTS => 'receipts', ProcurementOperations::RECEIPT => 'receipt', + ProcurementOperations::RETURNS => 'returns', ProcurementOperations::RETURN_DETAIL => 'return', + default => throw ProcurementException::invalid('Unknown procurement HTTP read.'), + }; + $request = $this->requests->getCurrentRequest(); + $supplier = $request?->query->getString('supplierId'); + $status = $request?->query->getString('status'); + $archived = 'all' === $request?->query->getString('archived') ? null : ($request?->query->getBoolean('archived', false) ?? false); + /** @var ReadProcurementResult $result */ + $result = $this->queries->ask(new ReadProcurementQuery($actorId, $this->identifier($uriVariables, 'organizationId') ?? '', $action, $this->identifier($uriVariables, 'id'), $request?->query->getString('search', '') ?? '', $archived, null === $status || '' === $status ? null : $status, null === $supplier || '' === $supplier ? null : $supplier, $request?->query->getInt('page', 1) ?? 1, $request?->query->getInt('itemsPerPage', 30) ?? 30)); + $items = []; + foreach ($result->items as $data) { + $items[] = match ($result->kind) { + 'supplier' => SupplierOutput::fromProjection($data), 'order' => PurchaseOrderOutput::fromProjection($data), + 'receipt' => ProcurementReceiptOutput::fromProjection($data), + 'return' => ProcurementReturnOutput::fromProjection($data), + default => throw ProcurementException::invalid('Invalid procurement read result.'), + }; + } + if ($result->collection) { + return new TraversablePaginator(new ArrayIterator($items), (float) $result->page, (float) $result->itemsPerPage, (float) $result->total); + } + + return $items[0] ?? throw ProcurementException::notFound(); + } + + /** + * @param array $variables + */ + private function identifier(array $variables, string $field): ?string + { + return is_string($variables[$field] ?? null) ? $variables[$field] : null; + } +} diff --git a/src/Procurement/Presentation/Api/Resource/ProcurementResource.php b/src/Procurement/Presentation/Api/Resource/ProcurementResource.php new file mode 100644 index 000000000..a9090c63b --- /dev/null +++ b/src/Procurement/Presentation/Api/Resource/ProcurementResource.php @@ -0,0 +1,45 @@ + ['procurement:read'], 'skip_null_values' => false], + denormalizationContext: ['groups' => ['procurement:write']], + operations: [ + new GetCollection(name: ProcurementOperations::RETURNS, uriTemplate: '/organizations/{organizationId}/procurement/receipts/{id}/returns', output: ProcurementReturnOutput::class, provider: ProcurementProvider::class, paginationEnabled: true, paginationClientItemsPerPage: true, paginationItemsPerPage: 30, paginationMaximumItemsPerPage: 100, openapi: new Operation(tags: ['Procurement'], summary: 'List retained physical supplier returns')), + new Get(name: ProcurementOperations::RETURN_DETAIL, uriTemplate: '/organizations/{organizationId}/procurement/returns/{id}', output: ProcurementReturnOutput::class, provider: ProcurementProvider::class, openapi: new Operation(tags: ['Procurement'], summary: 'Read a retained physical return')), + new Post(name: ProcurementOperations::RECONCILE_RETURN, uriTemplate: '/organizations/{organizationId}/procurement/returns/{id}/reconcile', output: ProcurementReturnOutput::class, input: IndividualizeReceiptInput::class, read: false, status: 200, processor: ProcurementProcessor::class, openapi: new Operation(tags: ['Procurement'], summary: 'Explicitly reconcile a physical supply return')), + new GetCollection(name: ProcurementOperations::SUPPLIERS, uriTemplate: '/organizations/{organizationId}/procurement/suppliers', output: SupplierOutput::class, provider: ProcurementProvider::class, paginationEnabled: true, paginationClientItemsPerPage: true, paginationItemsPerPage: 30, paginationMaximumItemsPerPage: 100, parameters: ['search' => new QueryParameter(schema: ['type' => 'string'], castToArray: false), 'archived' => new QueryParameter(description: 'false or omitted selects active suppliers; true selects archived suppliers; all selects both.', schema: ['type' => 'string', 'enum' => ['false', 'true', 'all']], castToArray: false, castToNativeType: false)], openapi: new Operation(tags: ['Procurement'], summary: 'List suppliers')), + new Post(name: ProcurementOperations::CREATE_SUPPLIER, uriTemplate: '/organizations/{organizationId}/procurement/suppliers', output: SupplierOutput::class, processor: ProcurementProcessor::class, input: ChangeSupplierInput::class, read: false, status: 201, openapi: new Operation(tags: ['Procurement'], summary: 'Create an internal supplier')), + new Get(name: ProcurementOperations::SUPPLIER, uriTemplate: '/organizations/{organizationId}/procurement/suppliers/{id}', output: SupplierOutput::class, provider: ProcurementProvider::class, openapi: new Operation(tags: ['Procurement'], summary: 'Read a supplier')), + new Patch(name: ProcurementOperations::CHANGE_SUPPLIER, uriTemplate: '/organizations/{organizationId}/procurement/suppliers/{id}', output: SupplierOutput::class, processor: ProcurementProcessor::class, input: ChangeSupplierInput::class, read: false, status: 200, openapi: new Operation(tags: ['Procurement'], summary: 'Change a supplier')), + new Post(name: ProcurementOperations::ARCHIVE_SUPPLIER, uriTemplate: '/organizations/{organizationId}/procurement/suppliers/{id}/archive', output: SupplierOutput::class, processor: ProcurementProcessor::class, input: false, read: false, status: 200, openapi: new Operation(tags: ['Procurement'], summary: 'Archive a supplier retaining current purchases')), + new GetCollection(name: ProcurementOperations::ORDERS, uriTemplate: '/organizations/{organizationId}/procurement/orders', output: PurchaseOrderOutput::class, provider: ProcurementProvider::class, paginationEnabled: true, paginationClientItemsPerPage: true, paginationItemsPerPage: 30, paginationMaximumItemsPerPage: 100, parameters: ['status' => new QueryParameter(schema: ['type' => 'string', 'enum' => ['draft', 'ordered', 'partial_received', 'received', 'cancelled']], castToArray: false), 'supplierId' => new QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], castToArray: false)], openapi: new Operation(tags: ['Procurement'], summary: 'List purchase orders')), + new Post(name: ProcurementOperations::CREATE_ORDER, uriTemplate: '/organizations/{organizationId}/procurement/orders', output: PurchaseOrderOutput::class, processor: ProcurementProcessor::class, input: ChangePurchaseOrderInput::class, read: false, status: 201, openapi: new Operation(tags: ['Procurement'], summary: 'Create a purchase draft')), + new Get(name: ProcurementOperations::ORDER, uriTemplate: '/organizations/{organizationId}/procurement/orders/{id}', output: PurchaseOrderOutput::class, provider: ProcurementProvider::class, openapi: new Operation(tags: ['Procurement'], summary: 'Read a purchase order')), + new Patch(name: ProcurementOperations::CHANGE_ORDER, uriTemplate: '/organizations/{organizationId}/procurement/orders/{id}', output: PurchaseOrderOutput::class, processor: ProcurementProcessor::class, input: ChangePurchaseOrderInput::class, read: false, status: 200, openapi: new Operation(tags: ['Procurement'], summary: 'Edit a purchase draft')), + new Post(name: ProcurementOperations::PLACE_ORDER, uriTemplate: '/organizations/{organizationId}/procurement/orders/{id}/order', output: PurchaseOrderOutput::class, processor: ProcurementProcessor::class, input: false, read: false, status: 200, openapi: new Operation(tags: ['Procurement'], summary: 'Place a purchase order')), + new Post(name: ProcurementOperations::CANCEL_REMAINING, uriTemplate: '/organizations/{organizationId}/procurement/orders/{id}/cancel-remaining', output: PurchaseOrderOutput::class, processor: ProcurementProcessor::class, input: false, read: false, status: 200, openapi: new Operation(tags: ['Procurement'], summary: 'Cancel only quantities not yet received')), + new GetCollection(name: ProcurementOperations::RECEIPTS, uriTemplate: '/organizations/{organizationId}/procurement/orders/{id}/receipts', output: ProcurementReceiptOutput::class, provider: ProcurementProvider::class, paginationEnabled: true, paginationClientItemsPerPage: true, paginationItemsPerPage: 30, paginationMaximumItemsPerPage: 100, openapi: new Operation(tags: ['Procurement'], summary: 'List retained physical receipts')), + new Post(name: ProcurementOperations::RECEIVE, uriTemplate: '/organizations/{organizationId}/procurement/orders/{id}/receipts', output: ProcurementReceiptOutput::class, processor: ProcurementProcessor::class, input: ReceivePurchaseOrderInput::class, read: false, status: 201, openapi: new Operation(tags: ['Procurement'], summary: 'Atomically record physical and stock receipt')), + new Get(name: ProcurementOperations::RECEIPT, uriTemplate: '/organizations/{organizationId}/procurement/receipts/{id}', output: ProcurementReceiptOutput::class, provider: ProcurementProvider::class, openapi: new Operation(tags: ['Procurement'], summary: 'Read a physical receipt')), + new Post(name: ProcurementOperations::INDIVIDUALIZE, uriTemplate: '/organizations/{organizationId}/procurement/receipts/{id}/individualize', output: ProcurementReceiptOutput::class, processor: ProcurementProcessor::class, input: IndividualizeReceiptInput::class, read: false, status: 200, openapi: new Operation(tags: ['Procurement'], summary: 'Create reserve equipment for received units')), + new Post(name: ProcurementOperations::RETURN, uriTemplate: '/organizations/{organizationId}/procurement/receipts/{id}/returns', output: ProcurementReceiptOutput::class, processor: ProcurementProcessor::class, input: ReturnProcurementReceiptInput::class, read: false, status: 200, openapi: new Operation(tags: ['Procurement'], summary: 'Record a motivated supply return')), + ], +)] +final class ProcurementResource +{ +} diff --git a/src/ServiceRequest/Application/Contract/ServiceRequestView.php b/src/ServiceRequest/Application/Contract/ServiceRequestView.php new file mode 100644 index 000000000..d70bde733 --- /dev/null +++ b/src/ServiceRequest/Application/Contract/ServiceRequestView.php @@ -0,0 +1,72 @@ +id, $request->organizationId, $request->equipmentId, $request->siteId, $request->title, $request->description, $request->priority, $request->status, $request->originInspectionId, $request->originNonConformityId, $request->qualificationNote, $request->decisionReason, $request->interventionId, $request->taskId, $request->requestedAt, $request->updatedAt, $request->qualifiedAt, $request->rejectedAt, $request->cancelledAt, $request->convertedAt, $request->revision, self::snapshot($request->targetSnapshot)); + } + + /** + * @param array $snapshot + * + * @return array{equipment:?array{id:string,name:?string,assetCode:?string,status:?string},site:?array{id:string,name:string},customer:?array{id:string,name:string}} + */ + private static function snapshot(array $snapshot): array + { + $equipment = is_array($snapshot['equipment'] ?? null) ? $snapshot['equipment'] : []; + $site = is_array($snapshot['site'] ?? null) ? $snapshot['site'] : []; + $customer = is_array($snapshot['customer'] ?? null) ? $snapshot['customer'] : []; + + return [ + 'equipment' => is_string($equipment['id'] ?? null) ? ['id' => $equipment['id'], 'name' => self::text($equipment['name'] ?? null), 'assetCode' => self::text($equipment['assetCode'] ?? null), 'status' => self::text($equipment['status'] ?? null)] : null, + 'site' => is_string($site['id'] ?? null) && is_string($site['name'] ?? null) ? ['id' => $site['id'], 'name' => $site['name']] : null, + 'customer' => is_string($customer['id'] ?? null) && is_string($customer['name'] ?? null) ? ['id' => $customer['id'], 'name' => $customer['name']] : null, + ]; + } + + private static function text(mixed $value): ?string + { + return is_string($value) ? $value : null; + } +} diff --git a/src/ServiceRequest/Application/Contract/Source/ServiceRequestOriginUnavailable.php b/src/ServiceRequest/Application/Contract/Source/ServiceRequestOriginUnavailable.php new file mode 100644 index 000000000..2dc778866 --- /dev/null +++ b/src/ServiceRequest/Application/Contract/Source/ServiceRequestOriginUnavailable.php @@ -0,0 +1,22 @@ + + */ + public function list(string $organizationId, ?string $status, ?string $equipmentId, ?string $siteId, string $search, int $offset, int $limit): array; + + public function count(string $organizationId, ?string $status, ?string $equipmentId, ?string $siteId, string $search): int; + + public function conversionReceiptForRequest(string $requestId, string $organizationId): ?ServiceRequestConversionReceipt; + + public function conversionReceiptByOperation(string $organizationId, string $clientOperationId): ?ServiceRequestConversionReceipt; + + public function saveConversionReceipt(ServiceRequestConversionReceipt $receipt): void; +} diff --git a/src/ServiceRequest/Application/Port/Outbound/ServiceRequestSiteTargetPort.php b/src/ServiceRequest/Application/Port/Outbound/ServiceRequestSiteTargetPort.php new file mode 100644 index 000000000..039780f01 --- /dev/null +++ b/src/ServiceRequest/Application/Port/Outbound/ServiceRequestSiteTargetPort.php @@ -0,0 +1,25 @@ +authorization->resolveAccess($actorId, $organizationId, $permission); + if ($decision->isOutsideScope()) { + throw ServiceRequestException::notFound(); + } + if (!$decision->isGranted()) { + throw ServiceRequestException::denied(); + } + } + + public function assertRevision(int $actualRevision, ?int $expectedRevision): void + { + if (null === $expectedRevision) { + throw new ServiceRequestException('service_request_precondition_required', 'If-Match is required for this mutation.'); + } + if ($actualRevision !== $expectedRevision) { + throw ServiceRequestException::stale(); + } + } +} diff --git a/src/ServiceRequest/Application/Service/ServiceRequestTargetGuard.php b/src/ServiceRequest/Application/Service/ServiceRequestTargetGuard.php new file mode 100644 index 000000000..33e92fc25 --- /dev/null +++ b/src/ServiceRequest/Application/Service/ServiceRequestTargetGuard.php @@ -0,0 +1,46 @@ +sites->lock($organizationId); + $equipment = null === $equipmentId ? null : $this->equipment->find($equipmentId, $organizationId); + if (null !== $equipmentId && null === $equipment) { + throw ServiceRequestException::invalid('The repair target is unavailable in this organization.'); + } + if (null !== $equipment && 'decommissioned' === $equipment->status) { + throw ServiceRequestException::transitionConflict('Retired equipment cannot receive new repair work.'); + } + if (null !== $siteId && null !== $equipment && null === $equipment->facilityId) { + throw ServiceRequestException::invalid('The equipment does not belong to the selected site.'); + } + $site = null === $siteId && null === $equipment?->facilityId ? null : $this->sites->find($organizationId, $siteId, $equipment?->facilityId); + if ((null !== $siteId || null !== $equipment?->facilityId) && null === $site) { + throw ServiceRequestException::invalid('The repair target is unavailable in this organization.'); + } + if (null !== $site && $site->archived) { + throw ServiceRequestException::transitionConflict('Archived sites cannot receive new repair work.'); + } + + return ['equipment' => null === $equipment ? null : ['id' => $equipment->id, 'name' => $equipment->name, 'assetCode' => $equipment->assetCode, 'status' => $equipment->status], 'site' => null === $site ? null : ['id' => $site->id, 'name' => $site->name], 'customer' => $site?->customer]; + } +} diff --git a/src/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestCommand.php b/src/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestCommand.php new file mode 100644 index 000000000..c4c5ec789 --- /dev/null +++ b/src/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestCommand.php @@ -0,0 +1,18 @@ + $changes + */ + public function __construct(public string $actorId, public string $organizationId, public string $requestId, public string $action, public ?int $expectedRevision, public array $changes = [], public ?string $note = null, public ?string $reason = null, public ?string $equipmentId = null) + { + } +} diff --git a/src/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestHandler.php b/src/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestHandler.php new file mode 100644 index 000000000..3e75f5f00 --- /dev/null +++ b/src/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestHandler.php @@ -0,0 +1,67 @@ +access->assertAccess($command->actorId, $command->organizationId, 'manage'); + + return $this->transactions->transactional(function () use ($command): ChangeServiceRequestResult { + $request = $this->requests->find($command->requestId, $command->organizationId, true) ?? throw ServiceRequestException::notFound(); + $this->access->assertRevision($request->revision, $command->expectedRevision); + $now = $this->clock->now(); + $changed = match ($command->action) { + 'patch' => $request->change($command->changes, $now), + 'qualify' => $this->qualify($request, $command, $now), + 'reject' => $request->reject($command->reason ?? '', $now), + 'cancel' => $request->cancel($command->reason ?? '', $now), + default => throw ServiceRequestException::invalid('Unsupported request action.'), + }; + if ($changed !== $request) { + $this->requests->save($changed, $request->revision); + $this->events->dispatch(new ServiceRequestChangedEvent($changed->organizationId, $changed->id, $command->action, $changed->revision, $changed->updatedAt)); + } + + return new ChangeServiceRequestResult(ServiceRequestView::fromRequest($changed)); + }); + } + + private function qualify(ServiceRequest $request, ChangeServiceRequestCommand $command, DateTimeImmutable $now): ServiceRequest + { + if (null !== $request->equipmentId && null !== $command->equipmentId && $command->equipmentId !== $request->equipmentId) { + throw ServiceRequestException::transitionConflict('The selected equipment cannot replace this request target.'); + } + $equipmentId = $request->equipmentId ?? $command->equipmentId; + if (null === $equipmentId) { + throw ServiceRequestException::invalid('Select the equipment requiring repair before qualification.'); + } + $current = $this->targets->snapshot($request->organizationId, $equipmentId, $request->siteId); + $this->origins->assertMatches($request->organizationId, $equipmentId, $request->siteId, $request->originInspectionId, $request->originNonConformityId); + if (null === $request->equipmentId) { + $snapshot = $request->targetSnapshot; + $snapshot['equipment'] = $current['equipment']; + $request = $request->assignEquipment($equipmentId, $request->siteId, $snapshot, $now); + } + + return $request->qualify($command->note, $now); + } +} diff --git a/src/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestResult.php b/src/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestResult.php new file mode 100644 index 000000000..fa415b857 --- /dev/null +++ b/src/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestResult.php @@ -0,0 +1,16 @@ +access->assertAccess($command->actorId, $command->organizationId, 'manage'); + + return $this->transactions->transactional(function () use ($command): ConvertServiceRequestResult { + $request = $this->requests->find($command->requestId, $command->organizationId, true) ?? throw ServiceRequestException::notFound(); + if (null === $command->expectedRevision) { + throw new ServiceRequestException('service_request_precondition_required', 'If-Match is required for conversion.'); + } + if ($command->expectedRevision < 1) { + throw ServiceRequestException::stale(); + } + $operationId = $this->uuid($command->clientOperationId); + $existingInterventionId = null === $command->existingInterventionId ? null : $this->uuid($command->existingInterventionId); + $existingTaskId = null === $command->existingTaskId ? null : $this->uuid($command->existingTaskId); + if (null !== $existingTaskId && null === $existingInterventionId) { + throw ServiceRequestException::invalid('An existing task requires its intervention identifier.'); + } + $hash = hash('sha256', json_encode(['requestId' => $request->id, 'existingInterventionId' => $existingInterventionId, 'existingTaskId' => $existingTaskId], JSON_THROW_ON_ERROR)); + $receipt = $this->requests->conversionReceiptForRequest($request->id, $request->organizationId); + if (null !== $receipt) { + if ($receipt->clientOperationId !== $operationId || $receipt->payloadHash !== $hash || 'converted' !== $request->status || $request->interventionId !== $receipt->interventionId || $request->taskId !== $receipt->taskId) { + throw ServiceRequestException::operationConflict(); + } + + return new ConvertServiceRequestResult(ServiceRequestView::fromRequest($request)); + } + if (null !== $this->requests->conversionReceiptByOperation($request->organizationId, $operationId)) { + throw ServiceRequestException::operationConflict(); + } + $this->access->assertRevision($request->revision, $command->expectedRevision); + if ('qualified' !== $request->status || null === $request->equipmentId) { + throw ServiceRequestException::transitionConflict('Only qualified equipment repair requests can be converted.'); + } + $selection = new ServiceRequestWorkRequest($request->organizationId, $request->id, $command->actorId, $request->equipmentId, $request->siteId, $request->title, $request->description, $existingInterventionId, $existingTaskId, $operationId); + $this->work->reserveSelection($selection); + $this->targets->snapshot($request->organizationId, $request->equipmentId, $request->siteId); + $link = $this->work->createOrLink($selection); + $now = $this->clock->now(); + $converted = $request->convert($link->interventionId, $link->taskId, $now); + $this->requests->save($converted, $request->revision); + $this->requests->saveConversionReceipt(new ServiceRequestConversionReceipt($converted->organizationId, $converted->id, $operationId, $hash, $link->interventionId, $link->taskId, $now)); + $this->events->dispatch(new ServiceRequestChangedEvent($converted->organizationId, $converted->id, 'converted', $converted->revision, $converted->updatedAt)); + + return new ConvertServiceRequestResult(ServiceRequestView::fromRequest($converted)); + }); + } + + private function uuid(string $value): string + { + try { + new Uuid($value); + } catch (InvalidValueException) { + throw ServiceRequestException::invalid('Invalid conversion identifier.'); + } + + return strtolower($value); + } +} diff --git a/src/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestResult.php b/src/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestResult.php new file mode 100644 index 000000000..f7401d634 --- /dev/null +++ b/src/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestResult.php @@ -0,0 +1,16 @@ +access->assertAccess($command->actorId, $command->organizationId, 'create'); + + return $this->transactions->transactional(function () use ($command): CreateServiceRequestResult { + $snapshot = $this->targets->snapshot($command->organizationId, $command->equipmentId, $command->siteId); + $siteId = $snapshot['site']['id'] ?? null; + $this->origins->assertMatches($command->organizationId, $command->equipmentId, $siteId, $command->originInspectionId, $command->originNonConformityId); + $request = ServiceRequest::create($this->ids->generate(), $command->organizationId, $command->equipmentId, $siteId, $snapshot, $command->title, $command->description, $this->clock->now(), $command->priority, $command->originInspectionId, $command->originNonConformityId); + $this->requests->save($request); + $this->events->dispatch(new ServiceRequestChangedEvent($request->organizationId, $request->id, 'requested', $request->revision, $request->updatedAt)); + + return new CreateServiceRequestResult(ServiceRequestView::fromRequest($request)); + }); + } +} diff --git a/src/ServiceRequest/Application/UseCase/Command/CreateServiceRequest/CreateServiceRequestResult.php b/src/ServiceRequest/Application/UseCase/Command/CreateServiceRequest/CreateServiceRequestResult.php new file mode 100644 index 000000000..2b12b7dbd --- /dev/null +++ b/src/ServiceRequest/Application/UseCase/Command/CreateServiceRequest/CreateServiceRequestResult.php @@ -0,0 +1,16 @@ +access->assertAccess($query->actorId, $query->organizationId, 'read'); + $request = $this->requests->find($query->requestId, $query->organizationId) ?? throw ServiceRequestException::notFound(); + + return new GetServiceRequestResult(ServiceRequestView::fromRequest($request)); + } +} diff --git a/src/ServiceRequest/Application/UseCase/Query/GetServiceRequest/GetServiceRequestQuery.php b/src/ServiceRequest/Application/UseCase/Query/GetServiceRequest/GetServiceRequestQuery.php new file mode 100644 index 000000000..1daf4d870 --- /dev/null +++ b/src/ServiceRequest/Application/UseCase/Query/GetServiceRequest/GetServiceRequestQuery.php @@ -0,0 +1,15 @@ +access->assertAccess($query->actorId, $query->organizationId, 'read'); + if ($query->page < 1 || $query->itemsPerPage < 1 || $query->itemsPerPage > 100 || mb_strlen($query->search) > 160 || (null !== $query->status && !in_array($query->status, ['requested', 'qualified', 'rejected', 'cancelled', 'converted'], true))) { + throw ServiceRequestException::invalid('Invalid request pagination or filters.'); + } + + try { + foreach ([$query->equipmentId, $query->siteId] as $id) { + if (null !== $id) { + new Uuid($id); + } + } + } catch (InvalidValueException) { + throw ServiceRequestException::invalid('Invalid target filter.'); + } + $search = trim($query->search); + $items = $this->requests->list($query->organizationId, $query->status, $query->equipmentId, $query->siteId, $search, ($query->page - 1) * $query->itemsPerPage, $query->itemsPerPage); + + return new ListServiceRequestsResult(array_map(ServiceRequestView::fromRequest(...), $items), $this->requests->count($query->organizationId, $query->status, $query->equipmentId, $query->siteId, $search), $query->page, $query->itemsPerPage); + } +} diff --git a/src/ServiceRequest/Application/UseCase/Query/ListServiceRequests/ListServiceRequestsQuery.php b/src/ServiceRequest/Application/UseCase/Query/ListServiceRequests/ListServiceRequestsQuery.php new file mode 100644 index 000000000..b4eec1f84 --- /dev/null +++ b/src/ServiceRequest/Application/UseCase/Query/ListServiceRequests/ListServiceRequestsQuery.php @@ -0,0 +1,15 @@ + $items + */ + public function __construct(public array $items, public int $total, public int $page, public int $itemsPerPage) + { + } +} diff --git a/src/ServiceRequest/Domain/Event/ServiceRequestChangedEvent.php b/src/ServiceRequest/Domain/Event/ServiceRequestChangedEvent.php new file mode 100644 index 000000000..f5106fbf0 --- /dev/null +++ b/src/ServiceRequest/Domain/Event/ServiceRequestChangedEvent.php @@ -0,0 +1,15 @@ + + */ +final class ServiceRequestException extends RuntimeException +{ + public function __construct(public readonly string $reason, string $message) + { + parent::__construct($message); + } + + public static function invalid(string $message): self + { + return new self('service_request_invalid', $message); + } + + public static function notFound(): self + { + return new self('service_request_not_found', 'Service request not found.'); + } + + public static function denied(): self + { + return new self('service_request_access_denied', 'Missing service request permission.'); + } + + public static function stale(): self + { + return new self('service_request_revision_stale', 'The service request revision is stale.'); + } + + public static function transitionConflict(string $message = 'This service request cannot perform this transition.'): self + { + return new self('service_request_transition_conflict', $message); + } + + public static function operationConflict(string $message = 'This conversion operation already belongs to another request or payload.'): self + { + return new self('service_request_operation_conflict', $message); + } +} diff --git a/src/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequest.php b/src/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequest.php new file mode 100644 index 000000000..6b721392d --- /dev/null +++ b/src/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequest.php @@ -0,0 +1,272 @@ + + */ +final readonly class ServiceRequest +{ + private const array PRIORITIES = ['low', 'normal', 'high', 'urgent']; + + /** + * @param array $targetSnapshot retained owner-published target identity + */ + private function __construct( + public string $id, + public string $organizationId, + public ?string $equipmentId, + public ?string $siteId, + public array $targetSnapshot, + public string $title, + public string $description, + public string $priority, + public ?string $originInspectionId, + public ?string $originNonConformityId, + public string $status, + public int $revision, + public DateTimeImmutable $requestedAt, + public DateTimeImmutable $updatedAt, + public ?DateTimeImmutable $qualifiedAt = null, + public ?DateTimeImmutable $rejectedAt = null, + public ?DateTimeImmutable $cancelledAt = null, + public ?DateTimeImmutable $convertedAt = null, + public ?string $decisionReason = null, + public ?string $qualificationNote = null, + public ?string $interventionId = null, + public ?string $taskId = null, + ) { + } + + /** + * @param array $targetSnapshot retained equipment, site and internal customer identity + */ + public static function create(string $id, string $organizationId, ?string $equipmentId, ?string $siteId, array $targetSnapshot, string $title, string $description, DateTimeImmutable $now, string $priority = 'normal', ?string $originInspectionId = null, ?string $originNonConformityId = null): self + { + self::uuid($id); + self::uuid($organizationId); + foreach ([$equipmentId, $siteId, $originInspectionId, $originNonConformityId] as $reference) { + if (null !== $reference) { + self::uuid($reference); + } + } + if (null === $equipmentId && null === $siteId) { + throw ServiceRequestException::invalid('A service request must target an equipment or a site.'); + } + $targetSnapshot = self::snapshot($targetSnapshot); + + return new self($id, $organizationId, $equipmentId, $siteId, $targetSnapshot, self::requiredText($title, 160, 'Title'), self::requiredText($description, 10000, 'Description'), self::priority($priority), $originInspectionId, $originNonConformityId, 'requested', 1, $now, $now); + } + + /** + * @param array $targetSnapshot persisted retained target identity + */ + public static function reconstitute(string $id, string $organizationId, ?string $equipmentId, ?string $siteId, array $targetSnapshot, string $title, string $description, string $priority, ?string $originInspectionId, ?string $originNonConformityId, string $status, int $revision, DateTimeImmutable $requestedAt, DateTimeImmutable $updatedAt, ?DateTimeImmutable $qualifiedAt = null, ?DateTimeImmutable $rejectedAt = null, ?DateTimeImmutable $cancelledAt = null, ?DateTimeImmutable $convertedAt = null, ?string $decisionReason = null, ?string $qualificationNote = null, ?string $interventionId = null, ?string $taskId = null): self + { + return new self($id, $organizationId, $equipmentId, $siteId, self::snapshot($targetSnapshot), $title, $description, $priority, $originInspectionId, $originNonConformityId, $status, $revision, $requestedAt, $updatedAt, $qualifiedAt, $rejectedAt, $cancelledAt, $convertedAt, $decisionReason, $qualificationNote, $interventionId, $taskId); + } + + /** + * @param array $changes editable request description fields + */ + public function change(array $changes, DateTimeImmutable $now): self + { + $this->assertState(['requested'], $now); + foreach ($changes as $field => $value) { + if (!in_array($field, ['title', 'description', 'priority'], true) || !is_string($value)) { + throw ServiceRequestException::invalid('Only title, description and priority can be changed.'); + } + } + /** @var array $changes */ + $title = array_key_exists('title', $changes) ? self::requiredText($changes['title'], 160, 'Title') : $this->title; + $description = array_key_exists('description', $changes) ? self::requiredText($changes['description'], 10000, 'Description') : $this->description; + $priority = array_key_exists('priority', $changes) ? self::priority($changes['priority']) : $this->priority; + if ($title === $this->title && $description === $this->description && $priority === $this->priority) { + return $this; + } + + return new self($this->id, $this->organizationId, $this->equipmentId, $this->siteId, $this->targetSnapshot, $title, $description, $priority, $this->originInspectionId, $this->originNonConformityId, $this->status, $this->revision + 1, $this->requestedAt, $now, $this->qualifiedAt, $this->rejectedAt, $this->cancelledAt, $this->convertedAt, $this->decisionReason, $this->qualificationNote, $this->interventionId, $this->taskId); + } + + public function qualify(?string $note, DateTimeImmutable $now): self + { + $this->assertState(['requested'], $now); + if (null === $this->equipmentId) { + throw ServiceRequestException::invalid('Select the equipment requiring maintenance before qualifying the request.'); + } + + return $this->transition('qualified', $now, qualificationNote: self::optionalText($note, 10000, 'Qualification note')); + } + + /** + * @param array $targetSnapshot resolved equipment identity within the original site + */ + public function assignEquipment(string $equipmentId, ?string $siteId, array $targetSnapshot, DateTimeImmutable $now): self + { + $this->assertState(['requested'], $now); + if (null !== $this->equipmentId || (null !== $this->siteId && $siteId !== $this->siteId)) { + throw ServiceRequestException::transitionConflict('The request target cannot be replaced or moved to another site.'); + } + self::uuid($equipmentId); + if (null !== $siteId) { + self::uuid($siteId); + } + + return new self($this->id, $this->organizationId, $equipmentId, $siteId, self::snapshot($targetSnapshot), $this->title, $this->description, $this->priority, $this->originInspectionId, $this->originNonConformityId, $this->status, $this->revision + 1, $this->requestedAt, $now, $this->qualifiedAt, $this->rejectedAt, $this->cancelledAt, $this->convertedAt, $this->decisionReason, $this->qualificationNote, $this->interventionId, $this->taskId); + } + + public function reject(string $reason, DateTimeImmutable $now): self + { + $this->assertEditable($now); + + return $this->transition('rejected', $now, decisionReason: self::requiredText($reason, 2000, 'Rejection reason')); + } + + public function cancel(string $reason, DateTimeImmutable $now): self + { + $this->assertEditable($now); + + return $this->transition('cancelled', $now, decisionReason: self::requiredText($reason, 2000, 'Cancellation reason')); + } + + public function convert(string $interventionId, string $taskId, DateTimeImmutable $now): self + { + $this->assertState(['qualified'], $now); + self::uuid($interventionId); + self::uuid($taskId); + + return $this->transition('converted', $now, interventionId: $interventionId, taskId: $taskId); + } + + private function transition(string $status, DateTimeImmutable $now, ?string $decisionReason = null, ?string $qualificationNote = null, ?string $interventionId = null, ?string $taskId = null): self + { + return new self($this->id, $this->organizationId, $this->equipmentId, $this->siteId, $this->targetSnapshot, $this->title, $this->description, $this->priority, $this->originInspectionId, $this->originNonConformityId, $status, $this->revision + 1, $this->requestedAt, $now, 'qualified' === $status ? $now : $this->qualifiedAt, 'rejected' === $status ? $now : $this->rejectedAt, 'cancelled' === $status ? $now : $this->cancelledAt, 'converted' === $status ? $now : $this->convertedAt, $decisionReason ?? $this->decisionReason, $qualificationNote ?? $this->qualificationNote, $interventionId ?? $this->interventionId, $taskId ?? $this->taskId); + } + + private function assertEditable(DateTimeImmutable $now): void + { + $this->assertState(['requested', 'qualified'], $now); + } + + /** + * @param list $states allowed predecessor states + */ + private function assertState(array $states, DateTimeImmutable $now): void + { + if (!in_array($this->status, $states, true)) { + throw ServiceRequestException::transitionConflict(); + } + if ($now < $this->updatedAt) { + throw ServiceRequestException::invalid('The operation time cannot precede the last request update.'); + } + } + + private static function uuid(string $value): void + { + try { + new Uuid($value); + } catch (InvalidValueException) { + throw ServiceRequestException::invalid('Invalid service request identifier or reference.'); + } + } + + private static function priority(string $value): string + { + if (!in_array($value, self::PRIORITIES, true)) { + throw ServiceRequestException::invalid('Unsupported service request priority.'); + } + + return $value; + } + + private static function requiredText(string $value, int $maximum, string $field): string + { + $value = trim($value); + if ('' === $value || mb_strlen($value) > $maximum) { + throw ServiceRequestException::invalid($field . ' is required and must not exceed ' . $maximum . ' characters.'); + } + + return $value; + } + + private static function optionalText(?string $value, int $maximum, string $field): ?string + { + if (null === $value || '' === trim($value)) { + return null; + } + + return self::requiredText($value, $maximum, $field); + } + + /** + * @param array $snapshot retained JSON identity + * + * @return array isolated JSON values without mutable PHP references + */ + private static function snapshot(array $snapshot): array + { + foreach ($snapshot as $key => $value) { + if (!is_string($key)) { + throw ServiceRequestException::invalid('Target snapshot must be a JSON object.'); + } + self::snapshotValue($value, 1); + } + + try { + $json = json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); + if (strlen($json) > 65536) { + throw ServiceRequestException::invalid('Target snapshot is too large.'); + } + + /** @var array $isolated */ + $isolated = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + return $isolated; + } catch (JsonException) { + throw ServiceRequestException::invalid('Target snapshot must contain valid JSON values.'); + } + } + + private static function snapshotValue(mixed $value, int $depth): void + { + if ($depth > 16) { + throw ServiceRequestException::invalid('Target snapshot is too deeply nested.'); + } + if (is_array($value)) { + foreach ($value as $nested) { + self::snapshotValue($nested, $depth + 1); + } + } elseif (null !== $value && !is_string($value) && !is_int($value) && !is_float($value) && !is_bool($value)) { + throw ServiceRequestException::invalid('Target snapshot must contain immutable JSON values.'); + } + } +} diff --git a/src/ServiceRequest/Domain/ValueObject/ServiceRequestConversionReceipt.php b/src/ServiceRequest/Domain/ValueObject/ServiceRequestConversionReceipt.php new file mode 100644 index 000000000..917e0f877 --- /dev/null +++ b/src/ServiceRequest/Domain/ValueObject/ServiceRequestConversionReceipt.php @@ -0,0 +1,37 @@ + + */ +final readonly class ServiceRequestConversionReceipt +{ + public function __construct(public string $organizationId, public string $requestId, public string $clientOperationId, public string $payloadHash, public string $interventionId, public string $taskId, public DateTimeImmutable $createdAt) + { + try { + foreach ([$organizationId, $requestId, $clientOperationId, $interventionId, $taskId] as $id) { + new Uuid($id); + } + } catch (InvalidValueException) { + throw ServiceRequestException::invalid('Invalid service request conversion identifier.'); + } + if (1 !== preg_match('/^[a-f0-9]{64}$/D', $payloadHash)) { + throw ServiceRequestException::invalid('Invalid service request conversion fingerprint.'); + } + } +} diff --git a/src/ServiceRequest/Infrastructure/Persistence/Doctrine/Record/ServiceRequestConversionReceiptRecord.php b/src/ServiceRequest/Infrastructure/Persistence/Doctrine/Record/ServiceRequestConversionReceiptRecord.php new file mode 100644 index 000000000..1149b0933 --- /dev/null +++ b/src/ServiceRequest/Infrastructure/Persistence/Doctrine/Record/ServiceRequestConversionReceiptRecord.php @@ -0,0 +1,44 @@ + + */ +#[ORM\Entity] +#[ORM\Table(name: 'service_request_conversion_receipts')] +#[ORM\UniqueConstraint(name: 'uniq_service_request_conversion_operation', columns: ['organization_id', 'client_operation_id'])] +class ServiceRequestConversionReceiptRecord +{ + #[ORM\Id] + #[ORM\Column(name: 'request_id', type: 'string', length: 36)] + public string $requestId; + + #[ORM\Column(name: 'organization_id', type: 'string', length: 36)] + public string $organizationId; + + #[ORM\Column(name: 'client_operation_id', type: 'string', length: 36)] + public string $clientOperationId; + + #[ORM\Column(name: 'payload_hash', type: 'string', length: 64)] + public string $payloadHash; + + #[ORM\Column(name: 'intervention_id', type: 'string', length: 36)] + public string $interventionId; + + #[ORM\Column(name: 'task_id', type: 'string', length: 36)] + public string $taskId; + + #[ORM\Column(name: 'created_at', type: 'datetime_immutable')] + public DateTimeImmutable $createdAt; +} diff --git a/src/ServiceRequest/Infrastructure/Persistence/Doctrine/Record/ServiceRequestRecord.php b/src/ServiceRequest/Infrastructure/Persistence/Doctrine/Record/ServiceRequestRecord.php new file mode 100644 index 000000000..1fee7d5b4 --- /dev/null +++ b/src/ServiceRequest/Infrastructure/Persistence/Doctrine/Record/ServiceRequestRecord.php @@ -0,0 +1,95 @@ + + */ +#[ORM\Entity] +#[ORM\Table(name: 'service_requests')] +#[ORM\Index(name: 'idx_service_request_org_requested', columns: ['organization_id', 'requested_at'])] +#[ORM\Index(name: 'idx_service_request_org_status', columns: ['organization_id', 'status', 'requested_at'])] +#[ORM\Index(name: 'idx_service_request_org_equipment', columns: ['organization_id', 'equipment_id'])] +#[ORM\Index(name: 'idx_service_request_org_site', columns: ['organization_id', 'site_id'])] +class ServiceRequestRecord +{ + #[ORM\Id] + #[ORM\Column(type: 'string', length: 36)] + public string $id; + + #[ORM\Column(name: 'organization_id', type: 'string', length: 36)] + public string $organizationId; + + #[ORM\Column(name: 'equipment_id', type: 'string', length: 36, nullable: true)] + public ?string $equipmentId = null; + + #[ORM\Column(name: 'site_id', type: 'string', length: 36, nullable: true)] + public ?string $siteId = null; + + /** + * @var array retained target, site and internal customer identity + */ + #[ORM\Column(name: 'target_snapshot', type: 'json', options: ['jsonb' => true])] + public array $targetSnapshot = []; + + #[ORM\Column(type: 'string', length: 160)] + public string $title; + + #[ORM\Column(type: 'text')] + public string $description; + + #[ORM\Column(type: 'string', length: 16, options: ['default' => 'normal'])] + public string $priority = 'normal'; + + #[ORM\Column(name: 'origin_inspection_id', type: 'string', length: 36, nullable: true)] + public ?string $originInspectionId = null; + + #[ORM\Column(name: 'origin_non_conformity_id', type: 'string', length: 36, nullable: true)] + public ?string $originNonConformityId = null; + + #[ORM\Column(type: 'string', length: 16, options: ['default' => 'requested'])] + public string $status = 'requested'; + + #[ORM\Column(type: 'integer', options: ['default' => 1])] + public int $revision = 1; + + #[ORM\Column(name: 'requested_at', type: 'datetime_immutable')] + public DateTimeImmutable $requestedAt; + + #[ORM\Column(name: 'updated_at', type: 'datetime_immutable')] + public DateTimeImmutable $updatedAt; + + #[ORM\Column(name: 'qualified_at', type: 'datetime_immutable', nullable: true)] + public ?DateTimeImmutable $qualifiedAt = null; + + #[ORM\Column(name: 'rejected_at', type: 'datetime_immutable', nullable: true)] + public ?DateTimeImmutable $rejectedAt = null; + + #[ORM\Column(name: 'cancelled_at', type: 'datetime_immutable', nullable: true)] + public ?DateTimeImmutable $cancelledAt = null; + + #[ORM\Column(name: 'converted_at', type: 'datetime_immutable', nullable: true)] + public ?DateTimeImmutable $convertedAt = null; + + #[ORM\Column(name: 'decision_reason', type: 'text', nullable: true)] + public ?string $decisionReason = null; + + #[ORM\Column(name: 'qualification_note', type: 'text', nullable: true)] + public ?string $qualificationNote = null; + + #[ORM\Column(name: 'intervention_id', type: 'string', length: 36, nullable: true)] + public ?string $interventionId = null; + + #[ORM\Column(name: 'task_id', type: 'string', length: 36, nullable: true)] + public ?string $taskId = null; +} diff --git a/src/ServiceRequest/Infrastructure/Persistence/Doctrine/Repository/ServiceRequestRepository.php b/src/ServiceRequest/Infrastructure/Persistence/Doctrine/Repository/ServiceRequestRepository.php new file mode 100644 index 000000000..c462302fa --- /dev/null +++ b/src/ServiceRequest/Infrastructure/Persistence/Doctrine/Repository/ServiceRequestRepository.php @@ -0,0 +1,219 @@ + + */ +final readonly class ServiceRequestRepository implements ServiceRequestRepositoryPort +{ + public function __construct(private EntityManagerInterface $entityManager) + { + } + + public function find(string $id, string $organizationId, bool $lock = false): ?ServiceRequest + { + $connection = $this->entityManager->getConnection(); + if ($lock && !$connection->isTransactionActive()) { + throw new LogicException('A service request row lock requires an active main transaction.'); + } + $row = $connection->fetchAssociative('SELECT * FROM service_requests WHERE id = :id AND organization_id = :organization' . ($lock ? ' FOR UPDATE' : ''), ['id' => $id, 'organization' => $organizationId]); + + return false === $row ? null : $this->map($row); + } + + public function save(ServiceRequest $request, ?int $expectedRevision = null): void + { + $connection = $this->entityManager->getConnection(); + $values = [ + 'organization_id' => $request->organizationId, + 'equipment_id' => $request->equipmentId, + 'site_id' => $request->siteId, + 'target_snapshot' => json_encode($request->targetSnapshot, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION), + 'title' => $request->title, + 'description' => $request->description, + 'priority' => $request->priority, + 'origin_inspection_id' => $request->originInspectionId, + 'origin_non_conformity_id' => $request->originNonConformityId, + 'status' => $request->status, + 'revision' => $request->revision, + 'requested_at' => self::format($request->requestedAt), + 'updated_at' => self::format($request->updatedAt), + 'qualified_at' => self::format($request->qualifiedAt), + 'rejected_at' => self::format($request->rejectedAt), + 'cancelled_at' => self::format($request->cancelledAt), + 'converted_at' => self::format($request->convertedAt), + 'decision_reason' => $request->decisionReason, + 'qualification_note' => $request->qualificationNote, + 'intervention_id' => $request->interventionId, + 'task_id' => $request->taskId, + ]; + + try { + if (null === $expectedRevision) { + $connection->insert('service_requests', ['id' => $request->id] + $values); + } elseif (1 !== $connection->update('service_requests', $values, ['id' => $request->id, 'organization_id' => $request->organizationId, 'revision' => $expectedRevision])) { + throw ServiceRequestException::stale(); + } + } catch (UniqueConstraintViolationException) { + throw ServiceRequestException::operationConflict('A service request already exists with this identifier.'); + } + } + + /** + * @return list + */ + public function list(string $organizationId, ?string $status, ?string $equipmentId, ?string $siteId, string $search, int $offset, int $limit): array + { + $criteria = self::criteria($organizationId, $status, $equipmentId, $siteId, $search); + $rows = $this->entityManager->getConnection()->fetchAllAssociative('SELECT * FROM service_requests WHERE ' . $criteria['sql'] . ' ORDER BY requested_at DESC, id ASC LIMIT :limit OFFSET :offset', $criteria['parameters'] + ['limit' => $limit, 'offset' => $offset], ['limit' => ParameterType::INTEGER, 'offset' => ParameterType::INTEGER]); + + return array_map($this->map(...), $rows); + } + + public function count(string $organizationId, ?string $status, ?string $equipmentId, ?string $siteId, string $search): int + { + $criteria = self::criteria($organizationId, $status, $equipmentId, $siteId, $search); + /** @var int|string $count */ + $count = $this->entityManager->getConnection()->fetchOne('SELECT COUNT(*) FROM service_requests WHERE ' . $criteria['sql'], $criteria['parameters']); + + return (int) $count; + } + + public function conversionReceiptForRequest(string $requestId, string $organizationId): ?ServiceRequestConversionReceipt + { + $row = $this->entityManager->getConnection()->fetchAssociative('SELECT * FROM service_request_conversion_receipts WHERE request_id = :request AND organization_id = :organization', ['request' => $requestId, 'organization' => $organizationId]); + + return false === $row ? null : self::receipt($row); + } + + public function conversionReceiptByOperation(string $organizationId, string $clientOperationId): ?ServiceRequestConversionReceipt + { + $row = $this->entityManager->getConnection()->fetchAssociative('SELECT * FROM service_request_conversion_receipts WHERE organization_id = :organization AND client_operation_id = :operation', ['organization' => $organizationId, 'operation' => $clientOperationId]); + + return false === $row ? null : self::receipt($row); + } + + public function saveConversionReceipt(ServiceRequestConversionReceipt $receipt): void + { + try { + $inserted = $this->entityManager->getConnection()->executeStatement("INSERT INTO service_request_conversion_receipts (request_id, organization_id, client_operation_id, payload_hash, intervention_id, task_id, created_at) SELECT id, organization_id, :operation, :hash, :intervention, :task, :created FROM service_requests WHERE id = :request AND organization_id = :organization AND status = 'converted' AND intervention_id = :intervention AND task_id = :task", [ + 'operation' => $receipt->clientOperationId, + 'hash' => $receipt->payloadHash, + 'intervention' => $receipt->interventionId, + 'task' => $receipt->taskId, + 'created' => self::format($receipt->createdAt), + 'request' => $receipt->requestId, + 'organization' => $receipt->organizationId, + ]); + if (1 !== $inserted) { + throw ServiceRequestException::operationConflict('A conversion receipt must match its converted request and organization.'); + } + } catch (UniqueConstraintViolationException) { + throw ServiceRequestException::operationConflict(); + } + } + + /** + * @return array{sql:string,parameters:array} + */ + private static function criteria(string $organizationId, ?string $status, ?string $equipmentId, ?string $siteId, string $search): array + { + $sql = 'organization_id = :organization'; + $parameters = ['organization' => $organizationId]; + foreach (['status' => $status, 'equipment_id' => $equipmentId, 'site_id' => $siteId] as $column => $value) { + if (null !== $value) { + $sql .= ' AND ' . $column . ' = :' . $column; + $parameters[$column] = $value; + } + } + $search = trim($search); + if ('' !== $search) { + $sql .= " AND (title ILIKE :search ESCAPE '\\' OR description ILIKE :search ESCAPE '\\')"; + $parameters['search'] = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $search) . '%'; + } + + return ['sql' => $sql, 'parameters' => $parameters]; + } + + /** + * @param array $row persisted request + */ + private function map(array $row): ServiceRequest + { + /** @var array{id:string,organization_id:string,equipment_id:?string,site_id:?string,target_snapshot:string,title:string,description:string,priority:string,origin_inspection_id:?string,origin_non_conformity_id:?string,status:string,revision:int|string,requested_at:string,updated_at:string,qualified_at:?string,rejected_at:?string,cancelled_at:?string,converted_at:?string,decision_reason:?string,qualification_note:?string,intervention_id:?string,task_id:?string} $row */ + /** @var array $snapshot */ + $snapshot = json_decode($row['target_snapshot'], true, 512, JSON_THROW_ON_ERROR); + + return ServiceRequest::reconstitute( + id: $row['id'], + organizationId: $row['organization_id'], + equipmentId: $row['equipment_id'], + siteId: $row['site_id'], + targetSnapshot: $snapshot, + title: $row['title'], + description: $row['description'], + priority: $row['priority'], + originInspectionId: $row['origin_inspection_id'], + originNonConformityId: $row['origin_non_conformity_id'], + status: $row['status'], + revision: (int) $row['revision'], + requestedAt: new DateTimeImmutable($row['requested_at'], new DateTimeZone('UTC')), + updatedAt: new DateTimeImmutable($row['updated_at'], new DateTimeZone('UTC')), + qualifiedAt: self::date($row['qualified_at']), + rejectedAt: self::date($row['rejected_at']), + cancelledAt: self::date($row['cancelled_at']), + convertedAt: self::date($row['converted_at']), + decisionReason: $row['decision_reason'], + qualificationNote: $row['qualification_note'], + interventionId: $row['intervention_id'], + taskId: $row['task_id'], + ); + } + + /** + * @param array $row persisted conversion receipt + */ + private static function receipt(array $row): ServiceRequestConversionReceipt + { + /** @var array{organization_id:string,request_id:string,client_operation_id:string,payload_hash:string,intervention_id:string,task_id:string,created_at:string} $row */ + return new ServiceRequestConversionReceipt($row['organization_id'], $row['request_id'], $row['client_operation_id'], $row['payload_hash'], $row['intervention_id'], $row['task_id'], new DateTimeImmutable($row['created_at'], new DateTimeZone('UTC'))); + } + + private static function format(?DateTimeImmutable $date): ?string + { + return $date?->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'); + } + + private static function date(?string $date): ?DateTimeImmutable + { + return null === $date ? null : new DateTimeImmutable($date, new DateTimeZone('UTC')); + } +} diff --git a/src/ServiceRequest/MODULE.md b/src/ServiceRequest/MODULE.md new file mode 100644 index 000000000..4448c7847 --- /dev/null +++ b/src/ServiceRequest/MODULE.md @@ -0,0 +1,141 @@ +# ServiceRequest Module + +## Overview + +ServiceRequest owns internal repair needs and their explicit qualification before +corrective work. It belongs to `main`. Requests can initially name an equipment or +an active root site; site-only requests select an equipment during qualification. +Customers remain internal organization records, without a portal or shared rights. + +## API Endpoints + +All routes require an authenticated member. An unentitled member receives 403; +an outsider, foreign request or missing request receives the same 404. + +| Method | Route | Permission | Result | +| --- | --- | --- | --- | +| GET | `/api/organizations/{organizationId}/service-requests` | `organization.service_requests.read` | Paginated requests | +| GET | `/api/organizations/{organizationId}/service-requests/{id}` | `organization.service_requests.read` | Retained request dossier | +| POST | `/api/organizations/{organizationId}/service-requests` | `organization.service_requests.create` | Requested (201) | +| PATCH | `/api/organizations/{organizationId}/service-requests/{id}` | `organization.service_requests.manage` | Edit requested description (200) | +| POST | `/api/organizations/{organizationId}/service-requests/{id}/qualify` | `organization.service_requests.manage` | Qualified (200) | +| POST | `/api/organizations/{organizationId}/service-requests/{id}/reject` | `organization.service_requests.manage` | Rejected (200) | +| POST | `/api/organizations/{organizationId}/service-requests/{id}/cancel` | `organization.service_requests.manage` | Cancelled (200) | +| POST | `/api/organizations/{organizationId}/service-requests/{id}/convert` | `organization.service_requests.manage` and Intervention planning | Converted (200) | + +Create accepts nullable `equipmentId`/`siteId` with at least one target, required +`title` (1–160 characters), `description` (1–10000), `priority` +(`low`, `normal`, `high`, `urgent`; default `normal`), optional +`originInspectionId`/`originNonConformityId`. Equipment ownership and publication, +root-site ancestry and any inspection origin are verified through owner ports. +Equipment derives its root site; an explicit site must match. Published reserve +equipment can be requested without a site. Retired equipment and archived +ancestors cannot receive new repair work. + +PATCH accepts only `title`, `description`, `priority`. Qualification accepts +optional `note` (at most 10000) and `equipmentId`; equipment is required when a +site-only request is qualified, must belong to its original site, and cannot +replace an already selected equipment. Reject/cancel require a motivated `reason` +(1–2000). Convert accepts UUID `clientOperationId`, optional +`existingInterventionId`/`existingTaskId`; a task requires its intervention. + +Every mutation after create requires `If-Match: "revision-N"`. Missing headers +produce 428, stale or malformed headers 412. A matching conversion receipt accepts +the original positive revision after a lost response, while other writes compare +the current locked revision. Qualification that selects an equipment records both +assignment and qualification, incrementing the revision twice. + +All actions return the same output: identifiers, target references, title, +description, priority, status, optional origins, qualification note, decision +reason, linked intervention/task, timestamps for each lifecycle decision, +`revision` and `targetSnapshot`. The snapshot contains only equipment identity +(`id`, nullable `name`/`assetCode`/`status`), site (`id`, `name`) and customer +(`id`, `name`). Customer contacts and other private fields are never returned. + +Collection filters are `status`, `equipmentId`, `siteId`, `search` (title and +description, at most 160), `page=1`, `itemsPerPage=30` (1–100). Totals use the same +scope and filters. Requested date descending and id ascending define stable order. + +## Flows + +Lifecycle is `requested → qualified → converted`. Requested or qualified requests +can be explicitly rejected or cancelled. Terminal requests keep their identity, +target, timestamps and decisions; they cannot be edited or converted again. +Qualification creates no work. Current targets and origin evidence are rechecked +before qualification, and current target availability before initial conversion. +Historical reads and committed conversion replay preserve their original facts. + +Conversion locks the request on main, validates its explicit selection and invokes +Intervention's public work port within the same transaction. It creates a corrective +draft with a repair task, or links explicitly selected compatible work. Without a +task selection, an existing corrective intervention reuses its single open repair +for the same equipment, adds one if absent, or refuses an ambiguous selection. +Completed tasks and submitted, published or abandoned interventions cannot acquire +new repair requests. Existing proofs and execution results are preserved. + +Intervention requires `organization.interventions.plan`; adding discovered work +outside draft additionally follows its execution permission and participant rules. +Request permissions never grant intervention or customer rights implicitly. + +One immutable receipt belongs to a request; `(organizationId, clientOperationId)` +is unique. Its hash contains the request id and canonicalized explicit work +selection. Matching replay returns the original link without additional work, +events or target checks. A changed selection, another key for a converted request, +or a reused key on another request produces 409. Request, work, receipt and +transactional outbox event commit together. A receipt collision rolls back the +second conversion's work. Existing intervention selection is reserved before site +hierarchy, equipment and ancestor row locks, following publication's parent-first +order. The reservation writes no work and is skipped for a committed receipt replay. +Target scope stays consistent while the conversion commits. + +## Architecture + +Domain owns immutable lifecycle rules, snapshot values and receipts. Application +handlers are the single business entry points for permissions, target validation, +revision checks and transaction coordination. Presentation only translates DTOs, +commands, queries and errors. PostgreSQL persistence uses the explicit main +manager, scoped reads, row locks, compare-and-swap updates and receipt uniqueness. + +Equipment implements `ServiceRequestEquipmentTargetPort`; Facility implements +`ServiceRequestSiteTargetPort` using its published hierarchy and Customer's public +lookup. Inspection implements `ServiceRequestOriginPort`. Intervention implements +`ServiceRequestWorkPort` through its canonical workflow and work-item writers. +No ServiceRequest source imports a sibling persistence record or domain class. +`ServiceRequestChangedEvent` carries identifiers, change and revision without +customer contacts or request descriptions. + +## Configuration + +`config/modules/service_request.yaml` registers main repositories, public target +aliases, guards, the named main transaction manager, bus handlers and API services. +The owning Inspection and Intervention modules register their origin/work bridges. +Main Doctrine mapping and API Platform discovery include ServiceRequest. +Additive migration `Version20261006110000` creates requests and receipts with UTC +`TIMESTAMP(0) WITHOUT TIME ZONE`, immutable date metadata, scope indexes and +target, lifecycle, priority and conversion constraints. It preserves V1 data, +routes, QR identities and offline queues. + +## Testing + +Domain tests cover lifecycle, immutable targets, text bounds, valid identities, +chronology and exact receipt identity. PostgreSQL repository tests exercise scoped +filters, stable pagination, row locking, revisions, uniqueness and atomic rollback. +Handler tests cover denied access before persistence, explicit qualification, +retained site/customer identity, matching and conflicting replay, collisions and +transactional work failure. HTTP PostgreSQL tests verify 403/404/422/409/412/428, +site-only qualification, reserve equipment, draft/foreign/retired/archive refusals, +minimal snapshots and real corrective conversion followed by lost-response replay. +Intervention's bridge tests independently verify task reuse and proof preservation. + +## Error Codes + +| Code | HTTP | Meaning | +| --- | --- | --- | +| `service_request_not_found` | 404 | Missing request or organization outside scope | +| `service_request_access_denied` | 403 | Member lacks the required request permission | +| `service_request_invalid` | 422 | Invalid fields or target | +| `service_request_origin_unavailable` | 422 | Unknown, foreign, unpublished or mismatched origin proof | +| `service_request_transition_conflict` | 409 | Lifecycle or active-target conflict | +| `service_request_operation_conflict` | 409 | Conversion key or selection collision | +| `service_request_revision_stale` | 412 | Incorrect revision | +| `service_request_precondition_required` | 428 | Missing If-Match | diff --git a/src/ServiceRequest/Presentation/Api/Dto/Input/ConvertServiceRequestInput.php b/src/ServiceRequest/Presentation/Api/Dto/Input/ConvertServiceRequestInput.php new file mode 100644 index 000000000..e2cfe9f8b --- /dev/null +++ b/src/ServiceRequest/Presentation/Api/Dto/Input/ConvertServiceRequestInput.php @@ -0,0 +1,34 @@ + null, 'site' => null, 'customer' => null]; + + public static function fromView(ServiceRequestView $view): self + { + $output = new self(); + $output->id = $view->id; + $output->organizationId = $view->organizationId; + $output->equipmentId = $view->equipmentId; + $output->siteId = $view->siteId; + $output->title = $view->title; + $output->description = $view->description; + $output->priority = $view->priority; + $output->status = $view->status; + $output->originInspectionId = $view->originInspectionId; + $output->originNonConformityId = $view->originNonConformityId; + $output->qualificationNote = $view->qualificationNote; + $output->decisionReason = $view->decisionReason; + $output->interventionId = $view->interventionId; + $output->taskId = $view->taskId; + $output->revision = $view->revision; + $output->targetSnapshot = $view->targetSnapshot; + $output->requestedAt = $view->requestedAt->format(DATE_ATOM); + $output->updatedAt = $view->updatedAt->format(DATE_ATOM); + $output->qualifiedAt = $view->qualifiedAt?->format(DATE_ATOM); + $output->rejectedAt = $view->rejectedAt?->format(DATE_ATOM); + $output->cancelledAt = $view->cancelledAt?->format(DATE_ATOM); + $output->convertedAt = $view->convertedAt?->format(DATE_ATOM); + + return $output; + } +} diff --git a/src/ServiceRequest/Presentation/Api/EventSubscriber/ServiceRequestExceptionSubscriber.php b/src/ServiceRequest/Presentation/Api/EventSubscriber/ServiceRequestExceptionSubscriber.php new file mode 100644 index 000000000..1c24105d9 --- /dev/null +++ b/src/ServiceRequest/Presentation/Api/EventSubscriber/ServiceRequestExceptionSubscriber.php @@ -0,0 +1,41 @@ + + */ + public static function getSubscribedEvents(): array + { + return [KernelEvents::EXCEPTION => ['onException', 10]]; + } + + public function onException(ExceptionEvent $event): void + { + $error = $event->getThrowable(); + do { + if ($error instanceof ServiceRequestException || $error instanceof ServiceRequestOriginUnavailable) { + $code = $error instanceof ServiceRequestException ? $error->reason : 'service_request_origin_unavailable'; + $status = match ($code) { + 'service_request_not_found' => 404, 'service_request_access_denied' => 403, 'service_request_revision_stale' => 412, 'service_request_precondition_required' => 428, 'service_request_transition_conflict', 'service_request_operation_conflict' => 409, default => 422 + }; + $event->setResponse(new JsonResponse(['type' => '/errors/' . $code, 'title' => 'Repair request refused', 'status' => $status, 'code' => $code, 'detail' => $error->getMessage()], $status, ['Content-Type' => 'application/problem+json'])); + + return; + } + $error = $error->getPrevious(); + } while (null !== $error); + } +} diff --git a/src/ServiceRequest/Presentation/Api/Operation/ServiceRequestOperations.php b/src/ServiceRequest/Presentation/Api/Operation/ServiceRequestOperations.php new file mode 100644 index 000000000..569abc6d7 --- /dev/null +++ b/src/ServiceRequest/Presentation/Api/Operation/ServiceRequestOperations.php @@ -0,0 +1,25 @@ + + */ +final readonly class ServiceRequestProcessor implements ProcessorInterface +{ + public function __construct(private CommandBusPort $commands, private CurrentActorPort $actor, private RequestStack $requests) + { + } + + /** + * @param array $uriVariables + * @param array $context + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ServiceRequestOutput + { + $actorId = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $organizationId = $this->id($uriVariables, 'organizationId'); + if (ServiceRequestOperations::CREATE === $operation->getName()) { + if (!$data instanceof CreateServiceRequestInput) { + throw ServiceRequestException::invalid('Invalid repair request input.'); + } + /** @var CreateServiceRequestResult $result */ + $result = $this->commands->dispatch(new CreateServiceRequestCommand($actorId, $organizationId, $data->equipmentId, $data->siteId, $data->title, $data->description, $data->priority, $data->originInspectionId, $data->originNonConformityId)); + + return ServiceRequestOutput::fromView($result->request); + } + $requestId = $this->id($uriVariables, 'id'); + $revision = $this->revision(); + if (ServiceRequestOperations::CONVERT === $operation->getName()) { + if (!$data instanceof ConvertServiceRequestInput) { + throw ServiceRequestException::invalid('Invalid repair conversion input.'); + } + /** @var ConvertServiceRequestResult $result */ + $result = $this->commands->dispatch(new ConvertServiceRequestCommand($actorId, $organizationId, $requestId, $revision, $data->clientOperationId, $data->existingInterventionId, $data->existingTaskId)); + + return ServiceRequestOutput::fromView($result->request); + } + $action = match ($operation->getName()) { + ServiceRequestOperations::QUALIFY => 'qualify', ServiceRequestOperations::REJECT => 'reject', ServiceRequestOperations::CANCEL => 'cancel', default => 'patch' + }; + $changes = []; + $note = null; + $reason = null; + $equipmentId = null; + if ('patch' === $action) { + if (!$data instanceof UpdateServiceRequestInput) { + throw ServiceRequestException::invalid('Invalid repair request patch.'); + } + foreach ($this->requests->getCurrentRequest()?->getPayload()->all() ?? [] as $field => $value) { + if (!property_exists($data, $field)) { + throw ServiceRequestException::invalid('Unknown repair request field.'); + } + $changes[$field] = $data->{$field}; + } + } elseif ('qualify' === $action) { + if (!$data instanceof QualifyServiceRequestInput) { + throw ServiceRequestException::invalid('Invalid qualification input.'); + } + $note = $data->note; + $equipmentId = $data->equipmentId; + } else { + if (!$data instanceof DecisionServiceRequestInput) { + throw ServiceRequestException::invalid('Invalid repair request decision.'); + } + $reason = $data->reason; + } + /** @var ChangeServiceRequestResult $result */ + $result = $this->commands->dispatch(new ChangeServiceRequestCommand($actorId, $organizationId, $requestId, $action, $revision, $changes, $note, $reason, $equipmentId)); + + return ServiceRequestOutput::fromView($result->request); + } + + private function revision(): ?int + { + $header = $this->requests->getCurrentRequest()?->headers->get('If-Match'); + + return null === $header ? null : (1 === preg_match('/^"revision-(\d+)"$/', $header, $matches) ? (int) $matches[1] : -1); + } + + /** + * @param array $variables + */ + private function id(array $variables, string $key): string + { + $value = $variables[$key] ?? null; + + return is_string($value) ? $value : ''; + } +} diff --git a/src/ServiceRequest/Presentation/Api/Provider/ServiceRequestProvider.php b/src/ServiceRequest/Presentation/Api/Provider/ServiceRequestProvider.php new file mode 100644 index 000000000..acb87abd9 --- /dev/null +++ b/src/ServiceRequest/Presentation/Api/Provider/ServiceRequestProvider.php @@ -0,0 +1,75 @@ + + */ +final readonly class ServiceRequestProvider implements ProviderInterface +{ + public function __construct(private QueryBusPort $queries, private CurrentActorPort $actor, private RequestStack $requests) + { + } + + /** + * @param array $uriVariables + * @param array $context + * + * @return ServiceRequestOutput|TraversablePaginator + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ServiceRequestOutput|TraversablePaginator + { + $actorId = $this->actor->userId() ?? throw new AccessDeniedHttpException('Authentication required.'); + $organizationId = $this->id($uriVariables, 'organizationId'); + if (ServiceRequestOperations::LIST === $operation->getName()) { + $request = $this->requests->getCurrentRequest(); + /** @var ListServiceRequestsResult $result */ + $result = $this->queries->ask(new ListServiceRequestsQuery($actorId, $organizationId, $this->filter($request?->query->get('status')), $this->filter($request?->query->get('equipmentId')), $this->filter($request?->query->get('siteId')), $request?->query->getString('search', '') ?? '', $request?->query->getInt('page', 1) ?? 1, $request?->query->getInt('itemsPerPage', 30) ?? 30)); + + return new TraversablePaginator(new ArrayIterator(array_map(ServiceRequestOutput::fromView(...), $result->items)), (float) $result->page, (float) $result->itemsPerPage, (float) $result->total); + } + /** @var GetServiceRequestResult $result */ + $result = $this->queries->ask(new GetServiceRequestQuery($actorId, $organizationId, $this->id($uriVariables, 'id'))); + + return ServiceRequestOutput::fromView($result->request); + } + + private function filter(mixed $value): ?string + { + return is_string($value) && '' !== $value ? $value : null; + } + + /** + * @param array $variables + */ + private function id(array $variables, string $key): string + { + $value = $variables[$key] ?? null; + + return is_string($value) ? $value : ''; + } +} diff --git a/src/ServiceRequest/Presentation/Api/Resource/ServiceRequestResource.php b/src/ServiceRequest/Presentation/Api/Resource/ServiceRequestResource.php new file mode 100644 index 000000000..a42104d43 --- /dev/null +++ b/src/ServiceRequest/Presentation/Api/Resource/ServiceRequestResource.php @@ -0,0 +1,33 @@ + ['service_request:read']], denormalizationContext: ['groups' => ['service_request:write']], operations: [ + new GetCollection(name: ServiceRequestOperations::LIST, uriTemplate: '/{organizationId}/service-requests', input: false, output: ServiceRequestOutput::class, provider: ServiceRequestProvider::class, paginationEnabled: true, paginationClientItemsPerPage: true, paginationMaximumItemsPerPage: 100, paginationItemsPerPage: 30, parameters: [ + 'status' => new QueryParameter(schema: ['type' => 'string', 'enum' => ['requested', 'qualified', 'rejected', 'cancelled', 'converted']], required: false, castToArray: false, constraints: []), + 'equipmentId' => new QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], required: false, castToArray: false, constraints: []), + 'siteId' => new QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], required: false, castToArray: false, constraints: []), + 'search' => new QueryParameter(schema: ['type' => 'string', 'maxLength' => 160], required: false, castToArray: false, constraints: []), + ], openapi: new Operation(tags: ['ServiceRequest'], summary: 'list an internal repair request')), + new Get(name: ServiceRequestOperations::GET, uriTemplate: '/{organizationId}/service-requests/{id}', input: false, output: ServiceRequestOutput::class, provider: ServiceRequestProvider::class, openapi: new Operation(tags: ['ServiceRequest'], summary: 'get an internal repair request')), + new Post(name: ServiceRequestOperations::CREATE, uriTemplate: '/{organizationId}/service-requests', input: CreateServiceRequestInput::class, output: ServiceRequestOutput::class, status: 201, read: false, processor: ServiceRequestProcessor::class, openapi: new Operation(tags: ['ServiceRequest'], summary: 'create an internal repair request')), + new Patch(name: ServiceRequestOperations::PATCH, uriTemplate: '/{organizationId}/service-requests/{id}', input: UpdateServiceRequestInput::class, output: ServiceRequestOutput::class, status: 200, read: false, processor: ServiceRequestProcessor::class, openapi: new Operation(tags: ['ServiceRequest'], summary: 'patch an internal repair request', parameters: [new Parameter(name: 'If-Match', in: 'header', required: true, schema: ['type' => 'string', 'example' => '"revision-1"'])])), + new Post(name: ServiceRequestOperations::QUALIFY, uriTemplate: '/{organizationId}/service-requests/{id}/qualify', input: QualifyServiceRequestInput::class, output: ServiceRequestOutput::class, status: 200, read: false, processor: ServiceRequestProcessor::class, openapi: new Operation(tags: ['ServiceRequest'], summary: 'qualify an internal repair request', parameters: [new Parameter(name: 'If-Match', in: 'header', required: true, schema: ['type' => 'string', 'example' => '"revision-1"'])])), + new Post(name: ServiceRequestOperations::REJECT, uriTemplate: '/{organizationId}/service-requests/{id}/reject', input: DecisionServiceRequestInput::class, output: ServiceRequestOutput::class, status: 200, read: false, processor: ServiceRequestProcessor::class, openapi: new Operation(tags: ['ServiceRequest'], summary: 'reject an internal repair request', parameters: [new Parameter(name: 'If-Match', in: 'header', required: true, schema: ['type' => 'string', 'example' => '"revision-1"'])])), + new Post(name: ServiceRequestOperations::CANCEL, uriTemplate: '/{organizationId}/service-requests/{id}/cancel', input: DecisionServiceRequestInput::class, output: ServiceRequestOutput::class, status: 200, read: false, processor: ServiceRequestProcessor::class, openapi: new Operation(tags: ['ServiceRequest'], summary: 'cancel an internal repair request', parameters: [new Parameter(name: 'If-Match', in: 'header', required: true, schema: ['type' => 'string', 'example' => '"revision-1"'])])), + new Post(name: ServiceRequestOperations::CONVERT, uriTemplate: '/{organizationId}/service-requests/{id}/convert', input: ConvertServiceRequestInput::class, output: ServiceRequestOutput::class, status: 200, read: false, processor: ServiceRequestProcessor::class, openapi: new Operation(tags: ['ServiceRequest'], summary: 'convert an internal repair request', parameters: [new Parameter(name: 'If-Match', in: 'header', required: true, schema: ['type' => 'string', 'example' => '"revision-1"'])])), +])] +final class ServiceRequestResource +{ +} diff --git a/src/Shared/Domain/ValueObject/DecimalAmount.php b/src/Shared/Domain/ValueObject/DecimalAmount.php new file mode 100644 index 000000000..ae7d0d12f --- /dev/null +++ b/src/Shared/Domain/ValueObject/DecimalAmount.php @@ -0,0 +1,270 @@ +value, $other->value, self::SCALE)); + } + + /** + * Method subtract. + * + * @param self $other exact operand + * + * @return self exact difference + */ + public function subtract(self $other): self + { + return new self(bcsub($this->value, $other->value, self::SCALE)); + } + + /** + * Method multiply. + * + * @param self $other exact operand + * + * @return self product rounded once to the internal precision + */ + public function multiply(self $other): self + { + return self::rounded(bcmul($this->value, $other->value, self::SCALE + 1)); + } + + /** + * Method divide. + * + * @param self $other nonzero exact divisor + * + * @return self quotient rounded once to the internal precision + */ + public function divide(self $other): self + { + if ($other->isZero()) { + throw new InvalidValueException('A decimal divisor must be nonzero.'); + } + + return self::rounded(bcdiv($this->value, $other->value, self::SCALE + 1)); + } + + /** + * Method multiplyAndDivide. + * + * @param self $multiplier exact quantity to allocate + * @param self $divisor nonzero total quantity + * + * @return self proportional value rounded once, without an intermediate rounded average + */ + public function multiplyAndDivide(self $multiplier, self $divisor): self + { + if ($divisor->isZero()) { + throw new InvalidValueException('A decimal divisor must be nonzero.'); + } + + $product = bcmul($this->value, $multiplier->value, self::SCALE * 2); + + return self::rounded(bcdiv($product, $divisor->value, self::SCALE + 1)); + } + + /** + * Method multiplyInt. + * + * @param int $quantity integer multiplier + * + * @return self exact product + */ + public function multiplyInt(int $quantity): self + { + return new self(bcmul($this->value, (string) $quantity, self::SCALE)); + } + + /** + * Method divideInt. + * + * @param int $divisor nonzero integer divisor + * + * @return self rounded quotient + */ + public function divideInt(int $divisor): self + { + return $this->divide(self::fromInt($divisor)); + } + + /** + * Method compareTo. + * + * @param self $other exact operand + * + * @return int negative, zero or positive comparison + */ + public function compareTo(self $other): int + { + return bccomp($this->value, $other->value, self::SCALE); + } + + /** + * Method isZero. + * + * @return bool whether the value is exactly zero + */ + public function isZero(): bool + { + return 0 === bccomp($this->value, '0', self::SCALE); + } + + /** + * Method isNegative. + * + * @return bool whether the value is strictly negative + */ + public function isNegative(): bool + { + return bccomp($this->value, '0', self::SCALE) < 0; + } + + /** + * Method isInteger. + * + * @return bool whether no fractional quantity remains + */ + public function isInteger(): bool + { + return 0 === bccomp($this->value, bcadd($this->value, '0', 0), self::SCALE); + } + + /** + * Method toInt. + * + * @return int exact integer for bounded equipment individualization + */ + public function toInt(): int + { + if (!$this->isInteger() || bccomp($this->value, (string) PHP_INT_MAX, self::SCALE) > 0 || bccomp($this->value, (string) PHP_INT_MIN, self::SCALE) < 0) { + throw new InvalidValueException('The decimal must be an integer within the platform integer range.'); + } + + return (int) bcadd($this->value, '0', 0); + } + + /** + * Method toString. + * + * @return numeric-string exact transport and storage representation + */ + public function toString(): string + { + return $this->value; + } + + /** + * Method value. + * + * @return numeric-string exact transport and storage representation + */ + public function value(): string + { + return $this->value; + } + + /** + * Method rounded. + * + * @param numeric-string $value arithmetic result retaining the seventh fractional digit + * + * @return self signed half-up result; zero has no negative sign + */ + private static function rounded(string $value): self + { + $truncated = bcadd($value, '0', self::SCALE); + $fraction = explode('.', $value, 2)[1] ?? ''; + if ((int) ($fraction[self::SCALE] ?? '0') >= 5) { + $truncated = bcadd($truncated, str_starts_with($value, '-') ? '-0.000001' : '0.000001', self::SCALE); + } + + return new self($truncated); + } +} diff --git a/templates/equipment/report.html.twig b/templates/equipment/report.html.twig index 680ffa9b7..9b65c882d 100644 --- a/templates/equipment/report.html.twig +++ b/templates/equipment/report.html.twig @@ -9,6 +9,18 @@

{{ 'equipment.title'|trans({}, 'pdf', lang) }}

+ + + + + + + + + + + + @@ -48,8 +60,12 @@ - - + + + + + + {% if tags is not empty %} @@ -64,6 +80,20 @@
{{ 'equipment.meta.name'|trans({}, 'pdf', lang) }}{{ name|default('—') }}
{{ 'equipment.meta.assetCode'|trans({}, 'pdf', lang) }}{{ assetCode|default('—') }}
{{ 'equipment.meta.criticality'|trans({}, 'pdf', lang) }}{{ criticality|default(null) ? ('equipment.criticality.' ~ criticality)|trans({}, 'pdf', lang) : '—' }}
{{ 'equipment.meta.type'|trans({}, 'pdf', lang) }} {{ type }}{% if subType is not empty %} — {{ subType }}{% endif %}{{ commissionedAt|default('—') }}
{{ 'equipment.meta.maintenanceDueStatus'|trans({}, 'pdf', lang) }}{{ ui.status('maintenanceDue', maintenanceDueStatus, lang) }}{{ 'equipment.meta.controlDueStatus'|trans({}, 'pdf', lang) }}{{ ui.status('maintenanceDue', controlDueStatus, lang) }} — {{ controlNextDueAt|default('—') }}
{{ 'equipment.meta.serviceDueStatus'|trans({}, 'pdf', lang) }}{{ ui.status('maintenanceDue', serviceDueStatus, lang) }} — {{ serviceNextDueAt|default('—') }}
+

{{ 'equipment.trackingNote'|trans({}, 'pdf', lang) }}

+ {% if predecessorEquipmentId|default(null) or successorEquipmentId|default(null) %} +

{{ 'equipment.replacement.heading'|trans({}, 'pdf', lang) }}

+ {% if predecessorEquipmentId|default(null) %}

{{ 'equipment.replacement.predecessor'|trans({}, 'pdf', lang) }} : {{ predecessorEquipmentId }}

{% endif %} + {% if successorEquipmentId|default(null) %}

{{ 'equipment.replacement.successor'|trans({}, 'pdf', lang) }} : {{ successorEquipmentId }}

{% endif %} + {% endif %} + {% if technicalProperties|default([]) is not empty %} +

{{ 'equipment.characteristics.heading'|trans({}, 'pdf', lang) }}

+ + + {% for property in technicalProperties %}{% endfor %} +
{{ 'equipment.characteristics.key'|trans({}, 'pdf', lang) }}{{ 'equipment.characteristics.value'|trans({}, 'pdf', lang) }}{{ 'equipment.characteristics.unit'|trans({}, 'pdf', lang) }}
{{ property.key }}{{ property.value }}{{ property.unit|default('—') }}
+ {% endif %} +

{{ 'equipment.maintenance.heading'|trans({}, 'pdf', lang) }}

{% if maintenanceLogs is empty %}

{{ 'equipment.maintenance.empty'|trans({}, 'pdf', lang) }}

diff --git a/templates/intervention/report.html.twig b/templates/intervention/report.html.twig index 92424ce64..0ac010031 100644 --- a/templates/intervention/report.html.twig +++ b/templates/intervention/report.html.twig @@ -9,6 +9,7 @@ {% block content %}

{{ 'intervention.title'|trans({ '%number%': number }, 'pdf', lang) }}

+

{{ ('intervention.reportMode.' ~ reportMode|default('live'))|trans({}, 'pdf', lang) }}{% if capturedAt|default(null) %} — {{ capturedAt }}{% endif %}

@@ -31,6 +32,12 @@ + {% if customerName|default(null) %} + + + + + {% endif %} @@ -101,6 +108,11 @@ + {% if workItem.executionResult|default(null) %} + + + + {% endif %} {% endfor %}
{{ 'intervention.meta.site'|trans({}, 'pdf', lang) }} {{ siteName|default('—') }}
{{ 'intervention.meta.customer'|trans({}, 'pdf', lang) }}{{ customerName }}
{{ 'intervention.meta.responsible'|trans({}, 'pdf', lang) }} {{ responsibleName|default('—') }}{{ workItem.skipReason|default('—') }} {{ workItem.evidenceCount }}
{{ workItem.executionResult.performedAt }} — {{ ('intervention.result.outcome.' ~ workItem.executionResult.outcome)|trans({}, 'pdf', lang) }}
{{ workItem.executionResult.workPerformed }}
diff --git a/tests/Functional/Api/CustomerApiTest.php b/tests/Functional/Api/CustomerApiTest.php new file mode 100644 index 000000000..c178c7129 --- /dev/null +++ b/tests/Functional/Api/CustomerApiTest.php @@ -0,0 +1,396 @@ +client(); + $body = $this->request($client, 'POST', '/customers', ['name' => ' Building Owner ', 'code' => 'C-1', 'contacts' => [['name' => 'Pat', 'email' => 'pat@example.com']]]); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('Building Owner', $body['name']); + self::assertSame(self::ORG, $body['organizationId']); + self::assertSame(1, $body['revision']); + self::assertIsString($body['id']); + } + + #[Test] + public function readsArchivedCustomer(): void + { + $client = $this->client(); + $this->seedCustomer(true); + $body = $this->request($client, 'GET', '/customers/' . self::CUSTOMER); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(self::CUSTOMER, $body['id']); + self::assertNotNull($body['archivedAt']); + } + + #[Test] + public function filtersArchivedCollectionAndMatchingTotal(): void + { + $client = $this->client(); + $this->seedCustomer(true); + $this->seedCustomer(false, '550e8400-e29b-41d4-a716-448040000011'); + $body = $this->request($client, 'GET', '/customers?archived=true&search=Owner&itemsPerPage=1'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(1, $body['totalItems']); + /** @var list $members */ + $members = $body['member']; + self::assertCount(1, $members); + self::assertSame(self::CUSTOMER, $members[0]['id']); + } + + #[Test] + public function updatesNullableFieldsAtExpectedRevision(): void + { + $client = $this->client(); + $this->seedCustomer(); + $body = $this->request($client, 'PATCH', '/customers/' . self::CUSTOMER, ['code' => null], ['HTTP_IF_MATCH' => '"revision-1"']); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertNull($body['code']); + self::assertSame('owner@example.com', $body['email']); + self::assertSame(2, $body['revision']); + } + + #[Test] + public function archivesWithoutDeletingIdentity(): void + { + $client = $this->client(); + $this->seedCustomer(); + $body = $this->request($client, 'POST', '/customers/' . self::CUSTOMER . '/archive', extra: ['HTTP_IF_MATCH' => '"revision-1"']); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(self::CUSTOMER, $body['id']); + self::assertNotNull($body['archivedAt']); + self::assertSame(2, $body['revision']); + } + + #[Test] + public function restoresRetainedCustomer(): void + { + $client = $this->client(); + $this->seedCustomer(true); + $body = $this->request($client, 'POST', '/customers/' . self::CUSTOMER . '/restore', extra: ['HTTP_IF_MATCH' => '"revision-1"']); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertNull($body['archivedAt']); + self::assertSame(2, $body['revision']); + } + + #[Test] + public function requiresRevisionAfterCheckingAccess(): void + { + $client = $this->client(); + $this->seedCustomer(); + $this->request($client, 'PATCH', '/customers/' . self::CUSTOMER, ['name' => 'Changed']); + self::assertSame(428, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function refusesStaleRevision(): void + { + $client = $this->client(); + $this->seedCustomer(); + $this->request($client, 'PATCH', '/customers/' . self::CUSTOMER, ['name' => 'Changed'], ['HTTP_IF_MATCH' => '"revision-0"']); + self::assertSame(412, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function refusesDuplicateOrganizationCode(): void + { + $client = $this->client(); + $this->seedCustomer(); + $this->request($client, 'POST', '/customers', ['name' => 'Other', 'code' => 'CLIENT-1']); + self::assertSame(409, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function deniesUnentitledCollectionRead(): void + { + $client = $this->client(self::MEMBER); + $this->request($client, 'GET', '/customers'); + self::assertSame(403, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function deniesUnentitledWrite(): void + { + $client = $this->client(self::MEMBER); + $this->request($client, 'POST', '/customers', ['name' => 'Owner']); + self::assertSame(403, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function hidesOutsideOrganizationRead(): void + { + $client = $this->client('550e8400-e29b-41d4-a716-448040000099'); + $this->request($client, 'GET', '/customers'); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function hidesOutsideOrganizationWrite(): void + { + $client = $this->client('550e8400-e29b-41d4-a716-448040000099'); + $this->request($client, 'POST', '/customers', ['name' => 'Owner']); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function createsSiteWithActiveCustomer(): void + { + $client = $this->client(); + $this->seedCustomer(); + $body = $this->request($client, 'POST', '/facilities', ['name' => 'Customer Site', 'type' => 'site', 'customerId' => self::CUSTOMER]); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(self::CUSTOMER, $body['customerId']); + } + + #[Test] + public function refusesCustomerOnChildFacility(): void + { + $client = $this->client(); + $this->seedCustomer(); + $this->seedSite(); + $this->request($client, 'POST', '/facilities', ['name' => 'Building', 'type' => 'building', 'parentFacilityId' => self::SITE, 'customerId' => self::CUSTOMER]); + self::assertSame(422, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function refusesNewArchivedCustomerAssignment(): void + { + $client = $this->client(); + $this->seedCustomer(true); + $this->request($client, 'POST', '/facilities', ['name' => 'Customer Site', 'type' => 'site', 'customerId' => self::CUSTOMER]); + self::assertSame(422, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function refusesUnknownCustomerAssignment(): void + { + $client = $this->client(); + $this->request($client, 'POST', '/facilities', ['name' => 'Customer Site', 'type' => 'site', 'customerId' => self::CUSTOMER]); + self::assertSame(422, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function preservesArchivedCustomerOnSiteRead(): void + { + $client = $this->client(); + $this->seedCustomer(true); + $this->seedSite(self::CUSTOMER); + $body = $this->request($client, 'GET', '/facilities/' . self::SITE); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(self::CUSTOMER, $body['customerId']); + } + + #[Test] + public function siteCollectionFiltersCustomerAndTotalTogether(): void + { + $client = $this->client(); + $this->seedCustomer(); + $this->seedSite(self::CUSTOMER); + $body = $this->request($client, 'GET', '/facilities?rootsOnly=true&customerId=' . self::CUSTOMER); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(1, $body['totalItems']); + /** @var list $members */ + $members = $body['member']; + self::assertSame(self::CUSTOMER, $members[0]['customerId']); + } + + #[Test] + public function canonicalPatchAssignsActiveCustomerToRootSite(): void + { + $client = $this->client(); + $this->seedCustomer(); + $this->seedSite(); + $client->request('PATCH', '/api/facilities/' . self::SITE, server: ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_ACCEPT' => 'application/ld+json', 'HTTP_IF_MATCH' => '"revision-1"'], content: json_encode(['customerId' => self::CUSTOMER], JSON_THROW_ON_ERROR)); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + /** @var array $body */ + $body = json_decode((string) $client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertSame(self::CUSTOMER, $body['customerId']); + self::assertSame(2, $body['revision']); + } + + #[Test] + public function unknownCustomerFilterIsNotFound(): void + { + $client = $this->client(); + $this->request($client, 'GET', '/facilities?rootsOnly=true&customerId=' . self::CUSTOMER); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function canonicalPatchKeepsUnchangedArchivedCustomerDuringDescriptiveEdit(): void + { + $client = $this->client(); + $this->seedCustomer(true); + $this->seedSite(self::CUSTOMER); + $client->request('PATCH', '/api/facilities/' . self::SITE, server: ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_ACCEPT' => 'application/ld+json', 'HTTP_IF_MATCH' => '"revision-1"'], content: json_encode(['customerId' => self::CUSTOMER, 'name' => 'Updated Site'], JSON_THROW_ON_ERROR)); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + /** @var array $body */ + $body = json_decode((string) $client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertSame(self::CUSTOMER, $body['customerId']); + self::assertSame('Updated Site', $body['name']); + } + + #[Test] + public function closureSnapshotRetainsBuildingAndReadsCustomerFromRootSite(): void + { + $this->client(); + $this->seedCustomer(true); + $site = $this->seedSite(self::CUSTOMER); + $building = new FacilityRecord(); + $building->id = '550e8400-e29b-41d4-a716-448040000021'; + $building->organization = $site->organization; + $building->parentFacility = $site; + $building->type = 'building'; + $building->name = 'Selected Building'; + $building->createdAt = $site->createdAt; + $building->updatedAt = $site->updatedAt; + $this->main()->persist($building); + $this->main()->flush(); + /** @var InterventionSiteCustomerSnapshotPort $snapshots */ $snapshots = static::getContainer()->get(InterventionSiteCustomerSnapshotPort::class); + $snapshot = $snapshots->snapshot(self::ORG, $building->id); + self::assertSame(['id' => $building->id, 'name' => 'Selected Building'], $snapshot['site']); + self::assertNotNull($snapshot['customer']); + self::assertSame(self::CUSTOMER, $snapshot['customer']['id']); + self::assertSame('Owner', $snapshot['customer']['name']); + } + + private function client(string $actor = self::OWNER): KernelBrowser + { + $client = static::createClient(); + $em = $this->main(); + $now = new DateTimeImmutable(); + $org = new OrganizationRecord(); + $org->id = self::ORG; + $org->name = 'Customer API'; + $org->slug = 'customer-api'; + $org->ownerUserId = self::OWNER; + $org->createdByUserId = self::OWNER; + $org->status = 'active'; + $org->isActive = true; + $org->createdAt = $now; + $org->updatedAt = $now; + $em->persist($org); + foreach ([self::OWNER => '550e8400-e29b-41d4-a716-448040000005', self::MEMBER => '550e8400-e29b-41d4-a716-448040000004'] as $userId => $memberId) { + $member = new OrganizationMemberRecord(); + $member->id = $memberId; + $member->organization = $org; + $member->userId = $userId; + $member->isActive = true; + $member->joinedAt = $now; + $em->persist($member); + if (self::OWNER === $userId) { + $role = new OrganizationRoleRecord(); + $role->id = '550e8400-e29b-41d4-a716-448040000006'; + $role->organization = $org; + $role->name = 'customer_owner'; + $role->permissions = ['*']; + $role->isSystem = false; + $role->createdAt = $now; + $em->persist($role); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $em->persist($assignment); + } + } + $em->flush(); + $client->loginUser(new SecurityUser($actor, $actor . '@example.com', 'password', ['ROLE_USER']), 'api'); + + return $client; + } + + private function main(): EntityManagerInterface + { + /** @var EntityManagerInterface $em */ $em = static::getContainer()->get('doctrine.orm.main_entity_manager'); + + return $em; + } + + private function seedCustomer(bool $archived = false, string $id = self::CUSTOMER): CustomerRecord + { + $now = new DateTimeImmutable(); + $customer = new CustomerRecord(); + $customer->id = $id; + $customer->organizationId = self::ORG; + $customer->name = 'Owner'; + $customer->code = self::CUSTOMER === $id ? 'CLIENT-1' : null; + $customer->email = 'owner@example.com'; + $customer->createdAt = $now; + $customer->updatedAt = $now; + $customer->archivedAt = $archived ? $now : null; + $this->main()->persist($customer); + $this->main()->flush(); + + return $customer; + } + + private function seedSite(?string $customerId = null): FacilityRecord + { + $now = new DateTimeImmutable(); + $site = new FacilityRecord(); + $site->id = self::SITE; + $site->organization = $this->main()->getReference(OrganizationRecord::class, self::ORG); + $site->customerId = $customerId; + $site->type = 'site'; + $site->name = 'Customer Site'; + $site->createdAt = $now; + $site->updatedAt = $now; + $this->main()->persist($site); + $this->main()->flush(); + + return $site; + } + + /** + * @param array $body + * @param array $extra + * + * @return array + */ + private function request(KernelBrowser $client, string $method, string $path, array $body = [], array $extra = []): array + { + $client->request($method, '/api/organizations/' . self::ORG . $path, server: ['CONTENT_TYPE' => 'PATCH' === $method ? 'application/merge-patch+json' : 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json'] + $extra, content: json_encode($body, JSON_THROW_ON_ERROR)); + /** @var array $decoded */ $decoded = json_decode((string) $client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + + return $decoded; + } +} diff --git a/tests/Functional/Api/EquipmentApiTest.php b/tests/Functional/Api/EquipmentApiTest.php index 509c7b69d..714ec03d3 100644 --- a/tests/Functional/Api/EquipmentApiTest.php +++ b/tests/Functional/Api/EquipmentApiTest.php @@ -19,6 +19,8 @@ use function json_decode; use function json_encode; +use const JSON_THROW_ON_ERROR; + /** * Test EquipmentApiTest. * @@ -55,6 +57,107 @@ final class EquipmentApiTest extends WebTestCase private const string OPERATIONAL_EQUIPMENT_ID = '770e8400-e29b-41d4-a716-446655490022'; + private ?KernelBrowser $authenticatedClient = null; + + #[Test] + public function identityRoundTripsAcrossBothSurfacesAndRejectsDuplicateAssetCodes(): void + { + $client = static::createClient(); + $this->seedContractFixtures(); + $this->loginAsAdmin($client); + $base = '/api/organizations/' . self::ORGANIZATION_ID . '/equipment'; + $payload = ['type' => 'fire_extinguisher', 'name' => 'Stair B', 'assetCode' => 'EXT-001', 'criticality' => 'high', 'technicalProperties' => [['key' => 'charge', 'value' => '6', 'unit' => 'kg']]]; + $client->request('POST', $base, server: ['CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json'], content: json_encode($payload, JSON_THROW_ON_ERROR)); + self::assertResponseStatusCodeSame(201); + $created = json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($created); + self::assertSame('EXT-001', $created['assetCode']); + self::assertSame($payload['technicalProperties'], $created['technicalProperties']); + $id = $created['id']; + self::assertIsString($id); + $this->loginAsAdmin($client); + $client->request('GET', '/api/equipment/' . $id, server: ['HTTP_ACCEPT' => 'application/ld+json']); + self::assertResponseIsSuccessful(); + $read = json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($read); + self::assertIsInt($read['revision']); + self::assertSame('Stair B', $read['name']); + $this->loginAsAdmin($client); + $client->request('PATCH', '/api/equipment/' . $id, server: ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_ACCEPT' => 'application/ld+json', 'HTTP_IF_MATCH' => '"revision-' . $read['revision'] . '"'], content: '{"name":null}'); + self::assertResponseStatusCodeSame(200); + $patched = json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($patched); + self::assertNull($patched['name']); + self::assertSame('EXT-001', $patched['assetCode']); + $this->loginAsAdmin($client); + $client->request('POST', $base, server: ['CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json'], content: json_encode($payload, JSON_THROW_ON_ERROR)); + self::assertResponseStatusCodeSame(409); + } + + #[Test] + public function familyFilterAndFacilitySummaryUseTheSameServerScope(): void + { + $client = static::createClient(); + $this->seedContractFixtures(); + $this->loginAsAdmin($client); + $base = '/api/organizations/' . self::ORGANIZATION_ID . '/facilities/' . self::FACILITY_ID; + $client->request('GET', $base . '/equipment?family=fire&includeDescendants=true', server: ['HTTP_ACCEPT' => 'application/ld+json']); + self::assertResponseStatusCodeSame(200); + $collection = json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($collection); + self::assertSame(2, $collection['totalItems']); + $this->loginAsAdmin($client); + $client->request('GET', $base . '/equipment-summary?family=fire&includeDescendants=true', server: ['HTTP_ACCEPT' => 'application/ld+json']); + self::assertResponseStatusCodeSame(200); + $summary = json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($summary); + self::assertSame($collection['totalItems'], $summary['totalItems']); + $this->loginAsAdmin($client); + $client->request('GET', $base . '/equipment?family=safety', server: ['HTTP_ACCEPT' => 'application/ld+json']); + self::assertResponseStatusCodeSame(200); + $safetyCollection = json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($safetyCollection); + self::assertSame(0, $safetyCollection['totalItems']); + $this->loginAsAdmin($client); + $client->request('GET', '/api/organizations/' . self::ORGANIZATION_ID . '/equipment-summary?family=fire', server: ['HTTP_ACCEPT' => 'application/ld+json']); + self::assertResponseStatusCodeSame(200); + $organizationSummary = json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($organizationSummary); + self::assertSame('organization', $organizationSummary['scope']); + self::assertSame(3, $organizationSummary['totalItems']); + } + + #[Test] + public function organizationSummaryRequiresAuthenticationAndHidesUnknownCustomers(): void + { + $client = static::createClient(); + $client->request('GET', '/api/organizations/' . self::ORGANIZATION_ID . '/equipment-summary'); + self::assertContains($client->getResponse()->getStatusCode(), [401, 403]); + $this->seedContractFixtures(); + $this->loginAsAdmin($client); + $client->request('GET', '/api/organizations/' . self::ORGANIZATION_ID . '/equipment-summary?customerId=' . self::DUMMY_UUID); + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function organizationSummaryDeniesMissingReadAndHidesOtherOrganizations(): void + { + $client = static::createClient(); + $this->seedContractFixtures(); + /** @var EntityManagerInterface $manager */ + $manager = static::getContainer()->get('doctrine.orm.main_entity_manager'); + $role = $manager->find(OrganizationRoleRecord::class, self::ADMIN_ROLE_ID); + self::assertInstanceOf(OrganizationRoleRecord::class, $role); + $role->permissions = []; + $manager->flush(); + $this->loginAsAdmin($client); + $client->request('GET', '/api/organizations/' . self::ORGANIZATION_ID . '/equipment-summary'); + self::assertResponseStatusCodeSame(403); + $this->loginAsAdmin($client); + $client->request('GET', '/api/organizations/' . self::DUMMY_UUID . '/equipment-summary'); + self::assertResponseStatusCodeSame(404); + } + #[Test] public function testListEquipmentWithMaintenanceDueStatusFilterRequiresAuthentication(): void { @@ -284,6 +387,15 @@ private function decodeOk(KernelBrowser $client, string $action): array */ private function loginAsAdmin(KernelBrowser $client): void { + if ($this->authenticatedClient === $client) { + return; + } + $this->authenticatedClient = $client; + $client->disableReboot(); + $users = $this->createStub(\User\Application\Port\Outbound\UserRepositoryPort::class); + $users->method('findById')->willReturnCallback(static fn (\User\Domain\ValueObject\UserId $id) => \Tests\Support\Factory\UserTestFactory::createActive((string) $id, (string) $id . '@corp.example')); + static::getContainer()->set(\User\Application\Port\Outbound\UserRepositoryPort::class, $users); + $client->setServerParameter('HTTP_AUTHORIZATION', 'Bearer ' . \Tests\Support\Auth\InteractiveTokenFactory::issue(static::getContainer(), self::ADMIN_USER_ID, 'equipment-admin@example.com')); $user = new SecurityUser( id: self::ADMIN_USER_ID, email: 'equipment-contract-admin@example.com', diff --git a/tests/Functional/Api/EquipmentInspectionSummaryApiTest.php b/tests/Functional/Api/EquipmentInspectionSummaryApiTest.php new file mode 100644 index 000000000..2718c24e7 --- /dev/null +++ b/tests/Functional/Api/EquipmentInspectionSummaryApiTest.php @@ -0,0 +1,267 @@ +client(); + $equipment = $this->seedEquipment(); + $old = $this->seedInspection('550e8400-e29b-41d4-a716-448050000020', 'closed', 'published', 'fail', '2026-10-03T08:00:00Z'); + $last = $this->seedInspection('550e8400-e29b-41d4-a716-448050000021', 'closed', 'published', 'pass', '2026-10-04T09:30:00Z'); + $submitted = $this->seedInspection('550e8400-e29b-41d4-a716-448050000022', 'submitted', 'published', 'partial', '2026-10-05T10:30:00Z'); + $draft = $this->seedInspection('550e8400-e29b-41d4-a716-448050000023', 'closed', 'draft', 'fail', '2026-10-06T11:00:00Z'); + $this->finding($old, '550e8400-e29b-41d4-a716-448050000030', 'high', 'open'); + $this->finding($old, '550e8400-e29b-41d4-a716-448050000031', 'critical', 'in_progress'); + $this->finding($last, '550e8400-e29b-41d4-a716-448050000032', 'medium', 'resolved'); + $this->finding($draft, '550e8400-e29b-41d4-a716-448050000033', 'critical', 'open'); + $other = $this->seedInspection('550e8400-e29b-41d4-a716-448050000024', 'closed', 'published', 'fail', '2026-10-06T12:00:00Z', '550e8400-e29b-41d4-a716-448050000011'); + $this->finding($other, '550e8400-e29b-41d4-a716-448050000034', 'low', 'open'); + $body = $this->summary($client); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(self::EQUIPMENT, $body['equipmentId']); + self::assertSame(2, $body['openAnomalies']); + self::assertSame(['low' => 0, 'medium' => 0, 'high' => 1, 'critical' => 1], $body['bySeverity']); + self::assertSame($last->id, $body['lastInspectionId']); + self::assertSame('2026-10-04T09:30:00+00:00', $body['lastInspectionPerformedAt']); + self::assertSame('pass', $body['lastInspectionResult']); + self::assertSame('under_maintenance', $equipment->status); + self::assertNotSame($submitted->id, $body['lastInspectionId']); + } + + #[Test] + public function noRecordedControlHasExplicitNullFacts(): void + { + $client = $this->client(); + $this->seedEquipment(); + $body = $this->summary($client); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(0, $body['openAnomalies']); + self::assertNull($body['lastInspectionId']); + self::assertNull($body['lastInspectionPerformedAt']); + self::assertNull($body['lastInspectionResult']); + } + + #[Test] + public function missingInspectionReadIsDenied(): void + { + $client = $this->client(['organization.equipment.read']); + $this->seedEquipment(); + $this->summary($client); + self::assertSame(403, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function missingEquipmentReadIsDenied(): void + { + $client = $this->client(['organization.inspection.read']); + $this->seedEquipment(); + $this->summary($client); + self::assertSame(403, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function outsideOrganizationIsIndistinguishableFromAbsentResource(): void + { + $client = $this->client(actor: '550e8400-e29b-41d4-a716-448050000099'); + $this->seedEquipment(); + $this->summary($client); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function unknownEquipmentIsNotFound(): void + { + $client = $this->client(); + $this->summary($client); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function equipmentFromAnotherOrganizationIsNotFound(): void + { + $client = $this->client(); + $foreign = $this->organization('550e8400-e29b-41d4-a716-448050000098'); + $this->seedEquipment($foreign); + $this->summary($client); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function sameEquipmentIdentifierInForeignInspectionCannotContaminateFacts(): void + { + $client = $this->client(); + $this->seedEquipment(); + $foreign = $this->organization('550e8400-e29b-41d4-a716-448050000098'); + $inspection = $this->seedInspection('550e8400-e29b-41d4-a716-448050000020', 'closed', 'published', 'fail', '2026-10-05T11:00:00Z', organization: $foreign); + $this->finding($inspection, '550e8400-e29b-41d4-a716-448050000030', 'critical', 'open'); + $body = $this->summary($client); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(0, $body['openAnomalies']); + self::assertNull($body['lastInspectionId']); + } + + #[Test] + public function privateEquipmentDraftIsNotExposedByPublishedDossier(): void + { + $client = $this->client(); + $equipment = $this->seedEquipment(); + $equipment->recordStatus = 'draft'; + $equipment->interventionId = '550e8400-e29b-41d4-a716-448050000090'; + $this->main()->flush(); + $this->summary($client); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + /** + * @param list $permissions + */ + private function client(array $permissions = ['organization.equipment.read', 'organization.inspection.read'], string $actor = self::USER): KernelBrowser + { + $client = static::createClient(); + $org = $this->organization(self::ORG); + $member = new OrganizationMemberRecord(); + $member->id = '550e8400-e29b-41d4-a716-448050000003'; + $member->organization = $org; + $member->userId = self::USER; + $member->isActive = true; + $member->joinedAt = new DateTimeImmutable(); + $this->main()->persist($member); + $role = new OrganizationRoleRecord(); + $role->id = '550e8400-e29b-41d4-a716-448050000004'; + $role->organization = $org; + $role->name = 'summary_reader'; + $role->permissions = $permissions; + $role->isSystem = false; + $role->createdAt = new DateTimeImmutable(); + $this->main()->persist($role); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = new DateTimeImmutable(); + $this->main()->persist($assignment); + $this->main()->flush(); + $client->loginUser(new SecurityUser($actor, $actor . '@example.com', 'password', ['ROLE_USER']), 'api'); + + return $client; + } + + private function main(): EntityManagerInterface + { + /** @var EntityManagerInterface $em */ $em = static::getContainer()->get('doctrine.orm.main_entity_manager'); + + return $em; + } + + private function organization(string $id): OrganizationRecord + { + $now = new DateTimeImmutable(); + $organization = new OrganizationRecord(); + $organization->id = $id; + $organization->name = 'Inspection Summary'; + $organization->slug = 'inspection-summary-' . $id; + $organization->ownerUserId = self::USER; + $organization->createdByUserId = self::USER; + $organization->status = 'active'; + $organization->isActive = true; + $organization->createdAt = $now; + $organization->updatedAt = $now; + $this->main()->persist($organization); + $this->main()->flush(); + + return $organization; + } + + private function seedEquipment(?OrganizationRecord $organization = null): EquipmentRecord + { + $now = new DateTimeImmutable(); + $equipment = new EquipmentRecord(); + $equipment->id = self::EQUIPMENT; + $equipment->organization = $organization ?? $this->main()->getReference(OrganizationRecord::class, self::ORG); + $equipment->type = 'fire_extinguisher'; + $equipment->status = 'under_maintenance'; + $equipment->createdAt = $now; + $equipment->updatedAt = $now; + $this->main()->persist($equipment); + $this->main()->flush(); + + return $equipment; + } + + private function seedInspection(string $id, string $status, string $recordStatus, string $result, string $performedAt, string $equipmentId = self::EQUIPMENT, ?OrganizationRecord $organization = null): InspectionRecord + { + $now = new DateTimeImmutable(); + $inspection = new InspectionRecord(); + $inspection->id = $id; + $inspection->organization = $organization ?? $this->main()->getReference(OrganizationRecord::class, self::ORG); + $inspection->equipmentId = $equipmentId; + $inspection->inspectorType = 'internal'; + $inspection->inspectorName = 'Inspector'; + $inspection->status = $status; + $inspection->recordStatus = $recordStatus; + $inspection->result = $result; + $inspection->performedAt = new DateTimeImmutable($performedAt); + $inspection->createdAt = $now; + $inspection->updatedAt = $now; + $this->main()->persist($inspection); + $this->main()->flush(); + + return $inspection; + } + + private function finding(InspectionRecord $inspection, string $id, string $severity, string $status): void + { + $now = new DateTimeImmutable(); + $finding = new NonConformityRecord(); + $finding->id = $id; + $finding->inspection = $inspection; + $finding->description = 'Recorded defect'; + $finding->severity = $severity; + $finding->status = $status; + $finding->createdAt = $now; + $finding->updatedAt = $now; + $this->main()->persist($finding); + $this->main()->flush(); + } + + /** + * @return array + */ + private function summary(KernelBrowser $client): array + { + $client->request('GET', '/api/organizations/' . self::ORG . '/equipment/' . self::EQUIPMENT . '/inspection-summary', server: ['HTTP_ACCEPT' => 'application/ld+json']); + /** @var array $body */ $body = json_decode((string) $client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + + return $body; + } +} diff --git a/tests/Functional/Api/EquipmentReplacementApiTest.php b/tests/Functional/Api/EquipmentReplacementApiTest.php new file mode 100644 index 000000000..e4967f784 --- /dev/null +++ b/tests/Functional/Api/EquipmentReplacementApiTest.php @@ -0,0 +1,399 @@ +client(); + $payload = ['clientOperationId' => self::OPERATION, 'successorEquipmentId' => self::STOCK]; + $first = $this->replace($client, $payload); + self::assertFalse($first['replayed']); + self::assertSame(self::OLD, $first['predecessorEquipmentId']); + self::assertSame(self::STOCK, $first['successorEquipmentId']); + $replayed = $this->replace($client, $payload); + self::assertTrue($replayed['replayed']); + + $manager = $this->manager(); + $manager->clear(); + $old = $manager->find(EquipmentRecord::class, self::OLD); + $new = $manager->find(EquipmentRecord::class, self::STOCK); + self::assertInstanceOf(EquipmentRecord::class, $old); + self::assertInstanceOf(EquipmentRecord::class, $new); + self::assertSame('decommissioned', $old->status); + self::assertSame(self::STOCK, $old->successorEquipmentId); + self::assertSame('OLD-SERIAL', $old->serialNumber); + self::assertSame('Room A', $old->locationLabel); + self::assertSame(self::SITE, $old->facilityId); + self::assertSame(self::OLD, $new->predecessorEquipmentId); + self::assertSame('operational', $new->status); + self::assertSame(self::SITE, $new->facilityId); + self::assertSame('Room A', $new->locationLabel); + self::assertNotNull($new->commissionedAt); + self::assertSame(1, $manager->getRepository(EquipmentReplacementReceiptRecord::class)->count(['organizationId' => self::ORG])); + } + + /** + * Method createsTheSuccessorAtomicallyAndReplaysWithoutCreatingAnother + * + * @access public + * + * @return void + */ + #[Test] + public function createsTheSuccessorAtomicallyAndReplaysWithoutCreatingAnother(): void + { + $client = $this->client(); + $payload = [ + 'clientOperationId' => self::OPERATION, + 'successor' => ['type' => 'fire_extinguisher', 'serialNumber' => 'CREATED-SERIAL', 'name' => 'New extinguisher', 'assetCode' => 'NEW-001', 'criticality' => 'high'], + ]; + $first = $this->replace($client, $payload); + $replayed = $this->replace($client, $payload); + self::assertTrue($replayed['replayed']); + self::assertSame($first['successorEquipmentId'], $replayed['successorEquipmentId']); + self::assertSame(1, $this->manager()->getRepository(EquipmentRecord::class)->count(['serialNumber' => 'CREATED-SERIAL'])); + $new = $this->manager()->find(EquipmentRecord::class, $first['successorEquipmentId']); + self::assertInstanceOf(EquipmentRecord::class, $new); + self::assertSame(self::SITE, $new->facilityId); + self::assertSame('operational', $new->status); + self::assertSame('NEW-001', $new->assetCode); + } + + /** + * Method changedReplayPayloadConflictsWithoutChangingTheReceipt + * + * @access public + * + * @return void + */ + #[Test] + public function changedReplayPayloadConflictsWithoutChangingTheReceipt(): void + { + $client = $this->client(); + $this->replace($client, ['clientOperationId' => self::OPERATION, 'successorEquipmentId' => self::STOCK]); + $this->replace($client, [ + 'clientOperationId' => self::OPERATION, 'successor' => ['type' => 'fire_extinguisher'], + ], 409); + self::assertSame(2, $this->manager()->getRepository(EquipmentRecord::class)->count(['organization' => self::ORG])); + } + + /** + * Method rejectsAnAlreadyDeployedSuccessorWithoutRetiringTheOriginal + * + * @access public + * + * @return void + */ + #[Test] + public function rejectsAnAlreadyDeployedSuccessorWithoutRetiringTheOriginal(): void + { + $client = $this->client(); + $new = $this->manager()->find(EquipmentRecord::class, self::STOCK); + self::assertInstanceOf(EquipmentRecord::class, $new); + $new->status = 'operational'; + $new->facilityId = self::SITE; + $this->manager()->flush(); + $this->replace($client, ['clientOperationId' => self::OPERATION, 'successorEquipmentId' => self::STOCK], 409); + $this->manager()->clear(); + $old = $this->manager()->find(EquipmentRecord::class, self::OLD); + self::assertInstanceOf(EquipmentRecord::class, $old); + self::assertSame('operational', $old->status); + self::assertNull($old->successorEquipmentId); + self::assertSame(0, $this->manager()->getRepository(EquipmentReplacementReceiptRecord::class)->count(['organizationId' => self::ORG])); + } + + /** + * Method invalidCreationRollsBackWithoutRetiringTheOriginal + * + * @access public + * + * @return void + */ + #[Test] + public function invalidCreationRollsBackWithoutRetiringTheOriginal(): void + { + $client = $this->client(); + $this->replace($client, [ + 'clientOperationId' => self::OPERATION, + 'successor' => ['type' => 'fire_extinguisher', 'serialNumber' => 'OLD-SERIAL'], + ], 409); + $this->manager()->clear(); + $old = $this->manager()->find(EquipmentRecord::class, self::OLD); + self::assertInstanceOf(EquipmentRecord::class, $old); + self::assertSame('operational', $old->status); + self::assertNull($old->successorEquipmentId); + self::assertSame(0, $this->manager()->getRepository(EquipmentReplacementReceiptRecord::class)->count(['organizationId' => self::ORG])); + } + + /** + * Method missingEquipmentAndForeignOrganizationAreHidden + * + * @access public + * + * @return void + */ + #[Test] + public function missingEquipmentAndForeignOrganizationAreHidden(): void + { + $client = $this->client(); + $foreign = new OrganizationRecord(); + $foreign->id = '770e8400-e29b-41d4-a716-446655491099'; + $foreign->name = 'Foreign replacement organization'; + $foreign->slug = 'foreign-replacement-contract'; + $foreign->ownerUserId = self::USER; + $foreign->createdByUserId = self::USER; + $foreign->status = 'active'; + $foreign->isActive = true; + $foreign->createdAt = $foreign->updatedAt = new DateTimeImmutable(); + $this->manager()->persist($foreign); + $foreignAsset = new EquipmentRecord(); + $foreignAsset->id = '770e8400-e29b-41d4-a716-446655491098'; + $foreignAsset->organization = $foreign; + $foreignAsset->type = 'fire_extinguisher'; + $foreignAsset->status = 'in_stock'; + $foreignAsset->createdAt = $foreignAsset->updatedAt = new DateTimeImmutable(); + $this->manager()->persist($foreignAsset); + $this->manager()->flush(); + $this->replace($client, ['clientOperationId' => self::OPERATION, 'successorEquipmentId' => '770e8400-e29b-41d4-a716-446655491099'], 404); + $this->replace($client, ['clientOperationId' => self::OPERATION, 'successorEquipmentId' => $foreignAsset->id], 404); + $client->loginUser(new SecurityUser(self::USER, 'replacement@example.com', 'unused', ['ROLE_USER']), 'api'); + $client->request( + 'POST', + '/api/organizations/770e8400-e29b-41d4-a716-446655491099/equipment/' . self::OLD . '/replace', + server: ['CONTENT_TYPE' => 'application/ld+json'], + content: json_encode(['clientOperationId' => self::OPERATION, 'successorEquipmentId' => self::STOCK], JSON_THROW_ON_ERROR), + ); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + /** + * Method deniesMembersWithoutWritePermission + * + * @access public + * + * @return void + */ + #[Test] + public function deniesMembersWithoutWritePermission(): void + { + $client = $this->client(['organization.equipment.read']); + $this->replace($client, ['clientOperationId' => self::OPERATION, 'successorEquipmentId' => self::STOCK], 403); + } + + /** + * Method validatesTheSuccessorChoiceBeforeDispatch + * + * @access public + * + * @return void + */ + #[Test] + public function validatesTheSuccessorChoiceBeforeDispatch(): void + { + $client = $this->client(); + $this->replace($client, ['clientOperationId' => self::OPERATION], 422); + } + + /** + * Method aReceiptPersistenceFailureRollsBackTheNewAssetAndRetirement + * + * PostgreSQL rejects the final receipt after nested asset creation has succeeded. + * + * @access public + * + * @return void + */ + #[Test] + public function aReceiptPersistenceFailureRollsBackTheNewAssetAndRetirement(): void + { + $client = $this->client(); + $connection = $this->manager()->getConnection(); + $connection->executeStatement("CREATE FUNCTION reject_replacement_receipt() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'replacement receipt failure'; END; $$"); + $connection->executeStatement('CREATE TRIGGER reject_replacement_receipt BEFORE INSERT ON equipment_replacement_receipts FOR EACH ROW EXECUTE FUNCTION reject_replacement_receipt()'); + $this->replace($client, [ + 'clientOperationId' => self::OPERATION, + 'successor' => ['type' => 'fire_extinguisher', 'serialNumber' => 'ROLLBACK-SERIAL'], + ], 500); + self::assertSame('operational', $connection->fetchOne('SELECT status FROM equipment WHERE id = :id', ['id' => self::OLD])); + self::assertNull($connection->fetchOne('SELECT successor_equipment_id FROM equipment WHERE id = :id', ['id' => self::OLD])); + $assetCount = $connection->fetchOne('SELECT COUNT(*) FROM equipment WHERE serial_number = :serial', ['serial' => 'ROLLBACK-SERIAL']); + $receiptCount = $connection->fetchOne('SELECT COUNT(*) FROM equipment_replacement_receipts WHERE organization_id = :organization', ['organization' => self::ORG]); + self::assertIsNumeric($assetCount); + self::assertIsNumeric($receiptCount); + self::assertSame(0, (int) $assetCount); + self::assertSame(0, (int) $receiptCount); + } + + /** + * Method replace + * + * @access private + * + * @param KernelBrowser $client the API client + * @param array $payload the replacement body + * @param int $status the expected response status + * + * @return array decoded output + */ + private function replace(KernelBrowser $client, array $payload, int $status = 200): array + { + $client->loginUser(new SecurityUser(self::USER, 'replacement@example.com', 'unused', ['ROLE_USER']), 'api'); + $client->request( + 'POST', + '/api/organizations/' . self::ORG . '/equipment/' . self::OLD . '/replace', + server: ['CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json'], + content: json_encode($payload, JSON_THROW_ON_ERROR), + ); + self::assertSame($status, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $output = json_decode((string) $client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($output); + $object = []; + foreach ($output as $key => $value) { + self::assertIsString($key); + $object[$key] = $value; + } + + return $object; + } + + /** + * Method manager + * + * @access private + * + * @return EntityManagerInterface the main test manager + */ + private function manager(): EntityManagerInterface + { + $manager = static::getContainer()->get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $manager); + + return $manager; + } + + /** + * Method client + * + * @access private + * + * @param list $permissions the test role permissions + * + * @return KernelBrowser the seeded authenticated client + */ + private function client(array $permissions = ['*']): KernelBrowser + { + $client = static::createClient(); + $client->disableReboot(); + $users = $this->createStub(\User\Application\Port\Outbound\UserRepositoryPort::class); + $users->method('findById')->willReturnCallback(static fn (\User\Domain\ValueObject\UserId $id) => \Tests\Support\Factory\UserTestFactory::createActive((string) $id, (string) $id . '@corp.example')); + static::getContainer()->set(\User\Application\Port\Outbound\UserRepositoryPort::class, $users); + $client->setServerParameter('HTTP_AUTHORIZATION', 'Bearer ' . \Tests\Support\Auth\InteractiveTokenFactory::issue(static::getContainer(), self::USER, 'replacement@example.com')); + $now = new DateTimeImmutable(); + $manager = $this->manager(); + $organization = new OrganizationRecord(); + $organization->id = self::ORG; + $organization->name = 'Replacement contract organization'; + $organization->slug = 'replacement-contract'; + $organization->ownerUserId = self::USER; + $organization->createdByUserId = self::USER; + $organization->status = 'active'; + $organization->isActive = true; + $organization->createdAt = $organization->updatedAt = $now; + $manager->persist($organization); + $role = new OrganizationRoleRecord(); + $role->id = '770e8400-e29b-41d4-a716-446655491007'; + $role->organization = $organization; + $role->name = 'replacement-contract'; + $role->permissions = $permissions; + $role->isSystem = false; + $role->createdAt = $now; + $manager->persist($role); + $member = new OrganizationMemberRecord(); + $member->id = '770e8400-e29b-41d4-a716-446655491008'; + $member->organization = $organization; + $member->userId = self::USER; + $member->isActive = true; + $member->joinedAt = $now; + $manager->persist($member); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $manager->persist($assignment); + $site = new FacilityRecord(); + $site->id = self::SITE; + $site->organization = $organization; + $site->name = 'Replacement site'; + $site->type = 'site'; + $site->status = 'active'; + $site->metadata = []; + $site->createdAt = $site->updatedAt = $now; + $manager->persist($site); + foreach ([self::OLD => 'operational', self::STOCK => 'in_stock'] as $id => $status) { + $equipment = new EquipmentRecord(); + $equipment->id = $id; + $equipment->organization = $organization; + $equipment->type = 'fire_extinguisher'; + $equipment->status = $status; + $equipment->facilityId = self::OLD === $id ? self::SITE : null; + $equipment->serialNumber = self::OLD === $id ? 'OLD-SERIAL' : 'STOCK-SERIAL'; + $equipment->locationLabel = self::OLD === $id ? 'Room A' : null; + $equipment->createdAt = $equipment->updatedAt = $now; + $manager->persist($equipment); + } + $manager->flush(); + $client->loginUser(new SecurityUser(self::USER, 'replacement@example.test', 'hashed', ['ROLE_USER']), 'api'); + + return $client; + } + // #endregion +} diff --git a/tests/Functional/Api/EquipmentTypeCatalogApiTest.php b/tests/Functional/Api/EquipmentTypeCatalogApiTest.php new file mode 100644 index 000000000..bc38e3e30 --- /dev/null +++ b/tests/Functional/Api/EquipmentTypeCatalogApiTest.php @@ -0,0 +1,268 @@ +request('GET', $this->path()); + self::assertContains($client->getResponse()->getStatusCode(), [401, 403]); + } + + #[Test] + public function testCustomTypeCreationArchiveAndStaleRevision(): void + { + $client = static::createClient(); + $this->seed($client, ['organization.equipment.read', 'organization.equipment.write']); + + $client->request('GET', $this->path(), server: ['HTTP_ACCEPT' => 'application/ld+json']); + $initial = $this->response($client, 200); + self::assertIsArray($initial['member']); + self::assertCount(12, $initial['member']); + $default = null; + foreach ($initial['member'] as $type) { + self::assertIsArray($type); + if ('fire_extinguisher' === $type['value']) { + $default = $type; + } + } + self::assertIsArray($default); + self::assertSame('fire', $default['family']); + self::assertFalse($default['archived']); + self::assertSame(1, $default['revision']); + + $this->write($client, 'POST', '', ['value' => 'water_mist', 'label' => 'Water mist', 'family' => 'fire']); + $created = $this->response($client, 201); + self::assertSame('water_mist', $created['value']); + self::assertSame('Water mist', $created['label']); + self::assertSame(1, $created['revision']); + + $this->write($client, 'POST', '', ['value' => 'water_mist', 'label' => 'Duplicate', 'family' => 'fire']); + self::assertSame('equipment_type_exists', $this->response($client, 409)['code']); + + $this->write($client, 'PATCH', '/water_mist', ['revision' => 1, 'archived' => true]); + $archived = $this->response($client, 200); + self::assertTrue($archived['archived']); + self::assertSame(2, $archived['revision']); + + $this->write($client, 'PATCH', '/water_mist', ['revision' => 1, 'label' => 'Stale label']); + self::assertSame('equipment_type_revision_conflict', $this->response($client, 412)['code']); + + $client->loginUser(new SecurityUser(self::USER_ID, 'equipment-type-catalog@example.com', 'unused', ['ROLE_USER']), 'api'); + $client->request('GET', $this->path('/water_mist'), server: ['HTTP_ACCEPT' => 'application/ld+json']); + self::assertTrue($this->response($client, 200)['archived']); + } + + #[Test] + public function testDefaultOverridePersistsAndPreservesHistoricalEquipment(): void + { + $client = static::createClient(); + $this->seed($client, ['*']); + $this->write($client, 'PATCH', '/fire_extinguisher', ['revision' => 1, 'label' => 'Extincteur', 'archived' => true]); + $updated = $this->response($client, 200); + self::assertSame('Extincteur', $updated['label']); + self::assertSame(2, $updated['revision']); + + /** @var EquipmentTypeCatalogPort $catalog */ + $catalog = static::getContainer()->get(EquipmentTypeCatalogPort::class); + $catalog->validateAvailableType(self::ORGANIZATION_ID, 'fire_extinguisher', 'fire_extinguisher'); + + $this->expectException(EquipmentTypeCatalogException::class); + $catalog->validateAvailableType(self::ORGANIZATION_ID, 'fire_extinguisher'); + } + + #[Test] + public function testUncataloguedHistoricalCodesAreVisibleButUnavailableForNewAssignments(): void + { + $client = static::createClient(); + $this->seed($client, ['*']); + /** @var EntityManagerInterface $entityManager */ + $entityManager = static::getContainer()->get('doctrine.orm.main_entity_manager'); + $organization = $entityManager->find(OrganizationRecord::class, self::ORGANIZATION_ID); + self::assertInstanceOf(OrganizationRecord::class, $organization); + $record = new EquipmentRecord(); + $record->id = '770e8400-e29b-41d4-a716-446655491010'; + $record->organization = $organization; + $record->type = 'legacy_custom'; + $record->status = 'in_stock'; + $record->recordStatus = 'published'; + $record->createdAt = new DateTimeImmutable(); + $record->updatedAt = $record->createdAt; + $entityManager->persist($record); + $entityManager->flush(); + + $client->request('GET', $this->path('/legacy_custom'), server: ['HTTP_ACCEPT' => 'application/ld+json']); + $historical = $this->response($client, 200); + self::assertSame('legacy_custom', $historical['value']); + self::assertTrue($historical['archived']); + self::assertSame('other', $historical['family']); + /** @var EquipmentTypeCatalogPort $catalog */ + $catalog = static::getContainer()->get(EquipmentTypeCatalogPort::class); + $catalog->validateAvailableType(self::ORGANIZATION_ID, 'legacy_custom', 'legacy_custom'); + $this->expectException(EquipmentTypeCatalogException::class); + $catalog->validateAvailableType(self::ORGANIZATION_ID, 'legacy_custom'); + } + + #[Test] + public function testReadOnlyMemberCannotWrite(): void + { + $client = static::createClient(); + $this->seed($client, ['organization.equipment.read']); + $this->write($client, 'POST', '', ['value' => 'water_mist', 'label' => 'Water mist', 'family' => 'fire']); + self::assertSame(403, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function testMemberWithoutEquipmentReadIsDenied(): void + { + $client = static::createClient(); + $this->seed($client, ['organization.facility.read']); + $client->request('GET', $this->path()); + self::assertSame(403, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function testAnotherOrganizationIsHidden(): void + { + $client = static::createClient(); + $this->seed($client, ['*']); + $client->request('GET', '/api/organizations/770e8400-e29b-41d4-a716-446655491099/equipment-types'); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function testInvalidCatalogInputAndMissingRevisionAreRejected(): void + { + $client = static::createClient(); + $this->seed($client, ['*']); + $this->write($client, 'POST', '', ['value' => 'INVALID', 'label' => 'Invalid', 'family' => 'fire']); + self::assertSame(422, $client->getResponse()->getStatusCode()); + $this->write($client, 'PATCH', '/fire_extinguisher', ['archived' => true]); + self::assertSame(422, $client->getResponse()->getStatusCode()); + } + + /** + * Method path. + */ + private function path(string $suffix = ''): string + { + return '/api/organizations/' . self::ORGANIZATION_ID . '/equipment-types' . $suffix; + } + + /** + * Method write. + * + * @param array $body serialized write payload + */ + private function write(KernelBrowser $client, string $method, string $suffix, array $body): void + { + $client->loginUser(new SecurityUser(self::USER_ID, 'equipment-type-catalog@example.com', 'unused', ['ROLE_USER']), 'api'); + $client->request($method, $this->path($suffix), server: [ + 'CONTENT_TYPE' => 'PATCH' === $method ? 'application/merge-patch+json' : 'application/ld+json', + 'HTTP_ACCEPT' => 'application/ld+json', + ], content: (string) json_encode($body)); + } + + /** + * Method response. + * + * @return array decoded response + */ + private function response(KernelBrowser $client, int $status): array + { + self::assertSame($status, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $body = json_decode((string) $client->getResponse()->getContent(), true); + self::assertIsArray($body); + $object = []; + foreach ($body as $key => $value) { + self::assertIsString($key); + $object[$key] = $value; + } + + return $object; + } + + /** + * Method seed. + * + * @param list $permissions permissions of the testing member + */ + private function seed(KernelBrowser $client, array $permissions): void + { + $client->disableReboot(); + $users = $this->createStub(\User\Application\Port\Outbound\UserRepositoryPort::class); + $users->method('findById')->willReturnCallback(static fn (\User\Domain\ValueObject\UserId $id) => \Tests\Support\Factory\UserTestFactory::createActive((string) $id, (string) $id . '@corp.example')); + static::getContainer()->set(\User\Application\Port\Outbound\UserRepositoryPort::class, $users); + $client->setServerParameter('HTTP_AUTHORIZATION', 'Bearer ' . \Tests\Support\Auth\InteractiveTokenFactory::issue(static::getContainer(), self::USER_ID, 'equipment-type-catalog@example.com')); + /** @var EntityManagerInterface $entityManager */ + $entityManager = static::getContainer()->get('doctrine.orm.main_entity_manager'); + $now = new DateTimeImmutable(); + $organization = new OrganizationRecord(); + $organization->id = self::ORGANIZATION_ID; + $organization->name = 'Equipment type catalog tests'; + $organization->slug = 'equipment-type-catalog-tests'; + $organization->ownerUserId = '770e8400-e29b-41d4-a716-446655491098'; + $organization->createdByUserId = self::USER_ID; + $organization->status = 'active'; + $organization->isActive = true; + $organization->createdAt = $now; + $organization->updatedAt = $now; + $entityManager->persist($organization); + $role = new OrganizationRoleRecord(); + $role->id = self::ROLE_ID; + $role->organization = $organization; + $role->name = 'catalog-test-role'; + $role->permissions = $permissions; + $role->isSystem = false; + $role->createdAt = $now; + $entityManager->persist($role); + $member = new OrganizationMemberRecord(); + $member->id = self::MEMBER_ID; + $member->organization = $organization; + $member->userId = self::USER_ID; + $member->isActive = true; + $member->joinedAt = $now; + $entityManager->persist($member); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $entityManager->persist($assignment); + $entityManager->flush(); + $client->loginUser(new SecurityUser(self::USER_ID, 'equipment-type-catalog@example.com', 'unused', ['ROLE_USER']), 'api'); + } +} diff --git a/tests/Functional/Api/InterventionApiTest.php b/tests/Functional/Api/InterventionApiTest.php index d27ab3e6b..96d5a86ad 100644 --- a/tests/Functional/Api/InterventionApiTest.php +++ b/tests/Functional/Api/InterventionApiTest.php @@ -7,6 +7,7 @@ use Auth\Infrastructure\Security\User\SecurityUser; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; +use Equipment\Infrastructure\Persistence\Doctrine\Record\EquipmentRecord; use Intervention\Infrastructure\Persistence\Doctrine\Record\{InterventionChangeRecord, InterventionRecord, InterventionWorkItemRecord}; use Organization\Infrastructure\Persistence\Doctrine\Record\{OrganizationMemberRecord, OrganizationMemberRoleRecord, OrganizationRecord, OrganizationRoleRecord}; use PHPUnit\Framework\Attributes\Test; @@ -47,6 +48,170 @@ final class InterventionApiTest extends WebTestCase private const string OTHER_OWNER_USER_ID = '650e8400-e29b-41d4-a716-449001000102'; + #[Test] + public function testEquipmentOpenWorkReturnsExistingScopedTasks(): void + { + $client = static::createClient(); + $client->catchExceptions(false); + $this->seedOrganizationWithFullAccessAdmin(); + [$taskId, $equipmentId] = $this->seedRepairOperation(); + $client->loginUser($this->securityUser(self::ADMIN_USER_ID), 'api'); + $client->request('GET', '/api/organizations/' . self::ORGANIZATION_ID . '/equipment/' . $equipmentId . '/open-work', server: ['HTTP_ACCEPT' => 'application/ld+json']); + self::assertResponseIsSuccessful(); + $body = $this->decodeObject($client->getResponse()->getContent() ?: '{}'); + $items = $body['member'] ?? $body['hydra:member'] ?? []; + self::assertIsArray($items); + self::assertCount(1, $items); + $firstItem = $items[0] ?? null; + self::assertIsArray($firstItem); + self::assertSame($taskId, $firstItem['workItemId']); + self::assertSame('repair', $firstItem['action']); + self::assertSame(401, $firstItem['number']); + } + + #[Test] + public function testEquipmentOpenWorkDoesNotDiscloseAnotherOrganizationsTasks(): void + { + $client = static::createClient(); + $this->seedOrganizationWithFullAccessAdmin(); + [, $equipmentId] = $this->seedRepairOperation(); + $client->loginUser($this->securityUser(self::OTHER_OWNER_USER_ID), 'api'); + $client->request('GET', '/api/organizations/' . self::ORGANIZATION_ID . '/equipment/' . $equipmentId . '/open-work'); + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function testEquipmentOpenWorkRequiresInterventionReadPermission(): void + { + $client = static::createClient(); + $this->seedOrganizationWithFullAccessAdmin(); + [, $equipmentId] = $this->seedRepairOperation(); + $manager = $this->entityManager(); + $organization = $manager->getReference(OrganizationRecord::class, self::ORGANIZATION_ID); + self::assertInstanceOf(OrganizationRecord::class, $organization); + $now = new DateTimeImmutable(); + $role = $this->seedRole($manager, $organization, '650e8400-e29b-41d4-a716-449001000321', ['organization.equipment.read'], $now, 'equipment_only'); + $member = $this->seedMember($manager, $organization, '650e8400-e29b-41d4-a716-449001000322', self::OTHER_OWNER_USER_ID, $now); + $this->assignRole($manager, $member, $role, $now); + $manager->flush(); + $client->loginUser($this->securityUser(self::OTHER_OWNER_USER_ID), 'api'); + $client->request('GET', '/api/organizations/' . self::ORGANIZATION_ID . '/equipment/' . $equipmentId . '/open-work'); + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function testRepairExecutionResultIsStagedWithServerAttributionAndRevisionGuard(): void + { + $client = static::createClient(); + $this->seedOrganizationWithFullAccessAdmin(); + [$taskId, $equipmentId] = $this->seedRepairOperation(); + $client->loginUser($this->securityUser(self::ADMIN_USER_ID), 'api'); + $payload = ['status' => 'completed', 'executionResult' => ['equipmentId' => $equipmentId, 'performedAt' => '2026-09-30T14:00:00+02:00', 'outcome' => 'successful', 'workPerformed' => 'Valve repaired', 'authorId' => self::OTHER_OWNER_USER_ID, 'state' => 'validated']]; + $client->request('PATCH', '/api/intervention-work-items/' . $taskId, server: ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_ACCEPT' => 'application/ld+json', 'HTTP_IF_MATCH' => '"revision-1"'], content: (string) json_encode($payload)); + self::assertResponseStatusCodeSame(200); + $body = $this->decodeObject($client->getResponse()->getContent() ?: '{}'); + self::assertSame('completed', $body['status']); + $result = $body['executionResult'] ?? null; + self::assertIsArray($result); + self::assertSame('staged', $result['state'] ?? null); + self::assertSame(self::ADMIN_MEMBER_ID, $result['authorId'] ?? null); + self::assertSame('2026-09-30T14:00:00+02:00', $result['performedAt'] ?? null); + static::ensureKernelShutdown(); + $client = static::createClient(); + $client->loginUser($this->securityUser(self::ADMIN_USER_ID), 'api'); + $client->request('PATCH', '/api/intervention-work-items/' . $taskId, server: ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_IF_MATCH' => '"revision-1"'], content: (string) json_encode($payload)); + self::assertResponseStatusCodeSame(412); + } + + #[Test] + public function testFailedRepairCannotBecomeCompletedAndRollbackKeepsTheTaskOpen(): void + { + $client = static::createClient(); + $this->seedOrganizationWithFullAccessAdmin(); + [$taskId, $equipmentId] = $this->seedRepairOperation(); + $client->loginUser($this->securityUser(self::ADMIN_USER_ID), 'api'); + $payload = ['status' => 'completed', 'executionResult' => ['equipmentId' => $equipmentId, 'performedAt' => '2026-09-30T14:00:00+02:00', 'outcome' => 'failed', 'workPerformed' => 'Valve still blocked']]; + $client->request('PATCH', '/api/intervention-work-items/' . $taskId, server: ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_IF_MATCH' => '"revision-1"'], content: (string) json_encode($payload)); + self::assertResponseStatusCodeSame(422); + static::ensureKernelShutdown(); + $client = static::createClient(); + $client->loginUser($this->securityUser(self::ADMIN_USER_ID), 'api'); + $client->request('GET', '/api/intervention-work-items/' . $taskId, server: ['HTTP_ACCEPT' => 'application/ld+json']); + self::assertResponseIsSuccessful(); + $body = $this->decodeObject($client->getResponse()->getContent() ?: '{}'); + self::assertSame('planned', $body['status']); + self::assertNull($body['executionResult'] ?? null); + self::assertSame(1, $body['revision']); + } + + #[Test] + public function testSuccessfulRetryRetainsTheFailedAttemptWithoutDuplicatingIdenticalFacts(): void + { + $client = static::createClient(); + $this->seedOrganizationWithFullAccessAdmin(); + [$taskId, $equipmentId] = $this->seedRepairOperation(); + $fact = ['equipmentId' => $equipmentId, 'performedAt' => '2026-09-30T14:00:00+02:00', 'outcome' => 'failed', 'workPerformed' => 'Valve still blocked']; + $client->loginUser($this->securityUser(self::ADMIN_USER_ID), 'api'); + $client->request('PATCH', '/api/intervention-work-items/' . $taskId, server: ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_IF_MATCH' => '"revision-1"'], content: (string) json_encode(['status' => 'in_progress', 'executionResult' => $fact])); + self::assertResponseIsSuccessful(); + $fact = [...$fact, 'outcome' => 'successful', 'workPerformed' => 'Valve repaired', 'performedAt' => '2026-10-01T14:00:00+02:00']; + static::ensureKernelShutdown(); + $client = static::createClient(); + $client->loginUser($this->securityUser(self::ADMIN_USER_ID), 'api'); + $client->request('PATCH', '/api/intervention-work-items/' . $taskId, server: ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_IF_MATCH' => '"revision-2"'], content: (string) json_encode(['status' => 'completed', 'executionResult' => $fact])); + self::assertResponseIsSuccessful(); + $body = $this->decodeObject($client->getResponse()->getContent() ?: '{}'); + $result = $body['executionResult'] ?? null; + self::assertIsArray($result); + $history = $result['history'] ?? null; + self::assertIsArray($history); + $firstAttempt = $history[0] ?? null; + self::assertIsArray($firstAttempt); + self::assertSame('failed', $firstAttempt['outcome'] ?? null); + self::assertSame('2026-09-30T14:00:00+02:00', $firstAttempt['performedAt'] ?? null); + static::ensureKernelShutdown(); + $client = static::createClient(); + $client->loginUser($this->securityUser(self::ADMIN_USER_ID), 'api'); + $client->request('PATCH', '/api/intervention-work-items/' . $taskId, server: ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_IF_MATCH' => '"revision-3"'], content: (string) json_encode(['executionResult' => $fact])); + self::assertResponseIsSuccessful(); + $body = $this->decodeObject($client->getResponse()->getContent() ?: '{}'); + $result = $body['executionResult'] ?? null; + self::assertIsArray($result); + $history = $result['history'] ?? null; + self::assertIsArray($history); + self::assertCount(1, $history); + } + + #[Test] + public function testExecutionResultOfAnotherOrganizationRemainsHidden(): void + { + $client = static::createClient(); + $this->seedOrganizationWithFullAccessAdmin(); + [$taskId, $equipmentId] = $this->seedRepairOperation(); + $client->loginUser($this->securityUser(self::OTHER_OWNER_USER_ID), 'api'); + $client->request('PATCH', '/api/intervention-work-items/' . $taskId, server: ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_IF_MATCH' => '"revision-1"'], content: (string) json_encode(['executionResult' => ['equipmentId' => $equipmentId, 'performedAt' => '2026-09-30T14:00:00+02:00', 'outcome' => 'successful', 'workPerformed' => 'Hidden work']])); + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function testMemberWithoutExecutionPermissionCannotStageAResult(): void + { + $client = static::createClient(); + $this->seedOrganizationWithFullAccessAdmin(); + [$taskId, $equipmentId] = $this->seedRepairOperation(); + $manager = $this->entityManager(); + $organization = $manager->getReference(OrganizationRecord::class, self::ORGANIZATION_ID); + self::assertInstanceOf(OrganizationRecord::class, $organization); + $now = new DateTimeImmutable(); + $role = $this->seedRole($manager, $organization, '650e8400-e29b-41d4-a716-449001000311', ['organization.interventions.read'], $now, 'result_read_only'); + $member = $this->seedMember($manager, $organization, '650e8400-e29b-41d4-a716-449001000312', self::OTHER_OWNER_USER_ID, $now); + $this->assignRole($manager, $member, $role, $now); + $manager->flush(); + $client->loginUser($this->securityUser(self::OTHER_OWNER_USER_ID), 'api'); + $client->request('PATCH', '/api/intervention-work-items/' . $taskId, server: ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_IF_MATCH' => '"revision-1"'], content: (string) json_encode(['executionResult' => ['equipmentId' => $equipmentId, 'performedAt' => '2026-09-30T14:00:00+02:00', 'outcome' => 'successful', 'workPerformed' => 'Denied work']])); + self::assertResponseStatusCodeSame(403); + } + // #region Create #[Test] @@ -617,6 +782,28 @@ private function seedOrganizationWithFullAccessAdmin(): void $entityManager->flush(); } + /** + * @return array{string,string} seeded work item and equipment identifiers + */ + private function seedRepairOperation(): array + { + $interventionId = $this->seedIntervention('650e8400-e29b-41d4-a716-449001000301', 'in_progress', 401, self::ADMIN_MEMBER_ID); + $equipment = new EquipmentRecord(); + $equipment->id = '650e8400-e29b-41d4-a716-449001000302'; + $equipment->organization = $this->entityManager()->getReference(OrganizationRecord::class, self::ORGANIZATION_ID); + $equipment->type = 'fire_extinguisher'; + $equipment->createdAt = $equipment->updatedAt = new DateTimeImmutable('2026-09-01T00:00:00+00:00'); + $this->entityManager()->persist($equipment); + $this->entityManager()->flush(); + $taskId = $this->seedWorkItem('650e8400-e29b-41d4-a716-449001000303', $interventionId, 'planned', '/api/equipment/' . $equipment->id); + $task = $this->entityManager()->find(InterventionWorkItemRecord::class, $taskId); + self::assertInstanceOf(InterventionWorkItemRecord::class, $task); + $task->action = 'repair'; + $this->entityManager()->flush(); + + return [$taskId, $equipment->id]; + } + private function seedOrganization( EntityManagerInterface $entityManager, string $id, diff --git a/tests/Functional/Api/InventoryApiTest.php b/tests/Functional/Api/InventoryApiTest.php new file mode 100644 index 000000000..3c4a38aab --- /dev/null +++ b/tests/Functional/Api/InventoryApiTest.php @@ -0,0 +1,416 @@ +client(); + [$part,$warehouse] = $this->catalog($client); + $port = self::getContainer()->get(InventoryStockReceiptPort::class); + self::assertInstanceOf(InventoryStockReceiptPort::class, $port); + $original = $port->receive(new InventoryReceiptRequest(self::ORG, $part, $warehouse, '2', '1', 'EUR', $this->op(), $this->op(), self::USER)); + $port->receive(new InventoryReceiptRequest(self::ORG, $part, $warehouse, '2', '10', 'EUR', $this->op(), $this->op(), self::USER)); + $first = $port->reverse(new InventoryReceiptReturnRequest(self::ORG, $original->movementId, '1', 'Supplier return first', $this->op(), self::USER)); + $last = $port->reverse(new InventoryReceiptReturnRequest(self::ORG, $original->movementId, '1', 'Supplier return last', $this->op(), self::USER)); + self::assertSame('5.500000', $first->totalValue); + self::assertSame('5.500000', $last->totalValue); + $balance = $this->balance($client); + self::assertSame('2.000000', $balance['quantity']); + self::assertIsArray($balance['valuation']); + self::assertSame('11.000000', $balance['valuation']['totalValue']); + } + + #[Test] + public function shortagesRemainPendingThenReconcileWithoutChangingTheReplaySnapshot(): void + { + $client = $this->client(); + [$part,$warehouse] = $this->catalog($client); + $this->correct($client, $part, $warehouse, '1', '3'); + $payload = $this->consumption($part, $warehouse, '2'); + $pending = $this->request($client, 'POST', 'inventory-consumptions', $payload, 201); + self::assertSame('received_pending', $pending['status']); + self::assertSame('insufficient_stock', $pending['reason']); + self::assertSame('2.000000', $pending['quantity']); + self::assertSame('1.000000', $this->balance($client)['quantity']); + $gate = self::getContainer()->get(InventoryInterventionResourcesPort::class); + self::assertInstanceOf(InventoryInterventionResourcesPort::class, $gate); + self::assertSame(['inventory_pending_declarations'], $gate->publicationBlockers(self::ORG, self::INTERVENTION)); + $this->correct($client, $part, $warehouse, '1', '5'); + self::assertIsString($pending['id']); + $confirmed = $this->request($client, 'POST', 'inventory-consumptions/' . $pending['id'] . '/reconcile', [], 200); + self::assertSame('confirmed', $confirmed['status']); + self::assertSame('0.000000', $this->balance($client)['quantity']); + self::assertSame([], $gate->publicationBlockers(self::ORG, self::INTERVENTION)); + $caseReplay = $payload; + self::assertIsString($caseReplay['clientOperationId']); + self::assertIsString($caseReplay['partId']); + $caseReplay['clientOperationId'] = strtoupper($caseReplay['clientOperationId']); + $caseReplay['partId'] = strtoupper($caseReplay['partId']); + $replay = $this->request($client, 'POST', 'inventory-consumptions', $caseReplay, 201); + self::assertTrue($replay['replayed']); + self::assertSame('received_pending', $replay['status']); + self::assertSame($pending['id'], $replay['id']); + $current = $this->request($client, 'GET', 'inventory-consumptions/' . $pending['id']); + self::assertSame('confirmed', $current['status']); + $payload['quantity'] = '3'; + $this->request($client, 'POST', 'inventory-consumptions', $payload, 409); + } + + #[Test] + public function consumptionAndReturnsKeepMotivatedLinkedMovementsAndExactCost(): void + { + $client = $this->client(); + [$part,$warehouse] = $this->catalog($client); + $this->correct($client, $part, $warehouse, '3', '1.666667'); + $consumed = $this->request($client, 'POST', 'inventory-consumptions', $this->consumption($part, $warehouse, '2'), 201); + self::assertSame('confirmed', $consumed['status']); + self::assertIsString($consumed['id']); + $returned = $this->request($client, 'POST', 'inventory-returns', ['clientOperationId' => $this->op(), 'consumptionId' => $consumed['id'], 'quantity' => '1', 'reason' => 'Unused spare returned'], 201); + self::assertSame('1.000000', $returned['quantity']); + self::assertSame($consumed['movementId'], $returned['correctionOf']); + self::assertSame('Unused spare returned', $returned['reason']); + self::assertSame('2.000000', $this->balance($client)['quantity']); + $this->request($client, 'POST', 'inventory-returns', ['clientOperationId' => $this->op(), 'consumptionId' => $consumed['id'], 'quantity' => '2', 'reason' => 'Too many'], 409); + $facts = self::getContainer()->get(InventoryInterventionResourcesPort::class); + self::assertInstanceOf(InventoryInterventionResourcesPort::class, $facts); + $cost = $facts->costFacts(self::ORG, self::INTERVENTION); + self::assertCount(2, $cost); + self::assertSame('2.000000', $cost[0]->quantity); + self::assertSame('-1.000000', $cost[1]->quantity); + self::assertSame($cost[0]->factId, $cost[1]->correctionOf); + } + + /** + * Method repeatedTinyValueReturnsRestoreStockWithoutOvercreditAndReplayWithoutAnotherMovement + * + * Verifies exact return allocation and immutable replay through the PostgreSQL HTTP flow. + * + * @access public + * + * @return void + */ + #[Test] + public function repeatedTinyValueReturnsRestoreStockWithoutOvercreditAndReplayWithoutAnotherMovement(): void + { + $client = $this->client(); + [$part,$warehouse] = $this->catalog($client); + $this->correct($client, $part, $warehouse, '2', '0.000001'); + $this->correct($client, $part, $warehouse, '2', '0'); + $consumed = $this->request($client, 'POST', 'inventory-consumptions', $this->consumption($part, $warehouse, '4'), 201); + self::assertSame('confirmed', $consumed['status']); + foreach (['0.000001', '0.000000', '0.000001', '0.000000'] as $value) { + $payload = ['clientOperationId' => $this->op(), 'consumptionId' => $consumed['id'], 'quantity' => '1', 'reason' => 'Unused tiny-value part returned']; + $returned = $this->request($client, 'POST', 'inventory-returns', $payload, 201); + self::assertSame('1.000000', $returned['quantity']); + self::assertSame($consumed['movementId'], $returned['correctionOf']); + self::assertSame('Unused tiny-value part returned', $returned['reason']); + self::assertIsArray($returned['valuation']); + self::assertSame($value, $returned['valuation']['totalValue']); + $replayed = $this->request($client, 'POST', 'inventory-returns', $payload, 201); + self::assertTrue($replayed['replayed']); + self::assertSame($returned['id'], $replayed['id']); + self::assertIsArray($replayed['valuation']); + foreach (['unitCost', 'totalValue', 'currency', 'incomplete'] as $field) { + self::assertSame($returned['valuation'][$field], $replayed['valuation'][$field]); + } + } + $balance = $this->balance($client); + self::assertSame('4.000000', $balance['quantity']); + self::assertIsArray($balance['valuation']); + self::assertSame('0.000002', $balance['valuation']['totalValue']); + $movements = $this->request($client, 'GET', 'inventory-movements?interventionId=' . self::INTERVENTION); + self::assertSame(5, $movements['totalItems']); + $this->request($client, 'POST', 'inventory-returns', ['clientOperationId' => $this->op(), 'consumptionId' => $consumed['id'], 'quantity' => '0.000001', 'reason' => 'Above original quantity'], 409); + $unchanged = $this->balance($client); + self::assertSame($balance['id'], $unchanged['id']); + self::assertSame($balance['quantity'], $unchanged['quantity']); + self::assertIsArray($unchanged['valuation']); + self::assertSame($balance['valuation']['totalValue'], $unchanged['valuation']['totalValue']); + } + + #[Test] + public function unknownPurchaseValueRemainsIncompleteAndNeverBecomesZero(): void + { + $client = $this->client(); + [$part,$warehouse] = $this->catalog($client); + $port = self::getContainer()->get(InventoryStockReceiptPort::class); + self::assertInstanceOf(InventoryStockReceiptPort::class, $port); + $port->receive(new InventoryReceiptRequest(self::ORG, $part, $warehouse, '2', null, 'EUR', $this->op(), $this->op(), self::USER)); + $balance = $this->balance($client); + self::assertIsArray($balance['valuation']); + self::assertTrue($balance['valuation']['incomplete']); + self::assertArrayNotHasKey('totalValue', $balance['valuation']); + $this->request($client, 'POST', 'inventory-consumptions', $this->consumption($part, $warehouse, '1'), 201); + $facts = self::getContainer()->get(InventoryInterventionResourcesPort::class); + self::assertInstanceOf(InventoryInterventionResourcesPort::class, $facts); + $cost = $facts->costFacts(self::ORG, self::INTERVENTION); + self::assertCount(1, $cost); + self::assertNull($cost[0]->exactAmount); + } + + #[Test] + public function archivedReferencesRetainHistoryAndPhysicalDeclarations(): void + { + $client = $this->client(); + [$part,$warehouse] = $this->catalog($client); + $this->correct($client, $part, $warehouse, '2', '4'); + $this->request($client, 'PATCH', 'inventory-parts/' . $part, ['archived' => true], 200); + $declaration = $this->request($client, 'POST', 'inventory-consumptions', $this->consumption($part, $warehouse, '1'), 201); + self::assertSame('received_pending', $declaration['status']); + self::assertSame('archived_reference', $declaration['reason']); + self::assertSame('2.000000', $this->balance($client)['quantity']); + $partOutput = $this->request($client, 'GET', 'inventory-parts/' . $part); + self::assertTrue($partOutput['archived']); + } + + #[Test] + public function internalValuesRequireDedicatedReadAndCorrectionsRequireDedicatedManage(): void + { + $client = $this->client(); + [$part,$warehouse] = $this->catalog($client); + $this->correct($client, $part, $warehouse, '1', '17'); + $this->permissions(['organization.inventory.read', 'organization.inventory.consume', 'organization.interventions.execute']); + self::assertArrayNotHasKey('valuation', $this->balance($client)); + $this->request($client, 'POST', 'inventory-corrections', ['clientOperationId' => $this->op(), 'partId' => $part, 'warehouseId' => $warehouse, 'quantity' => '1', 'reason' => 'Denied finance', 'unitCost' => '1'], 403); + $consumed = $this->request($client, 'POST', 'inventory-consumptions', $this->consumption($part, $warehouse, '1'), 201); + self::assertSame('confirmed', $consumed['status']); + $this->permissions(['organization.inventory.consume']); + $this->request($client, 'POST', 'inventory-consumptions', $this->consumption($part, $warehouse, '1'), 403); + $this->request($client, 'GET', 'inventory-balances', null, 403); + } + + #[Test] + public function unknownAndForeignReferencesAndOrganizationsAreHidden(): void + { + $client = $this->client(); + [$part,$warehouse] = $this->catalog($client); + $connection = $this->manager()->getConnection(); + $foreign = $this->op(); + $connection->insert('inventory_parts', ['id' => $foreign, 'organization_id' => $this->op(), 'code' => 'FOREIGN', 'label' => 'Foreign', 'unit' => 'piece', 'kind' => 'part', 'archived' => false], ['archived' => \Doctrine\DBAL\ParameterType::BOOLEAN]); + foreach ([$foreign, $this->op()] as $id) { + $this->request($client, 'GET', 'inventory-parts/' . $id, null, 404); + $this->request($client, 'POST', 'inventory-consumptions', $this->consumption($id, $warehouse, '1'), 404); + } + $this->request($client, 'GET', 'inventory-balances?warehouseId=' . $this->op(), null, 404); + $client->request('GET', '/api/organizations/' . $this->op() . '/inventory-parts', server:['HTTP_ACCEPT' => 'application/ld+json']); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function lateDeclarationsChangeCurrentStockWithoutRewritingThePublishedDossier(): void + { + $client = $this->client(); + [$part,$warehouse] = $this->catalog($client); + $this->correct($client, $part, $warehouse, '1', '2'); + $manager = $this->manager(); + $intervention = $manager->find(InterventionRecord::class, self::INTERVENTION); + self::assertInstanceOf(InterventionRecord::class, $intervention); + $intervention->status = 'published'; + $intervention->closureSnapshot = ['version' => 1, 'original' => 'preserve']; + $manager->flush(); + $declaration = $this->request($client, 'POST', 'inventory-consumptions', $this->consumption($part, $warehouse, '1'), 201); + self::assertTrue($declaration['late']); + self::assertSame('confirmed', $declaration['status']); + $manager->clear(); + $stored = $manager->find(InterventionRecord::class, self::INTERVENTION); + self::assertInstanceOf(InterventionRecord::class, $stored); + self::assertSame(['version' => 1, 'original' => 'preserve'], $stored->closureSnapshot); + } + + #[Test] + public function searchArchiveAndPaginationCountTheSameServerScope(): void + { + $client = $this->client(); + foreach (['A', 'B', 'C'] as $code) { + $this->request($client, 'POST', 'inventory-parts', ['code' => $code, 'label' => 'Filter ' . $code, 'unit' => 'piece', 'kind' => 'part'], 201); + } + $page = $this->request($client, 'GET', 'inventory-parts?search=Filter&archived=false&itemsPerPage=2&page=2'); + self::assertSame(3, $page['totalItems']); + self::assertIsArray($page['member']); + self::assertCount(1, $page['member']); + $literal = $this->request($client, 'GET', 'inventory-parts?search=%25'); + self::assertSame(0, $literal['totalItems']); + $this->request($client, 'GET', 'inventory-parts?archived=invalid', null, 422); + } + + /** + * @param list $permissions + */ + private function client(array $permissions = ['*']): KernelBrowser + { + $client = self::createClient(); + $client->disableReboot(); + $users = $this->createStub(\User\Application\Port\Outbound\UserRepositoryPort::class); + $users->method('findById')->willReturnCallback(static fn (\User\Domain\ValueObject\UserId $id) => \Tests\Support\Factory\UserTestFactory::createActive((string) $id, (string) $id . '@corp.example')); + self::getContainer()->set(\User\Application\Port\Outbound\UserRepositoryPort::class, $users); + $client->setServerParameter('HTTP_AUTHORIZATION', 'Bearer ' . \Tests\Support\Auth\InteractiveTokenFactory::issue(self::getContainer(), self::USER, 'inventory@corp.example')); + $em = $this->manager(); + $now = new DateTimeImmutable(); + $org = new OrganizationRecord(); + $org->id = self::ORG; + $org->name = 'Inventory contract'; + $org->slug = 'inventory-contract'; + $org->ownerUserId = self::USER; + $org->createdByUserId = self::USER; + $org->status = 'active'; + $org->isActive = true; + $org->createdAt = $org->updatedAt = $now; + $em->persist($org); + $role = new OrganizationRoleRecord(); + $role->id = self::ROLE; + $role->organization = $org; + $role->name = 'inventory-contract'; + $role->permissions = $permissions; + $role->isSystem = false; + $role->createdAt = $now; + $em->persist($role); + $member = new OrganizationMemberRecord(); + $member->id = 'beb10000-0000-4000-8000-000000000005'; + $member->organization = $org; + $member->userId = self::USER; + $member->isActive = true; + $member->joinedAt = $now; + $em->persist($member); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $em->persist($assignment); + $intervention = new InterventionRecord(); + $intervention->id = self::INTERVENTION; + $intervention->organization = $org; + $intervention->name = 'Stock intervention'; + $intervention->type = 'maintenance'; + $intervention->status = 'in_progress'; + $intervention->number = 1; + $intervention->responsibleId = 'beb10000-0000-4000-8000-000000000005'; + $intervention->createdAt = $intervention->updatedAt = $now; + $em->persist($intervention); + $em->flush(); + + return $client; + } + + /** + * @param list $permissions + */ + private function permissions(array $permissions): void + { + $role = $this->manager()->find(OrganizationRoleRecord::class, self::ROLE); + self::assertInstanceOf(OrganizationRoleRecord::class, $role); + $role->permissions = $permissions; + $this->manager()->flush(); + } + + /** + * @return array{string,string} + */ + private function catalog(KernelBrowser $client): array + { + $part = $this->request($client, 'POST', 'inventory-parts', ['code' => 'SEAL', 'label' => 'Seal', 'unit' => 'piece', 'kind' => 'part'], 201); + $warehouse = $this->request($client, 'POST', 'inventory-warehouses', ['code' => 'MAIN', 'name' => 'Main store'], 201); + self::assertIsString($part['id']); + self::assertIsString($warehouse['id']); + + return [$part['id'], $warehouse['id']]; + } + + private function correct(KernelBrowser $client, string $part, string $warehouse, string $qty, ?string $price): void + { + $this->request($client, 'POST', 'inventory-corrections', ['clientOperationId' => $this->op(), 'partId' => $part, 'warehouseId' => $warehouse, 'quantity' => $qty, 'reason' => 'Counted initial stock', 'unitCost' => $price], 201); + } + + /** + * @return array + */ + private function consumption(string $part, string $warehouse, string $qty): array + { + return ['clientOperationId' => $this->op(), 'partId' => $part, 'warehouseId' => $warehouse, 'quantity' => $qty, 'interventionId' => self::INTERVENTION, 'occurredAt' => '2026-10-06T10:00:00+00:00']; + } + + /** + * @return array + */ + private function balance(KernelBrowser $client): array + { + $list = $this->request($client, 'GET', 'inventory-balances'); + self::assertIsArray($list['member']); + self::assertCount(1, $list['member']); + $balance = $list['member'][0]; + self::assertIsArray($balance); + $typed = []; + foreach ($balance as $k => $v) { + self::assertIsString($k); + $typed[$k] = $v; + } + + return $typed; + } + + /** + * @param array|null $body request payload + * + * @return array decoded response + */ + private function request(KernelBrowser $client, string $method, string $path, ?array $body = null, int $status = 200): array + { + $client->request($method, '/api/organizations/' . self::ORG . '/' . $path, server:['CONTENT_TYPE' => 'PATCH' === $method ? 'application/merge-patch+json' : 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json'], content:null === $body ? null : json_encode((object) $body, JSON_THROW_ON_ERROR)); + self::assertSame($status, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $output = json_decode((string) $client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($output); + $typed = []; + foreach ($output as $k => $v) { + self::assertIsString($k); + $typed[$k] = $v; + } + + return $typed; + } + + private function op(): string + { + return 'beb10000-0000-4000-8000-' . str_pad((string) ++$this->operation, 12, '0', STR_PAD_LEFT); + } + + private function manager(): EntityManagerInterface + { + $em = self::getContainer()->get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $em); + + return $em; + } +} diff --git a/tests/Functional/Api/MaintenanceCostApiTest.php b/tests/Functional/Api/MaintenanceCostApiTest.php new file mode 100644 index 000000000..6409edc08 --- /dev/null +++ b/tests/Functional/Api/MaintenanceCostApiTest.php @@ -0,0 +1,526 @@ +client(); + $body = $this->request($client, 'GET'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('EUR', $body['currency']); + self::assertSame(0, $body['planningRevision']); + self::assertNull($body['frozen']); + self::assertIsArray($body['current']); + self::assertSame('0.000000', $body['current']['total']); + self::assertSame('0.000000', $body['current']['knownTotal']); + self::assertTrue($body['current']['complete']); + } + + #[Test] + public function recordedTimeWithoutARateRemainsAnIncompleteCost(): void + { + $client = $this->client(); + $this->seedTime(); + $body = $this->request($client, 'GET'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertIsArray($body['current']); + self::assertNull($body['current']['total']); + self::assertSame('0.000000', $body['current']['knownTotal']); + self::assertFalse($body['current']['complete']); + self::assertIsArray($body['current']['items']); + self::assertCount(1, $body['current']['items']); + self::assertIsArray($body['current']['items'][0]); + self::assertNull($body['current']['items'][0]['amount']); + self::assertNull($body['current']['items'][0]['hourlyAmount']); + } + + #[Test] + public function financialPlanningUsesItsOwnRevisionAndExactResourceAmounts(): void + { + $client = $this->client(); + $body = $this->request($client, 'PATCH', '/planning', [ + 'plannedBudget' => '100.000001', 'estimatedMinutes' => 30, + 'resources' => [ + ['workItemId' => self::TASK, 'kind' => 'material', 'description' => 'Valve seals', 'quantity' => '2', 'unitCost' => '10.123456'], + ['workItemId' => self::TASK, 'kind' => 'time', 'description' => 'Repair labor', 'estimatedMinutes' => 30, 'unitCost' => '60'], + ], + ], ['HTTP_IF_MATCH' => '"revision-0"']); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(1, $body['planningRevision']); + self::assertSame('100.000001', $body['plannedBudget']); + self::assertSame(30, $body['estimatedMinutes']); + self::assertIsArray($body['resources']); + self::assertCount(2, $body['resources']); + self::assertIsArray($body['resources'][0]); + self::assertIsArray($body['resources'][1]); + self::assertSame('20.246912', $body['resources'][0]['amount']); + self::assertSame('30.000000', $body['resources'][1]['amount']); + self::assertIsArray($body['current']); + self::assertSame('0.000000', $body['current']['total']); + } + + #[Test] + public function timePlanningRecomputesItsAmountFromMinutesAndHourlyRate(): void + { + $client = $this->client(); + $body = $this->request($client, 'PATCH', '/planning', ['resources' => [['workItemId' => self::TASK, 'kind' => 'time', 'description' => 'Repair labor', 'quantity' => null, 'unitCost' => '50', 'estimatedMinutes' => 60, 'amount' => '100']]], ['HTTP_IF_MATCH' => '"revision-0"']); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertIsArray($body['resources']); + self::assertIsArray($body['resources'][0]); + self::assertSame('50.000000', $body['resources'][0]['amount']); + } + + /** + * @return iterable}> + */ + public static function incompatibleResourceFields(): iterable + { + yield 'material quantity retained after selecting time' => [['kind' => 'time', 'quantity' => '2', 'unitCost' => '50', 'estimatedMinutes' => 60]]; + yield 'work minutes retained after selecting material' => [['kind' => 'material', 'quantity' => '2', 'unitCost' => '50', 'estimatedMinutes' => 60]]; + yield 'material quantity retained after selecting external expense' => [['kind' => 'external', 'quantity' => '2', 'amount' => '50']]; + yield 'unit cost retained after selecting external expense' => [['kind' => 'external', 'unitCost' => '50', 'amount' => '50']]; + yield 'work minutes retained after selecting external expense' => [['kind' => 'external', 'estimatedMinutes' => 60, 'amount' => '50']]; + } + + /** + * @param array $resource + */ + #[Test] + #[DataProvider('incompatibleResourceFields')] + public function resourceKindRejectsIncompatibleFieldsWithoutSavingABudget(array $resource): void + { + $client = $this->client(); + $this->request($client, 'PATCH', '/planning', ['plannedBudget' => '100', 'resources' => [$resource + ['workItemId' => self::TASK, 'description' => 'Prepared repair resource']]], ['HTTP_IF_MATCH' => '"revision-0"']); + self::assertSame(422, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $planning = $this->costStore()->planning(self::ORG, self::WORK); + self::assertSame(0, $planning->revision); + self::assertNull($planning->plannedBudget); + self::assertSame([], $planning->resources); + } + + #[Test] + public function externalPlanningRetainsOnlyItsExplicitAmount(): void + { + $client = $this->client(); + $body = $this->request($client, 'PATCH', '/planning', ['resources' => [['workItemId' => self::TASK, 'kind' => 'external', 'description' => 'Specialist visit', 'quantity' => null, 'unitCost' => null, 'estimatedMinutes' => null, 'amount' => '50.123456']]], ['HTTP_IF_MATCH' => '"revision-0"']); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertIsArray($body['resources']); + self::assertIsArray($body['resources'][0]); + self::assertSame('50.123456', $body['resources'][0]['amount']); + } + + /** + * @return iterable}> + */ + public static function incompleteResourceSources(): iterable + { + yield 'unknown hourly rate' => [['kind' => 'time', 'unitCost' => null, 'estimatedMinutes' => 60]]; + yield 'unknown work duration' => [['kind' => 'time', 'unitCost' => '50', 'estimatedMinutes' => null]]; + yield 'unknown part unit cost' => [['kind' => 'material', 'unitCost' => null, 'quantity' => '2']]; + yield 'unknown material quantity' => [['kind' => 'material', 'unitCost' => '50', 'quantity' => null]]; + } + + /** + * @param array $resource + */ + #[Test] + #[DataProvider('incompleteResourceSources')] + public function incompletePlanningSourcesDoNotRetainAnEarlierCalculatedAmount(array $resource): void + { + $client = $this->client(); + $body = $this->request($client, 'PATCH', '/planning', ['resources' => [$resource + ['workItemId' => self::TASK, 'description' => 'Repair resource', 'amount' => '100']]], ['HTTP_IF_MATCH' => '"revision-0"']); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertIsArray($body['resources']); + self::assertIsArray($body['resources'][0]); + self::assertNull($body['resources'][0]['amount']); + } + + #[Test] + public function planningRequiresItsExpectedRevision(): void + { + $client = $this->client(); + $this->request($client, 'PATCH', '/planning', ['plannedBudget' => '100']); + self::assertSame(428, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + } + + #[Test] + public function stalePlanningCannotOverwriteTheCurrentRevision(): void + { + $client = $this->client(); + $this->request($client, 'PATCH', '/planning', ['plannedBudget' => '100'], ['HTTP_IF_MATCH' => '"revision-1"']); + self::assertSame(412, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + } + + #[Test] + public function operationalMemberCannotReadFinancialAmounts(): void + { + $client = $this->client(self::USER); + $this->request($client, 'GET'); + self::assertSame(403, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function financialReaderCannotWritePlanning(): void + { + $client = $this->client(self::READER); + $this->request($client, 'PATCH', '/planning', ['plannedBudget' => '100'], ['HTTP_IF_MATCH' => '"revision-0"']); + self::assertSame(403, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function managementWithoutReadCannotObtainThePrivateProjectionByWritingAnExpense(): void + { + $client = $this->client(self::READER, ['organization.maintenance_cost.manage']); + $this->seedExpense(); + $input = $this->expenseInput(); + $input['clientId'] = 'unreadable-expense-2'; + $body = $this->request($client, 'POST', '/expenses', $input); + self::assertSame(403, $client->getResponse()->getStatusCode()); + self::assertArrayNotHasKey('current', $body); + self::assertArrayNotHasKey('frozen', $body); + self::assertStringNotContainsString('50.000001', (string) $client->getResponse()->getContent()); + self::assertCount(1, $this->costStore()->expenses(self::ORG, self::WORK)); + } + + #[Test] + public function managementWithoutReadCannotObtainCostsByPreparingABudget(): void + { + $client = $this->client(self::READER, ['organization.maintenance_cost.manage']); + $body = $this->request($client, 'PATCH', '/planning', ['plannedBudget' => '100'], ['HTTP_IF_MATCH' => '"revision-0"']); + self::assertSame(403, $client->getResponse()->getStatusCode()); + self::assertArrayNotHasKey('current', $body); + self::assertSame(0, $this->costStore()->planning(self::ORG, self::WORK)->revision); + } + + #[Test] + public function readOnlyFinanceReceivesANonEditablePlanningCapability(): void + { + $client = $this->client(self::READER); + $body = $this->request($client, 'GET'); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertFalse($body['planningEditable']); + } + + #[Test] + public function outsiderCannotDiscoverInterventionFinance(): void + { + $client = $this->client('750e8400-e29b-41d4-a716-448040000099'); + $this->request($client, 'GET'); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function authorizedReaderCannotCrossTheInterventionOrganizationBoundary(): void + { + $client = $this->client(); + $foreign = new OrganizationRecord(); + $foreign->id = '750e8400-e29b-41d4-a716-448040000091'; + $foreign->name = 'Other finance scope'; + $foreign->slug = 'other-finance-scope'; + $foreign->ownerUserId = '750e8400-e29b-41d4-a716-448040000099'; + $foreign->createdByUserId = $foreign->ownerUserId; + $foreign->status = 'active'; + $foreign->isActive = true; + $foreign->createdAt = new DateTimeImmutable(); + $foreign->updatedAt = $foreign->createdAt; + $this->main()->persist($foreign); + $work = $this->seedWork($foreign, '750e8400-e29b-41d4-a716-448040000092'); + $this->request($client, 'GET', workId: $work->id); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function appendsAnExactExpenseAndFreezesItsCurrency(): void + { + $client = $this->client(); + $body = $this->request($client, 'POST', '/expenses', $this->expenseInput()); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertIsArray($body['current']); + self::assertSame('50.000001', $body['current']['total']); + self::assertSame('50.000001', $body['current']['knownTotal']); + self::assertTrue($body['current']['complete']); + self::assertTrue($this->main()->getConnection()->fetchOne('SELECT locked FROM maintenance_cost_currency_settings WHERE organization_id = :org', ['org' => self::ORG])); + self::assertCount(1, $this->costStore()->expenses(self::ORG, self::WORK)); + } + + #[Test] + public function exactReplayRetainsOnePhysicalExpenseFact(): void + { + $client = $this->client(); + $original = $this->seedExpense(); + $body = $this->request($client, 'POST', '/expenses', $this->expenseInput()); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertIsArray($body['current']); + self::assertSame('50.000001', $body['current']['total']); + self::assertCount(1, $this->costStore()->expenses(self::ORG, self::WORK)); + self::assertSame($original, $this->costStore()->expenseByClientId(self::ORG, 'external-expense-1')?->id); + } + + #[Test] + public function conflictingReplayCannotOverwriteAnExpense(): void + { + $client = $this->client(); + $this->seedExpense(); + $input = $this->expenseInput(); + $input['amount'] = '51.000001'; + $this->request($client, 'POST', '/expenses', $input); + self::assertSame(409, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('50.000001', $this->costStore()->expenseByClientId(self::ORG, 'external-expense-1')?->amount); + } + + #[Test] + public function uuidExpenseReplayNormalizesItsCaseWithoutDuplicatingTheFact(): void + { + $client = $this->client(); + $input = $this->expenseInput(); + $input['clientId'] = 'abc18400-e29b-41d4-a716-448040000041'; + /** @var CommandBusPort $commands */ + $commands = self::getContainer()->get(CommandBusPort::class); + $commands->dispatch(new WriteMaintenanceCostCommand(self::OWNER, self::ORG, self::WORK, 'expense', $input)); + $input['clientId'] = strtoupper($input['clientId']); + $this->request($client, 'POST', '/expenses', $input); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertCount(1, $this->costStore()->expenses(self::ORG, self::WORK)); + self::assertSame('abc18400-e29b-41d4-a716-448040000041', $this->costStore()->expenses(self::ORG, self::WORK)[0]->clientId); + } + + #[Test] + public function negativeExpenseRequiresTheOriginalFact(): void + { + $client = $this->client(); + $input = $this->expenseInput(); + $input['amount'] = '-10'; + $this->request($client, 'POST', '/expenses', $input); + self::assertSame(422, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertCount(0, $this->costStore()->expenses(self::ORG, self::WORK)); + } + + #[Test] + public function motivatedCorrectionRetainsItsOriginalExpense(): void + { + $client = $this->client(); + $original = $this->seedExpense(); + $input = $this->expenseInput(); + $input['clientId'] = 'external-expense-adjustment-1'; + $input['amount'] = '-10'; + $input['description'] = 'Supplier credit for returned unused part'; + $input['adjustmentOf'] = $original; + $body = $this->request($client, 'POST', '/expenses', $input); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertIsArray($body['current']); + self::assertSame('40.000001', $body['current']['total']); + self::assertSame($original, $this->costStore()->expenseByClientId(self::ORG, 'external-expense-adjustment-1')?->adjustmentOf); + self::assertCount(2, $this->costStore()->expenses(self::ORG, self::WORK)); + } + + #[Test] + public function floatingPointAmountIsRejected(): void + { + $client = $this->client(); + $input = $this->expenseInput(); + $input['amount'] = 50.1; + $this->request($client, 'POST', '/expenses', $input); + self::assertSame(422, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertCount(0, $this->costStore()->expenses(self::ORG, self::WORK)); + } + + #[Test] + public function ordinaryInterventionReadDoesNotEmbedPrivateCosts(): void + { + $client = $this->client(); + $this->seedExpense(); + $client->request('GET', '/api/interventions/' . self::WORK, server: ['HTTP_ACCEPT' => 'application/ld+json']); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + /** @var array $body */ + $body = json_decode((string) $client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + foreach (['currency', 'costs', 'plannedBudget', 'current', 'frozen', 'maintenanceCost'] as $field) { + self::assertArrayNotHasKey($field, $body); + } + } + + /** + * @param list|null $readerPermissions + */ + private function client(string $actor = self::OWNER, ?array $readerPermissions = null): KernelBrowser + { + $client = static::createClient(); + $em = $this->main(); + $now = new DateTimeImmutable(); + $organization = new OrganizationRecord(); + $organization->id = self::ORG; + $organization->name = 'Maintenance Costs API'; + $organization->slug = 'maintenance-costs-api'; + $organization->ownerUserId = self::OWNER; + $organization->createdByUserId = self::OWNER; + $organization->status = 'active'; + $organization->isActive = true; + $organization->createdAt = $now; + $organization->updatedAt = $now; + $em->persist($organization); + foreach ([self::OWNER => '750e8400-e29b-41d4-a716-448040000005', self::USER => self::MEMBER, self::READER => '750e8400-e29b-41d4-a716-448040000008'] as $userId => $memberId) { + $member = new OrganizationMemberRecord(); + $member->id = $memberId; + $member->organization = $organization; + $member->userId = $userId; + $member->isActive = true; + $member->joinedAt = $now; + $em->persist($member); + if (self::OWNER === $userId || self::READER === $userId) { + $role = new OrganizationRoleRecord(); + $role->id = self::OWNER === $userId ? '750e8400-e29b-41d4-a716-448040000006' : '750e8400-e29b-41d4-a716-448040000009'; + $role->organization = $organization; + $role->name = self::OWNER === $userId ? 'cost_owner' : 'cost_reader'; + $role->permissions = self::OWNER === $userId ? ['*'] : ($readerPermissions ?? ['organization.maintenance_cost.read']); + $role->isSystem = false; + $role->createdAt = $now; + $em->persist($role); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $em->persist($assignment); + } + } + $this->seedWork($organization); + $em->flush(); + $client->loginUser(new SecurityUser($actor, $actor . '@example.com', 'password', ['ROLE_USER']), 'api'); + + return $client; + } + + private function seedWork(OrganizationRecord $organization, string $id = self::WORK): InterventionRecord + { + $now = new DateTimeImmutable(); + $work = new InterventionRecord(); + $work->id = $id; + $work->organization = $organization; + $work->name = 'Fire park repair'; + $work->number = 1; + $work->type = 'corrective_maintenance'; + $work->status = 'draft'; + $work->createdAt = $now; + $work->updatedAt = $now; + $this->main()->persist($work); + if (self::WORK === $id) { + $task = new InterventionWorkItemRecord(); + $task->id = self::TASK; + $task->intervention = $work; + $task->action = 'repair'; + $task->status = 'planned'; + $task->source = 'planned'; + $task->createdAt = $now; + $task->updatedAt = $now; + $this->main()->persist($task); + } + $this->main()->flush(); + + return $work; + } + + private function seedTime(): void + { + $entry = new InterventionTimeEntryRecord(); + $entry->id = '750e8400-e29b-41d4-a716-448040000030'; + $entry->workItem = $this->main()->getReference(InterventionWorkItemRecord::class, self::TASK); + $entry->organizationId = self::ORG; + $entry->memberId = self::MEMBER; + $entry->workedOn = '2026-01-01'; + $entry->minutes = 30; + $entry->createdBy = self::OWNER; + $entry->updatedBy = self::OWNER; + $entry->createdAt = new DateTimeImmutable('2026-01-01T12:00:00Z'); + $entry->updatedAt = $entry->createdAt; + $this->main()->persist($entry); + $this->main()->flush(); + } + + private function seedExpense(): string + { + /** @var CommandBusPort $commands */ + $commands = self::getContainer()->get(CommandBusPort::class); + $commands->dispatch(new WriteMaintenanceCostCommand(self::OWNER, self::ORG, self::WORK, 'expense', $this->expenseInput())); + $expense = $this->costStore()->expenseByClientId(self::ORG, 'external-expense-1'); + self::assertNotNull($expense); + + return $expense->id; + } + + private function costStore(): MaintenanceCostStorePort + { + /** @var MaintenanceCostStorePort $store */ + $store = self::getContainer()->get(MaintenanceCostStorePort::class); + + return $store; + } + + private function main(): EntityManagerInterface + { + /** @var EntityManagerInterface $em */ + $em = self::getContainer()->get('doctrine.orm.main_entity_manager'); + + return $em; + } + + /** + * @return array + */ + private function expenseInput(): array + { + return ['clientId' => 'external-expense-1', 'amount' => '50.000001', 'description' => 'External specialist repair', 'incurredAt' => '2026-01-01T12:00:00+00:00', 'workItemId' => self::TASK]; + } + + /** + * @param array|null $body + * @param array $headers + * + * @return array + */ + private function request(KernelBrowser $client, string $method, string $suffix = '', ?array $body = null, array $headers = [], string $workId = self::WORK): array + { + $client->request($method, '/api/organizations/' . self::ORG . '/interventions/' . $workId . '/costs' . $suffix, server: $headers + [ + 'CONTENT_TYPE' => 'PATCH' === $method ? 'application/merge-patch+json' : 'application/ld+json', + 'HTTP_ACCEPT' => 'application/ld+json', + ], content: null === $body ? null : json_encode($body, JSON_THROW_ON_ERROR)); + /** @var array $decoded */ + $decoded = json_decode((string) $client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + + return $decoded; + } +} diff --git a/tests/Functional/Api/MaintenanceCurrencyRatesApiTest.php b/tests/Functional/Api/MaintenanceCurrencyRatesApiTest.php new file mode 100644 index 000000000..0e8df2de4 --- /dev/null +++ b/tests/Functional/Api/MaintenanceCurrencyRatesApiTest.php @@ -0,0 +1,277 @@ +client(); + $body = $this->request($client, 'GET', '/currency'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('EUR', $body['currency']); + self::assertFalse($body['locked']); + self::assertSame(0, $this->countRows('maintenance_cost_currency_settings')); + } + + #[Test] + public function configuresCurrencyBeforeTheFirstFinancialFact(): void + { + $client = $this->client(); + $body = $this->request($client, 'PATCH', '/currency', ['currency' => 'USD']); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('USD', $body['currency']); + self::assertFalse($body['locked']); + } + + #[Test] + public function aCapturedFactPreventsCurrencyChanges(): void + { + $client = $this->client(); + /** @var MaintenanceCurrencyPort $currency */ + $currency = self::getContainer()->get(MaintenanceCurrencyPort::class); + $this->main()->getConnection()->transactional(static fn () => $currency->lock(self::ORG)); + $this->request($client, 'PATCH', '/currency', ['currency' => 'USD']); + self::assertSame(409, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('EUR', $currency->forOrganization(self::ORG)); + } + + #[Test] + public function createsAnExactRateForAnOrganizationMember(): void + { + $client = $this->client(); + $body = $this->request($client, 'POST', '/rates', $this->rateInput()); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('42.000001', $body['hourlyAmount']); + self::assertSame(self::MEMBER, $body['memberId']); + self::assertSame('EUR', $body['currency']); + self::assertSame('2026-01-01', $body['effectiveFrom']); + self::assertFalse($body['replayed']); + } + + #[Test] + public function idempotentRequestKeepsItsOnlyRate(): void + { + $client = $this->client(); + $this->seedRate(); + $body = $this->request($client, 'POST', '/rates', $this->rateInput()); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(self::RATE, $body['id']); + self::assertTrue($body['replayed']); + self::assertSame(1, $this->countRows('maintenance_cost_hourly_rates')); + } + + #[Test] + public function differentParametersCannotReuseAClientIdentity(): void + { + $client = $this->client(); + $this->seedRate(); + $input = $this->rateInput(); + $input['hourlyAmount'] = '43'; + $this->request($client, 'POST', '/rates', $input); + self::assertSame(409, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + } + + #[Test] + public function anotherRequestCannotOverwriteTheSameEffectiveDate(): void + { + $client = $this->client(); + $this->seedRate(); + $input = $this->rateInput(); + $input['clientId'] = '650e8400-e29b-41d4-a716-448040000012'; + $this->request($client, 'POST', '/rates', $input); + self::assertSame(409, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + } + + #[Test] + public function memberFromAnotherOrganizationIsNotFound(): void + { + $client = $this->client(); + $input = $this->rateInput(); + $input['memberId'] = '650e8400-e29b-41d4-a716-448040000099'; + $this->request($client, 'POST', '/rates', $input); + self::assertSame(404, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + } + + #[Test] + public function listsExactRatesWithTheSameMemberFilterAndTotal(): void + { + $client = $this->client(); + $this->seedRate(); + $body = $this->request($client, 'GET', '/rates?memberId=' . self::MEMBER . '&itemsPerPage=1'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(1, $body['totalItems']); + self::assertIsArray($body['member']); + self::assertCount(1, $body['member']); + self::assertIsArray($body['member'][0]); + self::assertSame('42.000001', $body['member'][0]['hourlyAmount']); + } + + #[Test] + public function operationalMemberCannotReadPrivateRates(): void + { + $client = $this->client(self::USER); + $this->request($client, 'GET', '/rates'); + self::assertSame(403, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function operationalMemberCannotConfigurePrivateFinance(): void + { + $client = $this->client(self::USER); + $this->request($client, 'PATCH', '/currency', ['currency' => 'USD']); + self::assertSame(403, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function outsiderCannotEnumerateOrganizationCurrency(): void + { + $client = $this->client('650e8400-e29b-41d4-a716-448040000099'); + $this->request($client, 'GET', '/currency'); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function outsiderCannotAppendARate(): void + { + $client = $this->client('650e8400-e29b-41d4-a716-448040000099'); + $this->request($client, 'POST', '/rates', $this->rateInput()); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function negativeRateIsRejectedWithoutAWrite(): void + { + $client = $this->client(); + $input = $this->rateInput(); + $input['hourlyAmount'] = '-1'; + $this->request($client, 'POST', '/rates', $input); + self::assertSame(422, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(0, $this->countRows('maintenance_cost_hourly_rates')); + } + + private function client(string $actor = self::OWNER): KernelBrowser + { + $client = static::createClient(); + $em = $this->main(); + $now = new DateTimeImmutable(); + $organization = new OrganizationRecord(); + $organization->id = self::ORG; + $organization->name = 'Private Cost Finance'; + $organization->slug = 'private-cost-finance'; + $organization->ownerUserId = self::OWNER; + $organization->createdByUserId = self::OWNER; + $organization->status = 'active'; + $organization->isActive = true; + $organization->createdAt = $now; + $organization->updatedAt = $now; + $em->persist($organization); + foreach ([self::OWNER => '650e8400-e29b-41d4-a716-448040000005', self::USER => self::MEMBER] as $userId => $memberId) { + $member = new OrganizationMemberRecord(); + $member->id = $memberId; + $member->organization = $organization; + $member->userId = $userId; + $member->isActive = true; + $member->joinedAt = $now; + $em->persist($member); + if (self::OWNER === $userId) { + $role = new OrganizationRoleRecord(); + $role->id = '650e8400-e29b-41d4-a716-448040000006'; + $role->organization = $organization; + $role->name = 'private_cost_owner'; + $role->permissions = ['*']; + $role->isSystem = false; + $role->createdAt = $now; + $em->persist($role); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $em->persist($assignment); + } + } + $em->flush(); + $client->loginUser(new SecurityUser($actor, $actor . '@example.com', 'password', ['ROLE_USER']), 'api'); + + return $client; + } + + private function seedRate(): void + { + /** @var MaintenanceRateStorePort $rates */ + $rates = self::getContainer()->get(MaintenanceRateStorePort::class); + $rates->synchronized(self::ORG, static fn () => $rates->append(self::ORG, self::CLIENT, new MaintenanceRateSnapshot(self::RATE, self::MEMBER, '42.000001', 'EUR', '2026-01-01'))); + } + + private function main(): EntityManagerInterface + { + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.main_entity_manager'); + + return $em; + } + + /** + * @return array + */ + private function rateInput(): array + { + return ['clientId' => self::CLIENT, 'memberId' => self::MEMBER, 'hourlyAmount' => '42.000001', 'effectiveFrom' => '2026-01-01']; + } + + /** + * @param array|null $body + * + * @return array + */ + private function request(KernelBrowser $client, string $method, string $suffix, ?array $body = null): array + { + $client->request($method, '/api/organizations/' . self::ORG . '/maintenance-cost' . $suffix, server: [ + 'CONTENT_TYPE' => 'PATCH' === $method ? 'application/merge-patch+json' : 'application/ld+json', + 'HTTP_ACCEPT' => 'application/ld+json', + ], content: null === $body ? null : json_encode($body, JSON_THROW_ON_ERROR)); + /** @var array $decoded */ + $decoded = json_decode((string) $client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + + return $decoded; + } + + private function countRows(string $table): int + { + /** @var int|numeric-string $count */ + $count = $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM ' . $table . ' WHERE organization_id = :org', ['org' => self::ORG]); + + return (int) $count; + } +} diff --git a/tests/Functional/Api/MaintenanceEconomicApiTest.php b/tests/Functional/Api/MaintenanceEconomicApiTest.php new file mode 100644 index 000000000..46dae1ed5 --- /dev/null +++ b/tests/Functional/Api/MaintenanceEconomicApiTest.php @@ -0,0 +1,506 @@ +client(); + $this->expense('850e8400-e29b-41d4-a716-448040000030', '12.123456'); + $body = $this->get($client, '/reports?from=2026-10-01&to=2026-10-31&groupBy=equipment'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('EUR', $body['currency']); + self::assertSame(1, $body['interventionCount']); + self::assertSame(0, $body['totalItems']); + self::assertIsArray($body['current']); + self::assertSame('12.123456', $body['current']['total']); + self::assertIsArray($body['unallocated']); + self::assertNull($body['unallocated']['id']); + self::assertIsArray($body['unallocated']['current']); + self::assertSame('12.123456', $body['unallocated']['current']['total']); + self::assertIsArray($body['planned']); + self::assertFalse($body['planned']['complete']); + self::assertNull($body['variance']); + self::assertIsArray($body['reconciliation']); + self::assertTrue($body['reconciliation']['reconciled']); + $client->request('GET', '/api/interventions/' . self::WORK, server: ['HTTP_ACCEPT' => 'application/ld+json']); + self::assertSame(403, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function privateDirectoryKeepsOnlyMinimalLabelsAndUsesServerSearch(): void + { + $client = $this->client(); + $body = $this->get($client, '/dossiers?search=Economic&page=1&itemsPerPage=1'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(1, $body['totalItems']); + self::assertIsArray($body['member']); + self::assertCount(1, $body['member']); + self::assertIsArray($body['member'][0]); + self::assertSame('Economic fire park repair', $body['member'][0]['name']); + self::assertSame('live', $body['member'][0]['snapshotState']); + foreach (['contacts', 'dossier', 'closureSnapshot', 'current', 'frozen', 'attachments', 'description'] as $key) { + self::assertArrayNotHasKey($key, $body['member'][0]); + } + $empty = $this->get($client, '/dossiers?search=Absent'); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(0, $empty['totalItems']); + self::assertSame([], $empty['member']); + } + + #[Test] + public function missingHistoricPublicationIsExplicitlyIncomplete(): void + { + $client = $this->client(); + $work = $this->main()->find(InterventionRecord::class, self::WORK); + self::assertInstanceOf(InterventionRecord::class, $work); + $work->status = 'published'; + $publication = new PublicationRecord(); + $publication->id = '850e8400-e29b-41d4-a716-448040000091'; + $publication->intervention = $work; + $publication->status = 'completed'; + $publication->interventionRevision = 1; + $publication->createdAt = new DateTimeImmutable('2026-10-07T14:00:00Z'); + $publication->completedAt = new DateTimeImmutable('2026-10-07T15:00:00Z'); + $this->main()->persist($publication); + $this->main()->flush(); + $body = $this->get($client, '/reports?from=2026-10-01&to=2026-10-31'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(1, $body['missingSnapshotCount']); + self::assertSame(1, $body['publishedInterventionCount']); + self::assertIsArray($body['frozen']); + self::assertNull($body['frozen']['total']); + self::assertFalse($body['frozen']['complete']); + self::assertSame('0.000000', $body['frozen']['knownTotal']); + } + + #[Test] + public function laterFactsChangeCurrentTotalWithoutReplacingPrivatePublication(): void + { + $client = $this->client(); + $this->expense('850e8400-e29b-41d4-a716-448040000030', '50.000001'); + $item = new MaintenanceCostItem('expense:850e8400-e29b-41d4-a716-448040000030', 'expense', null, '850e8400-e29b-41d4-a716-448040000030', null, '50.000001', 'EUR', 'External repair', '2026-10-07T12:00:00+00:00'); + $store = self::getContainer()->get(MaintenanceCostStorePort::class); + self::assertInstanceOf(MaintenanceCostStorePort::class, $store); + $store->saveSnapshot(self::ORG, self::WORK, new MaintenanceCostSnapshot(1, '2026-10-07T15:00:00Z', '850e8400-e29b-41d4-a716-448040000090', 1, 'EUR', new MaintenanceCostTotals('50.000001', '50.000001', true, [$item]), new MaintenanceCostPlanning('100.000000'))); + $this->expense('850e8400-e29b-41d4-a716-448040000031', '20.000002'); + $body = $this->get($client, '/reports?from=2026-10-01&to=2026-10-31'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertIsArray($body['current']); + self::assertSame('70.000003', $body['current']['total']); + self::assertIsArray($body['frozen']); + self::assertSame('50.000001', $body['frozen']['total']); + self::assertSame('50.000001', $store->snapshot(self::ORG, self::WORK)?->totals->total); + } + + /** + * @return iterable + */ + public static function directMaterialFilters(): iterable + { + yield 'asset without work item' => ['equipment', 'equipmentId', '850e8400-e29b-41d4-a716-448040000061', 'Directly maintained asset']; + yield 'actual asset root site' => ['site', 'siteId', '850e8400-e29b-41d4-a716-448040000062', 'Direct material site']; + yield 'actual asset internal client' => ['customer', 'customerId', '850e8400-e29b-41d4-a716-448040000063', 'Direct material client']; + } + + #[Test] + #[DataProvider('directMaterialFilters')] + public function directMaterialWithoutOperationalTaskRemainsInEveryApplicableFinancialFilter(string $group, string $filter, string $id, string $name): void + { + $client = $this->client(); + $this->directMaterial(); + $unrelated = new InterventionRecord(); + $unrelated->id = '850e8400-e29b-41d4-a716-448040000065'; + $unrelated->organization = $this->main()->getReference(OrganizationRecord::class, self::ORG); + $unrelated->number = 2; + $unrelated->name = 'Unrelated global repair'; + $unrelated->type = 'corrective_maintenance'; + $unrelated->status = 'draft'; + $unrelated->createdAt = $unrelated->updatedAt = new DateTimeImmutable('2026-10-07T12:00:00Z'); + $this->main()->persist($unrelated); + $this->main()->flush(); + $this->expense('850e8400-e29b-41d4-a716-448040000066', '7.000000', $unrelated->id); + $body = $this->get($client, '/reports?from=2026-10-01&to=2026-10-31&groupBy=' . $group . '&' . $filter . '=' . $id); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(1, $body['interventionCount']); + self::assertIsArray($body['current']); + self::assertSame('10.625000', $body['current']['total']); + self::assertIsArray($body['rows']); + self::assertCount(1, $body['rows']); + self::assertIsArray($body['rows'][0]); + self::assertSame($id, $body['rows'][0]['id']); + self::assertSame($name, $body['rows'][0]['name']); + self::assertIsArray($body['rows'][0]['current']); + self::assertSame('10.625000', $body['rows'][0]['current']['total']); + self::assertIsArray($body['reconciliation']); + self::assertTrue($body['reconciliation']['reconciled']); + } + + #[Test] + #[DataProvider('directMaterialFilters')] + public function directMaterialFinancialDirectoryHasMinimalNamedTargetsAndExactFilteredPages(string $group, string $filter, string $id, string $name): void + { + $client = $this->client(); + $this->directMaterial(); + $directory = $this->get($client, '/dossiers?search=Economic&from=2026-10-01&to=2026-10-31&itemsPerPage=1&' . $filter . '=' . $id); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(1, $directory['totalItems']); + self::assertIsArray($directory['member']); + self::assertCount(1, $directory['member']); + self::assertIsArray($directory['member'][0]); + self::assertSame(self::WORK, $directory['member'][0]['id']); + self::assertIsArray($directory['member'][0]['equipment']); + self::assertCount(1, $directory['member'][0]['equipment']); + self::assertIsArray($directory['member'][0]['equipment'][0]); + $equipment = $directory['member'][0]['equipment'][0]; + self::assertSame('850e8400-e29b-41d4-a716-448040000061', $equipment['id']); + self::assertSame('Directly maintained asset', $equipment['name']); + if ('equipment' !== $group) { + self::assertIsArray($equipment[$group]); + self::assertSame($id, $equipment[$group]['id']); + self::assertSame($name, $equipment[$group]['name']); + } + foreach (['current', 'frozen', 'amount', 'description', 'contacts'] as $key) { + self::assertArrayNotHasKey($key, $directory['member'][0]); + self::assertArrayNotHasKey($key, $equipment); + } + $next = $this->get($client, '/dossiers?page=2&itemsPerPage=1&' . $filter . '=' . $id); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(1, $next['totalItems']); + self::assertSame([], $next['member']); + foreach (['search=Absent', 'from=2026-09-01&to=2026-09-30'] as $otherFilter) { + $empty = $this->get($client, '/dossiers?' . $otherFilter . '&' . $filter . '=' . $id); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(0, $empty['totalItems']); + self::assertSame([], $empty['member']); + } + $all = $this->get($client, '/dossiers'); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertIsArray($all['member']); + self::assertIsArray($all['member'][0]); + self::assertSame([$equipment], $all['member'][0]['equipment']); + } + + #[Test] + public function frozenDirectMaterialDirectoryRetainsCapturedTargetsAfterTheEquipmentMoves(): void + { + $client = $this->client(); + $this->directMaterial(); + $costs = self::getContainer()->get(MaintenanceCostReadPort::class); + self::assertInstanceOf(MaintenanceCostReadPort::class, $costs); + $current = $costs->view(self::ORG, self::WORK); + $store = self::getContainer()->get(MaintenanceCostStorePort::class); + self::assertInstanceOf(MaintenanceCostStorePort::class, $store); + $store->saveSnapshot(self::ORG, self::WORK, new MaintenanceCostSnapshot(1, '2026-10-07T15:00:00Z', '850e8400-e29b-41d4-a716-448040000091', 1, 'EUR', $current->current, $current->planning)); + $this->publishWithoutOperationalSnapshot(); + $equipment = $this->main()->find(EquipmentRecord::class, '850e8400-e29b-41d4-a716-448040000061'); + self::assertInstanceOf(EquipmentRecord::class, $equipment); + $equipment->facilityId = null; + $equipment->name = 'Moved and renamed asset'; + $this->main()->flush(); + foreach (['siteId=850e8400-e29b-41d4-a716-448040000062', 'customerId=850e8400-e29b-41d4-a716-448040000063', 'equipmentId=850e8400-e29b-41d4-a716-448040000061'] as $filter) { + $directory = $this->get($client, '/dossiers?' . $filter); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(1, $directory['totalItems']); + self::assertIsArray($directory['member']); + self::assertIsArray($directory['member'][0]); + self::assertIsArray($directory['member'][0]['equipment']); + self::assertIsArray($directory['member'][0]['equipment'][0]); + self::assertSame('Directly maintained asset', $directory['member'][0]['equipment'][0]['name']); + self::assertIsArray($directory['member'][0]['equipment'][0]['site']); + self::assertSame('Direct material site', $directory['member'][0]['equipment'][0]['site']['name']); + } + $report = $this->get($client, '/reports?from=2026-10-01&to=2026-10-31&groupBy=site&siteId=850e8400-e29b-41d4-a716-448040000062'); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(1, $report['interventionCount']); + self::assertIsArray($report['current']); + self::assertIsArray($report['frozen']); + self::assertSame('10.625000', $report['current']['total']); + self::assertSame('10.625000', $report['frozen']['total']); + self::assertSame($current->current->items[0]->allocation, $store->snapshot(self::ORG, self::WORK)?->totals->items[0]->allocation); + } + + #[Test] + public function historicalDirectMaterialWithNoFinancialSnapshotNeverAcquiresCurrentSiteOrClient(): void + { + $client = $this->client(); + $this->directMaterial(); + $this->publishWithoutOperationalSnapshot(); + $report = $this->get($client, '/reports?from=2026-10-01&to=2026-10-31&groupBy=site'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(0, $report['totalItems']); + self::assertSame([], $report['rows']); + self::assertIsArray($report['unallocated']); + self::assertIsArray($report['unallocated']['current']); + self::assertSame('10.625000', $report['unallocated']['current']['total']); + $directory = $this->get($client, '/dossiers'); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertIsArray($directory['member']); + self::assertIsArray($directory['member'][0]); + self::assertIsArray($directory['member'][0]['equipment']); + self::assertIsArray($directory['member'][0]['equipment'][0]); + self::assertNull($directory['member'][0]['equipment'][0]['name']); + self::assertNull($directory['member'][0]['equipment'][0]['site']); + self::assertNull($directory['member'][0]['equipment'][0]['customer']); + foreach (['siteId=850e8400-e29b-41d4-a716-448040000062', 'customerId=850e8400-e29b-41d4-a716-448040000063'] as $filter) { + $filtered = $this->get($client, '/dossiers?' . $filter); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(0, $filtered['totalItems']); + self::assertSame([], $filtered['member']); + } + } + + /** + * @return iterable + */ + public static function routes(): iterable + { + yield 'report' => ['/reports?from=2026-10-01&to=2026-10-31']; + yield 'directory' => ['/dossiers']; + } + + #[Test] + #[DataProvider('routes')] + public function financialDenialPrecedesAllPrivateData(string $suffix): void + { + $client = $this->client(self::USER); + $this->get($client, $suffix); + self::assertSame(403, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + } + + #[Test] + #[DataProvider('routes')] + public function organizationOutsideScopeHasNoExistenceOracle(string $suffix): void + { + $client = $this->client(); + $client->request('GET', '/api/organizations/850e8400-e29b-41d4-a716-448040000099/maintenance-cost' . $suffix, server: ['HTTP_ACCEPT' => 'application/ld+json']); + self::assertSame(404, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + } + + /** + * @return iterable + */ + public static function invalidWindows(): iterable + { + yield 'invalid calendar day' => ['/reports?from=2026-02-30&to=2026-03-01']; + yield 'reversed' => ['/reports?from=2026-10-31&to=2026-10-01']; + yield 'too broad' => ['/reports?from=2025-01-01&to=2026-10-31']; + yield 'unknown group' => ['/reports?from=2026-10-01&to=2026-10-31&groupBy=invoice']; + yield 'missing second directory date' => ['/dossiers?from=2026-10-01']; + yield 'oversized row page' => ['/reports?from=2026-10-01&to=2026-10-31&itemsPerPage=101']; + yield 'invalid report equipment' => ['/reports?from=2026-10-01&to=2026-10-31&equipmentId=invalid']; + yield 'invalid report site' => ['/reports?from=2026-10-01&to=2026-10-31&siteId=invalid']; + yield 'invalid directory client' => ['/dossiers?customerId=invalid']; + } + + #[Test] + #[DataProvider('invalidWindows')] + public function invalidReportsFailWithoutPartialTotals(string $suffix): void + { + $client = $this->client(); + $body = $this->get($client, $suffix); + self::assertSame(422, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertArrayNotHasKey('current', $body); + } + + /** + * @param non-empty-string $actor authenticated fixture principal + */ + private function client(string $actor = self::READER): KernelBrowser + { + $client = static::createClient(); + $client->disableReboot(); + $users = $this->createStub(UserRepositoryPort::class); + $users->method('findById')->willReturnCallback(static fn (UserId $id) => UserTestFactory::createActive((string) $id, (string) $id . '@example.com')); + self::getContainer()->set(UserRepositoryPort::class, $users); + $client->setServerParameter('HTTP_AUTHORIZATION', 'Bearer ' . InteractiveTokenFactory::issue(self::getContainer(), $actor, $actor . '@example.com')); + $now = new DateTimeImmutable('2026-10-07T12:00:00Z'); + $org = new OrganizationRecord(); + $org->id = self::ORG; + $org->name = 'Economic API'; + $org->slug = 'economic-api'; + $org->ownerUserId = self::OWNER; + $org->createdByUserId = self::OWNER; + $org->status = 'active'; + $org->isActive = true; + $org->createdAt = $org->updatedAt = $now; + $this->main()->persist($org); + foreach ([self::READER, self::USER] as $index => $userId) { + $member = new OrganizationMemberRecord(); + $member->id = '850e8400-e29b-41d4-a716-44804000000' . (5 + $index); + $member->organization = $org; + $member->userId = $userId; + $member->joinedAt = $now; + $member->isActive = true; + $this->main()->persist($member); + if (self::READER === $userId) { + $role = new OrganizationRoleRecord(); + $role->id = '850e8400-e29b-41d4-a716-448040000007'; + $role->organization = $org; + $role->name = 'Private finance only'; + $role->permissions = ['organization.maintenance_cost.read']; + $role->isSystem = false; + $role->createdAt = $now; + $this->main()->persist($role); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $this->main()->persist($assignment); + } + } + $work = new InterventionRecord(); + $work->id = self::WORK; + $work->organization = $org; + $work->name = 'Economic fire park repair'; + $work->number = 1; + $work->type = 'corrective_maintenance'; + $work->status = 'draft'; + $work->createdAt = $work->updatedAt = $now; + $this->main()->persist($work); + $this->main()->flush(); + + return $client; + } + + private function expense(string $id, string $amount, string $workId = self::WORK): void + { + $record = new MaintenanceExpenseRecord(); + $record->id = $id; + $record->organizationId = self::ORG; + $record->interventionId = $workId; + $record->clientId = $id; + $record->amount = $amount; + $record->currency = 'EUR'; + $record->description = 'External repair'; + $record->incurredAt = $record->createdAt = new DateTimeImmutable('2026-10-07T12:00:00Z'); + $record->createdBy = self::OWNER; + $record->payloadHash = 'economic-fixture'; + $this->main()->persist($record); + $this->main()->flush(); + } + + private function publishWithoutOperationalSnapshot(): void + { + $work = $this->main()->find(InterventionRecord::class, self::WORK); + self::assertInstanceOf(InterventionRecord::class, $work); + $work->status = 'published'; + $publication = new PublicationRecord(); + $publication->id = '850e8400-e29b-41d4-a716-448040000091'; + $publication->intervention = $work; + $publication->status = 'completed'; + $publication->interventionRevision = 1; + $publication->createdAt = new DateTimeImmutable('2026-10-07T14:00:00Z'); + $publication->completedAt = new DateTimeImmutable('2026-10-07T15:00:00Z'); + $this->main()->persist($publication); + $this->main()->flush(); + } + + private function directMaterial(): void + { + $now = new DateTimeImmutable('2026-10-07T12:00:00Z'); + $customer = new CustomerRecord(); + $customer->id = '850e8400-e29b-41d4-a716-448040000063'; + $customer->organizationId = self::ORG; + $customer->name = 'Direct material client'; + $customer->createdAt = $customer->updatedAt = $now; + $this->main()->persist($customer); + $site = new FacilityRecord(); + $site->id = '850e8400-e29b-41d4-a716-448040000062'; + $site->organization = $this->main()->getReference(OrganizationRecord::class, self::ORG); + $site->type = 'site'; + $site->name = 'Direct material site'; + $site->customerId = $customer->id; + $site->createdAt = $site->updatedAt = $now; + $this->main()->persist($site); + $equipment = new EquipmentRecord(); + $equipment->id = '850e8400-e29b-41d4-a716-448040000061'; + $equipment->organization = $site->organization; + $equipment->facilityId = $site->id; + $equipment->type = 'extinguisher'; + $equipment->name = 'Directly maintained asset'; + $equipment->createdAt = $equipment->updatedAt = $now; + $this->main()->persist($equipment); + $movement = new InventoryMovementRecord(); + $movement->id = '850e8400-e29b-41d4-a716-448040000064'; + $movement->organizationId = self::ORG; + $movement->interventionId = self::WORK; + $movement->equipmentId = $equipment->id; + $movement->warehouseId = '850e8400-e29b-41d4-a716-448040000067'; + $movement->partId = '850e8400-e29b-41d4-a716-448040000068'; + $movement->kind = 'consumption'; + $movement->quantity = '-2.500000'; + $movement->unitCost = '4.250000'; + $movement->totalValue = '-10.625000'; + $movement->currency = 'EUR'; + $movement->reason = 'Direct asset maintenance'; + $movement->actorId = self::OWNER; + $movement->occurredAt = $now; + $this->main()->persist($movement); + $this->main()->flush(); + } + + /** + * @return array + */ + private function get(KernelBrowser $client, string $suffix): array + { + $client->request('GET', '/api/organizations/' . self::ORG . '/maintenance-cost' . $suffix, server: ['HTTP_ACCEPT' => 'application/ld+json']); + /** @var array $body */ + $body = json_decode((string) $client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + + return $body; + } + + private function main(): EntityManagerInterface + { + $manager = self::getContainer()->get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $manager); + + return $manager; + } +} diff --git a/tests/Functional/Api/MaintenanceExportApiTest.php b/tests/Functional/Api/MaintenanceExportApiTest.php new file mode 100644 index 000000000..8f2815b3c --- /dev/null +++ b/tests/Functional/Api/MaintenanceExportApiTest.php @@ -0,0 +1,766 @@ +start(); + $export = $this->call($client, 'POST', '/maintenance-exports', $this->input()); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $id = $this->id($export); + self::assertSame('generated', $export['state']); + self::assertSame(1, $export['schemaVersion']); + $json = $this->download($client, $id, 'json'); + $csv = $this->download($client, $id, 'csv'); + self::assertStringContainsString('Site at publication', $json); + self::assertStringContainsString('Customer at publication', $json); + self::assertStringContainsString('Extincteur A-01', $json); + self::assertStringNotContainsString('contacts', $json); + self::assertStringNotContainsString('amount', $json); + self::assertStringNotContainsString('currency', $csv); + self::assertIsArray($export['files']); + self::assertIsArray($export['files']['json']); + self::assertSame(hash('sha256', $json), $export['files']['json']['sha256']); + $this->main()->getConnection()->executeStatement('UPDATE interventions SET name = :name WHERE id = :id', ['name' => 'Live renamed work', 'id' => self::WORK]); + $this->changeTime(60, 2); + $confirmed = $this->call($client, 'POST', '/maintenance-exports/' . $id . '/confirm', ['clientOperationId' => self::OP2, 'externalImportReference' => 'ERP-IMPORTED-42'], 1); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('import_confirmed', $confirmed['state']); + self::assertSame(2, $confirmed['revision']); + self::assertSame($json, $this->download($client, $id, 'json')); + self::assertSame($csv, $this->download($client, $id, 'csv')); + $upper = $this->input(); + $upper['clientOperationId'] = strtoupper(self::OP); + $upper['interventionIds'] = [strtoupper(self::WORK)]; + $replay = $this->call($client, 'POST', '/maintenance-exports', $upper); + self::assertSame(201, $client->getResponse()->getStatusCode()); + self::assertTrue($replay['replayed']); + self::assertSame($id, $replay['id']); + self::assertSame('generated', $replay['state'], 'A transport replay returns the exact original receipt metadata.'); + self::assertSame($json, $this->download($client, $id, 'json')); + } + + #[Test] + public function genuineCorrectionsAppendCompensationAndCannotRepeatOrFork(): void + { + $client = $this->start(); + $original = $this->call($client, 'POST', '/maintenance-exports', $this->input()); + $id = $this->id($original); + $bytes = $this->download($client, $id, 'json'); + $this->call($client, 'POST', '/maintenance-exports/' . $id . '/adjustments', ['clientOperationId' => self::OP2, 'reason' => 'No real change'], 1); + self::assertSame(409, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $this->changeTime(60, 2); + $input = ['clientOperationId' => self::OP2, 'reason' => 'Corrected the validated time journal']; + $adjusted = $this->call($client, 'POST', '/maintenance-exports/' . $id . '/adjustments', $input, 1); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $adjustmentId = $this->id($adjusted); + self::assertSame($id, $adjusted['originalExportId']); + self::assertSame($id, $adjusted['adjustmentOf']); + $adjustment = $this->decode($this->download($client, $adjustmentId, 'json')); + self::assertIsArray($adjustment['rows']); + self::assertCount(2, $adjustment['rows']); + self::assertIsArray($adjustment['rows'][0]); + self::assertIsArray($adjustment['rows'][1]); + self::assertSame(-30, $adjustment['rows'][0]['minutes']); + self::assertSame(60, $adjustment['rows'][1]['minutes']); + $newRowId = $adjustment['rows'][1]['id']; + $replay = $this->call($client, 'POST', '/maintenance-exports/' . $id . '/adjustments', $input, 0); + self::assertSame(201, $client->getResponse()->getStatusCode()); + self::assertTrue($replay['replayed']); + self::assertSame($adjustmentId, $replay['id']); + $this->call($client, 'POST', '/maintenance-exports/' . $id . '/adjustments', ['clientOperationId' => self::OP3, 'reason' => 'Illegal fork'], 1); + self::assertSame(409, $client->getResponse()->getStatusCode()); + $this->call($client, 'POST', '/maintenance-exports/' . $adjustmentId . '/adjustments', ['clientOperationId' => self::OP3, 'reason' => 'No second change'], 1); + self::assertSame(409, $client->getResponse()->getStatusCode()); + $this->changeTime(80, 3); + $second = $this->call($client, 'POST', '/maintenance-exports/' . $adjustmentId . '/adjustments', ['clientOperationId' => self::OP3, 'reason' => 'Second corrected time fact'], 1); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $secondJson = $this->decode($this->download($client, $this->id($second), 'json')); + self::assertIsArray($secondJson['rows']); + self::assertIsArray($secondJson['rows'][0]); + self::assertSame($newRowId, $secondJson['rows'][0]['correctionOf']); + self::assertSame($bytes, $this->download($client, $id, 'json')); + $work = $this->main()->find(InterventionRecord::class, self::WORK); + self::assertInstanceOf(InterventionRecord::class, $work); + $times = $work->closureSnapshot['timeEntries'] ?? null; + self::assertIsArray($times); + self::assertIsArray($times[0]); + self::assertSame(30, $times[0]['minutes'] ?? null); + } + + #[Test] + public function missingHistoricalSnapshotBlocksGenerationInsteadOfInventingIdentity(): void + { + $client = $this->start(false); + $sources = $this->call($client, 'GET', '/maintenance-export-sources'); + self::assertSame(200, $client->getResponse()->getStatusCode()); + $members = $sources['member'] ?? $sources['hydra:member'] ?? null; + self::assertIsArray($members); + self::assertIsArray($members[0]); + self::assertSame('snapshot_missing', $members[0]['blockedReason']); + self::assertFalse($members[0]['ready']); + self::assertNull($members[0]['site']); + $this->call($client, 'POST', '/maintenance-exports', $this->input()); + self::assertSame(422, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(0, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM maintenance_export_documents WHERE organization_id = ?', [self::ORG])); + } + + #[Test] + public function internalCostsRemainExplicitlyIncompleteAndRequireIndependentReadAuthority(): void + { + $client = $this->start(); + $input = $this->input(); + $input['includeInternalCosts'] = true; + $export = $this->call($client, 'POST', '/maintenance-exports', $input); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertFalse($export['costsComplete']); + self::assertSame(1, $export['incompleteCostCount']); + $id = $this->id($export); + $json = $this->download($client, $id, 'json'); + self::assertStringContainsString('"amount":null', $json); + $this->actor = self::WRITER; + $this->call($client, 'GET', '/maintenance-exports/' . $id); + self::assertSame(403, $client->getResponse()->getStatusCode()); + $this->call($client, 'GET', '/maintenance-exports/' . $id . '/files/json'); + self::assertSame(403, $client->getResponse()->getStatusCode()); + $input['clientOperationId'] = self::OP2; + $this->call($client, 'POST', '/maintenance-exports', $input); + self::assertSame(403, $client->getResponse()->getStatusCode()); + $listing = $this->call($client, 'GET', '/maintenance-exports'); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(0, $listing['totalItems'] ?? $listing['hydra:totalItems'] ?? null); + } + + #[Test] + public function importConfirmationHasDedicatedRightsRevisionAndIdempotentReceipt(): void + { + $client = $this->start(); + $export = $this->call($client, 'POST', '/maintenance-exports', $this->input()); + $id = $this->id($export); + $body = ['clientOperationId' => self::OP2, 'externalImportReference' => 'ERP-file-99']; + $this->actor = self::WRITER; + $this->call($client, 'POST', '/maintenance-exports/' . $id . '/confirm', $body, 1); + self::assertSame(403, $client->getResponse()->getStatusCode()); + $this->actor = self::ADMIN; + $this->call($client, 'POST', '/maintenance-exports/' . $id . '/confirm', $body); + self::assertSame(428, $client->getResponse()->getStatusCode()); + $this->call($client, 'POST', '/maintenance-exports/' . $id . '/confirm', $body, 0); + self::assertSame(412, $client->getResponse()->getStatusCode()); + $confirmed = $this->call($client, 'POST', '/maintenance-exports/' . $id . '/confirm', $body, 1); + self::assertSame(200, $client->getResponse()->getStatusCode()); + $body['clientOperationId'] = strtoupper(self::OP2); + $replay = $this->call($client, 'POST', '/maintenance-exports/' . $id . '/confirm', $body, 1); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertTrue($replay['replayed']); + self::assertIsArray($confirmed['confirmation']); + self::assertIsArray($replay['confirmation']); + foreach (['clientOperationId', 'externalImportReference', 'actorId', 'confirmedAt'] as $field) { + self::assertSame($confirmed['confirmation'][$field], $replay['confirmation'][$field]); + } + $body['externalImportReference'] = 'Changed-ERP-reference'; + $this->call($client, 'POST', '/maintenance-exports/' . $id . '/confirm', $body, 2); + self::assertSame(409, $client->getResponse()->getStatusCode()); + $body['clientOperationId'] = self::OP3; + $this->call($client, 'POST', '/maintenance-exports/' . $id . '/confirm', $body, 2); + self::assertSame(409, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function sourceDirectoryUsesFrozenTitlesAndLiteralSearch(): void + { + $client = $this->start(); + $this->main()->getConnection()->executeStatement('UPDATE interventions SET name = ? WHERE id = ?', ['Renamed live', self::WORK]); + $found = $this->call($client, 'GET', '/maintenance-export-sources?search=Published%20repair'); + self::assertSame(1, $found['totalItems'] ?? $found['hydra:totalItems'] ?? null); + $none = $this->call($client, 'GET', '/maintenance-export-sources?search=%25'); + self::assertSame(0, $none['totalItems'] ?? $none['hydra:totalItems'] ?? null); + } + + #[Test] + public function managementWithoutReadAndOutsideOrganizationAreDeniedBeforeSourceCapture(): void + { + $client = $this->start(); + $this->actor = self::MANAGE_ONLY; + $this->call($client, 'POST', '/maintenance-exports', $this->input()); + self::assertSame(403, $client->getResponse()->getStatusCode()); + $this->actor = self::READER; + $this->call($client, 'POST', '/maintenance-exports', $this->input()); + self::assertSame(403, $client->getResponse()->getStatusCode()); + $this->actor = self::OUTSIDER; + $this->call($client, 'GET', '/maintenance-export-sources'); + self::assertSame(404, $client->getResponse()->getStatusCode()); + $this->call($client, 'POST', '/maintenance-exports', $this->input()); + self::assertSame(404, $client->getResponse()->getStatusCode()); + $this->actor = self::ADMIN; + $input = $this->input(); + $input['interventionIds'] = [self::OUTSIDER]; + $this->call($client, 'POST', '/maintenance-exports', $input); + self::assertSame(404, $client->getResponse()->getStatusCode()); + self::assertSame(0, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM maintenance_export_documents WHERE organization_id = ?', [self::ORG])); + } + + /** + * Method externalReferenceCorrectionUsesScopedOwnerIdentityAndPreservesOriginalBytes + * + * @return void + */ + #[Test] + public function externalReferenceCorrectionUsesScopedOwnerIdentityAndPreservesOriginalBytes(): void + { + $client = $this->start(); + $path = '/maintenance-export-references/customer/' . self::CUSTOMER; + $input = ['clientOperationId' => self::OP2, 'system' => 'erp', 'reference' => '=CUSTOMER-001']; + $first = $this->call($client, 'PATCH', $path, $input, 0); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(1, $first['revision']); + $export = $this->call($client, 'POST', '/maintenance-exports', $this->input()); + self::assertSame(201, $client->getResponse()->getStatusCode()); + $id = $this->id($export); + $json = $this->download($client, $id, 'json'); + $csv = $this->download($client, $id, 'csv'); + self::assertStringContainsString('=CUSTOMER-001', $json); + self::assertStringContainsString("'=CUSTOMER-001", $csv); + self::assertStringNotContainsString('private@example.com', $json); + $input['clientOperationId'] = self::OP3; + $input['reference'] = 'CUSTOMER-002'; + $updated = $this->call($client, 'PATCH', $path, $input, 1); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(2, $updated['revision']); + $replay = $this->call($client, 'PATCH', $path, ['clientOperationId' => self::OP2, 'system' => 'erp', 'reference' => '=CUSTOMER-001'], 0); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertTrue($replay['replayed']); + self::assertSame(1, $replay['revision']); + $this->call($client, 'PATCH', '/maintenance-export-references/customer/' . self::OUTSIDER, ['clientOperationId' => self::OP4, 'system' => 'erp', 'reference' => 'FOREIGN'], 0); + self::assertSame(404, $client->getResponse()->getStatusCode()); + $adjusted = $this->call($client, 'POST', '/maintenance-exports/' . $id . '/adjustments', ['clientOperationId' => self::OP4, 'reason' => 'Correct the ERP customer reference'], 1); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertStringContainsString('CUSTOMER-002', $this->download($client, $this->id($adjusted), 'json')); + self::assertSame($json, $this->download($client, $id, 'json')); + self::assertSame($csv, $this->download($client, $id, 'csv')); + } + + #[Test] + public function aLateFinancialExpenseIsAnExactNewAdjustmentAndSeparatelyConfirmedImport(): void + { + $client = $this->start(); + $input = $this->input(); + $input['includeInternalCosts'] = true; + $export = $this->call($client, 'POST', '/maintenance-exports', $input); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $id = $this->id($export); + $bytes = $this->download($client, $id, 'json'); + $expense = $this->call($client, 'POST', '/interventions/' . self::WORK . '/costs/expenses', ['clientId' => 'erp-late-expense', 'amount' => '12.345678', 'description' => 'Late received service expense', 'incurredAt' => '2026-10-05T13:00:00Z', 'workItemId' => self::TASK]); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertIsArray($expense['frozen']); + self::assertNull($expense['frozen']['total']); + $adjusted = $this->call($client, 'POST', '/maintenance-exports/' . $id . '/adjustments', ['clientOperationId' => self::OP2, 'reason' => 'Include the separately received late expense'], 1); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $adjustmentId = $this->id($adjusted); + $json = $this->decode($this->download($client, $adjustmentId, 'json')); + self::assertIsArray($json['rows']); + $expenseRows = []; + foreach ($json['rows'] as $row) { + self::assertIsArray($row); + if ('expense' === ($row['kind'] ?? null)) { + $expenseRows[] = $row; + } + } + self::assertCount(1, $expenseRows); + self::assertSame('12.345678', $expenseRows[0]['amount']); + self::assertSame('add', $expenseRows[0]['change']); + self::assertSame('generated', $adjusted['state']); + $confirmed = $this->call($client, 'POST', '/maintenance-exports/' . $adjustmentId . '/confirm', ['clientOperationId' => self::OP3, 'externalImportReference' => 'ERP-adjustment-002'], 1); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame('import_confirmed', $confirmed['state']); + self::assertSame($bytes, $this->download($client, $id, 'json')); + $original = $this->call($client, 'GET', '/maintenance-exports/' . $id); + self::assertSame('generated', $original['state']); + } + + #[Test] + public function skippedTargetCostsRetainTheirOwnJsonCsvReferencesThroughTimeAdjustment(): void + { + $client = $this->start(); + $this->addSkippedFinancialTarget(); + $publishedSnapshot = $this->main()->getConnection()->fetchOne('SELECT closure_snapshot FROM interventions WHERE id = ?', [self::WORK]); + $input = $this->input(); + $input['includeInternalCosts'] = true; + $export = $this->call($client, 'POST', '/maintenance-exports', $input); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $id = $this->id($export); + $json = $this->download($client, $id, 'json'); + $csv = $this->download($client, $id, 'csv'); + $document = $this->decode($json); + self::assertIsArray($document['rows']); + self::assertCount(4, $document['rows']); + $prestations = 0; + $financialB = []; + foreach ($document['rows'] as $row) { + self::assertIsArray($row); + if ('prestation' === $row['kind']) { + ++$prestations; + self::assertSame(self::TASK, $row['workItemId']); + } + if (self::TASK_B === $row['workItemId']) { + $this->assertFinancialTargetB($row); + self::assertIsString($row['kind']); + $financialB[$row['kind']] = $row; + } + } + self::assertSame(1, $prestations); + self::assertCount(2, $financialB); + self::assertSame('12.345678', $financialB['expense']['amount']); + self::assertSame('18.000000', $financialB['time']['amount']); + $csvB = 0; + foreach ($this->csvRows($csv) as $row) { + if (self::TASK_B === $row['workItemId']) { + $this->assertFinancialTargetB($row); + ++$csvB; + } + } + self::assertSame(2, $csvB); + $this->main()->getConnection()->executeStatement('UPDATE intervention_time_entries SET minutes = 30, revision = 2 WHERE id = ?', [self::TIME_B]); + $adjusted = $this->call($client, 'POST', '/maintenance-exports/' . $id . '/adjustments', ['clientOperationId' => self::OP2, 'reason' => 'Correct the time spent on skipped equipment B'], 1); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $adjustmentId = $this->id($adjusted); + $adjustment = $this->decode($this->download($client, $adjustmentId, 'json')); + self::assertIsArray($adjustment['rows']); + self::assertCount(2, $adjustment['rows']); + foreach ($adjustment['rows'] as $row) { + self::assertIsArray($row); + $this->assertFinancialTargetB($row); + self::assertSame('time', $row['kind']); + self::assertSame($financialB['time']['id'], $row['correctionOf']); + self::assertSame('reverse' === $row['change'] ? '-18.000000' : '30.000000', $row['amount']); + } + foreach ($this->csvRows($this->download($client, $adjustmentId, 'csv')) as $row) { + $this->assertFinancialTargetB($row); + self::assertSame('time', $row['kind']); + } + self::assertSame($json, $this->download($client, $id, 'json')); + self::assertSame($csv, $this->download($client, $id, 'csv')); + self::assertSame($publishedSnapshot, $this->main()->getConnection()->fetchOne('SELECT closure_snapshot FROM interventions WHERE id = ?', [self::WORK])); + } + + /** + * Method addSkippedFinancialTarget + * + * Adds a real second task, independent journal and frozen private contributions to the existing publication. + * + * @return void + */ + private function addSkippedFinancialTarget(): void + { + $em = $this->main(); + $now = new DateTimeImmutable('2026-10-05T12:00:00Z'); + $work = $em->find(InterventionRecord::class, self::WORK); + self::assertInstanceOf(InterventionRecord::class, $work); + $snapshot = $work->closureSnapshot; + self::assertIsArray($snapshot); + self::assertIsArray($snapshot['workItems']); + self::assertIsArray($snapshot['timeEntries']); + $identity = ['id' => self::EQUIPMENT_B, 'name' => 'Extincteur B-02', 'assetReference' => 'B-02', 'type' => 'fire_extinguisher', 'brand' => null, 'model' => null, 'serialNumber' => null, 'facilityId' => self::SITE_B, 'site' => ['id' => self::SITE_B, 'name' => 'Site B at publication'], 'customer' => ['id' => self::CUSTOMER_B, 'name' => 'Customer B at publication']]; + $task = ['id' => self::TASK_B, 'action' => 'repair', 'status' => 'skipped', 'target' => '/api/equipment/' . self::EQUIPMENT_B, 'resultResource' => null, 'executionResult' => ['state' => 'skipped', 'reason' => 'Equipment access unavailable'], 'equipmentIdentity' => $identity, 'spentMinutes' => 18, 'evidenceCount' => 0, 'createdAt' => $now->format('c'), 'updatedAt' => $now->format('c')]; + $snapshot['workItems'][] = $task; + $snapshot['timeEntries'][] = ['id' => self::TIME_B, 'workItemId' => self::TASK_B, 'memberId' => self::MEMBER, 'workedOn' => '2026-10-05', 'minutes' => 18, 'revision' => 1, 'cancelled' => false]; + $work->closureSnapshot = $snapshot; + $record = new InterventionWorkItemRecord(); + $record->id = self::TASK_B; + $record->intervention = $work; + $record->action = 'repair'; + $record->status = 'skipped'; + $record->target = $task['target']; + $record->executionResult = $task['executionResult']; + $record->createdAt = $now; + $record->updatedAt = $now; + $em->persist($record); + $time = new InterventionTimeEntryRecord(); + $time->id = self::TIME_B; + $time->organizationId = self::ORG; + $time->workItem = $record; + $time->memberId = self::MEMBER; + $time->workedOn = '2026-10-05'; + $time->minutes = 18; + $time->createdBy = self::ADMIN; + $time->updatedBy = self::ADMIN; + $time->createdAt = $now; + $time->updatedAt = $now; + $em->persist($time); + $expense = new MaintenanceExpenseRecord(); + $expense->id = self::EXPENSE_B; + $expense->organizationId = self::ORG; + $expense->interventionId = self::WORK; + $expense->workItemId = self::TASK_B; + $expense->clientId = 'skipped-equipment-expense'; + $expense->amount = '12.345678'; + $expense->currency = 'EUR'; + $expense->description = 'Travel to inaccessible equipment B'; + $expense->incurredAt = $now; + $expense->createdBy = self::ADMIN; + $expense->payloadHash = hash('sha256', $expense->clientId); + $expense->createdAt = $now; + $em->persist($expense); + $cost = $em->getRepository(MaintenanceCostSnapshotRecord::class)->findOneBy(['organizationId' => self::ORG, 'interventionId' => self::WORK]); + self::assertInstanceOf(MaintenanceCostSnapshotRecord::class, $cost); + $cost->items[0]['allocation'] = ['identityState' => 'captured', 'equipment' => ['id' => self::EQUIPMENT, 'name' => 'Extincteur A-01', 'assetReference' => 'A-01'], 'site' => ['id' => self::SITE, 'name' => 'Site at publication'], 'customer' => ['id' => self::CUSTOMER, 'name' => 'Customer at publication']]; + $allocation = ['identityState' => 'captured', 'equipment' => ['id' => self::EQUIPMENT_B, 'name' => $identity['name'], 'assetReference' => $identity['assetReference']], 'site' => $identity['site'], 'customer' => $identity['customer']]; + $cost->items[] = ['id' => 'time:' . self::TIME_B . ':1', 'kind' => 'time', 'workItemId' => self::TASK_B, 'sourceId' => self::TIME_B, 'sourceRevision' => 1, 'amount' => '18.000000', 'currency' => 'EUR', 'description' => 'Recorded work time', 'occurredAt' => '2026-10-05', 'correctionOf' => null, 'hourlyAmount' => '60.000000', 'rateId' => null, 'equipmentId' => self::EQUIPMENT_B, 'allocation' => $allocation]; + $cost->items[] = ['id' => 'expense:' . self::EXPENSE_B, 'kind' => 'expense', 'workItemId' => self::TASK_B, 'sourceId' => self::EXPENSE_B, 'sourceRevision' => null, 'amount' => $expense->amount, 'currency' => 'EUR', 'description' => $expense->description, 'occurredAt' => $now->format('c'), 'correctionOf' => null, 'hourlyAmount' => null, 'rateId' => null, 'equipmentId' => self::EQUIPMENT_B, 'allocation' => $allocation]; + $cost->knownTotal = '30.345678'; + $em->flush(); + $repository = new MaintenanceExportRepository($em->getConnection()); + $repository->synchronized(self::ORG, function () use ($repository, $now): void { + foreach (['equipment' => self::EQUIPMENT_B, 'site' => self::SITE_B, 'customer' => self::CUSTOMER_B] as $type => $id) { + $repository->saveReference(new ExternalReference(ExportArtifact::rowId('target-b-reference-' . $type), self::ORG, 'erp', $type, $id, 'ERP-B-' . $type, 1, $now)); + } + }); + } + + /** + * Method assertFinancialTargetB + * + * @param array $row JSON or CSV financial contribution + * + * @return void + */ + private function assertFinancialTargetB(array $row): void + { + self::assertSame(self::TASK_B, $row['workItemId']); + self::assertSame(self::EQUIPMENT_B, $row['equipmentId']); + self::assertSame('Extincteur B-02', $row['equipmentName']); + self::assertSame('B-02', $row['assetReference']); + self::assertSame(self::SITE_B, $row['siteId']); + self::assertSame('Site B at publication', $row['siteName']); + self::assertSame(self::CUSTOMER_B, $row['customerId']); + self::assertSame('Customer B at publication', $row['customerName']); + self::assertSame('ERP-B-equipment', $row['equipmentReference']); + self::assertSame('ERP-B-site', $row['siteReference']); + self::assertSame('ERP-B-customer', $row['customerReference']); + } + + /** + * Method csvRows + * + * @param string $bytes exact saved CSV without multiline fixture fields + * + * @return list> parsed financial rows + */ + private function csvRows(string $bytes): array + { + $lines = explode("\r\n", trim($bytes)); + $columns = str_getcsv($lines[0], ',', '"', ''); + $rows = []; + foreach ($lines as $index => $line) { + if (0 === $index) { + continue; + } + /** @var list $columns */ + $rows[] = array_combine($columns, str_getcsv($line, ',', '"', '')); + } + + return $rows; + } + + /** + * Method start + * + * @param bool $snapshot preserve an actual publication fixture or exercise missing history + * + * @return KernelBrowser fixture-owning client + */ + private function start(bool $snapshot = true): KernelBrowser + { + $this->actor = self::ADMIN; + $client = static::createClient(); + $em = $this->main(); + $now = new DateTimeImmutable('2026-10-05T12:00:00Z'); + $organization = new OrganizationRecord(); + $organization->id = self::ORG; + $organization->name = 'Exports API'; + $organization->slug = 'exports-api'; + $organization->ownerUserId = self::ADMIN; + $organization->createdByUserId = self::ADMIN; + $organization->createdAt = $now; + $organization->updatedAt = $now; + $em->persist($organization); + $customer = new CustomerRecord(); + $customer->id = self::CUSTOMER; + $customer->organizationId = self::ORG; + $customer->name = 'Live internal customer'; + $customer->email = 'private@example.com'; + $customer->createdAt = $now; + $customer->updatedAt = $now; + $em->persist($customer); + foreach ([self::ADMIN => ['*'], self::WRITER => ['organization.maintenance_exports.read', 'organization.maintenance_exports.manage'], self::READER => ['organization.maintenance_exports.read'], self::MANAGE_ONLY => ['organization.maintenance_exports.manage', 'organization.maintenance_cost.read']] as $user => $permissions) { + $member = new OrganizationMemberRecord(); + $member->id = '880e8402' . substr($user, 8); + $member->organization = $organization; + $member->userId = $user; + $member->joinedAt = $now; + $em->persist($member); + $role = new OrganizationRoleRecord(); + $role->id = '880e8401' . substr($user, 8); + $role->organization = $organization; + $role->name = 'export-' . $user; + $role->permissions = $permissions; + $role->createdAt = $now; + $em->persist($role); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $em->persist($assignment); + } + $work = new InterventionRecord(); + $work->id = self::WORK; + $work->organization = $organization; + $work->name = 'Live work'; + $work->number = 1; + $work->type = 'corrective_maintenance'; + $work->status = 'published'; + $work->revision = 4; + $work->createdAt = $now; + $work->updatedAt = $now; + $entry = ['id' => self::TIME, 'workItemId' => self::TASK, 'memberId' => self::MEMBER, 'workedOn' => '2026-10-05', 'minutes' => 30, 'revision' => 1, 'cancelled' => false]; + $identity = ['id' => self::EQUIPMENT, 'name' => 'Extincteur A-01', 'assetReference' => 'A-01', 'type' => 'fire_extinguisher', 'brand' => null, 'model' => null, 'serialNumber' => null, 'facilityId' => self::SITE, 'site' => ['id' => self::SITE, 'name' => 'Site at publication'], 'customer' => ['id' => self::CUSTOMER, 'name' => 'Customer at publication']]; + $task = ['id' => self::TASK, 'action' => 'repair', 'status' => 'completed', 'target' => '/api/equipment/' . self::EQUIPMENT, 'resultResource' => null, 'executionResult' => ['state' => 'validated', 'equipmentId' => self::EQUIPMENT, 'performedAt' => $now->format('c'), 'outcome' => 'successful', 'workPerformed' => 'Restored extinguisher'], 'equipmentIdentity' => $identity, 'spentMinutes' => 30, 'evidenceCount' => 1, 'createdAt' => $now->format('c'), 'updatedAt' => $now->format('c')]; + $work->closureSnapshot = $snapshot ? ['version' => 2, 'capturedAt' => $now->format('c'), 'revision' => 4, 'interventionId' => self::WORK, 'number' => 1, 'name' => 'Published repair', 'type' => 'corrective_maintenance', 'publicationId' => self::PUBLICATION, 'site' => $identity['site'], 'customer' => $identity['customer'], 'workItems' => [$task], 'timeEntries' => [$entry], 'attachments' => [], 'report' => ['name' => 'Published repair', 'number' => 1, 'type' => 'corrective_maintenance']] : null; + $em->persist($work); + $record = new InterventionWorkItemRecord(); + $record->id = self::TASK; + $record->intervention = $work; + $record->action = 'repair'; + $record->status = 'completed'; + $record->target = $task['target']; + $record->executionResult = $task['executionResult']; + $record->createdAt = $now; + $record->updatedAt = $now; + $em->persist($record); + $time = new InterventionTimeEntryRecord(); + $time->id = self::TIME; + $time->organizationId = self::ORG; + $time->workItem = $record; + $time->memberId = self::MEMBER; + $time->workedOn = '2026-10-05'; + $time->minutes = 30; + $time->createdBy = self::ADMIN; + $time->updatedBy = self::ADMIN; + $time->createdAt = $now; + $time->updatedAt = $now; + $em->persist($time); + $publication = new PublicationRecord(); + $publication->id = self::PUBLICATION; + $publication->intervention = $work; + $publication->interventionRevision = 3; + $publication->status = 'completed'; + $publication->createdAt = $now; + $publication->completedAt = $now; + $em->persist($publication); + $cost = new MaintenanceCostSnapshotRecord(); + $cost->organizationId = self::ORG; + $cost->interventionId = self::WORK; + $cost->publicationId = self::PUBLICATION; + $cost->interventionRevision = 3; + $cost->capturedAt = $now->format('c'); + $cost->currency = 'EUR'; + $cost->knownTotal = '0.000000'; + $cost->total = null; + $cost->complete = false; + $cost->items = [['id' => 'time:' . self::TIME . ':1', 'kind' => 'time', 'workItemId' => self::TASK, 'sourceId' => self::TIME, 'sourceRevision' => 1, 'amount' => null, 'currency' => 'EUR', 'description' => 'Recorded work time', 'occurredAt' => '2026-10-05', 'correctionOf' => null, 'hourlyAmount' => null, 'rateId' => null, 'equipmentId' => self::EQUIPMENT]]; + $em->persist($cost); + $em->flush(); + $client->loginUser(new SecurityUser($this->actor, 'exports@example.com', 'hashed', ['ROLE_USER']), 'api'); + + return $client; + } + + /** + * Method call + * + * @param array|null $body declaration + * + * @return array decoded metadata + */ + private function call(KernelBrowser &$client, string $method, string $suffix, ?array $body = null, ?int $revision = null): array + { + self::ensureKernelShutdown(); + $client = static::createClient(); + $client->disableReboot(); + $client->loginUser(new SecurityUser($this->actor, $this->actor . '@example.com', 'hashed', ['ROLE_USER']), 'api'); + $headers = ['CONTENT_TYPE' => 'PATCH' === $method ? 'application/merge-patch+json' : 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json']; + if (null !== $revision) { + $headers['HTTP_IF_MATCH'] = '"revision-' . $revision . '"'; + } + $client->request($method, '/api/organizations/' . self::ORG . $suffix, server:$headers, content:null === $body ? null : json_encode($body, JSON_THROW_ON_ERROR)); + if (str_contains($suffix, '/files/') && $client->getResponse()->isSuccessful()) { + return []; + } + + return $this->decode((string) $client->getResponse()->getContent()); + } + + /** + * Method download + * + * @return string exact response bytes + */ + private function download(KernelBrowser &$client, string $id, string $format): string + { + $this->call($client, 'GET', '/maintenance-exports/' . $id . '/files/' . $format); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + + return (string) $client->getResponse()->getContent(); + } + + /** + * Method input + * + * @return array stable selection + */ + private function input(): array + { + return ['clientOperationId' => self::OP, 'interventionIds' => [self::WORK], 'system' => 'erp']; + } + + /** + * Method id + * + * @param array $projection metadata + * + * @return string retained UUID + */ + private function id(array $projection): string + { + self::assertIsString($projection['id'] ?? null); + + return $projection['id']; + } + + /** + * Method decode + * + * @return array JSON object + */ + private function decode(string $bytes): array + { + $data = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($data); + $object = []; + foreach ($data as $key => $value) { + self::assertIsString($key); + $object[$key] = $value; + } + + return $object; + } + + /** + * Method changeTime + * + * @return void update independent live journal only + */ + private function changeTime(int $minutes, int $revision): void + { + $this->main()->getConnection()->executeStatement('UPDATE intervention_time_entries SET minutes = :minutes, revision = :revision WHERE id = :id', ['minutes' => $minutes, 'revision' => $revision, 'id' => self::TIME]); + } + + /** + * Method main + * + * @return EntityManagerInterface explicit business manager + */ + private function main(): EntityManagerInterface + { + $em = static::getContainer()->get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $em); + + return $em; + } +} diff --git a/tests/Functional/Api/MaintenancePlanApiTest.php b/tests/Functional/Api/MaintenancePlanApiTest.php new file mode 100644 index 000000000..f85186bd5 --- /dev/null +++ b/tests/Functional/Api/MaintenancePlanApiTest.php @@ -0,0 +1,392 @@ +seed(); + $this->login($client, self::ADMIN); + $control = $this->request($client, 'POST', '/plans', $this->input('control', 'P1Y')); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertFalse($control['active']); + self::assertIsString($control['id']); + self::assertSame('Europe/Paris', $control['calendarTimezone']); + $maintenance = $this->request($client, 'POST', '/plans', $this->input('maintenance', 'P1M')); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertNotSame($control['id'], $maintenance['id']); + self::assertIsString($maintenance['id']); + $preview = $this->request($client, 'GET', '/plans/' . $maintenance['id'] . '/preview'); + self::assertSame(['2027-01-31T00:00:00+01:00', '2027-02-28T00:00:00+01:00', '2027-03-31T00:00:00+02:00'], $preview['dates']); + $this->request($client, 'PATCH', '/plans/' . $control['id'], ['active' => true]); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $this->request($client, 'PATCH', '/plans/' . $maintenance['id'], ['active' => true]); + $this->request($client, 'POST', '/plans/activate'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $first = $this->request($client, 'POST', '/plans/' . $maintenance['id'] . '/generate', []); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $replay = $this->request($client, 'POST', '/plans/' . $maintenance['id'] . '/generate', []); + self::assertTrue($replay['replayed']); + self::assertSame($first['occurrenceId'], $replay['occurrenceId']); + self::assertSame($first['interventionId'], $replay['interventionId']); + self::assertSame($first['number'], $replay['number']); + $connection = $this->main()->getConnection(); + self::assertSame(1, $connection->fetchOne('SELECT COUNT(*) FROM maintenance_occurrences WHERE organization_id = :org', ['org' => self::ORG])); + self::assertSame('2027-01-30 23:00:00', $connection->fetchOne('SELECT next_due_at FROM maintenance_schedules WHERE organization_id = :org AND equipment_id = :equipment', ['org' => self::ORG, 'equipment' => self::EQUIPMENT])); + self::assertSame('preventive_maintenance', $connection->fetchOne('SELECT type FROM interventions WHERE id = :id', ['id' => $first['interventionId']])); + self::assertSame('maintenance', $connection->fetchOne('SELECT operation_kind FROM intervention_work_items WHERE intervention_id = :id', ['id' => $first['interventionId']])); + } + + #[Test] + public function archiveRetainsThePlanAndItsReadableHistory(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $plan = $this->request($client, 'POST', '/plans', $this->input('control', 'P1Y')); + self::assertIsString($plan['id']); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $this->request($client, 'DELETE', '/plans/' . $plan['id']); + self::assertSame(204, $client->getResponse()->getStatusCode()); + $saved = $this->request($client, 'GET', '/plans/' . $plan['id']); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertFalse($saved['active']); + self::assertNotNull($saved['archivedAt']); + } + + #[Test] + public function archivedHistoricalCandidatesStayExcludedWhenTheEngineIsActivated(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $this->seedLegacySchedule(); + $this->request($client, 'POST', '/plans/prepare-legacy'); + self::assertSame(200, $client->getResponse()->getStatusCode()); + $planId = $this->main()->getConnection()->fetchOne('SELECT id FROM maintenance_plans WHERE organization_id = :org', ['org' => self::ORG]); + self::assertIsString($planId); + $this->request($client, 'DELETE', '/plans/' . $planId); + self::assertSame(204, $client->getResponse()->getStatusCode()); + $archived = $this->request($client, 'GET', '/plans/' . $planId); + self::assertNotNull($archived['archivedAt']); + $this->request($client, 'POST', '/plans/activate'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $saved = $this->request($client, 'GET', '/plans/' . $planId); + self::assertSame($archived['archivedAt'], $saved['archivedAt']); + self::assertFalse($saved['active']); + $this->request($client, 'POST', '/plans/' . $planId . '/generate', []); + self::assertSame(422, $client->getResponse()->getStatusCode()); + self::assertSame(0, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM maintenance_occurrences WHERE organization_id = :org', ['org' => self::ORG])); + } + + #[Test] + public function aNameOnlyEditRetainsTheOpenOccurrenceAndRejectsCalendarChanges(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $input = $this->input('maintenance', 'P1M'); + $input['active'] = true; + $plan = $this->request($client, 'POST', '/plans', $input); + self::assertIsString($plan['id']); + $this->request($client, 'POST', '/plans/activate'); + self::assertSame(200, $client->getResponse()->getStatusCode()); + $generated = $this->request($client, 'POST', '/plans/' . $plan['id'] . '/generate', []); + self::assertSame(200, $client->getResponse()->getStatusCode()); + $renamed = $this->request($client, 'PATCH', '/plans/' . $plan['id'], ['name' => 'Renamed monthly servicing']); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('Renamed monthly servicing', $renamed['name']); + self::assertSame($plan['anchorAt'], $renamed['anchorAt']); + self::assertSame($plan['nextDueAt'], $renamed['nextDueAt']); + self::assertIsArray($renamed['openOccurrence']); + self::assertSame($generated['occurrenceId'], $renamed['openOccurrence']['id']); + self::assertSame('2027-01-31T00:00:00+01:00', $renamed['openOccurrence']['dueAt']); + $this->request($client, 'PATCH', '/plans/' . $plan['id'], ['nextDueOn' => '2027-02-28']); + self::assertSame(422, $client->getResponse()->getStatusCode()); + $saved = $this->request($client, 'GET', '/plans/' . $plan['id']); + self::assertIsArray($saved['openOccurrence']); + foreach (['id', 'dueAt', 'attempt', 'interventionId', 'status', 'number', 'retryAllowed'] as $field) { + self::assertSame($renamed['openOccurrence'][$field], $saved['openOccurrence'][$field]); + } + self::assertSame($plan['nextDueAt'], $saved['nextDueAt']); + } + + #[Test] + public function theHistoricalCampaignEndpointUsesReservationsAfterHandover(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $input = $this->input('control', 'P1Y'); + $input['anchorOn'] = '2026-10-01'; + $plan = $this->request($client, 'POST', '/plans', $input); + self::assertIsString($plan['id']); + $this->request($client, 'PATCH', '/plans/' . $plan['id'], ['active' => true]); + $this->request($client, 'POST', '/plans/activate'); + $campaign = ['organization' => '/api/organizations/' . self::ORG, 'name' => 'Control campaign', 'dueBefore' => '2027-01-31T00:00:00Z']; + $first = $this->request($client, 'POST', '/api/maintenance/campaigns', $campaign); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertIsString($first['interventionId']); + $this->request($client, 'POST', '/api/maintenance/campaigns', $campaign); + self::assertSame(422, $client->getResponse()->getStatusCode()); + self::assertSame(1, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM maintenance_occurrences WHERE organization_id = :org', ['org' => self::ORG])); + self::assertSame(1, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM interventions WHERE organization_id = :org', ['org' => self::ORG])); + } + + #[Test] + public function legacyWorkIsAttachedRatherThanRegeneratedAndKnownDatesStayExact(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $this->seedLegacySchedule(); + $factory = static::getContainer()->get(InterventionDraftFactoryPort::class); + self::assertInstanceOf(InterventionDraftFactoryPort::class, $factory); + $draft = $factory->create(new CreateInterventionDraftRequest(self::ORG, 'inspection_campaign', 'Prepared historical control', 'maintenance:campaign', workItems: [new InterventionDraftWorkItem('inspection', '/api/equipment/' . self::EQUIPMENT)], actorUserId: self::ADMIN)); + $this->request($client, 'POST', '/plans/activate'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $row = $this->main()->getConnection()->fetchAssociative('SELECT id, interval, next_due_at, last_completed_at FROM maintenance_plans WHERE organization_id = :org', ['org' => self::ORG]); + self::assertIsArray($row); + self::assertIsString($row['id']); + self::assertSame('P6M', $row['interval']); + self::assertSame('2027-03-01 00:00:00', $row['next_due_at']); + self::assertSame('2026-09-01 00:00:00', $row['last_completed_at']); + $replay = $this->request($client, 'POST', '/plans/' . $row['id'] . '/generate', []); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertTrue($replay['replayed']); + self::assertSame($draft->interventionId, $replay['interventionId']); + self::assertSame($draft->number, $replay['number']); + self::assertSame(1, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM interventions WHERE organization_id = :org', ['org' => self::ORG])); + } + + #[Test] + public function ambiguousLegacyWorkRollsBackTheWholeEngineActivation(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $this->seedLegacySchedule(); + $factory = static::getContainer()->get(InterventionDraftFactoryPort::class); + self::assertInstanceOf(InterventionDraftFactoryPort::class, $factory); + foreach (['A', 'B'] as $name) { + $factory->create(new CreateInterventionDraftRequest(self::ORG, 'inspection_campaign', 'Ambiguous control ' . $name, 'maintenance:campaign', workItems: [new InterventionDraftWorkItem('inspection', '/api/equipment/' . self::EQUIPMENT)], actorUserId: self::ADMIN)); + } + $this->request($client, 'POST', '/plans/activate'); + self::assertSame(422, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $engine = $this->request($client, 'GET', '/engine'); + self::assertSame('legacy', $engine['mode']); + self::assertSame(0, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM maintenance_occurrences WHERE organization_id = :org', ['org' => self::ORG])); + self::assertSame(0, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM maintenance_plans WHERE organization_id = :org', ['org' => self::ORG])); + } + + #[Test] + public function editingTheHistoricalPlanUpdatesItsOverrideSourceRatherThanSilentlyResetting(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $this->seedLegacySchedule(); + $this->request($client, 'POST', '/plans/activate'); + $planId = $this->main()->getConnection()->fetchOne('SELECT id FROM maintenance_plans WHERE organization_id = :org', ['org' => self::ORG]); + self::assertIsString($planId); + $updated = $this->request($client, 'PATCH', '/plans/' . $planId, ['interval' => 'P1Y']); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('P1Y', $updated['interval']); + $reloaded = $this->request($client, 'GET', '/plans/' . $planId); + self::assertSame('P1Y', $reloaded['interval']); + self::assertSame('2027-09-01T00:00:00+00:00', $reloaded['nextDueAt']); + self::assertSame('P1Y', $this->main()->getConnection()->fetchOne('SELECT interval_override FROM maintenance_schedules WHERE organization_id = :org', ['org' => self::ORG])); + } + + #[Test] + #[DataProvider('denials')] + public function scopeAndPermissionDenials(string $user, string $method, string $path, int $status): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, $user); + $this->request($client, $method, $path, $this->input('control', 'P1Y')); + self::assertSame($status, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + } + + /** + * @return iterable + */ + public static function denials(): iterable + { + yield 'reader write' => [self::READER, 'POST', '/plans', 403]; + yield 'outside list' => [self::OUTSIDER, 'GET', '/plans', 404]; + yield 'outside write' => [self::OUTSIDER, 'POST', '/plans', 404]; + yield 'outside engine' => [self::OUTSIDER, 'GET', '/engine', 404]; + } + + #[Test] + public function invalidCadenceAndUnknownEquipmentAreRejected(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $this->request($client, 'POST', '/plans', $this->input('control', 'P0M')); + self::assertSame(422, $client->getResponse()->getStatusCode()); + $input = $this->input('control', 'P1Y'); + $input['equipmentId'] = self::OUTSIDER; + $this->request($client, 'POST', '/plans', $input); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + /** + * @param array|null $body + * + * @return array + */ + private function request(KernelBrowser &$client, string $method, string $path, ?array $body = null): array + { + self::ensureKernelShutdown(); + $client = static::createClient(); + if (null !== $this->loggedUserId) { + $this->login($client, $this->loggedUserId); + } + $url = str_starts_with($path, '/api/') ? $path : '/api/organizations/' . self::ORG . '/maintenance' . $path; + $client->request($method, $url, server: ['CONTENT_TYPE' => 'PATCH' === $method ? 'application/merge-patch+json' : 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json'], content: null === $body ? null : json_encode((object) $body, JSON_THROW_ON_ERROR)); + $content = (string) $client->getResponse()->getContent(); + + if ('' === $content) { + return []; + } + /** @var array $decoded */ + $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR); + + return $decoded; + } + + /** + * @return array + */ + private function input(string $kind, string $interval): array + { + return ['equipmentId' => self::EQUIPMENT, 'name' => $kind . ' operation', 'operationKind' => $kind, 'interval' => $interval, 'anchorOn' => '2027-01-31']; + } + + private function main(): EntityManagerInterface + { + $manager = static::getContainer()->get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $manager); + + return $manager; + } + + private function login(KernelBrowser $client, string $id): void + { + $this->loggedUserId = $id; + $client->disableReboot(); + $client->loginUser(new SecurityUser($id, 'plan-' . $id . '@example.com', 'hashed-password', ['ROLE_USER']), 'api'); + } + + private function seed(): void + { + $manager = $this->main(); + $connection = $manager->getConnection(); + foreach (['maintenance_operation_receipts', 'maintenance_occurrences', 'maintenance_plans', 'maintenance_engines'] as $table) { + $connection->delete($table, ['organization_id' => self::ORG]); + } + $existing = $manager->find(OrganizationRecord::class, self::ORG); + if (null !== $existing) { + $manager->remove($existing); + $manager->flush(); + } + $now = new DateTimeImmutable('2026-10-06T00:00:00Z'); + $organization = new OrganizationRecord(); + $organization->id = self::ORG; + $organization->name = 'Preventive plans test'; + $organization->slug = 'preventive-plans-test'; + $organization->ownerUserId = self::ADMIN; + $organization->createdByUserId = self::ADMIN; + $organization->createdAt = $now; + $organization->updatedAt = $now; + $organization->settings = ['regional' => ['timezone' => 'Europe/Paris']]; + $manager->persist($organization); + foreach ([self::ADMIN => ['*'], self::READER => ['organization.maintenance.read']] as $userId => $permissions) { + $role = new OrganizationRoleRecord(); + $role->id = '780e8401' . substr($userId, 8); + $role->organization = $organization; + $role->name = 'test-' . $userId; + $role->permissions = $permissions; + $role->createdAt = $now; + $manager->persist($role); + $member = new OrganizationMemberRecord(); + $member->id = '780e8402' . substr($userId, 8); + $member->organization = $organization; + $member->userId = $userId; + $member->joinedAt = $now; + $manager->persist($member); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $manager->persist($assignment); + } + $equipment = new EquipmentRecord(); + $equipment->id = self::EQUIPMENT; + $equipment->organization = $organization; + $equipment->type = 'fire_extinguisher'; + $equipment->status = 'operational'; + $equipment->createdAt = $now; + $equipment->updatedAt = $now; + $manager->persist($equipment); + $manager->flush(); + } + + private function seedLegacySchedule(): void + { + $schedule = new MaintenanceScheduleRecord(); + $schedule->id = '780e8400-e29b-41d4-a716-446655440030'; + $schedule->organization = $this->main()->getReference(OrganizationRecord::class, self::ORG); + $schedule->equipmentId = self::EQUIPMENT; + $schedule->equipmentType = 'fire_extinguisher'; + $schedule->intervalOverride = 'P6M'; + $schedule->lastInspectionClosedAt = new DateTimeImmutable('2026-09-01Z'); + $schedule->nextDueAt = new DateTimeImmutable('2027-03-01Z'); + $schedule->dueStatus = 'up_to_date'; + $schedule->createdAt = new DateTimeImmutable('2026-10-06Z'); + $schedule->updatedAt = new DateTimeImmutable('2026-10-06Z'); + $this->main()->persist($schedule); + $this->main()->flush(); + } +} diff --git a/tests/Functional/Api/OrganizationOperatingProfileApiTest.php b/tests/Functional/Api/OrganizationOperatingProfileApiTest.php new file mode 100644 index 000000000..234774ba5 --- /dev/null +++ b/tests/Functional/Api/OrganizationOperatingProfileApiTest.php @@ -0,0 +1,277 @@ + + */ +final class OrganizationOperatingProfileApiTest extends WebTestCase +{ + private const string ORGANIZATION = '550e8400-e29b-41d4-a716-446655448721'; + + private const string USER = '550e8400-e29b-41d4-a716-446655448722'; + + private const string OWNER = '550e8400-e29b-41d4-a716-446655448723'; + + private const string ROLE = '550e8400-e29b-41d4-a716-446655448724'; + + private const string MEMBER = '550e8400-e29b-41d4-a716-446655448725'; + + #[Test] + #[DataProvider('creationProfiles')] + public function creationPersistsAndReturnsProfile(?string $profile, string $expected): void + { + $client = $this->client(); + $users = $this->createStub(UserRepositoryPort::class); + $users->method('findById')->willReturn(UserTestFactory::createActive(self::USER)); + static::getContainer()->set(UserRepositoryPort::class, $users); + $payload = ['name' => 'Operating profile creation']; + if (null !== $profile) { + $payload['operatingProfile'] = $profile; + } + + $client->request('POST', '/api/organizations', server: ['CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json'], content: json_encode($payload, JSON_THROW_ON_ERROR)); + + self::assertResponseStatusCodeSame(201); + $body = $this->body($client); + self::assertSame($expected, $body['operatingProfile']); + self::assertSame($expected, $this->manager()->getConnection()->fetchOne('SELECT operating_profile FROM organizations WHERE id = ?', [$body['id']])); + } + + #[Test] + public function creationRejectsUnsupportedProfileBeforeWriting(): void + { + $client = $this->client(); + + $client->request('POST', '/api/organizations', server: ['CONTENT_TYPE' => 'application/ld+json'], content: json_encode(['name' => 'Invalid operating profile', 'operatingProfile' => 'administrator'], JSON_THROW_ON_ERROR)); + + self::assertResponseStatusCodeSame(422); + $count = $this->manager()->getConnection()->fetchOne('SELECT COUNT(*) FROM organizations WHERE created_by_user_id = ?', [self::USER]); + self::assertTrue(is_int($count) || is_string($count)); + self::assertSame(0, (int) $count); + } + + #[Test] + public function profileUpdatePersistsWithoutChangingRoles(): void + { + $client = $this->client(); + $this->seedOrganization('operator', ['*']); + $permissionsBefore = $this->manager()->getConnection()->fetchOne('SELECT permissions FROM organization_roles WHERE id = ?', [self::ROLE]); + + $this->patch($client, ['operatingProfile' => 'service_provider']); + + self::assertResponseStatusCodeSame(200); + self::assertSame('service_provider', $this->body($client)['operatingProfile']); + self::assertSame('service_provider', $this->persistedProfile()); + self::assertSame($permissionsBefore, $this->manager()->getConnection()->fetchOne('SELECT permissions FROM organization_roles WHERE id = ?', [self::ROLE])); + $count = $this->manager()->getConnection()->fetchOne('SELECT COUNT(*) FROM organization_member_roles WHERE member_id = ?', [self::MEMBER]); + self::assertTrue(is_int($count) || is_string($count)); + self::assertSame(1, (int) $count); + } + + /** + * @param array $payload partial update + */ + #[Test] + #[DataProvider('preservingUpdates')] + public function nullOrOmittedProfilePreservesExistingProvider(array $payload): void + { + $client = $this->client(); + $this->seedOrganization('service_provider', ['*']); + + $this->patch($client, $payload); + + self::assertResponseStatusCodeSame(200); + self::assertSame('service_provider', $this->body($client)['operatingProfile']); + self::assertSame('service_provider', $this->persistedProfile()); + } + + #[Test] + public function updateRejectsUnsupportedProfileWithoutChangingExistingValue(): void + { + $client = $this->client(); + $this->seedOrganization('operator', ['*']); + + $this->patch($client, ['operatingProfile' => 'administrator']); + + self::assertResponseStatusCodeSame(422); + self::assertSame('operator', $this->persistedProfile()); + } + + #[Test] + public function providerProfileDoesNotGrantSettingsPermissionToReadOnlyMember(): void + { + $client = $this->client(); + $this->seedOrganization('service_provider', ['organization.read']); + + $this->patch($client, ['operatingProfile' => 'operator']); + + self::assertResponseStatusCodeSame(403); + self::assertSame('service_provider', $this->persistedProfile()); + } + + #[Test] + public function callerCannotUpdateForeignOrganizationProfile(): void + { + $client = $this->client(); + $this->seedOrganization('operator', ['*'], memberUserId: self::OWNER); + + $this->patch($client, ['operatingProfile' => 'service_provider']); + + self::assertResponseStatusCodeSame(403); + self::assertSame('operator', $this->persistedProfile()); + } + + #[Test] + public function detailReturnsPersistedProviderProfile(): void + { + $client = $this->client(); + $this->seedOrganization('service_provider', ['organization.read']); + + $client->request('GET', '/api/organizations/' . self::ORGANIZATION, server: ['HTTP_ACCEPT' => 'application/ld+json']); + + self::assertResponseStatusCodeSame(200); + self::assertSame('service_provider', $this->body($client)['operatingProfile']); + } + + #[Test] + public function collectionReturnsPersistedProviderProfile(): void + { + $client = $this->client(); + $this->seedOrganization('service_provider', ['organization.read']); + + $client->request('GET', '/api/organizations', server: ['HTTP_ACCEPT' => 'application/ld+json']); + + self::assertResponseStatusCodeSame(200); + $body = $this->body($client); + self::assertIsArray($body['member']); + self::assertCount(1, $body['member']); + self::assertIsArray($body['member'][0]); + self::assertSame(self::ORGANIZATION, $body['member'][0]['id']); + self::assertSame('service_provider', $body['member'][0]['operatingProfile']); + } + + /** + * @return iterable + */ + public static function creationProfiles(): iterable + { + yield 'omitted defaults to operator' => [null, 'operator']; + yield 'explicit operator' => ['operator', 'operator']; + yield 'service provider' => ['service_provider', 'service_provider']; + } + + /** + * @return iterable}> + */ + public static function preservingUpdates(): iterable + { + yield 'omitted' => [['description' => 'Updated description']]; + yield 'null' => [['operatingProfile' => null]]; + } + + private function client(): KernelBrowser + { + $client = static::createClient(); + $client->loginUser(new SecurityUser(self::USER, 'operating-profile-test@example.com', 'hashed-password', ['ROLE_USER']), 'api'); + + return $client; + } + + /** + * @param list $permissions assigned permissions + */ + private function seedOrganization(string $profile, array $permissions, string $memberUserId = self::USER): void + { + $manager = $this->manager(); + $now = new DateTimeImmutable(); + $organization = new OrganizationRecord(); + $organization->id = self::ORGANIZATION; + $organization->name = 'Operating profile test'; + $organization->slug = 'operating-profile-test'; + $organization->ownerUserId = self::OWNER; + $organization->createdByUserId = self::OWNER; + $organization->operatingProfile = $profile; + $organization->status = 'active'; + $organization->isActive = true; + $organization->createdAt = $now; + $organization->updatedAt = $now; + $manager->persist($organization); + $role = new OrganizationRoleRecord(); + $role->id = self::ROLE; + $role->organization = $organization; + $role->name = 'operating_profile_tester'; + $role->permissions = $permissions; + $role->createdAt = $now; + $manager->persist($role); + $member = new OrganizationMemberRecord(); + $member->id = self::MEMBER; + $member->organization = $organization; + $member->userId = $memberUserId; + $member->joinedAt = $now; + $manager->persist($member); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $manager->persist($assignment); + $manager->flush(); + } + + /** + * @param array $payload partial update + */ + private function patch(KernelBrowser $client, array $payload): void + { + $client->request('PATCH', '/api/organizations/' . self::ORGANIZATION, server: ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_ACCEPT' => 'application/ld+json'], content: json_encode($payload, JSON_THROW_ON_ERROR)); + } + + private function persistedProfile(): mixed + { + return $this->manager()->getConnection()->fetchOne('SELECT operating_profile FROM organizations WHERE id = ?', [self::ORGANIZATION]); + } + + private function manager(): EntityManagerInterface + { + $manager = static::getContainer()->get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $manager); + + return $manager; + } + + /** + * @return array + */ + private function body(KernelBrowser $client): array + { + $body = json_decode((string) $client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($body); + + /** @var array $body */ + return $body; + } +} diff --git a/tests/Functional/Api/ParkAnomaliesApiTest.php b/tests/Functional/Api/ParkAnomaliesApiTest.php new file mode 100644 index 000000000..2f8a6e26c --- /dev/null +++ b/tests/Functional/Api/ParkAnomaliesApiTest.php @@ -0,0 +1,310 @@ + + */ +final class ParkAnomaliesApiTest extends WebTestCase +{ + #[Test] + public function countersAndPaginatedRowsShareCustomerFamilyAndSubtree(): void + { + $client = $this->client(); + $scope = '?family=fire&customerId=' . self::id(10) . '&facilityId=' . self::id(20); + $summary = $this->get($client, '/park-anomalies-summary' . $scope); + self::assertSame(3, $summary['openAnomalies']); + self::assertSame(['low' => 1, 'medium' => 1, 'high' => 1, 'critical' => 0], $summary['bySeverity']); + $rows = $this->get($client, '/park-anomalies' . $scope . '&itemsPerPage=1&page=2'); + self::assertIsArray($rows['member']); + self::assertIsArray($rows['member'][0]); + self::assertSame($summary['openAnomalies'], $rows['totalItems']); + self::assertCount(1, $rows['member']); + self::assertContains($rows['member'][0]['inspectionId'], [self::id(100), self::id(101)]); + self::assertContains($rows['member'][0]['equipmentId'], [self::id(30), self::id(31)]); + self::assertContains($rows['member'][0]['status'], ['open', 'in_progress']); + $safety = $this->get($client, '/park-anomalies-summary?family=safety&facilityId=' . self::id(20)); + self::assertIsArray($safety['bySeverity']); + self::assertSame(1, $safety['openAnomalies']); + self::assertSame(1, $safety['bySeverity']['critical']); + } + + #[Test] + public function aChildOnlyAnomalyIsExcludedFromDirectScopeAndRetainsFamilyAndCustomer(): void + { + $client = $this->client(); + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.main_entity_manager'); + foreach ([200, 201] as $number) { + $finding = $em->find(NonConformityRecord::class, self::id($number)); + self::assertInstanceOf(NonConformityRecord::class, $finding); + $finding->status = 'done'; + } + $em->flush(); + $scope = '?family=fire&customerId=' . self::id(10) . '&facilityId=' . self::id(20); + $direct = $this->get($client, '/park-anomalies-summary' . $scope . '&includeDescendants=false'); + self::assertSame(0, $direct['openAnomalies']); + self::assertSame(['low' => 0, 'medium' => 0, 'high' => 0, 'critical' => 0], $direct['bySeverity']); + $directRows = $this->get($client, '/park-anomalies' . $scope . '&includeDescendants=false'); + self::assertSame(0, $directRows['totalItems']); + self::assertSame([], $directRows['member']); + foreach (['', '&includeDescendants=true'] as $descendants) { + $subtree = $this->get($client, '/park-anomalies-summary' . $scope . $descendants); + $rows = $this->get($client, '/park-anomalies' . $scope . $descendants . '&itemsPerPage=1'); + self::assertSame(1, $subtree['openAnomalies']); + self::assertSame($subtree['openAnomalies'], $rows['totalItems']); + self::assertIsArray($rows['member']); + self::assertCount(1, $rows['member']); + self::assertIsArray($rows['member'][0]); + self::assertSame(self::id(31), $rows['member'][0]['equipmentId']); + self::assertSame(self::id(203), $rows['member'][0]['id']); + } + $otherCustomer = '?family=fire&customerId=' . self::id(11) . '&facilityId=' . self::id(20) . '&includeDescendants=true'; + self::assertSame(0, $this->get($client, '/park-anomalies-summary' . $otherCustomer)['openAnomalies']); + self::assertSame(0, $this->get($client, '/park-anomalies' . $otherCustomer)['totalItems']); + } + + #[Test] + public function noFamilyDefaultIsImposedAndDraftOrForeignFactsAreExcluded(): void + { + $client = $this->client(); + $summary = $this->get($client, '/park-anomalies-summary'); + self::assertSame(5, $summary['openAnomalies']); + self::assertSame(['low' => 2, 'medium' => 1, 'high' => 1, 'critical' => 1], $summary['bySeverity']); + $rows = $this->get($client, '/park-anomalies?itemsPerPage=100'); + self::assertIsArray($rows['member']); + self::assertSame(5, $rows['totalItems']); + self::assertCount(5, $rows['member']); + $empty = $this->get($client, '/park-anomalies-summary?customerId=' . self::id(11)); + self::assertSame(0, $empty['openAnomalies']); + } + + #[Test] + public function inspectionCollectionsUseTheSameFamilyCustomerAndSubtreeBeforeCount(): void + { + $client = $this->client(); + $filters = '?family=fire&customerId=' . self::id(10); + $org = $this->get($client, '/inspections' . $filters . '&itemsPerPage=1'); + self::assertIsArray($org['member']); + self::assertSame(2, $org['totalItems']); + self::assertCount(1, $org['member']); + $direct = $this->get($client, '/facilities/' . self::id(20) . '/inspections' . $filters); + self::assertSame(1, $direct['totalItems']); + $subtree = $this->get($client, '/facilities/' . self::id(20) . '/inspections' . $filters . '&includeDescendants=true'); + self::assertSame(2, $subtree['totalItems']); + } + + #[Test] + public function eitherMissingReadPermissionIsForbidden(): void + { + $client = $this->client(['organization.inspection.read']); + $this->get($client, '/park-anomalies-summary', 403); + $this->get($client, '/park-anomalies', 403); + } + + #[Test] + public function equipmentPermissionAloneDoesNotRevealAnomalies(): void + { + $client = $this->client(['organization.equipment.read']); + $this->get($client, '/park-anomalies-summary', 403); + $this->get($client, '/park-anomalies', 403); + } + + #[Test] + public function unknownAndForeignExplicitScopesAreEquallyHidden(): void + { + $client = $this->client(); + foreach (['customerId=' . self::id(901), 'customerId=' . self::id(910), 'facilityId=' . self::id(902), 'facilityId=' . self::id(920)] as $filter) { + $this->get($client, '/park-anomalies-summary?' . $filter, 404); + $this->get($client, '/park-anomalies?' . $filter, 404); + $this->get($client, '/park-anomalies-summary?' . $filter . '&includeDescendants=false', 404); + $this->get($client, '/park-anomalies?' . $filter . '&includeDescendants=false', 404); + } + $client->request('GET', '/api/organizations/' . self::id(900) . '/park-anomalies-summary'); + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function authenticationIsRequiredForBothProjections(): void + { + $client = static::createClient(); + foreach (['park-anomalies', 'park-anomalies-summary'] as $endpoint) { + $client->request('GET', '/api/organizations/' . self::id(1) . '/' . $endpoint); + self::assertContains($client->getResponse()->getStatusCode(), [401, 403]); + } + } + + /** + * @return array + */ + private function get(KernelBrowser $client, string $suffix, int $status = 200): array + { + $client->request('GET', '/api/organizations/' . self::id(1) . $suffix, server: ['HTTP_ACCEPT' => 'application/ld+json']); + self::assertSame($status, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $body = json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($body); + $object = []; + foreach ($body as $key => $value) { + self::assertIsString($key); + $object[$key] = $value; + } + + return $object; + } + + /** + * @return non-empty-string + */ + private static function id(int $number): string + { + return sprintf('980e8400-e29b-41d4-a716-44665548%04d', $number); + } + + /** + * @param list $permissions + */ + private function client(array $permissions = ['organization.inspection.read', 'organization.equipment.read']): KernelBrowser + { + $client = static::createClient(); + $client->disableReboot(); + $this->seed($permissions); + $users = $this->createStub(UserRepositoryPort::class); + $users->method('findById')->willReturnCallback(static fn (UserId $id) => UserTestFactory::createActive((string) $id, (string) $id . '@corp.example')); + static::getContainer()->set(UserRepositoryPort::class, $users); + $client->setServerParameter('HTTP_AUTHORIZATION', 'Bearer ' . InteractiveTokenFactory::issue(static::getContainer(), self::id(2), 'parc@example.com')); + + return $client; + } + + /** + * @param list $permissions + */ + private function seed(array $permissions): void + { + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.main_entity_manager'); + $now = new DateTimeImmutable('2026-10-06T12:00:00+00:00'); + $orgs = []; + foreach ([1, 900] as $number) { + $org = new OrganizationRecord(); + $org->id = self::id($number); + $org->name = 'Parc ' . $number; + $org->slug = 'parc-scope-' . $number; + $org->ownerUserId = $org->createdByUserId = self::id(999); + $org->status = 'active'; + $org->isActive = true; + $org->createdAt = $org->updatedAt = $now; + $em->persist($org); + $orgs[$number] = $org; + } + $role = new OrganizationRoleRecord(); + $role->id = self::id(3); + $role->organization = $orgs[1]; + $role->name = 'parc-reader'; + $role->permissions = $permissions; + $role->isSystem = false; + $role->createdAt = $now; + $em->persist($role); + $member = new OrganizationMemberRecord(); + $member->id = self::id(4); + $member->organization = $orgs[1]; + $member->userId = self::id(2); + $member->isActive = true; + $member->joinedAt = $now; + $em->persist($member); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $em->persist($assignment); + foreach ([10, 11, 910] as $number) { + $customer = new CustomerRecord(); + $customer->id = self::id($number); + $customer->organizationId = $orgs[910 === $number ? 900 : 1]->id; + $customer->name = 'Customer ' . $number; + $customer->createdAt = $customer->updatedAt = $now; + $em->persist($customer); + } + $facilities = []; + foreach ([20, 21, 22, 920] as $number) { + $facility = new FacilityRecord(); + $facility->id = self::id($number); + $facility->organization = $orgs[920 === $number ? 900 : 1]; + $facility->type = 21 === $number ? 'building' : 'site'; + $facility->name = 'Place ' . $number; + $facility->status = 'active'; + $facility->customerId = 20 === $number ? self::id(10) : null; + $facility->parentFacility = 21 === $number ? $facilities[20] : null; + $facility->createdAt = $facility->updatedAt = $now; + $em->persist($facility); + $facilities[$number] = $facility; + } + foreach ([30 => 20, 31 => 21, 32 => 21, 33 => 22, 34 => 20] as $number => $facilityNumber) { + $equipment = new EquipmentRecord(); + $equipment->id = self::id($number); + $equipment->organization = $orgs[1]; + $equipment->facilityId = self::id($facilityNumber); + $equipment->type = 32 === $number ? 'camera' : 'fire_extinguisher'; + $equipment->serialNumber = 'PARC-' . $number; + $equipment->status = 'operational'; + $equipment->recordStatus = 34 === $number ? 'draft' : 'published'; + $equipment->createdAt = $equipment->updatedAt = $now; + $em->persist($equipment); + } + $inspections = []; + foreach ([100 => [30, 20], 101 => [31, 21], 102 => [32, 21], 103 => [33, 22], 104 => [34, 20], 105 => [31, 21], 106 => [31, 21]] as $number => [$equipmentNumber, $facilityNumber]) { + $inspection = new InspectionRecord(); + $inspection->id = self::id($number); + $inspection->organization = $orgs[106 === $number ? 900 : 1]; + $inspection->equipmentId = self::id($equipmentNumber); + $inspection->facilityId = self::id($facilityNumber); + $inspection->inspectorType = 'user'; + $inspection->inspectorName = 'Technician'; + $inspection->inspectorUserId = self::id(2); + $inspection->result = 'fail'; + $inspection->status = 'submitted'; + $inspection->recordStatus = 105 === $number ? 'draft' : 'published'; + $inspection->performedAt = $inspection->createdAt = $inspection->updatedAt = $now; + $em->persist($inspection); + $inspections[$number] = $inspection; + } + foreach ([200 => [100, 'low', 'open'], 201 => [100, 'medium', 'in_progress'], 202 => [100, 'critical', 'done'], 203 => [101, 'high', 'open'], 204 => [102, 'critical', 'open'], 205 => [103, 'low', 'open'], 206 => [104, 'critical', 'open'], 207 => [105, 'critical', 'open'], 208 => [106, 'critical', 'open']] as $number => [$inspectionNumber, $severity, $status]) { + $nc = new NonConformityRecord(); + $nc->id = self::id($number); + $nc->inspection = $inspections[$inspectionNumber]; + $nc->description = 'Issue ' . $number; + $nc->severity = $severity; + $nc->status = $status; + $nc->createdAt = $nc->updatedAt = $now; + $em->persist($nc); + } + $em->flush(); + } +} diff --git a/tests/Functional/Api/ProcurementApiTest.php b/tests/Functional/Api/ProcurementApiTest.php new file mode 100644 index 000000000..fcb4cfb39 --- /dev/null +++ b/tests/Functional/Api/ProcurementApiTest.php @@ -0,0 +1,516 @@ +seed(); + $this->login($client, self::ADMIN); + $supplier = $this->supplier($client); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $id = $this->id($supplier); + self::assertSame('"revision-1"', $client->getResponse()->headers->get('ETag')); + $this->request($client, 'PATCH', '/suppliers/' . $id, ['name' => 'Revised supplier']); + self::assertSame(428, $client->getResponse()->getStatusCode()); + $changed = $this->request($client, 'PATCH', '/suppliers/' . $id, ['name' => 'Revised supplier'], 1); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(2, $changed['revision']); + $this->request($client, 'PATCH', '/suppliers/' . $id, ['name' => 'Stale write'], 1); + self::assertSame(412, $client->getResponse()->getStatusCode()); + $archived = $this->request($client, 'POST', '/suppliers/' . $id . '/archive', null, 2); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertNotNull($archived['archivedAt']); + $saved = $this->request($client, 'GET', '/suppliers/' . $id); + self::assertSame('Revised supplier', $saved['name']); + self::assertSame([['name' => 'Store contact', 'email' => 'store@example.com', 'phone' => null, 'role' => 'warehouse']], $saved['contacts']); + } + + /** + * Checks all suppliers is explicit and retains backward-compatible active defaults. + */ + #[Test] + public function supplierArchiveFiltersUseExactScopedCollections(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $archived = $this->supplier($client); + $archivedId = $this->id($archived); + $this->request($client, 'POST', '/suppliers/' . $archivedId . '/archive', null, 1); + $activeId = $this->id($this->supplier($client)); + + foreach (['' => [$activeId], '?archived=false' => [$activeId], '?archived=true' => [$archivedId]] as $filter => $ids) { + $result = $this->request($client, 'GET', '/suppliers' . $filter); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(1, $result['totalItems']); + self::assertIsArray($result['member']); + self::assertSame($ids, array_column($result['member'], 'id')); + } + $all = $this->request($client, 'GET', '/suppliers?archived=all&search=Supply&itemsPerPage=1&page=2'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(2, $all['totalItems']); + self::assertIsArray($all['member']); + self::assertCount(1, $all['member']); + $this->login($client, self::OUTSIDER); + $this->request($client, 'GET', '/suppliers?archived=all'); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + #[Test] + public function fractionalPartialReceiptReplaysAndReturnMovesStockExactlyOnce(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $supplier = $this->supplier($client); + $order = $this->purchase($client, $this->id($supplier), 'part', '2.500000', '4.250000'); + $orderId = $this->id($order); + $this->request($client, 'POST', '/orders/' . $orderId . '/order', null, 1); + $this->request($client, 'POST', '/suppliers/' . $this->id($supplier) . '/archive', null, 1); + $input = $this->receiptInput('1.250000'); + $first = $this->request($client, 'POST', '/orders/' . $orderId . '/receipts', $input, 2); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $receiptId = $this->id($first); + self::assertSame('stock_received', $first['status']); + self::assertSame('1.250000', $first['quantity']); + $replay = $this->request($client, 'POST', '/orders/' . $orderId . '/receipts', $input, 2); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertTrue($replay['replayed']); + self::assertSame($receiptId, $replay['id']); + $stored = $this->request($client, 'GET', '/orders/' . $orderId); + self::assertSame('partial_received', $stored['status']); + self::assertIsArray($stored['lines']); + self::assertIsArray($stored['lines'][0]); + self::assertSame('1.250000', $stored['lines'][0]['remainingQuantity']); + $return = ['clientOperationId' => self::SECOND_OPERATION, 'quantity' => '0.250000', 'reason' => 'Damaged packaging']; + $returned = $this->request($client, 'POST', '/receipts/' . $receiptId . '/returns', $return, 1); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('0.250000', $returned['returnedQuantity']); + $this->request($client, 'POST', '/receipts/' . $receiptId . '/returns', $return, 1); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame('1.000000', $this->main()->getConnection()->fetchOne('SELECT quantity FROM inventory_balances WHERE organization_id = ? AND part_id = ? AND warehouse_id = ?', [self::ORG, self::PART, self::WAREHOUSE])); + self::assertSame('4.250000', $this->main()->getConnection()->fetchOne('SELECT total_value FROM inventory_balances WHERE organization_id = ? AND part_id = ? AND warehouse_id = ?', [self::ORG, self::PART, self::WAREHOUSE])); + self::assertSame(1, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM procurement_receipts WHERE organization_id = ?', [self::ORG])); + } + + #[Test] + public function duplicateMismatchAndOverReceiptDoNotCreatePhysicalOrStockRows(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $supplier = $this->supplier($client); + $order = $this->purchase($client, $this->id($supplier), 'part', '1.000000', null); + $orderId = $this->id($order); + $this->request($client, 'POST', '/orders/' . $orderId . '/order', null, 1); + $this->request($client, 'POST', '/orders/' . $orderId . '/receipts', $this->receiptInput('1.000000'), 2); + $this->request($client, 'POST', '/orders/' . $orderId . '/receipts', $this->receiptInput('0.500000'), 2); + self::assertSame(409, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $input = $this->receiptInput('1.000000'); + $input['clientOperationId'] = self::SECOND_OPERATION; + $this->request($client, 'POST', '/orders/' . $orderId . '/receipts', $input, 3); + self::assertSame(409, $client->getResponse()->getStatusCode()); + self::assertSame(1, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM procurement_receipts WHERE organization_id = ?', [self::ORG])); + self::assertSame('1.000000', $this->main()->getConnection()->fetchOne('SELECT quantity FROM inventory_balances WHERE organization_id = ?', [self::ORG])); + } + + #[Test] + public function hardwareReceiptSurvivesQuotaThenAnExplicitRetryCreatesOneReserveEquipment(): void + { + $client = static::createClient(); + $this->seed(0); + $this->login($client, self::ADMIN); + $supplier = $this->supplier($client); + $order = $this->purchase($client, $this->id($supplier), 'equipment_to_individualize', '1.000000', null); + $orderId = $this->id($order); + $this->request($client, 'POST', '/orders/' . $orderId . '/order', null, 1); + $input = $this->receiptInput('1.000000'); + unset($input['warehouseId']); + $receipt = $this->request($client, 'POST', '/orders/' . $orderId . '/receipts', $input, 2); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $receiptId = $this->id($receipt); + self::assertSame('awaiting_individualization', $receipt['status']); + $attempt = ['clientOperationId' => self::SECOND_OPERATION]; + $blocked = $this->request($client, 'POST', '/receipts/' . $receiptId . '/individualize', $attempt, 1); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('quota_exceeded', $blocked['blockedReason']); + $sameAttempt = $this->request($client, 'POST', '/receipts/' . $receiptId . '/individualize', $attempt, 1); + self::assertTrue($sameAttempt['replayed']); + self::assertSame(0, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM equipment WHERE organization_id = ?', [self::ORG])); + $plan = $this->main()->find(PlanRecord::class, self::PLAN); + self::assertInstanceOf(PlanRecord::class, $plan); + $plan->limits = ['equipment' => 1]; + $this->main()->flush(); + $newAttempt = ['clientOperationId' => self::THIRD_OPERATION]; + $individualized = $this->request($client, 'POST', '/receipts/' . $receiptId . '/individualize', $newAttempt, 2); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('individualized', $individualized['status']); + self::assertIsArray($individualized['equipmentIds']); + self::assertCount(1, $individualized['equipmentIds']); + $this->request($client, 'POST', '/receipts/' . $receiptId . '/individualize', $newAttempt, 2); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(1, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM equipment WHERE organization_id = ?', [self::ORG])); + self::assertSame('in_stock', $this->main()->getConnection()->fetchOne('SELECT status FROM equipment WHERE organization_id = ?', [self::ORG])); + self::assertSame(0, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM inventory_balances WHERE organization_id = ?', [self::ORG])); + } + + #[Test] + public function aQuantityReceiverCanReadAndReceiveWithoutSeeingInternalCosts(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $supplier = $this->supplier($client); + $order = $this->purchase($client, $this->id($supplier), 'part', '2.500000', '4.250000'); + $orderId = $this->id($order); + $this->request($client, 'POST', '/orders/' . $orderId . '/order', null, 1); + $this->login($client, self::RECEIVER); + $hidden = $this->request($client, 'GET', '/orders/' . $orderId); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertFalse($hidden['financialVisible']); + self::assertIsArray($hidden['lines']); + self::assertIsArray($hidden['lines'][0]); + self::assertArrayNotHasKey('unitCost', $hidden['lines'][0]); + $receipt = $this->request($client, 'POST', '/orders/' . $orderId . '/receipts', $this->receiptInput('1.250000'), 2); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertFalse($receipt['financialVisible']); + self::assertArrayNotHasKey('unitCost', $receipt); + $this->purchase($client, $this->id($supplier), 'part', '1.000000', '0.000000'); + self::assertSame(403, $client->getResponse()->getStatusCode()); + } + + #[Test] + #[DataProvider('denials')] + public function denialDoesNotCreateASupplier(string $actorId, int $expectedStatus): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, $actorId); + $this->supplier($client); + self::assertSame($expectedStatus, $client->getResponse()->getStatusCode()); + self::assertSame(0, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM procurement_suppliers WHERE organization_id = ?', [self::ORG])); + } + + /** + * @return iterable + */ + public static function denials(): iterable + { + yield 'reader missing manage' => [self::READER, 403]; + yield 'outsider' => [self::OUTSIDER, 404]; + } + + #[Test] + public function cancellationKeepsPartialReceiptsAndRefusesMoreDeliveries(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $supplier = $this->supplier($client); + $orderId = $this->id($this->purchase($client, $this->id($supplier), 'part', '2.500000', null)); + $this->request($client, 'POST', '/orders/' . $orderId . '/order', null, 1); + $first = $this->request($client, 'POST', '/orders/' . $orderId . '/receipts', $this->receiptInput('1.000000'), 2); + self::assertSame(201, $client->getResponse()->getStatusCode()); + $cancelled = $this->request($client, 'POST', '/orders/' . $orderId . '/cancel-remaining', null, 3); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('cancelled', $cancelled['status']); + $input = $this->receiptInput('1.000000'); + $input['clientOperationId'] = self::SECOND_OPERATION; + $this->request($client, 'POST', '/orders/' . $orderId . '/receipts', $input, 4); + self::assertSame(409, $client->getResponse()->getStatusCode()); + $saved = $this->request($client, 'GET', '/receipts/' . $this->id($first)); + self::assertSame('1.000000', $saved['quantity']); + } + + #[Test] + public function aPhysicalReturnWithComputerStockShortageIsRetainedAndExplicitlyReconciled(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $supplier = $this->supplier($client); + $orderId = $this->id($this->purchase($client, $this->id($supplier), 'part', '2.500000', '4.250000')); + $this->request($client, 'POST', '/orders/' . $orderId . '/order', null, 1); + $receipt = $this->request($client, 'POST', '/orders/' . $orderId . '/receipts', $this->receiptInput('2.500000'), 2); + self::assertSame(201, $client->getResponse()->getStatusCode()); + $receiptId = $this->id($receipt); + $this->main()->getConnection()->update('inventory_balances', ['quantity' => '0.000000', 'total_value' => '0.000000'], ['organization_id' => self::ORG]); + $input = ['clientOperationId' => self::SECOND_OPERATION, 'quantity' => '1.000000', 'reason' => 'Physical return with an inaccurate computer balance']; + $pending = $this->request($client, 'POST', '/receipts/' . $receiptId . '/returns', $input, 1); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('1.000000', $pending['returnedQuantity']); + self::assertSame('1.000000', $pending['pendingReturnQuantity']); + $list = $this->request($client, 'GET', '/receipts/' . $receiptId . '/returns'); + self::assertSame(200, $client->getResponse()->getStatusCode()); + $items = $list['member'] ?? $list['hydra:member'] ?? null; + self::assertIsArray($items); + self::assertCount(1, $items); + self::assertIsArray($items[0]); + self::assertArrayHasKey('id', $items[0]); + self::assertIsString($items[0]['id']); + $returnId = $items[0]['id']; + self::assertSame('awaiting_reconciliation', $items[0]['status']); + self::assertSame('stock_insufficient', $items[0]['blockedReason']); + self::assertSame('0.000000', $this->main()->getConnection()->fetchOne('SELECT quantity FROM inventory_balances WHERE organization_id = ?', [self::ORG])); + $this->main()->getConnection()->update('inventory_balances', ['quantity' => '1.000000', 'total_value' => '4.250000'], ['organization_id' => self::ORG]); + $reconcile = ['clientOperationId' => self::THIRD_OPERATION]; + $confirmed = $this->request($client, 'POST', '/returns/' . $returnId . '/reconcile', $reconcile, 1); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('confirmed', $confirmed['status']); + $replay = $this->request($client, 'POST', '/returns/' . $returnId . '/reconcile', $reconcile, 1); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertTrue($replay['replayed']); + $saved = $this->request($client, 'GET', '/receipts/' . $receiptId); + self::assertSame('1.000000', $saved['returnedQuantity']); + self::assertSame('0.000000', $saved['pendingReturnQuantity']); + $declaration = $this->request($client, 'GET', '/returns/' . $returnId); + self::assertSame($input['reason'], $declaration['reason']); + self::assertSame(self::SECOND_OPERATION, $declaration['clientOperationId']); + self::assertSame('0.000000', $this->main()->getConnection()->fetchOne('SELECT quantity FROM inventory_balances WHERE organization_id = ?', [self::ORG])); + self::assertSame(1, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM procurement_returns WHERE organization_id = ?', [self::ORG])); + } + + #[Test] + public function uppercaseUuidAliasesReplayTheSamePhysicalDelivery(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $supplier = $this->supplier($client); + $orderId = $this->id($this->purchase($client, $this->id($supplier), 'part', '2.500000', null)); + $this->request($client, 'POST', '/orders/' . $orderId . '/order', null, 1); + $input = $this->receiptInput('1.000000'); + $first = $this->request($client, 'POST', '/orders/' . $orderId . '/receipts', $input, 2); + self::assertSame(201, $client->getResponse()->getStatusCode()); + foreach (['lineId', 'warehouseId', 'clientOperationId'] as $field) { + self::assertIsString($input[$field]); + $input[$field] = strtoupper($input[$field]); + } + $replay = $this->request($client, 'POST', '/orders/' . strtoupper($orderId) . '/receipts', $input, 2); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertTrue($replay['replayed']); + self::assertSame($first['id'], $replay['id']); + self::assertSame(1, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM procurement_receipts WHERE organization_id = ?', [self::ORG])); + self::assertSame('1.000000', $this->main()->getConnection()->fetchOne('SELECT quantity FROM inventory_balances WHERE organization_id = ?', [self::ORG])); + } + + #[Test] + public function orderedArticleIdentityAndUnitRemainVisibleWithoutFinancialRights(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $supplier = $this->supplier($client); + $orderId = $this->id($this->purchase($client, $this->id($supplier), 'part', '2.500000', null)); + self::assertSame(201, $client->getResponse()->getStatusCode()); + $this->request($client, 'POST', '/orders/' . $orderId . '/order', null, 1); + $part = $this->main()->find(InventoryPartRecord::class, self::PART); + self::assertInstanceOf(InventoryPartRecord::class, $part); + $part->label = 'Renamed after ordering'; + $part->unit = 'kilogram'; + $this->main()->flush(); + $this->login($client, self::RECEIVER); + $saved = $this->request($client, 'GET', '/orders/' . $orderId); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertIsArray($saved['lines']); + self::assertIsArray($saved['lines'][0]); + self::assertSame('REF-PART', $saved['lines'][0]['partCode']); + self::assertSame('Consumable', $saved['lines'][0]['partLabel']); + self::assertSame('litre', $saved['lines'][0]['partUnit']); + self::assertFalse($saved['financialVisible']); + self::assertArrayNotHasKey('unitCost', $saved['lines'][0]); + } + + /** + * @return array + */ + private function supplier(KernelBrowser &$client): array + { + return $this->request($client, 'POST', '/suppliers', ['name' => 'Supply company', 'contacts' => [['name' => 'Store contact', 'email' => 'store@example.com', 'role' => 'warehouse']]]); + } + + /** + * @return array + */ + private function purchase(KernelBrowser &$client, string $supplierId, string $kind, string $quantity, ?string $unitCost): array + { + $line = ['id' => self::LINE, 'kind' => $kind, 'quantity' => $quantity]; + if (null !== $unitCost) { + $line['unitCost'] = $unitCost; + } + if ('part' === $kind) { + $line['partId'] = self::PART; + } else { + $line['typeCode'] = 'fire_extinguisher'; + $line['identityTemplate'] = ['name' => 'Reserve extinguisher', 'brand' => 'Test brand']; + } + + return $this->request($client, 'POST', '/orders', ['name' => 'Internal purchase', 'supplierId' => $supplierId, 'lines' => [$line]]); + } + + /** + * @return array + */ + private function receiptInput(string $quantity): array + { + return ['lineId' => self::LINE, 'warehouseId' => self::WAREHOUSE, 'quantity' => $quantity, 'receivedAt' => '2026-10-05T12:00:00Z', 'clientOperationId' => self::OPERATION]; + } + + /** + * @param array $data + */ + private function id(array $data): string + { + self::assertIsString($data['id'] ?? null); + + return $data['id']; + } + + /** + * @param array|null $body + * + * @return array + */ + private function request(KernelBrowser &$client, string $method, string $path, ?array $body = null, ?int $revision = null): array + { + self::ensureKernelShutdown(); + $client = static::createClient(); + if (null !== $this->loggedUserId) { + $this->login($client, $this->loggedUserId); + } + $server = ['CONTENT_TYPE' => 'PATCH' === $method ? 'application/merge-patch+json' : 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json']; + if (null !== $revision) { + $server['HTTP_IF_MATCH'] = '"revision-' . $revision . '"'; + } + $client->request($method, '/api/organizations/' . self::ORG . '/procurement' . $path, server: $server, content: null === $body ? null : json_encode((object) $body, JSON_THROW_ON_ERROR)); + $content = (string) $client->getResponse()->getContent(); + /** @var array $decoded */ + $decoded = '' === $content ? [] : json_decode($content, true, flags: JSON_THROW_ON_ERROR); + + return $decoded; + } + + private function login(KernelBrowser $client, string $id): void + { + $this->loggedUserId = $id; + $client->disableReboot(); + $client->loginUser(new SecurityUser($id, 'procurement-' . $id . '@example.com', 'hashed-password', ['ROLE_USER']), 'api'); + } + + private function main(): EntityManagerInterface + { + $manager = static::getContainer()->get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $manager); + + return $manager; + } + + private function seed(int $equipmentLimit = 100): void + { + $manager = $this->main(); + $now = new DateTimeImmutable('2026-10-06T00:00:00Z'); + $plan = new PlanRecord(); + $plan->id = self::PLAN; + $plan->key = 'procurement-test'; + $plan->name = 'Procurement test'; + $plan->limits = ['equipment' => $equipmentLimit]; + $plan->createdAt = $now; + $plan->updatedAt = $now; + $manager->persist($plan); + $organization = new OrganizationRecord(); + $organization->id = self::ORG; + $organization->name = 'Procurement test'; + $organization->slug = 'procurement-test'; + $organization->ownerUserId = self::ADMIN; + $organization->createdByUserId = self::ADMIN; + $organization->planId = self::PLAN; + $organization->createdAt = $now; + $organization->updatedAt = $now; + $manager->persist($organization); + foreach ([self::ADMIN => ['*'], self::RECEIVER => ['organization.procurement.read', 'organization.procurement.manage', 'organization.inventory.manage'], self::READER => ['organization.procurement.read']] as $userId => $permissions) { + $role = new OrganizationRoleRecord(); + $role->id = '790e8401' . substr($userId, 8); + $role->organization = $organization; + $role->name = 'test-' . $userId; + $role->permissions = $permissions; + $role->createdAt = $now; + $manager->persist($role); + $member = new OrganizationMemberRecord(); + $member->id = '790e8402' . substr($userId, 8); + $member->organization = $organization; + $member->userId = $userId; + $member->joinedAt = $now; + $manager->persist($member); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $manager->persist($assignment); + } + $part = new InventoryPartRecord(); + $part->id = self::PART; + $part->organizationId = self::ORG; + $part->code = 'REF-PART'; + $part->label = 'Consumable'; + $part->unit = 'litre'; + $part->kind = 'consumable'; + $manager->persist($part); + $warehouse = new InventoryWarehouseRecord(); + $warehouse->id = self::WAREHOUSE; + $warehouse->organizationId = self::ORG; + $warehouse->code = 'WAREHOUSE'; + $warehouse->label = 'Main warehouse'; + $manager->persist($warehouse); + $manager->flush(); + } +} diff --git a/tests/Functional/Api/ServiceRequestApiTest.php b/tests/Functional/Api/ServiceRequestApiTest.php new file mode 100644 index 000000000..0c956300e --- /dev/null +++ b/tests/Functional/Api/ServiceRequestApiTest.php @@ -0,0 +1,662 @@ +client(permissions: ['organization.service_requests.create']); + $this->seedTarget(); + $body = $this->request($client, 'POST', '', $this->createBody()); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('Seal damaged', $body['title']); + self::assertSame('requested', $body['status']); + self::assertSame(self::SITE, $body['siteId']); + self::assertSame(1, $body['revision']); + /** @var array{customer:array,site:array{id:string,name:string},equipment:array{id:string,name:string}} $snapshot */ + $snapshot = $body['targetSnapshot']; + self::assertSame(['id' => self::CUSTOMER, 'name' => 'Internal client'], $snapshot['customer']); + self::assertSame(['id' => self::SITE, 'name' => 'Fire site'], $snapshot['site']); + self::assertSame(self::EQUIPMENT, $snapshot['equipment']['id']); + self::assertArrayNotHasKey('contacts', $snapshot['customer']); + self::assertNull($body['interventionId']); + } + + public function testCreateSiteOnlyRequestWithoutMandatoryCustomer(): void + { + $client = $this->client(); + $this->seedTarget(customer: false); + $body = $this->request($client, 'POST', '', ['siteId' => self::SITE, 'title' => 'Locate repair target', 'description' => 'Damage reported in the storage room.']); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertNull($body['equipmentId']); + self::assertSame('requested', $body['status']); + /** @var array{customer:mixed} $snapshot */ $snapshot = $body['targetSnapshot']; + self::assertNull($snapshot['customer']); + } + + public function testStockEquipmentCanBeRequestedWithoutClaimingASite(): void + { + $client = $this->client(); + $equipment = $this->seedTarget(); + $equipment->facilityId = null; + $equipment->status = 'in_stock'; + $this->main()->flush(); + $body = $this->request($client, 'POST', '', $this->createBody()); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertNull($body['siteId']); + /** @var array{site:mixed,customer:mixed} $snapshot */ $snapshot = $body['targetSnapshot']; + self::assertNull($snapshot['site']); + self::assertNull($snapshot['customer']); + } + + #[DataProvider('unavailableTargets')] + public function testCreationRefusesUnavailableTargetsWithoutSaving(string $scenario, int $status): void + { + $client = $this->client(); + $equipment = $this->seedTarget(); + $body = $this->createBody(); + if ('retired' === $scenario) { + $equipment->status = 'decommissioned'; + } elseif ('draft' === $scenario) { + $equipment->recordStatus = 'draft'; + } elseif ('foreign' === $scenario) { + $equipment->organization = $this->organization(self::OTHER_ORG); + } elseif ('archived' === $scenario) { + $this->main()->getConnection()->executeStatement("UPDATE facilities SET status = 'archived' WHERE id = ?", [self::SITE]); + } elseif ('missing' === $scenario) { + $body['equipmentId'] = 'ad69061e-b12f-41de-8a66-000000000099'; + } else { + unset($body['equipmentId']); + } + $this->main()->flush(); + $this->request($client, 'POST', '', $body); + self::assertSame($status, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(0, $this->repository()->count(self::ORG, null, null, null, '')); + } + + /** + * @return iterable + */ + public static function unavailableTargets(): iterable + { + yield 'retired' => ['retired', 409]; + yield 'archived ancestor' => ['archived', 409]; + yield 'draft' => ['draft', 422]; + yield 'foreign' => ['foreign', 422]; + yield 'missing' => ['missing', 422]; + yield 'no target' => ['none', 422]; + } + + public function testCreationRefusesAnEquipmentOutsideExplicitSite(): void + { + $client = $this->client(); + $this->seedTarget(); + $site = new FacilityRecord(); + $site->id = 'ad69061e-b12f-41de-8a66-000000000022'; + $site->organization = $this->main()->getReference(OrganizationRecord::class, self::ORG); + $site->name = 'Other site'; + $site->type = 'site'; + $site->createdAt = $site->updatedAt = new DateTimeImmutable('2026-10-05T10:00:00+00:00'); + $this->main()->persist($site); + $this->main()->flush(); + $this->request($client, 'POST', '', $this->createBody() + ['siteId' => $site->id]); + self::assertSame(422, $client->getResponse()->getStatusCode()); + } + + public function testCreationRefusesAnUnprovenInspectionOrigin(): void + { + $client = $this->client(); + $this->seedTarget(); + $this->request($client, 'POST', '', $this->createBody() + ['originInspectionId' => 'ad69061e-b12f-41de-8a66-000000000060']); + self::assertSame(422, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(0, $this->repository()->count(self::ORG, null, null, null, '')); + } + + #[DataProvider('originScopes')] + public function testOriginRequiresPublishedMatchingInspectionAndFinding(string $scenario, int $expected): void + { + $client = $this->client(permissions: ['organization.service_requests.create']); + $this->seedTarget(); + $inspection = new InspectionRecord(); + $inspection->id = 'ad69061e-b12f-41de-8a66-000000000060'; + $inspection->organization = 'foreign' === $scenario ? $this->organization(self::OTHER_ORG) : $this->main()->getReference(OrganizationRecord::class, self::ORG); + $inspection->equipmentId = 'equipment' === $scenario ? 'ad69061e-b12f-41de-8a66-000000000039' : self::EQUIPMENT; + $inspection->facilityId = 'site' === $scenario ? null : self::BUILDING; + $inspection->recordStatus = 'draft' === $scenario ? 'draft' : 'published'; + $inspection->status = 'closed'; + $inspection->result = 'fail'; + $inspection->inspectorType = 'internal'; + $inspection->inspectorName = 'Technician'; + $inspection->performedAt = $inspection->createdAt = $inspection->updatedAt = new DateTimeImmutable('2026-10-05T10:00:00+00:00'); + $this->main()->persist($inspection); + $finding = new NonConformityRecord(); + $finding->id = 'ad69061e-b12f-41de-8a66-000000000061'; + $finding->inspection = $inspection; + $finding->description = 'Broken seal'; + $finding->severity = 'major'; + $finding->status = 'open'; + $finding->createdAt = $finding->updatedAt = $inspection->createdAt; + $this->main()->persist($finding); + $this->main()->flush(); + $origin = ['originInspectionId' => 'inspection selection' === $scenario ? 'ad69061e-b12f-41de-8a66-000000000063' : $inspection->id, 'originNonConformityId' => 'missing finding' === $scenario ? 'ad69061e-b12f-41de-8a66-000000000062' : $finding->id]; + $body = $this->request($client, 'POST', '', $this->createBody() + $origin); + self::assertSame($expected, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + if (201 === $expected) { + self::assertSame($inspection->id, $body['originInspectionId']); + self::assertSame($finding->id, $body['originNonConformityId']); + } else { + self::assertSame(0, $this->repository()->count(self::ORG, null, null, null, '')); + } + } + + /** + * @return iterable + */ + public static function originScopes(): iterable + { + yield 'matching published inspection and finding' => ['valid', 201]; + yield 'foreign inspection and finding' => ['foreign', 422]; + yield 'unpublished inspection' => ['draft', 422]; + yield 'different equipment' => ['equipment', 422]; + yield 'different site' => ['site', 422]; + yield 'finding belongs to another selected inspection' => ['inspection selection', 422]; + yield 'unknown finding' => ['missing finding', 422]; + } + + public function testHistoricalReadRetainsTargetAndDropsUnexpectedPrivateFields(): void + { + $client = $this->client(permissions: ['organization.service_requests.read']); + $equipment = $this->seedTarget(); + $this->seedRequest(extraSnapshot: ['customer' => ['id' => self::CUSTOMER, 'name' => 'Original client', 'contacts' => [['name' => 'Private', 'email' => 'private@example.com']]], 'privateSource' => 'hidden']); + $equipment->status = 'decommissioned'; + $equipment->name = 'Changed now'; + $this->main()->flush(); + $body = $this->request($client, 'GET', '/' . self::REQUEST); + self::assertSame(200, $client->getResponse()->getStatusCode()); + /** @var array{equipment:array{name:string},customer:array} $snapshot */ $snapshot = $body['targetSnapshot']; + self::assertSame('Extinguisher A', $snapshot['equipment']['name']); + self::assertSame(['id' => self::CUSTOMER, 'name' => 'Original client'], $snapshot['customer']); + self::assertArrayNotHasKey('privateSource', $snapshot); + } + + public function testListFiltersCountAndPageWithinOrganization(): void + { + $client = $this->client(); + $this->seedTarget(); + $this->seedRequest(qualified: true); + $this->seedRequest(id: 'ad69061e-b12f-41de-8a66-000000000041'); + $this->organization(self::OTHER_ORG); + $this->seedRequest(id: 'ad69061e-b12f-41de-8a66-000000000042', organizationId: self::OTHER_ORG, qualified: true); + $body = $this->request($client, 'GET', '?status=qualified&equipmentId=' . self::EQUIPMENT . '&siteId=' . self::SITE . '&search=Seal&itemsPerPage=1'); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(1, $body['totalItems']); + /** @var list $members */ $members = $body['member']; + self::assertCount(1, $members); + self::assertSame(self::REQUEST, $members[0]['id']); + } + + #[DataProvider('permissionRefusals')] + public function testPermissionsAndOrganizationScopesAreChecked(string $method, string $path, string $actor, int $expected): void + { + $client = $this->client($actor); + $this->seedTarget(); + $this->seedRequest(); + $this->request($client, $method, $path, 'PATCH' === $method ? ['title' => 'Updated'] : $this->createBody(), 1); + self::assertSame($expected, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + } + + /** + * @return iterable + */ + public static function permissionRefusals(): iterable + { + yield 'unentitled read' => ['GET', '', self::MEMBER, 403]; + yield 'unentitled create' => ['POST', '', self::MEMBER, 403]; + yield 'unentitled manage' => ['PATCH', '/' . self::REQUEST, self::MEMBER, 403]; + yield 'outside read' => ['GET', '', 'ad69061e-b12f-41de-8a66-000000000099', 404]; + yield 'outside create' => ['POST', '', 'ad69061e-b12f-41de-8a66-000000000099', 404]; + } + + public function testForeignRequestIdentityIsHidden(): void + { + $client = $this->client(); + $this->seedTarget(); + $this->organization(self::OTHER_ORG); + $this->seedRequest(organizationId: self::OTHER_ORG); + $this->request($client, 'GET', '/' . self::REQUEST); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + public function testPatchChangesDescriptionAndKeepsTargetAtExpectedRevision(): void + { + $client = $this->client(); + $this->seedTarget(); + $this->seedRequest(); + $body = $this->request($client, 'PATCH', '/' . self::REQUEST, ['description' => ' Replace the seal. ', 'priority' => 'high'], 1); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('Replace the seal.', $body['description']); + self::assertSame('high', $body['priority']); + self::assertSame(self::EQUIPMENT, $body['equipmentId']); + self::assertSame(2, $body['revision']); + } + + #[DataProvider('revisionRefusals')] + public function testRevisionPreconditionsPreventMutation(?string $header, int $expected): void + { + $client = $this->client(); + $this->seedTarget(); + $this->seedRequest(); + $this->request($client, 'PATCH', '/' . self::REQUEST, ['title' => 'New'], rawRevision: $header); + self::assertSame($expected, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(1, $this->repository()->find(self::REQUEST, self::ORG)?->revision); + } + + /** + * @return iterable + */ + public static function revisionRefusals(): iterable + { + yield 'missing' => [null, 428]; + yield 'stale' => ['"revision-2"', 412]; + yield 'malformed' => ['1', 412]; + } + + public function testSiteOnlyQualificationSelectsEquipmentWithinOriginalSite(): void + { + $client = $this->client(); + $this->seedTarget(); + $this->seedRequest(equipmentId: null); + $body = $this->request($client, 'POST', '/' . self::REQUEST . '/qualify', ['equipmentId' => self::EQUIPMENT, 'note' => 'Verified target'], 1); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('qualified', $body['status']); + self::assertSame(self::EQUIPMENT, $body['equipmentId']); + self::assertSame(self::SITE, $body['siteId']); + self::assertSame('Verified target', $body['qualificationNote']); + self::assertSame(3, $body['revision']); + self::assertNotNull($body['qualifiedAt']); + } + + public function testSiteOnlyQualificationRequiresEquipmentBeforeWork(): void + { + $client = $this->client(); + $this->seedTarget(); + $this->seedRequest(equipmentId: null); + $this->request($client, 'POST', '/' . self::REQUEST . '/qualify', ['note' => 'No equipment chosen'], 1); + self::assertSame(422, $client->getResponse()->getStatusCode()); + self::assertSame('requested', $this->repository()->find(self::REQUEST, self::ORG)?->status); + } + + #[DataProvider('decisions')] + public function testExplicitDecisionKeepsTargetAndReason(string $action, string $expected): void + { + $client = $this->client(); + $this->seedTarget(); + $this->seedRequest(qualified: true); + $body = $this->request($client, 'POST', '/' . self::REQUEST . '/' . $action, ['reason' => 'Duplicate field report'], 2); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame($expected, $body['status']); + self::assertSame('Duplicate field report', $body['decisionReason']); + self::assertSame(self::EQUIPMENT, $body['equipmentId']); + self::assertNotNull($body['qualifiedAt']); + } + + /** + * @return iterable + */ + public static function decisions(): iterable + { + yield 'reject' => ['reject', 'rejected']; + yield 'cancel' => ['cancel', 'cancelled']; + } + + public function testConversionCreatesAndLinksExactlyOneCorrectiveRepair(): void + { + $client = $this->client(); + $this->seedTarget(); + $this->seedRequest(qualified: true); + $body = $this->request($client, 'POST', '/' . self::REQUEST . '/convert', ['clientOperationId' => self::OPERATION], 2); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('converted', $body['status']); + self::assertSame(3, $body['revision']); + self::assertIsString($body['interventionId']); + self::assertIsString($body['taskId']); + self::assertNotNull($body['convertedAt']); + self::assertSame(1, $this->main()->getRepository(InterventionRecord::class)->count(['organization' => self::ORG])); + $task = $this->main()->find(InterventionWorkItemRecord::class, $body['taskId']); + self::assertInstanceOf(InterventionWorkItemRecord::class, $task); + self::assertSame('repair', $task->action); + self::assertSame('/api/equipment/' . self::EQUIPMENT, $task->target); + self::assertSame($body['interventionId'], $this->repository()->conversionReceiptByOperation(self::ORG, self::OPERATION)?->interventionId); + } + + public function testConversionReplayAcceptsOriginalRevisionAfterRetirementAndPublishedWork(): void + { + $client = $this->client(); + $this->seedTarget(); + $this->seedRequest(qualified: true); + $first = $this->convert(); + $this->main()->getConnection()->executeStatement("UPDATE equipment SET status = 'decommissioned' WHERE id = ?", [self::EQUIPMENT]); + $this->main()->getConnection()->executeStatement("UPDATE facilities SET status = 'archived' WHERE id = ?", [self::SITE]); + $this->main()->getConnection()->executeStatement("UPDATE interventions SET status = 'published' WHERE id = ?", [$first->request->interventionId]); + $this->main()->clear(); + $body = $this->request($client, 'POST', '/' . self::REQUEST . '/convert', ['clientOperationId' => self::OPERATION], 2); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame($first->request->interventionId, $body['interventionId']); + self::assertSame($first->request->taskId, $body['taskId']); + self::assertSame(3, $body['revision']); + self::assertSame(1, $this->main()->getRepository(InterventionRecord::class)->count(['organization' => self::ORG])); + } + + public function testConversionLinksAnOpenFailedRepairAndPreservesItsExecutionHistory(): void + { + $client = $this->client(); + $this->seedTarget(); + $this->seedRequest(qualified: true); + $order = new InterventionRecord(); + $order->id = 'ad69061e-b12f-41de-8a66-000000000070'; + $order->organization = $this->main()->getReference(OrganizationRecord::class, self::ORG); + $order->name = 'Repair already open'; + $order->type = 'corrective_maintenance'; + $order->siteId = self::SITE; + $order->status = 'in_progress'; + $order->number = 1; + $order->createdAt = $order->updatedAt = new DateTimeImmutable('2026-10-05T10:00:00+00:00'); + $this->main()->persist($order); + $task = new InterventionWorkItemRecord(); + $task->id = 'ad69061e-b12f-41de-8a66-000000000071'; + $task->intervention = $order; + $task->action = 'repair'; + $task->target = '/api/equipment/' . self::EQUIPMENT; + $task->status = 'in_progress'; + $task->revision = 9; + $task->executionResult = ['equipmentId' => self::EQUIPMENT, 'performedAt' => '2026-10-05T10:00:00+00:00', 'outcome' => 'failed', 'workPerformed' => 'Seal unavailable', 'state' => 'staged', 'validatedAt' => null, 'history' => [['equipmentId' => self::EQUIPMENT, 'performedAt' => '2026-10-04T10:00:00+00:00', 'outcome' => 'failed', 'workPerformed' => 'Initial assessment']]]; + $task->resultResource = $task->target; + $task->createdAt = $task->updatedAt = $order->createdAt; + $this->main()->persist($task); + $this->main()->flush(); + $proof = $task->executionResult; + $body = $this->request($client, 'POST', '/' . self::REQUEST . '/convert', ['clientOperationId' => self::OPERATION, 'existingInterventionId' => $order->id, 'existingTaskId' => $task->id], 2); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('converted', $body['status']); + self::assertSame($order->id, $body['interventionId']); + self::assertSame($task->id, $body['taskId']); + $retained = $this->main()->find(InterventionWorkItemRecord::class, $task->id); + self::assertInstanceOf(InterventionWorkItemRecord::class, $retained); + self::assertSame($proof, $retained->executionResult); + self::assertSame(9, $retained->revision); + self::assertSame('/api/equipment/' . self::EQUIPMENT, $retained->resultResource); + self::assertSame(1, $this->main()->getRepository(InterventionWorkItemRecord::class)->count(['intervention' => $order->id])); + } + + public function testChangedConversionSelectionConflictsWithoutDuplicatingWork(): void + { + $client = $this->client(); + $this->seedTarget(); + $this->seedRequest(qualified: true); + $first = $this->convert(); + $this->request($client, 'POST', '/' . self::REQUEST . '/convert', ['clientOperationId' => self::OPERATION, 'existingInterventionId' => $first->request->interventionId], 2); + self::assertSame(409, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame(1, $this->main()->getRepository(InterventionRecord::class)->count(['organization' => self::ORG])); + self::assertSame($first->request->taskId, $this->repository()->find(self::REQUEST, self::ORG)?->taskId); + } + + public function testOperationCollisionBetweenRequestsPreservesSecondQualifiedRequest(): void + { + $client = $this->client(); + $this->seedTarget(); + $this->seedRequest(qualified: true); + $second = 'ad69061e-b12f-41de-8a66-000000000041'; + $this->seedRequest(id: $second, qualified: true); + $this->convert(); + $this->request($client, 'POST', '/' . $second . '/convert', ['clientOperationId' => self::OPERATION], 2); + self::assertSame(409, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('qualified', $this->repository()->find($second, self::ORG)?->status); + self::assertNull($this->repository()->conversionReceiptForRequest($second, self::ORG)); + self::assertSame(1, $this->main()->getRepository(InterventionRecord::class)->count(['organization' => self::ORG])); + } + + public function testConversionRequiresInterventionPlanningBeyondRequestManagement(): void + { + $client = $this->client(permissions: ['organization.service_requests.manage']); + $this->seedTarget(); + $this->seedRequest(qualified: true); + $this->request($client, 'POST', '/' . self::REQUEST . '/convert', ['clientOperationId' => self::OPERATION], 2); + self::assertSame(403, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('qualified', $this->repository()->find(self::REQUEST, self::ORG)?->status); + self::assertSame(0, $this->main()->getRepository(InterventionRecord::class)->count(['organization' => self::ORG])); + } + + public function testExplicitForeignInterventionIsHiddenDuringConversion(): void + { + $client = $this->client(); + $this->seedTarget(); + $this->seedRequest(qualified: true); + $order = new InterventionRecord(); + $order->id = 'ad69061e-b12f-41de-8a66-000000000070'; + $order->organization = $this->organization(self::OTHER_ORG); + $order->name = 'Foreign corrective draft'; + $order->type = 'corrective_maintenance'; + $order->number = 1; + $order->createdAt = $order->updatedAt = new DateTimeImmutable('2026-10-05T10:00:00+00:00'); + $this->main()->persist($order); + $this->main()->flush(); + $this->request($client, 'POST', '/' . self::REQUEST . '/convert', ['clientOperationId' => self::OPERATION, 'existingInterventionId' => $order->id], 2); + self::assertSame(404, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertNull($this->repository()->conversionReceiptForRequest(self::REQUEST, self::ORG)); + } + + /** + * @param list $permissions + */ + private function client(string $actor = self::OWNER, array $permissions = ['*']): KernelBrowser + { + $client = static::createClient(); + $organization = $this->organization(self::ORG); + $now = new DateTimeImmutable('2026-10-05T10:00:00+00:00'); + foreach ([self::OWNER => 'ad69061e-b12f-41de-8a66-000000000004', self::MEMBER => 'ad69061e-b12f-41de-8a66-000000000005'] as $userId => $memberId) { + $member = new OrganizationMemberRecord(); + $member->id = $memberId; + $member->organization = $organization; + $member->userId = $userId; + $member->isActive = true; + $member->joinedAt = $now; + $this->main()->persist($member); + if (self::OWNER === $userId) { + $role = new OrganizationRoleRecord(); + $role->id = 'ad69061e-b12f-41de-8a66-000000000006'; + $role->organization = $organization; + $role->name = 'service_request_test'; + $role->permissions = $permissions; + $role->isSystem = false; + $role->createdAt = $now; + $this->main()->persist($role); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $this->main()->persist($assignment); + } + } + $this->main()->flush(); + $client->loginUser(new SecurityUser($actor, $actor . '@example.com', 'password', ['ROLE_USER']), 'api'); + + return $client; + } + + private function organization(string $id): OrganizationRecord + { + $organization = new OrganizationRecord(); + $organization->id = $id; + $organization->name = 'Repair requests'; + $organization->slug = $id; + $organization->ownerUserId = self::OWNER; + $organization->createdByUserId = self::OWNER; + $organization->status = 'active'; + $organization->isActive = true; + $organization->createdAt = $organization->updatedAt = new DateTimeImmutable('2026-10-05T10:00:00+00:00'); + $this->main()->persist($organization); + $this->main()->flush(); + + return $organization; + } + + private function seedTarget(bool $customer = true): EquipmentRecord + { + $now = new DateTimeImmutable('2026-10-05T10:00:00+00:00'); + if ($customer) { + $client = new CustomerRecord(); + $client->id = self::CUSTOMER; + $client->organizationId = self::ORG; + $client->name = 'Internal client'; + $client->contacts = [['name' => 'Private contact', 'email' => 'private@example.com', 'phone' => null, 'role' => null]]; + $client->createdAt = $client->updatedAt = $now; + $this->main()->persist($client); + } + $site = new FacilityRecord(); + $site->id = self::SITE; + $site->organization = $this->main()->getReference(OrganizationRecord::class, self::ORG); + $site->type = 'site'; + $site->name = 'Fire site'; + $site->customerId = $customer ? self::CUSTOMER : null; + $site->createdAt = $site->updatedAt = $now; + $this->main()->persist($site); + $building = new FacilityRecord(); + $building->id = self::BUILDING; + $building->organization = $site->organization; + $building->type = 'building'; + $building->name = 'Building A'; + $building->parentFacility = $site; + $building->createdAt = $building->updatedAt = $now; + $this->main()->persist($building); + $equipment = new EquipmentRecord(); + $equipment->id = self::EQUIPMENT; + $equipment->organization = $site->organization; + $equipment->facilityId = $building->id; + $equipment->type = 'fire_extinguisher'; + $equipment->name = 'Extinguisher A'; + $equipment->assetCode = 'EXT-A'; + $equipment->status = 'in_service'; + $equipment->createdAt = $equipment->updatedAt = $now; + $this->main()->persist($equipment); + $this->main()->flush(); + + return $equipment; + } + + /** + * @param array $extraSnapshot + */ + private function seedRequest(string $id = self::REQUEST, string $organizationId = self::ORG, bool $qualified = false, ?string $equipmentId = self::EQUIPMENT, array $extraSnapshot = []): ServiceRequest + { + $now = new DateTimeImmutable('2026-10-05T10:00:00+00:00'); + $snapshot = $extraSnapshot + ['equipment' => null === $equipmentId ? null : ['id' => $equipmentId, 'name' => 'Extinguisher A', 'assetCode' => 'EXT-A', 'status' => 'in_service'], 'site' => ['id' => self::SITE, 'name' => 'Fire site'], 'customer' => ['id' => self::CUSTOMER, 'name' => 'Internal client']]; + $request = ServiceRequest::create($id, $organizationId, $equipmentId, self::SITE, $snapshot, 'Seal damaged', 'Replace the broken seal and verify tightness.', $now); + if ($qualified) { + $request = $request->qualify('Field repair accepted', $now->modify('+1 minute')); + } + $this->repository()->save($request); + + return $request; + } + + private function convert(): ConvertServiceRequestResult + { + /** @var CommandBusPort $bus */ $bus = static::getContainer()->get(CommandBusPort::class); + $result = $bus->dispatch(new ConvertServiceRequestCommand(self::OWNER, self::ORG, self::REQUEST, 2, self::OPERATION)); + self::assertInstanceOf(ConvertServiceRequestResult::class, $result); + + return $result; + } + + private function repository(): ServiceRequestRepositoryPort + { + /** @var ServiceRequestRepositoryPort $repository */ $repository = static::getContainer()->get(ServiceRequestRepositoryPort::class); + + return $repository; + } + + private function main(): EntityManagerInterface + { + /** @var EntityManagerInterface $manager */ $manager = static::getContainer()->get('doctrine.orm.main_entity_manager'); + + return $manager; + } + + /** + * @return array + */ + private function createBody(): array + { + return ['equipmentId' => self::EQUIPMENT, 'title' => ' Seal damaged ', 'description' => 'Replace the broken seal and verify tightness.']; + } + + /** + * @param array $body + * + * @return array + */ + private function request(KernelBrowser $client, string $method, string $path, array $body = [], ?int $revision = null, ?string $rawRevision = null): array + { + $headers = ['CONTENT_TYPE' => 'PATCH' === $method ? 'application/merge-patch+json' : 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json']; + $header = $rawRevision ?? (null === $revision ? null : '"revision-' . $revision . '"'); + if (null !== $header) { + $headers['HTTP_IF_MATCH'] = $header; + } + $client->request($method, '/api/organizations/' . self::ORG . '/service-requests' . $path, server: $headers, content: json_encode($body, JSON_THROW_ON_ERROR)); + /** @var array $decoded */ $decoded = json_decode((string) $client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + + return $decoded; + } +} diff --git a/tests/Integration/Customer/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportCustomerIdentityAdapterTest.php b/tests/Integration/Customer/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportCustomerIdentityAdapterTest.php new file mode 100644 index 000000000..df92d5966 --- /dev/null +++ b/tests/Integration/Customer/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportCustomerIdentityAdapterTest.php @@ -0,0 +1,93 @@ +get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $main); + $this->main = $main; + $this->adapter = new MaintenanceExportCustomerIdentityAdapter($main); + } + + #[Test] + public function retainsActiveAndArchivedCustomerIdentities(): void + { + $this->customer(self::CUSTOMER_ID); + $this->customer(self::ARCHIVED_ID, archived: true); + $this->main->flush(); + $this->main->clear(); + + self::assertTrue($this->adapter->exists(self::ORGANIZATION_ID, 'customer', self::CUSTOMER_ID)); + self::assertTrue($this->adapter->exists(self::ORGANIZATION_ID, 'customer', self::ARCHIVED_ID)); + } + + #[Test] + public function returnsTheSameFalseForForeignAndUnknownIdentities(): void + { + $this->customer(self::CUSTOMER_ID, self::OTHER_ORGANIZATION_ID); + $this->main->flush(); + $this->main->clear(); + + self::assertFalse($this->adapter->exists(self::ORGANIZATION_ID, 'customer', self::CUSTOMER_ID)); + self::assertFalse($this->adapter->exists(self::ORGANIZATION_ID, 'customer', self::ARCHIVED_ID)); + self::assertTrue($this->adapter->exists(self::OTHER_ORGANIZATION_ID, 'customer', self::CUSTOMER_ID)); + } + + #[Test] + public function neverTreatsAnotherResourceTypeAsACustomer(): void + { + $this->customer(self::CUSTOMER_ID); + $this->main->flush(); + + foreach (['site', 'equipment', 'CUSTOMER', 'unknown'] as $type) { + self::assertFalse($this->adapter->exists(self::ORGANIZATION_ID, $type, self::CUSTOMER_ID)); + } + } + + private function customer(string $id, string $organizationId = self::ORGANIZATION_ID, bool $archived = false): void + { + $customer = new CustomerRecord(); + $customer->id = $id; + $customer->organizationId = $organizationId; + $customer->name = 'Retained internal client'; + $customer->email = 'private@example.com'; + $customer->contacts = [['name' => 'Private contact', 'email' => 'contact@example.com', 'phone' => null, 'role' => null]]; + $customer->createdAt = new DateTimeImmutable('2026-10-07T08:00:00+00:00'); + $customer->updatedAt = $customer->createdAt; + $customer->archivedAt = $archived ? $customer->createdAt : null; + $this->main->persist($customer); + } +} diff --git a/tests/Integration/Equipment/Infrastructure/Adapter/Intervention/InterventionEquipmentSnapshotAdapterTest.php b/tests/Integration/Equipment/Infrastructure/Adapter/Intervention/InterventionEquipmentSnapshotAdapterTest.php new file mode 100644 index 000000000..7935bd5bf --- /dev/null +++ b/tests/Integration/Equipment/Infrastructure/Adapter/Intervention/InterventionEquipmentSnapshotAdapterTest.php @@ -0,0 +1,196 @@ + + */ +#[CoversClass(InterventionEquipmentSnapshotAdapter::class)] +final class InterventionEquipmentSnapshotAdapterTest extends KernelTestCase +{ + private EntityManagerInterface $entityManager; + + protected function setUp(): void + { + self::bootKernel(); + /** @var EntityManagerInterface $manager */ + $manager = self::getContainer()->get('doctrine.orm.main_entity_manager'); + $this->entityManager = $manager; + } + + public function testRetiredEquipmentAndArchivedSitesKeepDeclaredIdentityFromTheirOwner(): void + { + $organization = $this->organization(1); + $customer = new CustomerRecord(); + $customer->id = self::id(10); + $customer->organizationId = $organization->id; + $customer->name = 'Archived site customer'; + $customer->contacts = [['name' => 'Private contact', 'email' => 'private@example.test', 'phone' => null, 'role' => null]]; + $customer->createdAt = $customer->updatedAt = new DateTimeImmutable('2026-01-01T10:00:00Z'); + $this->entityManager->persist($customer); + $site = $this->facility(11, $organization, 'site', null); + $site->status = 'archived'; + $site->customerId = $customer->id; + $building = $this->facility(12, $organization, 'building', $site); + $equipment = $this->equipment(20, $organization, $building->id); + $equipment->type = 'archived_custom_fire_type'; + $equipment->status = 'retired'; + $equipment->name = 'Old cylinder'; + $equipment->assetCode = 'FIRE-001'; + $equipment->brand = 'Recorded brand'; + $equipment->model = 'Recorded model'; + $equipment->serialNumber = 'SERIAL-001'; + $this->entityManager->flush(); + $snapshots = $this->port()->snapshots($organization->id, [$equipment->id, $equipment->id]); + self::assertSame([$equipment->id], array_keys($snapshots)); + $snapshot = $snapshots[$equipment->id]; + self::assertSame('Old cylinder', $snapshot->name); + self::assertSame('FIRE-001', $snapshot->assetReference); + self::assertSame('archived_custom_fire_type', $snapshot->type); + self::assertSame('Recorded brand', $snapshot->brand); + self::assertSame('Recorded model', $snapshot->model); + self::assertSame('SERIAL-001', $snapshot->serialNumber); + self::assertSame($building->id, $snapshot->facilityId); + self::assertSame(['id' => $site->id, 'name' => $site->name], $snapshot->site); + self::assertSame(['id' => $customer->id, 'name' => $customer->name], $snapshot->customer); + self::assertSame([$equipment->id], $this->port()->equipmentIdsInFacilities($organization->id, [$site->id, $building->id])); + } + + public function testMissingDraftAndForeignAssetsAreOmittedAndForeignLocationsCannotLeakIdentity(): void + { + $organization = $this->organization(1); + $foreign = $this->organization(2); + $site = $this->facility(11, $organization, 'site', null); + $foreignSite = $this->facility(12, $foreign, 'site', null); + $valid = $this->equipment(20, $organization, $site->id); + $draft = $this->equipment(21, $organization, $site->id); + $draft->recordStatus = 'draft'; + $other = $this->equipment(22, $foreign, $site->id); + $mislocated = $this->equipment(23, $organization, $foreignSite->id); + $this->entityManager->flush(); + $port = $this->port(); + $snapshots = $port->snapshots($organization->id, [$other->id, $draft->id, $mislocated->id, self::id(999), $valid->id]); + self::assertSame([$valid->id, $mislocated->id], array_keys($snapshots)); + self::assertNull($snapshots[$valid->id]->name); + self::assertNull($snapshots[$valid->id]->assetReference); + self::assertNull($snapshots[$valid->id]->serialNumber); + self::assertSame($foreignSite->id, $snapshots[$mislocated->id]->facilityId); + self::assertNull($snapshots[$mislocated->id]->site); + self::assertNull($snapshots[$mislocated->id]->customer); + self::assertSame([$valid->id], $port->equipmentIdsInFacilities($organization->id, [$site->id])); + self::assertSame([], $port->snapshots($organization->id, [])); + self::assertSame([], $port->equipmentIdsInFacilities($organization->id, [])); + } + + public function testOversizedIdentityScopesFailExplicitlyRatherThanTruncating(): void + { + $this->expectException(InterventionFactsScopeTooLarge::class); + $this->port()->snapshots(self::id(1), array_map(self::id(...), range(10000, 20000))); + } + + /** + * Resolves the public identity bridge through its explicit main connection alias. + * + * @since 1.0.0 + */ + private function port(): InterventionEquipmentSnapshotPort + { + $port = self::getContainer()->get(InterventionEquipmentSnapshotPort::class); + self::assertInstanceOf(InterventionEquipmentSnapshotPort::class, $port); + + return $port; + } + + /** + * Builds an isolated organization in the main database. + * + * @since 1.0.0 + */ + private function organization(int $number): OrganizationRecord + { + $record = new OrganizationRecord(); + $record->id = self::id($number); + $record->name = 'Snapshot owner ' . $number; + $record->slug = 'equipment-snapshot-owner-' . $number; + $record->ownerUserId = $record->createdByUserId = self::id(1000 + $number); + $record->status = 'active'; + $record->isActive = true; + $record->createdAt = $record->updatedAt = new DateTimeImmutable('2026-01-01T10:00:00Z'); + $this->entityManager->persist($record); + + return $record; + } + + /** + * Builds a published hierarchy node owned by one organization. + * + * @since 1.0.0 + */ + private function facility(int $number, OrganizationRecord $organization, string $type, ?FacilityRecord $parent): FacilityRecord + { + $record = new FacilityRecord(); + $record->id = self::id($number); + $record->organization = $organization; + $record->type = $type; + $record->name = 'Facility ' . $number; + $record->parentFacility = $parent; + $record->createdAt = $record->updatedAt = new DateTimeImmutable('2026-01-01T10:00:00Z'); + $this->entityManager->persist($record); + + return $record; + } + + /** + * Builds an asset without synthesizing absent identity fields. + * + * @since 1.0.0 + */ + private function equipment(int $number, OrganizationRecord $organization, ?string $facilityId): EquipmentRecord + { + $record = new EquipmentRecord(); + $record->id = self::id($number); + $record->organization = $organization; + $record->facilityId = $facilityId; + $record->type = 'fire_extinguisher'; + $record->createdAt = $record->updatedAt = new DateTimeImmutable('2026-01-01T10:00:00Z'); + $this->entityManager->persist($record); + + return $record; + } + + /** + * Generates deterministic IDs independent from publication and shared template fixtures. + * + * @since 1.0.0 + */ + private static function id(int $number): string + { + return sprintf('ea338400-e29b-41d4-a716-%012d', $number); + } +} diff --git a/tests/Integration/Equipment/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportEquipmentIdentityAdapterTest.php b/tests/Integration/Equipment/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportEquipmentIdentityAdapterTest.php new file mode 100644 index 000000000..0c7d818fe --- /dev/null +++ b/tests/Integration/Equipment/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportEquipmentIdentityAdapterTest.php @@ -0,0 +1,121 @@ +get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $main); + $this->main = $main; + $this->adapter = new MaintenanceExportEquipmentIdentityAdapter($main); + $this->organization(self::ORGANIZATION_ID, 'export-asset-owner'); + $this->organization(self::OTHER_ORGANIZATION_ID, 'export-asset-other'); + $main->flush(); + } + + #[Test] + public function retainsPublishedOperationalAndRetiredEquipment(): void + { + $this->equipment(self::EQUIPMENT_ID); + $this->equipment(self::RETIRED_ID, status: 'decommissioned'); + $this->main->flush(); + $this->main->clear(); + + self::assertTrue($this->adapter->exists(self::ORGANIZATION_ID, 'equipment', self::EQUIPMENT_ID)); + self::assertTrue($this->adapter->exists(self::ORGANIZATION_ID, 'equipment', self::RETIRED_ID)); + } + + #[Test] + public function refusesAnUnpublishedDraftEvenForItsOwner(): void + { + $this->equipment(self::DRAFT_ID, recordStatus: 'draft'); + $this->main->flush(); + $this->main->clear(); + + self::assertFalse($this->adapter->exists(self::ORGANIZATION_ID, 'equipment', self::DRAFT_ID)); + } + + #[Test] + public function returnsTheSameFalseForForeignAndUnknownIdentities(): void + { + $this->equipment(self::EQUIPMENT_ID, self::OTHER_ORGANIZATION_ID); + $this->main->flush(); + $this->main->clear(); + + self::assertFalse($this->adapter->exists(self::ORGANIZATION_ID, 'equipment', self::EQUIPMENT_ID)); + self::assertFalse($this->adapter->exists(self::ORGANIZATION_ID, 'equipment', self::RETIRED_ID)); + self::assertTrue($this->adapter->exists(self::OTHER_ORGANIZATION_ID, 'equipment', self::EQUIPMENT_ID)); + } + + #[Test] + public function neverTreatsAnotherResourceTypeAsEquipment(): void + { + $this->equipment(self::EQUIPMENT_ID); + $this->main->flush(); + + foreach (['site', 'customer', 'EQUIPMENT', 'unknown'] as $type) { + self::assertFalse($this->adapter->exists(self::ORGANIZATION_ID, $type, self::EQUIPMENT_ID)); + } + } + + private function organization(string $id, string $slug): void + { + $organization = new OrganizationRecord(); + $organization->id = $id; + $organization->name = 'Export identity test'; + $organization->slug = $slug; + $organization->ownerUserId = self::ORGANIZATION_ID; + $organization->createdByUserId = self::ORGANIZATION_ID; + $organization->createdAt = new DateTimeImmutable('2026-10-07T08:00:00+00:00'); + $organization->updatedAt = $organization->createdAt; + $this->main->persist($organization); + } + + private function equipment(string $id, string $organizationId = self::ORGANIZATION_ID, string $status = 'operational', string $recordStatus = 'published'): void + { + $equipment = new EquipmentRecord(); + $equipment->id = $id; + $equipment->organization = $this->main->getReference(OrganizationRecord::class, $organizationId); + $equipment->type = 'fire_extinguisher'; + $equipment->status = $status; + $equipment->recordStatus = $recordStatus; + $equipment->createdAt = new DateTimeImmutable('2026-10-07T08:00:00+00:00'); + $equipment->updatedAt = $equipment->createdAt; + $this->main->persist($equipment); + } +} diff --git a/tests/Integration/Facility/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportSiteIdentityAdapterTest.php b/tests/Integration/Facility/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportSiteIdentityAdapterTest.php new file mode 100644 index 000000000..f799d4a7b --- /dev/null +++ b/tests/Integration/Facility/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportSiteIdentityAdapterTest.php @@ -0,0 +1,143 @@ +get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $main); + $this->main = $main; + $this->adapter = new MaintenanceExportSiteIdentityAdapter($main); + $this->organization(self::ORGANIZATION_ID, 'export-site-owner'); + $this->organization(self::OTHER_ORGANIZATION_ID, 'export-site-other'); + $main->flush(); + } + + #[Test] + public function retainsPublishedActiveAndArchivedRootSites(): void + { + $this->facility(self::SITE_ID); + $this->facility(self::ARCHIVED_ID, status: 'archived'); + $this->main->flush(); + $this->main->clear(); + + self::assertTrue($this->adapter->exists(self::ORGANIZATION_ID, 'site', self::SITE_ID)); + self::assertTrue($this->adapter->exists(self::ORGANIZATION_ID, 'site', self::ARCHIVED_ID)); + } + + #[Test] + public function refusesAnUnpublishedDraftEvenForItsOwner(): void + { + $this->facility(self::DRAFT_ID, recordStatus: 'draft'); + $this->main->flush(); + $this->main->clear(); + + self::assertFalse($this->adapter->exists(self::ORGANIZATION_ID, 'site', self::DRAFT_ID)); + } + + #[Test] + public function refusesBuildingsAndSitesThatAreNotRoots(): void + { + $site = $this->facility(self::SITE_ID); + $this->facility(self::BUILDING_ID, type: 'building', parent: $site); + $this->facility(self::NESTED_SITE_ID, parent: $site); + $this->main->flush(); + $this->main->clear(); + + self::assertTrue($this->adapter->exists(self::ORGANIZATION_ID, 'site', self::SITE_ID)); + self::assertFalse($this->adapter->exists(self::ORGANIZATION_ID, 'site', self::BUILDING_ID)); + self::assertFalse($this->adapter->exists(self::ORGANIZATION_ID, 'site', self::NESTED_SITE_ID)); + } + + #[Test] + public function returnsTheSameFalseForForeignAndUnknownIdentities(): void + { + $this->facility(self::SITE_ID, self::OTHER_ORGANIZATION_ID); + $this->main->flush(); + $this->main->clear(); + + self::assertFalse($this->adapter->exists(self::ORGANIZATION_ID, 'site', self::SITE_ID)); + self::assertFalse($this->adapter->exists(self::ORGANIZATION_ID, 'site', self::ARCHIVED_ID)); + self::assertTrue($this->adapter->exists(self::OTHER_ORGANIZATION_ID, 'site', self::SITE_ID)); + } + + #[Test] + public function neverTreatsAnotherResourceTypeAsASite(): void + { + $this->facility(self::SITE_ID); + $this->main->flush(); + + foreach (['customer', 'equipment', 'SITE', 'unknown'] as $type) { + self::assertFalse($this->adapter->exists(self::ORGANIZATION_ID, $type, self::SITE_ID)); + } + } + + private function organization(string $id, string $slug): void + { + $organization = new OrganizationRecord(); + $organization->id = $id; + $organization->name = 'Export identity test'; + $organization->slug = $slug; + $organization->ownerUserId = self::ORGANIZATION_ID; + $organization->createdByUserId = self::ORGANIZATION_ID; + $organization->createdAt = new DateTimeImmutable('2026-10-07T08:00:00+00:00'); + $organization->updatedAt = $organization->createdAt; + $this->main->persist($organization); + } + + private function facility(string $id, string $organizationId = self::ORGANIZATION_ID, string $status = 'active', string $recordStatus = 'published', string $type = 'site', ?FacilityRecord $parent = null): FacilityRecord + { + $facility = new FacilityRecord(); + $facility->id = $id; + $facility->organization = $this->main->getReference(OrganizationRecord::class, $organizationId); + $facility->type = $type; + $facility->name = 'Retained site'; + $facility->status = $status; + $facility->recordStatus = $recordStatus; + $facility->parentFacility = $parent; + $facility->createdAt = new DateTimeImmutable('2026-10-07T08:00:00+00:00'); + $facility->updatedAt = $facility->createdAt; + $this->main->persist($facility); + + return $facility; + } +} diff --git a/tests/Integration/Inspection/Infrastructure/Adapter/Intervention/InterventionInspectionResultAdapterTest.php b/tests/Integration/Inspection/Infrastructure/Adapter/Intervention/InterventionInspectionResultAdapterTest.php new file mode 100644 index 000000000..44a2778b3 --- /dev/null +++ b/tests/Integration/Inspection/Infrastructure/Adapter/Intervention/InterventionInspectionResultAdapterTest.php @@ -0,0 +1,178 @@ + + */ +#[CoversClass(InterventionInspectionResultAdapter::class)] +final class InterventionInspectionResultAdapterTest extends KernelTestCase +{ + private const string ORGANIZATION = '7a3e8400-e29b-41d4-a716-4466557a3001'; + + private const string OTHER_ORGANIZATION = '7a3e8400-e29b-41d4-a716-4466557a3002'; + + private const string INTERVENTION = '7a3e8400-e29b-41d4-a716-4466557a3003'; + + private const string OTHER_INTERVENTION = '7a3e8400-e29b-41d4-a716-4466557a3004'; + + private const string INSPECTION = '7a3e8400-e29b-41d4-a716-4466557a3005'; + + private const string EQUIPMENT = '7a3e8400-e29b-41d4-a716-4466557a3006'; + + private const string AUTHOR = '7a3e8400-e29b-41d4-a716-4466557a3007'; + + private const string UNKNOWN = '7a3e8400-e29b-41d4-a716-4466557a3999'; + + private const string PERFORMED_AT = '2026-08-03T14:25:36+00:00'; + + private EntityManagerInterface $entityManager; + + private InterventionInspectionResultAdapter $adapter; + + protected function setUp(): void + { + self::bootKernel(); + $manager = self::getContainer()->get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $manager); + $this->entityManager = $manager; + $this->adapter = new InterventionInspectionResultAdapter($manager); + $this->organization(self::ORGANIZATION, 'inspection-result-proof'); + $this->organization(self::OTHER_ORGANIZATION, 'foreign-inspection-result-proof'); + $manager->flush(); + } + + protected function tearDown(): void + { + parent::tearDown(); + if ($this->entityManager->isOpen()) { + $this->entityManager->close(); + } + } + + #[Test] + #[DataProvider('recordedFacts')] + public function matchingScopePreservesTheRecordedFact(string $result, string $status, string $recordStatus, ?string $authorId, ?string $notes): void + { + $inspection = $this->inspection(self::ORGANIZATION, self::INTERVENTION); + $inspection->result = $result; + $inspection->status = $status; + $inspection->recordStatus = $recordStatus; + $inspection->inspectorType = null === $authorId ? 'external' : 'user'; + $inspection->inspectorUserId = $authorId; + $inspection->notes = $notes; + $this->entityManager->flush(); + $this->entityManager->clear(); + + $proof = $this->adapter->find(self::ORGANIZATION, self::INTERVENTION, self::INSPECTION); + + self::assertInstanceOf(InterventionInspectionResult::class, $proof); + self::assertSame(self::INSPECTION, $proof->id); + self::assertSame(self::EQUIPMENT, $proof->equipmentId); + self::assertSame($result, $proof->result); + self::assertSame($status, $proof->status); + self::assertSame(self::PERFORMED_AT, $proof->performedAt->format('c')); + self::assertSame($authorId, $proof->authorId); + self::assertSame($notes, $proof->notes); + } + + #[Test] + public function absentInspectionReturnsNoProof(): void + { + self::assertNull($this->adapter->find(self::ORGANIZATION, self::INTERVENTION, self::UNKNOWN)); + } + + #[Test] + #[DataProvider('unrelatedScopes')] + public function existingInspectionOutsideThePublicationScopeReturnsNoProof(string $organizationId, ?string $interventionId): void + { + $this->inspection($organizationId, $interventionId); + $this->entityManager->flush(); + $this->entityManager->clear(); + + self::assertNull($this->adapter->find(self::ORGANIZATION, self::INTERVENTION, self::INSPECTION)); + } + + #[Test] + public function requestedForeignOrganizationCannotReadAnOtherwiseMatchingInspection(): void + { + $this->inspection(self::ORGANIZATION, self::INTERVENTION); + $this->entityManager->flush(); + $this->entityManager->clear(); + + self::assertNull($this->adapter->find(self::OTHER_ORGANIZATION, self::INTERVENTION, self::INSPECTION)); + } + + /** + * @return iterable + */ + public static function recordedFacts(): iterable + { + yield 'published completed pass' => ['pass', 'closed', 'published', self::AUTHOR, "Vérification effectuée.\nAucune anomalie constatée."]; + yield 'unpublished completed fail' => ['fail', 'closed', 'draft', self::AUTHOR, 'Équipement à remplacer.']; + yield 'submitted partial' => ['partial', 'submitted', 'draft', self::AUTHOR, 'Accès partiel au matériel.']; + yield 'draft remains draft' => ['fail', 'draft', 'draft', null, null]; + yield 'cancelled remains cancelled' => ['partial', 'cancelled', 'published', null, 'Contrôle interrompu.']; + } + + /** + * @return iterable + */ + public static function unrelatedScopes(): iterable + { + yield 'foreign organization with matching intervention' => [self::OTHER_ORGANIZATION, self::INTERVENTION]; + yield 'sibling intervention with matching organization' => [self::ORGANIZATION, self::OTHER_INTERVENTION]; + yield 'standalone inspection with matching organization' => [self::ORGANIZATION, null]; + } + + private function organization(string $id, string $slug): void + { + $organization = new OrganizationRecord(); + $organization->id = $id; + $organization->name = 'Inspection result proof'; + $organization->slug = $slug; + $organization->ownerUserId = self::AUTHOR; + $organization->createdByUserId = self::AUTHOR; + $organization->createdAt = new DateTimeImmutable(self::PERFORMED_AT); + $organization->updatedAt = $organization->createdAt; + $this->entityManager->persist($organization); + } + + private function inspection(string $organizationId, ?string $interventionId): InspectionRecord + { + $organization = $this->entityManager->find(OrganizationRecord::class, $organizationId); + self::assertInstanceOf(OrganizationRecord::class, $organization); + $inspection = new InspectionRecord(); + $inspection->id = self::INSPECTION; + $inspection->organization = $organization; + $inspection->interventionId = $interventionId; + $inspection->equipmentId = self::EQUIPMENT; + $inspection->inspectorType = 'user'; + $inspection->inspectorName = 'Recorded inspector'; + $inspection->inspectorUserId = self::AUTHOR; + $inspection->result = 'pass'; + $inspection->status = 'closed'; + $inspection->performedAt = new DateTimeImmutable(self::PERFORMED_AT); + $inspection->createdAt = new DateTimeImmutable('2026-08-05T10:00:00+00:00'); + $inspection->updatedAt = $inspection->createdAt; + $this->entityManager->persist($inspection); + + return $inspection; + } +} diff --git a/tests/Integration/Inspection/Infrastructure/Persistence/Doctrine/Repository/ParkAnomalyRepositoryTest.php b/tests/Integration/Inspection/Infrastructure/Persistence/Doctrine/Repository/ParkAnomalyRepositoryTest.php new file mode 100644 index 000000000..d061ea5f3 --- /dev/null +++ b/tests/Integration/Inspection/Infrastructure/Persistence/Doctrine/Repository/ParkAnomalyRepositoryTest.php @@ -0,0 +1,196 @@ +get('doctrine.orm.main_entity_manager'); + $this->entityManager = $entityManager; + $this->repository = new ParkAnomalyRepository($entityManager, 'UTC'); + $this->seed(); + } + + #[Test] + public function testCandidatesListCountAndSummarySharePublishedUnresolvedOrganizationPredicates(): void + { + self::assertSame([self::EQUIPMENT_A, self::EQUIPMENT_B], $this->repository->findCandidateEquipmentIds(self::ORGANIZATION_ID)); + $scope = [self::EQUIPMENT_A, self::EQUIPMENT_B, self::DRAFT_EQUIPMENT]; + $rows = $this->repository->list(self::ORGANIZATION_ID, $scope, new Pagination(0, 20), new Sorting('createdAt', SortDirection::DESC)); + self::assertCount(3, $rows); + self::assertSame(3, $this->repository->count(self::ORGANIZATION_ID, $scope)); + $summary = $this->repository->summary(self::ORGANIZATION_ID, $scope); + self::assertSame(3, $summary->openAnomalies); + self::assertSame(['low' => 0, 'medium' => 1, 'high' => 1, 'critical' => 1], $summary->bySeverity); + self::assertSame('993e8400-e29b-41d4-a716-446655493032', $rows[0]->id); + self::assertSame('2026-10-06T10:00:00+00:00', $rows[0]->createdAt->format('c')); + } + + #[Test] + public function testResolvedEquipmentScopeIsAppliedBeforeSqlPaginationAndCounts(): void + { + $scope = [self::EQUIPMENT_A]; + $rows = $this->repository->list(self::ORGANIZATION_ID, $scope, new Pagination(1, 1), new Sorting('createdAt', SortDirection::DESC)); + self::assertCount(1, $rows); + self::assertSame(self::EQUIPMENT_A, $rows[0]->equipmentId); + self::assertSame('993e8400-e29b-41d4-a716-446655493030', $rows[0]->id); + self::assertSame(2, $this->repository->count(self::ORGANIZATION_ID, $scope)); + self::assertSame(['low' => 0, 'medium' => 0, 'high' => 1, 'critical' => 1], $this->repository->summary(self::ORGANIZATION_ID, $scope)->bySeverity); + } + + #[Test] + public function testEmptyResolvedScopeNeverMeansAllEquipment(): void + { + self::assertSame([], $this->repository->list(self::ORGANIZATION_ID, [], new Pagination(), new Sorting('createdAt', SortDirection::DESC))); + self::assertSame(0, $this->repository->count(self::ORGANIZATION_ID, [])); + $summary = $this->repository->summary(self::ORGANIZATION_ID, []); + self::assertSame(0, $summary->openAnomalies); + self::assertSame(['low' => 0, 'medium' => 0, 'high' => 0, 'critical' => 0], $summary->bySeverity); + } + + #[Test] + public function testUnknownSortCannotInjectSqlAndUsesStableDefault(): void + { + $scope = [self::EQUIPMENT_A, self::EQUIPMENT_B]; + $rows = $this->repository->list(self::ORGANIZATION_ID, $scope, new Pagination(0, 1), new Sorting('createdAt; DROP TABLE inspections', SortDirection::DESC)); + self::assertCount(1, $rows); + self::assertSame('993e8400-e29b-41d4-a716-446655493032', $rows[0]->id); + self::assertSame(3, $this->repository->count(self::ORGANIZATION_ID, $scope)); + } + + #[Test] + public function testTimestampsRespectConfiguredStorageTimeZone(): void + { + $repository = new ParkAnomalyRepository($this->entityManager, 'Europe/Paris'); + $rows = $repository->list(self::ORGANIZATION_ID, [self::EQUIPMENT_A], new Pagination(0, 1), new Sorting('createdAt', SortDirection::DESC)); + self::assertCount(1, $rows); + self::assertSame('2026-10-06T10:00:00+02:00', $rows[0]->createdAt->format('c')); + self::assertSame('2026-10-07T10:00:00+02:00', $rows[0]->dueAt?->format('c')); + } + + #[Test] + public function testInvalidStorageTimeZoneFailsExplicitly(): void + { + $this->expectException(InvalidStorageTimeZoneException::class); + new ParkAnomalyRepository($this->entityManager, 'invalid-storage-zone'); + } + + /** + * Method seed. + */ + private function seed(): void + { + $organization = $this->organization(self::ORGANIZATION_ID, 'park-anomaly-repository'); + $foreignOrganization = $this->organization(self::OTHER_ORGANIZATION_ID, 'park-anomaly-repository-other'); + $publishedA = $this->inspection('993e8400-e29b-41d4-a716-446655493020', $organization, self::EQUIPMENT_A, 'published'); + $publishedB = $this->inspection('993e8400-e29b-41d4-a716-446655493021', $organization, self::EQUIPMENT_B, 'published'); + $draft = $this->inspection('993e8400-e29b-41d4-a716-446655493022', $organization, self::DRAFT_EQUIPMENT, 'draft'); + $foreign = $this->inspection('993e8400-e29b-41d4-a716-446655493023', $foreignOrganization, self::EQUIPMENT_A, 'published'); + $this->finding('993e8400-e29b-41d4-a716-446655493030', $publishedA, 'high', 'open'); + $this->finding('993e8400-e29b-41d4-a716-446655493031', $publishedA, 'critical', 'in_progress'); + $this->finding('993e8400-e29b-41d4-a716-446655493032', $publishedB, 'medium', 'open'); + $this->finding('993e8400-e29b-41d4-a716-446655493033', $publishedA, 'low', 'done'); + $this->finding('993e8400-e29b-41d4-a716-446655493034', $publishedA, 'low', 'cancelled'); + $this->finding('993e8400-e29b-41d4-a716-446655493035', $draft, 'critical', 'open'); + $this->finding('993e8400-e29b-41d4-a716-446655493036', $foreign, 'critical', 'open'); + $this->entityManager->flush(); + } + + /** + * Method organization. + */ + private function organization(string $id, string $slug): OrganizationRecord + { + $now = new DateTimeImmutable('2026-10-06T10:00:00+00:00'); + $organization = new OrganizationRecord(); + $organization->id = $id; + $organization->name = 'Park anomaly repository tests'; + $organization->slug = $slug; + $organization->ownerUserId = '993e8400-e29b-41d4-a716-446655493009'; + $organization->createdByUserId = $organization->ownerUserId; + $organization->status = 'active'; + $organization->isActive = true; + $organization->createdAt = $now; + $organization->updatedAt = $now; + $this->entityManager->persist($organization); + + return $organization; + } + + /** + * Method inspection. + */ + private function inspection(string $id, OrganizationRecord $organization, string $equipmentId, string $publicationState): InspectionRecord + { + $inspection = new InspectionRecord(); + $inspection->id = $id; + $inspection->organization = $organization; + $inspection->equipmentId = $equipmentId; + $inspection->recordStatus = $publicationState; + $inspection->inspectorType = 'user'; + $inspection->inspectorName = 'Park inspection'; + $inspection->result = 'fail'; + $inspection->status = 'draft' === $publicationState ? 'draft' : 'closed'; + $inspection->performedAt = new DateTimeImmutable('2026-10-06T10:00:00+00:00'); + $inspection->createdAt = $inspection->performedAt; + $inspection->updatedAt = $inspection->performedAt; + $this->entityManager->persist($inspection); + + return $inspection; + } + + /** + * Method finding. + */ + private function finding(string $id, InspectionRecord $inspection, string $severity, string $status): void + { + $finding = new NonConformityRecord(); + $finding->id = $id; + $finding->inspection = $inspection; + $finding->description = 'Scoped park finding'; + $finding->severity = $severity; + $finding->status = $status; + $finding->dueAt = new DateTimeImmutable('2026-10-07T10:00:00+00:00'); + $finding->createdAt = new DateTimeImmutable('2026-10-06T10:00:00+00:00'); + $finding->updatedAt = $finding->createdAt; + $this->entityManager->persist($finding); + } +} diff --git a/tests/Integration/Intervention/Infrastructure/Adapter/Equipment/InterventionServiceReportAdapterTest.php b/tests/Integration/Intervention/Infrastructure/Adapter/Equipment/InterventionServiceReportAdapterTest.php index 941a469db..b8d615710 100644 --- a/tests/Integration/Intervention/Infrastructure/Adapter/Equipment/InterventionServiceReportAdapterTest.php +++ b/tests/Integration/Intervention/Infrastructure/Adapter/Equipment/InterventionServiceReportAdapterTest.php @@ -71,6 +71,36 @@ public function testServiceReportReturnsNullWhenInterventionIsNotFound(): void self::assertNull($this->adapter->serviceReport('660e8400-e29b-41d4-a716-446655469999')); } + #[Test] + public function testValidatedEquipmentWorkWithoutAProposalAppearsInTheMaintenanceHistory(): void + { + $intervention = $this->createIntervention(); + $item = new InterventionWorkItemRecord(); + $item->id = '660e8400-e29b-41d4-a716-446655460021'; + $item->intervention = $intervention; + $item->action = 'repair'; + $item->status = 'completed'; + $item->target = '/api/equipment/' . self::EQUIPMENT_ID_1; + $item->executionResult = ['equipmentId' => self::EQUIPMENT_ID_1, 'performedAt' => '2026-09-30T15:00:00+02:00', 'outcome' => 'successful', 'workPerformed' => 'Valve repair', 'authorId' => self::RESPONSIBLE_ID, 'state' => 'validated']; + $item->createdAt = $item->updatedAt = new DateTimeImmutable(); + $this->entityManager->persist($item); + $this->entityManager->flush(); + $report = $this->adapter->serviceReport(self::INTERVENTION_ID); + self::assertNotNull($report); + self::assertCount(1, $report->equipment); + self::assertSame('repair', $report->equipment[0]->action); + self::assertSame($item->id, $report->equipment[0]->workItemId); + self::assertSame('operation:' . $item->id, $report->equipment[0]->changeToken); + self::assertSame('2026-09-30T15:00:00+02:00', $report->equipment[0]->performedAt?->format('c')); + self::assertSame('Valve repair', $report->equipment[0]->notes); + self::assertSame(self::RESPONSIBLE_ID, $report->equipment[0]->authorId); + $this->createChange('660e8400-e29b-41d4-a716-446655460034', $intervention, '/api/equipment/' . self::EQUIPMENT_ID_1, ['brand' => 'Maintained'], 'applied', $item); + $this->entityManager->flush(); + $updatedReport = $this->adapter->serviceReport(self::INTERVENTION_ID); + self::assertNotNull($updatedReport); + self::assertCount(1, $updatedReport->equipment); + } + #[Test] public function testServiceReportReturnsOnlyAppliedEquipmentChanges(): void { diff --git a/tests/Integration/Intervention/Infrastructure/Adapter/Inventory/InterventionInventoryContextAdapterTest.php b/tests/Integration/Intervention/Infrastructure/Adapter/Inventory/InterventionInventoryContextAdapterTest.php new file mode 100644 index 000000000..3fc9bd02a --- /dev/null +++ b/tests/Integration/Intervention/Infrastructure/Adapter/Inventory/InterventionInventoryContextAdapterTest.php @@ -0,0 +1,142 @@ +fixture(null); + $this->expectException(InterventionAccessDeniedException::class); + $manager->getConnection()->transactional(fn () => $context->validate(self::ORG, self::ORDER, null, null, self::OUTSIDE_USER)); + } + + public function testAResponsibleMemberCannotConsumeOnAnotherMembersAssignedTask(): void + { + [$manager, $context] = $this->fixture(self::ASSIGNED); + $this->expectException(InterventionAccessDeniedException::class); + $manager->getConnection()->transactional(fn () => $context->validate(self::ORG, self::ORDER, self::TASK, null, self::RESPONSIBLE_USER)); + } + + public function testTheAssignedMemberMayDeclareALatePhysicalFactWithoutMutatingPublishedWork(): void + { + [$manager, $context] = $this->fixture(self::ASSIGNED, true); + $before = $manager->getConnection()->fetchAssociative('SELECT status, revision, closure_snapshot FROM interventions WHERE id = ?', [self::ORDER]); + $result = $manager->getConnection()->transactional(fn () => $context->validate(self::ORG, self::ORDER, self::TASK, null, self::ASSIGNED_USER)); + self::assertTrue($result->published); + self::assertSame($before, $manager->getConnection()->fetchAssociative('SELECT status, revision, closure_snapshot FROM interventions WHERE id = ?', [self::ORDER])); + } + + public function testAnActiveParticipantMayDeclareStockForAnUnassignedTask(): void + { + [$manager, $context] = $this->fixture(null); + $result = $manager->getConnection()->transactional(fn () => $context->validate(self::ORG, self::ORDER, self::TASK, null, self::ASSIGNED_USER)); + self::assertFalse($result->published); + } + + public function testReadOnlyExistenceChecksKeepExplicitInventoryListsInsideTheirOrganization(): void + { + [, $context] = $this->fixture(null); + self::assertTrue($context->existsInOrganization(self::ORG, self::ORDER)); + self::assertFalse($context->existsInOrganization('970e8400-e29b-41d4-a716-449140000099', self::ORDER)); + self::assertFalse($context->existsInOrganization(self::ORG, '970e8400-e29b-41d4-a716-449140000098')); + } + + /** + * @return array{EntityManagerInterface,InterventionInventoryContextPort} + */ + private function fixture(?string $assignee, bool $published = false): array + { + self::bootKernel(); + /** @var EntityManagerInterface $manager */ + $manager = self::getContainer()->get('doctrine.orm.main_entity_manager'); + $now = new DateTimeImmutable('2026-10-01T10:00:00Z'); + $org = new OrganizationRecord(); + $org->id = self::ORG; + $org->name = 'Stock execution scope'; + $org->slug = 'stock-execution-scope'; + $org->ownerUserId = self::RESPONSIBLE_USER; + $org->createdByUserId = self::RESPONSIBLE_USER; + $org->status = 'active'; + $org->isActive = true; + $org->createdAt = $org->updatedAt = $now; + $manager->persist($org); + $role = new OrganizationRoleRecord(); + $role->id = '970e8400-e29b-41d4-a716-449140000031'; + $role->organization = $org; + $role->name = 'stock_executor'; + $role->permissions = ['organization.interventions.execute']; + $role->isSystem = false; + $role->createdAt = $now; + $manager->persist($role); + foreach ([self::RESPONSIBLE => self::RESPONSIBLE_USER, self::ASSIGNED => self::ASSIGNED_USER, self::OUTSIDE_TEAM => self::OUTSIDE_USER] as $memberId => $userId) { + $member = new OrganizationMemberRecord(); + $member->id = $memberId; + $member->organization = $org; + $member->userId = $userId; + $member->isActive = true; + $member->joinedAt = $now; + $manager->persist($member); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $manager->persist($assignment); + } + $order = new InterventionRecord(); + $order->id = self::ORDER; + $order->organization = $org; + $order->number = 1; + $order->type = 'corrective_maintenance'; + $order->name = 'Actual stock declarations'; + $order->status = $published ? 'published' : 'in_progress'; + $order->responsibleId = self::RESPONSIBLE; + $order->participants = [self::ASSIGNED]; + $order->closureSnapshot = $published ? ['version' => 1, 'identity' => 'original'] : null; + $order->createdAt = $order->updatedAt = $now; + $manager->persist($order); + $task = new InterventionWorkItemRecord(); + $task->id = self::TASK; + $task->intervention = $order; + $task->action = 'repair'; + $task->status = $published ? 'completed' : 'in_progress'; + $task->source = 'planned'; + $task->assigneeId = $assignee; + $task->createdAt = $task->updatedAt = $now; + $manager->persist($task); + $manager->flush(); + /** @var InterventionInventoryContextPort $context */ + $context = self::getContainer()->get(InterventionInventoryContextPort::class); + + return [$manager, $context]; + } +} diff --git a/tests/Integration/Intervention/Infrastructure/Adapter/Publication/DoctrinePublicationAdapterTest.php b/tests/Integration/Intervention/Infrastructure/Adapter/Publication/DoctrinePublicationAdapterTest.php index f5e8213f5..b3805c475 100644 --- a/tests/Integration/Intervention/Infrastructure/Adapter/Publication/DoctrinePublicationAdapterTest.php +++ b/tests/Integration/Intervention/Infrastructure/Adapter/Publication/DoctrinePublicationAdapterTest.php @@ -8,11 +8,11 @@ use Doctrine\ORM\EntityManagerInterface; use Facility\Infrastructure\Persistence\Doctrine\Record\FacilityRecord; use Intervention\Application\Contract\Resource\{InterventionResourceSummary, InterventionWorkItemSummary}; -use Intervention\Application\Port\Outbound\{InterventionAttachmentRepositoryPort, InterventionResourceGatewayPort}; +use Intervention\Application\Port\Outbound\{InterventionAttachmentRepositoryPort, InterventionPublicationResourcesIssuesPort, InterventionResourceGatewayPort}; use Intervention\Application\Service\InterventionIssueFinder; use Intervention\Application\UseCase\Command\Publication\ExecutePublication\{ExecutePublicationCommand, ExecutePublicationHandler}; use Intervention\Domain\Event\Publication\{InterventionPublicationFailedEvent, InterventionPublishedEvent}; -use Intervention\Domain\Exception\{InterventionConflictException, InterventionNotFoundException, PublicationNotFoundException}; +use Intervention\Domain\Exception\{InterventionConflictException, InterventionNotFoundException, InterventionValidationException, PublicationNotFoundException}; use Intervention\Infrastructure\Adapter\Publication\DoctrinePublicationAdapter; use Intervention\Infrastructure\Persistence\Doctrine\Record\{InterventionChangeRecord, InterventionRecord, PublicationRecord}; use Organization\Infrastructure\Persistence\Doctrine\Record\OrganizationRecord; @@ -23,8 +23,11 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport; +use function json_decode; use function sprintf; +use const JSON_THROW_ON_ERROR; + /** * Test DoctrinePublicationAdapterTest. * @@ -84,6 +87,70 @@ protected function tearDown(): void } } + #[Test] + public function testOperationFactsAndKnownTimeAreFrozenTogetherAtPublication(): void + { + $task = $this->seedEquipmentOperation('successful'); + $entry = new \Intervention\Infrastructure\Persistence\Doctrine\Record\InterventionTimeEntryRecord(); + $entry->id = '880e8400-e29b-41d4-a716-446655449203'; + $entry->workItem = $task; + $entry->organizationId = self::ORGANIZATION_ID; + $entry->memberId = self::RESPONSIBLE_MEMBER_ID; + $entry->workedOn = '2026-09-30'; + $entry->minutes = 30; + $entry->createdBy = $entry->updatedBy = self::RESPONSIBLE_MEMBER_ID; + $entry->createdAt = $entry->updatedAt = new DateTimeImmutable(); + $this->entityManager->persist($entry); + $this->entityManager->flush(); + $this->adapter->createOrGetPending(self::PUBLICATION_ID, self::INTERVENTION_ID, 1); + $this->adapter->markProcessing(self::PUBLICATION_ID); + self::assertTrue($this->adapter->publish(self::PUBLICATION_ID)); + $connection = $this->entityManager->getConnection(); + $captured = $connection->fetchOne('SELECT closure_snapshot FROM interventions WHERE id = ?', [self::INTERVENTION_ID]); + self::assertIsString($captured); + $snapshot = json_decode($captured, true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($snapshot); + self::assertSame(2, $snapshot['version']); + $workItems = $snapshot['workItems'] ?? null; + self::assertIsArray($workItems); + $firstWorkItem = $workItems[0] ?? null; + self::assertIsArray($firstWorkItem); + $result = $firstWorkItem['executionResult'] ?? null; + self::assertIsArray($result); + self::assertSame('validated', $result['state'] ?? null); + $timeEntries = $snapshot['timeEntries'] ?? null; + self::assertIsArray($timeEntries); + $firstTimeEntry = $timeEntries[0] ?? null; + self::assertIsArray($firstTimeEntry); + self::assertSame(30, $firstTimeEntry['minutes'] ?? null); + $connection->executeStatement('UPDATE intervention_time_entries SET minutes = 45 WHERE id = ?', [$entry->id]); + self::assertSame($captured, $connection->fetchOne('SELECT closure_snapshot FROM interventions WHERE id = ?', [self::INTERVENTION_ID])); + self::assertFalse($this->adapter->publish(self::PUBLICATION_ID)); + self::assertSame($captured, $connection->fetchOne('SELECT closure_snapshot FROM interventions WHERE id = ?', [self::INTERVENTION_ID])); + } + + #[Test] + public function testInvalidExecutionRollsBackPublishedDraftsProposalsAndClosureSnapshot(): void + { + $this->seedEquipmentOperation('failed'); + $draft = $this->hierarchyFacility('880e8400-e29b-41d4-a716-446655449204', 'building', self::FACILITY_ID, true); + $this->seedProposedFacilityRenameChange(); + $this->adapter->createOrGetPending(self::PUBLICATION_ID, self::INTERVENTION_ID, 1); + $this->adapter->markProcessing(self::PUBLICATION_ID); + $connection = $this->entityManager->getConnection(); + + try { + $this->adapter->publish(self::PUBLICATION_ID); + self::fail('An unsuccessful repair must reject the entire publication.'); + } catch (InterventionValidationException) { + self::assertSame('draft', $connection->fetchOne('SELECT record_status FROM facilities WHERE id = ?', [$draft->id])); + self::assertSame('Publication Adapter Site', $connection->fetchOne('SELECT name FROM facilities WHERE id = ?', [self::FACILITY_ID])); + self::assertSame('proposed', $connection->fetchOne('SELECT status FROM intervention_changes WHERE id = ?', [self::CHANGE_ID])); + self::assertSame('submitted', $connection->fetchOne('SELECT status FROM interventions WHERE id = ?', [self::INTERVENTION_ID])); + self::assertNull($connection->fetchOne('SELECT closure_snapshot FROM interventions WHERE id = ?', [self::INTERVENTION_ID])); + } + } + /** * Method testDiscardPreservesOrderedTypedReferencesAndExcludesForeignOrRemovedOwners. * @@ -840,6 +907,29 @@ public function testPublishRejectsAnInterventionWithoutAnOrganization(): void $this->adapter->publish(self::PUBLICATION_ID); } + private function seedEquipmentOperation(string $outcome): \Intervention\Infrastructure\Persistence\Doctrine\Record\InterventionWorkItemRecord + { + $equipment = new \Equipment\Infrastructure\Persistence\Doctrine\Record\EquipmentRecord(); + $equipment->id = '880e8400-e29b-41d4-a716-446655449201'; + $equipment->organization = $this->entityManager->getReference(OrganizationRecord::class, self::ORGANIZATION_ID); + $equipment->type = 'fire_extinguisher'; + $equipment->facilityId = self::FACILITY_ID; + $equipment->createdAt = $equipment->updatedAt = new DateTimeImmutable(); + $this->entityManager->persist($equipment); + $task = new \Intervention\Infrastructure\Persistence\Doctrine\Record\InterventionWorkItemRecord(); + $task->id = '880e8400-e29b-41d4-a716-446655449202'; + $task->intervention = $this->entityManager->getReference(InterventionRecord::class, self::INTERVENTION_ID); + $task->action = 'repair'; + $task->target = '/api/equipment/' . $equipment->id; + $task->status = 'completed'; + $task->executionResult = ['equipmentId' => $equipment->id, 'performedAt' => '2026-09-30T15:00:00+02:00', 'outcome' => $outcome, 'workPerformed' => 'Valve repair', 'authorId' => self::RESPONSIBLE_MEMBER_ID, 'state' => 'staged']; + $task->createdAt = $task->updatedAt = new DateTimeImmutable(); + $this->entityManager->persist($task); + $this->entityManager->flush(); + + return $task; + } + /** * Builds a hierarchy row for publication invariants, without implicit repair. * @@ -891,7 +981,7 @@ private function executionHandler(EventDispatcherPort $events): ExecutePublicati return new ExecutePublicationHandler( $this->adapter, - new InterventionIssueFinder($resources, $this->createStub(InterventionAttachmentRepositoryPort::class)), + new InterventionIssueFinder($resources, $this->createStub(InterventionAttachmentRepositoryPort::class), $this->createStub(InterventionPublicationResourcesIssuesPort::class)), $events, new DbalTransactionManagerAdapter($this->entityManager->getConnection()), ); diff --git a/tests/Integration/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapterTest.php b/tests/Integration/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapterTest.php new file mode 100644 index 000000000..0bff1f858 --- /dev/null +++ b/tests/Integration/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapterTest.php @@ -0,0 +1,685 @@ + + */ +#[CoversClass(InterventionPublicationFactsAdapter::class)] +final class InterventionPublicationFactsAdapterTest extends KernelTestCase +{ + private EntityManagerInterface $entityManager; + + private OrganizationRecord $organization; + + protected function setUp(): void + { + self::bootKernel(); + /** @var EntityManagerInterface $manager */ + $manager = self::getContainer()->get('doctrine.orm.main_entity_manager'); + $this->entityManager = $manager; + $this->organization = $this->organization(1); + $this->entityManager->flush(); + } + + public function testEconomicDatesUseCompletedPublicationWithInclusiveStartAndExclusiveEnd(): void + { + $start = $this->intervention(100, $this->snapshot(100)); + $end = $this->intervention(101, $this->snapshot(101)); + $before = $this->intervention(102, $this->snapshot(102)); + $newest = $this->intervention(103, $this->snapshot(103)); + $this->publication(200, $start, '2026-10-01T00:00:00Z'); + $this->publication(201, $end, '2026-11-01T00:00:00Z'); + $this->publication(202, $before, '2026-09-30T23:59:59Z'); + $this->publication(203, $newest, '2026-09-01T00:00:00Z'); + $this->publication(204, $newest, '2026-10-31T23:59:59Z', revision: 4); + $planned = $this->intervention(104, null, 'planned'); + $planned->plannedStartAt = new DateTimeImmutable('2026-10-10T10:00:00Z'); + $created = $this->intervention(105, null, 'draft'); + $created->plannedStartAt = null; + $created->createdAt = new DateTimeImmutable('2026-10-11T10:00:00Z'); + $impostor = $this->intervention(106, null, 'draft'); + $impostor->createdAt = new DateTimeImmutable('2026-10-12T10:00:00Z'); + $impostor->plannedStartAt = new DateTimeImmutable('2026-12-01T10:00:00Z'); + $this->entityManager->flush(); + $from = new DateTimeImmutable('2026-10-01T00:00:00Z'); + $to = new DateTimeImmutable('2026-11-01T00:00:00Z'); + $result = $this->port()->economicWindow($this->organization->id, $from, $to); + self::assertSame(4, $result->totalItems); + self::assertEqualsCanonicalizing([$start->id, $newest->id, $planned->id, $created->id], array_map(static fn ($context): string => $context->id, $result->items)); + self::assertSame(self::id(204), $this->port()->published($this->organization->id, $newest->id)?->publicationId); + $page = $this->port()->economicPage($this->organization->id, from: $from, to: $to); + self::assertSame(4, $page->totalItems); + self::assertSame(serialize($result->items), serialize($page->items)); + } + + public function testEconomicWindowKeepsExactTotalWhenItsSourceLimitIsReached(): void + { + for ($offset = 0; $offset < 5; ++$offset) { + $record = $this->intervention(100 + $offset, $this->snapshot(100 + $offset)); + $this->publication(200 + $offset, $record, '2026-10-01T10:00:00Z'); + } + $foreign = $this->organization(2); + $other = $this->intervention(110, $this->snapshot(110), organization: $foreign); + $this->publication(210, $other, '2026-10-01T10:00:00Z'); + $this->entityManager->flush(); + $result = $this->port()->economicWindow($this->organization->id, new DateTimeImmutable('2026-10-01T00:00:00Z'), new DateTimeImmutable('2026-11-01T00:00:00Z'), limit: 2); + self::assertSame(5, $result->totalItems); + self::assertCount(2, $result->items); + self::assertSame([self::id(100), self::id(101)], array_map(static fn ($context): string => $context->id, $result->items)); + } + + public function testFinancialIdentityUnionPreservesScopedExactCountsAndPagesBeforeSearchAndDates(): void + { + $first = $this->intervention(100, null, 'draft'); + $second = $this->intervention(101, null, 'draft'); + $first->name = $second->name = 'Direct financial asset'; + $first->plannedStartAt = $second->plannedStartAt = null; + $first->createdAt = new DateTimeImmutable('2026-10-07T12:00:00Z'); + $second->createdAt = new DateTimeImmutable('2026-10-06T12:00:00Z'); + $foreign = $this->organization(2); + $other = $this->intervention(102, null, 'draft', $foreign); + $this->entityManager->flush(); + $ids = [$first->id, $second->id, $first->id, $other->id]; + $port = $this->port(); + foreach ([1 => $first->id, 2 => $second->id] as $page => $expected) { + $result = $port->economicPage($this->organization->id, page: $page, itemsPerPage: 1, search: 'financial', from: new DateTimeImmutable('2026-10-01T00:00:00Z'), to: new DateTimeImmutable('2026-11-01T00:00:00Z'), equipmentId: self::id(999), financialInterventionIds: $ids); + self::assertSame(2, $result->totalItems); + self::assertCount(1, $result->items); + self::assertSame($expected, $result->items[0]->id); + } + $emptyPage = $port->economicPage($this->organization->id, page: 3, itemsPerPage: 1, equipmentId: self::id(999), financialInterventionIds: $ids); + self::assertSame(2, $emptyPage->totalItems); + self::assertSame([], $emptyPage->items); + self::assertSame(0, $port->economicPage($this->organization->id, search: 'absent', equipmentId: self::id(999), financialInterventionIds: $ids)->totalItems); + self::assertSame(0, $port->economicPage($this->organization->id, from: new DateTimeImmutable('2026-09-01T00:00:00Z'), to: new DateTimeImmutable('2026-10-01T00:00:00Z'), equipmentId: self::id(999), financialInterventionIds: $ids)->totalItems); + } + + public function testFinancialIdentityUnionRefusesOversizedInput(): void + { + $this->expectException(InterventionFactsScopeTooLarge::class); + $this->expectExceptionMessage('exceeds 10000'); + $this->port()->economicPage($this->organization->id, financialInterventionIds: array_map(self::id(...), range(1, 10001))); + } + + public function testCapturedPublicationIdentityAndOffsetDateTakePriorityOverLaterCompletionRows(): void + { + $snapshot = $this->snapshot(100); + $snapshot['publicationId'] = self::id(200); + $snapshot['publishedAt'] = '2026-10-01T00:30:00+02:00'; + $record = $this->intervention(100, $snapshot); + $this->publication(200, $record, '2026-09-30T22:30:00Z'); + $this->publication(201, $record, '2026-10-20T10:00:00Z', revision: 4); + $this->entityManager->flush(); + $port = $this->port(); + $facts = $port->published($this->organization->id, $record->id); + self::assertNotNull($facts); + self::assertSame(self::id(200), $facts->publicationId); + self::assertSame('2026-10-01T00:30:00+02:00', $facts->publishedAt?->format('c')); + $september = $port->economicWindow($this->organization->id, new DateTimeImmutable('2026-09-30T00:00:00Z'), new DateTimeImmutable('2026-10-01T00:00:00Z')); + self::assertSame(1, $september->totalItems); + self::assertSame($record->id, $september->items[0]->id); + $october = $port->economicWindow($this->organization->id, new DateTimeImmutable('2026-10-01T00:00:00Z'), new DateTimeImmutable('2026-11-01T00:00:00Z')); + self::assertSame(0, $october->totalItems); + self::assertSame([], $october->items); + } + + public function testLiteralSearchDoesNotInterpretPercentUnderscoreOrEscapeCharacters(): void + { + $snapshot = $this->snapshot(100); + $snapshot['name'] = 'Literal 100%_alarm!'; + $captured = $this->intervention(100, $snapshot); + $decoy = $this->intervention(101, $this->snapshot(101)); + $decoy->name = 'Literal 100AAalarm!'; + $live = $this->intervention(102, null, 'in_progress'); + $live->name = 'Another %_ live alarm!'; + $this->entityManager->flush(); + $published = $this->port()->publishedPage($this->organization->id, search: '%_'); + self::assertSame(1, $published->totalItems); + self::assertSame($captured->id, $published->items[0]->id); + $economic = $this->port()->economicPage($this->organization->id, search: '%_'); + self::assertSame(2, $economic->totalItems); + self::assertEqualsCanonicalizing([$captured->id, $live->id], array_map(static fn ($context): string => $context->id, $economic->items)); + self::assertSame(1, $this->port()->publishedPage($this->organization->id, search: 'alarm!')->totalItems); + } + + public function testAccentedSearchAcceptsOneHundredAndSixtyCharacters(): void + { + $search = str_repeat('é', 160); + $snapshot = $this->snapshot(100); + $snapshot['name'] = $search; + $captured = $this->intervention(100, $snapshot); + $this->entityManager->flush(); + + $published = $this->port()->publishedPage($this->organization->id, search: $search); + $economic = $this->port()->economicPage($this->organization->id, search: $search); + self::assertSame(1, $published->totalItems); + self::assertSame($captured->id, $published->items[0]->id); + self::assertSame(1, $economic->totalItems); + self::assertSame($captured->id, $economic->items[0]->id); + } + + public function testPublishedBatchRejectsMoreThanOneHundredUniqueSources(): void + { + $this->expectException(InterventionFactsScopeTooLarge::class); + $this->port()->publishedBatch($this->organization->id, array_map(self::id(...), range(100, 200))); + } + + public function testCombinedSourcePageRefusesAnOversizedTaskPayloadInsteadOfTruncatingIt(): void + { + for ($offset = 0; $offset < 3; ++$offset) { + $workItems = array_map(static fn (int $number): array => ['id' => self::id($number + 7000 * $offset), 'action' => 'repair', 'status' => 'completed'], range(10000, 16999)); + $record = $this->intervention(100 + $offset, $this->snapshot(100 + $offset, $workItems)); + $this->publication(200 + $offset, $record, '2026-10-01T10:00:00Z'); + } + $this->entityManager->flush(); + $this->expectException(InterventionFactsScopeTooLarge::class); + $this->port()->economicWindow($this->organization->id, new DateTimeImmutable('2026-10-01T00:00:00Z'), new DateTimeImmutable('2026-11-01T00:00:00Z'), limit: 3); + } + + public function testPublicationBatchRequiresNarrowingWhileAnIndividualDossierRemainsReadable(): void + { + $ids = []; + for ($offset = 0; $offset < 2; ++$offset) { + $workItems = array_map(static fn (int $number): array => ['id' => self::id($number + 5001 * $offset), 'action' => 'repair', 'status' => 'completed'], range(10000, 15000)); + $record = $this->intervention(100 + $offset, $this->snapshot(100 + $offset, $workItems)); + $this->publication(200 + $offset, $record, '2026-10-01T10:00:00Z'); + $ids[] = $record->id; + } + $this->entityManager->flush(); + $port = $this->port(); + $page = $port->publishedPage($this->organization->id, itemsPerPage: 1); + self::assertSame(2, $page->totalItems); + self::assertCount(5001, $page->items[0]->workItems); + $this->expectException(InterventionFactsScopeTooLarge::class); + $port->publishedBatch($this->organization->id, $ids); + } + + #[DataProvider('invalidPagination')] + public function testSourcePagesRejectInvalidOrUnboundedPagination(int $page, int $size): void + { + $this->expectException(InvalidArgumentException::class); + $this->port()->publishedPage($this->organization->id, $page, $size); + } + + /** + * Defines invalid source pagination without creating database-size fixtures. + * + * @since 1.0.0 + * + * @return iterable + */ + public static function invalidPagination(): iterable + { + yield 'zero page' => [0, 50]; + yield 'zero size' => [1, 0]; + yield 'over maximum' => [1, 101]; + yield 'unbounded offset' => [1000001, 50]; + } + + public function testAllocationFiltersUseEachCapturedTaskSiteCustomerAndExactEquipmentTarget(): void + { + $first = $this->snapshotWork(400, self::id(302), self::id(301), self::id(300)); + $second = $this->snapshotWork(401, self::id(312), self::id(311), self::id(310)); + $second['target'] = json_encode(['equipmentId' => self::id(312), 'notes' => self::id(302)], JSON_THROW_ON_ERROR); + $record = $this->intervention(100, $this->snapshot(100, [$first, $second], self::id(321), self::id(320))); + $decoy = $this->snapshotWork(402, self::id(332), self::id(331), self::id(330)); + $decoy['target'] = json_encode(['equipmentId' => self::id(332), 'notes' => self::id(312)], JSON_THROW_ON_ERROR); + $other = $this->intervention(101, $this->snapshot(101, [$decoy], self::id(331), self::id(330))); + $this->publication(200, $record, '2026-10-01T10:00:00Z'); + $this->publication(201, $other, '2026-10-01T10:00:00Z'); + $this->entityManager->flush(); + $port = $this->port(); + foreach ([$port->economicPage($this->organization->id, siteId: self::id(311)), $port->economicPage($this->organization->id, customerId: self::id(310)), $port->economicPage($this->organization->id, equipmentId: self::id(312)), $port->economicPage($this->organization->id, siteId: self::id(321)), $port->economicPage($this->organization->id, customerId: self::id(320))] as $result) { + self::assertSame(1, $result->totalItems); + self::assertSame($record->id, $result->items[0]->id); + } + self::assertSame(0, $port->economicPage($this->organization->id, siteId: self::id(311), customerId: self::id(330), equipmentId: self::id(312))->totalItems); + $context = $port->economicContext($this->organization->id, $record->id); + self::assertNotNull($context); + self::assertSame([self::id(302), self::id(312)], array_map(static fn ($work): ?string => $work->equipmentId, $context->workItems)); + self::assertSame(['id' => self::id(311), 'name' => 'Original site'], $context->workItems[1]->site); + self::assertSame(['id' => self::id(310), 'name' => 'Original customer'], $context->workItems[1]->customer); + } + + public function testLiveAllocationSupportsHistoricalJsonAndDoesNotResolveForeignEquipment(): void + { + [$site, $customer, $equipment] = $this->identities(300); + $foreign = $this->organization(2); + [, , $foreignEquipment] = $this->identities(310, $foreign); + $record = $this->intervention(100, null, 'in_progress'); + $record->siteId = $site->id; + $first = $this->workItem(400, $record, '/api/equipment/' . $equipment->id); + $second = $this->workItem(401, $record, json_encode(['equipmentId' => $equipment->id], JSON_THROW_ON_ERROR)); + $third = $this->workItem(402, $record, '/api/equipment/' . $foreignEquipment->id); + $fourth = $this->workItem(403, $record, json_encode(['notes' => $equipment->id], JSON_THROW_ON_ERROR)); + $this->entityManager->flush(); + $context = $this->port()->economicContext($this->organization->id, $record->id); + self::assertNotNull($context); + self::assertSame('live', $context->snapshotState); + self::assertSame(['id' => $site->id, 'name' => $site->name], $context->site); + self::assertSame(['id' => $customer->id, 'name' => $customer->name], $context->customer); + self::assertCount(4, $context->workItems); + self::assertSame([$first->id, $second->id, $third->id, $fourth->id], array_map(static fn ($work): string => $work->id, $context->workItems)); + self::assertSame($equipment->id, $context->workItems[0]->equipmentIdentity?->id); + self::assertSame($equipment->id, $context->workItems[1]->equipmentIdentity?->id); + self::assertNull($context->workItems[2]->equipmentIdentity); + self::assertNull($context->workItems[2]->site); + self::assertNull($context->workItems[2]->customer); + self::assertNull($context->workItems[3]->equipmentId); + self::assertFalse($context->identityComplete); + $result = $this->port()->economicPage($this->organization->id, siteId: $site->id, customerId: $customer->id, equipmentId: $equipment->id); + self::assertSame(1, $result->totalItems); + self::assertSame($record->id, $result->items[0]->id); + } + + public function testPublishedReadsNeverReturnForeignMissingOrUnpublishedWork(): void + { + $foreign = $this->organization(2); + $first = $this->intervention(100, $this->snapshot(100)); + $second = $this->intervention(101, $this->snapshot(101)); + $other = $this->intervention(102, $this->snapshot(102), organization: $foreign); + $draft = $this->intervention(103, $this->snapshot(103), 'draft'); + $this->publication(200, $first, '2026-10-01T10:00:00Z'); + $this->publication(201, $second, '2026-10-01T10:00:00Z'); + $this->publication(202, $other, '2026-10-01T10:00:00Z'); + $this->publication(203, $draft, '2026-10-01T10:00:00Z'); + $this->entityManager->flush(); + $port = $this->port(); + self::assertNull($port->published($this->organization->id, $other->id)); + self::assertNull($port->published($this->organization->id, $draft->id)); + self::assertNull($port->published($this->organization->id, self::id(999))); + self::assertNull($port->economicContext($this->organization->id, $other->id)); + self::assertNull($port->economicContext($this->organization->id, self::id(999))); + $facts = $port->publishedBatch($this->organization->id, [$second->id, $other->id, $first->id, $first->id, $draft->id, self::id(999)]); + self::assertSame([$first->id, $second->id], array_map(static fn ($fact): string => $fact->id, $facts)); + self::assertSame([], $port->publishedBatch($this->organization->id, [])); + } + + public function testPagingHasAnExactCountIncludesMissingSnapshotsAndDoesNotDuplicatePublications(): void + { + $ids = []; + for ($offset = 0; $offset < 5; ++$offset) { + $record = $this->intervention(100 + $offset, 2 === $offset ? null : $this->snapshot(100 + $offset)); + $ids[] = $record->id; + $this->publication(200 + $offset, $record, '2026-10-01T10:00:00Z'); + if (0 === $offset) { + $this->publication(210, $record, '2026-10-02T10:00:00Z', revision: 4); + } + } + $this->intervention(105, null, 'draft'); + $foreign = $this->organization(2); + $this->intervention(106, null, organization: $foreign); + $this->entityManager->flush(); + $port = $this->port(); + $readIds = []; + for ($page = 1; $page <= 3; ++$page) { + $result = $port->publishedPage($this->organization->id, $page, 2); + self::assertSame(5, $result->totalItems); + self::assertSame($page, $result->page); + self::assertSame(2, $result->itemsPerPage); + self::assertCount(3 === $page ? 1 : 2, $result->items); + foreach ($result->items as $fact) { + $readIds[] = $fact->id; + } + } + self::assertSame($ids, $readIds); + self::assertSame([], $port->publishedPage($this->organization->id, 4, 2)->items); + self::assertSame('snapshot_missing', $port->published($this->organization->id, $ids[2])?->snapshotState); + self::assertSame(self::id(210), $port->published($this->organization->id, $ids[0])?->publicationId); + } + + public function testMissingDossiersNeverInventHistoryFromRichLiveRecords(): void + { + [$site, $customer, $equipment] = $this->identities(300); + $record = $this->intervention(100, null); + $record->siteId = $site->id; + $this->workItem(400, $record, '/api/equipment/' . $equipment->id); + $this->publication(200, $record, '2026-10-01T10:00:00Z'); + $this->entityManager->flush(); + $port = $this->port(); + $fact = $port->published($this->organization->id, $record->id); + self::assertNotNull($fact); + self::assertSame('snapshot_missing', $fact->snapshotState); + self::assertNull($fact->snapshotVersion); + self::assertFalse($fact->identityComplete); + self::assertNull($fact->site); + self::assertNull($fact->customer); + self::assertNull($fact->dossier); + self::assertSame([], $fact->workItems); + $context = $port->economicContext($this->organization->id, $record->id); + self::assertNotNull($context); + self::assertSame('snapshot_missing', $context->snapshotState); + self::assertNull($context->site); + self::assertNull($context->customer); + self::assertSame([], $context->workItems); + self::assertSame(0, $port->economicPage($this->organization->id, siteId: $site->id)->totalItems); + self::assertSame(0, $port->economicPage($this->organization->id, customerId: $customer->id)->totalItems); + self::assertSame(0, $port->economicPage($this->organization->id, equipmentId: $equipment->id)->totalItems); + } + + public function testVersionOneRetainsKnownTargetsWithoutInventingFullAssetIdentityOrValidation(): void + { + [$site, $customer, $equipment] = $this->identities(300); + $work = $this->snapshotWork(400, $equipment->id); + unset($work['equipmentIdentity'], $work['site'], $work['customer']); + $work['executionResult'] = ['state' => 'staged', 'outcome' => 'successful']; + $snapshot = $this->snapshot(100, [$work], $site->id, $customer->id); + $snapshot['version'] = 1; + $snapshot['customer'] = ['id' => $customer->id, 'name' => 'Original customer', 'email' => 'private@example.test', 'contacts' => [['name' => 'Private contact']]]; + unset($snapshot['number'], $snapshot['name'], $snapshot['type'], $snapshot['createdAt'], $snapshot['plannedStartAt'], $snapshot['dueAt']); + $record = $this->intervention(100, $snapshot); + $this->workItem(401, $record, '/api/equipment/' . self::id(999)); + $this->publication(200, $record, '2026-10-01T10:00:00Z'); + $this->entityManager->flush(); + $fact = $this->port()->published($this->organization->id, $record->id); + self::assertNotNull($fact); + self::assertSame('available', $fact->snapshotState); + self::assertSame(1, $fact->snapshotVersion); + self::assertFalse($fact->identityComplete); + self::assertSame(['id' => $site->id, 'name' => 'Original site'], $fact->site); + self::assertSame(['id' => $customer->id, 'name' => 'Original customer'], $fact->customer); + self::assertSame(['id' => $customer->id, 'name' => 'Original customer'], $fact->dossier['customer'] ?? null); + self::assertCount(1, $fact->workItems); + self::assertSame(self::id(400), $fact->workItems[0]->id); + self::assertSame($equipment->id, $fact->workItems[0]->equipmentId); + self::assertNull($fact->workItems[0]->equipmentIdentity); + self::assertFalse($fact->workItems[0]->validated); + } + + public function testVersionTwoIdentitiesAndWorkRemainFrozenAfterTheLiveParcIsRenamed(): void + { + [$site, $customer, $equipment] = $this->identities(300); + $work = $this->snapshotWork(400, $equipment->id, $site->id, $customer->id); + $snapshot = $this->snapshot(100, [$work], $site->id, $customer->id); + $record = $this->intervention(100, $snapshot); + $record->siteId = $site->id; + $task = $this->workItem(400, $record, '/api/equipment/' . $equipment->id); + $this->publication(200, $record, '2026-10-01T10:00:00Z'); + $this->entityManager->flush(); + $port = $this->port(); + $before = $port->published($this->organization->id, $record->id); + $contextBefore = $port->economicContext($this->organization->id, $record->id); + self::assertNotNull($before); + self::assertTrue($before->identityComplete); + self::assertSame('Original intervention', $before->name); + self::assertSame(40, $before->number); + self::assertSame(4, $before->revision); + self::assertSame('2026-09-30T07:00:00+00:00', $before->createdAt->format('c')); + self::assertCount(1, $before->workItems); + self::assertTrue($before->workItems[0]->validated); + self::assertNotNull($before->workItems[0]->equipmentIdentity); + self::assertSame('Original equipment', $before->workItems[0]->equipmentIdentity->name); + self::assertSame('ASSET-300', $before->workItems[0]->equipmentIdentity->assetReference); + self::assertSame('SERIAL-300', $before->workItems[0]->equipmentIdentity->serialNumber); + $site->name = 'Renamed site'; + $customer->name = 'Renamed customer'; + $equipment->name = 'Renamed equipment'; + $equipment->assetCode = 'NEW-REFERENCE'; + $equipment->serialNumber = 'NEW-SERIAL'; + $equipment->facilityId = null; + $record->name = 'Renamed intervention'; + $record->siteId = null; + $task->target = '/api/equipment/' . self::id(999); + $this->entityManager->flush(); + self::assertSame(serialize($before), serialize($port->published($this->organization->id, $record->id))); + self::assertSame(serialize($contextBefore), serialize($port->economicContext($this->organization->id, $record->id))); + self::assertSame($snapshot, $port->published($this->organization->id, $record->id)?->dossier); + } + + public function testLegacyClosedInspectionEvidenceDoesNotInventResultsOrValidateLiveCompletedTasks(): void + { + $work = $this->snapshotWork(400, self::id(302)); + $work['action'] = 'inspection'; + $work['executionResult'] = null; + $work['resultResource'] = '/api/inspections/' . self::id(500); + unset($work['equipmentIdentity'], $work['site'], $work['customer']); + $skipped = $work; + $skipped['id'] = self::id(401); + $skipped['status'] = 'skipped'; + $invalid = $work; + $invalid['id'] = self::id(402); + $invalid['resultResource'] = '/api/inspections/' . self::id(500) . '/attachments'; + $snapshot = $this->snapshot(100, [$work, $skipped, $invalid]); + $snapshot['version'] = 1; + $published = $this->intervention(100, $snapshot); + $this->publication(200, $published, '2026-10-01T10:00:00Z'); + $live = $this->intervention(101, null, 'in_progress'); + $task = $this->workItem(403, $live, '/api/equipment/' . self::id(302)); + $task->action = 'inspection'; + $task->resultResource = '/api/inspections/' . self::id(500); + $this->entityManager->flush(); + $facts = $this->port()->published($this->organization->id, $published->id); + self::assertNotNull($facts); + self::assertCount(3, $facts->workItems); + self::assertTrue($facts->workItems[0]->validated); + self::assertSame('published_inspection', $facts->workItems[0]->validationSource); + self::assertNull($facts->workItems[0]->executionResult); + self::assertNull($facts->workItems[0]->equipmentIdentity); + self::assertFalse($facts->identityComplete); + self::assertFalse($facts->workItems[1]->validated); + self::assertNull($facts->workItems[1]->validationSource); + self::assertFalse($facts->workItems[2]->validated); + self::assertNull($facts->workItems[2]->validationSource); + $context = $this->port()->economicContext($this->organization->id, $live->id); + self::assertNotNull($context); + self::assertCount(1, $context->workItems); + self::assertFalse($context->workItems[0]->validated); + self::assertNull($context->workItems[0]->validationSource); + self::assertNull($context->workItems[0]->executionResult); + } + + /** + * Creates an organization whose records cannot overlap the shared template fixtures. + * + * @since 1.0.0 + */ + private function organization(int $number): OrganizationRecord + { + $record = new OrganizationRecord(); + $record->id = self::id($number); + $record->name = 'Publication fact organization ' . $number; + $record->slug = 'publication-fact-organization-' . $number; + $record->ownerUserId = self::id(9000 + $number); + $record->createdByUserId = $record->ownerUserId; + $record->createdAt = $record->updatedAt = new DateTimeImmutable('2026-01-01T10:00:00Z'); + $record->status = 'active'; + $record->isActive = true; + $this->entityManager->persist($record); + + return $record; + } + + /** + * Creates mutable source identities independently of the immutable publication JSON. + * + * @since 1.0.0 + * + * @return array{FacilityRecord, CustomerRecord, EquipmentRecord} + */ + private function identities(int $number, ?OrganizationRecord $organization = null): array + { + $organization ??= $this->organization; + $now = new DateTimeImmutable('2026-01-01T10:00:00Z'); + $customer = new CustomerRecord(); + $customer->id = self::id($number); + $customer->organizationId = $organization->id; + $customer->name = 'Original customer'; + $customer->code = 'CUS-' . $number; + $customer->createdAt = $customer->updatedAt = $now; + $this->entityManager->persist($customer); + $site = new FacilityRecord(); + $site->id = self::id($number + 1); + $site->organization = $organization; + $site->type = 'site'; + $site->name = 'Original site'; + $site->code = 'SITE-' . $number; + $site->customerId = $customer->id; + $site->createdAt = $site->updatedAt = $now; + $this->entityManager->persist($site); + $equipment = new EquipmentRecord(); + $equipment->id = self::id($number + 2); + $equipment->organization = $organization; + $equipment->facilityId = $site->id; + $equipment->type = 'fire_extinguisher'; + $equipment->name = 'Original equipment'; + $equipment->assetCode = 'ASSET-' . $number; + $equipment->brand = 'Original brand'; + $equipment->model = 'Original model'; + $equipment->serialNumber = 'SERIAL-' . $number; + $equipment->status = 'installed'; + $equipment->createdAt = $equipment->updatedAt = $now; + $this->entityManager->persist($equipment); + + return [$site, $customer, $equipment]; + } + + /** + * Creates an intervention with deliberately unrelated creation and planning dates. + * + * @since 1.0.0 + * + * @param array|null $snapshot independent immutable dossier + */ + private function intervention(int $number, ?array $snapshot, string $status = 'published', ?OrganizationRecord $organization = null): InterventionRecord + { + $record = new InterventionRecord(); + $record->id = self::id($number); + $record->organization = $organization ?? $this->organization; + $record->number = $number; + $record->name = 'Live intervention ' . $number; + $record->type = 'corrective_maintenance'; + $record->status = $status; + $record->closureSnapshot = $snapshot; + $record->revision = 4; + $record->createdAt = new DateTimeImmutable('2026-01-01T10:00:00Z'); + $record->updatedAt = new DateTimeImmutable('2026-12-01T10:00:00Z'); + $record->plannedStartAt = new DateTimeImmutable('2026-02-01T10:00:00Z'); + $record->dueAt = new DateTimeImmutable('2026-03-01T10:00:00Z'); + $this->entityManager->persist($record); + + return $record; + } + + /** + * Creates the durable completion instant used to select published prestations. + * + * @since 1.0.0 + */ + private function publication(int $number, InterventionRecord $intervention, string $completedAt, string $status = 'completed', int $revision = 3): PublicationRecord + { + $record = new PublicationRecord(); + $record->id = self::id($number); + $record->intervention = $intervention; + $record->interventionRevision = $revision; + $record->status = $status; + $record->createdAt = new DateTimeImmutable('2026-01-01T10:00:00Z'); + $record->completedAt = new DateTimeImmutable($completedAt); + $this->entityManager->persist($record); + + return $record; + } + + /** + * Creates a live task; historical dossiers must not accidentally read its mutable values. + * + * @since 1.0.0 + */ + private function workItem(int $number, InterventionRecord $intervention, ?string $target): InterventionWorkItemRecord + { + $record = new InterventionWorkItemRecord(); + $record->id = self::id($number); + $record->intervention = $intervention; + $record->action = 'repair'; + $record->target = $target; + $record->status = 'completed'; + $record->createdAt = $record->updatedAt = new DateTimeImmutable('2026-09-01T10:00:00Z'); + $this->entityManager->persist($record); + + return $record; + } + + /** + * Generates deterministic identifiers confined to this fixture's UUID prefix. + * + * @since 1.0.0 + */ + private static function id(int $number): string + { + return sprintf('ea328400-e29b-41d4-a716-%012d', $number); + } + + /** + * Resolves the public port, verifying its main-database runtime wiring. + * + * @since 1.0.0 + */ + private function port(): InterventionPublicationFactsPort + { + $port = self::getContainer()->get(InterventionPublicationFactsPort::class); + self::assertInstanceOf(InterventionPublicationFactsPort::class, $port); + + return $port; + } + + /** + * Builds an independently declared immutable operational dossier. + * + * @since 1.0.0 + * + * @param list> $workItems frozen original tasks + * + * @return array + */ + private function snapshot(int $number, array $workItems = [], ?string $siteId = null, ?string $customerId = null): array + { + return ['version' => 2, 'capturedAt' => '2026-10-01T10:00:00+00:00', 'interventionId' => self::id($number), 'revision' => 4, 'number' => 40, 'name' => 'Original intervention', 'type' => 'corrective_maintenance', 'createdAt' => '2026-09-30T07:00:00+00:00', 'plannedStartAt' => '2026-09-30T08:00:00+00:00', 'dueAt' => '2026-09-30T17:00:00+00:00', 'site' => null === $siteId ? null : ['id' => $siteId, 'name' => 'Original site'], 'customer' => null === $customerId ? null : ['id' => $customerId, 'name' => 'Original customer'], 'workItems' => $workItems, 'attachments' => [], 'report' => ['number' => 40, 'name' => 'Original intervention', 'type' => 'corrective_maintenance', 'plannedStartAt' => '2026-09-30T08:00:00+00:00', 'dueAt' => '2026-09-30T17:00:00+00:00']]; + } + + /** + * Builds a captured task whose identities cannot be reconstructed from its current target. + * + * @since 1.0.0 + * + * @return array + */ + private function snapshotWork(int $number, string $equipmentId, ?string $siteId = null, ?string $customerId = null): array + { + $site = null === $siteId ? null : ['id' => $siteId, 'name' => 'Original site']; + $customer = null === $customerId ? null : ['id' => $customerId, 'name' => 'Original customer']; + + return ['id' => self::id($number), 'action' => 'repair', 'target' => '/api/equipment/' . $equipmentId, 'resultResource' => null, 'status' => 'completed', 'site' => $site, 'customer' => $customer, 'equipmentIdentity' => ['id' => $equipmentId, 'name' => 'Original equipment', 'assetReference' => 'ASSET-300', 'type' => 'fire_extinguisher', 'brand' => 'Original brand', 'model' => 'Original model', 'serialNumber' => 'SERIAL-300', 'facilityId' => $siteId, 'site' => $site, 'customer' => $customer], 'executionResult' => ['state' => 'validated', 'outcome' => 'successful', 'equipmentId' => $equipmentId, 'performedAt' => '2026-09-30T09:00:00+00:00', 'workPerformed' => 'Original repair'], 'spentMinutes' => 45, 'evidenceCount' => 1, 'createdAt' => '2026-09-30T08:00:00+00:00', 'updatedAt' => '2026-09-30T09:00:00+00:00']; + } +} diff --git a/tests/Integration/Intervention/Infrastructure/Adapter/Publication/PreventiveOperationPublicationTest.php b/tests/Integration/Intervention/Infrastructure/Adapter/Publication/PreventiveOperationPublicationTest.php new file mode 100644 index 000000000..0e28e281d --- /dev/null +++ b/tests/Integration/Intervention/Infrastructure/Adapter/Publication/PreventiveOperationPublicationTest.php @@ -0,0 +1,261 @@ +fixture('maintenance'); + self::assertTrue($publication->publish(self::PUBLICATION)); + self::assertSame('completed', $store->findOccurrence(self::ORG, self::OCCURRENCE)?->status); + self::assertSame('2026-10-30', $this->nextDue($store, self::PLAN)); + self::assertSame('2026-09-30', $this->nextDue($store, self::OTHER_PLAN)); + $other = $store->find(self::ORG, self::OTHER_PLAN); + self::assertInstanceOf(MaintenancePlanState::class, $other); + self::assertNull($other->lastCompletedAt); + self::assertTrue($store->hasReceipt(self::TASK, self::OCCURRENCE)); + self::assertFalse($publication->publish(self::PUBLICATION)); + self::assertSame('2026-10-30', $this->nextDue($store, self::PLAN)); + } + + public function testClosedAdverseControlIsPerformedWithoutAdvancingTheIndependentMaintenance(): void + { + [$publication, $store] = $this->fixture('control'); + self::assertTrue($publication->publish(self::PUBLICATION)); + self::assertSame('completed', $store->findOccurrence(self::ORG, self::OCCURRENCE)?->status); + self::assertSame('2027-09-30', $store->find(self::ORG, self::PLAN)?->nextDueAt?->format('Y-m-d')); + self::assertSame('2026-09-30', $store->find(self::ORG, self::OTHER_PLAN)?->nextDueAt?->format('Y-m-d')); + /** @var EntityManagerInterface $manager */ + $manager = self::getContainer()->get('doctrine.orm.main_entity_manager'); + self::assertSame('fail', $manager->getConnection()->fetchOne('SELECT result FROM inspections WHERE id = ?', ['880e8400-e29b-41d4-a716-446655448011'])); + } + + public function testClosureCustomerContainsOnlyIdentityAndRemainsFrozen(): void + { + [$publication] = $this->fixture('maintenance'); + self::assertTrue($publication->publish(self::PUBLICATION)); + /** @var EntityManagerInterface $manager */ + $manager = self::getContainer()->get('doctrine.orm.main_entity_manager'); + $captured = $manager->getConnection()->fetchOne('SELECT closure_snapshot FROM interventions WHERE id = ?', [self::ORDER]); + self::assertIsString($captured); + $snapshot = json_decode($captured, true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($snapshot); + self::assertSame(2, $snapshot['version']); + self::assertSame(self::PUBLICATION, $snapshot['publicationId']); + $customer = $snapshot['customer'] ?? null; + self::assertIsArray($customer); + self::assertSame(['id', 'name'], array_keys($customer)); + self::assertSame('Initial customer', $customer['name']); + $items = $snapshot['workItems'] ?? null; + self::assertIsArray($items); + $first = $items[0] ?? null; + self::assertIsArray($first); + $identity = $first['equipmentIdentity'] ?? null; + self::assertIsArray($identity); + self::assertSame('Initial fire extinguisher', $identity['name']); + self::assertSame('ASSET-PUBLISHED-42', $identity['assetReference']); + self::assertSame('Historic brand', $identity['brand']); + self::assertSame('Historic model', $identity['model']); + self::assertSame('HISTORIC-SERIAL-42', $identity['serialNumber']); + $equipmentSite = $identity['site'] ?? null; + $equipmentCustomer = $identity['customer'] ?? null; + self::assertIsArray($equipmentSite); + self::assertIsArray($equipmentCustomer); + self::assertSame('Fire prevention site', $equipmentSite['name'] ?? null); + self::assertSame(['id', 'name'], array_keys($equipmentCustomer)); + $manager->getConnection()->executeStatement('UPDATE customers SET name = ? WHERE id = ?', ['Changed customer', $customer['id']]); + $manager->getConnection()->executeStatement('UPDATE equipment SET name = ?, asset_code = ?, status = ? WHERE id = ?', ['Renamed replacement-era asset', 'NEW-ASSET-42', 'decommissioned', self::EQUIPMENT]); + $manager->getConnection()->executeStatement('UPDATE facilities SET name = ?, status = ? WHERE id = ?', ['Changed site', 'archived', $identity['facilityId']]); + self::assertSame($captured, $manager->getConnection()->fetchOne('SELECT closure_snapshot FROM interventions WHERE id = ?', [self::ORDER])); + /** @var InterventionPublicationFactsPort $facts */ + $facts = self::getContainer()->get(InterventionPublicationFactsPort::class); + $published = $facts->published(self::ORG, self::ORDER); + self::assertNotNull($published); + self::assertTrue($published->identityComplete); + self::assertSame('Initial fire extinguisher', $published->workItems[0]->equipmentIdentity?->name); + self::assertSame('Initial customer', $published->workItems[0]->customer['name'] ?? null); + self::assertSame('Fire prevention site', $published->workItems[0]->site['name'] ?? null); + } + + public function testClosedControlWithoutPreventiveOccurrenceCapturesItsValidatedOwnerResult(): void + { + [$publication] = $this->fixture('control'); + /** @var EntityManagerInterface $manager */ + $manager = self::getContainer()->get('doctrine.orm.main_entity_manager'); + $task = $manager->find(InterventionWorkItemRecord::class, self::TASK); + self::assertInstanceOf(InterventionWorkItemRecord::class, $task); + $task->operationId = $task->occurrenceId = $task->operationKind = null; + $manager->flush(); + self::assertTrue($publication->publish(self::PUBLICATION)); + /** @var InterventionPublicationFactsPort $facts */ + $facts = self::getContainer()->get(InterventionPublicationFactsPort::class); + $result = $facts->published(self::ORG, self::ORDER); + self::assertNotNull($result); + self::assertTrue($result->workItems[0]->validated); + self::assertSame('fail', $result->workItems[0]->executionResult['inspectionResult'] ?? null); + self::assertSame(self::EQUIPMENT, $result->workItems[0]->executionResult['equipmentId'] ?? null); + self::assertSame('performed', $result->workItems[0]->executionResult['outcome'] ?? null); + } + + /** + * @return array{DoctrinePublicationAdapter,MaintenancePlanStorePort} + */ + private function fixture(string $kind): array + { + self::bootKernel(); + /** @var EntityManagerInterface $manager */ + $manager = self::getContainer()->get('doctrine.orm.main_entity_manager'); + $created = new DateTimeImmutable('2026-09-01T00:00:00+00:00'); + $due = new DateTimeImmutable('2026-09-30T00:00:00+00:00'); + $organization = new OrganizationRecord(); + $organization->id = self::ORG; + $organization->name = 'Preventive publication test'; + $organization->slug = 'preventive-publication-test'; + $organization->ownerUserId = $organization->createdByUserId = '880e8400-e29b-41d4-a716-446655448009'; + $organization->createdAt = $organization->updatedAt = $created; + $organization->status = 'active'; + $organization->isActive = true; + $manager->persist($organization); + $customer = new CustomerRecord(); + $customer->id = '880e8400-e29b-41d4-a716-446655448012'; + $customer->organizationId = self::ORG; + $customer->name = 'Initial customer'; + $customer->contacts = [['name' => 'Private contact', 'email' => 'private@example.test', 'phone' => null, 'role' => null]]; + $customer->createdAt = $customer->updatedAt = $created; + $manager->persist($customer); + $site = new FacilityRecord(); + $site->id = '880e8400-e29b-41d4-a716-446655448010'; + $site->organization = $organization; + $site->name = 'Fire prevention site'; + $site->type = 'site'; + $site->status = 'active'; + $site->customerId = $customer->id; + $site->createdAt = $site->updatedAt = $created; + $manager->persist($site); + $equipment = new EquipmentRecord(); + $equipment->id = self::EQUIPMENT; + $equipment->organization = $organization; + $equipment->facilityId = $site->id; + $equipment->type = 'fire_extinguisher'; + $equipment->name = 'Initial fire extinguisher'; + $equipment->assetCode = 'ASSET-PUBLISHED-42'; + $equipment->brand = 'Historic brand'; + $equipment->model = 'Historic model'; + $equipment->serialNumber = 'HISTORIC-SERIAL-42'; + $equipment->createdAt = $equipment->updatedAt = $created; + $manager->persist($equipment); + $order = new InterventionRecord(); + $order->id = self::ORDER; + $order->organization = $organization; + $order->siteId = $site->id; + $order->type = 'control' === $kind ? 'inspection_campaign' : 'preventive_maintenance'; + $order->name = 'Real preventive publication'; + $order->status = 'submitted'; + $order->number = 42; + $order->createdAt = $order->updatedAt = $created; + $manager->persist($order); + $task = new InterventionWorkItemRecord(); + $task->id = self::TASK; + $task->intervention = $order; + $task->action = 'control' === $kind ? 'inspection' : 'maintenance'; + $task->status = 'completed'; + $task->target = '/api/equipment/' . self::EQUIPMENT; + $task->operationId = self::PLAN; + $task->occurrenceId = self::OCCURRENCE; + $task->operationKind = $kind; + $task->createdAt = $task->updatedAt = $created; + if ('control' === $kind) { + $inspection = new InspectionRecord(); + $inspection->id = '880e8400-e29b-41d4-a716-446655448011'; + $inspection->organization = $organization; + $inspection->equipmentId = self::EQUIPMENT; + $inspection->facilityId = $site->id; + $inspection->interventionId = self::ORDER; + $inspection->recordStatus = 'draft'; + $inspection->result = 'fail'; + $inspection->status = 'closed'; + $inspection->inspectorType = 'user'; + $inspection->inspectorName = 'Technician'; + $inspection->inspectorUserId = $organization->ownerUserId; + $inspection->performedAt = new DateTimeImmutable('2026-09-30T15:00:00+02:00'); + $inspection->createdAt = $inspection->updatedAt = $created; + $manager->persist($inspection); + $task->resultResource = '/api/inspections/' . $inspection->id; + } else { + $task->executionResult = ['equipmentId' => self::EQUIPMENT, 'performedAt' => '2026-09-30T15:00:00+02:00', 'outcome' => 'successful', 'workPerformed' => 'Extinguisher maintenance', 'authorId' => $organization->ownerUserId, 'state' => 'staged']; + } + $manager->persist($task); + $manager->flush(); + /** @var MaintenancePlanStorePort $store */ + $store = self::getContainer()->get(MaintenancePlanStorePort::class); + $plan = new MaintenancePlanState(self::PLAN, self::ORG, self::EQUIPMENT, $site->id, 'fire_extinguisher', 'Source operation', $kind, 'control' === $kind ? 'P1Y' : 'P1M', 'fixed', $due, $due, true, null, null, null, $created, $created); + $other = new MaintenancePlanState(self::OTHER_PLAN, self::ORG, self::EQUIPMENT, $site->id, 'fire_extinguisher', 'Independent operation', 'control' === $kind ? 'maintenance' : 'control', 'control' === $kind ? 'P1M' : 'P1Y', 'fixed', $due, $due, true, null, null, null, $created, $created); + $occurrence = new MaintenanceOccurrenceState(self::OCCURRENCE, self::PLAN, self::ORG, $due, 'open', 1, self::ORDER, null, null, $created, $created, 42); + $store->synchronized(self::ORG, static function () use ($store, $created, $plan, $other, $occurrence): void { + $store->activateEngine(self::ORG, $created); + $store->save($plan); + $store->save($other); + $store->saveOccurrence($occurrence); + }); + /** @var DoctrinePublicationAdapter $publication */ + $publication = self::getContainer()->get(DoctrinePublicationAdapter::class); + $publication->createOrGetPending(self::PUBLICATION, self::ORDER, 1); + $publication->markProcessing(self::PUBLICATION); + + return [$publication, $store]; + } + + private function nextDue(MaintenancePlanStorePort $store, string $planId): string + { + $plan = $store->find(self::ORG, $planId); + self::assertInstanceOf(MaintenancePlanState::class, $plan); + self::assertInstanceOf(DateTimeImmutable::class, $plan->nextDueAt); + + return $plan->nextDueAt->format('Y-m-d'); + } +} diff --git a/tests/Integration/Intervention/Infrastructure/Adapter/ServiceRequest/ServiceRequestWorkAdapterTest.php b/tests/Integration/Intervention/Infrastructure/Adapter/ServiceRequest/ServiceRequestWorkAdapterTest.php new file mode 100644 index 000000000..f12d144cc --- /dev/null +++ b/tests/Integration/Intervention/Infrastructure/Adapter/ServiceRequest/ServiceRequestWorkAdapterTest.php @@ -0,0 +1,383 @@ +get('doctrine.orm.main_entity_manager'); + $this->manager = $manager; + $workflow = $container->get(InterventionWorkflowGatewayPort::class); + self::assertInstanceOf(InterventionWorkflowGatewayPort::class, $workflow); + $authorization = $container->get(OrganizationAuthorizationPort::class); + self::assertInstanceOf(OrganizationAuthorizationPort::class, $authorization); + $equipment = $container->get(EquipmentParkScopePort::class); + self::assertInstanceOf(EquipmentParkScopePort::class, $equipment); + $activities = $container->get(InterventionActivityPort::class); + self::assertInstanceOf(InterventionActivityPort::class, $activities); + $members = $container->get(InterventionMemberPolicy::class); + self::assertInstanceOf(InterventionMemberPolicy::class, $members); + $this->adapter = new ServiceRequestWorkAdapter( + $manager, + $workflow, + $authorization, + $equipment, + $activities, + $members, + ); + } + + public function testCreatesOneCorrectiveDraftAndRepairTaskAcrossLostResponseReplay(): void + { + $connection = $this->manager->getConnection(); + $beforeNumber = $connection->fetchOne('SELECT last_number FROM intervention_number_counters WHERE organization_id = ?', [OrganizationFixtures::ORGANIZATION_ID]); + self::assertIsInt($beforeNumber); + $first = $this->convert($this->request()); + $replay = $this->convert($this->request()); + self::assertEquals($first, $replay); + self::assertTrue($first->created); + $order = $this->manager->find(InterventionRecord::class, $first->interventionId); + self::assertInstanceOf(InterventionRecord::class, $order); + self::assertSame('corrective_maintenance', $order->type); + self::assertSame('draft', $order->status); + self::assertSame('Repair damaged extinguisher seal', $order->name); + $task = $this->manager->find(InterventionWorkItemRecord::class, $first->taskId); + self::assertInstanceOf(InterventionWorkItemRecord::class, $task); + self::assertSame('repair', $task->action); + self::assertSame('planned', $task->status); + self::assertSame('/api/equipment/' . self::EQUIPMENT, $task->target); + self::assertNull($task->executionResult); + self::assertSame((int) $beforeNumber + 1, $order->number); + self::assertSame(1, $this->manager->getRepository(InterventionActivityRecord::class)->count(['clientId' => $this->key()])); + self::assertSame(1, $this->manager->getRepository(InterventionActivityRecord::class)->count(['intervention' => $order, 'event' => 'created'])); + $order->status = 'published'; + $this->manager->flush(); + self::assertEquals($first, $this->convert($this->request())); + } + + public function testReservationLocksTheParentBeforeEquipmentAndRecordsNoWork(): void + { + $connection = $this->manager->getConnection(); + $orderId = $connection->fetchOne('SELECT id FROM interventions WHERE organization_id = ? AND type = ? AND status IN (?, ?, ?, ?) ORDER BY id LIMIT 1', [OrganizationFixtures::ORGANIZATION_ID, 'corrective_maintenance', 'draft', 'planned', 'in_progress', 'changes_requested']); + self::assertIsString($orderId); + $parameters = $connection->getParams(); + self::assertTrue(str_contains((string) ($parameters['dbname'] ?? ''), '_w'), 'Independent lock checks must remain inside the isolated test clone.'); + $parameters = array_intersect_key($parameters, ['host' => true, 'port' => true, 'dbname' => true, 'user' => true, 'password' => true, 'charset' => true]); + $parameters['driver'] = 'pdo_pgsql'; + $other = DriverManager::getConnection($parameters); + self::assertNotSame($connection->fetchOne('SELECT pg_backend_pid()'), $other->fetchOne('SELECT pg_backend_pid()')); + $workflow = $this->createMock(InterventionWorkflowGatewayPort::class); + $workflow->expects(self::never())->method('mutate'); + $equipment = $this->createMock(EquipmentParkScopePort::class); + $equipment->expects(self::never())->method('filterIds'); + $activities = $this->createMock(InterventionActivityPort::class); + $activities->expects(self::never())->method('append'); + $authorization = self::getContainer()->get(OrganizationAuthorizationPort::class); + self::assertInstanceOf(OrganizationAuthorizationPort::class, $authorization); + $members = self::getContainer()->get(InterventionMemberPolicy::class); + self::assertInstanceOf(InterventionMemberPolicy::class, $members); + $adapter = new ServiceRequestWorkAdapter($this->manager, $workflow, $authorization, $equipment, $activities, $members); + $beforeOrder = $connection->fetchAssociative('SELECT revision, updated_at FROM interventions WHERE id = ?', [$orderId]); + $beforeTasks = $connection->fetchOne('SELECT COUNT(*) FROM intervention_work_items WHERE intervention_id = ?', [$orderId]); + $beforeActivities = $connection->fetchOne('SELECT COUNT(*) FROM intervention_activities WHERE intervention_id = ?', [$orderId]); + + try { + $connection->transactional(function () use ($adapter, $orderId, $other): void { + $adapter->reserveSelection($this->request($orderId)); + $other->beginTransaction(); + $other->executeStatement("SET LOCAL lock_timeout = '75ms'"); + + try { + $other->executeQuery('SELECT id FROM interventions WHERE id = ? FOR UPDATE', [$orderId])->free(); + self::fail('Publication must wait for the reserved intervention before taking equipment locks.'); + } catch (DriverException $exception) { + self::assertSame('55P03', $exception->getSQLState()); + } finally { + $other->rollBack(); + } + }); + } finally { + $other->close(); + } + self::assertSame($beforeOrder, $connection->fetchAssociative('SELECT revision, updated_at FROM interventions WHERE id = ?', [$orderId])); + self::assertSame($beforeTasks, $connection->fetchOne('SELECT COUNT(*) FROM intervention_work_items WHERE intervention_id = ?', [$orderId])); + self::assertSame($beforeActivities, $connection->fetchOne('SELECT COUNT(*) FROM intervention_activities WHERE intervention_id = ?', [$orderId])); + } + + public function testLinksOpenFailedRepairWithoutRewritingItsProofOrRevision(): void + { + [$order, $task] = $this->existingWork(); + $order->status = 'in_progress'; + $task->status = 'in_progress'; + $task->executionResult = [ + 'equipmentId' => self::EQUIPMENT, + 'performedAt' => '2026-09-30T14:00:00+00:00', + 'outcome' => 'failed', + 'workPerformed' => 'Seal unavailable', + 'state' => 'staged', + 'validatedAt' => null, + 'history' => [['equipmentId' => self::EQUIPMENT, 'performedAt' => '2026-09-29T14:00:00+00:00', 'outcome' => 'failed', 'workPerformed' => 'Initial assessment']], + ]; + $task->resultResource = '/api/equipment/' . self::EQUIPMENT; + $task->revision = 9; + $this->manager->flush(); + $before = $task->executionResult; + $updatedAt = $task->updatedAt; + $link = $this->convert($this->request($order->id, $task->id)); + self::assertFalse($link->created); + self::assertSame($task->id, $link->taskId); + self::assertSame($before, $task->executionResult); + self::assertSame('/api/equipment/' . self::EQUIPMENT, $task->resultResource); + self::assertSame(9, $task->revision); + self::assertEquals($updatedAt, $task->updatedAt); + self::assertSame(1, $this->manager->getRepository(InterventionWorkItemRecord::class)->count(['intervention' => $order])); + self::assertEquals($link, $this->convert($this->request($order->id, $task->id))); + } + + public function testUnspecifiedTaskReusesTheUniqueCompatibleRepair(): void + { + [$order, $task] = $this->existingWork(); + $link = $this->convert($this->request($order->id)); + self::assertSame($task->id, $link->taskId); + self::assertFalse($link->created); + self::assertSame(1, $this->manager->getRepository(InterventionWorkItemRecord::class)->count(['intervention' => $order])); + } + + public function testAddsOneRepairTaskToAnExplicitDraftWhenNoCompatibleTaskExists(): void + { + [$order, $task] = $this->existingWork(); + $task->action = 'inspection'; + $this->manager->flush(); + $link = $this->convert($this->request($order->id)); + self::assertNotSame($task->id, $link->taskId); + self::assertFalse($link->created); + self::assertSame(2, $this->manager->getRepository(InterventionWorkItemRecord::class)->count(['intervention' => $order])); + self::assertEquals($link, $this->convert($this->request($order->id))); + self::assertSame(2, $this->manager->getRepository(InterventionWorkItemRecord::class)->count(['intervention' => $order])); + } + + public function testAmbiguousOpenRepairsRequireAnExplicitTaskChoice(): void + { + [$order] = $this->existingWork(); + $this->task($order, '20b99d7c-78a6-44ae-a052-1a54b1f58e53'); + $this->manager->flush(); + $this->expectException(InterventionConflictException::class); + $this->expectExceptionMessage('Several open repairs match this equipment. Select the intended task.'); + $this->convert($this->request($order->id)); + } + + #[DataProvider('immutableStatuses')] + public function testImmutableOrderCannotAcquireNewRequestWork(string $status): void + { + [$order] = $this->existingWork(); + $order->status = $status; + $this->manager->flush(); + $this->expectException(InterventionConflictException::class); + $this->expectExceptionMessage('Select an open corrective intervention for this repair.'); + $this->convert($this->request($order->id)); + } + + /** + * @return iterable + */ + public static function immutableStatuses(): iterable + { + yield 'submitted' => ['submitted']; + yield 'published' => ['published']; + yield 'abandoned' => ['abandoned']; + } + + public function testInspectionCampaignCannotReceiveCorrectiveRequestWork(): void + { + [$order] = $this->existingWork(); + $order->type = 'inspection_campaign'; + $this->manager->flush(); + $this->expectException(InterventionConflictException::class); + $this->expectExceptionMessage('Select an open corrective intervention for this repair.'); + $this->convert($this->request($order->id)); + } + + public function testTaskOfAnotherEquipmentCannotBeExplicitlySelected(): void + { + [$order, $task] = $this->existingWork(); + $task->target = '/api/equipment/33333333-3333-4333-8333-333333333334'; + $this->manager->flush(); + $this->expectException(InterventionConflictException::class); + $this->expectExceptionMessage('Select an open repair task for the qualified equipment.'); + $this->convert($this->request($order->id, $task->id)); + } + + public function testForeignEquipmentCannotCreateCorrectiveWork(): void + { + $this->manager->getConnection()->executeStatement('UPDATE equipment SET organization_id = (SELECT id FROM organizations WHERE id <> ? LIMIT 1) WHERE id = ?', [OrganizationFixtures::ORGANIZATION_ID, self::EQUIPMENT]); + $this->manager->clear(); + $this->expectException(InterventionNotFoundException::class); + $this->convert($this->request()); + } + + public function testCompletedTaskCannotBeSelectedForANewRepair(): void + { + [$order, $task] = $this->existingWork(); + $task->status = 'completed'; + $this->manager->flush(); + $this->expectException(InterventionConflictException::class); + $this->expectExceptionMessage('Select an open repair task for the qualified equipment.'); + $this->convert($this->request($order->id, $task->id)); + } + + public function testForeignInterventionIsNotFound(): void + { + [$order] = $this->existingWork(); + $this->manager->getConnection()->executeStatement('UPDATE interventions SET organization_id = (SELECT id FROM organizations WHERE id <> ? LIMIT 1) WHERE id = ?', [OrganizationFixtures::ORGANIZATION_ID, $order->id]); + $this->manager->clear(); + $this->expectException(InterventionNotFoundException::class); + $this->convert($this->request($order->id)); + } + + public function testMissingEquipmentCannotCreateARepair(): void + { + $this->expectException(InterventionValidationException::class); + $this->expectExceptionMessage('Qualify the equipment before creating corrective work.'); + $this->convert($this->request(equipmentId: null)); + } + + public function testChangedReplaySelectionCannotRelinkTheRequest(): void + { + $first = $this->convert($this->request()); + $this->expectException(InterventionConflictException::class); + $this->expectExceptionMessage('The service request already identifies different corrective work.'); + $this->convert($this->request($first->interventionId, $first->taskId)); + } + + public function testOuterRollbackRemovesDraftTaskNumberAndLinkageTogether(): void + { + $connection = $this->manager->getConnection(); + $beforeNumber = $connection->fetchOne('SELECT last_number FROM intervention_number_counters WHERE organization_id = ?', [OrganizationFixtures::ORGANIZATION_ID]); + $created = null; + + try { + $connection->transactional(function () use (&$created): void { + $created = $this->adapter->createOrLink($this->request()); + + throw new RuntimeException('Request receipt failed after work creation.'); + }); + } catch (RuntimeException $exception) { + self::assertSame('Request receipt failed after work creation.', $exception->getMessage()); + } + self::assertInstanceOf(ServiceRequestWorkLink::class, $created); + $this->manager->clear(); + self::assertNull($this->manager->find(InterventionRecord::class, $created->interventionId)); + self::assertNull($this->manager->find(InterventionWorkItemRecord::class, $created->taskId)); + self::assertSame($beforeNumber, $connection->fetchOne('SELECT last_number FROM intervention_number_counters WHERE organization_id = ?', [OrganizationFixtures::ORGANIZATION_ID])); + self::assertSame(0, $this->manager->getRepository(InterventionActivityRecord::class)->count(['clientId' => $this->key()])); + } + + /** + * Method convert + * + * Reproduces the service-request caller's main transaction rather than relying on DAMA's physical test transaction. + * + * @access private + * + * @param ServiceRequestWorkRequest $request qualified conversion submitted by the caller + * + * @return ServiceRequestWorkLink committed corrective work identity + */ + private function convert(ServiceRequestWorkRequest $request): ServiceRequestWorkLink + { + return $this->manager->getConnection()->transactional(fn (): ServiceRequestWorkLink => $this->adapter->createOrLink($request)); + } + + /** + * @return array{InterventionRecord,InterventionWorkItemRecord} + */ + private function existingWork(): array + { + $organization = $this->manager->find(OrganizationRecord::class, OrganizationFixtures::ORGANIZATION_ID); + self::assertInstanceOf(OrganizationRecord::class, $organization); + $order = new InterventionRecord(); + $order->id = self::ORDER; + $order->organization = $organization; + $order->type = 'corrective_maintenance'; + $order->name = 'Existing corrective draft'; + $order->number = 90001; + $order->createdAt = $order->updatedAt = new DateTimeImmutable('2026-09-30T14:00:00+00:00'); + $this->manager->persist($order); + $task = $this->task($order, self::TASK); + $this->manager->flush(); + + return [$order, $task]; + } + + private function task(InterventionRecord $order, string $id): InterventionWorkItemRecord + { + $task = new InterventionWorkItemRecord(); + $task->id = $id; + $task->intervention = $order; + $task->action = 'repair'; + $task->target = '/api/equipment/' . self::EQUIPMENT; + $task->createdAt = $task->updatedAt = $order->createdAt; + $this->manager->persist($task); + + return $task; + } + + private function request(?string $orderId = null, ?string $taskId = null, ?string $equipmentId = self::EQUIPMENT): ServiceRequestWorkRequest + { + return new ServiceRequestWorkRequest(OrganizationFixtures::ORGANIZATION_ID, self::REQUEST, self::ACTOR, $equipmentId, null, 'Repair damaged extinguisher seal', 'Replace the seal and verify tightness.', $orderId, $taskId, '20b99d7c-78a6-44ae-a052-1a54b1f58e54'); + } + + private function key(): string + { + return hash('sha256', 'intervention.service_request:' . OrganizationFixtures::ORGANIZATION_ID . ':' . self::REQUEST); + } +} diff --git a/tests/Integration/Intervention/Infrastructure/DataFixtures/InterventionFixturesIntegrationTest.php b/tests/Integration/Intervention/Infrastructure/DataFixtures/InterventionFixturesIntegrationTest.php index 187921e06..412052e1b 100644 --- a/tests/Integration/Intervention/Infrastructure/DataFixtures/InterventionFixturesIntegrationTest.php +++ b/tests/Integration/Intervention/Infrastructure/DataFixtures/InterventionFixturesIntegrationTest.php @@ -9,15 +9,18 @@ use Doctrine\Common\DataFixtures\Purger\ORMPurger; use Doctrine\ORM\EntityManagerInterface; use Equipment\Infrastructure\DataFixtures\EquipmentFixtures; +use Equipment\Infrastructure\Persistence\Doctrine\Record\EquipmentRecord; use Facility\Infrastructure\DataFixtures\FacilityFixtures; use Intervention\Domain\ValueObject\{InterventionPriority, InterventionStatus, InterventionType}; use Intervention\Infrastructure\DataFixtures\InterventionFixtures; use Intervention\Infrastructure\Persistence\Doctrine\Record\{InterventionActivityRecord, InterventionAttachmentRecord, InterventionChangeRecord, InterventionLabelRecord, InterventionNumberCounterRecord, InterventionRecord, InterventionRecurrenceRecord, InterventionRecurrenceRunRecord, InterventionTemplateItemRecord, InterventionTemplateRecord, InterventionWorkItemRecord, PublicationRecord}; use Organization\Infrastructure\DataFixtures\OrganizationFixtures; use PHPUnit\Framework\Attributes\{CoversClass, Test}; +use Shared\Infrastructure\DataFixtures\SeedUuid; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use function count; +use function sprintf; /** * Test InterventionFixturesIntegrationTest. @@ -58,7 +61,7 @@ public function testLoadPersistsTheWholeInterventionGraph(): void $interventions = $this->entityManager->getRepository(InterventionRecord::class); - // The twelve hand-authored interventions plus the generated bulk pool. + // The hand-authored interventions plus the generated bulk pool. self::assertSame(count(InterventionFixtures::INTERVENTION_SEEDS) + InterventionFixtures::BULK_INTERVENTION_COUNT, $interventions->count([])); self::assertSame(count(InterventionFixtures::LABEL_SEEDS), $this->entityManager->getRepository(InterventionLabelRecord::class)->count([])); self::assertSame(count(InterventionFixtures::TEMPLATE_SEEDS), $this->entityManager->getRepository(InterventionTemplateRecord::class)->count([])); @@ -106,6 +109,48 @@ public function testEveryStatusTypeAndPriorityIsRepresented(): void } } + #[Test] + public function testMaintenanceExamplesKeepExistingIdentitiesAndWaitForActualResults(): void + { + $this->loadFixtures(); + + $interventions = $this->entityManager->getRepository(InterventionRecord::class); + for ($index = 0; $index < InterventionFixtures::BULK_INTERVENTION_COUNT; ++$index) { + $historical = $interventions->find(SeedUuid::from(sprintf('intervention-bulk:%d', $index))); + self::assertInstanceOf(InterventionRecord::class, $historical); + self::assertSame(13 + $index, $historical->number); + } + + /** @var InterventionFixtures $fixtures */ + $fixtures = static::getContainer()->get(InterventionFixtures::class); + $examples = [ + ['intervention-seed-extinguisher-maintenance', 'maintenance', EquipmentFixtures::EXTINGUISHER_REFERENCE, 'planned'], + ['intervention-seed-fire-door-repair', 'repair', EquipmentFixtures::BUILDING_FIRE_DOOR_REFERENCE, 'in_progress'], + ]; + foreach ($examples as [$reference, $action, $equipmentReference, $status]) { + $intervention = $fixtures->getReference($reference, InterventionRecord::class); + $equipment = $fixtures->getReference($equipmentReference, EquipmentRecord::class); + $workItems = $this->entityManager->getRepository(InterventionWorkItemRecord::class)->findBy(['intervention' => $intervention]); + self::assertCount(1, $workItems); + $workItem = $workItems[0]; + self::assertSame($action, $workItem->action); + self::assertSame('/api/equipment/' . $equipment->id, $workItem->target); + self::assertSame($status, $intervention->status); + self::assertSame($status, $workItem->status); + self::assertNull($workItem->executionResult); + self::assertNull($workItem->resultResource); + self::assertNull($intervention->closureSnapshot); + self::assertSame(0, $this->entityManager->getRepository(PublicationRecord::class)->count(['intervention' => $intervention])); + + $activities = $this->entityManager->getRepository(InterventionActivityRecord::class)->findBy(['intervention' => $intervention], ['createdAt' => 'ASC']); + self::assertSame('created', $activities[0]->event); + self::assertSame(['from' => 'draft', 'to' => 'planned'], $activities[1]->payload); + if ('in_progress' === $status) { + self::assertSame(['from' => 'planned', 'to' => 'in_progress'], $activities[2]->payload); + } + } + } + /** * A skipped work item without a reason is rejected by the workflow gateway; * seeding one would be a state the runtime can never produce. diff --git a/tests/Integration/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionViewMapperTest.php b/tests/Integration/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionViewMapperTest.php index b5c661bba..4172af49f 100644 --- a/tests/Integration/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionViewMapperTest.php +++ b/tests/Integration/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionViewMapperTest.php @@ -12,7 +12,7 @@ InterventionValidationContext, InterventionWorkItemSummary }; -use Intervention\Application\Port\Outbound\InterventionResourceGatewayPort; +use Intervention\Application\Port\Outbound\{InterventionPublicationResourcesIssuesPort, InterventionResourceGatewayPort}; use Intervention\Application\Service\InterventionIssueFinder; use Intervention\Infrastructure\Persistence\Doctrine\Mapper\InterventionViewMapper; use Intervention\Infrastructure\Persistence\Doctrine\Record\{ @@ -114,7 +114,7 @@ protected function setUp(): void $this->mapper = new InterventionViewMapper( $this->entityManager, $resources, - new InterventionIssueFinder($resources, new InterventionAttachmentRepository($this->entityManager)), + new InterventionIssueFinder($resources, new InterventionAttachmentRepository($this->entityManager), $this->createStub(InterventionPublicationResourcesIssuesPort::class)), ); $this->seed(); diff --git a/tests/Integration/Inventory/InventoryLastPieceConcurrencyTest.php b/tests/Integration/Inventory/InventoryLastPieceConcurrencyTest.php new file mode 100644 index 000000000..95fadfb8a --- /dev/null +++ b/tests/Integration/Inventory/InventoryLastPieceConcurrencyTest.php @@ -0,0 +1,130 @@ +get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $em); + $this->main = $em; + $this->a = $em->getConnection(); + $this->b = DriverManager::getConnection($this->a->getParams()); + $this->clean(); + self::assertNotSame($this->a->fetchOne('SELECT pg_backend_pid()'), $this->b->fetchOne('SELECT pg_backend_pid()'), 'The workers must use genuinely independent PostgreSQL sessions.'); + $store = new InventoryRepository($em); + $this->a->beginTransaction(); + $store->saveReference('parts', new InventoryReference(self::PART, self::ORG, 'LAST', 'Last piece', 'piece', 'part')); + $store->saveReference('warehouses', new InventoryReference(self::WAREHOUSE, self::ORG, 'W', 'Warehouse')); + $store->saveBalance(new StockBalance('bec10000-0000-4000-8000-000000000004', self::ORG, self::PART, self::WAREHOUSE, '1.000000', '7.000000', 'EUR')); + $this->a->commit(); + } + + protected function tearDown(): void + { + foreach ([$this->a, $this->b] as $c) { + while ($c->isTransactionActive()) { + $c->rollBack(); + } + } + $this->clean(); + $this->b->close(); + parent::tearDown(); + } + + #[Test] + public function theSecondWorkerWaitsThenPersistsItsFullPendingDeclaration(): void + { + $workerA = $this->handler($this->main, $this->a, 10); + $other = new EntityManager($this->b, $this->main->getConfiguration()); + $workerB = $this->handler($other, $this->b, 20); + $this->a->beginTransaction(); + $first = $workerA($this->consume(10)); + self::assertNotNull($first->declaration); + self::assertSame('confirmed', $first->declaration->status); + $this->b->executeStatement("SET lock_timeout='150ms'"); + + try { + $workerB($this->consume(20)); + self::fail('The second worker must wait until the first complete stock issue commits.'); + } catch (DriverException $e) { + self::assertSame('55P03', $e->getSQLState()); + } + $this->a->commit(); + $pending = $workerB($this->consume(20)); + self::assertNotNull($pending->declaration); + self::assertSame('received_pending', $pending->declaration->status); + self::assertSame('insufficient_stock', $pending->declaration->reason); + self::assertSame('1.000000', $pending->declaration->quantity); + $replay = $workerB($this->consume(20)); + self::assertTrue($replay->replayed); + self::assertSame('0.000000', $this->b->fetchOne('SELECT quantity FROM inventory_balances WHERE organization_id=?', [self::ORG])); + self::assertSame(1, $this->b->fetchOne("SELECT COUNT(*) FROM inventory_movements WHERE organization_id=? AND kind='consumption'", [self::ORG])); + self::assertSame(2, $this->b->fetchOne('SELECT COUNT(*) FROM inventory_declarations WHERE organization_id=?', [self::ORG])); + } + + private function consume(int $n): ApplyInventoryStockCommand + { + return new ApplyInventoryStockCommand(self::ORG, 'bec10000-0000-4000-8000-000000000005', 'consumption', 'bec10000-0000-4000-8000-' . str_pad((string) $n, 12, '0', STR_PAD_LEFT), self::PART, self::WAREHOUSE, '1', new DateTimeImmutable('2026-10-06T10:00:00+00:00'), 'bec10000-0000-4000-8000-' . str_pad((string) ($n + 100), 12, '0', STR_PAD_LEFT)); + } + + private function handler(EntityManagerInterface $em, Connection $connection, int $counter): ApplyInventoryStockHandler + { + $tx = $this->createStub(TransactionManagerPort::class); + $tx->method('transactional')->willReturnCallback(static fn (callable $call): mixed => $connection->transactional(static fn (Connection $active): mixed => $call())); + $ids = $this->createStub(UuidGeneratorPort::class); + $ids->method('generate')->willReturnCallback(static function () use (&$counter): string {return 'bec20000-0000-4000-8000-' . str_pad((string) ++$counter, 12, '0', STR_PAD_LEFT); }); + $currency = $this->createStub(MaintenanceCurrencyPort::class); + $currency->method('lock')->willReturn('EUR'); + $currency->method('forOrganization')->willReturn('EUR'); + $context = $this->createStub(InterventionInventoryContextPort::class); + $context->method('validate')->willReturn(new InventoryInterventionContext(false)); + + return new ApplyInventoryStockHandler(new InventoryRepository($em), $tx, $ids, $currency, $context); + } + + private function clean(): void + { + foreach (['inventory_operation_receipts', 'inventory_declarations', 'inventory_movements', 'inventory_balances', 'inventory_parts', 'inventory_warehouses'] as $table) { + $this->a->executeStatement('DELETE FROM ' . $table . ' WHERE organization_id=?', [self::ORG]); + } + $this->main->clear(); + } +} diff --git a/tests/Integration/Maintenance/Infrastructure/Adapter/Equipment/MaintenanceOperationsDueAdapterTest.php b/tests/Integration/Maintenance/Infrastructure/Adapter/Equipment/MaintenanceOperationsDueAdapterTest.php new file mode 100644 index 000000000..3a3b02559 --- /dev/null +++ b/tests/Integration/Maintenance/Infrastructure/Adapter/Equipment/MaintenanceOperationsDueAdapterTest.php @@ -0,0 +1,111 @@ +fixture('operational', false); + $store->save($this->plan('control', self::EQ, new DateTimeImmutable('2027-01-01Z'))); + $store->save($this->plan('maintenance', self::EQ, new DateTimeImmutable('2026-10-01Z'))); + $result = $adapter->forEquipment(self::ORG, [self::EQ, self::OTHER]); + self::assertArrayNotHasKey(self::OTHER, $result); + self::assertSame('plans', $result[self::EQ]->engineMode); + self::assertSame('up_to_date', $result[self::EQ]->controlDueStatus); + self::assertSame('overdue', $result[self::EQ]->serviceDueStatus); + self::assertSame('2027-01-01', $result[self::EQ]->controlNextDueAt?->format('Y-m-d')); + self::assertSame('2026-10-01', $result[self::EQ]->serviceNextDueAt?->format('Y-m-d')); + } + + #[Test] + public function anUninitializedActiveOperationIsDueRatherThanUpToDate(): void + { + [$adapter, $store] = $this->fixture('operational', false); + $plan = $this->plan('control', self::EQ, new DateTimeImmutable('2027-01-01Z')); + $plan->nextDueAt = null; + $store->save($plan); + $result = $adapter->forEquipment(self::ORG, [self::EQ]); + self::assertSame('overdue', $result[self::EQ]->controlDueStatus); + self::assertNull($result[self::EQ]->controlNextDueAt); + self::assertSame('unscheduled', $result[self::EQ]->serviceDueStatus); + } + + #[Test] + #[DataProvider('suspensions')] + public function inactiveLifecycleDoesNotExposeFutureWorkAsScheduled(string $status, bool $archived): void + { + [$adapter, $store] = $this->fixture($status, $archived); + $store->save($this->plan('control', self::EQ, new DateTimeImmutable('2027-01-01Z'))); + $store->save($this->plan('maintenance', self::EQ, new DateTimeImmutable('2026-10-01Z'))); + $result = $adapter->forEquipment(self::ORG, [self::EQ]); + self::assertSame('unscheduled', $result[self::EQ]->controlDueStatus); + self::assertSame('unscheduled', $result[self::EQ]->serviceDueStatus); + self::assertNull($result[self::EQ]->controlNextDueAt); + self::assertNull($result[self::EQ]->serviceNextDueAt); + self::assertNotNull($store->find(self::ORG, $this->plan('control', self::EQ, new DateTimeImmutable('2027-01-01Z'))->id)); + } + + /** + * @return iterable + */ + public static function suspensions(): iterable + { + yield 'retired equipment' => ['decommissioned', false]; + yield 'archived site' => ['operational', true]; + } + + /** + * @return array{MaintenanceOperationsDueAdapter, MaintenancePlanRepository} + */ + private function fixture(string $status, bool $archived): array + { + self::bootKernel(); + $connection = static::getContainer()->get('doctrine.dbal.main_connection'); + self::assertInstanceOf(Connection::class, $connection); + $store = new MaintenancePlanRepository($connection); + $now = new DateTimeImmutable('2026-10-06Z'); + $store->activateEngine(self::ORG, $now); + $equipment = $this->createStub(MaintenanceEquipmentDirectoryPort::class); + $equipment->method('findEquipmentByIds')->willReturn([new TrackableEquipment(self::EQ, self::ORG, null, 'fire_extinguisher', $status), new TrackableEquipment(self::OTHER, self::OTHER, null, 'fire_extinguisher', 'operational')]); + $facilities = $this->createStub(MaintenanceFacilityLifecyclePort::class); + $facilities->method('isArchived')->willReturn($archived); + $policies = $this->createStub(MaintenanceCompliancePolicyPort::class); + $policies->method('compliancePolicy')->willReturn(new MaintenanceCompliancePolicy(['fire_extinguisher' => 'P1Y'], 30)); + $clock = $this->createStub(ClockPort::class); + $clock->method('now')->willReturn($now); + + return [new MaintenanceOperationsDueAdapter($connection, $equipment, $facilities, $policies, $clock), $store]; + } + + private function plan(string $kind, string $equipmentId, DateTimeImmutable $due): MaintenancePlanState + { + $now = new DateTimeImmutable('2026-10-06Z'); + $id = 'control' === $kind ? '780e8400-e29b-41d4-a716-446655447010' : '780e8400-e29b-41d4-a716-446655447011'; + + return new MaintenancePlanState($id, self::ORG, $equipmentId, null, 'fire_extinguisher', $kind . ' operation', $kind, 'P1Y', 'fixed', $due, $due, true, null, null, null, $now, $now); + } +} diff --git a/tests/Integration/Maintenance/Infrastructure/Persistence/Doctrine/Repository/MaintenancePlanRepositoryTest.php b/tests/Integration/Maintenance/Infrastructure/Persistence/Doctrine/Repository/MaintenancePlanRepositoryTest.php new file mode 100644 index 000000000..da6ea00e3 --- /dev/null +++ b/tests/Integration/Maintenance/Infrastructure/Persistence/Doctrine/Repository/MaintenancePlanRepositoryTest.php @@ -0,0 +1,248 @@ +a = DriverManager::getConnection(['url' => $url]); + $this->b = DriverManager::getConnection(['url' => $url]); + $this->store = new MaintenancePlanRepository($this->a); + $this->sender = $this->transport($this->a); + $this->receiver = $this->transport($this->b); + $this->sender->setup(); + $this->clean(); + } + + protected function tearDown(): void + { + foreach ([$this->a, $this->b] as $connection) { + while ($connection->isTransactionActive()) { + $connection->rollBack(); + } + } + $this->clean(); + $this->a->close(); + $this->b->close(); + parent::tearDown(); + } + + #[Test] + public function localMonthEndCalendarSurvivesUtcStorageAndDst(): void + { + $plan = $this->plan(); + $this->store->save($plan); + $read = new MaintenancePlanRepository($this->b)->find(self::ORG, self::PLAN); + self::assertNotNull($read); + self::assertSame('Europe/Paris', $read->calendarTimezone); + self::assertSame('2027-01-31T00:00:00+01:00', $read->anchorAt?->format(DATE_ATOM)); + self::assertSame('2027-01-30 23:00:00', $this->b->fetchOne('SELECT anchor_at FROM maintenance_plans WHERE id = ?', [self::PLAN])); + } + + /** + * Method openOccurrenceOutputPreservesCalendarAfterUtcReload + * + * Reloads real PostgreSQL rows before translating calendar dates and historical instants. + * + * @access public + * + * @param string $cadenceMode the fixed or historical calendar mode + * @param string $dueAt the original occurrence date with its explicit offset + * @param string $storedDueAt the expected UTC timestamp persisted in PostgreSQL + * @param string $expectedDueAt the expected HTTP timestamp after reloading + * + * @return void + */ + #[Test] + #[DataProvider('occurrenceCalendarCases')] + public function openOccurrenceOutputPreservesCalendarAfterUtcReload(string $cadenceMode, string $dueAt, string $storedDueAt, string $expectedDueAt): void + { + $plan = $this->plan(); + $plan->cadenceMode = $cadenceMode; + $plan->anchorAt = $plan->nextDueAt = new DateTimeImmutable($dueAt); + $occurrence = $this->occurrence(); + $occurrence->dueAt = new DateTimeImmutable($dueAt); + $this->store->save($plan); + $this->store->saveOccurrence($occurrence); + + $reader = new MaintenancePlanRepository($this->b); + $persistedPlan = $reader->find(self::ORG, self::PLAN); + $persistedOccurrence = $reader->openOccurrence(self::ORG, self::PLAN); + self::assertNotNull($persistedPlan); + self::assertNotNull($persistedOccurrence); + self::assertSame('Europe/Paris', $persistedPlan->calendarTimezone); + self::assertSame('UTC', $persistedOccurrence->dueAt->getTimezone()->getName()); + self::assertSame($storedDueAt, $this->b->fetchOne('SELECT due_at FROM maintenance_occurrences WHERE id = ?', [self::OCCURRENCE])); + + $output = new MaintenancePlanOutputFactory()->fromDetails(new MaintenancePlanDetails($persistedPlan, $persistedOccurrence)); + + self::assertSame($expectedDueAt, $output->openOccurrence?->dueAt); + if ('fixed' === $cadenceMode) { + self::assertSame($expectedDueAt, $output->nextDueAt); + } + } + + /** + * Method occurrenceCalendarCases + * + * Covers both Paris offsets and the legacy timestamp transport contract. + * + * @access public + * + * @return iterable calendar transport examples + */ + public static function occurrenceCalendarCases(): iterable + { + yield 'fixed winter calendar' => ['fixed', '2027-01-31T00:00:00+01:00', '2027-01-30 23:00:00', '2027-01-31T00:00:00+01:00']; + yield 'fixed summer calendar' => ['fixed', '2027-03-31T00:00:00+02:00', '2027-03-30 22:00:00', '2027-03-31T00:00:00+02:00']; + yield 'legacy instant' => ['legacy', '2027-01-31T14:15:00+00:00', '2027-01-31 14:15:00', '2027-01-31T14:15:00+00:00']; + } + + #[Test] + public function occurrenceAndOutboxRollBackTogetherAndBecomeVisibleOnlyAfterCommit(): void + { + $ids = $this->createStub(UuidFactory::class); + $ids->method('generateRaw')->willReturn(self::SECOND); + $dispatcher = new TransactionalEventDispatcher($this->a, $this->sender, $ids, $this->createStub(CurrentActorPort::class)); + + try { + $this->store->synchronized(self::ORG, function () use ($dispatcher): void { + $this->store->save($this->plan()); + $this->store->saveOccurrence($this->occurrence()); + $dispatcher->dispatch(new MaintenanceCampaignGeneratedEvent(self::ORG, self::SECOND, 1, self::SECOND)); + self::assertSame(0, $this->b->fetchOne('SELECT COUNT(*) FROM maintenance_occurrences WHERE organization_id = ?', [self::ORG])); + self::assertSame(0, $this->receiver->getMessageCount()); + + throw new RuntimeException('Simulated post-reservation failure'); + }); + } catch (RuntimeException $exception) { + self::assertSame('Simulated post-reservation failure', $exception->getMessage()); + } + self::assertNull(new MaintenancePlanRepository($this->b)->find(self::ORG, self::PLAN)); + self::assertSame(0, $this->receiver->getMessageCount()); + $this->store->synchronized(self::ORG, function () use ($dispatcher): void { + $this->store->save($this->plan()); + $this->store->saveOccurrence($this->occurrence()); + $dispatcher->dispatch(new MaintenanceCampaignGeneratedEvent(self::ORG, self::SECOND, 1, self::SECOND)); + }); + self::assertSame(1, $this->receiver->getMessageCount()); + self::assertSame(self::OCCURRENCE, new MaintenancePlanRepository($this->b)->openOccurrence(self::ORG, self::PLAN)?->id); + } + + #[Test] + public function historicalWorkersAndThePlansEngineHoldTheSameOrganizationLock(): void + { + $legacy = new MaintenanceScheduleLockAdapter($this->a); + $other = new MaintenancePlanRepository($this->b); + $this->b->executeStatement("SET lock_timeout = '100ms'"); + $this->a->beginTransaction(); + $legacy->synchronized(self::ORG, self::SECOND, static fn (): bool => true); + + try { + $other->synchronized(self::ORG, static fn (): bool => true); + self::fail('The concurrent engine must wait on the legacy worker'); + } catch (DriverException $exception) { + self::assertSame('55P03', $exception->getSQLState()); + } + $other->synchronized(self::SECOND, static fn (): bool => true); + $this->a->commit(); + $other->synchronized(self::ORG, static fn (): bool => true); + } + + #[Test] + public function theDatabaseRejectsTwoOpenOccurrencesForOneOperation(): void + { + $this->store->save($this->plan()); + $this->store->saveOccurrence($this->occurrence()); + $duplicate = $this->occurrence(); + $duplicate->id = self::SECOND; + + try { + new MaintenancePlanRepository($this->b)->saveOccurrence($duplicate); + self::fail('Expected the unique open-occurrence constraint'); + } catch (UniqueConstraintViolationException) { + self::assertSame(self::OCCURRENCE, $this->store->openOccurrence(self::ORG, self::PLAN)?->id); + } + } + + private function plan(): MaintenancePlanState + { + $now = new DateTimeImmutable('2026-10-06Z'); + $due = new DateTimeImmutable('2027-01-31T00:00:00', new DateTimeZone('Europe/Paris')); + + return new MaintenancePlanState(self::PLAN, self::ORG, self::SECOND, null, 'fire_extinguisher', 'Monthly control', 'control', 'P1M', 'fixed', $due, $due, true, null, null, null, $now, $now, 'Europe/Paris'); + } + + private function occurrence(): MaintenanceOccurrenceState + { + $now = new DateTimeImmutable('2026-10-06Z'); + + return new MaintenanceOccurrenceState(self::OCCURRENCE, self::PLAN, self::ORG, new DateTimeImmutable('2027-01-31T00:00:00Z'), 'open', 0, null, null, null, $now, $now); + } + + private function transport(Connection $connection): DoctrineTransport + { + $registry = $this->createStub(ConnectionRegistry::class); + $registry->method('getConnection')->willReturn($connection); + $transport = new DoctrineTransportFactory($registry)->createTransport('doctrine://main?queue_name=' . self::QUEUE . '&auto_setup=false', ['use_notify' => false], new PhpSerializer()); + self::assertInstanceOf(DoctrineTransport::class, $transport); + + return $transport; + } + + private function clean(): void + { + $this->a->delete('messenger_messages', ['queue_name' => self::QUEUE]); + foreach (['maintenance_operation_receipts', 'maintenance_occurrences', 'maintenance_plans', 'maintenance_engines'] as $table) { + $this->a->delete($table, ['organization_id' => self::ORG]); + } + } +} diff --git a/tests/Integration/MaintenanceCost/Infrastructure/Adapter/Currency/MaintenanceCurrencyAdapterTest.php b/tests/Integration/MaintenanceCost/Infrastructure/Adapter/Currency/MaintenanceCurrencyAdapterTest.php new file mode 100644 index 000000000..7d3c24a28 --- /dev/null +++ b/tests/Integration/MaintenanceCost/Infrastructure/Adapter/Currency/MaintenanceCurrencyAdapterTest.php @@ -0,0 +1,68 @@ +connection(); + $adapter = new MaintenanceCurrencyAdapter($db); + self::assertSame('EUR', $adapter->forOrganization(self::ORG)); + self::assertFalse($adapter->read(self::ORG)->locked); + self::assertSame(0, $this->countSettings($db)); + } + + #[Test] + public function firstFactFreezesTheConfiguredCurrencyAndReplayKeepsIt(): void + { + $db = $this->connection(); + $adapter = new MaintenanceCurrencyAdapter($db); + $adapter->synchronized(self::ORG, static fn () => $adapter->save(new MaintenanceCurrencySnapshot(self::ORG, 'USD', false))); + $db->transactional(static fn () => self::assertSame('USD', $adapter->lock(self::ORG))); + $db->transactional(static fn () => self::assertSame('USD', $adapter->lock(self::ORG))); + self::assertTrue($adapter->read(self::ORG)->locked); + self::assertSame(1, $this->countSettings($db)); + } + + #[Test] + public function rollbackOfTheFirstFactDoesNotLeaveItsCurrencyLock(): void + { + $db = $this->connection(); + $adapter = new MaintenanceCurrencyAdapter($db); + $db->beginTransaction(); + $adapter->lock(self::ORG); + $db->rollBack(); + self::assertFalse($adapter->read(self::ORG)->locked); + self::assertSame(0, $this->countSettings($db)); + } + + private function connection(): Connection + { + self::bootKernel(); + /** @var Connection $connection */ + $connection = self::getContainer()->get('doctrine.dbal.main_connection'); + + return $connection; + } + + private function countSettings(Connection $db): int + { + /** @var int|numeric-string $count */ + $count = $db->fetchOne('SELECT COUNT(*) FROM maintenance_cost_currency_settings WHERE organization_id = :org', ['org' => self::ORG]); + + return (int) $count; + } +} diff --git a/tests/Integration/MaintenanceCost/Infrastructure/Adapter/Rate/MaintenanceRateAdapterTest.php b/tests/Integration/MaintenanceCost/Infrastructure/Adapter/Rate/MaintenanceRateAdapterTest.php new file mode 100644 index 000000000..fa0f3c003 --- /dev/null +++ b/tests/Integration/MaintenanceCost/Infrastructure/Adapter/Rate/MaintenanceRateAdapterTest.php @@ -0,0 +1,58 @@ +adapter(); + $first = new MaintenanceRateSnapshot('650e8400-e29b-41d4-a716-448040000011', self::MEMBER, '42.000001', 'EUR', '2026-01-01'); + $second = new MaintenanceRateSnapshot('650e8400-e29b-41d4-a716-448040000012', self::MEMBER, '43.000001', 'EUR', '2026-07-01'); + $adapter->synchronized(self::ORG, static function () use ($adapter, $first, $second): void { + $adapter->append(self::ORG, '650e8400-e29b-41d4-a716-448040000021', $first); + $adapter->append(self::ORG, '650e8400-e29b-41d4-a716-448040000022', $second); + }); + self::assertNull($adapter->forMember(self::ORG, self::MEMBER, '2025-12-31')); + self::assertSame('42.000001', $adapter->forMember(self::ORG, self::MEMBER, '2026-06-30')?->hourlyAmount); + self::assertSame('43.000001', $adapter->forMember(self::ORG, self::MEMBER, '2026-07-01')?->hourlyAmount); + self::assertNull($adapter->forMember('650e8400-e29b-41d4-a716-448040000099', self::MEMBER, '2026-07-01')); + self::assertSame(2, $adapter->count(self::ORG)); + self::assertCount(1, $adapter->list(self::ORG, 1, 1)); + } + + #[Test] + public function snapshotsUseTheOrganizationsCommonConfiguredCurrency(): void + { + $adapter = $this->adapter(); + /** @var Connection $db */ + $db = self::getContainer()->get('doctrine.dbal.main_connection'); + $db->insert('maintenance_cost_currency_settings', ['organization_id' => self::ORG, 'currency' => 'USD', 'locked' => false], ['locked' => \Doctrine\DBAL\ParameterType::BOOLEAN]); + $rate = new MaintenanceRateSnapshot('650e8400-e29b-41d4-a716-448040000011', self::MEMBER, '42.000001', 'USD', '2026-01-01'); + $adapter->synchronized(self::ORG, static fn () => $adapter->append(self::ORG, '650e8400-e29b-41d4-a716-448040000021', $rate)); + self::assertSame('USD', $adapter->forMember(self::ORG, self::MEMBER, '2026-01-01')?->currency); + } + + private function adapter(): MaintenanceRateAdapter + { + self::bootKernel(); + /** @var Connection $connection */ + $connection = self::getContainer()->get('doctrine.dbal.main_connection'); + + return new MaintenanceRateAdapter($connection); + } +} diff --git a/tests/Integration/MaintenanceCost/Infrastructure/Publication/MaintenanceCostPublicationTest.php b/tests/Integration/MaintenanceCost/Infrastructure/Publication/MaintenanceCostPublicationTest.php new file mode 100644 index 000000000..aae58932d --- /dev/null +++ b/tests/Integration/MaintenanceCost/Infrastructure/Publication/MaintenanceCostPublicationTest.php @@ -0,0 +1,349 @@ +fixture(true); + foreach (['980e8400-e29b-41d4-a716-449130000090', '980e8400-e29b-41d4-a716-449130000091'] as $id) { + $expense = new MaintenanceExpenseRecord(); + $expense->id = $id; + $expense->organizationId = self::ORG; + $expense->interventionId = self::ORDER; + $expense->clientId = $id; + $expense->amount = '999999999999999999.000000'; + $expense->currency = 'EUR'; + $expense->description = 'Large exact validated external resource'; + $expense->createdBy = self::MEMBER; + $expense->payloadHash = 'publication-regression'; + $expense->incurredAt = $expense->createdAt = new DateTimeImmutable('2026-10-01T12:00:00Z'); + $manager->persist($expense); + } + $manager->flush(); + self::assertTrue($this->publisher()->publish(self::PUBLICATION)); + $snapshot = $this->costs()->snapshot(self::ORG, self::ORDER); + self::assertNotNull($snapshot); + self::assertSame('2000000000000000048.000000', $snapshot->totals->total); + self::assertSame('2000000000000000048.000000', $snapshot->totals->knownTotal); + self::assertTrue($snapshot->totals->complete); + self::assertSame($snapshot->totals->total, $this->projection()->view(self::ORG, self::ORDER)->current->total); + } + + public function testDirectMaterialIdentityIsFrozenAndItsLaterReturnUsesTheOriginalClient(): void + { + $manager = $this->fixture(true); + $now = new DateTimeImmutable('2026-10-01T12:00:00Z'); + $customer = new CustomerRecord(); + $customer->id = '980e8400-e29b-41d4-a716-449130000070'; + $customer->organizationId = self::ORG; + $customer->name = 'Original client'; + $customer->createdAt = $customer->updatedAt = $now; + $manager->persist($customer); + $site = new FacilityRecord(); + $site->id = '980e8400-e29b-41d4-a716-449130000071'; + $site->organization = $manager->getReference(OrganizationRecord::class, self::ORG); + $site->type = 'site'; + $site->name = 'Original site'; + $site->customerId = $customer->id; + $site->createdAt = $site->updatedAt = $now; + $manager->persist($site); + $equipment = new EquipmentRecord(); + $equipment->id = '980e8400-e29b-41d4-a716-449130000072'; + $equipment->organization = $site->organization; + $equipment->facilityId = $site->id; + $equipment->type = 'extinguisher'; + $equipment->name = 'Original asset'; + $equipment->createdAt = $equipment->updatedAt = $now; + $manager->persist($equipment); + $this->movement($manager, self::MOVEMENT, '-1.000000', '-10.000000'); + $manager->flush(); + $movement = $manager->find(InventoryMovementRecord::class, self::MOVEMENT); + self::assertInstanceOf(InventoryMovementRecord::class, $movement); + $movement->workItemId = null; + $movement->equipmentId = $equipment->id; + $manager->flush(); + self::assertTrue($this->publisher()->publish(self::PUBLICATION)); + $snapshot = $this->costs()->snapshot(self::ORG, self::ORDER); + self::assertNotNull($snapshot); + $original = $snapshot->totals->items[1]; + self::assertNotNull($original->allocation); + self::assertSame('captured', $original->allocation['identityState']); + self::assertSame(['id' => $site->id, 'name' => 'Original site'], $original->allocation['site']); + self::assertSame(['id' => $customer->id, 'name' => 'Original client'], $original->allocation['customer']); + $customer->name = 'Current renamed client'; + $site->name = 'Current renamed site'; + $equipment->name = 'Current renamed asset'; + $return = new InventoryMovementRecord(); + $return->id = '980e8400-e29b-41d4-a716-449130000073'; + $return->organizationId = self::ORG; + $return->warehouseId = self::WAREHOUSE; + $return->partId = self::PART; + $return->kind = 'return'; + $return->quantity = '0.500000'; + $return->unitCost = '10.000000'; + $return->totalValue = '5.000000'; + $return->currency = 'EUR'; + $return->reason = 'Unused part returned after publication'; + $return->actorId = self::MEMBER; + $return->occurredAt = new DateTimeImmutable('2026-10-02T12:00:00Z'); + $return->interventionId = self::ORDER; + $return->equipmentId = $equipment->id; + $return->correctionOf = self::MOVEMENT; + $return->late = true; + $manager->persist($return); + $manager->flush(); + $current = $this->projection()->view(self::ORG, self::ORDER); + self::assertSame('55.000000', $current->current->total); + self::assertSame('-5.000000', $current->current->items[2]->amount); + self::assertSame($original->allocation, $current->current->items[2]->allocation); + self::assertSame('60.000000', $current->frozen?->totals->total); + self::assertSame($original->allocation, $this->costs()->snapshot(self::ORG, self::ORDER)?->totals->items[1]->allocation); + } + + public function testPublicationFreezesTimeMaterialsAndBudgetThenCorrectionsOnlyChangeCurrentCosts(): void + { + $manager = $this->fixture(true); + $costs = $this->costs(); + $costs->savePlanning(self::ORG, self::ORDER, new MaintenanceCostPlanning('200.000000', 60, [], 1)); + $this->movement($manager, self::MOVEMENT, '-1.000000', '-10.000000'); + $manager->flush(); + $publication = $this->publisher(); + self::assertTrue($publication->publish(self::PUBLICATION)); + $frozen = $costs->snapshot(self::ORG, self::ORDER); + self::assertNotNull($frozen); + self::assertSame('60.000000', $frozen->totals->total); + self::assertSame('200.000000', $frozen->planning?->plannedBudget); + self::assertCount(2, $frozen->totals->items); + $dossier = $manager->getConnection()->fetchOne('SELECT closure_snapshot FROM interventions WHERE id = ?', [self::ORDER]); + self::assertIsString($dossier); + self::assertStringNotContainsString('hourlyAmount', $dossier); + self::assertStringNotContainsString('knownTotal', $dossier); + self::assertStringNotContainsString('plannedBudget', $dossier); + + /** @var InterventionTimeEntryRepositoryPort $times */ + $times = self::getContainer()->get(InterventionTimeEntryRepositoryPort::class); + $corrected = new TimeEntry(self::ENTRY, self::TASK, self::MEMBER, '2026-10-01', 30, 'Inventory reconciliation')->correct(1, '2026-10-01', 60, 'Correction to one hour'); + $times->save($corrected, self::ORG, self::MEMBER); + $this->movement($manager, '980e8400-e29b-41d4-a716-449130000010', '-1.000000', '-5.000000', true); + $manager->flush(); + $current = $this->projection()->view(self::ORG, self::ORDER); + self::assertSame('115.000000', $current->current->total); + self::assertSame('60.000000', $current->frozen?->totals->total); + self::assertSame('time:' . self::ENTRY . ':1', $current->current->items[0]->correctionOf); + self::assertSame('publication:' . self::PUBLICATION, $current->current->items[2]->correctionOf); + self::assertSame($dossier, $manager->getConnection()->fetchOne('SELECT closure_snapshot FROM interventions WHERE id = ?', [self::ORDER])); + self::assertFalse($publication->publish(self::PUBLICATION)); + self::assertSame('60.000000', $costs->snapshot(self::ORG, self::ORDER)?->totals->total); + self::assertSame('EUR', $manager->getConnection()->fetchOne('SELECT currency FROM maintenance_cost_currency_settings WHERE organization_id = ?', [self::ORG])); + } + + public function testUnknownRateIsRetainedAsIncompleteAtClosure(): void + { + $this->fixture(false); + self::assertTrue($this->publisher()->publish(self::PUBLICATION)); + $snapshot = $this->costs()->snapshot(self::ORG, self::ORDER); + self::assertNotNull($snapshot); + self::assertNull($snapshot->totals->total); + self::assertSame('0.000000', $snapshot->totals->knownTotal); + self::assertFalse($snapshot->totals->complete); + } + + public function testReceivedUnresolvedStockDeclarationRollsBackPublicationAndSnapshot(): void + { + $manager = $this->fixture(true); + /** @var InventoryStorePort $stock */ + $stock = self::getContainer()->get(InventoryStorePort::class); + $stock->saveDeclaration(new ConsumptionDeclaration('980e8400-e29b-41d4-a716-449130000011', self::ORG, self::PART, self::WAREHOUSE, '2.000000', self::ORDER, self::TASK, null, self::MEMBER, new DateTimeImmutable('2026-10-01T12:00:00Z'), 'received_pending', 'insufficient_stock', null)); + + try { + $this->publisher()->publish(self::PUBLICATION); + self::fail('An unresolved physical declaration must block publication.'); + } catch (InventoryPublicationBlockedException) { + self::assertSame('submitted', $manager->getConnection()->fetchOne('SELECT status FROM interventions WHERE id = ?', [self::ORDER])); + self::assertNull($manager->getConnection()->fetchOne('SELECT closure_snapshot FROM interventions WHERE id = ?', [self::ORDER])); + self::assertSame(0, $this->countRows($manager, 'maintenance_cost_snapshots')); + self::assertSame('received_pending', $manager->getConnection()->fetchOne('SELECT status FROM inventory_declarations WHERE id = ?', ['980e8400-e29b-41d4-a716-449130000011'])); + } + } + + public function testFailureAfterCostCaptureRollsBackThePrivateSnapshotAndCurrencyLock(): void + { + $manager = $this->fixture(true, true); + + try { + $this->publisher()->publish(self::PUBLICATION); + self::fail('A failed dossier capture must roll back the whole publication.'); + } catch (RuntimeException $error) { + self::assertSame('Dossier capture unavailable', $error->getMessage()); + self::assertSame('submitted', $manager->getConnection()->fetchOne('SELECT status FROM interventions WHERE id = ?', [self::ORDER])); + self::assertSame(0, $this->countRows($manager, 'maintenance_cost_snapshots')); + self::assertSame(0, $this->countRows($manager, 'maintenance_cost_currency_settings')); + } + } + + private function fixture(bool $rate, bool $snapshotFailure = false): EntityManagerInterface + { + self::bootKernel(); + if ($snapshotFailure) { + $sites = $this->createStub(InterventionSiteCustomerSnapshotPort::class); + $sites->method('snapshot')->willThrowException(new RuntimeException('Dossier capture unavailable')); + self::getContainer()->set(InterventionSiteCustomerSnapshotPort::class, $sites); + } + /** @var EntityManagerInterface $manager */ + $manager = self::getContainer()->get('doctrine.orm.main_entity_manager'); + $now = new DateTimeImmutable('2026-10-01T10:00:00Z'); + $organization = new OrganizationRecord(); + $organization->id = self::ORG; + $organization->name = 'Private maintenance costing'; + $organization->slug = 'private-maintenance-costing'; + $organization->ownerUserId = self::MEMBER; + $organization->createdByUserId = self::MEMBER; + $organization->status = 'active'; + $organization->isActive = true; + $organization->createdAt = $now; + $organization->updatedAt = $now; + $manager->persist($organization); + $order = new InterventionRecord(); + $order->id = self::ORDER; + $order->organization = $organization; + $order->number = 1; + $order->type = 'inventory'; + $order->name = 'Inventory and time reconciliation'; + $order->status = 'submitted'; + $order->priority = 'normal'; + $order->responsibleId = self::MEMBER; + $order->revision = 1; + $order->createdAt = $now; + $order->updatedAt = $now; + $manager->persist($order); + $task = new InterventionWorkItemRecord(); + $task->id = self::TASK; + $task->intervention = $order; + $task->action = 'inventory'; + $task->target = 'Inventory reconciliation'; + $task->status = 'completed'; + $task->source = 'planned'; + $task->required = true; + $task->createdAt = $now; + $task->updatedAt = $now; + $manager->persist($task); + $manager->flush(); + /** @var InterventionTimeEntryRepositoryPort $times */ + $times = self::getContainer()->get(InterventionTimeEntryRepositoryPort::class); + $times->save(new TimeEntry(self::ENTRY, self::TASK, self::MEMBER, '2026-10-01', 30, 'Inventory reconciliation'), self::ORG, self::MEMBER); + if ($rate) { + /** @var MaintenanceRateStorePort $rates */ + $rates = self::getContainer()->get(MaintenanceRateStorePort::class); + $rates->synchronized(self::ORG, fn () => $rates->append(self::ORG, '980e8400-e29b-41d4-a716-449130000012', new MaintenanceRateSnapshot('980e8400-e29b-41d4-a716-449130000013', self::MEMBER, '100.000000', 'EUR', '2026-01-01'))); + } + $this->publisher()->createOrGetPending(self::PUBLICATION, self::ORDER, 1); + $this->publisher()->markProcessing(self::PUBLICATION); + + return $manager; + } + + private function movement(EntityManagerInterface $manager, string $id, string $quantity, string $value, bool $late = false): void + { + $movement = new InventoryMovementRecord(); + $movement->id = $id; + $movement->organizationId = self::ORG; + $movement->warehouseId = self::WAREHOUSE; + $movement->partId = self::PART; + $movement->kind = 'consumption'; + $movement->quantity = $quantity; + $movement->unitCost = $late ? '5.000000' : '10.000000'; + $movement->totalValue = $value; + $movement->currency = 'EUR'; + $movement->reason = $late ? 'Delayed synchronized consumption' : 'Actual material consumption'; + $movement->actorId = self::MEMBER; + $movement->occurredAt = new DateTimeImmutable('2026-10-01T12:00:00Z'); + $movement->interventionId = self::ORDER; + $movement->workItemId = self::TASK; + $movement->late = $late; + $manager->persist($movement); + } + + private function publisher(): DoctrinePublicationAdapter + { + /** @var DoctrinePublicationAdapter $publisher */ + $publisher = self::getContainer()->get(DoctrinePublicationAdapter::class); + + return $publisher; + } + + private function countRows(EntityManagerInterface $manager, string $table): int + { + $value = $manager->getConnection()->fetchOne('SELECT COUNT(*) FROM ' . $table . ' WHERE organization_id = ?', [self::ORG]); + if (is_int($value)) { + return $value; + } + self::assertIsString($value); + self::assertIsNumeric($value); + + return (int) $value; + } + + private function costs(): MaintenanceCostStorePort + { + /** @var MaintenanceCostStorePort $costs */ + $costs = self::getContainer()->get(MaintenanceCostStorePort::class); + + return $costs; + } + + private function projection(): MaintenanceCostProjection + { + /** @var MaintenanceCostProjection $projection */ + $projection = self::getContainer()->get(MaintenanceCostProjection::class); + + return $projection; + } +} diff --git a/tests/Integration/MaintenanceExport/Infrastructure/Persistence/Doctrine/Repository/MaintenanceExportConcurrencyTest.php b/tests/Integration/MaintenanceExport/Infrastructure/Persistence/Doctrine/Repository/MaintenanceExportConcurrencyTest.php new file mode 100644 index 000000000..2485103b7 --- /dev/null +++ b/tests/Integration/MaintenanceExport/Infrastructure/Persistence/Doctrine/Repository/MaintenanceExportConcurrencyTest.php @@ -0,0 +1,141 @@ +get('doctrine.dbal.main_connection'); + self::assertInstanceOf(Connection::class, $main); + $this->a = $main; + $this->b = DriverManager::getConnection($main->getParams()); + $this->clean(); + self::assertNotSame($this->a->fetchOne('SELECT pg_backend_pid()'), $this->b->fetchOne('SELECT pg_backend_pid()')); + } + + protected function tearDown(): void + { + foreach ([$this->a, $this->b] as $connection) { + while ($connection->isTransactionActive()) { + $connection->rollBack(); + } + } + $this->clean(); + $this->b->close(); + parent::tearDown(); + } + + #[Test] + public function sameOrganizationWaitsAndReplaysTheCommittedReceiptWithoutBlockingAnotherOrganization(): void + { + $first = new MaintenanceExportRepository($this->a); + $second = new MaintenanceExportRepository($this->b); + $document = $this->document(self::DOCUMENT, self::ORG); + $receipt = new ExportOperation(self::ORG, self::ACTOR, self::OPERATION, 'generate', hash('sha256', 'stable source selection'), self::DOCUMENT, $document->projection()); + $this->b->executeStatement("SET lock_timeout = '150ms'"); + $first->synchronized(self::ORG, function () use ($first, $second, $document, $receipt): void { + $first->saveDocument($document); + $first->saveOperation($receipt); + self::assertNull($second->operation(self::ORG, self::ACTOR, self::OPERATION)); + $second->synchronized(self::OTHER_ORG, fn () => $second->saveDocument($this->document(self::OTHER_DOCUMENT, self::OTHER_ORG))); + + try { + $second->synchronized(self::ORG, static fn (): string => 'must wait'); + self::fail('Concurrent source capture must wait for the owning organization transaction.'); + } catch (DriverException $error) { + self::assertSame('55P03', $error->getSQLState()); + } + }); + $replayed = $second->synchronized(self::ORG, function () use ($second): ?ExportOperation { + return $second->operation(self::ORG, self::ACTOR, self::OPERATION); + }); + self::assertNotNull($replayed); + self::assertSame(self::DOCUMENT, $replayed->resourceId); + self::assertSame($receipt->fingerprint, $replayed->fingerprint); + self::assertEquals($receipt->result, $replayed->result); + self::assertSame(1, $second->countDocuments(self::ORG, true, null)); + self::assertSame(1, $second->countDocuments(self::OTHER_ORG, true, null)); + } + + #[Test] + public function directMutationRequiresAnAmbientMainTransaction(): void + { + self::assertFalse($this->b->isTransactionActive()); + $repository = new MaintenanceExportRepository($this->b); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Maintenance export writes require a main transaction.'); + $repository->saveDocument($this->document(self::DOCUMENT, self::ORG)); + } + + /** + * Method document + * + * @param string $id unique artifact identity + * @param string $organization owning scope + * + * @return ExportDocument small artifact for concurrency proof + */ + private function document(string $id, string $organization): ExportDocument + { + return new ExportDocument($id, $organization, self::ACTOR, 'initial', 'erp', false, [], null, null, null, new DateTimeImmutable('2026-10-07T10:00:00Z'), [], [], '{"version":1}', "id\r\n"); + } + + /** + * Method clean + * + * Clears only this fixture scope inside the suite's disposable PostgreSQL clone. + * + * @return void + */ + private function clean(): void + { + foreach (['maintenance_export_operations', 'maintenance_external_references', 'maintenance_export_documents'] as $table) { + $this->a->executeStatement('DELETE FROM ' . $table . ' WHERE organization_id IN (?, ?)', [self::ORG, self::OTHER_ORG]); + } + } + // #endregion +} diff --git a/tests/Integration/MaintenanceExport/Infrastructure/Persistence/Doctrine/Repository/MaintenanceExportRepositoryTest.php b/tests/Integration/MaintenanceExport/Infrastructure/Persistence/Doctrine/Repository/MaintenanceExportRepositoryTest.php new file mode 100644 index 000000000..265e12ed8 --- /dev/null +++ b/tests/Integration/MaintenanceExport/Infrastructure/Persistence/Doctrine/Repository/MaintenanceExportRepositoryTest.php @@ -0,0 +1,414 @@ +get('doctrine.dbal.main_connection'); + self::assertInstanceOf(Connection::class, $connection); + $this->connection = $connection; + $this->repository = new MaintenanceExportRepository($connection); + } + + #[Test] + public function retainedUtf8BytesAndTheirHashesSurvivePostgresqlRoundTrip(): void + { + $document = $this->document(); + $this->repository->synchronized(self::ORG, fn () => $this->repository->saveDocument($document)); + $saved = $this->repository->document(self::ORG, self::DOCUMENT); + self::assertNotNull($saved); + self::assertSame($document->jsonBytes, $saved->jsonBytes); + self::assertSame($document->csvBytes, $saved->csvBytes); + self::assertEquals($document->rows, $saved->rows); + self::assertEquals($document->baseline, $saved->baseline); + self::assertSame('2026-10-07T08:00:00+00:00', $saved->createdAt->format('c')); + self::assertNull($saved->costsComplete); + self::assertNull($saved->incompleteCostCount); + self::assertSame(hash('sha256', $document->jsonBytes), $this->connection->fetchOne('SELECT json_sha256 FROM maintenance_export_documents WHERE id = ?', [self::DOCUMENT])); + self::assertSame(hash('sha256', $document->csvBytes), $this->connection->fetchOne('SELECT csv_sha256 FROM maintenance_export_documents WHERE id = ?', [self::DOCUMENT])); + self::assertNull($saved->confirmation()); + self::assertSame(1, $saved->revision()); + } + + #[Test] + public function confirmationChangesOnlyItsOwnMetadataAndKeepsEveryArtifactField(): void + { + $this->persist($this->document()); + $before = $this->row(); + $saved = $this->repository->document(self::ORG, self::DOCUMENT); + self::assertNotNull($saved); + $saved->confirm(1, self::OPERATION, 'IMPORT-ERP-001', self::ACTOR, new DateTimeImmutable('2026-10-07T11:00:00Z')); + $this->persist($saved); + $after = $this->row(); + unset($before['revision'], $before['confirmation'], $after['revision'], $after['confirmation']); + self::assertSame($before, $after); + $confirmed = $this->repository->document(self::ORG, self::DOCUMENT); + self::assertNotNull($confirmed); + self::assertSame(2, $confirmed->revision()); + self::assertSame('IMPORT-ERP-001', $confirmed->confirmation()['externalImportReference'] ?? null); + $this->persist($confirmed); + self::assertSame(1, $this->repository->countDocuments(self::ORG, true, null)); + } + + #[Test] + public function immutableBytesCannotBeOverwrittenByAnotherDocumentObject(): void + { + $this->persist($this->document()); + $before = $this->row(); + + try { + $this->persist($this->document(json: '{"rewritten":true}')); + self::fail('A retained artifact must be immutable.'); + } catch (MaintenanceExportException $error) { + self::assertSame('conflict', $error->reason); + self::assertSame($before, $this->row()); + } + } + + #[Test] + public function aConfirmedImportCannotBeReplacedByAnotherReceipt(): void + { + $saved = $this->document(); + $saved->confirm(1, self::OPERATION, 'FIRST', self::ACTOR, new DateTimeImmutable('2026-10-07T11:00:00Z')); + $this->persist($saved); + $replacement = $this->document(); + $replacement->confirm(1, self::SECOND, 'SECOND', self::ACTOR, new DateTimeImmutable('2026-10-07T12:00:00Z')); + + try { + $this->persist($replacement); + self::fail('A retained acknowledgement must be immutable.'); + } catch (MaintenanceExportException $error) { + self::assertSame('conflict', $error->reason); + self::assertSame('FIRST', $this->repository->document(self::ORG, self::DOCUMENT)?->confirmation()['externalImportReference'] ?? null); + } + } + + #[Test] + public function organizationAndFinancialVisibilityApplyToBothPageAndCount(): void + { + $this->persist($this->document()); + $this->persist($this->document(id: self::SECOND, financial: true)); + $this->persist($this->document(id: self::THIRD, organization: self::OTHER_ORG, system: 'other_erp')); + self::assertNull($this->repository->document(self::OTHER_ORG, self::DOCUMENT)); + self::assertCount(1, $this->repository->documentSummaries(self::ORG, false, null, 0, 10)); + self::assertSame(1, $this->repository->countDocuments(self::ORG, false, null)); + self::assertCount(2, $this->repository->documentSummaries(self::ORG, true, 'erp', 0, 10)); + self::assertSame(2, $this->repository->countDocuments(self::ORG, true, 'erp')); + self::assertCount(0, $this->repository->documentSummaries(self::ORG, true, 'other_erp', 0, 10)); + self::assertSame(0, $this->repository->countDocuments(self::ORG, true, 'other_erp')); + self::assertCount(1, $this->repository->documentSummaries(self::ORG, true, null, 1, 1)); + $financial = $this->repository->document(self::ORG, self::SECOND); + self::assertNotNull($financial); + self::assertFalse($financial->costsComplete); + self::assertSame(1, $financial->incompleteCostCount); + } + + #[Test] + public function metadataProjectionMatchesVerifiedDocumentIncludingConfirmationAndUtf8ByteSizes(): void + { + $document = $this->document(financial: true); + $document->confirm(1, self::OPERATION, 'ERP-IMPORT', self::ACTOR, new DateTimeImmutable('2026-10-07T12:00:00Z')); + $this->persist($document); + $page = $this->repository->documentSummaries(self::ORG, true, 'erp', 0, 30); + $verified = $this->repository->document(self::ORG, self::DOCUMENT); + self::assertNotNull($verified); + self::assertCount(1, $page); + self::assertEquals($verified->projection(), $page[0]->projection()); + self::assertSame(strlen($document->jsonBytes), $page[0]->jsonSize); + self::assertSame(strlen($document->csvBytes), $page[0]->csvSize); + self::assertSame('import_confirmed', $page[0]->projection()['state']); + } + + #[Test] + public function nearLimitArchivePagesReturnOnlyMetadataWithBoundedPhpMemory(): void + { + $rows = []; + $baseline = []; + for ($index = 0; $index < 4000; ++$index) { + $rowId = ExportArtifact::rowId('large-archive-row-' . $index); + $row = ['id' => $rowId, 'kind' => 'prestation', 'change' => 'add', 'correctionOf' => null, 'interventionId' => self::SOURCE, 'publicationId' => self::SOURCE, 'publicationRevision' => 4, 'workItemId' => $rowId, 'sourceId' => $rowId, 'sourceRevision' => 4, 'equipmentId' => $rowId, 'equipmentName' => str_repeat('équipement ', 20), 'assetReference' => str_repeat('A', 100), 'siteId' => self::SOURCE, 'siteName' => str_repeat('S', 180), 'customerId' => self::SOURCE, 'customerName' => str_repeat('C', 180), 'equipmentReference' => str_repeat('E', 200), 'siteReference' => str_repeat('S', 200), 'customerReference' => str_repeat('C', 200), 'action' => 'repair', 'performedOn' => '2026-10-07T12:00:00Z', 'outcome' => 'successful', 'minutes' => 30, 'evidenceCount' => 1]; + $rows[] = $row; + $baseline['work:' . self::SOURCE . ':' . $rowId] = $row; + } + $json = ExportArtifact::json(['schemaVersion' => 1, 'rows' => $rows]); + $csv = ExportArtifact::csv($rows, false); + $jsonSize = strlen($json); + $csvSize = strlen($csv); + $jsonHash = hash('sha256', $json); + $csvHash = hash('sha256', $csv); + self::assertGreaterThan(7 * 1024 * 1024, $jsonSize); + self::assertGreaterThan(5 * 1024 * 1024, $csvSize); + for ($index = 0; $index < 31; ++$index) { + $document = new ExportDocument(ExportArtifact::rowId('large-archive-document-' . $index), self::ORG, self::ACTOR, 'initial', 'erp', false, [self::SOURCE], null, null, null, new DateTimeImmutable('2026-10-07T12:00:00Z'), $rows, $baseline, $json, $csv); + $this->persist($document); + } + unset($document, $rows, $row, $baseline, $json, $csv); + gc_collect_cycles(); + $queries = []; + $queryConnection = $this->createMock(Connection::class); + $queryConnection->expects($this->exactly(2))->method('fetchAllAssociative')->willReturnCallback(function (string $sql, array $params, array $types) use (&$queries): array { + /** @var array|string,mixed> $params */ + /** @var array $types */ + $projected = $this->connection->fetchAllAssociative($sql, $params, $types); + $queries[] = ['sql' => $sql, 'columns' => array_keys($projected[0])]; + + return $projected; + }); + $archive = new MaintenanceExportRepository($queryConnection); + $memory = memory_get_usage(); + $defaultPage = $archive->documentSummaries(self::ORG, false, 'erp', 0, 30); + $maximumPage = $archive->documentSummaries(self::ORG, false, 'erp', 0, 100); + self::assertLessThan(8 * 1024 * 1024, memory_get_usage() - $memory, 'Archive memory must depend on metadata, not the combined saved artifact sizes.'); + self::assertCount(30, $defaultPage); + self::assertCount(31, $maximumPage); + self::assertSame(4000, $defaultPage[0]->rowCount); + self::assertSame($jsonSize, $defaultPage[0]->jsonSize); + self::assertSame($csvSize, $defaultPage[0]->csvSize); + self::assertSame($jsonHash, $defaultPage[0]->jsonSha256); + self::assertSame($csvHash, $defaultPage[0]->csvSha256); + self::assertCount(2, $queries); + foreach ($queries as $query) { + self::assertStringNotContainsString('SELECT *', $query['sql']); + self::assertStringNotContainsString('baseline', $query['sql']); + self::assertStringContainsString('jsonb_array_length(rows) AS row_count', $query['sql']); + self::assertStringContainsString('octet_length(json_bytes) AS json_size', $query['sql']); + foreach (['rows', 'baseline', 'json_bytes', 'csv_bytes', 'immutable_hash'] as $heavyField) { + self::assertNotContains($heavyField, $query['columns'], 'PostgreSQL must not transfer artifact or correction payloads for a metadata page.'); + } + } + } + + #[Test] + public function aPrecedingExportCanHaveOnlyOneDirectAdjustment(): void + { + $this->persist($this->document()); + $this->persist($this->document(id: self::SECOND, adjustmentOf: self::DOCUMENT)); + self::assertTrue($this->repository->hasAdjustment(self::ORG, self::DOCUMENT)); + self::assertFalse($this->repository->hasAdjustment(self::OTHER_ORG, self::DOCUMENT)); + + try { + $this->persist($this->document(id: self::THIRD, adjustmentOf: self::DOCUMENT)); + self::fail('Two adjustments cannot share a preceding state.'); + } catch (MaintenanceExportException $error) { + self::assertSame('conflict', $error->reason); + self::assertSame(2, $this->repository->countDocuments(self::ORG, true, null)); + self::assertNull($this->repository->document(self::ORG, self::THIRD)); + } + } + + #[Test] + public function operationReplayKeepsTheOriginalResponseAfterLaterConfirmation(): void + { + $document = $this->document(); + $operation = new ExportOperation(self::ORG, self::ACTOR, self::OPERATION, 'generate', hash('sha256', 'original declaration'), self::DOCUMENT, $document->projection()); + $this->repository->synchronized(self::ORG, function () use ($document, $operation): void { + $this->repository->saveDocument($document); + $this->repository->saveOperation($operation); + }); + $document->confirm(1, self::SECOND, 'ERP-IMPORT', self::ACTOR, new DateTimeImmutable('2026-10-07T12:00:00Z')); + $this->persist($document); + $this->repository->synchronized(self::ORG, fn () => $this->repository->saveOperation($operation)); + $receipt = $this->repository->operation(self::ORG, self::ACTOR, self::OPERATION); + self::assertNotNull($receipt); + self::assertEquals($operation->result, $receipt->result); + self::assertSame('generated', $receipt->result['state']); + self::assertSame(1, $receipt->result['revision']); + self::assertNull($this->repository->operation(self::ORG, self::OTHER_ACTOR, self::OPERATION)); + self::assertNull($this->repository->operation(self::OTHER_ORG, self::ACTOR, self::OPERATION)); + } + + #[Test] + public function anOperationKeyCannotSilentlyChangeItsActionOrBody(): void + { + $operation = new ExportOperation(self::ORG, self::ACTOR, self::OPERATION, 'generate', hash('sha256', 'first'), self::DOCUMENT, ['state' => 'generated']); + $this->repository->synchronized(self::ORG, fn () => $this->repository->saveOperation($operation)); + + try { + $this->repository->synchronized(self::ORG, fn () => $this->repository->saveOperation(new ExportOperation(self::ORG, self::ACTOR, self::OPERATION, 'confirm', hash('sha256', 'different'), self::DOCUMENT, ['state' => 'import_confirmed']))); + self::fail('A stable operation identity must retain its first declaration.'); + } catch (MaintenanceExportException $error) { + self::assertSame('conflict', $error->reason); + self::assertSame('generate', $this->repository->operation(self::ORG, self::ACTOR, self::OPERATION)?->action); + } + } + + #[Test] + public function referenceChangesKeepTheirIdentityAndNeverRewriteSavedRows(): void + { + $this->persist($this->document()); + $this->repository->synchronized(self::ORG, fn () => $this->repository->saveReference($this->reference())); + $before = $this->row(); + $this->repository->synchronized(self::ORG, fn () => $this->repository->saveReference($this->reference(revision: 2, value: 'ERP-UPDATED'))); + $reference = $this->repository->reference(self::ORG, 'erp', 'equipment', self::SOURCE); + self::assertNotNull($reference); + self::assertSame(self::REFERENCE, $reference->id); + self::assertSame(2, $reference->revision); + self::assertSame('ERP-UPDATED', $reference->reference); + self::assertSame($before, $this->row()); + self::assertNull($this->repository->reference(self::OTHER_ORG, 'erp', 'equipment', self::SOURCE)); + self::assertSame(1, $this->repository->countReferences(self::ORG, 'erp', 'equipment', self::SOURCE)); + self::assertCount(1, $this->repository->references(self::ORG, 'erp', 'equipment', self::SOURCE, 0, 1)); + self::assertSame(0, $this->repository->countReferences(self::ORG, 'other_erp', null, null)); + self::assertCount(0, $this->repository->references(self::OTHER_ORG, null, null, null, 0, 10)); + } + + #[Test] + public function staleReferenceUpdatesCannotReplaceTheCurrentMapping(): void + { + $this->repository->synchronized(self::ORG, fn () => $this->repository->saveReference($this->reference())); + $this->repository->synchronized(self::ORG, fn () => $this->repository->saveReference($this->reference(revision: 2, value: 'ERP-LATEST'))); + + try { + $this->repository->synchronized(self::ORG, fn () => $this->repository->saveReference($this->reference(revision: 1, value: 'ERP-STALE'))); + self::fail('A stale reference must not replace the current revision.'); + } catch (MaintenanceExportException $error) { + self::assertSame('stale', $error->reason); + self::assertSame('ERP-LATEST', $this->repository->reference(self::ORG, 'erp', 'equipment', self::SOURCE)?->reference); + } + } + + #[Test] + public function aFailureRollsBackArtifactsReferencesAndReplayReceiptTogether(): void + { + try { + $this->repository->synchronized(self::ORG, function (): void { + $this->repository->saveDocument($this->document()); + $this->repository->saveReference($this->reference()); + $this->repository->saveOperation(new ExportOperation(self::ORG, self::ACTOR, self::OPERATION, 'generate', hash('sha256', 'rollback'), self::DOCUMENT, ['state' => 'generated'])); + + throw new RuntimeException('Source capture failed.'); + }); + } catch (RuntimeException $error) { + self::assertSame('Source capture failed.', $error->getMessage()); + self::assertNull($this->repository->document(self::ORG, self::DOCUMENT)); + self::assertNull($this->repository->reference(self::ORG, 'erp', 'equipment', self::SOURCE)); + self::assertNull($this->repository->operation(self::ORG, self::ACTOR, self::OPERATION)); + } + } + + #[Test] + public function aCorruptedArtifactFailsIntegrityVerification(): void + { + $this->persist($this->document()); + $this->connection->executeStatement('UPDATE maintenance_export_documents SET csv_bytes = ? WHERE id = ?', ['corrupted', self::DOCUMENT]); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Retained export integrity verification failed.'); + $this->repository->document(self::ORG, self::DOCUMENT); + } + + /** + * Method document + * + * @param string $id document identity + * @param string $organization owning scope + * @param string $system explicit ERP + * @param bool $financial private incomplete cost metadata + * @param string|null $adjustmentOf preceding immutable export + * @param string|null $json optional altered artifact for refusal coverage + * + * @return ExportDocument genuine UTF-8 and CRLF artifact fixture + */ + private function document(string $id = self::DOCUMENT, string $organization = self::ORG, string $system = 'erp', bool $financial = false, ?string $adjustmentOf = null, ?string $json = null): ExportDocument + { + $rows = [['rowId' => 'prestations:' . self::SOURCE, 'label' => 'Contrôle électricité — réserve', 'externalReference' => 'ERP-INITIAL', 'sourceId' => self::SOURCE, 'properties' => ['z' => 'last', 'a' => 'first']]]; + + return new ExportDocument($id, $organization, self::ACTOR, null === $adjustmentOf ? 'initial' : 'adjustment', $system, $financial, [self::SOURCE], null === $adjustmentOf ? null : self::DOCUMENT, $adjustmentOf, null === $adjustmentOf ? null : 'Correction après clôture', new DateTimeImmutable('2026-10-07T10:00:00+02:00'), $rows, ['prestations:' . self::SOURCE => $rows[0]], $json ?? "{\n \"libellé\": \"Contrôle électricité — réserve\"\n}\n", "\xEF\xBB\xBFid;libellé\r\n\"{$id}\";\"Contrôle électricité — réserve\"\r\n", $financial ? false : null, $financial ? 1 : null); + } + + /** + * Method reference + * + * @param int $revision next mapping revision + * @param string $value external ERP reference + * + * @return ExternalReference scoped mapping fixture + */ + private function reference(int $revision = 1, string $value = 'ERP-INITIAL'): ExternalReference + { + return new ExternalReference(self::REFERENCE, self::ORG, 'erp', 'equipment', self::SOURCE, $value, $revision, new DateTimeImmutable('2026-10-07T11:00:00Z')); + } + + /** + * Method persist + * + * @param ExportDocument $document owned artifact + * + * @return void one serialized main save + */ + private function persist(ExportDocument $document): void + { + $this->repository->synchronized($document->organizationId, fn () => $this->repository->saveDocument($document)); + } + + /** + * Method row + * + * @return array full PostgreSQL row for immutability comparison + */ + private function row(): array + { + $row = $this->connection->fetchAssociative('SELECT * FROM maintenance_export_documents WHERE organization_id = ? AND id = ?', [self::ORG, self::DOCUMENT]); + self::assertIsArray($row); + + return $row; + } + // #endregion +} diff --git a/tests/Integration/Procurement/Infrastructure/Adapter/Reporting/ProcurementEconomicOverviewAdapterTest.php b/tests/Integration/Procurement/Infrastructure/Adapter/Reporting/ProcurementEconomicOverviewAdapterTest.php new file mode 100644 index 000000000..314c97517 --- /dev/null +++ b/tests/Integration/Procurement/Infrastructure/Adapter/Reporting/ProcurementEconomicOverviewAdapterTest.php @@ -0,0 +1,270 @@ +connection = DriverManager::getConnection(['url' => $url]); + $this->connection->beginTransaction(); + $currencies = $this->createMock(MaintenanceCurrencyPort::class); + $currencies->method('forOrganization')->willReturn('EUR'); + $currencies->expects(self::never())->method('lock'); + $this->adapter = new ProcurementEconomicOverviewAdapter($this->connection, $currencies); + } + + protected function tearDown(): void + { + $this->connection->rollBack(); + $this->connection->close(); + parent::tearDown(); + } + + #[Test] + public function fractionalPartialDeliveryAndPhysicalReturnHaveIndependentExactAmounts(): void + { + $line = $this->line('2.500000', '1.250000', '4.250000'); + $order = $this->order([$line], 'partial_received'); + $this->receipt($order, $line['id'], '1.250000', '4.250000', '0.250000'); + $view = $this->adapter->overview(self::ORG, $this->from(), $this->to()); + self::assertSame('10.625000', $view->ordered->total); + self::assertSame('5.312500', $view->received->total); + self::assertSame('5.312500', $view->outstanding->total); + self::assertSame('1.062500', $view->returned->total); + self::assertSame(1, $view->orderCount); + self::assertSame(1, $view->receiptCount); + self::assertSame(0, $view->pendingIndividualizationCount); + self::assertSame('order_created_at', $view->basis); + self::assertSame('selected_orders_all_history', $view->receiptScope); + } + + #[Test] + public function unknownPricesPreserveKnownSubtotalsWithoutMakingZeroRemaindersUnknown(): void + { + $known = $this->line('2.500000', '1.250000', '4.250000'); + $unknown = $this->line('1.000000', '1.000000', null); + $order = $this->order([$known, $unknown], 'partial_received'); + $this->receipt($order, $known['id'], '1.250000', '4.250000', '0.250000'); + $this->receipt($order, $unknown['id'], '1.000000', null); + $view = $this->adapter->overview(self::ORG, $this->from(), $this->to()); + self::assertNull($view->ordered->total); + self::assertFalse($view->ordered->complete); + self::assertSame('10.625000', $view->ordered->knownTotal); + self::assertNull($view->received->total); + self::assertSame('5.312500', $view->received->knownTotal); + self::assertTrue($view->outstanding->complete); + self::assertSame('5.312500', $view->outstanding->total); + self::assertTrue($view->returned->complete); + self::assertSame('1.062500', $view->returned->total); + } + + #[Test] + public function cancellationRemovesOnlyUndeliveredCommitmentAndReturnsNeverReopenIt(): void + { + $line = $this->line('10.000000', '3.000000', '2.000000'); + $order = $this->order([$line], 'cancelled'); + $this->receipt($order, $line['id'], '3.000000', '2.000000', '1.000000'); + $this->order([$this->line('100.000000', '0.000000', null)], 'cancelled'); + $view = $this->adapter->overview(self::ORG, $this->from(), $this->to()); + self::assertSame('6.000000', $view->ordered->total); + self::assertTrue($view->ordered->complete); + self::assertSame('6.000000', $view->received->total); + self::assertSame('0.000000', $view->outstanding->total); + self::assertSame('2.000000', $view->returned->total); + self::assertSame(2, $view->orderCount); + } + + #[Test] + public function orderCreationWindowIsHalfOpenAndIncludesLaterReceiptsWithoutForeignFacts(): void + { + $line = $this->line('1.000000', '1.000000', '3.000000', 'equipment_to_individualize'); + $selected = $this->order([$line], 'received', self::ORG, 'EUR', '2026-10-01 00:00:00'); + $this->receipt($selected, $line['id'], '1.000000', '3.000000', '0.000000', 'equipment_to_individualize', self::ORG, 'EUR', '2026-12-01 00:00:00'); + $this->receipt($selected, $line['id'], '500.000000', '100.000000', '0.000000', 'part', self::FOREIGN); + $this->order([$line], 'received', self::ORG, 'EUR', '2026-09-30 23:59:59'); + $this->order([$line], 'received', self::ORG, 'EUR', '2026-11-01 00:00:00'); + $this->order([$line], 'draft'); + $foreign = $this->order([$line], 'received', self::FOREIGN); + $this->receipt($foreign, $line['id'], '500.000000', '100.000000', '0.000000', 'part', self::FOREIGN); + $view = $this->adapter->overview(self::ORG, $this->from(), $this->to()); + self::assertSame(1, $view->orderCount); + self::assertSame(1, $view->receiptCount); + self::assertSame(1, $view->pendingIndividualizationCount); + self::assertSame('3.000000', $view->received->total); + self::assertSame('3.000000', $view->ordered->total); + } + + #[Test] + public function fullyReturnedEquipmentIsNoLongerAwaitingIndividualizationAndUnknownReturnStaysIncomplete(): void + { + $line = $this->line('1.000000', '1.000000', null, 'equipment_to_individualize'); + $order = $this->order([$line], 'received'); + $this->receipt($order, $line['id'], '1.000000', null, '1.000000', 'equipment_to_individualize'); + $view = $this->adapter->overview(self::ORG, $this->from(), $this->to()); + self::assertSame(0, $view->pendingIndividualizationCount); + self::assertNull($view->returned->total); + self::assertFalse($view->returned->complete); + self::assertSame('0.000000', $view->returned->knownTotal); + self::assertSame('0.000000', $view->outstanding->total); + } + + #[Test] + public function pendingReturnReconciliationDoesNotCreateAnotherReturnOrReceiptValue(): void + { + $line = $this->line('2.000000', '2.000000', '4.000000'); + $order = $this->order([$line], 'received'); + $receipt = $this->receipt($order, $line['id'], '2.000000', '4.000000', '1.000000'); + $this->connection->update('procurement_receipts', ['pending_return_quantity' => '1.000000'], ['id' => $receipt]); + $this->connection->insert('procurement_returns', ['id' => $this->id(), 'organization_id' => self::ORG, 'receipt_id' => $receipt, 'client_operation_id' => $this->id(), 'quantity' => '1.000000', 'reason' => 'Physical return awaiting stock reconciliation', 'actor_id' => $this->id(), 'created_at' => '2026-10-07 00:00:00', 'status' => 'pending_reconciliation', 'revision' => 1]); + $view = $this->adapter->overview(self::ORG, $this->from(), $this->to()); + self::assertSame('8.000000', $view->received->total); + self::assertSame('4.000000', $view->returned->total); + self::assertSame(1, $view->receiptCount); + } + + #[Test] + public function unknownEmptyScopeReturnsExplicitKnownZero(): void + { + $view = $this->adapter->overview(self::FOREIGN, $this->from(), $this->to()); + self::assertSame('EUR', $view->currency); + self::assertSame(0, $view->orderCount); + self::assertSame('0.000000', $view->ordered->total); + self::assertTrue($view->ordered->complete); + self::assertSame('0.000000', $view->received->total); + self::assertSame('0.000000', $view->outstanding->total); + self::assertSame('0.000000', $view->returned->total); + } + + #[Test] + public function oversizedSelectionRefusesInsteadOfReturningFirstFiveHundredTotals(): void + { + for ($index = 0; $index < 501; ++$index) { + $this->order([$this->line('1.000000', '0.000000', '1.000000')], 'ordered'); + } + $this->assertUnavailable('order_limit'); + } + + #[Test] + public function exactlyFiveHundredOrdersAndLargeExactValuesRemainSupported(): void + { + $this->order([$this->line('100000.000000', '0.000000', '999999999999999999.000000')], 'ordered'); + for ($index = 1; $index < 500; ++$index) { + $this->order([$this->line('1.000000', '0.000000', '0.000000')], 'ordered'); + } + $view = $this->adapter->overview(self::ORG, $this->from(), $this->to()); + self::assertSame(500, $view->orderCount); + self::assertSame('99999999999999999900000.000000', $view->ordered->total); + self::assertSame($view->ordered->total, $view->outstanding->total); + } + + #[Test] + public function configuredCurrencyMismatchInAnOrderIsRefused(): void + { + $this->order([$this->line('1.000000', '0.000000', '1.000000')], 'ordered', self::ORG, 'USD'); + $this->assertUnavailable('mixed_currency'); + } + + #[Test] + public function retainedReceiptCurrencyMismatchIsRefused(): void + { + $line = $this->line('1.000000', '1.000000', '1.000000'); + $order = $this->order([$line], 'received'); + $this->receipt($order, $line['id'], '1.000000', '1.000000', '0.000000', 'part', self::ORG, 'USD'); + $this->assertUnavailable('mixed_currency'); + } + + #[Test] + public function invalidHalfOpenPeriodIsRefused(): void + { + $this->expectException(ProcurementEconomicOverviewUnavailable::class); + $this->expectExceptionMessage('exclusive end after its start'); + $this->adapter->overview(self::ORG, $this->to(), $this->from()); + } + + /** + * @return array{id:string,kind:string,partId:?string,typeCode:?string,identityTemplate:array,quantity:string,unitCost:?string,receivedQuantity:string,returnedQuantity:string} + */ + private function line(string $quantity, string $received, ?string $price, string $kind = 'part'): array + { + return ['id' => $this->id(), 'kind' => $kind, 'partId' => 'part' === $kind ? $this->id() : null, 'typeCode' => 'part' === $kind ? null : 'extinguisher', 'identityTemplate' => [], 'quantity' => $quantity, 'unitCost' => $price, 'receivedQuantity' => $received, 'returnedQuantity' => '0.000000']; + } + + /** + * @param list> $lines + */ + private function order(array $lines, string $status, string $organization = self::ORG, string $currency = 'EUR', string $createdAt = '2026-10-07 00:00:00'): string + { + $id = $this->id(); + $this->connection->insert('procurement_orders', ['id' => $id, 'organization_id' => $organization, 'supplier_id' => $this->id(), 'name' => 'Economic overview order', 'currency' => $currency, 'status' => $status, 'lines' => json_encode($lines, JSON_THROW_ON_ERROR), 'revision' => 2, 'created_at' => $createdAt, 'updated_at' => $createdAt]); + + return $id; + } + + private function receipt(string $order, string $line, string $quantity, ?string $price, string $returned = '0.000000', string $kind = 'part', string $organization = self::ORG, string $currency = 'EUR', string $receivedAt = '2026-10-07 00:00:00'): string + { + $id = $this->id(); + $this->connection->insert('procurement_receipts', ['id' => $id, 'organization_id' => $organization, 'order_id' => $order, 'line_id' => $line, 'kind' => $kind, 'quantity' => $quantity, 'unit_cost' => $price, 'currency' => $currency, 'received_at' => $receivedAt, 'actor_id' => $this->id(), 'created_at' => $receivedAt, 'equipment_ids' => '[]', 'returned_quantity' => $returned, 'revision' => 1]); + + return $id; + } + + private function assertUnavailable(string $reason): void + { + try { + $this->adapter->overview(self::ORG, $this->from(), $this->to()); + self::fail('An unreliable economic overview must be refused.'); + } catch (ProcurementEconomicOverviewUnavailable $failure) { + self::assertSame($reason, $failure->reason); + } + } + + private function id(): string + { + return sprintf('a602e840-e29b-41d4-a716-%012d', $this->sequence++); + } + + private function from(): DateTimeImmutable + { + return new DateTimeImmutable('2026-10-01T00:00:00Z'); + } + + private function to(): DateTimeImmutable + { + return new DateTimeImmutable('2026-11-01T00:00:00Z'); + } +} diff --git a/tests/Integration/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepositoryTest.php b/tests/Integration/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepositoryTest.php new file mode 100644 index 000000000..8fa6f4853 --- /dev/null +++ b/tests/Integration/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepositoryTest.php @@ -0,0 +1,256 @@ +a = DriverManager::getConnection(['url' => $url]); + $this->b = DriverManager::getConnection(['url' => $url]); + $this->repository = new ProcurementRepository($this->a); + $this->sender = $this->transport($this->a); + $this->receiver = $this->transport($this->b); + $this->sender->setup(); + $this->clean(); + } + + protected function tearDown(): void + { + foreach ([$this->a, $this->b] as $connection) { + while ($connection->isTransactionActive()) { + $connection->rollBack(); + } + } + $this->clean(); + $this->a->close(); + $this->b->close(); + parent::tearDown(); + } + + #[Test] + public function exactQuantitiesCostsAndMotivatedDeclarationsRoundTrip(): void + { + $order = $this->order('2.500000'); + $order->recordReceipt(2, self::LINE, '1.250000', $this->now()); + $this->repository->saveOrder($order); + $receipt = $this->receipt('1.250000'); + $this->repository->saveReceipt($receipt); + $this->repository->saveOperation(new ProcurementOperationState(self::ORG, self::OPERATION, 'return', 'fingerprint', self::RECEIPT, ['quantity' => '0.250000', 'reason' => 'Packaging damaged', 'actorId' => self::SUPPLIER])); + $other = new ProcurementRepository($this->b); + $read = $other->order(self::ORG, self::ORDER); + self::assertNotNull($read); + self::assertSame('1.250000', $read->lines()[0]->remainingQuantity()); + self::assertSame('4.250000', $read->lines()[0]->unitCost); + $readReceipt = $other->receipt(self::ORG, self::RECEIPT); + self::assertNotNull($readReceipt); + self::assertSame('1.250000', $readReceipt->quantity); + self::assertSame('4.250000', $readReceipt->unitCost); + self::assertSame('Packaging damaged', $other->operation(self::ORG, self::OPERATION)?->declaration['reason']); + } + + #[Test] + public function theLastQuantityIsSerializedAndAStaleSecondWorkerCannotReceiveIt(): void + { + $this->repository->saveOrder($this->order()); + $this->a->beginTransaction(); + $this->repository->synchronized(self::ORG, function (): void { + $order = $this->repository->order(self::ORG, self::ORDER); + self::assertNotNull($order); + $order->recordReceipt(2, self::LINE, '1.000000', $this->now()); + $this->repository->saveOrder($order); + $this->repository->saveReceipt($this->receipt()); + }); + $this->b->beginTransaction(); + $this->b->executeStatement("SET LOCAL lock_timeout = '40ms'"); + + try { + new ProcurementRepository($this->b)->synchronized(self::ORG, static fn (): bool => true); + self::fail('The other worker must wait for the organization transaction.'); + } catch (DriverException $exception) { + self::assertSame('55P03', $exception->getSQLState()); + } + $this->b->rollBack(); + $this->a->commit(); + + try { + new ProcurementRepository($this->b)->synchronized(self::ORG, function (): void { + $order = new ProcurementRepository($this->b)->order(self::ORG, self::ORDER); + self::assertNotNull($order); + $order->recordReceipt(2, self::LINE, '1.000000', $this->now()); + }); + self::fail('The second worker must see the changed revision.'); + } catch (ProcurementException $exception) { + self::assertSame('stale', $exception->errorCode); + } + $final = new ProcurementRepository($this->b)->order(self::ORG, self::ORDER); + self::assertNotNull($final); + self::assertSame('received', $final->status()->value); + self::assertSame('0.000000', $final->lines()[0]->remainingQuantity()); + self::assertSame(1, $this->b->fetchOne('SELECT COUNT(*) FROM procurement_receipts WHERE organization_id = ?', [self::ORG])); + } + + #[Test] + public function orderPhysicalReceiptOperationAndOutboxCommitOrRollbackTogether(): void + { + $ids = $this->createStub(UuidFactory::class); + $ids->method('generateRaw')->willReturn(self::OPERATION); + $dispatcher = new TransactionalEventDispatcher($this->a, $this->sender, $ids, $this->createStub(CurrentActorPort::class)); + $write = function () use ($dispatcher): void { + $this->repository->saveOrder($this->order()); + $this->repository->saveReceipt($this->receipt()); + $this->repository->saveOperation(new ProcurementOperationState(self::ORG, self::OPERATION, 'receive', 'fingerprint', self::RECEIPT)); + $dispatcher->dispatch(new ProcurementChangedEvent(self::ORG, self::RECEIPT, 'receive', $this->now())); + self::assertNull(new ProcurementRepository($this->b)->receipt(self::ORG, self::RECEIPT)); + self::assertSame(0, $this->receiver->getMessageCount()); + }; + + try { + $this->repository->synchronized(self::ORG, function () use ($write): void { + $write(); + + throw new RuntimeException('Simulate stock confirmation failure'); + }); + } catch (RuntimeException $exception) { + self::assertSame('Simulate stock confirmation failure', $exception->getMessage()); + } + self::assertNull(new ProcurementRepository($this->b)->order(self::ORG, self::ORDER)); + self::assertNull(new ProcurementRepository($this->b)->receipt(self::ORG, self::RECEIPT)); + self::assertNull(new ProcurementRepository($this->b)->operation(self::ORG, self::OPERATION)); + self::assertSame(0, $this->receiver->getMessageCount()); + $this->repository->synchronized(self::ORG, $write); + self::assertNotNull(new ProcurementRepository($this->b)->receipt(self::ORG, self::RECEIPT)); + self::assertSame(1, $this->receiver->getMessageCount()); + } + + #[Test] + public function foreignIdentifiersAreNotLoadedAndUnrelatedOrganizationsDoNotBlock(): void + { + $this->repository->saveSupplier(Supplier::create(self::SUPPLIER, self::ORG, 'Supplier', null, null, null, [], $this->now())); + $this->repository->saveOrder($this->order()); + $this->repository->saveReceipt($this->receipt()); + $other = new ProcurementRepository($this->b); + self::assertNull($other->supplier(self::FOREIGN, self::SUPPLIER)); + self::assertNull($other->order(self::FOREIGN, self::ORDER)); + self::assertNull($other->receipt(self::FOREIGN, self::RECEIPT)); + self::assertSame([], $other->orders(self::FOREIGN, null, null, 0, 100)); + $this->a->beginTransaction(); + $this->repository->synchronized(self::ORG, static fn (): bool => true); + $independent = false; + $other->synchronized(self::FOREIGN, static function () use (&$independent): void { $independent = true; }); + self::assertTrue($independent); + $this->a->rollBack(); + } + + /** + * Proves the nullable archive predicate preserves search, scope, count and paging in PostgreSQL. + */ + #[Test] + public function allSupplierSelectionRetainsActiveAndArchivedWithExactPagedCount(): void + { + $active = Supplier::create(self::SUPPLIER, self::ORG, 'Safety active', null, null, null, [], $this->now()); + $archived = Supplier::create(self::ORDER, self::ORG, 'Safety archived', null, null, null, [], $this->now()); + $archived->archive(1, $this->now()); + $this->repository->saveSupplier($active); + $this->repository->saveSupplier($archived); + $this->repository->saveSupplier(Supplier::create(self::LINE, self::ORG, 'Unrelated', null, null, null, [], $this->now())); + + self::assertSame([self::SUPPLIER], array_map(static fn (Supplier $supplier): string => $supplier->id, $this->repository->suppliers(self::ORG, 'Safety', false, 0, 30))); + self::assertSame([self::ORDER], array_map(static fn (Supplier $supplier): string => $supplier->id, $this->repository->suppliers(self::ORG, 'Safety', true, 0, 30))); + self::assertSame(2, $this->repository->countSuppliers(self::ORG, 'Safety', null)); + self::assertCount(1, $this->repository->suppliers(self::ORG, 'Safety', null, 1, 1)); + self::assertCount(2, $this->repository->suppliers(self::ORG, 'Safety', null, 0, 30)); + self::assertSame([], $this->repository->suppliers(self::FOREIGN, 'Safety', null, 0, 30)); + self::assertSame(0, $this->repository->countSuppliers(self::FOREIGN, 'Safety', null)); + } + + private function order(string $quantity = '1.000000'): PurchaseOrder + { + $order = PurchaseOrder::create(self::ORDER, self::ORG, self::SUPPLIER, 'EUR', 'Purchase', [ProcurementLine::create(self::LINE, 'part', self::PART, null, [], $quantity, '4.250000')], $this->now()); + $order->order(1, $this->now()); + + return $order; + } + + private function receipt(string $quantity = '1.000000'): ProcurementReceiptState + { + return new ProcurementReceiptState(self::RECEIPT, self::ORG, self::ORDER, self::LINE, 'part', $quantity, self::PART, '4.250000', 'EUR', $this->now(), self::SUPPLIER, $this->now()); + } + + private function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-10-06T00:00:00Z'); + } + + private function transport(Connection $connection): DoctrineTransport + { + $registry = $this->createStub(ConnectionRegistry::class); + $registry->method('getConnection')->willReturn($connection); + $transport = new DoctrineTransportFactory($registry)->createTransport('doctrine://main?queue_name=' . self::QUEUE . '&auto_setup=false', ['use_notify' => false], new PhpSerializer()); + self::assertInstanceOf(DoctrineTransport::class, $transport); + + return $transport; + } + + private function clean(): void + { + $this->a->delete('messenger_messages', ['queue_name' => self::QUEUE]); + foreach (['procurement_operations', 'procurement_returns', 'procurement_receipts', 'procurement_orders', 'procurement_suppliers'] as $table) { + $this->a->delete($table, ['organization_id' => self::ORG]); + } + } +} diff --git a/tests/Integration/ServiceRequest/Infrastructure/Persistence/Doctrine/Repository/ServiceRequestRepositoryTest.php b/tests/Integration/ServiceRequest/Infrastructure/Persistence/Doctrine/Repository/ServiceRequestRepositoryTest.php new file mode 100644 index 000000000..c49a5d4fe --- /dev/null +++ b/tests/Integration/ServiceRequest/Infrastructure/Persistence/Doctrine/Repository/ServiceRequestRepositoryTest.php @@ -0,0 +1,408 @@ + + */ +final class ServiceRequestRepositoryTest extends KernelTestCase +{ + private const string ORGANIZATION_ID = 'a0e10000-0000-4000-8000-000000000001'; + + private const string OTHER_ORGANIZATION_ID = 'a0e10000-0000-4000-8000-000000000002'; + + private const string REQUEST_ID = 'a0e10000-0000-4000-8000-000000000003'; + + private const string SECOND_REQUEST_ID = 'a0e10000-0000-4000-8000-000000000004'; + + private const string EQUIPMENT_ID = 'a0e10000-0000-4000-8000-000000000005'; + + private const string SECOND_EQUIPMENT_ID = 'a0e10000-0000-4000-8000-000000000006'; + + private const string SITE_ID = 'a0e10000-0000-4000-8000-000000000007'; + + private const string SECOND_SITE_ID = 'a0e10000-0000-4000-8000-000000000008'; + + private const string INSPECTION_ID = 'a0e10000-0000-4000-8000-000000000009'; + + private const string NON_CONFORMITY_ID = 'a0e10000-0000-4000-8000-000000000010'; + + private const string INTERVENTION_ID = 'a0e10000-0000-4000-8000-000000000011'; + + private const string TASK_ID = 'a0e10000-0000-4000-8000-000000000012'; + + private const string OPERATION_ID = 'a0e10000-0000-4000-8000-000000000013'; + + private const string SECOND_OPERATION_ID = 'a0e10000-0000-4000-8000-000000000014'; + + private EntityManagerInterface $entityManager; + + private Connection $connection; + + private ServiceRequestRepository $repository; + + // #region Methods + protected function setUp(): void + { + self::bootKernel(); + $entityManager = self::getContainer()->get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + $this->entityManager = $entityManager; + $this->connection = $entityManager->getConnection(); + $this->repository = new ServiceRequestRepository($entityManager); + foreach ([self::ORGANIZATION_ID, self::OTHER_ORGANIZATION_ID] as $organizationId) { + $this->connection->delete('service_request_conversion_receipts', ['organization_id' => $organizationId]); + $this->connection->delete('service_requests', ['organization_id' => $organizationId]); + } + } + + #[Test] + public function retainsTheFullConvertedRequestAndCustomerIdentityAfterReload(): void + { + $request = self::request()->qualify('Gauge replacement approved', self::now()->modify('+1 hour'))->convert(self::INTERVENTION_ID, self::TASK_ID, self::now()->modify('+2 hours')); + $this->repository->save($request); + $this->entityManager->clear(); + $stored = $this->repository->find(self::REQUEST_ID, self::ORGANIZATION_ID); + self::assertNotNull($stored); + + self::assertSame($request->id, $stored->id); + self::assertSame($request->organizationId, $stored->organizationId); + self::assertSame($request->equipmentId, $stored->equipmentId); + self::assertSame($request->siteId, $stored->siteId); + self::assertEquals($request->targetSnapshot, $stored->targetSnapshot); + self::assertSame(['id' => self::SECOND_OPERATION_ID, 'name' => 'Building owner'], $stored->targetSnapshot['customer']); + self::assertSame('Repair extinguisher', $stored->title); + self::assertSame('Pressure gauge damaged', $stored->description); + self::assertSame('high', $stored->priority); + self::assertSame(self::INSPECTION_ID, $stored->originInspectionId); + self::assertSame(self::NON_CONFORMITY_ID, $stored->originNonConformityId); + self::assertSame('converted', $stored->status); + self::assertSame(3, $stored->revision); + self::assertSame('Gauge replacement approved', $stored->qualificationNote); + self::assertSame(self::INTERVENTION_ID, $stored->interventionId); + self::assertSame(self::TASK_ID, $stored->taskId); + self::assertNull($stored->decisionReason); + self::assertNull($stored->rejectedAt); + self::assertNull($stored->cancelledAt); + self::assertSame('2026-10-06T12:00:00+00:00', $stored->requestedAt->format(DATE_ATOM)); + self::assertSame('2026-10-06T13:00:00+00:00', $stored->qualifiedAt?->format(DATE_ATOM)); + self::assertSame('2026-10-06T14:00:00+00:00', $stored->convertedAt?->format(DATE_ATOM)); + self::assertSame('2026-10-06T14:00:00+00:00', $stored->updatedAt->format(DATE_ATOM)); + self::assertSame('2026-10-06 12:00:00', $this->connection->fetchOne('SELECT requested_at FROM service_requests WHERE id = ?', [self::REQUEST_ID])); + self::assertSame('2026-10-06 14:00:00', $this->connection->fetchOne('SELECT converted_at FROM service_requests WHERE id = ?', [self::REQUEST_ID])); + } + + /** + * @return iterable + */ + public static function decisionStates(): iterable + { + yield 'rejected' => ['rejected']; + yield 'cancelled' => ['cancelled']; + } + + #[Test] + #[DataProvider('decisionStates')] + public function retainsDecisionReasonsAndTheirActualUtcDates(string $state): void + { + $request = self::request()->qualify('Qualified from inspection', self::now()->modify('+1 hour')); + $decidedAt = self::now()->modify('+2 hours'); + $request = 'rejected' === $state ? $request->reject('Not in contract', $decidedAt) : $request->cancel('Duplicate work', $decidedAt); + $this->repository->save($request); + $this->entityManager->clear(); + $stored = $this->repository->find(self::REQUEST_ID, self::ORGANIZATION_ID); + self::assertNotNull($stored); + + self::assertSame($state, $stored->status); + self::assertSame($request->decisionReason, $stored->decisionReason); + self::assertSame('Qualified from inspection', $stored->qualificationNote); + self::assertSame('2026-10-06T13:00:00+00:00', $stored->qualifiedAt?->format(DATE_ATOM)); + self::assertSame('2026-10-06T14:00:00+00:00', ('rejected' === $state ? $stored->rejectedAt : $stored->cancelledAt)?->format(DATE_ATOM)); + self::assertNull('rejected' === $state ? $stored->cancelledAt : $stored->rejectedAt); + self::assertNull($stored->convertedAt); + self::assertNull($stored->interventionId); + self::assertNull($stored->taskId); + self::assertSame(3, $stored->revision); + } + + #[Test] + public function persistsTheExplicitEquipmentSelectionForASiteOnlyRequest(): void + { + $request = ServiceRequest::create(self::REQUEST_ID, self::ORGANIZATION_ID, null, self::SITE_ID, ['site' => ['id' => self::SITE_ID, 'name' => 'Warehouse']], 'Locate leak', 'Equipment to identify', self::now()); + $this->repository->save($request); + $this->entityManager->clear(); + $loaded = $this->repository->find(self::REQUEST_ID, self::ORGANIZATION_ID); + self::assertNotNull($loaded); + self::assertNull($loaded->equipmentId); + self::assertSame(self::SITE_ID, $loaded->siteId); + self::assertNull($loaded->originInspectionId); + self::assertNull($loaded->originNonConformityId); + $snapshot = ['equipment' => ['id' => self::EQUIPMENT_ID, 'name' => 'Valve'], 'site' => ['id' => self::SITE_ID, 'name' => 'Warehouse']]; + $assigned = $loaded->assignEquipment(self::EQUIPMENT_ID, self::SITE_ID, $snapshot, self::now()->modify('+1 hour')); + $this->repository->save($assigned, $loaded->revision); + $this->entityManager->clear(); + $stored = $this->repository->find(self::REQUEST_ID, self::ORGANIZATION_ID); + self::assertNotNull($stored); + + self::assertSame(self::EQUIPMENT_ID, $stored->equipmentId); + self::assertSame(self::SITE_ID, $stored->siteId); + self::assertEquals($snapshot, $stored->targetSnapshot); + self::assertSame('requested', $stored->status); + self::assertSame(2, $stored->revision); + self::assertSame('2026-10-06T13:00:00+00:00', $stored->updatedAt->format(DATE_ATOM)); + self::assertNull($stored->qualifiedAt); + } + + #[Test] + public function refusesStaleWritesWithoutChangingTheWinningRevision(): void + { + $request = self::request(); + $this->repository->save($request); + $first = $this->repository->find(self::REQUEST_ID, self::ORGANIZATION_ID); + $second = $this->repository->find(self::REQUEST_ID, self::ORGANIZATION_ID); + self::assertNotNull($first); + self::assertNotNull($second); + $winner = $first->change(['title' => 'Replace gauge'], self::now()->modify('+1 hour')); + $this->repository->save($winner, 1); + + try { + $this->repository->save($second->change(['priority' => 'urgent'], self::now()->modify('+2 hours')), 1); + self::fail('A stale revision must not replace the committed request.'); + } catch (ServiceRequestException $exception) { + self::assertSame('service_request_revision_stale', $exception->reason); + } + $this->entityManager->clear(); + $stored = $this->repository->find(self::REQUEST_ID, self::ORGANIZATION_ID); + self::assertNotNull($stored); + self::assertSame('Replace gauge', $stored->title); + self::assertSame('high', $stored->priority); + self::assertSame(2, $stored->revision); + } + + #[Test] + public function scopesReadsAndUpdatesToTheOwningOrganization(): void + { + $request = self::request(); + $this->repository->save($request); + self::assertNull($this->repository->find(self::REQUEST_ID, self::OTHER_ORGANIZATION_ID)); + $foreign = self::request(organizationId: self::OTHER_ORGANIZATION_ID)->change(['title' => 'Foreign overwrite'], self::now()->modify('+1 hour')); + + try { + $this->repository->save($foreign, 1); + self::fail('The foreign organization must not mutate the request.'); + } catch (ServiceRequestException $exception) { + self::assertSame('service_request_revision_stale', $exception->reason); + } + $this->entityManager->clear(); + $stored = $this->repository->find(self::REQUEST_ID, self::ORGANIZATION_ID); + self::assertNotNull($stored); + self::assertSame('Repair extinguisher', $stored->title); + self::assertSame(1, $stored->revision); + self::assertSame(0, $this->repository->count(self::OTHER_ORGANIZATION_ID, null, null, null, '')); + } + + #[Test] + public function usesTheSameFiltersForListsAndCountsAndPaginatesWithoutDuplicates(): void + { + $first = self::request(title: 'Valve leak', description: 'Pressure lost'); + $qualified = self::request(self::SECOND_REQUEST_ID, title: 'Gauge repair', description: 'Control defect')->qualify(null, self::now()->modify('+1 hour')); + $rejected = self::request(self::INSPECTION_ID, siteId: self::SECOND_SITE_ID, title: 'Old valve', description: 'Leak resolved')->reject('Outside scope', self::now()); + $cancelled = self::request(self::NON_CONFORMITY_ID, equipmentId: self::SECOND_EQUIPMENT_ID, siteId: self::SECOND_SITE_ID, title: 'Detector test')->cancel('Duplicate', self::now()); + $converted = self::request(self::INTERVENTION_ID, equipmentId: self::SECOND_EQUIPMENT_ID, siteId: self::SECOND_SITE_ID, title: 'Valve replacement')->qualify(null, self::now())->convert(self::INTERVENTION_ID, self::TASK_ID, self::now()); + $foreign = self::request(self::TASK_ID, self::OTHER_ORGANIZATION_ID, title: 'Valve leak', description: 'Control defect'); + foreach ([$first, $qualified, $rejected, $cancelled, $converted, $foreign] as $request) { + $this->repository->save($request); + } + $all = $this->repository->list(self::ORGANIZATION_ID, null, null, null, '', 0, 10); + self::assertCount(5, $all); + self::assertSame([self::REQUEST_ID, self::SECOND_REQUEST_ID, self::INSPECTION_ID, self::NON_CONFORMITY_ID, self::INTERVENTION_ID], self::ids($all)); + self::assertSame(5, $this->repository->count(self::ORGANIZATION_ID, null, null, null, '')); + $firstPage = $this->repository->list(self::ORGANIZATION_ID, null, null, null, '', 0, 2); + $secondPage = $this->repository->list(self::ORGANIZATION_ID, null, null, null, '', 2, 3); + self::assertSame(self::ids($all), [...self::ids($firstPage), ...self::ids($secondPage)]); + self::assertSame([], $this->repository->list(self::ORGANIZATION_ID, null, null, null, '', 5, 3)); + + foreach ([ + ['qualified', self::EQUIPMENT_ID, self::SITE_ID, 'CONTROL', [self::SECOND_REQUEST_ID]], + [null, self::EQUIPMENT_ID, null, '', [self::REQUEST_ID, self::SECOND_REQUEST_ID, self::INSPECTION_ID]], + [null, null, self::SECOND_SITE_ID, 'valve', [self::INSPECTION_ID, self::INTERVENTION_ID]], + [null, null, null, "' OR 1=1 --", []], + [null, null, null, '%', []], + [null, null, null, '_', []], + [null, null, null, '\\', []], + ] as [$status, $equipmentId, $siteId, $search, $expectedIds]) { + $matches = $this->repository->list(self::ORGANIZATION_ID, $status, $equipmentId, $siteId, $search, 0, 10); + self::assertEqualsCanonicalizing($expectedIds, self::ids($matches)); + self::assertSame(count($matches), $this->repository->count(self::ORGANIZATION_ID, $status, $equipmentId, $siteId, $search)); + } + } + + #[Test] + public function retainsAndScopesConversionReceiptsByRequestAndOperation(): void + { + $request = self::convertedRequest(); + $this->repository->save($request); + $receipt = self::receipt(); + $this->repository->saveConversionReceipt($receipt); + $foreignRequest = self::convertedRequest(self::SECOND_REQUEST_ID, self::OTHER_ORGANIZATION_ID); + $this->repository->save($foreignRequest); + $foreignReceipt = self::receipt(self::SECOND_REQUEST_ID, self::OTHER_ORGANIZATION_ID); + $this->repository->saveConversionReceipt($foreignReceipt); + $this->entityManager->clear(); + + $byRequest = $this->repository->conversionReceiptForRequest(self::REQUEST_ID, self::ORGANIZATION_ID); + $byOperation = $this->repository->conversionReceiptByOperation(self::ORGANIZATION_ID, self::OPERATION_ID); + self::assertNotNull($byRequest); + self::assertNotNull($byOperation); + self::assertSame(self::ORGANIZATION_ID, $byRequest->organizationId); + self::assertSame(self::REQUEST_ID, $byRequest->requestId); + self::assertSame(self::OPERATION_ID, $byRequest->clientOperationId); + self::assertSame(str_repeat('a', 64), $byRequest->payloadHash); + self::assertSame(self::INTERVENTION_ID, $byRequest->interventionId); + self::assertSame(self::TASK_ID, $byRequest->taskId); + self::assertSame('2026-10-06T14:00:00+00:00', $byRequest->createdAt->format(DATE_ATOM)); + self::assertEquals($byRequest, $byOperation); + self::assertNull($this->repository->conversionReceiptForRequest(self::REQUEST_ID, self::OTHER_ORGANIZATION_ID)); + self::assertSame(self::SECOND_REQUEST_ID, $this->repository->conversionReceiptByOperation(self::OTHER_ORGANIZATION_ID, self::OPERATION_ID)?->requestId); + self::assertNull($this->repository->conversionReceiptByOperation(self::ORGANIZATION_ID, self::SECOND_OPERATION_ID)); + } + + /** + * @return iterable + */ + public static function invalidReceiptOwners(): iterable + { + yield 'missing request' => ['missing']; + yield 'requested' => ['requested']; + yield 'foreign organization' => ['foreign']; + yield 'different intervention' => ['intervention']; + yield 'different task' => ['task']; + } + + #[Test] + #[DataProvider('invalidReceiptOwners')] + public function refusesReceiptsThatDoNotMatchAConvertedRequest(string $case): void + { + if ('missing' !== $case) { + $this->repository->save('requested' === $case ? self::request() : self::convertedRequest()); + } + $receipt = self::receipt( + organizationId: 'foreign' === $case ? self::OTHER_ORGANIZATION_ID : self::ORGANIZATION_ID, + interventionId: 'intervention' === $case ? self::SECOND_EQUIPMENT_ID : self::INTERVENTION_ID, + taskId: 'task' === $case ? self::SECOND_EQUIPMENT_ID : self::TASK_ID, + ); + + try { + $this->repository->saveConversionReceipt($receipt); + self::fail('A receipt must identify the committed converted request.'); + } catch (ServiceRequestException $exception) { + self::assertSame('service_request_operation_conflict', $exception->reason); + } + self::assertNull($this->repository->conversionReceiptForRequest(self::REQUEST_ID, self::ORGANIZATION_ID)); + } + + #[Test] + public function refusesASecondReceiptForTheSameRequest(): void + { + $this->repository->save(self::convertedRequest()); + $this->repository->saveConversionReceipt(self::receipt()); + + try { + $this->connection->transactional(function (): void { + $this->repository->saveConversionReceipt(self::receipt(operationId: self::SECOND_OPERATION_ID)); + }); + self::fail('One request must retain a single conversion receipt.'); + } catch (ServiceRequestException $exception) { + self::assertSame('service_request_operation_conflict', $exception->reason); + } + self::assertSame(self::OPERATION_ID, $this->repository->conversionReceiptForRequest(self::REQUEST_ID, self::ORGANIZATION_ID)?->clientOperationId); + self::assertNull($this->repository->conversionReceiptByOperation(self::ORGANIZATION_ID, self::SECOND_OPERATION_ID)); + } + + #[Test] + public function rollsBackTheRequestConversionWhenAnOperationReceiptConflicts(): void + { + $this->repository->save(self::convertedRequest()); + $this->repository->saveConversionReceipt(self::receipt()); + $qualified = self::request(self::SECOND_REQUEST_ID)->qualify('Repair approved', self::now()->modify('+1 hour')); + $this->repository->save($qualified); + + try { + $this->connection->transactional(function (): void { + $request = $this->repository->find(self::SECOND_REQUEST_ID, self::ORGANIZATION_ID, true); + self::assertNotNull($request); + $this->repository->save($request->convert(self::INTERVENTION_ID, self::TASK_ID, self::now()->modify('+2 hours')), $request->revision); + $this->repository->saveConversionReceipt(self::receipt(self::SECOND_REQUEST_ID)); + }); + self::fail('An operation receipt cannot convert two different requests.'); + } catch (ServiceRequestException $exception) { + self::assertSame('service_request_operation_conflict', $exception->reason); + } + $this->entityManager->clear(); + $stored = $this->repository->find(self::SECOND_REQUEST_ID, self::ORGANIZATION_ID); + self::assertNotNull($stored); + self::assertSame('qualified', $stored->status); + self::assertSame(2, $stored->revision); + self::assertNull($stored->convertedAt); + self::assertNull($stored->interventionId); + self::assertNull($stored->taskId); + self::assertNull($this->repository->conversionReceiptForRequest(self::SECOND_REQUEST_ID, self::ORGANIZATION_ID)); + self::assertSame(self::REQUEST_ID, $this->repository->conversionReceiptByOperation(self::ORGANIZATION_ID, self::OPERATION_ID)?->requestId); + } + + private static function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-10-06T14:00:00+02:00'); + } + + private static function request(string $id = self::REQUEST_ID, string $organizationId = self::ORGANIZATION_ID, string $equipmentId = self::EQUIPMENT_ID, string $siteId = self::SITE_ID, string $title = 'Repair extinguisher', string $description = 'Pressure gauge damaged'): ServiceRequest + { + return ServiceRequest::create($id, $organizationId, $equipmentId, $siteId, ['equipment' => ['id' => $equipmentId, 'name' => 'Extinguisher', 'assetCode' => 'EXT-01'], 'site' => ['id' => $siteId, 'name' => 'Warehouse'], 'customer' => ['id' => self::SECOND_OPERATION_ID, 'name' => 'Building owner']], $title, $description, self::now(), 'high', self::INSPECTION_ID, self::NON_CONFORMITY_ID); + } + + private static function convertedRequest(string $id = self::REQUEST_ID, string $organizationId = self::ORGANIZATION_ID): ServiceRequest + { + return self::request($id, $organizationId)->qualify('Repair approved', self::now()->modify('+1 hour'))->convert(self::INTERVENTION_ID, self::TASK_ID, self::now()->modify('+2 hours')); + } + + private static function receipt(string $requestId = self::REQUEST_ID, string $organizationId = self::ORGANIZATION_ID, string $operationId = self::OPERATION_ID, string $interventionId = self::INTERVENTION_ID, string $taskId = self::TASK_ID): ServiceRequestConversionReceipt + { + return new ServiceRequestConversionReceipt($organizationId, $requestId, $operationId, str_repeat('a', 64), $interventionId, $taskId, self::now()->modify('+2 hours')); + } + + /** + * @param list $requests + * + * @return list + */ + private static function ids(array $requests): array + { + return array_map(static fn (ServiceRequest $request): string => $request->id, $requests); + } + // #endregion +} diff --git a/tests/Unit/Customer/Application/UseCase/CustomerHandlersTest.php b/tests/Unit/Customer/Application/UseCase/CustomerHandlersTest.php new file mode 100644 index 000000000..88022bfec --- /dev/null +++ b/tests/Unit/Customer/Application/UseCase/CustomerHandlersTest.php @@ -0,0 +1,128 @@ +createMock(CustomerRepositoryPort::class); + $repository->expects(self::once())->method('save')->with(self::callback(static fn (Customer $customer): bool => self::ORG === $customer->organizationId && 'Owner' === $customer->name)); + $ids = $this->createMock(UuidGeneratorPort::class); + $ids->expects(self::once())->method('generate')->willReturn(self::ID); + $clock = $this->createMock(ClockPort::class); + $clock->expects(self::once())->method('now')->willReturn($now); + $events = $this->createMock(EventDispatcherPort::class); + $events->expects(self::once())->method('dispatch')->with(self::callback(static fn (\Customer\Domain\Event\CustomerChangedEvent $event): bool => 'created' === $event->change && self::ID === $event->customerId)); + $result = new CreateCustomerHandler($repository, $this->access(OrganizationAccessDecision::GRANTED), $clock, $ids, $this->transactions(), $events)(new CreateCustomerCommand(self::ACTOR, self::ORG, 'Owner')); + self::assertSame(self::ID, $result->customer->id); + self::assertSame(1, $result->customer->revision); + } + + #[Test] + public function deniesUnentitledMemberBeforeReadingCustomers(): void + { + $repository = $this->createMock(CustomerRepositoryPort::class); + $repository->expects(self::never())->method('find'); + $this->expectException(CustomerException::class); + $this->expectExceptionMessage('Missing customer permission.'); + new GetCustomerHandler($repository, $this->access(OrganizationAccessDecision::MISSING_PERMISSION))(new GetCustomerQuery(self::ACTOR, self::ORG, self::ID)); + } + + #[Test] + public function hidesOutsideOrganizationBeforeReadingCustomers(): void + { + $repository = $this->createMock(CustomerRepositoryPort::class); + $repository->expects(self::never())->method('find'); + $this->expectException(CustomerException::class); + $this->expectExceptionMessage('Customer not found.'); + new GetCustomerHandler($repository, $this->access(OrganizationAccessDecision::OUTSIDE_SCOPE))(new GetCustomerQuery(self::ACTOR, self::ORG, self::ID)); + } + + #[Test] + public function archivalReplayDoesNotWriteAgain(): void + { + $now = new DateTimeImmutable(); + $customer = Customer::create(self::ID, self::ORG, 'Owner', null, null, null, [], $now)->archive($now); + $repository = $this->createMock(CustomerRepositoryPort::class); + $repository->expects(self::once())->method('find')->with(self::ID, self::ORG, true)->willReturn($customer); + $repository->expects(self::never())->method('save'); + $clock = $this->createMock(ClockPort::class); + $clock->expects(self::once())->method('now')->willReturn($now); + $events = $this->createMock(EventDispatcherPort::class); + $events->expects(self::never())->method('dispatch'); + $handler = new ChangeCustomerHandler($repository, $this->access(OrganizationAccessDecision::GRANTED), $clock, $this->transactions(), $events); + $result = $handler(new ChangeCustomerCommand(self::ACTOR, self::ORG, self::ID, 'archive', 2)); + self::assertSame(2, $result->customer->revision); + } + + #[Test] + public function staleRevisionNeverWrites(): void + { + $now = new DateTimeImmutable(); + $customer = Customer::create(self::ID, self::ORG, 'Owner', null, null, null, [], $now); + $repository = $this->createMock(CustomerRepositoryPort::class); + $repository->method('find')->willReturn($customer); + $repository->expects(self::never())->method('save'); + $clock = $this->createMock(ClockPort::class); + $clock->expects(self::never())->method('now'); + $this->expectException(CustomerException::class); + $this->expectExceptionMessage('revision is stale'); + $events = $this->createMock(EventDispatcherPort::class); + $events->expects(self::never())->method('dispatch'); + new ChangeCustomerHandler($repository, $this->access(OrganizationAccessDecision::GRANTED), $clock, $this->transactions(), $events)(new ChangeCustomerCommand(self::ACTOR, self::ORG, self::ID, 'patch', 0, ['name' => 'New'])); + } + + #[Test] + public function listAndTotalUseIdenticalScopeAndFilters(): void + { + $repository = $this->createMock(CustomerRepositoryPort::class); + $repository->expects(self::once())->method('list')->with(self::ORG, 'Owner', true, 10, 10)->willReturn([]); + $repository->expects(self::once())->method('count')->with(self::ORG, 'Owner', true)->willReturn(15); + $result = new ListCustomersHandler($repository, $this->access(OrganizationAccessDecision::GRANTED))(new ListCustomersQuery(self::ACTOR, self::ORG, ' Owner ', true, 2, 10)); + self::assertSame(15, $result->total); + self::assertSame(2, $result->page); + } + + private function access(OrganizationAccessDecision $decision): CustomerAccessGuard + { + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::once())->method('resolveAccess')->with(self::ACTOR, self::ORG, self::isString())->willReturn($decision); + + return new CustomerAccessGuard($authorization); + } + + private function transactions(): TransactionManagerPort + { + $transactions = $this->createMock(TransactionManagerPort::class); + $transactions->expects(self::once())->method('transactional')->willReturnCallback(static fn (callable $operation): mixed => $operation()); + + return $transactions; + } +} diff --git a/tests/Unit/Customer/Domain/Model/Customer/CustomerTest.php b/tests/Unit/Customer/Domain/Model/Customer/CustomerTest.php new file mode 100644 index 000000000..84c5c2b40 --- /dev/null +++ b/tests/Unit/Customer/Domain/Model/Customer/CustomerTest.php @@ -0,0 +1,65 @@ + ' Pat ', 'email' => 'pat@example.com', 'role' => ' Manager ']], $now); + self::assertSame('Building Owner', $customer->name); + self::assertSame('C-1', $customer->code); + self::assertNull($customer->email); + self::assertSame([['name' => 'Pat', 'email' => 'pat@example.com', 'phone' => null, 'role' => 'Manager']], $customer->contacts); + $archived = $customer->archive($now->modify('+1 day')); + self::assertSame(2, $archived->revision); + self::assertSame($customer->id, $archived->id); + self::assertSame($customer->contacts, $archived->contacts); + self::assertSame($archived, $archived->archive($now->modify('+2 days'))); + $restored = $archived->restore($now->modify('+3 days')); + self::assertNull($restored->archivedAt); + self::assertSame(3, $restored->revision); + self::assertSame($customer->createdAt, $restored->createdAt); + } + + #[Test] + public function patchesOmittedFieldsAndExplicitNullDifferently(): void + { + $now = new DateTimeImmutable(); + $customer = Customer::create(self::ID, self::ORG, 'Owner', 'C-1', 'owner@example.com', null, [], $now); + $changed = $customer->change(['code' => null, 'name' => 'New owner'], $now); + self::assertNull($changed->code); + self::assertSame('owner@example.com', $changed->email); + self::assertSame(2, $changed->revision); + } + + #[Test] + public function refusesInvalidContactAndNullName(): void + { + $this->expectException(CustomerException::class); + Customer::create(self::ID, self::ORG, 'Owner', null, null, null, [['name' => 'Pat', 'email' => 'invalid']], new DateTimeImmutable()); + } + + #[Test] + public function refusesNullNameRatherThanTreatingItAsAbsent(): void + { + $customer = Customer::create(self::ID, self::ORG, 'Owner', null, null, null, [], new DateTimeImmutable()); + $this->expectException(CustomerException::class); + $customer->change(['name' => null], new DateTimeImmutable()); + } +} diff --git a/tests/Unit/Customer/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportCustomerIdentityAdapterTest.php b/tests/Unit/Customer/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportCustomerIdentityAdapterTest.php new file mode 100644 index 000000000..fd8469ec7 --- /dev/null +++ b/tests/Unit/Customer/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportCustomerIdentityAdapterTest.php @@ -0,0 +1,37 @@ +createMock(EntityManagerInterface::class); + $main->expects(self::never())->method('getConnection'); + $adapter = new MaintenanceExportCustomerIdentityAdapter($main); + + foreach (['site', 'equipment', 'CUSTOMER', 'unknown'] as $type) { + self::assertFalse($adapter->exists( + 'd17e8400-e29b-41d4-a716-446655790001', + $type, + 'd17e8400-e29b-41d4-a716-446655790002', + )); + } + } +} diff --git a/tests/Unit/Customer/Presentation/Api/CustomerTransportTest.php b/tests/Unit/Customer/Presentation/Api/CustomerTransportTest.php new file mode 100644 index 000000000..9759c1498 --- /dev/null +++ b/tests/Unit/Customer/Presentation/Api/CustomerTransportTest.php @@ -0,0 +1,79 @@ +createMock(CommandBusPort::class); + $commands->expects(self::once())->method('dispatch')->with(self::callback(static fn (CreateCustomerCommand $command): bool => self::ORG === $command->organizationId && 'Owner' === $command->name))->willReturn(new CreateCustomerResult($this->view())); + $input = new CreateCustomerInput(); + $input->name = 'Owner'; + $output = new CustomerProcessor($commands, $this->actor(), new RequestStack())->process($input, new Post(name: CustomerOperations::CREATE), ['organizationId' => self::ORG]); + self::assertSame(self::ID, $output->id); + self::assertSame(1, $output->revision); + } + + #[Test] + public function patchOnlyIncludesSuppliedFieldsAndParsesQuotedRevision(): void + { + $requests = new RequestStack(); + $request = Request::create('/', 'PATCH', content: '{"code":null}'); + $request->headers->set('If-Match', '"revision-1"'); + $requests->push($request); + $commands = $this->createMock(CommandBusPort::class); + $commands->expects(self::once())->method('dispatch')->with(self::callback(static fn (ChangeCustomerCommand $command): bool => 1 === $command->expectedRevision && ['code' => null] === $command->changes && 'patch' === $command->action))->willReturn(new ChangeCustomerResult($this->view())); + new CustomerProcessor($commands, $this->actor(), $requests)->process(new ChangeCustomerInput(), new Patch(name: CustomerOperations::PATCH), ['organizationId' => self::ORG, 'id' => self::ID]); + } + + #[Test] + public function collectionCarriesSearchArchivalAndPagination(): void + { + $requests = new RequestStack(); + $requests->push(Request::create('/?search=Owner&archived=true&page=2&itemsPerPage=10')); + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::once())->method('ask')->with(self::callback(static fn (ListCustomersQuery $query): bool => 'Owner' === $query->search && $query->archived && 2 === $query->page && 10 === $query->itemsPerPage))->willReturn(new ListCustomersResult([], 12, 2, 10)); + $result = new CustomerProvider($queries, $this->actor(), $requests)->provide(new GetCollection(name: CustomerOperations::LIST), ['organizationId' => self::ORG]); + self::assertInstanceOf(\ApiPlatform\State\Pagination\TraversablePaginator::class, $result); + self::assertSame(12.0, $result->getTotalItems()); + } + + private function view(): CustomerView + { + return CustomerView::fromCustomer(Customer::create(self::ID, self::ORG, 'Owner', null, null, null, [], new DateTimeImmutable())); + } + + private function actor(): CurrentActorPort + { + $actor = $this->createMock(CurrentActorPort::class); + $actor->expects(self::once())->method('userId')->willReturn(self::ID); + + return $actor; + } +} diff --git a/tests/Unit/Equipment/Application/Service/EquipmentReserveReceiptServiceTest.php b/tests/Unit/Equipment/Application/Service/EquipmentReserveReceiptServiceTest.php new file mode 100644 index 000000000..659bd4028 --- /dev/null +++ b/tests/Unit/Equipment/Application/Service/EquipmentReserveReceiptServiceTest.php @@ -0,0 +1,84 @@ +createMock(CommandBusPort::class); + $commands->expects(self::never())->method('dispatch'); + $quota = $this->createMock(OrganizationQuotaPort::class); + $quota->expects(self::once())->method('assertCanAddMultiple')->with(self::ORG, OrganizationQuotaResource::EQUIPMENT, 2)->willThrowException(OrganizationQuotaExceededException::forResource('equipment', 1)); + $result = $this->service($commands, $quota)->reserve(new EquipmentReserveReceiptRequest(self::ORG, self::ACTOR, 'fire_extinguisher', ['name' => 'Reserve'], 2)); + self::assertSame([], $result->equipmentIds); + self::assertSame('quota_exceeded', $result->blockedReason); + } + + #[Test] + public function equipmentPermissionRevocationKeepsPhysicalReceiptPendingWithoutTouchingQuota(): void + { + $commands = $this->createMock(CommandBusPort::class); + $commands->expects(self::never())->method('dispatch'); + $quota = $this->createMock(OrganizationQuotaPort::class); + $quota->expects(self::never())->method('assertCanAddMultiple'); + $result = $this->service($commands, $quota, OrganizationAccessDecision::MISSING_PERMISSION)->reserve(new EquipmentReserveReceiptRequest(self::ORG, self::ACTOR, 'fire_extinguisher', [], 1)); + self::assertSame('missing_equipment_permission', $result->blockedReason); + self::assertSame([], $result->equipmentIds); + } + + #[Test] + public function archivedEquipmentTypeCannotCreateNewIndividualizedReserve(): void + { + $commands = $this->createMock(CommandBusPort::class); + $commands->expects(self::never())->method('dispatch'); + $quota = $this->createMock(OrganizationQuotaPort::class); + $quota->expects(self::never())->method('assertCanAddMultiple'); + $result = $this->service($commands, $quota, archived:true)->reserve(new EquipmentReserveReceiptRequest(self::ORG, self::ACTOR, 'fire_extinguisher', [], 1)); + self::assertSame('unavailable_type', $result->blockedReason); + } + + #[Test] + public function sharedTemplatesCannotRepeatUniquePatrimonialIdentities(): void + { + $commands = $this->createMock(CommandBusPort::class); + $commands->expects(self::never())->method('dispatch'); + $quota = $this->createMock(OrganizationQuotaPort::class); + $quota->expects(self::never())->method('assertCanAddMultiple'); + $this->expectException(InvalidValueException::class); + $this->service($commands, $quota)->reserve(new EquipmentReserveReceiptRequest(self::ORG, self::ACTOR, 'fire_extinguisher', ['assetCode' => 'SAME'], 2)); + } + + private function service(CommandBusPort $commands, OrganizationQuotaPort $quota, OrganizationAccessDecision $decision = OrganizationAccessDecision::GRANTED, bool $archived = false): EquipmentReserveReceiptService + { + $catalog = $this->createStub(EquipmentTypeCatalogPort::class); + $catalog->method('find')->willReturn(new EquipmentTypeDefinition('fire_extinguisher', 'Extinguisher', 'fire', $archived)); + $auth = $this->createStub(OrganizationAuthorizationPort::class); + $auth->method('resolveAccess')->willReturn($decision); + $tx = $this->createStub(TransactionManagerPort::class); + $tx->method('transactional')->willReturnCallback(static fn (callable $call): mixed => $call()); + + return new EquipmentReserveReceiptService($commands, $tx, $quota, $catalog, $auth); + } +} diff --git a/tests/Unit/Equipment/Application/Service/EquipmentSelectionScopeResolverTest.php b/tests/Unit/Equipment/Application/Service/EquipmentSelectionScopeResolverTest.php new file mode 100644 index 000000000..df8cc565c --- /dev/null +++ b/tests/Unit/Equipment/Application/Service/EquipmentSelectionScopeResolverTest.php @@ -0,0 +1,45 @@ +createStub(EquipmentTypeCatalogPort::class); + $catalog->method('list')->willReturn([ + new EquipmentTypeDefinition('fire_extinguisher', 'Extinguisher', 'fire'), + new EquipmentTypeDefinition('legacy_fire', 'Legacy', 'fire', true), + new EquipmentTypeDefinition('camera', 'Camera', 'safety'), + ]); + $resolver = new EquipmentSelectionScopeResolver($catalog, $this->createStub(CustomerLookupPort::class), $this->createStub(FacilityCustomerScopePort::class)); + self::assertSame(['fire_extinguisher', 'legacy_fire'], $resolver->typesForFamily('org', 'fire')); + self::assertNull($resolver->typesForFamily('org', null)); + $this->expectException(InvalidValueException::class); + $resolver->typesForFamily('org', 'building'); + } + + #[Test] + public function unknownAndForeignCustomerAreHiddenBeforeFacilityLookup(): void + { + $customers = $this->createStub(CustomerLookupPort::class); + $customers->method('find')->willReturn(null); + $facilities = $this->createMock(FacilityCustomerScopePort::class); + $facilities->expects(self::never())->method('findPublishedIdsForCustomer'); + $resolver = new EquipmentSelectionScopeResolver($this->createStub(EquipmentTypeCatalogPort::class), $customers, $facilities); + $this->expectException(EquipmentNotFoundException::class); + $resolver->customerFacilities('org', 'foreign', ['site']); + } +} diff --git a/tests/Unit/Equipment/Application/UseCase/Command/Equipment/RecordInterventionServiceHistory/RecordInterventionServiceHistoryHandlerTest.php b/tests/Unit/Equipment/Application/UseCase/Command/Equipment/RecordInterventionServiceHistory/RecordInterventionServiceHistoryHandlerTest.php index 073c3d062..40525e8e9 100644 --- a/tests/Unit/Equipment/Application/UseCase/Command/Equipment/RecordInterventionServiceHistory/RecordInterventionServiceHistoryHandlerTest.php +++ b/tests/Unit/Equipment/Application/UseCase/Command/Equipment/RecordInterventionServiceHistory/RecordInterventionServiceHistoryHandlerTest.php @@ -37,6 +37,25 @@ final class RecordInterventionServiceHistoryHandlerTest extends TestCase private const string CHANGE_TOKEN_2 = '550e8400-e29b-41d4-a716-446655482008'; + #[Test] + public function itRecordsTheActualWorkDateAuthorAndNotesFromValidatedExecution(): void + { + $performedAt = new DateTimeImmutable('2026-09-30T09:00:00+00:00'); + $author = '550e8400-e29b-41d4-a716-446655482099'; + $serviceReport = $this->createStub(InterventionServiceReportPort::class); + $serviceReport->method('serviceReport')->willReturn(new InterventionServiceReport(42, self::ACTOR_ID, [ + new ServicedEquipmentEntry(self::EQUIPMENT_ID_1, 'repair', self::CHANGE_TOKEN_1, 'work', $performedAt, 'Valve replaced', $author), + ])); + $logs = $this->createMock(MaintenanceLogRepositoryPort::class); + $logs->expects(self::once())->method('appendInterventionServiceEntry')->willReturnCallback(static function (EquipmentMaintenanceLog $log) use ($performedAt, $author): void { + self::assertSame($performedAt, $log->startedAt()); + self::assertSame($author, $log->actorId()); + self::assertSame('Valve replaced', $log->summary()); + self::assertSame('repair', $log->workItemAction()); + }); + ($this->handler($serviceReport, $logs))($this->command()); + } + #[Test] public function testInvokeReturnsVoidResultWhenNoServiceReportIsFound(): void { diff --git a/tests/Unit/Equipment/Application/UseCase/Command/Equipment/ReplaceEquipment/ReplaceEquipmentHandlerTest.php b/tests/Unit/Equipment/Application/UseCase/Command/Equipment/ReplaceEquipment/ReplaceEquipmentHandlerTest.php new file mode 100644 index 000000000..cc49b1efa --- /dev/null +++ b/tests/Unit/Equipment/Application/UseCase/Command/Equipment/ReplaceEquipment/ReplaceEquipmentHandlerTest.php @@ -0,0 +1,234 @@ +createMock(EquipmentReplacementRepositoryPort::class); + $repository->method('findReceiptForUpdate')->willReturnCallback(static function () use (&$receipt): ?EquipmentReplacementReceipt { + return $receipt; + }); + $repository->expects(self::once())->method('lockEquipment')->with('org', ['old', 'new'])->willReturn($this->candidates()); + $repository->expects(self::once())->method('save')->willReturnCallback(static function (EquipmentReplacement $replacement, EquipmentReplacementReceipt $saved) use (&$receipt): void { + self::assertSame('under_maintenance', $replacement->predecessor->status); + self::assertSame('operational', $replacement->successorStatus()); + $receipt = $saved; + }); + $maintenance = $this->createMock(EquipmentMaintenanceLogSynchronizerPort::class); + $maintenance->expects(self::once())->method('syncForStatusTransition')->with('old', 'org', 'under_maintenance', 'decommissioned'); + $commands = $this->createMock(CommandBusPort::class); + $commands->expects(self::never())->method('dispatch'); + $handler = $this->handler($repository, $maintenance, $commands); + $command = new ReplaceEquipmentCommand('org', 'old', 'operation', 'new'); + $first = $handler($command); + $replay = $handler($command); + self::assertFalse($first->replayed); + self::assertTrue($replay->replayed); + self::assertSame('old', $replay->predecessorEquipmentId); + self::assertSame('new', $replay->successorEquipmentId); + self::assertSame('operation', $replay->clientOperationId); + } + + /** + * Method aReusedOperationWithAnotherPayloadConflictsBeforeAnyReadsOrWrites + * + * @access public + * + * @return void + */ + #[Test] + public function aReusedOperationWithAnotherPayloadConflictsBeforeAnyReadsOrWrites(): void + { + $repository = $this->createMock(EquipmentReplacementRepositoryPort::class); + $repository->method('findReceiptForUpdate')->willReturn(new EquipmentReplacementReceipt('org', 'operation', 'old', 'new', 'different')); + $repository->expects(self::never())->method('lockEquipment'); + $repository->expects(self::never())->method('save'); + $this->expectException(EquipmentReplacementConflictException::class); + $this->handler($repository)(new ReplaceEquipmentCommand('org', 'old', 'operation', 'changed')); + } + + /** + * Method createsNewSuccessorBeforeCommittingItsReceipt + * + * @access public + * + * @return void + */ + #[Test] + public function createsNewSuccessorBeforeCommittingItsReceipt(): void + { + $repository = $this->createMock(EquipmentReplacementRepositoryPort::class); + $repository->method('findReceiptForUpdate')->willReturn(null); + $repository->expects(self::exactly(2))->method('lockEquipment')->willReturnCallback(function (string $organization, array $ids): array { + self::assertSame('org', $organization); + $candidates = $this->candidates(); + + return ['old'] === $ids ? ['old' => $candidates['old']] : ['new' => $candidates['new']]; + }); + $repository->expects(self::once())->method('save'); + $commands = $this->createMock(CommandBusPort::class); + $commands->expects(self::once())->method('dispatch')->with(self::callback( + static fn (object $command): bool => $command instanceof CreateEquipmentCommand && 'org' === $command->organizationId + && 'site' === $command->facilityId && 'fire_extinguisher' === $command->type + && 'Reserve asset' === $command->name && 'NEW-001' === $command->assetCode + && null === $command->interventionId && null === $command->clientId, + ))->willReturn($this->created()); + $result = $this->handler($repository, commands: $commands)(new ReplaceEquipmentCommand( + 'org', + 'old', + 'operation', + successor: new EquipmentReplacementSuccessor('fire_extinguisher', name: 'Reserve asset', assetCode: 'NEW-001'), + )); + self::assertSame('new', $result->successorEquipmentId); + self::assertFalse($result->replayed); + } + + /** + * Method missingOrForeignSuccessorCannotRetireTheOriginal + * + * @access public + * + * @return void + */ + #[Test] + public function missingOrForeignSuccessorCannotRetireTheOriginal(): void + { + $repository = $this->createMock(EquipmentReplacementRepositoryPort::class); + $repository->method('findReceiptForUpdate')->willReturn(null); + $repository->method('lockEquipment')->willReturn(['old' => $this->candidates()['old']]); + $repository->expects(self::never())->method('save'); + $this->expectException(EquipmentNotFoundException::class); + $this->handler($repository)(new ReplaceEquipmentCommand('org', 'old', 'operation', 'foreign')); + } + + /** + * Method quotaOrCreationFailureCannotRetireThePredecessor + * + * @access public + * + * @return void + */ + #[Test] + public function quotaOrCreationFailureCannotRetireThePredecessor(): void + { + $repository = $this->createMock(EquipmentReplacementRepositoryPort::class); + $repository->method('findReceiptForUpdate')->willReturn(null); + $repository->method('lockEquipment')->willReturn(['old' => $this->candidates()['old']]); + $repository->expects(self::never())->method('save'); + $commands = $this->createMock(CommandBusPort::class); + $commands->expects(self::once())->method('dispatch')->willThrowException(new RuntimeException('quota denied')); + $maintenance = $this->createMock(EquipmentMaintenanceLogSynchronizerPort::class); + $maintenance->expects(self::never())->method('syncForStatusTransition'); + $this->expectExceptionMessage('quota denied'); + $this->handler($repository, $maintenance, $commands)(new ReplaceEquipmentCommand( + 'org', + 'old', + 'operation', + successor: new EquipmentReplacementSuccessor('fire_extinguisher'), + )); + } + + /** + * Method refusesBothOrNeitherSuccessorDescriptions + * + * @access public + * + * @return void + */ + #[Test] + public function refusesBothOrNeitherSuccessorDescriptions(): void + { + $repository = $this->createMock(EquipmentReplacementRepositoryPort::class); + $repository->expects(self::never())->method('findReceiptForUpdate'); + $this->expectException(EquipmentReplacementConflictException::class); + $this->handler($repository)(new ReplaceEquipmentCommand('org', 'old', 'operation')); + } + + /** + * Method candidates + * + * @access private + * + * @return array the domain fixture + */ + private function candidates(): array + { + return [ + 'old' => new ReplacementEquipment('old', 'org', 'published', 'under_maintenance', 'site', 'Room'), + 'new' => new ReplacementEquipment('new', 'org', 'published', 'in_stock', null, null), + ]; + } + + /** + * Method created + * + * @access private + * + * @return CreateEquipmentResult the creation bus fixture + */ + private function created(): CreateEquipmentResult + { + return new CreateEquipmentResult('new', 'org', 'site', 'fire_extinguisher', null, null, null, null, null, 'in_stock', null, null, [], new DateTimeImmutable(), new DateTimeImmutable()); + } + + /** + * Method handler + * + * @access private + * + * @param EquipmentReplacementRepositoryPort $repository the test repository + * @param ?EquipmentMaintenanceLogSynchronizerPort $maintenance the lifecycle spy + * @param ?CommandBusPort $commands the creation bus spy + * + * @return ReplaceEquipmentHandler the use case under test + */ + private function handler(EquipmentReplacementRepositoryPort $repository, ?EquipmentMaintenanceLogSynchronizerPort $maintenance = null, ?CommandBusPort $commands = null): ReplaceEquipmentHandler + { + $transactions = $this->createStub(TransactionManagerPort::class); + $transactions->method('transactional')->willReturnCallback(static fn (callable $operation): mixed => $operation()); + + return new ReplaceEquipmentHandler( + $repository, + $transactions, + $maintenance ?? $this->createStub(EquipmentMaintenanceLogSynchronizerPort::class), + $commands ?? $this->createStub(CommandBusPort::class), + $this->createStub(FacilityValidationPort::class), + ); + } + // #endregion +} diff --git a/tests/Unit/Equipment/Application/UseCase/EquipmentTypeCatalog/EquipmentTypeCatalogHandlersTest.php b/tests/Unit/Equipment/Application/UseCase/EquipmentTypeCatalog/EquipmentTypeCatalogHandlersTest.php new file mode 100644 index 000000000..477a50673 --- /dev/null +++ b/tests/Unit/Equipment/Application/UseCase/EquipmentTypeCatalog/EquipmentTypeCatalogHandlersTest.php @@ -0,0 +1,93 @@ +createMock(EquipmentTypeCatalogPort::class); + $catalog->expects(self::once())->method('save')->with( + self::ORGANIZATION_ID, + self::callback(static fn (EquipmentTypeDefinition $type): bool => 'water_mist' === $type->value && 'Water mist' === $type->label && 'fire' === $type->family && !$type->archived && 1 === $type->revision), + null, + ); + $result = new CreateEquipmentTypeHandler($catalog)(new CreateEquipmentTypeCommand(self::ORGANIZATION_ID, 'water_mist', ' Water mist ', 'fire')); + self::assertSame('water_mist', $result->type->value); + self::assertSame('Water mist', $result->type->label); + self::assertSame(1, $result->type->revision); + } + + #[Test] + public function testListPreservesArchiveMetadataAndOrganizationScope(): void + { + $catalog = $this->createMock(EquipmentTypeCatalogPort::class); + $catalog->expects(self::once())->method('list')->with(self::ORGANIZATION_ID)->willReturn([ + new EquipmentTypeDefinition('old_unit', 'Old unit', 'other', true, 3), + ]); + $result = new ListEquipmentTypesHandler($catalog)(new ListEquipmentTypesQuery(self::ORGANIZATION_ID)); + self::assertCount(1, $result->types); + self::assertSame('old_unit', $result->types[0]->value); + self::assertSame('Old unit', $result->types[0]->label); + self::assertSame('other', $result->types[0]->family); + self::assertTrue($result->types[0]->archived); + self::assertSame(3, $result->types[0]->revision); + } + + #[Test] + public function testPatchArchivesWithOptimisticRevision(): void + { + $catalog = $this->createMock(EquipmentTypeCatalogPort::class); + $catalog->expects(self::once())->method('find')->with(self::ORGANIZATION_ID, 'fire_extinguisher')->willReturn(new EquipmentTypeDefinition('fire_extinguisher', 'Fire Extinguisher', 'fire')); + $catalog->expects(self::once())->method('save')->with( + self::ORGANIZATION_ID, + self::callback(static fn (EquipmentTypeDefinition $type): bool => $type->archived && 2 === $type->revision), + 1, + ); + $result = new PatchEquipmentTypeHandler($catalog)(new PatchEquipmentTypeCommand(self::ORGANIZATION_ID, 'fire_extinguisher', 1, archived: true)); + self::assertTrue($result->type->archived); + self::assertSame(2, $result->type->revision); + } + + #[Test] + public function testStalePatchNeverPersists(): void + { + $catalog = $this->createMock(EquipmentTypeCatalogPort::class); + $catalog->method('find')->willReturn(new EquipmentTypeDefinition('water_mist', 'Water mist', 'fire', false, 2)); + $catalog->expects(self::never())->method('save'); + $this->expectException(EquipmentTypeCatalogException::class); + new PatchEquipmentTypeHandler($catalog)(new PatchEquipmentTypeCommand(self::ORGANIZATION_ID, 'water_mist', 1, archived: true)); + } + + #[Test] + public function testUnknownPatchNeverPersists(): void + { + $catalog = $this->createMock(EquipmentTypeCatalogPort::class); + $catalog->method('find')->willReturn(null); + $catalog->expects(self::never())->method('save'); + $this->expectException(EquipmentTypeCatalogException::class); + new PatchEquipmentTypeHandler($catalog)(new PatchEquipmentTypeCommand(self::ORGANIZATION_ID, 'unknown_type', 1)); + } +} diff --git a/tests/Unit/Equipment/Application/UseCase/Query/Equipment/GetEquipment/GetEquipmentHandlerTest.php b/tests/Unit/Equipment/Application/UseCase/Query/Equipment/GetEquipment/GetEquipmentHandlerTest.php index 77fc20074..0565b0f6d 100644 --- a/tests/Unit/Equipment/Application/UseCase/Query/Equipment/GetEquipment/GetEquipmentHandlerTest.php +++ b/tests/Unit/Equipment/Application/UseCase/Query/Equipment/GetEquipment/GetEquipmentHandlerTest.php @@ -4,12 +4,15 @@ namespace Tests\Unit\Equipment\Application\UseCase\Query\Equipment\GetEquipment; +use DateTimeImmutable; use Equipment\Application\Port\Outbound\{EquipmentRepositoryPort, MaintenanceDueStatusPort, TagRepositoryPort}; use Equipment\Application\Port\Outbound\FacilityNamingPort; use Equipment\Application\UseCase\Query\Equipment\GetEquipment\{GetEquipmentHandler, GetEquipmentQuery, GetEquipmentResult}; use Equipment\Domain\Exception\EquipmentNotFoundException; use Equipment\Domain\Model\Equipment\Equipment; use Equipment\Domain\ValueObject\{EquipmentId, EquipmentOrganizationId, EquipmentType}; +use Maintenance\Application\Contract\Plan\MaintenanceEquipmentOperationsDue; +use Maintenance\Application\Port\Inbound\MaintenanceOperationsDuePort; use PHPUnit\Framework\Attributes\{CoversClass, Test}; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -145,6 +148,42 @@ public function testInvokeReturnsResultOnSuccess(): void self::assertSame('in_stock', $result->status); self::assertSame([], $result->tags); self::assertSame('due_soon', $result->maintenanceDueStatus); + self::assertSame('due_soon', $result->controlDueStatus); + self::assertSame('unscheduled', $result->serviceDueStatus); + self::assertNull($result->controlNextDueAt); + self::assertNull($result->serviceNextDueAt); + } + + #[Test] + public function projectsControlAndServiceIndependentlyThroughTheOwnerPort(): void + { + $equipment = Equipment::create( + EquipmentId::fromString(self::EQUIP_ID), + EquipmentOrganizationId::fromString(self::ORG_ID), + EquipmentType::FIRE_EXTINGUISHER, + ); + $repository = $this->createStub(EquipmentRepositoryPort::class); + $repository->method('findById')->willReturn($equipment); + $legacy = $this->createMock(MaintenanceDueStatusPort::class); + $legacy->expects(self::never())->method('dueStatusesForEquipment'); + $operations = $this->createMock(MaintenanceOperationsDuePort::class); + $operations->expects(self::once())->method('forEquipment')->with(self::ORG_ID, [self::EQUIP_ID])->willReturn([ + self::EQUIP_ID => new MaintenanceEquipmentOperationsDue( + 'up_to_date', + 'overdue', + new DateTimeImmutable('2027-02-01T10:00:00+01:00'), + new DateTimeImmutable('2026-10-01T08:30:00+00:00'), + 'plans', + ), + ]); + $handler = new GetEquipmentHandler($repository, $this->createStub(TagRepositoryPort::class), $legacy, $this->createStub(FacilityNamingPort::class), $operations); + $result = $handler(new GetEquipmentQuery(self::ORG_ID, self::EQUIP_ID)); + self::assertSame('up_to_date', $result->controlDueStatus); + self::assertSame('up_to_date', $result->maintenanceDueStatus); + self::assertSame('overdue', $result->serviceDueStatus); + self::assertSame('2027-02-01T10:00:00+01:00', $result->controlNextDueAt); + self::assertSame('2026-10-01T08:30:00+00:00', $result->serviceNextDueAt); + self::assertSame('in_stock', $result->status); } #[Test] diff --git a/tests/Unit/Equipment/Application/UseCase/Query/Equipment/GetOrganizationEquipmentSummary/GetOrganizationEquipmentSummaryHandlerTest.php b/tests/Unit/Equipment/Application/UseCase/Query/Equipment/GetOrganizationEquipmentSummary/GetOrganizationEquipmentSummaryHandlerTest.php new file mode 100644 index 000000000..204607eb8 --- /dev/null +++ b/tests/Unit/Equipment/Application/UseCase/Query/Equipment/GetOrganizationEquipmentSummary/GetOrganizationEquipmentSummaryHandlerTest.php @@ -0,0 +1,39 @@ +createStub(EquipmentTypeCatalogPort::class); + $catalog->method('list')->willReturn([new EquipmentTypeDefinition('water_mist', 'Water mist', 'fire'), new EquipmentTypeDefinition('camera', 'Camera', 'safety')]); + $customers = $this->createMock(CustomerLookupPort::class); + $customers->expects(self::once())->method('find')->with('customer', $org)->willReturn(new CustomerSnapshot('customer', 'Client', [], null)); + $facilities = $this->createMock(FacilityCustomerScopePort::class); + $facilities->expects(self::once())->method('findPublishedIdsForCustomer')->with($org, 'customer')->willReturn(['site', 'room']); + $repository = $this->createMock(EquipmentRepositoryPort::class); + $repository->expects(self::once())->method('countByStatusForCriteria')->with(new EquipmentOrganizationId($org), new EquipmentListCriteria(facilityIds: ['site', 'room'], typeCodes: ['water_mist'])) + ->willReturn(['operational' => 200, 'under_maintenance' => 2]); + $handler = new GetOrganizationEquipmentSummaryHandler($repository, new EquipmentSelectionScopeResolver($catalog, $customers, $facilities)); + $result = $handler(new GetOrganizationEquipmentSummaryQuery($org, 'fire', 'customer')); + self::assertSame('customer', $result->scope); + self::assertSame(202, $result->totalItems); + self::assertSame(['in_stock' => 0, 'operational' => 200, 'under_maintenance' => 2, 'decommissioned' => 0], $result->byStatus); + } +} diff --git a/tests/Unit/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsHandlerTest.php b/tests/Unit/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsHandlerTest.php index ef60b0f25..f34b9eb4c 100644 --- a/tests/Unit/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsHandlerTest.php +++ b/tests/Unit/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsHandlerTest.php @@ -14,6 +14,8 @@ use Equipment\Domain\Model\Equipment\Equipment; use Equipment\Domain\ValueObject\EquipmentFacilityId; use Equipment\Domain\ValueObject\{EquipmentId, EquipmentOrganizationId, EquipmentType}; +use Maintenance\Application\Contract\Plan\MaintenanceEquipmentOperationsDue; +use Maintenance\Application\Port\Inbound\MaintenanceOperationsDuePort; use PHPUnit\Framework\Attributes\{CoversClass, Test}; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -30,6 +32,87 @@ final class ListEquipmentsHandlerTest extends TestCase private const string EQUIP_ID_2 = '550e8400-e29b-41d4-a716-446655448003'; + #[Test] + public function resolvesIndependentDeadlinesInOnePageBatch(): void + { + $repository = $this->createStub(EquipmentRepositoryPort::class); + $repository->method('findByOrganizationId')->willReturn([ + Equipment::create(new EquipmentId(self::EQUIP_ID_1), new EquipmentOrganizationId(self::ORG_ID), EquipmentType::FIRE_EXTINGUISHER), + Equipment::create(new EquipmentId(self::EQUIP_ID_2), new EquipmentOrganizationId(self::ORG_ID), EquipmentType::FIRE_EXTINGUISHER), + ]); + $repository->method('countByOrganizationId')->willReturn(2); + $legacy = $this->createMock(MaintenanceDueStatusPort::class); + $legacy->expects(self::never())->method('dueStatusesForEquipment'); + $operations = $this->createMock(MaintenanceOperationsDuePort::class); + $operations->expects(self::once())->method('forEquipment')->with(self::ORG_ID, [self::EQUIP_ID_1, self::EQUIP_ID_2])->willReturn($this->independentDue()); + $handler = new ListEquipmentsHandler( + $repository, + $this->createStub(TagRepositoryPort::class), + $legacy, + $this->createStub(FacilityNamingPort::class), + $this->createStub(FacilitySubtreeScopePort::class), + operationsDue: $operations, + ); + $result = $handler(new ListEquipmentsQuery(self::ORG_ID)); + self::assertSame(2, $result->total); + self::assertSame('up_to_date', $result->items[0]->controlDueStatus); + self::assertSame('overdue', $result->items[0]->serviceDueStatus); + self::assertSame('2027-01-01T00:00:00+00:00', $result->items[0]->controlNextDueAt); + self::assertSame('2026-09-01T00:00:00+00:00', $result->items[0]->serviceNextDueAt); + self::assertSame('overdue', $result->items[1]->maintenanceDueStatus); + self::assertSame('unscheduled', $result->items[1]->serviceDueStatus); + self::assertNull($result->items[1]->serviceNextDueAt); + } + + #[Test] + public function theCompatibilityDueFilterMatchesControlsRatherThanServices(): void + { + $repository = $this->createMock(EquipmentRepositoryPort::class); + $repository->expects(self::once())->method('findByOrganizationId')->willReturn([ + Equipment::create(new EquipmentId(self::EQUIP_ID_1), new EquipmentOrganizationId(self::ORG_ID), EquipmentType::FIRE_EXTINGUISHER), + Equipment::create(new EquipmentId(self::EQUIP_ID_2), new EquipmentOrganizationId(self::ORG_ID), EquipmentType::FIRE_EXTINGUISHER), + ]); + $repository->expects(self::never())->method('countByOrganizationId'); + $legacy = $this->createMock(MaintenanceDueStatusPort::class); + $legacy->expects(self::never())->method('dueStatusesForEquipment'); + $operations = $this->createMock(MaintenanceOperationsDuePort::class); + $operations->expects(self::once())->method('forEquipment')->with(self::ORG_ID, [self::EQUIP_ID_1, self::EQUIP_ID_2])->willReturn($this->independentDue()); + $handler = new ListEquipmentsHandler( + $repository, + $this->createStub(TagRepositoryPort::class), + $legacy, + $this->createStub(FacilityNamingPort::class), + $this->createStub(FacilitySubtreeScopePort::class), + operationsDue: $operations, + ); + $result = $handler(new ListEquipmentsQuery(self::ORG_ID, maintenanceDueStatus: 'overdue')); + self::assertSame(1, $result->total); + self::assertSame(self::EQUIP_ID_2, $result->items[0]->equipmentId); + self::assertSame('overdue', $result->items[0]->controlDueStatus); + self::assertSame('unscheduled', $result->items[0]->serviceDueStatus); + } + + #[Test] + public function dueQueueCombinesSoonAndOverdueBeforeOnePagination(): void + { + $currentId = '550e8400-e29b-41d4-a716-446655448004'; + $repository = $this->createMock(EquipmentRepositoryPort::class); + $repository->expects(self::once())->method('findByOrganizationId')->willReturn([ + Equipment::create(new EquipmentId(self::EQUIP_ID_1), new EquipmentOrganizationId(self::ORG_ID), EquipmentType::FIRE_EXTINGUISHER), + Equipment::create(new EquipmentId(self::EQUIP_ID_2), new EquipmentOrganizationId(self::ORG_ID), EquipmentType::FIRE_EXTINGUISHER), + Equipment::create(new EquipmentId($currentId), new EquipmentOrganizationId(self::ORG_ID), EquipmentType::FIRE_EXTINGUISHER), + ]); + $repository->expects(self::never())->method('countByOrganizationId'); + $due = $this->createStub(MaintenanceDueStatusPort::class); + $due->method('dueStatusesForEquipment')->willReturn([self::EQUIP_ID_1 => 'due_soon', self::EQUIP_ID_2 => 'overdue', $currentId => 'up_to_date']); + $handler = new ListEquipmentsHandler($repository, $this->createStub(TagRepositoryPort::class), $due, $this->createStub(FacilityNamingPort::class), $this->createStub(FacilitySubtreeScopePort::class)); + $result = $handler(new ListEquipmentsQuery(self::ORG_ID, pagination: new Pagination(offset: 1, limit: 1), maintenanceDueStatus: 'due')); + self::assertSame(2, $result->total); + self::assertCount(1, $result->items); + self::assertSame(self::EQUIP_ID_2, $result->items[0]->equipmentId); + self::assertSame('overdue', $result->items[0]->maintenanceDueStatus); + } + #[Test] public function testDescendantCandidatesShareSearchAndPageCriteriaWithTheirCount(): void { @@ -639,4 +722,27 @@ public function testInvokeSkipsTheNamingLookupWhenNoEquipmentIsAssigned(): void self::assertCount(1, $result->items); self::assertNull($result->items[0]->facilityName); } + + /** + * @return array deadlines that deliberately disagree by operation kind + */ + private function independentDue(): array + { + return [ + self::EQUIP_ID_1 => new MaintenanceEquipmentOperationsDue( + 'up_to_date', + 'overdue', + new DateTimeImmutable('2027-01-01T00:00:00+00:00'), + new DateTimeImmutable('2026-09-01T00:00:00+00:00'), + 'plans', + ), + self::EQUIP_ID_2 => new MaintenanceEquipmentOperationsDue( + 'overdue', + 'unscheduled', + new DateTimeImmutable('2026-10-01T00:00:00+00:00'), + null, + 'plans', + ), + ]; + } } diff --git a/tests/Unit/Equipment/Domain/Model/EquipmentTypeCatalog/EquipmentTypeDefinitionTest.php b/tests/Unit/Equipment/Domain/Model/EquipmentTypeCatalog/EquipmentTypeDefinitionTest.php new file mode 100644 index 000000000..41707e7a5 --- /dev/null +++ b/tests/Unit/Equipment/Domain/Model/EquipmentTypeCatalog/EquipmentTypeDefinitionTest.php @@ -0,0 +1,63 @@ +assertAvailable('fire_extinguisher'); + $this->expectException(EquipmentTypeCatalogException::class); + $type->assertAvailable(null); + } + + #[Test] + public function testRevisionChangesOnlyForRealEditsAndCodeRemainsStable(): void + { + $type = EquipmentTypeDefinition::create('water_mist', ' Water mist ', 'fire'); + self::assertSame('Water mist', $type->label); + self::assertSame(1, $type->revise(null, null, null)->revision); + $archived = $type->revise('Mist unit', 'safety', true); + self::assertSame('water_mist', $archived->value); + self::assertSame(2, $archived->revision); + self::assertSame('Mist unit', $archived->label); + self::assertSame('safety', $archived->family); + self::assertTrue($archived->archived); + } + + #[Test] + #[DataProvider('invalidDefinitions')] + public function testInvalidDefinitionsAreRejected(string $code, string $label, string $family): void + { + $this->expectException(EquipmentTypeCatalogException::class); + EquipmentTypeDefinition::create($code, $label, $family); + } + + /** + * Method invalidDefinitions. + * + * @return iterable invalid catalog inputs + */ + public static function invalidDefinitions(): iterable + { + yield 'uppercase' => ['Mist', 'Mist unit', 'fire']; + yield 'long code' => ['abcdefghijklmnopqrstuvwxyzabcdefg', 'Mist unit', 'fire']; + yield 'empty label' => ['mist', ' ', 'fire']; + yield 'unknown family' => ['mist', 'Mist unit', 'industrial']; + } +} diff --git a/tests/Unit/Equipment/Domain/Model/Replacement/EquipmentReplacementTest.php b/tests/Unit/Equipment/Domain/Model/Replacement/EquipmentReplacementTest.php new file mode 100644 index 000000000..035fcdb76 --- /dev/null +++ b/tests/Unit/Equipment/Domain/Model/Replacement/EquipmentReplacementTest.php @@ -0,0 +1,98 @@ + invalid candidate pairs + */ + public static function invalidCandidates(): iterable + { + $old = new ReplacementEquipment('old', 'org', 'published', 'operational', 'site', 'Room'); + $stock = new ReplacementEquipment('new', 'org', 'published', 'in_stock', null, null); + yield 'same asset' => [$old, $old]; + yield 'foreign successor' => [$old, new ReplacementEquipment('new', 'other', 'published', 'in_stock', null, null)]; + yield 'draft predecessor' => [new ReplacementEquipment('old', 'org', 'draft', 'operational', 'site', null), $stock]; + yield 'retired predecessor' => [new ReplacementEquipment('old', 'org', 'published', 'decommissioned', 'site', null), $stock]; + yield 'already replaced predecessor' => [new ReplacementEquipment('old', 'org', 'published', 'operational', 'site', null, successorId: 'previous'), $stock]; + yield 'draft successor' => [$old, new ReplacementEquipment('new', 'org', 'draft', 'in_stock', null, null)]; + yield 'deployed successor' => [$old, new ReplacementEquipment('new', 'org', 'published', 'operational', 'site', null)]; + yield 'previous successor' => [$old, new ReplacementEquipment('new', 'org', 'published', 'in_stock', null, null, predecessorId: 'previous')]; + yield 'replacement chain reused' => [$old, new ReplacementEquipment('new', 'org', 'published', 'in_stock', null, null, successorId: 'previous')]; + yield 'deployed predecessor without site' => [new ReplacementEquipment('old', 'org', 'published', 'operational', null, null), $stock]; + } + + /** + * Method refusesInvalidCandidates + * + * @access public + * + * @param ReplacementEquipment $old the invalid predecessor + * @param ReplacementEquipment $successor the invalid successor + * + * @return void + */ + #[Test] + #[DataProvider('invalidCandidates')] + public function refusesInvalidCandidates(ReplacementEquipment $old, ReplacementEquipment $successor): void + { + $this->expectException(EquipmentReplacementConflictException::class); + new EquipmentReplacement($old, $successor); + } + + /** + * Method preservesChainsAndCommissionsAReplacementForMaintenance + * + * @access public + * + * @return void + */ + #[Test] + public function preservesChainsAndCommissionsAReplacementForMaintenance(): void + { + $replacement = new EquipmentReplacement( + new ReplacementEquipment('old', 'org', 'published', 'under_maintenance', 'site', 'Room', predecessorId: 'earlier'), + new ReplacementEquipment('new', 'org', 'published', 'in_stock', null, null), + ); + self::assertSame('operational', $replacement->successorStatus()); + self::assertSame('earlier', $replacement->predecessor->predecessorId); + } + + /** + * Method replacingAReserveKeepsItsSuccessorInStock + * + * @access public + * + * @return void + */ + #[Test] + public function replacingAReserveKeepsItsSuccessorInStock(): void + { + $replacement = new EquipmentReplacement( + new ReplacementEquipment('old', 'org', 'published', 'in_stock', null, null), + new ReplacementEquipment('new', 'org', 'published', 'in_stock', null, null), + ); + self::assertSame('in_stock', $replacement->successorStatus()); + } + // #endregion +} diff --git a/tests/Unit/Equipment/Domain/ValueObject/EquipmentIdentityTest.php b/tests/Unit/Equipment/Domain/ValueObject/EquipmentIdentityTest.php new file mode 100644 index 000000000..a8ebba07c --- /dev/null +++ b/tests/Unit/Equipment/Domain/ValueObject/EquipmentIdentityTest.php @@ -0,0 +1,48 @@ + 'charge', 'value' => '6', 'unit' => ' kg ']]); + self::assertSame('CO2 Stair B', $identity->name); + self::assertSame('EXT-001', $identity->assetCode); + self::assertSame([['key' => 'charge', 'value' => '6', 'unit' => 'kg']], $identity->technicalProperties); + self::assertNull(EquipmentIdentity::fromValues(' ', ' ')->assetCode); + } + + #[Test] + public function itRejectsRepeatedKeysAfterNormalization(): void + { + $this->expectException(InvalidValueException::class); + EquipmentIdentity::fromValues(technicalProperties: [['key' => 'charge', 'value' => '6'], ['key' => ' charge ', 'value' => '9']]); + } + + #[Test] + public function itRejectsUnknownCriticality(): void + { + $this->expectException(InvalidValueException::class); + EquipmentIdentity::fromValues(criticality: 'urgent'); + } + + #[Test] + public function itRejectsExcessivePropertiesAndPreservesBuiltInTypes(): void + { + self::assertSame(EquipmentType::FIRE_EXTINGUISHER, EquipmentTypeCode::fromString('fire_extinguisher')); + self::assertSame('water_mist', EquipmentTypeCode::fromString('water_mist')->value); + $this->expectException(InvalidValueException::class); + EquipmentIdentity::fromValues(technicalProperties: array_fill(0, 51, ['key' => 'capacity', 'value' => '6'])); + } +} diff --git a/tests/Unit/Equipment/Infrastructure/Adapter/Inspection/EquipmentValidationAdapterTest.php b/tests/Unit/Equipment/Infrastructure/Adapter/Inspection/EquipmentValidationAdapterTest.php index 510f239a6..ed6b00bb0 100644 --- a/tests/Unit/Equipment/Infrastructure/Adapter/Inspection/EquipmentValidationAdapterTest.php +++ b/tests/Unit/Equipment/Infrastructure/Adapter/Inspection/EquipmentValidationAdapterTest.php @@ -32,6 +32,46 @@ final class EquipmentValidationAdapterTest extends TestCase private const string OTHER_FACILITY_ID = '550e8400-e29b-41d4-a716-446655440004'; // #region Methods + #[Test] + public function publishedLookupPreservesRetiredHistoryWithoutUsingTheDraftLookup(): void + { + $repository = $this->createMock(EquipmentRepositoryPort::class); + $repository->expects(self::never())->method('findById'); + $repository->expects(self::once())->method('findPublishedById')->with(EquipmentId::fromString(self::EQUIP_ID))->willReturn($this->equipment(EquipmentStatus::DECOMMISSIONED, null)); + new EquipmentValidationAdapter($repository)->assertPublishedEquipmentExists(self::EQUIP_ID, self::ORG_ID); + } + + #[Test] + public function draftAndMissingPublishedEquipmentShareTheSameRefusal(): void + { + $repository = $this->createMock(EquipmentRepositoryPort::class); + $repository->expects(self::never())->method('findById'); + $repository->expects(self::once())->method('findPublishedById')->willReturn(null); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('not found'); + new EquipmentValidationAdapter($repository)->assertPublishedEquipmentExists(self::EQUIP_ID, self::ORG_ID); + } + + #[Test] + public function publishedForeignEquipmentIsHidden(): void + { + $repository = $this->createStub(EquipmentRepositoryPort::class); + $repository->method('findPublishedById')->willReturn($this->equipment(EquipmentStatus::OPERATIONAL, self::FACILITY_ID)); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('not found'); + new EquipmentValidationAdapter($repository)->assertPublishedEquipmentExists(self::EQUIP_ID, '550e8400-e29b-41d4-a716-446655440099'); + } + + #[Test] + public function malformedPublishedIdentityIsHiddenWithoutPersistenceLookup(): void + { + $repository = $this->createMock(EquipmentRepositoryPort::class); + $repository->expects(self::never())->method('findPublishedById'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('not found'); + new EquipmentValidationAdapter($repository)->assertPublishedEquipmentExists('unknown', self::ORG_ID); + } + #[Test] public function testAssertEquipmentIsInspectablePassesForOperationalEquipmentAtMatchingFacility(): void { diff --git a/tests/Unit/Equipment/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportEquipmentIdentityAdapterTest.php b/tests/Unit/Equipment/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportEquipmentIdentityAdapterTest.php new file mode 100644 index 000000000..79485e3bf --- /dev/null +++ b/tests/Unit/Equipment/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportEquipmentIdentityAdapterTest.php @@ -0,0 +1,37 @@ +createMock(EntityManagerInterface::class); + $main->expects(self::never())->method('getConnection'); + $adapter = new MaintenanceExportEquipmentIdentityAdapter($main); + + foreach (['customer', 'site', 'EQUIPMENT', 'unknown'] as $type) { + self::assertFalse($adapter->exists( + 'd17e8400-e29b-41d4-a716-446655790001', + $type, + 'd17e8400-e29b-41d4-a716-446655790002', + )); + } + } +} diff --git a/tests/Unit/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepositoryTest.php b/tests/Unit/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepositoryTest.php index d4793fa8f..dcb234888 100644 --- a/tests/Unit/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepositoryTest.php +++ b/tests/Unit/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepositoryTest.php @@ -78,7 +78,7 @@ public function testCountByOrganizationIdPushesWildcardSafeSearchPredicateIntoSq self::assertNotNull($searchClause, 'The search predicate must be pushed down into the query builder.'); self::assertSame( - "(LOWER(e.type) LIKE :search ESCAPE '\\' OR LOWER(e.subType) LIKE :search ESCAPE '\\' OR LOWER(e.brand) LIKE :search ESCAPE '\\' OR LOWER(e.model) LIKE :search ESCAPE '\\' OR LOWER(e.serialNumber) LIKE :search ESCAPE '\\' OR LOWER(e.status) LIKE :search ESCAPE '\\' OR LOWER(e.locationLabel) LIKE :search ESCAPE '\\')", + "(LOWER(e.type) LIKE :search ESCAPE '\\' OR LOWER(e.name) LIKE :search ESCAPE '\\' OR LOWER(e.assetCode) LIKE :search ESCAPE '\\' OR LOWER(e.subType) LIKE :search ESCAPE '\\' OR LOWER(e.brand) LIKE :search ESCAPE '\\' OR LOWER(e.model) LIKE :search ESCAPE '\\' OR LOWER(e.serialNumber) LIKE :search ESCAPE '\\' OR LOWER(e.status) LIKE :search ESCAPE '\\' OR LOWER(e.locationLabel) LIKE :search ESCAPE '\\')", $searchClause, ); diff --git a/tests/Unit/Equipment/Presentation/Api/Controller/ExportEquipmentReportControllerTest.php b/tests/Unit/Equipment/Presentation/Api/Controller/ExportEquipmentReportControllerTest.php index 779967df0..e619020a7 100644 --- a/tests/Unit/Equipment/Presentation/Api/Controller/ExportEquipmentReportControllerTest.php +++ b/tests/Unit/Equipment/Presentation/Api/Controller/ExportEquipmentReportControllerTest.php @@ -105,6 +105,12 @@ public function testItShapesTheIdentitySectionFromTheEquipmentResult(): void $context = $this->renderedContext($this->queryBus()); self::assertSame('extinguisher', $context['type']); + self::assertSame('Hall extinguisher', $context['name']); + self::assertSame('EXT-0042', $context['assetCode']); + self::assertSame('critical', $context['criticality']); + self::assertSame([['key' => 'capacity', 'value' => '6', 'unit' => 'kg']], $context['technicalProperties']); + self::assertSame('550e8400-e29b-41d4-a716-446655508105', $context['predecessorEquipmentId']); + self::assertNull($context['successorEquipmentId']); self::assertSame('co2', $context['subType']); self::assertSame('Acme', $context['brand']); self::assertSame('X-200', $context['model']); @@ -112,6 +118,10 @@ public function testItShapesTheIdentitySectionFromTheEquipmentResult(): void self::assertSame('operational', $context['status']); self::assertSame('Main Building', $context['facilityName']); self::assertSame('up_to_date', $context['maintenanceDueStatus']); + self::assertSame('up_to_date', $context['controlDueStatus']); + self::assertSame('overdue', $context['serviceDueStatus']); + self::assertSame('01/09/2026', $context['controlNextDueAt']); + self::assertSame('01/05/2026', $context['serviceNextDueAt']); self::assertSame(['fire', 'ground-floor'], $context['tags']); self::assertSame(1, $context['maintenanceLogsTotal']); } @@ -379,6 +389,15 @@ private function queryBus(): QueryBusPort updatedAt: new DateTimeImmutable('2026-06-01T00:00:00+00:00'), maintenanceDueStatus: 'up_to_date', facilityName: 'Main Building', + name: 'Hall extinguisher', + assetCode: 'EXT-0042', + criticality: 'critical', + technicalProperties: [['key' => 'capacity', 'value' => '6', 'unit' => 'kg']], + predecessorEquipmentId: '550e8400-e29b-41d4-a716-446655508105', + controlDueStatus: 'up_to_date', + serviceDueStatus: 'overdue', + controlNextDueAt: '2026-09-01T00:00:00+00:00', + serviceNextDueAt: '2026-05-01T00:00:00+00:00', ); $logsResult = new ListMaintenanceLogsResult( diff --git a/tests/Unit/Equipment/Presentation/Api/EquipmentTypeCatalog/EquipmentTypeCatalogPresentationTest.php b/tests/Unit/Equipment/Presentation/Api/EquipmentTypeCatalog/EquipmentTypeCatalogPresentationTest.php new file mode 100644 index 000000000..dc79e02f3 --- /dev/null +++ b/tests/Unit/Equipment/Presentation/Api/EquipmentTypeCatalog/EquipmentTypeCatalogPresentationTest.php @@ -0,0 +1,120 @@ +createMock(QueryBusPort::class); + $queryBus->expects(self::once())->method('ask')->with(self::callback(static fn (ListEquipmentTypesQuery $query): bool => self::ORGANIZATION_ID === $query->organizationId))->willReturn( + new ListEquipmentTypesResult([new EquipmentTypeDescriptor('water_mist', 'Water mist', 'fire', true, 2)]), + ); + $outputs = new EquipmentTypeCatalogProvider($queryBus, $this->access('organization.equipment.read'))->provide(new GetCollection(), ['organizationId' => self::ORGANIZATION_ID]); + self::assertIsArray($outputs); + self::assertCount(1, $outputs); + self::assertSame('water_mist', $outputs[0]->value); + self::assertSame('Water mist', $outputs[0]->label); + self::assertSame('fire', $outputs[0]->family); + self::assertTrue($outputs[0]->archived); + self::assertSame(2, $outputs[0]->revision); + } + + #[Test] + public function testMissingReadPermissionNeverDispatches(): void + { + $queryBus = $this->createMock(QueryBusPort::class); + $queryBus->expects(self::never())->method('ask'); + $this->expectException(AccessDeniedHttpException::class); + new EquipmentTypeCatalogProvider($queryBus, $this->access('organization.equipment.read', OrganizationAccessDecision::MISSING_PERMISSION))->provide(new GetCollection(), ['organizationId' => self::ORGANIZATION_ID]); + } + + #[Test] + public function testOutsideOrganizationIsHidden(): void + { + $queryBus = $this->createMock(QueryBusPort::class); + $queryBus->expects(self::never())->method('ask'); + $this->expectException(NotFoundHttpException::class); + new EquipmentTypeCatalogProvider($queryBus, $this->access('organization.equipment.read', OrganizationAccessDecision::OUTSIDE_SCOPE))->provide(new GetCollection(), ['organizationId' => self::ORGANIZATION_ID]); + } + + #[Test] + public function testProcessorCreatesViaCommandBus(): void + { + $commandBus = $this->createMock(CommandBusPort::class); + $commandBus->expects(self::once())->method('dispatch')->with(self::callback(static fn (CreateEquipmentTypeCommand $command): bool => self::ORGANIZATION_ID === $command->organizationId && 'water_mist' === $command->value && 'Water mist' === $command->label && 'fire' === $command->family))->willReturn( + new CreateEquipmentTypeResult(new EquipmentTypeDescriptor('water_mist', 'Water mist', 'fire', false, 1)), + ); + $input = new CreateEquipmentTypeInput(); + $input->value = 'water_mist'; + $input->label = 'Water mist'; + $output = new EquipmentTypeCatalogProcessor($commandBus, $this->access('organization.equipment.write'))->process($input, new Post(), ['organizationId' => self::ORGANIZATION_ID]); + self::assertSame('water_mist', $output->value); + self::assertSame(1, $output->revision); + } + + #[Test] + public function testProcessorPreservesPatchRevisionAndArchiveIntent(): void + { + $commandBus = $this->createMock(CommandBusPort::class); + $commandBus->expects(self::once())->method('dispatch')->with(self::callback(static fn (PatchEquipmentTypeCommand $command): bool => 'water_mist' === $command->value && 1 === $command->revision && true === $command->archived))->willReturn( + new PatchEquipmentTypeResult(new EquipmentTypeDescriptor('water_mist', 'Water mist', 'fire', true, 2)), + ); + $input = new PatchEquipmentTypeInput(); + $input->revision = 1; + $input->archived = true; + $output = new EquipmentTypeCatalogProcessor($commandBus, $this->access('organization.equipment.write'))->process($input, new Patch(), ['organizationId' => self::ORGANIZATION_ID, 'typeCode' => 'water_mist']); + self::assertTrue($output->archived); + self::assertSame(2, $output->revision); + } + + /** + * Method access. + * + * @param string $permission expected permission + * @param OrganizationAccessDecision $decision access decision + * + * @return EquipmentTypeCatalogAccess gated presentation service + */ + private function access(string $permission, OrganizationAccessDecision $decision = OrganizationAccessDecision::GRANTED): EquipmentTypeCatalogAccess + { + $user = new SecurityUser('770e8400-e29b-41d4-a716-446655491002', 'catalog@example.com', 'unused'); + $security = $this->createStub(Security::class); + $security->method('getUser')->willReturn($user); + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::once())->method('resolveAccess')->with($user->getId(), self::ORGANIZATION_ID, $permission)->willReturn($decision); + + return new EquipmentTypeCatalogAccess($security, $authorization); + } +} diff --git a/tests/Unit/Equipment/Presentation/Api/EventSubscriber/EquipmentReplacementFailureSubscriberTest.php b/tests/Unit/Equipment/Presentation/Api/EventSubscriber/EquipmentReplacementFailureSubscriberTest.php new file mode 100644 index 000000000..32a5b9569 --- /dev/null +++ b/tests/Unit/Equipment/Presentation/Api/EventSubscriber/EquipmentReplacementFailureSubscriberTest.php @@ -0,0 +1,82 @@ + mapped replacement failures + */ + public static function failures(): iterable + { + yield 'changed operation' => [EquipmentReplacementConflictException::because('changed'), 409]; + yield 'unavailable equipment' => [EquipmentNotFoundException::withId('hidden'), 404]; + yield 'duplicate serial' => [EquipmentSerialNumberAlreadyExistsException::withSerialNumber('duplicate'), 409]; + yield 'invalid identity' => [InvalidValueException::because('invalid'), 422]; + } + + /** + * Method preservesTheFailureStatusThroughWrapping + * + * @access public + * + * @param Throwable $failure the business failure + * @param int $status the recovery status + * + * @return void + */ + #[Test] + #[DataProvider('failures')] + public function preservesTheFailureStatusThroughWrapping(Throwable $failure, int $status): void + { + $request = new Request(); + $request->attributes->set('_api_operation_name', 'equipment_replace'); + $event = new ExceptionEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, new RuntimeException('wrapped', 0, $failure)); + new EquipmentReplacementFailureSubscriber()->onException($event); + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame($status, $response->getStatusCode()); + self::assertSame('application/problem+json', $response->headers->get('Content-Type')); + } + + /** + * Method leavesOtherOperationsToTheirErrorOwner + * + * @access public + * + * @return void + */ + #[Test] + public function leavesOtherOperationsToTheirErrorOwner(): void + { + $event = new ExceptionEvent($this->createStub(HttpKernelInterface::class), new Request(), HttpKernelInterface::MAIN_REQUEST, EquipmentNotFoundException::withId('hidden')); + new EquipmentReplacementFailureSubscriber()->onException($event); + self::assertNull($event->getResponse()); + } + // #endregion +} diff --git a/tests/Unit/Equipment/Presentation/Api/Factory/EquipmentOutputFactoryTest.php b/tests/Unit/Equipment/Presentation/Api/Factory/EquipmentOutputFactoryTest.php new file mode 100644 index 000000000..a9e2a172c --- /dev/null +++ b/tests/Unit/Equipment/Presentation/Api/Factory/EquipmentOutputFactoryTest.php @@ -0,0 +1,102 @@ +fromView($view); + self::assertSame('overdue', $view->maintenanceDueStatus); + self::assertSame('up_to_date', $view->controlDueStatus); + self::assertSame('up_to_date', $output->controlDueStatus); + self::assertSame($output->controlDueStatus, $output->maintenanceDueStatus); + self::assertSame('overdue', $output->serviceDueStatus); + self::assertSame('2027-01-01T00:00:00+00:00', $output->controlNextDueAt); + self::assertSame('2026-09-01T00:00:00+00:00', $output->serviceNextDueAt); + self::assertSame('operational', $output->status); + } + + /** + * Method preservesDefaultsForCommandResultsWithoutDeadlineProjection + * + * @access public + * + * @return void + */ + #[Test] + public function preservesDefaultsForCommandResultsWithoutDeadlineProjection(): void + { + $view = new CreateEquipmentResult( + 'equipment', + 'organization', + null, + 'fire_extinguisher', + null, + null, + null, + null, + null, + 'in_stock', + null, + null, + [], + new DateTimeImmutable(), + new DateTimeImmutable(), + ); + $output = new EquipmentOutputFactory()->fromView($view); + self::assertSame('unscheduled', $output->maintenanceDueStatus); + self::assertSame('unscheduled', $output->controlDueStatus); + self::assertSame('unscheduled', $output->serviceDueStatus); + self::assertNull($output->controlNextDueAt); + self::assertNull($output->serviceNextDueAt); + } + // #endregion +} diff --git a/tests/Unit/Equipment/Presentation/Api/Processor/Equipment/ReplaceEquipmentProcessorTest.php b/tests/Unit/Equipment/Presentation/Api/Processor/Equipment/ReplaceEquipmentProcessorTest.php new file mode 100644 index 000000000..3fc94a999 --- /dev/null +++ b/tests/Unit/Equipment/Presentation/Api/Processor/Equipment/ReplaceEquipmentProcessorTest.php @@ -0,0 +1,115 @@ + contextual denial decisions + */ + public static function denials(): iterable + { + yield 'foreign organization is hidden' => [OrganizationAccessDecision::OUTSIDE_SCOPE, 404]; + yield 'missing permission' => [OrganizationAccessDecision::MISSING_PERMISSION, 403]; + } + + /** + * Method deniesWithoutDispatching + * + * @access public + * + * @param OrganizationAccessDecision $decision the denied access + * @param int $status the expected HTTP status + * + * @return void + */ + #[Test] + #[DataProvider('denials')] + public function deniesWithoutDispatching(OrganizationAccessDecision $decision, int $status): void + { + $commands = $this->createMock(CommandBusPort::class); + $commands->expects(self::never())->method('dispatch'); + + try { + $this->processor($commands, $decision)->process(new ReplaceEquipmentInput(), new Post(), ['organizationId' => 'org', 'equipmentId' => 'old']); + } catch (HttpExceptionInterface $failure) { + self::assertSame($status, $failure->getStatusCode()); + + return; + } + self::fail('Expected contextual denial.'); + } + + /** + * Method translatesExistingSuccessorAndDurableResult + * + * @access public + * + * @return void + */ + #[Test] + public function translatesExistingSuccessorAndDurableResult(): void + { + $commands = $this->createMock(CommandBusPort::class); + $commands->expects(self::once())->method('dispatch')->with(self::callback( + static fn (object $command): bool => $command instanceof ReplaceEquipmentCommand && 'org' === $command->organizationId + && 'old' === $command->equipmentId && 'operation' === $command->clientOperationId && 'new' === $command->successorEquipmentId, + ))->willReturn(new ReplaceEquipmentResult('old', 'new', 'operation', true)); + $input = new ReplaceEquipmentInput(); + $input->clientOperationId = 'operation'; + $input->successorEquipmentId = 'new'; + $output = $this->processor($commands)->process($input, new Post(), ['organizationId' => 'org', 'equipmentId' => 'old']); + self::assertSame('old', $output->predecessorEquipmentId); + self::assertSame('new', $output->successorEquipmentId); + self::assertSame('operation', $output->clientOperationId); + self::assertTrue($output->replayed); + } + + /** + * Method processor + * + * @access private + * + * @param CommandBusPort $commands the command spy + * @param OrganizationAccessDecision $decision the access decision + * + * @return ReplaceEquipmentProcessor the HTTP translator + */ + private function processor(CommandBusPort $commands, OrganizationAccessDecision $decision = OrganizationAccessDecision::GRANTED): ReplaceEquipmentProcessor + { + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('resolveAccess')->willReturn($decision); + $security = $this->createStub(Security::class); + $security->method('getUser')->willReturn(new SecurityUser('550e8400-e29b-41d4-a716-446655440001', 'replace@example.test', 'hashed', ['ROLE_USER'])); + + return new ReplaceEquipmentProcessor($commands, $authorization, $security); + } + // #endregion +} diff --git a/tests/Unit/Equipment/Presentation/Api/Provider/Equipment/CanonicalEquipmentProviderTest.php b/tests/Unit/Equipment/Presentation/Api/Provider/Equipment/CanonicalEquipmentProviderTest.php index 41878baaa..e15d6d1a8 100644 --- a/tests/Unit/Equipment/Presentation/Api/Provider/Equipment/CanonicalEquipmentProviderTest.php +++ b/tests/Unit/Equipment/Presentation/Api/Provider/Equipment/CanonicalEquipmentProviderTest.php @@ -7,12 +7,14 @@ use ApiPlatform\Metadata\{Get, GetCollection}; use Auth\Infrastructure\Security\User\SecurityUser; use DateTimeImmutable; -use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\{EntityManagerInterface, Query, QueryBuilder}; use Equipment\Infrastructure\Persistence\Doctrine\Record\EquipmentRecord; use Equipment\Presentation\Api\Provider\Equipment\CanonicalEquipmentProvider; use Intervention\Application\Contract\Resource\InterventionAssignmentContext; use Intervention\Application\Port\Outbound\InterventionResourceGatewayPort; use Intervention\Application\Service\InterventionResourceManager; +use Maintenance\Application\Contract\Plan\MaintenanceEquipmentOperationsDue; +use Maintenance\Application\Port\Inbound\MaintenanceOperationsDuePort; use Organization\Application\Contract\Authorization\OrganizationAccessDecision; use Organization\Application\Port\Inbound\OrganizationAuthorizationPort; use Organization\Infrastructure\Persistence\Doctrine\Record\OrganizationRecord; @@ -22,6 +24,8 @@ use Symfony\Component\HttpFoundation\{Request, RequestStack}; use Symfony\Component\HttpKernel\Exception\{AccessDeniedHttpException, BadRequestHttpException, NotFoundHttpException}; +use function iterator_to_array; + /** * Test CanonicalEquipmentProviderTest. * @@ -40,6 +44,56 @@ final class CanonicalEquipmentProviderTest extends TestCase private const string INTERVENTION_ID = '550e8400-e29b-41d4-a716-446655442101'; + #[Test] + public function resolvesCollectionOperationDeadlinesInOneBulkCall(): void + { + $organization = new OrganizationRecord(); + $organization->id = self::ORGANIZATION_ID; + $records = []; + foreach (['550e8400-e29b-41d4-a716-446655442102', '550e8400-e29b-41d4-a716-446655442103'] as $id) { + $record = new EquipmentRecord(); + $record->id = $id; + $record->organization = $organization; + $record->type = 'fire_extinguisher'; + $record->createdAt = $record->updatedAt = new DateTimeImmutable('2026-10-01T00:00:00+00:00'); + $records[] = $record; + } + $query = $this->createStub(Query::class); + $query->method('getSingleScalarResult')->willReturn(2); + $query->method('getResult')->willReturn($records); + $builder = $this->createStub(QueryBuilder::class); + foreach (['select', 'from', 'where', 'andWhere', 'setParameter', 'orderBy', 'resetDQLPart', 'setFirstResult', 'setMaxResults'] as $method) { + $builder->method($method)->willReturnSelf(); + } + $builder->method('getQuery')->willReturn($query); + $entityManager = $this->createStub(EntityManagerInterface::class); + $entityManager->method('createQueryBuilder')->willReturn($builder); + $operations = $this->createMock(MaintenanceOperationsDuePort::class); + $operations->expects(self::once())->method('forEquipment')->with(self::ORGANIZATION_ID, [$records[0]->id, $records[1]->id])->willReturn([ + $records[0]->id => new MaintenanceEquipmentOperationsDue( + 'up_to_date', + 'overdue', + new DateTimeImmutable('2027-01-01T00:00:00+00:00'), + new DateTimeImmutable('2026-09-01T00:00:00+00:00'), + 'plans', + ), + $records[1]->id => new MaintenanceEquipmentOperationsDue('overdue', 'unscheduled', new DateTimeImmutable('2026-10-01T00:00:00+00:00'), null, 'plans'), + ]); + $requests = new RequestStack(); + $requests->push(Request::create('/api/equipment?organization=/api/organizations/' . self::ORGANIZATION_ID)); + $page = $this->provider($entityManager, $requests, null, operationsDue: $operations)->provide(new GetCollection()); + self::assertInstanceOf(\ApiPlatform\State\Pagination\TraversablePaginator::class, $page); + $items = iterator_to_array($page); + self::assertCount(2, $items); + self::assertSame('up_to_date', $items[0]->maintenanceDueStatus); + self::assertSame('up_to_date', $items[0]->controlDueStatus); + self::assertSame('overdue', $items[0]->serviceDueStatus); + self::assertSame('2027-01-01T00:00:00+00:00', $items[0]->controlNextDueAt); + self::assertSame('2026-09-01T00:00:00+00:00', $items[0]->serviceNextDueAt); + self::assertSame('overdue', $items[1]->controlDueStatus); + self::assertNull($items[1]->serviceNextDueAt); + } + #[Test] public function testProvideThrowsNotFoundWhenTheEquipmentRecordIsMissing(): void { @@ -165,6 +219,7 @@ private function provider( RequestStack $requestStack, ?InterventionAssignmentContext $context, OrganizationAccessDecision $decision = OrganizationAccessDecision::GRANTED, + ?MaintenanceOperationsDuePort $operationsDue = null, ): CanonicalEquipmentProvider { $authorization = $this->createStub(OrganizationAuthorizationPort::class); $authorization->method('resolveAccess')->willReturn($decision); @@ -184,6 +239,7 @@ private function provider( $requestStack, new InterventionResourceManager($resources), detail: new \Equipment\Presentation\Api\Factory\EquipmentDetailOutputFactory($this->detailQueries(), new \Equipment\Presentation\Api\Factory\EquipmentOutputFactory()), + operationsDue: $operationsDue, ); } diff --git a/tests/Unit/Equipment/Presentation/Api/Provider/Equipment/OrganizationEquipmentSummaryProviderTest.php b/tests/Unit/Equipment/Presentation/Api/Provider/Equipment/OrganizationEquipmentSummaryProviderTest.php new file mode 100644 index 000000000..a2789831d --- /dev/null +++ b/tests/Unit/Equipment/Presentation/Api/Provider/Equipment/OrganizationEquipmentSummaryProviderTest.php @@ -0,0 +1,64 @@ +createMock(QueryBusPort::class); + $queries->expects(self::once())->method('ask')->with(new GetOrganizationEquipmentSummaryQuery('org', 'fire', 'customer')) + ->willReturn(new GetFacilityEquipmentSummaryResult('customer', 7, ['in_stock' => 2, 'operational' => 3, 'under_maintenance' => 1, 'decommissioned' => 1], 2)); + $output = $this->provider($queries, OrganizationAccessDecision::GRANTED)->provide(new Get(), ['organizationId' => 'org']); + self::assertSame('customer', $output->scope); + self::assertSame(7, $output->totalItems); + self::assertSame(1, $output->byStatus['under_maintenance']); + } + + #[Test] + public function hidesOutsideScopeWithoutQuerying(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::never())->method('ask'); + $this->expectException(NotFoundHttpException::class); + $this->provider($queries, OrganizationAccessDecision::OUTSIDE_SCOPE)->provide(new Get(), ['organizationId' => 'org']); + } + + #[Test] + public function deniesMissingReadPermissionWithoutQuerying(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::never())->method('ask'); + $this->expectException(AccessDeniedHttpException::class); + $this->provider($queries, OrganizationAccessDecision::MISSING_PERMISSION)->provide(new Get(), ['organizationId' => 'org']); + } + + private function provider(QueryBusPort $queries, OrganizationAccessDecision $decision): OrganizationEquipmentSummaryProvider + { + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('resolveAccess')->willReturn($decision); + $security = $this->createStub(Security::class); + $security->method('getUser')->willReturn(new SecurityUser('user', 'user@example.com', 'password', ['ROLE_USER'])); + $requests = new RequestStack(); + $requests->push(Request::create('/summary?family=fire&customerId=customer')); + + return new OrganizationEquipmentSummaryProvider($queries, $authorization, $security, $requests); + } +} diff --git a/tests/Unit/Facility/Domain/ValueObject/FacilityCustomerReferenceTest.php b/tests/Unit/Facility/Domain/ValueObject/FacilityCustomerReferenceTest.php new file mode 100644 index 000000000..7cef02725 --- /dev/null +++ b/tests/Unit/Facility/Domain/ValueObject/FacilityCustomerReferenceTest.php @@ -0,0 +1,52 @@ +customerId()); + $building = \Facility\Domain\Model\Facility\Facility::create( + \Facility\Domain\ValueObject\FacilityId::fromString('550e8400-e29b-41d4-a716-446655440003'), + $organizationId, + FacilityType::BUILDING, + new \Facility\Domain\ValueObject\FacilityName('Building'), + new \Facility\Domain\Model\Facility\FacilityDetails(parentFacilityId: $site->id()), + ); + self::assertNull($building->customerId()); + } + + #[Test] + public function refusesCustomerOnBuilding(): void + { + $this->expectException(FacilityCustomerAssignmentException::class); + FacilityCustomerReference::assertValid(self::ID, FacilityType::BUILDING, null); + } + + #[Test] + public function refusesCustomerOnNestedSite(): void + { + $this->expectException(FacilityCustomerAssignmentException::class); + FacilityCustomerReference::assertValid(self::ID, FacilityType::SITE, self::ID); + } +} diff --git a/tests/Unit/Facility/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportSiteIdentityAdapterTest.php b/tests/Unit/Facility/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportSiteIdentityAdapterTest.php new file mode 100644 index 000000000..b50dbcd4a --- /dev/null +++ b/tests/Unit/Facility/Infrastructure/Adapter/MaintenanceExport/MaintenanceExportSiteIdentityAdapterTest.php @@ -0,0 +1,37 @@ +createMock(EntityManagerInterface::class); + $main->expects(self::never())->method('getConnection'); + $adapter = new MaintenanceExportSiteIdentityAdapter($main); + + foreach (['customer', 'equipment', 'SITE', 'unknown'] as $type) { + self::assertFalse($adapter->exists( + 'd17e8400-e29b-41d4-a716-446655790001', + $type, + 'd17e8400-e29b-41d4-a716-446655790002', + )); + } + } +} diff --git a/tests/Unit/Inspection/Application/UseCase/Query/Equipment/GetEquipmentInspectionSummary/GetEquipmentInspectionSummaryHandlerTest.php b/tests/Unit/Inspection/Application/UseCase/Query/Equipment/GetEquipmentInspectionSummary/GetEquipmentInspectionSummaryHandlerTest.php new file mode 100644 index 000000000..754bc3905 --- /dev/null +++ b/tests/Unit/Inspection/Application/UseCase/Query/Equipment/GetEquipmentInspectionSummary/GetEquipmentInspectionSummaryHandlerTest.php @@ -0,0 +1,101 @@ +createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::exactly(2))->method('resolveAccess')->with(self::USER, self::ORG, self::callback(static fn (string $permission): bool => in_array($permission, ['organization.inspection.read', 'organization.equipment.read'], true)))->willReturn(OrganizationAccessDecision::GRANTED); + $equipment = $this->createMock(EquipmentValidationPort::class); + $equipment->expects(self::once())->method('assertPublishedEquipmentExists')->with(self::EQUIPMENT, self::ORG); + $summary = new EquipmentInspectionSummary(self::EQUIPMENT, 2, ['low' => 0, 'medium' => 0, 'high' => 1, 'critical' => 1], 'inspection-1', '2026-10-05T12:00:00+00:00', 'pass'); + $summaries = $this->createMock(EquipmentInspectionSummaryPort::class); + $summaries->expects(self::once())->method('find')->with(self::ORG, self::EQUIPMENT)->willReturn($summary); + $result = new GetEquipmentInspectionSummaryHandler($authorization, $equipment, $summaries)(new GetEquipmentInspectionSummaryQuery(self::ORG, self::EQUIPMENT, self::USER)); + self::assertSame($summary, $result->summary); + self::assertSame('pass', $result->summary->lastInspectionResult); + self::assertSame(2, $result->summary->openAnomalies); + } + + #[Test] + public function deniesMissingInspectionReadBeforeEquipmentLookup(): void + { + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::once())->method('resolveAccess')->with(self::USER, self::ORG, 'organization.inspection.read')->willReturn(OrganizationAccessDecision::MISSING_PERMISSION); + $equipment = $this->createMock(EquipmentValidationPort::class); + $equipment->expects(self::never())->method('assertPublishedEquipmentExists'); + $summaries = $this->createMock(EquipmentInspectionSummaryPort::class); + $summaries->expects(self::never())->method('find'); + $this->expectException(EquipmentInspectionSummaryAccessDeniedException::class); + new GetEquipmentInspectionSummaryHandler($authorization, $equipment, $summaries)(new GetEquipmentInspectionSummaryQuery(self::ORG, self::EQUIPMENT, self::USER)); + } + + #[Test] + public function deniesMissingEquipmentReadBeforeEquipmentLookup(): void + { + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::exactly(2))->method('resolveAccess')->willReturnOnConsecutiveCalls(OrganizationAccessDecision::GRANTED, OrganizationAccessDecision::MISSING_PERMISSION); + $equipment = $this->createMock(EquipmentValidationPort::class); + $equipment->expects(self::never())->method('assertPublishedEquipmentExists'); + $summaries = $this->createMock(EquipmentInspectionSummaryPort::class); + $summaries->expects(self::never())->method('find'); + $this->expectException(EquipmentInspectionSummaryAccessDeniedException::class); + new GetEquipmentInspectionSummaryHandler($authorization, $equipment, $summaries)(new GetEquipmentInspectionSummaryQuery(self::ORG, self::EQUIPMENT, self::USER)); + } + + #[Test] + public function hidesOutsideOrganizationWithoutReadingFacts(): void + { + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::once())->method('resolveAccess')->willReturn(OrganizationAccessDecision::OUTSIDE_SCOPE); + $equipment = $this->createMock(EquipmentValidationPort::class); + $equipment->expects(self::never())->method('assertPublishedEquipmentExists'); + $summaries = $this->createMock(EquipmentInspectionSummaryPort::class); + $summaries->expects(self::never())->method('find'); + $this->expectException(EquipmentInspectionSummaryNotFoundException::class); + new GetEquipmentInspectionSummaryHandler($authorization, $equipment, $summaries)(new GetEquipmentInspectionSummaryQuery(self::ORG, self::EQUIPMENT, self::USER)); + } + + #[Test] + public function turnsUnknownOrForeignEquipmentIntoSameNotFound(): void + { + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::exactly(2))->method('resolveAccess')->willReturn(OrganizationAccessDecision::GRANTED); + $equipment = $this->createMock(EquipmentValidationPort::class); + $equipment->expects(self::once())->method('assertPublishedEquipmentExists')->with(self::EQUIPMENT, self::ORG)->willThrowException(new InvalidArgumentException('Unavailable equipment')); + $summaries = $this->createMock(EquipmentInspectionSummaryPort::class); + $summaries->expects(self::never())->method('find'); + $this->expectException(EquipmentInspectionSummaryNotFoundException::class); + new GetEquipmentInspectionSummaryHandler($authorization, $equipment, $summaries)(new GetEquipmentInspectionSummaryQuery(self::ORG, self::EQUIPMENT, self::USER)); + } +} diff --git a/tests/Unit/Inspection/Application/UseCase/Query/Park/GetParkAnomaliesSummary/GetParkAnomaliesSummaryHandlerTest.php b/tests/Unit/Inspection/Application/UseCase/Query/Park/GetParkAnomaliesSummary/GetParkAnomaliesSummaryHandlerTest.php new file mode 100644 index 000000000..10253e088 --- /dev/null +++ b/tests/Unit/Inspection/Application/UseCase/Query/Park/GetParkAnomaliesSummary/GetParkAnomaliesSummaryHandlerTest.php @@ -0,0 +1,62 @@ +createMock(ParkAnomalyGatewayPort::class); + $gateway->expects(self::once())->method('findCandidateEquipmentIds')->with(self::ORGANIZATION_ID)->willReturn(['fire-equipment', 'camera-equipment']); + $scope = $this->createMock(EquipmentParkScopePort::class); + $scope->expects(self::once())->method('filterIds')->with(self::ORGANIZATION_ID, ['fire-equipment', 'camera-equipment'], 'fire', 'customer-id', 'facility-id', false)->willReturn(['fire-equipment']); + $gateway->expects(self::once())->method('summary')->with(self::ORGANIZATION_ID, ['fire-equipment'])->willReturn(new ParkAnomaliesCounts(10, ['low' => 1, 'medium' => 2, 'high' => 3, 'critical' => 4])); + $result = new GetParkAnomaliesSummaryHandler($gateway, $scope)(new GetParkAnomaliesSummaryQuery(self::ORGANIZATION_ID, 'fire', 'customer-id', 'facility-id', includeDescendants: false)); + self::assertSame(10, $result->openAnomalies); + self::assertSame(['low' => 1, 'medium' => 2, 'high' => 3, 'critical' => 4], $result->bySeverity); + } + + #[Test] + public function testEmptyCandidatesStillResolveScopeAndReturnAllZeroBuckets(): void + { + $gateway = $this->createMock(ParkAnomalyGatewayPort::class); + $gateway->method('findCandidateEquipmentIds')->willReturn([]); + $scope = $this->createMock(EquipmentParkScopePort::class); + $scope->expects(self::once())->method('filterIds')->with(self::ORGANIZATION_ID, [], null, null, null)->willReturn([]); + $gateway->expects(self::once())->method('summary')->with(self::ORGANIZATION_ID, [])->willReturn(new ParkAnomaliesCounts(0)); + $result = new GetParkAnomaliesSummaryHandler($gateway, $scope)(new GetParkAnomaliesSummaryQuery(self::ORGANIZATION_ID)); + self::assertSame(0, $result->openAnomalies); + self::assertSame(['low' => 0, 'medium' => 0, 'high' => 0, 'critical' => 0], $result->bySeverity); + } + + #[Test] + public function testInvalidFamilyRemainsInvalidWithoutCandidates(): void + { + $gateway = $this->createMock(ParkAnomalyGatewayPort::class); + $gateway->method('findCandidateEquipmentIds')->willReturn([]); + $gateway->expects(self::never())->method('summary'); + $scope = $this->createMock(EquipmentParkScopePort::class); + $scope->expects(self::once())->method('filterIds')->with(self::ORGANIZATION_ID, [], 'invalid', null, null)->willThrowException(new RuntimeException('Invalid family.')); + $this->expectException(RuntimeException::class); + new GetParkAnomaliesSummaryHandler($gateway, $scope)(new GetParkAnomaliesSummaryQuery(self::ORGANIZATION_ID, 'invalid')); + } +} diff --git a/tests/Unit/Inspection/Application/UseCase/Query/Park/ListParkAnomalies/ListParkAnomaliesHandlerTest.php b/tests/Unit/Inspection/Application/UseCase/Query/Park/ListParkAnomalies/ListParkAnomaliesHandlerTest.php new file mode 100644 index 000000000..9c6ca81d6 --- /dev/null +++ b/tests/Unit/Inspection/Application/UseCase/Query/Park/ListParkAnomalies/ListParkAnomaliesHandlerTest.php @@ -0,0 +1,130 @@ +createMock(ParkAnomalyGatewayPort::class); + $gateway->expects(self::once())->method('findCandidateEquipmentIds')->with(self::ORGANIZATION_ID)->willReturn($candidateIds); + $scope = $this->createMock(EquipmentParkScopePort::class); + $scope->expects(self::once())->method('filterIds')->with(self::ORGANIZATION_ID, $candidateIds, 'fire', 'customer-id', 'facility-id', false)->willReturn([self::EQUIPMENT_ID]); + $gateway->expects(self::once())->method('list')->with(self::ORGANIZATION_ID, [self::EQUIPMENT_ID], $pagination, $sorting)->willReturn([ + $this->entry('993e8400-e29b-41d4-a716-446655493030'), + $this->entry('993e8400-e29b-41d4-a716-446655493031'), + ]); + $gateway->expects(self::once())->method('count')->with(self::ORGANIZATION_ID, [self::EQUIPMENT_ID])->willReturn(7); + $naming = $this->createMock(EquipmentNamingPort::class); + $naming->expects(self::once())->method('findSerialNumbersByIds')->with([self::EQUIPMENT_ID])->willReturn([self::EQUIPMENT_ID => 'SN-FIRE-01']); + $result = new ListParkAnomaliesHandler($gateway, $scope, $naming)(new ListParkAnomaliesQuery(self::ORGANIZATION_ID, 'fire', 'customer-id', 'facility-id', $pagination, $sorting, includeDescendants: false)); + + self::assertCount(2, $result->items); + self::assertSame(7, $result->total); + self::assertSame(2, $result->limit); + self::assertSame(20, $result->offset); + self::assertSame(self::EQUIPMENT_ID, $result->items[0]->equipmentId); + self::assertSame('SN-FIRE-01', $result->items[0]->equipmentSerialNumber); + self::assertSame('Open defect', $result->items[0]->description); + self::assertSame('high', $result->items[0]->severity); + self::assertSame('open', $result->items[0]->status); + self::assertSame('2026-10-06T10:00:00+00:00', $result->items[0]->dueAt); + self::assertSame('Keep isolated', $result->items[0]->notes); + } + + #[Test] + public function testEmptyCandidatesStillValidateScopesAndAvoidNaming(): void + { + $gateway = $this->createMock(ParkAnomalyGatewayPort::class); + $gateway->method('findCandidateEquipmentIds')->willReturn([]); + $gateway->expects(self::once())->method('list')->with(self::ORGANIZATION_ID, [], new Pagination(), new Sorting('createdAt', SortDirection::DESC))->willReturn([]); + $gateway->expects(self::once())->method('count')->with(self::ORGANIZATION_ID, [])->willReturn(0); + $scope = $this->createMock(EquipmentParkScopePort::class); + $scope->expects(self::once())->method('filterIds')->with(self::ORGANIZATION_ID, [], 'fire', null, null)->willReturn([]); + $naming = $this->createMock(EquipmentNamingPort::class); + $naming->expects(self::never())->method('findSerialNumbersByIds'); + $result = new ListParkAnomaliesHandler($gateway, $scope, $naming)(new ListParkAnomaliesQuery(self::ORGANIZATION_ID, 'fire')); + + self::assertSame([], $result->items); + self::assertSame(0, $result->total); + } + + #[Test] + public function testInvalidScopeOnEmptyCandidatesDoesNotQueryAPage(): void + { + $gateway = $this->createMock(ParkAnomalyGatewayPort::class); + $gateway->method('findCandidateEquipmentIds')->willReturn([]); + $gateway->expects(self::never())->method('list'); + $gateway->expects(self::never())->method('count'); + $scope = $this->createMock(EquipmentParkScopePort::class); + $scope->expects(self::once())->method('filterIds')->with(self::ORGANIZATION_ID, [], null, 'foreign-customer', null)->willThrowException(new RuntimeException('Customer not found.')); + $this->expectException(RuntimeException::class); + new ListParkAnomaliesHandler($gateway, $scope, $this->createStub(EquipmentNamingPort::class))(new ListParkAnomaliesQuery(self::ORGANIZATION_ID, customerId: 'foreign-customer')); + } + + #[Test] + public function testInvalidPaginationNeverReadsCandidates(): void + { + $gateway = $this->createMock(ParkAnomalyGatewayPort::class); + $gateway->expects(self::never())->method('findCandidateEquipmentIds'); + $this->expectException(InvalidValueException::class); + new ListParkAnomaliesHandler($gateway, $this->createStub(EquipmentParkScopePort::class), $this->createStub(EquipmentNamingPort::class))(new ListParkAnomaliesQuery(self::ORGANIZATION_ID, pagination: new Pagination(-1, 20))); + } + + #[Test] + public function testUnknownSerialNumberRemainsNull(): void + { + $gateway = $this->createStub(ParkAnomalyGatewayPort::class); + $gateway->method('findCandidateEquipmentIds')->willReturn([self::EQUIPMENT_ID]); + $gateway->method('list')->willReturn([$this->entry('993e8400-e29b-41d4-a716-446655493030')]); + $gateway->method('count')->willReturn(1); + $scope = $this->createStub(EquipmentParkScopePort::class); + $scope->method('filterIds')->willReturn([self::EQUIPMENT_ID]); + $naming = $this->createStub(EquipmentNamingPort::class); + $naming->method('findSerialNumbersByIds')->willReturn([]); + $result = new ListParkAnomaliesHandler($gateway, $scope, $naming)(new ListParkAnomaliesQuery(self::ORGANIZATION_ID)); + self::assertNull($result->items[0]->equipmentSerialNumber); + } + + /** + * Method entry. + * + * @param string $id finding identifier + * + * @return ParkAnomalyEntry published unresolved finding + */ + private function entry(string $id): ParkAnomalyEntry + { + $at = new DateTimeImmutable('2026-10-06T10:00:00+00:00'); + + return new ParkAnomalyEntry($id, '993e8400-e29b-41d4-a716-446655493020', self::EQUIPMENT_ID, 'Open defect', 'high', 'open', $at, null, 'Keep isolated', $at, $at); + } +} diff --git a/tests/Unit/Inspection/Infrastructure/Adapter/Intervention/InterventionInspectionResultAdapterTest.php b/tests/Unit/Inspection/Infrastructure/Adapter/Intervention/InterventionInspectionResultAdapterTest.php new file mode 100644 index 000000000..f7c148a17 --- /dev/null +++ b/tests/Unit/Inspection/Infrastructure/Adapter/Intervention/InterventionInspectionResultAdapterTest.php @@ -0,0 +1,82 @@ +record(); + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('find')->with(InspectionRecord::class, 'inspection')->willReturn($record); + $result = new InterventionInspectionResultAdapter($em)->find('organization', 'intervention', 'inspection'); + self::assertNotNull($result); + self::assertSame('equipment', $result->equipmentId); + self::assertSame('fail', $result->result); + self::assertSame('closed', $result->status); + self::assertSame($record->performedAt, $result->performedAt); + self::assertSame('inspector', $result->authorId); + self::assertSame('Defect observed', $result->notes); + } + + #[Test] + public function refusesInspectionFromAnotherOrganization(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('find')->willReturn($this->record()); + self::assertNull(new InterventionInspectionResultAdapter($em)->find('foreign', 'intervention', 'inspection')); + } + + #[Test] + public function refusesInspectionFromAnotherIntervention(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('find')->willReturn($this->record()); + self::assertNull(new InterventionInspectionResultAdapter($em)->find('organization', 'foreign', 'inspection')); + } + + #[Test] + public function unknownInspectionReturnsSameNull(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('find')->willReturn(null); + self::assertNull(new InterventionInspectionResultAdapter($em)->find('organization', 'intervention', 'unknown')); + } + + private function record(): InspectionRecord + { + $org = new OrganizationRecord(); + $org->id = 'organization'; + $inspection = new InspectionRecord(); + $inspection->id = 'inspection'; + $inspection->organization = $org; + $inspection->interventionId = 'intervention'; + $inspection->equipmentId = 'equipment'; + $inspection->status = 'closed'; + $inspection->result = 'fail'; + $inspection->performedAt = new DateTimeImmutable('2026-10-05T14:30:00Z'); + $inspection->inspectorUserId = 'inspector'; + $inspection->notes = 'Defect observed'; + $inspection->recordStatus = 'draft'; + + return $inspection; + } +} diff --git a/tests/Unit/Inspection/Presentation/Api/Provider/Equipment/GetEquipmentInspectionSummaryProviderTest.php b/tests/Unit/Inspection/Presentation/Api/Provider/Equipment/GetEquipmentInspectionSummaryProviderTest.php new file mode 100644 index 000000000..17cae4e10 --- /dev/null +++ b/tests/Unit/Inspection/Presentation/Api/Provider/Equipment/GetEquipmentInspectionSummaryProviderTest.php @@ -0,0 +1,51 @@ +createMock(Security::class); + $security->expects(self::once())->method('getUser')->willReturn(new SecurityUser('user', 'user@example.com', 'password')); + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::once())->method('ask')->with(self::callback(static fn (GetEquipmentInspectionSummaryQuery $query): bool => 'org' === $query->organizationId && 'equipment' === $query->equipmentId && 'user' === $query->userId))->willReturn(new GetEquipmentInspectionSummaryResult(new EquipmentInspectionSummary('equipment', 1, ['low' => 0, 'medium' => 0, 'high' => 0, 'critical' => 1], 'inspection', '2026-10-05T14:30:00+00:00', 'pass'))); + $output = new GetEquipmentInspectionSummaryProvider($queries, $security)->provide(new Get(), ['organizationId' => 'org', 'equipmentId' => 'equipment']); + self::assertSame('equipment', $output->equipmentId); + self::assertSame(1, $output->openAnomalies); + self::assertSame('pass', $output->lastInspectionResult); + self::assertSame('2026-10-05T14:30:00+00:00', $output->lastInspectionPerformedAt); + } + + #[Test] + public function unauthenticatedCallNeverDispatches(): void + { + $security = $this->createMock(Security::class); + $security->expects(self::once())->method('getUser')->willReturn(null); + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::never())->method('ask'); + $this->expectException(AccessDeniedHttpException::class); + new GetEquipmentInspectionSummaryProvider($queries, $security)->provide(new Get(), ['organizationId' => 'org', 'equipmentId' => 'equipment']); + } +} diff --git a/tests/Unit/Inspection/Presentation/Api/Provider/Park/ParkAnomaliesProviderTest.php b/tests/Unit/Inspection/Presentation/Api/Provider/Park/ParkAnomaliesProviderTest.php new file mode 100644 index 000000000..360bd4ba6 --- /dev/null +++ b/tests/Unit/Inspection/Presentation/Api/Provider/Park/ParkAnomaliesProviderTest.php @@ -0,0 +1,117 @@ +createMock(QueryBusPort::class); + $queries->expects(self::once())->method('ask')->with(new GetParkAnomaliesSummaryQuery('org', 'fire', 'customer', 'facility'))->willReturn(new GetParkAnomaliesSummaryResult(8, ['low' => 2, 'medium' => 3, 'high' => 2, 'critical' => 1])); + $result = $this->provider($queries)->provide(new Get(name: ParkAnomaliesOperations::SUMMARY), ['organizationId' => 'org']); + self::assertInstanceOf(\Inspection\Presentation\Api\Dto\Output\Park\ParkAnomaliesSummaryOutput::class, $result); + self::assertSame(8, $result->openAnomalies); + self::assertSame(1, $result->bySeverity['critical']); + } + + #[Test] + public function listMapsCorrectInspectionAndEquipmentAndOnePagination(): void + { + $queries = $this->createMock(QueryBusPort::class); + $now = new DateTimeImmutable('2026-10-06T12:00:00+00:00'); + $queries->expects(self::once())->method('ask')->with(new ListParkAnomaliesQuery('org', 'fire', 'customer', 'facility', new Pagination(2, 2)))->willReturn(new PaginatedResult([ + new OrganizationNonConformityResult('finding', 'inspection', 'Valve issue', 'high', 'in_progress', null, null, null, $now, $now, 'equipment', 'SN-001'), + ], 8, 2, 2)); + $result = $this->provider($queries)->provide(new GetCollection(name: ParkAnomaliesOperations::LIST), ['organizationId' => 'org']); + self::assertInstanceOf(\ApiPlatform\State\Pagination\TraversablePaginator::class, $result); + self::assertSame(8.0, $result->getTotalItems()); + $rows = iterator_to_array($result); + self::assertInstanceOf(\Inspection\Presentation\Api\Dto\Output\NonConformity\NonConformityOutput::class, $rows[0]); + self::assertSame('inspection', $rows[0]->inspectionId); + self::assertSame('equipment', $rows[0]->equipmentId); + self::assertSame('SN-001', $rows[0]->equipmentSerialNumber); + } + + #[Test] + public function directScopeIsForwardedToCountsAndRows(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::exactly(2))->method('ask')->willReturnCallback(static function (GetParkAnomaliesSummaryQuery|ListParkAnomaliesQuery $query): GetParkAnomaliesSummaryResult|PaginatedResult { + self::assertFalse($query->includeDescendants); + self::assertSame('fire', $query->family); + self::assertSame('customer', $query->customerId); + self::assertSame('facility', $query->facilityId); + + return $query instanceof GetParkAnomaliesSummaryQuery ? new GetParkAnomaliesSummaryResult(0, ['low' => 0, 'medium' => 0, 'high' => 0, 'critical' => 0]) : new PaginatedResult([], 0, 2, 2); + }); + $provider = $this->provider($queries, querySuffix: '&includeDescendants=false'); + $provider->provide(new Get(name: ParkAnomaliesOperations::SUMMARY), ['organizationId' => 'org']); + $provider->provide(new GetCollection(name: ParkAnomaliesOperations::LIST), ['organizationId' => 'org']); + } + + #[Test] + public function missingInspectionPermissionDoesNotQueryOrReturnZero(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::never())->method('ask'); + $this->expectException(AccessDeniedHttpException::class); + $this->provider($queries, OrganizationAccessDecision::MISSING_PERMISSION)->provide(new Get(name: ParkAnomaliesOperations::SUMMARY), ['organizationId' => 'org']); + } + + #[Test] + public function missingEquipmentPermissionDoesNotQueryOrReturnZero(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::never())->method('ask'); + $this->expectException(AccessDeniedHttpException::class); + $this->provider($queries, OrganizationAccessDecision::GRANTED, OrganizationAccessDecision::MISSING_PERMISSION)->provide(new Get(name: ParkAnomaliesOperations::SUMMARY), ['organizationId' => 'org']); + } + + #[Test] + public function foreignOrganizationIsHiddenBeforeQuerying(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::never())->method('ask'); + $this->expectException(NotFoundHttpException::class); + $this->provider($queries, OrganizationAccessDecision::OUTSIDE_SCOPE)->provide(new Get(name: ParkAnomaliesOperations::SUMMARY), ['organizationId' => 'org']); + } + + private function provider(QueryBusPort $queries, OrganizationAccessDecision $inspection = OrganizationAccessDecision::GRANTED, OrganizationAccessDecision $equipment = OrganizationAccessDecision::GRANTED, string $querySuffix = ''): ParkAnomaliesProvider + { + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('resolveAccess')->willReturnMap([ + ['user', 'org', 'organization.inspection.read', $inspection], + ['user', 'org', 'organization.equipment.read', $equipment], + ]); + $security = $this->createStub(Security::class); + $security->method('getUser')->willReturn(new SecurityUser('user', 'user@example.com', 'unused', ['ROLE_USER'])); + $requests = new RequestStack(); + $requests->push(Request::create('/anomalies?family=fire&customerId=customer&facilityId=facility&page=2&itemsPerPage=2' . $querySuffix)); + + return new ParkAnomaliesProvider($queries, new ParkAnomaliesAccess($security, $authorization), $requests); + } +} diff --git a/tests/Unit/Intervention/Application/Service/InterventionIssueFinderTest.php b/tests/Unit/Intervention/Application/Service/InterventionIssueFinderTest.php index d95f730c0..a54352c03 100644 --- a/tests/Unit/Intervention/Application/Service/InterventionIssueFinderTest.php +++ b/tests/Unit/Intervention/Application/Service/InterventionIssueFinderTest.php @@ -4,8 +4,8 @@ namespace Tests\Unit\Intervention\Application\Service; -use Intervention\Application\Contract\Resource\{InterventionEquipmentDraft, InterventionResourceSummary, InterventionValidationContext, InterventionWorkItemSummary}; -use Intervention\Application\Port\Outbound\{InterventionAttachmentRepositoryPort, InterventionResourceGatewayPort}; +use Intervention\Application\Contract\Resource\{InterventionEquipmentDraft, InterventionIssue, InterventionResourceSummary, InterventionValidationContext, InterventionWorkItemSummary}; +use Intervention\Application\Port\Outbound\{InterventionAttachmentRepositoryPort, InterventionPublicationResourcesIssuesPort, InterventionResourceGatewayPort}; use Intervention\Application\Service\InterventionIssueFinder; use PHPUnit\Framework\Attributes\{DataProvider, Test}; use PHPUnit\Framework\TestCase; @@ -16,6 +16,19 @@ final class InterventionIssueFinderTest extends TestCase { + public function testReceivedResourceDeclarationsArePublicationBlockersWithoutFinancialFields(): void + { + $gateway = $this->createStub(InterventionResourceGatewayPort::class); + $gateway->method('summary')->willReturn(new InterventionResourceSummary(1, 1, 1)); + $gateway->method('workItemSummary')->willReturn(new InterventionWorkItemSummary(1, 0, 0, 0, 1)); + $gateway->method('validationContext')->willReturn(new InterventionValidationContext('inventory', 'submitted', 'site', 'member')); + $gateway->method('equipmentDrafts')->willReturn([]); + $resources = $this->createMock(InterventionPublicationResourcesIssuesPort::class); + $resources->expects(self::once())->method('issues')->with('order')->willReturn([new InterventionIssue('blocker', 'intervention', 'order', 'resources', 'inventory_pending_declarations')]); + $finder = new InterventionIssueFinder($gateway, $this->createStub(InterventionAttachmentRepositoryPort::class), $resources); + self::assertSame('inventory_pending_declarations', $finder->find('order')[0]->message); + } + /** * @return iterable */ @@ -237,6 +250,6 @@ private function finder( InterventionResourceGatewayPort $gateway, ?InterventionAttachmentRepositoryPort $attachments = null, ): InterventionIssueFinder { - return new InterventionIssueFinder($gateway, $attachments ?? $this->createStub(InterventionAttachmentRepositoryPort::class)); + return new InterventionIssueFinder($gateway, $attachments ?? $this->createStub(InterventionAttachmentRepositoryPort::class), $this->createStub(InterventionPublicationResourcesIssuesPort::class)); } } diff --git a/tests/Unit/Intervention/Application/UseCase/Command/Publication/ExecutePublication/ExecutePublicationHandlerTest.php b/tests/Unit/Intervention/Application/UseCase/Command/Publication/ExecutePublication/ExecutePublicationHandlerTest.php index e10376c2d..df4aa8efb 100644 --- a/tests/Unit/Intervention/Application/UseCase/Command/Publication/ExecutePublication/ExecutePublicationHandlerTest.php +++ b/tests/Unit/Intervention/Application/UseCase/Command/Publication/ExecutePublication/ExecutePublicationHandlerTest.php @@ -7,7 +7,7 @@ use DateTimeImmutable; use Intervention\Application\Contract\Publication\{InterventionPublicationContext, PublicationView}; use Intervention\Application\Contract\Resource\{InterventionResourceSummary, InterventionWorkItemSummary}; -use Intervention\Application\Port\Outbound\{InterventionAttachmentRepositoryPort, InterventionResourceGatewayPort, PublicationRepositoryPort}; +use Intervention\Application\Port\Outbound\{InterventionAttachmentRepositoryPort, InterventionPublicationResourcesIssuesPort, InterventionResourceGatewayPort, PublicationRepositoryPort}; use Intervention\Application\Service\InterventionIssueFinder; use Intervention\Application\UseCase\Command\Publication\ExecutePublication\{ExecutePublicationCommand, ExecutePublicationHandler}; use Intervention\Domain\Event\Publication\{InterventionPublicationFailedEvent, InterventionPublishedEvent}; @@ -257,7 +257,7 @@ private function handler( return new ExecutePublicationHandler( $repository, - new InterventionIssueFinder($resources, $this->createStub(InterventionAttachmentRepositoryPort::class)), + new InterventionIssueFinder($resources, $this->createStub(InterventionAttachmentRepositoryPort::class), $this->createStub(InterventionPublicationResourcesIssuesPort::class)), $eventDispatcher ?? $this->createStub(EventDispatcherPort::class), transactions: new \App\Tests\Support\Shared\ImmediateTransactionManager(), ); diff --git a/tests/Unit/Intervention/Application/UseCase/Command/Publication/RequestPublication/RequestPublicationHandlerTest.php b/tests/Unit/Intervention/Application/UseCase/Command/Publication/RequestPublication/RequestPublicationHandlerTest.php index 5c5eedc46..b09b8764e 100644 --- a/tests/Unit/Intervention/Application/UseCase/Command/Publication/RequestPublication/RequestPublicationHandlerTest.php +++ b/tests/Unit/Intervention/Application/UseCase/Command/Publication/RequestPublication/RequestPublicationHandlerTest.php @@ -7,7 +7,7 @@ use DateTimeImmutable; use Intervention\Application\Contract\Publication\{InterventionPublicationContext, PublicationView}; use Intervention\Application\Contract\Resource\{InterventionResourceSummary, InterventionWorkItemSummary}; -use Intervention\Application\Port\Outbound\{InterventionAttachmentRepositoryPort, InterventionResourceGatewayPort, PublicationQueuePort, PublicationRepositoryPort}; +use Intervention\Application\Port\Outbound\{InterventionAttachmentRepositoryPort, InterventionPublicationResourcesIssuesPort, InterventionResourceGatewayPort, PublicationQueuePort, PublicationRepositoryPort}; use Intervention\Application\Service\InterventionIssueFinder; use Intervention\Application\UseCase\Command\Publication\RequestPublication\{RequestPublicationCommand, RequestPublicationHandler}; use Intervention\Domain\Exception\{InterventionAccessDeniedException, InterventionBlockedException, InterventionConflictException, InterventionNotFoundException}; @@ -236,7 +236,7 @@ private function handler( return new RequestPublicationHandler( $repository, $queue, - new InterventionIssueFinder($resources, $this->createStub(InterventionAttachmentRepositoryPort::class)), + new InterventionIssueFinder($resources, $this->createStub(InterventionAttachmentRepositoryPort::class), $this->createStub(InterventionPublicationResourcesIssuesPort::class)), $authorization, $uuidFactory, transactions: new \App\Tests\Support\Shared\ImmediateTransactionManager(), diff --git a/tests/Unit/Intervention/Application/UseCase/Query/Equipment/ListEquipmentOpenWork/ListEquipmentOpenWorkHandlerTest.php b/tests/Unit/Intervention/Application/UseCase/Query/Equipment/ListEquipmentOpenWork/ListEquipmentOpenWorkHandlerTest.php new file mode 100644 index 000000000..b01efd2a7 --- /dev/null +++ b/tests/Unit/Intervention/Application/UseCase/Query/Equipment/ListEquipmentOpenWork/ListEquipmentOpenWorkHandlerTest.php @@ -0,0 +1,66 @@ +createStub(OrganizationAuthorizationPort::class); + $authorization->method('resolveAccess')->willReturn(OrganizationAccessDecision::GRANTED); + $resources = $this->createMock(InterventionResourceGatewayPort::class); + $resources->expects(self::once())->method('resourceBelongsToOrganization')->with(InterventionResourceType::EQUIPMENT, 'equipment', 'organization')->willReturn(true); + $item = new InterventionEquipmentWork('order', 42, 'Repair', 'in_progress', 'task', 'repair', 'planned'); + $work = $this->createMock(InterventionEquipmentWorkPort::class); + $work->expects(self::once())->method('findOpenWork')->with('organization', 'equipment')->willReturn([$item]); + self::assertSame([$item], new ListEquipmentOpenWorkHandler($authorization, $resources, $work)(new ListEquipmentOpenWorkQuery('user', 'organization', 'equipment'))->items); + } + + public function testOutsiderDoesNotReachEquipmentOrWorkReads(): void + { + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('resolveAccess')->willReturn(OrganizationAccessDecision::OUTSIDE_SCOPE); + $resources = $this->createMock(InterventionResourceGatewayPort::class); + $resources->expects(self::never())->method('resourceBelongsToOrganization'); + $work = $this->createMock(InterventionEquipmentWorkPort::class); + $work->expects(self::never())->method('findOpenWork'); + $this->expectException(InterventionNotFoundException::class); + new ListEquipmentOpenWorkHandler($authorization, $resources, $work)(new ListEquipmentOpenWorkQuery('user', 'organization', 'equipment')); + } + + public function testMemberWithoutReadPermissionIsDenied(): void + { + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('resolveAccess')->willReturn(OrganizationAccessDecision::MISSING_PERMISSION); + $resources = $this->createMock(InterventionResourceGatewayPort::class); + $resources->expects(self::never())->method('resourceBelongsToOrganization'); + $work = $this->createMock(InterventionEquipmentWorkPort::class); + $work->expects(self::never())->method('findOpenWork'); + $this->expectException(InterventionAccessDeniedException::class); + new ListEquipmentOpenWorkHandler($authorization, $resources, $work)(new ListEquipmentOpenWorkQuery('user', 'organization', 'equipment')); + } + + public function testForeignEquipmentIsHiddenEvenWhenOrganizationReadIsGranted(): void + { + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('resolveAccess')->willReturn(OrganizationAccessDecision::GRANTED); + $resources = $this->createStub(InterventionResourceGatewayPort::class); + $resources->method('resourceBelongsToOrganization')->willReturn(false); + $work = $this->createMock(InterventionEquipmentWorkPort::class); + $work->expects(self::never())->method('findOpenWork'); + $this->expectException(InterventionNotFoundException::class); + new ListEquipmentOpenWorkHandler($authorization, $resources, $work)(new ListEquipmentOpenWorkQuery('user', 'organization', 'equipment')); + } +} diff --git a/tests/Unit/Intervention/Domain/ValueObject/InterventionTypeTest.php b/tests/Unit/Intervention/Domain/ValueObject/InterventionTypeTest.php index 708e3c71d..d08e7d8b8 100644 --- a/tests/Unit/Intervention/Domain/ValueObject/InterventionTypeTest.php +++ b/tests/Unit/Intervention/Domain/ValueObject/InterventionTypeTest.php @@ -24,6 +24,8 @@ public function testBackingValuesAreStable(): void self::assertSame('site_setup', InterventionType::SITE_SETUP->value); self::assertSame('inventory', InterventionType::INVENTORY->value); self::assertSame('inspection_campaign', InterventionType::INSPECTION_CAMPAIGN->value); + self::assertSame('preventive_maintenance', InterventionType::PREVENTIVE_MAINTENANCE->value); + self::assertSame('corrective_maintenance', InterventionType::CORRECTIVE_MAINTENANCE->value); } #[Test] @@ -33,8 +35,14 @@ public function testFromResolvesTheBackingValue(): void } #[Test] - public function testExposesExactlyThreeCases(): void + public function testExposesExactlyFiveCases(): void { - self::assertCount(3, InterventionType::cases()); + self::assertSame([ + InterventionType::SITE_SETUP, + InterventionType::INVENTORY, + InterventionType::INSPECTION_CAMPAIGN, + InterventionType::PREVENTIVE_MAINTENANCE, + InterventionType::CORRECTIVE_MAINTENANCE, + ], InterventionType::cases()); } } diff --git a/tests/Unit/Intervention/Domain/ValueObject/WorkItemExecutionResultTest.php b/tests/Unit/Intervention/Domain/ValueObject/WorkItemExecutionResultTest.php new file mode 100644 index 000000000..8aff7337f --- /dev/null +++ b/tests/Unit/Intervention/Domain/ValueObject/WorkItemExecutionResultTest.php @@ -0,0 +1,53 @@ +payload()); + self::assertSame('2026-09-30T15:00:00+02:00', $result->performedAt->format('c')); + self::assertSame('Seal replaced', $result->workPerformed); + self::assertSame('successful', $result->outcome); + } + + public function testInvalidCalendarDateIsRejectedInsteadOfRolledForward(): void + { + $this->expectException(InterventionValidationException::class); + WorkItemExecutionResult::fromPayload([...$this->payload(), 'performedAt' => '2026-02-30T15:00:00+02:00']); + } + + public function testDateWithoutTimezoneIsRejected(): void + { + $this->expectException(InterventionValidationException::class); + WorkItemExecutionResult::fromPayload([...$this->payload(), 'performedAt' => '2026-09-30T15:00:00']); + } + + public function testFailedWorkIsAnExplicitFact(): void + { + self::assertSame('failed', WorkItemExecutionResult::fromPayload([...$this->payload(), 'outcome' => 'failed'])->outcome); + } + + public function testAnActualExecutionCannotBeDatedAfterItsRecordingInstant(): void + { + $this->expectException(InterventionValidationException::class); + WorkItemExecutionResult::fromPayload($this->payload())->assertAlreadyPerformed(new DateTimeImmutable('2026-09-29T00:00:00+00:00')); + } + + /** + * @return array + */ + private function payload(): array + { + return ['equipmentId' => '550e8400-e29b-41d4-a716-446655441511', 'performedAt' => '2026-09-30T15:00:00+02:00', 'outcome' => 'successful', 'workPerformed' => ' Seal replaced ']; + } +} diff --git a/tests/Unit/Intervention/Infrastructure/Adapter/Activity/DoctrineInterventionActivityAdapterTest.php b/tests/Unit/Intervention/Infrastructure/Adapter/Activity/DoctrineInterventionActivityAdapterTest.php index cb151acdb..3dc909e76 100644 --- a/tests/Unit/Intervention/Infrastructure/Adapter/Activity/DoctrineInterventionActivityAdapterTest.php +++ b/tests/Unit/Intervention/Infrastructure/Adapter/Activity/DoctrineInterventionActivityAdapterTest.php @@ -6,7 +6,7 @@ use Doctrine\ORM\EntityManagerInterface; use Intervention\Application\Contract\Activity\{InterventionActivityAppendRequest, InterventionActivityContent}; -use Intervention\Application\Port\Outbound\{InterventionAttachmentRepositoryPort, InterventionResourceGatewayPort}; +use Intervention\Application\Port\Outbound\{InterventionAttachmentRepositoryPort, InterventionPublicationResourcesIssuesPort, InterventionResourceGatewayPort}; use Intervention\Application\Service\InterventionIssueFinder; use Intervention\Domain\Exception\InterventionNotFoundException; use Intervention\Infrastructure\Adapter\Activity\DoctrineInterventionActivityAdapter; @@ -51,7 +51,7 @@ public function testAppendReportsAVanishedInterventionAsNotFound(): void $adapter = new DoctrineInterventionActivityAdapter( $entityManager, $this->createStub(UuidFactory::class), - new InterventionViewMapper($entityManager, $resources, new InterventionIssueFinder($resources, $attachments)), + new InterventionViewMapper($entityManager, $resources, new InterventionIssueFinder($resources, $attachments, $this->createStub(InterventionPublicationResourcesIssuesPort::class))), ); $this->expectException(InterventionNotFoundException::class); diff --git a/tests/Unit/Intervention/Infrastructure/Adapter/Publication/InterventionOperationPublicationAdapterTest.php b/tests/Unit/Intervention/Infrastructure/Adapter/Publication/InterventionOperationPublicationAdapterTest.php new file mode 100644 index 000000000..11c3a7496 --- /dev/null +++ b/tests/Unit/Intervention/Infrastructure/Adapter/Publication/InterventionOperationPublicationAdapterTest.php @@ -0,0 +1,111 @@ +item('maintenance'); + $item->operationKind = 'maintenance'; + $maintenance = $this->createMock(MaintenanceOperationResultsPort::class); + $maintenance->expects(self::once())->method('validateResult')->with(self::callback(static fn (MaintenanceOperationResult $result): bool => self::EQUIPMENT === $result->equipmentId && 'occurrence' === $result->occurrenceId && 'passed' === $result->outcome && '2026-09-30' === $result->performedAt->format('Y-m-d'))); + $maintenance->expects(self::once())->method('acknowledgeResult'); + $this->adapter($item, $maintenance)->publish($this->intervention(), 'organization'); + self::assertSame('validated', $item->executionResult['state'] ?? null); + } + + public function testAnAdverseClosedControlAcknowledgesPerformance(): void + { + $item = $this->item('inspection'); + $item->operationKind = 'control'; + $item->resultResource = '/api/inspections/control'; + $inspections = $this->createStub(InterventionInspectionResultPort::class); + $inspections->method('find')->willReturn(new InterventionInspectionResult('control', self::EQUIPMENT, 'fail', 'closed', new DateTimeImmutable('2026-09-30T15:00:00+02:00'), 'inspector', 'Defect remains open')); + $maintenance = $this->createMock(MaintenanceOperationResultsPort::class); + $maintenance->expects(self::once())->method('validateResult')->with(self::callback(static fn (MaintenanceOperationResult $result): bool => 'control' === $result->operationKind && 'failed' === $result->outcome)); + $maintenance->expects(self::once())->method('acknowledgeResult'); + $this->adapter($item, $maintenance, $inspections)->publish($this->intervention(), 'organization'); + self::assertSame('fail', $item->executionResult['inspectionResult'] ?? null); + } + + public function testAControlOfAnotherEquipmentNeverAcknowledges(): void + { + $item = $this->item('inspection'); + $item->operationKind = 'control'; + $item->resultResource = '/api/inspections/control'; + $inspections = $this->createStub(InterventionInspectionResultPort::class); + $inspections->method('find')->willReturn(new InterventionInspectionResult('control', 'other', 'pass', 'closed', new DateTimeImmutable(), null, null)); + $maintenance = $this->createMock(MaintenanceOperationResultsPort::class); + $maintenance->expects(self::never())->method('acknowledgeResult'); + $this->expectException(InterventionValidationException::class); + $this->adapter($item, $maintenance, $inspections)->publish($this->intervention(), 'organization'); + } + + public function testFailedRepairCannotPublishAsCompleted(): void + { + $item = $this->item('repair'); + $item->occurrenceId = null; + $item->executionResult['outcome'] = 'failed'; + $this->expectException(InterventionValidationException::class); + $this->adapter($item, $this->createStub(MaintenanceOperationResultsPort::class))->publish($this->intervention(), 'organization'); + } + + public function testSkippedMaintenanceDoesNotAcknowledgeItsOccurrence(): void + { + $item = $this->item('maintenance'); + $item->status = 'skipped'; + $maintenance = $this->createMock(MaintenanceOperationResultsPort::class); + $maintenance->expects(self::never())->method('validateResult'); + $maintenance->expects(self::never())->method('acknowledgeResult'); + $this->adapter($item, $maintenance)->publish($this->intervention(), 'organization'); + self::assertSame('staged', $item->executionResult['state'] ?? null); + } + + private function item(string $action): InterventionWorkItemRecord + { + $item = new InterventionWorkItemRecord(); + $item->id = 'task'; + $item->action = $action; + $item->status = 'completed'; + $item->target = '/api/equipment/' . self::EQUIPMENT; + $item->occurrenceId = 'occurrence'; + $item->executionResult = ['equipmentId' => self::EQUIPMENT, 'performedAt' => '2026-09-30T15:00:00+02:00', 'outcome' => 'successful', 'workPerformed' => 'Seal replaced', 'authorId' => 'member', 'state' => 'staged']; + + return $item; + } + + private function intervention(): InterventionRecord + { + $record = new InterventionRecord(); + $record->id = 'intervention'; + + return $record; + } + + private function adapter(InterventionWorkItemRecord $item, MaintenanceOperationResultsPort $maintenance, ?InterventionInspectionResultPort $inspections = null): InterventionOperationPublicationAdapter + { + $repository = $this->createStub(EntityRepository::class); + $repository->method('findBy')->willReturn([$item]); + $manager = $this->createStub(EntityManagerInterface::class); + $manager->method('getRepository')->willReturn($repository); + + return new InterventionOperationPublicationAdapter($manager, $inspections ?? $this->createStub(InterventionInspectionResultPort::class), $maintenance, $this->createStub(InterventionEquipmentReplacementPort::class)); + } +} diff --git a/tests/Unit/Intervention/Infrastructure/Adapter/ServiceRequest/ServiceRequestWorkAdapterTest.php b/tests/Unit/Intervention/Infrastructure/Adapter/ServiceRequest/ServiceRequestWorkAdapterTest.php new file mode 100644 index 000000000..651498bf1 --- /dev/null +++ b/tests/Unit/Intervention/Infrastructure/Adapter/ServiceRequest/ServiceRequestWorkAdapterTest.php @@ -0,0 +1,74 @@ +createMock(EntityManagerInterface::class); + $manager->expects(self::never())->method('getConnection'); + $this->expectException(InterventionNotFoundException::class); + $this->adapter($manager, OrganizationAccessDecision::OUTSIDE_SCOPE)->createOrLink($this->request()); + } + + public function testMissingInterventionPermissionDeniesBeforeAnyWrite(): void + { + $manager = $this->createMock(EntityManagerInterface::class); + $manager->expects(self::never())->method('getConnection'); + $this->expectException(InterventionAccessDeniedException::class); + $this->adapter($manager, OrganizationAccessDecision::MISSING_PERMISSION)->createOrLink($this->request()); + } + + public function testConversionRequiresTheCallersMainTransaction(): void + { + $connection = $this->createMock(Connection::class); + $connection->method('isTransactionActive')->willReturn(false); + $connection->expects(self::never())->method('executeQuery'); + $manager = $this->createStub(EntityManagerInterface::class); + $manager->method('getConnection')->willReturn($connection); + $this->expectException(InterventionConflictException::class); + $this->adapter($manager, OrganizationAccessDecision::GRANTED)->createOrLink($this->request()); + } + + private function adapter(EntityManagerInterface $manager, OrganizationAccessDecision $decision): ServiceRequestWorkAdapter + { + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::once())->method('resolveAccess')->with('actor', 'organization', 'organization.interventions.plan')->willReturn($decision); + $workflow = $this->createMock(InterventionWorkflowGatewayPort::class); + $workflow->expects(self::never())->method('mutate'); + $equipment = $this->createMock(EquipmentParkScopePort::class); + $equipment->expects(self::never())->method('filterIds'); + $activities = $this->createMock(InterventionActivityPort::class); + $activities->expects(self::never())->method('append'); + + return new ServiceRequestWorkAdapter($manager, $workflow, $authorization, $equipment, $activities, new InterventionMemberPolicy($this->createStub(OrganizationMemberRepositoryPort::class))); + } + + private function request(): ServiceRequestWorkRequest + { + return new ServiceRequestWorkRequest('organization', 'request', 'actor', 'equipment', null, 'Repair extinguisher', 'Replace damaged seal', null, null, 'operation'); + } +} diff --git a/tests/Unit/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionViewMapperTest.php b/tests/Unit/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionViewMapperTest.php index 28022e052..ca35d390f 100644 --- a/tests/Unit/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionViewMapperTest.php +++ b/tests/Unit/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionViewMapperTest.php @@ -5,7 +5,7 @@ namespace Tests\Unit\Intervention\Infrastructure\Persistence\Doctrine\Mapper; use Doctrine\ORM\EntityManagerInterface; -use Intervention\Application\Port\Outbound\{InterventionAttachmentRepositoryPort, InterventionResourceGatewayPort}; +use Intervention\Application\Port\Outbound\{InterventionAttachmentRepositoryPort, InterventionPublicationResourcesIssuesPort, InterventionResourceGatewayPort}; use Intervention\Application\Service\InterventionIssueFinder; use Intervention\Domain\Exception\{InterventionConflictException, InterventionNotFoundException}; use Intervention\Infrastructure\Persistence\Doctrine\Mapper\InterventionViewMapper; @@ -84,7 +84,7 @@ private function mapper(): InterventionViewMapper return new InterventionViewMapper( $this->createStub(EntityManagerInterface::class), $resources, - new InterventionIssueFinder($resources, $this->createStub(InterventionAttachmentRepositoryPort::class)), + new InterventionIssueFinder($resources, $this->createStub(InterventionAttachmentRepositoryPort::class), $this->createStub(InterventionPublicationResourcesIssuesPort::class)), ); } } diff --git a/tests/Unit/Intervention/Presentation/Api/Controller/ExportInterventionReportControllerTest.php b/tests/Unit/Intervention/Presentation/Api/Controller/ExportInterventionReportControllerTest.php index 5634746c7..d7282685c 100644 --- a/tests/Unit/Intervention/Presentation/Api/Controller/ExportInterventionReportControllerTest.php +++ b/tests/Unit/Intervention/Presentation/Api/Controller/ExportInterventionReportControllerTest.php @@ -74,6 +74,21 @@ public function testItReturnsThePdfAttachmentWithTheReportNumberInTheFilename(): ); } + #[Test] + public function testPublishedReportUsesFrozenIdentityAndResultsRatherThanLiveQueries(): void + { + $report = ['number' => 42, 'siteName' => 'Original site', 'customerName' => 'Original customer', 'workItems' => [['executionResult' => ['workPerformed' => 'Original repair']]], 'reportMode' => 'snapshot', 'generatedAt' => '2026-09-30T15:00:00+02:00', 'activities' => []]; + $queryBus = $this->createMock(QueryBusPort::class); + $view = $this->interventionView(); + $queryBus->expects(self::once())->method('ask')->with(self::isInstanceOf(GetInterventionWorkflowQuery::class))->willReturn(new GetInterventionWorkflowResult(new InterventionWorkflowView($view->resource, $view->organizationId, [...$view->data, 'closureSnapshot' => ['report' => $report]]))); + $context = $this->renderedContext($queryBus); + self::assertSame('Original site', $context['siteName']); + self::assertSame('Original customer', $context['customerName']); + self::assertSame($report['workItems'], $context['workItems']); + self::assertSame('snapshot', $context['reportMode']); + self::assertSame('30/09/2026 15:00', $context['generatedAtFormatted']); + } + #[Test] public function testItLocalizesTheContextWithBrandingLanguageAndFormattedDates(): void { @@ -374,9 +389,13 @@ private function siteNamingPort(): InterventionSiteNamingPort return $port; } - private function queryBus(): QueryBusPort + /** + * @param array|null $snapshot + */ + private function queryBus(?array $snapshot = null): QueryBusPort { - $interventionResult = new GetInterventionWorkflowResult($this->interventionView()); + $view = $this->interventionView(); + $interventionResult = new GetInterventionWorkflowResult(new InterventionWorkflowView($view->resource, $view->organizationId, [...$view->data, 'closureSnapshot' => $snapshot])); $workItemsResult = new ListInterventionWorkflowResult(new InterventionWorkflowPage(items: [], page: 1, itemsPerPage: 100, total: 0)); $changesResult = new ListInterventionWorkflowResult(new InterventionWorkflowPage( items: [$this->changeView('proposed'), $this->changeView('applied')], diff --git a/tests/Unit/Intervention/Presentation/Api/Processor/InterventionWorkItemProcessorTest.php b/tests/Unit/Intervention/Presentation/Api/Processor/InterventionWorkItemProcessorTest.php index 396e3bd28..b5cd58fea 100644 --- a/tests/Unit/Intervention/Presentation/Api/Processor/InterventionWorkItemProcessorTest.php +++ b/tests/Unit/Intervention/Presentation/Api/Processor/InterventionWorkItemProcessorTest.php @@ -139,6 +139,17 @@ public function testAPatchOnlySendsTheFieldsPresentInTheMergePatchBody(): void )->process($input, new Patch(), ['id' => self::WORK_ITEM_ID]); } + #[Test] + public function testAPatchCarriesExecutionFactsThroughTheExistingRevisionedMutation(): void + { + $facts = ['equipmentId' => '550e8400-e29b-41d4-a716-446655441511', 'performedAt' => '2026-09-30T15:00:00+02:00', 'outcome' => 'successful', 'workPerformed' => 'Seal replaced']; + $commandBus = $this->createMock(CommandBusPort::class); + $commandBus->expects(self::once())->method('dispatch')->with(self::callback(static fn (MutateInterventionWorkflowCommand $command): bool => 1 === $command->expectedRevision && ['executionResult' => $facts] === $command->payload))->willReturn(new MutateInterventionWorkflowResult($this->view())); + $input = new UpdateInterventionWorkItemInput(); + $input->executionResult = $facts; + $this->createProcessor($commandBus, 'PATCH', body: '{"executionResult":{}}', ifMatch: '"revision-1"')->process($input, new Patch(), ['id' => self::WORK_ITEM_ID]); + } + #[Test] public function testAPatchResolvesAnAssigneeIriIntoAMemberId(): void { diff --git a/tests/Unit/Intervention/Presentation/Api/Provider/EquipmentOpenWorkProviderTest.php b/tests/Unit/Intervention/Presentation/Api/Provider/EquipmentOpenWorkProviderTest.php new file mode 100644 index 000000000..5aafca14a --- /dev/null +++ b/tests/Unit/Intervention/Presentation/Api/Provider/EquipmentOpenWorkProviderTest.php @@ -0,0 +1,31 @@ +createStub(Security::class); + $security->method('getUser')->willReturn(new SecurityUser('user', 'user@example.com', 'hashed', ['ROLE_USER'])); + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::once())->method('ask')->with(self::callback(static fn (ListEquipmentOpenWorkQuery $query): bool => 'user' === $query->userId && 'organization' === $query->organizationId && 'equipment' === $query->equipmentId))->willReturn(new ListEquipmentOpenWorkResult([new InterventionEquipmentWork('order', 42, 'Repair', 'in_progress', 'task', 'repair', 'planned')])); + $result = new EquipmentOpenWorkProvider($queries, $security)->provide(new GetCollection(), ['organizationId' => 'organization', 'equipmentId' => 'equipment']); + self::assertSame('order', $result[0]->interventionId); + self::assertSame(42, $result[0]->number); + self::assertSame('repair', $result[0]->action); + self::assertSame('planned', $result[0]->workItemStatus); + } +} diff --git a/tests/Unit/Inventory/Application/ApplyInventoryStockHandlerTest.php b/tests/Unit/Inventory/Application/ApplyInventoryStockHandlerTest.php new file mode 100644 index 000000000..cbfd123e0 --- /dev/null +++ b/tests/Unit/Inventory/Application/ApplyInventoryStockHandlerTest.php @@ -0,0 +1,290 @@ +createMock(InventoryStorePort::class); + $this->references($store); + $store->method('balanceForUpdate')->willReturn(new StockBalance('balance', self::ORG, self::PART, self::WAREHOUSE, '1.000000', '3.000000', 'EUR')); + $store->expects(self::never())->method('saveBalance'); + $store->expects(self::never())->method('saveMovement'); + $saved = []; + $store->expects(self::exactly(2))->method('saveDeclaration')->willReturnCallback(static function (ConsumptionDeclaration $d) use (&$saved): void {$saved[] = $d; }); + $store->expects(self::once())->method('saveOperation'); + $result = $this->handler($store)($this->consume('2')); + self::assertNotNull($result->declaration); + self::assertSame('received_pending', $result->declaration->status); + self::assertSame('2.000000', $result->declaration->quantity); + self::assertSame('insufficient_stock', $result->declaration->reason); + self::assertNull($result->declaration->movementId); + self::assertCount(2, $saved); + } + + #[Test] + public function missingBalanceAndLatePublicationRetainAnExplicitPendingFact(): void + { + $store = $this->createMock(InventoryStorePort::class); + $this->references($store); + $store->expects(self::never())->method('saveMovement'); + $store->method('balanceForUpdate')->willReturn(null); + $result = $this->handler($store, true)($this->consume('1')); + self::assertNotNull($result->declaration); + self::assertTrue($result->declaration->late); + self::assertSame('missing_balance', $result->declaration->reason); + } + + #[Test] + public function replayUsesTheFirstPendingSnapshotEvenAfterTheDeclarationIsConfirmed(): void + { + $store = $this->createMock(InventoryStorePort::class); + $this->references($store); + $store->method('balanceForUpdate')->willReturn(null); + $receipt = null; + $store->method('operationForUpdate')->willReturnCallback(static function () use (&$receipt): ?InventoryOperationReceipt {return $receipt; }); + $store->expects(self::once())->method('saveOperation')->willReturnCallback(static function (InventoryOperationReceipt $r) use (&$receipt): void {$receipt = $r; }); + $store->expects(self::exactly(2))->method('saveDeclaration'); + $store->expects(self::never())->method('saveMovement'); + $handler = $this->handler($store); + $first = $handler($this->consume('1')); + $second = $handler($this->consume('1')); + self::assertNotNull($first->declaration); + self::assertNotNull($second->declaration); + self::assertTrue($second->replayed); + self::assertSame($first->declaration->id, $second->declaration->id); + self::assertSame('received_pending', $second->declaration->status); + } + + #[Test] + public function reusedOperationWithAnotherBodyFailsBeforeAnyPhysicalWrite(): void + { + $store = $this->createMock(InventoryStorePort::class); + $store->method('operationForUpdate')->willReturn(new InventoryOperationReceipt(self::ORG, self::OP, 'other', [])); + $store->expects(self::never())->method('saveDeclaration'); + $this->expectException(InventoryConflictException::class); + $this->handler($store)($this->consume('1')); + } + + /** + * Method partialAndFullReturnsRestoreExactlyTheOriginalValueWithoutDuplicateCredits + * + * Keeps partial credits bounded and restores the source quantity and value exactly across replay. + * + * @access public + * + * @param string $originalQuantity full consumed quantity + * @param string|null $originalValue exact consumed value, or unknown + * @param list $quantities + * @param list $values + * + * @return void + */ + #[Test] + #[DataProvider('returnAllocations')] + public function partialAndFullReturnsRestoreExactlyTheOriginalValueWithoutDuplicateCredits(string $originalQuantity, ?string $originalValue, array $quantities, array $values): void + { + $store = $this->createMock(InventoryStorePort::class); + $this->references($store); + $originalId = 'bee10000-0000-4000-8000-000000000007'; + $declarationId = 'bee10000-0000-4000-8000-000000000008'; + $original = new StockMovement($originalId, self::ORG, self::PART, self::WAREHOUSE, 'consumption', DecimalAmount::zero()->subtract(DecimalAmount::fromString($originalQuantity))->toString(), null === $originalValue ? null : DecimalAmount::fromString($originalValue)->divide(DecimalAmount::fromString($originalQuantity))->toString(), null === $originalValue ? null : DecimalAmount::zero()->subtract(DecimalAmount::fromString($originalValue))->toString(), 'EUR', 'Declared consumption', self::ACTOR, new DateTimeImmutable(), self::INTERVENTION); + $store->method('declaration')->willReturn(new ConsumptionDeclaration($declarationId, self::ORG, self::PART, self::WAREHOUSE, $originalQuantity, self::INTERVENTION, null, null, self::ACTOR, new DateTimeImmutable(), 'confirmed', null, $originalId, false)); + $balance = new StockBalance('balance', self::ORG, self::PART, self::WAREHOUSE, '0.000000', '0.000000', 'EUR'); + /** @var array $movements */ + $movements = []; + /** @var array $operations */ + $operations = []; + $store->method('balanceForUpdate')->willReturnCallback(static function () use (&$balance): StockBalance {return $balance; }); + $store->method('movement')->willReturnCallback(static function (string $org, string $id) use ($original, &$movements): ?StockMovement {return $id === $original->id ? $original : ($movements[$id] ?? null); }); + $store->method('linkedQuantity')->willReturnCallback(static function () use (&$movements): string { + $sum = DecimalAmount::zero(); + foreach ($movements as $movement) { + $sum = $sum->add(DecimalAmount::fromString($movement->quantity)); + } + + return $sum->toString(); + }); + $store->method('linkedValue')->willReturnCallback(static function () use (&$movements): ?string { + $sum = DecimalAmount::zero(); + foreach ($movements as $movement) { + if (null === $movement->totalValue) { + return null; + } + $sum = $sum->add(DecimalAmount::fromString($movement->totalValue)); + } + + return $sum->toString(); + }); + $store->method('operationForUpdate')->willReturnCallback(static function (string $org, string $id) use (&$operations): ?InventoryOperationReceipt {return $operations[$id] ?? null; }); + $store->expects(self::exactly(count($quantities)))->method('saveBalance')->willReturnCallback(static function (StockBalance $next) use (&$balance): void {$balance = $next; }); + $store->expects(self::exactly(count($quantities)))->method('saveMovement')->willReturnCallback(static function (StockMovement $movement) use (&$movements): void {$movements[$movement->id] = $movement; }); + $store->expects(self::exactly(count($quantities)))->method('saveOperation')->willReturnCallback(static function (InventoryOperationReceipt $receipt) use (&$operations): void {$operations[$receipt->clientOperationId] = $receipt; }); + $handler = $this->handler($store); + foreach ($quantities as $index => $quantity) { + $operation = 'bee10000-0000-4000-8000-' . str_pad((string) (100 + $index), 12, '0', STR_PAD_LEFT); + $command = new ApplyInventoryStockCommand(self::ORG, self::ACTOR, 'return', $operation, quantity:$quantity, reason:'Unused part returned', originalId:$declarationId); + $result = $handler($command); + self::assertNotNull($result->movement); + self::assertSame($values[$index], $result->movement->totalValue); + self::assertSame(DecimalAmount::fromString($quantity)->toString(), $result->movement->quantity); + self::assertSame($originalId, $result->movement->correctionOf); + self::assertSame('Unused part returned', $result->movement->reason); + if (null !== $balance->totalValue && null !== $originalValue) { + self::assertFalse(DecimalAmount::fromString($balance->totalValue)->isNegative()); + self::assertLessThanOrEqual(0, DecimalAmount::fromString($balance->totalValue)->compareTo(DecimalAmount::fromString($originalValue))); + } + $replayed = $handler($command); + self::assertTrue($replayed->replayed); + self::assertSame($result->movement, $replayed->movement); + self::assertNotNull($replayed->receipt); + self::assertSame($values[$index], $replayed->receipt->totalValue); + } + self::assertSame(DecimalAmount::fromString($originalQuantity)->toString(), $balance->quantity); + self::assertSame($originalValue, $balance->totalValue); + } + + /** + * Method returnAllocations + * + * Covers six-place rounding, fractional returns and unknown source costs. + * + * @access public + * + * @return iterable,list}> + */ + public static function returnAllocations(): iterable + { + yield 'four half-unit allocations' => ['4', '0.000002', ['1', '1', '1', '1'], ['0.000001', '0.000000', '0.000001', '0.000000']]; + yield 'thirds of one precision unit' => ['3', '0.000001', ['1', '1', '1'], ['0.000000', '0.000001', '0.000000']]; + yield 'fractional mixed quantities' => ['3', '1.000000', ['0.5', '1.5', '1'], ['0.166667', '0.500000', '0.333333']]; + yield 'partial then remaining quantity' => ['3', '1.000000', ['0.5', '2.5'], ['0.166667', '0.833333']]; + yield 'whole original quantity' => ['4', '0.000002', ['4'], ['0.000002']]; + yield 'known zero value' => ['4', '0.000000', ['1', '3'], ['0.000000', '0.000000']]; + yield 'unknown original value' => ['4', null, ['1', '3'], [null, null]]; + yield 'fractional half-unit allocations' => ['0.000004', '0.000002', ['0.000001', '0.000001', '0.000001', '0.000001'], ['0.000001', '0.000000', '0.000001', '0.000000']]; + } + + /** + * Method returnsPreservePreviouslyStoredRoundingAndUnknownValues + * + * Retains earlier immutable allocations without assigning an unknown or excess credit. + * + * @access public + * + * @param string|null $linkedValue total of earlier return values, or unknown + * @param string $quantity next return quantity + * @param string|null $expectedValue expected credit, or unknown + * @param bool $conflict whether prior excess value rejects the return + * + * @return void + */ + #[Test] + #[DataProvider('previousReturnValuations')] + public function returnsPreservePreviouslyStoredRoundingAndUnknownValues(?string $linkedValue, string $quantity, ?string $expectedValue, bool $conflict = false): void + { + $store = $this->createMock(InventoryStorePort::class); + $this->references($store); + $originalId = 'bee10000-0000-4000-8000-000000000007'; + $declarationId = 'bee10000-0000-4000-8000-000000000008'; + $store->method('declaration')->willReturn(new ConsumptionDeclaration($declarationId, self::ORG, self::PART, self::WAREHOUSE, '4.000000', self::INTERVENTION, null, null, self::ACTOR, new DateTimeImmutable(), 'confirmed', null, $originalId, false)); + $store->method('movement')->willReturn(new StockMovement($originalId, self::ORG, self::PART, self::WAREHOUSE, 'consumption', '-4.000000', '0.000001', '-0.000002', 'EUR', 'Declared consumption', self::ACTOR, new DateTimeImmutable(), self::INTERVENTION)); + $store->method('balanceForUpdate')->willReturn(new StockBalance('balance', self::ORG, self::PART, self::WAREHOUSE, '2.000000', $linkedValue, 'EUR')); + $store->method('linkedQuantity')->willReturn('2.000000'); + $store->method('linkedValue')->willReturn($linkedValue); + if ($conflict) { + $store->expects(self::never())->method('saveBalance'); + $store->expects(self::never())->method('saveMovement'); + $store->expects(self::never())->method('saveOperation'); + $this->expectException(InventoryConflictException::class); + } else { + $store->expects(self::once())->method('saveBalance')->willReturnCallback(static function (StockBalance $balance) use ($quantity, $linkedValue): void { + self::assertSame(DecimalAmount::fromString('2')->add(DecimalAmount::fromString($quantity))->toString(), $balance->quantity); + self::assertSame($linkedValue, $balance->totalValue); + }); + $store->expects(self::once())->method('saveMovement'); + $store->expects(self::once())->method('saveOperation'); + } + $result = $this->handler($store)(new ApplyInventoryStockCommand(self::ORG, self::ACTOR, 'return', self::OP, quantity:$quantity, reason:'Unused part returned', originalId:$declarationId)); + self::assertNotNull($result->movement); + self::assertSame($expectedValue, $result->movement->totalValue); + } + + /** + * Method previousReturnValuations + * + * Covers earlier rounded, unknown and already excessive return valuations. + * + * @access public + * + * @return iterable + */ + public static function previousReturnValuations(): iterable + { + yield 'old rounding used all original value' => ['0.000002', '0.1', '0.000000', false]; + yield 'unknown previous return' => [null, '1', null, false]; + yield 'old overcredit rejected before a rounded zero' => ['0.000003', '0.000001', null, true]; + } + + private function consume(string $q): ApplyInventoryStockCommand + { + return new ApplyInventoryStockCommand(self::ORG, self::ACTOR, 'consumption', self::OP, self::PART, self::WAREHOUSE, $q, new DateTimeImmutable('2026-10-06T10:00:00+00:00'), self::INTERVENTION); + } + + private function references(\PHPUnit\Framework\MockObject\MockObject&InventoryStorePort $store): void + { + $store->method('reference')->willReturnCallback(static fn (string $type): InventoryReference => 'parts' === $type ? new InventoryReference(self::PART, self::ORG, 'P', 'Part', 'piece', 'part') : new InventoryReference(self::WAREHOUSE, self::ORG, 'W', 'Warehouse')); + } + + private function handler(InventoryStorePort $store, bool $late = false): ApplyInventoryStockHandler + { + $tx = $this->createStub(TransactionManagerPort::class); + $tx->method('transactional')->willReturnCallback(static fn (callable $callback): mixed => $callback()); + $ids = $this->createStub(UuidGeneratorPort::class); + $counter = 10; + $ids->method('generate')->willReturnCallback(static function () use (&$counter): string {return 'bee10000-0000-4000-8000-' . str_pad((string) ++$counter, 12, '0', STR_PAD_LEFT); }); + $currency = $this->createStub(MaintenanceCurrencyPort::class); + $currency->method('forOrganization')->willReturn('EUR'); + $currency->method('lock')->willReturn('EUR'); + $context = $this->createStub(InterventionInventoryContextPort::class); + $context->method('validate')->willReturn(new InventoryInterventionContext($late)); + + return new ApplyInventoryStockHandler($store, $tx, $ids, $currency, $context); + } +} diff --git a/tests/Unit/Inventory/Domain/StockValuationTest.php b/tests/Unit/Inventory/Domain/StockValuationTest.php new file mode 100644 index 000000000..e49d6b995 --- /dev/null +++ b/tests/Unit/Inventory/Domain/StockValuationTest.php @@ -0,0 +1,71 @@ +receive('1.000000', '1.000000')->receive('2.000000', '4.000000'); + self::assertSame('3.000000', $balance->quantity); + self::assertSame('5.000000', $balance->totalValue); + $first = $balance->issue('1.000000'); + self::assertSame('1.666667', $first->totalValue); + $second = $first->balance->issue('1.000000'); + self::assertSame('1.666667', $second->totalValue); + $last = $second->balance->issue('1.000000'); + self::assertSame('1.666666', $last->totalValue); + self::assertSame('0.000000', $last->balance->quantity); + self::assertSame('0.000000', $last->balance->totalValue); + } + + #[Test] + public function fractionalQuantityDoesNotRoundTheIntermediateProduct(): void + { + $issue = new StockValuation('0.000003', '0.000001')->issue('0.000001'); + self::assertSame('0.000000', $issue->totalValue); + self::assertSame('0.000001', $issue->balance->totalValue); + $last = $issue->balance->issue('0.000002'); + self::assertSame('0.000001', $last->totalValue); + } + + #[Test] + public function unknownReceiptKeepsTheBalanceIncompleteUntilItIsEmptied(): void + { + $mixed = new StockValuation('1.000000', null)->receive('1.000000', '8.000000'); + self::assertNull($mixed->totalValue); + $issue = $mixed->issue('1.000000'); + self::assertNull($issue->totalValue); + $empty = $issue->balance->issue('1.000000'); + self::assertNull($empty->totalValue); + self::assertSame('0.000000', $empty->balance->totalValue); + self::assertSame('2.000000', $empty->balance->receive('1.000000', '2.000000')->totalValue); + } + + #[Test] + public function shortagesRefuseTheWholeIssue(): void + { + $this->expectException(InventoryConflictException::class); + new StockValuation('1.000000', '2.000000')->issue('2.000000'); + } + + #[Test] + public function quantitiesAreCanonicalStringsWithSixPlaces(): void + { + self::assertSame('1.230000', new StockQuantity('1.23')->value); + self::assertSame('-1.000000', new StockQuantity('-1', true)->value); + $this->expectException(InvalidArgumentException::class); + new StockQuantity('0.000000'); + } +} diff --git a/tests/Unit/Inventory/Infrastructure/Adapter/Intervention/InventoryEconomicIdentityTest.php b/tests/Unit/Inventory/Infrastructure/Adapter/Intervention/InventoryEconomicIdentityTest.php new file mode 100644 index 000000000..d978505a3 --- /dev/null +++ b/tests/Unit/Inventory/Infrastructure/Adapter/Intervention/InventoryEconomicIdentityTest.php @@ -0,0 +1,39 @@ +createStub(InventoryStorePort::class); + $at = new DateTimeImmutable('2026-10-07T12:00:00Z'); + $store->method('interventionMovements')->willReturn([new StockMovement('movement', 'org', 'part', 'warehouse', 'issue', '-1.250000', '4.250000', '-5.312500', 'EUR', 'repair', 'actor', $at, 'work', null, 'confirmed-equipment')]); + $store->method('list')->willReturn([new ConsumptionDeclaration('declaration', 'org', 'part', 'warehouse', '2.500000', 'work', null, 'pending-equipment', 'actor', $at, 'received_pending', 'insufficient_stock', null)]); + $currency = $this->createStub(MaintenanceCurrencyPort::class); + $currency->method('forOrganization')->willReturn('EUR'); + $facts = new InventoryInterventionResourcesAdapter($store, $currency)->costFacts('org', 'work'); + self::assertCount(2, $facts); + self::assertSame('confirmed-equipment', $facts[0]->equipmentId); + self::assertSame('1.250000', $facts[0]->quantity); + self::assertSame('5.312500', $facts[0]->exactAmount); + self::assertSame('pending-equipment', $facts[1]->equipmentId); + self::assertNull($facts[1]->exactAmount); + } +} diff --git a/tests/Unit/Inventory/Infrastructure/InventoryPartDirectoryAdapterTest.php b/tests/Unit/Inventory/Infrastructure/InventoryPartDirectoryAdapterTest.php new file mode 100644 index 000000000..9a8298541 --- /dev/null +++ b/tests/Unit/Inventory/Infrastructure/InventoryPartDirectoryAdapterTest.php @@ -0,0 +1,41 @@ +createMock(InventoryStorePort::class); + $store->expects(self::once())->method('referencesByIds')->with('parts', 'org', ['owned', 'foreign'])->willReturn(['owned' => new InventoryReference('owned', 'org', 'SEAL', 'Seal', 'piece', 'part', true)]); + $store->expects(self::never())->method('reference'); + $result = new InventoryPartDirectoryAdapter($store)->describeMany('org', ['owned', 'foreign']); + self::assertCount(1, $result); + self::assertArrayNotHasKey('foreign', $result); + self::assertTrue($result['owned']->archived); + self::assertSame('piece', $result['owned']->unit); + self::assertSame('SEAL', $result['owned']->code); + } + + #[Test] + public function oversizedCatalogBatchesAreRejectedBeforeQuerying(): void + { + $store = $this->createMock(InventoryStorePort::class); + $store->expects(self::never())->method('referencesByIds'); + $this->expectException(InvalidArgumentException::class); + new InventoryPartDirectoryAdapter($store)->describeMany('org', array_fill(0, 101, 'part')); + } +} diff --git a/tests/Unit/Inventory/Presentation/InventoryProcessorTest.php b/tests/Unit/Inventory/Presentation/InventoryProcessorTest.php new file mode 100644 index 000000000..609fa7b58 --- /dev/null +++ b/tests/Unit/Inventory/Presentation/InventoryProcessorTest.php @@ -0,0 +1,69 @@ +createMock(CommandBusPort::class); + $commands->expects(self::never())->method('dispatch'); + $auth = $this->createStub(OrganizationAuthorizationPort::class); + $auth->method('resolveAccess')->willReturnCallback(static fn (string $actor, string $org, string $permission): OrganizationAccessDecision => 'organization.maintenance_cost.manage' === $permission ? OrganizationAccessDecision::MISSING_PERMISSION : OrganizationAccessDecision::GRANTED); + $processor = new InventoryProcessor($commands, $this->access($auth), new InventoryOutputFactory()); + $this->expectException(AccessDeniedHttpException::class); + $processor->process(new CorrectInventoryStockInput(), new Post(name:'inventory_corrections_create'), ['organizationId' => self::ORG]); + } + + #[Test] + public function warehouseNamesAreTranslatedThroughTheCommandAndStableIdentifier(): void + { + $commands = $this->createMock(CommandBusPort::class); + $commands->expects(self::once())->method('dispatch')->willReturnCallback(static function (ManageInventoryReferenceCommand $c): ManageInventoryReferenceResult { + self::assertSame('warehouses', $c->type); + self::assertSame('Main stock', $c->label); + self::assertSame('M', $c->code); + + return new ManageInventoryReferenceResult(new InventoryReference('warehouse', self::ORG, 'M', 'Main stock')); + }); + $auth = $this->createStub(OrganizationAuthorizationPort::class); + $auth->method('resolveAccess')->willReturn(OrganizationAccessDecision::GRANTED); + $input = new CreateInventoryWarehouseInput(); + $input->code = 'M'; + $input->name = 'Main stock'; + $output = new InventoryProcessor($commands, $this->access($auth), new InventoryOutputFactory())->process($input, new Post(name:'inventory_warehouses_create'), ['organizationId' => self::ORG]); + self::assertInstanceOf(InventoryWarehouseOutput::class, $output); + self::assertSame('warehouse', $output->id); + self::assertSame('Main stock', $output->name); + } + + private function access(OrganizationAuthorizationPort $auth): InventoryAccess + { + $security = $this->createStub(Security::class); + $security->method('getUser')->willReturn(new SecurityUser('beb70000-0000-4000-8000-000000000003', 'stock@corp.example', 'unused', ['ROLE_USER'])); + + return new InventoryAccess($security, $auth); + } +} diff --git a/tests/Unit/Inventory/Presentation/InventoryProviderTest.php b/tests/Unit/Inventory/Presentation/InventoryProviderTest.php new file mode 100644 index 000000000..76da3ad80 --- /dev/null +++ b/tests/Unit/Inventory/Presentation/InventoryProviderTest.php @@ -0,0 +1,73 @@ +createMock(QueryBusPort::class); + $queries->expects(self::once())->method('ask')->willReturnCallback(static function (ListInventoryQuery $q): ListInventoryResult { + self::assertSame(2, $q->page); + self::assertSame(5, $q->itemsPerPage); + self::assertSame(['partId' => 'beb70000-0000-4000-8000-000000000002'], $q->filters); + + return new ListInventoryResult([new StockBalance('balance', self::ORG, 'part', 'warehouse', '1.000000', '75.000000', 'EUR')], 6); + }); + $auth = $this->createStub(OrganizationAuthorizationPort::class); + $auth->method('resolveAccess')->willReturnCallback(static fn (string $actor, string $org, string $permission): OrganizationAccessDecision => 'organization.maintenance_cost.read' === $permission ? OrganizationAccessDecision::MISSING_PERMISSION : OrganizationAccessDecision::GRANTED); + $stack = new RequestStack(); + $stack->push(new Request(['page' => '2', 'itemsPerPage' => '5', 'partId' => 'beb70000-0000-4000-8000-000000000002'])); + $provider = new InventoryProvider($queries, $this->access($auth), new InventoryOutputFactory(), $stack); + $page = $provider->provide(new GetCollection(name:'inventory_balances_list'), ['organizationId' => self::ORG]); + self::assertInstanceOf(\ApiPlatform\State\Pagination\TraversablePaginator::class, $page); + self::assertSame(6.0, $page->getTotalItems()); + $items = iterator_to_array($page); + self::assertCount(1, $items); + self::assertInstanceOf(\Inventory\Presentation\Api\Dto\Output\InventoryBalanceOutput::class, $items[0]); + self::assertNull($items[0]->valuation); + } + + #[Test] + public function missingReadPermissionStopsBeforeAnyInventoryQuery(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::never())->method('ask'); + $auth = $this->createStub(OrganizationAuthorizationPort::class); + $auth->method('resolveAccess')->willReturn(OrganizationAccessDecision::MISSING_PERMISSION); + $provider = new InventoryProvider($queries, $this->access($auth), new InventoryOutputFactory(), new RequestStack()); + $this->expectException(AccessDeniedHttpException::class); + $provider->provide(new GetCollection(name:'inventory_parts_list'), ['organizationId' => self::ORG]); + } + + private function access(OrganizationAuthorizationPort $auth): InventoryAccess + { + $security = $this->createStub(Security::class); + $security->method('getUser')->willReturn(new SecurityUser('beb70000-0000-4000-8000-000000000003', 'stock@corp.example', 'unused', ['ROLE_USER'])); + + return new InventoryAccess($security, $auth); + } +} diff --git a/tests/Unit/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanHandlerTest.php b/tests/Unit/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanHandlerTest.php new file mode 100644 index 000000000..d27daedcd --- /dev/null +++ b/tests/Unit/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanHandlerTest.php @@ -0,0 +1,539 @@ +)|null + */ + private ?Closure $planPages = null; + + private MaintenancePlanStorePort&MockObject $store; + + private MaintenanceScheduleRepositoryPort&MockObject $schedules; + + private InterventionDraftFactoryPort&MockObject $drafts; + + private InterventionMaintenanceWorkPort&MockObject $work; + + private EventDispatcherPort&MockObject $events; + + private MaintenanceFacilityLifecyclePort&Stub $facilities; + + private MaintenanceEquipmentDirectoryPort&Stub $equipment; + + private ManageMaintenancePlanHandler $handler; + + protected function setUp(): void + { + $now = new DateTimeImmutable('2026-10-06T00:00:00Z'); + $due = new DateTimeImmutable('2027-01-31T00:00:00Z'); + $this->plan = new MaintenancePlanState(self::PLAN, self::ORG, self::EQ, null, 'fire_extinguisher', 'Annual control', 'control', 'P1Y', 'fixed', $due, $due, true, null, null, null, $now, $now); + $this->store = $this->createMock(MaintenancePlanStorePort::class); + $this->store->expects(self::atMost(1))->method('synchronized')->willReturnCallback(static fn (string $organization, callable $work): mixed => $work()); + $this->store->method('engineMode')->willReturnCallback(fn (): string => $this->mode); + $this->store->method('find')->willReturnCallback(fn (): MaintenancePlanState => $this->plan); + $this->store->method('findByLegacySchedule')->willReturnCallback(fn (): ?MaintenancePlanState => null === $this->plan->legacyScheduleId ? null : $this->plan); + $this->store->method('list')->willReturnCallback(function (string $organization, int $limit, int $offset, ?string $equipment = null, ?string $kind = null, ?string $search = null, bool $includeArchived = false): array { + if (null !== $this->planPages) { + return ($this->planPages)($offset); + } + + return 0 !== $offset || (null !== $kind && $kind !== $this->plan->operationKind) || (!$includeArchived && null !== $this->plan->archivedAt) ? [] : [$this->plan]; + }); + $this->store->method('count')->willReturn(1); + $this->store->method('save')->willReturnCallback(function (MaintenancePlanState $plan): void { $this->plan = $plan; }); + $this->store->method('openOccurrence')->willReturnCallback(fn (): ?MaintenanceOccurrenceState => null !== $this->occurrence && 'open' === $this->occurrence->status ? $this->occurrence : null); + $this->store->method('findOccurrence')->willReturnCallback(fn (): ?MaintenanceOccurrenceState => $this->occurrence); + $this->store->method('saveOccurrence')->willReturnCallback(function (MaintenanceOccurrenceState $occurrence): void { $this->occurrence = $occurrence; }); + $this->schedules = $this->createMock(MaintenanceScheduleRepositoryPort::class); + $this->schedules->expects(self::atMost(5))->method('findById')->willReturnCallback(fn (): ?MaintenanceScheduleView => $this->legacy); + $this->schedules->method('findByOrganizationAndEquipment')->willReturnCallback(fn (): ?MaintenanceScheduleView => $this->legacy); + $this->schedules->method('save')->willReturnCallback(static fn (MaintenanceScheduleSnapshot $snapshot): MaintenanceScheduleView => new MaintenanceScheduleView($snapshot->id ?? self::PLAN, $snapshot->organizationId, $snapshot->equipmentId, $snapshot->facilityId, $snapshot->equipmentType, $snapshot->intervalOverride, $snapshot->lastInspectionClosedAt, $snapshot->nextDueAt, $snapshot->dueStatus, $snapshot->lastRemindedAt, $snapshot->remindedFor, new DateTimeImmutable('2026-10-06Z'), new DateTimeImmutable('2026-10-06Z'))); + $this->schedules->method('list')->willReturnCallback(fn (): MaintenanceSchedulePage => new MaintenanceSchedulePage(null === $this->legacy ? [] : [$this->legacy], 1, 200, null === $this->legacy ? 0 : 1)); + $this->equipment = $this->createStub(MaintenanceEquipmentDirectoryPort::class); + $this->currentEquipment = new TrackableEquipment(self::EQ, self::ORG, null, 'fire_extinguisher', 'operational'); + $this->equipment->method('findEquipment')->willReturnCallback($this->equipmentSnapshot(...)); + $this->facilities = $this->createStub(MaintenanceFacilityLifecyclePort::class); + $policy = $this->createStub(MaintenanceCompliancePolicyPort::class); + $this->currentPolicy = new MaintenanceCompliancePolicy(['fire_extinguisher' => 'P1Y'], 30); + $policy->method('compliancePolicy')->willReturnCallback(fn (): MaintenanceCompliancePolicy => $this->currentPolicy); + $this->drafts = $this->createMock(InterventionDraftFactoryPort::class); + $this->drafts->expects(self::atMost(1))->method('create')->willReturn(new CreatedInterventionDraft(self::WORK, 42, 1)); + $this->work = $this->createMock(InterventionMaintenanceWorkPort::class); + $this->work->expects(self::atMost(3))->method('status')->willReturnCallback(fn (): ?string => $this->workStatus); + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('resolveAccess')->willReturnCallback(fn (): OrganizationAccessDecision => $this->decision); + $clock = $this->createStub(ClockPort::class); + $clock->method('now')->willReturn($now); + $ids = $this->createStub(UuidGeneratorPort::class); + $ids->method('generate')->willReturn(self::OCCURRENCE); + $this->events = $this->createMock(EventDispatcherPort::class); + $this->events->expects(self::atMost(3))->method('dispatch'); + $this->handler = new ManageMaintenancePlanHandler($this->store, $this->schedules, $this->equipment, $this->facilities, $policy, $this->drafts, $this->work, $authorization, $clock, $ids, $this->events); + } + + #[Test] + public function generationLinksTheOccurrenceWithoutCompletingOrMovingItsDueDate(): void + { + $due = $this->plan->nextDueAt?->format(DATE_ATOM); + $this->drafts->expects(self::once())->method('create')->with(self::callback(function (CreateInterventionDraftRequest $request): bool { + self::assertSame('inspection_campaign', $request->type); + self::assertSame('/api/equipment/' . self::EQ, $request->workItems[0]->target); + self::assertSame(self::PLAN, $request->workItems[0]->operationId); + self::assertSame(self::OCCURRENCE, $request->workItems[0]->occurrenceId); + self::assertSame('control', $request->workItems[0]->operationKind); + self::assertSame('open', $this->occurrence?->status); + + return true; + }))->willReturn(new CreatedInterventionDraft(self::WORK, 42, 1)); + $this->events->expects(self::once())->method('dispatch'); + $result = ($this->handler)(new ManageMaintenancePlanCommand('generate', self::ORG, 'actor', planId: self::PLAN)); + self::assertSame(self::OCCURRENCE, $result->occurrenceId); + self::assertSame(self::WORK, $result->interventionId); + self::assertSame(42, $result->number); + self::assertSame(1, $this->occurrence?->attempt); + self::assertSame('open', $this->occurrence->status); + self::assertSame($due, $this->plan->nextDueAt?->format(DATE_ATOM)); + } + + #[Test] + public function replayReturnsTheSameAttemptWithoutDraftOrEvent(): void + { + $this->attempt(); + $this->drafts->expects(self::never())->method('create'); + $this->events->expects(self::never())->method('dispatch'); + $result = ($this->handler)(new ManageMaintenancePlanCommand('generate', self::ORG, 'actor', planId: self::PLAN)); + self::assertTrue($result->replayed); + self::assertSame(self::WORK, $result->interventionId); + self::assertSame(42, $result->number); + } + + #[Test] + public function aSkippedPublishedTaskRequiresAnExplicitRetryOnTheSameOccurrence(): void + { + $this->attempt(); + self::assertNotNull($this->occurrence); + $this->occurrence->interventionId = self::RESULT; + $this->workStatus = 'published'; + $result = ($this->handler)(new ManageMaintenancePlanCommand('generate', self::ORG, 'actor', planId: self::PLAN, retry: true)); + $retried = $this->occurrence; + self::assertSame(self::OCCURRENCE, $result->occurrenceId); + self::assertSame(self::WORK, $result->interventionId); + self::assertSame(2, $retried->attempt); + self::assertSame('2027-01-31', $retried->dueAt->format('Y-m-d')); + self::assertNull($retried->resultId); + } + + #[Test] + public function newMaintenancePlansStartPreparedWithoutAutomaticRegulatoryFrequency(): void + { + $this->store->expects(self::once())->method('save'); + $result = ($this->handler)(new ManageMaintenancePlanCommand('create', self::ORG, 'actor', equipmentId: self::EQ, name: 'Weekly servicing', operationKind: 'maintenance', interval: 'P1W', anchorAt: new DateTimeImmutable('2027-01-31T00:00:00Z'))); + self::assertFalse($result->details?->plan->active); + self::assertSame('P1W', $result->details->plan->interval); + self::assertSame('maintenance', $result->details->plan->operationKind); + } + + #[Test] + public function failedControlIsCompletedWhileItsDefectsRemainOutsideMaintenance(): void + { + $this->attempt(); + $result = $this->operationResult('failed'); + ($this->handler)(new ManageMaintenancePlanCommand('acknowledge_result', self::ORG, null, operationResult: $result)); + self::assertSame('completed', $this->occurrence?->status); + self::assertSame(self::RESULT, $this->occurrence->resultId); + self::assertSame('2028-01-31', $this->plan->nextDueAt?->format('Y-m-d')); + self::assertSame('2026-10-06', $this->plan->lastCompletedAt?->format('Y-m-d')); + } + + #[Test] + public function unsuccessfulMaintenanceKeepsTheSameOccurrenceDueForAnExplicitRetry(): void + { + $this->plan->operationKind = 'maintenance'; + $this->attempt(); + $this->schedules->expects(self::never())->method('save'); + ($this->handler)(new ManageMaintenancePlanCommand('acknowledge_result', self::ORG, null, operationResult: $this->operationResult('failed'))); + self::assertSame('open', $this->occurrence?->status); + self::assertSame('2027-01-31', $this->plan->nextDueAt?->format('Y-m-d')); + self::assertNull($this->plan->lastCompletedAt); + } + + #[Test] + public function validationChecksDoNotAcknowledgeTheOccurrence(): void + { + $this->attempt(); + $this->store->expects(self::never())->method('saveOccurrence'); + $this->store->expects(self::never())->method('saveReceipt'); + ($this->handler)(new ManageMaintenancePlanCommand('validate_result', self::ORG, null, operationResult: $this->operationResult('passed'))); + self::assertNull($this->occurrence?->resultId); + } + + #[Test] + public function receiptReplayDoesNotMoveTheCalendarAgain(): void + { + $this->attempt(); + $this->store->method('hasReceipt')->willReturn(true); + $this->store->expects(self::never())->method('saveOccurrence'); + $this->store->expects(self::never())->method('save'); + $this->events->expects(self::never())->method('dispatch'); + ($this->handler)(new ManageMaintenancePlanCommand('acknowledge_result', self::ORG, null, operationResult: $this->operationResult('passed'))); + self::assertSame('2027-01-31', $this->plan->nextDueAt?->format('Y-m-d')); + } + + #[Test] + public function resultForAnotherOperationIsRejected(): void + { + $this->attempt(); + $wrong = new MaintenanceOperationResult(self::ORG, self::EQ, self::OCCURRENCE, self::RESULT, self::WORK, 'maintenance', 'passed', new DateTimeImmutable('2026-10-06Z')); + $this->store->expects(self::never())->method('saveReceipt'); + $this->expectException(MaintenanceValidationException::class); + ($this->handler)(new ManageMaintenancePlanCommand('acknowledge_result', self::ORG, null, operationResult: $wrong)); + } + + #[Test] + public function maintenanceNeverContributesToTheControlProjection(): void + { + $this->plan->operationKind = 'maintenance'; + $this->schedules->expects(self::once())->method('save')->with(self::callback(static function (MaintenanceScheduleSnapshot $snapshot): bool { + self::assertSame('unscheduled', $snapshot->dueStatus); + self::assertNull($snapshot->nextDueAt); + self::assertNull($snapshot->lastInspectionClosedAt); + + return true; + }))->willReturn(new MaintenanceScheduleView(self::PLAN, self::ORG, self::EQ, null, 'fire_extinguisher', null, null, null, 'unscheduled', null, null, new DateTimeImmutable('2026-10-06Z'), new DateTimeImmutable('2026-10-06Z'))); + ($this->handler)(new ManageMaintenancePlanCommand('refresh_projection', self::ORG, null, equipmentId: self::EQ)); + } + + #[Test] + public function missingEquipmentSuspendsItsProjectionWithoutDeletingHistoryOrReminderEvidence(): void + { + $this->currentEquipment = null; + $last = new DateTimeImmutable('2026-09-01Z'); + $due = new DateTimeImmutable('2027-03-01Z'); + $this->legacy = new MaintenanceScheduleView(self::PLAN, self::ORG, self::EQ, null, 'fire_extinguisher', 'P6M', $last, $due, 'up_to_date', $last, $due, $last, $last); + $this->schedules->expects(self::never())->method('removeByOrganizationAndEquipment'); + $this->store->expects(self::never())->method('save'); + $this->store->expects(self::never())->method('saveOccurrence'); + $this->schedules->expects(self::once())->method('save')->with(self::callback(static function (MaintenanceScheduleSnapshot $snapshot) use ($last, $due): bool { + self::assertSame('unscheduled', $snapshot->dueStatus); + self::assertNull($snapshot->nextDueAt); + self::assertSame($last, $snapshot->lastInspectionClosedAt); + self::assertSame('P6M', $snapshot->intervalOverride); + self::assertSame($last, $snapshot->lastRemindedAt); + self::assertSame($due, $snapshot->remindedFor); + + return true; + }))->willReturn($this->legacy); + ($this->handler)(new ManageMaintenancePlanCommand('refresh_projection', self::ORG, null, equipmentId: self::EQ)); + } + + #[Test] + public function preparePreservesHistoricalOverrideAndKnownDatesWithoutGenerating(): void + { + $this->mode = 'legacy'; + $date = new DateTimeImmutable('2026-09-01Z'); + $this->legacy = new MaintenanceScheduleView(self::PLAN, self::ORG, self::EQ, null, 'fire_extinguisher', 'P6M', $date, new DateTimeImmutable('2027-03-01Z'), 'up_to_date', null, null, $date, $date); + $this->drafts->expects(self::never())->method('create'); + $result = ($this->handler)(new ManageMaintenancePlanCommand('prepare_legacy', self::ORG, 'actor')); + self::assertSame(1, $result->preparedCount); + self::assertSame('legacy', $this->plan->cadenceMode); + self::assertSame('P6M', $this->plan->interval); + self::assertSame('2027-03-01', $this->plan->nextDueAt?->format('Y-m-d')); + self::assertFalse($this->plan->active); + } + + #[Test] + #[DataProvider('archivedHistoricalSources')] + public function activationDoesNotReviveAnArchivedHistoricalCandidate(bool $cadenceRemoved): void + { + $this->mode = 'legacy'; + $date = new DateTimeImmutable('2026-09-01Z'); + $this->legacy = new MaintenanceScheduleView(self::PLAN, self::ORG, self::EQ, null, 'fire_extinguisher', $cadenceRemoved ? null : 'P6M', $date, new DateTimeImmutable('2027-03-01Z'), 'up_to_date', null, null, $date, $date); + if ($cadenceRemoved) { + $this->currentPolicy = new MaintenanceCompliancePolicy([], 30); + } + $this->plan->legacyScheduleId = self::PLAN; + $this->plan->active = false; + $this->plan->archivedAt = $date; + $this->store->expects(self::once())->method('save'); + $this->store->expects(self::never())->method('saveOccurrence'); + $this->store->expects(self::once())->method('activateEngine'); + $this->work->expects(self::never())->method('attachOccurrence'); + $this->drafts->expects(self::never())->method('create'); + ($this->handler)(new ManageMaintenancePlanCommand('activate', self::ORG, 'actor')); + self::assertSame(self::PLAN, $this->plan->id); + self::assertSame($date, $this->plan->archivedAt); + self::assertFalse($this->plan->active); + } + + /** + * @return iterable + */ + public static function archivedHistoricalSources(): iterable + { + yield 'historical override remains' => [false]; + yield 'historical default removed' => [true]; + } + + #[Test] + public function aNameOnlyEditKeepsTheOpenOccurrenceAndItsCalendar(): void + { + $this->attempt(); + $due = $this->plan->nextDueAt; + $anchor = $this->plan->anchorAt; + $this->store->expects(self::never())->method('saveOccurrence'); + ($this->handler)(new ManageMaintenancePlanCommand('update', self::ORG, 'actor', planId: self::PLAN, name: 'Renamed control')); + self::assertSame('Renamed control', $this->plan->name); + self::assertSame($due, $this->plan->nextDueAt); + self::assertSame($anchor, $this->plan->anchorAt); + self::assertSame($due, $this->occurrence?->dueAt); + self::assertSame(self::WORK, $this->occurrence?->interventionId); + } + + #[Test] + public function anOpenOccurrenceStillRejectsAnExplicitCalendarEdit(): void + { + $this->attempt(); + $this->store->expects(self::never())->method('save'); + $this->store->expects(self::never())->method('saveOccurrence'); + $this->expectException(MaintenanceValidationException::class); + ($this->handler)(new ManageMaintenancePlanCommand('update', self::ORG, 'actor', planId: self::PLAN, nextDueOn: '2028-01-31')); + } + + #[Test] + public function aCampaignOmitsEquipmentThatMovedAwayFromTheSelectedFacility(): void + { + $this->plan->facilityId = 'old-facility'; + $this->plan->nextDueAt = new DateTimeImmutable('2026-10-01Z'); + $this->currentEquipment = new TrackableEquipment(self::EQ, self::ORG, 'current-facility', 'fire_extinguisher', 'operational'); + $this->store->expects(self::never())->method('saveOccurrence'); + $this->drafts->expects(self::never())->method('create'); + $this->expectException(MaintenanceValidationException::class); + ($this->handler)(new ManageMaintenancePlanCommand('campaign', self::ORG, 'actor', facilityId: 'old-facility')); + } + + #[Test] + public function aCampaignUsesTheCurrentFacilityAndEquipmentType(): void + { + $this->plan->facilityId = 'old-facility'; + $this->plan->nextDueAt = new DateTimeImmutable('2026-10-01Z'); + $this->currentEquipment = new TrackableEquipment(self::EQ, self::ORG, 'current-facility', 'smoke_detector', 'operational'); + $this->store->expects(self::once())->method('save')->with(self::callback(static fn (MaintenancePlanState $plan): bool => 'current-facility' === $plan->facilityId && 'smoke_detector' === $plan->equipmentType)); + $result = ($this->handler)(new ManageMaintenancePlanCommand('campaign', self::ORG, 'actor', facilityId: 'current-facility', equipmentType: 'smoke_detector')); + self::assertSame(self::WORK, $result->interventionId); + self::assertSame(self::OCCURRENCE, $result->occurrenceId); + } + + #[Test] + public function aCampaignSkipsEquipmentOwnedByAnotherOrganization(): void + { + $this->plan->nextDueAt = new DateTimeImmutable('2026-10-01Z'); + $this->currentEquipment = new TrackableEquipment(self::EQ, self::RESULT, null, 'fire_extinguisher', 'operational'); + $this->store->expects(self::never())->method('saveOccurrence'); + $this->drafts->expects(self::never())->method('create'); + $this->expectException(MaintenanceValidationException::class); + ($this->handler)(new ManageMaintenancePlanCommand('campaign', self::ORG, 'actor')); + } + + #[Test] + public function aCampaignSkipsALegacyOperationWhoseDefaultCadenceWasRemoved(): void + { + $this->historicalPlan(); + $this->currentPolicy = new MaintenanceCompliancePolicy([], 30); + $this->store->expects(self::never())->method('saveOccurrence'); + $this->drafts->expects(self::never())->method('create'); + $this->expectException(MaintenanceValidationException::class); + ($this->handler)(new ManageMaintenancePlanCommand('campaign', self::ORG, 'actor')); + } + + #[Test] + public function aCampaignUsesTheRefreshedCadenceBeforeItsDueCutoff(): void + { + $this->historicalPlan(); + $this->currentPolicy = new MaintenanceCompliancePolicy(['fire_extinguisher' => 'P2Y'], 30); + $this->store->expects(self::never())->method('saveOccurrence'); + $this->drafts->expects(self::never())->method('create'); + $this->expectException(MaintenanceValidationException::class); + ($this->handler)(new ManageMaintenancePlanCommand('campaign', self::ORG, 'actor', dueBefore: new DateTimeImmutable('2027-01-01Z'))); + } + + #[Test] + public function aCampaignDefersCadenceSavesUntilAllDueOrderedPagesHaveBeenRead(): void + { + $lastPagePlan = clone $this->plan; + $lastPagePlan->id = self::RESULT; + $lastPagePlan->nextDueAt = new DateTimeImmutable('2026-10-01Z'); + $this->historicalPlan(); + $this->currentPolicy = new MaintenanceCompliancePolicy(['fire_extinguisher' => 'P2Y'], 30); + $firstPage = [$this->plan]; + for ($index = 1; $index < 200; ++$index) { + $inactive = clone $this->plan; + $inactive->active = false; + $firstPage[] = $inactive; + } + $this->planPages = static fn (int $offset): array => match ($offset) { + 0 => $firstPage, + 200 => [$lastPagePlan], + default => [], + }; + $this->store->expects(self::once())->method('save')->with(self::callback(static fn (MaintenancePlanState $plan): bool => self::RESULT === $plan->id)); + $result = ($this->handler)(new ManageMaintenancePlanCommand('campaign', self::ORG, 'actor')); + $occurrence = $this->occurrence; + self::assertSame(self::WORK, $result->interventionId); + self::assertNotNull($occurrence); + self::assertSame(self::RESULT, $occurrence->planId); + self::assertSame('2026-10-01', $occurrence->dueAt->format('Y-m-d')); + } + + #[Test] + public function aCampaignRefreshesLegacyPolicyForTheCurrentEquipmentType(): void + { + $this->historicalPlan(); + $this->currentEquipment = new TrackableEquipment(self::EQ, self::ORG, null, 'smoke_detector', 'operational'); + $this->currentPolicy = new MaintenanceCompliancePolicy(['smoke_detector' => 'P6M'], 30); + $result = ($this->handler)(new ManageMaintenancePlanCommand('campaign', self::ORG, 'actor', equipmentType: 'smoke_detector')); + self::assertSame(self::WORK, $result->interventionId); + self::assertSame('P6M', $this->plan->interval); + self::assertSame('2026-04-01', $this->occurrence?->dueAt->format('Y-m-d')); + } + + #[Test] + public function aCampaignKeepsTheExplicitLegacyOverrideWhenTheDefaultWasRemoved(): void + { + $this->historicalPlan('P6M'); + $this->currentPolicy = new MaintenanceCompliancePolicy([], 30); + $result = ($this->handler)(new ManageMaintenancePlanCommand('campaign', self::ORG, 'actor')); + self::assertSame(self::WORK, $result->interventionId); + self::assertSame('2026-04-01', $this->occurrence?->dueAt->format('Y-m-d')); + } + + #[Test] + public function ambiguousHistoricalWorkBlocksTheEngineSwitch(): void + { + $this->mode = 'legacy'; + $date = new DateTimeImmutable('2026-09-01Z'); + $this->legacy = new MaintenanceScheduleView(self::PLAN, self::ORG, self::EQ, null, 'fire_extinguisher', 'P6M', $date, new DateTimeImmutable('2027-03-01Z'), 'up_to_date', null, null, $date, $date); + $this->work->method('findOpenLegacyInspectionWork')->willReturn([self::EQ => [new InterventionMaintenanceWork(self::WORK, 42, self::RESULT), new InterventionMaintenanceWork(self::RESULT, 43, self::WORK)]]); + $this->store->expects(self::never())->method('activateEngine'); + $this->work->expects(self::never())->method('attachOccurrence'); + $this->expectException(MaintenanceValidationException::class); + ($this->handler)(new ManageMaintenancePlanCommand('activate', self::ORG, 'actor')); + } + + #[Test] + public function archivedSitesSuspendGeneration(): void + { + $this->facilities->method('isArchived')->willReturn(true); + $this->drafts->expects(self::never())->method('create'); + $this->expectException(MaintenanceValidationException::class); + ($this->handler)(new ManageMaintenancePlanCommand('generate', self::ORG, 'actor', planId: self::PLAN)); + } + + /** + * @param class-string $exception + */ + #[Test] + #[DataProvider('denials')] + public function authorizationDeniesBeforeReadingPlans(OrganizationAccessDecision $decision, string $exception): void + { + $this->decision = $decision; + $this->store->expects(self::never())->method('find'); + $this->store->expects(self::never())->method('synchronized'); + $this->expectException($exception); + ($this->handler)(new ManageMaintenancePlanCommand('generate', self::ORG, 'actor', planId: self::PLAN)); + } + + /** + * @return iterable}> + */ + public static function denials(): iterable + { + yield 'outside organization' => [OrganizationAccessDecision::OUTSIDE_SCOPE, MaintenanceNotFoundException::class]; + yield 'missing permission' => [OrganizationAccessDecision::MISSING_PERMISSION, MaintenanceAccessDeniedException::class]; + } + + private function equipmentSnapshot(): ?TrackableEquipment + { + return $this->currentEquipment; + } + + private function historicalPlan(?string $override = null): void + { + $date = new DateTimeImmutable('2025-10-01Z'); + $due = new DateTimeImmutable('2026-10-01Z'); + $this->plan->cadenceMode = 'legacy'; + $this->plan->legacyScheduleId = self::PLAN; + $this->plan->lastCompletedAt = $date; + $this->plan->anchorAt = $due; + $this->plan->nextDueAt = $due; + $this->legacy = new MaintenanceScheduleView(self::PLAN, self::ORG, self::EQ, null, 'fire_extinguisher', $override, $date, $due, 'overdue', null, null, $date, $date); + } + + private function attempt(): void + { + $date = new DateTimeImmutable('2026-10-01Z'); + $this->occurrence = new MaintenanceOccurrenceState(self::OCCURRENCE, self::PLAN, self::ORG, $this->plan->nextDueAt ?? $date, 'open', 1, self::WORK, null, null, $date, $date, 42); + } + + private function operationResult(string $outcome): MaintenanceOperationResult + { + return new MaintenanceOperationResult(self::ORG, self::EQ, self::OCCURRENCE, self::RESULT, self::WORK, $this->plan->operationKind, $outcome, new DateTimeImmutable('2026-10-06Z'), self::PLAN); + } +} diff --git a/tests/Unit/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansHandlerTest.php b/tests/Unit/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansHandlerTest.php new file mode 100644 index 000000000..886801d75 --- /dev/null +++ b/tests/Unit/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansHandlerTest.php @@ -0,0 +1,431 @@ +plans = $this->createMock(MaintenancePlanStorePort::class); + $this->work = $this->createMock(InterventionMaintenanceWorkPort::class); + $this->authorization = $this->createMock(OrganizationAuthorizationPort::class); + $this->plans->expects(self::never())->method('save'); + $this->plans->expects(self::never())->method('saveOccurrence'); + $this->plans->expects(self::never())->method('activateEngine'); + $this->plans->expects(self::never())->method('synchronized'); + $this->work->expects(self::never())->method('attachOccurrence'); + } + + /** + * Method testAuthorizedDetailReturnsCurrentStateWithoutWriting + * + * @access public + * + * @return void + */ + #[Test] + public function testAuthorizedDetailReturnsCurrentStateWithoutWriting(): void + { + $this->allow(); + $plan = $this->plan(); + $this->plans->expects(self::once())->method('engineMode')->with(self::ORGANIZATION)->willReturn('plans'); + $this->plans->expects(self::once())->method('find')->with(self::ORGANIZATION, self::PLAN)->willReturn($plan); + $this->plans->expects(self::once())->method('openOccurrence')->with(self::ORGANIZATION, self::PLAN)->willReturn(null); + $this->plans->expects(self::never())->method('list'); + $this->plans->expects(self::never())->method('count'); + + $result = $this->handler()(new ReadMaintenancePlansQuery(self::ORGANIZATION, self::ACTOR, 'detail', self::PLAN)); + + self::assertInstanceOf(ReadMaintenancePlansResult::class, $result); + self::assertSame('plans', $result->mode); + self::assertSame(1, $result->total); + self::assertCount(1, $result->items); + self::assertSame($plan, $result->items[0]->plan); + self::assertNull($result->items[0]->openOccurrence); + self::assertFalse($result->items[0]->retryAllowed); + self::assertSame([], $result->dates); + } + + /** + * Method testPreviewReturnsThreeAnchoredDatesAndDoesNotActivateEngine + * + * @access public + * + * @return void + */ + #[Test] + public function testPreviewReturnsThreeAnchoredDatesAndDoesNotActivateEngine(): void + { + $this->allow(); + $plan = $this->plan(); + $plan->nextDueAt = new DateTimeImmutable('2026-02-28T00:00:00+00:00'); + $this->plans->expects(self::once())->method('engineMode')->with(self::ORGANIZATION)->willReturn('legacy'); + $this->plans->expects(self::once())->method('find')->with(self::ORGANIZATION, self::PLAN)->willReturn($plan); + $this->plans->expects(self::once())->method('openOccurrence')->with(self::ORGANIZATION, self::PLAN)->willReturn(null); + + $result = $this->handler()(new ReadMaintenancePlansQuery(self::ORGANIZATION, self::ACTOR, 'preview', self::PLAN)); + + self::assertSame(['2026-02-28', '2026-03-31', '2026-04-30'], array_map(static fn (DateTimeImmutable $date): string => $date->format('Y-m-d'), $result->dates)); + self::assertSame('legacy', $result->mode); + self::assertSame('2026-01-31', $plan->anchorAt?->format('Y-m-d')); + self::assertSame('2026-02-28', $plan->nextDueAt->format('Y-m-d')); + self::assertFalse($plan->active); + } + + /** + * Method testHistoricalUnscheduledPlanHasNoInventedPreview + * + * @access public + * + * @return void + */ + #[Test] + public function testHistoricalUnscheduledPlanHasNoInventedPreview(): void + { + $this->allow(); + $plan = $this->plan(); + $plan->cadenceMode = 'legacy'; + $plan->interval = 'P1Y6M'; + $plan->anchorAt = null; + $plan->nextDueAt = null; + $this->plans->expects(self::once())->method('engineMode')->willReturn('legacy'); + $this->plans->expects(self::once())->method('find')->with(self::ORGANIZATION, self::PLAN)->willReturn($plan); + $this->plans->expects(self::once())->method('openOccurrence')->willReturn(null); + + $result = $this->handler()(new ReadMaintenancePlansQuery(self::ORGANIZATION, self::ACTOR, 'preview', self::PLAN)); + + self::assertSame([], $result->dates); + self::assertNull($plan->nextDueAt); + } + + /** + * Method testFilteredListUsesBoundedPageAndMatchingCount + * + * @access public + * + * @return void + */ + #[Test] + public function testFilteredListUsesBoundedPageAndMatchingCount(): void + { + $this->allow(); + $plan = $this->plan(); + $this->plans->expects(self::once())->method('engineMode')->with(self::ORGANIZATION)->willReturn('plans'); + $this->plans->expects(self::once())->method('list')->with(self::ORGANIZATION, 20, 40, self::EQUIPMENT, 'maintenance', 'extinguisher')->willReturn([$plan]); + $this->plans->expects(self::once())->method('count')->with(self::ORGANIZATION, self::EQUIPMENT, 'maintenance', 'extinguisher')->willReturn(61); + $this->plans->expects(self::once())->method('openOccurrence')->with(self::ORGANIZATION, self::PLAN)->willReturn(null); + $this->plans->expects(self::never())->method('find'); + + $result = $this->handler()(new ReadMaintenancePlansQuery(self::ORGANIZATION, self::ACTOR, page: 3, itemsPerPage: 20, equipmentId: self::EQUIPMENT, operationKind: 'maintenance', search: 'extinguisher')); + + self::assertSame(61, $result->total); + self::assertSame($plan, $result->items[0]->plan); + } + + /** + * Method testListClampsPagination + * + * @access public + * + * @param int $page the requested page + * @param int $size the requested page size + * @param int $expectedSize the bounded page size + * @param int $offset the bounded offset + * + * @return void + */ + #[Test] + #[DataProvider('paginationCases')] + public function testListClampsPagination(int $page, int $size, int $expectedSize, int $offset): void + { + $this->allow(); + $this->plans->expects(self::once())->method('engineMode')->willReturn('legacy'); + $this->plans->expects(self::once())->method('list')->with(self::ORGANIZATION, $expectedSize, $offset, null, null, null)->willReturn([]); + $this->plans->expects(self::once())->method('count')->with(self::ORGANIZATION, null, null, null)->willReturn(0); + + $result = $this->handler()(new ReadMaintenancePlansQuery(self::ORGANIZATION, self::ACTOR, page: $page, itemsPerPage: $size)); + + self::assertSame([], $result->items); + self::assertSame(0, $result->total); + } + + /** + * Method paginationCases + * + * @access public + * + * @return iterable pagination limits + */ + public static function paginationCases(): iterable + { + yield 'negative page and large size' => [-3, 500, 100, 0]; + yield 'non-positive size' => [2, 0, 1, 1]; + } + + /** + * Method testEngineReadReturnsPreparedCountWithoutCreatingPlans + * + * @access public + * + * @return void + */ + #[Test] + public function testEngineReadReturnsPreparedCountWithoutCreatingPlans(): void + { + $this->allow(); + $this->plans->expects(self::once())->method('engineMode')->with(self::ORGANIZATION)->willReturn('legacy'); + $this->plans->expects(self::once())->method('count')->with(self::ORGANIZATION)->willReturn(12); + $this->plans->expects(self::never())->method('find'); + $this->plans->expects(self::never())->method('list'); + + $result = $this->handler()(new ReadMaintenancePlansQuery(self::ORGANIZATION, self::ACTOR, 'engine')); + + self::assertSame('legacy', $result->mode); + self::assertSame(12, $result->preparedCount); + self::assertSame([], $result->items); + } + + /** + * Method testMissingPermissionDeniesBeforeReadingPlans + * + * @access public + * + * @return void + */ + #[Test] + public function testMissingPermissionDeniesBeforeReadingPlans(): void + { + $this->allow(OrganizationAccessDecision::MISSING_PERMISSION); + $this->plans->expects(self::never())->method('engineMode'); + $this->plans->expects(self::never())->method('find'); + $this->plans->expects(self::never())->method('list'); + $this->expectException(MaintenanceAccessDeniedException::class); + + $this->handler()(new ReadMaintenancePlansQuery(self::ORGANIZATION, self::ACTOR, 'detail', self::PLAN)); + } + + /** + * Method testOutsideOrganizationIsHiddenBeforeReadingPlans + * + * @access public + * + * @return void + */ + #[Test] + public function testOutsideOrganizationIsHiddenBeforeReadingPlans(): void + { + $this->allow(OrganizationAccessDecision::OUTSIDE_SCOPE); + $this->plans->expects(self::never())->method('engineMode'); + $this->plans->expects(self::never())->method('find'); + $this->plans->expects(self::never())->method('list'); + $this->expectException(MaintenanceNotFoundException::class); + + $this->handler()(new ReadMaintenancePlansQuery(self::ORGANIZATION, self::ACTOR, 'detail', self::PLAN)); + } + + /** + * Method testUnknownPlanReturnsNotFoundInsideAuthorizedOrganization + * + * @access public + * + * @return void + */ + #[Test] + public function testUnknownPlanReturnsNotFoundInsideAuthorizedOrganization(): void + { + $this->allow(); + $this->plans->expects(self::once())->method('engineMode')->willReturn('plans'); + $this->plans->expects(self::once())->method('find')->with(self::ORGANIZATION, self::PLAN)->willReturn(null); + $this->plans->expects(self::never())->method('openOccurrence'); + $this->expectException(MaintenanceNotFoundException::class); + + $this->handler()(new ReadMaintenancePlansQuery(self::ORGANIZATION, self::ACTOR, 'detail', self::PLAN)); + } + + /** + * Method testUnknownOperationKindIsRejected + * + * @access public + * + * @return void + */ + #[Test] + public function testUnknownOperationKindIsRejected(): void + { + $this->allow(); + $this->plans->expects(self::never())->method('engineMode'); + $this->plans->expects(self::never())->method('list'); + $this->expectException(MaintenanceValidationException::class); + + $this->handler()(new ReadMaintenancePlansQuery(self::ORGANIZATION, self::ACTOR, operationKind: 'repair')); + } + + /** + * Method testRetryDecisionComesFromCurrentAttemptAndWorkStatus + * + * @access public + * + * @param ?string $resultId the previous failed result, if any + * @param string $workStatus the current work state + * @param bool $expected whether the server permits an explicit retry + * + * @return void + */ + #[Test] + #[DataProvider('retryCases')] + public function testRetryDecisionComesFromCurrentAttemptAndWorkStatus(?string $resultId, string $workStatus, bool $expected): void + { + $this->allow(); + $this->plans->expects(self::once())->method('engineMode')->willReturn('plans'); + $this->plans->expects(self::once())->method('find')->with(self::ORGANIZATION, self::PLAN)->willReturn($this->plan()); + $open = new MaintenanceOccurrenceState('018fa006-1111-7111-8111-111111111111', self::PLAN, self::ORGANIZATION, new DateTimeImmutable('2026-01-31'), 'open', 1, self::INTERVENTION, null, $resultId, new DateTimeImmutable('2026-01-01'), new DateTimeImmutable('2026-01-01')); + $this->plans->expects(self::once())->method('openOccurrence')->with(self::ORGANIZATION, self::PLAN)->willReturn($open); + $this->work->expects(null === $resultId ? self::once() : self::never())->method('status')->with(self::ORGANIZATION, self::INTERVENTION)->willReturn($workStatus); + + $result = $this->handler()(new ReadMaintenancePlansQuery(self::ORGANIZATION, self::ACTOR, 'detail', self::PLAN)); + + self::assertSame($open, $result->items[0]->openOccurrence); + self::assertSame($expected, $result->items[0]->retryAllowed); + } + + /** + * Method retryCases + * + * @access public + * + * @return iterable retry conditions + */ + public static function retryCases(): iterable + { + yield 'abandoned attempt' => [null, 'abandoned', true]; + yield 'running attempt' => [null, 'in_progress', false]; + yield 'failed operation result' => ['018fa007-1111-7111-8111-111111111111', 'published', true]; + } + + /** + * Method allow + * + * @access private + * + * @param OrganizationAccessDecision $decision the scoped access decision + * + * @return void + */ + private function allow(OrganizationAccessDecision $decision = OrganizationAccessDecision::GRANTED): void + { + $this->authorization->expects(self::once())->method('resolveAccess')->with(self::ACTOR, self::ORGANIZATION, 'organization.maintenance.read')->willReturn($decision); + } + + /** + * Method handler + * + * @access private + * + * @return ReadMaintenancePlansHandler the handler with mocked ports + */ + private function handler(): ReadMaintenancePlansHandler + { + return new ReadMaintenancePlansHandler($this->plans, $this->work, $this->authorization); + } + + /** + * Method plan + * + * @access private + * + * @return MaintenancePlanState a prepared monthly plan + */ + private function plan(): MaintenancePlanState + { + return new MaintenancePlanState( + self::PLAN, + self::ORGANIZATION, + self::EQUIPMENT, + null, + 'fire_extinguisher', + 'Monthly service', + 'maintenance', + 'P1M', + 'fixed', + new DateTimeImmutable('2026-01-31T00:00:00+00:00'), + new DateTimeImmutable('2026-01-31T00:00:00+00:00'), + false, + null, + null, + null, + new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + ); + } + // #endregion +} diff --git a/tests/Unit/Maintenance/Domain/Model/MaintenanceOccurrenceTest.php b/tests/Unit/Maintenance/Domain/Model/MaintenanceOccurrenceTest.php new file mode 100644 index 000000000..25617bf36 --- /dev/null +++ b/tests/Unit/Maintenance/Domain/Model/MaintenanceOccurrenceTest.php @@ -0,0 +1,252 @@ +occurrence(); + $interventionId = '018fa004-1111-7111-8111-111111111111'; + + $occurrence->beginAttempt($interventionId); + $occurrence->beginAttempt($interventionId); + + self::assertSame('open', $occurrence->state()); + self::assertNull($occurrence->completedAt()); + self::assertSame(1, $occurrence->attempt()); + self::assertSame($interventionId, $occurrence->interventionId()); + self::assertSame('2026-01-31', $occurrence->dueAt->format('Y-m-d')); + } + + /** + * Method testDefectiveControlCompletesAndReplayRetainsOriginalValidation + * + * @access public + * + * @return void + */ + #[Test] + public function testDefectiveControlCompletesAndReplayRetainsOriginalValidation(): void + { + $occurrence = $this->occurrence(); + $occurrence->beginAttempt('018fa004-1111-7111-8111-111111111111'); + $resultId = '018fa005-1111-7111-8111-111111111111'; + + self::assertTrue($occurrence->validateResult(MaintenanceOperationKind::CONTROL, false, $resultId, new DateTimeImmutable('2026-02-02T00:00:00+00:00'))); + self::assertTrue($occurrence->validateResult(MaintenanceOperationKind::CONTROL, false, $resultId, new DateTimeImmutable('2026-02-10T00:00:00+00:00'))); + self::assertSame('completed', $occurrence->state()); + self::assertSame('2026-02-02', $occurrence->completedAt()?->format('Y-m-d')); + self::assertSame($resultId, $occurrence->resultId()); + } + + /** + * Method testFailedMaintenanceRemainsOpenUntilExplicitRetrySucceeds + * + * @access public + * + * @return void + */ + #[Test] + public function testFailedMaintenanceRemainsOpenUntilExplicitRetrySucceeds(): void + { + $occurrence = $this->occurrence(); + $occurrence->beginAttempt('018fa004-1111-7111-8111-111111111111'); + + self::assertFalse($occurrence->validateResult(MaintenanceOperationKind::MAINTENANCE, false, '018fa005-1111-7111-8111-111111111111', new DateTimeImmutable('2026-02-01'))); + self::assertSame('open', $occurrence->state()); + self::assertNull($occurrence->completedAt()); + + $retryId = '018fa006-1111-7111-8111-111111111111'; + $occurrence->retryAttempt($retryId); + $occurrence->retryAttempt($retryId); + + self::assertSame(2, $occurrence->attempt()); + self::assertSame($retryId, $occurrence->interventionId()); + self::assertNull($occurrence->resultId()); + self::assertSame('2026-01-31', $occurrence->dueAt->format('Y-m-d')); + self::assertTrue($occurrence->validateResult(MaintenanceOperationKind::MAINTENANCE, true, '018fa007-1111-7111-8111-111111111111', new DateTimeImmutable('2026-02-04'))); + self::assertSame('completed', $occurrence->state()); + } + + /** + * Method testAnotherGeneratedWorkNeedsExplicitRetry + * + * @access public + * + * @return void + */ + #[Test] + public function testAnotherGeneratedWorkNeedsExplicitRetry(): void + { + $occurrence = $this->occurrence(); + $occurrence->beginAttempt('018fa004-1111-7111-8111-111111111111'); + $this->expectException(InvalidValueException::class); + + $occurrence->beginAttempt('018fa006-1111-7111-8111-111111111111'); + } + + /** + * Method testCompletedOccurrenceCannotBeRetried + * + * @access public + * + * @return void + */ + #[Test] + public function testCompletedOccurrenceCannotBeRetried(): void + { + $occurrence = $this->occurrence(); + $occurrence->beginAttempt('018fa004-1111-7111-8111-111111111111'); + $occurrence->validateResult(MaintenanceOperationKind::CONTROL, true, '018fa005-1111-7111-8111-111111111111', new DateTimeImmutable('2026-02-01')); + $this->expectException(InvalidValueException::class); + + $occurrence->retryAttempt('018fa006-1111-7111-8111-111111111111'); + } + + /** + * Method testResultRequiresGeneratedWork + * + * @access public + * + * @return void + */ + #[Test] + public function testResultRequiresGeneratedWork(): void + { + $this->expectException(InvalidValueException::class); + + $this->occurrence()->validateResult(MaintenanceOperationKind::CONTROL, true, '018fa005-1111-7111-8111-111111111111', new DateTimeImmutable('2026-02-01')); + } + + /** + * Method testSecondResultCannotOverwriteFailedAttempt + * + * @access public + * + * @return void + */ + #[Test] + public function testSecondResultCannotOverwriteFailedAttempt(): void + { + $occurrence = $this->occurrence(); + $occurrence->beginAttempt('018fa004-1111-7111-8111-111111111111'); + $occurrence->validateResult(MaintenanceOperationKind::MAINTENANCE, false, '018fa005-1111-7111-8111-111111111111', new DateTimeImmutable('2026-02-01')); + $this->expectException(InvalidValueException::class); + + $occurrence->validateResult(MaintenanceOperationKind::MAINTENANCE, true, '018fa007-1111-7111-8111-111111111111', new DateTimeImmutable('2026-02-02')); + } + + /** + * Method testResultCannotPredateReservation + * + * @access public + * + * @return void + */ + #[Test] + public function testResultCannotPredateReservation(): void + { + $occurrence = $this->occurrence(); + $occurrence->beginAttempt('018fa004-1111-7111-8111-111111111111'); + $this->expectException(InvalidValueException::class); + + $occurrence->validateResult(MaintenanceOperationKind::CONTROL, true, '018fa005-1111-7111-8111-111111111111', new DateTimeImmutable('2025-12-31')); + } + + /** + * Method testRestorationKeepsAttemptAndCompletionReceipt + * + * @access public + * + * @return void + */ + #[Test] + public function testRestorationKeepsAttemptAndCompletionReceipt(): void + { + $occurrence = MaintenanceOccurrence::reconstitute( + '018fa001-1111-7111-8111-111111111111', + '018fa002-1111-7111-8111-111111111111', + new DateTimeImmutable('2026-01-31'), + new DateTimeImmutable('2026-01-01'), + 2, + '018fa004-1111-7111-8111-111111111111', + new DateTimeImmutable('2026-02-02'), + '018fa005-1111-7111-8111-111111111111', + ); + + self::assertSame('completed', $occurrence->state()); + self::assertSame(2, $occurrence->attempt()); + self::assertSame('2026-01-31', $occurrence->dueAt->format('Y-m-d')); + self::assertSame('2026-02-02', $occurrence->completedAt()?->format('Y-m-d')); + } + + /** + * Method testRestorationRejectsUnlinkedCompletion + * + * @access public + * + * @return void + */ + #[Test] + public function testRestorationRejectsUnlinkedCompletion(): void + { + $this->expectException(InvalidValueException::class); + + MaintenanceOccurrence::reconstitute( + '018fa001-1111-7111-8111-111111111111', + '018fa002-1111-7111-8111-111111111111', + new DateTimeImmutable('2026-01-31'), + new DateTimeImmutable('2026-01-01'), + 0, + null, + new DateTimeImmutable('2026-02-02'), + null, + ); + } + + /** + * Method occurrence + * + * @access private + * + * @return MaintenanceOccurrence an unattempted overdue occurrence + */ + private function occurrence(): MaintenanceOccurrence + { + return MaintenanceOccurrence::open( + '018fa001-1111-7111-8111-111111111111', + '018fa002-1111-7111-8111-111111111111', + new DateTimeImmutable('2026-01-31T00:00:00+00:00'), + new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + ); + } + // #endregion +} diff --git a/tests/Unit/Maintenance/Domain/Model/MaintenancePlanTest.php b/tests/Unit/Maintenance/Domain/Model/MaintenancePlanTest.php new file mode 100644 index 000000000..ccd6534c1 --- /dev/null +++ b/tests/Unit/Maintenance/Domain/Model/MaintenancePlanTest.php @@ -0,0 +1,298 @@ +plan('P1Y', MaintenanceOperationKind::CONTROL); + $maintenance = $this->plan('P1M', MaintenanceOperationKind::MAINTENANCE); + + $maintenance->complete(new DateTimeImmutable('2026-02-03T00:00:00+00:00')); + + self::assertSame('2026-02-28', $maintenance->nextDueAt()?->format('Y-m-d')); + self::assertSame('2026-01-31', $control->nextDueAt()?->format('Y-m-d')); + } + + /** + * Method testLateCompletionSkipsPastSlotsAndRetainsAnchor + * + * @access public + * + * @return void + */ + #[Test] + public function testLateCompletionSkipsPastSlotsAndRetainsAnchor(): void + { + $plan = $this->plan('P1M'); + + $plan->complete(new DateTimeImmutable('2026-04-15T00:00:00+00:00')); + + self::assertSame('2026-04-30', $plan->nextDueAt()?->format('Y-m-d')); + self::assertSame('2026-01-31', $plan->anchorAt?->format('Y-m-d')); + self::assertSame(['2026-04-30', '2026-05-31', '2026-06-30'], array_map(static fn (DateTimeImmutable $date): string => $date->format('Y-m-d'), $plan->preview())); + } + + /** + * Method testEarlyCompletionAdvancesItsDueSlot + * + * @access public + * + * @return void + */ + #[Test] + public function testEarlyCompletionAdvancesItsDueSlot(): void + { + $plan = $this->plan('P1M'); + + $plan->complete(new DateTimeImmutable('2026-01-29T00:00:00+00:00')); + + self::assertSame('2026-02-28', $plan->nextDueAt()?->format('Y-m-d')); + } + + /** + * Method testLegacyUnscheduledPlanStartsFromFirstValidation + * + * @access public + * + * @return void + */ + #[Test] + public function testLegacyUnscheduledPlanStartsFromFirstValidation(): void + { + $plan = MaintenancePlan::create( + '018fa001-1111-7111-8111-111111111111', + '018fa002-1111-7111-8111-111111111111', + '018fa003-1111-7111-8111-111111111111', + 'Historical inspection', + MaintenanceOperationKind::CONTROL, + PlanCadence::legacyFromString('P1M'), + null, + new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + true, + ); + + self::assertNull($plan->nextDueAt()); + self::assertSame([], $plan->preview()); + self::assertFalse($plan->canGenerate(false, false)); + + $plan->complete(new DateTimeImmutable('2026-01-31T00:00:00+00:00')); + + self::assertSame('2026-03-03', $plan->nextDueAt()?->format('Y-m-d')); + self::assertTrue($plan->canGenerate(false, false)); + } + + /** + * Method testGenerationIsSuspendedBySiteEquipmentAndPlanLifecycle + * + * @access public + * + * @return void + */ + #[Test] + public function testGenerationIsSuspendedBySiteEquipmentAndPlanLifecycle(): void + { + $plan = $this->plan('P1Y'); + + self::assertTrue($plan->canGenerate(false, false)); + self::assertFalse($plan->canGenerate(true, false)); + self::assertFalse($plan->canGenerate(false, true)); + + $plan->archive(); + + self::assertTrue($plan->isArchived()); + self::assertFalse($plan->canGenerate(false, false)); + self::assertSame('2026-01-31', $plan->nextDueAt()?->format('Y-m-d')); + $plan->complete(new DateTimeImmutable('2026-01-31')); + + self::assertSame('2027-01-31', $plan->nextDueAt()?->format('Y-m-d')); + self::assertFalse($plan->canGenerate(false, false)); + } + + /** + * Method testRestorationKeepsAnchorAndDueDateSeparate + * + * @access public + * + * @return void + */ + #[Test] + public function testRestorationKeepsAnchorAndDueDateSeparate(): void + { + $plan = MaintenancePlan::reconstitute( + '018fa001-1111-7111-8111-111111111111', + '018fa002-1111-7111-8111-111111111111', + '018fa003-1111-7111-8111-111111111111', + 'Monthly service', + MaintenanceOperationKind::MAINTENANCE, + PlanCadence::fromString('P1M'), + new DateTimeImmutable('2026-01-31T00:00:00+00:00'), + new DateTimeImmutable('2026-02-28T00:00:00+00:00'), + new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + false, + false, + ); + + self::assertSame(['2026-02-28', '2026-03-31', '2026-04-30'], array_map(static fn (DateTimeImmutable $date): string => $date->format('Y-m-d'), $plan->preview())); + } + + /** + * Method testRestorationRejectsDueDateOutsideAnchoredCalendar + * + * @access public + * + * @return void + */ + #[Test] + public function testRestorationRejectsDueDateOutsideAnchoredCalendar(): void + { + $this->expectException(InvalidValueException::class); + + MaintenancePlan::reconstitute( + '018fa001-1111-7111-8111-111111111111', + '018fa002-1111-7111-8111-111111111111', + '018fa003-1111-7111-8111-111111111111', + 'Monthly service', + MaintenanceOperationKind::MAINTENANCE, + PlanCadence::fromString('P1M'), + new DateTimeImmutable('2026-01-31T00:00:00+00:00'), + new DateTimeImmutable('2026-02-15T00:00:00+00:00'), + new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + false, + false, + ); + } + + /** + * Method testRestorationAcceptsLeapDayClampWithoutChangingAnchor + * + * @access public + * + * @return void + */ + #[Test] + public function testRestorationAcceptsLeapDayClampWithoutChangingAnchor(): void + { + $plan = MaintenancePlan::reconstitute( + '018fa001-1111-7111-8111-111111111111', + '018fa002-1111-7111-8111-111111111111', + '018fa003-1111-7111-8111-111111111111', + 'Annual control', + MaintenanceOperationKind::CONTROL, + PlanCadence::fromString('P1Y'), + new DateTimeImmutable('2024-02-29T00:00:00+00:00'), + new DateTimeImmutable('2025-02-28T00:00:00+00:00'), + new DateTimeImmutable('2024-01-01T00:00:00+00:00'), + false, + false, + ); + + self::assertSame('2024-02-29', $plan->anchorAt?->format('Y-m-d')); + self::assertSame(['2025-02-28', '2026-02-28', '2027-02-28', '2028-02-29'], array_map(static fn (DateTimeImmutable $date): string => $date->format('Y-m-d'), $plan->preview(4))); + } + + /** + * Method testFixedPlanRequiresFirstDueDate + * + * @access public + * + * @return void + */ + #[Test] + public function testFixedPlanRequiresFirstDueDate(): void + { + $this->expectException(InvalidValueException::class); + + MaintenancePlan::create( + '018fa001-1111-7111-8111-111111111111', + '018fa002-1111-7111-8111-111111111111', + '018fa003-1111-7111-8111-111111111111', + 'Inspection', + MaintenanceOperationKind::CONTROL, + PlanCadence::fromString('P1Y'), + null, + new DateTimeImmutable('2026-01-01'), + ); + } + + /** + * Method testLegacyFlagMustMatchCadence + * + * @access public + * + * @return void + */ + #[Test] + public function testLegacyFlagMustMatchCadence(): void + { + $this->expectException(InvalidValueException::class); + + MaintenancePlan::create( + '018fa001-1111-7111-8111-111111111111', + '018fa002-1111-7111-8111-111111111111', + '018fa003-1111-7111-8111-111111111111', + 'Inspection', + MaintenanceOperationKind::CONTROL, + PlanCadence::legacyFromString('P1M'), + new DateTimeImmutable('2026-01-31'), + new DateTimeImmutable('2026-01-01'), + ); + } + + /** + * Method plan + * + * Creates one operation against the same equipment for independence assertions. + * + * @access private + * + * @param string $interval the fixed cadence + * @param MaintenanceOperationKind $kind the operation kind + * + * @return MaintenancePlan the plan under test + */ + private function plan(string $interval, MaintenanceOperationKind $kind = MaintenanceOperationKind::MAINTENANCE): MaintenancePlan + { + return MaintenancePlan::create( + '018fa001-1111-7111-8111-111111111111', + '018fa002-1111-7111-8111-111111111111', + '018fa003-1111-7111-8111-111111111111', + 'Monthly operation', + $kind, + PlanCadence::fromString($interval), + new DateTimeImmutable('2026-01-31T00:00:00+00:00'), + new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + ); + } + // #endregion +} diff --git a/tests/Unit/Maintenance/Domain/ValueObject/MaintenanceOperationKindTest.php b/tests/Unit/Maintenance/Domain/ValueObject/MaintenanceOperationKindTest.php new file mode 100644 index 000000000..079604402 --- /dev/null +++ b/tests/Unit/Maintenance/Domain/ValueObject/MaintenanceOperationKindTest.php @@ -0,0 +1,39 @@ +completesOccurrence(false)); + self::assertTrue(MaintenanceOperationKind::CONTROL->completesOccurrence(true)); + self::assertFalse(MaintenanceOperationKind::MAINTENANCE->completesOccurrence(false)); + self::assertTrue(MaintenanceOperationKind::MAINTENANCE->completesOccurrence(true)); + } + // #endregion +} diff --git a/tests/Unit/Maintenance/Domain/ValueObject/PlanCadenceTest.php b/tests/Unit/Maintenance/Domain/ValueObject/PlanCadenceTest.php new file mode 100644 index 000000000..4aab9f929 --- /dev/null +++ b/tests/Unit/Maintenance/Domain/ValueObject/PlanCadenceTest.php @@ -0,0 +1,221 @@ +dateAt(new DateTimeImmutable($anchor . 'T08:30:00+00:00'), $index); + + self::assertSame($expected . 'T08:30:00+00:00', $date->format('c')); + self::assertFalse($cadence->legacy); + self::assertSame($interval, $cadence->value); + } + + /** + * Method fixedCalendarCases + * + * @access public + * + * @return iterable calendar examples + */ + public static function fixedCalendarCases(): iterable + { + yield 'monthly February clamp' => ['P1M', '2026-01-31', 1, '2026-02-28']; + yield 'monthly original day restored' => ['P1M', '2026-01-31', 2, '2026-03-31']; + yield 'monthly April clamp' => ['P1M', '2026-01-31', 3, '2026-04-30']; + yield 'two month year boundary' => ['P2M', '2026-11-30', 2, '2027-03-30']; + yield 'annual leap day clamp' => ['P1Y', '2024-02-29', 1, '2025-02-28']; + yield 'annual leap day restored' => ['P1Y', '2024-02-29', 4, '2028-02-29']; + yield 'daily leap year' => ['P1D', '2024-02-28', 2, '2024-03-01']; + yield 'weekly' => ['P2W', '2026-01-01', 2, '2026-01-29']; + yield 'index zero' => ['P1M', '2026-01-31', 0, '2026-01-31']; + } + + /** + * Method testInvalidCadencesAreRejected + * + * @access public + * + * @param string $interval the malformed or out-of-bounds interval + * + * @return void + */ + #[Test] + #[DataProvider('invalidCadences')] + public function testInvalidCadencesAreRejected(string $interval): void + { + $this->expectException(InvalidValueException::class); + + PlanCadence::fromString($interval); + } + + /** + * Method invalidCadences + * + * @access public + * + * @return iterable rejected durations + */ + public static function invalidCadences(): iterable + { + foreach (['', 'P0D', 'P01D', 'P-1D', 'P1Y6M', 'PT1H', 'P1.5D', 'P3651D', 'P521W', 'P121M', 'P11Y', 'P1D\n', ' P1D'] as $value) { + yield $value => [$value]; + } + } + + /** + * Method testPreviewUsesOriginalAnchorAfterFebruary + * + * @access public + * + * @return void + */ + #[Test] + public function testPreviewUsesOriginalAnchorAfterFebruary(): void + { + $dates = PlanCadence::fromString('P1M')->preview( + new DateTimeImmutable('2026-01-31T00:00:00+00:00'), + new DateTimeImmutable('2026-02-28T00:00:00+00:00'), + ); + + self::assertSame(['2026-02-28', '2026-03-31', '2026-04-30'], array_map(static fn (DateTimeImmutable $date): string => $date->format('Y-m-d'), $dates)); + } + + /** + * Method testNextAfterSkipsMissedDatesAndIsStrict + * + * @access public + * + * @return void + */ + #[Test] + public function testNextAfterSkipsMissedDatesAndIsStrict(): void + { + $cadence = PlanCadence::fromString('P1M'); + $anchor = new DateTimeImmutable('2026-01-31T08:00:00+00:00'); + + self::assertSame('2026-06-30', $cadence->nextAfter($anchor, new DateTimeImmutable('2026-06-14T08:00:00+00:00'))->format('Y-m-d')); + self::assertSame('2026-07-31', $cadence->nextAfter($anchor, new DateTimeImmutable('2026-06-30T08:00:00+00:00'))->format('Y-m-d')); + self::assertEquals($anchor, $cadence->nextAfter($anchor, new DateTimeImmutable('2025-12-31T08:00:00+00:00'))); + } + + /** + * Method testCalendarDaysKeepLocalTimeAcrossDaylightSaving + * + * @access public + * + * @return void + */ + #[Test] + public function testCalendarDaysKeepLocalTimeAcrossDaylightSaving(): void + { + $anchor = new DateTimeImmutable('2026-03-28 08:00:00', new DateTimeZone('Europe/Paris')); + $cadence = PlanCadence::fromString('P1D'); + + self::assertSame('2026-03-29T08:00:00+02:00', $cadence->addTo($anchor)->format('c')); + self::assertSame('2026-04-03T08:00:00+02:00', $cadence->nextAfter($anchor, new DateTimeImmutable('2026-04-02T10:00:00+00:00'))->format('c')); + } + + /** + * Method testLegacyCompositeAndOverflowBehaviorAreRetained + * + * @access public + * + * @return void + */ + #[Test] + public function testLegacyCompositeAndOverflowBehaviorAreRetained(): void + { + $anchor = new DateTimeImmutable('2026-01-31T00:00:00+00:00'); + $cadence = PlanCadence::legacyFromString('P1M'); + + self::assertTrue($cadence->legacy); + self::assertSame('2026-03-03', $cadence->addTo($anchor)->format('Y-m-d')); + self::assertSame('2026-04-03', $cadence->dateAt($anchor, 2)->format('Y-m-d')); + self::assertSame('2027-07-31', PlanCadence::legacyFromString('P1Y6M')->addTo($anchor)->format('Y-m-d')); + self::assertSame('2026-06-14', $cadence->nextAfter($anchor, new DateTimeImmutable('2026-05-14T00:00:00+00:00'))->format('Y-m-d')); + } + + /** + * Method testLegacyBoundsRemainHistorical + * + * @access public + * + * @return void + */ + #[Test] + public function testLegacyBoundsRemainHistorical(): void + { + $this->expectException(InvalidValueException::class); + + PlanCadence::legacyFromString('P1D'); + } + + /** + * Method testPreviewIsBounded + * + * @access public + * + * @return void + */ + #[Test] + public function testPreviewIsBounded(): void + { + $this->expectException(InvalidValueException::class); + + PlanCadence::fromString('P1D')->preview(new DateTimeImmutable('2026-01-01'), new DateTimeImmutable('2026-01-01'), 101); + } + + /** + * Method testNegativeIndexesAreRejected + * + * @access public + * + * @return void + */ + #[Test] + public function testNegativeIndexesAreRejected(): void + { + $this->expectException(InvalidValueException::class); + + PlanCadence::fromString('P1D')->dateAt(new DateTimeImmutable('2026-01-01'), -1); + } + // #endregion +} diff --git a/tests/Unit/Maintenance/Presentation/Api/Processor/MaintenancePlanProcessorTest.php b/tests/Unit/Maintenance/Presentation/Api/Processor/MaintenancePlanProcessorTest.php new file mode 100644 index 000000000..e87d19c20 --- /dev/null +++ b/tests/Unit/Maintenance/Presentation/Api/Processor/MaintenancePlanProcessorTest.php @@ -0,0 +1,492 @@ +equipmentId = self::EQUIPMENT; + $input->name = 'Monthly control'; + $input->operationKind = 'control'; + $input->interval = 'P1M'; + $input->anchorAt = '2026-01-31T08:00:00+02:00'; + $input->nextDueAt = '2026-02-28T08:00:00+02:00'; + $input->active = false; + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageMaintenancePlanCommand $command): bool => 'create' === $command->action && self::ORGANIZATION === $command->organizationId && self::ACTOR === $command->actorUserId && null === $command->planId && self::EQUIPMENT === $command->equipmentId && 'Monthly control' === $command->name && 'control' === $command->operationKind && 'P1M' === $command->interval && '2026-01-31T08:00:00+02:00' === $command->anchorAt?->format('c') && '2026-02-28T08:00:00+02:00' === $command->nextDueAt?->format('c') && false === $command->active && false === $command->retry))->willReturn(new ManageMaintenancePlanResult(details: $this->details())); + $processor = new MaintenancePlanProcessor($bus, $this->actor(), new MaintenancePlanOutputFactory()); + + $output = $processor->process($input, new Post(name: MaintenancePlanOperations::CREATE), ['organizationId' => self::ORGANIZATION]); + + self::assertInstanceOf(MaintenancePlanOutput::class, $output); + self::assertSame(self::PLAN, $output->id); + self::assertFalse($output->active); + self::assertSame('Monthly control', $output->name); + } + + /** + * Method testPatchDispatchesOnlyProvidedMutableFields + * + * @access public + * + * @return void + */ + #[Test] + public function testPatchDispatchesOnlyProvidedMutableFields(): void + { + $input = new ChangeMaintenancePlanInput(); + $input->name = 'Updated service'; + $input->interval = 'P2M'; + $input->anchorAt = '2026-01-31T08:00:00Z'; + $input->nextDueAt = '2026-03-31T08:00:00Z'; + $input->active = true; + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageMaintenancePlanCommand $command): bool => 'update' === $command->action && self::PLAN === $command->planId && null === $command->equipmentId && null === $command->operationKind && 'Updated service' === $command->name && 'P2M' === $command->interval && '2026-01-31T08:00:00+00:00' === $command->anchorAt?->format('c') && '2026-03-31T08:00:00+00:00' === $command->nextDueAt?->format('c') && true === $command->active))->willReturn(new ManageMaintenancePlanResult(details: $this->details())); + $processor = new MaintenancePlanProcessor($bus, $this->actor(), new MaintenancePlanOutputFactory()); + + $output = $processor->process($input, new Patch(name: MaintenancePlanOperations::UPDATE), ['organizationId' => self::ORGANIZATION, 'id' => self::PLAN]); + + self::assertInstanceOf(MaintenancePlanOutput::class, $output); + self::assertSame(self::PLAN, $output->id); + } + + /** + * Method testGenerationDispatchesExplicitRetryAndMapsReceipt + * + * @access public + * + * @return void + */ + #[Test] + public function testGenerationDispatchesExplicitRetryAndMapsReceipt(): void + { + $input = new GenerateMaintenancePlanInput(); + $input->retry = true; + $input->name = 'Repair attempt'; + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageMaintenancePlanCommand $command): bool => 'generate' === $command->action && self::ORGANIZATION === $command->organizationId && self::ACTOR === $command->actorUserId && self::PLAN === $command->planId && true === $command->retry && 'Repair attempt' === $command->name && null === $command->equipmentId && null === $command->anchorAt))->willReturn(new ManageMaintenancePlanResult(occurrenceId: 'occurrence-1', interventionId: 'intervention-1', number: 42, workItemsCount: 1, replayed: true)); + $processor = new MaintenancePlanProcessor($bus, $this->actor(), new MaintenancePlanOutputFactory()); + + $output = $processor->process($input, new Post(name: MaintenancePlanOperations::GENERATE), ['organizationId' => self::ORGANIZATION, 'id' => self::PLAN]); + + self::assertInstanceOf(GenerateMaintenancePlanOutput::class, $output); + self::assertSame('occurrence-1', $output->occurrenceId); + self::assertSame('intervention-1', $output->interventionId); + self::assertSame(42, $output->number); + self::assertSame(1, $output->workItemsCount); + self::assertTrue($output->replayed); + } + + /** + * Method testDefaultGenerationDoesNotRequestRetry + * + * @access public + * + * @return void + */ + #[Test] + public function testDefaultGenerationDoesNotRequestRetry(): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageMaintenancePlanCommand $command): bool => 'generate' === $command->action && false === $command->retry && null === $command->name))->willReturn(new ManageMaintenancePlanResult(occurrenceId: 'occurrence-1', interventionId: 'intervention-1', number: 7, workItemsCount: 1)); + $processor = new MaintenancePlanProcessor($bus, $this->actor(), new MaintenancePlanOutputFactory()); + + $output = $processor->process(new GenerateMaintenancePlanInput(), new Post(name: MaintenancePlanOperations::GENERATE), ['organizationId' => self::ORGANIZATION, 'id' => self::PLAN]); + + self::assertInstanceOf(GenerateMaintenancePlanOutput::class, $output); + self::assertFalse($output->replayed); + } + + /** + * Method testEngineActionsAreExplicitAndMapPreparedCount + * + * @access public + * + * @param string $operation the API metadata name + * @param string $action the dispatched engine action + * @param string $mode the returned authority + * + * @return void + */ + #[Test] + #[DataProvider('engineActions')] + public function testEngineActionsAreExplicitAndMapPreparedCount(string $operation, string $action, string $mode): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageMaintenancePlanCommand $command): bool => $action === $command->action && self::ORGANIZATION === $command->organizationId && self::ACTOR === $command->actorUserId && null === $command->planId))->willReturn(new ManageMaintenancePlanResult(mode: $mode, preparedCount: 18)); + $processor = new MaintenancePlanProcessor($bus, $this->actor(), new MaintenancePlanOutputFactory()); + + $output = $processor->process(null, new Post(name: $operation), ['organizationId' => self::ORGANIZATION]); + + self::assertInstanceOf(MaintenancePlanEngineOutput::class, $output); + self::assertSame($mode, $output->mode); + self::assertSame(18, $output->preparedCount); + } + + /** + * Method engineActions + * + * @access public + * + * @return iterable engine actions + */ + public static function engineActions(): iterable + { + yield 'prepare while keeping legacy authority' => [MaintenancePlanOperations::PREPARE, 'prepare_legacy', 'legacy']; + yield 'activate plan authority' => [MaintenancePlanOperations::ACTIVATE, 'activate', 'plans']; + } + + /** + * Method testArchiveDispatchesCommandAndReturnsNoBody + * + * @access public + * + * @return void + */ + #[Test] + public function testArchiveDispatchesCommandAndReturnsNoBody(): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageMaintenancePlanCommand $command): bool => 'archive' === $command->action && self::PLAN === $command->planId && self::ORGANIZATION === $command->organizationId && self::ACTOR === $command->actorUserId))->willReturn(new ManageMaintenancePlanResult()); + $processor = new MaintenancePlanProcessor($bus, $this->actor(), new MaintenancePlanOutputFactory()); + + self::assertNull($processor->process(null, new Delete(name: MaintenancePlanOperations::ARCHIVE), ['organizationId' => self::ORGANIZATION, 'id' => self::PLAN])); + } + + /** + * Method testPatchRejectsEquipmentAndOperationKindChanges + * + * @access public + * + * @param string $field the immutable field + * + * @return void + */ + #[Test] + #[DataProvider('immutableFields')] + public function testPatchRejectsEquipmentAndOperationKindChanges(string $field): void + { + $input = new ChangeMaintenancePlanInput(); + if ('equipmentId' === $field) { + $input->equipmentId = self::EQUIPMENT; + } else { + $input->operationKind = 'maintenance'; + } + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::never())->method('dispatch'); + $processor = new MaintenancePlanProcessor($bus, $this->actor(), new MaintenancePlanOutputFactory()); + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('immutable'); + + $processor->process($input, new Patch(name: MaintenancePlanOperations::UPDATE), ['organizationId' => self::ORGANIZATION, 'id' => self::PLAN]); + } + + /** + * Method immutableFields + * + * @access public + * + * @return iterable immutable identity fields + */ + public static function immutableFields(): iterable + { + yield 'equipment' => ['equipmentId']; + yield 'operation' => ['operationKind']; + } + + /** + * Method testMalformedCalendarDatesAreRejectedBeforeDispatch + * + * @access public + * + * @param string $field the calendar field + * @param string $value the malformed date + * + * @return void + */ + #[Test] + #[DataProvider('invalidDates')] + public function testMalformedCalendarDatesAreRejectedBeforeDispatch(string $field, string $value): void + { + $input = new ChangeMaintenancePlanInput(); + if ('anchorAt' === $field) { + $input->anchorAt = $value; + } else { + $input->nextDueAt = $value; + } + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::never())->method('dispatch'); + $processor = new MaintenancePlanProcessor($bus, $this->actor(), new MaintenancePlanOutputFactory()); + $this->expectException(BadRequestHttpException::class); + + $processor->process($input, new Patch(name: MaintenancePlanOperations::UPDATE), ['organizationId' => self::ORGANIZATION, 'id' => self::PLAN]); + } + + /** + * Method invalidDates + * + * @access public + * + * @return iterable invalid calendar payloads + */ + public static function invalidDates(): iterable + { + yield 'missing offset' => ['anchorAt', '2026-01-31T08:00:00']; + yield 'date only' => ['nextDueAt', '2026-01-31']; + yield 'relative date' => ['anchorAt', 'tomorrow']; + yield 'February overflow' => ['nextDueAt', '2026-02-30T08:00:00Z']; + yield 'non-leap February' => ['anchorAt', '2026-02-29T08:00:00Z']; + yield 'invalid month' => ['nextDueAt', '2026-13-01T08:00:00+00:00']; + yield 'invalid hour' => ['anchorAt', '2026-01-31T25:00:00Z']; + yield 'trailing content' => ['nextDueAt', '2026-01-31T08:00:00Z garbage']; + } + + /** + * Method testChangeActionsRequireTheirInputDto + * + * @access public + * + * @param string $operation the change operation + * + * @return void + */ + #[Test] + #[DataProvider('changeActions')] + public function testChangeActionsRequireTheirInputDto(string $operation): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::never())->method('dispatch'); + $processor = new MaintenancePlanProcessor($bus, $this->actor(), new MaintenancePlanOutputFactory()); + $this->expectException(BadRequestHttpException::class); + + $processor->process(new stdClass(), new Post(name: $operation), ['organizationId' => self::ORGANIZATION, 'id' => self::PLAN]); + } + + /** + * Method changeActions + * + * @access public + * + * @return iterable input-requiring write operations + */ + public static function changeActions(): iterable + { + yield 'create' => [MaintenancePlanOperations::CREATE]; + yield 'update' => [MaintenancePlanOperations::UPDATE]; + } + + /** + * Method testUnknownActionDoesNotDispatch + * + * @access public + * + * @return void + */ + #[Test] + public function testUnknownActionDoesNotDispatch(): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::never())->method('dispatch'); + $processor = new MaintenancePlanProcessor($bus, $this->actor(), new MaintenancePlanOutputFactory()); + $this->expectException(BadRequestHttpException::class); + + $processor->process(null, new Post(name: 'unsupported'), ['organizationId' => self::ORGANIZATION]); + } + + /** + * Method testUnauthenticatedActorNeverDispatchesWrite + * + * @access public + * + * @return void + */ + #[Test] + public function testUnauthenticatedActorNeverDispatchesWrite(): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::never())->method('dispatch'); + $processor = new MaintenancePlanProcessor($bus, $this->actor(null), new MaintenancePlanOutputFactory()); + $this->expectException(AccessDeniedHttpException::class); + + $processor->process(new ChangeMaintenancePlanInput(), new Post(name: MaintenancePlanOperations::CREATE), ['organizationId' => self::ORGANIZATION]); + } + + /** + * Method testInvalidRouteNeverDispatchesWrite + * + * @access public + * + * @param array $variables the invalid route values + * + * @return void + */ + #[Test] + #[DataProvider('invalidRoutes')] + public function testInvalidRouteNeverDispatchesWrite(array $variables): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::never())->method('dispatch'); + $processor = new MaintenancePlanProcessor($bus, $this->actor(), new MaintenancePlanOutputFactory()); + $this->expectException(BadRequestHttpException::class); + + $processor->process(new ChangeMaintenancePlanInput(), new Post(name: MaintenancePlanOperations::CREATE), $variables); + } + + /** + * Method invalidRoutes + * + * @access public + * + * @return iterable}> invalid route variables + */ + public static function invalidRoutes(): iterable + { + yield 'missing organization' => [[]]; + yield 'non-string organization' => [['organizationId' => 123]]; + yield 'non-string plan' => [['organizationId' => self::ORGANIZATION, 'id' => ['bad']]]; + } + + /** + * Method testDomainDenialPropagatesForCentralMapping + * + * @access public + * + * @return void + */ + #[Test] + public function testDomainDenialPropagatesForCentralMapping(): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->willThrowException(new MaintenanceAccessDeniedException('Missing manage permission.')); + $processor = new MaintenancePlanProcessor($bus, $this->actor(), new MaintenancePlanOutputFactory()); + $this->expectException(MaintenanceAccessDeniedException::class); + + $processor->process(new GenerateMaintenancePlanInput(), new Post(name: MaintenancePlanOperations::GENERATE), ['organizationId' => self::ORGANIZATION, 'id' => self::PLAN]); + } + + /** + * Method testOutsideOrganizationFailurePropagatesForCentralMapping + * + * @access public + * + * @return void + */ + #[Test] + public function testOutsideOrganizationFailurePropagatesForCentralMapping(): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->willThrowException(MaintenanceNotFoundException::withId(self::PLAN)); + $processor = new MaintenancePlanProcessor($bus, $this->actor(), new MaintenancePlanOutputFactory()); + $this->expectException(MaintenanceNotFoundException::class); + + $processor->process(null, new Delete(name: MaintenancePlanOperations::ARCHIVE), ['organizationId' => self::ORGANIZATION, 'id' => self::PLAN]); + } + + /** + * Method actor + * + * @access private + * + * @param ?string $id the authenticated user identifier + * + * @return CurrentActorPort the mocked actor + */ + private function actor(?string $id = self::ACTOR): CurrentActorPort + { + $actor = $this->createMock(CurrentActorPort::class); + $actor->expects(self::once())->method('userId')->willReturn($id); + + return $actor; + } + + /** + * Method details + * + * @access private + * + * @return MaintenancePlanDetails the command's typed output fixture + */ + private function details(): MaintenancePlanDetails + { + return new MaintenancePlanDetails(new MaintenancePlanState( + self::PLAN, + self::ORGANIZATION, + self::EQUIPMENT, + null, + 'fire_extinguisher', + 'Monthly control', + 'control', + 'P1M', + 'fixed', + new DateTimeImmutable('2026-01-31T08:00:00+02:00'), + new DateTimeImmutable('2026-02-28T08:00:00+02:00'), + false, + null, + null, + null, + new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + ), null); + } + // #endregion +} diff --git a/tests/Unit/Maintenance/Presentation/Api/Provider/MaintenancePlanProviderTest.php b/tests/Unit/Maintenance/Presentation/Api/Provider/MaintenancePlanProviderTest.php new file mode 100644 index 000000000..664df8509 --- /dev/null +++ b/tests/Unit/Maintenance/Presentation/Api/Provider/MaintenancePlanProviderTest.php @@ -0,0 +1,335 @@ +createMock(QueryBusPort::class); + $details = $this->details(); + $bus->expects(self::once())->method('ask')->with(self::callback(static fn (ReadMaintenancePlansQuery $query): bool => self::ORGANIZATION === $query->organizationId && self::ACTOR === $query->actorUserId && 'detail' === $query->action && self::PLAN === $query->planId))->willReturn(new ReadMaintenancePlansResult(items: [$details], total: 1, mode: 'plans')); + $provider = new MaintenancePlanProvider($bus, $this->actor(), new MaintenancePlanOutputFactory(), new RequestStack()); + + $output = $provider->provide(new Get(name: MaintenancePlanOperations::GET), ['organizationId' => self::ORGANIZATION, 'id' => self::PLAN]); + + self::assertInstanceOf(MaintenancePlanOutput::class, $output); + self::assertSame(self::PLAN, $output->id); + self::assertSame(self::EQUIPMENT, $output->equipmentId); + self::assertSame('control', $output->operationKind); + self::assertSame('2026-01-31T00:00:00+00:00', $output->anchorAt); + self::assertSame('2026-02-28T00:00:00+00:00', $output->nextDueAt); + self::assertFalse($output->active); + } + + /** + * Method testCollectionForwardsFilterAndPaginatorMetadata + * + * @access public + * + * @return void + */ + #[Test] + public function testCollectionForwardsFilterAndPaginatorMetadata(): void + { + $requests = new RequestStack(); + $requests->push(Request::create('/api/maintenance/plans', 'GET', ['page' => '3', 'itemsPerPage' => '20', 'equipmentId' => self::EQUIPMENT, 'operationKind' => 'control', 'search' => 'Annual'])); + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->with(self::callback(static fn (ReadMaintenancePlansQuery $query): bool => self::ORGANIZATION === $query->organizationId && self::ACTOR === $query->actorUserId && 'list' === $query->action && null === $query->planId && 3 === $query->page && 20 === $query->itemsPerPage && self::EQUIPMENT === $query->equipmentId && 'control' === $query->operationKind && 'Annual' === $query->search))->willReturn(new ReadMaintenancePlansResult(items: [$this->details()], total: 61, mode: 'plans')); + $provider = new MaintenancePlanProvider($bus, $this->actor(), new MaintenancePlanOutputFactory(), $requests); + + $output = $provider->provide(new GetCollection(name: MaintenancePlanOperations::LIST), ['organizationId' => self::ORGANIZATION]); + + self::assertInstanceOf(TraversablePaginator::class, $output); + self::assertSame(3.0, $output->getCurrentPage()); + self::assertSame(20.0, $output->getItemsPerPage()); + self::assertSame(61.0, $output->getTotalItems()); + $items = iterator_to_array($output); + self::assertCount(1, $items); + self::assertInstanceOf(MaintenancePlanOutput::class, $items[0]); + self::assertSame(self::PLAN, $items[0]->id); + } + + /** + * Method testCollectionWithoutRequestUsesDefaultPage + * + * @access public + * + * @return void + */ + #[Test] + public function testCollectionWithoutRequestUsesDefaultPage(): void + { + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->with(self::callback(static fn (ReadMaintenancePlansQuery $query): bool => 'list' === $query->action && 1 === $query->page && 30 === $query->itemsPerPage && null === $query->equipmentId && null === $query->operationKind && null === $query->search))->willReturn(new ReadMaintenancePlansResult()); + $provider = new MaintenancePlanProvider($bus, $this->actor(), new MaintenancePlanOutputFactory(), new RequestStack()); + + $output = $provider->provide(new GetCollection(name: MaintenancePlanOperations::LIST), ['organizationId' => self::ORGANIZATION]); + + self::assertInstanceOf(TraversablePaginator::class, $output); + self::assertSame(0.0, $output->getTotalItems()); + self::assertCount(0, iterator_to_array($output)); + } + + /** + * Method testPagingIsClampedBeforeDispatch + * + * @access public + * + * @param int $page the requested page + * @param int $size the requested size + * @param int $expectedPage the bounded page + * @param int $expectedSize the bounded size + * + * @return void + */ + #[Test] + #[DataProvider('pagingCases')] + public function testPagingIsClampedBeforeDispatch(int $page, int $size, int $expectedPage, int $expectedSize): void + { + $requests = new RequestStack(); + $requests->push(Request::create('/api/maintenance/plans', 'GET', ['page' => (string) $page, 'itemsPerPage' => (string) $size])); + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->with(self::callback(static fn (ReadMaintenancePlansQuery $query): bool => $expectedPage === $query->page && $expectedSize === $query->itemsPerPage))->willReturn(new ReadMaintenancePlansResult()); + $provider = new MaintenancePlanProvider($bus, $this->actor(), new MaintenancePlanOutputFactory(), $requests); + + $output = $provider->provide(new GetCollection(name: MaintenancePlanOperations::LIST), ['organizationId' => self::ORGANIZATION]); + + self::assertInstanceOf(TraversablePaginator::class, $output); + self::assertSame((float) $expectedPage, $output->getCurrentPage()); + self::assertSame((float) $expectedSize, $output->getItemsPerPage()); + } + + /** + * Method pagingCases + * + * @access public + * + * @return iterable bounded paging examples + */ + public static function pagingCases(): iterable + { + yield 'minimum page and maximum size' => [-4, 500, 1, 100]; + yield 'minimum size' => [2, 0, 2, 1]; + } + + /** + * Method testPreviewMetadataProducesIsoDates + * + * @access public + * + * @return void + */ + #[Test] + public function testPreviewMetadataProducesIsoDates(): void + { + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->with(self::callback(static fn (ReadMaintenancePlansQuery $query): bool => 'preview' === $query->action && self::PLAN === $query->planId && self::ORGANIZATION === $query->organizationId))->willReturn(new ReadMaintenancePlansResult(dates: [new DateTimeImmutable('2026-02-28T08:00:00+00:00'), new DateTimeImmutable('2026-03-31T08:00:00+00:00'), new DateTimeImmutable('2026-04-30T08:00:00+00:00')])); + $provider = new MaintenancePlanProvider($bus, $this->actor(), new MaintenancePlanOutputFactory(), new RequestStack()); + + $output = $provider->provide(new Get(name: MaintenancePlanOperations::PREVIEW), ['organizationId' => self::ORGANIZATION, 'id' => self::PLAN]); + + self::assertInstanceOf(MaintenancePlanPreviewOutput::class, $output); + self::assertSame(['2026-02-28T08:00:00+00:00', '2026-03-31T08:00:00+00:00', '2026-04-30T08:00:00+00:00'], $output->dates); + } + + /** + * Method testEngineMetadataProducesAuthorityAndPreparedCount + * + * @access public + * + * @return void + */ + #[Test] + public function testEngineMetadataProducesAuthorityAndPreparedCount(): void + { + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->with(self::callback(static fn (ReadMaintenancePlansQuery $query): bool => 'engine' === $query->action && null === $query->planId && self::ORGANIZATION === $query->organizationId))->willReturn(new ReadMaintenancePlansResult(mode: 'legacy', preparedCount: 7)); + $provider = new MaintenancePlanProvider($bus, $this->actor(), new MaintenancePlanOutputFactory(), new RequestStack()); + + $output = $provider->provide(new Get(name: MaintenancePlanOperations::ENGINE), ['organizationId' => self::ORGANIZATION]); + + self::assertInstanceOf(MaintenancePlanEngineOutput::class, $output); + self::assertSame('legacy', $output->mode); + self::assertSame(7, $output->preparedCount); + } + + /** + * Method testUnauthenticatedRequestDoesNotDispatchQuery + * + * @access public + * + * @return void + */ + #[Test] + public function testUnauthenticatedRequestDoesNotDispatchQuery(): void + { + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::never())->method('ask'); + $provider = new MaintenancePlanProvider($bus, $this->actor(null), new MaintenancePlanOutputFactory(), new RequestStack()); + $this->expectException(AccessDeniedHttpException::class); + + $provider->provide(new GetCollection(name: MaintenancePlanOperations::LIST), ['organizationId' => self::ORGANIZATION]); + } + + /** + * Method testInvalidRouteIdentifiersDoNotDispatchQuery + * + * @access public + * + * @param array $variables the invalid route variables + * + * @return void + */ + #[Test] + #[DataProvider('invalidRoutes')] + public function testInvalidRouteIdentifiersDoNotDispatchQuery(array $variables): void + { + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::never())->method('ask'); + $provider = new MaintenancePlanProvider($bus, $this->actor(), new MaintenancePlanOutputFactory(), new RequestStack()); + $this->expectException(BadRequestHttpException::class); + + $provider->provide(new Get(name: MaintenancePlanOperations::GET), $variables); + } + + /** + * Method invalidRoutes + * + * @access public + * + * @return iterable}> invalid route identifiers + */ + public static function invalidRoutes(): iterable + { + yield 'missing organization' => [[]]; + yield 'non-string organization' => [['organizationId' => 123]]; + yield 'non-string plan' => [['organizationId' => self::ORGANIZATION, 'id' => ['bad']]]; + } + + /** + * Method testBusinessDenialPropagatesToCentralErrorMapper + * + * @access public + * + * @return void + */ + #[Test] + public function testBusinessDenialPropagatesToCentralErrorMapper(): void + { + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->willThrowException(new MaintenanceAccessDeniedException('Missing permission.')); + $provider = new MaintenancePlanProvider($bus, $this->actor(), new MaintenancePlanOutputFactory(), new RequestStack()); + $this->expectException(MaintenanceAccessDeniedException::class); + + $provider->provide(new Get(name: MaintenancePlanOperations::GET), ['organizationId' => self::ORGANIZATION, 'id' => self::PLAN]); + } + + /** + * Method actor + * + * @access private + * + * @param ?string $id the authenticated actor or null + * + * @return CurrentActorPort the mocked actor port + */ + private function actor(?string $id = self::ACTOR): CurrentActorPort + { + $actor = $this->createMock(CurrentActorPort::class); + $actor->expects(self::once())->method('userId')->willReturn($id); + + return $actor; + } + + /** + * Method details + * + * @access private + * + * @return MaintenancePlanDetails a prepared control plan + */ + private function details(): MaintenancePlanDetails + { + return new MaintenancePlanDetails(new MaintenancePlanState( + self::PLAN, + self::ORGANIZATION, + self::EQUIPMENT, + null, + 'fire_extinguisher', + 'Annual control', + 'control', + 'P1M', + 'fixed', + new DateTimeImmutable('2026-01-31T00:00:00+00:00'), + new DateTimeImmutable('2026-02-28T00:00:00+00:00'), + false, + null, + null, + null, + new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + ), null); + } + // #endregion +} diff --git a/tests/Unit/MaintenanceCost/Application/Service/MaintenanceCostAllocationResolverTest.php b/tests/Unit/MaintenanceCost/Application/Service/MaintenanceCostAllocationResolverTest.php new file mode 100644 index 000000000..cc83f25fc --- /dev/null +++ b/tests/Unit/MaintenanceCost/Application/Service/MaintenanceCostAllocationResolverTest.php @@ -0,0 +1,302 @@ +createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($this->context()); + $equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $equipment->expects(self::once())->method('snapshots')->with('org', ['equipment'])->willReturn(['equipment' => $this->equipment()]); + $item = new MaintenanceCostItem('material', 'material', null, 'movement', null, '20.000000', 'EUR', 'Seal', '2026-10-07T12:00:00Z', equipmentId: 'equipment'); + $result = new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [$item], []); + self::assertNotNull($result[0]->allocation); + self::assertSame('equipment', $result[0]->equipmentId); + self::assertSame(['id' => 'site', 'name' => 'Asset site'], $result[0]->allocation['site']); + self::assertSame(['id' => 'customer', 'name' => 'Asset client'], $result[0]->allocation['customer']); + self::assertSame('live', $result[0]->allocation['identityState']); + } + + public function testTimeUsesTheWorkTargetInsteadOfDuplicatingTheDossier(): void + { + $identity = $this->equipment(); + $task = new InterventionPublishedWorkFact('task', 'repair', 'planned', null, null, 'equipment', $identity->site, $identity->customer, $identity, null, false, 0, 0, null, null); + $work = $this->createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($this->context([$task])); + $equipment = $this->createStub(InterventionEquipmentSnapshotPort::class); + $equipment->method('snapshots')->willReturn(['equipment' => $identity]); + $item = new MaintenanceCostItem('time', 'time', 'task', 'time-entry', 1, '10.000000', 'EUR', 'Time', '2026-10-07'); + $result = new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [$item], []); + self::assertNotNull($result[0]->allocation); + self::assertNotNull($result[0]->allocation['equipment']); + self::assertSame('equipment', $result[0]->equipmentId); + self::assertSame('Asset name', $result[0]->allocation['equipment']['name']); + } + + public function testCorrectionPreservesPrivateCapturedIdentityAfterParkRelocation(): void + { + $allocation = ['identityState' => 'captured', 'equipment' => ['id' => 'equipment', 'name' => 'Original asset', 'assetReference' => 'ORIGINAL'], 'site' => ['id' => 'old-site', 'name' => 'Original site'], 'customer' => ['id' => 'old-client', 'name' => 'Original client']]; + $original = new MaintenanceCostItem('material-original', 'material', null, 'movement', null, '20.000000', 'EUR', 'Seal', '2026-10-07', equipmentId: 'equipment', allocation: $allocation); + $corrected = new MaintenanceCostItem('material-corrected', 'material', null, 'movement', null, '25.000000', 'EUR', 'Seal', '2026-10-07', correctionOf: 'material-original', equipmentId: 'equipment'); + $work = $this->createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($this->context()); + $equipment = $this->createStub(InterventionEquipmentSnapshotPort::class); + $equipment->method('snapshots')->willReturn(['equipment' => $this->equipment()]); + $result = new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [$corrected], ['material:movement' => $original]); + self::assertSame($allocation, $result[0]->allocation); + self::assertSame('25.000000', $result[0]->amount); + self::assertSame('20.000000', $original->amount); + } + + public function testGlobalExpenseDoesNotAcquireTheDossierSiteOrClient(): void + { + $work = $this->createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($this->context()); + $equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $equipment->expects(self::never())->method('snapshots'); + $item = new MaintenanceCostItem('expense', 'expense', null, 'expense', null, '50.000000', 'EUR', 'Visit', '2026-10-07'); + $result = new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [$item], []); + self::assertNotNull($result[0]->allocation); + self::assertNull($result[0]->allocation['site']); + self::assertNull($result[0]->allocation['customer']); + self::assertNull($result[0]->allocation['equipment']); + } + + public function testUnresolvedEquipmentRetainsIdWithExplicitlyIncompleteIdentity(): void + { + $work = $this->createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($this->context()); + $equipment = $this->createStub(InterventionEquipmentSnapshotPort::class); + $equipment->method('snapshots')->willReturn([]); + $item = new MaintenanceCostItem('material', 'material', null, 'movement', null, '20.000000', 'EUR', 'Seal', '2026-10-07', equipmentId: 'equipment'); + $result = new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [$item], []); + self::assertNotNull($result[0]->allocation); + self::assertNotNull($result[0]->allocation['equipment']); + self::assertSame('equipment', $result[0]->allocation['equipment']['id']); + self::assertNull($result[0]->allocation['equipment']['name']); + self::assertSame('incomplete', $result[0]->allocation['identityState']); + self::assertNull($result[0]->allocation['site']); + } + + public function testForeignOperationalContextFailsBeforeEquipmentLookup(): void + { + $work = $this->createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($this->context(organization: 'foreign')); + $equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $equipment->expects(self::never())->method('snapshots'); + $this->expectException(MaintenanceCostException::class); + new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [], []); + } + + public function testMaterialReturnInheritsTheOriginalCapturedAllocation(): void + { + $allocation = ['identityState' => 'captured', 'equipment' => ['id' => 'equipment', 'name' => 'Original asset', 'assetReference' => 'ORIGINAL'], 'site' => ['id' => 'old-site', 'name' => 'Original site'], 'customer' => ['id' => 'old-client', 'name' => 'Original client']]; + $original = new MaintenanceCostItem('material:movement', 'material', null, 'movement', null, '20.000000', 'EUR', 'Seal', '2026-10-07', equipmentId: 'equipment', allocation: $allocation); + $return = new MaintenanceCostItem('material:return', 'material', null, 'return', null, '-5.000000', 'EUR', 'Returned seal', '2026-10-08', correctionOf: 'material:movement', equipmentId: 'equipment'); + $work = $this->createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($this->context()); + $equipment = $this->createStub(InterventionEquipmentSnapshotPort::class); + $equipment->method('snapshots')->willReturn(['equipment' => $this->equipment()]); + $result = new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [$return], ['material:movement' => $original]); + self::assertSame($allocation, $result[0]->allocation); + self::assertSame('-5.000000', $result[0]->amount); + } + + public function testLegacyCapturedTaskWithoutIdentityDoesNotUseCurrentParkLabels(): void + { + $task = new InterventionPublishedWorkFact('task', 'repair', 'completed', null, null, 'equipment', null, null, null, null, true, 30, 0, null, null); + $context = new InterventionEconomicContext('work', 'org', 1, 'Repair', 'corrective_maintenance', 'published', 2, new DateTimeImmutable('2026-10-07'), null, null, new DateTimeImmutable('2026-10-07'), 'publication', 'available', 1, false, null, null, [$task]); + $work = $this->createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($context); + $equipment = $this->createStub(InterventionEquipmentSnapshotPort::class); + $equipment->method('snapshots')->willReturn(['equipment' => $this->equipment()]); + $item = new MaintenanceCostItem('time:entry:1', 'time', 'task', 'entry', 1, '20.000000', 'EUR', 'Work', '2026-10-07'); + $result = new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [$item], []); + self::assertNotNull($result[0]->allocation); + self::assertNotNull($result[0]->allocation['equipment']); + self::assertSame('equipment', $result[0]->equipmentId); + self::assertSame('incomplete', $result[0]->allocation['identityState']); + self::assertNull($result[0]->allocation['equipment']['name']); + self::assertNull($result[0]->allocation['site']); + self::assertNull($result[0]->allocation['customer']); + } + + /** + * @param 'available'|'snapshot_missing' $snapshotState retained publication provenance + */ + #[DataProvider('historicalSnapshotStates')] + public function testPublishedDirectMaterialWithoutFinancialSnapshotRetainsIncompleteIdentity(string $snapshotState): void + { + $work = $this->createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($this->context(snapshotState: $snapshotState, publicationId: 'publication')); + $equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $equipment->expects(self::never())->method('snapshots'); + $item = new MaintenanceCostItem('material:movement', 'material', null, 'movement', null, '20.000000', 'EUR', 'Seal', '2026-10-07', equipmentId: 'equipment'); + $result = new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [$item], []); + self::assertSame('equipment', $result[0]->equipmentId); + self::assertSame(['identityState' => 'incomplete', 'equipment' => ['id' => 'equipment', 'name' => null, 'assetReference' => null], 'site' => null, 'customer' => null], $result[0]->allocation); + self::assertSame('20.000000', $result[0]->amount); + } + + /** + * @param 'available'|'snapshot_missing' $snapshotState retained publication provenance + */ + #[DataProvider('historicalSnapshotStates')] + public function testProvenLateMaterialUsesCurrentIdentityWithoutChangingCapturedSource(string $snapshotState): void + { + $work = $this->createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($this->context(snapshotState: $snapshotState, publicationId: 'publication')); + $equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $equipment->expects(self::once())->method('snapshots')->with('org', ['equipment'])->willReturn(['equipment' => $this->equipment()]); + $original = new MaintenanceCostItem('material:earlier', 'material', null, 'earlier', null, '10.000000', 'EUR', 'Earlier seal', '2026-10-07', equipmentId: 'equipment'); + $late = new MaintenanceCostItem('material:late', 'material', null, 'late', null, '20.000000', 'EUR', 'Late seal', '2026-10-08', correctionOf: 'publication:publication', equipmentId: 'equipment'); + $result = new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [$original, $late], ['material:earlier' => $original]); + self::assertNotNull($result[0]->allocation); + self::assertSame('incomplete', $result[0]->allocation['identityState']); + self::assertNull($result[0]->allocation['site']); + self::assertNotNull($result[1]->allocation); + self::assertSame('live', $result[1]->allocation['identityState']); + self::assertSame(['id' => 'site', 'name' => 'Asset site'], $result[1]->allocation['site']); + self::assertSame(['id' => 'customer', 'name' => 'Asset client'], $result[1]->allocation['customer']); + self::assertSame('publication:publication', $result[1]->correctionOf); + self::assertSame('20.000000', $result[1]->amount); + self::assertNull($original->allocation); + self::assertSame('10.000000', $original->amount); + } + + #[DataProvider('unprovenPublicationReferences')] + public function testUnprovenLateMaterialRemainsIncomplete(?string $publicationId): void + { + $work = $this->createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($this->context(snapshotState: 'snapshot_missing', publicationId: $publicationId)); + $equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $equipment->expects(self::never())->method('snapshots'); + $item = new MaintenanceCostItem('material:late', 'material', null, 'late', null, '20.000000', 'EUR', 'Seal', '2026-10-08', correctionOf: 'publication:publication', equipmentId: 'equipment'); + $result = new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [$item], []); + self::assertSame(['identityState' => 'incomplete', 'equipment' => ['id' => 'equipment', 'name' => null, 'assetReference' => null], 'site' => null, 'customer' => null], $result[0]->allocation); + } + + public function testCorrectionOfLegacyCapturedMaterialDoesNotAcquireCurrentIdentity(): void + { + $work = $this->createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($this->context(snapshotState: 'snapshot_missing', publicationId: 'publication')); + $equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $equipment->expects(self::never())->method('snapshots'); + $original = new MaintenanceCostItem('material:movement', 'material', null, 'movement', null, '20.000000', 'EUR', 'Seal', '2026-10-07', equipmentId: 'equipment'); + $return = new MaintenanceCostItem('material:return', 'material', null, 'return', null, '-5.000000', 'EUR', 'Returned seal', '2026-10-08', correctionOf: 'material:movement', equipmentId: 'equipment'); + $result = new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [$return], ['material:movement' => $original]); + self::assertSame(['identityState' => 'incomplete', 'equipment' => ['id' => 'equipment', 'name' => null, 'assetReference' => null], 'site' => null, 'customer' => null], $result[0]->allocation); + self::assertSame('-5.000000', $result[0]->amount); + self::assertSame('material:movement', $result[0]->correctionOf); + self::assertNull($original->allocation); + } + + public function testLateTargetedFactUsesCapturedTaskIdentity(): void + { + $identity = $this->equipment(); + $task = new InterventionPublishedWorkFact('task', 'repair', 'completed', null, null, 'equipment', $identity->site, $identity->customer, $identity, null, true, 30, 0, null, null); + $work = $this->createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($this->context([$task], snapshotState: 'available', publicationId: 'publication')); + $equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $equipment->expects(self::never())->method('snapshots'); + $item = new MaintenanceCostItem('time:late:1', 'time', 'task', 'late', 1, '20.000000', 'EUR', 'Late work', '2026-10-08', correctionOf: 'publication:publication'); + $result = new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [$item], []); + self::assertSame(['identityState' => 'captured', 'equipment' => ['id' => 'equipment', 'name' => 'Asset name', 'assetReference' => 'ASSET-01'], 'site' => $identity->site, 'customer' => $identity->customer], $result[0]->allocation); + } + + public function testCorrectionsOfLateMaterialInheritSourceIdentityRegardlessOfOrder(): void + { + $work = $this->createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($this->context(snapshotState: 'snapshot_missing', publicationId: 'publication')); + $equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $equipment->expects(self::once())->method('snapshots')->with('org', ['equipment'])->willReturn(['equipment' => $this->equipment()]); + $late = new MaintenanceCostItem('material:late', 'material', null, 'late', null, '20.000000', 'EUR', 'Late seal', '2026-10-08', correctionOf: 'publication:publication', equipmentId: 'equipment'); + $return = new MaintenanceCostItem('material:return', 'material', null, 'return', null, '-5.000000', 'EUR', 'Returned seal', '2026-10-09', correctionOf: 'material:late'); + $adjustment = new MaintenanceCostItem('material:adjustment', 'material', null, 'adjustment', null, '1.000000', 'EUR', 'Return adjustment', '2026-10-10', correctionOf: 'material:return'); + $result = new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [$adjustment, $return, $late], []); + self::assertNotNull($result[2]->allocation); + self::assertSame('live', $result[2]->allocation['identityState']); + self::assertSame($result[2]->allocation, $result[0]->allocation); + self::assertSame($result[2]->allocation, $result[1]->allocation); + self::assertSame('equipment', $result[0]->equipmentId); + self::assertSame('equipment', $result[1]->equipmentId); + self::assertSame('1.000000', $result[0]->amount); + self::assertSame('-5.000000', $result[1]->amount); + self::assertSame('material:return', $result[0]->correctionOf); + self::assertSame('material:late', $result[1]->correctionOf); + } + + public function testCyclicCurrentCorrectionsRetainTheirOwnIncompleteIdentities(): void + { + $work = $this->createStub(InterventionPublicationFactsPort::class); + $work->method('economicContext')->willReturn($this->context(snapshotState: 'snapshot_missing', publicationId: 'publication')); + $equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $equipment->expects(self::never())->method('snapshots'); + $first = new MaintenanceCostItem('material:first', 'material', null, 'first', null, '20.000000', 'EUR', 'First correction', '2026-10-08', correctionOf: 'material:second', equipmentId: 'first-equipment'); + $second = new MaintenanceCostItem('material:second', 'material', null, 'second', null, '-5.000000', 'EUR', 'Second correction', '2026-10-09', correctionOf: 'material:first', equipmentId: 'second-equipment'); + $self = new MaintenanceCostItem('material:self', 'material', null, 'self', null, '1.000000', 'EUR', 'Self correction', '2026-10-10', correctionOf: 'material:self', equipmentId: 'self-equipment'); + $result = new MaintenanceCostAllocationResolver($work, $equipment)->resolve('org', 'work', [$first, $second, $self], []); + foreach ($result as $index => $item) { + self::assertNotNull($item->allocation); + self::assertSame('incomplete', $item->allocation['identityState']); + self::assertSame([$first, $second, $self][$index]->equipmentId, $item->equipmentId); + self::assertNotNull($item->allocation['equipment']); + self::assertSame($item->equipmentId, $item->allocation['equipment']['id']); + self::assertNull($item->allocation['site']); + self::assertNull($item->allocation['customer']); + } + } + + /** + * @return iterable + */ + public static function historicalSnapshotStates(): iterable + { + yield 'missing operational dossier' => ['snapshot_missing']; + yield 'available operational dossier' => ['available']; + } + + /** + * @return iterable + */ + public static function unprovenPublicationReferences(): iterable + { + yield 'missing publication anchor' => [null]; + yield 'different publication anchor' => ['other-publication']; + } + + /** + * @param list $tasks + * @param 'available'|'snapshot_missing'|'live' $snapshotState identity provenance + */ + private function context(array $tasks = [], string $organization = 'org', string $snapshotState = 'live', ?string $publicationId = null): InterventionEconomicContext + { + return new InterventionEconomicContext('work', $organization, 1, 'Repair', 'corrective_maintenance', 'live' === $snapshotState ? 'draft' : 'published', 1, new DateTimeImmutable('2026-10-07'), null, null, 'live' === $snapshotState ? null : new DateTimeImmutable('2026-10-07'), $publicationId, $snapshotState, 'available' === $snapshotState ? 1 : null, 'snapshot_missing' !== $snapshotState, ['id' => 'dossier-site', 'name' => 'Dossier site'], ['id' => 'dossier-client', 'name' => 'Dossier client'], $tasks); + } + + private function equipment(): InterventionEquipmentSnapshot + { + return new InterventionEquipmentSnapshot('equipment', 'Asset name', 'ASSET-01', 'extinguisher', null, null, null, 'location', ['id' => 'site', 'name' => 'Asset site'], ['id' => 'customer', 'name' => 'Asset client']); + } +} diff --git a/tests/Unit/MaintenanceCost/Application/Service/MaintenanceCostProjectionTest.php b/tests/Unit/MaintenanceCost/Application/Service/MaintenanceCostProjectionTest.php new file mode 100644 index 000000000..35abbc2ea --- /dev/null +++ b/tests/Unit/MaintenanceCost/Application/Service/MaintenanceCostProjectionTest.php @@ -0,0 +1,91 @@ +projection([$this->time(2, 60)], new MaintenanceRateSnapshot('new-rate', 'member', '999.000000', 'EUR', '2026-09-01'))->current('org', 'order', 'EUR', $frozen); + self::assertSame('100.000000', $current->total); + self::assertSame('time:entry:1', $current->items[0]->correctionOf); + self::assertSame('initial-rate', $current->items[0]->rateId); + self::assertSame('50.000000', $frozen->totals->total); + } + + public function testIdenticalPublishedTimeIsNotRepricedByAChangedRate(): void + { + $original = new MaintenanceCostItem('time:entry:1', 'time', 'task', 'entry', 1, '50.000000', 'EUR', 'Work', '2026-10-01', null, '100.000000', 'initial-rate'); + $frozen = new MaintenanceCostSnapshot(1, '2026-10-02T10:00:00Z', 'publication', 4, 'EUR', new MaintenanceCostTotals('50.000000', '50.000000', true, [$original])); + $current = $this->projection([$this->time(1, 30)], new MaintenanceRateSnapshot('new-rate', 'member', '999.000000', 'EUR', '2026-09-01'))->current('org', 'order', 'EUR', $frozen); + self::assertSame('50.000000', $current->total); + self::assertNull($current->items[0]->correctionOf); + } + + public function testUnknownTimeAndMaterialValuationsKeepTheCurrentCostIncomplete(): void + { + $material = new InventoryCostFact('movement', 'part', '1.000000', null, null, 'EUR', new DateTimeImmutable('2026-10-01T10:00:00Z'), 'task'); + $current = $this->projection([$this->time(1, 30)], null, [$material])->current('org', 'order', 'EUR', null); + self::assertNull($current->total); + self::assertSame('0.000000', $current->knownTotal); + self::assertFalse($current->complete); + } + + public function testCancelledTimeIsAnExplicitZeroEvenWithoutAnHourlyRate(): void + { + $current = $this->projection([$this->time(2, 30, true)], null)->current('org', 'order', 'EUR', null); + self::assertSame('0.000000', $current->total); + self::assertTrue($current->complete); + } + + public function testALaterRateCanCompleteCurrentCostsWithoutChangingAnIncompleteSnapshot(): void + { + $original = new MaintenanceCostItem('time:entry:1', 'time', 'task', 'entry', 1, null, 'EUR', 'Work', '2026-10-01'); + $frozen = new MaintenanceCostSnapshot(1, '2026-10-02T10:00:00Z', 'publication', 4, 'EUR', new MaintenanceCostTotals(null, '0.000000', false, [$original])); + $current = $this->projection([$this->time(1, 30)], new MaintenanceRateSnapshot('new-rate', 'member', '100.000000', 'EUR', '2026-09-01'))->current('org', 'order', 'EUR', $frozen); + self::assertSame('50.000000', $current->total); + self::assertNull($frozen->totals->total); + self::assertFalse($frozen->totals->complete); + } + + /** + * @param list $times + * @param list $materials + */ + private function projection(array $times, ?MaintenanceRateSnapshot $rate, array $materials = []): MaintenanceCostProjection + { + $work = $this->createStub(InterventionCostSourceFactsPort::class); + $work->method('timeFacts')->willReturn($times); + $inventory = $this->createStub(InventoryInterventionResourcesPort::class); + $inventory->method('costFacts')->willReturn($materials); + $rates = $this->createStub(MaintenanceRatePort::class); + $rates->method('forMember')->willReturn($rate); + $store = $this->createStub(MaintenanceCostStorePort::class); + $store->method('expenses')->willReturn([]); + + return new MaintenanceCostProjection($work, $inventory, $this->createStub(MaintenanceCurrencyPort::class), $rates, $store, new MaintenanceCostCalculator()); + } + + private function time(int $revision, int $minutes, bool $cancelled = false): InterventionTimeCostFact + { + return new InterventionTimeCostFact('entry', 'task', 'member', '2026-10-01', $minutes, $revision, $cancelled, 'Work', new DateTimeImmutable('2026-10-02T10:00:00Z')); + } +} diff --git a/tests/Unit/MaintenanceCost/Application/Service/MaintenanceEconomicAggregatorTest.php b/tests/Unit/MaintenanceCost/Application/Service/MaintenanceEconomicAggregatorTest.php new file mode 100644 index 000000000..4f0c57ed8 --- /dev/null +++ b/tests/Unit/MaintenanceCost/Application/Service/MaintenanceEconomicAggregatorTest.php @@ -0,0 +1,723 @@ +context(); + $report = $this->report($context, [ + $this->item('labor', '12.100000', workItemId: 'task-a', kind: 'time'), + $this->item('part', '7.200000', equipmentId: 'equipment-b', kind: 'material'), + $this->item('travel', '5.000000'), + ]); + + self::assertSame('24.300000', $report->current->total); + self::assertSame(3, $report->current->contributionCount); + self::assertSame('12.100000', $this->row($report, 'equipment-a')->current->total); + self::assertSame('7.200000', $this->row($report, 'equipment-b')->current->total); + self::assertSame('5.000000', $report->unallocated->current->total); + self::assertSame('unallocated', $report->unallocated->identityState); + self::assertFalse($report->unallocated->allocationComplete); + self::assertSame(['intervention-a'], $this->row($report, 'equipment-a')->interventionIds); + self::assertTrue($report->reconciliation['reconciled']); + } + + /** + * Method testSiteGroupingCombinesEquipmentFactsAndKeepsTheGlobalExpenseSeparate + * + * @access public + * + * @return void + */ + public function testSiteGroupingCombinesEquipmentFactsAndKeepsTheGlobalExpenseSeparate(): void + { + $report = $this->report($this->context(), [ + $this->item('first', '10.000000', workItemId: 'task-a'), + $this->item('second', '20.000000', workItemId: 'task-b'), + $this->item('shared', '3.000000'), + ], query: $this->query(groupBy: 'site')); + + self::assertSame(1, $report->totalItems); + self::assertSame('site-a', $report->rows[0]->id); + self::assertSame('30.000000', $report->rows[0]->current->total); + self::assertSame('3.000000', $report->unallocated->current->total); + self::assertSame('33.000000', $report->current->total); + } + + /** + * Method testCustomerGroupingCombinesDifferentSitesWithoutDuplicatingAnIntervention + * + * @access public + * + * @return void + */ + public function testCustomerGroupingCombinesDifferentSitesWithoutDuplicatingAnIntervention(): void + { + $customer = ['id' => 'customer-a', 'name' => 'Hospital']; + $context = $this->context(tasks: [ + $this->task('task-a', 'equipment-a', ['id' => 'site-a', 'name' => 'East'], $customer), + $this->task('task-b', 'equipment-b', ['id' => 'site-b', 'name' => 'West'], $customer), + ]); + $report = $this->report($context, [ + $this->item('east', '10.000000', workItemId: 'task-a'), + $this->item('west', '20.000000', workItemId: 'task-b'), + ], query: $this->query(groupBy: 'customer')); + + self::assertSame(1, $report->totalItems); + self::assertSame('customer-a', $report->rows[0]->id); + self::assertSame('Hospital', $report->rows[0]->name); + self::assertSame('30.000000', $report->rows[0]->current->total); + self::assertSame(['intervention-a'], $report->rows[0]->interventionIds); + } + + /** + * Method testEquipmentFilterRetainsUnallocatedFactsAndReconcilesExcludedTargets + * + * @access public + * + * @return void + */ + public function testEquipmentFilterRetainsUnallocatedFactsAndReconcilesExcludedTargets(): void + { + $report = $this->report($this->context(), [ + $this->item('first', '10.000000', workItemId: 'task-a'), + $this->item('second', '20.000000', workItemId: 'task-b'), + $this->item('shared', '3.000000'), + ], query: $this->query(equipmentId: 'equipment-a')); + + self::assertSame(1, $report->totalItems); + self::assertSame('13.000000', $report->current->total); + self::assertSame('3.000000', $report->unallocated->current->total); + self::assertSame('33.000000', $report->reconciliation['sourceCurrent']->total); + self::assertSame('20.000000', $report->reconciliation['excludedCurrent']->total); + self::assertTrue($report->reconciliation['reconciled']); + } + + /** + * Method testTargetFiltersAreConjunctiveAndPreserveTheUnallocatedContribution + * + * @access public + * + * @param ?string $siteId selected site + * @param ?string $customerId selected customer + * @param string $expected selected exact cost + * @param string $unallocated global cost within matching dossiers + * + * @return void + */ + #[DataProvider('targetFilterCases')] + public function testTargetFiltersAreConjunctiveAndPreserveTheUnallocatedContribution(?string $siteId, ?string $customerId, string $expected, string $unallocated): void + { + $context = $this->context(tasks: [ + $this->task('task-a', 'equipment-a', ['id' => 'site-a', 'name' => 'East'], ['id' => 'customer-a', 'name' => 'Alpha']), + $this->task('task-b', 'equipment-b', ['id' => 'site-b', 'name' => 'West'], ['id' => 'customer-b', 'name' => 'Beta']), + ]); + $report = $this->report($context, [ + $this->item('first', '10.000000', workItemId: 'task-a'), + $this->item('second', '20.000000', workItemId: 'task-b'), + $this->item('shared', '3.000000'), + ], query: $this->query(siteId: $siteId, customerId: $customerId)); + + self::assertSame($expected, $report->current->total); + self::assertSame($unallocated, $report->unallocated->current->total); + self::assertTrue($report->reconciliation['reconciled']); + } + + /** + * Method targetFilterCases + * + * @access public + * + * @return iterable independently selected and incompatible filters + */ + public static function targetFilterCases(): iterable + { + yield 'site' => ['site-a', null, '13.000000', '3.000000']; + yield 'customer' => [null, 'customer-b', '23.000000', '3.000000']; + yield 'incompatible site and customer excludes this dossier' => ['site-a', 'customer-b', '0.000000', '0.000000']; + } + + /** + * Method testMissingHistoricSnapshotProducesUnknownFrozenCostWithoutGuessingLiveIdentity + * + * @access public + * + * @return void + */ + public function testMissingHistoricSnapshotProducesUnknownFrozenCostWithoutGuessingLiveIdentity(): void + { + $context = $this->context(status: 'published', snapshotState: 'snapshot_missing', tasks: []); + $report = $this->report($context, [$this->item('legacy-shared', '25.000000')], query: $this->query(groupBy: 'site')); + + self::assertSame([], $report->rows); + self::assertSame(1, $report->missingSnapshotCount); + self::assertSame(1, $report->publishedInterventionCount); + self::assertSame(0, $report->liveInterventionCount); + self::assertNull($report->frozen->total); + self::assertFalse($report->frozen->complete); + self::assertSame(1, $report->frozen->unknownCount); + self::assertSame('0.000000', $report->frozen->knownTotal); + self::assertSame('25.000000', $report->unallocated->current->total); + self::assertNull($report->unallocated->frozen->total); + } + + /** + * Method testUnknownContributionKeepsKnownSubtotalAndSuppressesVariance + * + * @access public + * + * @return void + */ + public function testUnknownContributionKeepsKnownSubtotalAndSuppressesVariance(): void + { + $report = $this->report($this->context(), [ + $this->item('known', '12.500001', workItemId: 'task-a'), + $this->item('unpriced', null, workItemId: 'task-a', kind: 'material'), + $this->item('correction', '-2.000000', workItemId: 'task-a'), + ], new MaintenanceCostPlanning('8.000000')); + + self::assertNull($report->current->total); + self::assertSame('10.500001', $report->current->knownTotal); + self::assertSame(1, $report->current->unknownCount); + self::assertSame(3, $report->current->contributionCount); + self::assertNull($report->variance); + self::assertFalse($this->row($report, 'equipment-a')->current->complete); + } + + /** + * Method testPlannedResourcesAreComparedToActualCostWithoutAddingTheGlobalBudgetAgain + * + * @access public + * + * @return void + */ + public function testPlannedResourcesAreComparedToActualCostWithoutAddingTheGlobalBudgetAgain(): void + { + $planning = new MaintenanceCostPlanning('200.000000', resources: [ + $this->resource('task-a', '60.000000'), + $this->resource('task-b', '30.000000'), + ]); + $report = $this->report($this->context(), [ + $this->item('first', '70.000000', workItemId: 'task-a'), + $this->item('second', '30.000000', workItemId: 'task-b'), + ], $planning); + + self::assertSame('90.000000', $report->planned->total); + self::assertSame('200.000000', $report->budget->total); + self::assertSame('200.000000', $report->unallocated->budget->total); + self::assertSame('0.000000', $report->unallocated->planned->total); + self::assertSame('60.000000', $this->row($report, 'equipment-a')->planned->total); + self::assertSame('10.000000', $report->variance); + self::assertSame('10.000000', $this->row($report, 'equipment-a')->variance); + } + + /** + * Method testFilteringPlanningResourcesReconcilesTheExcludedEstimate + * + * @access public + * + * @return void + */ + public function testFilteringPlanningResourcesReconcilesTheExcludedEstimate(): void + { + $report = $this->report($this->context(), [], new MaintenanceCostPlanning('100.000000', resources: [ + $this->resource('task-a', '20.000000'), + $this->resource('task-b', '30.000000'), + $this->resource(null, '4.000000'), + ]), query: $this->query(equipmentId: 'equipment-a')); + + self::assertSame('24.000000', $report->planned->total); + self::assertSame('54.000000', $report->reconciliation['sourcePlanned']->total); + self::assertSame('30.000000', $report->reconciliation['excludedPlanned']->total); + self::assertSame('4.000000', $report->unallocated->planned->total); + self::assertSame('100.000000', $report->budget->total); + self::assertTrue($report->reconciliation['reconciled']); + } + + /** + * Method testVarianceRequiresBothCompleteTotals + * + * @access public + * + * @param ?string $actual actual known contribution or unknown + * @param ?string $estimate planned known resource or unknown + * @param ?string $expected exact variance when both sides are known + * + * @return void + */ + #[DataProvider('varianceCases')] + public function testVarianceRequiresBothCompleteTotals(?string $actual, ?string $estimate, ?string $expected): void + { + $report = $this->report($this->context(), [$this->item('actual', $actual, workItemId: 'task-a')], new MaintenanceCostPlanning('999.000000', resources: [$this->resource('task-a', $estimate)])); + + self::assertSame($expected, $report->variance); + self::assertSame($expected, $this->row($report, 'equipment-a')->variance); + self::assertSame(null !== $estimate, $report->planned->complete); + self::assertSame(null !== $estimate ? 0 : 1, $report->planned->unknownCount); + } + + /** + * Method varianceCases + * + * @access public + * + * @return iterable missing valuation combinations + */ + public static function varianceCases(): iterable + { + yield 'complete' => ['12.000001', '10.000000', '2.000001']; + yield 'unknown current' => [null, '10.000000', null]; + yield 'unknown planning' => ['12.000001', null, null]; + yield 'both unknown' => [null, null, null]; + } + + /** + * Method testMissingPlanningRemainsUnknownInsteadOfAZeroEstimate + * + * @access public + * + * @return void + */ + public function testMissingPlanningRemainsUnknownInsteadOfAZeroEstimate(): void + { + $report = $this->report($this->context(), [$this->item('actual', '15.000000', workItemId: 'task-a')]); + + self::assertNull($report->planned->total); + self::assertSame('0.000000', $report->planned->knownTotal); + self::assertSame(1, $report->planned->unknownCount); + self::assertNull($report->variance); + self::assertNull($this->row($report, 'equipment-a')->variance); + } + + /** + * Method testLateCurrentFactsDoNotChangeFrozenAmountsOrCapturedPlanning + * + * @access public + * + * @return void + */ + public function testLateCurrentFactsDoNotChangeFrozenAmountsOrCapturedPlanning(): void + { + $context = $this->context(status: 'published', snapshotState: 'available'); + $original = $this->item('original', '20.000000', workItemId: 'task-a'); + $snapshot = $this->snapshot([$original], new MaintenanceCostPlanning('12.000000', resources: [$this->resource('task-a', '12.000000')])); + $report = $this->report($context, [$original, $this->item('late', '5.000000', workItemId: 'task-a')], new MaintenanceCostPlanning('900.000000'), $snapshot); + + self::assertSame('25.000000', $report->current->total); + self::assertSame('20.000000', $report->frozen->total); + self::assertSame('12.000000', $report->planned->total); + self::assertSame('12.000000', $report->budget->total); + self::assertSame('13.000000', $report->variance); + self::assertSame('20.000000', $this->row($report, 'equipment-a')->frozen->total); + self::assertSame(0, $report->missingSnapshotCount); + } + + /** + * Method testExplicitCapturedAllocationsRetainTheirOriginalLabelsInsteadOfCurrentTaskLabels + * + * @access public + * + * @return void + */ + public function testExplicitCapturedAllocationsRetainTheirOriginalLabelsInsteadOfCurrentTaskLabels(): void + { + $allocation = [ + 'identityState' => 'captured', + 'equipment' => ['id' => 'equipment-a', 'name' => 'Original extinguisher', 'assetReference' => 'EXT-OLD'], + 'site' => ['id' => 'site-a', 'name' => 'Original site'], + 'customer' => ['id' => 'customer-a', 'name' => 'Original customer'], + ]; + $item = $this->item('original', '20.000000', workItemId: 'task-a', allocation: $allocation); + $report = $this->report($this->context(status: 'published', snapshotState: 'available'), [$item], frozen: $this->snapshot([$item])); + + self::assertSame('Original extinguisher', $this->row($report, 'equipment-a')->name); + self::assertSame('captured', $this->row($report, 'equipment-a')->identityState); + self::assertTrue($this->row($report, 'equipment-a')->allocationComplete); + self::assertSame('20.000000', $this->row($report, 'equipment-a')->frozen->total); + } + + /** + * Method testAggregateMayExceedTheEighteenDigitLimitOfAnIndividualFactWithoutLosingPrecision + * + * @access public + * + * @return void + */ + public function testAggregateMayExceedTheEighteenDigitLimitOfAnIndividualFactWithoutLosingPrecision(): void + { + $report = $this->report($this->context(), [ + $this->item('first', '999999999999999999.999999', workItemId: 'task-a'), + $this->item('second', '999999999999999999.999999', workItemId: 'task-a'), + ]); + + self::assertSame('1999999999999999999.999998', $report->current->total); + self::assertSame('1999999999999999999.999998', $this->row($report, 'equipment-a')->current->total); + self::assertSame('1999999999999999999.999998', $report->reconciliation['sourceCurrent']->total); + } + + /** + * Method testPaginationDoesNotChangeFullScopeTotalsOrCounts + * + * @access public + * + * @return void + */ + public function testPaginationDoesNotChangeFullScopeTotalsOrCounts(): void + { + $context = $this->context(tasks: [ + $this->task('task-a', 'equipment-a'), + $this->task('task-b', 'equipment-b'), + $this->task('task-c', 'equipment-c'), + ]); + $report = $this->report($context, [ + $this->item('first', '10.000000', workItemId: 'task-a'), + $this->item('second', '20.000000', workItemId: 'task-b'), + $this->item('third', '30.000000', workItemId: 'task-c'), + ], query: $this->query(page: 2, itemsPerPage: 1)); + + self::assertSame(3, $report->totalItems); + self::assertCount(1, $report->rows); + self::assertSame('equipment-b', $report->rows[0]->id); + self::assertSame('20.000000', $report->rows[0]->current->total); + self::assertSame('60.000000', $report->current->total); + self::assertSame('60.000000', $report->reconciliation['sourceCurrent']->total); + self::assertSame(1, $report->interventionCount); + self::assertSame([['id' => 'intervention-a', 'number' => 1, 'name' => 'Annual campaign', 'status' => 'preparing', 'snapshotState' => 'live']], $report->dossiers); + } + + /** + * Method testForeignOrganizationSourcesAreRefused + * + * @access public + * + * @param bool $foreignContext whether the operational source belongs to another organization + * + * @return void + */ + #[DataProvider('foreignOrganizationCases')] + public function testForeignOrganizationSourcesAreRefused(bool $foreignContext): void + { + $context = $this->context(organizationId: $foreignContext ? 'other-organization' : 'organization-a'); + $view = $this->view($context, [], organizationId: $foreignContext ? 'organization-a' : 'other-organization'); + + $this->assertConflict($context, $view); + } + + /** + * Method foreignOrganizationCases + * + * @access public + * + * @return iterable every cross-module source is organization-scoped + */ + public static function foreignOrganizationCases(): iterable + { + yield 'operational context' => [true]; + yield 'financial view' => [false]; + } + + /** + * Method testMixedCurrencySourcesAreRefused + * + * @access public + * + * @param string $source source carrying another currency + * + * @return void + */ + #[DataProvider('mixedCurrencyCases')] + public function testMixedCurrencySourcesAreRefused(string $source): void + { + $context = $this->context(); + $view = $this->view($context, [$this->item('actual', '1.000000', currency: 'current' === $source ? 'USD' : 'EUR')], frozen: $this->snapshot([$this->item('historic', '1.000000', currency: 'frozen' === $source ? 'USD' : 'EUR')]), currency: 'view' === $source ? 'USD' : 'EUR'); + + $this->assertConflict($context, $view); + } + + /** + * Method mixedCurrencyCases + * + * @access public + * + * @return iterable source currency violations + */ + public static function mixedCurrencyCases(): iterable + { + yield 'current contribution' => ['current']; + yield 'frozen contribution' => ['frozen']; + yield 'financial view' => ['view']; + } + + /** + * Method testMissingFinancialViewFailsClosed + * + * @access public + * + * @return void + */ + public function testMissingFinancialViewFailsClosed(): void + { + $this->expectException(MaintenanceCostException::class); + $this->expectExceptionMessage('Maintenance cost resource not found.'); + new MaintenanceEconomicAggregator()->report($this->query(), 'EUR', [$this->context()], [], $this->procurement()); + } + + /** + * Method report + * + * @access private + * + * @param InterventionEconomicContext $context scoped work dossier + * @param list $items received current facts + * @param ?MaintenanceCostPlanning $planning current estimate + * @param ?MaintenanceCostSnapshot $frozen original publication facts + * @param ?ReadMaintenanceEconomicReportQuery $query selected destination scope + * + * @return MaintenanceEconomicReport actual report under test + */ + private function report(InterventionEconomicContext $context, array $items, ?MaintenanceCostPlanning $planning = null, ?MaintenanceCostSnapshot $frozen = null, ?ReadMaintenanceEconomicReportQuery $query = null): MaintenanceEconomicReport + { + return new MaintenanceEconomicAggregator()->report($query ?? $this->query(), 'EUR', [$context], [$context->id => $this->view($context, $items, $planning, $frozen)], $this->procurement()); + } + + /** + * Method context + * + * @access private + * + * @param string $status current work lifecycle + * @param 'available'|'snapshot_missing'|'live' $snapshotState retained identity provenance + * @param ?list $tasks explicit targets; absent uses two assets + * @param string $organizationId source owner + * + * @return InterventionEconomicContext operational source fixture + */ + private function context(string $status = 'preparing', string $snapshotState = 'live', ?array $tasks = null, string $organizationId = 'organization-a'): InterventionEconomicContext + { + return new InterventionEconomicContext('intervention-a', $organizationId, 1, 'Annual campaign', 'control', $status, 7, new DateTimeImmutable('2026-01-01T00:00:00Z'), null, null, 'published' === $status ? new DateTimeImmutable('2026-02-01T00:00:00Z') : null, 'published' === $status ? 'publication-a' : null, $snapshotState, 'available' === $snapshotState ? 1 : null, 'snapshot_missing' !== $snapshotState, ['id' => 'site-a', 'name' => 'Current root site'], ['id' => 'customer-a', 'name' => 'Current customer'], $tasks ?? [$this->task('task-a', 'equipment-a'), $this->task('task-b', 'equipment-b')]); + } + + /** + * Method task + * + * @access private + * + * @param string $id task identity + * @param string $equipmentId owned asset + * @param ?array{id:string,name:string} $site allocation site + * @param ?array{id:string,name:string} $customer allocation client + * + * @return InterventionPublishedWorkFact minimal captured work fixture + */ + private function task(string $id, string $equipmentId, ?array $site = null, ?array $customer = null): InterventionPublishedWorkFact + { + $site ??= ['id' => 'site-a', 'name' => 'Current root site']; + $customer ??= ['id' => 'customer-a', 'name' => 'Current customer']; + $identity = new InterventionEquipmentSnapshot($equipmentId, $equipmentId, 'ASSET-' . $equipmentId, 'extinguisher', null, null, null, 'facility-a', $site, $customer); + + return new InterventionPublishedWorkFact($id, 'control', 'completed', '/api/equipments/' . $equipmentId, null, $equipmentId, $site, $customer, $identity, null, true, 0, 0, null, null); + } + + /** + * Method item + * + * @access private + * + * @param string $id stable source identity + * @param ?string $amount exact valuation or unknown + * @param ?string $workItemId explicitly linked task + * @param ?string $equipmentId direct physical target + * @param string $kind source contribution category + * @param string $currency contribution currency + * @param ?array{identityState:string,equipment:?array{id:string,name:?string,assetReference:?string},site:?array{id:string,name:string},customer:?array{id:string,name:string}} $allocation captured private allocation overriding live labels + * + * @return MaintenanceCostItem source fixture + */ + private function item(string $id, ?string $amount, ?string $workItemId = null, ?string $equipmentId = null, string $kind = 'expense', string $currency = 'EUR', ?array $allocation = null): MaintenanceCostItem + { + return new MaintenanceCostItem($id, $kind, $workItemId, $id, 1, $amount, $currency, 'Contribution ' . $id, '2026-02-01T00:00:00Z', equipmentId: $equipmentId, allocation: $allocation); + } + + /** + * Method resource + * + * @access private + * + * @param ?string $taskId explicit allocation, absent for dossier-wide estimate + * @param ?string $amount exact planned valuation or unknown + * + * @return array{workItemId:?string,kind:string,description:string,quantity:?string,unitCost:?string,estimatedMinutes:?int,amount:?string} public prepared resource + */ + private function resource(?string $taskId, ?string $amount): array + { + return ['workItemId' => $taskId, 'kind' => 'external', 'description' => 'Planned service', 'quantity' => null, 'unitCost' => null, 'estimatedMinutes' => null, 'amount' => $amount]; + } + + /** + * Method view + * + * @access private + * + * @param InterventionEconomicContext $context originating work + * @param list $items current facts + * @param ?MaintenanceCostPlanning $planning prepared resources + * @param ?MaintenanceCostSnapshot $frozen published original + * @param ?string $organizationId override for isolation refusal + * @param string $currency configured finance currency + * + * @return MaintenanceCostView private source fixture + */ + private function view(InterventionEconomicContext $context, array $items, ?MaintenanceCostPlanning $planning = null, ?MaintenanceCostSnapshot $frozen = null, ?string $organizationId = null, string $currency = 'EUR'): MaintenanceCostView + { + return new MaintenanceCostView($context->id, $organizationId ?? $context->organizationId, $currency, $planning ?? new MaintenanceCostPlanning(), $this->totals($items), $frozen); + } + + /** + * Method snapshot + * + * @access private + * + * @param list $items original received facts + * @param ?MaintenanceCostPlanning $planning original prepared resources + * + * @return MaintenanceCostSnapshot immutable source fixture + */ + private function snapshot(array $items, ?MaintenanceCostPlanning $planning = null): MaintenanceCostSnapshot + { + return new MaintenanceCostSnapshot(1, '2026-02-01T00:00:00Z', 'publication-a', 7, 'EUR', $this->totals($items), $planning); + } + + /** + * Method totals + * + * @access private + * + * @param list $items literal exact source values + * + * @return MaintenanceCostTotals consistent source totals independent of allocation + */ + private function totals(array $items): MaintenanceCostTotals + { + $sum = '0.000000'; + $complete = true; + foreach ($items as $item) { + if (null === $item->amount) { + $complete = false; + } else { + if (!is_numeric($item->amount)) { + self::fail('Cost fixture must retain a literal exact decimal.'); + } + $sum = bcadd($sum, $item->amount, 6); + } + } + + return new MaintenanceCostTotals($complete ? $sum : null, $sum, $complete, $items); + } + + /** + * Method query + * + * @access private + * + * @param string $groupBy selected economic destination + * @param ?string $siteId retained site filter + * @param ?string $customerId retained client filter + * @param ?string $equipmentId retained equipment filter + * @param int $page requested page + * @param int $itemsPerPage allocated rows per page + * + * @return ReadMaintenanceEconomicReportQuery report request fixture + */ + private function query(string $groupBy = 'equipment', ?string $siteId = null, ?string $customerId = null, ?string $equipmentId = null, int $page = 1, int $itemsPerPage = 30): ReadMaintenanceEconomicReportQuery + { + return new ReadMaintenanceEconomicReportQuery('actor-a', 'organization-a', '2026-01-01', '2026-12-31', $groupBy, $siteId, $customerId, $equipmentId, $page, $itemsPerPage); + } + + /** + * Method procurement + * + * @access private + * + * @return ProcurementEconomicOverview empty authorized purchase source + */ + private function procurement(): ProcurementEconomicOverview + { + $zero = new ProcurementEconomicAmount('0.000000', '0.000000', true); + + return new ProcurementEconomicOverview('organization-a', 'EUR', '2026-01-01T00:00:00Z', '2027-01-01T00:00:00Z', 0, 0, 0, $zero, $zero, $zero, $zero); + } + + /** + * Method row + * + * @access private + * + * @param MaintenanceEconomicReport $report actual allocated report + * @param string $id selected row identity + * + * @return MaintenanceEconomicRow matched destination + */ + private function row(MaintenanceEconomicReport $report, string $id): MaintenanceEconomicRow + { + foreach ($report->rows as $row) { + if ($row->id === $id) { + return $row; + } + } + self::fail('Missing economic destination ' . $id); + } + + /** + * Method assertConflict + * + * @access private + * + * @param InterventionEconomicContext $context potentially foreign source + * @param MaintenanceCostView $view potentially conflicting finance facts + * + * @return void + */ + private function assertConflict(InterventionEconomicContext $context, MaintenanceCostView $view): void + { + try { + new MaintenanceEconomicAggregator()->report($this->query(), 'EUR', [$context], [$context->id => $view], $this->procurement()); + self::fail('Conflicting economic source was accepted.'); + } catch (MaintenanceCostException $error) { + self::assertSame('maintenance_cost_conflict', $error->reason); + } + } + // #endregion +} diff --git a/tests/Unit/MaintenanceCost/Application/Service/MaintenanceEconomicDirectoryTest.php b/tests/Unit/MaintenanceCost/Application/Service/MaintenanceEconomicDirectoryTest.php new file mode 100644 index 000000000..f21e37e76 --- /dev/null +++ b/tests/Unit/MaintenanceCost/Application/Service/MaintenanceEconomicDirectoryTest.php @@ -0,0 +1,380 @@ +costs = $this->createStub(MaintenanceCostReadPort::class); + $this->store = $this->createStub(MaintenanceCostStorePort::class); + $this->inventory = $this->createStub(InventoryInterventionResourcesPort::class); + $this->equipment = $this->createStub(InterventionEquipmentSnapshotPort::class); + $this->scopes = $this->createStub(InterventionEconomicScopePort::class); + } + + public function testContributionLimitAccumulatesAcrossDossiersEvenAfterAMatch(): void + { + $this->store = $this->createMock(MaintenanceCostStorePort::class); + $this->store->expects(self::once())->method('economicInterventionIds')->with('org', null, null, 'equipment')->willReturn(['first', 'second', 'unread']); + $this->inventory = $this->createMock(InventoryInterventionResourcesPort::class); + $this->inventory->expects(self::once())->method('economicInterventionIds')->with('org', ['equipment'])->willReturn([]); + $matching = $this->item($this->allocation()); + $unallocated = $this->item(null); + $this->costs = $this->createMock(MaintenanceCostReadPort::class); + $this->costs->expects(self::exactly(2))->method('view')->willReturnCallback( + function (string $organizationId, string $interventionId) use ($matching, $unallocated): MaintenanceCostView { + self::assertSame('org', $organizationId); + self::assertContains($interventionId, ['first', 'second']); + + return $this->view( + 'first' === $interventionId ? [$matching, ...array_fill(0, 24999, $unallocated)] : array_fill(0, 25001, $unallocated), + interventionId: $interventionId, + ); + }, + ); + + try { + $this->directory()->matchingIds('org', null, null, 'equipment'); + self::fail('An aggregate financial directory contribution scope was accepted.'); + } catch (MaintenanceCostException $error) { + self::assertSame('maintenance_cost_invalid', $error->reason); + self::assertSame('The financial directory scope exceeds 50000 contributions; narrow its target filters.', $error->getMessage()); + } + } + + // #region Methods + public function testUnfilteredSelectionDoesNotReadCandidatesOrFinancialSources(): void + { + $costs = $this->createMock(MaintenanceCostReadPort::class); + $costs->expects(self::never())->method('view'); + $store = $this->createMock(MaintenanceCostStorePort::class); + $store->expects(self::never())->method('economicInterventionIds'); + $inventory = $this->createMock(InventoryInterventionResourcesPort::class); + $inventory->expects(self::never())->method('economicInterventionIds'); + $equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $equipment->expects(self::never())->method('equipmentIdsInFacilities'); + $scopes = $this->createMock(InterventionEconomicScopePort::class); + $scopes->expects(self::never())->method('facilityIds'); + + self::assertSame([], new MaintenanceEconomicDirectory($costs, $store, $inventory, $equipment, $scopes)->matchingIds('org', null, null, null)); + } + + public function testDirectMaterialMatchesWithoutAWorkItemAndDuplicateCandidatesAreReadOnce(): void + { + $this->store = $this->createMock(MaintenanceCostStorePort::class); + $this->store->expects(self::once())->method('economicInterventionIds')->with('org', null, null, 'equipment')->willReturn(['work']); + $this->inventory = $this->createMock(InventoryInterventionResourcesPort::class); + $this->inventory->expects(self::once())->method('economicInterventionIds')->with('org', ['equipment'])->willReturn(['work', 'work']); + $this->costs = $this->createMock(MaintenanceCostReadPort::class); + $this->costs->expects(self::once())->method('view')->with('org', 'work')->willReturn($this->view([ + $this->item($this->allocation(), 'material'), + ])); + $this->expectNoCurrentScopeLookup(); + + self::assertSame(['work'], $this->directory()->matchingIds('org', null, null, 'equipment')); + } + + public function testEquipmentExposesOnlyMinimalCurrentAndFrozenAllocationIdentities(): void + { + $live = $this->allocation(); + $captured = $this->allocation('original-site', 'original-client', state: 'captured'); + $captured['equipment'] = ['id' => 'equipment', 'name' => 'Original asset', 'assetReference' => 'ORIGINAL']; + $unknown = $this->allocation(null, null, 'historic-equipment', 'incomplete'); + $unknown['equipment'] = ['id' => 'historic-equipment', 'name' => null, 'assetReference' => null]; + $global = ['identityState' => 'live', 'equipment' => null, 'site' => null, 'customer' => null]; + $this->costs = $this->createMock(MaintenanceCostReadPort::class); + $this->costs->expects(self::once())->method('view')->with('org', 'work')->willReturn($this->view([ + $this->item($live), + $this->item($global), + $this->item(null), + ], [$this->item($live), $this->item($captured), $this->item($unknown)])); + + self::assertSame([ + ['id' => 'equipment', 'name' => 'Asset', 'assetReference' => 'ASSET-01', 'site' => ['id' => 'site', 'name' => 'Site site'], 'customer' => ['id' => 'client', 'name' => 'Client client']], + ['id' => 'equipment', 'name' => 'Original asset', 'assetReference' => 'ORIGINAL', 'site' => ['id' => 'original-site', 'name' => 'Site original-site'], 'customer' => ['id' => 'original-client', 'name' => 'Client original-client']], + ['id' => 'historic-equipment', 'name' => null, 'assetReference' => null, 'site' => null, 'customer' => null], + ], $this->directory()->equipment('org', 'work')); + } + + public function testCapturedTargetStillMatchesWhenTheEquipmentHasMovedOutOfCurrentScope(): void + { + $this->scopes = $this->createMock(InterventionEconomicScopePort::class); + $this->scopes->expects(self::once())->method('facilityIds')->with('org', 'original-site', 'original-client')->willReturn(['original-facility']); + $this->equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $this->equipment->expects(self::once())->method('equipmentIdsInFacilities')->with('org', ['original-facility'])->willReturn([]); + $this->store = $this->createMock(MaintenanceCostStorePort::class); + $this->store->expects(self::once())->method('economicInterventionIds')->with('org', 'original-site', 'original-client', 'equipment')->willReturn(['work']); + $this->inventory = $this->createMock(InventoryInterventionResourcesPort::class); + $this->inventory->expects(self::once())->method('economicInterventionIds')->with('org', [])->willReturn([]); + $this->costs = $this->createMock(MaintenanceCostReadPort::class); + $this->costs->expects(self::once())->method('view')->with('org', 'work')->willReturn($this->view([ + $this->item($this->allocation('current-site', 'current-client')), + ], [$this->item($this->allocation('original-site', 'original-client', state: 'captured'))])); + + self::assertSame(['work'], $this->directory()->matchingIds('org', 'original-site', 'original-client', 'equipment')); + } + + #[DataProvider('historicTargetCases')] + public function testUnknownHistoricSiteOrClientIsNotInferredFromCandidateReferences(?string $siteId, ?string $customerId): void + { + $this->scopes = $this->createMock(InterventionEconomicScopePort::class); + $this->scopes->expects(self::once())->method('facilityIds')->with('org', $siteId, $customerId)->willReturn(['current-facility']); + $this->equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $this->equipment->expects(self::once())->method('equipmentIdsInFacilities')->with('org', ['current-facility'])->willReturn(['equipment']); + $this->store = $this->createMock(MaintenanceCostStorePort::class); + $this->store->expects(self::once())->method('economicInterventionIds')->with('org', $siteId, $customerId, 'equipment')->willReturn(['work']); + $this->inventory = $this->createMock(InventoryInterventionResourcesPort::class); + $this->inventory->expects(self::once())->method('economicInterventionIds')->with('org', ['equipment'])->willReturn(['work']); + $unknown = $this->item($this->allocation(null, null, state: 'incomplete'), 'material'); + $this->costs = $this->createMock(MaintenanceCostReadPort::class); + $this->costs->expects(self::once())->method('view')->with('org', 'work')->willReturn($this->view([$unknown], [$unknown])); + + self::assertSame([], $this->directory()->matchingIds('org', $siteId, $customerId, 'equipment')); + } + + /** + * @return iterable missing historical scope dimensions + */ + public static function historicTargetCases(): iterable + { + yield 'site' => ['site', null]; + yield 'client' => [null, 'client']; + yield 'site and client' => ['site', 'client']; + } + + #[DataProvider('combinedTargetCases')] + public function testAllCombinedFiltersMustMatchTheSameContribution(bool $matchingContribution): void + { + $this->scopes = $this->createMock(InterventionEconomicScopePort::class); + $this->scopes->expects(self::once())->method('facilityIds')->with('org', 'site', 'client')->willReturn(['facility']); + $this->equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $this->equipment->expects(self::once())->method('equipmentIdsInFacilities')->with('org', ['facility'])->willReturn(['equipment']); + $this->store = $this->createMock(MaintenanceCostStorePort::class); + $this->store->expects(self::once())->method('economicInterventionIds')->with('org', 'site', 'client', 'equipment')->willReturn(['work']); + $this->inventory = $this->createMock(InventoryInterventionResourcesPort::class); + $this->inventory->expects(self::once())->method('economicInterventionIds')->with('org', ['equipment'])->willReturn([]); + $items = [ + $this->item($this->allocation('site', 'other-client')), + $this->item($this->allocation('other-site', 'client')), + $this->item($this->allocation('site', 'client', 'other-equipment')), + ]; + if ($matchingContribution) { + $items[] = $this->item($this->allocation()); + } + $this->costs = $this->createMock(MaintenanceCostReadPort::class); + $this->costs->expects(self::once())->method('view')->with('org', 'work')->willReturn($this->view($items)); + + self::assertSame($matchingContribution ? ['work'] : [], $this->directory()->matchingIds('org', 'site', 'client', 'equipment')); + } + + /** + * @return iterable independent targets versus one matching allocation + */ + public static function combinedTargetCases(): iterable + { + yield 'different contributions cannot combine' => [false]; + yield 'one contribution satisfies every target' => [true]; + } + + #[DataProvider('foreignViewCases')] + public function testForeignProjectionIsRejectedBeforeReturningMatchesOrIdentities(string $operation, string $organizationId, string $interventionId): void + { + $this->store->method('economicInterventionIds')->willReturn(['work']); + $this->inventory->method('economicInterventionIds')->willReturn([]); + $this->costs = $this->createMock(MaintenanceCostReadPort::class); + $this->costs->expects(self::once())->method('view')->with('org', 'work')->willReturn($this->view([ + $this->item($this->allocation()), + ], organizationId: $organizationId, interventionId: $interventionId)); + + try { + if ('matchingIds' === $operation) { + $this->directory()->matchingIds('org', null, null, 'equipment'); + } else { + $this->directory()->equipment('org', 'work'); + } + self::fail('A foreign financial directory projection was accepted.'); + } catch (MaintenanceCostException $error) { + self::assertSame('maintenance_cost_conflict', $error->reason); + self::assertSame('The financial directory source is outside the authorized organization.', $error->getMessage()); + } + } + + /** + * @return iterable source scope mismatches for both public operations + */ + public static function foreignViewCases(): iterable + { + yield 'matching foreign organization' => ['matchingIds', 'foreign-org', 'work']; + yield 'matching foreign intervention' => ['matchingIds', 'org', 'foreign-work']; + yield 'identities foreign organization' => ['equipment', 'foreign-org', 'work']; + yield 'identities foreign intervention' => ['equipment', 'org', 'foreign-work']; + } + + public function testMoreThanTenThousandCandidatesIsRejectedBeforeAnyFinancialRead(): void + { + $candidates = array_map(static fn (int $index): string => 'work-' . $index, range(1, 10001)); + $this->store = $this->createMock(MaintenanceCostStorePort::class); + $this->store->expects(self::once())->method('economicInterventionIds')->with('org', null, null, 'equipment')->willReturn($candidates); + $this->inventory = $this->createMock(InventoryInterventionResourcesPort::class); + $this->inventory->expects(self::once())->method('economicInterventionIds')->with('org', ['equipment'])->willReturn([]); + $this->costs = $this->createMock(MaintenanceCostReadPort::class); + $this->costs->expects(self::never())->method('view'); + + try { + $this->directory()->matchingIds('org', null, null, 'equipment'); + self::fail('An oversized financial directory candidate scope was accepted.'); + } catch (MaintenanceCostException $error) { + self::assertSame('maintenance_cost_invalid', $error->reason); + self::assertSame('The financial directory scope exceeds 10000 interventions; narrow its target filters.', $error->getMessage()); + } + } + + public function testExactlyTenThousandUniqueCandidatesRemainsAcceptedAfterDeduplication(): void + { + $candidates = array_map(static fn (int $index): string => 'work-' . $index, range(1, 10000)); + $this->store = $this->createMock(MaintenanceCostStorePort::class); + $this->store->expects(self::once())->method('economicInterventionIds')->with('org', null, null, 'equipment')->willReturn($candidates); + $this->inventory = $this->createMock(InventoryInterventionResourcesPort::class); + $this->inventory->expects(self::once())->method('economicInterventionIds')->with('org', ['equipment'])->willReturn($candidates); + $this->costs = $this->createMock(MaintenanceCostReadPort::class); + $this->costs->expects(self::exactly(10000))->method('view')->with('org', self::anything())->willReturnCallback( + fn (string $organizationId, string $interventionId): MaintenanceCostView => $this->view([], organizationId: $organizationId, interventionId: $interventionId), + ); + + self::assertSame([], $this->directory()->matchingIds('org', null, null, 'equipment')); + } + + #[DataProvider('contributionLimitCases')] + public function testCurrentAndFrozenContributionsShareOneExplicitCalculationLimit(int $frozenCount, bool $overLimit): void + { + $this->store->method('economicInterventionIds')->willReturn(['work']); + $this->inventory->method('economicInterventionIds')->willReturn([]); + $item = $this->item(null); + $this->costs = $this->createMock(MaintenanceCostReadPort::class); + $this->costs->expects(self::once())->method('view')->with('org', 'work')->willReturn( + $this->view(array_fill(0, 25000, $item), array_fill(0, $frozenCount, $item)), + ); + if (!$overLimit) { + self::assertSame([], $this->directory()->matchingIds('org', null, null, 'equipment')); + + return; + } + + try { + $this->directory()->matchingIds('org', null, null, 'equipment'); + self::fail('An oversized financial directory contribution scope was accepted.'); + } catch (MaintenanceCostException $error) { + self::assertSame('maintenance_cost_invalid', $error->reason); + self::assertSame('The financial directory scope exceeds 50000 contributions; narrow its target filters.', $error->getMessage()); + } + } + + /** + * @return iterable exact combined contribution boundary + */ + public static function contributionLimitCases(): iterable + { + yield 'exactly fifty thousand' => [25000, false]; + yield 'fifty thousand and one' => [25001, true]; + } + + private function expectNoCurrentScopeLookup(): void + { + $this->scopes = $this->createMock(InterventionEconomicScopePort::class); + $this->scopes->expects(self::never())->method('facilityIds'); + $this->equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $this->equipment->expects(self::never())->method('equipmentIdsInFacilities'); + } + + private function directory(): MaintenanceEconomicDirectory + { + return new MaintenanceEconomicDirectory($this->costs, $this->store, $this->inventory, $this->equipment, $this->scopes); + } + + /** + * @param ?string $siteId known root site identity + * @param ?string $customerId known internal client identity + * @param string $equipmentId known equipment identity + * @param string $state current or captured provenance + * + * @return array{identityState:string,equipment:array{id:string,name:string,assetReference:string},site:?array{id:string,name:string},customer:?array{id:string,name:string}} allocation fixture + */ + private function allocation(?string $siteId = 'site', ?string $customerId = 'client', string $equipmentId = 'equipment', string $state = 'live'): array + { + return [ + 'identityState' => $state, + 'equipment' => ['id' => $equipmentId, 'name' => 'Asset', 'assetReference' => 'ASSET-01'], + 'site' => null === $siteId ? null : ['id' => $siteId, 'name' => 'Site ' . $siteId], + 'customer' => null === $customerId ? null : ['id' => $customerId, 'name' => 'Client ' . $customerId], + ]; + } + + /** + * @param ?array{identityState:string,equipment:?array{id:string,name:?string,assetReference:?string},site:?array{id:string,name:string},customer:?array{id:string,name:string}} $allocation verified target identities or legacy absence + * @param string $kind financial source category + * + * @return MaintenanceCostItem private source carrying no work item + */ + private function item(?array $allocation, string $kind = 'expense'): MaintenanceCostItem + { + return new MaintenanceCostItem('fact', $kind, null, 'source', null, '0.000000', 'EUR', 'Private contribution body', '2026-10-07T12:00:00Z', equipmentId: $allocation['equipment']['id'] ?? null, allocation: $allocation); + } + + /** + * @param list $current current financial facts + * @param ?list $frozen original publication facts + * @param string $organizationId source owner + * @param string $interventionId source dossier identity + * + * @return MaintenanceCostView bounded private projection + */ + private function view(array $current, ?array $frozen = null, string $organizationId = 'org', string $interventionId = 'work'): MaintenanceCostView + { + $totals = new MaintenanceCostTotals('0.000000', '0.000000', true, $current); + $snapshot = null === $frozen ? null : new MaintenanceCostSnapshot(1, '2026-10-07T12:00:00Z', 'publication', 1, 'EUR', new MaintenanceCostTotals('0.000000', '0.000000', true, $frozen)); + + return new MaintenanceCostView($interventionId, $organizationId, 'EUR', new MaintenanceCostPlanning(), $totals, $snapshot); + } + // #endregion +} diff --git a/tests/Unit/MaintenanceCost/Application/UseCase/Currency/MaintenanceCurrencyHandlersTest.php b/tests/Unit/MaintenanceCost/Application/UseCase/Currency/MaintenanceCurrencyHandlersTest.php new file mode 100644 index 000000000..f7caaa989 --- /dev/null +++ b/tests/Unit/MaintenanceCost/Application/UseCase/Currency/MaintenanceCurrencyHandlersTest.php @@ -0,0 +1,74 @@ +createMock(MaintenanceCurrencyStorePort::class); + $store->expects(self::once())->method('synchronized')->with(self::ORG, self::isCallable())->willReturnCallback(static fn (string $org, callable $work): mixed => $work()); + $store->expects(self::once())->method('read')->with(self::ORG)->willReturn(new MaintenanceCurrencySnapshot(self::ORG, 'EUR', false)); + $store->expects(self::once())->method('save')->with(self::callback(static fn (MaintenanceCurrencySnapshot $value): bool => 'USD' === $value->currency && !$value->locked)); + $handler = new ConfigureMaintenanceCurrencyHandler($store, $this->access(true)); + self::assertSame('USD', $handler(new ConfigureMaintenanceCurrencyCommand(self::ORG, 'actor', 'USD'))->currency->currency); + } + + #[Test] + public function refusesLockedChangesWithoutSaving(): void + { + $store = $this->createMock(MaintenanceCurrencyStorePort::class); + $store->method('synchronized')->willReturnCallback(static fn (string $org, callable $work): mixed => $work()); + $store->method('read')->willReturn(new MaintenanceCurrencySnapshot(self::ORG, 'EUR', true)); + $store->expects(self::never())->method('save'); + $this->expectException(MaintenanceCostException::class); + (new ConfigureMaintenanceCurrencyHandler($store, $this->access(true)))(new ConfigureMaintenanceCurrencyCommand(self::ORG, 'actor', 'USD')); + } + + #[Test] + public function readsWithoutCreatingACurrencySetting(): void + { + $store = $this->createMock(MaintenanceCurrencyStorePort::class); + $store->expects(self::once())->method('read')->with(self::ORG)->willReturn(new MaintenanceCurrencySnapshot(self::ORG, 'EUR', false)); + $store->expects(self::never())->method('save'); + self::assertSame('EUR', (new ReadMaintenanceCurrencyHandler($store, $this->access(false)))(new ReadMaintenanceCurrencyQuery(self::ORG, 'actor'))->currency->currency); + } + + #[Test] + public function checksAccessBeforeCurrencyState(): void + { + $auth = $this->createMock(OrganizationAuthorizationPort::class); + $auth->expects(self::once())->method('resolveAccess')->willReturn(OrganizationAccessDecision::OUTSIDE_SCOPE); + $store = $this->createMock(MaintenanceCurrencyStorePort::class); + $store->expects(self::never())->method('synchronized'); + $store->expects(self::never())->method('read'); + $this->expectException(MaintenanceCostException::class); + $this->expectExceptionMessage('not found'); + (new ConfigureMaintenanceCurrencyHandler($store, new MaintenanceCostAccessGuard($auth)))(new ConfigureMaintenanceCurrencyCommand(self::ORG, 'actor', 'USD')); + } + + private function access(bool $write): MaintenanceCostAccessGuard + { + $auth = $this->createMock(OrganizationAuthorizationPort::class); + $auth->expects(self::once())->method('resolveAccess')->with('actor', self::ORG, $write ? 'organization.maintenance_cost.manage' : 'organization.maintenance_cost.read')->willReturn(OrganizationAccessDecision::GRANTED); + + return new MaintenanceCostAccessGuard($auth); + } +} diff --git a/tests/Unit/MaintenanceCost/Application/UseCase/Rate/CreateMaintenanceRateHandlerTest.php b/tests/Unit/MaintenanceCost/Application/UseCase/Rate/CreateMaintenanceRateHandlerTest.php new file mode 100644 index 000000000..64aae41bc --- /dev/null +++ b/tests/Unit/MaintenanceCost/Application/UseCase/Rate/CreateMaintenanceRateHandlerTest.php @@ -0,0 +1,120 @@ +store(); + $store->expects(self::once())->method('findByClientId')->with(self::ORG, self::CLIENT)->willReturn(null); + $store->expects(self::once())->method('findByEffectiveDate')->with(self::ORG, self::MEMBER, '2026-01-01')->willReturn(null); + $store->expects(self::once())->method('append')->with(self::ORG, self::CLIENT, self::callback(static fn (MaintenanceRateSnapshot $rate): bool => self::RATE === $rate->id && '42.000000' === $rate->hourlyAmount && 'EUR' === $rate->currency)); + $ids = $this->createMock(UuidGeneratorPort::class); + $ids->expects(self::once())->method('generate')->willReturn(self::RATE); + $result = $this->handler($store, $ids)($this->command('42')); + self::assertFalse($result->replayed); + self::assertSame(self::RATE, $result->rate->id); + } + + #[Test] + public function stableReplayKeepsItsOriginalRateIdentity(): void + { + $store = $this->store(); + $existing = new MaintenanceRateSnapshot(self::RATE, self::MEMBER, '42.000000', 'EUR', '2026-01-01'); + $store->method('findByClientId')->willReturn($existing); + $store->expects(self::never())->method('append'); + $store->expects(self::never())->method('findByEffectiveDate'); + $ids = $this->createMock(UuidGeneratorPort::class); + $ids->expects(self::never())->method('generate'); + $result = $this->handler($store, $ids)($this->command('42.0')); + self::assertTrue($result->replayed); + self::assertSame($existing, $result->rate); + } + + #[Test] + public function conflictingReplayCannotReplaceAnAmount(): void + { + $store = $this->store(); + $store->method('findByClientId')->willReturn(new MaintenanceRateSnapshot(self::RATE, self::MEMBER, '42.000000', 'EUR', '2026-01-01')); + $store->expects(self::never())->method('append'); + $this->expectException(MaintenanceCostException::class); + $this->expectExceptionMessage('already used'); + $this->handler($store, $this->createStub(UuidGeneratorPort::class))($this->command('43')); + } + + #[Test] + public function anotherRequestCannotReplaceTheSameEffectiveDate(): void + { + $store = $this->store(); + $store->method('findByClientId')->willReturn(null); + $store->method('findByEffectiveDate')->willReturn(new MaintenanceRateSnapshot(self::RATE, self::MEMBER, '42.000000', 'EUR', '2026-01-01')); + $store->expects(self::never())->method('append'); + $this->expectException(MaintenanceCostException::class); + $this->expectExceptionMessage('already exists'); + $this->handler($store, $this->createStub(UuidGeneratorPort::class))($this->command('43')); + } + + #[Test] + public function rejectsAnotherOrganizationsMemberBeforePersistence(): void + { + $store = $this->createMock(MaintenanceRateStorePort::class); + $store->expects(self::never())->method('synchronized'); + $this->expectException(MaintenanceCostException::class); + $this->expectExceptionMessage('not found'); + $this->handler($store, $this->createStub(UuidGeneratorPort::class), false)($this->command('42')); + } + + private function command(string $amount): CreateMaintenanceRateCommand + { + return new CreateMaintenanceRateCommand(self::ORG, 'actor', self::MEMBER, $amount, '2026-01-01', self::CLIENT); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject&MaintenanceRateStorePort + */ + private function store(): MaintenanceRateStorePort + { + $store = $this->createMock(MaintenanceRateStorePort::class); + $store->method('synchronized')->willReturnCallback(static fn (string $org, callable $work): mixed => $work()); + + return $store; + } + + private function handler(MaintenanceRateStorePort $store, UuidGeneratorPort $ids, bool $scoped = true): CreateMaintenanceRateHandler + { + $auth = $this->createMock(OrganizationAuthorizationPort::class); + $auth->expects(self::once())->method('resolveAccess')->with('actor', self::ORG, 'organization.maintenance_cost.manage')->willReturn(OrganizationAccessDecision::GRANTED); + $workforce = $this->createMock(OrganizationWorkforceDirectoryPort::class); + $workforce->expects(self::once())->method('profiles')->with(self::ORG, [self::MEMBER])->willReturn($scoped ? [self::MEMBER => new OrganizationWorkforceMemberProfile(self::MEMBER, 'Technician', null, [])] : []); + $currency = $this->createStub(MaintenanceCurrencyPort::class); + $currency->method('forOrganization')->willReturn('EUR'); + + return new CreateMaintenanceRateHandler($store, $currency, new MaintenanceCostAccessGuard($auth), $workforce, $ids); + } +} diff --git a/tests/Unit/MaintenanceCost/Application/UseCase/Rate/ListMaintenanceRatesHandlerTest.php b/tests/Unit/MaintenanceCost/Application/UseCase/Rate/ListMaintenanceRatesHandlerTest.php new file mode 100644 index 000000000..732f60dfb --- /dev/null +++ b/tests/Unit/MaintenanceCost/Application/UseCase/Rate/ListMaintenanceRatesHandlerTest.php @@ -0,0 +1,47 @@ +createMock(OrganizationAuthorizationPort::class); + $auth->expects(self::once())->method('resolveAccess')->with('actor', self::ORG, 'organization.maintenance_cost.read')->willReturn(OrganizationAccessDecision::GRANTED); + $rate = new MaintenanceRateSnapshot('id', 'member', '1.000000', 'EUR', '2026-01-01'); + $store = $this->createMock(MaintenanceRateStorePort::class); + $store->expects(self::once())->method('list')->with(self::ORG, 10, 10, null)->willReturn([$rate]); + $store->expects(self::once())->method('count')->with(self::ORG, null)->willReturn(11); + $result = (new ListMaintenanceRatesHandler($store, new MaintenanceCostAccessGuard($auth), $this->createStub(OrganizationWorkforceDirectoryPort::class)))(new ListMaintenanceRatesQuery(self::ORG, 'actor', page: 2, itemsPerPage: 10)); + self::assertSame([$rate], $result->items); + self::assertSame(11, $result->total); + } + + #[Test] + public function operationalMembershipCannotReadPrivateRates(): void + { + $auth = $this->createMock(OrganizationAuthorizationPort::class); + $auth->expects(self::once())->method('resolveAccess')->willReturn(OrganizationAccessDecision::MISSING_PERMISSION); + $store = $this->createMock(MaintenanceRateStorePort::class); + $store->expects(self::never())->method('list'); + $this->expectException(MaintenanceCostException::class); + $this->expectExceptionMessage('Missing'); + (new ListMaintenanceRatesHandler($store, new MaintenanceCostAccessGuard($auth), $this->createStub(OrganizationWorkforceDirectoryPort::class)))(new ListMaintenanceRatesQuery(self::ORG, 'actor')); + } +} diff --git a/tests/Unit/MaintenanceCost/Application/UseCase/Reporting/MaintenanceEconomicHandlersTest.php b/tests/Unit/MaintenanceCost/Application/UseCase/Reporting/MaintenanceEconomicHandlersTest.php new file mode 100644 index 000000000..bca01bb06 --- /dev/null +++ b/tests/Unit/MaintenanceCost/Application/UseCase/Reporting/MaintenanceEconomicHandlersTest.php @@ -0,0 +1,147 @@ + + */ + public static function denials(): iterable + { + yield 'missing financial permission' => [OrganizationAccessDecision::MISSING_PERMISSION, 'maintenance_cost_access_denied']; + yield 'foreign organization' => [OrganizationAccessDecision::OUTSIDE_SCOPE, 'maintenance_cost_not_found']; + } + + #[DataProvider('denials')] + public function testDenialNeverReadsOperationalOrFinancialSources(OrganizationAccessDecision $decision, string $reason): void + { + $work = $this->createMock(InterventionPublicationFactsPort::class); + $work->expects(self::never())->method('economicWindow'); + $costs = $this->createMock(MaintenanceCostReadPort::class); + $costs->expects(self::never())->method('view'); + $procurement = $this->createMock(ProcurementEconomicOverviewPort::class); + $procurement->expects(self::never())->method('overview'); + $handler = new ReadMaintenanceEconomicReportHandler($this->access($decision), $work, $costs, $this->currencies(), $procurement, new MaintenanceEconomicAggregator()); + + try { + $handler(new ReadMaintenanceEconomicReportQuery('actor', self::ORG, '2026-10-01', '2026-10-31')); + self::fail('Private reporting must deny this actor.'); + } catch (MaintenanceCostException $error) { + self::assertSame($reason, $error->reason); + } + } + + public function testOversizedDirectoryCountRefusesInsteadOfTruncatingTotals(): void + { + $work = $this->createMock(InterventionPublicationFactsPort::class); + $work->expects(self::once())->method('economicWindow')->willReturn(new InterventionEconomicContextPage([], 501, 1, 501)); + $costs = $this->createMock(MaintenanceCostReadPort::class); + $costs->expects(self::never())->method('view'); + $procurement = $this->createMock(ProcurementEconomicOverviewPort::class); + $procurement->expects(self::never())->method('overview'); + $handler = new ReadMaintenanceEconomicReportHandler($this->access(OrganizationAccessDecision::GRANTED), $work, $costs, $this->currencies(), $procurement, new MaintenanceEconomicAggregator()); + $this->expectException(MaintenanceCostException::class); + $this->expectExceptionMessage('exceeds 500 interventions'); + $handler(new ReadMaintenanceEconomicReportQuery('actor', self::ORG, '2026-10-01', '2026-10-31')); + } + + public function testExactly500DossiersArePermittedWithoutPartialKnownTotals(): void + { + $work = $this->createStub(InterventionPublicationFactsPort::class); + $contexts = []; + for ($index = 1; $index <= 500; ++$index) { + $contexts[] = new InterventionEconomicContext('work-' . $index, self::ORG, $index, 'Repair ' . $index, 'corrective_maintenance', 'draft', 0, new DateTimeImmutable('2026-10-07T12:00:00Z'), null, null, null, null, 'live', null, true, null, null, []); + } + $work->method('economicWindow')->willReturn(new InterventionEconomicContextPage($contexts, 500, 1, 501)); + $costs = $this->createStub(MaintenanceCostReadPort::class); + $costs->method('view')->willReturnCallback(static fn (string $org, string $id): MaintenanceCostView => new MaintenanceCostView($id, $org, 'EUR', new MaintenanceCostPlanning(), new MaintenanceCostTotals('10.000000', '10.000000', true, [new MaintenanceCostItem('expense-' . $id, 'expense', null, $id, null, '10.000000', 'EUR', 'Repair', '2026-10-07T12:00:00Z')]), null)); + $procurement = $this->createStub(ProcurementEconomicOverviewPort::class); + $zero = new ProcurementEconomicAmount('0.000000', '0.000000', true); + $procurement->method('overview')->willReturn(new ProcurementEconomicOverview(self::ORG, 'EUR', '2026-10-01T00:00:00Z', '2026-11-01T00:00:00Z', 0, 0, 0, $zero, $zero, $zero, $zero)); + $handler = new ReadMaintenanceEconomicReportHandler($this->access(OrganizationAccessDecision::GRANTED), $work, $costs, $this->currencies(), $procurement, new MaintenanceEconomicAggregator()); + $report = $handler(new ReadMaintenanceEconomicReportQuery('actor', self::ORG, '2026-10-01', '2026-10-31'))->report; + self::assertTrue($report->reconciliation['reconciled']); + self::assertSame('5000.000000', $report->current->total); + self::assertSame(500, $report->interventionCount); + } + + public function testDirectoryScopeAndSearchAreForwardedUnderFinanceReadOnly(): void + { + $work = $this->createMock(InterventionPublicationFactsPort::class); + $work->expects(self::once())->method('economicPage')->with(self::ORG, 2, 10, 'repair', null, null, null, null, null, [])->willReturn(new InterventionEconomicContextPage([], 15, 2, 10)); + $result = new ListMaintenanceEconomicDossiersHandler($this->access(OrganizationAccessDecision::GRANTED), $work, $this->directory())(new ListMaintenanceEconomicDossiersQuery('actor', self::ORG, 2, 10, 'repair')); + self::assertSame(15, $result->page->totalItems); + self::assertSame(2, $result->page->page); + } + + public function testDirectoryDenialDoesNotResolveAnyNames(): void + { + $work = $this->createMock(InterventionPublicationFactsPort::class); + $work->expects(self::never())->method('economicPage'); + $this->expectException(MaintenanceCostException::class); + new ListMaintenanceEconomicDossiersHandler($this->access(OrganizationAccessDecision::MISSING_PERMISSION), $work, $this->directory())(new ListMaintenanceEconomicDossiersQuery('actor', self::ORG)); + } + + private function directory(): MaintenanceEconomicDirectory + { + $costs = $this->createMock(MaintenanceCostReadPort::class); + $costs->expects(self::never())->method('view'); + $store = $this->createMock(MaintenanceCostStorePort::class); + $store->expects(self::never())->method('economicInterventionIds'); + $inventory = $this->createMock(InventoryInterventionResourcesPort::class); + $inventory->expects(self::never())->method('economicInterventionIds'); + $equipment = $this->createMock(InterventionEquipmentSnapshotPort::class); + $equipment->expects(self::never())->method('equipmentIdsInFacilities'); + $scopes = $this->createMock(InterventionEconomicScopePort::class); + $scopes->expects(self::never())->method('facilityIds'); + + return new MaintenanceEconomicDirectory($costs, $store, $inventory, $equipment, $scopes); + } + + private function access(OrganizationAccessDecision $decision): MaintenanceCostAccessGuard + { + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::once())->method('resolveAccess')->with('actor', self::ORG, 'organization.maintenance_cost.read')->willReturn($decision); + + return new MaintenanceCostAccessGuard($authorization); + } + + private function currencies(): MaintenanceCurrencyPort + { + $currency = $this->createStub(MaintenanceCurrencyPort::class); + $currency->method('forOrganization')->willReturn('EUR'); + + return $currency; + } +} diff --git a/tests/Unit/MaintenanceCost/Domain/Model/Currency/MaintenanceCurrencyTest.php b/tests/Unit/MaintenanceCost/Domain/Model/Currency/MaintenanceCurrencyTest.php new file mode 100644 index 000000000..2cf1265bc --- /dev/null +++ b/tests/Unit/MaintenanceCost/Domain/Model/Currency/MaintenanceCurrencyTest.php @@ -0,0 +1,36 @@ +configure('USD')->currency); + self::assertSame('EUR', new MaintenanceCurrency('EUR', true)->configure('EUR')->currency); + } + + #[Test] + public function refusesToConvertAlreadyCapturedFacts(): void + { + $this->expectException(MaintenanceCostException::class); + $this->expectExceptionMessage('locked'); + new MaintenanceCurrency('EUR', true)->configure('USD'); + } + + #[Test] + public function rejectsMalformedCurrencyCodes(): void + { + $this->expectException(MaintenanceCostException::class); + new MaintenanceCurrency('eu'); + } +} diff --git a/tests/Unit/MaintenanceCost/Domain/Model/Rate/MaintenanceHourlyRateTest.php b/tests/Unit/MaintenanceCost/Domain/Model/Rate/MaintenanceHourlyRateTest.php new file mode 100644 index 000000000..056a2cfef --- /dev/null +++ b/tests/Unit/MaintenanceCost/Domain/Model/Rate/MaintenanceHourlyRateTest.php @@ -0,0 +1,46 @@ +hourlyAmount); + self::assertSame('0.000000', new MaintenanceHourlyRate(self::MEMBER, '0', '2026-01-01', self::CLIENT)->hourlyAmount); + } + + /** + * @return iterable + */ + public static function invalidRates(): iterable + { + yield 'negative' => [self::MEMBER, '-1', '2026-01-01', self::CLIENT]; + yield 'excess precision' => [self::MEMBER, '1.0000001', '2026-01-01', self::CLIENT]; + yield 'scientific notation' => [self::MEMBER, '1e2', '2026-01-01', self::CLIENT]; + yield 'nonexistent calendar date' => [self::MEMBER, '1', '2026-02-29', self::CLIENT]; + yield 'invalid member identity' => ['member', '1', '2026-01-01', self::CLIENT]; + yield 'invalid client identity' => [self::MEMBER, '1', '2026-01-01', 'client']; + } + + #[Test] + #[DataProvider('invalidRates')] + public function refusesInvalidParameters(string $member, string $amount, string $date, string $client): void + { + $this->expectException(MaintenanceCostException::class); + new MaintenanceHourlyRate($member, $amount, $date, $client); + } +} diff --git a/tests/Unit/MaintenanceCost/Domain/Service/MaintenanceCostCalculatorTest.php b/tests/Unit/MaintenanceCost/Domain/Service/MaintenanceCostCalculatorTest.php new file mode 100644 index 000000000..242f2212c --- /dev/null +++ b/tests/Unit/MaintenanceCost/Domain/Service/MaintenanceCostCalculatorTest.php @@ -0,0 +1,44 @@ +timeAmount('900000000000000000.000000', 120); + self::assertSame('1800000000000000000.000000', $amount); + self::assertSame(['total' => '1800000000000000000.000001', 'knownTotal' => '1800000000000000000.000001', 'complete' => true], $calculator->total([$amount, '0.000001'])); + self::assertSame(['total' => null, 'knownTotal' => $amount, 'complete' => false], $calculator->total([$amount, null])); + } + + public function testHourlyRateIsMultipliedByAllMinutesBeforeOneDivision(): void + { + $calculator = new MaintenanceCostCalculator(); + self::assertSame('0.000580', $calculator->timeAmount('0.000029', 1200)); + self::assertSame('59.000001', $calculator->timeAmount('60.000001', 59)); + self::assertSame('1.666667', $calculator->timeAmount('100.000001', 1)); + } + + public function testUnknownAmountDoesNotBecomeZeroOrACompleteSubtotal(): void + { + self::assertSame(['total' => null, 'knownTotal' => '0.300000', 'complete' => false], new MaintenanceCostCalculator()->total(['0.100000', null, '0.200000'])); + } + + public function testSignedCorrectionsAndLargeValuesKeepExactPrecision(): void + { + self::assertSame(['total' => '99999999999999999.000001', 'knownTotal' => '99999999999999999.000001', 'complete' => true], new MaintenanceCostCalculator()->total(['99999999999999999.100001', '-0.100000'])); + } + + public function testAnEmptyCostContainsAKnownZero(): void + { + self::assertSame(['total' => '0.000000', 'knownTotal' => '0.000000', 'complete' => true], new MaintenanceCostCalculator()->total([])); + } +} diff --git a/tests/Unit/MaintenanceCost/Domain/ValueObject/MaintenanceEconomicWindowTest.php b/tests/Unit/MaintenanceCost/Domain/ValueObject/MaintenanceEconomicWindowTest.php new file mode 100644 index 000000000..16f9ce3db --- /dev/null +++ b/tests/Unit/MaintenanceCost/Domain/ValueObject/MaintenanceEconomicWindowTest.php @@ -0,0 +1,153 @@ +from->format('c')); + self::assertSame('2026-10-26T00:00:00+00:00', $window->to->format('c')); + self::assertSame('UTC', $window->from->getTimezone()->getName()); + self::assertSame('UTC', $window->to->getTimezone()->getName()); + self::assertSame(86400, $window->to->getTimestamp() - $window->from->getTimestamp()); + } + + /** + * Method testLeapDayAndYearBoundaryAreRetained + * + * @access public + * + * @param string $from valid inclusive start + * @param string $to valid inclusive final day + * @param string $exclusiveEnd exact following calendar day + * + * @return void + */ + #[DataProvider('validBoundaryCases')] + public function testLeapDayAndYearBoundaryAreRetained(string $from, string $to, string $exclusiveEnd): void + { + $window = MaintenanceEconomicWindow::fromDates($from, $to); + + self::assertSame($from, $window->from->format('Y-m-d')); + self::assertSame($exclusiveEnd, $window->to->format('Y-m-d')); + } + + /** + * Method validBoundaryCases + * + * @access public + * + * @return iterable calendar boundaries which must remain exact + */ + public static function validBoundaryCases(): iterable + { + yield 'leap day' => ['2024-02-29', '2024-02-29', '2024-03-01']; + yield 'year boundary' => ['2025-12-31', '2026-01-01', '2026-01-02']; + yield '366 leap-year days' => ['2024-01-01', '2024-12-31', '2025-01-01']; + yield '366 days across years' => ['2025-01-01', '2026-01-01', '2026-01-02']; + } + + /** + * Method testInvalidNormalizedOrNonCalendarDatesAreRefused + * + * @access public + * + * @param string $date invalid input which PHP must not normalize silently + * + * @return void + */ + #[DataProvider('invalidDateCases')] + public function testInvalidNormalizedOrNonCalendarDatesAreRefused(string $date): void + { + try { + MaintenanceEconomicWindow::fromDates($date, $date); + self::fail('Invalid report date was accepted.'); + } catch (MaintenanceCostException $error) { + self::assertSame('maintenance_cost_invalid', $error->reason); + self::assertSame('Report dates must use valid YYYY-MM-DD calendar dates.', $error->getMessage()); + } + } + + /** + * Method invalidDateCases + * + * @access public + * + * @return iterable strict invalid calendar values + */ + public static function invalidDateCases(): iterable + { + yield 'non-leap February 29' => ['2025-02-29']; + yield 'April 31' => ['2026-04-31']; + yield 'month 13' => ['2026-13-01']; + yield 'month zero' => ['2026-00-01']; + yield 'day zero' => ['2026-01-00']; + yield 'unpadded month' => ['2026-1-01']; + yield 'unpadded day' => ['2026-01-1']; + yield 'leading whitespace' => [' 2026-01-01']; + yield 'timestamp' => ['2026-01-01T00:00:00Z']; + yield 'empty' => ['']; + } + + /** + * Method testInvertedAnd367DayWindowsAreRefused + * + * @access public + * + * @param string $from valid start beyond the bounded range + * @param string $to valid end beyond the bounded range + * + * @return void + */ + #[DataProvider('invalidRangeCases')] + public function testInvertedAnd367DayWindowsAreRefused(string $from, string $to): void + { + try { + MaintenanceEconomicWindow::fromDates($from, $to); + self::fail('Invalid report window was accepted.'); + } catch (MaintenanceCostException $error) { + self::assertSame('maintenance_cost_invalid', $error->reason); + self::assertSame('Use an ordered report window of at most 366 calendar days.', $error->getMessage()); + } + } + + /** + * Method invalidRangeCases + * + * @access public + * + * @return iterable inverted dates and exact oversized windows + */ + public static function invalidRangeCases(): iterable + { + yield 'inverted' => ['2026-10-07', '2026-10-06']; + yield '367 leap-year days' => ['2024-01-01', '2025-01-01']; + yield '367 days across years' => ['2025-01-01', '2026-01-02']; + } + // #endregion +} diff --git a/tests/Unit/MaintenanceCost/Infrastructure/Adapter/Currency/MaintenanceCurrencyAdapterTest.php b/tests/Unit/MaintenanceCost/Infrastructure/Adapter/Currency/MaintenanceCurrencyAdapterTest.php new file mode 100644 index 000000000..e273bfb63 --- /dev/null +++ b/tests/Unit/MaintenanceCost/Infrastructure/Adapter/Currency/MaintenanceCurrencyAdapterTest.php @@ -0,0 +1,25 @@ +createMock(Connection::class); + $connection->expects(self::once())->method('isTransactionActive')->willReturn(false); + $connection->expects(self::never())->method('executeStatement'); + $this->expectException(LogicException::class); + new MaintenanceCurrencyAdapter($connection)->lock('650e8400-e29b-41d4-a716-448040000001'); + } +} diff --git a/tests/Unit/MaintenanceCost/Infrastructure/Adapter/Rate/MaintenanceRateAdapterTest.php b/tests/Unit/MaintenanceCost/Infrastructure/Adapter/Rate/MaintenanceRateAdapterTest.php new file mode 100644 index 000000000..91eebc029 --- /dev/null +++ b/tests/Unit/MaintenanceCost/Infrastructure/Adapter/Rate/MaintenanceRateAdapterTest.php @@ -0,0 +1,26 @@ +createMock(Connection::class); + $connection->expects(self::once())->method('isTransactionActive')->willReturn(false); + $connection->expects(self::never())->method('insert'); + $this->expectException(LogicException::class); + new MaintenanceRateAdapter($connection)->append('organization', 'client', new MaintenanceRateSnapshot('rate', 'member', '1.000000', 'EUR', '2026-01-01')); + } +} diff --git a/tests/Unit/MaintenanceCost/Presentation/Api/Currency/MaintenanceCurrencyTransportTest.php b/tests/Unit/MaintenanceCost/Presentation/Api/Currency/MaintenanceCurrencyTransportTest.php new file mode 100644 index 000000000..59d7e731e --- /dev/null +++ b/tests/Unit/MaintenanceCost/Presentation/Api/Currency/MaintenanceCurrencyTransportTest.php @@ -0,0 +1,57 @@ +createStub(CurrentActorPort::class); + $actor->method('userId')->willReturn('actor'); + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ConfigureMaintenanceCurrencyCommand $command): bool => 'org' === $command->organizationId && 'actor' === $command->actorUserId && 'USD' === $command->currency))->willReturn(new ConfigureMaintenanceCurrencyResult(new MaintenanceCurrencySnapshot('org', 'USD', false))); + $input = new ConfigureMaintenanceCurrencyInput(); + $input->currency = 'USD'; + $output = new ConfigureMaintenanceCurrencyProcessor($bus, $actor)->process($input, new Patch(), ['organizationId' => 'org']); + self::assertSame('USD', $output->currency); + self::assertFalse($output->locked); + } + + #[Test] + public function providesTheReadOnlyLockState(): void + { + $actor = $this->createStub(CurrentActorPort::class); + $actor->method('userId')->willReturn('actor'); + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->with(self::callback(static fn (ReadMaintenanceCurrencyQuery $query): bool => 'org' === $query->organizationId && 'actor' === $query->actorUserId))->willReturn(new ReadMaintenanceCurrencyResult(new MaintenanceCurrencySnapshot('org', 'EUR', true))); + self::assertTrue(new MaintenanceCurrencyProvider($bus, $actor)->provide(new Get(), ['organizationId' => 'org'])->locked); + } + + #[Test] + public function unauthenticatedReadNeverDispatchesAQuery(): void + { + $actor = $this->createStub(CurrentActorPort::class); + $actor->method('userId')->willReturn(null); + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::never())->method('ask'); + $this->expectException(AccessDeniedHttpException::class); + new MaintenanceCurrencyProvider($bus, $actor)->provide(new Get(), ['organizationId' => 'org']); + } +} diff --git a/tests/Unit/MaintenanceCost/Presentation/Api/Rate/MaintenanceRateTransportTest.php b/tests/Unit/MaintenanceCost/Presentation/Api/Rate/MaintenanceRateTransportTest.php new file mode 100644 index 000000000..dacffb9dc --- /dev/null +++ b/tests/Unit/MaintenanceCost/Presentation/Api/Rate/MaintenanceRateTransportTest.php @@ -0,0 +1,57 @@ +createStub(CurrentActorPort::class); + $actor->method('userId')->willReturn('actor'); + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (CreateMaintenanceRateCommand $command): bool => 'org' === $command->organizationId && '42.000001' === $command->hourlyAmount && 'client' === $command->clientId))->willReturn(new CreateMaintenanceRateResult(new MaintenanceRateSnapshot('rate', 'member', '42.000001', 'EUR', '2026-01-01'), true)); + $input = new CreateMaintenanceRateInput(); + $input->memberId = 'member'; + $input->clientId = 'client'; + $input->hourlyAmount = '42.000001'; + $input->effectiveFrom = '2026-01-01'; + $output = new CreateMaintenanceRateProcessor($bus, $actor)->process($input, new Post(), ['organizationId' => 'org']); + self::assertSame('42.000001', $output->hourlyAmount); + self::assertTrue($output->replayed); + } + + #[Test] + public function paginatesRatesWithTheExplicitMemberFilter(): void + { + $actor = $this->createStub(CurrentActorPort::class); + $actor->method('userId')->willReturn('actor'); + $requests = new RequestStack(); + $requests->push(Request::create('/rates?memberId=member&page=2&itemsPerPage=10')); + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->with(self::callback(static fn (ListMaintenanceRatesQuery $query): bool => 'org' === $query->organizationId && 'member' === $query->memberId && 2 === $query->page && 10 === $query->itemsPerPage))->willReturn(new ListMaintenanceRatesResult([new MaintenanceRateSnapshot('rate', 'member', '42.000001', 'EUR', '2026-01-01')], 11, 2, 10)); + $page = new MaintenanceRateProvider($bus, $actor, $requests)->provide(new GetCollection(), ['organizationId' => 'org']); + self::assertSame(11.0, $page->getTotalItems()); + $rows = iterator_to_array($page); + self::assertCount(1, $rows); + self::assertSame('42.000001', $rows[0]->hourlyAmount); + } +} diff --git a/tests/Unit/MaintenanceCost/Presentation/Api/Reporting/MaintenanceEconomicProviderTest.php b/tests/Unit/MaintenanceCost/Presentation/Api/Reporting/MaintenanceEconomicProviderTest.php new file mode 100644 index 000000000..32aefcb87 --- /dev/null +++ b/tests/Unit/MaintenanceCost/Presentation/Api/Reporting/MaintenanceEconomicProviderTest.php @@ -0,0 +1,52 @@ +createStub(CurrentActorPort::class); + $actor->method('userId')->willReturn('actor'); + $requests = new RequestStack(); + $requests->push(Request::create('/dossiers?search=Valve&page=2&itemsPerPage=10&siteId=site&customerId=customer&equipmentId=equipment&from=2026-10-01&to=2026-10-31')); + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::once())->method('ask')->with(self::callback(static fn (ListMaintenanceEconomicDossiersQuery $query): bool => 'actor' === $query->actorId && 'org' === $query->organizationId && 2 === $query->page && 10 === $query->itemsPerPage && 'Valve' === $query->search && 'site' === $query->siteId && 'customer' === $query->customerId && 'equipment' === $query->equipmentId && '2026-10-01' === $query->from && '2026-10-31' === $query->to))->willReturn(new ListMaintenanceEconomicDossiersResult(new InterventionEconomicContextPage([], 15, 2, 10))); + $output = new MaintenanceEconomicProvider($queries, $actor, $requests)->provide(new GetCollection(name: MaintenanceEconomicOperations::DOSSIERS), ['organizationId' => 'org']); + self::assertInstanceOf(TraversablePaginator::class, $output); + self::assertSame(15.0, $output->getTotalItems()); + self::assertSame(2.0, $output->getCurrentPage()); + self::assertSame(10.0, $output->getItemsPerPage()); + } + + public function testUnauthenticatedDirectoryNeverDispatchesItsQuery(): void + { + $actor = $this->createStub(CurrentActorPort::class); + $actor->method('userId')->willReturn(null); + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::never())->method('ask'); + $this->expectException(AccessDeniedHttpException::class); + new MaintenanceEconomicProvider($queries, $actor, new RequestStack())->provide(new GetCollection(name: MaintenanceEconomicOperations::DOSSIERS), ['organizationId' => 'org']); + } +} diff --git a/tests/Unit/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportHandlerTest.php b/tests/Unit/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportHandlerTest.php new file mode 100644 index 000000000..94b6fbc40 --- /dev/null +++ b/tests/Unit/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportHandlerTest.php @@ -0,0 +1,222 @@ + immutable receipts + */ + private array $receipts = []; + + /** + * @var array retained artifacts + */ + private array $documents = []; + + /** + * @var MaintenanceExportRepositoryPort&MockObject repository contract + */ + private MaintenanceExportRepositoryPort $repository; + + /** + * @var MaintenanceExportSourcePort&MockObject source capture contract + */ + private MaintenanceExportSourcePort $sources; + + /** + * @var EventDispatcherPort&MockObject atomic outbox contract + */ + private EventDispatcherPort $events; + + protected function setUp(): void + { + $this->repository = $this->createMock(MaintenanceExportRepositoryPort::class); + $expected = match ($this->name()) { + 'organizationScopeDenialPrecedesMalformedSensitiveSourceDetails' => 0, + 'missingFinancialReadCannotCapturePrivateFacts', 'correctionCreatesAnotherDocumentAndPreservesOriginalContext' => 1, + default => 2, + }; + $this->repository->expects(self::exactly($expected))->method('synchronized')->willReturnCallback(static fn (string $organizationId, callable $work): mixed => $work()); + $this->repository->method('operation')->willReturnCallback(fn (string $org, string $actor, string $operation): ?ExportOperation => $this->receipts[$operation] ?? null); + $this->repository->method('saveOperation')->willReturnCallback(function (ExportOperation $operation): void { $this->receipts[$operation->clientOperationId] = $operation; }); + $this->repository->method('document')->willReturnCallback(fn (string $org, string $id): ?ExportDocument => $this->documents[$id] ?? null); + $this->repository->method('saveDocument')->willReturnCallback(function (ExportDocument $document): void { $this->documents[$document->id] = clone $document; }); + $this->sources = $this->createMock(MaintenanceExportSourcePort::class); + $this->events = $this->createMock(EventDispatcherPort::class); + } + + #[Test] + public function uppercaseReplayReturnsOriginalReceiptWithoutCapturingOrEnqueuingTwice(): void + { + $this->sources->expects(self::once())->method('capture')->with(self::ORG, [self::WORK], 'erp', false, false)->willReturn(new ExportSourceState($this->baseline(), null, null)); + $this->events->expects(self::once())->method('dispatch'); + $handler = $this->handler(); + $first = $handler($this->create()); + $bytes = $this->documents[self::DOC]->jsonBytes; + $input = ['clientOperationId' => strtoupper(self::OP), 'interventionIds' => [strtoupper(self::WORK)], 'system' => 'erp', 'includeInternalCosts' => false]; + $second = $handler(new ManageMaintenanceExportCommand(strtoupper(self::ACTOR), strtoupper(self::ORG), 'create', null, 999, $input)); + self::assertTrue($second->replayed); + self::assertSame($first->data, $second->data); + self::assertSame($bytes, $this->documents[self::DOC]->jsonBytes); + self::assertCount(1, $this->receipts); + } + + #[Test] + public function aReusedOperationForDifferentIntentConflictsWithoutAnotherEvent(): void + { + $this->sources->expects(self::once())->method('capture')->willReturn(new ExportSourceState($this->baseline(), null, null)); + $this->events->expects(self::once())->method('dispatch'); + $handler = $this->handler(); + $handler($this->create()); + $this->expectException(MaintenanceExportException::class); + $this->expectExceptionMessage('another declaration'); + $handler(new ManageMaintenanceExportCommand(self::ACTOR, self::ORG, 'create', null, null, ['clientOperationId' => self::OP, 'interventionIds' => [self::WORK], 'system' => 'other'])); + } + + #[Test] + public function missingFinancialReadCannotCapturePrivateFacts(): void + { + $this->sources->expects(self::never())->method('capture'); + $this->repository->expects(self::never())->method('saveDocument'); + $this->events->expects(self::never())->method('dispatch'); + $handler = $this->handler('organization.maintenance_cost.read'); + $this->expectException(MaintenanceExportException::class); + $this->expectExceptionMessage('permission'); + $handler($this->create(true)); + } + + #[Test] + public function organizationScopeDenialPrecedesMalformedSensitiveSourceDetails(): void + { + $this->repository->expects(self::never())->method('synchronized'); + $this->sources->expects(self::never())->method('capture'); + $this->events->expects(self::never())->method('dispatch'); + $handler = $this->handler(null, OrganizationAccessDecision::OUTSIDE_SCOPE); + $this->expectException(MaintenanceExportException::class); + $this->expectExceptionMessage('unavailable'); + $handler(new ManageMaintenanceExportCommand(self::ACTOR, self::ORG, 'create', null, null, ['interventionIds' => ['secret-invalid-id']])); + } + + #[Test] + public function confirmationRequiresCurrentRevisionAndCannotRewriteArtifactBytes(): void + { + $this->sources->expects(self::never())->method('capture'); + $this->events->expects(self::once())->method('dispatch'); + $document = $this->document(); + $this->documents[self::DOC] = $document; + $bytes = $document->jsonBytes; + $handler = $this->handler(); + $result = $handler(new ManageMaintenanceExportCommand(self::ACTOR, self::ORG, 'confirm', self::DOC, 1, ['clientOperationId' => self::OP, 'externalImportReference' => 'IMPORTED-88'])); + self::assertSame('import_confirmed', $result->data['state']); + self::assertSame(2, $result->data['revision']); + self::assertSame($bytes, $this->documents[self::DOC]->jsonBytes); + $replay = $handler(new ManageMaintenanceExportCommand(self::ACTOR, self::ORG, 'confirm', self::DOC, 0, ['clientOperationId' => self::OP, 'externalImportReference' => 'IMPORTED-88'])); + self::assertTrue($replay->replayed); + self::assertSame($result->data, $replay->data); + } + + #[Test] + public function correctionCreatesAnotherDocumentAndPreservesOriginalContext(): void + { + $document = $this->document(); + $this->documents[self::DOC] = $document; + $changed = $this->baseline(); + $changed['work']['minutes'] = 60; + $this->sources->expects(self::once())->method('capture')->with(self::ORG, [self::WORK], 'erp', false, true)->willReturn(new ExportSourceState($changed, null, null)); + $this->events->expects(self::once())->method('dispatch'); + $id = 'aa0e8400-e29b-41d4-a716-446655449099'; + $result = $this->handler(id:$id)(new ManageMaintenanceExportCommand(self::ACTOR, self::ORG, 'adjustment', self::DOC, 1, ['clientOperationId' => self::OP, 'reason' => 'Corrected time'])); + self::assertSame('adjustment', $result->data['kind']); + self::assertSame(self::DOC, $result->data['adjustmentOf']); + self::assertSame($document->jsonBytes, $this->documents[self::DOC]->jsonBytes); + self::assertSame(-30, $this->documents[$id]->rows[0]['minutes']); + self::assertSame(60, $this->documents[$id]->rows[1]['minutes']); + self::assertSame($this->documents[$id]->rows[1]['id'], $this->documents[$id]->baseline['work']['id']); + } + + /** + * Method handler + * + * @return ManageMaintenanceExportHandler port-only use case + */ + private function handler(?string $deniedPermission = null, OrganizationAccessDecision $decision = OrganizationAccessDecision::GRANTED, string $id = self::DOC): ManageMaintenanceExportHandler + { + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('resolveAccess')->willReturnCallback(static fn (string $actor, string $org, string $permission): OrganizationAccessDecision => $permission === $deniedPermission ? OrganizationAccessDecision::MISSING_PERMISSION : $decision); + $identity = $this->createStub(MaintenanceExportIdentityPort::class); + $ids = $this->createStub(UuidGeneratorPort::class); + $ids->method('generate')->willReturn($id); + $clock = $this->createStub(ClockPort::class); + $clock->method('now')->willReturn(new DateTimeImmutable('2026-10-07T12:00:00Z')); + + return new ManageMaintenanceExportHandler($this->repository, $this->sources, $identity, $authorization, $ids, $clock, $this->events); + } + + /** + * Method create + * + * @return ManageMaintenanceExportCommand stable initial intent + */ + private function create(bool $financial = false): ManageMaintenanceExportCommand + { + return new ManageMaintenanceExportCommand(self::ACTOR, self::ORG, 'create', null, null, ['clientOperationId' => self::OP, 'interventionIds' => [self::WORK], 'system' => 'erp', 'includeInternalCosts' => $financial]); + } + + /** + * Method baseline + * + * @return array> original validated row + */ + private function baseline(): array + { + return ['work' => ['id' => 'source-row', 'kind' => 'prestation', 'logicalSourceKey' => 'work', 'sourceId' => self::WORK, 'interventionId' => self::WORK, 'minutes' => 30]]; + } + + /** + * Method document + * + * @return ExportDocument fixture immutable artifact + */ + private function document(): ExportDocument + { + $rows = [...array_values($this->baseline())]; + + return new ExportDocument(self::DOC, self::ORG, self::ACTOR, 'initial', 'erp', false, [self::WORK], null, null, null, new DateTimeImmutable('2026-10-07T12:00:00Z'), $rows, $this->baseline(), ExportArtifact::json(['rows' => $rows]), ExportArtifact::csv($rows, false)); + } +} diff --git a/tests/Unit/MaintenanceExport/Domain/Model/ExportDocumentTest.php b/tests/Unit/MaintenanceExport/Domain/Model/ExportDocumentTest.php new file mode 100644 index 000000000..42ef42f02 --- /dev/null +++ b/tests/Unit/MaintenanceExport/Domain/Model/ExportDocumentTest.php @@ -0,0 +1,152 @@ +document(); + $projection = $document->projection(); + self::assertSame('generated', $projection['state']); + self::assertNull($projection['confirmation']); + self::assertSame(1, $document->revision()); + self::assertSame(1, $projection['schemaVersion']); + self::assertSame(['json' => ['mediaType' => 'application/json', 'sha256' => hash('sha256', $document->jsonBytes), 'size' => strlen($document->jsonBytes)], 'csv' => ['mediaType' => 'text/csv', 'sha256' => hash('sha256', $document->csvBytes), 'size' => strlen($document->csvBytes)]], $projection['files']); + self::assertArrayNotHasKey('rows', $projection); + self::assertArrayNotHasKey('baseline', $projection); + self::assertArrayNotHasKey('jsonBytes', $projection); + self::assertArrayNotHasKey('csvBytes', $projection); + } + + #[Test] + public function confirmationAdvancesItsRevisionAndKeepsBothArtifactsAndSourceFacts(): void + { + $document = $this->document(); + $json = $document->jsonBytes; + $csv = $document->csvBytes; + $rows = $document->rows; + $baseline = $document->baseline; + $document->confirm(1, self::OPERATION, ' IMPORT-42 ', self::ACTOR, new DateTimeImmutable('2026-10-07T12:00:00Z')); + self::assertSame(2, $document->revision()); + self::assertSame(['clientOperationId' => self::OPERATION, 'externalImportReference' => 'IMPORT-42', 'actorId' => self::ACTOR, 'confirmedAt' => '2026-10-07T12:00:00+00:00'], $document->confirmation()); + self::assertSame('import_confirmed', $document->projection()['state']); + self::assertSame($json, $document->jsonBytes); + self::assertSame($csv, $document->csvBytes); + self::assertSame($rows, $document->rows); + self::assertSame($baseline, $document->baseline); + } + + #[Test] + #[DataProvider('invalidRevision')] + public function missingOrStaleRevisionCannotAcknowledgeAnImport(?int $revision, string $reason): void + { + $document = $this->document(); + + try { + $document->assertRevision($revision); + self::fail('An exact If-Match revision is required.'); + } catch (MaintenanceExportException $error) { + self::assertSame($reason, $error->reason); + self::assertSame(1, $document->revision()); + self::assertNull($document->confirmation()); + } + } + + /** + * Method invalidRevision + * + * @return iterable absent and stale caller versions + */ + public static function invalidRevision(): iterable + { + yield 'absent' => [null, 'revision_required']; + yield 'prior' => [0, 'stale']; + yield 'future' => [2, 'stale']; + } + + #[Test] + public function aSecondAcknowledgementDoesNotReplaceTheFirstReceipt(): void + { + $document = $this->document(); + $document->confirm(1, self::OPERATION, 'ORIGINAL-IMPORT', self::ACTOR, new DateTimeImmutable('2026-10-07T12:00:00Z')); + $receipt = $document->confirmation(); + + try { + $document->confirm(2, self::OPERATION, 'OTHER-IMPORT', self::ACTOR, new DateTimeImmutable('2026-10-07T13:00:00Z')); + self::fail('The retained import receipt cannot be replaced.'); + } catch (MaintenanceExportException $error) { + self::assertSame('conflict', $error->reason); + self::assertSame($receipt, $document->confirmation()); + self::assertSame(2, $document->revision()); + } + } + + #[Test] + public function invalidImportReferenceLeavesGenerationAndRevisionIntact(): void + { + $document = $this->document(); + + try { + $document->confirm(1, self::OPERATION, ' ', self::ACTOR, new DateTimeImmutable('2026-10-07T12:00:00Z')); + self::fail('An import acknowledgement needs an explicit external receipt.'); + } catch (MaintenanceExportException $error) { + self::assertSame('invalid', $error->reason); + self::assertNull($document->confirmation()); + self::assertSame(1, $document->revision()); + } + } + + #[Test] + public function anAdjustmentNeedsItsOriginalPrecedingExportAndReason(): void + { + $this->expectException(MaintenanceExportException::class); + $this->expectExceptionMessage('An adjustment must retain its original and preceding export.'); + $this->document(kind: 'adjustment'); + } + + /** + * Method document + * + * @param string $kind initial or adjustment fixture + * + * @return ExportDocument retained source fact and exact artifacts + */ + private function document(string $kind = 'initial'): ExportDocument + { + $rows = [['id' => 'preserved-row', 'equipmentName' => 'Extincteur réserve']]; + + return new ExportDocument(self::DOCUMENT, self::ORG, self::ACTOR, $kind, 'erp', false, [], null, null, null, new DateTimeImmutable('2026-10-07T10:00:00Z'), $rows, ['equipment:one' => $rows[0]], "{\"version\":1}\n", "id,equipmentName\r\n\"preserved-row\",\"Extincteur réserve\"\r\n"); + } + // #endregion +} diff --git a/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportArtifactTest.php b/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportArtifactTest.php new file mode 100644 index 000000000..6620765b2 --- /dev/null +++ b/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportArtifactTest.php @@ -0,0 +1,161 @@ + 'équipement/contrôle', 'rows' => [['z' => '123456789012345678.123456', 'a' => null], ['id' => 'first']], 'a' => ['z' => false, 'a' => 'été']]; + self::assertSame('{"a":{"a":"été","z":false},"rows":[{"a":null,"z":"123456789012345678.123456"},{"id":"first"}],"z":"équipement/contrôle"}' . "\n", ExportArtifact::json($data)); + self::assertSame(ExportArtifact::json($data), ExportArtifact::json(['a' => ['a' => 'été', 'z' => false], 'rows' => $data['rows'], 'z' => $data['z']])); + } + + #[Test] + public function aNestedFloatingAmountCannotEnterRetainedJson(): void + { + $this->expectException(MaintenanceExportException::class); + $this->expectExceptionMessage('Export amounts must use exact decimal strings.'); + ExportArtifact::json(['rows' => [['resources' => ['unitCost' => 0.1]]]]); + } + + #[Test] + #[DataProvider('formulaText')] + public function csvNeutralizesFormulaTextWhilePreservingExactNegativeNumericColumns(string $formula): void + { + $cells = $this->csvRow(ExportArtifact::csv([['equipmentName' => $formula, 'siteName' => '-10', 'minutes' => -60, 'amount' => '-123456789012345678.123456', 'currency' => 'EUR', 'costComplete' => true]], true)); + self::assertSame("'" . $formula, $cells['equipmentName']); + self::assertSame("'-10", $cells['siteName']); + self::assertSame('-60', $cells['minutes']); + self::assertSame('-123456789012345678.123456', $cells['amount']); + self::assertSame('true', $cells['costComplete']); + } + + /** + * Method formulaText + * + * @return iterable spreadsheet formula markers and leading whitespace + */ + public static function formulaText(): iterable + { + yield 'equals' => ['=HYPERLINK("https://example.invalid")']; + yield 'plus' => ['+SUM(A1:A2)']; + yield 'minus text' => ['-Equipement']; + yield 'at' => ['@SUM(1)']; + yield 'space' => [' =1+1']; + yield 'tab' => ["\t@SUM(1)"]; + yield 'newline' => ["\n+SUM(A1:A2)"]; + } + + #[Test] + public function csvPreservesQuotesCommasAndLineBreaksUsingRfc4180Escaping(): void + { + $label = "Extincteur, \"réserve\"\r\nBâtiment nord"; + $csv = ExportArtifact::csv([['equipmentName' => $label, 'customerName' => 'École été']], false); + self::assertStringContainsString('"Extincteur, ""réserve""' . "\r\n" . 'Bâtiment nord"', $csv); + self::assertStringEndsWith("\r\n", $csv); + $cells = $this->csvRow($csv); + self::assertSame($label, $cells['equipmentName']); + self::assertSame('École été', $cells['customerName']); + self::assertArrayNotHasKey('amount', $cells); + self::assertArrayNotHasKey('currency', $cells); + } + + #[Test] + public function formulaLikeFinancialTextIsNotMistakenForAnExactNegativeAmount(): void + { + $cells = $this->csvRow(ExportArtifact::csv([['amount' => '-SUM(A1:A2)', 'minutes' => '=1+1']], true)); + self::assertSame("'-SUM(A1:A2)", $cells['amount']); + self::assertSame("'=1+1", $cells['minutes']); + } + + #[Test] + public function rowIdentityIsStableAndDifferentForDistinctSourceKeys(): void + { + $id = ExportArtifact::rowId('publication:one:work:two'); + self::assertSame($id, ExportArtifact::rowId('publication:one:work:two')); + self::assertNotSame($id, ExportArtifact::rowId('publication:one:work:three')); + self::assertMatchesRegularExpression('/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-8[0-9a-f]{3}-[0-9a-f]{12}$/D', $id); + } + + #[Test] + public function synchronousCsvWorkIsBoundedByRowCount(): void + { + $this->expectException(MaintenanceExportException::class); + $this->expectExceptionMessage('Too many export rows.'); + ExportArtifact::csv(array_fill(0, ExportArtifact::MAX_ROWS + 1, ['id' => 'bounded']), false); + } + + #[Test] + public function jsonCannotExceedTheRetainedArtifactByteLimit(): void + { + $this->expectException(MaintenanceExportException::class); + $this->expectExceptionMessage('Export artifact exceeds the size limit.'); + ExportArtifact::json(['description' => str_repeat('x', ExportArtifact::MAX_BYTES)]); + } + + #[Test] + public function csvCannotExceedTheRetainedArtifactByteLimit(): void + { + $this->expectException(MaintenanceExportException::class); + $this->expectExceptionMessage('Export artifact exceeds the size limit.'); + ExportArtifact::csv([['equipmentName' => str_repeat('x', ExportArtifact::MAX_BYTES)]], false); + } + + /** + * Method csvRow + * + * Uses the real CSV parser, including embedded quoted line breaks. + * + * @param string $bytes retained UTF-8 CSV + * + * @return array header-keyed first data row + */ + private function csvRow(string $bytes): array + { + $stream = fopen('php://memory', 'w+'); + self::assertIsResource($stream); + fwrite($stream, $bytes); + rewind($stream); + $header = fgetcsv($stream, null, ',', '"', ''); + $row = fgetcsv($stream, null, ',', '"', ''); + fclose($stream); + self::assertIsArray($header); + self::assertIsArray($row); + $names = []; + foreach ($header as $name) { + self::assertIsString($name); + $names[] = $name; + } + self::assertCount(count($names), $row); + + return array_combine($names, $row); + } + // #endregion +} diff --git a/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportIdentityTest.php b/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportIdentityTest.php new file mode 100644 index 000000000..9dafb5d3c --- /dev/null +++ b/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportIdentityTest.php @@ -0,0 +1,84 @@ +expectException(MaintenanceExportException::class); + ExportIdentity::system($system); + } + + /** + * Method invalidSystems + * + * @return iterable ambiguous or oversized external system codes + */ + public static function invalidSystems(): iterable + { + yield 'empty' => ['']; + yield 'whitespace' => ['two systems']; + yield 'path' => ['../erp']; + yield 'oversized' => [str_repeat('a', 41)]; + } + + #[Test] + public function unsupportedReferenceResourcesAreRejected(): void + { + $this->expectException(MaintenanceExportException::class); + ExportIdentity::resourceType('invoice'); + } + + #[Test] + #[DataProvider('invalidTexts')] + public function importReferenceRejectsEmptyControlCharactersAndCharacterOverflow(string $text, int $limit): void + { + $this->expectException(MaintenanceExportException::class); + ExportIdentity::text($text, $limit); + } + + /** + * Method invalidTexts + * + * @return iterable invalid retained human declarations + */ + public static function invalidTexts(): iterable + { + yield 'empty' => [' ', 200]; + yield 'nul' => ["ERP\0reference", 200]; + yield 'control' => ["ERP\x1Breference", 200]; + yield 'utf8 character overflow' => ['éééé', 3]; + } + // #endregion +} diff --git a/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportIdentityUnicodeTest.php b/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportIdentityUnicodeTest.php new file mode 100644 index 000000000..16008f93b --- /dev/null +++ b/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportIdentityUnicodeTest.php @@ -0,0 +1,35 @@ +expectException(MaintenanceExportException::class); + ExportIdentity::text(str_repeat('é', 201), 200); + } +} diff --git a/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportRowsTest.php b/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportRowsTest.php new file mode 100644 index 000000000..9bb58b408 --- /dev/null +++ b/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportRowsTest.php @@ -0,0 +1,137 @@ + ['id' => 'z-source', 'minutes' => 30], 'a' => ['id' => 'a-source', 'minutes' => 15]]); + self::assertSame([['id' => 'a-source', 'minutes' => 15, 'change' => 'add', 'correctionOf' => null], ['id' => 'z-source', 'minutes' => 30, 'change' => 'add', 'correctionOf' => null]], $rows); + } + + #[Test] + public function changedTimeAndCostCreateAnExactReversalAndFullReplacement(): void + { + $before = ['time:one' => ['id' => 'original-row', 'minutes' => 30, 'amount' => '123456789012345678.123456', 'sourceRevision' => 1]]; + $after = ['time:one' => ['id' => 'captured-new-row', 'minutes' => 60, 'amount' => '246913578024691356.246912', 'sourceRevision' => 2]]; + $rows = ExportRows::adjustment($before, $after, self::ADJUSTMENT); + self::assertCount(2, $rows); + self::assertSame('reverse', $rows[0]['change']); + self::assertSame(-30, $rows[0]['minutes']); + self::assertSame('-123456789012345678.123456', $rows[0]['amount']); + self::assertSame('original-row', $rows[0]['correctionOf']); + self::assertSame(1, $rows[0]['sourceRevision']); + self::assertSame('add', $rows[1]['change']); + self::assertSame(60, $rows[1]['minutes']); + self::assertSame('246913578024691356.246912', $rows[1]['amount']); + self::assertSame('original-row', $rows[1]['correctionOf']); + self::assertSame(ExportArtifact::rowId(self::ADJUSTMENT . '|reverse|time:one'), $rows[0]['id']); + self::assertSame(ExportArtifact::rowId(self::ADJUSTMENT . '|add|time:one'), $rows[1]['id']); + self::assertSame('123456789012345678.123456', $before['time:one']['amount']); + } + + #[Test] + public function unknownCostRemainsUnknownInACompensatingRow(): void + { + $rows = ExportRows::adjustment(['cost:one' => ['id' => 'unknown-original', 'amount' => null, 'costComplete' => false]], ['cost:one' => ['id' => 'resolved-capture', 'amount' => '10.000000', 'costComplete' => true]], self::ADJUSTMENT); + self::assertNull($rows[0]['amount']); + self::assertFalse($rows[0]['costComplete']); + self::assertSame('10.000000', $rows[1]['amount']); + self::assertTrue($rows[1]['costComplete']); + } + + #[Test] + public function addedAndRemovedFactsDoNotFabricateReplacementRows(): void + { + $rows = ExportRows::adjustment(['removed' => ['id' => 'removed-source', 'amount' => '-2.000000']], ['added' => ['id' => 'added-source', 'amount' => '3.000000']], self::ADJUSTMENT); + self::assertCount(2, $rows); + self::assertSame('add', $rows[0]['change']); + self::assertNull($rows[0]['correctionOf']); + self::assertSame('reverse', $rows[1]['change']); + self::assertSame('removed-source', $rows[1]['correctionOf']); + self::assertSame('2.000000', $rows[1]['amount']); + } + + #[Test] + public function aTransferredArtifactRowIdentityAloneDoesNotInventACorrection(): void + { + try { + ExportRows::adjustment(['same' => ['id' => 'earlier-artifact-row', 'minutes' => 60]], ['same' => ['id' => 'later-artifact-row', 'minutes' => 60]], self::ADJUSTMENT); + self::fail('Only an actual fact change can create an adjustment.'); + } catch (MaintenanceExportException $error) { + self::assertSame('conflict', $error->reason); + } + } + + #[Test] + public function anUnchangedBaselineHasAnExplicitConflict(): void + { + $facts = ['same' => ['id' => 'source-row', 'amount' => '10.000000']]; + + try { + ExportRows::adjustment($facts, $facts, self::ADJUSTMENT); + self::fail('A no-change adjustment must be rejected.'); + } catch (MaintenanceExportException $error) { + self::assertSame('conflict', $error->reason); + } + } + + #[Test] + public function jsonbObjectKeyOrderingCannotInventACorrection(): void + { + $before = ['same' => ['id' => 'preserved-row', 'amount' => '10.000000', 'minutes' => 60, 'context' => ['label' => 'Extincteur', 'status' => 'service']]]; + $after = ['same' => ['context' => ['status' => 'service', 'label' => 'Extincteur'], 'minutes' => 60, 'amount' => '10.000000', 'id' => 'captured-row']]; + + try { + ExportRows::adjustment($before, $after, self::ADJUSTMENT); + self::fail('JSONB ordering is not a changed source fact.'); + } catch (MaintenanceExportException $error) { + self::assertSame('conflict', $error->reason); + } + } + + #[Test] + public function anExportCannotHaveNoRows(): void + { + $this->expectException(MaintenanceExportException::class); + $this->expectExceptionMessage('An export requires between one and 10000 rows.'); + ExportRows::initial([]); + } + + #[Test] + public function expandedCorrectionWorkCannotExceedTheSynchronousRowLimit(): void + { + $keys = array_map(static fn (int $index): string => 'row:' . $index, range(0, 5000)); + $before = array_fill_keys($keys, ['id' => 'source-row', 'minutes' => 1]); + $after = array_fill_keys($keys, ['id' => 'new-row', 'minutes' => 2]); + $this->expectException(MaintenanceExportException::class); + $this->expectExceptionMessage('An export requires between one and 10000 rows.'); + ExportRows::adjustment($before, $after, self::ADJUSTMENT); + } + // #endregion +} diff --git a/tests/Unit/MaintenanceExport/Infrastructure/Adapter/Export/ExportSourceAdapterTest.php b/tests/Unit/MaintenanceExport/Infrastructure/Adapter/Export/ExportSourceAdapterTest.php new file mode 100644 index 000000000..3701c53c3 --- /dev/null +++ b/tests/Unit/MaintenanceExport/Infrastructure/Adapter/Export/ExportSourceAdapterTest.php @@ -0,0 +1,180 @@ +adapter($this->publication())->capture(self::ORG, [self::WORK], 'erp', false, false); + $row = $capture->baseline['work:' . self::WORK . ':' . self::TASK]; + foreach (['siteId', 'siteName', 'siteReference', 'customerId', 'customerName', 'customerReference'] as $field) { + self::assertNull($row[$field]); + } + } + + #[Test] + public function directMaterialRetainsItsOwnEquipmentSiteCustomerAndErpReferences(): void + { + $item = new MaintenanceCostItem('material:receipt', 'material', null, 'receipt', null, '12.500001', 'EUR', 'Direct consumption', '2026-10-07T12:00:00Z', equipmentId:self::EQUIPMENT_B, allocation:['identityState' => 'captured', 'equipment' => ['id' => self::EQUIPMENT_B, 'name' => 'Equipment B', 'assetReference' => 'B-02'], 'site' => ['id' => self::SITE_B, 'name' => 'Site B'], 'customer' => ['id' => self::CUSTOMER_B, 'name' => 'Customer B']]); + $capture = $this->adapter($this->publication(), [$item])->capture(self::ORG, [self::WORK], 'erp', true, false); + $row = $capture->baseline['cost:' . self::WORK . ':material:receipt']; + self::assertSame(self::EQUIPMENT_B, $row['equipmentId']); + self::assertSame('Equipment B', $row['equipmentName']); + self::assertSame(self::SITE_B, $row['siteId']); + self::assertSame('Site B', $row['siteName']); + self::assertSame('ERP-site-' . self::SITE_B, $row['siteReference']); + self::assertSame(self::CUSTOMER_B, $row['customerId']); + self::assertSame('Customer B', $row['customerName']); + self::assertSame('ERP-customer-' . self::CUSTOMER_B, $row['customerReference']); + self::assertSame('12.500001', $row['amount']); + self::assertTrue($row['identityComplete']); + } + + #[Test] + public function directMaterialWithoutCapturedAllocationDoesNotInventThePrimaryCustomer(): void + { + $item = new MaintenanceCostItem('material:receipt', 'material', null, 'receipt', null, null, 'EUR', 'Direct consumption', '2026-10-07T12:00:00Z', equipmentId:self::EQUIPMENT_B); + $capture = $this->adapter($this->publication(), [$item])->capture(self::ORG, [self::WORK], 'erp', true, false); + $row = $capture->baseline['cost:' . self::WORK . ':material:receipt']; + self::assertNull($row['siteId']); + self::assertNull($row['customerId']); + self::assertFalse($row['identityComplete']); + self::assertSame('incomplete', $row['equipmentIdentityState']); + self::assertFalse($capture->costsComplete); + self::assertSame(1, $capture->incompleteCostCount); + } + + #[Test] + public function skippedTaskFinancialRowsKeepTheirCapturedAllocationWithoutExportingItsPrestation(): void + { + $publication = $this->publication(); + $skippedId = 'bb0e8400-e29b-41d4-a716-446655449010'; + $equipment = new InterventionEquipmentSnapshot(self::EQUIPMENT_B, 'Equipment B', 'B-02', 'fire_extinguisher', null, null, null, self::SITE_B, ['id' => self::SITE_B, 'name' => 'Site B'], ['id' => self::CUSTOMER_B, 'name' => 'Customer B']); + $skipped = new InterventionPublishedWorkFact($skippedId, 'repair', 'skipped', null, null, self::EQUIPMENT_B, $equipment->site, $equipment->customer, $equipment, ['state' => 'skipped'], false, 18, 0, null, null); + $mixed = new InterventionPublicationFacts($publication->id, $publication->organizationId, $publication->number, $publication->name, $publication->type, $publication->status, $publication->revision, $publication->createdAt, null, null, $publication->publishedAt, $publication->publicationId, 'available', 2, true, $publication->site, $publication->customer, [...$publication->workItems, $skipped], $publication->dossier); + $allocation = ['identityState' => 'captured', 'equipment' => ['id' => self::EQUIPMENT_B, 'name' => 'Cost-captured equipment B', 'assetReference' => 'B-02'], 'site' => $equipment->site, 'customer' => $equipment->customer]; + $items = [ + new MaintenanceCostItem('expense:one', 'expense', $skippedId, 'one', null, '12.500001', 'EUR', 'External expense', '2026-10-07T12:00:00Z', equipmentId:self::EQUIPMENT_B, allocation:$allocation), + new MaintenanceCostItem('time:two', 'time', $skippedId, 'two', 1, '18.000000', 'EUR', 'Work time', '2026-10-07', equipmentId:self::EQUIPMENT_B, allocation:$allocation), + ]; + $capture = $this->adapter($mixed, $items)->capture(self::ORG, [self::WORK], 'erp', true, false); + self::assertCount(3, $capture->baseline); + self::assertArrayNotHasKey('work:' . self::WORK . ':' . $skippedId, $capture->baseline); + foreach ($items as $item) { + $row = $capture->baseline['cost:' . self::WORK . ':' . $item->kind . ':' . $item->sourceId]; + self::assertSame($skippedId, $row['workItemId']); + self::assertSame(self::EQUIPMENT_B, $row['equipmentId']); + self::assertSame('Cost-captured equipment B', $row['equipmentName']); + self::assertSame(self::SITE_B, $row['siteId']); + self::assertSame(self::CUSTOMER_B, $row['customerId']); + self::assertSame('ERP-equipment-' . self::EQUIPMENT_B, $row['equipmentReference']); + self::assertSame('ERP-site-' . self::SITE_B, $row['siteReference']); + self::assertSame('ERP-customer-' . self::CUSTOMER_B, $row['customerReference']); + self::assertTrue($row['identityComplete']); + } + } + + #[Test] + public function legacyFinancialItemOnSkippedTaskUsesItsRetainedIdentityWithoutPrimaryFallback(): void + { + $publication = $this->publication(); + $skippedId = 'bb0e8400-e29b-41d4-a716-446655449010'; + $equipment = new InterventionEquipmentSnapshot(self::EQUIPMENT_B, 'Equipment B', 'B-02', 'fire_extinguisher', null, null, null, self::SITE_B, ['id' => self::SITE_B, 'name' => 'Site B'], null); + $skipped = new InterventionPublishedWorkFact($skippedId, 'repair', 'skipped', null, null, self::EQUIPMENT_B, $equipment->site, null, $equipment, null, false, 18, 0, null, null); + $mixed = new InterventionPublicationFacts($publication->id, $publication->organizationId, $publication->number, $publication->name, $publication->type, $publication->status, $publication->revision, $publication->createdAt, null, null, $publication->publishedAt, $publication->publicationId, 'available', 2, true, $publication->site, $publication->customer, [...$publication->workItems, $skipped], $publication->dossier); + $item = new MaintenanceCostItem('expense:one', 'expense', $skippedId, 'one', null, '12.500001', 'EUR', 'External expense', '2026-10-07T12:00:00Z'); + $capture = $this->adapter($mixed, [$item])->capture(self::ORG, [self::WORK], 'erp', true, false); + $row = $capture->baseline['cost:' . self::WORK . ':expense:one']; + self::assertSame(self::EQUIPMENT_B, $row['equipmentId']); + self::assertSame(self::SITE_B, $row['siteId']); + self::assertNull($row['customerId']); + self::assertNull($row['customerReference']); + } + + #[Test] + public function sourceWithoutValidatedPrestationIsRejectedEvenWhenItHasFinancialRows(): void + { + $item = new MaintenanceCostItem('expense:one', 'expense', null, 'one', null, '10.000000', 'EUR', 'External expense', '2026-10-07T12:00:00Z'); + $adapter = $this->adapter($this->publication(false), [$item]); + $this->expectException(MaintenanceExportException::class); + $this->expectExceptionMessage('no validated prestation'); + $adapter->capture(self::ORG, [self::WORK], 'erp', true, false); + } + + /** + * Method adapter + * + * @param list $items captured private costs + * + * @return ExportSourceAdapter owner-port projection + */ + private function adapter(InterventionPublicationFacts $publication, array $items = []): ExportSourceAdapter + { + $publications = $this->createStub(InterventionPublicationFactsPort::class); + $publications->method('publishedBatch')->willReturn([$publication]); + $times = $this->createStub(InterventionCostSourceFactsPort::class); + $times->method('context')->willReturn(new InterventionCostContext(self::WORK, self::ORG, 'published', 2, 'Dossier', self::SITE_A, [self::TASK])); + $costs = $this->createStub(MaintenanceCostReadPort::class); + $total = new MaintenanceCostTotals(null, '0.000000', false, $items); + $frozen = new MaintenanceCostSnapshot(1, '2026-10-07T12:00:00Z', self::PUB, 1, 'EUR', $total); + $costs->method('view')->willReturn(new MaintenanceCostView(self::WORK, self::ORG, 'EUR', new MaintenanceCostPlanning(), $total, $frozen)); + $repository = $this->createStub(MaintenanceExportRepositoryPort::class); + $repository->method('reference')->willReturnCallback(static fn (string $org, string $system, string $type, string $id): ExternalReference => new ExternalReference('bb0e8400-e29b-41d4-a716-446655449099', $org, $system, $type, $id, 'ERP-' . $type . '-' . $id, 1, new DateTimeImmutable('2026-10-07T12:00:00Z'))); + + return new ExportSourceAdapter($publications, $times, $costs, $repository); + } + + /** + * Method publication + * + * @return InterventionPublicationFacts multi-target immutable dossier + */ + private function publication(bool $validated = true): InterventionPublicationFacts + { + $now = new DateTimeImmutable('2026-10-07T12:00:00Z'); + $work = new InterventionPublishedWorkFact(self::TASK, 'repair', 'completed', null, null, null, null, null, null, ['state' => 'validated'], $validated, 0, 0, $now, $now); + + return new InterventionPublicationFacts(self::WORK, self::ORG, 1, 'Dossier', 'corrective_maintenance', 'published', 2, $now, null, null, $now, self::PUB, 'available', 2, true, ['id' => self::SITE_A, 'name' => 'Site A'], ['id' => self::CUSTOMER_A, 'name' => 'Customer A'], [$work], ['version' => 2, 'timeEntries' => []]); + } +} diff --git a/tests/Unit/MaintenanceExport/Presentation/Api/Processor/MaintenanceExportProcessorTest.php b/tests/Unit/MaintenanceExport/Presentation/Api/Processor/MaintenanceExportProcessorTest.php new file mode 100644 index 000000000..a2bb929ca --- /dev/null +++ b/tests/Unit/MaintenanceExport/Presentation/Api/Processor/MaintenanceExportProcessorTest.php @@ -0,0 +1,47 @@ +createMock(CommandBusPort::class); + $actor = $this->createStub(CurrentActorPort::class); + $actor->method('userId')->willReturn('actor'); + $input = new WriteMaintenanceExportReferenceInput(); + $input->clientOperationId = 'operation'; + $input->reference = 'ERP-999'; + $input->system = 'erp'; + $request = Request::create('/reference', 'PATCH', server:['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_IF_MATCH' => '"revision-0"'], content:'{"clientOperationId":"operation","system":"erp","reference":"ERP-999"}'); + $stack = new RequestStack(); + $stack->push($request); + $commands->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageMaintenanceExportCommand $command): bool => 'organization' === $command->organizationId && 'reference' === $command->action && 'resource' === $command->id && 0 === $command->expectedRevision && 'equipment' === $command->payload['resourceType']))->willReturn(new ManageMaintenanceExportResult('reference', ['id' => 'mapping', 'organizationId' => 'organization', 'resourceType' => 'equipment', 'resourceId' => 'resource', 'system' => 'erp', 'reference' => 'ERP-999', 'revision' => 1, 'updatedAt' => '2026-10-07T12:00:00Z'])); + $output = new MaintenanceExportProcessor($commands, $actor, $stack)->process($input, new Patch(name:MaintenanceExportOperations::WRITE_REFERENCE), ['organizationId' => 'organization', 'resourceType' => 'equipment', 'resourceId' => 'resource']); + self::assertInstanceOf(MaintenanceExportReferenceOutput::class, $output); + self::assertSame(1, $output->revision); + self::assertSame('ERP-999', $output->reference); + self::assertFalse($output->replayed); + } +} diff --git a/tests/Unit/MaintenanceExport/Presentation/Api/Provider/MaintenanceExportProviderTest.php b/tests/Unit/MaintenanceExport/Presentation/Api/Provider/MaintenanceExportProviderTest.php new file mode 100644 index 000000000..b33e575c3 --- /dev/null +++ b/tests/Unit/MaintenanceExport/Presentation/Api/Provider/MaintenanceExportProviderTest.php @@ -0,0 +1,56 @@ +createMock(QueryBusPort::class); + $actor = $this->createStub(CurrentActorPort::class); + $actor->method('userId')->willReturn('actor'); + $stack = new RequestStack(); + $stack->push(Request::create('/api/organizations/org/maintenance-exports/export/files/csv')); + $bytes = "id,label\r\n\"id\",\"équipement \"\"historique\"\"\"\r\n"; + $queries->expects(self::once())->method('ask')->with(self::callback(static fn (ReadMaintenanceExportQuery $query): bool => 'org' === $query->organizationId && 'file' === $query->action && 'csv' === $query->format))->willReturn(new ReadMaintenanceExportResult('file', [], bytes:$bytes, mediaType:'text/csv', fileName:'fireguard-export.csv', sha256:hash('sha256', $bytes))); + $response = new MaintenanceExportProvider($queries, $actor, $stack)->provide(new Get(name:MaintenanceExportOperations::FILE), ['organizationId' => 'org', 'id' => 'export', 'format' => 'csv']); + self::assertInstanceOf(Response::class, $response); + self::assertSame($bytes, $response->getContent()); + self::assertSame('text/csv; charset=utf-8', $response->headers->get('Content-Type')); + self::assertSame('nosniff', $response->headers->get('X-Content-Type-Options')); + self::assertStringContainsString('no-store', $response->headers->get('Cache-Control') ?? ''); + } + + #[Test] + public function unauthenticatedReadNeverDispatches(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::never())->method('ask'); + $actor = $this->createStub(CurrentActorPort::class); + $actor->method('userId')->willReturn(null); + $this->expectException(AccessDeniedHttpException::class); + new MaintenanceExportProvider($queries, $actor, new RequestStack())->provide(new Get(name:MaintenanceExportOperations::EXPORT)); + } +} diff --git a/tests/Unit/Onboarding/Application/Service/OrganizationSetupOperatingProfileTest.php b/tests/Unit/Onboarding/Application/Service/OrganizationSetupOperatingProfileTest.php new file mode 100644 index 000000000..d9c80cedb --- /dev/null +++ b/tests/Unit/Onboarding/Application/Service/OrganizationSetupOperatingProfileTest.php @@ -0,0 +1,122 @@ + + */ +final class OrganizationSetupOperatingProfileTest extends TestCase +{ + #[Test] + public function omittedProfileIsPreparedAsOperator(): void + { + $repository = $this->createMock(OrganizationSetupRepositoryPort::class); + $repository->method('findSession')->with('user', true)->willReturn(new OrganizationSetupSession('session', 'user', 'in_progress', 'create_organization', null, true, [])); + $repository->expects(self::once())->method('saveOperations')->with('session', self::callback(static function (array $operations): bool { + self::assertCount(1, $operations); + self::assertInstanceOf(OrganizationSetupOperation::class, $operations[0]); + self::assertSame(['name' => 'Company', 'operatingProfile' => 'operator', 'slug' => null], $operations[0]->payload); + + return true; + })); + + $this->service($repository)->prepare('user', 'session', 'create_organization', [['itemKey' => 'org', 'payload' => ['name' => 'Company']]]); + } + + #[Test] + public function preparedProviderProfileIsPreservedOnReplayAfterConfirmation(): void + { + $operation = new OrganizationSetupOperation('create_organization', 'org', ['name' => 'Company', 'operatingProfile' => 'service_provider', 'slug' => null], 'created-id'); + $repository = $this->createMock(OrganizationSetupRepositoryPort::class); + $repository->method('findSession')->willReturn(new OrganizationSetupSession('session', 'user', 'in_progress', 'select_plan', 'created-id', true, [$operation])); + $repository->expects(self::never())->method('saveOperations'); + + $replayed = $this->service($repository)->begin(new OrganizationSetupContext('user', 'session', 'org'), 'create_organization', null, ['operatingProfile' => 'service_provider', 'name' => 'Company']); + + self::assertSame($operation, $replayed); + self::assertSame('service_provider', $replayed->payload['operatingProfile']); + self::assertSame('created-id', $replayed->resourceId); + } + + #[Test] + public function legacyReceiptCanReplayWithExplicitOperatorDefault(): void + { + $operation = new OrganizationSetupOperation('create_organization', 'org', ['name' => 'Company'], 'created-id'); + $repository = $this->createStub(OrganizationSetupRepositoryPort::class); + $repository->method('findSession')->willReturn(new OrganizationSetupSession('session', 'user', 'in_progress', 'select_plan', 'created-id', true, [$operation])); + + $replayed = $this->service($repository)->begin(new OrganizationSetupContext('user', 'session', 'org'), 'create_organization', null, ['name' => 'Company', 'operatingProfile' => 'operator']); + + self::assertSame('created-id', $replayed->resourceId); + } + + #[Test] + public function replayCannotReplacePreparedProviderWithImplicitOperator(): void + { + $operation = new OrganizationSetupOperation('create_organization', 'org', ['name' => 'Company', 'operatingProfile' => 'service_provider'], 'created-id'); + $repository = $this->createMock(OrganizationSetupRepositoryPort::class); + $repository->method('findSession')->willReturn(new OrganizationSetupSession('session', 'user', 'in_progress', 'select_plan', 'created-id', true, [$operation])); + $repository->expects(self::never())->method('saveOperations'); + + $this->expectException(OrganizationSetupConflict::class); + $this->service($repository)->begin(new OrganizationSetupContext('user', 'session', 'org'), 'create_organization', null, ['name' => 'Company']); + } + + #[Test] + public function completedKeyCannotBeRepreparedWithAnotherProfile(): void + { + $operation = new OrganizationSetupOperation('create_organization', 'org', ['name' => 'Company', 'operatingProfile' => 'service_provider'], 'created-id'); + $repository = $this->createMock(OrganizationSetupRepositoryPort::class); + $repository->method('findSession')->willReturn(new OrganizationSetupSession('session', 'user', 'in_progress', 'select_plan', 'created-id', true, [$operation])); + $repository->expects(self::never())->method('saveOperations'); + + $this->expectException(OrganizationSetupConflict::class); + $this->service($repository)->prepare('user', 'session', 'create_organization', [['itemKey' => 'org', 'payload' => ['name' => 'Company', 'operatingProfile' => 'operator']]]); + } + + #[Test] + #[DataProvider('invalidProfiles')] + public function invalidProfileCannotBePrepared(mixed $profile): void + { + $repository = $this->createMock(OrganizationSetupRepositoryPort::class); + $repository->method('findSession')->willReturn(new OrganizationSetupSession('session', 'user', 'in_progress', 'create_organization', null, true, [])); + $repository->expects(self::never())->method('saveOperations'); + + $this->expectException(OrganizationSetupConflict::class); + $this->service($repository)->prepare('user', 'session', 'create_organization', [['itemKey' => 'org', 'payload' => ['name' => 'Company', 'operatingProfile' => $profile]]]); + } + + /** + * @return iterable + */ + public static function invalidProfiles(): iterable + { + yield 'null' => [null]; + yield 'unknown' => ['administrator']; + yield 'empty' => ['']; + yield 'number' => [1]; + yield 'object' => [['value' => 'operator']]; + } + + private function service(OrganizationSetupRepositoryPort $repository): OrganizationSetupService + { + $transactions = $this->createStub(TransactionManagerPort::class); + $transactions->method('transactional')->willReturnCallback(static fn (callable $work): mixed => $work()); + + return new OrganizationSetupService($repository, $transactions); + } +} diff --git a/tests/Unit/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationHandlerTest.php b/tests/Unit/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationHandlerTest.php index a192f6bc0..7aeec5c6c 100644 --- a/tests/Unit/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationHandlerTest.php +++ b/tests/Unit/Organization/Application/UseCase/Command/Organization/CreateOrganization/CreateOrganizationHandlerTest.php @@ -158,7 +158,7 @@ public function testInvokeCreatesOrganizationOwnerMemberAndSystemRoles(): void self::assertSame(['organization.*'], $savedRoles[0]['permissions']); self::assertTrue($savedRoles[0]['isSystem']); self::assertSame('member', $savedRoles[1]['name']); - self::assertSame(['organization.read', 'organization.dashboard.read', 'organization.members.read', 'organization.roles.read', 'organization.facilities.read', 'organization.equipment.read', 'organization.inspection.read', 'organization.interventions.read', 'organization.maintenance.read', 'organization.teams.read', 'organization.messaging.read', 'organization.compliance.read', 'organization.approvals.read', 'organization.approvals.request', 'organization.events.read'], $savedRoles[1]['permissions']); + self::assertSame(['organization.read', 'organization.dashboard.read', 'organization.members.read', 'organization.roles.read', 'organization.facilities.read', 'organization.customers.read', 'organization.equipment.read', 'organization.inspection.read', 'organization.interventions.read', 'organization.maintenance.read', 'organization.teams.read', 'organization.messaging.read', 'organization.compliance.read', 'organization.approvals.read', 'organization.approvals.request', 'organization.events.read'], $savedRoles[1]['permissions']); self::assertTrue($savedRoles[1]['isSystem']); } diff --git a/tests/Unit/Organization/Domain/Catalog/OrganizationSystemRoleCatalogTest.php b/tests/Unit/Organization/Domain/Catalog/OrganizationSystemRoleCatalogTest.php index 5cad992d8..2900d82d5 100644 --- a/tests/Unit/Organization/Domain/Catalog/OrganizationSystemRoleCatalogTest.php +++ b/tests/Unit/Organization/Domain/Catalog/OrganizationSystemRoleCatalogTest.php @@ -54,6 +54,7 @@ public function testPermissionsForMemberIncludesDashboardReadPermissions(): void 'organization.members.read', 'organization.roles.read', 'organization.facilities.read', + 'organization.customers.read', 'organization.equipment.read', 'organization.inspection.read', 'organization.interventions.read', @@ -76,6 +77,7 @@ public function testMergePermissionsKeepsCanonicalSystemPermissions(): void 'organization.members.read', 'organization.roles.read', 'organization.facilities.read', + 'organization.customers.read', 'organization.equipment.read', 'organization.inspection.read', 'organization.interventions.read', diff --git a/tests/Unit/Organization/Domain/ValueObject/OrganizationOperatingProfileTest.php b/tests/Unit/Organization/Domain/ValueObject/OrganizationOperatingProfileTest.php new file mode 100644 index 000000000..9c1f9a419 --- /dev/null +++ b/tests/Unit/Organization/Domain/ValueObject/OrganizationOperatingProfileTest.php @@ -0,0 +1,80 @@ + + */ +final class OrganizationOperatingProfileTest extends TestCase +{ + private const string ORGANIZATION = '550e8400-e29b-41d4-a716-446655448701'; + + #[Test] + public function newAndLegacyOrganizationsDefaultToOperator(): void + { + $created = Organization::create(new OrganizationId(self::ORGANIZATION), new OrganizationName('Site operator'), 'owner'); + $restored = Organization::reconstitute(new RestoredOrganizationCore(new OrganizationId(self::ORGANIZATION), new OrganizationName('Legacy operator'), 'owner', true, new DateTimeImmutable('2026-01-01T00:00:00+00:00'))); + + self::assertSame(OrganizationOperatingProfile::OPERATOR, $created->operatingProfile()); + self::assertSame(OrganizationOperatingProfile::OPERATOR, $restored->operatingProfile()); + } + + #[Test] + public function serviceProviderProfileSurvivesCreationAndReconstitution(): void + { + $created = Organization::create(new OrganizationId(self::ORGANIZATION), new OrganizationName('Safety contractor'), 'owner', new OrganizationCreationOptions(operatingProfile: OrganizationOperatingProfile::SERVICE_PROVIDER)); + $restored = Organization::reconstitute(new RestoredOrganizationCore(new OrganizationId(self::ORGANIZATION), new OrganizationName('Safety contractor'), 'owner', true, $created->createdAt()), profile: new RestoredOrganizationProfile(operatingProfile: OrganizationOperatingProfile::SERVICE_PROVIDER)); + + self::assertSame(OrganizationOperatingProfile::SERVICE_PROVIDER, $created->operatingProfile()); + self::assertSame(OrganizationOperatingProfile::SERVICE_PROVIDER, $restored->operatingProfile()); + } + + #[Test] + public function changingProfilePreservesOwnershipAndSettings(): void + { + $organization = Organization::create(new OrganizationId(self::ORGANIZATION), new OrganizationName('Site operator'), 'owner'); + $settings = $organization->settings(); + $createdAt = $organization->createdAt(); + $status = $organization->status(); + $organization->changeOperatingProfile(OrganizationOperatingProfile::SERVICE_PROVIDER); + + self::assertSame(OrganizationOperatingProfile::SERVICE_PROVIDER, $organization->operatingProfile()); + self::assertSame('owner', $organization->ownerUserId()); + self::assertSame($settings, $organization->settings()); + self::assertSame($createdAt, $organization->createdAt()); + self::assertSame($status, $organization->status()); + } + + #[Test] + #[DataProvider('unsupportedProfiles')] + public function unsupportedProfileIsRejected(string $value): void + { + $this->expectException(InvalidValueException::class); + OrganizationOperatingProfile::fromString($value); + } + + /** + * @return iterable + */ + public static function unsupportedProfiles(): iterable + { + yield 'empty' => ['']; + yield 'unknown' => ['administrator']; + yield 'case sensitive' => ['OPERATOR']; + yield 'not silently trimmed' => [' service_provider ']; + } +} diff --git a/tests/Unit/Organization/Infrastructure/Persistence/Doctrine/Repository/OrganizationMemberRepositoryTest.php b/tests/Unit/Organization/Infrastructure/Persistence/Doctrine/Repository/OrganizationMemberRepositoryTest.php index b3deb66bd..516f3b4f9 100644 --- a/tests/Unit/Organization/Infrastructure/Persistence/Doctrine/Repository/OrganizationMemberRepositoryTest.php +++ b/tests/Unit/Organization/Infrastructure/Persistence/Doctrine/Repository/OrganizationMemberRepositoryTest.php @@ -97,6 +97,7 @@ public function testGetPermissionNamesForUserInOrganizationCompletesLegacySystem 'organization.members.read', 'organization.roles.read', 'organization.facilities.read', + 'organization.customers.read', 'organization.equipment.read', 'organization.inspection.read', 'organization.interventions.read', diff --git a/tests/Unit/Organization/Infrastructure/Persistence/Doctrine/Repository/OrganizationRoleRepositoryTest.php b/tests/Unit/Organization/Infrastructure/Persistence/Doctrine/Repository/OrganizationRoleRepositoryTest.php index a13675990..144aac7fd 100644 --- a/tests/Unit/Organization/Infrastructure/Persistence/Doctrine/Repository/OrganizationRoleRepositoryTest.php +++ b/tests/Unit/Organization/Infrastructure/Persistence/Doctrine/Repository/OrganizationRoleRepositoryTest.php @@ -70,6 +70,7 @@ public function testFindByOrganizationIdCompletesLegacySystemRolePermissions(): 'organization.members.read', 'organization.roles.read', 'organization.facilities.read', + 'organization.customers.read', 'organization.equipment.read', 'organization.inspection.read', 'organization.interventions.read', diff --git a/tests/Unit/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandlerTest.php b/tests/Unit/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandlerTest.php new file mode 100644 index 000000000..8e044bf1d --- /dev/null +++ b/tests/Unit/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandlerTest.php @@ -0,0 +1,342 @@ + + */ + private array $reverseResults = []; + + /** + * @var array + */ + private array $operations = []; + + private ManageProcurementHandler $handler; + + private DateTimeImmutable $now; + + protected function setUp(): void + { + $this->now = new DateTimeImmutable('2026-10-06T12:00:00Z'); + $this->supplier = Supplier::create(self::SUPPLIER, self::ORG, 'Fire parts', null, null, null, [], $this->now); + $this->order = PurchaseOrder::create(self::ORDER, self::ORG, self::SUPPLIER, 'EUR', 'Parts purchase', [$this->partLine()], $this->now); + $this->order->order(1, $this->now); + $this->repository = $this->createMock(ProcurementRepositoryPort::class); + $this->repository->expects(self::atMost(2))->method('synchronized')->willReturnCallback(static fn (string $organization, callable $work): mixed => $work()); + $this->repository->method('order')->willReturnCallback(fn (): PurchaseOrder => $this->order); + $this->repository->method('supplier')->willReturnCallback(fn (): Supplier => $this->supplier); + $this->repository->method('receipt')->willReturnCallback(fn (): ?ProcurementReceiptState => $this->receipt); + $this->repository->method('returnDeclaration')->willReturnCallback(fn (): ?ProcurementReturnState => $this->return); + $this->repository->expects(self::atMost(4))->method('saveReturn')->willReturnCallback(function (ProcurementReturnState $return): void { $this->return = $return; }); + $this->repository->method('operation')->willReturnCallback(fn (string $organization, string $id): ?ProcurementOperationState => $this->operations[$id] ?? null); + $this->repository->expects(self::atMost(2))->method('saveOrder')->willReturnCallback(function (PurchaseOrder $order): void { $this->order = $order; }); + $this->repository->expects(self::atMost(2))->method('saveReceipt')->willReturnCallback(function (ProcurementReceiptState $receipt): void { $this->receipt = $receipt; }); + $this->repository->expects(self::atMost(2))->method('saveOperation')->willReturnCallback(function (ProcurementOperationState $operation): void { $this->operations[$operation->clientOperationId] = $operation; }); + $this->authorization = $this->createStub(OrganizationAuthorizationPort::class); + $this->authorization->method('resolveAccess')->willReturnCallback(fn (string $actor, string $organization, string $permission): OrganizationAccessDecision => 'organization.maintenance_cost.manage' === $permission && !$this->financeWrite ? OrganizationAccessDecision::MISSING_PERMISSION : $this->decision); + $this->authorization->method('hasPermission')->willReturnCallback(fn (): bool => $this->finance); + $currencies = $this->createStub(MaintenanceCurrencyPort::class); + $currencies->method('lock')->willReturn('EUR'); + $currencies->method('forOrganization')->willReturn('EUR'); + $parts = $this->createStub(InventoryPartDirectoryPort::class); + $parts->method('existsActive')->willReturn(true); + $parts->method('describeMany')->willReturn([self::PART => new InventoryPartDescriptor(self::PART, 'REF', 'Consumable', 'litre', false)]); + $parts->method('warehouseExistsActive')->willReturn(true); + $this->stock = $this->createMock(InventoryStockReceiptPort::class); + $this->stock->expects(self::atMost(2))->method('receive')->willReturn(new InventoryReceiptResult(self::OPERATION, '1.250000', '4.250000', '5.312500')); + $this->stock->expects(self::atMost(2))->method('tryReverse')->willReturnCallback(fn (): InventoryReceiptResult => array_shift($this->reverseResults) ?? new InventoryReceiptResult(self::OPERATION, '0.250000', '4.250000', '1.062500')); + $this->equipment = $this->createMock(EquipmentReserveReceiptPort::class); + $this->equipment->expects(self::atMost(100))->method('supportsType')->willReturn(true); + $ids = $this->createStub(UuidGeneratorPort::class); + $ids->method('generate')->willReturn(self::RECEIPT); + $clock = $this->createStub(ClockPort::class); + $clock->method('now')->willReturn($this->now); + $this->events = $this->createMock(EventDispatcherPort::class); + $this->events->expects(self::atMost(2))->method('dispatch'); + $this->handler = new ManageProcurementHandler($this->repository, $this->authorization, $currencies, $parts, $this->stock, $this->equipment, new ProcurementProjection(), $clock, $ids, $this->events); + } + + #[Test] + public function partialDecimalReceiptReplaysWithoutAnotherStockMovementOrRevision(): void + { + $this->stock->expects(self::once())->method('receive')->with(self::callback(static fn (InventoryReceiptRequest $request): bool => '1.250000' === $request->quantity && '4.250000' === $request->unitCost && self::RECEIPT === $request->sourceReceiptId))->willReturn(new InventoryReceiptResult(self::OPERATION, '1.250000', '4.250000', '5.312500')); + $first = ($this->handler)($this->receive()); + $replay = ($this->handler)($this->receive()); + self::assertSame('1.250000', $first->data['quantity']); + self::assertTrue($replay->replayed); + self::assertSame($first->data['id'], $replay->data['id']); + self::assertSame(3, $this->order->revision()); + self::assertSame('partial_received', $this->order->status()->value); + self::assertSame('1.250000', $this->order->lines()[0]->remainingQuantity()); + } + + #[Test] + public function operationKeyCannotBeReusedForAnotherQuantity(): void + { + ($this->handler)($this->receive()); + $input = $this->input(); + $input['quantity'] = '0.750000'; + $this->expectException(ProcurementException::class); + $this->expectExceptionMessage('different declaration'); + ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'receive', self::ORDER, 2, $input)); + } + + #[Test] + public function receiptBeyondRemainingQuantityNeverCallsStock(): void + { + $this->stock->expects(self::never())->method('receive'); + $input = $this->input(); + $input['quantity'] = '2.750000'; + $this->expectException(ProcurementException::class); + ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'receive', self::ORDER, 2, $input)); + } + + #[Test] + public function anArchivedSupplierDoesNotPreventReceivingAnExistingOrder(): void + { + $this->supplier->archive(1, $this->now); + $this->stock->expects(self::once())->method('receive')->willReturn(new InventoryReceiptResult(self::OPERATION, '1.250000', '4.250000', '5.312500')); + self::assertSame('stock_received', ($this->handler)($this->receive())->data['status']); + } + + #[Test] + public function quantityReceiverHasNoFinancialFieldsInTheResponse(): void + { + $this->finance = false; + $this->stock->expects(self::once())->method('receive')->willReturn(new InventoryReceiptResult(self::OPERATION, '1.250000', '4.250000', '5.312500')); + $result = ($this->handler)($this->receive()); + self::assertFalse($result->data['financialVisible']); + self::assertFalse(array_key_exists('unitCost', $result->data)); + } + + #[Test] + public function changingAnExplicitPriceRequiresTheDedicatedFinancialPermission(): void + { + $this->financeWrite = false; + $this->expectException(ProcurementException::class); + $this->expectExceptionMessage('permission denied'); + ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'create_order', payload: ['name' => 'Price', 'supplierId' => self::SUPPLIER, 'lines' => [['id' => self::LINE, 'kind' => 'part', 'partId' => self::PART, 'quantity' => '1.000000', 'unitCost' => null]]])); + } + + #[Test] + public function quotaFailureKeepsThePhysicalHardwareReceiptPending(): void + { + $this->hardwareReceipt(); + $this->equipment->expects(self::once())->method('reserve')->with(self::callback(static fn (EquipmentReserveReceiptRequest $request): bool => 1 === $request->quantity))->willReturn(new EquipmentReserveReceiptResult([], 'quota_exceeded')); + $result = ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'individualize', self::RECEIPT, 1, ['clientOperationId' => self::OPERATION])); + self::assertSame('awaiting_individualization', $result->data['status']); + self::assertSame('quota_exceeded', $result->data['blockedReason']); + self::assertSame([], $result->data['equipmentIds']); + self::assertSame('1.000000', $result->data['quantity']); + self::assertSame('quota_exceeded', $this->operations[self::OPERATION]->declaration['blockedReason']); + self::assertTrue(($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'individualize', self::RECEIPT, 1, ['clientOperationId' => self::OPERATION]))->replayed); + } + + #[Test] + public function hardwareIndividualizationReplayKeepsOneAssetAndOneLink(): void + { + $this->hardwareReceipt(); + $this->equipment->expects(self::once())->method('reserve')->willReturn(new EquipmentReserveReceiptResult([self::EQUIPMENT], null)); + $command = new ManageProcurementCommand(self::ACTOR, self::ORG, 'individualize', self::RECEIPT, 1, ['clientOperationId' => self::OPERATION]); + $first = ($this->handler)($command); + $replay = ($this->handler)($command); + self::assertSame([self::EQUIPMENT], $first->data['equipmentIds']); + self::assertSame('individualized', $first->data['status']); + self::assertTrue($replay->replayed); + self::assertSame($first->data['equipmentIds'], $replay->data['equipmentIds']); + } + + #[Test] + public function motivatedReturnRetainsEvidenceAndDoesNotReopenPurchasedQuantity(): void + { + $this->order->recordReceipt(2, self::LINE, '2.500000', $this->now); + $this->receipt = new ProcurementReceiptState(self::RECEIPT, self::ORG, self::ORDER, self::LINE, 'part', '2.500000', self::WAREHOUSE, '4.250000', 'EUR', $this->now, self::ACTOR, $this->now, inventoryMovementId: self::OPERATION); + $this->stock->expects(self::once())->method('tryReverse')->with(self::callback(static fn (InventoryReceiptReturnRequest $request): bool => '0.250000' === $request->quantity && 'Damaged package' === $request->reason))->willReturn(new InventoryReceiptResult(self::OPERATION, '0.250000', '4.250000', '1.062500')); + $command = new ManageProcurementCommand(self::ACTOR, self::ORG, 'return', self::RECEIPT, 1, ['clientOperationId' => self::OPERATION, 'quantity' => '0.250000', 'reason' => 'Damaged package']); + $first = ($this->handler)($command); + $replay = ($this->handler)($command); + self::assertSame('0.250000', $first->data['returnedQuantity']); + self::assertSame('received', $this->order->status()->value); + self::assertSame('0.000000', $this->order->lines()[0]->remainingQuantity()); + self::assertSame('Damaged package', $this->operations[self::OPERATION]->declaration['reason']); + self::assertTrue($replay->replayed); + } + + #[Test] + #[DataProvider('denials')] + public function scopeIsCheckedBeforeAnyPhysicalDeclaration(OrganizationAccessDecision $decision, string $expectedCode): void + { + $this->decision = $decision; + $this->repository->expects(self::never())->method('synchronized'); + + try { + ($this->handler)($this->receive()); + self::fail('The operation must be denied.'); + } catch (ProcurementException $exception) { + self::assertSame($expectedCode, $exception->errorCode); + } + } + + /** + * @return iterable + */ + public static function denials(): iterable + { + yield 'outside' => [OrganizationAccessDecision::OUTSIDE_SCOPE, 'not_found']; + yield 'member without permission' => [OrganizationAccessDecision::MISSING_PERMISSION, 'denied']; + } + + #[Test] + public function missingRevisionDoesNotProduceStock(): void + { + $this->stock->expects(self::never())->method('receive'); + + try { + ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'receive', self::ORDER, null, $this->input())); + self::fail('A revision is required.'); + } catch (ProcurementException $exception) { + self::assertSame('revision_required', $exception->errorCode); + } + } + + #[Test] + public function uuidCaseAliasesReplayTheSamePhysicalReceipt(): void + { + $this->stock->expects(self::once())->method('receive')->willReturn(new InventoryReceiptResult(self::OPERATION, '1.250000', '4.250000', '5.312500')); + ($this->handler)($this->receive()); + $input = $this->input(); + foreach (['lineId', 'warehouseId', 'clientOperationId'] as $field) { + self::assertIsString($input[$field]); + $input[$field] = strtoupper($input[$field]); + } + $result = ($this->handler)(new ManageProcurementCommand(strtoupper(self::ACTOR), strtoupper(self::ORG), 'receive', strtoupper(self::ORDER), 2, $input)); + self::assertTrue($result->replayed); + self::assertSame(3, $this->order->revision()); + } + + #[Test] + public function physicalReturnSurvivesStockShortageAndReconcilesWithoutChangingItsDeclaration(): void + { + $this->order->recordReceipt(2, self::LINE, '2.500000', $this->now); + $this->receipt = new ProcurementReceiptState(self::RECEIPT, self::ORG, self::ORDER, self::LINE, 'part', '2.500000', self::WAREHOUSE, '4.250000', 'EUR', $this->now, self::ACTOR, $this->now, inventoryMovementId: self::OPERATION); + $this->reverseResults = [new InventoryReceiptResult('', '0.250000', null, null, blockedReason: 'stock_insufficient'), new InventoryReceiptResult(self::SECOND_OPERATION, '0.250000', '4.250000', '1.062500')]; + $this->stock->expects(self::exactly(2))->method('tryReverse')->willReturn(new InventoryReceiptResult('', '0.250000', null, null, blockedReason: 'stock_insufficient')); + $returned = ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'return', self::RECEIPT, 1, ['clientOperationId' => self::OPERATION, 'quantity' => '0.250000', 'reason' => 'Physical return already performed'])); + self::assertSame('0.250000', $returned->data['returnedQuantity']); + self::assertSame('0.250000', $returned->data['pendingReturnQuantity']); + $declaration = $this->return; + self::assertNotNull($declaration); + self::assertSame('awaiting_reconciliation', $declaration->status); + $confirmed = ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'reconcile_return', $declaration->id, 1, ['clientOperationId' => self::SECOND_OPERATION])); + self::assertSame('confirmed', $confirmed->data['status']); + $confirmedReceipt = $this->receipt; + self::assertNotNull($confirmedReceipt); + self::assertSame('0.250000', $confirmedReceipt->returnedQuantity); + self::assertSame('0.000000', $confirmedReceipt->pendingReturnQuantity); + self::assertSame('Physical return already performed', $declaration->reason); + self::assertSame(self::OPERATION, $declaration->clientOperationId); + } + + private function partLine(): ProcurementLine + { + return ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '2.500000', '4.250000'); + } + + private function hardwareReceipt(): void + { + $line = ProcurementLine::create(self::LINE, 'equipment_to_individualize', null, 'fire_extinguisher', ['name' => 'Reserve extinguisher'], '1.000000', null); + $this->order = PurchaseOrder::create(self::ORDER, self::ORG, self::SUPPLIER, 'EUR', 'Reserve assets', [$line], $this->now); + $this->order->order(1, $this->now); + $this->order->recordReceipt(2, self::LINE, '1.000000', $this->now); + $this->receipt = new ProcurementReceiptState(self::RECEIPT, self::ORG, self::ORDER, self::LINE, 'equipment_to_individualize', '1.000000', null, null, 'EUR', $this->now, self::ACTOR, $this->now); + } + + private function receive(): ManageProcurementCommand + { + return new ManageProcurementCommand(self::ACTOR, self::ORG, 'receive', self::ORDER, 2, $this->input()); + } + + /** + * @return array + */ + private function input(): array + { + return ['lineId' => self::LINE, 'warehouseId' => self::WAREHOUSE, 'quantity' => '1.250000', 'receivedAt' => '2026-10-06T10:00:00Z', 'clientOperationId' => self::OPERATION]; + } +} diff --git a/tests/Unit/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementHandlerTest.php b/tests/Unit/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementHandlerTest.php new file mode 100644 index 000000000..773ac6633 --- /dev/null +++ b/tests/Unit/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementHandlerTest.php @@ -0,0 +1,521 @@ +repository = $this->createMock(ProcurementRepositoryPort::class); + $this->authorization = $this->createMock(OrganizationAuthorizationPort::class); + foreach (['synchronized', 'saveSupplier', 'saveOrder', 'saveReceipt', 'saveOperation'] as $method) { + $this->repository->expects(self::never())->method($method); + } + } + + /** + * Method testSupplierListUsesSearchArchiveFilterAndBoundedPagination + * + * @access public + * + * @return void + */ + #[Test] + public function testSupplierListUsesSearchArchiveFilterAndBoundedPagination(): void + { + $this->allow(false); + $this->repository->expects(self::once())->method('suppliers')->with(self::ORGANIZATION, 'Safety', true, 40, 20)->willReturn([$this->supplier()]); + $this->repository->expects(self::once())->method('countSuppliers')->with(self::ORGANIZATION, 'Safety', true)->willReturn(41); + + $result = $this->handler()(new ReadProcurementQuery(self::ACTOR, self::ORGANIZATION, 'suppliers', search: 'Safety', archived: true, page: 3, itemsPerPage: 20)); + + self::assertSame('supplier', $result->kind); + self::assertTrue($result->collection); + self::assertSame(41, $result->total); + self::assertSame(3, $result->page); + self::assertSame(20, $result->itemsPerPage); + self::assertSame(self::SUPPLIER, $result->items[0]['id']); + self::assertSame(self::ORGANIZATION, $result->items[0]['organizationId']); + self::assertSame('Supplier', $result->items[0]['name']); + self::assertSame('sales@example.com', $result->items[0]['email']); + self::assertSame(1, $result->items[0]['revision']); + } + + /** + * Method testSupplierItemIsScopedAndReturnsSingleProjection + * + * @access public + * + * @return void + */ + #[Test] + public function testSupplierItemIsScopedAndReturnsSingleProjection(): void + { + $this->allow(false); + $this->repository->expects(self::once())->method('supplier')->with(self::ORGANIZATION, self::SUPPLIER)->willReturn($this->supplier()); + + $result = $this->handler()(new ReadProcurementQuery(self::ACTOR, self::ORGANIZATION, 'supplier', self::SUPPLIER)); + + self::assertFalse($result->collection); + self::assertSame(1, $result->total); + self::assertSame('Supplier', $result->items[0]['name']); + self::assertNull($result->items[0]['archivedAt']); + } + + /** + * Keeps nullable all filtering scoped, searched and paginated through the read use case. + */ + #[Test] + public function testAllSupplierFilterKeepsScopedPaginationAndCount(): void + { + $this->allow(false); + $this->repository->expects(self::once())->method('suppliers')->with(self::ORGANIZATION, 'Safety', null, 30, 30)->willReturn([$this->supplier()]); + $this->repository->expects(self::once())->method('countSuppliers')->with(self::ORGANIZATION, 'Safety', null)->willReturn(31); + + $result = $this->handler()(new ReadProcurementQuery(self::ACTOR, self::ORGANIZATION, 'suppliers', search: 'Safety', archived: null, page: 2)); + + self::assertSame(31, $result->total); + self::assertSame(2, $result->page); + self::assertSame(self::SUPPLIER, $result->items[0]['id']); + } + + /** + * Method testOrderCostsRequireIndependentFinancialPermission + * + * @access public + * + * @param bool $finance whether internal amounts may be disclosed + * + * @return void + */ + #[Test] + #[DataProvider('financialVisibility')] + public function testOrderCostsRequireIndependentFinancialPermission(bool $finance): void + { + $this->allow($finance); + $this->repository->expects(self::once())->method('order')->with(self::ORGANIZATION, self::ORDER)->willReturn($this->order()); + + $result = $this->handler()(new ReadProcurementQuery(self::ACTOR, self::ORGANIZATION, 'order', self::ORDER)); + + self::assertSame('order', $result->kind); + self::assertFalse($result->collection); + self::assertSame($finance, $result->items[0]['financialVisible']); + self::assertSame('EUR', $result->items[0]['currency']); + self::assertSame('draft', $result->items[0]['status']); + /** @var list> $lines */ + $lines = $result->items[0]['lines']; + self::assertSame('0.250000', $lines[0]['quantity']); + self::assertSame('0.000000', $lines[0]['receivedQuantity']); + self::assertSame($finance, array_key_exists('unitCost', $lines[0])); + if ($finance) { + self::assertSame('12.123456', $lines[0]['unitCost']); + } + } + + /** + * Method testOrderListUsesStatusSupplierFilterAndMatchingCount + * + * @access public + * + * @return void + */ + #[Test] + public function testOrderListUsesStatusSupplierFilterAndMatchingCount(): void + { + $this->allow(false); + $this->repository->expects(self::once())->method('orders')->with(self::ORGANIZATION, 'draft', self::SUPPLIER, 30, 30)->willReturn([$this->order()]); + $this->repository->expects(self::once())->method('countOrders')->with(self::ORGANIZATION, 'draft', self::SUPPLIER)->willReturn(31); + + $result = $this->handler()(new ReadProcurementQuery(self::ACTOR, self::ORGANIZATION, 'orders', status: 'draft', supplierId: self::SUPPLIER, page: 2)); + + self::assertTrue($result->collection); + self::assertSame(31, $result->total); + self::assertSame(2, $result->page); + self::assertSame(30, $result->itemsPerPage); + self::assertFalse($result->items[0]['financialVisible']); + /** @var list> $lines */ + $lines = $result->items[0]['lines']; + self::assertArrayNotHasKey('unitCost', $lines[0]); + } + + /** + * Method testReceiptCostIsHiddenWithoutFinancialPermission + * + * @access public + * + * @param bool $finance whether internal amounts may be disclosed + * + * @return void + */ + #[Test] + #[DataProvider('financialVisibility')] + public function testReceiptCostIsHiddenWithoutFinancialPermission(bool $finance): void + { + $this->allow($finance); + $this->repository->expects(self::once())->method('receipt')->with(self::ORGANIZATION, self::RECEIPT)->willReturn($this->receipt()); + + $result = $this->handler()(new ReadProcurementQuery(self::ACTOR, self::ORGANIZATION, 'receipt', self::RECEIPT)); + + self::assertSame('receipt', $result->kind); + self::assertFalse($result->collection); + self::assertSame('0.250000', $result->items[0]['quantity']); + self::assertSame('stock_received', $result->items[0]['status']); + self::assertSame($finance, $result->items[0]['financialVisible']); + self::assertSame($finance, array_key_exists('unitCost', $result->items[0])); + if ($finance) { + self::assertSame('12.123456', $result->items[0]['unitCost']); + } + } + + /** + * Method financialVisibility + * + * @access public + * + * @return iterable independent cost visibility decisions + */ + public static function financialVisibility(): iterable + { + yield 'operational user' => [false]; + yield 'financial user' => [true]; + } + + /** + * Method testReceiptListFirstResolvesOrderInsideScope + * + * @access public + * + * @return void + */ + #[Test] + public function testReceiptListFirstResolvesOrderInsideScope(): void + { + $this->allow(false); + $this->repository->expects(self::once())->method('order')->with(self::ORGANIZATION, self::ORDER)->willReturn($this->order()); + $this->repository->expects(self::once())->method('receipts')->with(self::ORGANIZATION, self::ORDER, 20, 10)->willReturn([$this->receipt()]); + $this->repository->expects(self::once())->method('countReceipts')->with(self::ORGANIZATION, self::ORDER)->willReturn(21); + + $result = $this->handler()(new ReadProcurementQuery(self::ACTOR, self::ORGANIZATION, 'receipts', self::ORDER, page: 3, itemsPerPage: 10)); + + self::assertTrue($result->collection); + self::assertSame(21, $result->total); + self::assertSame(3, $result->page); + self::assertSame(self::RECEIPT, $result->items[0]['id']); + self::assertArrayNotHasKey('unitCost', $result->items[0]); + } + + /** + * Method testReceiptListOfUnknownOrderDoesNotEnumerateReceipts + * + * @access public + * + * @return void + */ + #[Test] + public function testReceiptListOfUnknownOrderDoesNotEnumerateReceipts(): void + { + $this->allow(false); + $this->repository->expects(self::once())->method('order')->with(self::ORGANIZATION, self::ORDER)->willReturn(null); + $this->repository->expects(self::never())->method('receipts'); + $this->repository->expects(self::never())->method('countReceipts'); + $this->expectException(ProcurementException::class); + + $this->handler()(new ReadProcurementQuery(self::ACTOR, self::ORGANIZATION, 'receipts', self::ORDER)); + } + + /** + * Method testScopeAndPermissionDenialsOccurBeforeAnyRecordRead + * + * @access public + * + * @param OrganizationAccessDecision $decision the authorization outcome + * @param string $expectedCode the domain code for central HTTP mapping + * + * @return void + */ + #[Test] + #[DataProvider('denials')] + public function testScopeAndPermissionDenialsOccurBeforeAnyRecordRead(OrganizationAccessDecision $decision, string $expectedCode): void + { + $this->authorization->expects(self::once())->method('resolveAccess')->with(self::ACTOR, self::ORGANIZATION, 'organization.procurement.read')->willReturn($decision); + $this->authorization->expects(self::never())->method('hasPermission'); + foreach (['supplier', 'suppliers', 'order', 'orders', 'receipt', 'receipts'] as $method) { + $this->repository->expects(self::never())->method($method); + } + + try { + $this->handler()(new ReadProcurementQuery(self::ACTOR, self::ORGANIZATION, 'order', self::ORDER)); + self::fail('Expected scoped access denial.'); + } catch (ProcurementException $exception) { + self::assertSame($expectedCode, $exception->errorCode); + } + } + + /** + * Method denials + * + * @access public + * + * @return iterable scope-aware denial mappings + */ + public static function denials(): iterable + { + yield 'outside organization becomes 404' => [OrganizationAccessDecision::OUTSIDE_SCOPE, 'not_found']; + yield 'member missing permission becomes 403' => [OrganizationAccessDecision::MISSING_PERMISSION, 'denied']; + } + + /** + * Method testUnknownItemIsNotFoundInsideOrganization + * + * @access public + * + * @param string $action the detail action + * @param non-empty-string $method the repository method + * + * @return void + */ + #[Test] + #[DataProvider('unknownItems')] + public function testUnknownItemIsNotFoundInsideOrganization(string $action, string $method): void + { + $this->allow(false); + $this->repository->expects(self::once())->method($method)->with(self::ORGANIZATION, self::ORDER)->willReturn(null); + + try { + $this->handler()(new ReadProcurementQuery(self::ACTOR, self::ORGANIZATION, $action, self::ORDER)); + self::fail('Expected missing item.'); + } catch (ProcurementException $exception) { + self::assertSame('not_found', $exception->errorCode); + } + } + + /** + * Method unknownItems + * + * @access public + * + * @return iterable unknown detail resources + */ + public static function unknownItems(): iterable + { + yield 'supplier' => ['supplier', 'supplier']; + yield 'order' => ['order', 'order']; + yield 'receipt' => ['receipt', 'receipt']; + } + + /** + * Method testPaginationIsClampedByApplication + * + * @access public + * + * @param int $requestedSize the untrusted page size + * @param int $boundedSize the final page size + * + * @return void + */ + #[Test] + #[DataProvider('pageSizes')] + public function testPaginationIsClampedByApplication(int $requestedSize, int $boundedSize): void + { + $this->allow(false); + $this->repository->expects(self::once())->method('suppliers')->with(self::ORGANIZATION, '', false, 0, $boundedSize)->willReturn([]); + $this->repository->expects(self::once())->method('countSuppliers')->with(self::ORGANIZATION, '', false)->willReturn(0); + + $result = $this->handler()(new ReadProcurementQuery(self::ACTOR, self::ORGANIZATION, 'suppliers', page: -3, itemsPerPage: $requestedSize)); + + self::assertSame(1, $result->page); + self::assertSame($boundedSize, $result->itemsPerPage); + self::assertSame([], $result->items); + } + + /** + * Method pageSizes + * + * @access public + * + * @return iterable bounded page sizes + */ + public static function pageSizes(): iterable + { + yield 'maximum' => [500, 100]; + yield 'minimum' => [0, 1]; + } + + /** + * Method testUnknownReadActionIsRejected + * + * @access public + * + * @return void + */ + #[Test] + public function testUnknownReadActionIsRejected(): void + { + $this->allow(false); + $this->expectException(ProcurementException::class); + $this->expectExceptionMessage('Unknown procurement read.'); + + $this->handler()(new ReadProcurementQuery(self::ACTOR, self::ORGANIZATION, 'unknown')); + } + + /** + * Method allow + * + * @access private + * + * @param bool $finance the independent monetary permission + * + * @return void + */ + private function allow(bool $finance): void + { + $this->authorization->expects(self::once())->method('resolveAccess')->with(self::ACTOR, self::ORGANIZATION, 'organization.procurement.read')->willReturn(OrganizationAccessDecision::GRANTED); + $this->authorization->expects(self::once())->method('hasPermission')->with(self::ACTOR, self::ORGANIZATION, 'organization.maintenance_cost.read')->willReturn($finance); + } + + /** + * Method handler + * + * @access private + * + * @return ReadProcurementHandler the handler with mocked ports + */ + private function handler(): ReadProcurementHandler + { + return new ReadProcurementHandler($this->repository, $this->authorization, new ProcurementProjection()); + } + + /** + * Method supplier + * + * @access private + * + * @return Supplier the operational supplier fixture + */ + private function supplier(): Supplier + { + return Supplier::create(self::SUPPLIER, self::ORGANIZATION, 'Supplier', 'SUP', 'sales@example.com', null, [], $this->now()); + } + + /** + * Method order + * + * @access private + * + * @return PurchaseOrder an order retaining a known exact cost + */ + private function order(): PurchaseOrder + { + $line = ProcurementLine::create(self::LINE, 'part', '018fa007-1111-7111-8111-111111111111', null, [], '0.25', '12.123456'); + + return PurchaseOrder::create(self::ORDER, self::ORGANIZATION, self::SUPPLIER, 'EUR', 'Order', [$line], $this->now()); + } + + /** + * Method receipt + * + * @access private + * + * @return ProcurementReceiptState a physical receipt + */ + private function receipt(): ProcurementReceiptState + { + return new ProcurementReceiptState(self::RECEIPT, self::ORGANIZATION, self::ORDER, self::LINE, 'part', '0.250000', '018fa008-1111-7111-8111-111111111111', '12.123456', 'EUR', $this->now(), self::ACTOR, $this->now(), inventoryMovementId: '018fa009-1111-7111-8111-111111111111'); + } + + /** + * Method now + * + * @access private + * + * @return DateTimeImmutable the explicit fixture instant + */ + private function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-10-06T10:00:00+00:00'); + } + // #endregion +} diff --git a/tests/Unit/Procurement/Domain/Model/PurchaseOrderTest.php b/tests/Unit/Procurement/Domain/Model/PurchaseOrderTest.php new file mode 100644 index 000000000..23d2b0f40 --- /dev/null +++ b/tests/Unit/Procurement/Domain/Model/PurchaseOrderTest.php @@ -0,0 +1,508 @@ +purchaseOrder(); + + self::assertSame(self::ORDER, $order->id); + self::assertSame(self::ORGANIZATION, $order->organizationId); + self::assertSame(self::SUPPLIER, $order->supplierId()); + self::assertSame('EUR', $order->currency()); + self::assertSame('Parts replenishment', $order->name()); + self::assertSame(PurchaseOrderStatus::DRAFT, $order->status()); + self::assertSame(1, $order->revision()); + self::assertSame(self::LINE, $order->lines()[0]->id); + self::assertSame('5.000000', $order->lines()[0]->quantity); + self::assertFalse($order->cancelledRemaining()); + } + + /** + * Method testOnlyDraftCanChangeSupplierCurrencyNameAndLines + * + * @access public + * + * @return void + */ + #[Test] + public function testOnlyDraftCanChangeSupplierCurrencyNameAndLines(): void + { + $order = $this->purchaseOrder(); + $line = $this->line('10', '018fa006-1111-7111-8111-111111111111'); + $order->changeDraft(1, self::SUPPLIER, 'USD', 'Updated replenishment', [$line], $this->now()); + + self::assertSame('USD', $order->currency()); + self::assertSame('Updated replenishment', $order->name()); + self::assertSame([$line], $order->lines()); + self::assertSame(2, $order->revision()); + $order->order(2, $this->now()); + self::assertSame(PurchaseOrderStatus::ORDERED, $order->status()); + $this->expectException(ProcurementException::class); + $this->expectExceptionMessage('draft'); + + $order->changeDraft(3, self::SUPPLIER, 'EUR', 'Forbidden change', [], $this->now()); + } + + /** + * Method testPartialReceiptsAdvanceLifecycleAcrossAllLines + * + * @access public + * + * @return void + */ + #[Test] + public function testPartialReceiptsAdvanceLifecycleAcrossAllLines(): void + { + $equipmentLineId = '018fa006-1111-7111-8111-111111111111'; + $equipment = ProcurementLine::create($equipmentLineId, 'equipment_to_individualize', null, 'fire_extinguisher', ['name' => 'Extinguisher'], '2', '100'); + $order = $this->purchaseOrder([$this->line('5'), $equipment]); + $order->order(1, $this->now()); + $order->recordReceipt(2, self::LINE, '2', $this->now()); + + self::assertSame(PurchaseOrderStatus::PARTIAL_RECEIVED, $order->status()); + self::assertSame('2.000000', $order->lines()[0]->receivedQuantity); + self::assertSame('3.000000', $order->lines()[0]->remainingQuantity()); + + $order->recordReceipt(3, self::LINE, '3', $this->now()); + self::assertSame(PurchaseOrderStatus::PARTIAL_RECEIVED, $order->status()); + $order->recordReceipt(4, $equipmentLineId, '2', $this->now()); + + self::assertSame(PurchaseOrderStatus::RECEIVED, $order->status()); + self::assertSame(5, $order->revision()); + self::assertSame('0.000000', $order->lines()[1]->remainingQuantity()); + self::assertSame('equipment_to_individualize', $order->lines()[1]->kind); + self::assertEquals($this->now(), $order->updatedAt()); + } + + /** + * Method testFractionalConsumableReceiptIsExact + * + * @access public + * + * @return void + */ + #[Test] + public function testFractionalConsumableReceiptIsExact(): void + { + $order = $this->purchaseOrder([$this->line('0.3')]); + $order->order(1, $this->now()); + $order->recordReceipt(2, self::LINE, '0.1', $this->now()); + $order->recordReceipt(3, self::LINE, '0.2', $this->now()); + + self::assertSame('0.300000', $order->lines()[0]->receivedQuantity); + self::assertSame('0.000000', $order->lines()[0]->remainingQuantity()); + self::assertSame(PurchaseOrderStatus::RECEIVED, $order->status()); + } + + /** + * Method testCancellationRetainsReceiptsAndAllowsOnlyBoundedReturns + * + * @access public + * + * @return void + */ + #[Test] + public function testCancellationRetainsReceiptsAndAllowsOnlyBoundedReturns(): void + { + $order = $this->purchaseOrder(); + $order->order(1, $this->now()); + $order->recordReceipt(2, self::LINE, '2', $this->now()); + $order->cancelRemaining(3, $this->now()); + $order->recordReturn(4, self::LINE, '0.5', $this->now()); + + self::assertSame(PurchaseOrderStatus::CANCELLED, $order->status()); + self::assertTrue($order->cancelledRemaining()); + self::assertSame('2.000000', $order->lines()[0]->receivedQuantity); + self::assertSame('0.500000', $order->lines()[0]->returnedQuantity); + self::assertSame('3.000000', $order->lines()[0]->remainingQuantity()); + self::assertSame('1.500000', $order->lines()[0]->returnableQuantity()); + self::assertSame(5, $order->revision()); + $this->expectException(ProcurementException::class); + + $order->recordReceipt(5, self::LINE, '1', $this->now()); + } + + /** + * Method testReturnedGoodsDoNotReopenFullyReceivedOrder + * + * @access public + * + * @return void + */ + #[Test] + public function testReturnedGoodsDoNotReopenFullyReceivedOrder(): void + { + $order = $this->purchaseOrder(); + $order->order(1, $this->now()); + $order->recordReceipt(2, self::LINE, '5', $this->now()); + $order->recordReturn(3, self::LINE, '1.5', $this->now()); + + self::assertSame(PurchaseOrderStatus::RECEIVED, $order->status()); + self::assertSame('5.000000', $order->lines()[0]->receivedQuantity); + self::assertSame('1.500000', $order->lines()[0]->returnedQuantity); + self::assertSame('0.000000', $order->lines()[0]->remainingQuantity()); + self::assertSame('3.500000', $order->lines()[0]->returnableQuantity()); + } + + /** + * Method testDraftCannotReceiveGoods + * + * @access public + * + * @return void + */ + #[Test] + public function testDraftCannotReceiveGoods(): void + { + $order = $this->purchaseOrder(); + $this->expectException(ProcurementException::class); + + $order->recordReceipt(1, self::LINE, '1', $this->now()); + } + + /** + * Method testEmptyDraftCannotBeOrdered + * + * @access public + * + * @return void + */ + #[Test] + public function testEmptyDraftCannotBeOrdered(): void + { + $order = $this->purchaseOrder([]); + $this->expectException(ProcurementException::class); + + $order->order(1, $this->now()); + } + + /** + * Method testOverReceptionDoesNotPartiallyAdvanceOrder + * + * @access public + * + * @return void + */ + #[Test] + public function testOverReceptionDoesNotPartiallyAdvanceOrder(): void + { + $order = $this->purchaseOrder(); + $order->order(1, $this->now()); + + try { + $order->recordReceipt(2, self::LINE, '5.000001', $this->now()); + self::fail('Expected an over-reception failure.'); + } catch (ProcurementException $exception) { + self::assertSame('invalid', $exception->errorCode); + } + + self::assertSame(PurchaseOrderStatus::ORDERED, $order->status()); + self::assertSame(2, $order->revision()); + self::assertSame('0.000000', $order->lines()[0]->receivedQuantity); + } + + /** + * Method testStaleRevisionCannotCreateAnotherReceipt + * + * @access public + * + * @return void + */ + #[Test] + public function testStaleRevisionCannotCreateAnotherReceipt(): void + { + $order = $this->purchaseOrder(); + $order->order(1, $this->now()); + $order->recordReceipt(2, self::LINE, '1', $this->now()); + + try { + $order->recordReceipt(2, self::LINE, '1', $this->now()); + self::fail('Expected a stale revision.'); + } catch (ProcurementException $exception) { + self::assertSame('stale', $exception->errorCode); + } + + self::assertSame('1.000000', $order->lines()[0]->receivedQuantity); + self::assertSame(3, $order->revision()); + } + + /** + * Method testUnknownLineCannotReceiveAgainstAnotherOrder + * + * @access public + * + * @return void + */ + #[Test] + public function testUnknownLineCannotReceiveAgainstAnotherOrder(): void + { + $order = $this->purchaseOrder(); + $order->order(1, $this->now()); + $this->expectException(ProcurementException::class); + + $order->recordReceipt(2, '018fa099-1111-7111-8111-111111111111', '1', $this->now()); + } + + /** + * Method testGrossReceiptHistoryCannotBeIntroducedIntoDraft + * + * @access public + * + * @return void + */ + #[Test] + public function testGrossReceiptHistoryCannotBeIntroducedIntoDraft(): void + { + $this->expectException(ProcurementException::class); + + $this->purchaseOrder([$this->line()->receive('1')]); + } + + /** + * Method testDuplicateLineIdsAreRejected + * + * @access public + * + * @return void + */ + #[Test] + public function testDuplicateLineIdsAreRejected(): void + { + $this->expectException(ProcurementException::class); + + $this->purchaseOrder([$this->line('1'), $this->line('2')]); + } + + /** + * Method testInvalidDraftChangeLeavesExistingOrderIntact + * + * @access public + * + * @return void + */ + #[Test] + public function testInvalidDraftChangeLeavesExistingOrderIntact(): void + { + $order = $this->purchaseOrder(); + + try { + $order->changeDraft(1, self::SUPPLIER, 'EUR', 'New name', [$this->line('1'), $this->line('2')], $this->now()); + self::fail('Expected duplicate replacement lines to fail.'); + } catch (ProcurementException $exception) { + self::assertSame('invalid', $exception->errorCode); + } + + self::assertSame('Parts replenishment', $order->name()); + self::assertSame(1, $order->revision()); + self::assertCount(1, $order->lines()); + self::assertSame('5.000000', $order->lines()[0]->quantity); + } + + /** + * Method testRestorationRejectsStateNotMatchingGrossReceipts + * + * @access public + * + * @param PurchaseOrderStatus $status the inconsistent persisted lifecycle + * @param string $received the persisted gross receipt amount + * + * @return void + */ + #[Test] + #[DataProvider('inconsistentHistory')] + public function testRestorationRejectsStateNotMatchingGrossReceipts(PurchaseOrderStatus $status, string $received): void + { + $line = ProcurementLine::reconstitute(self::LINE, 'part', self::PART, null, [], '5', null, $received, '0'); + $this->expectException(ProcurementException::class); + + PurchaseOrder::reconstitute(self::ORDER, self::ORGANIZATION, self::SUPPLIER, 'EUR', 'Restored order', [$line], $status, 3, $this->now(), $this->now()); + } + + /** + * Method inconsistentHistory + * + * @access public + * + * @return iterable impossible order lifecycles + */ + public static function inconsistentHistory(): iterable + { + yield 'ordered with physical receipts' => [PurchaseOrderStatus::ORDERED, '1']; + yield 'received without all deliveries' => [PurchaseOrderStatus::RECEIVED, '1']; + yield 'partial without receipts' => [PurchaseOrderStatus::PARTIAL_RECEIVED, '0']; + yield 'partial with all receipts' => [PurchaseOrderStatus::PARTIAL_RECEIVED, '5']; + yield 'draft with receipts' => [PurchaseOrderStatus::DRAFT, '1']; + } + + /** + * Method testRestorationRetainsCancelledRemainderAndSupplierReturns + * + * @access public + * + * @return void + */ + #[Test] + public function testRestorationRetainsCancelledRemainderAndSupplierReturns(): void + { + $line = ProcurementLine::reconstitute(self::LINE, 'part', self::PART, null, [], '5', '10', '2', '0.5'); + $order = PurchaseOrder::reconstitute(self::ORDER, self::ORGANIZATION, self::SUPPLIER, 'EUR', 'Restored order', [$line], PurchaseOrderStatus::CANCELLED, 7, $this->now(), $this->now()); + + self::assertSame(7, $order->revision()); + self::assertTrue($order->cancelledRemaining()); + self::assertSame('2.000000', $order->lines()[0]->receivedQuantity); + self::assertSame('0.500000', $order->lines()[0]->returnedQuantity); + self::assertSame('10.000000', $order->lines()[0]->unitCost); + } + + /** + * Method testFullyReceivedOrderCannotCancelRemaining + * + * @access public + * + * @return void + */ + #[Test] + public function testFullyReceivedOrderCannotCancelRemaining(): void + { + $order = $this->purchaseOrder(); + $order->order(1, $this->now()); + $order->recordReceipt(2, self::LINE, '5', $this->now()); + $this->expectException(ProcurementException::class); + + $order->cancelRemaining(3, $this->now()); + } + + /** + * Method testRepeatedCancellationAtCurrentRevisionDoesNotChangeHistory + * + * @access public + * + * @return void + */ + #[Test] + public function testRepeatedCancellationAtCurrentRevisionDoesNotChangeHistory(): void + { + $order = $this->purchaseOrder(); + $order->cancelRemaining(1, $this->now()); + $order->cancelRemaining(2, $this->now()); + + self::assertSame(PurchaseOrderStatus::CANCELLED, $order->status()); + self::assertSame(2, $order->revision()); + self::assertSame('0.000000', $order->lines()[0]->receivedQuantity); + } + + /** + * Method testInvalidCurrencyIsRejected + * + * @access public + * + * @return void + */ + #[Test] + public function testInvalidCurrencyIsRejected(): void + { + $this->expectException(ProcurementException::class); + + PurchaseOrder::create(self::ORDER, self::ORGANIZATION, self::SUPPLIER, 'eur', 'Order', [], $this->now()); + } + + /** + * Method purchaseOrder + * + * @access private + * + * @param ?list $lines the draft lines, or the default replenishment + * + * @return PurchaseOrder the draft order + */ + private function purchaseOrder(?array $lines = null): PurchaseOrder + { + return PurchaseOrder::create(self::ORDER, self::ORGANIZATION, self::SUPPLIER, 'EUR', 'Parts replenishment', $lines ?? [$this->line()], $this->now()); + } + + /** + * Method line + * + * @access private + * + * @param string $quantity the exact ordered quantity + * @param string $id the stable line identifier + * + * @return ProcurementLine the immutable draft line + */ + private function line(string $quantity = '5', string $id = self::LINE): ProcurementLine + { + return ProcurementLine::create($id, 'part', self::PART, null, [], $quantity, '10.123456'); + } + + /** + * Method now + * + * @access private + * + * @return DateTimeImmutable the explicit operation instant + */ + private function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-10-06T10:00:00+00:00'); + } + // #endregion +} diff --git a/tests/Unit/Procurement/Domain/Model/SupplierTest.php b/tests/Unit/Procurement/Domain/Model/SupplierTest.php new file mode 100644 index 000000000..09608168b --- /dev/null +++ b/tests/Unit/Procurement/Domain/Model/SupplierTest.php @@ -0,0 +1,307 @@ + ' Alice ', 'email' => ' alice@example.com ', 'role' => ' Sales ']], $this->now()); + + self::assertSame(self::SUPPLIER, $supplier->id); + self::assertSame(self::ORGANIZATION, $supplier->organizationId); + self::assertSame('Supplier Example', $supplier->name()); + self::assertSame('SUP-1', $supplier->code()); + self::assertSame('purchases@example.com', $supplier->email()); + self::assertSame('01 23 45 67 89', $supplier->phone()); + self::assertSame([['name' => 'Alice', 'email' => 'alice@example.com', 'phone' => null, 'role' => 'Sales']], $supplier->contacts()); + self::assertTrue($supplier->isActive()); + self::assertNull($supplier->archivedAt()); + self::assertSame(1, $supplier->revision()); + self::assertEquals($this->now(), $supplier->createdAt); + self::assertEquals($this->now(), $supplier->updatedAt()); + } + + /** + * Method testBlankOptionalFieldsBecomeNull + * + * @access public + * + * @return void + */ + #[Test] + public function testBlankOptionalFieldsBecomeNull(): void + { + $supplier = Supplier::create(self::SUPPLIER, self::ORGANIZATION, 'Example', ' ', ' ', ' ', [], $this->now()); + + self::assertNull($supplier->code()); + self::assertNull($supplier->email()); + self::assertNull($supplier->phone()); + self::assertSame([], $supplier->contacts()); + } + + /** + * Method testActiveChangeAdvancesRevisionAndRetainsIdentity + * + * @access public + * + * @return void + */ + #[Test] + public function testActiveChangeAdvancesRevisionAndRetainsIdentity(): void + { + $supplier = $this->supplier(); + $changedAt = new DateTimeImmutable('2026-10-07T10:00:00+00:00'); + $supplier->change(1, 'Changed supplier', 'NEW', 'new@example.com', '+33123456789', [['name' => 'Bob']], $changedAt); + + self::assertSame(self::SUPPLIER, $supplier->id); + self::assertSame(self::ORGANIZATION, $supplier->organizationId); + self::assertSame('Changed supplier', $supplier->name()); + self::assertSame('NEW', $supplier->code()); + self::assertSame([['name' => 'Bob', 'email' => null, 'phone' => null, 'role' => null]], $supplier->contacts()); + self::assertSame(2, $supplier->revision()); + self::assertEquals($changedAt, $supplier->updatedAt()); + self::assertEquals($this->now(), $supplier->createdAt); + } + + /** + * Method testInvalidReplacementDoesNotPartiallyChangeSupplier + * + * @access public + * + * @return void + */ + #[Test] + public function testInvalidReplacementDoesNotPartiallyChangeSupplier(): void + { + $supplier = $this->supplier(); + + try { + $supplier->change(1, 'New name', 'NEW', 'invalid-email', null, [], $this->now()); + self::fail('Expected invalid replacement contact data.'); + } catch (ProcurementException $exception) { + self::assertSame('invalid', $exception->errorCode); + } + + self::assertSame('Original supplier', $supplier->name()); + self::assertNull($supplier->code()); + self::assertSame('supplier@example.com', $supplier->email()); + self::assertSame(1, $supplier->revision()); + } + + /** + * Method testArchivalRetainsContactsAndStopsEdits + * + * @access public + * + * @return void + */ + #[Test] + public function testArchivalRetainsContactsAndStopsEdits(): void + { + $supplier = $this->supplier(); + $archivedAt = new DateTimeImmutable('2026-10-07T10:00:00+00:00'); + $supplier->archive(1, $archivedAt); + $supplier->archive(2, new DateTimeImmutable('2026-10-08T10:00:00+00:00')); + + self::assertFalse($supplier->isActive()); + self::assertEquals($archivedAt, $supplier->archivedAt()); + self::assertEquals($archivedAt, $supplier->updatedAt()); + self::assertSame('supplier@example.com', $supplier->email()); + self::assertSame(2, $supplier->revision()); + $this->expectException(ProcurementException::class); + $this->expectExceptionMessage('archived'); + + $supplier->change(2, 'Changed', null, null, null, [], $archivedAt); + } + + /** + * Method testStaleRevisionRejectsMutationWithoutChanges + * + * @access public + * + * @return void + */ + #[Test] + public function testStaleRevisionRejectsMutationWithoutChanges(): void + { + $supplier = $this->supplier(); + + try { + $supplier->archive(0, $this->now()); + self::fail('Expected stale revision.'); + } catch (ProcurementException $exception) { + self::assertSame('stale', $exception->errorCode); + } + + self::assertTrue($supplier->isActive()); + self::assertSame(1, $supplier->revision()); + } + + /** + * Method testContactStructureAndValuesAreValidated + * + * @access public + * + * @param array $contacts the invalid contact data + * + * @return void + */ + #[Test] + #[DataProvider('invalidContacts')] + public function testContactStructureAndValuesAreValidated(array $contacts): void + { + $this->expectException(ProcurementException::class); + + Supplier::create(self::SUPPLIER, self::ORGANIZATION, 'Example', null, null, null, $contacts, $this->now()); + } + + /** + * Method invalidContacts + * + * @access public + * + * @return iterable}> malformed contacts + */ + public static function invalidContacts(): iterable + { + yield 'not a list' => [['contact' => ['name' => 'Alice']]]; + yield 'missing name' => [[['email' => 'alice@example.com']]]; + yield 'blank name' => [[['name' => ' ']]]; + yield 'invalid email' => [[['name' => 'Alice', 'email' => 'invalid']]]; + yield 'unknown executable field' => [[['name' => 'Alice', 'callback' => 'run']]]; + yield 'non-string phone' => [[['name' => 'Alice', 'phone' => 123]]]; + yield 'too many contacts' => [array_fill(0, 51, ['name' => 'Alice'])]; + } + + /** + * Method testInvalidUuidIsRejected + * + * @access public + * + * @return void + */ + #[Test] + public function testInvalidUuidIsRejected(): void + { + $this->expectException(InvalidValueException::class); + + Supplier::create('invalid', self::ORGANIZATION, 'Example', null, null, null, [], $this->now()); + } + + /** + * Method testRestorationKeepsArchiveAndRevision + * + * @access public + * + * @return void + */ + #[Test] + public function testRestorationKeepsArchiveAndRevision(): void + { + $archivedAt = new DateTimeImmutable('2026-10-07T10:00:00+00:00'); + $supplier = Supplier::reconstitute(self::SUPPLIER, self::ORGANIZATION, 'Supplier', 'SUP', 'supplier@example.com', null, [], $archivedAt, $this->now(), $archivedAt, 4); + + self::assertFalse($supplier->isActive()); + self::assertSame(4, $supplier->revision()); + self::assertSame('SUP', $supplier->code()); + self::assertEquals($this->now(), $supplier->createdAt); + self::assertEquals($archivedAt, $supplier->archivedAt()); + } + + /** + * Method testRestorationRejectsNonPositiveRevision + * + * @access public + * + * @return void + */ + #[Test] + public function testRestorationRejectsNonPositiveRevision(): void + { + $this->expectException(ProcurementException::class); + + Supplier::reconstitute(self::SUPPLIER, self::ORGANIZATION, 'Supplier', null, null, null, [], null, $this->now(), $this->now(), 0); + } + + /** + * Method testUpdateCannotMoveHistoryBackwards + * + * @access public + * + * @return void + */ + #[Test] + public function testUpdateCannotMoveHistoryBackwards(): void + { + $supplier = $this->supplier(); + $this->expectException(ProcurementException::class); + + $supplier->change(1, 'Changed', null, null, null, [], new DateTimeImmutable('2026-10-05T10:00:00+00:00')); + } + + /** + * Method supplier + * + * @access private + * + * @return Supplier an active supplier + */ + private function supplier(): Supplier + { + return Supplier::create(self::SUPPLIER, self::ORGANIZATION, 'Original supplier', null, 'supplier@example.com', null, [], $this->now()); + } + + /** + * Method now + * + * @access private + * + * @return DateTimeImmutable the explicit test instant + */ + private function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-10-06T10:00:00+00:00'); + } + // #endregion +} diff --git a/tests/Unit/Procurement/Domain/ValueObject/ProcurementLineTest.php b/tests/Unit/Procurement/Domain/ValueObject/ProcurementLineTest.php new file mode 100644 index 000000000..f2f8079da --- /dev/null +++ b/tests/Unit/Procurement/Domain/ValueObject/ProcurementLineTest.php @@ -0,0 +1,416 @@ +receive('1.250000'); + $returned = $received->returnReceived('0.250000'); + self::assertSame('REF', $returned->partCode); + self::assertSame('Foam concentrate', $returned->partLabel); + self::assertSame('litre', $returned->partUnit); + self::assertSame('1.250000', $returned->receivedQuantity); + self::assertSame('0.250000', $returned->returnedQuantity); + } + // #endregion + + // #region Methods + /** + * Method testConsumableFractionsAndCostsAreCanonicalExactStrings + * + * @access public + * + * @return void + */ + #[Test] + public function testConsumableFractionsAndCostsAreCanonicalExactStrings(): void + { + $line = ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '0001.25', '00012.123456'); + + self::assertSame(self::LINE, $line->id); + self::assertSame('part', $line->kind); + self::assertSame(self::PART, $line->partId); + self::assertNull($line->typeCode); + self::assertSame([], $line->identityTemplate); + self::assertSame('1.250000', $line->quantity); + self::assertSame('12.123456', $line->unitCost); + self::assertSame('0.000000', $line->receivedQuantity); + self::assertSame('0.000000', $line->returnedQuantity); + self::assertSame('1.250000', $line->remainingQuantity()); + } + + /** + * Method testUnknownUnitCostStaysDifferentFromKnownZero + * + * @access public + * + * @return void + */ + #[Test] + public function testUnknownUnitCostStaysDifferentFromKnownZero(): void + { + $unknown = ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '1', null); + $knownZero = ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '1', '0'); + + self::assertNull($unknown->unitCost); + self::assertSame('0.000000', $knownZero->unitCost); + } + + /** + * Method testEquipmentKeepsDeclarativeTemplateAndWholeUnits + * + * @access public + * + * @return void + */ + #[Test] + public function testEquipmentKeepsDeclarativeTemplateAndWholeUnits(): void + { + $template = ['name' => 'Portable extinguisher', 'brand' => 'Example', 'technicalProperties' => [['key' => 'capacity', 'value' => '6', 'unit' => 'kg']]]; + $line = ProcurementLine::create(self::LINE, 'equipment_to_individualize', null, ' fire_extinguisher ', $template, '3.000000', '120.5'); + + self::assertSame('equipment_to_individualize', $line->kind); + self::assertNull($line->partId); + self::assertSame('fire_extinguisher', $line->typeCode); + self::assertSame($template, $line->identityTemplate); + self::assertSame('3.000000', $line->quantity); + self::assertSame('120.500000', $line->unitCost); + self::assertSame('1.000000', $line->receive('1')->receivedQuantity); + } + + /** + * Method testReceivedAndReturnedSnapshotsDoNotMutateOriginalLine + * + * @access public + * + * @return void + */ + #[Test] + public function testReceivedAndReturnedSnapshotsDoNotMutateOriginalLine(): void + { + $original = ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '1.25', '10'); + $received = $original->receive('0.75'); + $returned = $received->returnReceived('0.25'); + + self::assertSame('0.000000', $original->receivedQuantity); + self::assertSame('0.750000', $received->receivedQuantity); + self::assertSame('0.000000', $received->returnedQuantity); + self::assertSame('0.750000', $returned->receivedQuantity); + self::assertSame('0.250000', $returned->returnedQuantity); + self::assertSame('0.500000', $returned->returnableQuantity()); + self::assertSame('0.500000', $returned->remainingQuantity()); + self::assertSame($original->id, $returned->id); + self::assertSame($original->unitCost, $returned->unitCost); + } + + /** + * Method testInvalidQuantitiesAreRejected + * + * @access public + * + * @param string $quantity the invalid ordered quantity + * + * @return void + */ + #[Test] + #[DataProvider('invalidQuantities')] + public function testInvalidQuantitiesAreRejected(string $quantity): void + { + $this->expectException(ProcurementException::class); + + ProcurementLine::create(self::LINE, 'part', self::PART, null, [], $quantity, null); + } + + /** + * Method invalidQuantities + * + * @access public + * + * @return iterable quantity bounds + */ + public static function invalidQuantities(): iterable + { + yield 'zero' => ['0']; + yield 'negative' => ['-0.000001']; + yield 'above maximum' => ['100000.000001']; + } + + /** + * Method testMaximumQuantityAndSmallestFractionAreAccepted + * + * @access public + * + * @return void + */ + #[Test] + public function testMaximumQuantityAndSmallestFractionAreAccepted(): void + { + self::assertSame('100000.000000', ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '100000', null)->quantity); + self::assertSame('0.000001', ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '0.000001', null)->quantity); + } + + /** + * Method testIdentitiesCannotMixStockAndIndividualEquipment + * + * @access public + * + * @param string $kind the goods category + * @param ?string $partId the article identity + * @param ?string $typeCode the equipment type + * @param array $template the equipment template + * + * @return void + */ + #[Test] + #[DataProvider('invalidIdentities')] + public function testIdentitiesCannotMixStockAndIndividualEquipment(string $kind, ?string $partId, ?string $typeCode, array $template): void + { + $this->expectException(ProcurementException::class); + + ProcurementLine::create(self::LINE, $kind, $partId, $typeCode, $template, '2', null); + } + + /** + * Method invalidIdentities + * + * @access public + * + * @return iterable}> invalid goods identities + */ + public static function invalidIdentities(): iterable + { + yield 'unknown category' => ['service', self::PART, null, []]; + yield 'missing part' => ['part', null, null, []]; + yield 'part with equipment type' => ['part', self::PART, 'fire_extinguisher', []]; + yield 'part with identity template' => ['part', self::PART, null, ['name' => 'Wrong inventory']]; + yield 'equipment with part' => ['equipment_to_individualize', self::PART, 'fire_extinguisher', []]; + yield 'equipment without type' => ['equipment_to_individualize', null, null, []]; + yield 'equipment with blank type' => ['equipment_to_individualize', null, ' ', []]; + } + + /** + * Method testIndividualEquipmentRejectsFractionalCreationReceiptAndReturn + * + * @access public + * + * @param string $ordered the ordered quantity + * @param string $received the gross received quantity + * @param string $returned the returned quantity + * + * @return void + */ + #[Test] + #[DataProvider('fractionalEquipment')] + public function testIndividualEquipmentRejectsFractionalCreationReceiptAndReturn(string $ordered, string $received, string $returned): void + { + $this->expectException(ProcurementException::class); + + ProcurementLine::reconstitute(self::LINE, 'equipment_to_individualize', null, 'fire_extinguisher', [], $ordered, null, $received, $returned); + } + + /** + * Method fractionalEquipment + * + * @access public + * + * @return iterable fractional equipment histories + */ + public static function fractionalEquipment(): iterable + { + yield 'fractional order' => ['1.5', '0', '0']; + yield 'fractional receipt' => ['2', '0.5', '0']; + yield 'fractional return' => ['2', '1', '0.5']; + } + + /** + * Method testRestorationRejectsImpossiblePhysicalHistory + * + * @access public + * + * @param string $received the restored gross received quantity + * @param string $returned the restored returned quantity + * + * @return void + */ + #[Test] + #[DataProvider('invalidHistory')] + public function testRestorationRejectsImpossiblePhysicalHistory(string $received, string $returned): void + { + $this->expectException(ProcurementException::class); + + ProcurementLine::reconstitute(self::LINE, 'part', self::PART, null, [], '5', null, $received, $returned); + } + + /** + * Method invalidHistory + * + * @access public + * + * @return iterable impossible historical quantities + */ + public static function invalidHistory(): iterable + { + yield 'receipt over order' => ['5.000001', '0']; + yield 'negative receipt' => ['-1', '0']; + yield 'return over receipt' => ['2', '2.000001']; + yield 'negative return' => ['2', '-1']; + } + + /** + * Method testOverReceptionDoesNotPartiallyChangeLine + * + * @access public + * + * @return void + */ + #[Test] + public function testOverReceptionDoesNotPartiallyChangeLine(): void + { + $line = ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '1.25', null)->receive('1'); + + try { + $line->receive('0.250001'); + self::fail('Expected an over-reception failure.'); + } catch (ProcurementException $exception) { + self::assertSame('invalid', $exception->errorCode); + } + + self::assertSame('1.000000', $line->receivedQuantity); + self::assertSame('0.250000', $line->remainingQuantity()); + } + + /** + * Method testAlreadyReturnedUnitsCannotBeReturnedAgain + * + * @access public + * + * @return void + */ + #[Test] + public function testAlreadyReturnedUnitsCannotBeReturnedAgain(): void + { + $line = ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '2', null)->receive('2')->returnReceived('1.5'); + $this->expectException(ProcurementException::class); + + $line->returnReceived('0.500001'); + } + + /** + * Method testNegativeUnitCostIsRejected + * + * @access public + * + * @return void + */ + #[Test] + public function testNegativeUnitCostIsRejected(): void + { + $this->expectException(ProcurementException::class); + + ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '1', '-0.000001'); + } + + /** + * Method testAmountsDoNotAcceptExponentCommaOrExcessPrecision + * + * @access public + * + * @param string $cost the malformed amount + * + * @return void + */ + #[Test] + #[DataProvider('invalidCosts')] + public function testAmountsDoNotAcceptExponentCommaOrExcessPrecision(string $cost): void + { + $this->expectException(InvalidValueException::class); + + ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '1', $cost); + } + + /** + * Method invalidCosts + * + * @access public + * + * @return iterable malformed exact amounts + */ + public static function invalidCosts(): iterable + { + yield 'scientific notation' => ['1e3']; + yield 'comma separator' => ['12,5']; + yield 'excess precision' => ['0.1234567']; + yield 'whitespace' => [' 12.5']; + } + + /** + * Method testTemplatesAreDeclarativeAndBounded + * + * @access public + * + * @param array $template the invalid identity payload + * + * @return void + */ + #[Test] + #[DataProvider('invalidTemplates')] + public function testTemplatesAreDeclarativeAndBounded(array $template): void + { + $this->expectException(ProcurementException::class); + + ProcurementLine::create(self::LINE, 'equipment_to_individualize', null, 'fire_extinguisher', $template, '1', null); + } + + /** + * Method invalidTemplates + * + * @access public + * + * @return iterable}> unbounded or executable identity payloads + */ + public static function invalidTemplates(): iterable + { + yield 'object value' => [['name' => new stdClass()]]; + yield 'nonfinite number' => [['capacity' => INF]]; + yield 'excessive depth' => [['a' => ['b' => ['c' => ['d' => ['e' => 'deep']]]]]]; + yield 'oversized string' => [['name' => str_repeat('x', 2001)]]; + yield 'blank key' => [[' ' => 'value']]; + } + // #endregion +} diff --git a/tests/Unit/Procurement/Domain/ValueObject/PurchaseOrderStatusTest.php b/tests/Unit/Procurement/Domain/ValueObject/PurchaseOrderStatusTest.php new file mode 100644 index 000000000..141fcbba2 --- /dev/null +++ b/tests/Unit/Procurement/Domain/ValueObject/PurchaseOrderStatusTest.php @@ -0,0 +1,40 @@ +allowsReceipt()); + self::assertTrue(PurchaseOrderStatus::ORDERED->allowsReceipt()); + self::assertTrue(PurchaseOrderStatus::PARTIAL_RECEIVED->allowsReceipt()); + self::assertFalse(PurchaseOrderStatus::RECEIVED->allowsReceipt()); + self::assertFalse(PurchaseOrderStatus::CANCELLED->allowsReceipt()); + } + // #endregion +} diff --git a/tests/Unit/Procurement/Presentation/Api/Processor/ProcurementProcessorTest.php b/tests/Unit/Procurement/Presentation/Api/Processor/ProcurementProcessorTest.php new file mode 100644 index 000000000..ae7672905 --- /dev/null +++ b/tests/Unit/Procurement/Presentation/Api/Processor/ProcurementProcessorTest.php @@ -0,0 +1,453 @@ +name = 'Supplier'; + $input->email = 'sales@example.com'; + $requests = $this->requests(['name' => 'Supplier', 'email' => 'sales@example.com']); + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageProcurementCommand $command): bool => self::ACTOR === $command->actorId && self::ORGANIZATION === $command->organizationId && 'create_supplier' === $command->action && null === $command->id && null === $command->expectedRevision && ['name' => 'Supplier', 'email' => 'sales@example.com'] === $command->payload))->willReturn(new ManageProcurementResult('supplier', $this->projection('supplier'))); + $processor = new ProcurementProcessor($bus, $this->actor(), $requests); + + $output = $processor->process($input, new Post(name: ProcurementOperations::CREATE_SUPPLIER), ['organizationId' => self::ORGANIZATION]); + + self::assertInstanceOf(SupplierOutput::class, $output); + self::assertSame(self::RESOURCE, $output->id); + self::assertSame('sales@example.com', $output->email); + self::assertFalse($output->replayed); + } + + /** + * Method testPatchPreservesExplicitNullAndDoesNotForwardAbsentFields + * + * @access public + * + * @return void + */ + #[Test] + public function testPatchPreservesExplicitNullAndDoesNotForwardAbsentFields(): void + { + $input = new ChangeSupplierInput(); + $input->email = null; + $input->name = 'Value absent from the HTTP patch'; + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageProcurementCommand $command): bool => 'change_supplier' === $command->action && self::RESOURCE === $command->id && 7 === $command->expectedRevision && ['email' => null] === $command->payload))->willReturn(new ManageProcurementResult('supplier', $this->projection('supplier'))); + $processor = new ProcurementProcessor($bus, $this->actor(), $this->requests(['email' => null], '"revision-7"')); + + $output = $processor->process($input, new Patch(name: ProcurementOperations::CHANGE_SUPPLIER), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE]); + + self::assertInstanceOf(SupplierOutput::class, $output); + } + + /** + * Method testOrderCreationPreservesUuidIdentityAndExactLineQuantities + * + * @access public + * + * @return void + */ + #[Test] + public function testOrderCreationPreservesUuidIdentityAndExactLineQuantities(): void + { + $input = new ChangePurchaseOrderInput(); + $input->name = 'Order'; + $input->supplierId = self::RESOURCE; + $input->lines = [['id' => self::OPERATION, 'kind' => 'part', 'partId' => self::RESOURCE, 'quantity' => '0.250000', 'unitCost' => '12.123456']]; + $payload = ['name' => $input->name, 'supplierId' => $input->supplierId, 'lines' => $input->lines]; + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageProcurementCommand $command): bool => 'create_order' === $command->action && $payload === $command->payload && null === $command->expectedRevision))->willReturn(new ManageProcurementResult('order', $this->projection('order'))); + $processor = new ProcurementProcessor($bus, $this->actor(), $this->requests($payload)); + + $output = $processor->process($input, new Post(name: ProcurementOperations::CREATE_ORDER), ['organizationId' => self::ORGANIZATION]); + + self::assertInstanceOf(PurchaseOrderOutput::class, $output); + self::assertSame('EUR', $output->currency); + self::assertSame('draft', $output->status); + } + + /** + * Method testBodylessActionsDispatchRevisionAndReturnAuthorizedProjection + * + * @access public + * + * @param string $operation the HTTP operation name + * @param string $action the application action + * @param string $kind the output projection kind + * + * @return void + */ + #[Test] + #[DataProvider('bodylessActions')] + public function testBodylessActionsDispatchRevisionAndReturnAuthorizedProjection(string $operation, string $action, string $kind): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageProcurementCommand $command): bool => $action === $command->action && self::ORGANIZATION === $command->organizationId && self::RESOURCE === $command->id && 7 === $command->expectedRevision && [] === $command->payload))->willReturn(new ManageProcurementResult($kind, $this->projection($kind))); + $processor = new ProcurementProcessor($bus, $this->actor(), $this->requests([], '"revision-7"')); + + $output = $processor->process(null, new Post(name: $operation), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE]); + + self::assertSame(self::RESOURCE, $output->id); + self::assertFalse($output->replayed); + } + + /** + * Method bodylessActions + * + * @access public + * + * @return iterable bodyless actions + */ + public static function bodylessActions(): iterable + { + yield 'archive supplier' => [ProcurementOperations::ARCHIVE_SUPPLIER, 'archive_supplier', 'supplier']; + yield 'place order' => [ProcurementOperations::PLACE_ORDER, 'order', 'order']; + yield 'cancel remaining' => [ProcurementOperations::CANCEL_REMAINING, 'cancel_remaining', 'order']; + } + + /** + * Method testPurchaseOrderPatchMapsExplicitNameAndRevision + * + * @access public + * + * @return void + */ + #[Test] + public function testPurchaseOrderPatchMapsExplicitNameAndRevision(): void + { + $input = new ChangePurchaseOrderInput(); + $input->name = 'Renamed'; + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageProcurementCommand $command): bool => 'change_order' === $command->action && 4 === $command->expectedRevision && ['name' => 'Renamed'] === $command->payload))->willReturn(new ManageProcurementResult('order', $this->projection('order'))); + $processor = new ProcurementProcessor($bus, $this->actor(), $this->requests(['name' => 'Renamed'], '"revision-4"')); + + self::assertInstanceOf(PurchaseOrderOutput::class, $processor->process($input, new Patch(name: ProcurementOperations::CHANGE_ORDER), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE])); + } + + /** + * Method testPhysicalReceiptKeepsOperationUuidAndDecimalStrings + * + * @access public + * + * @return void + */ + #[Test] + public function testPhysicalReceiptKeepsOperationUuidAndDecimalStrings(): void + { + $input = new ReceivePurchaseOrderInput(); + $input->lineId = self::RESOURCE; + $input->warehouseId = self::RESOURCE; + $input->quantity = '0.250000'; + $input->receivedAt = '2026-10-06T10:00:00+00:00'; + $input->clientOperationId = self::OPERATION; + $payload = ['lineId' => $input->lineId, 'warehouseId' => $input->warehouseId, 'quantity' => $input->quantity, 'receivedAt' => $input->receivedAt, 'clientOperationId' => $input->clientOperationId]; + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageProcurementCommand $command): bool => 'receive' === $command->action && self::RESOURCE === $command->id && 3 === $command->expectedRevision && $payload === $command->payload))->willReturn(new ManageProcurementResult('receipt', $this->projection('receipt'), true)); + $processor = new ProcurementProcessor($bus, $this->actor(), $this->requests($payload, '"revision-3"')); + + $output = $processor->process($input, new Post(name: ProcurementOperations::RECEIVE), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE]); + + self::assertInstanceOf(ProcurementReceiptOutput::class, $output); + self::assertSame('0.250000', $output->quantity); + self::assertTrue($output->replayed); + self::assertFalse($output->financialVisible); + self::assertArrayNotHasKey('unitCost', get_object_vars($output)); + } + + /** + * Method testIndividualizationKeepsStableOperationIdentifier + * + * @access public + * + * @return void + */ + #[Test] + public function testIndividualizationKeepsStableOperationIdentifier(): void + { + $input = new IndividualizeReceiptInput(); + $input->clientOperationId = self::OPERATION; + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageProcurementCommand $command): bool => 'individualize' === $command->action && ['clientOperationId' => self::OPERATION] === $command->payload))->willReturn(new ManageProcurementResult('receipt', $this->projection('receipt'), true)); + $processor = new ProcurementProcessor($bus, $this->actor(), $this->requests(['clientOperationId' => self::OPERATION], '"revision-1"')); + + $output = $processor->process($input, new Post(name: ProcurementOperations::INDIVIDUALIZE), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE]); + + self::assertInstanceOf(ProcurementReceiptOutput::class, $output); + self::assertTrue($output->replayed); + } + + /** + * Method testSupplierReturnKeepsReasonAndOperationIdentifier + * + * @access public + * + * @return void + */ + #[Test] + public function testSupplierReturnKeepsReasonAndOperationIdentifier(): void + { + $input = new ReturnProcurementReceiptInput(); + $input->quantity = '0.125000'; + $input->reason = 'Damaged container'; + $input->clientOperationId = self::OPERATION; + $payload = ['quantity' => $input->quantity, 'reason' => $input->reason, 'clientOperationId' => $input->clientOperationId]; + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageProcurementCommand $command): bool => 'return' === $command->action && $payload === $command->payload && 2 === $command->expectedRevision))->willReturn(new ManageProcurementResult('receipt', $this->projection('receipt'))); + $processor = new ProcurementProcessor($bus, $this->actor(), $this->requests($payload, '"revision-2"')); + + self::assertInstanceOf(ProcurementReceiptOutput::class, $processor->process($input, new Post(name: ProcurementOperations::RETURN), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE])); + } + + /** + * Method testMissingOrMalformedRevisionIsPassedToApplicationValidation + * + * @access public + * + * @param ?string $header the untrusted conditional request header + * @param ?int $expected the application revision sentinel + * + * @return void + */ + #[Test] + #[DataProvider('revisionHeaders')] + public function testMissingOrMalformedRevisionIsPassedToApplicationValidation(?string $header, ?int $expected): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageProcurementCommand $command): bool => 'order' === $command->action && $expected === $command->expectedRevision))->willReturn(new ManageProcurementResult('order', $this->projection('order'))); + $processor = new ProcurementProcessor($bus, $this->actor(), $this->requests([], $header)); + + self::assertInstanceOf(PurchaseOrderOutput::class, $processor->process(null, new Post(name: ProcurementOperations::PLACE_ORDER), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE])); + } + + /** + * Method revisionHeaders + * + * @access public + * + * @return iterable conditional revision transport + */ + public static function revisionHeaders(): iterable + { + yield 'missing becomes required precondition' => [null, null]; + yield 'malformed becomes invalid revision' => ['revision-7', -1]; + yield 'weak tag is unsupported' => ['W/"revision-7"', -1]; + yield 'valid revision' => ['"revision-7"', 7]; + } + + /** + * Method testUnknownPayloadFieldFailsBeforeDispatch + * + * @access public + * + * @return void + */ + #[Test] + public function testUnknownPayloadFieldFailsBeforeDispatch(): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::never())->method('dispatch'); + $processor = new ProcurementProcessor($bus, $this->actor(), $this->requests(['name' => 'Supplier', 'organizationId' => self::RESOURCE])); + $this->expectException(ProcurementException::class); + $this->expectExceptionMessage('Unknown procurement field.'); + + $processor->process(new ChangeSupplierInput(), new Post(name: ProcurementOperations::CREATE_SUPPLIER), ['organizationId' => self::ORGANIZATION]); + } + + /** + * Method testInputRequiredActionRejectsMissingDto + * + * @access public + * + * @return void + */ + #[Test] + public function testInputRequiredActionRejectsMissingDto(): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::never())->method('dispatch'); + $processor = new ProcurementProcessor($bus, $this->actor(), $this->requests([])); + $this->expectException(ProcurementException::class); + + $processor->process(null, new Post(name: ProcurementOperations::RECEIVE), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE]); + } + + /** + * Method testUnauthenticatedActorCannotDispatchMutation + * + * @access public + * + * @return void + */ + #[Test] + public function testUnauthenticatedActorCannotDispatchMutation(): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::never())->method('dispatch'); + $processor = new ProcurementProcessor($bus, $this->actor(null), new RequestStack()); + $this->expectException(AccessDeniedHttpException::class); + + $processor->process(null, new Post(name: ProcurementOperations::PLACE_ORDER), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE]); + } + + /** + * Method testUnknownMutationCannotDispatch + * + * @access public + * + * @return void + */ + #[Test] + public function testUnknownMutationCannotDispatch(): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::never())->method('dispatch'); + $processor = new ProcurementProcessor($bus, $this->actor(), new RequestStack()); + $this->expectException(ProcurementException::class); + + $processor->process(null, new Post(name: 'unknown'), ['organizationId' => self::ORGANIZATION]); + } + + /** + * Method testApplicationScopeDenialPropagatesForCentralMapping + * + * @access public + * + * @return void + */ + #[Test] + public function testApplicationScopeDenialPropagatesForCentralMapping(): void + { + $bus = $this->createMock(CommandBusPort::class); + $bus->expects(self::once())->method('dispatch')->willThrowException(ProcurementException::notFound()); + $processor = new ProcurementProcessor($bus, $this->actor(), $this->requests([], '"revision-1"')); + $this->expectException(ProcurementException::class); + + $processor->process(null, new Post(name: ProcurementOperations::PLACE_ORDER), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE]); + } + + /** + * Method actor + * + * @access private + * + * @param ?string $id the current actor identifier + * + * @return CurrentActorPort the mocked actor port + */ + private function actor(?string $id = self::ACTOR): CurrentActorPort + { + $actor = $this->createMock(CurrentActorPort::class); + $actor->expects(self::once())->method('userId')->willReturn($id); + + return $actor; + } + + /** + * Method requests + * + * @access private + * + * @param array $body the explicit raw JSON body + * @param ?string $revisionHeader the optional conditional revision + * + * @return RequestStack the HTTP request stack + */ + private function requests(array $body, ?string $revisionHeader = null): RequestStack + { + $request = Request::create('/api/procurement', 'POST', server: ['CONTENT_TYPE' => 'application/json'], content: json_encode($body, JSON_THROW_ON_ERROR)); + if (null !== $revisionHeader) { + $request->headers->set('If-Match', $revisionHeader); + } + $requests = new RequestStack(); + $requests->push($request); + + return $requests; + } + + /** + * Method projection + * + * @access private + * + * @param string $kind the authorized output projection + * + * @return array a minimal operational output projection + */ + private function projection(string $kind): array + { + $common = ['id' => self::RESOURCE, 'organizationId' => self::ORGANIZATION, 'revision' => 1, 'createdAt' => '2026-10-06T10:00:00+00:00']; + if ('supplier' === $kind) { + return $common + ['name' => 'Supplier', 'code' => null, 'email' => 'sales@example.com', 'phone' => null, 'contacts' => [], 'archivedAt' => null, 'updatedAt' => '2026-10-06T10:00:00+00:00']; + } + if ('order' === $kind) { + return $common + ['supplierId' => self::RESOURCE, 'name' => 'Order', 'currency' => 'EUR', 'status' => 'draft', 'lines' => [], 'financialVisible' => false, 'updatedAt' => '2026-10-06T10:00:00+00:00']; + } + + return $common + ['orderId' => self::RESOURCE, 'lineId' => self::RESOURCE, 'kind' => 'part', 'quantity' => '0.250000', 'warehouseId' => self::RESOURCE, 'currency' => 'EUR', 'receivedAt' => '2026-10-06T10:00:00+00:00', 'inventoryMovementId' => self::RESOURCE, 'equipmentIds' => [], 'returnedQuantity' => '0.000000', 'status' => 'stock_received', 'blockedReason' => null, 'financialVisible' => false]; + } + // #endregion +} diff --git a/tests/Unit/Procurement/Presentation/Api/Provider/ProcurementProviderTest.php b/tests/Unit/Procurement/Presentation/Api/Provider/ProcurementProviderTest.php new file mode 100644 index 000000000..f791f7f03 --- /dev/null +++ b/tests/Unit/Procurement/Presentation/Api/Provider/ProcurementProviderTest.php @@ -0,0 +1,325 @@ + $outputClass the expected output DTO + * + * @return void + */ + #[Test] + #[DataProvider('readOperations')] + public function testOperationMetadataSelectsExpectedScopedRead(string $operation, string $action, string $kind, bool $collection, string $outputClass): void + { + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->with(self::callback(static fn (ReadProcurementQuery $query): bool => self::ACTOR === $query->actorId && self::ORGANIZATION === $query->organizationId && self::RESOURCE === $query->id && $action === $query->action && 1 === $query->page && 30 === $query->itemsPerPage))->willReturn(new ReadProcurementResult($kind, [$this->projection($kind)], $collection)); + $provider = new ProcurementProvider($bus, $this->actor(), new RequestStack()); + + $output = $provider->provide(new Get(name: $operation), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE]); + + if ($collection) { + self::assertInstanceOf(TraversablePaginator::class, $output); + self::assertSame(1.0, $output->getTotalItems()); + $items = iterator_to_array($output); + self::assertCount(1, $items); + self::assertInstanceOf($outputClass, $items[0]); + self::assertSame(self::RESOURCE, $items[0]->id); + } else { + self::assertInstanceOf($outputClass, $output); + self::assertSame(self::RESOURCE, $output->id); + self::assertFalse($output->replayed); + } + } + + /** + * Method readOperations + * + * @access public + * + * @return iterable}> metadata mappings + */ + public static function readOperations(): iterable + { + yield 'suppliers' => [ProcurementOperations::SUPPLIERS, 'suppliers', 'supplier', true, SupplierOutput::class]; + yield 'supplier' => [ProcurementOperations::SUPPLIER, 'supplier', 'supplier', false, SupplierOutput::class]; + yield 'orders' => [ProcurementOperations::ORDERS, 'orders', 'order', true, PurchaseOrderOutput::class]; + yield 'order' => [ProcurementOperations::ORDER, 'order', 'order', false, PurchaseOrderOutput::class]; + yield 'receipts' => [ProcurementOperations::RECEIPTS, 'receipts', 'receipt', true, ProcurementReceiptOutput::class]; + yield 'receipt' => [ProcurementOperations::RECEIPT, 'receipt', 'receipt', false, ProcurementReceiptOutput::class]; + } + + /** + * Method testFiltersForwardedAndPaginationUsesAuthorizedResult + * + * @access public + * + * @return void + */ + #[Test] + public function testFiltersForwardedAndPaginationUsesAuthorizedResult(): void + { + $requests = new RequestStack(); + $requests->push(Request::create('/api/procurement/orders', 'GET', ['search' => 'Safety', 'archived' => 'true', 'status' => 'partial_received', 'supplierId' => self::RESOURCE, 'page' => '-3', 'itemsPerPage' => '500'])); + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->with(self::callback(static fn (ReadProcurementQuery $query): bool => 'orders' === $query->action && 'Safety' === $query->search && true === $query->archived && 'partial_received' === $query->status && self::RESOURCE === $query->supplierId && -3 === $query->page && 500 === $query->itemsPerPage && null === $query->id))->willReturn(new ReadProcurementResult('order', [$this->projection('order')], true, 203, 1, 100)); + $provider = new ProcurementProvider($bus, $this->actor(), $requests); + + $output = $provider->provide(new Get(name: ProcurementOperations::ORDERS), ['organizationId' => self::ORGANIZATION]); + + self::assertInstanceOf(TraversablePaginator::class, $output); + self::assertSame(1.0, $output->getCurrentPage()); + self::assertSame(100.0, $output->getItemsPerPage()); + self::assertSame(203.0, $output->getTotalItems()); + } + + /** + * Method testEmptyOptionalFiltersBecomeNull + * + * @access public + * + * @return void + */ + #[Test] + public function testEmptyOptionalFiltersBecomeNull(): void + { + $requests = new RequestStack(); + $requests->push(Request::create('/api/procurement/orders', 'GET', ['supplierId' => '', 'status' => ''])); + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->with(self::callback(static fn (ReadProcurementQuery $query): bool => null === $query->supplierId && null === $query->status && '' === $query->search && false === $query->archived))->willReturn(new ReadProcurementResult('order', [], true, 0)); + $provider = new ProcurementProvider($bus, $this->actor(), $requests); + + $output = $provider->provide(new Get(name: ProcurementOperations::ORDERS), ['organizationId' => self::ORGANIZATION]); + + self::assertInstanceOf(TraversablePaginator::class, $output); + self::assertCount(0, iterator_to_array($output)); + } + + /** + * Verifies the explicit all state survives the transport boundary. + */ + #[Test] + public function testAllSupplierArchiveFilterRemovesOnlyTheArchivePredicate(): void + { + $requests = new RequestStack(); + $requests->push(Request::create('/api/procurement/suppliers', 'GET', ['archived' => 'all', 'search' => 'Safety', 'page' => '2'])); + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->with(self::callback(static fn (ReadProcurementQuery $query): bool => 'suppliers' === $query->action && null === $query->archived && 'Safety' === $query->search && 2 === $query->page && self::ORGANIZATION === $query->organizationId))->willReturn(new ReadProcurementResult('supplier', [], true, 0, 2)); + $provider = new ProcurementProvider($bus, $this->actor(), $requests); + + self::assertInstanceOf(TraversablePaginator::class, $provider->provide(new Get(name: ProcurementOperations::SUPPLIERS), ['organizationId' => self::ORGANIZATION])); + } + + /** + * Method testOperationalReceiptDoesNotInitializeHiddenAmount + * + * @access public + * + * @return void + */ + #[Test] + public function testOperationalReceiptDoesNotInitializeHiddenAmount(): void + { + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->willReturn(new ReadProcurementResult('receipt', [$this->projection('receipt')], false)); + $provider = new ProcurementProvider($bus, $this->actor(), new RequestStack()); + + $output = $provider->provide(new Get(name: ProcurementOperations::RECEIPT), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE]); + + self::assertInstanceOf(ProcurementReceiptOutput::class, $output); + self::assertSame('0.250000', $output->quantity); + self::assertFalse($output->financialVisible); + self::assertArrayNotHasKey('unitCost', get_object_vars($output)); + self::assertSame('stock_received', $output->status); + } + + /** + * Method testFinancialReceiptKeepsExactAuthorizedAmount + * + * @access public + * + * @return void + */ + #[Test] + public function testFinancialReceiptKeepsExactAuthorizedAmount(): void + { + $data = $this->projection('receipt'); + $data['financialVisible'] = true; + $data['unitCost'] = '12.123456'; + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->willReturn(new ReadProcurementResult('receipt', [$data], false)); + $provider = new ProcurementProvider($bus, $this->actor(), new RequestStack()); + + $output = $provider->provide(new Get(name: ProcurementOperations::RECEIPT), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE]); + + self::assertInstanceOf(ProcurementReceiptOutput::class, $output); + self::assertTrue($output->financialVisible); + self::assertSame('12.123456', $output->unitCost); + } + + /** + * Method testEmptyDetailIsNotFound + * + * @access public + * + * @return void + */ + #[Test] + public function testEmptyDetailIsNotFound(): void + { + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->willReturn(new ReadProcurementResult('supplier', [], false)); + $provider = new ProcurementProvider($bus, $this->actor(), new RequestStack()); + $this->expectException(ProcurementException::class); + + $provider->provide(new Get(name: ProcurementOperations::SUPPLIER), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE]); + } + + /** + * Method testUnknownOperationDoesNotAskQueryBus + * + * @access public + * + * @return void + */ + #[Test] + public function testUnknownOperationDoesNotAskQueryBus(): void + { + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::never())->method('ask'); + $provider = new ProcurementProvider($bus, $this->actor(), new RequestStack()); + $this->expectException(ProcurementException::class); + + $provider->provide(new Get(name: 'unknown'), ['organizationId' => self::ORGANIZATION]); + } + + /** + * Method testUnauthenticatedActorDoesNotReadRows + * + * @access public + * + * @return void + */ + #[Test] + public function testUnauthenticatedActorDoesNotReadRows(): void + { + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::never())->method('ask'); + $provider = new ProcurementProvider($bus, $this->actor(null), new RequestStack()); + $this->expectException(AccessDeniedHttpException::class); + + $provider->provide(new Get(name: ProcurementOperations::SUPPLIERS), ['organizationId' => self::ORGANIZATION]); + } + + /** + * Method testBusinessScopeFailurePropagatesToCentralMapper + * + * @access public + * + * @return void + */ + #[Test] + public function testBusinessScopeFailurePropagatesToCentralMapper(): void + { + $bus = $this->createMock(QueryBusPort::class); + $bus->expects(self::once())->method('ask')->willThrowException(ProcurementException::notFound()); + $provider = new ProcurementProvider($bus, $this->actor(), new RequestStack()); + $this->expectException(ProcurementException::class); + + $provider->provide(new Get(name: ProcurementOperations::ORDER), ['organizationId' => self::ORGANIZATION, 'id' => self::RESOURCE]); + } + + /** + * Method actor + * + * @access private + * + * @param ?string $id the current actor identifier + * + * @return CurrentActorPort the mocked actor + */ + private function actor(?string $id = self::ACTOR): CurrentActorPort + { + $actor = $this->createMock(CurrentActorPort::class); + $actor->expects(self::once())->method('userId')->willReturn($id); + + return $actor; + } + + /** + * Method projection + * + * @access private + * + * @param string $kind the authorized projection kind + * + * @return array a minimal operational projection + */ + private function projection(string $kind): array + { + $common = ['id' => self::RESOURCE, 'organizationId' => self::ORGANIZATION, 'revision' => 1, 'createdAt' => '2026-10-06T10:00:00+00:00']; + if ('supplier' === $kind) { + return $common + ['name' => 'Supplier', 'code' => 'SUP', 'email' => 'sales@example.com', 'phone' => null, 'contacts' => [], 'archivedAt' => null, 'updatedAt' => '2026-10-06T10:00:00+00:00']; + } + if ('order' === $kind) { + return $common + ['supplierId' => self::RESOURCE, 'name' => 'Purchase order', 'currency' => 'EUR', 'status' => 'draft', 'lines' => [], 'financialVisible' => false, 'updatedAt' => '2026-10-06T10:00:00+00:00']; + } + + return $common + ['orderId' => self::RESOURCE, 'lineId' => self::RESOURCE, 'kind' => 'part', 'quantity' => '0.250000', 'warehouseId' => self::RESOURCE, 'currency' => 'EUR', 'receivedAt' => '2026-10-06T10:00:00+00:00', 'inventoryMovementId' => self::RESOURCE, 'equipmentIds' => [], 'returnedQuantity' => '0.000000', 'status' => 'stock_received', 'blockedReason' => null, 'financialVisible' => false]; + } + // #endregion +} diff --git a/tests/Unit/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestHandlerTest.php b/tests/Unit/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestHandlerTest.php new file mode 100644 index 000000000..3f214cd40 --- /dev/null +++ b/tests/Unit/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestHandlerTest.php @@ -0,0 +1,219 @@ + + */ +final class ChangeServiceRequestHandlerTest extends TestCase +{ + private const string REQUEST = '9b4e8400-e29b-41d4-a716-4466559b4001'; + + private const string ORGANIZATION = '9b4e8400-e29b-41d4-a716-4466559b4002'; + + private const string EQUIPMENT = '9b4e8400-e29b-41d4-a716-4466559b4003'; + + private const string SITE = '9b4e8400-e29b-41d4-a716-4466559b4004'; + + private const string CUSTOMER = '9b4e8400-e29b-41d4-a716-4466559b4005'; + + private const string INSPECTION = '9b4e8400-e29b-41d4-a716-4466559b4006'; + + private const string ACTOR = '9b4e8400-e29b-41d4-a716-4466559b4007'; + + private const string OTHER_EQUIPMENT = '9b4e8400-e29b-41d4-a716-4466559b4008'; + + private bool $inTransaction = false; + + #[Test] + public function siteQualificationAddsEquipmentAndPreservesReportedSiteCustomer(): void + { + $request = $this->request(null); + $requests = $this->createMock(ServiceRequestRepositoryPort::class); + $requests->expects(self::once())->method('find')->with(self::REQUEST, self::ORGANIZATION, true)->willReturn($request); + $requests->expects(self::once())->method('save')->with(self::callback(function (ServiceRequest $saved) use ($request): bool { + self::assertTrue($this->inTransaction); + self::assertSame('qualified', $saved->status); + self::assertSame(3, $saved->revision); + self::assertSame(self::EQUIPMENT, $saved->equipmentId); + self::assertSame($request->siteId, $saved->siteId); + self::assertSame($request->targetSnapshot['site'], $saved->targetSnapshot['site']); + self::assertSame($request->targetSnapshot['customer'], $saved->targetSnapshot['customer']); + self::assertSame(['id' => self::EQUIPMENT, 'name' => 'Extincteur entrée', 'assetCode' => 'EXT-001', 'status' => 'active'], $saved->targetSnapshot['equipment']); + self::assertSame('Réparer la poignée.', $saved->qualificationNote); + self::assertSame(self::INSPECTION, $saved->originInspectionId); + + return true; + }), 1); + $equipment = $this->createMock(ServiceRequestEquipmentTargetPort::class); + $equipment->expects(self::once())->method('find')->with(self::EQUIPMENT, self::ORGANIZATION)->willReturn(new ServiceRequestEquipmentTarget(self::EQUIPMENT, 'Extincteur entrée', 'EXT-001', 'active', self::SITE)); + $sites = $this->createMock(ServiceRequestSiteTargetPort::class); + $sites->expects(self::once())->method('lock')->with(self::ORGANIZATION); + $sites->expects(self::once())->method('find')->with(self::ORGANIZATION, self::SITE, self::SITE)->willReturn(new ServiceRequestSiteTarget(self::SITE, 'Renamed site', false, ['id' => self::CUSTOMER, 'name' => 'Renamed customer'])); + $origins = $this->createMock(ServiceRequestOriginPort::class); + $origins->expects(self::once())->method('assertMatches')->with(self::ORGANIZATION, self::EQUIPMENT, self::SITE, self::INSPECTION, null); + $events = $this->createMock(EventDispatcherPort::class); + $events->expects(self::once())->method('dispatch')->with(self::callback(function (object $event): bool { + self::assertTrue($this->inTransaction); + self::assertInstanceOf(ServiceRequestChangedEvent::class, $event); + self::assertSame(self::REQUEST, $event->requestId); + self::assertSame('qualify', $event->change); + self::assertSame(3, $event->revision); + + return true; + })); + + $result = $this->handler($requests, $origins, $equipment, $sites, $events)(new ChangeServiceRequestCommand(self::ACTOR, self::ORGANIZATION, self::REQUEST, 'qualify', 1, note: ' Réparer la poignée. ', equipmentId: self::EQUIPMENT)); + + self::assertSame('qualified', $result->request->status); + self::assertSame(['id' => self::CUSTOMER, 'name' => 'Reported customer'], $result->request->targetSnapshot['customer']); + self::assertSame(['id' => self::SITE, 'name' => 'Reported site'], $result->request->targetSnapshot['site']); + self::assertFalse($this->inTransaction); + } + + #[Test] + public function unrelatedOriginPreventsQualificationWithoutSavingOrEmittingEvent(): void + { + $request = $this->request(); + $requests = $this->createMock(ServiceRequestRepositoryPort::class); + $requests->expects(self::once())->method('find')->with(self::REQUEST, self::ORGANIZATION, true)->willReturn($request); + $requests->expects(self::never())->method('save'); + $equipment = $this->createStub(ServiceRequestEquipmentTargetPort::class); + $equipment->method('find')->willReturn(new ServiceRequestEquipmentTarget(self::EQUIPMENT, 'Extincteur', 'EXT-001', 'active', self::SITE)); + $sites = $this->createStub(ServiceRequestSiteTargetPort::class); + $sites->method('find')->willReturn(new ServiceRequestSiteTarget(self::SITE, 'Site', false, null)); + $origins = $this->createMock(ServiceRequestOriginPort::class); + $origins->expects(self::once())->method('assertMatches')->with(self::ORGANIZATION, self::EQUIPMENT, self::SITE, self::INSPECTION, null)->willThrowException(new ServiceRequestOriginUnavailable()); + $events = $this->createMock(EventDispatcherPort::class); + $events->expects(self::never())->method('dispatch'); + + $this->expectException(ServiceRequestOriginUnavailable::class); + $this->handler($requests, $origins, $equipment, $sites, $events)(new ChangeServiceRequestCommand(self::ACTOR, self::ORGANIZATION, self::REQUEST, 'qualify', 1)); + } + + #[Test] + public function rejectionRemainsPossibleWithoutReadingTheCurrentTarget(): void + { + $request = $this->request(); + $requests = $this->createMock(ServiceRequestRepositoryPort::class); + $requests->expects(self::once())->method('find')->with(self::REQUEST, self::ORGANIZATION, true)->willReturn($request); + $requests->expects(self::once())->method('save')->with(self::callback(static function (ServiceRequest $saved): bool { + self::assertSame('rejected', $saved->status); + self::assertSame('Demande déjà traitée.', $saved->decisionReason); + self::assertSame(2, $saved->revision); + + return true; + }), 1); + $equipment = $this->createMock(ServiceRequestEquipmentTargetPort::class); + $equipment->expects(self::never())->method('find'); + $sites = $this->createMock(ServiceRequestSiteTargetPort::class); + $sites->expects(self::never())->method('lock'); + $sites->expects(self::never())->method('find'); + $origins = $this->createMock(ServiceRequestOriginPort::class); + $origins->expects(self::never())->method('assertMatches'); + $events = $this->createMock(EventDispatcherPort::class); + $events->expects(self::once())->method('dispatch')->with(self::callback(static fn (object $event): bool => $event instanceof ServiceRequestChangedEvent && 'reject' === $event->change && 2 === $event->revision)); + + $result = $this->handler($requests, $origins, $equipment, $sites, $events)(new ChangeServiceRequestCommand(self::ACTOR, self::ORGANIZATION, self::REQUEST, 'reject', 1, reason: ' Demande déjà traitée. ')); + + self::assertSame('rejected', $result->request->status); + self::assertSame($request->targetSnapshot, $result->request->targetSnapshot); + } + + #[Test] + public function emptyPatchDoesNotSaveOrEmitAnotherWorkflowFact(): void + { + $request = $this->request(); + $requests = $this->createMock(ServiceRequestRepositoryPort::class); + $requests->expects(self::once())->method('find')->with(self::REQUEST, self::ORGANIZATION, true)->willReturn($request); + $requests->expects(self::never())->method('save'); + $equipment = $this->createStub(ServiceRequestEquipmentTargetPort::class); + $sites = $this->createStub(ServiceRequestSiteTargetPort::class); + $origins = $this->createMock(ServiceRequestOriginPort::class); + $origins->expects(self::never())->method('assertMatches'); + $events = $this->createMock(EventDispatcherPort::class); + $events->expects(self::never())->method('dispatch'); + + $result = $this->handler($requests, $origins, $equipment, $sites, $events)(new ChangeServiceRequestCommand(self::ACTOR, self::ORGANIZATION, self::REQUEST, 'patch', 1)); + + self::assertSame(1, $result->request->revision); + self::assertSame($request->updatedAt, $result->request->updatedAt); + } + + #[Test] + public function qualificationCannotReplaceAnAlreadySelectedEquipment(): void + { + $requests = $this->createMock(ServiceRequestRepositoryPort::class); + $requests->expects(self::once())->method('find')->with(self::REQUEST, self::ORGANIZATION, true)->willReturn($this->request()); + $requests->expects(self::never())->method('save'); + $equipment = $this->createMock(ServiceRequestEquipmentTargetPort::class); + $equipment->expects(self::never())->method('find'); + $sites = $this->createMock(ServiceRequestSiteTargetPort::class); + $sites->expects(self::never())->method('lock'); + $sites->expects(self::never())->method('find'); + $origins = $this->createMock(ServiceRequestOriginPort::class); + $origins->expects(self::never())->method('assertMatches'); + $events = $this->createMock(EventDispatcherPort::class); + $events->expects(self::never())->method('dispatch'); + + try { + $this->handler($requests, $origins, $equipment, $sites, $events)(new ChangeServiceRequestCommand(self::ACTOR, self::ORGANIZATION, self::REQUEST, 'qualify', 1, equipmentId: self::OTHER_EQUIPMENT)); + self::fail('Expected a target replacement conflict.'); + } catch (ServiceRequestException $exception) { + self::assertSame('service_request_transition_conflict', $exception->reason); + } + self::assertFalse($this->inTransaction); + } + + private function handler(ServiceRequestRepositoryPort $requests, ServiceRequestOriginPort $origins, ServiceRequestEquipmentTargetPort $equipment, ServiceRequestSiteTargetPort $sites, EventDispatcherPort $events): ChangeServiceRequestHandler + { + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::once())->method('resolveAccess')->with(self::ACTOR, self::ORGANIZATION, 'organization.service_requests.manage')->willReturn(OrganizationAccessDecision::GRANTED); + $clock = $this->createStub(ClockPort::class); + $clock->method('now')->willReturn(new DateTimeImmutable('2026-10-06T12:00:00+00:00')); + $transactions = $this->createStub(TransactionManagerPort::class); + $transactions->method('transactional')->willReturnCallback(function (callable $work): mixed { + self::assertFalse($this->inTransaction); + $this->inTransaction = true; + + try { + return $work(); + } finally { + $this->inTransaction = false; + } + }); + + return new ChangeServiceRequestHandler($requests, new ServiceRequestAccessGuard($authorization), new ServiceRequestTargetGuard($equipment, $sites), $origins, $clock, $transactions, $events); + } + + private function request(?string $equipmentId = self::EQUIPMENT): ServiceRequest + { + return ServiceRequest::create(self::REQUEST, self::ORGANIZATION, $equipmentId, self::SITE, [ + 'equipment' => null === $equipmentId ? null : ['id' => $equipmentId, 'name' => 'Extincteur', 'assetCode' => 'EXT-001', 'status' => 'active'], + 'site' => ['id' => self::SITE, 'name' => 'Reported site'], + 'customer' => ['id' => self::CUSTOMER, 'name' => 'Reported customer'], + ], 'Poignée cassée', 'Le contrôle signale une poignée cassée.', new DateTimeImmutable('2026-10-06T11:00:00+00:00'), originInspectionId: self::INSPECTION); + } +} diff --git a/tests/Unit/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestHandlerTest.php b/tests/Unit/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestHandlerTest.php new file mode 100644 index 000000000..d9ff040e4 --- /dev/null +++ b/tests/Unit/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestHandlerTest.php @@ -0,0 +1,442 @@ + + */ +final class ConvertServiceRequestHandlerTest extends TestCase +{ + private const string ACTOR = 'baf10000-0000-4000-8000-000000000001'; + + private const string ORGANIZATION = 'baf10000-0000-4000-8000-000000000002'; + + private const string REQUEST = 'baf10000-0000-4000-8000-000000000003'; + + private const string EQUIPMENT = 'baf10000-0000-4000-8000-000000000004'; + + private const string SITE = 'baf10000-0000-4000-8000-000000000005'; + + private const string FACILITY = 'baf10000-0000-4000-8000-000000000006'; + + private const string INTERVENTION = 'baf10000-0000-4000-8000-000000000007'; + + private const string TASK = 'baf10000-0000-4000-8000-000000000008'; + + private const string OPERATION = 'baf10000-0000-4000-8000-000000000009'; + + private const string OTHER_REQUEST = 'baf10000-0000-4000-8000-000000000010'; + + private ServiceRequestRepositoryPort&MockObject $requests; + + private OrganizationAuthorizationPort&MockObject $authorization; + + private ServiceRequestEquipmentTargetPort&MockObject $equipment; + + private ServiceRequestSiteTargetPort&MockObject $sites; + + private ServiceRequestWorkPort&MockObject $work; + + private ClockPort&MockObject $clock; + + private TransactionManagerPort&MockObject $transactions; + + private EventDispatcherPort&MockObject $events; + + private bool $transactionActive = false; + + private bool $targetLocked = false; + + private bool $selectionReserved = false; + + // #region Methods + protected function setUp(): void + { + $this->requests = $this->createMock(ServiceRequestRepositoryPort::class); + $this->authorization = $this->createMock(OrganizationAuthorizationPort::class); + $this->equipment = $this->createMock(ServiceRequestEquipmentTargetPort::class); + $this->sites = $this->createMock(ServiceRequestSiteTargetPort::class); + $this->work = $this->createMock(ServiceRequestWorkPort::class); + $this->clock = $this->createMock(ClockPort::class); + $this->transactions = $this->createMock(TransactionManagerPort::class); + $this->events = $this->createMock(EventDispatcherPort::class); + $this->events->expects(self::never())->method('dispatchAll'); + } + + /** + * @return iterable + */ + public static function deniedAccess(): iterable + { + yield 'missing manage permission' => [OrganizationAccessDecision::MISSING_PERMISSION, 'service_request_access_denied']; + yield 'outside organization' => [OrganizationAccessDecision::OUTSIDE_SCOPE, 'service_request_not_found']; + } + + #[Test] + #[DataProvider('deniedAccess')] + public function checksManageAccessBeforeOpeningATransactionOrReadingTheRequest(OrganizationAccessDecision $decision, string $reason): void + { + $this->authorization->expects(self::once())->method('resolveAccess')->with(self::ACTOR, self::ORGANIZATION, 'organization.service_requests.manage')->willReturn($decision); + $this->transactions->expects(self::never())->method('transactional'); + $this->requests->expects(self::never())->method('find'); + $this->noEffects(); + $this->noTargetReads(); + self::assertReason($reason, fn () => $this->handler()(self::command())); + } + + #[Test] + public function convertsQualifiedWorkWithNormalizedReferencesAndAnExactReceipt(): void + { + $request = self::qualified(); + $now = self::now()->modify('+2 hours'); + $this->allowAccess(); + $this->transaction(); + $this->find($request); + $this->requests->expects(self::once())->method('conversionReceiptForRequest')->with(self::REQUEST, self::ORGANIZATION)->willReturn(null); + $this->requests->expects(self::once())->method('conversionReceiptByOperation')->with(self::ORGANIZATION, self::OPERATION)->willReturn(null); + $this->currentTarget(); + $effects = []; + $this->work->expects(self::once())->method('createOrLink')->willReturnCallback(function (ServiceRequestWorkRequest $work) use (&$effects): ServiceRequestWorkLink { + self::assertTrue($this->transactionActive); + self::assertSame(self::ORGANIZATION, $work->organizationId); + self::assertSame(self::REQUEST, $work->requestId); + self::assertSame(self::ACTOR, $work->actorId); + self::assertSame(self::EQUIPMENT, $work->equipmentId); + self::assertSame(self::SITE, $work->siteId); + self::assertSame('Repair extinguisher', $work->title); + self::assertSame('Pressure gauge damaged', $work->description); + self::assertSame(self::INTERVENTION, $work->existingInterventionId); + self::assertSame(self::TASK, $work->existingTaskId); + self::assertSame(self::OPERATION, $work->clientOperationId); + $effects[] = 'work'; + + return new ServiceRequestWorkLink(self::INTERVENTION, self::TASK, false); + }); + $this->clock->expects(self::once())->method('now')->willReturn($now); + $this->requests->expects(self::once())->method('save')->willReturnCallback(function (ServiceRequest $saved, ?int $revision) use ($request, $now, &$effects): void { + self::assertTrue($this->transactionActive); + self::assertSame(2, $revision); + self::assertEquals($request->convert(self::INTERVENTION, self::TASK, $now), $saved); + $effects[] = 'save'; + }); + $this->requests->expects(self::once())->method('saveConversionReceipt')->willReturnCallback(function (ServiceRequestConversionReceipt $receipt) use ($now, &$effects): void { + self::assertTrue($this->transactionActive); + self::assertSame(self::ORGANIZATION, $receipt->organizationId); + self::assertSame(self::REQUEST, $receipt->requestId); + self::assertSame(self::OPERATION, $receipt->clientOperationId); + self::assertSame(self::fingerprint(self::INTERVENTION, self::TASK), $receipt->payloadHash); + self::assertSame(self::INTERVENTION, $receipt->interventionId); + self::assertSame(self::TASK, $receipt->taskId); + self::assertSame($now, $receipt->createdAt); + $effects[] = 'receipt'; + }); + $this->events->expects(self::once())->method('dispatch')->willReturnCallback(function (object $event) use ($now, &$effects): void { + self::assertTrue($this->transactionActive); + self::assertInstanceOf(ServiceRequestChangedEvent::class, $event); + self::assertSame(self::ORGANIZATION, $event->organizationId); + self::assertSame(self::REQUEST, $event->requestId); + self::assertSame('converted', $event->change); + self::assertSame(3, $event->revision); + self::assertSame($now, $event->occurredAt); + $effects[] = 'event'; + }); + $result = $this->handler()(self::command(existingInterventionId: strtoupper(self::INTERVENTION), existingTaskId: strtoupper(self::TASK))); + + self::assertSame(['work', 'save', 'receipt', 'event'], $effects); + self::assertFalse($this->transactionActive); + self::assertSame(self::REQUEST, $result->request->id); + self::assertSame(self::ORGANIZATION, $result->request->organizationId); + self::assertSame(self::EQUIPMENT, $result->request->equipmentId); + self::assertSame(self::SITE, $result->request->siteId); + self::assertSame('Repair extinguisher', $result->request->title); + self::assertSame('Pressure gauge damaged', $result->request->description); + self::assertSame('normal', $result->request->priority); + self::assertSame('converted', $result->request->status); + self::assertSame(3, $result->request->revision); + self::assertSame(self::INTERVENTION, $result->request->interventionId); + self::assertSame(self::TASK, $result->request->taskId); + self::assertSame($now, $result->request->convertedAt); + self::assertSame($now, $result->request->updatedAt); + self::assertSame($request->requestedAt, $result->request->requestedAt); + self::assertSame($request->qualifiedAt, $result->request->qualifiedAt); + self::assertSame('Repair approved', $result->request->qualificationNote); + self::assertNull($result->request->originInspectionId); + self::assertNull($result->request->originNonConformityId); + self::assertNull($result->request->decisionReason); + self::assertNull($result->request->rejectedAt); + self::assertNull($result->request->cancelledAt); + self::assertSame($request->targetSnapshot, $result->request->targetSnapshot); + } + + #[Test] + public function replaysTheReceiptWithAnOldPositiveRevisionWithoutCurrentTargetReads(): void + { + $request = self::qualified()->convert(self::INTERVENTION, self::TASK, self::now()->modify('+2 hours')); + $this->allowAccess(); + $this->transaction(); + $this->find($request); + $this->requests->expects(self::once())->method('conversionReceiptForRequest')->with(self::REQUEST, self::ORGANIZATION)->willReturn(self::receipt(self::fingerprint(self::INTERVENTION, self::TASK))); + $this->requests->expects(self::never())->method('conversionReceiptByOperation'); + $this->noEffects(); + $this->noTargetReads(); + $result = $this->handler()(self::command(1, strtoupper(self::INTERVENTION), strtoupper(self::TASK))); + + self::assertSame('converted', $result->request->status); + self::assertSame(3, $result->request->revision); + self::assertSame(self::INTERVENTION, $result->request->interventionId); + self::assertSame(self::TASK, $result->request->taskId); + self::assertSame($request->updatedAt, $result->request->updatedAt); + self::assertSame($request->targetSnapshot, $result->request->targetSnapshot); + self::assertFalse($this->transactionActive); + } + + #[Test] + public function refusesReusingTheSameOperationWithAnotherBody(): void + { + $request = self::qualified()->convert(self::INTERVENTION, self::TASK, self::now()->modify('+2 hours')); + $this->allowAccess(); + $this->transaction(); + $this->find($request); + $this->requests->expects(self::once())->method('conversionReceiptForRequest')->willReturn(self::receipt(self::fingerprint(null, null))); + $this->requests->expects(self::never())->method('conversionReceiptByOperation'); + $this->noEffects(); + $this->noTargetReads(); + self::assertReason('service_request_operation_conflict', fn () => $this->handler()(self::command(1, self::INTERVENTION))); + self::assertFalse($this->transactionActive); + } + + #[Test] + public function refusesAnOperationThatAlreadyConvertedAnotherRequest(): void + { + $this->allowAccess(); + $this->transaction(); + $this->find(self::qualified()); + $this->requests->expects(self::once())->method('conversionReceiptForRequest')->willReturn(null); + $this->requests->expects(self::once())->method('conversionReceiptByOperation')->with(self::ORGANIZATION, self::OPERATION)->willReturn(self::receipt(self::fingerprint(null, null), self::OTHER_REQUEST)); + $this->noEffects(); + $this->noTargetReads(); + self::assertReason('service_request_operation_conflict', fn () => $this->handler()(self::command())); + } + + /** + * @return iterable + */ + public static function failedPreconditions(): iterable + { + yield 'absent revision' => [null, 'service_request_precondition_required', false]; + yield 'zero revision' => [0, 'service_request_revision_stale', false]; + yield 'negative revision' => [-1, 'service_request_revision_stale', false]; + yield 'stale positive revision' => [1, 'service_request_revision_stale', true]; + } + + #[Test] + #[DataProvider('failedPreconditions')] + public function refusesMissingOrStalePreconditionsBeforeAnyWork(?int $revision, string $reason, bool $checksReceipt): void + { + $this->allowAccess(); + $this->transaction(); + $this->find(self::qualified()); + $this->requests->expects($checksReceipt ? self::once() : self::never())->method('conversionReceiptForRequest')->willReturn(null); + $this->requests->expects($checksReceipt ? self::once() : self::never())->method('conversionReceiptByOperation')->willReturn(null); + $this->noEffects(); + $this->noTargetReads(); + self::assertReason($reason, fn () => $this->handler()(self::command($revision))); + } + + #[Test] + public function requiresQualificationBeforeCreatingRepairWork(): void + { + $this->allowAccess(); + $this->transaction(); + $this->find(self::requested()); + $this->requests->expects(self::once())->method('conversionReceiptForRequest')->willReturn(null); + $this->requests->expects(self::once())->method('conversionReceiptByOperation')->willReturn(null); + $this->noEffects(); + $this->noTargetReads(); + self::assertReason('service_request_transition_conflict', fn () => $this->handler()(self::command(1))); + } + + #[Test] + public function propagatesWorkFailureInsideTheTransactionWithoutSavingOrDispatching(): void + { + $this->allowAccess(); + $this->transaction(); + $this->find(self::qualified()); + $this->requests->expects(self::once())->method('conversionReceiptForRequest')->willReturn(null); + $this->requests->expects(self::once())->method('conversionReceiptByOperation')->willReturn(null); + $this->currentTarget(); + $failure = new RuntimeException('Intervention work rejected'); + $this->work->expects(self::once())->method('createOrLink')->willReturnCallback(function (ServiceRequestWorkRequest $request) use ($failure): never { + self::assertTrue($this->transactionActive); + self::assertSame(self::REQUEST, $request->requestId); + + throw $failure; + }); + $this->requests->expects(self::never())->method('save'); + $this->requests->expects(self::never())->method('saveConversionReceipt'); + $this->events->expects(self::never())->method('dispatch'); + $this->clock->expects(self::never())->method('now'); + + try { + $this->handler()(self::command()); + self::fail('A work failure must propagate from the transaction callback.'); + } catch (RuntimeException $exception) { + self::assertSame($failure, $exception); + } + self::assertFalse($this->transactionActive); + } + + private function allowAccess(): void + { + $this->authorization->expects(self::once())->method('resolveAccess')->with(self::ACTOR, self::ORGANIZATION, 'organization.service_requests.manage')->willReturnCallback(function (): OrganizationAccessDecision { + self::assertFalse($this->transactionActive); + + return OrganizationAccessDecision::GRANTED; + }); + } + + private function transaction(): void + { + $this->transactions->expects(self::once())->method('transactional')->willReturnCallback(function (callable $operation): mixed { + self::assertFalse($this->transactionActive); + $this->transactionActive = true; + + try { + return $operation(); + } finally { + $this->transactionActive = false; + } + }); + } + + private function find(ServiceRequest $request): void + { + $this->requests->expects(self::once())->method('find')->with(self::REQUEST, self::ORGANIZATION, true)->willReturnCallback(function () use ($request): ServiceRequest { + self::assertTrue($this->transactionActive); + + return $request; + }); + } + + private function currentTarget(): void + { + $this->work->expects(self::once())->method('reserveSelection')->willReturnCallback(function (ServiceRequestWorkRequest $selection): void { + self::assertTrue($this->transactionActive); + self::assertFalse($this->targetLocked); + self::assertSame(self::ORGANIZATION, $selection->organizationId); + self::assertSame(self::REQUEST, $selection->requestId); + $this->selectionReserved = true; + }); + $this->sites->expects(self::once())->method('lock')->with(self::ORGANIZATION)->willReturnCallback(function (): void { + self::assertTrue($this->transactionActive); + self::assertTrue($this->selectionReserved); + $this->targetLocked = true; + }); + $this->equipment->expects(self::once())->method('find')->with(self::EQUIPMENT, self::ORGANIZATION)->willReturnCallback(function (): ServiceRequestEquipmentTarget { + self::assertTrue($this->transactionActive); + self::assertTrue($this->targetLocked); + + return new ServiceRequestEquipmentTarget(self::EQUIPMENT, 'New display name', 'EXT-01', 'operational', self::FACILITY); + }); + $this->sites->expects(self::once())->method('find')->with(self::ORGANIZATION, self::SITE, self::FACILITY)->willReturn(new ServiceRequestSiteTarget(self::SITE, 'Renamed warehouse', false, ['id' => self::OTHER_REQUEST, 'name' => 'Current owner'])); + } + + private function noTargetReads(): void + { + $this->sites->expects(self::never())->method('lock'); + $this->sites->expects(self::never())->method('find'); + $this->equipment->expects(self::never())->method('find'); + } + + private function noEffects(): void + { + $this->work->expects(self::never())->method('reserveSelection'); + $this->work->expects(self::never())->method('createOrLink'); + $this->requests->expects(self::never())->method('save'); + $this->requests->expects(self::never())->method('saveConversionReceipt'); + $this->events->expects(self::never())->method('dispatch'); + $this->clock->expects(self::never())->method('now'); + } + + private function handler(): ConvertServiceRequestHandler + { + return new ConvertServiceRequestHandler($this->requests, new ServiceRequestAccessGuard($this->authorization), new ServiceRequestTargetGuard($this->equipment, $this->sites), $this->work, $this->clock, $this->transactions, $this->events); + } + + private static function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-10-06T12:00:00Z'); + } + + private static function requested(): ServiceRequest + { + return ServiceRequest::create(self::REQUEST, self::ORGANIZATION, self::EQUIPMENT, self::SITE, ['equipment' => ['id' => self::EQUIPMENT, 'name' => 'Extinguisher', 'assetCode' => 'EXT-01', 'status' => 'operational'], 'site' => ['id' => self::SITE, 'name' => 'Warehouse'], 'customer' => ['id' => self::OTHER_REQUEST, 'name' => 'Building owner']], 'Repair extinguisher', 'Pressure gauge damaged', self::now()); + } + + private static function qualified(): ServiceRequest + { + return self::requested()->qualify('Repair approved', self::now()->modify('+1 hour')); + } + + private static function command(?int $revision = 2, ?string $existingInterventionId = null, ?string $existingTaskId = null): ConvertServiceRequestCommand + { + return new ConvertServiceRequestCommand(self::ACTOR, self::ORGANIZATION, self::REQUEST, $revision, strtoupper(self::OPERATION), $existingInterventionId, $existingTaskId); + } + + private static function fingerprint(?string $existingInterventionId, ?string $existingTaskId): string + { + return hash('sha256', json_encode(['requestId' => self::REQUEST, 'existingInterventionId' => $existingInterventionId, 'existingTaskId' => $existingTaskId], JSON_THROW_ON_ERROR)); + } + + private static function receipt(string $hash, string $requestId = self::REQUEST): ServiceRequestConversionReceipt + { + return new ServiceRequestConversionReceipt(self::ORGANIZATION, $requestId, self::OPERATION, $hash, self::INTERVENTION, self::TASK, self::now()->modify('+2 hours')); + } + + /** + * @param callable():mixed $operation + */ + private static function assertReason(string $reason, callable $operation): void + { + try { + $operation(); + self::fail('Expected a service request refusal.'); + } catch (ServiceRequestException $exception) { + self::assertSame($reason, $exception->reason); + } + } + // #endregion +} diff --git a/tests/Unit/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequestTest.php b/tests/Unit/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequestTest.php new file mode 100644 index 000000000..652841327 --- /dev/null +++ b/tests/Unit/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequestTest.php @@ -0,0 +1,648 @@ + + */ +final class ServiceRequestTest extends TestCase +{ + private const string ID = '550e8400-e29b-41d4-a716-446655440001'; + + private const string ORGANIZATION_ID = '550e8400-e29b-41d4-a716-446655440002'; + + private const string EQUIPMENT_ID = '550e8400-e29b-41d4-a716-446655440003'; + + private const string SITE_ID = '550e8400-e29b-41d4-a716-446655440004'; + + private const string INSPECTION_ID = '550e8400-e29b-41d4-a716-446655440005'; + + private const string NON_CONFORMITY_ID = '550e8400-e29b-41d4-a716-446655440006'; + + private const string INTERVENTION_ID = '550e8400-e29b-41d4-a716-446655440007'; + + private const string TASK_ID = '550e8400-e29b-41d4-a716-446655440008'; + + // #region Methods + #[Test] + public function createsANormalizedRequestWithStableTargetAndOrigin(): void + { + $now = self::now(); + $snapshot = ['equipment' => ['name' => 'Extinguisher', 'assetCode' => 'EXT-01'], 'site' => ['name' => 'Warehouse'], 'customer' => null]; + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, self::EQUIPMENT_ID, self::SITE_ID, $snapshot, ' Repair extinguisher ', " Pressure gauge damaged\nCheck the seal. ", $now, originInspectionId: self::INSPECTION_ID, originNonConformityId: self::NON_CONFORMITY_ID); + + self::assertSame(self::ID, $request->id); + self::assertSame(self::ORGANIZATION_ID, $request->organizationId); + self::assertSame(self::EQUIPMENT_ID, $request->equipmentId); + self::assertSame(self::SITE_ID, $request->siteId); + self::assertSame($snapshot, $request->targetSnapshot); + self::assertSame('Repair extinguisher', $request->title); + self::assertSame("Pressure gauge damaged\nCheck the seal.", $request->description); + self::assertSame('normal', $request->priority); + self::assertSame('requested', $request->status); + self::assertSame(1, $request->revision); + self::assertSame($now, $request->requestedAt); + self::assertSame($now, $request->updatedAt); + self::assertSame(self::INSPECTION_ID, $request->originInspectionId); + self::assertSame(self::NON_CONFORMITY_ID, $request->originNonConformityId); + self::assertNull($request->qualificationNote); + self::assertNull($request->decisionReason); + self::assertNull($request->qualifiedAt); + self::assertNull($request->rejectedAt); + self::assertNull($request->cancelledAt); + self::assertNull($request->convertedAt); + self::assertNull($request->interventionId); + self::assertNull($request->taskId); + + $snapshot['equipment']['name'] = 'Changed after creation'; + self::assertSame('Extinguisher', $request->targetSnapshot['equipment']['name']); + } + + /** + * @return iterable + */ + public static function validTargets(): iterable + { + yield 'equipment' => [self::EQUIPMENT_ID, null]; + yield 'site' => [null, self::SITE_ID]; + yield 'equipment and site' => [self::EQUIPMENT_ID, self::SITE_ID]; + } + + #[Test] + #[DataProvider('validTargets')] + public function acceptsAnEquipmentOrASiteTarget(?string $equipmentId, ?string $siteId): void + { + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, $equipmentId, $siteId, [], 'Repair', 'Repair needed', self::now()); + + self::assertSame($equipmentId, $request->equipmentId); + self::assertSame($siteId, $request->siteId); + self::assertSame([], $request->targetSnapshot); + self::assertNull($request->originInspectionId); + self::assertNull($request->originNonConformityId); + } + + #[Test] + public function requiresAnEquipmentBeforeQualifyingASiteRequest(): void + { + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, null, self::SITE_ID, ['site' => ['label' => 'Warehouse']], 'Repair', 'Leak in the building', self::now()); + $this->expectException(ServiceRequestException::class); + + $request->qualify('Repair approved', self::now()); + } + + #[Test] + public function assignsTheEquipmentOfASiteRequestBeforeQualification(): void + { + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, null, self::SITE_ID, ['site' => ['label' => 'Warehouse']], 'Repair', 'Leak in the building', self::now(), originInspectionId: self::INSPECTION_ID, originNonConformityId: self::NON_CONFORMITY_ID); + $assignedAt = self::now()->modify('+1 hour'); + $snapshot = ['site' => ['label' => 'Warehouse'], 'equipment' => ['label' => 'Valve', 'rating' => 1.0]]; + $assigned = $request->assignEquipment(self::EQUIPMENT_ID, self::SITE_ID, $snapshot, $assignedAt); + + self::assertNull($request->equipmentId); + self::assertSame(['site' => ['label' => 'Warehouse']], $request->targetSnapshot); + self::assertSame(self::EQUIPMENT_ID, $assigned->equipmentId); + self::assertSame(self::SITE_ID, $assigned->siteId); + self::assertSame($snapshot, $assigned->targetSnapshot); + self::assertSame('requested', $assigned->status); + self::assertSame($assignedAt, $assigned->updatedAt); + self::assertSame(2, $assigned->revision); + self::assertSame($request->id, $assigned->id); + self::assertSame($request->organizationId, $assigned->organizationId); + self::assertSame($request->requestedAt, $assigned->requestedAt); + self::assertSame($request->title, $assigned->title); + self::assertSame($request->description, $assigned->description); + self::assertSame($request->originInspectionId, $assigned->originInspectionId); + self::assertSame($request->originNonConformityId, $assigned->originNonConformityId); + + $qualified = $assigned->qualify('Valve selected', $assignedAt->modify('+1 hour')); + self::assertSame('qualified', $qualified->status); + self::assertSame(3, $qualified->revision); + self::assertRetainedTarget($assigned, $qualified); + } + + /** + * @return iterable + */ + public static function invalidAssignments(): iterable + { + yield 'invalid equipment' => ['invalid', self::SITE_ID]; + yield 'invalid site' => [self::EQUIPMENT_ID, 'invalid']; + yield 'different site' => [self::EQUIPMENT_ID, self::TASK_ID]; + yield 'missing original site' => [self::EQUIPMENT_ID, null]; + } + + #[Test] + #[DataProvider('invalidAssignments')] + public function refusesAssignmentsThatLoseOrChangeTheOriginalSite(string $equipmentId, ?string $siteId): void + { + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, null, self::SITE_ID, [], 'Repair', 'Leak in the building', self::now()); + $this->expectException(ServiceRequestException::class); + + $request->assignEquipment($equipmentId, $siteId, [], self::now()); + } + + /** + * @return iterable + */ + public static function assignedStates(): iterable + { + foreach (['requested', 'qualified', 'rejected', 'cancelled', 'converted'] as $state) { + yield $state => [$state]; + } + } + + #[Test] + #[DataProvider('assignedStates')] + public function refusesRetargetingAnExistingEquipment(string $state): void + { + $request = self::inState($state); + $this->expectException(ServiceRequestException::class); + + $request->assignEquipment(self::TASK_ID, self::SITE_ID, [], self::now()->modify('+3 hours')); + } + + #[Test] + public function refusesAnEquipmentAssignmentBeforeTheLatestRevision(): void + { + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, null, self::SITE_ID, [], 'Repair', 'Leak in the building', self::now())->change(['priority' => 'high'], self::now()->modify('+1 hour')); + $this->expectException(ServiceRequestException::class); + + $request->assignEquipment(self::EQUIPMENT_ID, self::SITE_ID, [], self::now()); + } + + /** + * @return iterable + */ + public static function invalidIdentifiers(): iterable + { + yield 'request' => ['invalid', self::ORGANIZATION_ID, self::EQUIPMENT_ID, self::SITE_ID, null, null]; + yield 'organization' => [self::ID, '', self::EQUIPMENT_ID, self::SITE_ID, null, null]; + yield 'equipment' => [self::ID, self::ORGANIZATION_ID, 'invalid', self::SITE_ID, null, null]; + yield 'site' => [self::ID, self::ORGANIZATION_ID, self::EQUIPMENT_ID, 'invalid', null, null]; + yield 'inspection origin' => [self::ID, self::ORGANIZATION_ID, self::EQUIPMENT_ID, self::SITE_ID, 'invalid', null]; + yield 'nonconformity origin' => [self::ID, self::ORGANIZATION_ID, self::EQUIPMENT_ID, self::SITE_ID, null, 'invalid']; + yield 'missing target' => [self::ID, self::ORGANIZATION_ID, null, null, null, null]; + } + + #[Test] + #[DataProvider('invalidIdentifiers')] + public function refusesInvalidIdentifiersAndMissingTargets(string $id, string $organizationId, ?string $equipmentId, ?string $siteId, ?string $inspectionId, ?string $nonConformityId): void + { + $this->expectException(ServiceRequestException::class); + + ServiceRequest::create($id, $organizationId, $equipmentId, $siteId, [], 'Repair', 'Repair needed', self::now(), originInspectionId: $inspectionId, originNonConformityId: $nonConformityId); + } + + /** + * @return iterable + */ + public static function priorities(): iterable + { + yield 'low' => ['low']; + yield 'normal' => ['normal']; + yield 'high' => ['high']; + yield 'urgent' => ['urgent']; + } + + #[Test] + #[DataProvider('priorities')] + public function acceptsEveryDeclaredPriority(string $priority): void + { + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, self::EQUIPMENT_ID, null, [], 'Repair', 'Repair needed', self::now(), $priority); + + self::assertSame($priority, $request->priority); + } + + /** + * @return iterable + */ + public static function invalidContent(): iterable + { + yield 'empty title' => [' ', 'Repair needed', 'normal']; + yield 'long title' => [str_repeat('é', 161), 'Repair needed', 'normal']; + yield 'empty description' => ['Repair', ' ', 'normal']; + yield 'long description' => ['Repair', str_repeat('é', 10001), 'normal']; + yield 'unknown priority' => ['Repair', 'Repair needed', 'critical']; + yield 'empty priority' => ['Repair', 'Repair needed', '']; + } + + #[Test] + #[DataProvider('invalidContent')] + public function refusesInvalidTextAndPriority(string $title, string $description, string $priority): void + { + $this->expectException(ServiceRequestException::class); + + ServiceRequest::create(self::ID, self::ORGANIZATION_ID, self::EQUIPMENT_ID, null, [], $title, $description, self::now(), $priority); + } + + #[Test] + public function countsUnicodeCharactersAtTheAcceptedTextLimits(): void + { + $title = str_repeat('é', 160); + $description = str_repeat('é', 10000); + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, self::EQUIPMENT_ID, null, [], $title, $description, self::now()); + + self::assertSame($title, $request->title); + self::assertSame($description, $request->description); + self::assertSame($description, $request->qualify($description, self::now())->qualificationNote); + } + + /** + * @return iterable}> + */ + public static function invalidSnapshots(): iterable + { + yield 'object' => [['target' => new stdClass()]]; + yield 'infinity' => [['measure' => INF]]; + yield 'not a number' => [['measure' => NAN]]; + yield 'invalid utf8' => [['label' => "\xB1\x31"]]; + yield 'oversized' => [['label' => str_repeat('a', 65537)]]; + $deep = ['label' => 'Extinguisher']; + for ($depth = 0; $depth < 17; ++$depth) { + $deep = ['target' => $deep]; + } + yield 'too deep' => [$deep]; + } + + /** + * @param array $snapshot + */ + #[Test] + #[DataProvider('invalidSnapshots')] + public function refusesSnapshotsThatCannotBeRetainedAsBoundedJson(array $snapshot): void + { + $this->expectException(ServiceRequestException::class); + + ServiceRequest::create(self::ID, self::ORGANIZATION_ID, self::EQUIPMENT_ID, null, $snapshot, 'Repair', 'Repair needed', self::now()); + } + + #[Test] + public function refusesAResourceInsideTheSnapshot(): void + { + $stream = fopen('php://memory', 'r+'); + self::assertIsResource($stream); + $this->expectException(ServiceRequestException::class); + + try { + ServiceRequest::create(self::ID, self::ORGANIZATION_ID, self::EQUIPMENT_ID, null, ['stream' => $stream], 'Repair', 'Repair needed', self::now()); + } finally { + fclose($stream); + } + } + + #[Test] + public function isolatesMutableReferencesInsideTheRetainedSnapshot(): void + { + $label = 'Extinguisher'; + $snapshot = ['nested' => ['label' => &$label]]; + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, self::EQUIPMENT_ID, null, $snapshot, 'Repair', 'Repair needed', self::now()); + $label = 'Changed afterwards'; + + self::assertIsArray($request->targetSnapshot['nested']); + self::assertSame('Extinguisher', $request->targetSnapshot['nested']['label']); + } + + #[Test] + public function changesOnlyEditableFieldsWithoutMutatingTheOriginal(): void + { + $request = self::request(); + $changedAt = self::now()->modify('+1 hour'); + $changed = $request->change(['title' => ' Replace gauge ', 'description' => ' Replacement required ', 'priority' => 'urgent'], $changedAt); + + self::assertNotSame($request, $changed); + self::assertSame('Repair', $request->title); + self::assertSame('normal', $request->priority); + self::assertSame(1, $request->revision); + self::assertSame('Replace gauge', $changed->title); + self::assertSame('Replacement required', $changed->description); + self::assertSame('urgent', $changed->priority); + self::assertSame('requested', $changed->status); + self::assertSame(2, $changed->revision); + self::assertSame($changedAt, $changed->updatedAt); + self::assertRetainedTarget($request, $changed); + } + + #[Test] + public function preservesOmittedFieldsAndReturnsTheSameInstanceForNoOpChanges(): void + { + $request = self::request(); + self::assertSame($request, $request->change([], self::now()->modify('+1 hour'))); + self::assertSame($request, $request->change(['title' => ' Repair ', 'description' => ' Repair needed ', 'priority' => 'normal'], self::now()->modify('+1 hour'))); + $changed = $request->change(['priority' => 'high'], self::now()->modify('+1 hour')); + + self::assertSame($request->title, $changed->title); + self::assertSame($request->description, $changed->description); + self::assertSame('high', $changed->priority); + self::assertSame(2, $changed->revision); + self::assertEquals(self::now(), $request->updatedAt); + } + + /** + * @return iterable}> + */ + public static function invalidChanges(): iterable + { + yield 'null title' => [['title' => null]]; + yield 'numeric title' => [['title' => 42]]; + yield 'empty title' => [['title' => ' ']]; + yield 'long title' => [['title' => str_repeat('a', 161)]]; + yield 'null description' => [['description' => null]]; + yield 'array description' => [['description' => []]]; + yield 'empty description' => [['description' => ' ']]; + yield 'long description' => [['description' => str_repeat('a', 10001)]]; + yield 'null priority' => [['priority' => null]]; + yield 'unknown priority' => [['priority' => 'critical']]; + yield 'replace target' => [['equipmentId' => self::TASK_ID]]; + yield 'replace snapshot' => [['targetSnapshot' => []]]; + yield 'force status' => [['status' => 'converted']]; + yield 'force revision' => [['revision' => 10]]; + yield 'unknown field' => [['label' => 'Repair']]; + } + + /** + * @param array $changes + */ + #[Test] + #[DataProvider('invalidChanges')] + public function refusesInvalidChangesAndMutationOfIdentityOrHistory(array $changes): void + { + $request = self::request(); + $this->expectException(ServiceRequestException::class); + + $request->change($changes, self::now()); + } + + /** + * @return iterable + */ + public static function qualificationNotes(): iterable + { + yield 'absent' => [null, null]; + yield 'blank' => [' ', null]; + yield 'trimmed' => [' Repair approved ', 'Repair approved']; + } + + #[Test] + #[DataProvider('qualificationNotes')] + public function qualifiesAndRetainsTheTargetWithoutMutatingTheRequestedState(?string $note, ?string $expectedNote): void + { + $request = self::request(); + $qualifiedAt = self::now()->modify('+1 hour'); + $qualified = $request->qualify($note, $qualifiedAt); + + self::assertSame('requested', $request->status); + self::assertSame('qualified', $qualified->status); + self::assertSame($expectedNote, $qualified->qualificationNote); + self::assertSame($qualifiedAt, $qualified->qualifiedAt); + self::assertSame($qualifiedAt, $qualified->updatedAt); + self::assertSame(2, $qualified->revision); + self::assertRetainedTarget($request, $qualified); + + self::assertSame($qualifiedAt, $qualified->qualifiedAt); + self::assertSame($expectedNote, $qualified->qualificationNote); + } + + #[Test] + public function refusesAnOversizedQualificationNote(): void + { + $this->expectException(ServiceRequestException::class); + + self::request()->qualify(str_repeat('é', 10001), self::now()); + } + + /** + * @return iterable + */ + public static function decisions(): iterable + { + yield 'reject requested' => ['requested', 'reject']; + yield 'reject qualified' => ['qualified', 'reject']; + yield 'cancel requested' => ['requested', 'cancel']; + yield 'cancel qualified' => ['qualified', 'cancel']; + } + + #[Test] + #[DataProvider('decisions')] + public function recordsTheDecisionWithoutDiscardingItsHistory(string $state, string $operation): void + { + $request = self::inState($state); + $decidedAt = self::now()->modify('+2 hours'); + $decided = 'reject' === $operation ? $request->reject(' Outside maintenance scope ', $decidedAt) : $request->cancel(' Outside maintenance scope ', $decidedAt); + + self::assertSame('reject' === $operation ? 'rejected' : 'cancelled', $decided->status); + self::assertSame('Outside maintenance scope', $decided->decisionReason); + self::assertSame($decidedAt, $decided->updatedAt); + self::assertSame($request->revision + 1, $decided->revision); + self::assertSame($request->qualificationNote, $decided->qualificationNote); + self::assertSame($request->qualifiedAt, $decided->qualifiedAt); + self::assertSame('reject' === $operation ? $decidedAt : null, $decided->rejectedAt); + self::assertSame('cancel' === $operation ? $decidedAt : null, $decided->cancelledAt); + self::assertNull($decided->convertedAt); + self::assertNull($decided->interventionId); + self::assertNull($decided->taskId); + self::assertRetainedTarget($request, $decided); + } + + /** + * @return iterable + */ + public static function invalidDecisionReasons(): iterable + { + yield 'blank rejection' => ['reject', ' ']; + yield 'long rejection' => ['reject', str_repeat('é', 2001)]; + yield 'blank cancellation' => ['cancel', ' ']; + yield 'long cancellation' => ['cancel', str_repeat('é', 2001)]; + } + + #[Test] + #[DataProvider('invalidDecisionReasons')] + public function requiresABoundedDecisionReason(string $operation, string $reason): void + { + $request = self::request(); + $this->expectException(ServiceRequestException::class); + + if ('reject' === $operation) { + $request->reject($reason, self::now()); + } else { + $request->cancel($reason, self::now()); + } + } + + #[Test] + public function convertsAQualifiedRequestWithExactReceiptReferences(): void + { + $request = self::inState('qualified'); + $convertedAt = self::now()->modify('+2 hours'); + $converted = $request->convert(self::INTERVENTION_ID, self::TASK_ID, $convertedAt); + + self::assertSame('qualified', $request->status); + self::assertSame('converted', $converted->status); + self::assertSame(self::INTERVENTION_ID, $converted->interventionId); + self::assertSame(self::TASK_ID, $converted->taskId); + self::assertSame($convertedAt, $converted->convertedAt); + self::assertSame($convertedAt, $converted->updatedAt); + self::assertSame(3, $converted->revision); + self::assertSame($request->qualifiedAt, $converted->qualifiedAt); + self::assertSame($request->qualificationNote, $converted->qualificationNote); + self::assertNull($converted->decisionReason); + self::assertNull($converted->rejectedAt); + self::assertNull($converted->cancelledAt); + self::assertRetainedTarget($request, $converted); + } + + /** + * @return iterable + */ + public static function invalidConversionReferences(): iterable + { + yield 'intervention' => ['invalid', self::TASK_ID]; + yield 'task' => [self::INTERVENTION_ID, 'invalid']; + } + + #[Test] + #[DataProvider('invalidConversionReferences')] + public function refusesInvalidConversionReceiptReferences(string $interventionId, string $taskId): void + { + $request = self::inState('qualified'); + $this->expectException(ServiceRequestException::class); + + $request->convert($interventionId, $taskId, self::now()->modify('+2 hours')); + } + + /** + * @return iterable + */ + public static function forbiddenTransitions(): iterable + { + yield 'requested conversion' => ['requested', 'convert']; + yield 'qualified qualification' => ['qualified', 'qualify']; + yield 'qualified edit' => ['qualified', 'change']; + foreach (['rejected', 'cancelled', 'converted'] as $state) { + foreach (['change', 'qualify', 'reject', 'cancel', 'convert'] as $operation) { + yield $state . ' ' . $operation => [$state, $operation]; + } + } + } + + #[Test] + #[DataProvider('forbiddenTransitions')] + public function refusesInvalidTransitionsIncludingEveryTerminalState(string $state, string $operation): void + { + $request = self::inState($state); + + try { + self::act($request, $operation, self::now()->modify('+3 hours')); + self::fail('The transition must preserve the request lifecycle.'); + } catch (ServiceRequestException $exception) { + self::assertSame('service_request_transition_conflict', $exception->reason); + self::assertSame($state, $request->status); + } + } + + /** + * @return iterable + */ + public static function datedMutations(): iterable + { + yield 'requested edit' => ['requested', 'change']; + yield 'qualification' => ['requested', 'qualify']; + yield 'requested rejection' => ['requested', 'reject']; + yield 'requested cancellation' => ['requested', 'cancel']; + yield 'qualified rejection' => ['qualified', 'reject']; + yield 'qualified cancellation' => ['qualified', 'cancel']; + yield 'conversion' => ['qualified', 'convert']; + } + + #[Test] + #[DataProvider('datedMutations')] + public function refusesMutationDatesBeforeTheLatestRevision(string $state, string $operation): void + { + $request = self::inState($state); + $this->expectException(ServiceRequestException::class); + + self::act($request, $operation, $request->updatedAt->modify('-1 second')); + } + + #[Test] + #[DataProvider('datedMutations')] + public function acceptsMutationDatesEqualToTheLatestRevision(string $state, string $operation): void + { + $request = self::inState($state); + $changed = self::act($request, $operation, $request->updatedAt); + + self::assertSame($request->updatedAt, $changed->updatedAt); + self::assertSame($request->revision + 1, $changed->revision); + self::assertRetainedTarget($request, $changed); + } + + private static function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-10-06T12:00:00Z'); + } + + private static function request(): ServiceRequest + { + return ServiceRequest::create(self::ID, self::ORGANIZATION_ID, self::EQUIPMENT_ID, self::SITE_ID, ['equipment' => ['label' => 'Extinguisher'], 'site' => ['label' => 'Warehouse']], 'Repair', 'Repair needed', self::now(), originInspectionId: self::INSPECTION_ID, originNonConformityId: self::NON_CONFORMITY_ID); + } + + private static function inState(string $state): ServiceRequest + { + $request = self::request(); + + return match ($state) { + 'qualified' => $request->qualify('Repair approved', self::now()->modify('+1 hour')), + 'rejected' => $request->reject('Outside scope', self::now()->modify('+1 hour')), + 'cancelled' => $request->cancel('Duplicate request', self::now()->modify('+1 hour')), + 'converted' => $request->qualify('Repair approved', self::now()->modify('+1 hour'))->convert(self::INTERVENTION_ID, self::TASK_ID, self::now()->modify('+2 hours')), + default => $request, + }; + } + + private static function act(ServiceRequest $request, string $operation, DateTimeImmutable $now): ServiceRequest + { + return match ($operation) { + 'change' => $request->change(['title' => 'New title'], $now), + 'qualify' => $request->qualify('Repair approved', $now), + 'reject' => $request->reject('Outside scope', $now), + 'cancel' => $request->cancel('Duplicate request', $now), + 'convert' => $request->convert(self::INTERVENTION_ID, self::TASK_ID, $now), + default => self::fail('Unsupported test operation.'), + }; + } + + private static function assertRetainedTarget(ServiceRequest $original, ServiceRequest $changed): void + { + self::assertSame($original->id, $changed->id); + self::assertSame($original->organizationId, $changed->organizationId); + self::assertSame($original->equipmentId, $changed->equipmentId); + self::assertSame($original->siteId, $changed->siteId); + self::assertSame($original->targetSnapshot, $changed->targetSnapshot); + self::assertSame($original->originInspectionId, $changed->originInspectionId); + self::assertSame($original->originNonConformityId, $changed->originNonConformityId); + self::assertSame($original->requestedAt, $changed->requestedAt); + } + // #endregion +} diff --git a/tests/Unit/Shared/Domain/ValueObject/DecimalAmountTest.php b/tests/Unit/Shared/Domain/ValueObject/DecimalAmountTest.php new file mode 100644 index 000000000..e640ba304 --- /dev/null +++ b/tests/Unit/Shared/Domain/ValueObject/DecimalAmountTest.php @@ -0,0 +1,113 @@ +add(DecimalAmount::fromString('0.000001'))->toString()); + self::assertSame('0.000001', $amount->subtract(DecimalAmount::fromString('999999999999999999'))->toString()); + self::assertSame('0.000000', DecimalAmount::fromString('-0.000000')->toString()); + } + + /** + * Method testSignedRoundingIsSymmetric. + * + * @param string $input signed numerator + * @param string $expected rounded quotient + */ + #[DataProvider('roundingCases')] + public function testSignedRoundingIsSymmetric(string $input, string $expected): void + { + self::assertSame($expected, DecimalAmount::fromString($input)->divideInt(2)->value()); + } + + /** + * Method roundingCases. + * + * @return iterable positive and negative midpoint boundaries + */ + public static function roundingCases(): iterable + { + yield 'positive midpoint' => ['0.000001', '0.000001']; + yield 'negative midpoint' => ['-0.000001', '-0.000001']; + yield 'positive below midpoint' => ['0.000004', '0.000002']; + yield 'negative below midpoint' => ['-0.000004', '-0.000002']; + } + + /** + * Method testAllocationRoundsOnlyTheFinalProportionalValue. + */ + public function testAllocationRoundsOnlyTheFinalProportionalValue(): void + { + $total = DecimalAmount::fromString('10'); + self::assertSame('10.000000', $total->multiplyAndDivide(DecimalAmount::fromInt(3), DecimalAmount::fromInt(3))->toString()); + self::assertSame('3.333333', $total->multiplyAndDivide(DecimalAmount::fromInt(1), DecimalAmount::fromInt(3))->toString()); + self::assertSame('1.000000', DecimalAmount::fromString('60.000001')->multiplyInt(1)->divideInt(60)->toString()); + self::assertSame('-0.000001', DecimalAmount::fromString('-0.000001')->multiply(DecimalAmount::fromString('0.5'))->toString()); + } + + /** + * Method testIndividualizationRequiresAnExactBoundedInteger. + */ + public function testIndividualizationRequiresAnExactBoundedInteger(): void + { + self::assertSame(100, DecimalAmount::fromString('100.000000')->toInt()); + self::assertFalse(DecimalAmount::fromString('0.5')->isInteger()); + $this->expectException(InvalidValueException::class); + DecimalAmount::fromString('0.5')->toInt(); + } + + /** + * Method testZeroDivisorsAreRejected. + */ + public function testZeroDivisorsAreRejected(): void + { + $this->expectException(InvalidValueException::class); + DecimalAmount::fromInt(1)->multiplyAndDivide(DecimalAmount::fromInt(1), DecimalAmount::zero()); + } + + /** + * Method testInvalidTransportValuesAreRejected. + * + * @param string $value malformed or excessive precision + */ + #[DataProvider('invalidValues')] + public function testInvalidTransportValuesAreRejected(string $value): void + { + $this->expectException(InvalidValueException::class); + DecimalAmount::fromString($value); + } + + /** + * Method invalidValues. + * + * @return iterable values which must never silently coerce + */ + public static function invalidValues(): iterable + { + foreach (['1e3', '1,5', 'NaN', ' 1', '1 ', '+1', '0.0000001', '1.', '.5', '1000000000000000000'] as $value) { + yield $value => [$value]; + } + } +} diff --git a/translations/pdf.en.yaml b/translations/pdf.en.yaml index ec3295fb7..cda132531 100644 --- a/translations/pdf.en.yaml +++ b/translations/pdf.en.yaml @@ -47,12 +47,22 @@ compliance: intervention: title: "Intervention report — FG-%number%" + reportMode: + snapshot: "Validated dossier — preserved at publication" + live: "Draft dossier — awaiting validation" + legacy_live: "Historical report — no preserved publication snapshot" + result: + outcome: + successful: "Successful" + failed: "Failed" + performed: "Performed" meta: name: "Name" type: "Type" status: "Status" priority: "Priority" site: "Site" + customer: "Customer" responsible: "Responsible" participants: "Participants" plannedStart: "Planned start" @@ -158,6 +168,9 @@ nonConformities: equipment: title: "Equipment sheet" meta: + name: "Equipment name" + assetCode: "Asset reference" + criticality: "Declared criticality" type: "Type" brand: "Brand" model: "Model" @@ -168,6 +181,8 @@ equipment: installedAt: "Installed on" commissionedAt: "Commissioned on" maintenanceDueStatus: "Maintenance due status" + controlDueStatus: "Control deadline" + serviceDueStatus: "Servicing deadline" tags: "Tags" maintenance: heading: "Maintenance history" @@ -178,6 +193,23 @@ equipment: intervention: "Intervention" action: "Action" summary: "Summary" + performedAt: "Actually performed on" + notes: "Work performed" + criticality: + low: "Low" + medium: "Medium" + high: "High" + critical: "Critical" + characteristics: + heading: "Declared characteristics" + key: "Characteristic" + value: "Value" + unit: "Unit" + replacement: + heading: "Replacement history" + predecessor: "Replaced equipment" + successor: "Successor equipment" + trackingNote: "Operational status, control deadlines and open anomalies are independent. An up-to-date control does not mean that no defect exists. Configured tracking is not automatic regulatory certification." attachments: heading: "Attachments" empty: "No attachments." @@ -208,6 +240,8 @@ status: site_setup: "Site setup" inventory: "Inventory" inspection_campaign: "Inspection campaign" + preventive_maintenance: "Preventive maintenance" + corrective_maintenance: "Corrective maintenance" workItem: planned: "Planned" in_progress: "In progress" diff --git a/translations/pdf.es.yaml b/translations/pdf.es.yaml index 51a67fbe4..213018564 100644 --- a/translations/pdf.es.yaml +++ b/translations/pdf.es.yaml @@ -47,12 +47,22 @@ compliance: intervention: title: "Informe de intervención — FG-%number%" + reportMode: + snapshot: "Expediente validado — conservado en la publicación" + live: "Expediente provisional — pendiente de validación" + legacy_live: "Informe histórico — sin instantánea conservada en la publicación" + result: + outcome: + successful: "Correcta" + failed: "Fallida" + performed: "Realizada" meta: name: "Nombre" type: "Tipo" status: "Estado" priority: "Prioridad" site: "Sitio" + customer: "Cliente" responsible: "Responsable" participants: "Participantes" plannedStart: "Inicio previsto" @@ -158,6 +168,9 @@ nonConformities: equipment: title: "Ficha de equipo" meta: + name: "Nombre del equipo" + assetCode: "Referencia patrimonial" + criticality: "Criticidad declarada" type: "Tipo" brand: "Marca" model: "Modelo" @@ -168,6 +181,8 @@ equipment: installedAt: "Instalado el" commissionedAt: "Puesto en servicio el" maintenanceDueStatus: "Estado de vencimiento de mantenimiento" + controlDueStatus: "Plazo de inspección" + serviceDueStatus: "Plazo de mantenimiento" tags: "Etiquetas" maintenance: heading: "Historial de mantenimiento" @@ -178,6 +193,23 @@ equipment: intervention: "Intervención" action: "Acción" summary: "Resumen" + performedAt: "Realizado el" + notes: "Trabajos realizados" + criticality: + low: "Baja" + medium: "Media" + high: "Alta" + critical: "Crítica" + characteristics: + heading: "Características declaradas" + key: "Característica" + value: "Valor" + unit: "Unidad" + replacement: + heading: "Historial de sustituciones" + predecessor: "Equipo sustituido" + successor: "Equipo sucesor" + trackingNote: "El estado operativo, los plazos de inspección y las anomalías abiertas son independientes. Una inspección al día no significa que no existan defectos. El seguimiento configurado no constituye una certificación reglamentaria automática." attachments: heading: "Adjuntos" empty: "Sin adjuntos." @@ -208,6 +240,8 @@ status: site_setup: "Configuración del sitio" inventory: "Inventario" inspection_campaign: "Campaña de inspección" + preventive_maintenance: "Mantenimiento preventivo" + corrective_maintenance: "Mantenimiento correctivo" workItem: planned: "Planificado" in_progress: "En curso" diff --git a/translations/pdf.fr.yaml b/translations/pdf.fr.yaml index 95408f11b..c5dbe2ae3 100644 --- a/translations/pdf.fr.yaml +++ b/translations/pdf.fr.yaml @@ -47,12 +47,22 @@ compliance: intervention: title: "Rapport d'intervention — FG-%number%" + reportMode: + snapshot: "Dossier validé — conservé lors de la publication" + live: "Dossier provisoire — en attente de validation" + legacy_live: "Rapport historique — sans instantané conservé à la publication" + result: + outcome: + successful: "Réussie" + failed: "Échouée" + performed: "Réalisée" meta: name: "Nom" type: "Type" status: "Statut" priority: "Priorité" site: "Site" + customer: "Client" responsible: "Responsable" participants: "Participants" plannedStart: "Début prévu" @@ -158,6 +168,9 @@ nonConformities: equipment: title: "Fiche équipement" meta: + name: "Nom de l'équipement" + assetCode: "Repère patrimonial" + criticality: "Criticité déclarée" type: "Type" brand: "Marque" model: "Modèle" @@ -168,6 +181,8 @@ equipment: installedAt: "Installé le" commissionedAt: "Mis en service le" maintenanceDueStatus: "Statut d'échéance de maintenance" + controlDueStatus: "Échéance de contrôle" + serviceDueStatus: "Échéance d'entretien" tags: "Étiquettes" maintenance: heading: "Historique de maintenance" @@ -178,6 +193,23 @@ equipment: intervention: "Intervention" action: "Action" summary: "Résumé" + performedAt: "Réalisé le" + notes: "Travaux réalisés" + criticality: + low: "Faible" + medium: "Moyenne" + high: "Élevée" + critical: "Critique" + characteristics: + heading: "Caractéristiques déclarées" + key: "Caractéristique" + value: "Valeur" + unit: "Unité" + replacement: + heading: "Historique des remplacements" + predecessor: "Équipement remplacé" + successor: "Équipement successeur" + trackingNote: "L'état opérationnel, les échéances de contrôle et les anomalies ouvertes sont indépendants. Un contrôle à jour ne signifie pas qu'aucun défaut n'existe. Le suivi configuré ne constitue pas une certification réglementaire automatique." attachments: heading: "Pièces jointes" empty: "Aucune pièce jointe." @@ -208,6 +240,8 @@ status: site_setup: "Configuration de site" inventory: "Inventaire" inspection_campaign: "Campagne d'inspection" + preventive_maintenance: "Maintenance préventive" + corrective_maintenance: "Maintenance corrective" workItem: planned: "Planifié" in_progress: "En cours"