From 9d5e63a6e602175d3e66bc433d2fa69e281d05e2 Mon Sep 17 00:00:00 2001 From: "Valentin F." <95174216+DevSkyLex@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:54:33 +0200 Subject: [PATCH] refactor(maintenance): simplify state restoration and workflow orchestration --- config/modules/inventory.yaml | 4 +- config/modules/maintenance.yaml | 3 + config/modules/maintenance_cost.yaml | 1 + .../Service/CustomerAccessGuard.php | 2 +- .../CreateCustomer/CreateCustomerHandler.php | 3 +- .../Domain/Model/Customer/Customer.php | 267 ++++++++- .../Domain/ValueObject/CustomerDetails.php | 42 ++ .../Domain/ValueObject/CustomerHistory.php | 42 ++ .../Repository/CustomerRepository.php | 17 +- .../Api/Processor/CustomerProcessor.php | 22 +- .../EquipmentReserveReceiptService.php | 4 +- .../ListEquipments/ListEquipmentsHandler.php | 68 ++- .../Domain/Model/Equipment/Equipment.php | 24 +- .../Domain/ValueObject/EquipmentIdentity.php | 147 ++++- .../ValueObject/RestoredEquipmentHistory.php | 41 ++ .../Inspection/EquipmentValidationAdapter.php | 15 +- .../EquipmentInterventionResourceAdapter.php | 70 ++- .../Doctrine/Mapper/EquipmentMapper.php | 12 +- .../EquipmentTypeCatalogRepository.php | 12 +- .../Equipment/UpdateEquipmentProcessor.php | 61 +- .../Resource/EquipmentTypeCatalogResource.php | 16 +- .../ValueObject/FacilityCustomerReference.php | 2 +- .../ServiceRequestSiteTargetAdapter.php | 215 ++++++- .../Repository/ParkAnomalyRepository.php | 6 +- .../Inspection/ListInspectionsProvider.php | 209 ++++++- .../InterventionEconomicSourceFilter.php | 48 ++ .../InterventionPublicationFactsPort.php | 12 +- .../InterventionServiceReportAdapter.php | 39 +- .../InterventionInventoryContextAdapter.php | 127 ++++- .../InterventionClosureSnapshotAdapter.php | 128 ++++- .../InterventionPublicationFactsAdapter.php | 135 +++-- .../InterventionPublicationFactsMapper.php | 81 ++- .../InterventionWorkflowWorkItemWriter.php | 172 ++++-- src/Intervention/MODULE.md | 4 +- .../Api/Resource/InterventionTimeResource.php | 13 +- .../ApplyInventoryStockHandler.php | 111 ++-- .../ManageInventoryReferenceHandler.php | 50 +- .../ListInventory/ListInventoryHandler.php | 85 ++- .../Model/Stock/StockReturnValuation.php | 63 +++ .../Domain/Model/Stock/StockValuation.php | 17 +- .../Lock/InventoryTransactionLock.php | 56 ++ .../Doctrine/Mapper/InventoryRowMapper.php | 245 ++++++++ .../Query/InventoryCollectionQuery.php | 77 +++ .../Repository/InventoryRepository.php | 222 +++----- src/Inventory/MODULE.md | 4 +- .../Api/Operation/InventoryOperations.php | 5 + .../Resource/InventoryConsumptionResource.php | 8 +- .../Api/Resource/InventoryPartResource.php | 8 +- .../Resource/InventoryWarehouseResource.php | 8 +- .../Plan/MaintenanceLegacyPlanPort.php | 62 +++ .../Service/MaintenanceLegacyPlanService.php | 286 ++++++++++ .../Service/MaintenancePlanModelFactory.php | 77 +++ .../ManageMaintenancePlanHandler.php | 280 ++++------ .../ReadMaintenancePlansHandler.php | 16 +- .../Domain/Model/MaintenanceOccurrence.php | 97 +--- .../Domain/Model/MaintenancePlan.php | 139 +++-- .../ValueObject/MaintenanceControlDates.php | 85 +++ .../MaintenanceOccurrenceAttempt.php | 158 ++++++ .../ValueObject/MaintenancePlanCalendar.php | 129 +++++ .../ValueObject/MaintenancePlanIdentity.php | 39 ++ .../Domain/ValueObject/PlanCadence.php | 2 +- .../MaintenanceOperationsDueAdapter.php | 131 +++-- .../Lock/MaintenanceScheduleLockAdapter.php | 15 +- .../Processor/MaintenancePlanProcessor.php | 100 +++- .../Api/Resource/MaintenancePlanResource.php | 8 +- .../Service/MaintenanceCostAccessGuard.php | 2 +- .../MaintenanceCostAllocationResolver.php | 233 ++++++-- .../Service/MaintenanceCostProjection.php | 98 +++- .../MaintenanceEconomicAggregation.php | 206 +++++++ .../Service/MaintenanceEconomicAggregator.php | 262 +++++---- .../Service/MaintenanceEconomicBucket.php | 7 +- .../Service/MaintenanceEconomicDirectory.php | 101 +++- .../WriteMaintenanceCostHandler.php | 174 ++++-- ...ListMaintenanceEconomicDossiersHandler.php | 6 +- .../ReadMaintenanceEconomicReportHandler.php | 2 +- .../MaintenanceCostFailureSubscriber.php | 24 +- .../Cost/MaintenanceCostProcessor.php | 5 +- .../ManageMaintenanceExportHandler.php | 367 ++++++++---- .../ReadMaintenanceExportHandler.php | 147 ++++- .../Domain/ValueObject/ExportArtifact.php | 49 +- .../Domain/ValueObject/ExportRows.php | 64 ++- .../Adapter/Export/ExportSourceAdapter.php | 255 ++++++--- .../Mapper/MaintenanceExportMapper.php | 309 ++++++++++ .../MaintenanceExportRepository.php | 318 +---------- src/MaintenanceExport/MODULE.md | 4 +- .../Output/MaintenanceExportSourceOutput.php | 2 +- .../Processor/MaintenanceExportProcessor.php | 39 +- .../Resource/MaintenanceExportResource.php | 26 +- .../Contract/ProcurementReceiptState.php | 6 +- .../Service/ProcurementDraftLines.php | 199 +++++++ .../Application/Service/ProcurementInput.php | 264 +++++++++ .../Service/ProcurementOperationReplay.php | 308 ++++++++++ .../Service/ProcurementProjection.php | 33 ++ .../ManageProcurementHandler.php | 527 +++++++----------- .../Domain/Model/PurchaseOrder.php | 260 +++------ src/Procurement/Domain/Model/Supplier.php | 223 ++------ .../ValueObject/ProcurementGoodsIdentity.php | 183 ++++++ .../Domain/ValueObject/ProcurementLine.php | 259 ++++----- .../ValueObject/ProcurementLineAmounts.php | 93 ++++ .../ValueObject/PurchaseOrderHistory.php | 39 ++ .../ValueObject/PurchaseOrderIdentity.php | 89 +++ .../Domain/ValueObject/PurchaseOrderLines.php | 170 ++++++ .../Domain/ValueObject/SupplierDetails.php | 179 ++++++ .../Domain/ValueObject/SupplierHistory.php | 39 ++ .../ProcurementTableStorageAdapter.php | 161 ++++++ .../Mapper/ProcurementStateMapper.php | 269 +++++++++ .../Repository/ProcurementRepository.php | 421 ++++++++------ src/Procurement/MODULE.md | 4 + .../Api/Processor/ProcurementProcessor.php | 5 +- .../Service/ServiceRequestTargetGuard.php | 49 +- .../ConvertServiceRequestHandler.php | 65 ++- .../CreateServiceRequestHandler.php | 3 +- .../ListServiceRequestsHandler.php | 2 +- .../Model/ServiceRequest/ServiceRequest.php | 278 +++++++-- .../ValueObject/ServiceRequestContent.php | 33 ++ .../ServiceRequestConversionReceipt.php | 2 +- .../ValueObject/ServiceRequestLifecycle.php | 37 ++ .../ValueObject/ServiceRequestTarget.php | 35 ++ .../ValueObject/ServiceRequestTimeline.php | 37 ++ .../Repository/ServiceRequestRepository.php | 29 +- src/ServiceRequest/MODULE.md | 7 +- .../Api/Processor/ServiceRequestProcessor.php | 149 +++-- .../Api/Resource/ServiceRequestResource.php | 18 +- src/Shared/Domain/ValueObject/Uuid.php | 27 +- .../Image/ImageInputValidationAdapter.php | 21 +- src/Shared/MODULE.md | 1 + tests/Functional/Api/ParkAnomaliesApiTest.php | 6 + .../Functional/Api/ServiceRequestApiTest.php | 3 +- .../EquipmentRepositoryCoverageTest.php | 32 +- .../Repository/EquipmentRepositoryTest.php | 50 +- .../ServiceRequestSiteTargetAdapterTest.php | 314 +++++++++++ ...nterventionPublicationFactsAdapterTest.php | 30 +- .../InterventionRetentionConcurrencyTest.php | 4 +- .../InventoryLastPieceConcurrencyTest.php | 12 +- .../Repository/ProcurementRepositoryTest.php | 16 +- .../ServiceRequestRepositoryTest.php | 5 +- .../UseCase/CustomerHandlersTest.php | 62 ++- .../Domain/Model/Customer/CustomerTest.php | 58 +- .../Api/CustomerTransportTest.php | 32 +- .../EquipmentReserveReceiptServiceTest.php | 31 +- .../Domain/Model/Equipment/EquipmentTest.php | 54 +- .../ValueObject/EquipmentIdentityTest.php | 58 +- .../EquipmentValidationAdapterTest.php | 5 +- ...uipmentInterventionResourceAdapterTest.php | 30 + .../Doctrine/Mapper/EquipmentMapperTest.php | 24 +- .../UpdateEquipmentProcessorTest.php | 46 ++ .../FacilityCustomerReferenceTest.php | 56 +- .../ServiceRequestSiteTargetAdapterTest.php | 192 +++++++ .../ListInspectionsProviderTest.php | 262 ++++++++- .../Lock/InventoryTransactionLockTest.php | 65 +++ .../Mapper/InventoryRowMapperTest.php | 373 +++++++++++++ .../ManageMaintenancePlanHandlerTest.php | 4 +- .../Model/MaintenanceOccurrenceTest.php | 86 ++- .../Domain/Model/MaintenancePlanTest.php | 121 ++-- .../MaintenanceControlDatesTest.php | 80 +++ .../MaintenanceOccurrenceAttemptTest.php | 388 +++++++++++++ .../MaintenancePlanCalendarTest.php | 444 +++++++++++++++ .../MaintenancePlanIdentityTest.php | 81 +++ .../Domain/ValueObject/PlanCadenceTest.php | 2 +- .../Service/MaintenanceCostProjectionTest.php | 45 +- .../MaintenanceEconomicHandlersTest.php | 4 +- .../ReadMaintenanceExportHandlerTest.php | 193 +++++++ .../Domain/ValueObject/ExportArtifactTest.php | 23 + .../ManageProcurementHandlerTest.php | 11 +- .../ReadProcurementHandlerTest.php | 6 +- .../Domain/Model/PurchaseOrderTest.php | 13 +- .../Procurement/Domain/Model/SupplierTest.php | 15 +- .../ValueObject/ProcurementLineTest.php | 55 +- .../ChangeServiceRequestHandlerTest.php | 5 +- .../ConvertServiceRequestHandlerTest.php | 3 +- .../ServiceRequest/ServiceRequestTest.php | 70 ++- .../Processor/ServiceRequestProcessorTest.php | 131 +++++ .../Shared/Domain/ValueObject/UuidTest.php | 44 +- 173 files changed, 12322 insertions(+), 3193 deletions(-) create mode 100644 src/Customer/Domain/ValueObject/CustomerDetails.php create mode 100644 src/Customer/Domain/ValueObject/CustomerHistory.php create mode 100644 src/Equipment/Domain/ValueObject/RestoredEquipmentHistory.php create mode 100644 src/Intervention/Application/Contract/Publication/InterventionEconomicSourceFilter.php create mode 100644 src/Inventory/Domain/Model/Stock/StockReturnValuation.php create mode 100644 src/Inventory/Infrastructure/Persistence/Doctrine/Lock/InventoryTransactionLock.php create mode 100644 src/Inventory/Infrastructure/Persistence/Doctrine/Mapper/InventoryRowMapper.php create mode 100644 src/Inventory/Infrastructure/Persistence/Doctrine/Query/InventoryCollectionQuery.php create mode 100644 src/Maintenance/Application/Port/Outbound/Plan/MaintenanceLegacyPlanPort.php create mode 100644 src/Maintenance/Application/Service/MaintenanceLegacyPlanService.php create mode 100644 src/Maintenance/Application/Service/MaintenancePlanModelFactory.php create mode 100644 src/Maintenance/Domain/ValueObject/MaintenanceControlDates.php create mode 100644 src/Maintenance/Domain/ValueObject/MaintenanceOccurrenceAttempt.php create mode 100644 src/Maintenance/Domain/ValueObject/MaintenancePlanCalendar.php create mode 100644 src/Maintenance/Domain/ValueObject/MaintenancePlanIdentity.php create mode 100644 src/MaintenanceCost/Application/Service/MaintenanceEconomicAggregation.php create mode 100644 src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Mapper/MaintenanceExportMapper.php create mode 100644 src/Procurement/Application/Service/ProcurementDraftLines.php create mode 100644 src/Procurement/Application/Service/ProcurementInput.php create mode 100644 src/Procurement/Application/Service/ProcurementOperationReplay.php create mode 100644 src/Procurement/Domain/ValueObject/ProcurementGoodsIdentity.php create mode 100644 src/Procurement/Domain/ValueObject/ProcurementLineAmounts.php create mode 100644 src/Procurement/Domain/ValueObject/PurchaseOrderHistory.php create mode 100644 src/Procurement/Domain/ValueObject/PurchaseOrderIdentity.php create mode 100644 src/Procurement/Domain/ValueObject/PurchaseOrderLines.php create mode 100644 src/Procurement/Domain/ValueObject/SupplierDetails.php create mode 100644 src/Procurement/Domain/ValueObject/SupplierHistory.php create mode 100644 src/Procurement/Infrastructure/Adapter/Doctrine/ProcurementTableStorageAdapter.php create mode 100644 src/Procurement/Infrastructure/Persistence/Doctrine/Mapper/ProcurementStateMapper.php create mode 100644 src/ServiceRequest/Domain/ValueObject/ServiceRequestContent.php create mode 100644 src/ServiceRequest/Domain/ValueObject/ServiceRequestLifecycle.php create mode 100644 src/ServiceRequest/Domain/ValueObject/ServiceRequestTarget.php create mode 100644 src/ServiceRequest/Domain/ValueObject/ServiceRequestTimeline.php create mode 100644 tests/Integration/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapterTest.php create mode 100644 tests/Unit/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapterTest.php create mode 100644 tests/Unit/Inventory/Infrastructure/Persistence/Doctrine/Lock/InventoryTransactionLockTest.php create mode 100644 tests/Unit/Inventory/Infrastructure/Persistence/Doctrine/Mapper/InventoryRowMapperTest.php create mode 100644 tests/Unit/Maintenance/Domain/ValueObject/MaintenanceControlDatesTest.php create mode 100644 tests/Unit/Maintenance/Domain/ValueObject/MaintenanceOccurrenceAttemptTest.php create mode 100644 tests/Unit/Maintenance/Domain/ValueObject/MaintenancePlanCalendarTest.php create mode 100644 tests/Unit/Maintenance/Domain/ValueObject/MaintenancePlanIdentityTest.php create mode 100644 tests/Unit/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportHandlerTest.php create mode 100644 tests/Unit/ServiceRequest/Presentation/Api/Processor/ServiceRequestProcessorTest.php diff --git a/config/modules/inventory.yaml b/config/modules/inventory.yaml index 1c5431680..ada0da43c 100644 --- a/config/modules/inventory.yaml +++ b/config/modules/inventory.yaml @@ -6,9 +6,11 @@ services: Inventory\Presentation\: resource: '../../src/Inventory/Presentation' + # Pure query helpers and repository-owned locks are outside service discovery. + # The lock receives only the repository's explicitly wired main connection. Inventory\Infrastructure\Persistence\Doctrine\Repository\InventoryRepository: arguments: - $entityManager: '@doctrine.orm.main_entity_manager' + $connection: '@doctrine.dbal.main_connection' Inventory\Application\Port\Outbound\InventoryStorePort: alias: Inventory\Infrastructure\Persistence\Doctrine\Repository\InventoryRepository diff --git a/config/modules/maintenance.yaml b/config/modules/maintenance.yaml index cd99fe529..f0470bc4f 100644 --- a/config/modules/maintenance.yaml +++ b/config/modules/maintenance.yaml @@ -70,6 +70,9 @@ services: Maintenance\Application\Port\Outbound\Plan\MaintenancePlanStorePort: alias: Maintenance\Infrastructure\Persistence\Doctrine\Repository\MaintenancePlanRepository + Maintenance\Application\Port\Outbound\Plan\MaintenanceLegacyPlanPort: + alias: Maintenance\Application\Service\MaintenanceLegacyPlanService + Maintenance\Infrastructure\Persistence\Doctrine\Repository\MaintenancePlanRepository: arguments: $connection: '@doctrine.dbal.main_connection' diff --git a/config/modules/maintenance_cost.yaml b/config/modules/maintenance_cost.yaml index 4a1c6545b..9ba09e82d 100644 --- a/config/modules/maintenance_cost.yaml +++ b/config/modules/maintenance_cost.yaml @@ -8,6 +8,7 @@ services: MaintenanceCost\Application\Service\: resource: '../../src/MaintenanceCost/Application/Service' + exclude: '../../src/MaintenanceCost/Application/Service/MaintenanceEconomicAggregation.php' MaintenanceCost\Domain\Service\: resource: '../../src/MaintenanceCost/Domain/Service' diff --git a/src/Customer/Application/Service/CustomerAccessGuard.php b/src/Customer/Application/Service/CustomerAccessGuard.php index 55904055e..3430bffe7 100644 --- a/src/Customer/Application/Service/CustomerAccessGuard.php +++ b/src/Customer/Application/Service/CustomerAccessGuard.php @@ -17,7 +17,7 @@ public function __construct(private OrganizationAuthorizationPort $authorization public function assertAccess(string $actorId, string $organizationId, bool $write): void { - new Uuid($organizationId); + Uuid::assertValid($organizationId); $decision = $this->authorization->resolveAccess($actorId, $organizationId, $write ? 'organization.customers.manage' : 'organization.customers.read'); if ($decision->isOutsideScope()) { throw CustomerException::notFound(); diff --git a/src/Customer/Application/UseCase/Command/CreateCustomer/CreateCustomerHandler.php b/src/Customer/Application/UseCase/Command/CreateCustomer/CreateCustomerHandler.php index 4306f0fb7..f35196f18 100644 --- a/src/Customer/Application/UseCase/Command/CreateCustomer/CreateCustomerHandler.php +++ b/src/Customer/Application/UseCase/Command/CreateCustomer/CreateCustomerHandler.php @@ -9,6 +9,7 @@ use Customer\Application\Service\CustomerAccessGuard; use Customer\Domain\Event\CustomerChangedEvent; use Customer\Domain\Model\Customer\Customer; +use Customer\Domain\ValueObject\CustomerDetails; use Shared\Application\Message\CommandHandler; use Shared\Application\Port\Outbound\{ClockPort, EventDispatcherPort, TransactionManagerPort, UuidGeneratorPort}; @@ -24,7 +25,7 @@ public function __invoke(CreateCustomerCommand $command): CreateCustomerResult $this->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()); + $customer = Customer::create($this->ids->generate(), $command->organizationId, new CustomerDetails($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)); diff --git a/src/Customer/Domain/Model/Customer/Customer.php b/src/Customer/Domain/Model/Customer/Customer.php index 9626dca7f..92d4a96e4 100644 --- a/src/Customer/Domain/Model/Customer/Customer.php +++ b/src/Customer/Domain/Model/Customer/Customer.php @@ -5,6 +5,7 @@ namespace Customer\Domain\Model\Customer; use Customer\Domain\Exception\CustomerException; +use Customer\Domain\ValueObject\{CustomerDetails, CustomerHistory}; use DateTimeImmutable; use Shared\Domain\ValueObject\Uuid; @@ -20,48 +21,161 @@ use const FILTER_VALIDATE_EMAIL; -/** Class Customer. Organization-owned internal customer with retained archival history. @category Model */ +/** + * Class Customer + * + * Owns an organization's customer identity, validated details and retained archival history. + * + * @category Model + */ final readonly class Customer { + // #region Properties /** - * @param list $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, - ) { + * Property id + */ + public string $id; + + /** + * Property organizationId + */ + public string $organizationId; + + /** + * Property name + */ + public string $name; + + /** + * Property code + */ + public ?string $code; + + /** + * Property email + */ + public ?string $email; + + /** + * Property phone + */ + public ?string $phone; + + /** + * Property contacts + * + * @var list + */ + public array $contacts; + + /** + * Property archivedAt + */ + public ?DateTimeImmutable $archivedAt; + + /** + * Property createdAt + */ + public DateTimeImmutable $createdAt; + + /** + * Property updatedAt + */ + public DateTimeImmutable $updatedAt; + + /** + * Property revision + */ + public int $revision; + // #endregion + + // #region Constructor + /** + * Method __construct + * + * Combines customer details and history while preserving the public aggregate state. + * + * @access private + * + * @param string $id the customer identifier + * @param string $organizationId the owning organization identifier + * @param CustomerDetails $details validated or faithfully restored customer details + * @param CustomerHistory $history the persisted lifecycle state + * + * @return void + */ + private function __construct(string $id, string $organizationId, CustomerDetails $details, CustomerHistory $history) + { + $this->id = $id; + $this->organizationId = $organizationId; + $this->name = $details->name; + $this->code = $details->code; + $this->email = $details->email; + $this->phone = $details->phone; + /** @var list $contacts */ + $contacts = $details->contacts; + $this->contacts = $contacts; + $this->archivedAt = $history->archivedAt; + $this->createdAt = $history->createdAt; + $this->updatedAt = $history->updatedAt; + $this->revision = $history->revision; } + // #endregion + // #region Methods /** - * @param array $contacts + * Method create + * + * Validates identifiers before normalizing details and starting the first revision. + * + * @access public + * + * @param string $id the customer identifier + * @param string $organizationId the owning organization identifier + * @param CustomerDetails $details the supplied descriptive fields + * @param DateTimeImmutable $now the creation time + * + * @return self the validated customer */ - public static function create(string $id, string $organizationId, string $name, ?string $code, ?string $email, ?string $phone, array $contacts, DateTimeImmutable $now): self + public static function create(string $id, string $organizationId, CustomerDetails $details, DateTimeImmutable $now): self { - new Uuid($id); - new Uuid($organizationId); + Uuid::assertValid($id); + Uuid::assertValid($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); + return new self($id, $organizationId, self::normalize($details), new CustomerHistory(null, $now, $now, 1)); } /** - * @param list $contacts + * Method reconstitute + * + * Restores every persisted field without normalization or creation-time validation. + * The repository supplies the retained contact shapes and lifecycle revision. + * + * @access public + * + * @param string $id the persisted customer identifier + * @param string $organizationId the persisted owner identifier + * @param CustomerDetails $details the exact persisted descriptive fields + * @param CustomerHistory $history the exact persisted lifecycle state + * + * @return self the retained customer state */ - 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 + public static function reconstitute(string $id, string $organizationId, CustomerDetails $details, CustomerHistory $history): self { - return new self($id, $organizationId, $name, $code, $email, $phone, $contacts, $archivedAt, $createdAt, $updatedAt, $revision); + return new self($id, $organizationId, $details, $history); } /** - * @param array $changes + * Method change + * + * Applies supplied fields while retaining omitted values and clearing explicit nulls. + * + * @access public + * + * @param array $changes the fields present in the patch + * @param DateTimeImmutable $now the mutation time + * + * @return self the customer at its next revision */ public function change(array $changes, DateTimeImmutable $now): self { @@ -86,27 +200,92 @@ public function change(array $changes, DateTimeImmutable $now): self /** @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); + $details = self::normalize(new CustomerDetails($name, $code, $email, $phone, $contacts)); + + return new self($this->id, $this->organizationId, $details, new CustomerHistory($this->archivedAt, $this->createdAt, $now, $this->revision + 1)); } + /** + * Method archive + * + * Retains customer details and makes archival replay idempotent. + * + * @access public + * + * @param DateTimeImmutable $now the archival time + * + * @return self the archived customer or its unchanged retained state + */ 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); + return new self($this->id, $this->organizationId, $this->details(), new CustomerHistory($now, $this->createdAt, $now, $this->revision + 1)); } + /** + * Method restore + * + * Clears archival state once while retaining creation history and descriptive fields. + * + * @access public + * + * @param DateTimeImmutable $now the restoration time + * + * @return self the active customer or its unchanged retained state + */ 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); + return new self($this->id, $this->organizationId, $this->details(), new CustomerHistory(null, $this->createdAt, $now, $this->revision + 1)); } + /** + * Method details + * + * Groups the retained descriptive fields without revalidating historical state. + * + * @access private + * + * @return CustomerDetails the current descriptive state + */ + private function details(): CustomerDetails + { + return new CustomerDetails($this->name, $this->code, $this->email, $this->phone, $this->contacts); + } + + /** + * Method normalize + * + * Enforces creation and edit invariants independently of retained restoration state. + * + * @access private + * + * @param CustomerDetails $details the supplied descriptive fields + * + * @return CustomerDetails the normalized and validated descriptive state + */ + private static function normalize(CustomerDetails $details): CustomerDetails + { + return new CustomerDetails(self::name($details->name), self::text($details->code, 80), self::email($details->email), self::text($details->phone, 40), self::contacts($details->contacts)); + } + + /** + * Method name + * + * Requires a nonempty normalized customer or contact name within the domain limit. + * + * @access private + * + * @param string $value the supplied name + * + * @return string the normalized name + */ private static function name(string $value): string { $value = trim($value); @@ -117,6 +296,18 @@ private static function name(string $value): string return $value; } + /** + * Method text + * + * Normalizes optional fields and enforces their character limits. + * + * @access private + * + * @param string|null $value the nullable supplied field + * @param int $max the maximum character count + * + * @return string|null the normalized field, with empty strings cleared + */ private static function text(?string $value, int $max): ?string { $value = null === $value ? null : trim($value); @@ -127,6 +318,17 @@ private static function text(?string $value, int $max): ?string return '' === $value ? null : $value; } + /** + * Method email + * + * Normalizes optional email addresses and requires a valid retained format for edits. + * + * @access private + * + * @param string|null $value the nullable supplied address + * + * @return string|null the validated address + */ private static function email(?string $value): ?string { $value = self::text($value, 254); @@ -138,7 +340,13 @@ private static function email(?string $value): ?string } /** - * @param array $contacts + * Method contacts + * + * Validates contact shapes and normalizes their optional descriptive fields. + * + * @access private + * + * @param array $contacts the supplied contact list * * @return list */ @@ -163,4 +371,5 @@ private static function contacts(array $contacts): array return $normalized; } + // #endregion } diff --git a/src/Customer/Domain/ValueObject/CustomerDetails.php b/src/Customer/Domain/ValueObject/CustomerDetails.php new file mode 100644 index 000000000..8f441de81 --- /dev/null +++ b/src/Customer/Domain/ValueObject/CustomerDetails.php @@ -0,0 +1,42 @@ + $contacts supplied contacts or the retained normalized list + * + * @return void + */ + public function __construct( + public string $name, + public ?string $code, + public ?string $email, + public ?string $phone, + public array $contacts, + ) { + } + // #endregion +} diff --git a/src/Customer/Domain/ValueObject/CustomerHistory.php b/src/Customer/Domain/ValueObject/CustomerHistory.php new file mode 100644 index 000000000..beb3af88f --- /dev/null +++ b/src/Customer/Domain/ValueObject/CustomerHistory.php @@ -0,0 +1,42 @@ +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]; + $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(self::TIMESTAMP_FORMAT), 'created_at' => $customer->createdAt->format(self::TIMESTAMP_FORMAT), 'updated_at' => $customer->updatedAt->format(self::TIMESTAMP_FORMAT), 'revision' => $customer->revision]; try { if (null === $expectedRevision) { @@ -83,6 +93,9 @@ private function map(array $row): Customer /** @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']); + $details = new CustomerDetails((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); + $history = new CustomerHistory(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']); + + return Customer::reconstitute((string) $row['id'], (string) $row['organization_id'], $details, $history); } } diff --git a/src/Customer/Presentation/Api/Processor/CustomerProcessor.php b/src/Customer/Presentation/Api/Processor/CustomerProcessor.php index d6bfd4317..cff364985 100644 --- a/src/Customer/Presentation/Api/Processor/CustomerProcessor.php +++ b/src/Customer/Presentation/Api/Processor/CustomerProcessor.php @@ -68,7 +68,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = } } $header = $this->requests->getCurrentRequest()?->headers->get('If-Match'); - $revision = null === $header ? null : (1 === preg_match('/^"revision-(\d+)"$/', $header, $matches) ? (int) $matches[1] : -1); + $revision = $this->revision($header); /** @var ChangeCustomerResult $result */ $result = $this->commands->dispatch(new ChangeCustomerCommand($actorId, $organizationId, $this->identifier($uriVariables, 'id'), $action, $revision, $changes)); @@ -84,4 +84,24 @@ private function identifier(array $variables, string $key): string return is_string($value) ? $value : ''; } + + /** + * Method revision + * + * Distinguishes absent and malformed preconditions for the application access-first checks. + * + * @access private + * + * @param string|null $header the If-Match header + * + * @return int|null the supplied revision, null when absent or -1 when malformed + */ + private function revision(?string $header): ?int + { + if (null === $header) { + return null; + } + + return 1 === preg_match('/^"revision-(\d+)"$/', $header, $matches) ? (int) $matches[1] : -1; + } } diff --git a/src/Equipment/Application/Service/EquipmentReserveReceiptService.php b/src/Equipment/Application/Service/EquipmentReserveReceiptService.php index 0454b6a08..289c355ff 100644 --- a/src/Equipment/Application/Service/EquipmentReserveReceiptService.php +++ b/src/Equipment/Application/Service/EquipmentReserveReceiptService.php @@ -32,8 +32,8 @@ public function supportsType(string $organizationId, string $typeCode): bool public function reserve(EquipmentReserveReceiptRequest $request): EquipmentReserveReceiptResult { - new Uuid($request->organizationId); - new Uuid($request->actorId); + Uuid::assertValid($request->organizationId); + Uuid::assertValid($request->actorId); if ($request->quantity < 1 || $request->quantity > 100) { throw new InvalidValueException('Reserve batches must contain between one and one hundred equipment.'); } diff --git a/src/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsHandler.php b/src/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsHandler.php index 42be03801..880a97b26 100644 --- a/src/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsHandler.php +++ b/src/Equipment/Application/UseCase/Query/Equipment/ListEquipments/ListEquipmentsHandler.php @@ -96,9 +96,7 @@ public function __invoke(ListEquipmentsQuery $query): PaginatedResult { try { $organizationId = EquipmentOrganizationId::fromString($query->organizationId); - $type = null !== $query->type - ? (null === $this->selectionScopes ? \Equipment\Domain\ValueObject\EquipmentType::from($query->type)->value : EquipmentTypeCode::fromString($query->type)->value) - : null; + $type = $this->typeFilter($query->type); $status = null !== $query->status ? EquipmentStatus::from($query->status)->value : null; } catch (InvalidValueException|ValueError $exception) { throw InvalidValueException::because($exception->getMessage(), $exception); @@ -108,17 +106,7 @@ public function __invoke(ListEquipmentsQuery $query): PaginatedResult throw InvalidValueException::because('Invalid maintenanceDueStatus filter.'); } - $facilityIds = null; - if ($query->includeDescendants) { - if (null === $query->facilityId) { - throw InvalidValueException::because('includeDescendants requires a facilityId.'); - } - $facilityId = EquipmentFacilityId::fromString($query->facilityId); - $facilityIds = $this->facilitySubtree->findPublishedSubtreeIds((string) $organizationId, (string) $facilityId); - if ([] === $facilityIds) { - throw EquipmentNotFoundException::forFacilityScope((string) $facilityId); - } - } + $facilityIds = $this->facilityScope($organizationId, $query); $criteria = new EquipmentListCriteria( facilityId: $query->includeDescendants ? null : $query->facilityId, @@ -184,6 +172,58 @@ public function __invoke(ListEquipmentsQuery $query): PaginatedResult ); } + /** + * Method typeFilter + * + * Keeps legacy enum validation when no organization catalog resolver is configured. + * + * @access private + * + * @param ?string $type requested equipment type, when filtered + * + * @return ?string validated type code + */ + private function typeFilter(?string $type): ?string + { + if (null === $type) { + return null; + } + if (null === $this->selectionScopes) { + return \Equipment\Domain\ValueObject\EquipmentType::from($type)->value; + } + + return EquipmentTypeCode::fromString($type)->value; + } + + /** + * Method facilityScope + * + * Resolves a published subtree only for descendant-inclusive requests. + * + * @access private + * + * @param EquipmentOrganizationId $organizationId owning organization + * @param ListEquipmentsQuery $query requested facility scope + * + * @return ?list bounded descendant identifiers or null for direct scope + */ + private function facilityScope(EquipmentOrganizationId $organizationId, ListEquipmentsQuery $query): ?array + { + if (!$query->includeDescendants) { + return null; + } + if (null === $query->facilityId) { + throw InvalidValueException::because('includeDescendants requires a facilityId.'); + } + $facilityId = EquipmentFacilityId::fromString($query->facilityId); + $facilityIds = $this->facilitySubtree->findPublishedSubtreeIds((string) $organizationId, (string) $facilityId); + if ([] === $facilityIds) { + throw EquipmentNotFoundException::forFacilityScope((string) $facilityId); + } + + return $facilityIds; + } + /** * Method listFilteredByDueStatus. * diff --git a/src/Equipment/Domain/Model/Equipment/Equipment.php b/src/Equipment/Domain/Model/Equipment/Equipment.php index 56c8b3131..9624bfc60 100644 --- a/src/Equipment/Domain/Model/Equipment/Equipment.php +++ b/src/Equipment/Domain/Model/Equipment/Equipment.php @@ -16,7 +16,8 @@ EquipmentType, EquipmentTypeCode, PlanPosition, - RestoredEquipmentAssignment + RestoredEquipmentAssignment, + RestoredEquipmentHistory }; use InvalidArgumentException; @@ -127,17 +128,17 @@ public static function create( /** * Method reconstitute. * - * Reconstitutes an equipment aggregate from persisted state. + * Restores persisted state without applying creation normalization or replaying lifecycle transitions. * + * @access public * @since 1.0.0 * * @param EquipmentId $id the equipment identifier * @param EquipmentOrganizationId $organizationId the organization identifier - * @param EquipmentType $type the equipment type + * @param EquipmentType|EquipmentTypeCode $type the equipment type * @param EquipmentCatalogDetails $details the persisted catalog fields * @param RestoredEquipmentAssignment $assignment the persisted lifecycle and placement - * @param DateTimeImmutable $createdAt the creation timestamp - * @param DateTimeImmutable $updatedAt the update timestamp + * @param RestoredEquipmentHistory $history the persisted timestamps and replacement lineage * * @return self the reconstituted equipment aggregate */ @@ -147,18 +148,15 @@ public static function reconstitute( EquipmentType|EquipmentTypeCode $type, EquipmentCatalogDetails $details, RestoredEquipmentAssignment $assignment, - DateTimeImmutable $createdAt, - DateTimeImmutable $updatedAt, - ?string $predecessorEquipmentId = null, - ?string $successorEquipmentId = null, + RestoredEquipmentHistory $history, ): self { return new self( id: $id, organizationId: $organizationId, type: $type, status: $assignment->status, - createdAt: $createdAt, - updatedAt: $updatedAt, + createdAt: $history->createdAt, + updatedAt: $history->updatedAt, facilityId: $assignment->facilityId, subType: $details->subType, brand: $details->brand, @@ -169,8 +167,8 @@ public static function reconstitute( commissionedAt: $assignment->commissionedAt, planPosition: $assignment->planPosition, identity: $details->identity ?? EquipmentIdentity::fromValues(), - predecessorEquipmentId: $predecessorEquipmentId, - successorEquipmentId: $successorEquipmentId, + predecessorEquipmentId: $history->predecessorEquipmentId, + successorEquipmentId: $history->successorEquipmentId, ); } diff --git a/src/Equipment/Domain/ValueObject/EquipmentIdentity.php b/src/Equipment/Domain/ValueObject/EquipmentIdentity.php index 96a585158..59ef98924 100644 --- a/src/Equipment/Domain/ValueObject/EquipmentIdentity.php +++ b/src/Equipment/Domain/ValueObject/EquipmentIdentity.php @@ -15,7 +15,9 @@ use function trim; /** - * Declarative asset identity, independent from operational condition. + * Class EquipmentIdentity + * + * Describes an asset independently from its operational condition. * * @category ValueObject * @version 1.0.0 @@ -24,10 +26,21 @@ */ final readonly class EquipmentIdentity { + // #region Constructor /** + * Method __construct + * + * Stores the normalized identity after its ordered validation. + * + * @access private * @since 1.0.0 * + * @param ?string $name optional declared equipment label + * @param ?string $assetCode optional organization-owned asset reference + * @param ?string $criticality optional declared criticality * @param list $technicalProperties descriptive properties + * + * @return void */ private function __construct( public ?string $name, @@ -36,48 +49,141 @@ private function __construct( public array $technicalProperties, ) { } + // #endregion + // #region Methods /** + * Method fromValues + * + * Validates criticality and technical properties before normalizing identity text. + * + * @access public * @since 1.0.0 * + * @param ?string $name optional declared equipment label + * @param ?string $assetCode optional organization-owned asset reference + * @param ?string $criticality optional declared criticality * @param array $technicalProperties declarative properties + * + * @return self the normalized identity */ 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.'); } + $properties = self::normalizeTechnicalProperties($technicalProperties); + + return new self(self::text($name, 255), self::text($assetCode, 100), $criticality, $properties); + } + + /** + * Method normalizeTechnicalProperties + * + * Validates the bounded list and preserves entry order and normalized key uniqueness. + * + * @access private + * + * @param array $technicalProperties declarative properties + * + * @return list the normalized properties + */ + private static function normalizeTechnicalProperties(array $technicalProperties): array + { 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)]; + $normalized = self::normalizeTechnicalProperty($property, $keys); + $keys[] = $normalized['key']; + $properties[] = $normalized; } - return new self(self::text($name, 255), self::text($assetCode, 100), $criticality, $properties); + return $properties; + } + + /** + * Method normalizeTechnicalProperty + * + * Checks one entry's shape, key and value before normalizing its optional unit. + * + * @access private + * + * @param mixed $property an unvalidated property entry + * @param list $keys previously accepted normalized keys + * + * @return array{key: string, value: string, unit: ?string} the normalized entry + */ + private static function normalizeTechnicalProperty(mixed $property, array $keys): array + { + self::assertTechnicalPropertyShape($property); + $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.'); + } + + return ['key' => $key, 'value' => $value, 'unit' => self::normalizeTechnicalPropertyUnit($property['unit'] ?? null)]; } /** + * Method assertTechnicalPropertyShape + * + * Rejects malformed fields before any key or value content validation. + * + * @access private + * + * @param mixed $property an unvalidated property entry + * + * @return void + * + * @phpstan-assert array{key: string, value: string, unit?: ?string} $property + */ + private static function assertTechnicalPropertyShape(mixed $property): void + { + 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.'); + } + } + + /** + * Method normalizeTechnicalPropertyUnit + * + * Normalizes optional unit text using the unit-specific type and length limits. + * + * @access private + * + * @param mixed $unit the optional unit representation + * + * @return ?string the normalized unit + */ + private static function normalizeTechnicalPropertyUnit(mixed $unit): ?string + { + if (null !== $unit && !is_string($unit)) { + throw InvalidValueException::because('Technical property unit must be a string or null.'); + } + + return self::text($unit, 32); + } + + /** + * Method text + * + * Trims optional identity text and enforces its maximum character length. + * + * @access private * @since 1.0.0 + * + * @param ?string $value the optional text representation + * @param int $maximum the maximum number of characters after trimming + * + * @return ?string the normalized text */ private static function text(?string $value, int $maximum): ?string { @@ -90,4 +196,5 @@ private static function text(?string $value, int $maximum): ?string return trim($value); } + // #endregion } diff --git a/src/Equipment/Domain/ValueObject/RestoredEquipmentHistory.php b/src/Equipment/Domain/ValueObject/RestoredEquipmentHistory.php new file mode 100644 index 000000000..7628e01ec --- /dev/null +++ b/src/Equipment/Domain/ValueObject/RestoredEquipmentHistory.php @@ -0,0 +1,41 @@ +equipmentRepository->findById(EquipmentId::fromString($equipmentId)); if (null === $equipment || (string) $equipment->organizationId() !== $organizationId) { - throw new InvalidArgumentException(sprintf('Equipment with ID "%s" not found.', $equipmentId)); + throw new InvalidArgumentException(sprintf(self::NOT_FOUND_MESSAGE, $equipmentId)); } } @@ -66,7 +75,7 @@ public function assertPublishedEquipmentExists(string $equipmentId, string $orga $equipment = null; } if (null === $equipment || (string) $equipment->organizationId() !== $organizationId) { - throw new InvalidArgumentException(sprintf('Equipment with ID "%s" not found.', $equipmentId)); + throw new InvalidArgumentException(sprintf(self::NOT_FOUND_MESSAGE, $equipmentId)); } } @@ -78,7 +87,7 @@ public function assertEquipmentIsInspectable(string $equipmentId, string $organi $equipment = $this->equipmentRepository->findById(EquipmentId::fromString($equipmentId)); if (null === $equipment || (string) $equipment->organizationId() !== $organizationId) { - throw new InvalidArgumentException(sprintf('Equipment with ID "%s" not found.', $equipmentId)); + throw new InvalidArgumentException(sprintf(self::NOT_FOUND_MESSAGE, $equipmentId)); } if ($equipment->status()->isDecommissioned()) { diff --git a/src/Equipment/Infrastructure/Adapter/Intervention/EquipmentInterventionResourceAdapter.php b/src/Equipment/Infrastructure/Adapter/Intervention/EquipmentInterventionResourceAdapter.php index c5ae69912..4da051152 100644 --- a/src/Equipment/Infrastructure/Adapter/Intervention/EquipmentInterventionResourceAdapter.php +++ b/src/Equipment/Infrastructure/Adapter/Intervention/EquipmentInterventionResourceAdapter.php @@ -445,14 +445,7 @@ public function draftResourceIris(string $interventionId): array */ private function applyScalarFields(EquipmentRecord $record, array $patch): void { - if (array_key_exists('type', $patch)) { - $type = $patch['type']; - 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; - } + $this->applyType($record, $patch); foreach (['subType', 'brand', 'model', 'serialNumber', 'locationLabel'] as $property) { if (array_key_exists($property, $patch)) { @@ -464,6 +457,48 @@ private function applyScalarFields(EquipmentRecord $record, array $patch): void } } + $this->applyStatus($record, $patch); + $this->applyIdentity($record, $patch); + } + + /** + * Method applyType + * + * Validates a submitted catalog type while preserving an unchanged historical type. + * + * @access private + * + * @param EquipmentRecord $record equipment being changed + * @param array $patch submitted changes + * + * @return void + */ + private function applyType(EquipmentRecord $record, array $patch): void + { + if (array_key_exists('type', $patch)) { + $type = $patch['type']; + 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; + } + } + + /** + * Method applyStatus + * + * Accepts only known statuses before the published transition checks run. + * + * @access private + * + * @param EquipmentRecord $record equipment being changed + * @param array $patch submitted changes + * + * @return void + */ + private function applyStatus(EquipmentRecord $record, array $patch): void + { if (array_key_exists('status', $patch)) { $status = $patch['status']; if (!is_string($status) || !in_array($status, self::STATUSES, true)) { @@ -471,15 +506,28 @@ private function applyScalarFields(EquipmentRecord $record, array $patch): void } $record->status = $status; } + } + + /** + * Method applyIdentity + * + * Revalidates the complete declarative identity while preserving omitted fields and explicit nulls. + * + * @access private + * + * @param EquipmentRecord $record equipment being changed + * @param array $patch submitted changes + * + * @return void + */ + private function applyIdentity(EquipmentRecord $record, array $patch): void + { $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']; diff --git a/src/Equipment/Infrastructure/Persistence/Doctrine/Mapper/EquipmentMapper.php b/src/Equipment/Infrastructure/Persistence/Doctrine/Mapper/EquipmentMapper.php index 3a0e2e619..741d5cc53 100644 --- a/src/Equipment/Infrastructure/Persistence/Doctrine/Mapper/EquipmentMapper.php +++ b/src/Equipment/Infrastructure/Persistence/Doctrine/Mapper/EquipmentMapper.php @@ -14,7 +14,7 @@ EquipmentTypeCode, PlanPosition }; -use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, RestoredEquipmentAssignment}; +use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, RestoredEquipmentAssignment, RestoredEquipmentHistory}; use Equipment\Infrastructure\Persistence\Doctrine\Record\EquipmentRecord; use LogicException; use Organization\Infrastructure\Persistence\Doctrine\Record\OrganizationRecord; @@ -66,10 +66,12 @@ public static function toDomain(EquipmentRecord $record): Equipment commissionedAt: $record->commissionedAt, planPosition: null !== $record->planPosition ? PlanPosition::fromArray($record->planPosition) : null, ), - createdAt: $record->createdAt, - updatedAt: $record->updatedAt, - predecessorEquipmentId: $record->predecessorEquipmentId, - successorEquipmentId: $record->successorEquipmentId, + history: new RestoredEquipmentHistory( + createdAt: $record->createdAt, + updatedAt: $record->updatedAt, + predecessorEquipmentId: $record->predecessorEquipmentId, + successorEquipmentId: $record->successorEquipmentId, + ), ); } diff --git a/src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentTypeCatalogRepository.php b/src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentTypeCatalogRepository.php index 523c86738..58f883663 100644 --- a/src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentTypeCatalogRepository.php +++ b/src/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentTypeCatalogRepository.php @@ -123,14 +123,14 @@ public function find(string $organizationId, string $typeCode): ?EquipmentTypeDe default => 'fire', }; - return new EquipmentTypeDefinition($type->value, $type->label(), $family); + $definition = new EquipmentTypeDefinition($type->value, $type->label(), $family); + } elseif ('' !== trim($typeCode) && false !== $connection->fetchOne('SELECT 1 FROM equipment WHERE organization_id = :organization AND type = :code LIMIT 1', ['organization' => $organizationId, 'code' => $typeCode])) { + $definition = new EquipmentTypeDefinition($typeCode, $typeCode, 'other', true); + } else { + $definition = null; } - 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; + return $definition; } /** diff --git a/src/Equipment/Presentation/Api/Processor/Equipment/UpdateEquipmentProcessor.php b/src/Equipment/Presentation/Api/Processor/Equipment/UpdateEquipmentProcessor.php index 3d65ae87d..2f512fe0b 100644 --- a/src/Equipment/Presentation/Api/Processor/Equipment/UpdateEquipmentProcessor.php +++ b/src/Equipment/Presentation/Api/Processor/Equipment/UpdateEquipmentProcessor.php @@ -104,28 +104,8 @@ 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, - equipmentId: $equipmentId, - type: $data->type, - subType: $data->subType, - brand: $data->brand, - 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, - )); + $result = $this->commandBus->dispatch($this->command($data, $organizationId, $equipmentId)); } catch (EquipmentNotFoundException $exception) { throw new NotFoundHttpException($exception->getMessage(), $exception); } catch (EquipmentSerialNumberAlreadyExistsException $exception) { @@ -153,5 +133,44 @@ public function process(mixed $data, Operation $operation, array $uriVariables = return $this->outputFactory->read($organizationId, $result->equipmentId); } + + /** + * Method command + * + * Preserves omitted identity fields while allowing explicitly submitted null values to clear them. + * + * @access private + * + * @param UpdateEquipmentInput $data deserialized equipment values + * @param string $organizationId owning organization + * @param string $equipmentId equipment being changed + * + * @return UpdateEquipmentCommand update values and their explicit presence flags + */ + private function command(UpdateEquipmentInput $data, string $organizationId, string $equipmentId): UpdateEquipmentCommand + { + $body = $this->requests?->getCurrentRequest()?->getContent(); + $decoded = null !== $body && '' !== $body ? json_decode($body, true) : []; + $fields = is_array($decoded) ? $decoded : []; + + return new UpdateEquipmentCommand( + organizationId: $organizationId, + equipmentId: $equipmentId, + type: $data->type, + subType: $data->subType, + brand: $data->brand, + 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, + ); + } // #endregion } diff --git a/src/Equipment/Presentation/Api/Resource/EquipmentTypeCatalogResource.php b/src/Equipment/Presentation/Api/Resource/EquipmentTypeCatalogResource.php index 4d2626c75..5561d5cad 100644 --- a/src/Equipment/Presentation/Api/Resource/EquipmentTypeCatalogResource.php +++ b/src/Equipment/Presentation/Api/Resource/EquipmentTypeCatalogResource.php @@ -30,7 +30,7 @@ output: EquipmentTypeOutput::class, provider: EquipmentTypeCatalogProvider::class, paginationEnabled: false, - security: "is_granted('ROLE_USER')", + security: self::AUTHENTICATED, normalizationContext: ['groups' => ['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'), @@ -44,7 +44,7 @@ input: false, output: EquipmentTypeOutput::class, provider: EquipmentTypeCatalogProvider::class, - security: "is_granted('ROLE_USER')", + security: self::AUTHENTICATED, normalizationContext: ['groups' => ['equipment_type:read']], openapi: new Operation(tags: ['Equipment'], summary: 'Read an organization equipment type', responses: [ 200 => new Response(description: 'Equipment type descriptor'), @@ -60,7 +60,7 @@ input: CreateEquipmentTypeInput::class, output: EquipmentTypeOutput::class, processor: EquipmentTypeCatalogProcessor::class, - security: "is_granted('ROLE_USER')", + security: self::AUTHENTICATED, 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: [ @@ -79,7 +79,7 @@ input: PatchEquipmentTypeInput::class, output: EquipmentTypeOutput::class, processor: EquipmentTypeCatalogProcessor::class, - security: "is_granted('ROLE_USER')", + security: self::AUTHENTICATED, 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: [ @@ -94,4 +94,12 @@ )] final class EquipmentTypeCatalogResource { + // #region Constants + /** + * Constant AUTHENTICATED + * + * Organization-scoped providers and processors perform the contextual permission checks. + */ + private const string AUTHENTICATED = "is_granted('ROLE_USER')"; + // #endregion } diff --git a/src/Facility/Domain/ValueObject/FacilityCustomerReference.php b/src/Facility/Domain/ValueObject/FacilityCustomerReference.php index 5337b4afb..d5a9bbe8f 100644 --- a/src/Facility/Domain/ValueObject/FacilityCustomerReference.php +++ b/src/Facility/Domain/ValueObject/FacilityCustomerReference.php @@ -15,7 +15,7 @@ public static function assertValid(?string $customerId, FacilityType $type, ?str if (null === $customerId) { return; } - new Uuid($customerId); + Uuid::assertValid($customerId); if (FacilityType::SITE !== $type || null !== $parentId) { throw FacilityCustomerAssignmentException::rootRequired(); } diff --git a/src/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapter.php b/src/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapter.php index c16121d3c..8de0a67f4 100644 --- a/src/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapter.php +++ b/src/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapter.php @@ -5,27 +5,73 @@ namespace Facility\Infrastructure\Adapter\ServiceRequest; use Customer\Application\Port\Inbound\CustomerLookupPort; +use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManagerInterface; use Facility\Application\Port\Inbound\FacilityHierarchyPort; use ServiceRequest\Application\Contract\Target\ServiceRequestSiteTarget; use ServiceRequest\Application\Port\Outbound\ServiceRequestSiteTargetPort; -use function array_map; use function count; use function implode; -/** Class ServiceRequestSiteTargetAdapter. Resolves published ancestry and minimal customer identity on main. @category Adapter */ +/** + * Class ServiceRequestSiteTargetAdapter + * + * Resolves published ancestry and minimal customer identity on main. + * + * @category Adapter + * + * @phpstan-type AncestryRow array{id:string,name:string,type:string,parent_facility_id:?string,customer_id:?string,status:string} + */ final readonly class ServiceRequestSiteTargetAdapter implements ServiceRequestSiteTargetPort { + // #region Constructor + /** + * Method __construct + * + * @access public + * + * @param EntityManagerInterface $entityManager explicitly wired main manager + * @param CustomerLookupPort $customers organization-scoped customer identities + * @param FacilityHierarchyPort $hierarchy shared organization hierarchy lock + * + * @return void + */ public function __construct(private EntityManagerInterface $entityManager, private CustomerLookupPort $customers, private FacilityHierarchyPort $hierarchy) { } + // #endregion + // #region Methods + /** + * Method lock + * + * Acquires the shared hierarchy lock before callers resolve and lock their target. + * + * @access public + * + * @param string $organizationId owning organization + * + * @return void + */ public function lock(string $organizationId): void { $this->hierarchy->lock($organizationId); } + /** + * Method find + * + * Resolves the selected facility's published root site within one organization. + * + * @access public + * + * @param string $organizationId owning organization + * @param string|null $siteId optional root site constraint + * @param string|null $facilityId preferred target whose ancestry is resolved + * + * @return ServiceRequestSiteTarget|null the scoped target, or null when unavailable + */ public function find(string $organizationId, ?string $siteId, ?string $facilityId): ?ServiceRequestSiteTarget { $targetId = $facilityId ?? $siteId; @@ -33,8 +79,29 @@ public function find(string $organizationId, ?string $siteId, ?string $facilityI return null; } $connection = $this->entityManager->getConnection(); - /** @var list $rows */ - $rows = $connection->fetchAllAssociative(<<<'SQL' + $rows = $this->publishedAncestry($connection, $organizationId, $targetId); + $root = $this->matchingRootSite($rows, $siteId); + + return null === $root ? null : $this->siteTarget($connection, $organizationId, $rows, $root); + } + + /** + * Method publishedAncestry + * + * Fences every recursive edge by organization and publication, with cycle and depth bounds. + * + * @access private + * + * @param Connection $connection owning main connection + * @param string $organizationId owning organization + * @param string $targetId selected facility + * + * @return list the available ancestry + */ + private function publishedAncestry(Connection $connection, string $organizationId, string $targetId): array + { + /** @var list */ + return $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' @@ -46,37 +113,54 @@ public function find(string $organizationId, ?string $siteId, ?string $facilityI ) SELECT id, name, type, parent_facility_id, customer_id, status FROM ancestry SQL, ['target' => $targetId, 'organization' => $organizationId]); - if ([] === $rows) { - return null; - } + } + + /** + * Method matchingRootSite + * + * Requires an actual root site and preserves an explicit site constraint. + * + * @access private + * + * @param list $rows available ancestry + * @param string|null $siteId optional root site constraint + * + * @return AncestryRow|null the matching root + */ + private function matchingRootSite(array $rows, ?string $siteId): ?array + { $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'])) { + 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']; - } + + return $root; + } + + /** + * Method siteTarget + * + * Resolves customer identity only after the ancestry has passed its locked recheck. + * + * @access private + * + * @param Connection $connection owning main connection + * @param string $organizationId owning organization + * @param list $rows resolved ancestry + * @param AncestryRow $root matching root site + * + * @return ServiceRequestSiteTarget|null the target, or null when a retained identity is unavailable + */ + private function siteTarget(Connection $connection, string $organizationId, array $rows, array $root): ?ServiceRequestSiteTarget + { + $archived = $this->ancestryArchived($connection, $organizationId, $rows); + if (null === $archived) { + return null; } $customer = null === $root['customer_id'] ? null : $this->customers->find($root['customer_id'], $organizationId); if (null !== $root['customer_id'] && null === $customer) { @@ -85,4 +169,81 @@ public function find(string $organizationId, ?string $siteId, ?string $facilityI return new ServiceRequestSiteTarget($root['id'], $root['name'], $archived, null === $customer ? null : ['id' => $customer->id, 'name' => $customer->name]); } + + /** + * Method ancestryArchived + * + * Retains initially observed archives and catches unavailable or archived rows after locking. + * + * @access private + * + * @param Connection $connection owning main connection + * @param string $organizationId owning organization + * @param list $rows initially resolved ancestry + * + * @return bool|null the archival state, or null when a row became unavailable + */ + private function ancestryArchived(Connection $connection, string $organizationId, array $rows): ?bool + { + $archived = $this->hasArchived($rows); + if (!$connection->isTransactionActive()) { + return $archived; + } + $locked = $this->lockAncestry($connection, $organizationId, $rows); + if (count($locked) !== count($rows)) { + return null; + } + + return $archived || $this->hasArchived($locked); + } + + /** + * Method lockAncestry + * + * Locks published rows in the existing identifier order on the owning transaction. + * + * @access private + * + * @param Connection $connection owning main connection + * @param string $organizationId owning organization + * @param list $rows initially resolved ancestry + * + * @return list rows still available under the shared lock + */ + private function lockAncestry(Connection $connection, string $organizationId, array $rows): array + { + $placeholders = []; + $parameters = ['organization' => $organizationId]; + foreach ($rows as $index => $row) { + $key = 'id' . $index; + $placeholders[] = ':' . $key; + $parameters[$key] = $row['id']; + } + + /** @var list */ + return $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); + } + + /** + * Method hasArchived + * + * Marks a target archived when any observed ancestor is archived. + * + * @access private + * + * @param list $rows ancestry statuses + * + * @return bool whether an archive was observed + */ + private function hasArchived(array $rows): bool + { + foreach ($rows as $row) { + if ('archived' === $row['status']) { + return true; + } + } + + return false; + } + // #endregion } diff --git a/src/Inspection/Infrastructure/Persistence/Doctrine/Repository/ParkAnomalyRepository.php b/src/Inspection/Infrastructure/Persistence/Doctrine/Repository/ParkAnomalyRepository.php index 6d92f080d..cf8028f27 100644 --- a/src/Inspection/Infrastructure/Persistence/Doctrine/Repository/ParkAnomalyRepository.php +++ b/src/Inspection/Infrastructure/Persistence/Doctrine/Repository/ParkAnomalyRepository.php @@ -81,14 +81,12 @@ public function __construct( */ public function findCandidateEquipmentIds(string $organizationId): array { - /** @var list $ids */ - $ids = $this->entityManager->getConnection()->fetchFirstColumn( + /** @var list */ + return $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; } /** diff --git a/src/Inspection/Presentation/Api/Provider/Inspection/ListInspectionsProvider.php b/src/Inspection/Presentation/Api/Provider/Inspection/ListInspectionsProvider.php index 069560d14..a609db5ab 100644 --- a/src/Inspection/Presentation/Api/Provider/Inspection/ListInspectionsProvider.php +++ b/src/Inspection/Presentation/Api/Provider/Inspection/ListInspectionsProvider.php @@ -19,21 +19,31 @@ use Shared\Application\Contract\Pagination\{PaginatedResult, Pagination}; use Shared\Application\Exception\MessengerRuntimeException; use Shared\Application\Port\Inbound\QueryBusPort; +use Shared\Presentation\Api\Http\OperationParameterReader; use Shared\Presentation\Api\Search\SearchExtractor; use Shared\Presentation\Api\Sorting\SortingExtractor; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Component\HttpKernel\Exception\{AccessDeniedHttpException, BadRequestHttpException}; +use Symfony\Component\HttpKernel\Exception\{AccessDeniedHttpException, BadRequestHttpException, NotFoundHttpException}; use function is_numeric; use function is_string; use function max; -/** @implements ProviderInterface */ +/** + * Class ListInspectionsProvider. + * + * Translates authorized inspection collection requests into queries and paginated outputs. + * + * @category Provider + * + * @implements ProviderInterface + */ final readonly class ListInspectionsProvider implements ProviderInterface { use InspectionExceptionUnwrapperTrait; + // #region Constructor /** * Constructor. * @@ -55,55 +65,171 @@ public function __construct( private RequestStack $requestStack, ) { } + // #endregion + // #region Methods /** - * @return TraversablePaginator + * Method provide. + * + * Authorizes the requested collection before dispatching its filtered query. + * + * @access public + * + * @param Operation $operation the collection operation + * @param array $uriVariables the organization and optional facility route scope + * @param array $context the parsed pagination, search and sorting context + * + * @return TraversablePaginator the inspection collection page */ public function provide(Operation $operation, array $uriVariables = [], array $context = []): object + { + $user = $this->authenticatedUser(); + $organizationId = self::organizationId($uriVariables); + $this->assertReadAccess($user, $organizationId); + $bounds = self::pageBounds($operation, $context); + $query = $this->listQuery( + $operation, + $uriVariables, + $context, + $organizationId, + ($bounds['page'] - 1) * $bounds['itemsPerPage'], + $bounds['itemsPerPage'], + ); + + return $this->paginator($this->queryResult($query), $bounds['page'], $bounds['itemsPerPage']); + } + + /** + * Method authenticatedUser. + * + * Requires the security principal expected by organization authorization. + * + * @access private + * + * @return SecurityUser the authenticated caller + */ + private function authenticatedUser(): SecurityUser { $user = $this->security->getUser(); if (!$user instanceof SecurityUser) { throw new AccessDeniedHttpException('Authentication required.'); } + return $user; + } + + /** + * Method organizationId. + * + * Requires the organization scope supplied by the collection route. + * + * @access private + * + * @param array $uriVariables the collection route variables + * + * @return string the organization identifier + */ + private static function organizationId(array $uriVariables): string + { $organizationId = $uriVariables['organizationId'] ?? null; if (!is_string($organizationId) || '' === $organizationId) { throw new BadRequestHttpException('OrganizationId URI parameter is required.'); } + return $organizationId; + } + + /** + * Method hasParkScope. + * + * Preserves the raw request filters that trigger the additional equipment permission. + * + * @access private + * + * @return bool whether the request explicitly selects a parc scope + */ + private function hasParkScope(): bool + { $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.'); - } + + return null !== self::optionalString($requestQuery?->get('family')) + || null !== self::optionalString($requestQuery?->get('customerId')) + || ($requestQuery?->getBoolean('includeDescendants', false) ?? false); + } + + /** + * Method assertReadAccess. + * + * Keeps legacy direct reads and explicit parc reads on their existing authorization paths. + * + * @access private + * + * @param SecurityUser $user the authenticated caller + * @param string $organizationId the requested organization scope + * + * @return void + */ + private function assertReadAccess(SecurityUser $user, string $organizationId): void + { + if (!$this->hasParkScope()) { + if (!$this->authorization->hasPermission($user->getId(), $organizationId, 'organization.inspection.read')) { + throw new AccessDeniedHttpException('Missing organization.inspection.read permission.'); } - } elseif (!$this->authorization->hasPermission($user->getId(), $organizationId, 'organization.inspection.read')) { - throw new AccessDeniedHttpException('Missing organization.inspection.read permission.'); + + return; } - $filters = \Shared\Presentation\Api\Http\OperationParameterReader::filters($operation, $context); - /** @var array $filters */ + 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.'); + } + } + } + + /** + * Method pageBounds. + * + * Retains numeric pagination coercion, defaults and the minimum of one. + * + * @access private + * + * @param Operation $operation the collection operation with parsed parameters + * @param array $context the legacy filter context + * + * @return array{page: int, itemsPerPage: int} the transport pagination bounds + */ + private static function pageBounds(Operation $operation, array $context): array + { + $filters = OperationParameterReader::filters($operation, $context); $pageValue = $filters['page'] ?? 1; $itemsPerPageValue = $filters['itemsPerPage'] ?? 30; $page = is_numeric($pageValue) ? (int) $pageValue : 1; $itemsPerPage = is_numeric($itemsPerPageValue) ? (int) $itemsPerPageValue : 30; - $page = max(1, $page); - $itemsPerPage = max(1, $itemsPerPage); - - $offset = ($page - 1) * $itemsPerPage; - $query = $this->listQuery($operation, $uriVariables, $context, $organizationId, $offset, $itemsPerPage); + return ['page' => max(1, $page), 'itemsPerPage' => max(1, $itemsPerPage)]; + } + /** + * Method queryResult. + * + * Preserves HTTP validation mapping and the original cause for wrapped query failures. + * + * @access private + * + * @param ListInspectionsQuery $query the authorized collection query + * + * @return PaginatedResult the filtered inspection page + */ + private function queryResult(ListInspectionsQuery $query): PaginatedResult + { try { - /** @var PaginatedResult $queryResult */ - $queryResult = $this->queryBus->ask($query); + /** @var PaginatedResult */ + return $this->queryBus->ask($query); } catch (InvalidArgumentException $exception) { throw new BadRequestHttpException($exception->getMessage(), $exception); } catch (MessengerRuntimeException $exception) { @@ -114,7 +240,23 @@ public function provide(Operation $operation, array $uriVariables = [], array $c throw $exception; } + } + /** + * Method paginator. + * + * Maps the query page to the existing inspection output and Hydra pagination shape. + * + * @access private + * + * @param PaginatedResult $queryResult the inspection page + * @param int $page the current transport page + * @param int $itemsPerPage the requested transport page size + * + * @return TraversablePaginator the mapped inspection page + */ + private function paginator(PaginatedResult $queryResult, int $page, int $itemsPerPage): TraversablePaginator + { $outputs = []; foreach ($queryResult->items as $inspection) { $outputs[] = $this->mapResult($inspection); @@ -129,8 +271,20 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } /** - * @param array $uriVariables - * @param array $context + * Method listQuery. + * + * Translates parsed filters while preserving facility route precedence. + * + * @access private + * + * @param Operation $operation the collection operation + * @param array $uriVariables the collection route scope + * @param array $context the search and sorting context + * @param string $organizationId the authorized organization scope + * @param int $offset the zero-based page offset + * @param int $itemsPerPage the page size + * + * @return ListInspectionsQuery the filtered inspection query */ private function listQuery( Operation $operation, @@ -140,7 +294,7 @@ private function listQuery( int $offset, int $itemsPerPage, ): ListInspectionsQuery { - $params = \Shared\Presentation\Api\Http\OperationParameterReader::query($operation, $this->requestStack->getCurrentRequest()); + $params = OperationParameterReader::query($operation, $this->requestStack->getCurrentRequest()); $facilityId = self::optionalString($uriVariables['facilityId'] ?? null) ?? $params->get('facilityId'); return new ListInspectionsQuery( @@ -196,4 +350,5 @@ private function mapResult(GetInspectionResult $result): InspectionOutput { return $this->outputMapper->fromGetResult($result); } + // #endregion } diff --git a/src/Intervention/Application/Contract/Publication/InterventionEconomicSourceFilter.php b/src/Intervention/Application/Contract/Publication/InterventionEconomicSourceFilter.php new file mode 100644 index 000000000..e1596ed3d --- /dev/null +++ b/src/Intervention/Application/Contract/Publication/InterventionEconomicSourceFilter.php @@ -0,0 +1,48 @@ + $financialInterventionIds at most 10000 finance-authorized additional matching sources + * + * @return void + */ + public function __construct( + public ?string $search = null, + public ?DateTimeImmutable $from = null, + public ?DateTimeImmutable $to = null, + public ?string $siteId = null, + public ?string $customerId = null, + public ?string $equipmentId = null, + public array $financialInterventionIds = [], + ) { + } + // #endregion +} diff --git a/src/Intervention/Application/Port/Inbound/InterventionPublicationFactsPort.php b/src/Intervention/Application/Port/Inbound/InterventionPublicationFactsPort.php index 73d9eb170..57551743e 100644 --- a/src/Intervention/Application/Port/Inbound/InterventionPublicationFactsPort.php +++ b/src/Intervention/Application/Port/Inbound/InterventionPublicationFactsPort.php @@ -5,7 +5,7 @@ namespace Intervention\Application\Port\Inbound; use DateTimeImmutable; -use Intervention\Application\Contract\Publication\{InterventionEconomicContext, InterventionEconomicContextPage, InterventionPublicationFacts, InterventionPublicationFactsPage}; +use Intervention\Application\Contract\Publication\{InterventionEconomicContext, InterventionEconomicContextPage, InterventionEconomicSourceFilter, InterventionPublicationFacts, InterventionPublicationFactsPage}; /** * Interface InterventionPublicationFactsPort @@ -85,17 +85,11 @@ public function economicContext(string $organizationId, string $interventionId): * @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 + * @param ?InterventionEconomicSourceFilter $filter operational predicates and finance-authorized additional identifiers; search and dates apply to all matches * * @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; + public function economicPage(string $organizationId, int $page = 1, int $itemsPerPage = 50, ?InterventionEconomicSourceFilter $filter = null): InterventionEconomicContextPage; /** * Method economicWindow diff --git a/src/Intervention/Infrastructure/Adapter/Equipment/InterventionServiceReportAdapter.php b/src/Intervention/Infrastructure/Adapter/Equipment/InterventionServiceReportAdapter.php index 5b88423df..3f36c4f39 100644 --- a/src/Intervention/Infrastructure/Adapter/Equipment/InterventionServiceReportAdapter.php +++ b/src/Intervention/Infrastructure/Adapter/Equipment/InterventionServiceReportAdapter.php @@ -90,15 +90,7 @@ public function serviceReport(string $interventionId): ?InterventionServiceRepor } } } - 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); - } - } + $equipment = [...$equipment, ...$this->completedOperations($intervention, $reportedWorkItems)]; return new InterventionServiceReport( number: $intervention->number, @@ -143,6 +135,35 @@ private function toServicedEquipmentEntry(InterventionChangeRecord $change): ?Se ); } + /** + * Method completedOperations + * + * Adds validated operations only after publication and avoids duplicating tasks represented by applied changes. + * + * @access private + * + * @param InterventionRecord $intervention owned intervention + * @param array $reportedWorkItems tasks already represented in the applied-change history + * + * @return list additional immutable service facts + */ + private function completedOperations(InterventionRecord $intervention, array $reportedWorkItems): array + { + if ('published' !== $intervention->status) { + return []; + } + $equipment = []; + 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); + } + + return $equipment; + } + /** * Method deriveAction. * diff --git a/src/Intervention/Infrastructure/Adapter/Inventory/InterventionInventoryContextAdapter.php b/src/Intervention/Infrastructure/Adapter/Inventory/InterventionInventoryContextAdapter.php index b8731fc8f..c8917d515 100644 --- a/src/Intervention/Infrastructure/Adapter/Inventory/InterventionInventoryContextAdapter.php +++ b/src/Intervention/Infrastructure/Adapter/Inventory/InterventionInventoryContextAdapter.php @@ -23,11 +23,48 @@ /** Class InterventionInventoryContextAdapter. Shares stock/publication locking without exposing persistence records. @category Adapter */ final readonly class InterventionInventoryContextAdapter implements InterventionInventoryContextPort { + // #region Constructor public function __construct(private EntityManagerInterface $entityManager, private OrganizationAuthorizationPort $authorization, private InterventionResourceGatewayPort $resources, private InterventionMemberPolicy $members) { } + // #endregion + // #region Methods public function validate(string $organizationId, string $interventionId, ?string $workItemId, ?string $equipmentId, string $actorId): InventoryInterventionContext + { + $this->assertExecutionAccess($organizationId, $interventionId, $actorId); + $intervention = $this->lockIntervention($organizationId, $interventionId); + if (null !== $equipmentId && !$this->resources->resourceBelongsToOrganization(InterventionResourceType::EQUIPMENT, $equipmentId, $organizationId)) { + throw InterventionNotFoundException::withId($equipmentId); + } + if (null !== $workItemId) { + $this->assertWorkItemContext($intervention, $organizationId, $workItemId, $equipmentId, $actorId); + } 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]); + } + + /** + * Method assertExecutionAccess + * + * Rejects inaccessible work before acquiring either physical-declaration lock. + * + * @access private + * + * @param string $organizationId owning organization + * @param string $interventionId hidden work identifier + * @param string $actorId authenticated actor + * + * @return void + */ + private function assertExecutionAccess(string $organizationId, string $interventionId, string $actorId): void { $decision = $this->authorization->resolveAccess($actorId, $organizationId, 'organization.interventions.execute'); if ($decision->isOutsideScope()) { @@ -36,6 +73,22 @@ public function validate(string $organizationId, string $interventionId, ?string if (!$decision->isGranted()) { throw new InterventionAccessDeniedException('Intervention execution permission is required.'); } + } + + /** + * Method lockIntervention + * + * Takes the physical-declaration fence before the parent row lock, matching publication and deletion. + * + * @access private + * + * @param string $organizationId owning organization + * @param string $interventionId requested parent + * + * @return InterventionRecord fresh locked parent + */ + private function lockIntervention(string $organizationId, string $interventionId): InterventionRecord + { if (!$this->entityManager->getConnection()->isTransactionActive()) { throw new InterventionValidationException('Physical declarations require the main transaction.'); } @@ -45,37 +98,57 @@ public function validate(string $organizationId, string $interventionId, ?string 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); + + return $intervention; + } + + /** + * Method assertWorkItemContext + * + * Keeps the task, equipment and executor within the locked intervention context. + * + * @access private + * + * @param InterventionRecord $intervention locked parent + * @param string $organizationId owning organization + * @param string $workItemId requested task + * @param ?string $equipmentId declared equipment, when present + * @param string $actorId authenticated executor + * + * @return void + */ + private function assertWorkItemContext(InterventionRecord $intervention, string $organizationId, string $workItemId, ?string $equipmentId, string $actorId): void + { + $item = $this->entityManager->find(InterventionWorkItemRecord::class, $workItemId); + if (!$item instanceof InterventionWorkItemRecord || $item->intervention?->id !== $intervention->id) { + throw InterventionNotFoundException::withId($workItemId); } - 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); + if (null !== $equipmentId && $this->equipmentTarget($item->target) !== $equipmentId) { + throw new InterventionValidationException('The physical declaration must match the task equipment.'); } - - return new InventoryInterventionContext('published' === $intervention->status); + $this->members->assertCanExecuteWorkItem($organizationId, $actorId, $intervention->responsibleId, $intervention->participants, $item->assigneeId); } - public function existsInOrganization(string $organizationId, string $interventionId): bool + /** + * Method equipmentTarget + * + * Recognizes canonical IRIs and retained offline task targets without resolving live identity. + * + * @access private + * + * @param ?string $target retained task target + * + * @return ?string declared asset identifier + */ + private function equipmentTarget(?string $target): ?string { - return null !== $this->entityManager->getRepository(InterventionRecord::class)->findOneBy(['id' => $interventionId, 'organization' => $organizationId]); + $value = $target ?? ''; + if (1 === preg_match('#^/api/equipment/([^/]+)$#', $value, $match)) { + return $match[1]; + } + $decoded = json_decode($value, true); + + return is_array($decoded) && is_string($decoded['equipmentId'] ?? null) ? $decoded['equipmentId'] : null; } + // #endregion } diff --git a/src/Intervention/Infrastructure/Adapter/Publication/InterventionClosureSnapshotAdapter.php b/src/Intervention/Infrastructure/Adapter/Publication/InterventionClosureSnapshotAdapter.php index be912e446..5d6349e9b 100644 --- a/src/Intervention/Infrastructure/Adapter/Publication/InterventionClosureSnapshotAdapter.php +++ b/src/Intervention/Infrastructure/Adapter/Publication/InterventionClosureSnapshotAdapter.php @@ -29,6 +29,7 @@ */ final readonly class InterventionClosureSnapshotAdapter { + // #region Constructor /** * Method __construct * @@ -46,7 +47,9 @@ public function __construct(private EntityManagerInterface $entityManager, private InterventionMemberNamingPort $members, private InterventionSiteCustomerSnapshotPort $sites, private InterventionEquipmentSnapshotPort $equipment, private InterventionPublicationFactsMapper $facts, private InterventionInspectionResultPort $inspections) { } + // #endregion + // #region Methods /** * Method capture * @@ -72,38 +75,15 @@ public function capture(InterventionRecord $intervention, string $organizationId $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))); + $this->assertSourcesBounded([$items, $attachments, $activities, $changes, $times]); + $equipmentSnapshots = $this->equipmentSnapshots($organizationId, $items); $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; - } - } + $spentMinutes = $this->spentMinutes($times); + $evidenceCounts = $this->evidenceCounts($attachments); $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); @@ -128,6 +108,99 @@ public function capture(InterventionRecord $intervention, string $organizationId 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 assertSourcesBounded + * + * Refuses oversized sources before resolving owner identities or constructing the dossier. + * + * @access private + * + * @param list> $sources loaded owned record collections + * + * @return void + */ + private function assertSourcesBounded(array $sources): void + { + foreach ($sources as $records) { + if (count($records) > 10000) { + throw new InterventionFactsScopeTooLarge('A publication dossier exceeds 10000 records in one source; split the work before publication.'); + } + } + } + + /** + * Method equipmentSnapshots + * + * Resolves original targets and replacement successors together through the equipment owner's public bridge. + * + * @access private + * + * @param string $organizationId owning organization + * @param list $items bounded reviewed tasks + * + * @return array owner-supplied immutable equipment identity + */ + private function equipmentSnapshots(string $organizationId, array $items): array + { + $ids = []; + foreach ($items as $item) { + foreach ([$item->target, $item->resultResource] as $target) { + $id = $this->facts->equipmentId($target); + if (null !== $id) { + $ids[] = $id; + } + } + } + + return $this->equipment->snapshots($organizationId, array_values(array_unique($ids))); + } + + /** + * Method spentMinutes + * + * Captures only uncancelled entries without modifying the independent journal. + * + * @access private + * + * @param list $times bounded source entries + * + * @return array whole minutes grouped by task + */ + private function spentMinutes(array $times): array + { + $minutes = []; + foreach ($times as $entry) { + if (!$entry->cancelled && null !== $entry->workItem) { + $minutes[$entry->workItem->id] = ($minutes[$entry->workItem->id] ?? 0) + $entry->minutes; + } + } + + return $minutes; + } + + /** + * Method evidenceCounts + * + * Counts task-linked proof without including intervention-only attachments. + * + * @access private + * + * @param list $attachments bounded source evidence + * + * @return array evidence counts grouped by task + */ + private function evidenceCounts(array $attachments): array + { + $counts = []; + foreach ($attachments as $attachment) { + if (null !== $attachment->workItem) { + $counts[$attachment->workItem->id] = ($counts[$attachment->workItem->id] ?? 0) + 1; + } + } + + return $counts; + } + /** * Method workItem * @@ -185,4 +258,5 @@ private function workItem(InterventionWorkItemRecord $item, string $organization 'updatedAt' => $item->updatedAt->format('c'), ]; } + // #endregion } diff --git a/src/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapter.php b/src/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapter.php index 9a923b022..eec57262c 100644 --- a/src/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapter.php +++ b/src/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapter.php @@ -8,7 +8,7 @@ use DateTimeZone; use Doctrine\DBAL\ArrayParameterType; use Doctrine\ORM\EntityManagerInterface; -use Intervention\Application\Contract\Publication\{InterventionEconomicContext, InterventionEconomicContextPage, InterventionFactsScopeTooLarge, InterventionPublicationFacts, InterventionPublicationFactsPage, InterventionPublishedWorkFact}; +use Intervention\Application\Contract\Publication\{InterventionEconomicContext, InterventionEconomicContextPage, InterventionEconomicSourceFilter, InterventionFactsScopeTooLarge, InterventionPublicationFacts, InterventionPublicationFactsPage, InterventionPublishedWorkFact}; use Intervention\Application\Port\Inbound\InterventionPublicationFactsPort; use Intervention\Application\Port\Outbound\{InterventionEconomicScopePort, InterventionEquipmentSnapshotPort}; use Intervention\Infrastructure\Persistence\Doctrine\Mapper\InterventionPublicationFactsMapper; @@ -64,6 +64,16 @@ * 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:]]*\"([^\"]+)\"'))"; + + /** + * Constant SQL_AND + */ + private const string SQL_AND = ' AND '; + + /** + * Constant SQL_WHERE + */ + private const string SQL_WHERE = ' WHERE '; // #endregion // #region Constructor @@ -184,21 +194,15 @@ public function economicContext(string $organizationId, string $interventionId): * @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 + * @param ?InterventionEconomicSourceFilter $filter bounded operational predicates and authorized financial-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 + public function economicPage(string $organizationId, int $page = 1, int $itemsPerPage = 50, ?InterventionEconomicSourceFilter $filter = null): InterventionEconomicContextPage { $this->pagination($page, $itemsPerPage, 100); - return $this->economicSources($organizationId, $page, $itemsPerPage, $search, $from, $to, $siteId, $customerId, $equipmentId, $financialInterventionIds); + return $this->economicSources($organizationId, $page, $itemsPerPage, $filter ?? new InterventionEconomicSourceFilter()); } /** @@ -220,7 +224,7 @@ public function economicWindow(string $organizationId, DateTimeImmutable $from, { $this->pagination(1, $limit, 501); - return $this->economicSources($organizationId, 1, $limit, null, $from, $to, $siteId, $customerId, $equipmentId); + return $this->economicSources($organizationId, 1, $limit, new InterventionEconomicSourceFilter(from: $from, to: $to, siteId: $siteId, customerId: $customerId, equipmentId: $equipmentId)); } /** @@ -233,45 +237,39 @@ public function economicWindow(string $organizationId, DateTimeImmutable $from, * @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 + * @param InterventionEconomicSourceFilter $filter bounded source predicates and authorized additional identifiers * * @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 + private function economicSources(string $organizationId, int $page, int $size, InterventionEconomicSourceFilter $filter): InterventionEconomicContextPage { - $financialInterventionIds = array_values(array_unique($financialInterventionIds)); + $financialInterventionIds = array_values(array_unique($filter->financialInterventionIds)); if (count($financialInterventionIds) > 10000) { throw new InterventionFactsScopeTooLarge('The financial source scope exceeds 10000 intervention identifiers.'); } - if (null !== $from && null !== $to && $from >= $to) { + if (null !== $filter->from && null !== $filter->to && $filter->from >= $filter->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) { + if (null !== $filter->from) { $filters[] = self::SOURCE_DATE . ' >= :from'; - $parameters['from'] = $from->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s.u'); + $parameters['from'] = $filter->from->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s.u'); } - if (null !== $to) { + if (null !== $filter->to) { $filters[] = self::SOURCE_DATE . ' < :to'; - $parameters['to'] = $to->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s.u'); + $parameters['to'] = $filter->to->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s.u'); } - if (null !== $search && '' !== $search) { - if (mb_strlen($search, 'UTF-8') > 160) { + if (null !== $filter->search && '' !== $filter->search) { + if (mb_strlen($filter->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) . '%'; + $parameters['search'] = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $filter->search) . '%'; } - if (null !== $siteId || null !== $customerId || null !== $equipmentId) { - $identityFilter = $this->identityFilter($organizationId, $siteId, $customerId, $equipmentId, $parameters, $types); + if (null !== $filter->siteId || null !== $filter->customerId || null !== $filter->equipmentId) { + $identityFilter = $this->identityFilter($organizationId, $filter->siteId, $filter->customerId, $filter->equipmentId, $parameters, $types); if ([] !== $financialInterventionIds) { $parameters['financialSources'] = $financialInterventionIds; $types['financialSources'] = ArrayParameterType::STRING; @@ -279,12 +277,29 @@ private function economicSources(string $organizationId, int $page, int $size, ? } $filters[] = $identityFilter; } - $where = implode(' AND ', $filters); + $where = implode(self::SQL_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); + + return new InterventionEconomicContextPage($this->contexts($rows), $total, $page, $size); + } + + /** + * Method contexts + * + * Enforces the combined task bound while mapping the same exact page selected by the count predicate. + * + * @access private + * + * @param list> $rows bounded owned source rows + * + * @return list immutable or explicitly current contexts + */ + private function contexts(array $rows): array + { $items = []; $taskCount = 0; foreach ($rows as $row) { @@ -296,7 +311,7 @@ private function economicSources(string $organizationId, int $page, int $size, ? $items[] = $context; } - return new InterventionEconomicContextPage($items, $total, $page, $size); + return $items; } /** @@ -316,6 +331,28 @@ private function economicSources(string $organizationId, int $page, int $size, ? * @return string scoped SQL predicate */ private function identityFilter(string $organizationId, ?string $siteId, ?string $customerId, ?string $equipmentId, array &$parameters, array &$types): string + { + $published = $this->publishedIdentityFilter($siteId, $customerId, $equipmentId, $parameters); + $live = $this->liveIdentityFilter($organizationId, $siteId, $customerId, $equipmentId, $parameters, $types); + + return "((i.status = 'published' AND (" . $published . ")) OR (i.status <> 'published' AND (" . $live . ')))'; + } + + /** + * Method publishedIdentityFilter + * + * Combines retained root and same-task identity without consulting current locations or assets. + * + * @access private + * + * @param ?string $siteId retained root site filter + * @param ?string $customerId retained internal client filter + * @param ?string $equipmentId retained asset filter + * @param array $parameters shared bound values + * + * @return string immutable published source predicate + */ + private function publishedIdentityFilter(?string $siteId, ?string $customerId, ?string $equipmentId, array &$parameters): string { $root = []; $task = []; @@ -333,10 +370,32 @@ private function identityFilter(string $organizationId, ?string $siteId, ?string $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) . ')'; + $published = "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(i.closure_snapshot::jsonb -> 'workItems', '[]'::jsonb)) j WHERE " . implode(self::SQL_AND, $task) . ')'; if (null === $equipmentId && [] !== $root) { - $published = '(' . implode(' AND ', $root) . ') OR ' . $published; + $published = '(' . implode(self::SQL_AND, $root) . ') OR ' . $published; } + + return $published; + } + + /** + * Method liveIdentityFilter + * + * Resolves bounded current organization scopes only for unpublished sources. + * + * @access private + * + * @param string $organizationId owning organization + * @param ?string $siteId current root site filter + * @param ?string $customerId current internal client filter + * @param ?string $equipmentId current asset filter + * @param array $parameters shared bound values + * @param array $types shared array parameter types + * + * @return string current source predicate or explicit false + */ + private function liveIdentityFilter(string $organizationId, ?string $siteId, ?string $customerId, ?string $equipmentId, array &$parameters, array &$types): string + { $live = []; $scope = null === $siteId && null === $customerId ? null : $this->scopes->facilityIds($organizationId, $siteId, $customerId); $equipmentScope = null === $scope ? null : $this->equipment->equipmentIdsInFacilities($organizationId, $scope); @@ -355,7 +414,7 @@ private function identityFilter(string $organizationId, ?string $siteId, ?string } } - return "((i.status = 'published' AND (" . $published . ")) OR (i.status <> 'published' AND (" . ([] === $live ? 'FALSE' : implode(' OR ', $live)) . ')))'; + return [] === $live ? 'FALSE' : implode(' OR ', $live); } /** @@ -376,7 +435,7 @@ private function rows(string $where, array $parameters, array $types, int $limit { $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); + return $this->entityManager->getConnection()->fetchAllAssociative('SELECT i.*, p.id AS publication_id, p.completed_at AS published_at ' . self::SOURCE . self::SQL_WHERE . $where . ' ORDER BY i.created_at DESC, i.id ASC LIMIT ' . $limit . ' OFFSET ' . $offset, $parameters, $types); } /** @@ -397,7 +456,7 @@ private function rows(string $where, array $parameters, array $types, int $limit */ 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; + $selected = 'SELECT i.id, i.status, i.closure_snapshot ' . self::SOURCE . self::SQL_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"; @@ -425,7 +484,7 @@ private function assertPageBounded(string $where, array $parameters, array $type */ private function total(string $where, array $parameters, array $types): int { - $total = $this->entityManager->getConnection()->fetchOne('SELECT COUNT(*) ' . self::SOURCE . ' WHERE ' . $where, $parameters, $types); + $total = $this->entityManager->getConnection()->fetchOne('SELECT COUNT(*) ' . self::SOURCE . self::SQL_WHERE . $where, $parameters, $types); return is_int($total) || is_string($total) ? (int) $total : throw new UnexpectedValueException('The scoped intervention count is unavailable.'); } diff --git a/src/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionPublicationFactsMapper.php b/src/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionPublicationFactsMapper.php index 08e113a91..564996512 100644 --- a/src/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionPublicationFactsMapper.php +++ b/src/Intervention/Infrastructure/Persistence/Doctrine/Mapper/InterventionPublicationFactsMapper.php @@ -46,17 +46,24 @@ public function context(array $row, ?array $snapshot, array $liveItems = [], ?ar { $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; + $items = $liveItems; + $site = $liveSite; + $customer = $liveCustomer; + $state = 'live'; + $plannedStartAt = $row['planned_start_at']; + $dueAt = $row['due_at']; + $publishedAt = null; + $publicationId = null; + if ($published) { + $items = null === $snapshot ? [] : $this->snapshotItems($snapshot); + $site = $this->identity($snapshot['site'] ?? null); + $customer = $this->identity($snapshot['customer'] ?? null); + $state = null === $snapshot ? 'snapshot_missing' : 'available'; + $plannedStartAt = $snapshot['plannedStartAt'] ?? $report['plannedStartAt'] ?? null; + $dueAt = $snapshot['dueAt'] ?? $report['dueAt'] ?? null; + $publishedAt = $this->date($snapshot['publishedAt'] ?? $row['published_at'] ?? $snapshot['capturedAt'] ?? null); + $publicationId = $this->nullableString($snapshot['publicationId'] ?? $row['publication_id'] ?? null); } - 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']), @@ -67,15 +74,15 @@ public function context(array $row, ?array $snapshot, array $liveItems = [], ?ar $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, + $this->date($plannedStartAt), + $this->date($dueAt), + $publishedAt, + $publicationId, $state, is_int($snapshot['version'] ?? null) ? $snapshot['version'] : null, - $complete, - $published ? $this->identity($snapshot['site'] ?? null) : $liveSite, - $published ? $this->identity($snapshot['customer'] ?? null) : $liveCustomer, + $this->identityComplete($published, $snapshot, $row['site_id'] ?? null, $site, $items), + $site, + $customer, $items, ); } @@ -101,10 +108,7 @@ public function snapshot(mixed $value): ?array } /** @var array $value */ - /** @var array $minimized */ - $minimized = $this->minimizeIdentities($value); - - return $minimized; + return $this->minimizeIdentities($value); } /** @@ -193,6 +197,35 @@ 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 identityComplete + * + * Requires the retained dossier and location identity when a source or task declares them. + * + * @access private + * + * @param bool $published whether live identity is forbidden + * @param ?array $snapshot supported retained dossier + * @param mixed $siteId declared root location identifier + * @param ?array{id:string,name:string} $site resolved original or current site + * @param list $items bounded task facts + * + * @return bool whether all declared operational identity is available + */ + private function identityComplete(bool $published, ?array $snapshot, mixed $siteId, ?array $site, array $items): bool + { + if (($published && null === $snapshot) || (null !== $siteId && null === $site)) { + return false; + } + foreach ($items as $item) { + if (null !== $item->equipmentId && (null === $item->equipmentIdentity || (null !== $item->equipmentIdentity->facilityId && null === $item->equipmentIdentity->site))) { + return false; + } + } + + return true; + } + /** * Method snapshotItems * @@ -253,9 +286,11 @@ private function date(mixed $value): ?DateTimeImmutable * * @access private * - * @param array $source retained JSON object or list + * @template TKey of array-key + * + * @param array $source retained JSON object or list * - * @return array retained operational facts with minimal identity nodes + * @return array retained operational facts with minimal identity nodes */ private function minimizeIdentities(array $source): array { diff --git a/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkItemWriter.php b/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkItemWriter.php index a611221f5..d18f235fc 100644 --- a/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkItemWriter.php +++ b/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkItemWriter.php @@ -398,16 +398,7 @@ private function createWorkItem(InterventionWorkflowMutation $mutation, array &$ $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->assertOperationSource($record, $intervention); $this->runtime->support->assertFacilityTarget($record->target, $intervention); $this->runtime->support->assertFacilityTarget($record->resultResource, $intervention); $record->assigneeId = $assigneeId; @@ -432,6 +423,32 @@ private function createWorkItem(InterventionWorkflowMutation $mutation, array &$ return $this->runtime->views->workItemView($record); } + /** + * Method assertOperationSource + * + * Keeps preventive occurrence identity complete and aligned with the prepared task action and equipment. + * + * @access private + * + * @param InterventionWorkItemRecord $record prepared task + * @param InterventionRecord $intervention owning intervention + * + * @return void + */ + private function assertOperationSource(InterventionWorkItemRecord $record, InterventionRecord $intervention): void + { + $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); + } + } + /** * Method applyExecutionResult * @@ -452,48 +469,105 @@ private function applyExecutionResult(InterventionWorkItemRecord $record, Interv 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.'); - } + $this->stageExecutionResult($record, $intervention, $mutation); } 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.'); + $this->assertCompletedExecution($record, $intervention); + } + } + + /** + * Method stageExecutionResult + * + * Validates the authenticated attempt before retaining its history or creating an activity. + * + * @access private + * + * @param InterventionWorkItemRecord $record locked equipment task + * @param InterventionRecord $intervention owning intervention + * @param InterventionWorkflowMutation $mutation authenticated execution mutation + * + * @return void + */ + private function stageExecutionResult(InterventionWorkItemRecord $record, InterventionRecord $intervention, InterventionWorkflowMutation $mutation): void + { + $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; + + return; + } + if (!is_array($payload)) { + throw new InterventionValidationException('The execution result must be an object.'); + } + $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.'); + } + $this->recordExecutionAttempt($record, $intervention, $result, $authorId); + } + + /** + * Method recordExecutionAttempt + * + * Preserves the previous attempts and leaves identical replay without an additional activity. + * + * @access private + * + * @param InterventionWorkItemRecord $record locked equipment task + * @param InterventionRecord $intervention owning intervention + * @param WorkItemExecutionResult $result validated actual execution facts + * @param string $authorId authenticated organization member + * + * @return void + */ + private function recordExecutionAttempt(InterventionWorkItemRecord $record, InterventionRecord $intervention, WorkItemExecutionResult $result, string $authorId): void + { + $previous = $record->executionResult; + $fact = $result->toArray(); + if (null !== $previous && array_intersect_key($previous, $fact) === $fact) { + return; + } + $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]))); + } + + /** + * Method assertCompletedExecution + * + * Requires successful execution and the replacement successor before accepting completion. + * + * @access private + * + * @param InterventionWorkItemRecord $record completed equipment task + * @param InterventionRecord $intervention owning intervention + * + * @return void + */ + private function assertCompletedExecution(InterventionWorkItemRecord $record, InterventionRecord $intervention): void + { + 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.'); } } diff --git a/src/Intervention/MODULE.md b/src/Intervention/MODULE.md index 9f94464b5..43198c8aa 100644 --- a/src/Intervention/MODULE.md +++ b/src/Intervention/MODULE.md @@ -17,7 +17,9 @@ or rewritten on read; contact fields in legacy identity nodes are minimized at t 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 +keep organization scope and pagination explicit; `InterventionEconomicSourceFilter` groups +literal search, source dates, operational identity and finance-authorized additional identifiers. +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 diff --git a/src/Intervention/Presentation/Api/Resource/InterventionTimeResource.php b/src/Intervention/Presentation/Api/Resource/InterventionTimeResource.php index c8e62e5be..203c0a9e0 100644 --- a/src/Intervention/Presentation/Api/Resource/InterventionTimeResource.php +++ b/src/Intervention/Presentation/Api/Resource/InterventionTimeResource.php @@ -20,7 +20,7 @@ * @author Valentin FORTIN */ #[ApiResource(shortName: 'InterventionTime', normalizationContext: ['skip_null_values' => false], operations: [ - new Get(name: InterventionTimeOperations::GET, uriTemplate: '/intervention-work-items/{taskId}/time-entries/{entryId}', uriVariables: ['taskId', 'entryId'], output: TimeEntryOutput::class, provider: InterventionTimeProvider::class, security: self::SECURITY_ROLE_USER), + new Get(name: InterventionTimeOperations::GET, uriTemplate: self::ENTRY_URI, uriVariables: ['taskId', 'entryId'], output: TimeEntryOutput::class, provider: InterventionTimeProvider::class, security: self::SECURITY_ROLE_USER), new Get(name: InterventionTimeOperations::LIST, uriTemplate: '/intervention-work-items/{taskId}/time-entries', uriVariables: ['taskId'], output: TimeJournalOutput::class, provider: InterventionTimeProvider::class, security: self::SECURITY_ROLE_USER, parameters: [ 'page' => new QueryParameter(schema: ['type' => 'integer', 'minimum' => 1, 'default' => 1], castToArray: false, castToNativeType: false, constraints: []), 'itemsPerPage' => new QueryParameter(schema: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 30], castToArray: false, castToNativeType: false, constraints: []), @@ -31,12 +31,19 @@ 'itemsPerPage' => new QueryParameter(schema: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 30], castToArray: false, castToNativeType: false, constraints: []), ]), new Post(name: InterventionTimeOperations::CREATE, uriTemplate: '/intervention-work-items/{taskId}/time-entries', uriVariables: ['taskId'], read: false, input: WriteTimeEntryInput::class, output: TimeEntryOutput::class, processor: InterventionTimeProcessor::class, status: 201, security: self::SECURITY_ROLE_USER), - new Patch(name: InterventionTimeOperations::CORRECT, uriTemplate: '/intervention-work-items/{taskId}/time-entries/{entryId}', uriVariables: ['taskId', 'entryId'], read: false, input: WriteTimeEntryInput::class, output: TimeEntryOutput::class, processor: InterventionTimeProcessor::class, security: self::SECURITY_ROLE_USER), - new Delete(name: InterventionTimeOperations::CANCEL, uriTemplate: '/intervention-work-items/{taskId}/time-entries/{entryId}', uriVariables: ['taskId', 'entryId'], read: false, input: false, output: false, processor: InterventionTimeProcessor::class, status: 204, security: self::SECURITY_ROLE_USER), + new Patch(name: InterventionTimeOperations::CORRECT, uriTemplate: self::ENTRY_URI, uriVariables: ['taskId', 'entryId'], read: false, input: WriteTimeEntryInput::class, output: TimeEntryOutput::class, processor: InterventionTimeProcessor::class, security: self::SECURITY_ROLE_USER), + new Delete(name: InterventionTimeOperations::CANCEL, uriTemplate: self::ENTRY_URI, uriVariables: ['taskId', 'entryId'], read: false, input: false, output: false, processor: InterventionTimeProcessor::class, status: 204, security: self::SECURITY_ROLE_USER), ])] final class InterventionTimeResource { // #region Constants + /** + * Constant ENTRY_URI + * + * Identifies one time entry consistently for reading, correction and cancellation. + */ + private const string ENTRY_URI = '/intervention-work-items/{taskId}/time-entries/{entryId}'; + /** * Constant SECURITY_ROLE_USER */ diff --git a/src/Inventory/Application/UseCase/Command/ApplyInventoryStock/ApplyInventoryStockHandler.php b/src/Inventory/Application/UseCase/Command/ApplyInventoryStock/ApplyInventoryStockHandler.php index 6a25102cf..ce996aef3 100644 --- a/src/Inventory/Application/UseCase/Command/ApplyInventoryStock/ApplyInventoryStockHandler.php +++ b/src/Inventory/Application/UseCase/Command/ApplyInventoryStock/ApplyInventoryStockHandler.php @@ -11,7 +11,7 @@ use Inventory\Application\Contract\Stock\{InventoryOperationReceipt,InventoryReceiptResult}; use Inventory\Application\Port\Outbound\InventoryStorePort; use Inventory\Domain\Exception\{InventoryConflictException,InventoryNotFoundException}; -use Inventory\Domain\Model\Stock\{ConsumptionDeclaration, InventoryReference, StockBalance, StockMovement, StockValuation}; +use Inventory\Domain\Model\Stock\{ConsumptionDeclaration, InventoryReference, StockBalance, StockMovement, StockReturnValuation, StockValuation}; use Inventory\Domain\ValueObject\StockQuantity; use LogicException; use MaintenanceCost\Application\Port\Inbound\MaintenanceCurrencyPort; @@ -33,6 +33,16 @@ /** Atomically retains physical declarations, full stock issues, valuation and immutable replay snapshots. @category UseCase */ final readonly class ApplyInventoryStockHandler implements CommandHandler { + /** + * Constant DECLARATION_NOT_FOUND + */ + private const string DECLARATION_NOT_FOUND = 'Consumption declaration not found.'; + + /** + * Constant ZERO + */ + private const string ZERO = '0.000000'; + public function __construct(private InventoryStorePort $store, private TransactionManagerPort $transactions, private \Shared\Application\Port\Outbound\UuidGeneratorPort $ids, private MaintenanceCurrencyPort $currency, private InterventionInventoryContextPort $interventions) { } @@ -40,8 +50,8 @@ public function __construct(private InventoryStorePort $store, private Transacti public function __invoke(ApplyInventoryStockCommand $command): ApplyInventoryStockResult { $command = $this->canonical($command); - new Uuid($command->organizationId); - new Uuid($command->actorId); + Uuid::assertValid($command->organizationId); + Uuid::assertValid($command->actorId); if (!in_array($command->kind, ['receipt', 'receipt_return', 'consumption', 'return', 'correction', 'reconcile'], true)) { throw new InvalidArgumentException('Unknown inventory operation.'); } @@ -51,7 +61,7 @@ public function __invoke(ApplyInventoryStockCommand $command): ApplyInventorySto if (null === $command->clientOperationId) { throw new InvalidArgumentException('A stable clientOperationId is required.'); } - new Uuid($command->clientOperationId); + Uuid::assertValid($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)); @@ -99,7 +109,7 @@ private function validateWorkContext(ApplyInventoryStockCommand $command): ?Inve return $this->interventions->validate($command->organizationId, $command->interventionId ?? throw new InvalidArgumentException('An intervention is required.'), $command->workItemId, $command->equipmentId, $command->actorId); } if ('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.'); + $declaration = $this->store->declaration($command->organizationId, $command->originalId ?? throw new InvalidArgumentException('An original consumption is required.')) ?? throw new InventoryNotFoundException(self::DECLARATION_NOT_FOUND); return $this->interventions->validate($command->organizationId, $declaration->interventionId, $declaration->workItemId, $declaration->equipmentId, $command->actorId); } @@ -113,7 +123,7 @@ private function consume(ApplyInventoryStockCommand $command, string $quantity, $intervention = $command->interventionId ?? throw new InvalidArgumentException('An interventionId is required.'); foreach ([$intervention, $command->workItemId, $command->equipmentId] as $id) { if (null !== $id) { - new Uuid($id); + Uuid::assertValid($id); } } [$part,$warehouse] = $this->references($org, $command->partId, $command->warehouseId); @@ -161,15 +171,15 @@ private function resolve(ConsumptionDeclaration $declaration, InventoryReference private function reconcile(ApplyInventoryStockCommand $command): ApplyInventoryStockResult { $id = $command->originalId ?? throw new InvalidArgumentException('A declarationId is required.'); - new Uuid($id); + Uuid::assertValid($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.'); + throw new InventoryNotFoundException(self::DECLARATION_NOT_FOUND); } $context = $this->interventions->validate($command->organizationId, $candidate->interventionId, $candidate->workItemId, $candidate->equipmentId, $command->actorId); $currency = $this->currency->lock($command->organizationId); - $declaration = $this->store->declaration($command->organizationId, $id, true) ?? throw new InventoryNotFoundException('Consumption declaration not found.'); + $declaration = $this->store->declaration($command->organizationId, $id, true) ?? throw new InventoryNotFoundException(self::DECLARATION_NOT_FOUND); if ('confirmed' === $declaration->status) { return new ApplyInventoryStockResult(declaration:$declaration, replayed:true); } @@ -190,7 +200,7 @@ private function receive(ApplyInventoryStockCommand $command, string $quantity, $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); + $valuation = new StockValuation(null === $balance ? self::ZERO : $balance->quantity, null === $balance ? self::ZERO : $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); @@ -201,25 +211,9 @@ private function receive(ApplyInventoryStockCommand $command, string $quantity, private function returnStock(ApplyInventoryStockCommand $command, string $quantity, string $currency, ?InventoryInterventionContext $context): 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.'); - } - $late = ($context ?? throw new LogicException('Missing work 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.'); - } + [$original,$late] = $this->returnableMovement($command, $context); + $originalId = $original->id; [$part,$warehouse] = $this->references($org, $original->partId, $original->warehouseId); $balance = $this->store->balanceForUpdate($org, $warehouse->id, $part->id) ?? throw new InventoryConflictException('Missing inventory balance.'); $returned = DecimalAmount::fromString($this->store->linkedQuantity($org, $originalId)); @@ -235,24 +229,8 @@ private function returnStock(ApplyInventoryStockCommand $command, string $quanti 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.'); - } + $value = 'return' === $command->kind ? StockReturnValuation::allocate($original, $quantity, $returned->toString(), $linkedValue) : null; $sign = 'return' === $command->kind ? 1 : -1; $valuation = new StockValuation($balance->quantity, $balance->totalValue); if (1 === $sign) { @@ -263,19 +241,53 @@ private function returnStock(ApplyInventoryStockCommand $command, string $quanti $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); + $signedValue = $value; + if (-1 === $sign && null !== $value) { + $signedValue = $this->negative($value); + } + $movement = new StockMovement($this->ids->generate(), $org, $part->id, $warehouse->id, $command->kind, 1 === $sign ? $quantity : $this->negative($quantity), $unit, $signedValue, $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)); } + /** + * Method returnableMovement + * + * Resolves the original movement and its publication status before taking the stock lock. + * + * @access private + * + * @param ApplyInventoryStockCommand $command the canonical return command + * @param InventoryInterventionContext|null $context the already validated work context + * + * @return array{StockMovement,bool} the original movement and late flag + */ + private function returnableMovement(ApplyInventoryStockCommand $command, ?InventoryInterventionContext $context): array + { + $originalId = $command->originalId ?? throw new InvalidArgumentException('The original declaration or movement is required.'); + Uuid::assertValid($originalId); + $late = false; + if ('return' === $command->kind) { + $declaration = $this->store->declaration($command->organizationId, $originalId) ?? throw new InventoryNotFoundException(self::DECLARATION_NOT_FOUND); + $late = ($context ?? throw new LogicException('Missing work context.'))->published; + $originalId = $declaration->movementId ?? throw new InventoryConflictException('An unresolved declaration cannot be returned to stock.'); + } + $original = $this->store->movement($command->organizationId, $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.'); + } + + return [$original, $late]; + } + private function correct(ApplyInventoryStockCommand $command, string $quantity, string $currency): ApplyInventoryStockResult { $reason = $this->reason($command->reason); [$part,$warehouse] = $this->references($command->organizationId, $command->partId, $command->warehouseId); $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); + $valuation = new StockValuation(null === $balance ? self::ZERO : $balance->quantity, null === $balance ? self::ZERO : $balance->totalValue); $delta = DecimalAmount::fromString($quantity); $value = null; $unit = null; @@ -303,8 +315,9 @@ private function references(string $org, ?string $partId, ?string $warehouseId): { if (null === $partId || null === $warehouseId) { throw new InvalidArgumentException('Part and warehouse identifiers are required.'); - }new Uuid($partId); - new Uuid($warehouseId); + } + Uuid::assertValid($partId); + Uuid::assertValid($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.'); diff --git a/src/Inventory/Application/UseCase/Command/ManageInventoryReference/ManageInventoryReferenceHandler.php b/src/Inventory/Application/UseCase/Command/ManageInventoryReference/ManageInventoryReferenceHandler.php index 276650e95..16414c11f 100644 --- a/src/Inventory/Application/UseCase/Command/ManageInventoryReference/ManageInventoryReferenceHandler.php +++ b/src/Inventory/Application/UseCase/Command/ManageInventoryReference/ManageInventoryReferenceHandler.php @@ -24,7 +24,7 @@ public function __construct(private InventoryStorePort $store, private Transacti public function __invoke(ManageInventoryReferenceCommand $command): ManageInventoryReferenceResult { - new Uuid($command->organizationId); + Uuid::assertValid($command->organizationId); if (!in_array($command->type, ['parts', 'warehouses'], true)) { throw new InvalidArgumentException('Invalid inventory reference type.'); } @@ -32,18 +32,58 @@ public function __invoke(ManageInventoryReferenceCommand $command): ManageInvent return $this->transactions->transactional(function () use ($command): ManageInventoryReferenceResult { $existing = null; if (null !== $command->id) { - new Uuid($command->id); + Uuid::assertValid($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); + $reference = null === $existing ? $this->createReference($command) : $this->patchReference($command, $existing); $this->store->saveReference($command->type, $reference); return new ManageInventoryReferenceResult($reference); }); } + + /** + * Method createReference + * + * Applies the part defaults only to a newly created part reference. + * + * @access private + * + * @param ManageInventoryReferenceCommand $command the validated catalog command + * + * @return InventoryReference the new part or warehouse + */ + private function createReference(ManageInventoryReferenceCommand $command): InventoryReference + { + $unit = null; + $kind = null; + if ('parts' === $command->type) { + $unit = $command->unit ?? 'piece'; + $kind = $command->kind ?? 'part'; + } + + return new InventoryReference($this->ids->generate(), $command->organizationId, trim($command->code ?? ''), trim($command->label ?? ''), $unit, $kind, $command->archived ?? false); + } + + /** + * Method patchReference + * + * Preserves the existing reference identity, code and kind for catalog patches. + * + * @access private + * + * @param ManageInventoryReferenceCommand $command the validated catalog patch + * @param InventoryReference $existing the locked owned reference + * + * @return InventoryReference the patched part or warehouse + */ + private function patchReference(ManageInventoryReferenceCommand $command, InventoryReference $existing): InventoryReference + { + $unit = 'parts' === $command->type ? $command->unit ?? $existing->unit : null; + + return new InventoryReference($existing->id, $command->organizationId, $existing->code, trim($command->label ?? $existing->label), $unit, $existing->kind, $command->archived ?? $existing->archived); + } } diff --git a/src/Inventory/Application/UseCase/Query/ListInventory/ListInventoryHandler.php b/src/Inventory/Application/UseCase/Query/ListInventory/ListInventoryHandler.php index 00e61d233..b79ffef0c 100644 --- a/src/Inventory/Application/UseCase/Query/ListInventory/ListInventoryHandler.php +++ b/src/Inventory/Application/UseCase/Query/ListInventory/ListInventoryHandler.php @@ -23,7 +23,35 @@ public function __construct(private InventoryStorePort $store, private Intervent public function __invoke(ListInventoryQuery $query): ListInventoryResult { - new Uuid($query->organizationId); + Uuid::assertValid($query->organizationId); + $this->validateFilters($query); + $this->validateReferenceFilters($query); + if (null !== $query->id) { + return $this->detail($query); + } + if (isset($query->filters['interventionId'])) { + Uuid::assertValid($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)); + } + + /** + * Method validateFilters + * + * Rejects invalid collection filter values before resolving scoped identities. + * + * @access private + * + * @param ListInventoryQuery $query the requested inventory read + * + * @return void + */ + private function validateFilters(ListInventoryQuery $query): void + { if (isset($query->filters['archived']) && !in_array($query->filters['archived'], ['true', 'false'], true)) { throw new InvalidArgumentException('archived must be true or false.'); } @@ -36,32 +64,53 @@ public function __invoke(ListInventoryQuery $query): ListInventoryResult if ($query->page < 1 || $query->itemsPerPage < 1 || $query->itemsPerPage > 100) { throw new InvalidArgumentException('Inventory pagination must be between 1 and 100.'); } + } + + /** + * Method validateReferenceFilters + * + * Requires part and warehouse filters to identify references owned by the organization. + * + * @access private + * + * @param ListInventoryQuery $query the requested inventory read + * + * @return void + */ + private function validateReferenceFilters(ListInventoryQuery $query): void + { foreach (['warehouseId' => 'warehouses', 'partId' => 'parts'] as $key => $type) { if (isset($query->filters[$key])) { - new Uuid($query->filters[$key]); + Uuid::assertValid($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.'); - } + /** + * Method detail + * + * Projects one owned reference, declaration or immutable movement. + * + * @access private + * + * @param ListInventoryQuery $query the requested detail + * + * @return ListInventoryResult the owned inventory item + */ + private function detail(ListInventoryQuery $query): ListInventoryResult + { + $id = $query->id ?? throw new InvalidArgumentException('Unsupported inventory detail.'); + Uuid::assertValid($id); + $item = match($query->type) { + 'parts','warehouses' => $this->store->reference($query->type, $query->organizationId, $id),'consumptions' => $this->store->declaration($query->organizationId, $id),'movements' => $this->store->movement($query->organizationId, $id),default => throw new InvalidArgumentException('Unsupported inventory detail.') + }; + if (null === $item) { + throw new InventoryNotFoundException('Inventory item 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)); + return new ListInventoryResult([$item], 1); } } diff --git a/src/Inventory/Domain/Model/Stock/StockReturnValuation.php b/src/Inventory/Domain/Model/Stock/StockReturnValuation.php new file mode 100644 index 000000000..3ba4c2809 --- /dev/null +++ b/src/Inventory/Domain/Model/Stock/StockReturnValuation.php @@ -0,0 +1,63 @@ +totalValue ? null : DecimalAmount::fromString($original->totalValue); + if (null !== $originalValue && $originalValue->isNegative()) { + $originalValue = DecimalAmount::zero()->subtract($originalValue); + } + if (null === $originalValue || null === $linkedValue) { + return null; + } + $remainingValue = $originalValue->subtract(DecimalAmount::fromString($linkedValue)); + if ($remainingValue->isNegative()) { + throw new InventoryConflictException('Original stock valuation is inconsistent.'); + } + $originalQuantity = DecimalAmount::fromString($original->quantity); + if ($originalQuantity->isNegative()) { + $originalQuantity = DecimalAmount::zero()->subtract($originalQuantity); + } + $returned = DecimalAmount::fromString($returnedQuantity); + $currentQuantity = DecimalAmount::fromString($quantity); + $lastReturn = 0 === $returned->add($currentQuantity)->compareTo($originalQuantity); + $value = $lastReturn ? $remainingValue->toString() : $remainingValue->multiplyAndDivide($currentQuantity, $originalQuantity->subtract($returned))->toString(); + if (DecimalAmount::fromString($value)->isNegative()) { + throw new InventoryConflictException('Original stock valuation is inconsistent.'); + } + + return $value; + } + // #endregion +} diff --git a/src/Inventory/Domain/Model/Stock/StockValuation.php b/src/Inventory/Domain/Model/Stock/StockValuation.php index 86aad9bcf..e5d8edd83 100644 --- a/src/Inventory/Domain/Model/Stock/StockValuation.php +++ b/src/Inventory/Domain/Model/Stock/StockValuation.php @@ -11,6 +11,11 @@ /** Nonnegative quantities and CUMP, with all remaining value allocated on the last issue. @category Model */ final readonly class StockValuation { + /** + * Constant ZERO + */ + private const string ZERO = '0.000000'; + public function __construct(public string $quantity, public ?string $totalValue) { if (DecimalAmount::fromString($quantity)->isNegative() || (null !== $totalValue && DecimalAmount::fromString($totalValue)->isNegative())) { @@ -24,9 +29,9 @@ public function receive(string $quantity, ?string $value): self if (null !== $value && DecimalAmount::fromString($value)->isNegative()) { throw new InventoryConflictException('Receipt valuation cannot be negative.'); } - $known = '0.000000' === $this->quantity || null !== $this->totalValue; + $known = self::ZERO === $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); + return new self(DecimalAmount::fromString($this->quantity)->add(DecimalAmount::fromString($quantity))->toString(), $known && null !== $value ? DecimalAmount::fromString($this->totalValue ?? self::ZERO)->add(DecimalAmount::fromString($value))->toString() : null); } public function issue(string $quantity): StockIssue @@ -43,7 +48,13 @@ public function issue(string $quantity): StockIssue } } $remaining = DecimalAmount::fromString($this->quantity)->subtract(DecimalAmount::fromString($quantity))->toString(); + $remainingValue = null; + if (self::ZERO === $remaining) { + $remainingValue = self::ZERO; + } elseif (null !== $value) { + $remainingValue = DecimalAmount::fromString($this->totalValue ?? self::ZERO)->subtract(DecimalAmount::fromString($value))->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()); + return new StockIssue(new self($remaining, $remainingValue), $value, null === $value ? null : DecimalAmount::fromString($value)->divide(DecimalAmount::fromString($quantity))->toString()); } } diff --git a/src/Inventory/Infrastructure/Persistence/Doctrine/Lock/InventoryTransactionLock.php b/src/Inventory/Infrastructure/Persistence/Doctrine/Lock/InventoryTransactionLock.php new file mode 100644 index 000000000..4ca11e7fc --- /dev/null +++ b/src/Inventory/Infrastructure/Persistence/Doctrine/Lock/InventoryTransactionLock.php @@ -0,0 +1,56 @@ +connection->isTransactionActive()) { + throw new LogicException('Inventory write requires a main transaction.'); + } + $this->connection->executeQuery('SELECT pg_advisory_xact_lock(hashtextextended(:identity,0))', ['identity' => $identity]); + } + // #endregion +} diff --git a/src/Inventory/Infrastructure/Persistence/Doctrine/Mapper/InventoryRowMapper.php b/src/Inventory/Infrastructure/Persistence/Doctrine/Mapper/InventoryRowMapper.php new file mode 100644 index 000000000..f6bee019f --- /dev/null +++ b/src/Inventory/Infrastructure/Persistence/Doctrine/Mapper/InventoryRowMapper.php @@ -0,0 +1,245 @@ + $row the persisted receipt + * + * @return InventoryOperationReceipt the original operation snapshot + */ + public function operationReceipt(string $org, string $operationId, array $row): InventoryOperationReceipt + { + $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); + } + + /** + * Method linkedValue + * + * Keeps a linked valuation unknown when any contributing value is absent. + * + * @access public + * + * @param array $row the amount and unknown-value count aggregate + * + * @return string|null the exact six-place amount, or an unknown valuation + */ + public function linkedValue(array $row): ?string + { + $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(); + } + + /** + * Method reference + * + * Restores catalog fields while retaining archived references. + * + * @access public + * + * @param string $type the parts or warehouses collection + * @param array $row the persisted reference + * + * @return InventoryReference the catalog reference + */ + public function reference(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']); + } + + /** + * Method balance + * + * Preserves exact quantities and nullable internal valuations. + * + * @access public + * + * @param array $row the persisted balance + * + * @return StockBalance the current stock balance + */ + public function balance(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')); + } + + /** + * Method movement + * + * Restores immutable movement facts and nullable links in the storage zone. + * + * @access public + * + * @param array $row the persisted movement + * + * @return StockMovement the original movement + */ + public function movement(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']); + } + + /** + * Method declaration + * + * Restores the declaration's original quantity, operational scope and current resolution. + * + * @access public + * + * @param array $row the persisted consumption declaration + * + * @return ConsumptionDeclaration the retained declaration + */ + public function declaration(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']); + } + + /** + * Method collection + * + * Preserves the repository's SQL order when restoring a collection projection. + * + * @access public + * + * @param string $type the requested Inventory collection + * @param list> $rows the already ordered SQL result + * + * @return list the restored collection + */ + public function collection(string $type, array $rows): array + { + $items = []; + foreach ($rows as $row) { + $items[] = match ($type) { + 'parts', 'warehouses' => $this->reference($type, $row), + 'balances' => $this->balance($row), + 'movements' => $this->movement($row), + 'consumptions' => $this->declaration($row), + default => throw new LogicException('Unknown inventory projection.'), + }; + } + + return $items; + } + + /** + * Method movements + * + * Preserves the repository's ordering of immutable intervention facts. + * + * @access public + * + * @param list> $rows the already ordered movement rows + * + * @return list the restored movement facts + */ + public function movements(array $rows): array + { + $movements = []; + foreach ($rows as $row) { + $movements[] = $this->movement($row); + } + + return $movements; + } + + /** + * Method s + * + * Rejects malformed persisted string fields instead of silently coercing them. + * + * @access private + * + * @param array $r the persisted row + * @param string $key the required field + * + * @return string the original field value + */ + 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]; + } + + /** + * Method n + * + * Keeps absent or null persisted values unknown without relaxing string validation. + * + * @access private + * + * @param array $r the persisted row + * @param string $key the nullable field + * + * @return string|null the original value or null + */ + private static function n(array $r, string $key): ?string + { + return null === ($r[$key] ?? null) ? null : self::s($r, $key); + } + // #endregion +} diff --git a/src/Inventory/Infrastructure/Persistence/Doctrine/Query/InventoryCollectionQuery.php b/src/Inventory/Infrastructure/Persistence/Doctrine/Query/InventoryCollectionQuery.php new file mode 100644 index 000000000..cfc100ce7 --- /dev/null +++ b/src/Inventory/Infrastructure/Persistence/Doctrine/Query/InventoryCollectionQuery.php @@ -0,0 +1,77 @@ + 'inventory_parts','warehouses' => 'inventory_warehouses','balances' => 'inventory_balances','movements' => 'inventory_movements','consumptions' => 'inventory_declarations',default => throw new LogicException('Unknown inventory collection.') + }; + } + + /** + * Method selection + * + * Keeps list and count filters identical and bound to the owning organization. + * + * @access public + * + * @param string $type the collection type + * @param string $org the owning organization + * @param array $filters the exact collection filters + * + * @return array{string,array} SQL predicate and bound parameters + */ + public static 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]; + } + // #endregion +} diff --git a/src/Inventory/Infrastructure/Persistence/Doctrine/Repository/InventoryRepository.php b/src/Inventory/Infrastructure/Persistence/Doctrine/Repository/InventoryRepository.php index 1c94e3e0b..4e2f3fcf7 100644 --- a/src/Inventory/Infrastructure/Persistence/Doctrine/Repository/InventoryRepository.php +++ b/src/Inventory/Infrastructure/Persistence/Doctrine/Repository/InventoryRepository.php @@ -4,15 +4,16 @@ namespace Inventory\Infrastructure\Persistence\Doctrine\Repository; -use DateTimeImmutable; use DateTimeZone; use Doctrine\DBAL\{ArrayParameterType,Connection,Exception\UniqueConstraintViolationException,ParameterType}; -use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; use Inventory\Application\Contract\Stock\InventoryOperationReceipt; use Inventory\Application\Port\Outbound\InventoryStorePort; use Inventory\Domain\Exception\InventoryConflictException; use Inventory\Domain\Model\Stock\{ConsumptionDeclaration, InventoryReference, StockBalance, StockMovement}; +use Inventory\Infrastructure\Persistence\Doctrine\Lock\InventoryTransactionLock; +use Inventory\Infrastructure\Persistence\Doctrine\Mapper\InventoryRowMapper; +use Inventory\Infrastructure\Persistence\Doctrine\Query\InventoryCollectionQuery; use LogicException; use Shared\Domain\ValueObject\DecimalAmount; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -23,58 +24,81 @@ use function array_values; use function count; use function implode; -use function in_array; -use function is_array; use function is_int; use function is_string; -use function json_decode; use function json_encode; -use function str_replace; use const JSON_THROW_ON_ERROR; /** SQL reads and locks only Inventory tables in main. @category Repository */ final readonly class InventoryRepository implements InventoryStorePort { + /** + * Constant SELECT_ALL + */ + private const string SELECT_ALL = 'SELECT * FROM '; + + /** + * Property storageZone + */ private DateTimeZone $storageZone; - public function __construct(private EntityManagerInterface $entityManager, #[Autowire('%env(default:database_storage_timezone_default:DATABASE_STORAGE_TIMEZONE)%')] string $storageTimeZone = 'UTC') + /** + * Property mapper + */ + private InventoryRowMapper $mapper; + + /** + * Property locks + */ + private InventoryTransactionLock $locks; + + /** + * Method __construct + * + * Shares the explicitly wired main connection with every query and transaction-scoped fence. + * + * @access public + * + * @param Connection $connection the owning main connection + * @param string $storageTimeZone the storage zone for persisted wall-clock timestamps + * + * @return void + */ + public function __construct(private Connection $connection, #[Autowire('%env(default:database_storage_timezone_default:DATABASE_STORAGE_TIMEZONE)%')] string $storageTimeZone = 'UTC') { $this->storageZone = new DateTimeZone($storageTimeZone); + $this->mapper = new InventoryRowMapper($this->storageZone); + $this->locks = new InventoryTransactionLock($this->connection); } 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]); + $this->locks->acquire('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); + return $this->mapper->operationReceipt($org, $operationId, $row); } 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)]); + $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); + $this->locks->acquire('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]); + $table = InventoryCollectionQuery::table($type); + $row = $this->connection->fetchAssociative(self::SELECT_ALL . $table . ' WHERE organization_id=:org AND id=:id' . ($lock ? ' FOR UPDATE' : ''), ['org' => $org, 'id' => $id]); - return false === $row ? null : $this->referenceRow($type, $row); + return false === $row ? null : $this->mapper->reference($type, $row); } public function saveReference(string $type, InventoryReference $reference): void @@ -86,7 +110,7 @@ public function saveReference(string $type, InventoryReference $reference): void } 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]); + $this->connection->executeStatement('INSERT INTO ' . InventoryCollectionQuery::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); } @@ -100,10 +124,10 @@ public function referencesByIds(string $type, string $org, array $ids): array 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]); + $rows = $this->connection->fetchAllAssociative(self::SELECT_ALL . InventoryCollectionQuery::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); + $item = $this->mapper->reference($type, $row); $references[$item->id] = $item; } @@ -112,32 +136,32 @@ public function referencesByIds(string $type, string $org, array $ids): array 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]); + $this->locks->acquire('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); + return false === $row ? null : $this->mapper->balance($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]); + $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]); + $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]); + $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); + return false === $row ? null : $this->mapper->movement($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]); + $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.'); } @@ -147,34 +171,29 @@ public function linkedQuantity(string $org, string $movementId): string 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]); + $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(); + return $this->mapper->linkedValue($row); } 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]); + $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]); + $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); + return false === $row ? null : $this->mapper->declaration($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]); + $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.'); } @@ -184,29 +203,27 @@ public function hasPendingDeclarations(string $org, string $interventionId): boo 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]); + $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); + return $this->mapper->movements($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]); + [$where,$params] = InventoryCollectionQuery::selection($type, $org, $filters); + $rows = $this->connection->fetchAllAssociative(self::SELECT_ALL . InventoryCollectionQuery::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); + return $this->mapper->collection($type, $rows); } public function count(string $type, string $org, array $filters): int { - [$where,$params] = $this->selection($type, $org, $filters); + [$where,$params] = InventoryCollectionQuery::selection($type, $org, $filters); - $count = $this->connection()->fetchOne('SELECT COUNT(*) FROM ' . $this->table($type) . ' WHERE ' . $where, $params); + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM ' . InventoryCollectionQuery::table($type) . ' WHERE ' . $where, $params); if (!is_string($count) && !is_int($count)) { throw new LogicException('Invalid inventory count.'); } @@ -236,112 +253,11 @@ public function economicInterventionIds(string $org, array $equipmentIds): array 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]); + $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 index 04c9f0e89..d83e5b9b3 100644 --- a/src/Inventory/MODULE.md +++ b/src/Inventory/MODULE.md @@ -55,7 +55,9 @@ CUMP allocates total balance value proportionally with one final half-up roundin ## 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. +`config/modules/inventory.yaml` wires every service and explicitly uses the main connection for the SQL repository and the main entity manager for its transaction manager. Pure row mapping and collection predicates do not open connections; advisory fences use the repository's existing main transaction. 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. + +Within `Infrastructure/Persistence/Doctrine`, `Query` owns the pure collection predicates, `Lock` owns transaction-scoped advisory fences, and `Mapper` restores persisted values. The repository constructs its lock collaborator with the same explicitly wired main connection. Query and lock collaborators are outside service discovery and never receive a separate default connection. 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. diff --git a/src/Inventory/Presentation/Api/Operation/InventoryOperations.php b/src/Inventory/Presentation/Api/Operation/InventoryOperations.php index d02443aee..6e18a6081 100644 --- a/src/Inventory/Presentation/Api/Operation/InventoryOperations.php +++ b/src/Inventory/Presentation/Api/Operation/InventoryOperations.php @@ -7,6 +7,11 @@ /** @category Operation */ final class InventoryOperations { + /** + * Constant AUTHENTICATED_USER + */ + public const string AUTHENTICATED_USER = "is_granted('ROLE_USER')"; + public const string PARTS_LIST = 'inventory_parts_list'; public const string PARTS_GET = 'inventory_parts_get'; diff --git a/src/Inventory/Presentation/Api/Resource/InventoryConsumptionResource.php b/src/Inventory/Presentation/Api/Resource/InventoryConsumptionResource.php index 747da3595..4a69e7a06 100644 --- a/src/Inventory/Presentation/Api/Resource/InventoryConsumptionResource.php +++ b/src/Inventory/Presentation/Api/Resource/InventoryConsumptionResource.php @@ -14,10 +14,10 @@ /** Declare complete physical consumptions, retaining shortages. @category Resource */ #[ApiResource(shortName:'InventoryConsumption', routePrefix:'/organizations', operations:[ - new GetCollection(name:InventoryOperations::CONSUMPTIONS_LIST, uriTemplate:'/{organizationId}/inventory-consumptions', input:false, output:InventoryConsumptionOutput::class, provider:InventoryProvider::class, paginationEnabled:true, paginationClientItemsPerPage:true, security:"is_granted('ROLE_USER')", normalizationContext:['groups' => ['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]), + new GetCollection(name:InventoryOperations::CONSUMPTIONS_LIST, uriTemplate:'/{organizationId}/inventory-consumptions', input:false, output:InventoryConsumptionOutput::class, provider:InventoryProvider::class, paginationEnabled:true, paginationClientItemsPerPage:true, security:InventoryOperations::AUTHENTICATED_USER, normalizationContext:['groups' => ['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:InventoryOperations::AUTHENTICATED_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:InventoryOperations::AUTHENTICATED_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:InventoryOperations::AUTHENTICATED_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/InventoryPartResource.php b/src/Inventory/Presentation/Api/Resource/InventoryPartResource.php index c419fc642..8ce248675 100644 --- a/src/Inventory/Presentation/Api/Resource/InventoryPartResource.php +++ b/src/Inventory/Presentation/Api/Resource/InventoryPartResource.php @@ -15,10 +15,10 @@ /** Manage quantitative part and consumable references. @category Resource */ #[ApiResource(shortName:'InventoryPart', routePrefix:'/organizations', operations:[ - new GetCollection(name:InventoryOperations::PARTS_LIST, uriTemplate:'/{organizationId}/inventory-parts', input:false, output:InventoryPartOutput::class, provider:InventoryProvider::class, paginationEnabled:true, paginationClientItemsPerPage:true, security:"is_granted('ROLE_USER')", normalizationContext:['groups' => ['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]), + new GetCollection(name:InventoryOperations::PARTS_LIST, uriTemplate:'/{organizationId}/inventory-parts', input:false, output:InventoryPartOutput::class, provider:InventoryProvider::class, paginationEnabled:true, paginationClientItemsPerPage:true, security:InventoryOperations::AUTHENTICATED_USER, normalizationContext:['groups' => ['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:InventoryOperations::AUTHENTICATED_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:InventoryOperations::AUTHENTICATED_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:InventoryOperations::AUTHENTICATED_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 index 275d847bb..693659de7 100644 --- a/src/Inventory/Presentation/Api/Resource/InventoryWarehouseResource.php +++ b/src/Inventory/Presentation/Api/Resource/InventoryWarehouseResource.php @@ -15,10 +15,10 @@ /** Manage warehouses. @category Resource */ #[ApiResource(shortName:'InventoryWarehouse', routePrefix:'/organizations', operations:[ - new GetCollection(name:InventoryOperations::WAREHOUSES_LIST, uriTemplate:'/{organizationId}/inventory-warehouses', input:false, output:InventoryWarehouseOutput::class, provider:InventoryProvider::class, paginationEnabled:true, paginationClientItemsPerPage:true, security:"is_granted('ROLE_USER')", normalizationContext:['groups' => ['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]), + new GetCollection(name:InventoryOperations::WAREHOUSES_LIST, uriTemplate:'/{organizationId}/inventory-warehouses', input:false, output:InventoryWarehouseOutput::class, provider:InventoryProvider::class, paginationEnabled:true, paginationClientItemsPerPage:true, security:InventoryOperations::AUTHENTICATED_USER, normalizationContext:['groups' => ['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:InventoryOperations::AUTHENTICATED_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:InventoryOperations::AUTHENTICATED_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:InventoryOperations::AUTHENTICATED_USER, normalizationContext:['groups' => ['inventory:read'], 'skip_null_values' => true], denormalizationContext:['groups' => ['inventory:write'], 'allow_extra_attributes' => false]), ])] final class InventoryWarehouseResource { diff --git a/src/Maintenance/Application/Port/Outbound/Plan/MaintenanceLegacyPlanPort.php b/src/Maintenance/Application/Port/Outbound/Plan/MaintenanceLegacyPlanPort.php new file mode 100644 index 000000000..7b18bfe4c --- /dev/null +++ b/src/Maintenance/Application/Port/Outbound/Plan/MaintenanceLegacyPlanPort.php @@ -0,0 +1,62 @@ +plans->engineMode($organizationId)) { + return $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) { + if ($this->prepareSchedule($organizationId, $schedule, $policy)) { + ++$prepared; + } + } + } while (200 === count($schedules->items)); + $offset = 0; + do { + $plans = $this->plans->list($organizationId, 200, $offset, includeArchived: true); + foreach ($plans as $plan) { + $this->archiveUnavailableSource($plan, $policy); + } + $offset += 200; + } while (200 === count($plans)); + + return $prepared; + } + + /** + * Method activate + * + * Maps each unambiguous live historical control before committing engine authority. + * + * @access public + * + * @param string $organizationId the locked organization + * + * @return int the preparation count + */ + public function activate(string $organizationId): int + { + if ('plans' === $this->plans->engineMode($organizationId)) { + return $this->plans->count($organizationId); + } + $prepared = $this->prepare($organizationId); + $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) { + $this->activatePlan($organizationId, $plan, $candidates[$plan->equipmentId] ?? []); + } + $offset += 200; + } while (200 === count($page)); + $this->plans->activateEngine($organizationId, $this->clock->now()); + + return $prepared; + } + + /** + * Method refreshPolicy + * + * Keeps historical override/default precedence without moving an open occurrence. + * + * @access public + * + * @param MaintenancePlanState $plan the candidate operation + * @param bool $save whether changes may be persisted before the caller finishes paging + * + * @return bool whether a cadence source remains available + */ + public function refreshPolicy(MaintenancePlanState $plan, bool $save = true): bool + { + if (null === $plan->legacyScheduleId) { + return true; + } + $schedule = $this->schedules->findById($plan->legacyScheduleId); + $interval = null === $schedule ? null : ($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; + } + + /** + * Method prepareSchedule + * + * Preserves original source dates and existing plan history without enabling generation. + * + * @access private + * + * @param string $organizationId the owning organization + * @param MaintenanceScheduleView $schedule the historical schedule + * @param MaintenanceCompliancePolicy $policy the organization's cadence policy + * + * @return bool whether the source has an effective cadence + */ + private function prepareSchedule(string $organizationId, MaintenanceScheduleView $schedule, MaintenanceCompliancePolicy $policy): bool + { + $interval = $schedule->intervalOverride ?? $policy->periodicityFor($schedule->equipmentType); + if (null === $interval) { + return false; + } + PlanCadence::legacyFromString($interval); + $now = $this->clock->now(); + $existing = $this->plans->findByLegacySchedule($organizationId, $schedule->id); + $plan = new MaintenancePlanState($existing->id ?? $this->ids->generate(), $organizationId, $schedule->equipmentId, $schedule->facilityId, $schedule->equipmentType, $existing->name ?? 'Periodic control', 'control', $interval, 'legacy', $schedule->nextDueAt ?? $existing?->anchorAt, $schedule->nextDueAt ?? $existing?->nextDueAt, false, $schedule->id, $schedule->lastInspectionClosedAt, $existing?->archivedAt, $existing->createdAt ?? $now, $now); + $this->plans->save($plan); + + return true; + } + + /** + * Method archiveUnavailableSource + * + * Stops historical candidates whose original schedule or effective cadence disappeared. + * + * @access private + * + * @param MaintenancePlanState $plan the prepared candidate + * @param MaintenanceCompliancePolicy $policy the current cadence policy + * + * @return void + */ + private function archiveUnavailableSource(MaintenancePlanState $plan, MaintenanceCompliancePolicy $policy): void + { + if (null === $plan->legacyScheduleId) { + return; + } + $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); + } + } + + /** + * Method activatePlan + * + * Reserves the stable occurrence before linking a single existing control task. + * + * @access private + * + * @param string $organizationId the locked organization + * @param MaintenancePlanState $plan the prepared candidate + * @param list $matches the current open legacy tasks + * + * @return void + */ + private function activatePlan(string $organizationId, MaintenancePlanState $plan, array $matches): void + { + if (null !== $plan->archivedAt) { + return; + } + if (null === $plan->legacyScheduleId) { + $this->assertNewControlMapping($organizationId, $plan, $matches); + + return; + } + 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); + } + + /** + * Method assertNewControlMapping + * + * Refuses live control work that cannot be attributed to a historical plan safely. + * + * @access private + * + * @param string $organizationId the locked organization + * @param MaintenancePlanState $plan the independent candidate + * @param list $matches the equipment's open historical tasks + * + * @return void + */ + private function assertNewControlMapping(string $organizationId, MaintenancePlanState $plan, array $matches): void + { + if ('control' === $plan->operationKind && [] !== $matches) { + $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.'); + } + } + } + // #endregion +} diff --git a/src/Maintenance/Application/Service/MaintenancePlanModelFactory.php b/src/Maintenance/Application/Service/MaintenancePlanModelFactory.php new file mode 100644 index 000000000..8b8bd814c --- /dev/null +++ b/src/Maintenance/Application/Service/MaintenancePlanModelFactory.php @@ -0,0 +1,77 @@ +name)) > 160) { + throw new MaintenanceValidationException('A plan name cannot exceed 160 characters.'); + } + $legacy = 'legacy' === $plan->cadenceMode; + $cadence = $legacy ? PlanCadence::legacyFromString($plan->interval) : PlanCadence::fromString($plan->interval); + + return MaintenancePlan::reconstitute( + new MaintenancePlanIdentity($plan->id, $plan->organizationId, $plan->equipmentId), + $plan->name, + MaintenanceOperationKind::from($plan->operationKind), + new MaintenancePlanCalendar($cadence, $plan->anchorAt, $plan->nextDueAt, $legacy), + $plan->createdAt, + null !== $plan->archivedAt, + ); + } + + /** + * Method occurrence + * + * Restores the original reservation and the current explicit attempt/receipt. + * + * @access public + * + * @param MaintenanceOccurrenceState $occurrence the persisted application snapshot + * + * @return MaintenanceOccurrence the validated occurrence + */ + public static function occurrence(MaintenanceOccurrenceState $occurrence): MaintenanceOccurrence + { + return MaintenanceOccurrence::reconstitute( + $occurrence->id, + $occurrence->planId, + $occurrence->dueAt, + $occurrence->createdAt, + new MaintenanceOccurrenceAttempt($occurrence->attempt, $occurrence->interventionId, $occurrence->completedAt, $occurrence->resultId), + ); + } + // #endregion +} diff --git a/src/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanHandler.php b/src/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanHandler.php index 83f1d585b..82f921da6 100644 --- a/src/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanHandler.php +++ b/src/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanHandler.php @@ -8,28 +8,27 @@ use DateTimeZone; use Intervention\Application\Contract\Draft\{CreateInterventionDraftRequest, InterventionDraftWorkItem}; use Intervention\Application\Port\Inbound\{InterventionDraftFactoryPort, InterventionMaintenanceWorkPort}; +use Maintenance\Application\Contract\Directory\TrackableEquipment; use Maintenance\Application\Contract\Plan\{MaintenanceOccurrenceState, MaintenancePlanDetails, MaintenancePlanState}; -use Maintenance\Application\Contract\Schedule\MaintenanceScheduleSnapshot; +use Maintenance\Application\Contract\Schedule\{MaintenanceScheduleSnapshot, MaintenanceScheduleView}; use Maintenance\Application\Port\Outbound\Compliance\MaintenanceCompliancePolicyPort; use Maintenance\Application\Port\Outbound\Directory\{MaintenanceEquipmentDirectoryPort, MaintenanceFacilityLifecyclePort}; -use Maintenance\Application\Port\Outbound\Plan\MaintenancePlanStorePort; +use Maintenance\Application\Port\Outbound\Plan\{MaintenanceLegacyPlanPort, MaintenancePlanStorePort}; use Maintenance\Application\Port\Outbound\Schedule\MaintenanceScheduleRepositoryPort; +use Maintenance\Application\Service\MaintenancePlanModelFactory; use Maintenance\Domain\Event\Campaign\MaintenanceCampaignGeneratedEvent; use Maintenance\Domain\Event\Reminder\MaintenanceReminderRequestedEvent; use Maintenance\Domain\Exception\{MaintenanceAccessDeniedException, MaintenanceNotFoundException, MaintenanceValidationException}; -use Maintenance\Domain\Model\{MaintenanceOccurrence, MaintenancePlan}; +use Maintenance\Domain\Model\MaintenancePlan; use Maintenance\Domain\Service\MaintenanceScheduleRecomputePolicy; -use Maintenance\Domain\ValueObject\{MaintenanceDueStatus, MaintenanceOperationKind, PeriodicityInterval, PlanCadence}; +use Maintenance\Domain\ValueObject\{MaintenanceControlDates, MaintenanceDueStatus, MaintenanceOperationKind, MaintenancePlanIdentity, PeriodicityInterval, PlanCadence}; use Organization\Application\Port\Inbound\{OrganizationAuthorizationPort, OrganizationWorkforceDirectoryPort}; use Shared\Application\Message\CommandHandler; use Shared\Application\Port\Outbound\{ClockPort, EventDispatcherPort, UuidGeneratorPort}; -use function array_map; use function count; use function in_array; use function max; -use function mb_strlen; -use function trim; /** Coordinates plans, reservations, drafts and publication on one locked main transaction. */ final readonly class ManageMaintenancePlanHandler implements CommandHandler @@ -46,6 +45,7 @@ public function __construct( private ClockPort $clock, private UuidGeneratorPort $ids, private EventDispatcherPort $events, + private MaintenanceLegacyPlanPort $legacy, private int $maxCampaignWorkItems = 25, private ?OrganizationWorkforceDirectoryPort $regional = null, ) { @@ -65,7 +65,7 @@ public function __invoke(ManageMaintenancePlanCommand $command): ManageMaintenan 'create' => $this->create($command), 'update', 'archive' => $this->update($command), 'set_legacy_override' => $this->legacyOverride($command), - 'prepare_legacy' => $this->prepareLegacy($command->organizationId), + 'prepare_legacy' => new ManageMaintenancePlanResult(mode: $this->plans->engineMode($command->organizationId), preparedCount: $this->legacy->prepare($command->organizationId)), 'activate' => $this->activate($command->organizationId), 'generate' => $this->generate($command), 'campaign' => $this->campaign($command), @@ -104,9 +104,9 @@ private function create(ManageMaintenancePlanCommand $command): ManageMaintenanc $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); + $model = MaintenancePlan::create(new MaintenancePlanIdentity($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); + MaintenancePlanModelFactory::plan($plan); $this->plans->save($plan); $this->projection($plan->organizationId, $plan->equipmentId); @@ -123,26 +123,30 @@ private function update(ManageMaintenancePlanCommand $command): ManageMaintenanc } $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); + } + 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; + MaintenancePlanModelFactory::plan($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); @@ -151,50 +155,6 @@ private function update(ManageMaintenancePlanCommand $command): ManageMaintenanc 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.'); @@ -222,46 +182,7 @@ 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()); + $prepared = $this->legacy->activate($organizationId); $offset = 0; do { $page = $this->plans->list($organizationId, 200, $offset); @@ -303,7 +224,7 @@ private function campaign(ManageMaintenancePlanCommand $command): ManageMaintena 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)) { + if (!$this->campaignCandidate($plan, $command, $before)) { continue; } $selected[] = $plan; @@ -323,6 +244,29 @@ private function campaign(ManageMaintenancePlanCommand $command): ManageMaintena return $this->generatePlans($selected, $command->name ?? 'Control campaign', $command->actorUserId, false); } + /** + * Method campaignCandidate + * + * Refreshes current scope and legacy cadence before applying campaign filters, + * deferring saves until all due-ordered pages have been read. + * + * @access private + * + * @param MaintenancePlanState $plan the control candidate + * @param ManageMaintenancePlanCommand $command the campaign filters + * @param DateTimeImmutable $before the inclusive due cutoff + * + * @return bool whether unassigned work can be reserved for this control + */ + private function campaignCandidate(MaintenancePlanState $plan, ManageMaintenancePlanCommand $command, DateTimeImmutable $before): bool + { + return $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); + } + /** * @param list $plans */ @@ -340,7 +284,7 @@ private function generatePlans(array $plans, string $name, ?string $actorUserId, $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); + $model = MaintenancePlanModelFactory::occurrence($occurrence); $retry ? $model->retryAttempt($draft->interventionId) : $model->beginAttempt($draft->interventionId); $occurrence->attempt = $model->attempt(); $occurrence->interventionId = $model->interventionId(); @@ -374,7 +318,7 @@ private function result(ManageMaintenancePlanCommand $command): ManageMaintenanc if ('validate_result' === $command->action) { return $this->details($plan); } - $model = $this->occurrenceModel($occurrence); + $model = MaintenancePlanModelFactory::occurrence($occurrence); $completed = $model->validateResult(MaintenanceOperationKind::from($plan->operationKind), 'passed' === $result->outcome, $result->resultId, $this->clock->now()); $occurrence->status = $model->state(); $occurrence->resultId = $model->resultId(); @@ -383,7 +327,7 @@ private function result(ManageMaintenancePlanCommand $command): ManageMaintenanc $this->plans->saveOccurrence($occurrence); $this->plans->saveReceipt($result); if ($completed) { - $calendar = $this->model($plan); + $calendar = MaintenancePlanModelFactory::plan($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); @@ -411,53 +355,64 @@ private function projection(string $organizationId, string $equipmentId): Manage } $existing = $this->schedules->findByOrganizationAndEquipment($organizationId, $equipmentId); $policy = $this->compliance->compliancePolicy($organizationId); - $next = null; - $last = null; - $tracked = false; - $missingDueDate = false; + $dates = MaintenanceControlDates::empty(); $offset = 0; do { $page = $this->plans->list($organizationId, 200, $offset, $equipmentId, 'control'); foreach ($page as $plan) { - if (!$plan->active || !$this->refreshLegacyPolicy($plan)) { + if (!$plan->active || !$this->legacy->refreshPolicy($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; - } + $dates = $dates->including($plan->nextDueAt, $plan->lastCompletedAt); } $offset += 200; } while (200 === count($page)); - if ($missingDueDate) { - $next = null; - } + $next = $dates->nextDueAt; + $last = $dates->lastCompletedAt; + $tracked = $dates->tracked; 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, $lastRemindedAt] = $this->projectionReminder($equipment, $existing, $next, $tracked, $dueStatus); + $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'); + } + + /** + * Method projectionReminder + * + * Retains reminder evidence and enqueues only a newly due control projection. + * + * @access private + * + * @param TrackableEquipment $equipment the current scoped equipment + * @param ?MaintenanceScheduleView $existing the prior control projection + * @param ?DateTimeImmutable $next the combined control due date + * @param bool $tracked whether the current equipment has an eligible control + * @param MaintenanceDueStatus $dueStatus the current control status + * + * @return array{?DateTimeImmutable, ?DateTimeImmutable} the due date already reminded and reminder timestamp + */ + private function projectionReminder(TrackableEquipment $equipment, ?MaintenanceScheduleView $existing, ?DateTimeImmutable $next, bool $tracked, MaintenanceDueStatus $dueStatus): array + { $remindedFor = $existing?->remindedFor; $lastRemindedAt = $existing?->lastRemindedAt; + $rules = new MaintenanceScheduleRecomputePolicy(); 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)); + $this->events->dispatch(new MaintenanceReminderRequestedEvent($equipment->organizationId, $equipment->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'); + return [$remindedFor, $lastRemindedAt]; } private function assertTrackable(MaintenancePlanState $plan): void @@ -489,38 +444,7 @@ private function trackable(MaintenancePlanState $plan, bool $savePolicy = true): $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; + return $this->legacy->refreshPolicy($plan, $savePolicy); } private function requirePlan(string $organizationId, ?string $id): MaintenancePlanState @@ -544,20 +468,6 @@ private function calendarDate(?DateTimeImmutable $instant, ?string $date, DateTi 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)); diff --git a/src/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansHandler.php b/src/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansHandler.php index 96a315ccd..ffc3b290a 100644 --- a/src/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansHandler.php +++ b/src/Maintenance/Application/UseCase/Query/Plan/ReadMaintenancePlans/ReadMaintenancePlansHandler.php @@ -9,7 +9,7 @@ use Maintenance\Application\Port\Outbound\Plan\MaintenancePlanStorePort; use Maintenance\Domain\Exception\{MaintenanceAccessDeniedException, MaintenanceNotFoundException, MaintenanceValidationException}; use Maintenance\Domain\Model\MaintenancePlan; -use Maintenance\Domain\ValueObject\{MaintenanceOperationKind, PlanCadence}; +use Maintenance\Domain\ValueObject\{MaintenanceOperationKind, MaintenancePlanCalendar, MaintenancePlanIdentity, PlanCadence}; use Organization\Application\Port\Inbound\OrganizationAuthorizationPort; use Shared\Application\Message\QueryHandler; @@ -43,7 +43,19 @@ public function __invoke(ReadMaintenancePlansQuery $query): ReadMaintenancePlans } 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); + $model = MaintenancePlan::reconstitute( + new MaintenancePlanIdentity($plan->id, $plan->organizationId, $plan->equipmentId), + $plan->name, + MaintenanceOperationKind::from($plan->operationKind), + new MaintenancePlanCalendar( + 'legacy' === $plan->cadenceMode ? PlanCadence::legacyFromString($plan->interval) : PlanCadence::fromString($plan->interval), + $plan->anchorAt, + $plan->nextDueAt, + 'legacy' === $plan->cadenceMode, + ), + $plan->createdAt, + null !== $plan->archivedAt, + ); return new ReadMaintenancePlansResult(items: [$this->details($plan)], total: 1, dates: 'preview' === $query->action ? $model->preview() : [], mode: $mode); } diff --git a/src/Maintenance/Domain/Model/MaintenanceOccurrence.php b/src/Maintenance/Domain/Model/MaintenanceOccurrence.php index 29186d183..065bd3a62 100644 --- a/src/Maintenance/Domain/Model/MaintenanceOccurrence.php +++ b/src/Maintenance/Domain/Model/MaintenanceOccurrence.php @@ -5,7 +5,7 @@ namespace Maintenance\Domain\Model; use DateTimeImmutable; -use Maintenance\Domain\ValueObject\MaintenanceOperationKind; +use Maintenance\Domain\ValueObject\{MaintenanceOccurrenceAttempt, MaintenanceOperationKind}; use Shared\Domain\Exception\InvalidValueException; use Shared\Domain\ValueObject\Uuid; @@ -30,10 +30,7 @@ final class MaintenanceOccurrence * @param string $planId the owning plan UUID * @param DateTimeImmutable $dueAt the immutable original due date * @param DateTimeImmutable $createdAt the reservation instant - * @param int $attempt the number of explicitly started attempts - * @param ?string $interventionId the latest attempted intervention UUID - * @param ?DateTimeImmutable $completedAt the validated completion instant - * @param ?string $resultId the latest validated result UUID + * @param MaintenanceOccurrenceAttempt $attempt the validated work and receipt lifecycle * * @return void */ @@ -42,29 +39,11 @@ private function __construct( public readonly string $planId, public readonly DateTimeImmutable $dueAt, public readonly DateTimeImmutable $createdAt, - private int $attempt, - private ?string $interventionId, - private ?DateTimeImmutable $completedAt, - private ?string $resultId, + private MaintenanceOccurrenceAttempt $attempt, ) { - new Uuid($id); - new Uuid($planId); - if (null !== $interventionId) { - new Uuid($interventionId); - } - if (null !== $resultId) { - new Uuid($resultId); - } - - if ($attempt < 0 || (0 === $attempt) !== (null === $interventionId)) { - throw InvalidValueException::because('An occurrence attempt must identify its intervention.'); - } - - if (null !== $resultId && null === $interventionId) { - throw InvalidValueException::because('An occurrence result must belong to an explicit attempt.'); - } - - if (null !== $completedAt && (null === $resultId || $completedAt < $createdAt)) { + Uuid::assertValid($id); + Uuid::assertValid($planId); + if (null !== $attempt->completedAt && $attempt->completedAt < $createdAt) { throw InvalidValueException::because('A completed occurrence must retain a validated result after reservation.'); } } @@ -87,7 +66,7 @@ private function __construct( */ public static function open(string $id, string $planId, DateTimeImmutable $dueAt, DateTimeImmutable $now): self { - return new self($id, $planId, $dueAt, $now, 0, null, null, null); + return new self($id, $planId, $dueAt, $now, MaintenanceOccurrenceAttempt::unattempted()); } /** @@ -101,10 +80,7 @@ public static function open(string $id, string $planId, DateTimeImmutable $dueAt * @param string $planId the owning plan UUID * @param DateTimeImmutable $dueAt the immutable original due date * @param DateTimeImmutable $createdAt the reservation instant - * @param int $attempt the explicit attempt count - * @param ?string $interventionId the latest attempted intervention UUID - * @param ?DateTimeImmutable $completedAt the completion instant - * @param ?string $resultId the latest validated result UUID + * @param MaintenanceOccurrenceAttempt $attempt the restored work and receipt lifecycle * * @return self the restored occurrence */ @@ -113,12 +89,9 @@ public static function reconstitute( string $planId, DateTimeImmutable $dueAt, DateTimeImmutable $createdAt, - int $attempt, - ?string $interventionId, - ?DateTimeImmutable $completedAt, - ?string $resultId, + MaintenanceOccurrenceAttempt $attempt, ): self { - return new self($id, $planId, $dueAt, $createdAt, $attempt, $interventionId, $completedAt, $resultId); + return new self($id, $planId, $dueAt, $createdAt, $attempt); } /** @@ -134,17 +107,7 @@ public static function reconstitute( */ public function beginAttempt(string $interventionId): void { - new Uuid($interventionId); - - if ($this->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; + $this->attempt = $this->attempt->beginAttempt($interventionId); } /** @@ -161,18 +124,7 @@ public function beginAttempt(string $interventionId): 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; + $this->attempt = $this->attempt->retryAttempt($interventionId); } /** @@ -196,21 +148,18 @@ public function validateResult( string $resultId, DateTimeImmutable $validatedAt, ): bool { - new Uuid($resultId); + Uuid::assertValid($resultId); - if ($this->resultId === $resultId) { - return null !== $this->completedAt; + if ($this->attempt->resultId === $resultId) { + return null !== $this->attempt->completedAt; } - if (null === $this->interventionId || null !== $this->resultId || null !== $this->completedAt || $validatedAt < $this->createdAt) { + if ($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; - } + $this->attempt = $this->attempt->validateResult($kind, $successful, $resultId, $validatedAt); - return null !== $this->completedAt; + return null !== $this->attempt->completedAt; } /** @@ -224,7 +173,7 @@ public function validateResult( */ public function state(): string { - return null === $this->completedAt ? 'open' : 'completed'; + return null === $this->attempt->completedAt ? 'open' : 'completed'; } /** @@ -238,7 +187,7 @@ public function state(): string */ public function attempt(): int { - return $this->attempt; + return $this->attempt->number; } /** @@ -252,7 +201,7 @@ public function attempt(): int */ public function interventionId(): ?string { - return $this->interventionId; + return $this->attempt->interventionId; } /** @@ -266,7 +215,7 @@ public function interventionId(): ?string */ public function completedAt(): ?DateTimeImmutable { - return $this->completedAt; + return $this->attempt->completedAt; } /** @@ -280,7 +229,7 @@ public function completedAt(): ?DateTimeImmutable */ public function resultId(): ?string { - return $this->resultId; + return $this->attempt->resultId; } // #endregion } diff --git a/src/Maintenance/Domain/Model/MaintenancePlan.php b/src/Maintenance/Domain/Model/MaintenancePlan.php index d89a2a820..5ecd0e07f 100644 --- a/src/Maintenance/Domain/Model/MaintenancePlan.php +++ b/src/Maintenance/Domain/Model/MaintenancePlan.php @@ -5,11 +5,9 @@ namespace Maintenance\Domain\Model; use DateTimeImmutable; -use Maintenance\Domain\ValueObject\{MaintenanceOperationKind, PlanCadence}; +use Maintenance\Domain\ValueObject\{MaintenanceOperationKind, MaintenancePlanCalendar, MaintenancePlanIdentity, PlanCadence}; use Shared\Domain\Exception\InvalidValueException; -use Shared\Domain\ValueObject\Uuid; -use function max; use function mb_strlen; use function trim; @@ -22,6 +20,50 @@ */ final class MaintenancePlan { + // #region Properties + /** + * Property id + * + * Identifies the operation within its validated ownership scope. + */ + public readonly string $id; + + /** + * Property organizationId + * + * Identifies the organization owning the operation. + */ + public readonly string $organizationId; + + /** + * Property equipmentId + * + * Identifies the equipment whose operation is independently scheduled. + */ + public readonly string $equipmentId; + + /** + * Property cadence + * + * Exposes the immutable interval and calculation mode. + */ + public readonly PlanCadence $cadence; + + /** + * Property anchorAt + * + * Retains the original calendar anchor through month-end clamping. + */ + public readonly ?DateTimeImmutable $anchorAt; + + /** + * Property legacy + * + * Exposes the explicit historical arithmetic marker. + */ + public readonly bool $legacy; + // #endregion + // #region Constructor /** * Method __construct @@ -30,52 +72,33 @@ final class MaintenancePlan * * @access private * - * @param string $id the plan UUID - * @param string $organizationId the owning organization UUID - * @param string $equipmentId the equipment UUID + * @param MaintenancePlanIdentity $identity the validated plan ownership scope * @param string $name the operation's display name * @param MaintenanceOperationKind $kind the independently scheduled operation - * @param PlanCadence $cadence the explicit interval and calculation mode - * @param ?DateTimeImmutable $anchorAt the original fixed due date, absent for unscheduled historical rows - * @param ?DateTimeImmutable $nextDueAt the next due date + * @param MaintenancePlanCalendar $calendar the validated anchored or historical calendar * @param DateTimeImmutable $createdAt the creation instant - * @param bool $legacy whether the plan preserves historical sliding arithmetic * @param bool $archived whether generation has been stopped * * @return void */ private function __construct( - public readonly string $id, - public readonly string $organizationId, - public readonly string $equipmentId, + MaintenancePlanIdentity $identity, public readonly string $name, public readonly MaintenanceOperationKind $kind, - public readonly PlanCadence $cadence, - public readonly ?DateTimeImmutable $anchorAt, - private ?DateTimeImmutable $nextDueAt, + private MaintenancePlanCalendar $calendar, public readonly DateTimeImmutable $createdAt, - public readonly bool $legacy, private bool $archived, ) { - new Uuid($id); - new Uuid($organizationId); - new Uuid($equipmentId); - if ('' === trim($name) || mb_strlen($name) > 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.'); - } + $this->id = $identity->id; + $this->organizationId = $identity->organizationId; + $this->equipmentId = $identity->equipmentId; + $this->cadence = $calendar->cadence; + $this->anchorAt = $calendar->anchorAt; + $this->legacy = $calendar->legacy; } // #endregion @@ -87,9 +110,7 @@ private function __construct( * * @access public * - * @param string $id the plan UUID - * @param string $organizationId the owning organization UUID - * @param string $equipmentId the equipment UUID + * @param MaintenancePlanIdentity $identity the validated plan ownership scope * @param string $name the operation's display name * @param MaintenanceOperationKind $kind the independently scheduled operation * @param PlanCadence $cadence the explicit interval @@ -100,9 +121,7 @@ private function __construct( * @return self the new plan */ public static function create( - string $id, - string $organizationId, - string $equipmentId, + MaintenancePlanIdentity $identity, string $name, MaintenanceOperationKind $kind, PlanCadence $cadence, @@ -110,7 +129,7 @@ public static function create( DateTimeImmutable $now, bool $legacy = false, ): self { - return new self($id, $organizationId, $equipmentId, trim($name), $kind, $cadence, $firstDueAt, $firstDueAt, $now, $legacy, false); + return new self($identity, trim($name), $kind, MaintenancePlanCalendar::start($cadence, $firstDueAt, $legacy), $now, false); } /** @@ -121,34 +140,24 @@ public static function create( * * @access public * - * @param string $id the plan UUID - * @param string $organizationId the owning organization UUID - * @param string $equipmentId the equipment UUID + * @param MaintenancePlanIdentity $identity the validated plan ownership scope * @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 MaintenancePlanCalendar $calendar the restored calendar with its original anchor and due slot * @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, + MaintenancePlanIdentity $identity, string $name, MaintenanceOperationKind $kind, - PlanCadence $cadence, - ?DateTimeImmutable $firstDueAt, - ?DateTimeImmutable $nextDueAt, + MaintenancePlanCalendar $calendar, DateTimeImmutable $createdAt, - bool $legacy, bool $archived, ): self { - return new self($id, $organizationId, $equipmentId, $name, $kind, $cadence, $firstDueAt, $nextDueAt, $createdAt, $legacy, $archived); + return new self($identity, $name, $kind, $calendar, $createdAt, $archived); } /** @@ -162,7 +171,7 @@ public static function reconstitute( */ public function nextDueAt(): ?DateTimeImmutable { - return $this->nextDueAt; + return $this->calendar->nextDueAt; } /** @@ -192,11 +201,7 @@ public function isArchived(): bool */ public function preview(int $count = 3): array { - if (null === $this->nextDueAt) { - return []; - } - - return $this->cadence->preview($this->anchorAt ?? $this->nextDueAt, $this->nextDueAt, $count); + return $this->calendar->preview($count); } /** @@ -213,17 +218,7 @@ public function preview(int $count = 3): array */ 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)); + $this->calendar = $this->calendar->advance($validatedAt); } /** @@ -254,7 +249,7 @@ public function archive(): void */ public function canGenerate(bool $siteArchived, bool $equipmentRetired): bool { - return !$this->archived && !$siteArchived && !$equipmentRetired && null !== $this->nextDueAt; + return !$this->archived && !$siteArchived && !$equipmentRetired && null !== $this->calendar->nextDueAt; } // #endregion } diff --git a/src/Maintenance/Domain/ValueObject/MaintenanceControlDates.php b/src/Maintenance/Domain/ValueObject/MaintenanceControlDates.php new file mode 100644 index 000000000..2fe035f5a --- /dev/null +++ b/src/Maintenance/Domain/ValueObject/MaintenanceControlDates.php @@ -0,0 +1,85 @@ +missingDueDate || null === $nextDueAt; + $next = $this->nextDueAt; + if (null !== $nextDueAt && (null === $next || $nextDueAt < $next)) { + $next = $nextDueAt; + } + $last = $this->lastCompletedAt; + if (null !== $lastCompletedAt && (null === $last || $lastCompletedAt > $last)) { + $last = $lastCompletedAt; + } + + return new self(true, $missingDueDate ? null : $next, $last, $missingDueDate); + } + // #endregion +} diff --git a/src/Maintenance/Domain/ValueObject/MaintenanceOccurrenceAttempt.php b/src/Maintenance/Domain/ValueObject/MaintenanceOccurrenceAttempt.php new file mode 100644 index 000000000..b64e46c0f --- /dev/null +++ b/src/Maintenance/Domain/ValueObject/MaintenanceOccurrenceAttempt.php @@ -0,0 +1,158 @@ +interventionId === $interventionId) { + return $this; + } + if (null !== $this->completedAt || null !== $this->interventionId) { + throw InvalidValueException::because('An existing occurrence attempt needs an explicit retry.'); + } + + return new self(1, $interventionId, null, null); + } + + /** + * Method retryAttempt + * + * Replaces work only for an attempted open lifecycle, clearing its previous receipt. + * + * @access public + * + * @param string $interventionId the new intervention UUID + * + * @return self the next attempt or unchanged replay + */ + public function retryAttempt(string $interventionId): self + { + Uuid::assertValid($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; + } + + return new self($this->number + 1, $interventionId, null, null); + } + + /** + * Method validateResult + * + * Retains one receipt per attempt; unsuccessful servicing stays open and + * control completion follows its operation kind even when defects remain. + * + * @access public + * + * @param MaintenanceOperationKind $kind the owning plan's operation kind + * @param bool $successful whether the performed work succeeded + * @param string $resultId the validated result UUID + * @param DateTimeImmutable $validatedAt the validation instant checked against reservation by the occurrence + * + * @return self the validated lifecycle or unchanged replay + */ + public function validateResult(MaintenanceOperationKind $kind, bool $successful, string $resultId, DateTimeImmutable $validatedAt): self + { + Uuid::assertValid($resultId); + + if ($this->resultId === $resultId) { + return $this; + } + if (null === $this->interventionId || null !== $this->resultId || null !== $this->completedAt) { + throw InvalidValueException::because('A result must validate the current occurrence attempt exactly once.'); + } + + return new self($this->number, $this->interventionId, $kind->completesOccurrence($successful) ? $validatedAt : null, $resultId); + } + // #endregion +} diff --git a/src/Maintenance/Domain/ValueObject/MaintenancePlanCalendar.php b/src/Maintenance/Domain/ValueObject/MaintenancePlanCalendar.php new file mode 100644 index 000000000..7c38816bc --- /dev/null +++ b/src/Maintenance/Domain/ValueObject/MaintenancePlanCalendar.php @@ -0,0 +1,129 @@ +legacy) { + throw InvalidValueException::because('A fixed plan needs an anchor and next due date; its calculation mode must match its cadence.'); + } + + if ($legacy) { + return; + } + if (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 ($nextDueAt < $anchorAt) { + throw InvalidValueException::because('A fixed plan cannot be due before its original anchor.'); + } + + if ($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 start + * + * Starts a calendar whose first due slot is its original anchor. + * + * @access public + * + * @param PlanCadence $cadence the validated interval + * @param ?DateTimeImmutable $firstDueAt the first due date, nullable only for historical plans + * @param bool $legacy whether historical sliding arithmetic applies + * + * @return self the initial calendar + */ + public static function start(PlanCadence $cadence, ?DateTimeImmutable $firstDueAt, bool $legacy = false): self + { + return new self($cadence, $firstDueAt, $firstDueAt, $legacy); + } + + /** + * Method advance + * + * Advances an anchored slot past both its due date and validation instant, + * while historical calendars continue sliding from validation. + * + * @access public + * + * @param DateTimeImmutable $validatedAt the validated completion instant + * + * @return self the advanced calendar with the original anchor + */ + public function advance(DateTimeImmutable $validatedAt): self + { + if ($this->legacy) { + return new self($this->cadence, $this->anchorAt, $this->cadence->addTo($validatedAt), true); + } + + if (null === $this->anchorAt || null === $this->nextDueAt) { + throw InvalidValueException::because('A fixed maintenance plan needs an explicit calendar.'); + } + + return new self($this->cadence, $this->anchorAt, $this->cadence->nextAfter($this->anchorAt, max($this->nextDueAt, $validatedAt)), false); + } + + /** + * Method preview + * + * Returns the upcoming slots without initializing an unscheduled historical calendar. + * + * @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); + } + // #endregion +} diff --git a/src/Maintenance/Domain/ValueObject/MaintenancePlanIdentity.php b/src/Maintenance/Domain/ValueObject/MaintenancePlanIdentity.php new file mode 100644 index 000000000..2eafd3c9d --- /dev/null +++ b/src/Maintenance/Domain/ValueObject/MaintenancePlanIdentity.php @@ -0,0 +1,39 @@ +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); - } + $current = $this->currentEquipment($organizationId, $chunk, $facilitySuspension); 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); - } - } + foreach (array_keys($current) as $equipmentId) { + $result[$equipmentId] = new MaintenanceEquipmentOperationsDue('unscheduled', 'unscheduled', null, null, $mode); + } + $due = 'legacy' === $mode ? $this->legacyDue($organizationId, $current, $mode) : $this->planDue($organizationId, $current, $policy, $mode); + $result = array_replace($result, $due); + } + + return $result; + } + /** + * Method currentEquipment + * + * Resolves lifecycle and organization scope before reading either due-state engine. + * + * @access private + * + * @param string $organizationId the organization to project + * @param list $equipmentIds the bounded equipment chunk + * @param array $facilitySuspension the shared facility lifecycle cache + * + * @return array scoped equipment and whether its lifecycle is trackable + */ + private function currentEquipment(string $organizationId, array $equipmentIds, array &$facilitySuspension): array + { + $current = []; + foreach ($this->equipment->findEquipmentByIds($equipmentIds) as $equipment) { + if ($equipment->organizationId !== $organizationId) { 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']); + $facilityKey = $equipment->facilityId ?? ''; + $facilitySuspension[$facilityKey] ??= $this->facilities->isArchived($equipment->facilityId, $organizationId); + $current[$equipment->equipmentId] = 'decommissioned' !== $equipment->status && !$facilitySuspension[$facilityKey]; + } + + return $current; + } + + /** + * Method legacyDue + * + * Reads historical controls while suspended equipment keeps its unscheduled default. + * + * @access private + * + * @param string $organizationId the scoped organization + * @param array $current the equipment lifecycle decisions + * @param string $mode the persisted engine authority + * + * @return array historical control statuses + */ + private function legacyDue(string $organizationId, array $current, string $mode): array + { + /** @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)', ['org' => $organizationId, 'ids' => array_keys($current)], ['ids' => ArrayParameterType::STRING]); + $result = []; + 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); } - 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; + } + + /** + * Method planDue + * + * Aggregates controls and servicing independently, retaining historical cadence availability. + * + * @access private + * + * @param string $organizationId the scoped organization + * @param array $current the equipment lifecycle decisions + * @param MaintenanceCompliancePolicy $policy the current historical defaults and reminder window + * @param string $mode the persisted engine authority + * + * @return array independent operation statuses + */ + private function planDue(string $organizationId, array $current, MaintenanceCompliancePolicy $policy, string $mode): array + { + /** @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', ['org' => $organizationId, 'ids' => array_keys($current)], ['ids' => ArrayParameterType::STRING]); + $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']); + } + $result = []; + 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; @@ -107,10 +168,12 @@ private function aggregate(array $dates, int $reminderDays): array } $now = $this->clock->now(); if ($now > $first) { - return ['overdue', $first]; + $status = 'overdue'; + } else { + $status = $now >= $first->modify('-' . $reminderDays . ' days') ? 'due_soon' : 'up_to_date'; } - return [$now >= $first->modify('-' . $reminderDays . ' days') ? 'due_soon' : 'up_to_date', $first]; + return [$status, $first]; } private function date(?string $date): ?DateTimeImmutable diff --git a/src/Maintenance/Infrastructure/Persistence/Doctrine/Lock/MaintenanceScheduleLockAdapter.php b/src/Maintenance/Infrastructure/Persistence/Doctrine/Lock/MaintenanceScheduleLockAdapter.php index 179bff3cc..5bcd60248 100644 --- a/src/Maintenance/Infrastructure/Persistence/Doctrine/Lock/MaintenanceScheduleLockAdapter.php +++ b/src/Maintenance/Infrastructure/Persistence/Doctrine/Lock/MaintenanceScheduleLockAdapter.php @@ -16,6 +16,15 @@ /** Transaction-scoped lock also protects the first insert, before a row exists. */ final readonly class MaintenanceScheduleLockAdapter implements MaintenanceScheduleLockPort { + // #region Properties + /** + * Constant ADVISORY_LOCK_SQL + * + * Acquires a transaction-scoped lock from the stable maintenance scope key. + */ + private const string ADVISORY_LOCK_SQL = 'SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))'; + // #endregion + // #region Constructor /** * Method __construct @@ -52,8 +61,8 @@ 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))', [ + $this->connection->executeStatement(self::ADVISORY_LOCK_SQL, ['key' => 'maintenance.engine.' . $organizationId]); + $this->connection->executeStatement(self::ADVISORY_LOCK_SQL, [ 'key' => 'maintenance.schedule.' . $organizationId . '.' . $equipmentId, ]); @@ -77,7 +86,7 @@ public function synchronizedBatch(array $scopes, callable $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]); + $this->connection->executeStatement(self::ADVISORY_LOCK_SQL, ['key' => 'maintenance.engine.' . $organizationId]); } $values = []; $parameters = []; diff --git a/src/Maintenance/Presentation/Api/Processor/MaintenancePlanProcessor.php b/src/Maintenance/Presentation/Api/Processor/MaintenancePlanProcessor.php index d491e8c77..0e1aa68eb 100644 --- a/src/Maintenance/Presentation/Api/Processor/MaintenancePlanProcessor.php +++ b/src/Maintenance/Presentation/Api/Processor/MaintenancePlanProcessor.php @@ -44,6 +44,35 @@ public function process(mixed $data, Operation $operation, array $uriVariables = MaintenancePlanOperations::ACTIVATE => 'activate', default => throw new BadRequestHttpException('Unsupported maintenance operation.'), }; + /** @var ManageMaintenancePlanResult $result */ + $result = $this->commands->dispatch($this->command($data, $action, $organizationId, $actor, $planId)); + + return match ($action) { + 'archive' => null, + 'prepare_legacy', 'activate' => $this->engineOutput($result), + 'generate' => $this->generationOutput($result), + default => null === $result->details ? null : $this->outputs->fromDetails($result->details), + }; + } + + /** + * Method command + * + * Translates mutable fields while preserving omitted patch values and the + * operation's equipment/kind identity. + * + * @access private + * + * @param mixed $data the operation's request DTO + * @param string $action the supported plan action + * @param string $organizationId the route's organization + * @param string $actor the authenticated actor + * @param ?string $planId the existing plan, absent for collection actions + * + * @return ManageMaintenancePlanCommand the command to dispatch + */ + private function command(mixed $data, string $action, string $organizationId, string $actor, ?string $planId): ManageMaintenancePlanCommand + { $change = $data instanceof ChangeMaintenancePlanInput ? $data : null; $generate = $data instanceof GenerateMaintenancePlanInput ? $data : null; if (('create' === $action || 'update' === $action) && null === $change) { @@ -52,45 +81,66 @@ public function process(mixed $data, Operation $operation, array $uriVariables = 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( + + return new ManageMaintenancePlanCommand( $action, $organizationId, $actor, planId: $planId, equipmentId: $change?->equipmentId, - name: null !== $change ? $change->name : (null !== $generate ? $generate->name : null), + name: $change->name ?? $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, + retry: $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; - } + /** + * Method engineOutput + * + * Exposes the authority and preparation count returned by an engine action. + * + * @access private + * + * @param ManageMaintenancePlanResult $result the engine action result + * + * @return MaintenancePlanEngineOutput the transport response + */ + private function engineOutput(ManageMaintenancePlanResult $result): MaintenancePlanEngineOutput + { + $output = new MaintenancePlanEngineOutput(); + $output->mode = $result->mode; + $output->preparedCount = $result->preparedCount; + + return $output; + } + + /** + * Method generationOutput + * + * Exposes the stable occurrence and the generated or replayed attempt. + * + * @access private + * + * @param ManageMaintenancePlanResult $result the generation receipt + * + * @return GenerateMaintenancePlanOutput the transport response + */ + private function generationOutput(ManageMaintenancePlanResult $result): GenerateMaintenancePlanOutput + { + $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 null === $result->details ? null : $this->outputs->fromDetails($result->details); + return $output; } private function date(?string $value): ?DateTimeImmutable diff --git a/src/Maintenance/Presentation/Api/Resource/MaintenancePlanResource.php b/src/Maintenance/Presentation/Api/Resource/MaintenancePlanResource.php index 19a06b669..c979d5339 100644 --- a/src/Maintenance/Presentation/Api/Resource/MaintenancePlanResource.php +++ b/src/Maintenance/Presentation/Api/Resource/MaintenancePlanResource.php @@ -28,14 +28,16 @@ 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::GET, uriTemplate: self::PLAN_PATH, 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: self::PLAN_PATH, 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: self::PLAN_PATH, 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 PLAN_PATH = '/organizations/{organizationId}/maintenance/plans/{id}'; + 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/Service/MaintenanceCostAccessGuard.php b/src/MaintenanceCost/Application/Service/MaintenanceCostAccessGuard.php index a0a3b41ea..3fafb57be 100644 --- a/src/MaintenanceCost/Application/Service/MaintenanceCostAccessGuard.php +++ b/src/MaintenanceCost/Application/Service/MaintenanceCostAccessGuard.php @@ -17,7 +17,7 @@ public function __construct(private OrganizationAuthorizationPort $authorization public function assertAccess(string $actorId, string $organizationId, bool $write): void { - new Uuid($organizationId); + Uuid::assertValid($organizationId); $decision = $this->authorization->resolveAccess($actorId, $organizationId, $write ? 'organization.maintenance_cost.manage' : 'organization.maintenance_cost.read'); if ($decision->isOutsideScope()) { throw MaintenanceCostException::notFound(); diff --git a/src/MaintenanceCost/Application/Service/MaintenanceCostAllocationResolver.php b/src/MaintenanceCost/Application/Service/MaintenanceCostAllocationResolver.php index 5ab7b4bb2..672bc63f8 100644 --- a/src/MaintenanceCost/Application/Service/MaintenanceCostAllocationResolver.php +++ b/src/MaintenanceCost/Application/Service/MaintenanceCostAllocationResolver.php @@ -4,6 +4,7 @@ namespace MaintenanceCost\Application\Service; +use Intervention\Application\Contract\Publication\{InterventionEconomicContext, InterventionEquipmentSnapshot, InterventionPublishedWorkFact}; use Intervention\Application\Port\Inbound\InterventionPublicationFactsPort; use Intervention\Application\Port\Outbound\InterventionEquipmentSnapshotPort; use MaintenanceCost\Application\Contract\Cost\MaintenanceCostItem; @@ -46,53 +47,156 @@ public function resolve(string $organizationId, string $interventionId, array $i 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)); + $originalSources = []; + foreach ($items as $index => $item) { + $original = $this->original($item, $originals, $capturedById); + $originalSources[$index] = $original; 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; - } } + $equipmentIds = $this->liveEquipmentIds($items, $originalSources, $tasks, $context); $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; + foreach ($items as $index => $item) { $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; - } - } + $original = $originalSources[$index]; + $allocation = $original->allocation ?? $this->allocation($item, $original, $task, $context, $identities, $equipmentId); $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 original + * + * Source identity takes precedence over a correction reference when both point to captured facts. + * + * @access private + * + * @param MaintenanceCostItem $item current contribution + * @param array $originals captured facts keyed by kind and source + * @param array $capturedById captured facts keyed by contribution identity + * + * @return ?MaintenanceCostItem captured source when retained + */ + private function original(MaintenanceCostItem $item, array $originals, array $capturedById): ?MaintenanceCostItem + { + return $originals[$item->kind . ':' . $item->sourceId] ?? (null === $item->correctionOf ? null : ($capturedById[$item->correctionOf] ?? null)); + } + + /** + * Method liveEquipmentIds + * + * Looks up current labels only for sources allowed to acquire them. + * + * @access private + * + * @param list $items current contributions + * @param array $originalSources captured source at each current contribution index + * @param array $tasks owned task identities + * @param ?InterventionEconomicContext $context publication provenance + * + * @return array equipment identities eligible for a live lookup + */ + private function liveEquipmentIds(array $items, array $originalSources, array $tasks, ?InterventionEconomicContext $context): array + { + $equipmentIds = []; + foreach ($items as $index => $item) { + $original = $originalSources[$index]; + $task = null === $item->workItemId ? null : ($tasks[$item->workItemId] ?? null); + $id = $item->equipmentId ?? $task?->equipmentId; + if (null !== $id && null === $original?->allocation && !$this->useCapturedTask($context, $task, $id) && $this->canUseLiveIdentity($context, $item, $original)) { + $equipmentIds[$id] = true; + } + } + + return $equipmentIds; + } + + /** + * Method useCapturedTask + * + * An available dossier can provide only the identity of its own matching task target. + * + * @access private + * + * @param ?InterventionEconomicContext $context publication provenance + * @param ?InterventionPublishedWorkFact $task owned task + * @param ?string $equipmentId contribution target + * + * @return bool whether the captured task is the identity authority + */ + private function useCapturedTask(?InterventionEconomicContext $context, ?InterventionPublishedWorkFact $task, ?string $equipmentId): bool + { + return null !== $task && (null === $equipmentId || $task->equipmentId === $equipmentId) && 'available' === $context?->snapshotState; + } + + /** + * Method canUseLiveIdentity + * + * Only an unpublished source or a proven new fact anchored to this publication may acquire later labels. + * + * @access private + * + * @param ?InterventionEconomicContext $context publication provenance + * @param MaintenanceCostItem $item current contribution + * @param ?MaintenanceCostItem $original captured source + * + * @return bool whether live park identities are permitted + */ + private function canUseLiveIdentity(?InterventionEconomicContext $context, MaintenanceCostItem $item, ?MaintenanceCostItem $original): bool + { + return 'live' === $context?->snapshotState || (null === $original && null !== $context?->publicationId && $item->correctionOf === 'publication:' . $context->publicationId); + } + + /** + * Method allocation + * + * Retains incomplete historical identities rather than substituting the current park. + * + * @access private + * + * @param MaintenanceCostItem $item current contribution + * @param ?MaintenanceCostItem $original captured source + * @param ?InterventionPublishedWorkFact $task owned task identity + * @param ?InterventionEconomicContext $context publication provenance + * @param array $identities permitted current equipment identities + * @param ?string $equipmentId contribution target + * + * @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 allocation identity + */ + private function allocation(MaintenanceCostItem $item, ?MaintenanceCostItem $original, ?InterventionPublishedWorkFact $task, ?InterventionEconomicContext $context, array $identities, ?string $equipmentId): array + { + $useCapturedTask = $this->useCapturedTask($context, $task, $equipmentId); + $capturedIdentity = $useCapturedTask ? $task?->equipmentIdentity : null; + $equipmentIdentity = $capturedIdentity; + if (null === $equipmentIdentity && !$useCapturedTask && $this->canUseLiveIdentity($context, $item, $original)) { + $equipmentIdentity = null === $equipmentId ? null : ($identities[$equipmentId] ?? null); + } + $state = 'incomplete'; + if (null !== $capturedIdentity) { + $state = 'captured'; + } elseif (null !== $equipmentIdentity || (null !== $task && 'live' === $context?->snapshotState)) { + $state = 'live'; + } + $allocation = [ + 'identityState' => $state, + '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; + } + + return $allocation; + } + /** * Method inheritCurrentCorrections * @@ -115,31 +219,52 @@ private function inheritCurrentCorrections(array $items, array $capturedSources) $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; - } + $source = $this->correctionSource($item, $byId, $capturedSources, $sources); $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; } + + /** + * Method correctionSource + * + * Memoizes each correction chain's source; cycles retain each item's own identity. + * + * @access private + * + * @param MaintenanceCostItem $item contribution whose allocation must be inherited + * @param array $byId current source lookup + * @param array $capturedSources frozen source anchors + * @param array $sources memoized chain results + * + * @return ?MaintenanceCostItem terminal source or null for a cyclic chain + */ + private function correctionSource(MaintenanceCostItem $item, array $byId, array $capturedSources, array &$sources): ?MaintenanceCostItem + { + $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; + } + + return $source; + } } diff --git a/src/MaintenanceCost/Application/Service/MaintenanceCostProjection.php b/src/MaintenanceCost/Application/Service/MaintenanceCostProjection.php index 656a478fe..3a2936d89 100644 --- a/src/MaintenanceCost/Application/Service/MaintenanceCostProjection.php +++ b/src/MaintenanceCost/Application/Service/MaintenanceCostProjection.php @@ -4,6 +4,7 @@ namespace MaintenanceCost\Application\Service; +use Intervention\Application\Contract\Cost\InterventionTimeCostFact; use Intervention\Application\Port\Inbound\InterventionCostSourceFactsPort; use Inventory\Application\Port\Inbound\InventoryInterventionResourcesPort; use MaintenanceCost\Application\Contract\Cost\{MaintenanceCostItem, MaintenanceCostSnapshot, MaintenanceCostTotals, MaintenanceCostView}; @@ -43,30 +44,20 @@ public function current(string $organizationId, string $interventionId, string $ } 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); + $items[] = $this->timeItem($organizationId, $currency, $fact, $original, $frozen); } 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); + $correctionOf = $this->correctionReference('material', $fact->factId, $fact->correctionOf, $originals, $frozen); $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); + $correctionOf = $this->correctionReference('expense', $expense->id, $expense->adjustmentOf, $originals, $frozen); $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) { @@ -76,4 +67,85 @@ public function current(string $organizationId, string $interventionId, string $ return new MaintenanceCostTotals($totals['total'], $totals['knownTotal'], $totals['complete'], $items); } + + /** + * Method timeItem + * + * Preserves the published hourly rate for the same work date while valuing corrected minutes. + * + * @access private + * + * @param string $organizationId owned rate scope + * @param string $currency exact organization currency + * @param InterventionTimeCostFact $fact current journal contribution + * @param ?MaintenanceCostItem $original captured version of this source + * @param ?MaintenanceCostSnapshot $frozen private publication anchor + * + * @return MaintenanceCostItem current time valuation and correction identity + */ + private function timeItem(string $organizationId, string $currency, InterventionTimeCostFact $fact, ?MaintenanceCostItem $original, ?MaintenanceCostSnapshot $frozen): MaintenanceCostItem + { + $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 = $this->timeAmount($fact, $hourlyAmount); + $correctionOf = null; + if (null !== $original && ($original->sourceRevision !== $fact->revision || $original->amount !== $amount)) { + $correctionOf = $original->id; + } elseif (null === $original && null !== $frozen) { + $correctionOf = 'publication:' . $frozen->publicationId; + } + + return 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); + } + + /** + * Method timeAmount + * + * Cancellation is an explicit zero even when no hourly rate exists. + * + * @access private + * + * @param InterventionTimeCostFact $fact current journal state + * @param ?string $hourlyAmount captured or effective exact rate + * + * @return ?string exact amount or an unknown valuation + */ + private function timeAmount(InterventionTimeCostFact $fact, ?string $hourlyAmount): ?string + { + if ($fact->cancelled) { + return '0.000000'; + } + + return null === $hourlyAmount ? null : $this->calculator->timeAmount($hourlyAmount, $fact->minutes); + } + + /** + * Method correctionReference + * + * Append-only facts reference their original contribution or the publication that precedes them. + * + * @access private + * + * @param string $kind contribution category + * @param string $sourceId current fact identity + * @param ?string $correctionOf original source identity + * @param array $originals captured contributions keyed by category and source + * @param ?MaintenanceCostSnapshot $frozen private publication anchor + * + * @return ?string correction identity retained in the private projection + */ + private function correctionReference(string $kind, string $sourceId, ?string $correctionOf, array $originals, ?MaintenanceCostSnapshot $frozen): ?string + { + if (null !== $correctionOf) { + return $kind . ':' . $correctionOf; + } + + return !isset($originals[$kind . ':' . $sourceId]) && null !== $frozen ? 'publication:' . $frozen->publicationId : null; + } } diff --git a/src/MaintenanceCost/Application/Service/MaintenanceEconomicAggregation.php b/src/MaintenanceCost/Application/Service/MaintenanceEconomicAggregation.php new file mode 100644 index 000000000..6b668eea0 --- /dev/null +++ b/src/MaintenanceCost/Application/Service/MaintenanceEconomicAggregation.php @@ -0,0 +1,206 @@ + + */ + private array $buckets = []; + + /** + * Property selected + * + * @var array + */ + private array $selected; + + /** + * Property source + * + * @var array + */ + private array $source; + + /** + * Property excluded + * + * @var array + */ + private array $excluded; + // #endregion + + // #region Constructor + /** + * Method __construct + * + * Starts one report's independent accumulation state. + * + * @access public + * + * @return void + */ + public function __construct() + { + $this->unallocated = new MaintenanceEconomicBucket(null, null); + $this->selected = ['current' => new MaintenanceEconomicAccumulator(), 'frozen' => new MaintenanceEconomicAccumulator(), 'planned' => new MaintenanceEconomicAccumulator(), 'budget' => new MaintenanceEconomicAccumulator()]; + $this->source = ['current' => new MaintenanceEconomicAccumulator(), 'frozen' => new MaintenanceEconomicAccumulator(), 'planned' => new MaintenanceEconomicAccumulator()]; + $this->excluded = ['current' => new MaintenanceEconomicAccumulator(), 'frozen' => new MaintenanceEconomicAccumulator(), 'planned' => new MaintenanceEconomicAccumulator()]; + } + // #endregion + + // #region Methods + /** + * Method retain + * + * Records each source once, keeping known different targets separate and unresolved scope unallocated. + * + * @access public + * + * @param string $kind current, frozen or planned contribution + * @param ?string $amount exact valuation or unknown + * @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 retained source identity + * @param string $interventionId source dossier identity + * @param ?array{id:string,name:?string} $identity selected grouping identity + * @param ?bool $matches true matching target, false known different target, null unresolved scope + * + * @return void + */ + public function retain(string $kind, ?string $amount, array $allocation, string $interventionId, ?array $identity, ?bool $matches): void + { + $this->source[$kind]->add($amount); + $hasAllocation = null !== $allocation['equipment'] || null !== $allocation['site'] || null !== $allocation['customer']; + if ($hasAllocation && false === $matches) { + $this->excluded[$kind]->add($amount); + + return; + } + if (null === $matches) { + $identity = null; + } + $this->selected[$kind]->add($amount); + if (null === $identity) { + $bucket = $this->unallocated; + } else { + $bucket = $this->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); + } + + /** + * Method budget + * + * The budget stays separate from resource estimates and belongs to no target bucket. + * + * @access public + * + * @param ?string $amount exact declared budget or unknown + * @param string $interventionId source dossier identity + * @param bool $published whether the source is captured + * + * @return void + */ + public function budget(?string $amount, string $interventionId, bool $published): void + { + $this->unallocated->budget->add($amount); + $this->selected['budget']->add($amount); + $this->unallocated->source($interventionId, $published ? 'captured' : 'live', false, null); + } + + /** + * Method amount + * + * Supplies a full-scope selected total independently of row pagination. + * + * @access public + * + * @param string $kind selected contribution category + * + * @return MaintenanceEconomicAmount exact subtotal and completeness + */ + public function amount(string $kind): MaintenanceEconomicAmount + { + return $this->selected[$kind]->amount(); + } + + /** + * Method rows + * + * Sorts complete allocation rows before the caller paginates them. + * + * @access public + * + * @return list all allocated destinations + */ + public function rows(): array + { + $rows = []; + foreach ($this->buckets as $bucket) { + $rows[] = $bucket->row(); + } + usort($rows, static fn ($a, $b): int => ($a->name ?? $a->id ?? '') <=> ($b->name ?? $b->id ?? '')); + + return $rows; + } + + /** + * Method reconciliation + * + * Verifies exact amounts and both known and unknown contribution counts across selection boundaries. + * + * @access public + * + * @return array{sourceCurrent:MaintenanceEconomicAmount,excludedCurrent:MaintenanceEconomicAmount,sourceFrozen:MaintenanceEconomicAmount,excludedFrozen:MaintenanceEconomicAmount,sourcePlanned:MaintenanceEconomicAmount,excludedPlanned:MaintenanceEconomicAmount,reconciled:bool} complete reconciliation evidence + */ + public function reconciliation(): array + { + $reconciled = true; + foreach (['current', 'frozen', 'planned'] as $kind) { + $includedAmount = $this->selected[$kind]->amount(); + $excludedAmount = $this->excluded[$kind]->amount(); + $sourceAmount = $this->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 ['sourceCurrent' => $this->source['current']->amount(), 'excludedCurrent' => $this->excluded['current']->amount(), 'sourceFrozen' => $this->source['frozen']->amount(), 'excludedFrozen' => $this->excluded['frozen']->amount(), 'sourcePlanned' => $this->source['planned']->amount(), 'excludedPlanned' => $this->excluded['planned']->amount(), 'reconciled' => $reconciled]; + } + // #endregion +} diff --git a/src/MaintenanceCost/Application/Service/MaintenanceEconomicAggregator.php b/src/MaintenanceCost/Application/Service/MaintenanceEconomicAggregator.php index d4881f814..7ea773e3a 100644 --- a/src/MaintenanceCost/Application/Service/MaintenanceEconomicAggregator.php +++ b/src/MaintenanceCost/Application/Service/MaintenanceEconomicAggregator.php @@ -13,11 +13,8 @@ use function array_map; use function array_slice; -use function bcadd; -use function bccomp; use function bcsub; use function count; -use function usort; /** * Class MaintenanceEconomicAggregator @@ -35,79 +32,139 @@ */ 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()]; + $contexts = $this->eligibleContexts($query, $currency, $contexts, $views); + $aggregation = new MaintenanceEconomicAggregation(); $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.'); - } + $this->assertSource($query, $currency, $context, $view); $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); + $this->retainActualCosts($query, $currency, $context, $view, $aggregation, $factCount); + $this->retainPlanning($query, $currency, $context, $view, $aggregation, $factCount); + } + $rows = $aggregation->rows(); + $current = $aggregation->amount('current'); + $planned = $aggregation->amount('planned'); + + 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), $aggregation->unallocated->row(), $current, $aggregation->amount('frozen'), $planned, $aggregation->amount('budget'), $current->complete && $planned->complete && $planned->contributionCount > 0 ? bcsub($current->knownTotal, $planned->knownTotal, 6) : null, $aggregation->reconciliation(), $procurement, array_map(static fn (InterventionEconomicContext $context): array => ['id' => $context->id, 'number' => $context->number, 'name' => $context->name, 'status' => $context->status, 'snapshotState' => $context->snapshotState], $contexts)); + } + + /** + * Method eligibleContexts + * + * Authorizes every candidate before financial target selection. + * + * @access private + * + * @param ReadMaintenanceEconomicReportQuery $query authorized report scope + * @param string $currency organization currency + * @param list $contexts bounded candidate dossiers + * @param array $views private facts by dossier identity + * + * @return list dossiers retained by source target identities + */ + private function eligibleContexts(ReadMaintenanceEconomicReportQuery $query, string $currency, array $contexts, array $views): array + { + $eligible = []; + foreach ($contexts as $context) { + $view = $views[$context->id] ?? throw MaintenanceCostException::notFound(); + $this->assertSource($query, $currency, $context, $view); + if ($this->eligible($query, $context, $view)) { + $eligible[] = $context; } - $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); + } + + return $eligible; + } + + /** + * Method assertSource + * + * Keeps operational and private source facts inside one authorized organization currency. + * + * @access private + * + * @param ReadMaintenanceEconomicReportQuery $query authorized organization scope + * @param string $currency organization currency + * @param InterventionEconomicContext $context operational source + * @param MaintenanceCostView $view private source + * + * @return void + */ + private function assertSource(ReadMaintenanceEconomicReportQuery $query, string $currency, InterventionEconomicContext $context, MaintenanceCostView $view): void + { + if ($view->organizationId !== $query->organizationId || $context->organizationId !== $query->organizationId || $view->currency !== $currency) { + throw MaintenanceCostException::conflict('Economic sources do not share one authorized organization currency.'); + } + } + + /** + * Method retainActualCosts + * + * Values current and captured facts separately, including an explicit unknown for a missing private publication. + * + * @access private + * + * @param ReadMaintenanceEconomicReportQuery $query selected target scope + * @param string $currency organization currency + * @param InterventionEconomicContext $context source allocation identities + * @param MaintenanceCostView $view current and captured financial facts + * @param MaintenanceEconomicAggregation $aggregation report accumulation state + * @param int $factCount contributions retained across every source + * + * @return void + */ + private function retainActualCosts(ReadMaintenanceEconomicReportQuery $query, string $currency, InterventionEconomicContext $context, MaintenanceCostView $view, MaintenanceEconomicAggregation $aggregation, int &$factCount): void + { + 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.'); } + $this->retain($kind, $item->amount, $this->allocation($item, $context), $context->id, $query, $aggregation); + $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)); + if ('published' === $context->status && null === $view->frozen) { + $this->retain('frozen', null, $this->emptyAllocation(), $context->id, $query, $aggregation); + } + } + + /** + * Method retainPlanning + * + * Published preparation remains frozen; resources replace the budget baseline rather than adding it twice. + * + * @access private + * + * @param ReadMaintenanceEconomicReportQuery $query selected target scope + * @param string $currency organization currency + * @param InterventionEconomicContext $context source allocation identities + * @param MaintenanceCostView $view live and captured preparation + * @param MaintenanceEconomicAggregation $aggregation report accumulation state + * @param int $factCount contributions retained across every source + * + * @return void + */ + private function retainPlanning(ReadMaintenanceEconomicReportQuery $query, string $currency, InterventionEconomicContext $context, MaintenanceCostView $view, MaintenanceEconomicAggregation $aggregation, int &$factCount): void + { + $isPublished = 'published' === $context->status; + $planning = $isPublished && null !== $view->frozen?->planning ? $view->frozen->planning : $view->planning; + $aggregation->budget($planning->plannedBudget, $context->id, $isPublished); + if ([] === $planning->resources) { + $this->retain('planned', $planning->plannedBudget, $this->emptyAllocation(), $context->id, $query, $aggregation); + + return; + } + 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, $aggregation); + $this->bounded(++$factCount); + } } /** @@ -137,49 +194,27 @@ private function allocation(MaintenanceCostItem $item, InterventionEconomicConte $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]; + $state = null !== $identity ? 'captured' : 'incomplete'; + if ('live' === $context->snapshotState) { + $state = 'live'; + } + + return ['identityState' => $state, '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 + * @param MaintenanceEconomicAggregation $aggregation related bucket and reconciliation state */ - private function retain(string $kind, ?string $amount, array $allocation, string $interventionId, ReadMaintenanceEconomicReportQuery $query, array &$buckets, MaintenanceEconomicBucket $unallocated, array $selected, array $source, array $excluded): void + private function retain(string $kind, ?string $amount, array $allocation, string $interventionId, ReadMaintenanceEconomicReportQuery $query, MaintenanceEconomicAggregation $aggregation): 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); + $aggregation->retain($kind, $amount, $allocation, $interventionId, $identity, $this->matches($query, $allocation)); } /** @@ -225,23 +260,42 @@ private function eligible(ReadMaintenanceEconomicReportQuery $query, Interventio 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; - } + $items = [...$view->current->items, ...($view->frozen?->totals->items ?? [])]; + if ($this->hasEligibleTarget($query, $context, $items)) { + return true; } + $taskTargets = []; foreach ($context->workItems as $task) { - $item = new MaintenanceCostItem('target', 'target', $task->id, 'target', null, null, $view->currency, '', ''); + $taskTargets[] = new MaintenanceCostItem('target', 'target', $task->id, 'target', null, null, $view->currency, '', ''); + } + $root = ['identityState' => 'live' === $context->snapshotState ? 'live' : 'captured', 'equipment' => null, 'site' => $context->site, 'customer' => $context->customer]; + + return $this->hasEligibleTarget($query, $context, $taskTargets) || (null === $query->equipmentId && true === $this->matches($query, $root)); + } + + /** + * Method hasEligibleTarget + * + * Unknown filter dimensions remain eligible only when the source has at least one target identity. + * + * @access private + * + * @param ReadMaintenanceEconomicReportQuery $query selected target scope + * @param InterventionEconomicContext $context captured task fallback identities + * @param list $items financial facts or minimal operational target placeholders + * + * @return bool whether any source target can satisfy the selection + */ + private function hasEligibleTarget(ReadMaintenanceEconomicReportQuery $query, InterventionEconomicContext $context, array $items): bool + { + foreach ($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; } } - $root = ['identityState' => 'live' === $context->snapshotState ? 'live' : 'captured', 'equipment' => null, 'site' => $context->site, 'customer' => $context->customer]; - return null === $query->equipmentId && true === $this->matches($query, $root); + return false; } } diff --git a/src/MaintenanceCost/Application/Service/MaintenanceEconomicBucket.php b/src/MaintenanceCost/Application/Service/MaintenanceEconomicBucket.php index ff041b149..2dd9bf462 100644 --- a/src/MaintenanceCost/Application/Service/MaintenanceEconomicBucket.php +++ b/src/MaintenanceCost/Application/Service/MaintenanceEconomicBucket.php @@ -65,6 +65,11 @@ public function row(): MaintenanceEconomicRow $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)); + $state = '' === $this->state ? 'incomplete' : $this->state; + if (null === $this->id) { + $state = 'unallocated'; + } + + return new MaintenanceEconomicRow($this->id, $this->name, $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 index 92ff98724..0dbe1790f 100644 --- a/src/MaintenanceCost/Application/Service/MaintenanceEconomicDirectory.php +++ b/src/MaintenanceCost/Application/Service/MaintenanceEconomicDirectory.php @@ -6,7 +6,7 @@ use Intervention\Application\Port\Outbound\{InterventionEconomicScopePort, InterventionEquipmentSnapshotPort}; use Inventory\Application\Port\Inbound\InventoryInterventionResourcesPort; -use MaintenanceCost\Application\Contract\Cost\MaintenanceCostView; +use MaintenanceCost\Application\Contract\Cost\{MaintenanceCostItem, MaintenanceCostView}; use MaintenanceCost\Application\Port\Inbound\MaintenanceCostReadPort; use MaintenanceCost\Application\Port\Outbound\MaintenanceCostStorePort; use MaintenanceCost\Domain\Exception\MaintenanceCostException; @@ -67,12 +67,7 @@ public function matchingIds(string $organizationId, ?string $siteId, ?string $cu 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] : []); - } + $equipmentIds = $this->equipmentScope($organizationId, $siteId, $customerId, $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.'); @@ -87,22 +82,8 @@ public function matchingIds(string $organizationId, ?string $siteId, ?string $cu 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; - } + if ($this->matchesAnyAllocation($items, ['site' => $siteId, 'customer' => $customerId, 'equipment' => $equipmentId])) { + $matches[$id] = true; } } @@ -138,6 +119,80 @@ public function equipment(string $organizationId, string $interventionId): array return array_values($equipment); } + /** + * Method equipmentScope + * + * Current equipment references are only candidates; captured allocations remain independently selectable. + * + * @access private + * + * @param string $organizationId authorized organization + * @param ?string $siteId optional site scope + * @param ?string $customerId optional client scope + * @param ?string $equipmentId optional exact asset + * + * @return list current candidate equipment identifiers + */ + private function equipmentScope(string $organizationId, ?string $siteId, ?string $customerId, ?string $equipmentId): array + { + if (null === $siteId && null === $customerId) { + return null === $equipmentId ? [] : [$equipmentId]; + } + $facilities = $this->scopes->facilityIds($organizationId, $siteId, $customerId); + $scope = [] === $facilities ? [] : $this->equipment->equipmentIdsInFacilities($organizationId, $facilities); + if (null !== $equipmentId) { + $scope = in_array($equipmentId, $scope, true) ? [$equipmentId] : []; + } + + return $scope; + } + + /** + * Method matchesAnyAllocation + * + * Missing historic scope cannot be inferred from a candidate material reference. + * + * @access private + * + * @param list $items current and captured contributions + * @param array $filters requested target dimensions + * + * @return bool whether a verified allocation satisfies every filter + */ + private function matchesAnyAllocation(array $items, array $filters): bool + { + foreach ($items as $item) { + if (null !== $item->allocation && $this->matchesAllocation($item->allocation, $filters)) { + return true; + } + } + + return false; + } + + /** + * Method matchesAllocation + * + * Requires an exact identity for every requested dimension. + * + * @access private + * + * @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 + * @param array $filters requested target dimensions + * + * @return bool whether all requested targets match + */ + private function matchesAllocation(array $allocation, array $filters): bool + { + foreach ($filters as $target => $identifier) { + if (null !== $identifier && ($allocation[$target]['id'] ?? null) !== $identifier) { + return false; + } + } + + return true; + } + /** * Method assertScope * diff --git a/src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostHandler.php b/src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostHandler.php index de69354b2..c0ea012be 100644 --- a/src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostHandler.php +++ b/src/MaintenanceCost/Application/UseCase/Command/Cost/WriteMaintenanceCost/WriteMaintenanceCostHandler.php @@ -103,6 +103,39 @@ private function expense(WriteMaintenanceCostCommand $command, InterventionCostC $rawAmount = $this->string($command->values['amount'] ?? null, 26); $amount = $this->amount($rawAmount, true); $date = $this->string($command->values['incurredAt'] ?? null, 40); + $incurredAt = $this->incurredAt($date); + $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); + $this->assertAdjustment($command, $adjustmentOf, $amount); + $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())); + } + + /** + * Method incurredAt + * + * Keeps the actual expense date timezone-qualified, calendar-valid and no later than now. + * + * @access private + * + * @param string $date literal declared date + * + * @return DateTimeImmutable validated UTC instant + */ + private function incurredAt(string $date): DateTimeImmutable + { 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.'); } @@ -119,11 +152,25 @@ private function expense(WriteMaintenanceCostCommand $command, InterventionCostC 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); + + return $incurredAt; + } + + /** + * Method assertAdjustment + * + * Corrections reference one original expense in the same work dossier. + * + * @access private + * + * @param WriteMaintenanceCostCommand $command authorized mutation + * @param ?string $adjustmentOf original expense identity + * @param string $amount exact signed amount + * + * @return void + */ + private function assertAdjustment(WriteMaintenanceCostCommand $command, ?string $adjustmentOf, string $amount): void + { if (null !== $adjustmentOf) { $original = $this->store->expense($command->organizationId, $adjustmentOf); if (null === $original || $original->interventionId !== $command->interventionId || null !== $original->adjustmentOf) { @@ -132,17 +179,6 @@ private function expense(WriteMaintenanceCostCommand $command, InterventionCostC } 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())); } /** @@ -155,43 +191,83 @@ private function resources(mixed $input, InterventionCostContext $context): arra } $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(); + $result[] = $this->resource($resource, $context); + } + + return $result; + } + + /** + * Method resource + * + * Validates one estimate and recalculates its kind-specific amount without retaining stale prices. + * + * @access private + * + * @param mixed $resource declared structured estimate + * @param InterventionCostContext $context owned work-item identities + * + * @return array{workItemId:?string,kind:string,description:string,quantity:?string,unitCost:?string,estimatedMinutes:?int,amount:?string} complete normalized estimate + */ + private function resource(mixed $resource, InterventionCostContext $context): array + { + 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.'); } - $kind = $this->string($resource['kind'] ?? null, 16); - if (!in_array($kind, ['time', 'material', 'external'], true)) { - throw MaintenanceCostException::invalid('Unknown estimated resource kind.'); + } + $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); + $amount = $this->resourceAmount($kind, $quantity, $unitCost, $minutes, $amount); + + return ['workItemId' => $workItemId, 'kind' => $kind, 'description' => $this->string($resource['description'] ?? null, 1000), 'quantity' => $quantity, 'unitCost' => $unitCost, 'estimatedMinutes' => $minutes, 'amount' => $amount]; + } + + /** + * Method resourceAmount + * + * Uses the estimate kind's inputs and keeps insufficient valuations unknown. + * + * @access private + * + * @param string $kind validated estimate category + * @param ?string $quantity exact material quantity + * @param ?string $unitCost exact unit price or hourly amount + * @param ?int $minutes estimated work duration + * @param ?string $amount declared external amount + * + * @return ?string exact estimate when all required inputs exist + */ + private function resourceAmount(string $kind, ?string $quantity, ?string $unitCost, ?int $minutes, ?string $amount): ?string + { + if ('time' === $kind) { + if (null !== $quantity) { + throw MaintenanceCostException::invalid('A time estimate cannot include a material quantity.'); } - $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.'); + $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.'); } - $result[] = ['workItemId' => $workItemId, 'kind' => $kind, 'description' => $this->string($resource['description'] ?? null, 1000), 'quantity' => $quantity, 'unitCost' => $unitCost, 'estimatedMinutes' => $minutes, 'amount' => $amount]; + $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.'); } - return $result; + return $amount; } private function nullableAmount(mixed $value): ?string @@ -228,7 +304,7 @@ private function nullableId(mixed $value): ?string return null; } $id = $this->string($value, 36); - new Uuid($id); + Uuid::assertValid($id); return $id; } diff --git a/src/MaintenanceCost/Application/UseCase/Query/Reporting/ListMaintenanceEconomicDossiers/ListMaintenanceEconomicDossiersHandler.php b/src/MaintenanceCost/Application/UseCase/Query/Reporting/ListMaintenanceEconomicDossiers/ListMaintenanceEconomicDossiersHandler.php index 6b94afba9..38f58ec04 100644 --- a/src/MaintenanceCost/Application/UseCase/Query/Reporting/ListMaintenanceEconomicDossiers/ListMaintenanceEconomicDossiersHandler.php +++ b/src/MaintenanceCost/Application/UseCase/Query/Reporting/ListMaintenanceEconomicDossiers/ListMaintenanceEconomicDossiersHandler.php @@ -4,6 +4,7 @@ namespace MaintenanceCost\Application\UseCase\Query\Reporting\ListMaintenanceEconomicDossiers; +use Intervention\Application\Contract\Publication\InterventionEconomicSourceFilter; use Intervention\Application\Port\Inbound\InterventionPublicationFactsPort; use MaintenanceCost\Application\Service\{MaintenanceCostAccessGuard, MaintenanceEconomicDirectory}; use MaintenanceCost\Domain\Exception\MaintenanceCostException; @@ -35,12 +36,13 @@ public function __invoke(ListMaintenanceEconomicDossiersQuery $query): ListMaint $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); + Uuid::assertValid($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); + $filter = new InterventionEconomicSourceFilter($query->search, $window?->from, $window?->to, $query->siteId, $query->customerId, $query->equipmentId, $financialIds); + $page = $this->work->economicPage($query->organizationId, $query->page, $query->itemsPerPage, $filter); $equipment = []; foreach ($page->items as $context) { $equipment[$context->id] = $this->directory->equipment($query->organizationId, $context->id); diff --git a/src/MaintenanceCost/Application/UseCase/Query/Reporting/ReadMaintenanceEconomicReport/ReadMaintenanceEconomicReportHandler.php b/src/MaintenanceCost/Application/UseCase/Query/Reporting/ReadMaintenanceEconomicReport/ReadMaintenanceEconomicReportHandler.php index 1f054e4e7..1fe27e7e8 100644 --- a/src/MaintenanceCost/Application/UseCase/Query/Reporting/ReadMaintenanceEconomicReport/ReadMaintenanceEconomicReportHandler.php +++ b/src/MaintenanceCost/Application/UseCase/Query/Reporting/ReadMaintenanceEconomicReport/ReadMaintenanceEconomicReportHandler.php @@ -39,7 +39,7 @@ public function __invoke(ReadMaintenanceEconomicReportQuery $query): ReadMainten } foreach ([$query->siteId, $query->customerId, $query->equipmentId] as $identifier) { if (null !== $identifier) { - new Uuid($identifier); + Uuid::assertValid($identifier); } } // Financial direct-material targets need not exist among operational tasks. diff --git a/src/MaintenanceCost/Presentation/Api/EventSubscriber/MaintenanceCostFailureSubscriber.php b/src/MaintenanceCost/Presentation/Api/EventSubscriber/MaintenanceCostFailureSubscriber.php index b29206cb6..26f1ee176 100644 --- a/src/MaintenanceCost/Presentation/Api/EventSubscriber/MaintenanceCostFailureSubscriber.php +++ b/src/MaintenanceCost/Presentation/Api/EventSubscriber/MaintenanceCostFailureSubscriber.php @@ -11,6 +11,7 @@ use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Throwable; use function is_string; use function str_starts_with; @@ -32,7 +33,7 @@ public function onException(ExceptionEvent $event): void $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); + $reason = $this->reason($error, $costOperation); if (null !== $reason) { $status = match ($reason) { 'maintenance_cost_not_found' => 404, @@ -49,4 +50,25 @@ public function onException(ExceptionEvent $event): void $error = $error->getPrevious(); } while (null !== $error); } + + /** + * Method reason + * + * Limits generic validation errors to this module's operations. + * + * @access private + * + * @param Throwable $error failure at the current exception-chain position + * @param bool $costOperation whether this request belongs to the financial surface + * + * @return ?string module error code when owned by this subscriber + */ + private function reason(Throwable $error, bool $costOperation): ?string + { + if ($error instanceof MaintenanceCostException) { + return $error->reason; + } + + return $costOperation && ($error instanceof InvalidValueException || $error instanceof NotNormalizableValueException) ? 'maintenance_cost_invalid' : null; + } } diff --git a/src/MaintenanceCost/Presentation/Api/Processor/Cost/MaintenanceCostProcessor.php b/src/MaintenanceCost/Presentation/Api/Processor/Cost/MaintenanceCostProcessor.php index e2bde27c0..614b9b53b 100644 --- a/src/MaintenanceCost/Presentation/Api/Processor/Cost/MaintenanceCostProcessor.php +++ b/src/MaintenanceCost/Presentation/Api/Processor/Cost/MaintenanceCostProcessor.php @@ -41,7 +41,10 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $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); + $revision = null; + if (null !== $header) { + $revision = 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)); diff --git a/src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportHandler.php b/src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportHandler.php index 6239f8eb5..20b558b5d 100644 --- a/src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportHandler.php +++ b/src/MaintenanceExport/Application/UseCase/Command/ManageMaintenanceExport/ManageMaintenanceExportHandler.php @@ -4,6 +4,7 @@ namespace MaintenanceExport\Application\UseCase\Command\ManageMaintenanceExport; +use DateTimeImmutable; use DateTimeZone; use MaintenanceExport\Application\Contract\ExportOperation; use MaintenanceExport\Application\Port\Outbound\{MaintenanceExportIdentityPort, MaintenanceExportRepositoryPort, MaintenanceExportSourcePort}; @@ -28,6 +29,8 @@ * Authorization, bounded source capture and durable receipts share one main transaction. * * @category Handler + * + * @phpstan-type ExportPayload array{clientOperationId:string,interventionIds:list,system:string,includeInternalCosts:bool,resourceType:string,resourceId:string,reference:string,reason:string,externalImportReference:string} */ final readonly class ManageMaintenanceExportHandler { @@ -62,87 +65,16 @@ public function __invoke(ManageMaintenanceExportCommand $command): ManageMainten 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); + return $this->replay($command, $receipt, $fingerprint, $actor, $org); } $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; - } + $resource = match ($command->action) { + 'reference' => $this->writeReference($command, $org, $payload, $now), + 'confirm' => $this->confirmDocument($command, $org, $actor, $id, $payload, $now), + default => $this->generateDocument($command, $org, $actor, $id, $payload, $now), + }; + $result = $resource->projection(); + $resourceId = $resource->id; $this->repository->saveOperation(new ExportOperation($org, $actor, $operationId, $command->action, $fingerprint, $resourceId, $result)); $this->events->dispatch(new MaintenanceExportChangedEvent($org, $resourceId, $command->action, $now)); @@ -150,52 +82,265 @@ public function __invoke(ManageMaintenanceExportCommand $command): ManageMainten }); } + /** + * Method replay + * + * Original receipt authorization precedes stale revisions and never repeats effects. + * + * @access private + * + * @param ManageMaintenanceExportCommand $command canonical operation intent + * @param ExportOperation $receipt immutable original response + * @param string $fingerprint canonical intent hash + * @param string $actor authorized actor + * @param string $org authorized organization + * + * @return ManageMaintenanceExportResult original authorized metadata + */ + private function replay(ManageMaintenanceExportCommand $command, ExportOperation $receipt, string $fingerprint, string $actor, string $org): ManageMaintenanceExportResult + { + if ($receipt->fingerprint !== $fingerprint || $receipt->action !== $command->action) { + throw MaintenanceExportException::conflict('The operation identifier belongs to another declaration.'); + } + $this->financialAccess($actor, $org, true === ($receipt->result['includeInternalCosts'] ?? false)); + + return new ManageMaintenanceExportResult('reference' === $command->action ? 'reference' : 'export', $receipt->result, true); + } + + /** + * Method writeReference + * + * @access private + * + * @param ManageMaintenanceExportCommand $command optimistic reference mutation + * @param string $org authorized organization + * @param ExportPayload $payload canonical declared mapping + * @param DateTimeImmutable $now transaction timestamp + * + * @return ExternalReference durably saved owner-scoped mapping + */ + private function writeReference(ManageMaintenanceExportCommand $command, string $org, array $payload, DateTimeImmutable $now): ExternalReference + { + $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); + + return $reference; + } + + /** + * Method confirmDocument + * + * @access private + * + * @param ManageMaintenanceExportCommand $command optimistic acknowledgement + * @param string $org authorized organization + * @param string $actor authorized actor + * @param string|null $id scoped artifact identity + * @param ExportPayload $payload canonical import receipt + * @param DateTimeImmutable $now transaction timestamp + * + * @return ExportDocument saved acknowledgement with unchanged artifacts + */ + private function confirmDocument(ManageMaintenanceExportCommand $command, string $org, string $actor, ?string $id, array $payload, DateTimeImmutable $now): ExportDocument + { + $document = $this->document($org, $id); + $this->financialAccess($actor, $org, $document->includeInternalCosts); + $expected = $command->expectedRevision ?? throw MaintenanceExportException::revisionRequired(); + $document->confirm($expected, $payload['clientOperationId'], $payload['externalImportReference'], $actor, $now); + $this->repository->saveDocument($document); + + return $document; + } + + /** + * Method generateDocument + * + * Source capture and saved bytes remain inside the receipt transaction. + * + * @access private + * + * @param ManageMaintenanceExportCommand $command initial or correcting intent + * @param string $org authorized organization + * @param string $actor authorized actor + * @param string|null $id preceding artifact identity + * @param ExportPayload $payload canonical declaration + * @param DateTimeImmutable $now transaction timestamp + * + * @return ExportDocument durably retained original or compensating artifact + */ + private function generateDocument(ManageMaintenanceExportCommand $command, string $org, string $actor, ?string $id, array $payload, DateTimeImmutable $now): ExportDocument + { + $previous = $this->previousDocument($command, $org, $actor, $id); + $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 = $this->retainedBaseline($facts->baseline, $previous, $rows); + $kind = null === $previous ? 'initial' : 'adjustment'; + $original = $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); + + return $document; + } + + /** + * Method previousDocument + * + * Financial authorization precedes revision and chain checks. + * + * @access private + * + * @param ManageMaintenanceExportCommand $command correcting intent + * @param string $org authorized organization + * @param string $actor authorized actor + * @param string|null $id preceding artifact identity + * + * @return ExportDocument|null latest artifact, absent for initial generation + */ + private function previousDocument(ManageMaintenanceExportCommand $command, string $org, string $actor, ?string $id): ?ExportDocument + { + if ('adjustment' !== $command->action) { + return null; + } + $previous = $this->document($org, $id); + $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.'); + } + + return $previous; + } + + /** + * Method retainedBaseline + * + * Subsequent corrections refer to the rows actually introduced by the preceding artifact. + * + * @access private + * + * @param array> $baseline captured source facts + * @param ExportDocument|null $previous preceding retained artifact + * @param list> $rows newly exported rows + * + * @return array> source facts with retained artifact row identities + */ + private function retainedBaseline(array $baseline, ?ExportDocument $previous, array $rows): array + { + 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']; + } + } + + return $baseline; + } + /** * 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 + * @return ExportPayload 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.'); - } + $result = match ($command->action) { + 'create' => [...$result, ...$this->createPayload($payload)], + 'adjustment' => [...$result, 'reason' => ExportIdentity::text($this->text($payload, 'reason'), 1000)], + 'confirm' => [...$result, 'externalImportReference' => ExportIdentity::text($this->text($payload, 'externalImportReference'), 200)], + 'reference' => [...$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)], + default => throw MaintenanceExportException::invalid('Unknown export operation.'), + }; ksort($result); return $result; } + /** + * Method createPayload + * + * @access private + * + * @param array $payload initial declaration + * + * @return array{interventionIds:list,system:string,includeInternalCosts:bool} canonical source selection + */ + private function createPayload(array $payload): array + { + $canonical = $this->interventionIds($payload['interventionIds'] ?? null); + $financial = $payload['includeInternalCosts'] ?? false; + if (!is_bool($financial)) { + throw MaintenanceExportException::invalid('includeInternalCosts must be a boolean.'); + } + + return ['interventionIds' => $canonical, 'system' => ExportIdentity::system($this->text($payload, 'system')), 'includeInternalCosts' => $financial]; + } + + /** + * Method interventionIds + * + * @access private + * + * @param mixed $ids declared source selection + * + * @return list bounded unique normalized and sorted identities + */ + private function interventionIds(mixed $ids): array + { + 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); + + return $canonical; + } + /** * Method text * diff --git a/src/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportHandler.php b/src/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportHandler.php index 54ae290f7..5d14ffc62 100644 --- a/src/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportHandler.php +++ b/src/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportHandler.php @@ -4,6 +4,7 @@ namespace MaintenanceExport\Application\UseCase\Query\ReadMaintenanceExport; +use Intervention\Application\Contract\Publication\InterventionPublicationFacts; use Intervention\Application\Port\Inbound\InterventionPublicationFactsPort; use MaintenanceExport\Application\Port\Outbound\MaintenanceExportRepositoryPort; use MaintenanceExport\Domain\Exception\MaintenanceExportException; @@ -56,37 +57,131 @@ public function __invoke(ReadMaintenanceExportQuery $query): ReadMaintenanceExpo 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 match ($query->action) { + 'sources' => $this->sources($query, $org), + 'references' => $this->references($query, $org, $system), + 'exports' => $this->exports($query, $org, $financial, $system), + default => $this->document($query, $org, $financial), + }; + } - return new ReadMaintenanceExportResult('reference', $items, $this->repository->countReferences($org, $system, $type, $id), $query->page, $query->itemsPerPage, true); + /** + * Method sources + * + * @access private + * + * @param ReadMaintenanceExportQuery $query bounded source selection + * @param string $org authorized organization + * + * @return ReadMaintenanceExportResult original published identities and availability + */ + private function sources(ReadMaintenanceExportQuery $query, string $org): ReadMaintenanceExportResult + { + if (null !== $query->search && mb_strlen($query->search, 'UTF-8') > 160) { + throw MaintenanceExportException::invalid('Source searches may contain at most 160 characters.'); } - 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)); + $page = $this->publications->publishedPage($org, $query->page, $query->itemsPerPage, $query->search); + $items = array_map($this->sourceProjection(...), $page->items); + + return new ReadMaintenanceExportResult('source', $items, $page->totalItems, $page->page, $page->itemsPerPage, true); + } - return new ReadMaintenanceExportResult('export', $items, $this->repository->countDocuments($org, $financial, $system), $query->page, $query->itemsPerPage, true); + /** + * Method sourceProjection + * + * @access private + * + * @param InterventionPublicationFacts $source immutable published metadata + * + * @return array minimal source without contacts + */ + private function sourceProjection(InterventionPublicationFacts $source): array + { + $blocked = $this->blockedReason($source); + + return ['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]; + } + + /** + * Method blockedReason + * + * Missing publication history takes precedence over work validation. + * + * @access private + * + * @param InterventionPublicationFacts $source immutable publication facts + * + * @return string|null explicit reason this source cannot be exported + */ + private function blockedReason(InterventionPublicationFacts $source): ?string + { + if ('available' !== $source->snapshotState || null === $source->publicationId) { + return 'snapshot_missing'; + } + foreach ($source->workItems as $work) { + if ($work->validated) { + return null; + } } + + return 'no_validated_work'; + } + + /** + * Method references + * + * @access private + * + * @param ReadMaintenanceExportQuery $query bounded reference selection + * @param string $org authorized organization + * @param string|null $system canonical ERP filter + * + * @return ReadMaintenanceExportResult scoped mappings and matching count + */ + private function references(ReadMaintenanceExportQuery $query, string $org, ?string $system): ReadMaintenanceExportResult + { + $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); + } + + /** + * Method exports + * + * @access private + * + * @param ReadMaintenanceExportQuery $query bounded archive selection + * @param string $org authorized organization + * @param bool $financial independent cost visibility + * @param string|null $system canonical ERP filter + * + * @return ReadMaintenanceExportResult lightweight metadata without loading artifact bytes + */ + private function exports(ReadMaintenanceExportQuery $query, string $org, bool $financial, ?string $system): ReadMaintenanceExportResult + { + $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); + } + + /** + * Method document + * + * Financial access precedes format validation or any retained-byte disclosure. + * + * @access private + * + * @param ReadMaintenanceExportQuery $query metadata or file read + * @param string $org authorized organization + * @param bool $financial independent cost visibility + * + * @return ReadMaintenanceExportResult verified document metadata or exact bytes + */ + private function document(ReadMaintenanceExportQuery $query, string $org, bool $financial): ReadMaintenanceExportResult + { $id = null === $query->id ? throw MaintenanceExportException::notFound() : ExportIdentity::uuid($query->id); $document = $this->repository->document($org, $id) ?? throw MaintenanceExportException::notFound(); if ($document->includeInternalCosts && !$financial) { diff --git a/src/MaintenanceExport/Domain/ValueObject/ExportArtifact.php b/src/MaintenanceExport/Domain/ValueObject/ExportArtifact.php index 1b5cb5e34..2cc56ad5c 100644 --- a/src/MaintenanceExport/Domain/ValueObject/ExportArtifact.php +++ b/src/MaintenanceExport/Domain/ValueObject/ExportArtifact.php @@ -88,13 +88,7 @@ public static function csv(array $rows, bool $financial): string 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) . '"'; + $cells[] = self::csvCell($row[$column] ?? null, $column); } $lines[] = implode(',', $cells); } @@ -120,6 +114,47 @@ public static function rowId(string $key): string return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-5' . substr($hex, 13, 3) . '-8' . substr($hex, 17, 3) . '-' . substr($hex, 20, 12); } + /** + * Method csvCell + * + * Preserves ASCII decimal columns while neutralizing formulas in textual values. + * + * @access private + * + * @param mixed $value retained column value + * @param string $column ordered CSV column + * + * @return string formula-safe RFC4180 quoted cell + */ + private static function csvCell(mixed $value, string $column): string + { + $cell = self::csvText($value); + $numeric = in_array($column, ['minutes', 'amount', 'sourceRevision', 'publicationRevision', 'evidenceCount'], true) && 1 === preg_match('/^-?\d+(?:\.\d+)?$/', $cell); + if (!$numeric && 1 === preg_match('/^[\x00-\x20]*[=+\-@]/', $cell)) { + $cell = "'" . $cell; + } + + return '"' . str_replace('"', '""', $cell) . '"'; + } + + /** + * Method csvText + * + * @access private + * + * @param mixed $value retained scalar or explicit unknown + * + * @return string exact CSV scalar representation + */ + private static function csvText(mixed $value): string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + return is_string($value) || is_int($value) ? (string) $value : ''; + } + /** * Method canonical * diff --git a/src/MaintenanceExport/Domain/ValueObject/ExportRows.php b/src/MaintenanceExport/Domain/ValueObject/ExportRows.php index e966ca810..618ee34f5 100644 --- a/src/MaintenanceExport/Domain/ValueObject/ExportRows.php +++ b/src/MaintenanceExport/Domain/ValueObject/ExportRows.php @@ -60,26 +60,11 @@ public static function adjustment(array $before, array $after, string $adjustmen 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)) { + if (null !== $old && null !== $new && self::sameFacts($old, $new)) { 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; + $rows[] = self::reversal($old, $adjustmentId, $key); } if (null !== $new) { $rows[] = [...$new, 'id' => ExportArtifact::rowId($adjustmentId . '|add|' . $key), 'change' => 'add', 'correctionOf' => $old['id'] ?? null]; @@ -93,6 +78,51 @@ public static function adjustment(array $before, array $after, string $adjustmen return $rows; } + /** + * Method sameFacts + * + * Artifact row identity and PostgreSQL object ordering cannot invent a correction. + * + * @access private + * + * @param array $before preceding retained fact + * @param array $after current source fact + * + * @return bool canonical facts are unchanged + */ + private static function sameFacts(array $before, array $after): bool + { + unset($before['id'], $after['id']); + + return ExportArtifact::json($before) === ExportArtifact::json($after); + } + + /** + * Method reversal + * + * Unknown amounts remain unknown; known quantities are compensated exactly. + * + * @access private + * + * @param array $row preceding retained row + * @param string $adjustmentId identity of the compensating artifact + * @param string $key stable logical source key + * + * @return array linked compensating row + */ + private static function reversal(array $row, string $adjustmentId, string $key): array + { + $reversal = [...$row, 'id' => ExportArtifact::rowId($adjustmentId . '|reverse|' . $key), 'change' => 'reverse', 'correctionOf' => $row['id']]; + if (is_int($row['minutes'] ?? null)) { + $reversal['minutes'] = -$row['minutes']; + } + if (is_string($row['amount'] ?? null)) { + $reversal['amount'] = DecimalAmount::zero()->subtract(DecimalAmount::fromString($row['amount']))->toString(); + } + + return $reversal; + } + /** * Method assertBounded * diff --git a/src/MaintenanceExport/Infrastructure/Adapter/Export/ExportSourceAdapter.php b/src/MaintenanceExport/Infrastructure/Adapter/Export/ExportSourceAdapter.php index 3d852b860..de22074e5 100644 --- a/src/MaintenanceExport/Infrastructure/Adapter/Export/ExportSourceAdapter.php +++ b/src/MaintenanceExport/Infrastructure/Adapter/Export/ExportSourceAdapter.php @@ -6,6 +6,7 @@ use Intervention\Application\Contract\Publication\{InterventionPublicationFacts, InterventionPublishedWorkFact}; use Intervention\Application\Port\Inbound\{InterventionCostSourceFactsPort, InterventionPublicationFactsPort}; +use MaintenanceCost\Application\Contract\Cost\{MaintenanceCostItem, MaintenanceCostTotals}; use MaintenanceCost\Application\Port\Inbound\MaintenanceCostReadPort; use MaintenanceExport\Application\Contract\ExportSourceState; use MaintenanceExport\Application\Port\Outbound\{MaintenanceExportRepositoryPort, MaintenanceExportSourcePort}; @@ -65,94 +66,22 @@ public function capture(string $organizationId, array $interventionIds, string $ $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.'); - } + $this->assertPublication($publication); $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.'); - } - } + $workIdentities = $this->captureWork($organizationId, $publication, $system, $includeInternalCosts, $timeRows, $baseline, $sourceBytes); 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; + $totals = $this->costTotals($organizationId, $publication, $current); 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']); - } + $row = $this->costRow($organizationId, $system, $identity, $item); 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.'); - } + $this->appendRow($baseline, $sourceBytes, $key, $row); } } ksort($baseline); @@ -160,6 +89,178 @@ public function capture(string $organizationId, array $interventionIds, string $ return new ExportSourceState($baseline, $includeInternalCosts ? 0 === $incomplete : null, $includeInternalCosts ? $incomplete : null); } + /** + * Method assertPublication + * + * @access private + * + * @param InterventionPublicationFacts $publication original retained dossier + * + * @return void rejects missing history or absent validated work + */ + private function assertPublication(InterventionPublicationFacts $publication): void + { + if ('available' !== $publication->snapshotState || null === $publication->dossier || null === $publication->publicationId) { + throw MaintenanceExportException::snapshotMissing(); + } + foreach ($publication->workItems as $work) { + if ($work->validated) { + return; + } + } + + throw MaintenanceExportException::invalid('A selected publication has no validated prestation.'); + } + + /** + * Method captureWork + * + * Skipped task identities remain available to private costs without exporting their prestations. + * + * @access private + * + * @param string $organizationId owning organization + * @param InterventionPublicationFacts $publication verified retained publication + * @param string $system ERP mapping selection + * @param bool $includeInternalCosts retains skipped identities for cost contributions + * @param array> $timeRows selected time journal + * @param array> $baseline accumulated bounded source facts + * @param int $sourceBytes accumulated canonical source byte count + * + * @return array> original identities keyed by work item + */ + private function captureWork(string $organizationId, InterventionPublicationFacts $publication, string $system, bool $includeInternalCosts, array $timeRows, array &$baseline, int &$sourceBytes): array + { + $identities = []; + foreach ($publication->workItems as $work) { + if (!$work->validated && !$includeInternalCosts) { + continue; + } + $identities[$work->id] = $this->workRow($organizationId, $publication, $work, $system); + if (!$work->validated) { + continue; + } + $row = $identities[$work->id]; + $row['timeFacts'] = $timeRows[$work->id] ?? []; + $row['minutes'] = $this->workedMinutes($row['timeFacts']); + $key = 'work:' . $publication->id . ':' . $work->id; + $row['id'] = ExportArtifact::rowId('work|' . $organizationId . '|' . $publication->publicationId . '|' . $work->id); + $this->appendRow($baseline, $sourceBytes, $key, $row); + } + + return $identities; + } + + /** + * Method workedMinutes + * + * @access private + * + * @param list $times frozen or current time facts + * + * @return int uncancelled whole minutes + */ + private function workedMinutes(array $times): int + { + $minutes = 0; + foreach ($times as $time) { + if (!$time['cancelled']) { + $minutes += $time['minutes']; + } + } + + return $minutes; + } + + /** + * Method costTotals + * + * Initial exports require a financial snapshot from the same publication. + * + * @access private + * + * @param string $organizationId owning organization + * @param InterventionPublicationFacts $publication verified retained publication + * @param bool $current selects independent current corrections + * + * @return MaintenanceCostTotals exact private cost contributions + */ + private function costTotals(string $organizationId, InterventionPublicationFacts $publication, bool $current): MaintenanceCostTotals + { + $costs = $this->costs->view($organizationId, $publication->id); + if ($current) { + return $costs->current; + } + $frozen = $costs->frozen ?? throw MaintenanceExportException::snapshotMissing(); + if ($frozen->publicationId !== $publication->publicationId) { + throw MaintenanceExportException::conflict('The retained financial snapshot belongs to another publication.'); + } + + return $frozen->totals; + } + + /** + * Method costRow + * + * @access private + * + * @param string $organizationId owning organization + * @param string $system ERP mapping selection + * @param array $identity retained work identity or original dossier context + * @param MaintenanceCostItem $item exact private contribution + * + * @return array captured cost and independently allocated identity + */ + private function costRow(string $organizationId, string $system, array $identity, MaintenanceCostItem $item): array + { + 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']); + } + + return $row; + } + + /** + * Method appendRow + * + * Every source contribution shares the same cumulative row and byte bounds. + * + * @access private + * + * @param array> $baseline accumulated source facts + * @param int $sourceBytes accumulated canonical source bytes + * @param string $key stable logical source identity + * @param array $row exact captured contribution + * + * @return void appends only within the synchronous artifact limits + */ + private function appendRow(array &$baseline, int &$sourceBytes, string $key, array $row): void + { + $row['logicalSourceKey'] = $key; + $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.'); + } + } + /** * Method timeRows * diff --git a/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Mapper/MaintenanceExportMapper.php b/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Mapper/MaintenanceExportMapper.php new file mode 100644 index 000000000..e37560a22 --- /dev/null +++ b/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Mapper/MaintenanceExportMapper.php @@ -0,0 +1,309 @@ + $row original scoped operation receipt + * + * @return ExportOperation exact accepted response and intent fingerprint + */ + public function operationRow(array $row): ExportOperation + { + return 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 immutableData + * + * @param ExportDocument $document preserved document + * + * @return array immutable SQL values only + */ + public 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 + */ + public 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 + */ + public 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 public + * + * @param array $row projected organization-scoped metadata + * + * @return ExportDocumentSummary saved counts and hashes without rehydrating an artifact + */ + public 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 referenceRow + * + * @param array $row scoped SQL mapping + * + * @return ExternalReference exact optimistic mapping + */ + public 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 time + * + * @param DateTimeImmutable $date source instant + * + * @return string UTC storage instant + */ + public 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 + */ + public function json(mixed $value): string + { + return json_encode($this->canonical($value), JSON_THROW_ON_ERROR); + } + + /** + * Method number + * + * @param mixed $value integer column or aggregate + * + * @return int validated nonnegative integer + */ + public 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 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 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 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/Infrastructure/Persistence/Doctrine/Repository/MaintenanceExportRepository.php b/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Repository/MaintenanceExportRepository.php index a26b0d6f7..6fdd63803 100644 --- a/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Repository/MaintenanceExportRepository.php +++ b/src/MaintenanceExport/Infrastructure/Persistence/Doctrine/Repository/MaintenanceExportRepository.php @@ -4,30 +4,16 @@ namespace MaintenanceExport\Infrastructure\Persistence\Doctrine\Repository; -use DateTimeImmutable; -use DateTimeZone; use Doctrine\DBAL\{Connection, Exception\UniqueConstraintViolationException, ParameterType}; use LogicException; use MaintenanceExport\Application\Contract\{ExportDocumentSummary, ExportOperation}; use MaintenanceExport\Application\Port\Outbound\MaintenanceExportRepositoryPort; use MaintenanceExport\Domain\Exception\MaintenanceExportException; use MaintenanceExport\Domain\Model\{ExportDocument, ExternalReference}; +use MaintenanceExport\Infrastructure\Persistence\Doctrine\Mapper\MaintenanceExportMapper; -use function array_is_list; use function array_map; -use function hash; use function hash_equals; -use function is_array; -use function is_bool; -use function is_int; -use function is_string; -use function json_decode; -use function json_encode; -use function ksort; -use function preg_match; - -use const JSON_THROW_ON_ERROR; -use const SORT_STRING; /** * Class MaintenanceExportRepository @@ -38,6 +24,15 @@ */ final readonly class MaintenanceExportRepository implements MaintenanceExportRepositoryPort { + // #region Properties + /** + * Property mapper + * + * Keeps exact storage encoding and integrity verification in one stateless mapper. + */ + private MaintenanceExportMapper $mapper; + // #endregion + // #region Constructor /** * Method __construct @@ -50,6 +45,7 @@ */ public function __construct(private Connection $connection) { + $this->mapper = new MaintenanceExportMapper(); } // #endregion @@ -89,7 +85,7 @@ 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); + return false === $row ? null : $this->mapper->documentRow($row); } /** @@ -103,20 +99,20 @@ public function saveDocument(ExportDocument $document): void { $this->lock($document->organizationId); $existing = $this->document($document->organizationId, $document->id); - $immutable = $this->immutableData($document); + $immutable = $this->mapper->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]); + $this->connection->insert('maintenance_export_documents', $immutable + ['immutable_hash' => $this->mapper->immutableHash($document), 'revision' => $document->revision(), 'confirmation' => null === $document->confirmation() ? null : $this->mapper->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))) { + if (!hash_equals($this->mapper->immutableHash($existing), $this->mapper->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())) { + if ($existing->revision() === $document->revision() && $this->mapper->json($existing->confirmation()) === $this->mapper->json($document->confirmation())) { return; } if (null !== $existing->confirmation()) { @@ -125,7 +121,7 @@ public function saveDocument(ExportDocument $document): void 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]); + $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->mapper->json($document->confirmation()), 'org' => $document->organizationId, 'id' => $document->id, 'expected' => $existing->revision()], ['revision' => ParameterType::INTEGER, 'expected' => ParameterType::INTEGER]); if (1 !== $updated) { throw MaintenanceExportException::stale(); } @@ -141,7 +137,7 @@ public function saveDocument(ExportDocument $document): void */ 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; + return $this->mapper->number($this->connection->fetchOne('SELECT COUNT(*) FROM maintenance_export_documents WHERE organization_id = :org AND adjustment_of = :id', ['org' => $organizationId, 'id' => $id])) > 0; } /** @@ -160,7 +156,7 @@ public function documentSummaries(string $organizationId, bool $includeFinancial [$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); + return array_map($this->mapper->documentSummaryRow(...), $rows); } /** @@ -176,7 +172,7 @@ public function countDocuments(string $organizationId, bool $includeFinancial, ? { [$where, $params] = $this->documentSelection($organizationId, $includeFinancial, $system); - return $this->number($this->connection->fetchOne('SELECT COUNT(*) FROM maintenance_export_documents WHERE ' . $where, $params)); + return $this->mapper->number($this->connection->fetchOne('SELECT COUNT(*) FROM maintenance_export_documents WHERE ' . $where, $params)); } /** @@ -192,7 +188,7 @@ public function operation(string $organizationId, string $actorId, string $clien { $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'))); + return false === $row ? null : $this->mapper->operationRow($row); } /** @@ -207,7 +203,7 @@ 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)) { + if ($existing->action === $operation->action && $existing->fingerprint === $operation->fingerprint && $existing->resourceId === $operation->resourceId && $this->mapper->json($existing->result) === $this->mapper->json($operation->result)) { return; } @@ -215,7 +211,7 @@ public function saveOperation(ExportOperation $operation): void } 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)]); + $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->mapper->json($operation->result)]); } catch (UniqueConstraintViolationException) { throw MaintenanceExportException::conflict('The operation receipt already exists.'); } @@ -235,7 +231,7 @@ public function reference(string $organizationId, string $system, string $resour { $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); + return false === $row ? null : $this->mapper->referenceRow($row); } /** @@ -255,7 +251,7 @@ public function saveReference(ExternalReference $reference): void } 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)]); + $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->mapper->time($reference->updatedAt)]); } catch (UniqueConstraintViolationException) { throw MaintenanceExportException::conflict('The external reference identity already exists.'); } @@ -265,13 +261,13 @@ public function saveReference(ExternalReference $reference): void 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)) { + if ($existing->revision === $reference->revision && $existing->reference === $reference->reference && $this->mapper->time($existing->updatedAt) === $this->mapper->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]); + $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->mapper->time($reference->updatedAt), 'org' => $reference->organizationId, 'id' => $reference->id, 'expected' => $existing->revision], ['revision' => ParameterType::INTEGER, 'expected' => ParameterType::INTEGER]); if (1 !== $updated) { throw MaintenanceExportException::stale(); } @@ -294,7 +290,7 @@ public function references(string $organizationId, ?string $system, ?string $res [$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); + return array_map($this->mapper->referenceRow(...), $rows); } /** @@ -311,7 +307,7 @@ public function countReferences(string $organizationId, ?string $system, ?string { [$where, $params] = $this->referenceSelection($organizationId, $system, $resourceType, $resourceId); - return $this->number($this->connection->fetchOne('SELECT COUNT(*) FROM maintenance_external_references WHERE ' . $where, $params)); + return $this->mapper->number($this->connection->fetchOne('SELECT COUNT(*) FROM maintenance_external_references WHERE ' . $where, $params)); } /** @@ -329,112 +325,6 @@ private function lock(string $organizationId): void $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 * @@ -480,155 +370,5 @@ private function referenceSelection(string $org, ?string $system, ?string $type, 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 index a20979d9c..2d211319f 100644 --- a/src/MaintenanceExport/MODULE.md +++ b/src/MaintenanceExport/MODULE.md @@ -39,7 +39,7 @@ Generation and import confirmation are separate states (`generated` and `import_ ## 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. +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. Exact SQL restoration and canonical integrity verification share a pure mapper; the repository owns the connection, transaction, scope and optimistic write policy. 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. @@ -53,7 +53,7 @@ Synchronous generation is bounded to 100 selected dossiers, 10000 output rows an ## 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. +Domain tests cover canonical Unicode JSON, exact decimal strings, formula-safe CSV with ASCII-only numeric exceptions, stable identifiers, character limits, compensation, unchanged JSONB facts and immutable confirmations. Handler/transport tests prove permission ordering, no-event rejection, original receipt replay, missing-history precedence in source readiness 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. diff --git a/src/MaintenanceExport/Presentation/Api/Dto/Output/MaintenanceExportSourceOutput.php b/src/MaintenanceExport/Presentation/Api/Dto/Output/MaintenanceExportSourceOutput.php index ce28e2679..246b91b0f 100644 --- a/src/MaintenanceExport/Presentation/Api/Dto/Output/MaintenanceExportSourceOutput.php +++ b/src/MaintenanceExport/Presentation/Api/Dto/Output/MaintenanceExportSourceOutput.php @@ -69,7 +69,7 @@ final class MaintenanceExportSourceOutput * * @return self transport output */ - public static function fromProjection(array $data, bool $replayed = false): self + public static function fromProjection(array $data): 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 diff --git a/src/MaintenanceExport/Presentation/Api/Processor/MaintenanceExportProcessor.php b/src/MaintenanceExport/Presentation/Api/Processor/MaintenanceExportProcessor.php index b74b87b73..bbbfb3cd1 100644 --- a/src/MaintenanceExport/Presentation/Api/Processor/MaintenanceExportProcessor.php +++ b/src/MaintenanceExport/Presentation/Api/Processor/MaintenanceExportProcessor.php @@ -73,15 +73,50 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $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); + $revision = $this->revision($header); + $id = 'reference' === $action ? $payload['resourceId'] : $this->optionalId($uriVariables); /** * @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)); + $result = $this->commands->dispatch(new ManageMaintenanceExportCommand($actor, $this->string($uriVariables, 'organizationId'), $action, $id, $revision, $payload)); return 'reference' === $result->kind ? MaintenanceExportReferenceOutput::fromProjection($result->data, $result->replayed) : MaintenanceExportOutput::fromProjection($result->data, $result->replayed); } + /** + * Method revision + * + * Missing and malformed preconditions retain their distinct command values. + * + * @access private + * + * @param string|null $header supplied If-Match value + * + * @return int|null parsed revision, null when absent or minus one when malformed + */ + private function revision(?string $header): ?int + { + if (null === $header) { + return null; + } + + return 1 === preg_match('/^"revision-(\d+)"$/', $header, $matches) ? (int) $matches[1] : -1; + } + + /** + * Method optionalId + * + * @access private + * + * @param array $variables scoped route + * + * @return string|null supplied document identifier + */ + private function optionalId(array $variables): ?string + { + return is_string($variables['id'] ?? null) ? $variables['id'] : null; + } + /** * Method string * diff --git a/src/MaintenanceExport/Presentation/Api/Resource/MaintenanceExportResource.php b/src/MaintenanceExport/Presentation/Api/Resource/MaintenanceExportResource.php index 75d4c335c..17a042f85 100644 --- a/src/MaintenanceExport/Presentation/Api/Resource/MaintenanceExportResource.php +++ b/src/MaintenanceExport/Presentation/Api/Resource/MaintenanceExportResource.php @@ -24,17 +24,25 @@ normalizationContext:['groups' => ['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')), + 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:[self::OPENAPI_TAG], 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:[self::OPENAPI_TAG], 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:[self::OPENAPI_TAG], 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:[self::OPENAPI_TAG], 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:[self::OPENAPI_TAG], 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:[self::OPENAPI_TAG], 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:[self::OPENAPI_TAG], 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:[self::OPENAPI_TAG], 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:[self::OPENAPI_TAG], summary:'Set an external identity reference with an optimistic revision')), ], )] final class MaintenanceExportResource { + // #region Constants + /** + * Constant OPENAPI_TAG + * + * Groups the retained export and identity mapping operations in the API documentation. + */ + private const string OPENAPI_TAG = 'Maintenance export'; + // #endregion } diff --git a/src/Procurement/Application/Contract/ProcurementReceiptState.php b/src/Procurement/Application/Contract/ProcurementReceiptState.php index e798ee4b2..4d230bc79 100644 --- a/src/Procurement/Application/Contract/ProcurementReceiptState.php +++ b/src/Procurement/Application/Contract/ProcurementReceiptState.php @@ -40,6 +40,10 @@ public function status(): string return 'returned'; } - return 'part' === $this->kind ? 'stock_received' : ([] === $this->equipmentIds ? 'awaiting_individualization' : 'individualized'); + if ('part' === $this->kind) { + return 'stock_received'; + } + + return [] === $this->equipmentIds ? 'awaiting_individualization' : 'individualized'; } } diff --git a/src/Procurement/Application/Service/ProcurementDraftLines.php b/src/Procurement/Application/Service/ProcurementDraftLines.php new file mode 100644 index 000000000..d46d0e2f2 --- /dev/null +++ b/src/Procurement/Application/Service/ProcurementDraftLines.php @@ -0,0 +1,199 @@ + $existing the retained draft snapshots + * + * @return list the validated replacements + */ + public function prepare(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; + } + $descriptors = $this->describeParts($command->organizationId, $input); + $lines = []; + foreach ($input as $fields) { + if (!is_array($fields)) { + throw ProcurementException::invalid('An order line must be an object.'); + } + /** @var array $fields */ + $lines[] = $this->prepareLine($command, $fields, $known, $descriptors); + } + + return $lines; + } + + /** + * Method describeParts + * + * @access private + * + * @param string $organizationId the owning organization + * @param list $lines the submitted lines + * + * @return array the scoped catalog snapshots + */ + private function describeParts(string $organizationId, array $lines): array + { + $partIds = []; + foreach ($lines as $row) { + if (is_array($row) && 'part' === ($row['kind'] ?? null) && is_string($row['partId'] ?? null)) { + $partIds[] = $this->input->uuid($row['partId']); + } + } + + return $this->parts->describeMany($organizationId, array_values(array_unique($partIds))); + } + + /** + * Method prepareLine + * + * @access private + * + * @param ManageProcurementCommand $command the scoped mutation + * @param array $fields the submitted line + * @param array $known the retained lines indexed by identity + * @param array $descriptors the scoped catalog snapshots + * + * @return ProcurementLine the validated replacement snapshot + */ + private function prepareLine(ManageProcurementCommand $command, array $fields, array $known, array $descriptors): ProcurementLine + { + $this->input->fields($fields, ['id', 'kind', 'partId', 'typeCode', 'identityTemplate', 'quantity', 'unitCost']); + $id = array_key_exists('id', $fields) ? $this->input->uuid($this->input->text($fields, 'id')) : $this->ids->generate(); + $kind = $this->input->text($fields, 'kind'); + $partId = $this->input->optionalText($fields, 'partId'); + $partId = null === $partId ? null : $this->input->uuid($partId); + $typeCode = $this->input->optionalText($fields, 'typeCode'); + $descriptor = null === $partId ? null : ($descriptors[$partId] ?? null); + $this->assertCatalogIdentity($command->organizationId, $kind, $typeCode, $descriptor); + $unitCost = $this->unitCost($command, $fields, $known[$id] ?? 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->input->equipmentTemplate($identity); + } + + return ProcurementLine::create($id, new ProcurementGoodsIdentity($kind, $partId, $typeCode, $identity, $descriptor?->code, $descriptor?->label, $descriptor?->unit), $this->input->quantity($fields['quantity'] ?? null), $unitCost); + } + + /** + * Method assertCatalogIdentity + * + * @access private + * + * @param string $organizationId the owning organization + * @param string $kind the submitted line kind + * @param ?string $typeCode the declared equipment type + * @param ?InventoryPartDescriptor $descriptor the scoped part identity + * + * @return void + */ + private function assertCatalogIdentity(string $organizationId, string $kind, ?string $typeCode, ?InventoryPartDescriptor $descriptor): void + { + if ('part' === $kind && (null === $descriptor || $descriptor->archived)) { + throw ProcurementException::notFound(); + } + if ('equipment_to_individualize' === $kind && (null === $typeCode || !$this->equipment->supportsType($organizationId, $typeCode))) { + throw ProcurementException::notFound(); + } + } + + /** + * Method unitCost + * + * Omitted prices retain the existing cost; explicitly clearing a price requires authority. + * + * @access private + * + * @param ManageProcurementCommand $command the scoped mutation + * @param array $fields the submitted line + * @param ?ProcurementLine $existing the retained snapshot, if any + * + * @return ?string the exact price or authorized unknown value + */ + private function unitCost(ManageProcurementCommand $command, array $fields, ?ProcurementLine $existing): ?string + { + if (!array_key_exists('unitCost', $fields)) { + return $existing?->unitCost; + } + $decision = $this->authorization->resolveAccess($command->actorId, $command->organizationId, 'organization.maintenance_cost.manage'); + if (OrganizationAccessDecision::GRANTED !== $decision) { + throw OrganizationAccessDecision::OUTSIDE_SCOPE === $decision ? ProcurementException::notFound() : ProcurementException::denied(); + } + + return $this->input->optionalText($fields, 'unitCost'); + } + // #endregion +} diff --git a/src/Procurement/Application/Service/ProcurementInput.php b/src/Procurement/Application/Service/ProcurementInput.php new file mode 100644 index 000000000..553b68383 --- /dev/null +++ b/src/Procurement/Application/Service/ProcurementInput.php @@ -0,0 +1,264 @@ + $payload + * @param list $allowed + * + * @return void + */ + public 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); + } + } + } + + /** + * Method text + * + * Requires a submitted string without coercing transport values. + * + * @access public + * + * @param array $payload + * @param string $field the field value + * + * @return string + */ + public 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; + } + + /** + * Method optionalText + * + * Distinguishes an explicit nullable value from a required string. + * + * @access public + * + * @param array $payload + * @param string $field the field value + * + * @return ?string + */ + public function optionalText(array $payload, string $field): ?string + { + if (!array_key_exists($field, $payload) || null === $payload[$field]) { + return null; + } + + return $this->text($payload, $field); + } + + /** + * Method textOr + * + * Retains the previous value when a patch omits the field. + * + * @access public + * + * @param array $payload + * @param string $field the field value + * @param string $fallback the fallback value + * + * @return string + */ + public function textOr(array $payload, string $field, string $fallback): string + { + return array_key_exists($field, $payload) ? $this->text($payload, $field) : $fallback; + } + + /** + * Method optionalOr + * + * Preserves patch omission separately from an explicit clear. + * + * @access public + * + * @param array $payload + * @param string $field the field value + * @param ?string $fallback the fallback value + * + * @return ?string + */ + public function optionalOr(array $payload, string $field, ?string $fallback): ?string + { + return array_key_exists($field, $payload) ? $this->optionalText($payload, $field) : $fallback; + } + + /** + * Method quantity + * + * Requires a positive exact decimal string within the procurement quantity limit. + * + * @access public + * + * @param mixed $quantity the quantity value + * + * @return string + */ + public 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(); + } + + /** + * Method uuid + * + * Validates and normalizes identifier spelling for scoped lookups and replay. + * + * @access public + * + * @param string $id the id value + * + * @return string + */ + public function uuid(string $id): string + { + return strtolower(new Uuid($id)->value); + } + + /** + * Method date + * + * Requires a valid physical timestamp with an explicit offset. + * + * @access public + * + * @param string $value the value value + * + * @return DateTimeImmutable + */ + public 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; + } + + /** + * Method contacts + * + * Decodes declarative contact fields without coercing their values. + * + * @access public + * + * @param mixed $input the input value + * + * @return list + */ + public 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; + } + + /** + * Method equipmentTemplate + * + * Decodes only supported equipment identity and technical property fields. + * + * @access public + * + * @param array $input + * + * @return array{name?:?string,brand?:?string,model?:?string,subType?:?string,serialNumber?:?string,assetCode?:?string,criticality?:?string,technicalProperties?:list} + */ + public 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; + } + // #endregion +} diff --git a/src/Procurement/Application/Service/ProcurementOperationReplay.php b/src/Procurement/Application/Service/ProcurementOperationReplay.php new file mode 100644 index 000000000..7ebe67616 --- /dev/null +++ b/src/Procurement/Application/Service/ProcurementOperationReplay.php @@ -0,0 +1,308 @@ +creationOperationId($command); + if (null === $operationId) { + return null; + } + + return $this->replay($command, $operationId, $command->action, $this->creationFingerprint($command), $finance); + } + + /** + * Method recordCreation + * + * Retains the created resource identity in the same transaction as its resource. + * + * @access public + * + * @param ManageProcurementCommand $command the command value + * @param ManageProcurementResult $result the result value + * + * @return void + */ + public function recordCreation(ManageProcurementCommand $command, ManageProcurementResult $result): void + { + $operationId = $this->creationOperationId($command); + if (null === $operationId) { + return; + } + $resourceId = $result->data['id'] ?? null; + if (!is_string($resourceId)) { + throw ProcurementException::conflict('The created resource has no durable identity.'); + } + $this->repository->saveOperation(new ProcurementOperationState($command->organizationId, $operationId, $command->action, $this->creationFingerprint($command), $resourceId, $command->payload + ['actorId' => $command->actorId])); + } + + /** + * Method operationId + * + * Validates the stable client operation identity. + * + * @access public + * + * @param ManageProcurementCommand $command the command value + * + * @return string + */ + public function operationId(ManageProcurementCommand $command): string + { + return $this->input->uuid($this->input->text($command->payload, 'clientOperationId')); + } + + /** + * Method replay + * + * Rejects a changed declaration before resolving its retained resource projection. + * + * @access public + * + * @param ManageProcurementCommand $command the command value + * @param string $operationId the operationId value + * @param string $kind the kind value + * @param string $fingerprint the fingerprint value + * @param bool $finance the finance value + * + * @return ?ManageProcurementResult + */ + public 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.'); + } + + return match ($kind) { + 'create_supplier' => $this->supplierReplay($command, $operation), + 'create_order' => $this->orderReplay($command, $operation, $finance), + 'reconcile_return' => $this->returnReplay($command, $operation), + default => $this->receiptReplay($command, $operation, $finance), + }; + } + + /** + * Method fingerprint + * + * Keeps the exact original PHP JSON serialization used by retained operation hashes. + * + * @access public + * + * @param array $values + * + * @return string + */ + public function fingerprint(array $values): string + { + return hash('sha256', json_encode($values, JSON_THROW_ON_ERROR)); + } + + /** + * Method supplierReplay + * + * Projects the retained supplier without repeating creation effects. + * + * @access private + * + * @param ManageProcurementCommand $command the command value + * @param ProcurementOperationState $operation the operation value + * + * @return ManageProcurementResult + */ + private function supplierReplay(ManageProcurementCommand $command, ProcurementOperationState $operation): ManageProcurementResult + { + $supplier = $this->repository->supplier($command->organizationId, $operation->receiptId) ?? throw ProcurementException::conflict('The saved creation has no supplier.'); + + return new ManageProcurementResult('supplier', $this->projection->supplier($supplier), true); + } + + /** + * Method orderReplay + * + * Projects the retained order with the caller's financial visibility. + * + * @access private + * + * @param ManageProcurementCommand $command the command value + * @param ProcurementOperationState $operation the operation value + * @param bool $finance the finance value + * + * @return ManageProcurementResult + */ + private function orderReplay(ManageProcurementCommand $command, ProcurementOperationState $operation, bool $finance): ManageProcurementResult + { + $order = $this->repository->order($command->organizationId, $operation->receiptId) ?? throw ProcurementException::conflict('The saved creation has no order.'); + + return new ManageProcurementResult('order', $this->projection->order($order, $finance), true); + } + + /** + * Method returnReplay + * + * Projects the retained reconciliation declaration without repeating stock effects. + * + * @access private + * + * @param ManageProcurementCommand $command the command value + * @param ProcurementOperationState $operation the operation value + * + * @return ManageProcurementResult + */ + private function returnReplay(ManageProcurementCommand $command, ProcurementOperationState $operation): ManageProcurementResult + { + $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); + } + + /** + * Method receiptReplay + * + * Projects the retained physical receipt without another stock or equipment effect. + * + * @access private + * + * @param ManageProcurementCommand $command the command value + * @param ProcurementOperationState $operation the operation value + * @param bool $finance the finance value + * + * @return ManageProcurementResult + */ + private function receiptReplay(ManageProcurementCommand $command, ProcurementOperationState $operation, bool $finance): ManageProcurementResult + { + $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); + } + + /** + * Method creationOperationId + * + * Retains compatibility with older creation callers that omit an operation key. + * + * @access private + * + * @param ManageProcurementCommand $command the command value + * + * @return ?string + */ + private function creationOperationId(ManageProcurementCommand $command): ?string + { + if (!in_array($command->action, ['create_supplier', 'create_order'], true) || null === ($command->payload['clientOperationId'] ?? null)) { + return null; + } + + return $this->operationId($command); + } + + /** + * Method creationFingerprint + * + * Excludes the transport operation key from the submitted creation declaration. + * + * @access private + * + * @param ManageProcurementCommand $command the command value + * + * @return string + */ + private function creationFingerprint(ManageProcurementCommand $command): string + { + $payload = $command->payload; + unset($payload['clientOperationId']); + + return $this->fingerprint($this->creationPayload($payload)); + } + + /** + * Method creationPayload + * + * Keeps object-key order and UUID spelling from changing a retained creation's identity. + * Generated resource and line UUIDs are deliberately absent from the submitted fingerprint. + * + * @access private + * + * @param array $payload the submitted creation values + * + * @return array the deterministic submitted values + */ + private function creationPayload(array $payload): array + { + foreach ($payload as $field => $value) { + if (is_array($value)) { + $payload[$field] = $this->creationPayload($value); + } elseif (is_string($value) && in_array($field, ['id', 'supplierId', 'partId'], true)) { + $payload[$field] = $this->input->uuid($value); + } + } + if (!array_is_list($payload)) { + ksort($payload); + } + + return $payload; + } + // #endregion +} diff --git a/src/Procurement/Application/Service/ProcurementProjection.php b/src/Procurement/Application/Service/ProcurementProjection.php index f024ca8ea..72b622d51 100644 --- a/src/Procurement/Application/Service/ProcurementProjection.php +++ b/src/Procurement/Application/Service/ProcurementProjection.php @@ -14,6 +14,12 @@ final readonly class ProcurementProjection { /** + * Method supplier + * + * @access public + * + * @param Supplier $supplier the supplier value + * * @return array */ public function supplier(Supplier $supplier): array @@ -22,6 +28,13 @@ public function supplier(Supplier $supplier): array } /** + * Method order + * + * @access public + * + * @param PurchaseOrder $order the order value + * @param bool $financialVisible the financialVisible value + * * @return array */ public function order(PurchaseOrder $order, bool $financialVisible): array @@ -35,6 +48,13 @@ public function order(PurchaseOrder $order, bool $financialVisible): array } /** + * Method receipt + * + * @access public + * + * @param ProcurementReceiptState $receipt the receipt value + * @param bool $financialVisible the financialVisible value + * * @return array */ public function receipt(ProcurementReceiptState $receipt, bool $financialVisible): array @@ -49,6 +69,12 @@ public function receipt(ProcurementReceiptState $receipt, bool $financialVisible } /** + * Method returnDeclaration + * + * @access public + * + * @param ProcurementReturnState $return the return value + * * @return array */ public function returnDeclaration(ProcurementReturnState $return): array @@ -57,6 +83,13 @@ public function returnDeclaration(ProcurementReturnState $return): array } /** + * Method line + * + * @access private + * + * @param ProcurementLine $line the line value + * @param bool $financialVisible the financialVisible value + * * @return array */ private function line(ProcurementLine $line, bool $financialVisible): array diff --git a/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandler.php b/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandler.php index 5b2158bfb..00003fb50 100644 --- a/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandler.php +++ b/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandler.php @@ -4,8 +4,6 @@ namespace Procurement\Application\UseCase\Command\ManageProcurement; -use DateMalformedStringException; -use DateTimeImmutable; use DateTimeZone; use Equipment\Application\Contract\Procurement\EquipmentReserveReceiptRequest; use Equipment\Application\Port\Inbound\EquipmentReserveReceiptPort; @@ -16,36 +14,58 @@ use Organization\Application\Port\Inbound\OrganizationAuthorizationPort; use Procurement\Application\Contract\{ProcurementOperationState, ProcurementReceiptState, ProcurementReturnState}; use Procurement\Application\Port\Outbound\Persistence\ProcurementRepositoryPort; -use Procurement\Application\Service\ProcurementProjection; +use Procurement\Application\Service\{ProcurementDraftLines, ProcurementInput, ProcurementOperationReplay, ProcurementProjection}; use Procurement\Domain\Event\ProcurementChangedEvent; use Procurement\Domain\Exception\ProcurementException; use Procurement\Domain\Model\{PurchaseOrder, Supplier}; -use Procurement\Domain\ValueObject\ProcurementLine; +use Procurement\Domain\ValueObject\{ProcurementLine, SupplierDetails}; use Shared\Application\Message\CommandHandler; use Shared\Application\Port\Outbound\{ClockPort, EventDispatcherPort, UuidGeneratorPort}; -use Shared\Domain\ValueObject\{DecimalAmount, Uuid}; +use Shared\Domain\ValueObject\DecimalAmount; -use function array_is_list; use function array_key_exists; -use function array_unique; -use function array_values; use function count; -use function hash; -use function in_array; -use function is_array; use function is_string; -use function json_encode; -use function ksort; -use function preg_match; use function strlen; use function strtolower; use function trim; -use const JSON_THROW_ON_ERROR; - /** Reserves remaining quantities and stock receipts atomically in the main database. */ final readonly class ManageProcurementHandler implements CommandHandler { + /** + * Property input + */ + private ProcurementInput $input; + + /** + * Property draftLines + */ + private ProcurementDraftLines $draftLines; + + /** + * Property operations + */ + private ProcurementOperationReplay $operations; + + /** + * Method __construct + * + * @access public + * + * @param ProcurementRepositoryPort $repository the repository value + * @param OrganizationAuthorizationPort $authorization the authorization value + * @param MaintenanceCurrencyPort $currencies the currencies value + * @param InventoryPartDirectoryPort $parts the parts value + * @param InventoryStockReceiptPort $stock the stock value + * @param EquipmentReserveReceiptPort $equipment the equipment value + * @param ProcurementProjection $projection the projection value + * @param ClockPort $clock the clock value + * @param UuidGeneratorPort $ids the ids value + * @param EventDispatcherPort $events the events value + * + * @return void + */ public function __construct( private ProcurementRepositoryPort $repository, private OrganizationAuthorizationPort $authorization, @@ -58,8 +78,22 @@ public function __construct( private UuidGeneratorPort $ids, private EventDispatcherPort $events, ) { + $this->input = new ProcurementInput(); + $this->draftLines = new ProcurementDraftLines($authorization, $parts, $equipment, $ids, $this->input); + $this->operations = new ProcurementOperationReplay($repository, $projection, $this->input); } + /** + * Method __invoke + * + * Runs each authorized mutation and its retained operation under one organization lock. + * + * @access public + * + * @param ManageProcurementCommand $command the command value + * + * @return ManageProcurementResult + */ public function __invoke(ManageProcurementCommand $command): ManageProcurementResult { $command = new ManageProcurementCommand(strtolower($command->actorId), strtolower($command->organizationId), $command->action, null === $command->id ? null : strtolower($command->id), $command->expectedRevision, $command->payload); @@ -70,16 +104,9 @@ public function __invoke(ManageProcurementCommand $command): ManageProcurementRe $finance = $this->authorization->hasPermission($command->actorId, $command->organizationId, 'organization.maintenance_cost.read'); return $this->repository->synchronized($command->organizationId, function () use ($command, $finance): ManageProcurementResult { - $creation = in_array($command->action, ['create_supplier', 'create_order'], true); - $operationId = $creation && null !== ($command->payload['clientOperationId'] ?? null) ? $this->operationId($command) : null; - $creationPayload = $command->payload; - unset($creationPayload['clientOperationId']); - $fingerprint = null === $operationId ? '' : $this->fingerprint($this->creationPayload($creationPayload)); - if (null !== $operationId) { - $replay = $this->replay($command, $operationId, $command->action, $fingerprint, $finance); - if (null !== $replay) { - return $replay; - } + $replay = $this->operations->creationReplay($command, $finance); + if (null !== $replay) { + return $replay; } $result = match ($command->action) { 'create_supplier', 'change_supplier', 'archive_supplier' => $this->supplier($command), @@ -90,13 +117,7 @@ public function __invoke(ManageProcurementCommand $command): ManageProcurementRe 'reconcile_return' => $this->reconcileReturn($command, $finance), default => throw ProcurementException::invalid('Unknown procurement mutation.'), }; - if (null !== $operationId) { - $resourceId = $result->data['id'] ?? null; - if (!is_string($resourceId)) { - throw ProcurementException::conflict('The created resource has no durable identity.'); - } - $this->repository->saveOperation(new ProcurementOperationState($command->organizationId, $operationId, $command->action, $fingerprint, $resourceId, $command->payload + ['actorId' => $command->actorId])); - } + $this->operations->recordCreation($command, $result); if (!$result->replayed) { $resourceId = $result->data['id'] ?? null; if (is_string($resourceId)) { @@ -108,19 +129,28 @@ public function __invoke(ManageProcurementCommand $command): ManageProcurementRe }); } + /** + * Method supplier + * + * @access private + * + * @param ManageProcurementCommand $command the command value + * + * @return ManageProcurementResult + */ private function supplier(ManageProcurementCommand $command): ManageProcurementResult { - $this->fields($command->payload, 'create_supplier' === $command->action ? ['name', 'code', 'email', 'phone', 'contacts', 'clientOperationId'] : ['name', 'code', 'email', 'phone', 'contacts']); + $this->input->fields($command->payload, 'create_supplier' === $command->action ? ['name', 'code', 'email', 'phone', 'contacts', 'clientOperationId'] : ['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); + $supplier = Supplier::create($this->ids->generate(), $command->organizationId, new SupplierDetails($this->input->text($command->payload, 'name'), $this->input->optionalText($command->payload, 'code'), $this->input->optionalText($command->payload, 'email'), $this->input->optionalText($command->payload, 'phone'), $this->input->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); + $supplier->change($revision, $this->input->textOr($command->payload, 'name', $supplier->name()), $this->input->optionalOr($command->payload, 'code', $supplier->code()), $this->input->optionalOr($command->payload, 'email', $supplier->email()), $this->input->optionalOr($command->payload, 'phone', $supplier->phone()), $this->input->contacts($command->payload['contacts'] ?? $supplier->contacts()), $now); } } $this->repository->saveSupplier($supplier); @@ -128,14 +158,24 @@ private function supplier(ManageProcurementCommand $command): ManageProcurementR return new ManageProcurementResult('supplier', $this->projection->supplier($supplier)); } + /** + * Method order + * + * @access private + * + * @param ManageProcurementCommand $command the command value + * @param bool $finance the finance value + * + * @return ManageProcurementResult + */ private function order(ManageProcurementCommand $command, bool $finance): ManageProcurementResult { - $this->fields($command->payload, 'create_order' === $command->action ? ['name', 'supplierId', 'lines', 'clientOperationId'] : ['name', 'supplierId', 'lines']); + $this->input->fields($command->payload, 'create_order' === $command->action ? ['name', 'supplierId', 'lines', 'clientOperationId'] : ['name', 'supplierId', 'lines']); $now = $this->clock->now(); if ('create_order' === $command->action) { - $supplierId = $this->uuid($this->text($command->payload, 'supplierId')); + $supplierId = $this->input->uuid($this->input->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); + $order = PurchaseOrder::create($this->ids->generate(), $command->organizationId, $supplierId, $this->currencies->lock($command->organizationId), $this->input->text($command->payload, 'name'), $this->draftLines->prepare($command, $command->payload['lines'] ?? [], []), $now); } else { $order = $this->repository->order($command->organizationId, $command->id ?? '') ?? throw ProcurementException::notFound(); $revision = $this->revision($command); @@ -145,10 +185,10 @@ private function order(ManageProcurementCommand $command, bool $finance): Manage } elseif ('cancel_remaining' === $command->action) { $order->cancelRemaining($revision, $now); } else { - $supplierId = $this->uuid($this->textOr($command->payload, 'supplierId', $order->supplierId())); + $supplierId = $this->input->uuid($this->input->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); + $lines = array_key_exists('lines', $command->payload) ? $this->draftLines->prepare($command, $command->payload['lines'], $order->lines()) : $order->lines(); + $order->changeDraft($revision, $supplierId, $order->currency(), $this->input->textOr($command->payload, 'name', $order->name()), $lines, $now); } } $this->repository->saveOrder($order); @@ -156,32 +196,42 @@ private function order(ManageProcurementCommand $command, bool $finance): Manage return new ManageProcurementResult('order', $this->projection->order($order, $finance)); } + /** + * Method receive + * + * @access private + * + * @param ManageProcurementCommand $command the command value + * @param bool $finance the finance value + * + * @return ManageProcurementResult + */ private function receive(ManageProcurementCommand $command, bool $finance): ManageProcurementResult { - $this->fields($command->payload, ['lineId', 'warehouseId', 'quantity', 'receivedAt', 'clientOperationId']); + $this->input->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')); + $lineId = $this->input->uuid($this->input->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')); + $quantity = $this->input->quantity($command->payload['quantity'] ?? null); + $warehouseId = $this->input->optionalText($command->payload, 'warehouseId'); + $warehouseId = null === $warehouseId ? null : $this->input->uuid($warehouseId); + $receivedAt = $this->input->date($this->input->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); + $operationId = $this->operations->operationId($command); + $fingerprint = $this->operations->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->operations->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)))) { + if ('part' === $line->kind && (null === $warehouseId || !$this->parts->warehouseExistsActive($command->organizationId, $this->input->uuid($warehouseId)))) { throw ProcurementException::notFound(); } $order->recordReceipt($this->revision($command), $lineId, $quantity, $this->clock->now()); @@ -201,13 +251,23 @@ private function receive(ManageProcurementCommand $command, bool $finance): Mana return new ManageProcurementResult('receipt', $this->projection->receipt($receipt, $finance)); } + /** + * Method individualize + * + * @access private + * + * @param ManageProcurementCommand $command the command value + * @param bool $finance the finance value + * + * @return ManageProcurementResult + */ private function individualize(ManageProcurementCommand $command, bool $finance): ManageProcurementResult { - $this->fields($command->payload, ['clientOperationId']); + $this->input->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); + $operationId = $this->operations->operationId($command); + $fingerprint = $this->operations->fingerprint(['receiptId' => $receipt->id]); + $replay = $this->operations->replay($command, $operationId, 'individualize', $fingerprint, $finance); if (null !== $replay) { return $replay; } @@ -219,7 +279,7 @@ private function individualize(ManageProcurementCommand $command, bool $finance) 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())); + $result = $this->equipment->reserve(new EquipmentReserveReceiptRequest($command->organizationId, $command->actorId, $line->typeCode ?? throw ProcurementException::invalid('Missing equipment type.'), $this->input->equipmentTemplate($line->identityTemplate), $remaining->toInt())); $receipt->blockedReason = $result->blockedReason; if (null === $result->blockedReason) { if (count($result->equipmentIds) !== $remaining->toInt()) { @@ -235,21 +295,31 @@ private function individualize(ManageProcurementCommand $command, bool $finance) return new ManageProcurementResult('receipt', $this->projection->receipt($receipt, $finance)); } + /** + * Method returnReceipt + * + * @access private + * + * @param ManageProcurementCommand $command the command value + * @param bool $finance the finance value + * + * @return ManageProcurementResult + */ private function returnReceipt(ManageProcurementCommand $command, bool $finance): ManageProcurementResult { - $this->fields($command->payload, ['quantity', 'reason', 'clientOperationId']); + $this->input->fields($command->payload, ['quantity', 'reason', 'clientOperationId']); $receipt = $this->repository->receipt($command->organizationId, $command->id ?? '') ?? throw ProcurementException::notFound(); - $quantity = $this->quantity($command->payload['quantity'] ?? null); + $quantity = $this->input->quantity($command->payload['quantity'] ?? null); if ('part' === $receipt->kind) { $this->access($command, 'organization.inventory.manage'); } - $reason = trim($this->text($command->payload, 'reason')); + $reason = trim($this->input->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); + $operationId = $this->operations->operationId($command); + $fingerprint = $this->operations->fingerprint(['receiptId' => $receipt->id, 'quantity' => $quantity, 'reason' => $reason]); + $replay = $this->operations->replay($command, $operationId, 'return', $fingerprint, $finance); if (null !== $replay) { return $replay; } @@ -291,17 +361,27 @@ private function returnReceipt(ManageProcurementCommand $command, bool $finance) return new ManageProcurementResult('receipt', $this->projection->receipt($receipt, $finance)); } + /** + * Method reconcileReturn + * + * @access private + * + * @param ManageProcurementCommand $command the command value + * @param bool $finance the finance value + * + * @return ManageProcurementResult + */ private function reconcileReturn(ManageProcurementCommand $command, bool $finance): ManageProcurementResult { - $this->fields($command->payload, ['clientOperationId']); + $this->input->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); + $operationId = $this->operations->operationId($command); + $fingerprint = $this->operations->fingerprint(['returnId' => $return->id]); + $replay = $this->operations->replay($command, $operationId, 'reconcile_return', $fingerprint, $finance); if (null !== $replay) { return $replay; } @@ -329,65 +409,15 @@ private function reconcileReturn(ManageProcurementCommand $command, bool $financ } /** - * @param list $existing + * Method access + * + * @access private + * + * @param ManageProcurementCommand $command the command value + * @param string $permission the permission value * - * @return list + * @return void */ - 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); @@ -396,6 +426,16 @@ private function access(ManageProcurementCommand $command, string $permission): } } + /** + * Method activeSupplier + * + * @access private + * + * @param string $organizationId the organizationId value + * @param string $supplierId the supplierId value + * + * @return void + */ private function activeSupplier(string $organizationId, string $supplierId): void { $supplier = $this->repository->supplier($organizationId, $supplierId) ?? throw ProcurementException::notFound(); @@ -404,238 +444,55 @@ private function activeSupplier(string $organizationId, string $supplierId): voi } } - 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 ('create_supplier' === $kind) { - $supplier = $this->repository->supplier($command->organizationId, $operation->receiptId) ?? throw ProcurementException::conflict('The saved creation has no supplier.'); - - return new ManageProcurementResult('supplier', $this->projection->supplier($supplier), true); - } - if ('create_order' === $kind) { - $order = $this->repository->order($command->organizationId, $operation->receiptId) ?? throw ProcurementException::conflict('The saved creation has no order.'); - - return new ManageProcurementResult('order', $this->projection->order($order, $finance), true); - } - 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)); - } - - /** - * Method creationPayload - * - * Keeps object-key order and UUID spelling from changing a retained creation's identity. - * Generated resource and line UUIDs are deliberately absent from the submitted fingerprint. + * Method revision * * @access private * - * @param array $payload the submitted creation values + * @param ManageProcurementCommand $command the command value * - * @return array the deterministic submitted values - */ - private function creationPayload(array $payload): array - { - foreach ($payload as $field => $value) { - if (is_array($value)) { - $payload[$field] = $this->creationPayload($value); - } elseif (is_string($value) && in_array($field, ['id', 'supplierId', 'partId'], true)) { - $payload[$field] = $this->uuid($value); - } - } - if (!array_is_list($payload)) { - ksort($payload); - } - - return $payload; - } - - /** - * @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 + * @return int */ - 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 + private function revision(ManageProcurementCommand $command): int { - 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 $command->expectedRevision ?? throw ProcurementException::revisionRequired(); } /** - * @return list + * Method receiptRevision + * + * @access private + * + * @param ManageProcurementCommand $command the command value + * @param ProcurementReceiptState $receipt the receipt value + * + * @return void */ - private function contacts(mixed $input): array + private function receiptRevision(ManageProcurementCommand $command, ProcurementReceiptState $receipt): void { - 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')]; + if ($this->revision($command) !== $receipt->revision) { + throw ProcurementException::stale(); } - - return $contacts; } /** - * @param array $input + * Method lineById + * + * @access private + * + * @param PurchaseOrder $order the order value + * @param string $id the id value * - * @return array{name?:?string,brand?:?string,model?:?string,subType?:?string,serialNumber?:?string,assetCode?:?string,criticality?:?string,technicalProperties?:list} + * @return ProcurementLine */ - private function equipmentTemplate(array $input): array + private function lineById(PurchaseOrder $order, string $id): ProcurementLine { - $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')]; + foreach ($order->lines() as $line) { + if ($line->id === $id) { + return $line; } - $output['technicalProperties'] = $rows; } - return $output; + throw ProcurementException::notFound(); } } diff --git a/src/Procurement/Domain/Model/PurchaseOrder.php b/src/Procurement/Domain/Model/PurchaseOrder.php index 8b542d47e..fc7890e0e 100644 --- a/src/Procurement/Domain/Model/PurchaseOrder.php +++ b/src/Procurement/Domain/Model/PurchaseOrder.php @@ -6,16 +6,10 @@ use DateTimeImmutable; use Procurement\Domain\Exception\ProcurementException; -use Procurement\Domain\ValueObject\{ProcurementLine, PurchaseOrderStatus}; -use Shared\Domain\ValueObject\{DecimalAmount, Uuid}; +use Procurement\Domain\ValueObject\{ProcurementLine, PurchaseOrderHistory, PurchaseOrderIdentity, PurchaseOrderLines, PurchaseOrderStatus}; +use Shared\Domain\ValueObject\Uuid; -use function array_is_list; -use function array_values; -use function count; use function in_array; -use function mb_strlen; -use function preg_match; -use function trim; /** * Class PurchaseOrder @@ -27,12 +21,46 @@ final class PurchaseOrder { // #region Properties + /** + * Constant ZERO_QUANTITY + * + * Uses the exact canonical quantity spelling retained in line snapshots. + */ + private const string ZERO_QUANTITY = '0.000000'; + + /** + * Property createdAt + */ + public readonly DateTimeImmutable $createdAt; + /** * Property lines * - * @var list + * Holds immutable validated snapshots of the retained line quantities. + */ + private PurchaseOrderLines $lines; + + /** + * Property identity + * + * Holds draft values adopted together after validation. */ - private array $lines; + private PurchaseOrderIdentity $identity; + + /** + * Property updatedAt + */ + private DateTimeImmutable $updatedAt; + + /** + * Property status + */ + private PurchaseOrderStatus $status; + + /** + * Property revision + */ + private int $revision; // #endregion // #region Constructor @@ -45,39 +73,22 @@ final class PurchaseOrder * * @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 + * @param PurchaseOrderLines $lines the immutable line snapshots + * @param PurchaseOrderIdentity $identity the identity value + * @param PurchaseOrderHistory $history the history value * * @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); + private function __construct(public readonly string $id, public readonly string $organizationId, PurchaseOrderIdentity $identity, PurchaseOrderLines $lines, PurchaseOrderHistory $history) + { + Uuid::assertValid($id); + Uuid::assertValid($organizationId); + $this->identity = $identity; + $this->lines = $lines; + $this->status = $history->status; + $this->revision = $history->revision; + $this->createdAt = $history->createdAt; + $this->updatedAt = $history->updatedAt; $this->assertRestoredStatus(); } // #endregion @@ -102,7 +113,7 @@ private function __construct( */ 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); + return new self($id, $organizationId, new PurchaseOrderIdentity($supplierId, $currency, $name), new PurchaseOrderLines($lines), new PurchaseOrderHistory(PurchaseOrderStatus::DRAFT, 1, $now, $now)); } /** @@ -114,20 +125,15 @@ public static function create(string $id, string $organizationId, string $suppli * * @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 + * @param PurchaseOrderLines $lines the persisted line snapshots + * @param PurchaseOrderIdentity $identity the identity value + * @param PurchaseOrderHistory $history the history value * * @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 + public static function reconstitute(string $id, string $organizationId, PurchaseOrderIdentity $identity, PurchaseOrderLines $lines, PurchaseOrderHistory $history): self { - return new self($id, $organizationId, $supplierId, $currency, $name, $lines, $status, $revision, $createdAt, $updatedAt); + return new self($id, $organizationId, $identity, $lines, $history); } /** @@ -154,19 +160,15 @@ public function changeDraft(int $expectedRevision, string $supplierId, string $c 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) { + $identity = new PurchaseOrderIdentity($supplierId, $currency, $name); + $lines = new PurchaseOrderLines($lines); + foreach ($lines->values() as $line) { + if (self::ZERO_QUANTITY !== $line->receivedQuantity || self::ZERO_QUANTITY !== $line->returnedQuantity) { throw ProcurementException::invalid('Draft lines cannot contain receipt or return history.'); } } - $this->supplierId = $supplierId; - $this->currency = $currency; - $this->name = $name; + $this->identity = $identity; $this->lines = $lines; $this->touch($now); } @@ -187,7 +189,7 @@ public function order(int $expectedRevision, DateTimeImmutable $now): void { $this->assertRevision($expectedRevision); $this->assertTime($now); - if (PurchaseOrderStatus::DRAFT !== $this->status || [] === $this->lines) { + if (PurchaseOrderStatus::DRAFT !== $this->status || [] === $this->lines->values()) { throw ProcurementException::conflict('Ordering requires a nonempty draft.'); } @@ -245,12 +247,8 @@ public function recordReceipt(int $expectedRevision, string $lineId, string $qua 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->lines = $this->lines->receive($lineId, $quantity); + $this->status = $this->lines->receivedStatus(); $this->touch($now); } @@ -277,11 +275,7 @@ public function recordReturn(int $expectedRevision, string $lineId, string $quan 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->lines = $this->lines->returnReceived($lineId, $quantity); $this->touch($now); } @@ -294,7 +288,7 @@ public function recordReturn(int $expectedRevision, string $lineId, string $quan */ public function supplierId(): string { - return $this->supplierId; + return $this->identity->supplierId; } /** @@ -306,7 +300,7 @@ public function supplierId(): string */ public function currency(): string { - return $this->currency; + return $this->identity->currency; } /** @@ -318,7 +312,7 @@ public function currency(): string */ public function name(): string { - return $this->name; + return $this->identity->name; } /** @@ -330,7 +324,7 @@ public function name(): string */ public function lines(): array { - return $this->lines; + return $this->lines->values(); } /** @@ -381,49 +375,6 @@ 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 * @@ -434,8 +385,8 @@ private function receivedStatus(): PurchaseOrderStatus private function assertRestoredStatus(): void { if (PurchaseOrderStatus::DRAFT === $this->status) { - foreach ($this->lines as $line) { - if ('0.000000' !== $line->receivedQuantity || '0.000000' !== $line->returnedQuantity) { + foreach ($this->lines->values() as $line) { + if (self::ZERO_QUANTITY !== $line->receivedQuantity || self::ZERO_QUANTITY !== $line->returnedQuantity) { throw ProcurementException::invalid('Draft lines cannot contain receipt or return history.'); } } @@ -445,76 +396,11 @@ private function assertRestoredStatus(): void if (PurchaseOrderStatus::CANCELLED === $this->status) { return; } - if ([] === $this->lines || $this->receivedStatus() !== $this->status) { + if ([] === $this->lines->values() || $this->lines->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 * diff --git a/src/Procurement/Domain/Model/Supplier.php b/src/Procurement/Domain/Model/Supplier.php index 0abb48fdc..4b2ab92ad 100644 --- a/src/Procurement/Domain/Model/Supplier.php +++ b/src/Procurement/Domain/Model/Supplier.php @@ -6,19 +6,9 @@ use DateTimeImmutable; use Procurement\Domain\Exception\ProcurementException; +use Procurement\Domain\ValueObject\{SupplierDetails, SupplierHistory}; use Shared\Domain\ValueObject\Uuid; -use function array_is_list; -use function count; -use function filter_var; -use function in_array; -use function is_array; -use function is_string; -use function mb_strlen; -use function trim; - -use const FILTER_VALIDATE_EMAIL; - /** * Class Supplier * @@ -28,13 +18,33 @@ */ final class Supplier { + /** + * Property createdAt + */ + public readonly DateTimeImmutable $createdAt; + // #region Properties /** - * Property contacts + * Property details * - * @var list + * Holds validated contact data adopted together by each mutation. + */ + private SupplierDetails $details; + + /** + * Property updatedAt */ - private array $contacts; + private DateTimeImmutable $updatedAt; + + /** + * Property archivedAt + */ + private ?DateTimeImmutable $archivedAt; + + /** + * Property revision + */ + private int $revision; // #endregion // #region Constructor @@ -47,42 +57,20 @@ final class Supplier * * @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 + * @param SupplierDetails $details the details value + * @param SupplierHistory $history the history value * * @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); + private function __construct(public readonly string $id, public readonly string $organizationId, SupplierDetails $details, SupplierHistory $history) + { + Uuid::assertValid($id); + Uuid::assertValid($organizationId); + $this->details = $details; + $this->archivedAt = $history->archivedAt; + $this->createdAt = $history->createdAt; + $this->updatedAt = $history->updatedAt; + $this->revision = $history->revision; } // #endregion @@ -96,18 +84,14 @@ private function __construct( * * @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 + * @param SupplierDetails $details the details value * * @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 + public static function create(string $id, string $organizationId, SupplierDetails $details, DateTimeImmutable $now): self { - return new self($id, $organizationId, $name, $code, $email, $phone, $contacts, null, $now, $now, 1); + return new self($id, $organizationId, $details, new SupplierHistory(null, $now, $now, 1)); } /** @@ -119,21 +103,14 @@ public static function create(string $id, string $organizationId, string $name, * * @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 + * @param SupplierDetails $details the details value + * @param SupplierHistory $history the history value * * @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 + public static function reconstitute(string $id, string $organizationId, SupplierDetails $details, SupplierHistory $history): self { - return new self($id, $organizationId, $name, $code, $email, $phone, $contacts, $archivedAt, $createdAt, $updatedAt, $revision); + return new self($id, $organizationId, $details, $history); } /** @@ -161,17 +138,7 @@ public function change(int $expectedRevision, string $name, ?string $code, ?stri 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->details = new SupplierDetails($name, $code, $email, $phone, $contacts); $this->touch($now); } @@ -208,7 +175,7 @@ public function archive(int $expectedRevision, DateTimeImmutable $now): void */ public function name(): string { - return $this->name; + return $this->details->name; } /** @@ -220,7 +187,7 @@ public function name(): string */ public function code(): ?string { - return $this->code; + return $this->details->code; } /** @@ -232,7 +199,7 @@ public function code(): ?string */ public function email(): ?string { - return $this->email; + return $this->details->email; } /** @@ -244,7 +211,7 @@ public function email(): ?string */ public function phone(): ?string { - return $this->phone; + return $this->details->phone; } /** @@ -256,7 +223,7 @@ public function phone(): ?string */ public function contacts(): array { - return $this->contacts; + return $this->details->contacts; } /** @@ -354,99 +321,5 @@ private function touch(DateTimeImmutable $now): void ++$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/ProcurementGoodsIdentity.php b/src/Procurement/Domain/ValueObject/ProcurementGoodsIdentity.php new file mode 100644 index 000000000..8dd97852a --- /dev/null +++ b/src/Procurement/Domain/ValueObject/ProcurementGoodsIdentity.php @@ -0,0 +1,183 @@ + $identityTemplate the bounded declarative identity + * @param ?string $partCode the retained catalog code + * @param ?string $partLabel the retained catalog label + * @param ?string $partUnit the retained catalog unit + * + * @return void + */ + public function __construct(public string $kind, public ?string $partId, ?string $typeCode, public array $identityTemplate, public ?string $partCode = null, public ?string $partLabel = null, public ?string $partUnit = null) + { + $this->typeCode = null === $typeCode ? null : trim($typeCode); + if (!in_array($kind, ['part', 'equipment_to_individualize'], true)) { + throw ProcurementException::invalid('Unknown procurement line kind.'); + } + if ('part' === $kind) { + if (null === $partId || null !== $this->typeCode || [] !== $identityTemplate) { + throw ProcurementException::invalid('A part line needs only an article identity.'); + } + Uuid::assertValid($partId); + } elseif (null !== $partId || null === $this->typeCode || '' === $this->typeCode || mb_strlen($this->typeCode) > 32) { + throw ProcurementException::invalid('An equipment line needs a type code and cannot also refer to a consumable article.'); + } + self::validateTemplate($identityTemplate); + self::assertRetainedPartIdentity($partCode, $partLabel, $partUnit); + } + // #endregion + + // #region Methods + /** + * Method assertRetainedPartIdentity + * + * @access private + * + * @param ?string $partCode the retained catalog code + * @param ?string $partLabel the retained catalog label + * @param ?string $partUnit the retained catalog unit + * + * @return void + */ + private static function assertRetainedPartIdentity(?string $partCode, ?string $partLabel, ?string $partUnit): void + { + 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.'); + } + } + } + + /** + * 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.'); + } + + } elseif (is_float($value)) { + if (!is_finite($value)) { + throw ProcurementException::invalid('Identity template numbers must be finite.'); + } + + } elseif (is_array($value)) { + self::validateTemplateArray($value, $depth, $nodes); + } else { + throw ProcurementException::invalid('Identity templates must contain declarative JSON values.'); + } + } + + /** + * Method validateTemplateArray + * + * Shares the node budget across every nested item before accepting a template. + * + * @access private + * + * @param array $values the declarative child values + * @param int $depth the current nesting depth + * @param int $nodes the shared number of visited values + * + * @return void + */ + private static function validateTemplateArray(array $values, int $depth, int &$nodes): void + { + foreach ($values 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); + } + } + // #endregion +} diff --git a/src/Procurement/Domain/ValueObject/ProcurementLine.php b/src/Procurement/Domain/ValueObject/ProcurementLine.php index 671fb1202..7a0bc280f 100644 --- a/src/Procurement/Domain/ValueObject/ProcurementLine.php +++ b/src/Procurement/Domain/ValueObject/ProcurementLine.php @@ -7,18 +7,6 @@ use Procurement\Domain\Exception\ProcurementException; use Shared\Domain\ValueObject\{DecimalAmount, Uuid}; -use function array_is_list; -use function count; -use function in_array; -use function is_array; -use function is_bool; -use function is_finite; -use function is_float; -use function is_int; -use function is_string; -use function mb_strlen; -use function trim; - /** * Class ProcurementLine * @@ -28,69 +16,107 @@ */ final readonly class ProcurementLine { + // #region Properties + /** + * Property id + */ + public string $id; + + /** + * Property kind + */ + public string $kind; + + /** + * Property partId + */ + public ?string $partId; + + /** + * Property typeCode + */ + public ?string $typeCode; + + /** + * Property identityTemplate + * + * @var array */ + public array $identityTemplate; + + /** + * Property quantity + */ + public string $quantity; + + /** + * Property unitCost + */ + public ?string $unitCost; + + /** + * Property receivedQuantity + */ + public string $receivedQuantity; + + /** + * Property returnedQuantity + */ + public string $returnedQuantity; + + /** + * Property partCode + */ + public ?string $partCode; + + /** + * Property partLabel + */ + public ?string $partLabel; + + /** + * Property partUnit + */ + public ?string $partUnit; + + /** + * Property identity + */ + private ProcurementGoodsIdentity $identity; + // #endregion + // #region Constructor /** * Method __construct * - * Validates quantities and line identity without mutating physical history. + * Adopts a validated snapshot only when equipment quantities remain whole units. * * @access private * * @param string $id the stable order-line UUID - * @param string $kind part or equipment_to_individualize - * @param ?string $partId the article UUID for a consumable line - * @param ?string $typeCode the declared type for individually tracked equipment - * @param array $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 + * @param ProcurementGoodsIdentity $identity the validated goods snapshot + * @param ProcurementLineAmounts $amounts the exact retained quantities and price * * @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())) { + private function __construct(string $id, ProcurementGoodsIdentity $identity, ProcurementLineAmounts $amounts) + { + Uuid::assertValid($id); + if ('equipment_to_individualize' === $identity->kind && (!DecimalAmount::fromString($amounts->quantity)->isInteger() || !DecimalAmount::fromString($amounts->receivedQuantity)->isInteger() || !DecimalAmount::fromString($amounts->returnedQuantity)->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.'); - } - } + $this->id = $id; + $this->kind = $identity->kind; + $this->partId = $identity->partId; + $this->typeCode = $identity->typeCode; + $this->identityTemplate = $identity->identityTemplate; + $this->quantity = $amounts->quantity; + $this->unitCost = $amounts->unitCost; + $this->receivedQuantity = $amounts->receivedQuantity; + $this->returnedQuantity = $amounts->returnedQuantity; + $this->partCode = $identity->partCode; + $this->partLabel = $identity->partLabel; + $this->partUnit = $identity->partUnit; + $this->identity = $identity; } // #endregion @@ -103,18 +129,15 @@ private function __construct( * @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 ProcurementGoodsIdentity $identity the validated goods snapshot * @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 + public static function create(string $id, ProcurementGoodsIdentity $identity, string $quantity, ?string $unitCost): self { - return self::reconstitute($id, $kind, $partId, $typeCode, $identityTemplate, $quantity, $unitCost, '0.000000', '0.000000', $partCode, $partLabel, $partUnit); + return new self($id, $identity, new ProcurementLineAmounts($quantity, $unitCost)); } /** @@ -125,25 +148,14 @@ public static function create(string $id, string $kind, ?string $partId, ?string * @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 + * @param ProcurementGoodsIdentity $identity the validated goods snapshot + * @param ProcurementLineAmounts $amounts the exact retained quantities and price * * @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 + public static function reconstitute(string $id, ProcurementGoodsIdentity $identity, ProcurementLineAmounts $amounts): 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); + return new self($id, $identity, $amounts); } /** @@ -164,7 +176,7 @@ public function receive(string $quantity): self 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); + return new self($this->id, $this->identity, new ProcurementLineAmounts($this->quantity, $this->unitCost, DecimalAmount::fromString($this->receivedQuantity)->add($received)->toString(), $this->returnedQuantity)); } /** @@ -185,7 +197,7 @@ public function returnReceived(string $quantity): self 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); + return new self($this->id, $this->identity, new ProcurementLineAmounts($this->quantity, $this->unitCost, $this->receivedQuantity, DecimalAmount::fromString($this->returnedQuantity)->add($returned)->toString())); } /** @@ -211,82 +223,5 @@ 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/ProcurementLineAmounts.php b/src/Procurement/Domain/ValueObject/ProcurementLineAmounts.php new file mode 100644 index 000000000..d517a254d --- /dev/null +++ b/src/Procurement/Domain/ValueObject/ProcurementLineAmounts.php @@ -0,0 +1,93 @@ +isNegative()) { + throw ProcurementException::invalid('A procurement unit cost cannot be negative.'); + } + $ordered = DecimalAmount::fromString($quantity); + $received = DecimalAmount::fromString($receivedQuantity); + $returned = DecimalAmount::fromString($returnedQuantity); + self::assertQuantities($ordered, $received, $returned); + $this->quantity = $ordered->toString(); + $this->unitCost = $cost?->toString(); + $this->receivedQuantity = $received->toString(); + $this->returnedQuantity = $returned->toString(); + } + // #endregion + + // #region Methods + /** + * Method assertQuantities + * + * Keeps ordered, gross received and physically returned quantities bounded together. + * + * @access private + * + * @param DecimalAmount $ordered the positive bounded ordered quantity + * @param DecimalAmount $received the retained gross delivery + * @param DecimalAmount $returned the retained physical return + * + * @return void + */ + private static function assertQuantities(DecimalAmount $ordered, DecimalAmount $received, DecimalAmount $returned): void + { + 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.'); + } + } + + // #endregion +} diff --git a/src/Procurement/Domain/ValueObject/PurchaseOrderHistory.php b/src/Procurement/Domain/ValueObject/PurchaseOrderHistory.php new file mode 100644 index 000000000..84235f761 --- /dev/null +++ b/src/Procurement/Domain/ValueObject/PurchaseOrderHistory.php @@ -0,0 +1,39 @@ +name = self::normalizeName($name); + } + // #endregion + + // #region Methods + /** + * 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; + } + + // #endregion +} diff --git a/src/Procurement/Domain/ValueObject/PurchaseOrderLines.php b/src/Procurement/Domain/ValueObject/PurchaseOrderLines.php new file mode 100644 index 000000000..7f466520a --- /dev/null +++ b/src/Procurement/Domain/ValueObject/PurchaseOrderLines.php @@ -0,0 +1,170 @@ + */ + private array $values; + // #endregion + + // #region Constructor + /** + * Method __construct + * + * @access public + * + * @param array $lines the candidate ordered snapshots + * + * @return void + */ + public function __construct(array $lines) + { + $this->values = self::validateLines($lines); + } + // #endregion + + // #region Methods + /** + * Method values + * + * @access public + * + * @return list the retained snapshots + */ + public function values(): array + { + return $this->values; + } + + /** + * Method receive + * + * @access public + * + * @param string $lineId the retained line UUID + * @param string $quantity the positive exact received quantity + * + * @return self the fully validated replacement collection + */ + public function receive(string $lineId, string $quantity): self + { + $index = $this->lineIndex($lineId); + $lines = $this->values; + $lines[$index] = $lines[$index]->receive($quantity); + + return new self(array_values($lines)); + } + + /** + * Method returnReceived + * + * @access public + * + * @param string $lineId the retained line UUID + * @param string $quantity the positive exact returned quantity + * + * @return self the fully validated replacement collection + */ + public function returnReceived(string $lineId, string $quantity): self + { + $index = $this->lineIndex($lineId); + $lines = $this->values; + $lines[$index] = $lines[$index]->returnReceived($quantity); + + return new self(array_values($lines)); + } + + /** + * Method receivedStatus + * + * @access public + * + * @return PurchaseOrderStatus the lifecycle derived from gross receipts + */ + public function receivedStatus(): PurchaseOrderStatus + { + $received = DecimalAmount::zero(); + $remaining = DecimalAmount::zero(); + foreach ($this->values 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 lineIndex + * + * @access private + * + * @param string $lineId the retained line UUID + * + * @return int its current list index + */ + private function lineIndex(string $lineId): int + { + foreach ($this->values as $index => $line) { + if ($line->id === $lineId) { + return $index; + } + } + + throw ProcurementException::invalid('The order does not contain this line.'); + } + + /** + * 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; + } + + // #endregion +} diff --git a/src/Procurement/Domain/ValueObject/SupplierDetails.php b/src/Procurement/Domain/ValueObject/SupplierDetails.php new file mode 100644 index 000000000..55a67e63c --- /dev/null +++ b/src/Procurement/Domain/ValueObject/SupplierDetails.php @@ -0,0 +1,179 @@ + */ + public array $contacts; + // #endregion + + // #region Constructor + /** + * Method __construct + * + * Validates every candidate before any aggregate adopts the replacement. + * + * @access public + * + * @param string $name the raw supplier name + * @param ?string $code the nullable supplier reference + * @param ?string $email the nullable main email + * @param ?string $phone the nullable main telephone + * @param array $contacts the raw contact list + * + * @return void + */ + public function __construct(string $name, ?string $code, ?string $email, ?string $phone, array $contacts) + { + $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 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/SupplierHistory.php b/src/Procurement/Domain/ValueObject/SupplierHistory.php new file mode 100644 index 000000000..cd01e1683 --- /dev/null +++ b/src/Procurement/Domain/ValueObject/SupplierHistory.php @@ -0,0 +1,39 @@ + $updatedAt))) { + throw ProcurementException::invalid('Supplier revision and historical timestamps are inconsistent.'); + } + } + // #endregion +} diff --git a/src/Procurement/Infrastructure/Adapter/Doctrine/ProcurementTableStorageAdapter.php b/src/Procurement/Infrastructure/Adapter/Doctrine/ProcurementTableStorageAdapter.php new file mode 100644 index 000000000..cab051c9c --- /dev/null +++ b/src/Procurement/Infrastructure/Adapter/Doctrine/ProcurementTableStorageAdapter.php @@ -0,0 +1,161 @@ +|null + */ + public 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; + } + + /** + * Method upsert + * + * Retains physical identity columns while updating only permitted reconciliation fields. + * + * @access public + * + * @param array $values + * @param string $table the table value + * + * @return void + */ + public 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(); + } + } + + /** + * Method page + * + * Applies the existing deterministic sort and bounded integer pagination. + * + * @access public + * + * @param array $parameters + * @param string $table the table value + * @param string $criteria the criteria value + * @param int $offset the offset value + * @param int $limit the limit value + * @param string $sort the sort value + * + * @return list> + */ + public 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]); + } + + /** + * Method supplierCriteria + * + * Shares archive and search predicates between supplier count and collection reads. + * + * @access public + * + * @param ?bool $archived the archived value + * + * @return string + */ + public function supplierCriteria(?bool $archived): string + { + $criteria = 'organization_id = :org'; + if (null !== $archived) { + $criteria .= $archived ? ' AND archived_at IS NOT NULL' : ' AND archived_at IS NULL'; + } + + return $criteria . " AND (name ILIKE :search OR COALESCE(code, '') ILIKE :search)"; + } + + /** + * Method orderCriteria + * + * Shares scoped lifecycle and supplier filters between order count and collection reads. + * + * @access public + * + * @param string $organizationId the organizationId value + * @param ?string $status the status value + * @param ?string $supplierId the supplierId value + * + * @return array{string,array} + */ + public 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]; + } + + + // #endregion +} diff --git a/src/Procurement/Infrastructure/Persistence/Doctrine/Mapper/ProcurementStateMapper.php b/src/Procurement/Infrastructure/Persistence/Doctrine/Mapper/ProcurementStateMapper.php new file mode 100644 index 000000000..c4a5c4333 --- /dev/null +++ b/src/Procurement/Infrastructure/Persistence/Doctrine/Mapper/ProcurementStateMapper.php @@ -0,0 +1,269 @@ + the persisted column values + */ + public function supplierToRow(Supplier $supplier): array + { + return ['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()]; + } + + /** + * Method orderToRow + * + * @access public + * + * @param PurchaseOrder $order the retained state + * + * @return array the persisted column values + */ + public function orderToRow(PurchaseOrder $order): array + { + $lines = []; + foreach ($order->lines() as $line) { + $lines[] = get_object_vars($line); + } + + return ['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())]; + } + + /** + * Method receiptToRow + * + * @access public + * + * @param ProcurementReceiptState $receipt the retained state + * + * @return array the persisted column values + */ + public function receiptToRow(ProcurementReceiptState $receipt): array + { + return ['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]; + } + + /** + * Method returnToRow + * + * @access public + * + * @param ProcurementReturnState $return the retained state + * + * @return array the persisted column values + */ + public function returnToRow(ProcurementReturnState $return): array + { + return ['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]; + } + + /** + * Method operationFromRow + * + * @access public + * + * @param array $row the persisted operation columns + * + * @return ProcurementOperationState the retained replay identity + */ + public function operationFromRow(array $row): ProcurementOperationState + { + /** @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); + } + + /** + * Method operationToRow + * + * @access public + * + * @param ProcurementOperationState $operation the retained replay identity + * + * @return array the persisted column values + */ + public function operationToRow(ProcurementOperationState $operation): array + { + return ['organization_id' => $operation->organizationId, 'client_operation_id' => $operation->clientOperationId, 'kind' => $operation->kind, 'fingerprint' => $operation->fingerprint, 'receipt_id' => $operation->receiptId, 'declaration' => $this->json($operation->declaration)]; + } + + /** + * Method supplierFromRow + * + * Restores validated supplier contact data and its exact lifecycle history. + * + * @access public + * + * @param array $row + * + * @return Supplier + */ + public 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'], new SupplierDetails($row['name'], $row['code'], $row['email'], $row['phone'], $contacts), new SupplierHistory($this->dateOrNull($row['archived_at']), $this->date($row['created_at']), $this->date($row['updated_at']), (int) $row['revision'])); + } + + /** + * Method orderFromRow + * + * Restores ordered catalog snapshots and their retained gross receipt lifecycle. + * + * @access public + * + * @param array $row + * + * @return PurchaseOrder + */ + public 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'], new ProcurementGoodsIdentity($line['kind'], $line['partId'], $line['typeCode'], $line['identityTemplate'], $line['partCode'] ?? null, $line['partLabel'] ?? null, $line['partUnit'] ?? null), new ProcurementLineAmounts($line['quantity'], $line['unitCost'], $line['receivedQuantity'], $line['returnedQuantity'])); + } + + return PurchaseOrder::reconstitute($row['id'], $row['organization_id'], new PurchaseOrderIdentity($row['supplier_id'], $row['currency'], $row['name']), new PurchaseOrderLines($lines), new PurchaseOrderHistory(PurchaseOrderStatus::from($row['status']), (int) $row['revision'], $this->date($row['created_at']), $this->date($row['updated_at']))); + } + + /** + * Method receiptFromRow + * + * Restores immutable physical receipt identity and mutable reconciliation state. + * + * @access public + * + * @param array $row + * + * @return ProcurementReceiptState + */ + public 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']); + } + + /** + * Method returnFromRow + * + * Restores retained return evidence and its reconciliation state. + * + * @access public + * + * @param array $row + * + * @return ProcurementReturnState + */ + public 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']); + } + + /** + * Method time + * + * Persists nullable instants in the existing UTC timestamp representation. + * + * @access private + * + * @param ?DateTimeImmutable $time the time value + * + * @return ?string + */ + private function time(?DateTimeImmutable $time): ?string + { + return $time?->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'); + } + + /** + * Method date + * + * Requires a valid physical timestamp with an explicit offset. + * + * @access private + * + * @param string $value the value value + * + * @return DateTimeImmutable + */ + private function date(string $value): DateTimeImmutable + { + return new DateTimeImmutable($value, new DateTimeZone('UTC')); + } + + /** + * Method dateOrNull + * + * Restores an optional retained instant without inventing a timestamp. + * + * @access private + * + * @param ?string $value the value value + * + * @return ?DateTimeImmutable + */ + private function dateOrNull(?string $value): ?DateTimeImmutable + { + return null === $value ? null : $this->date($value); + } + + /** + * Method json + * + * Uses the existing PHP JSON representation for persisted declarative values. + * + * @access private + * + * @param array $value + * + * @return string + */ + private function json(array $value): string + { + return json_encode($value, JSON_THROW_ON_ERROR); + } + // #endregion +} diff --git a/src/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepository.php b/src/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepository.php index 7f1cf7797..1551e07b5 100644 --- a/src/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepository.php +++ b/src/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepository.php @@ -4,32 +4,54 @@ namespace Procurement\Infrastructure\Persistence\Doctrine\Repository; -use DateTimeImmutable; -use DateTimeZone; -use Doctrine\DBAL\{Connection, ParameterType}; +use Doctrine\DBAL\Connection; use Procurement\Application\Contract\{ProcurementOperationState, ProcurementReceiptState, ProcurementReturnState}; use Procurement\Application\Port\Outbound\Persistence\ProcurementRepositoryPort; use Procurement\Domain\Exception\ProcurementException; use Procurement\Domain\Model\{PurchaseOrder, Supplier}; -use Procurement\Domain\ValueObject\{ProcurementLine, PurchaseOrderStatus}; +use Procurement\Infrastructure\Adapter\Doctrine\ProcurementTableStorageAdapter; +use Procurement\Infrastructure\Persistence\Doctrine\Mapper\ProcurementStateMapper; -use function array_keys; use function array_map; -use function get_object_vars; -use function implode; -use function in_array; -use function json_decode; -use function json_encode; - -use const JSON_THROW_ON_ERROR; /** Explicit-main PostgreSQL repository with scoped reads and serialized physical operations. */ final readonly class ProcurementRepository implements ProcurementRepositoryPort { + /** + * Property mapper + */ + private ProcurementStateMapper $mapper; + + /** + * Property storage + */ + private ProcurementTableStorageAdapter $storage; + + /** + * Method __construct + * + * @access public + * + * @param Connection $connection the connection value + * + * @return void + */ public function __construct(private Connection $connection) { + $this->mapper = new ProcurementStateMapper(); + $this->storage = new ProcurementTableStorageAdapter($connection); } + /** + * Method synchronized + * + * @access public + * + * @param string $organizationId the organizationId value + * @param callable $work the work value + * + * @return mixed + */ public function synchronized(string $organizationId, callable $work): mixed { return $this->connection->transactional(function () use ($organizationId, $work): mixed { @@ -39,63 +61,157 @@ public function synchronized(string $organizationId, callable $work): mixed }); } + /** + * Method supplier + * + * @access public + * + * @param string $organizationId the organizationId value + * @param string $id the id value + * + * @return ?Supplier + */ public function supplier(string $organizationId, string $id): ?Supplier { - $row = $this->one('procurement_suppliers', $organizationId, $id); + $row = $this->storage->one('procurement_suppliers', $organizationId, $id); - return null === $row ? null : $this->supplierFromRow($row); + return null === $row ? null : $this->mapper->supplierFromRow($row); } + /** + * Method saveSupplier + * + * @access public + * + * @param Supplier $supplier the supplier value + * + * @return void + */ 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()]); + $this->storage->upsert('procurement_suppliers', $this->mapper->supplierToRow($supplier)); } + /** + * Method order + * + * @access public + * + * @param string $organizationId the organizationId value + * @param string $id the id value + * + * @return ?PurchaseOrder + */ public function order(string $organizationId, string $id): ?PurchaseOrder { - $row = $this->one('procurement_orders', $organizationId, $id); + $row = $this->storage->one('procurement_orders', $organizationId, $id); - return null === $row ? null : $this->orderFromRow($row); + return null === $row ? null : $this->mapper->orderFromRow($row); } + /** + * Method saveOrder + * + * @access public + * + * @param PurchaseOrder $order the order value + * + * @return void + */ 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())]); + $this->storage->upsert('procurement_orders', $this->mapper->orderToRow($order)); } + /** + * Method receipt + * + * @access public + * + * @param string $organizationId the organizationId value + * @param string $id the id value + * + * @return ?ProcurementReceiptState + */ public function receipt(string $organizationId, string $id): ?ProcurementReceiptState { - $row = $this->one('procurement_receipts', $organizationId, $id); + $row = $this->storage->one('procurement_receipts', $organizationId, $id); - return null === $row ? null : $this->receiptFromRow($row); + return null === $row ? null : $this->mapper->receiptFromRow($row); } + /** + * Method saveReceipt + * + * @access public + * + * @param ProcurementReceiptState $receipt the receipt value + * + * @return void + */ 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]); + $this->storage->upsert('procurement_receipts', $this->mapper->receiptToRow($receipt)); } + /** + * Method returnDeclaration + * + * @access public + * + * @param string $organizationId the organizationId value + * @param string $id the id value + * + * @return ?ProcurementReturnState + */ public function returnDeclaration(string $organizationId, string $id): ?ProcurementReturnState { - $row = $this->one('procurement_returns', $organizationId, $id); + $row = $this->storage->one('procurement_returns', $organizationId, $id); - return null === $row ? null : $this->returnFromRow($row); + return null === $row ? null : $this->mapper->returnFromRow($row); } + /** + * Method saveReturn + * + * @access public + * + * @param ProcurementReturnState $return the return value + * + * @return void + */ 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]); + $this->storage->upsert('procurement_returns', $this->mapper->returnToRow($return)); } + /** + * Method returns + * + * @access public + * + * @param string $organizationId the organizationId value + * @param string $receiptId the receiptId value + * @param int $offset the offset value + * @param int $limit the limit value + * + * @return list + */ 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')); + return array_map($this->mapper->returnFromRow(...), $this->storage->page('procurement_returns', 'organization_id = :org AND receipt_id = :receipt', ['org' => $organizationId, 'receipt' => $receiptId], $offset, $limit, 'created_at, id')); } + /** + * Method countReturns + * + * @access public + * + * @param string $organizationId the organizationId value + * @param string $receiptId the receiptId value + * + * @return int + */ public function countReturns(string $organizationId, string $receiptId): int { /** @var int|string $count */ @@ -104,19 +220,35 @@ public function countReturns(string $organizationId, string $receiptId): int return (int) $count; } + /** + * Method operation + * + * @access public + * + * @param string $organizationId the organizationId value + * @param string $clientOperationId the clientOperationId value + * + * @return ?ProcurementOperationState + */ 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); + return $this->mapper->operationFromRow($row); } + /** + * Method saveOperation + * + * @access public + * + * @param ProcurementOperationState $operation the operation value + * + * @return void + */ public function saveOperation(ProcurementOperationState $operation): void { $existing = $this->operation($operation->organizationId, $operation->clientOperationId); @@ -127,35 +259,83 @@ public function saveOperation(ProcurementOperationState $operation): void 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)]); + $this->connection->insert('procurement_operations', $this->mapper->operationToRow($operation)); } + /** + * Method suppliers + * + * @access public + * + * @param string $organizationId the organizationId value + * @param string $search the search value + * @param ?bool $archived the archived value + * @param int $offset the offset value + * @param int $limit the limit value + * + * @return list + */ 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'); + $criteria = $this->storage->supplierCriteria($archived); + $rows = $this->storage->page('procurement_suppliers', $criteria, ['org' => $organizationId, 'search' => '%' . $search . '%'], $offset, $limit, 'LOWER(name), id'); - return array_map($this->supplierFromRow(...), $rows); + return array_map($this->mapper->supplierFromRow(...), $rows); } + /** + * Method countSuppliers + * + * @access public + * + * @param string $organizationId the organizationId value + * @param string $search the search value + * @param ?bool $archived the archived value + * + * @return int + */ 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 . '%']); + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM procurement_suppliers WHERE ' . $this->storage->supplierCriteria($archived), ['org' => $organizationId, 'search' => '%' . $search . '%']); return (int) $count; } + /** + * Method orders + * + * @access public + * + * @param string $organizationId the organizationId value + * @param ?string $status the status value + * @param ?string $supplierId the supplierId value + * @param int $offset the offset value + * @param int $limit the limit value + * + * @return list + */ public function orders(string $organizationId, ?string $status, ?string $supplierId, int $offset, int $limit): array { - [$criteria, $parameters] = $this->orderCriteria($organizationId, $status, $supplierId); + [$criteria, $parameters] = $this->storage->orderCriteria($organizationId, $status, $supplierId); - return array_map($this->orderFromRow(...), $this->page('procurement_orders', $criteria, $parameters, $offset, $limit, 'created_at DESC, id')); + return array_map($this->mapper->orderFromRow(...), $this->storage->page('procurement_orders', $criteria, $parameters, $offset, $limit, 'created_at DESC, id')); } + /** + * Method countOrders + * + * @access public + * + * @param string $organizationId the organizationId value + * @param ?string $status the status value + * @param ?string $supplierId the supplierId value + * + * @return int + */ public function countOrders(string $organizationId, ?string $status, ?string $supplierId): int { - [$criteria, $parameters] = $this->orderCriteria($organizationId, $status, $supplierId); + [$criteria, $parameters] = $this->storage->orderCriteria($organizationId, $status, $supplierId); /** @var int|string $count */ $count = $this->connection->fetchOne('SELECT COUNT(*) FROM procurement_orders WHERE ' . $criteria, $parameters); @@ -163,151 +343,38 @@ public function countOrders(string $organizationId, ?string $status, ?string $su 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 + * Method receipts * - * @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 + * @access public + * + * @param string $organizationId the organizationId value + * @param string $orderId the orderId value + * @param int $offset the offset value + * @param int $limit the limit value + * + * @return list */ - private function receiptFromRow(array $row): ProcurementReceiptState + public function receipts(string $organizationId, string $orderId, int $offset, int $limit): array { - /** @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']); + return array_map($this->mapper->receiptFromRow(...), $this->storage->page('procurement_receipts', 'organization_id = :org AND order_id = :order', ['org' => $organizationId, 'order' => $orderId], $offset, $limit, 'created_at, id')); } /** - * @param array $row + * Method countReceipts + * + * @access public + * + * @param string $organizationId the organizationId value + * @param string $orderId the orderId value + * + * @return int */ - 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 + public function countReceipts(string $organizationId, string $orderId): int { - return null === $value ? null : $this->date($value); - } + /** @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]); - /** - * @param array $value - */ - private function json(array $value): string - { - return json_encode($value, JSON_THROW_ON_ERROR); + return (int) $count; } } diff --git a/src/Procurement/MODULE.md b/src/Procurement/MODULE.md index e40894068..bab66bde6 100644 --- a/src/Procurement/MODULE.md +++ b/src/Procurement/MODULE.md @@ -53,12 +53,16 @@ Presentation translates DTOs into CommandBusPort and QueryBusPort messages. Mana 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. Creation receipts share the organization-scoped operation ledger; its historical receipt_id field identifies the created supplier/order for creation kinds and a physical receipt for delivery kinds. Object-key order and UUID spelling do not change a creation fingerprint; generated order-line identities remain attached to the first saved draft. All stock bridges acquire currency before Inventory operation, reference and balance locks; intervention stock paths acquire their publication fence/parent first. +The repository owns the persistence port and transaction. Its internal Doctrine table adapter applies scoped row reads, pagination predicates and immutable-column updates on the same connection; a separate mapper reconstructs domain state from retained rows. These components remain private implementation details and expose no SQL through Application ports. + 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 table adapter is constructed by the explicitly main-bound repository with its own connection. It is not registered as a separate service; module resource loading covers Presentation and Application services only. + 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. Additive main migration Version20261008121000 widens supplier codes to the validated 80-character limit. Its rollback is refused by PostgreSQL while references longer than 64 characters remain, preserving retained supplier data. UTC timestamps carry datetime_immutable type comments. Decimal columns use NUMERIC(24,6). diff --git a/src/Procurement/Presentation/Api/Processor/ProcurementProcessor.php b/src/Procurement/Presentation/Api/Processor/ProcurementProcessor.php index 5236269e3..491851527 100644 --- a/src/Procurement/Presentation/Api/Processor/ProcurementProcessor.php +++ b/src/Procurement/Presentation/Api/Processor/ProcurementProcessor.php @@ -65,7 +65,10 @@ public function process(mixed $data, Operation $operation, array $uriVariables = } } $header = $request?->headers->get('If-Match'); - $revision = null === $header ? null : (1 === preg_match('/^"revision-(\\d+)"$/', $header, $matches) ? (int) $matches[1] : -1); + $revision = null; + if (null !== $header) { + $revision = 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)); diff --git a/src/ServiceRequest/Application/Service/ServiceRequestTargetGuard.php b/src/ServiceRequest/Application/Service/ServiceRequestTargetGuard.php index 33e92fc25..169661ef9 100644 --- a/src/ServiceRequest/Application/Service/ServiceRequestTargetGuard.php +++ b/src/ServiceRequest/Application/Service/ServiceRequestTargetGuard.php @@ -4,6 +4,7 @@ namespace ServiceRequest\Application\Service; +use ServiceRequest\Application\Contract\Target\{ServiceRequestEquipmentTarget, ServiceRequestSiteTarget}; use ServiceRequest\Application\Port\Outbound\{ServiceRequestEquipmentTargetPort, ServiceRequestSiteTargetPort}; use ServiceRequest\Domain\Exception\ServiceRequestException; @@ -23,13 +24,53 @@ public function snapshot(string $organizationId, ?string $equipmentId, ?string $ throw ServiceRequestException::invalid('A repair request must target an equipment or a site.'); } $this->sites->lock($organizationId); - $equipment = null === $equipmentId ? null : $this->equipment->find($equipmentId, $organizationId); - if (null !== $equipmentId && null === $equipment) { + $equipment = $this->equipmentTarget($organizationId, $equipmentId); + $site = $this->siteTarget($organizationId, $equipment, $siteId); + + 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]; + } + + /** + * Method equipmentTarget + * + * @access private + * + * @param string $organizationId owning organization + * @param string|null $equipmentId optional selected equipment + * + * @return ServiceRequestEquipmentTarget|null available published equipment + */ + private function equipmentTarget(string $organizationId, ?string $equipmentId): ?ServiceRequestEquipmentTarget + { + if (null === $equipmentId) { + return null; + } + $equipment = $this->equipment->find($equipmentId, $organizationId); + if (null === $equipment) { throw ServiceRequestException::invalid('The repair target is unavailable in this organization.'); } - if (null !== $equipment && 'decommissioned' === $equipment->status) { + if ('decommissioned' === $equipment->status) { throw ServiceRequestException::transitionConflict('Retired equipment cannot receive new repair work.'); } + + return $equipment; + } + + /** + * Method siteTarget + * + * Reserve equipment may remain without a site, while an explicit site must match its ancestry. + * + * @access private + * + * @param string $organizationId owning organization + * @param ServiceRequestEquipmentTarget|null $equipment verified optional equipment + * @param string|null $siteId optional declared root site + * + * @return ServiceRequestSiteTarget|null verified active site and internal customer + */ + private function siteTarget(string $organizationId, ?ServiceRequestEquipmentTarget $equipment, ?string $siteId): ?ServiceRequestSiteTarget + { if (null !== $siteId && null !== $equipment && null === $equipment->facilityId) { throw ServiceRequestException::invalid('The equipment does not belong to the selected site.'); } @@ -41,6 +82,6 @@ public function snapshot(string $organizationId, ?string $equipmentId, ?string $ 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]; + return $site; } } diff --git a/src/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestHandler.php b/src/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestHandler.php index 28b9acb44..b667ea2a6 100644 --- a/src/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestHandler.php +++ b/src/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestHandler.php @@ -10,6 +10,7 @@ use ServiceRequest\Application\Service\{ServiceRequestAccessGuard, ServiceRequestTargetGuard}; use ServiceRequest\Domain\Event\ServiceRequestChangedEvent; use ServiceRequest\Domain\Exception\ServiceRequestException; +use ServiceRequest\Domain\Model\ServiceRequest\ServiceRequest; use ServiceRequest\Domain\ValueObject\ServiceRequestConversionReceipt; use Shared\Application\Message\CommandHandler; use Shared\Application\Port\Outbound\{ClockPort, EventDispatcherPort, TransactionManagerPort}; @@ -41,18 +42,12 @@ public function __invoke(ConvertServiceRequestCommand $command): ConvertServiceR 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)); + $intent = $this->intent($command, $request->id); + $operationId = $intent['operationId']; + $hash = $intent['hash']; $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(); - } + $this->assertReplay($request, $receipt, $operationId, $hash); return new ConvertServiceRequestResult(ServiceRequestView::fromRequest($request)); } @@ -63,7 +58,7 @@ public function __invoke(ConvertServiceRequestCommand $command): ConvertServiceR 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); + $selection = new ServiceRequestWorkRequest($request->organizationId, $request->id, $command->actorId, $request->equipmentId, $request->siteId, $request->title, $request->description, $intent['interventionId'], $intent['taskId'], $operationId); $this->work->reserveSelection($selection); $this->targets->snapshot($request->organizationId, $request->equipmentId, $request->siteId); $link = $this->work->createOrLink($selection); @@ -77,10 +72,56 @@ public function __invoke(ConvertServiceRequestCommand $command): ConvertServiceR }); } + /** + * Method intent + * + * Receipt identity includes the canonical explicit work selection in its original key order. + * + * @access private + * + * @param ConvertServiceRequestCommand $command requested operation and work selection + * @param string $requestId locked request identity + * + * @return array{operationId:string,interventionId:string|null,taskId:string|null,hash:string} canonical conversion intent + */ + private function intent(ConvertServiceRequestCommand $command, string $requestId): array + { + $operationId = $this->uuid($command->clientOperationId); + $interventionId = null === $command->existingInterventionId ? null : $this->uuid($command->existingInterventionId); + $taskId = null === $command->existingTaskId ? null : $this->uuid($command->existingTaskId); + if (null !== $taskId && null === $interventionId) { + throw ServiceRequestException::invalid('An existing task requires its intervention identifier.'); + } + $hash = hash('sha256', json_encode(['requestId' => $requestId, 'existingInterventionId' => $interventionId, 'existingTaskId' => $taskId], JSON_THROW_ON_ERROR)); + + return ['operationId' => $operationId, 'interventionId' => $interventionId, 'taskId' => $taskId, 'hash' => $hash]; + } + + /** + * Method assertReplay + * + * Matching committed receipts bypass current target checks without admitting a changed key or work link. + * + * @access private + * + * @param ServiceRequest $request locked retained request + * @param ServiceRequestConversionReceipt $receipt original committed conversion + * @param string $operationId canonical operation key + * @param string $hash canonical work selection fingerprint + * + * @return void rejects incompatible or inconsistent replay + */ + private function assertReplay(ServiceRequest $request, ServiceRequestConversionReceipt $receipt, string $operationId, string $hash): void + { + if ($receipt->clientOperationId !== $operationId || $receipt->payloadHash !== $hash || 'converted' !== $request->status || $request->interventionId !== $receipt->interventionId || $request->taskId !== $receipt->taskId) { + throw ServiceRequestException::operationConflict(); + } + } + private function uuid(string $value): string { try { - new Uuid($value); + Uuid::assertValid($value); } catch (InvalidValueException) { throw ServiceRequestException::invalid('Invalid conversion identifier.'); } diff --git a/src/ServiceRequest/Application/UseCase/Command/CreateServiceRequest/CreateServiceRequestHandler.php b/src/ServiceRequest/Application/UseCase/Command/CreateServiceRequest/CreateServiceRequestHandler.php index 9c75feed2..8011bafe9 100644 --- a/src/ServiceRequest/Application/UseCase/Command/CreateServiceRequest/CreateServiceRequestHandler.php +++ b/src/ServiceRequest/Application/UseCase/Command/CreateServiceRequest/CreateServiceRequestHandler.php @@ -9,6 +9,7 @@ use ServiceRequest\Application\Service\{ServiceRequestAccessGuard, ServiceRequestTargetGuard}; use ServiceRequest\Domain\Event\ServiceRequestChangedEvent; use ServiceRequest\Domain\Model\ServiceRequest\ServiceRequest; +use ServiceRequest\Domain\ValueObject\{ServiceRequestContent, ServiceRequestTarget}; use Shared\Application\Message\CommandHandler; use Shared\Application\Port\Outbound\{ClockPort, EventDispatcherPort, TransactionManagerPort, UuidGeneratorPort}; @@ -27,7 +28,7 @@ public function __invoke(CreateServiceRequestCommand $command): CreateServiceReq $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); + $request = ServiceRequest::create($this->ids->generate(), $command->organizationId, new ServiceRequestTarget($command->equipmentId, $siteId, $snapshot, $command->originInspectionId, $command->originNonConformityId), new ServiceRequestContent($command->title, $command->description, $command->priority), $this->clock->now()); $this->requests->save($request); $this->events->dispatch(new ServiceRequestChangedEvent($request->organizationId, $request->id, 'requested', $request->revision, $request->updatedAt)); diff --git a/src/ServiceRequest/Application/UseCase/Query/ListServiceRequests/ListServiceRequestsHandler.php b/src/ServiceRequest/Application/UseCase/Query/ListServiceRequests/ListServiceRequestsHandler.php index 7d18531c1..70cd300fd 100644 --- a/src/ServiceRequest/Application/UseCase/Query/ListServiceRequests/ListServiceRequestsHandler.php +++ b/src/ServiceRequest/Application/UseCase/Query/ListServiceRequests/ListServiceRequestsHandler.php @@ -34,7 +34,7 @@ public function __invoke(ListServiceRequestsQuery $query): ListServiceRequestsRe try { foreach ([$query->equipmentId, $query->siteId] as $id) { if (null !== $id) { - new Uuid($id); + Uuid::assertValid($id); } } } catch (InvalidValueException) { diff --git a/src/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequest.php b/src/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequest.php index 6b721392d..62173d40f 100644 --- a/src/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequest.php +++ b/src/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequest.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use JsonException; use ServiceRequest\Domain\Exception\ServiceRequestException; +use ServiceRequest\Domain\ValueObject\{ServiceRequestContent, ServiceRequestLifecycle, ServiceRequestTarget, ServiceRequestTimeline}; use Shared\Domain\Exception\InvalidValueException; use Shared\Domain\ValueObject\Uuid; @@ -38,61 +39,217 @@ { private const array PRIORITIES = ['low', 'normal', 'high', 'urgent']; + // #region Properties /** - * @param array $targetSnapshot retained owner-published target identity + * Property id. Stable request 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, - ) { + public string $id; + + /** + * Property organizationId. Owning organization. + */ + public string $organizationId; + + /** + * Property equipmentId. Selected equipment, absent for an unqualified site-only request. + */ + public ?string $equipmentId; + + /** + * Property siteId. Original root site or explicit reserve-equipment absence. + */ + public ?string $siteId; + + /** + * Property targetSnapshot + * + * @var array isolated retained owner identity + */ + public array $targetSnapshot; + + /** + * Property title. Retained request title. + */ + public string $title; + + /** + * Property description. Retained repair explanation. + */ + public string $description; + + /** + * Property priority. Retained priority. + */ + public string $priority; + + /** + * Property originInspectionId. Original inspection evidence. + */ + public ?string $originInspectionId; + + /** + * Property originNonConformityId. Original non-conformity evidence. + */ + public ?string $originNonConformityId; + + /** + * Property status. Retained lifecycle state. + */ + public string $status; + + /** + * Property revision. Exact optimistic revision. + */ + public int $revision; + + /** + * Property requestedAt. Original creation instant. + */ + public DateTimeImmutable $requestedAt; + + /** + * Property updatedAt. Latest change instant. + */ + public DateTimeImmutable $updatedAt; + + /** + * Property qualifiedAt. Original qualification instant. + */ + public ?DateTimeImmutable $qualifiedAt; + + /** + * Property rejectedAt. Explicit rejection instant. + */ + public ?DateTimeImmutable $rejectedAt; + + /** + * Property cancelledAt. Explicit cancellation instant. + */ + public ?DateTimeImmutable $cancelledAt; + + /** + * Property convertedAt. Committed conversion instant. + */ + public ?DateTimeImmutable $convertedAt; + + /** + * Property decisionReason. Retained rejection or cancellation reason. + */ + public ?string $decisionReason; + + /** + * Property qualificationNote. Retained qualification explanation. + */ + public ?string $qualificationNote; + + /** + * Property interventionId. Committed conversion intervention. + */ + public ?string $interventionId; + + /** + * Property taskId. Committed conversion task. + */ + public ?string $taskId; + // #endregion + + // #region Constructor + /** + * Method __construct + * + * Every public field preserves its persisted meaning while typed state groups keep restoration explicit. + * + * @access private + * + * @param string $id retained request identity + * @param string $organizationId owning organization + * @param ServiceRequestTarget $target isolated retained target and origin evidence + * @param ServiceRequestContent $content retained request content + * @param ServiceRequestLifecycle $lifecycle retained revision, decisions and chronology + * + * @return void + */ + private function __construct(string $id, string $organizationId, ServiceRequestTarget $target, ServiceRequestContent $content, ServiceRequestLifecycle $lifecycle) + { + $this->id = $id; + $this->organizationId = $organizationId; + $this->equipmentId = $target->equipmentId; + $this->siteId = $target->siteId; + $this->targetSnapshot = $target->snapshot; + $this->originInspectionId = $target->originInspectionId; + $this->originNonConformityId = $target->originNonConformityId; + $this->title = $content->title; + $this->description = $content->description; + $this->priority = $content->priority; + $this->status = $lifecycle->status; + $this->revision = $lifecycle->revision; + $this->requestedAt = $lifecycle->timeline->requestedAt; + $this->updatedAt = $lifecycle->timeline->updatedAt; + $this->qualifiedAt = $lifecycle->timeline->qualifiedAt; + $this->rejectedAt = $lifecycle->timeline->rejectedAt; + $this->cancelledAt = $lifecycle->timeline->cancelledAt; + $this->convertedAt = $lifecycle->timeline->convertedAt; + $this->decisionReason = $lifecycle->decisionReason; + $this->qualificationNote = $lifecycle->qualificationNote; + $this->interventionId = $lifecycle->interventionId; + $this->taskId = $lifecycle->taskId; } + // #endregion + // #region Methods /** - * @param array $targetSnapshot retained equipment, site and internal customer identity + * Method create + * + * Validates newly declared references before isolating the target and normalizing content. + * + * @access public + * + * @param string $id generated request identity + * @param string $organizationId owning organization + * @param ServiceRequestTarget $target declared owner target and origin evidence + * @param ServiceRequestContent $content declared title, description and priority + * @param DateTimeImmutable $now creation instant + * + * @return self requested state at revision one */ - 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 + public static function create(string $id, string $organizationId, ServiceRequestTarget $target, ServiceRequestContent $content, DateTimeImmutable $now): self { self::uuid($id); self::uuid($organizationId); - foreach ([$equipmentId, $siteId, $originInspectionId, $originNonConformityId] as $reference) { + foreach ([$target->equipmentId, $target->siteId, $target->originInspectionId, $target->originNonConformityId] as $reference) { if (null !== $reference) { self::uuid($reference); } } - if (null === $equipmentId && null === $siteId) { + if (null === $target->equipmentId && null === $target->siteId) { throw ServiceRequestException::invalid('A service request must target an equipment or a site.'); } - $targetSnapshot = self::snapshot($targetSnapshot); + $target = new ServiceRequestTarget($target->equipmentId, $target->siteId, self::snapshot($target->snapshot), $target->originInspectionId, $target->originNonConformityId); + $content = new ServiceRequestContent(self::requiredText($content->title, 160, 'Title'), self::requiredText($content->description, 10000, 'Description'), self::priority($content->priority)); - 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); + return new self($id, $organizationId, $target, $content, new ServiceRequestLifecycle('requested', 1, new ServiceRequestTimeline($now, $now))); } /** - * @param array $targetSnapshot persisted retained target identity + * Method reconstitute + * + * Historical content and lifecycle are retained exactly; only JSON isolation is reapplied. + * + * @access public + * + * @param string $id persisted request identity + * @param string $organizationId persisted owning organization + * @param ServiceRequestTarget $target persisted target and original evidence + * @param ServiceRequestContent $content persisted content without new-write normalization + * @param ServiceRequestLifecycle $lifecycle persisted revision, decisions and all lifecycle dates + * + * @return self exact persisted aggregate */ - 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 + public static function reconstitute(string $id, string $organizationId, ServiceRequestTarget $target, ServiceRequestContent $content, ServiceRequestLifecycle $lifecycle): 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); + $target = new ServiceRequestTarget($target->equipmentId, $target->siteId, self::snapshot($target->snapshot), $target->originInspectionId, $target->originNonConformityId); + + return new self($id, $organizationId, $target, $content, $lifecycle); } /** @@ -114,7 +271,7 @@ public function change(array $changes, DateTimeImmutable $now): self 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); + return new self($this->id, $this->organizationId, $this->target(), new ServiceRequestContent($title, $description, $priority), new ServiceRequestLifecycle($this->status, $this->revision + 1, new ServiceRequestTimeline($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 @@ -141,7 +298,7 @@ public function assignEquipment(string $equipmentId, ?string $siteId, array $tar 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); + return new self($this->id, $this->organizationId, new ServiceRequestTarget($equipmentId, $siteId, self::snapshot($targetSnapshot), $this->originInspectionId, $this->originNonConformityId), new ServiceRequestContent($this->title, $this->description, $this->priority), new ServiceRequestLifecycle($this->status, $this->revision + 1, new ServiceRequestTimeline($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 @@ -169,7 +326,19 @@ public function convert(string $interventionId, string $taskId, DateTimeImmutabl 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); + return new self($this->id, $this->organizationId, $this->target(), new ServiceRequestContent($this->title, $this->description, $this->priority), new ServiceRequestLifecycle($status, $this->revision + 1, new ServiceRequestTimeline($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)); + } + + /** + * Method target + * + * @access private + * + * @return ServiceRequestTarget retained target and original evidence + */ + private function target(): ServiceRequestTarget + { + return new ServiceRequestTarget($this->equipmentId, $this->siteId, $this->targetSnapshot, $this->originInspectionId, $this->originNonConformityId); } private function assertEditable(DateTimeImmutable $now): void @@ -193,7 +362,7 @@ private function assertState(array $states, DateTimeImmutable $now): void private static function uuid(string $value): void { try { - new Uuid($value); + Uuid::assertValid($value); } catch (InvalidValueException) { throw ServiceRequestException::invalid('Invalid service request identifier or reference.'); } @@ -247,15 +416,39 @@ private static function snapshot(array $snapshot): array throw ServiceRequestException::invalid('Target snapshot is too large.'); } - /** @var array $isolated */ - $isolated = json_decode($json, true, 512, JSON_THROW_ON_ERROR); - - return $isolated; + return self::snapshotObject(json_decode($json, true, 512, JSON_THROW_ON_ERROR)); } catch (JsonException) { throw ServiceRequestException::invalid('Target snapshot must contain valid JSON values.'); } } + /** + * Method snapshotObject + * + * Restores the decoded object through a checked string-keyed mapping. + * + * @access private + * + * @param mixed $decoded isolated JSON result + * + * @return array explicitly object-shaped isolated identity + */ + private static function snapshotObject(mixed $decoded): array + { + if (!is_array($decoded)) { + throw ServiceRequestException::invalid('Target snapshot must be a JSON object.'); + } + $snapshot = []; + foreach ($decoded as $key => $value) { + if (!is_string($key)) { + throw ServiceRequestException::invalid('Target snapshot must be a JSON object.'); + } + $snapshot[$key] = $value; + } + + return $snapshot; + } + private static function snapshotValue(mixed $value, int $depth): void { if ($depth > 16) { @@ -269,4 +462,5 @@ private static function snapshotValue(mixed $value, int $depth): void throw ServiceRequestException::invalid('Target snapshot must contain immutable JSON values.'); } } + // #endregion } diff --git a/src/ServiceRequest/Domain/ValueObject/ServiceRequestContent.php b/src/ServiceRequest/Domain/ValueObject/ServiceRequestContent.php new file mode 100644 index 000000000..f57e5ad37 --- /dev/null +++ b/src/ServiceRequest/Domain/ValueObject/ServiceRequestContent.php @@ -0,0 +1,33 @@ + $snapshot owner-published retained identity + * @param string|null $originInspectionId original inspection evidence + * @param string|null $originNonConformityId original non-conformity evidence + * + * @return void + */ + public function __construct(public ?string $equipmentId, public ?string $siteId, public array $snapshot, public ?string $originInspectionId = null, public ?string $originNonConformityId = null) + { + } + // #endregion +} diff --git a/src/ServiceRequest/Domain/ValueObject/ServiceRequestTimeline.php b/src/ServiceRequest/Domain/ValueObject/ServiceRequestTimeline.php new file mode 100644 index 000000000..61bc087d3 --- /dev/null +++ b/src/ServiceRequest/Domain/ValueObject/ServiceRequestTimeline.php @@ -0,0 +1,37 @@ +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); + return $this->create($data, $actorId, $organizationId); } $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); + return $this->convert($data, $actorId, $organizationId, $requestId, $revision); } $action = match ($operation->getName()) { ServiceRequestOperations::QUALIFY => 'qualify', ServiceRequestOperations::REJECT => 'reject', ServiceRequestOperations::CANCEL => 'cancel', default => 'patch' }; - $changes = []; - $note = null; - $reason = null; - $equipmentId = null; + /** @var ChangeServiceRequestResult $result */ + $result = $this->commands->dispatch($this->change($data, $action, $actorId, $organizationId, $requestId, $revision)); + + return ServiceRequestOutput::fromView($result->request); + } + + /** + * Method create + * + * @access private + * + * @param mixed $data declared creation input + * @param string $actorId authenticated actor + * @param string $organizationId route scope + * + * @return ServiceRequestOutput created request metadata + */ + private function create(mixed $data, string $actorId, string $organizationId): ServiceRequestOutput + { + 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); + } + + /** + * Method convert + * + * @access private + * + * @param mixed $data explicit conversion input + * @param string $actorId authenticated actor + * @param string $organizationId route scope + * @param string $requestId route request identity + * @param int|null $revision parsed optimistic precondition + * + * @return ServiceRequestOutput converted or replayed request metadata + */ + private function convert(mixed $data, string $actorId, string $organizationId, string $requestId, ?int $revision): ServiceRequestOutput + { + 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); + } + + /** + * Method change + * + * Translates lifecycle intent without applying domain policy or filling omitted patch fields. + * + * @access private + * + * @param mixed $data patch, qualification or decision input + * @param string $action explicit lifecycle operation + * @param string $actorId authenticated actor + * @param string $organizationId route scope + * @param string $requestId route request identity + * @param int|null $revision parsed optimistic precondition + * + * @return ChangeServiceRequestCommand exact declared fields and action + */ + private function change(mixed $data, string $action, string $actorId, string $organizationId, string $requestId, ?int $revision): ChangeServiceRequestCommand + { 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) { + return new ChangeServiceRequestCommand($actorId, $organizationId, $requestId, $action, $revision, $this->patch($data)); + } + if ('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.'); + + return new ChangeServiceRequestCommand($actorId, $organizationId, $requestId, $action, $revision, note:$data->note, equipmentId:$data->equipmentId); + } + if (!$data instanceof DecisionServiceRequestInput) { + throw ServiceRequestException::invalid('Invalid repair request decision.'); + } + + return new ChangeServiceRequestCommand($actorId, $organizationId, $requestId, $action, $revision, reason:$data->reason); + } + + /** + * Method patch + * + * The request body alone selects changed fields, preserving omitted versus explicit null values. + * + * @access private + * + * @param mixed $data deserialized patch input + * + * @return array supplied patch fields + */ + private function patch(mixed $data): array + { + if (!$data instanceof UpdateServiceRequestInput) { + throw ServiceRequestException::invalid('Invalid repair request patch.'); + } + $changes = []; + foreach ($this->requests->getCurrentRequest()?->getPayload()->all() ?? [] as $field => $value) { + if (!property_exists($data, $field)) { + throw ServiceRequestException::invalid('Unknown repair request field.'); } - $reason = $data->reason; + $changes[$field] = $data->{$field}; } - /** @var ChangeServiceRequestResult $result */ - $result = $this->commands->dispatch(new ChangeServiceRequestCommand($actorId, $organizationId, $requestId, $action, $revision, $changes, $note, $reason, $equipmentId)); - return ServiceRequestOutput::fromView($result->request); + return $changes; } private function revision(): ?int { $header = $this->requests->getCurrentRequest()?->headers->get('If-Match'); + if (null === $header) { + return null; + } - return null === $header ? null : (1 === preg_match('/^"revision-(\d+)"$/', $header, $matches) ? (int) $matches[1] : -1); + return 1 === preg_match('/^"revision-(\d+)"$/', $header, $matches) ? (int) $matches[1] : -1; } /** diff --git a/src/ServiceRequest/Presentation/Api/Resource/ServiceRequestResource.php b/src/ServiceRequest/Presentation/Api/Resource/ServiceRequestResource.php index a42104d43..c9bb8df93 100644 --- a/src/ServiceRequest/Presentation/Api/Resource/ServiceRequestResource.php +++ b/src/ServiceRequest/Presentation/Api/Resource/ServiceRequestResource.php @@ -22,12 +22,20 @@ ], 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"'])])), + 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' => self::REVISION_EXAMPLE])])), + 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' => self::REVISION_EXAMPLE])])), + 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' => self::REVISION_EXAMPLE])])), + 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' => self::REVISION_EXAMPLE])])), + 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' => self::REVISION_EXAMPLE])])), ])] final class ServiceRequestResource { + // #region Constants + /** + * Constant REVISION_EXAMPLE + * + * Keeps the required optimistic precondition example consistent across lifecycle operations. + */ + private const string REVISION_EXAMPLE = '"revision-1"'; + // #endregion } diff --git a/src/Shared/Domain/ValueObject/Uuid.php b/src/Shared/Domain/ValueObject/Uuid.php index 44d6b8b27..a76d0efdd 100644 --- a/src/Shared/Domain/ValueObject/Uuid.php +++ b/src/Shared/Domain/ValueObject/Uuid.php @@ -17,7 +17,7 @@ * by UuidFactory in the Application layer to maintain hexagonal purity. * * @category ValueObject - * @version 2.0.0 + * @version 2.1.0 * * @author Valentin FORTIN */ @@ -50,9 +50,7 @@ */ public function __construct(public string $value) { - if ('' === $value || !preg_match(self::PATTERN, $value)) { - throw InvalidValueException::because(message: 'Invalid UUID provided.'); - } + self::assertValid($value); } /** @@ -72,6 +70,27 @@ public function __toString(): string // #endregion // #region Methods + /** + * Method assertValid. + * + * Validates an identifier without allocating a value object when callers + * retain their original string representation. + * + * @since 2.1.0 + * + * @param string $value the UUID string + * + * @return void no return value + * + * @throws InvalidValueException if the UUID format is invalid + */ + public static function assertValid(string $value): void + { + if ('' === $value || !preg_match(self::PATTERN, $value)) { + throw InvalidValueException::because(message: 'Invalid UUID provided.'); + } + } + /** * Method equals. * diff --git a/src/Shared/Infrastructure/Image/ImageInputValidationAdapter.php b/src/Shared/Infrastructure/Image/ImageInputValidationAdapter.php index e01eb9c8b..6175db52e 100644 --- a/src/Shared/Infrastructure/Image/ImageInputValidationAdapter.php +++ b/src/Shared/Infrastructure/Image/ImageInputValidationAdapter.php @@ -28,6 +28,19 @@ */ final readonly class ImageInputValidationAdapter implements ImageInputValidationPort { + // #region Constants + /** + * Constant INVALID_GIF_CONTENT. + * + * Shared failure text for malformed GIF blocks. + * + * @since 1.1.0 + * + * @var string INVALID_GIF_CONTENT + */ + private const string INVALID_GIF_CONTENT = 'Invalid GIF image content.'; + // #endregion + // #region Methods /** * Method validate @@ -106,7 +119,7 @@ private function validateGifFirstFrame(string $contents, int $canvasWidth, int $ { $length = strlen($contents); if ($length < 13) { - throw new InvalidImageInputException('Invalid GIF image content.'); + throw new InvalidImageInputException(self::INVALID_GIF_CONTENT); } $packed = ord($contents[10]); @@ -122,7 +135,7 @@ private function validateGifFirstFrame(string $contents, int $canvasWidth, int $ } if (',' !== $marker || $offset + 10 > $length) { - throw new InvalidImageInputException('Invalid GIF image content.'); + throw new InvalidImageInputException(self::INVALID_GIF_CONTENT); } $descriptor = substr($contents, $offset + 1, 9); @@ -139,7 +152,7 @@ private function validateGifFirstFrame(string $contents, int $canvasWidth, int $ return; } - throw new InvalidImageInputException('Invalid GIF image content.'); + throw new InvalidImageInputException(self::INVALID_GIF_CONTENT); } /** @@ -169,7 +182,7 @@ private function skipGifSubBlocks(string $contents, int $offset): int $offset += $blockLength; } - throw new InvalidImageInputException('Invalid GIF image content.'); + throw new InvalidImageInputException(self::INVALID_GIF_CONTENT); } // #endregion } diff --git a/src/Shared/MODULE.md b/src/Shared/MODULE.md index 0600b2cad..80580c0a9 100644 --- a/src/Shared/MODULE.md +++ b/src/Shared/MODULE.md @@ -124,6 +124,7 @@ contract tests covering its current error and access-order semantics. - Application: message types, ports, contracts (pagination), factories, and shared exceptions. - Domain: value objects, domain events, traits, and domain services. +- `Uuid::assertValid` provides the constructor's identifier validation without allocating an object; validation-only callers retain their original string and the same invalid-value error. - Infrastructure: Symfony adapters, serializer normalizer, event dispatcher/listener, and infrastructure exceptions. diff --git a/tests/Functional/Api/ParkAnomaliesApiTest.php b/tests/Functional/Api/ParkAnomaliesApiTest.php index 2f8a6e26c..55325f409 100644 --- a/tests/Functional/Api/ParkAnomaliesApiTest.php +++ b/tests/Functional/Api/ParkAnomaliesApiTest.php @@ -127,6 +127,7 @@ public function eitherMissingReadPermissionIsForbidden(): void $client = $this->client(['organization.inspection.read']); $this->get($client, '/park-anomalies-summary', 403); $this->get($client, '/park-anomalies', 403); + $this->get($client, '/inspections?family=fire', 403); } #[Test] @@ -135,6 +136,7 @@ public function equipmentPermissionAloneDoesNotRevealAnomalies(): void $client = $this->client(['organization.equipment.read']); $this->get($client, '/park-anomalies-summary', 403); $this->get($client, '/park-anomalies', 403); + $this->get($client, '/inspections?family=fire', 403); } #[Test] @@ -149,6 +151,10 @@ public function unknownAndForeignExplicitScopesAreEquallyHidden(): void } $client->request('GET', '/api/organizations/' . self::id(900) . '/park-anomalies-summary'); self::assertResponseStatusCodeSame(404); + foreach ([900, 999] as $organization) { + $client->request('GET', '/api/organizations/' . self::id($organization) . '/inspections?family=fire'); + self::assertResponseStatusCodeSame(404); + } } #[Test] diff --git a/tests/Functional/Api/ServiceRequestApiTest.php b/tests/Functional/Api/ServiceRequestApiTest.php index 0c956300e..95c6cfd64 100644 --- a/tests/Functional/Api/ServiceRequestApiTest.php +++ b/tests/Functional/Api/ServiceRequestApiTest.php @@ -17,6 +17,7 @@ use ServiceRequest\Application\Port\Outbound\ServiceRequestRepositoryPort; use ServiceRequest\Application\UseCase\Command\ConvertServiceRequest\{ConvertServiceRequestCommand, ConvertServiceRequestResult}; use ServiceRequest\Domain\Model\ServiceRequest\ServiceRequest; +use ServiceRequest\Domain\ValueObject\{ServiceRequestContent, ServiceRequestTarget}; use Shared\Application\Port\Inbound\CommandBusPort; use Symfony\Bundle\FrameworkBundle\{KernelBrowser, Test\WebTestCase}; @@ -602,7 +603,7 @@ private function seedRequest(string $id = self::REQUEST, string $organizationId { $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); + $request = ServiceRequest::create($id, $organizationId, new ServiceRequestTarget($equipmentId, self::SITE, $snapshot, null, null), new ServiceRequestContent('Seal damaged', 'Replace the broken seal and verify tightness.', 'normal'), $now); if ($qualified) { $request = $request->qualify('Field repair accepted', $now->modify('+1 minute')); } diff --git a/tests/Integration/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepositoryCoverageTest.php b/tests/Integration/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepositoryCoverageTest.php index 3fe765976..bd4fb6041 100644 --- a/tests/Integration/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepositoryCoverageTest.php +++ b/tests/Integration/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepositoryCoverageTest.php @@ -10,7 +10,7 @@ use Equipment\Application\Contract\Equipment\EquipmentListCriteria; use Equipment\Domain\Exception\EquipmentSerialNumberAlreadyExistsException; use Equipment\Domain\Model\Equipment\Equipment; -use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, RestoredEquipmentAssignment}; +use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, RestoredEquipmentAssignment, RestoredEquipmentHistory}; use Equipment\Domain\ValueObject\{EquipmentFacilityId, EquipmentId, EquipmentOrganizationId, EquipmentStatus, EquipmentType}; use Equipment\Infrastructure\Exception\InvalidStorageTimeZoneException; use Equipment\Infrastructure\Persistence\Doctrine\Mapper\EquipmentTimelineParametersMapper; @@ -96,8 +96,10 @@ public function testSaveUpdatesExistingRecordInPlace(): void assignment: new RestoredEquipmentAssignment( status: EquipmentStatus::IN_STOCK, ), - createdAt: new DateTimeImmutable('2026-02-01T00:00:00+00:00'), - updatedAt: new DateTimeImmutable('2026-02-01T00:00:00+00:00'), + history: new RestoredEquipmentHistory( + createdAt: new DateTimeImmutable('2026-02-01T00:00:00+00:00'), + updatedAt: new DateTimeImmutable('2026-02-01T00:00:00+00:00'), + ), )); $this->entityManager->clear(); @@ -117,8 +119,10 @@ public function testSaveUpdatesExistingRecordInPlace(): void status: EquipmentStatus::OPERATIONAL, facilityId: EquipmentFacilityId::fromString(self::FACILITY_A), ), - createdAt: new DateTimeImmutable('2026-06-06T00:00:00+00:00'), - updatedAt: new DateTimeImmutable('2026-02-05T00:00:00+00:00'), + history: new RestoredEquipmentHistory( + createdAt: new DateTimeImmutable('2026-06-06T00:00:00+00:00'), + updatedAt: new DateTimeImmutable('2026-02-05T00:00:00+00:00'), + ), )); $this->entityManager->clear(); @@ -151,8 +155,10 @@ public function testSaveThrowsWhenSerialNumberDuplicatedWithinOrganization(): vo assignment: new RestoredEquipmentAssignment( status: EquipmentStatus::IN_STOCK, ), - createdAt: new DateTimeImmutable('2026-01-01T00:00:00+00:00'), - updatedAt: new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + history: new RestoredEquipmentHistory( + createdAt: new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + updatedAt: new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + ), )); $this->entityManager->clear(); @@ -168,8 +174,10 @@ public function testSaveThrowsWhenSerialNumberDuplicatedWithinOrganization(): vo assignment: new RestoredEquipmentAssignment( status: EquipmentStatus::IN_STOCK, ), - createdAt: new DateTimeImmutable('2026-01-02T00:00:00+00:00'), - updatedAt: new DateTimeImmutable('2026-01-02T00:00:00+00:00'), + history: new RestoredEquipmentHistory( + createdAt: new DateTimeImmutable('2026-01-02T00:00:00+00:00'), + updatedAt: new DateTimeImmutable('2026-01-02T00:00:00+00:00'), + ), )); } @@ -323,8 +331,10 @@ public function testSaveRethrowsAFlushFailureThatIsNotASerialNumberViolation(): assignment: new RestoredEquipmentAssignment( status: EquipmentStatus::IN_STOCK, ), - createdAt: new DateTimeImmutable('2026-02-01T00:00:00+00:00'), - updatedAt: new DateTimeImmutable('2026-02-01T00:00:00+00:00'), + history: new RestoredEquipmentHistory( + createdAt: new DateTimeImmutable('2026-02-01T00:00:00+00:00'), + updatedAt: new DateTimeImmutable('2026-02-01T00:00:00+00:00'), + ), )); } diff --git a/tests/Integration/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepositoryTest.php b/tests/Integration/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepositoryTest.php index 8f11eccd1..c6d009790 100644 --- a/tests/Integration/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepositoryTest.php +++ b/tests/Integration/Equipment/Infrastructure/Persistence/Doctrine/Repository/EquipmentRepositoryTest.php @@ -8,8 +8,8 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\ORM\EntityManagerInterface; use Equipment\Domain\Model\Equipment\Equipment; -use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, RestoredEquipmentAssignment}; -use Equipment\Domain\ValueObject\{EquipmentId, EquipmentOrganizationId, EquipmentStatus, EquipmentType}; +use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, RestoredEquipmentAssignment, RestoredEquipmentHistory}; +use Equipment\Domain\ValueObject\{EquipmentId, EquipmentIdentity, EquipmentOrganizationId, EquipmentStatus, EquipmentType}; use Equipment\Infrastructure\Persistence\Doctrine\Record\EquipmentRecord; use Equipment\Infrastructure\Persistence\Doctrine\Repository\EquipmentRepository; use Organization\Infrastructure\Persistence\Doctrine\Record\OrganizationRecord; @@ -111,6 +111,46 @@ public function testFindPublishedByIdIgnoresDraftRecords(): void ); } + #[Test] + public function testPublishedRetiredEquipmentPreservesReplacementHistoryAcrossAnUpdate(): void + { + $id = '660e8400-e29b-41d4-a716-4466554c0022'; + $predecessorId = '660e8400-e29b-41d4-a716-4466554c0023'; + $successorId = '660e8400-e29b-41d4-a716-4466554c0024'; + $createdAt = new DateTimeImmutable('2025-01-01T00:00:00+00:00'); + $updatedAt = new DateTimeImmutable('2026-01-01T00:00:00+00:00'); + $this->persistEquipmentRecord($id, self::ORGANIZATION_ID, 'published', 'SN-RETIRED', $createdAt, 'decommissioned'); + $this->entityManager->flush(); + + $record = $this->entityManager->find(EquipmentRecord::class, $id); + self::assertInstanceOf(EquipmentRecord::class, $record); + $record->updatedAt = $updatedAt; + $record->predecessorEquipmentId = $predecessorId; + $record->successorEquipmentId = $successorId; + $this->entityManager->flush(); + $this->entityManager->clear(); + + $equipment = $this->repository->findPublishedById(EquipmentId::fromString($id)); + self::assertInstanceOf(Equipment::class, $equipment); + self::assertSame(EquipmentStatus::DECOMMISSIONED, $equipment->status()); + self::assertEquals($createdAt, $equipment->createdAt()); + self::assertEquals($updatedAt, $equipment->updatedAt()); + self::assertSame($predecessorId, $equipment->predecessorEquipmentId()); + self::assertSame($successorId, $equipment->successorEquipmentId()); + + $equipment->updateIdentity(EquipmentIdentity::fromValues(name: 'Retired equipment')); + $this->repository->save($equipment); + $this->entityManager->clear(); + + $reloaded = $this->repository->findPublishedById(EquipmentId::fromString($id)); + self::assertInstanceOf(Equipment::class, $reloaded); + self::assertSame('Retired equipment', $reloaded->identity()->name); + self::assertSame(EquipmentStatus::DECOMMISSIONED, $reloaded->status()); + self::assertEquals($createdAt, $reloaded->createdAt()); + self::assertSame($predecessorId, $reloaded->predecessorEquipmentId()); + self::assertSame($successorId, $reloaded->successorEquipmentId()); + } + #[Test] public function testFindByOrganizationIdIsScopedPaginatedAndExcludesDrafts(): void { @@ -193,8 +233,10 @@ private function buildEquipment( assignment: new RestoredEquipmentAssignment( status: $status, ), - createdAt: new DateTimeImmutable('2026-01-01T00:00:00+00:00'), - updatedAt: new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + history: new RestoredEquipmentHistory( + createdAt: new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + updatedAt: new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + ), ); } diff --git a/tests/Integration/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapterTest.php b/tests/Integration/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapterTest.php new file mode 100644 index 000000000..cb7459618 --- /dev/null +++ b/tests/Integration/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapterTest.php @@ -0,0 +1,314 @@ +get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $manager); + $this->manager = $manager; + $this->organization = $this->organization(self::ORGANIZATION); + $this->root = $this->facility(self::SITE, 'site'); + $this->building = $this->facility(self::BUILDING, 'building', $this->root); + $this->manager->flush(); + } + + /** + * Method testDerivesMinimalRootIdentityAndRetainsArchivedAncestry + * + * @access public + * + * @return void + */ + public function testDerivesMinimalRootIdentityAndRetainsArchivedAncestry(): void + { + $this->root->customerId = self::CUSTOMER; + $this->building->status = 'archived'; + $this->manager->flush(); + $customers = $this->createMock(CustomerLookupPort::class); + $customers->expects(self::once())->method('find')->with(self::CUSTOMER, self::ORGANIZATION)->willReturn( + new CustomerSnapshot(self::CUSTOMER, 'Retained customer', [['name' => 'Hidden contact', 'email' => 'private@example.com', 'phone' => null, 'role' => null]], new DateTimeImmutable('2026-10-01T10:00:00+00:00')), + ); + $target = $this->adapter($customers)->find(self::ORGANIZATION, self::SITE, self::BUILDING); + self::assertNotNull($target); + self::assertSame(self::SITE, $target->id); + self::assertSame('site target', $target->name); + self::assertTrue($target->archived); + self::assertSame(['id' => self::CUSTOMER, 'name' => 'Retained customer'], $target->customer); + } + + /** + * Method testUnassignedRootAndAbsentTargetRemainDistinct + * + * @access public + * + * @return void + */ + public function testUnassignedRootAndAbsentTargetRemainDistinct(): void + { + $customers = $this->createMock(CustomerLookupPort::class); + $customers->expects(self::never())->method('find'); + $adapter = $this->adapter($customers); + $target = $adapter->find(self::ORGANIZATION, self::SITE, null); + self::assertNotNull($target); + self::assertNull($target->customer); + self::assertFalse($target->archived); + self::assertNull($adapter->find(self::ORGANIZATION, null, null)); + } + + /** + * Method testUnavailableAncestryCannotExposeRootOrCustomer + * + * @access public + * + * @param string $scenario unavailable target or ancestry + * + * @return void + */ + #[DataProvider('unavailableAncestries')] + public function testUnavailableAncestryCannotExposeRootOrCustomer(string $scenario): void + { + $siteId = self::SITE; + $organizationId = self::ORGANIZATION; + $this->root->customerId = self::CUSTOMER; + if ('foreign target' === $scenario) { + $organizationId = self::OTHER_ORGANIZATION; + } elseif ('foreign ancestor' === $scenario) { + $this->root->organization = $this->organization(self::OTHER_ORGANIZATION); + } elseif ('draft target' === $scenario) { + $this->building->recordStatus = 'draft'; + } elseif ('draft ancestor' === $scenario) { + $this->root->recordStatus = 'draft'; + } elseif ('cycle' === $scenario) { + $this->root->parentFacility = $this->building; + $this->root->customerId = null; + } elseif ('non-site root' === $scenario) { + $this->root->type = 'building'; + $this->root->customerId = null; + } else { + $siteId = self::CUSTOMER; + } + $this->manager->flush(); + $customers = $this->createMock(CustomerLookupPort::class); + $customers->expects(self::never())->method('find'); + self::assertNull($this->adapter($customers)->find($organizationId, $siteId, self::BUILDING)); + } + + /** + * Method unavailableAncestries + * + * @access public + * + * @return iterable inaccessible ancestry cases + */ + public static function unavailableAncestries(): iterable + { + foreach (['foreign target', 'foreign ancestor', 'draft target', 'draft ancestor', 'cycle', 'non-site root', 'explicit other site'] as $scenario) { + yield $scenario => [$scenario]; + } + } + + /** + * Method testRootMustRemainInsideTheSixtyFourRowAncestryBound + * + * @access public + * + * @param int $descendants chain length below the root + * @param bool $available whether the actual root lies inside the bound + * + * @return void + */ + #[DataProvider('ancestryDepths')] + public function testRootMustRemainInsideTheSixtyFourRowAncestryBound(int $descendants, bool $available): void + { + $target = $this->root; + for ($index = 0; $index < $descendants; ++$index) { + $id = 'c7200000-0000-4000-8000-' . str_pad((string) (100 + $index), 12, '0', STR_PAD_LEFT); + $target = $this->facility($id, 'zone', $target); + } + $this->manager->flush(); + $customers = $this->createMock(CustomerLookupPort::class); + $customers->expects(self::never())->method('find'); + $resolved = $this->adapter($customers)->find(self::ORGANIZATION, null, $target->id); + self::assertSame($available, null !== $resolved); + if (null !== $resolved) { + self::assertSame(self::SITE, $resolved->id); + } + } + + /** + * Method ancestryDepths + * + * @access public + * + * @return iterable boundary cases + */ + public static function ancestryDepths(): iterable + { + yield 'root is the sixty-fourth row' => [63, true]; + yield 'root is beyond the bound' => [64, false]; + } + + /** + * Method testUnavailableRetainedCustomerCannotProducePartialTarget + * + * @access public + * + * @return void + */ + public function testUnavailableRetainedCustomerCannotProducePartialTarget(): void + { + $this->root->customerId = self::CUSTOMER; + $this->manager->flush(); + $customers = $this->createMock(CustomerLookupPort::class); + $customers->expects(self::once())->method('find')->with(self::CUSTOMER, self::ORGANIZATION)->willReturn(null); + self::assertNull($this->adapter($customers)->find(self::ORGANIZATION, null, self::BUILDING)); + } + + /** + * Method organization + * + * @access private + * + * @param string $id organization identifier + * + * @return OrganizationRecord the test organization + */ + private function organization(string $id): OrganizationRecord + { + $organization = new OrganizationRecord(); + $organization->id = $id; + $organization->name = 'Site target tests'; + $organization->slug = 'site-target-' . $id; + $organization->ownerUserId = self::CUSTOMER; + $organization->createdByUserId = self::CUSTOMER; + $organization->createdAt = $organization->updatedAt = new DateTimeImmutable('2026-10-08T10:00:00+00:00'); + $this->manager->persist($organization); + + return $organization; + } + + /** + * Method facility + * + * @access private + * + * @param string $id facility identifier + * @param string $type stored facility type + * @param FacilityRecord|null $parent retained parent relation + * + * @return FacilityRecord the test facility + */ + private function facility(string $id, string $type, ?FacilityRecord $parent = null): FacilityRecord + { + $facility = new FacilityRecord(); + $facility->id = $id; + $facility->organization = $this->organization; + $facility->type = $type; + $facility->name = $type . ' target'; + $facility->parentFacility = $parent; + $facility->createdAt = $facility->updatedAt = new DateTimeImmutable('2026-10-08T10:00:00+00:00'); + $this->manager->persist($facility); + + return $facility; + } + + /** + * Method adapter + * + * @access private + * + * @param CustomerLookupPort $customers scoped customer lookup double + * + * @return ServiceRequestSiteTargetAdapter the adapter using the real main manager + */ + private function adapter(CustomerLookupPort $customers): ServiceRequestSiteTargetAdapter + { + return new ServiceRequestSiteTargetAdapter($this->manager, $customers, $this->createStub(FacilityHierarchyPort::class)); + } + // #endregion +} diff --git a/tests/Integration/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapterTest.php b/tests/Integration/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapterTest.php index 0bff1f858..6f2f4e6eb 100644 --- a/tests/Integration/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapterTest.php +++ b/tests/Integration/Intervention/Infrastructure/Adapter/Publication/InterventionPublicationFactsAdapterTest.php @@ -9,7 +9,7 @@ use Doctrine\ORM\EntityManagerInterface; use Equipment\Infrastructure\Persistence\Doctrine\Record\EquipmentRecord; use Facility\Infrastructure\Persistence\Doctrine\Record\FacilityRecord; -use Intervention\Application\Contract\Publication\InterventionFactsScopeTooLarge; +use Intervention\Application\Contract\Publication\{InterventionEconomicSourceFilter, InterventionFactsScopeTooLarge}; use Intervention\Application\Port\Inbound\InterventionPublicationFactsPort; use Intervention\Infrastructure\Adapter\Publication\InterventionPublicationFactsAdapter; use Intervention\Infrastructure\Persistence\Doctrine\Record\{InterventionRecord, InterventionWorkItemRecord, PublicationRecord}; @@ -80,7 +80,7 @@ public function testEconomicDatesUseCompletedPublicationWithInclusiveStartAndExc 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); + $page = $this->port()->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(from: $from, to: $to)); self::assertSame(4, $page->totalItems); self::assertSame(serialize($result->items), serialize($page->items)); } @@ -115,23 +115,23 @@ public function testFinancialIdentityUnionPreservesScopedExactCountsAndPagesBefo $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); + $result = $port->economicPage($this->organization->id, page: $page, itemsPerPage: 1, filter: new InterventionEconomicSourceFilter(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); + $emptyPage = $port->economicPage($this->organization->id, page: 3, itemsPerPage: 1, filter: new InterventionEconomicSourceFilter(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); + self::assertSame(0, $port->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(search: 'absent', equipmentId: self::id(999), financialInterventionIds: $ids))->totalItems); + self::assertSame(0, $port->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(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))); + $this->port()->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(financialInterventionIds: array_map(self::id(...), range(1, 10001)))); } public function testCapturedPublicationIdentityAndOffsetDateTakePriorityOverLaterCompletionRows(): void @@ -169,7 +169,7 @@ public function testLiteralSearchDoesNotInterpretPercentUnderscoreOrEscapeCharac $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: '%_'); + $economic = $this->port()->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(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); @@ -184,7 +184,7 @@ public function testAccentedSearchAcceptsOneHundredAndSixtyCharacters(): void $this->entityManager->flush(); $published = $this->port()->publishedPage($this->organization->id, search: $search); - $economic = $this->port()->economicPage($this->organization->id, search: $search); + $economic = $this->port()->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(search: $search)); self::assertSame(1, $published->totalItems); self::assertSame($captured->id, $published->items[0]->id); self::assertSame(1, $economic->totalItems); @@ -262,11 +262,11 @@ public function testAllocationFiltersUseEachCapturedTaskSiteCustomerAndExactEqui $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) { + foreach ([$port->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(siteId: self::id(311))), $port->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(customerId: self::id(310))), $port->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(equipmentId: self::id(312))), $port->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(siteId: self::id(321))), $port->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(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); + self::assertSame(0, $port->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(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)); @@ -300,7 +300,7 @@ public function testLiveAllocationSupportsHistoricalJsonAndDoesNotResolveForeign 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); + $result = $this->port()->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(siteId: $site->id, customerId: $customer->id, equipmentId: $equipment->id)); self::assertSame(1, $result->totalItems); self::assertSame($record->id, $result->items[0]->id); } @@ -385,9 +385,9 @@ public function testMissingDossiersNeverInventHistoryFromRichLiveRecords(): void 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); + self::assertSame(0, $port->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(siteId: $site->id))->totalItems); + self::assertSame(0, $port->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(customerId: $customer->id))->totalItems); + self::assertSame(0, $port->economicPage($this->organization->id, filter: new InterventionEconomicSourceFilter(equipmentId: $equipment->id))->totalItems); } public function testVersionOneRetainsKnownTargetsWithoutInventingFullAssetIdentityOrValidation(): void diff --git a/tests/Integration/Intervention/Infrastructure/Adapter/Workflow/InterventionRetentionConcurrencyTest.php b/tests/Integration/Intervention/Infrastructure/Adapter/Workflow/InterventionRetentionConcurrencyTest.php index 621e2296d..79830e89c 100644 --- a/tests/Integration/Intervention/Infrastructure/Adapter/Workflow/InterventionRetentionConcurrencyTest.php +++ b/tests/Integration/Intervention/Infrastructure/Adapter/Workflow/InterventionRetentionConcurrencyTest.php @@ -124,7 +124,7 @@ protected function setUp(): void $task->createdAt = $task->updatedAt = $now; $main->persist($task); $main->flush(); - $store = new InventoryRepository($main); + $store = new InventoryRepository($main->getConnection()); $this->a->transactional(function () use ($store): void { $store->saveReference('parts', new InventoryReference(self::PART, self::ORG, 'RET', 'Retained part', 'piece', 'part')); $store->saveReference('warehouses', new InventoryReference(self::WAREHOUSE, self::ORG, 'RET', 'Retained warehouse')); @@ -163,7 +163,7 @@ public function deletionWaitsForTheOwnerWriteThenRetainsCommittedFacts(string $k $this->b->beginTransaction(); $work = new InterventionCostSourceFactsAdapter($this->writer); $currency = new MaintenanceCurrencyAdapter($this->b); - $stock = new InventoryRepository($this->writer); + $stock = new InventoryRepository($this->writer->getConnection()); $inventory = new InventoryInterventionResourcesAdapter($stock, $currency); $costs = new MaintenanceCostRepository($this->writer); $calculator = new MaintenanceCostCalculator(); diff --git a/tests/Integration/Inventory/InventoryLastPieceConcurrencyTest.php b/tests/Integration/Inventory/InventoryLastPieceConcurrencyTest.php index 22fd3b187..2564c9841 100644 --- a/tests/Integration/Inventory/InventoryLastPieceConcurrencyTest.php +++ b/tests/Integration/Inventory/InventoryLastPieceConcurrencyTest.php @@ -24,7 +24,7 @@ use Procurement\Application\Service\ProcurementProjection; use Procurement\Application\UseCase\Command\ManageProcurement\{ManageProcurementCommand, ManageProcurementHandler}; use Procurement\Domain\Model\{PurchaseOrder, Supplier}; -use Procurement\Domain\ValueObject\ProcurementLine; +use Procurement\Domain\ValueObject\{ProcurementGoodsIdentity, ProcurementLine, SupplierDetails}; use Procurement\Infrastructure\Persistence\Doctrine\Repository\ProcurementRepository; use Shared\Application\Port\Inbound\CommandBusPort; use Shared\Application\Port\Outbound\{ClockPort, EventDispatcherPort, TransactionManagerPort,UuidGeneratorPort}; @@ -60,7 +60,7 @@ protected function setUp(): void $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); + $store = new InventoryRepository($em->getConnection()); $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')); @@ -162,8 +162,8 @@ private function procurement(EntityManagerInterface $em): ManageProcurementHandl { $repository = new ProcurementRepository($this->b); $now = new DateTimeImmutable('2026-10-06T12:00:00Z'); - $supplier = Supplier::create('bec10000-0000-4000-8000-000000000100', self::ORG, 'Concurrency supplier', null, null, null, [], $now); - $order = PurchaseOrder::create('bec10000-0000-4000-8000-000000000101', self::ORG, $supplier->id, 'EUR', 'Concurrent receipt', [ProcurementLine::create('bec10000-0000-4000-8000-000000000102', 'part', self::PART, null, [], '1', '7')], $now); + $supplier = Supplier::create('bec10000-0000-4000-8000-000000000100', self::ORG, new SupplierDetails('Concurrency supplier', null, null, null, []), $now); + $order = PurchaseOrder::create('bec10000-0000-4000-8000-000000000101', self::ORG, $supplier->id, 'EUR', 'Concurrent receipt', [ProcurementLine::create('bec10000-0000-4000-8000-000000000102', new ProcurementGoodsIdentity('part', self::PART, null, []), '1', '7')], $now); $order->order(1, $now); $repository->saveSupplier($supplier); $repository->saveOrder($order); @@ -178,7 +178,7 @@ private function procurement(EntityManagerInterface $em): ManageProcurementHandl $ids = $this->createStub(UuidGeneratorPort::class); $ids->method('generate')->willReturn('bec10000-0000-4000-8000-000000000104'); - return new ManageProcurementHandler($repository, $authorization, new MaintenanceCurrencyAdapter($this->b), new InventoryPartDirectoryAdapter(new InventoryRepository($em)), new InventoryStockReceiptAdapter($bus), $this->createStub(EquipmentReserveReceiptPort::class), new ProcurementProjection(), $clock, $ids, $this->createStub(EventDispatcherPort::class)); + return new ManageProcurementHandler($repository, $authorization, new MaintenanceCurrencyAdapter($this->b), new InventoryPartDirectoryAdapter(new InventoryRepository($em->getConnection())), new InventoryStockReceiptAdapter($bus), $this->createStub(EquipmentReserveReceiptPort::class), new ProcurementProjection(), $clock, $ids, $this->createStub(EventDispatcherPort::class)); } private function consume(int $n): ApplyInventoryStockCommand @@ -196,7 +196,7 @@ private function handler(EntityManagerInterface $em, Connection $connection, int $context = $this->createStub(InterventionInventoryContextPort::class); $context->method('validate')->willReturn(new InventoryInterventionContext(false)); - return new ApplyInventoryStockHandler(new InventoryRepository($em), $tx, $ids, $currency, $context); + return new ApplyInventoryStockHandler(new InventoryRepository($em->getConnection()), $tx, $ids, $currency, $context); } private function clean(): void diff --git a/tests/Integration/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepositoryTest.php b/tests/Integration/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepositoryTest.php index a35231436..4394edbf5 100644 --- a/tests/Integration/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepositoryTest.php +++ b/tests/Integration/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepositoryTest.php @@ -14,7 +14,7 @@ use Procurement\Domain\Event\ProcurementChangedEvent; use Procurement\Domain\Exception\ProcurementException; use Procurement\Domain\Model\{PurchaseOrder, Supplier}; -use Procurement\Domain\ValueObject\ProcurementLine; +use Procurement\Domain\ValueObject\{ProcurementGoodsIdentity, ProcurementLine, SupplierDetails}; use Procurement\Infrastructure\Persistence\Doctrine\Repository\ProcurementRepository; use RuntimeException; use Shared\Application\Factory\UuidFactory; @@ -85,8 +85,8 @@ protected function tearDown(): void #[Test] public function creationResourcesAndReplayIdentitiesCommitOrRollbackTogether(): void { - $supplier = Supplier::create(self::SUPPLIER, self::ORG, 'Creation retry supplier', null, null, null, [], $this->now()); - $order = PurchaseOrder::create(self::ORDER, self::ORG, self::SUPPLIER, 'EUR', 'Creation retry draft', [ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '1', null)], $this->now()); + $supplier = Supplier::create(self::SUPPLIER, self::ORG, new SupplierDetails('Creation retry supplier', null, null, null, []), $this->now()); + $order = PurchaseOrder::create(self::ORDER, self::ORG, self::SUPPLIER, 'EUR', 'Creation retry draft', [ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), '1', null)], $this->now()); $write = function () use ($supplier, $order): void { $this->repository->saveSupplier($supplier); $this->repository->saveOrder($order); @@ -218,7 +218,7 @@ public function orderPhysicalReceiptOperationAndOutboxCommitOrRollbackTogether() #[Test] public function foreignIdentifiersAreNotLoadedAndUnrelatedOrganizationsDoNotBlock(): void { - $this->repository->saveSupplier(Supplier::create(self::SUPPLIER, self::ORG, 'Supplier', null, null, null, [], $this->now())); + $this->repository->saveSupplier(Supplier::create(self::SUPPLIER, self::ORG, new SupplierDetails('Supplier', null, null, null, []), $this->now())); $this->repository->saveOrder($this->order()); $this->repository->saveReceipt($this->receipt()); $other = new ProcurementRepository($this->b); @@ -240,12 +240,12 @@ public function foreignIdentifiersAreNotLoadedAndUnrelatedOrganizationsDoNotBloc #[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()); + $active = Supplier::create(self::SUPPLIER, self::ORG, new SupplierDetails('Safety active', null, null, null, []), $this->now()); + $archived = Supplier::create(self::ORDER, self::ORG, new SupplierDetails('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())); + $this->repository->saveSupplier(Supplier::create(self::LINE, self::ORG, new SupplierDetails('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))); @@ -258,7 +258,7 @@ public function allSupplierSelectionRetainsActiveAndArchivedWithExactPagedCount( 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 = PurchaseOrder::create(self::ORDER, self::ORG, self::SUPPLIER, 'EUR', 'Purchase', [ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), $quantity, '4.250000')], $this->now()); $order->order(1, $this->now()); return $order; diff --git a/tests/Integration/ServiceRequest/Infrastructure/Persistence/Doctrine/Repository/ServiceRequestRepositoryTest.php b/tests/Integration/ServiceRequest/Infrastructure/Persistence/Doctrine/Repository/ServiceRequestRepositoryTest.php index c49a5d4fe..838b90444 100644 --- a/tests/Integration/ServiceRequest/Infrastructure/Persistence/Doctrine/Repository/ServiceRequestRepositoryTest.php +++ b/tests/Integration/ServiceRequest/Infrastructure/Persistence/Doctrine/Repository/ServiceRequestRepositoryTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\Attributes\{DataProvider, Test}; use ServiceRequest\Domain\Exception\ServiceRequestException; use ServiceRequest\Domain\Model\ServiceRequest\ServiceRequest; +use ServiceRequest\Domain\ValueObject\{ServiceRequestContent, ServiceRequestTarget}; use ServiceRequest\Domain\ValueObject\ServiceRequestConversionReceipt; use ServiceRequest\Infrastructure\Persistence\Doctrine\Repository\ServiceRequestRepository; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -153,7 +154,7 @@ public function retainsDecisionReasonsAndTheirActualUtcDates(string $state): voi #[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()); + $request = ServiceRequest::create(self::REQUEST_ID, self::ORGANIZATION_ID, new ServiceRequestTarget(null, self::SITE_ID, ['site' => ['id' => self::SITE_ID, 'name' => 'Warehouse']], null, null), new ServiceRequestContent('Locate leak', 'Equipment to identify', 'normal'), self::now()); $this->repository->save($request); $this->entityManager->clear(); $loaded = $this->repository->find(self::REQUEST_ID, self::ORGANIZATION_ID); @@ -382,7 +383,7 @@ private static function now(): DateTimeImmutable 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); + return ServiceRequest::create($id, $organizationId, new ServiceRequestTarget($equipmentId, $siteId, ['equipment' => ['id' => $equipmentId, 'name' => 'Extinguisher', 'assetCode' => 'EXT-01'], 'site' => ['id' => $siteId, 'name' => 'Warehouse'], 'customer' => ['id' => self::SECOND_OPERATION_ID, 'name' => 'Building owner']], self::INSPECTION_ID, self::NON_CONFORMITY_ID), new ServiceRequestContent($title, $description, 'high'), self::now()); } private static function convertedRequest(string $id = self::REQUEST_ID, string $organizationId = self::ORGANIZATION_ID): ServiceRequest diff --git a/tests/Unit/Customer/Application/UseCase/CustomerHandlersTest.php b/tests/Unit/Customer/Application/UseCase/CustomerHandlersTest.php index 88022bfec..a1bb43cc3 100644 --- a/tests/Unit/Customer/Application/UseCase/CustomerHandlersTest.php +++ b/tests/Unit/Customer/Application/UseCase/CustomerHandlersTest.php @@ -12,10 +12,11 @@ use Customer\Application\UseCase\Query\ListCustomers\{ListCustomersHandler, ListCustomersQuery}; use Customer\Domain\Exception\CustomerException; use Customer\Domain\Model\Customer\Customer; +use Customer\Domain\ValueObject\CustomerDetails; use DateTimeImmutable; use Organization\Application\Contract\Authorization\OrganizationAccessDecision; use Organization\Application\Port\Inbound\OrganizationAuthorizationPort; -use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\{DataProvider, Test}; use PHPUnit\Framework\TestCase; use Shared\Application\Port\Outbound\{ClockPort, EventDispatcherPort, TransactionManagerPort, UuidGeneratorPort}; @@ -69,7 +70,7 @@ public function hidesOutsideOrganizationBeforeReadingCustomers(): void public function archivalReplayDoesNotWriteAgain(): void { $now = new DateTimeImmutable(); - $customer = Customer::create(self::ID, self::ORG, 'Owner', null, null, null, [], $now)->archive($now); + $customer = Customer::create(self::ID, self::ORG, new CustomerDetails('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'); @@ -82,13 +83,66 @@ public function archivalReplayDoesNotWriteAgain(): void self::assertSame(2, $result->customer->revision); } + #[Test] + public function restorationReplayDoesNotWriteAgain(): void + { + $now = new DateTimeImmutable(); + $customer = Customer::create(self::ID, self::ORG, new CustomerDetails('Owner', null, null, null, []), $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->modify('+1 day')); + $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, 'restore', 1)); + + self::assertSame(1, $result->customer->revision); + self::assertSame($now, $result->customer->createdAt); + self::assertSame($now, $result->customer->updatedAt); + } + + #[Test] + #[DataProvider('deniedMutationPreconditions')] + public function deniesMutationBeforeCheckingPreconditions(OrganizationAccessDecision $decision, ?int $revision, string $message): void + { + $repository = $this->createMock(CustomerRepositoryPort::class); + $repository->expects(self::never())->method('find'); + $repository->expects(self::never())->method('save'); + $transactions = $this->createMock(TransactionManagerPort::class); + $transactions->expects(self::never())->method('transactional'); + $clock = $this->createMock(ClockPort::class); + $clock->expects(self::never())->method('now'); + $events = $this->createMock(EventDispatcherPort::class); + $events->expects(self::never())->method('dispatch'); + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::once())->method('resolveAccess')->with(self::ACTOR, self::ORG, 'organization.customers.manage')->willReturn($decision); + $handler = new ChangeCustomerHandler($repository, new CustomerAccessGuard($authorization), $clock, $transactions, $events); + + $this->expectException(CustomerException::class); + $this->expectExceptionMessage($message); + $handler(new ChangeCustomerCommand(self::ACTOR, self::ORG, self::ID, 'patch', $revision, ['name' => 'Changed'])); + } + + /** + * @return iterable + */ + public static function deniedMutationPreconditions(): iterable + { + yield 'missing permission, absent revision' => [OrganizationAccessDecision::MISSING_PERMISSION, null, 'Missing customer permission.']; + yield 'missing permission, malformed revision' => [OrganizationAccessDecision::MISSING_PERMISSION, -1, 'Missing customer permission.']; + yield 'outside scope, absent revision' => [OrganizationAccessDecision::OUTSIDE_SCOPE, null, 'Customer not found.']; + yield 'outside scope, malformed revision' => [OrganizationAccessDecision::OUTSIDE_SCOPE, -1, 'Customer not found.']; + } + #[Test] public function staleRevisionNeverWrites(): void { $now = new DateTimeImmutable(); - $customer = Customer::create(self::ID, self::ORG, 'Owner', null, null, null, [], $now); + $customer = Customer::create(self::ID, self::ORG, new CustomerDetails('Owner', null, null, null, []), $now); $repository = $this->createMock(CustomerRepositoryPort::class); - $repository->method('find')->willReturn($customer); + $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::never())->method('now'); diff --git a/tests/Unit/Customer/Domain/Model/Customer/CustomerTest.php b/tests/Unit/Customer/Domain/Model/Customer/CustomerTest.php index 84c5c2b40..23e2fdff6 100644 --- a/tests/Unit/Customer/Domain/Model/Customer/CustomerTest.php +++ b/tests/Unit/Customer/Domain/Model/Customer/CustomerTest.php @@ -6,6 +6,7 @@ use Customer\Domain\Exception\CustomerException; use Customer\Domain\Model\Customer\Customer; +use Customer\Domain\ValueObject\{CustomerDetails, CustomerHistory}; use DateTimeImmutable; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -21,7 +22,7 @@ final class CustomerTest extends TestCase public function normalizesContactDetailsAndPreservesHistoryAcrossArchive(): void { $now = new DateTimeImmutable('2026-10-06T12:00:00Z'); - $customer = Customer::create(self::ID, self::ORG, ' Building Owner ', ' C-1 ', ' ', '+33 123', [['name' => ' Pat ', 'email' => 'pat@example.com', 'role' => ' Manager ']], $now); + $customer = Customer::create(self::ID, self::ORG, new CustomerDetails(' Building Owner ', ' C-1 ', ' ', '+33 123', [['name' => ' Pat ', 'email' => 'pat@example.com', 'role' => ' Manager ']]), $now); self::assertSame('Building Owner', $customer->name); self::assertSame('C-1', $customer->code); self::assertNull($customer->email); @@ -35,13 +36,14 @@ public function normalizesContactDetailsAndPreservesHistoryAcrossArchive(): void self::assertNull($restored->archivedAt); self::assertSame(3, $restored->revision); self::assertSame($customer->createdAt, $restored->createdAt); + self::assertSame($restored, $restored->restore($now->modify('+4 days'))); } #[Test] public function patchesOmittedFieldsAndExplicitNullDifferently(): void { $now = new DateTimeImmutable(); - $customer = Customer::create(self::ID, self::ORG, 'Owner', 'C-1', 'owner@example.com', null, [], $now); + $customer = Customer::create(self::ID, self::ORG, new CustomerDetails('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); @@ -52,14 +54,62 @@ public function patchesOmittedFieldsAndExplicitNullDifferently(): void public function refusesInvalidContactAndNullName(): void { $this->expectException(CustomerException::class); - Customer::create(self::ID, self::ORG, 'Owner', null, null, null, [['name' => 'Pat', 'email' => 'invalid']], new DateTimeImmutable()); + Customer::create(self::ID, self::ORG, new CustomerDetails('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()); + $customer = Customer::create(self::ID, self::ORG, new CustomerDetails('Owner', null, null, null, []), new DateTimeImmutable()); $this->expectException(CustomerException::class); $customer->change(['name' => null], new DateTimeImmutable()); } + + #[Test] + public function reconstitutesEveryPersistedFieldWithoutNormalization(): void + { + $details = new CustomerDetails(' Retained owner ', '', 'legacy-address', ' +33 123 ', [['name' => ' Pat ', 'email' => null, 'phone' => '', 'role' => ' Manager ']]); + $history = new CustomerHistory(new DateTimeImmutable('2026-10-07T11:00:00Z'), new DateTimeImmutable('2026-10-06T10:00:00Z'), new DateTimeImmutable('2026-10-07T12:00:00Z'), 9); + $customer = Customer::reconstitute(self::ID, self::ORG, $details, $history); + + self::assertSame(self::ID, $customer->id); + self::assertSame(self::ORG, $customer->organizationId); + self::assertSame($details->name, $customer->name); + self::assertSame($details->code, $customer->code); + self::assertSame($details->email, $customer->email); + self::assertSame($details->phone, $customer->phone); + self::assertSame($details->contacts, $customer->contacts); + self::assertSame($history->archivedAt, $customer->archivedAt); + self::assertSame($history->createdAt, $customer->createdAt); + self::assertSame($history->updatedAt, $customer->updatedAt); + self::assertSame($history->revision, $customer->revision); + } + + #[Test] + public function editsRestoredDetailsWithoutResettingHistoryOrOmittedFields(): void + { + $details = new CustomerDetails('Owner', 'C-1', 'owner@example.com', '+33 123', [['name' => 'Pat', 'email' => 'pat@example.com', 'phone' => null, 'role' => 'Manager']]); + $history = new CustomerHistory(new DateTimeImmutable('2026-10-07T11:00:00Z'), new DateTimeImmutable('2026-10-06T10:00:00Z'), new DateTimeImmutable('2026-10-07T12:00:00Z'), 9); + $now = new DateTimeImmutable('2026-10-08T12:00:00Z'); + $customer = Customer::reconstitute(self::ID, self::ORG, $details, $history); + $changed = $customer->change(['email' => null, 'phone' => null], $now); + + self::assertSame(self::ID, $changed->id); + self::assertSame(self::ORG, $changed->organizationId); + self::assertSame($details->name, $changed->name); + self::assertSame($details->code, $changed->code); + self::assertSame($details->contacts, $changed->contacts); + self::assertNull($changed->email); + self::assertNull($changed->phone); + self::assertSame($history->archivedAt, $changed->archivedAt); + self::assertSame($history->createdAt, $changed->createdAt); + self::assertSame($now, $changed->updatedAt); + self::assertSame(10, $changed->revision); + + $restored = $changed->restore($now->modify('+1 day')); + self::assertNull($restored->archivedAt); + self::assertSame($changed->contacts, $restored->contacts); + self::assertSame($history->createdAt, $restored->createdAt); + self::assertSame(11, $restored->revision); + } } diff --git a/tests/Unit/Customer/Presentation/Api/CustomerTransportTest.php b/tests/Unit/Customer/Presentation/Api/CustomerTransportTest.php index 9759c1498..cb631ff15 100644 --- a/tests/Unit/Customer/Presentation/Api/CustomerTransportTest.php +++ b/tests/Unit/Customer/Presentation/Api/CustomerTransportTest.php @@ -10,12 +10,13 @@ use Customer\Application\UseCase\Command\CreateCustomer\{CreateCustomerCommand, CreateCustomerResult}; use Customer\Application\UseCase\Query\ListCustomers\{ListCustomersQuery, ListCustomersResult}; use Customer\Domain\Model\Customer\Customer; +use Customer\Domain\ValueObject\CustomerDetails; use Customer\Presentation\Api\Dto\Input\{ChangeCustomerInput, CreateCustomerInput}; use Customer\Presentation\Api\Operation\CustomerOperations; use Customer\Presentation\Api\Processor\CustomerProcessor; use Customer\Presentation\Api\Provider\CustomerProvider; use DateTimeImmutable; -use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\{DataProvider, Test}; use PHPUnit\Framework\TestCase; use Shared\Application\Port\Inbound\{CommandBusPort, QueryBusPort}; use Shared\Application\Port\Outbound\CurrentActorPort; @@ -52,6 +53,33 @@ public function patchOnlyIncludesSuppliedFieldsAndParsesQuotedRevision(): void new CustomerProcessor($commands, $this->actor(), $requests)->process(new ChangeCustomerInput(), new Patch(name: CustomerOperations::PATCH), ['organizationId' => self::ORG, 'id' => self::ID]); } + #[Test] + #[DataProvider('revisionHeaders')] + public function preservesAbsentAndMalformedRevisionHeadersForApplicationChecks(?string $header, ?int $revision): void + { + $requests = new RequestStack(); + $request = Request::create('/', 'PATCH', content: '{"code":null}'); + if (null !== $header) { + $request->headers->set('If-Match', $header); + } + $requests->push($request); + $commands = $this->createMock(CommandBusPort::class); + $commands->expects(self::once())->method('dispatch')->with(self::callback(static fn (ChangeCustomerCommand $command): bool => $revision === $command->expectedRevision && ['code' => null] === $command->changes))->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]); + } + + /** + * @return iterable + */ + public static function revisionHeaders(): iterable + { + yield 'absent' => [null, null]; + yield 'unquoted' => ['revision-1', -1]; + yield 'invalid number' => ['"revision-invalid"', -1]; + yield 'negative number' => ['"revision--1"', -1]; + } + #[Test] public function collectionCarriesSearchArchivalAndPagination(): void { @@ -66,7 +94,7 @@ public function collectionCarriesSearchArchivalAndPagination(): void private function view(): CustomerView { - return CustomerView::fromCustomer(Customer::create(self::ID, self::ORG, 'Owner', null, null, null, [], new DateTimeImmutable())); + return CustomerView::fromCustomer(Customer::create(self::ID, self::ORG, new CustomerDetails('Owner', null, null, null, []), new DateTimeImmutable())); } private function actor(): CurrentActorPort diff --git a/tests/Unit/Equipment/Application/Service/EquipmentReserveReceiptServiceTest.php b/tests/Unit/Equipment/Application/Service/EquipmentReserveReceiptServiceTest.php index 659bd4028..f3b9f155f 100644 --- a/tests/Unit/Equipment/Application/Service/EquipmentReserveReceiptServiceTest.php +++ b/tests/Unit/Equipment/Application/Service/EquipmentReserveReceiptServiceTest.php @@ -11,7 +11,7 @@ use Organization\Application\Contract\Authorization\OrganizationAccessDecision; use Organization\Application\Contract\Quota\{OrganizationQuotaExceededException,OrganizationQuotaResource}; use Organization\Application\Port\Inbound\{OrganizationAuthorizationPort, OrganizationQuotaPort}; -use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\{DataProvider, Test}; use PHPUnit\Framework\TestCase; use Shared\Application\Port\Inbound\CommandBusPort; use Shared\Application\Port\Outbound\TransactionManagerPort; @@ -70,6 +70,35 @@ public function sharedTemplatesCannotRepeatUniquePatrimonialIdentities(): void $this->service($commands, $quota)->reserve(new EquipmentReserveReceiptRequest(self::ORG, self::ACTOR, 'fire_extinguisher', ['assetCode' => 'SAME'], 2)); } + #[Test] + #[DataProvider('invalidIdentifiers')] + public function invalidIdentifiersAreRejectedBeforeAuthorizationOrReserveCreation(string $organizationId, string $actorId): void + { + $commands = $this->createMock(CommandBusPort::class); + $commands->expects(self::never())->method('dispatch'); + $transactions = $this->createMock(TransactionManagerPort::class); + $transactions->expects(self::never())->method('transactional'); + $quota = $this->createMock(OrganizationQuotaPort::class); + $quota->expects(self::never())->method('assertCanAddMultiple'); + $catalog = $this->createMock(EquipmentTypeCatalogPort::class); + $catalog->expects(self::never())->method('find'); + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::never())->method('resolveAccess'); + + $this->expectException(InvalidValueException::class); + new EquipmentReserveReceiptService($commands, $transactions, $quota, $catalog, $authorization) + ->reserve(new EquipmentReserveReceiptRequest($organizationId, $actorId, 'fire_extinguisher', [], 1)); + } + + /** + * @return iterable + */ + public static function invalidIdentifiers(): iterable + { + yield 'organization' => ['invalid-organization', self::ACTOR]; + yield 'actor' => [self::ORG, 'invalid-actor']; + } + private function service(CommandBusPort $commands, OrganizationQuotaPort $quota, OrganizationAccessDecision $decision = OrganizationAccessDecision::GRANTED, bool $archived = false): EquipmentReserveReceiptService { $catalog = $this->createStub(EquipmentTypeCatalogPort::class); diff --git a/tests/Unit/Equipment/Domain/Model/Equipment/EquipmentTest.php b/tests/Unit/Equipment/Domain/Model/Equipment/EquipmentTest.php index 4fa21200d..cfd63bd37 100644 --- a/tests/Unit/Equipment/Domain/Model/Equipment/EquipmentTest.php +++ b/tests/Unit/Equipment/Domain/Model/Equipment/EquipmentTest.php @@ -7,8 +7,8 @@ use DateTimeImmutable; use Equipment\Domain\Exception\EquipmentAlreadyDecommissionedException; use Equipment\Domain\Model\Equipment\Equipment; -use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, RestoredEquipmentAssignment}; -use Equipment\Domain\ValueObject\{EquipmentFacilityId, EquipmentId, EquipmentOrganizationId, EquipmentStatus, EquipmentType}; +use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, RestoredEquipmentAssignment, RestoredEquipmentHistory}; +use Equipment\Domain\ValueObject\{EquipmentFacilityId, EquipmentId, EquipmentIdentity, EquipmentOrganizationId, EquipmentStatus, EquipmentType, EquipmentTypeCode, PlanPosition}; use InvalidArgumentException; use PHPUnit\Framework\Attributes\{CoversClass, Test}; use PHPUnit\Framework\TestCase; @@ -170,6 +170,10 @@ public function testReconstituteRestoresPersistedState(): void $updatedAt = new DateTimeImmutable('2026-02-01 09:00:00'); $installedAt = new DateTimeImmutable('2026-01-15 10:00:00'); $commissionedAt = new DateTimeImmutable('2026-01-20 11:00:00'); + $identity = EquipmentIdentity::fromValues('Sprinkler ceiling', 'SP-777', 'high'); + $position = new PlanPosition(self::FACILITY_ID, 0.25, 0.75); + $predecessorId = '550e8400-e29b-41d4-a716-446655449004'; + $successorId = '550e8400-e29b-41d4-a716-446655449005'; $equipment = Equipment::reconstitute( id: EquipmentId::fromString(self::EQUIP_ID), @@ -181,15 +185,21 @@ public function testReconstituteRestoresPersistedState(): void model: 'TY-B', serialNumber: 'SN-777', locationLabel: 'Ceiling', + identity: $identity, ), assignment: new RestoredEquipmentAssignment( status: EquipmentStatus::OPERATIONAL, facilityId: EquipmentFacilityId::fromString(self::FACILITY_ID), installedAt: $installedAt, commissionedAt: $commissionedAt, + planPosition: $position, + ), + history: new RestoredEquipmentHistory( + createdAt: $createdAt, + updatedAt: $updatedAt, + predecessorEquipmentId: $predecessorId, + successorEquipmentId: $successorId, ), - createdAt: $createdAt, - updatedAt: $updatedAt, ); self::assertSame(EquipmentStatus::OPERATIONAL, $equipment->status()); @@ -203,6 +213,39 @@ public function testReconstituteRestoresPersistedState(): void self::assertSame($updatedAt, $equipment->updatedAt()); self::assertSame($installedAt, $equipment->installedAt()); self::assertSame($commissionedAt, $equipment->commissionedAt()); + self::assertSame($position, $equipment->planPosition()); + self::assertSame($identity, $equipment->identity()); + self::assertSame($predecessorId, $equipment->predecessorEquipmentId()); + self::assertSame($successorId, $equipment->successorEquipmentId()); + } + + #[Test] + public function testReconstitutePreservesLegacyStateWithoutCreationOrLifecycleValidation(): void + { + $createdAt = new DateTimeImmutable('2026-02-01'); + $updatedAt = new DateTimeImmutable('2026-01-01'); + $brand = ' ' . str_repeat('a', 101) . ' '; + + $equipment = Equipment::reconstitute( + id: EquipmentId::fromString(self::EQUIP_ID), + organizationId: EquipmentOrganizationId::fromString(self::ORG_ID), + type: EquipmentTypeCode::fromString('historical_custom_type'), + details: new EquipmentCatalogDetails(brand: $brand, subType: ' '), + assignment: new RestoredEquipmentAssignment(status: EquipmentStatus::OPERATIONAL), + history: new RestoredEquipmentHistory(createdAt: $createdAt, updatedAt: $updatedAt), + ); + + self::assertSame('historical_custom_type', $equipment->type()->value); + self::assertSame($brand, $equipment->brand()); + self::assertSame(' ', $equipment->subType()); + self::assertSame(EquipmentStatus::OPERATIONAL, $equipment->status()); + self::assertNull($equipment->facilityId()); + self::assertNull($equipment->commissionedAt()); + self::assertSame($createdAt, $equipment->createdAt()); + self::assertSame($updatedAt, $equipment->updatedAt()); + self::assertNull($equipment->predecessorEquipmentId()); + self::assertNull($equipment->successorEquipmentId()); + self::assertSame([], $equipment->identity()->technicalProperties); } #[Test] @@ -353,8 +396,7 @@ public function testPutUnderMaintenanceRequiresFacilityAssignment(): void status: EquipmentStatus::OPERATIONAL, facilityId: null, ), - createdAt: new DateTimeImmutable(), - updatedAt: new DateTimeImmutable(), + history: new RestoredEquipmentHistory(createdAt: new DateTimeImmutable(), updatedAt: new DateTimeImmutable()), ); $this->expectException(InvalidArgumentException::class); diff --git a/tests/Unit/Equipment/Domain/ValueObject/EquipmentIdentityTest.php b/tests/Unit/Equipment/Domain/ValueObject/EquipmentIdentityTest.php index a8ebba07c..71683136f 100644 --- a/tests/Unit/Equipment/Domain/ValueObject/EquipmentIdentityTest.php +++ b/tests/Unit/Equipment/Domain/ValueObject/EquipmentIdentityTest.php @@ -5,11 +5,12 @@ namespace Tests\Unit\Equipment\Domain\ValueObject; use Equipment\Domain\ValueObject\{EquipmentIdentity, EquipmentType, EquipmentTypeCode}; -use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\{DataProvider, Test}; use PHPUnit\Framework\TestCase; use Shared\Domain\Exception\InvalidValueException; use function array_fill; +use function str_repeat; final class EquipmentIdentityTest extends TestCase { @@ -27,6 +28,7 @@ public function itNormalizesIdentityAndKeepsExplicitProperties(): void public function itRejectsRepeatedKeysAfterNormalization(): void { $this->expectException(InvalidValueException::class); + $this->expectExceptionMessage('Technical property keys must be nonempty and unique.'); EquipmentIdentity::fromValues(technicalProperties: [['key' => 'charge', 'value' => '6'], ['key' => ' charge ', 'value' => '9']]); } @@ -34,6 +36,7 @@ public function itRejectsRepeatedKeysAfterNormalization(): void public function itRejectsUnknownCriticality(): void { $this->expectException(InvalidValueException::class); + $this->expectExceptionMessage('Equipment criticality is invalid.'); EquipmentIdentity::fromValues(criticality: 'urgent'); } @@ -43,6 +46,59 @@ 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); + $this->expectExceptionMessage('Technical properties must be a list of at most 50 entries.'); EquipmentIdentity::fromValues(technicalProperties: array_fill(0, 51, ['key' => 'capacity', 'value' => '6'])); } + + #[Test] + public function itPreservesPropertyValuesAndUsesCharacterLimitsAfterTrimming(): void + { + $key = str_repeat('é', 64); + $value = ' ' . str_repeat('é', 253) . ' '; + $unit = str_repeat('é', 32); + + $identity = EquipmentIdentity::fromValues(technicalProperties: [ + ['key' => ' ' . $key . ' ', 'value' => $value, 'unit' => ' ' . $unit . ' '], + ['key' => 'unitless', 'value' => '', 'unit' => ' '], + ['key' => 'omitted', 'value' => ' unchanged '], + ]); + + self::assertSame([ + ['key' => $key, 'value' => $value, 'unit' => $unit], + ['key' => 'unitless', 'value' => '', 'unit' => null], + ['key' => 'omitted', 'value' => ' unchanged ', 'unit' => null], + ], $identity->technicalProperties); + } + + /** + * @param array $properties + */ + #[Test] + #[DataProvider('invalidProperties')] + public function itPreservesValidationErrorsAndTheirPrecedence(?string $criticality, array $properties, string $message): void + { + $this->expectException(InvalidValueException::class); + $this->expectExceptionMessage($message); + + EquipmentIdentity::fromValues(name: str_repeat('a', 256), criticality: $criticality, technicalProperties: $properties); + } + + /** + * @return iterable, string}> + */ + public static function invalidProperties(): iterable + { + yield 'criticality before list shape' => ['urgent', ['named' => null], 'Equipment criticality is invalid.']; + yield 'list shape before entry shape' => [null, [1 => null], 'Technical properties must be a list of at most 50 entries.']; + yield 'list size before entry shape' => [null, array_fill(0, 51, null), 'Technical properties must be a list of at most 50 entries.']; + yield 'entry must be an array' => [null, ['property'], 'Technical properties require a string key, value and optional unit.']; + yield 'missing key' => [null, [['value' => '6']], 'Technical properties require a string key, value and optional unit.']; + yield 'non-string value' => [null, [['key' => 'charge', 'value' => 6]], 'Technical properties require a string key, value and optional unit.']; + yield 'unit type before empty key' => [null, [['key' => ' ', 'value' => '6', 'unit' => 6]], 'Technical properties require a string key, value and optional unit.']; + yield 'empty key before value length' => [null, [['key' => ' ', 'value' => str_repeat('a', 256)]], 'Technical property keys must be nonempty and unique.']; + yield 'duplicate key before value length' => [null, [['key' => 'charge', 'value' => '6'], ['key' => ' charge ', 'value' => str_repeat('a', 256)]], 'Technical property keys must be nonempty and unique.']; + yield 'key length before value length' => [null, [['key' => str_repeat('a', 65), 'value' => str_repeat('a', 256)]], 'Equipment identity text exceeds its maximum length.']; + yield 'value length before unit length' => [null, [['key' => 'charge', 'value' => str_repeat('a', 256), 'unit' => str_repeat('a', 33)]], 'Technical property values must be at most 255 characters.']; + yield 'unit length before name normalization' => [null, [['key' => 'charge', 'value' => '6', 'unit' => str_repeat('a', 33)]], 'Equipment identity text exceeds its maximum length.']; + } } diff --git a/tests/Unit/Equipment/Infrastructure/Adapter/Inspection/EquipmentValidationAdapterTest.php b/tests/Unit/Equipment/Infrastructure/Adapter/Inspection/EquipmentValidationAdapterTest.php index ed6b00bb0..3cd4a17e5 100644 --- a/tests/Unit/Equipment/Infrastructure/Adapter/Inspection/EquipmentValidationAdapterTest.php +++ b/tests/Unit/Equipment/Infrastructure/Adapter/Inspection/EquipmentValidationAdapterTest.php @@ -14,7 +14,7 @@ EquipmentStatus, EquipmentType }; -use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, RestoredEquipmentAssignment}; +use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, RestoredEquipmentAssignment, RestoredEquipmentHistory}; use Equipment\Infrastructure\Adapter\Inspection\EquipmentValidationAdapter; use InvalidArgumentException; use PHPUnit\Framework\Attributes\{CoversClass, Test}; @@ -213,8 +213,7 @@ private function equipment(EquipmentStatus $status, ?string $facilityId): Equipm status: $status, facilityId: null !== $facilityId ? EquipmentFacilityId::fromString($facilityId) : null, ), - createdAt: $now, - updatedAt: $now, + history: new RestoredEquipmentHistory(createdAt: $now, updatedAt: $now), ); } diff --git a/tests/Unit/Equipment/Infrastructure/Adapter/Intervention/EquipmentInterventionResourceAdapterTest.php b/tests/Unit/Equipment/Infrastructure/Adapter/Intervention/EquipmentInterventionResourceAdapterTest.php index 67d5cd92e..577313631 100644 --- a/tests/Unit/Equipment/Infrastructure/Adapter/Intervention/EquipmentInterventionResourceAdapterTest.php +++ b/tests/Unit/Equipment/Infrastructure/Adapter/Intervention/EquipmentInterventionResourceAdapterTest.php @@ -169,6 +169,36 @@ public function testApplyClearsThePlanPositionWhenTheFacilityIsCleared(): void self::assertNull($record->planPosition); } + #[Test] + public function identityPatchesPreserveOmittedValuesAndClearOnlySubmittedFields(): void + { + $record = $this->draftRecord('in_stock'); + $record->recordStatus = 'published'; + $record->name = 'Panel'; + $record->assetCode = 'AST-001'; + $record->criticality = 'high'; + $record->technicalProperties = [['key' => 'voltage', 'value' => '24', 'unit' => 'V']]; + + $entityManager = $this->createStub(EntityManagerInterface::class); + $entityManager->method('find')->willReturn($record); + $synchronizer = $this->createMock(EquipmentMaintenanceLogSynchronizerPort::class); + $synchronizer->expects(self::never())->method('syncForStatusTransition'); + $adapter = $this->adapter($entityManager, $synchronizer); + + $adapter->apply(self::ORGANIZATION_ID, '/api/equipment/' . self::EQUIPMENT_ID, ['brand' => 'Sicli']); + self::assertSame('Panel', $record->name); + self::assertSame('AST-001', $record->assetCode); + self::assertSame('high', $record->criticality); + self::assertSame([['key' => 'voltage', 'value' => '24', 'unit' => 'V']], $record->technicalProperties); + + $adapter->apply(self::ORGANIZATION_ID, '/api/equipment/' . self::EQUIPMENT_ID, ['name' => null, 'technicalProperties' => []]); + self::assertNull($record->name); + self::assertSame([], $record->technicalProperties); + self::assertSame('AST-001', $record->assetCode); + self::assertSame('high', $record->criticality); + self::assertSame('Sicli', $record->brand); + } + private function adapter( EntityManagerInterface $entityManager, EquipmentMaintenanceLogSynchronizerPort $synchronizer, diff --git a/tests/Unit/Equipment/Infrastructure/Persistence/Doctrine/Mapper/EquipmentMapperTest.php b/tests/Unit/Equipment/Infrastructure/Persistence/Doctrine/Mapper/EquipmentMapperTest.php index c6ec830b2..8099739c6 100644 --- a/tests/Unit/Equipment/Infrastructure/Persistence/Doctrine/Mapper/EquipmentMapperTest.php +++ b/tests/Unit/Equipment/Infrastructure/Persistence/Doctrine/Mapper/EquipmentMapperTest.php @@ -13,7 +13,7 @@ EquipmentStatus, EquipmentType }; -use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, RestoredEquipmentAssignment}; +use Equipment\Domain\ValueObject\{EquipmentCatalogDetails, RestoredEquipmentAssignment, RestoredEquipmentHistory}; use Equipment\Infrastructure\Persistence\Doctrine\Mapper\EquipmentMapper; use Equipment\Infrastructure\Persistence\Doctrine\Record\EquipmentRecord; use LogicException; @@ -56,6 +56,15 @@ public function testToDomainMapsEveryColumn(): void self::assertSame('Hall', $equipment->locationLabel()); self::assertEquals(new DateTimeImmutable('2026-01-05T00:00:00+00:00'), $equipment->installedAt()); self::assertEquals(new DateTimeImmutable('2026-01-06T00:00:00+00:00'), $equipment->commissionedAt()); + self::assertSame($record->createdAt, $equipment->createdAt()); + self::assertSame($record->updatedAt, $equipment->updatedAt()); + self::assertSame($record->predecessorEquipmentId, $equipment->predecessorEquipmentId()); + self::assertSame($record->successorEquipmentId, $equipment->successorEquipmentId()); + self::assertSame($record->planPosition, $equipment->planPosition()?->toArray()); + self::assertSame($record->name, $equipment->identity()->name); + self::assertSame($record->assetCode, $equipment->identity()->assetCode); + self::assertSame($record->criticality, $equipment->identity()->criticality); + self::assertSame($record->technicalProperties, $equipment->identity()->technicalProperties); } #[Test] @@ -129,8 +138,7 @@ public function testToRecordWritesANullFacilityIdForUnassignedEquipment(): void assignment: new RestoredEquipmentAssignment( status: EquipmentStatus::IN_STOCK, ), - createdAt: $now, - updatedAt: $now, + history: new RestoredEquipmentHistory(createdAt: $now, updatedAt: $now), ); $record = EquipmentMapper::toRecord($equipment); @@ -157,8 +165,7 @@ public function testARoundTripPreservesTheFacilityAssignment(): void status: EquipmentStatus::DECOMMISSIONED, facilityId: EquipmentFacilityId::fromString(self::FACILITY_ID), ), - createdAt: $now, - updatedAt: $now, + history: new RestoredEquipmentHistory(createdAt: $now, updatedAt: $now), ); $record = EquipmentMapper::toRecord($equipment); @@ -191,6 +198,13 @@ private function record(): EquipmentRecord $record->model = 'ABC-9'; $record->serialNumber = 'EXT-001'; $record->locationLabel = 'Hall'; + $record->name = 'Hall extinguisher'; + $record->assetCode = 'EXT-001'; + $record->criticality = 'high'; + $record->technicalProperties = [['key' => 'charge', 'value' => '6', 'unit' => 'kg']]; + $record->predecessorEquipmentId = '550e8400-e29b-41d4-a716-446655493004'; + $record->successorEquipmentId = '550e8400-e29b-41d4-a716-446655493005'; + $record->planPosition = ['attachmentId' => '550e8400-e29b-41d4-a716-446655493006', 'x' => 0.25, 'y' => 0.75]; $record->status = 'operational'; $record->installedAt = new DateTimeImmutable('2026-01-05T00:00:00+00:00'); $record->commissionedAt = new DateTimeImmutable('2026-01-06T00:00:00+00:00'); diff --git a/tests/Unit/Equipment/Presentation/Api/Processor/Equipment/UpdateEquipmentProcessorTest.php b/tests/Unit/Equipment/Presentation/Api/Processor/Equipment/UpdateEquipmentProcessorTest.php index 56fb991d8..03f232d74 100644 --- a/tests/Unit/Equipment/Presentation/Api/Processor/Equipment/UpdateEquipmentProcessorTest.php +++ b/tests/Unit/Equipment/Presentation/Api/Processor/Equipment/UpdateEquipmentProcessorTest.php @@ -22,6 +22,7 @@ use Shared\Application\Exception\MessengerRuntimeException; use Shared\Application\Port\Inbound\CommandBusPort; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpFoundation\{Request, RequestStack}; use Symfony\Component\HttpKernel\Exception\{ AccessDeniedHttpException, BadRequestHttpException, @@ -344,6 +345,51 @@ public function testProcessRethrowsAnUnrecognisedMessengerFailure(): void ); } + #[Test] + #[DataProvider('identityPatchBodies')] + public function identityPresenceDistinguishesOmittedFieldsFromExplicitClears(string $body, bool $present): void + { + $requests = new RequestStack(); + $requests->push(Request::create('/api/equipment', 'PATCH', content: $body)); + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('resolveAccess')->willReturn(OrganizationAccessDecision::GRANTED); + $commandBus = $this->createMock(CommandBusPort::class); + $commandBus->expects(self::once())->method('dispatch') + ->with(self::callback(static function (UpdateEquipmentCommand $command) use ($present): bool { + self::assertSame($present, $command->hasName); + self::assertSame($present, $command->hasAssetCode); + self::assertSame($present, $command->hasCriticality); + self::assertSame($present, $command->hasTechnicalProperties); + self::assertNull($command->name); + self::assertNull($command->assetCode); + self::assertNull($command->criticality); + self::assertSame([], $command->technicalProperties); + + return true; + })) + ->willThrowException(new RuntimeException('Command inspected.')); + + $processor = new UpdateEquipmentProcessor( + $commandBus, + $authorization, + $this->updateSecurity(), + \Tests\Support\MutationDetailFixtures::equipment(null), + $requests, + ); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Command inspected.'); + $processor->process(new UpdateEquipmentInput(), new Patch(), $this->updateUriVariables()); + } + + /** + * @return iterable + */ + public static function identityPatchBodies(): iterable + { + yield 'omitted' => ['{}', false]; + yield 'explicit clears' => ['{"name":null,"assetCode":null,"criticality":null,"technicalProperties":[]}', true]; + } + /** * @return iterable}> */ diff --git a/tests/Unit/Facility/Domain/ValueObject/FacilityCustomerReferenceTest.php b/tests/Unit/Facility/Domain/ValueObject/FacilityCustomerReferenceTest.php index 7cef02725..0eaa0b0ea 100644 --- a/tests/Unit/Facility/Domain/ValueObject/FacilityCustomerReferenceTest.php +++ b/tests/Unit/Facility/Domain/ValueObject/FacilityCustomerReferenceTest.php @@ -6,8 +6,9 @@ use Facility\Domain\Exception\FacilityCustomerAssignmentException; use Facility\Domain\ValueObject\{FacilityCustomerReference, FacilityType}; -use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\{DataProvider, Test}; use PHPUnit\Framework\TestCase; +use Shared\Domain\Exception\InvalidValueException; /** Class FacilityCustomerReferenceTest. Customer scope stays on root sites. @category Test */ final class FacilityCustomerReferenceTest extends TestCase @@ -49,4 +50,57 @@ public function refusesCustomerOnNestedSite(): void $this->expectException(FacilityCustomerAssignmentException::class); FacilityCustomerReference::assertValid(self::ID, FacilityType::SITE, self::ID); } + + /** + * Method validatesCustomerFormatBeforeRootPlacement + * + * @access public + * + * @param string $customerId invalid identifier checked before placement + * + * @return void + */ + #[Test] + #[DataProvider('invalidCustomerIdentifiers')] + public function validatesCustomerFormatBeforeRootPlacement(string $customerId): void + { + $this->expectException(InvalidValueException::class); + $this->expectExceptionMessage('Invalid UUID provided.'); + FacilityCustomerReference::assertValid($customerId, FacilityType::BUILDING, self::ID); + } + + /** + * Method invalidCustomerIdentifiers + * + * @access public + * + * @return iterable + */ + public static function invalidCustomerIdentifiers(): iterable + { + yield 'empty identifier' => ['']; + yield 'malformed identifier' => ['unknown']; + yield 'unsupported UUID version' => ['550e8400-e29b-81d4-a716-446655440001']; + } + + /** + * Method keepsOriginalCustomerIdentifierCase + * + * @access public + * + * @return void + */ + #[Test] + public function keepsOriginalCustomerIdentifierCase(): void + { + $customerId = '550E8400-E29B-41D4-A716-446655440001'; + $site = \Facility\Domain\Model\Facility\Facility::create( + \Facility\Domain\ValueObject\FacilityId::fromString(self::ID), + \Facility\Domain\ValueObject\FacilityOrganizationId::fromString('550e8400-e29b-41d4-a716-446655440002'), + FacilityType::SITE, + new \Facility\Domain\ValueObject\FacilityName('Root Site'), + new \Facility\Domain\Model\Facility\FacilityDetails(customerId: $customerId), + ); + self::assertSame($customerId, $site->customerId()); + } } diff --git a/tests/Unit/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapterTest.php b/tests/Unit/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapterTest.php new file mode 100644 index 000000000..1d13b5ba6 --- /dev/null +++ b/tests/Unit/Facility/Infrastructure/Adapter/ServiceRequest/ServiceRequestSiteTargetAdapterTest.php @@ -0,0 +1,192 @@ + $calls */ + $calls = new ArrayObject(); + $connection = $this->createMock(Connection::class); + $connection->method('isTransactionActive')->willReturn(true); + $connection->expects(self::exactly(2))->method('fetchAllAssociative')->willReturnCallback( + static function (string $sql, array $parameters) use ($calls): array { + $events = $calls->getArrayCopy(); + if ([] === $events || 'hierarchy' !== $events[0]) { + self::fail('The caller must acquire the hierarchy lock before resolving its target.'); + } + if (['hierarchy'] === $events) { + $calls[] = 'ancestry'; + self::assertSame(['target' => 'floor', 'organization' => 'organization'], $parameters); + + return self::ancestry(); + } + $calls[] = 'rows'; + self::assertStringContainsString('ORDER BY id FOR SHARE', $sql); + self::assertSame(['organization' => 'organization', 'id0' => 'floor', 'id1' => 'site'], $parameters); + + return [['id' => 'floor', 'status' => 'active'], ['id' => 'site', 'status' => 'active']]; + }, + ); + $hierarchy = $this->createMock(FacilityHierarchyPort::class); + $hierarchy->expects(self::once())->method('lock')->with('organization')->willReturnCallback(static function () use ($calls): void { + $calls[] = 'hierarchy'; + }); + $customers = $this->createMock(CustomerLookupPort::class); + $customers->expects(self::once())->method('find')->with('customer', 'organization')->willReturnCallback(static function () use ($calls): CustomerSnapshot { + self::assertSame(['hierarchy', 'ancestry', 'rows'], $calls->getArrayCopy()); + $calls[] = 'customer'; + + return new CustomerSnapshot('customer', 'Retained customer', [], null); + }); + $adapter = $this->adapter($connection, $customers, $hierarchy); + $adapter->lock('organization'); + $target = $adapter->find('organization', 'site', 'floor'); + self::assertNotNull($target); + self::assertSame(['id' => 'customer', 'name' => 'Retained customer'], $target->customer); + self::assertSame(['hierarchy', 'ancestry', 'rows', 'customer'], $calls->getArrayCopy()); + } + + /** + * Method testRetainsAnyArchiveObservedBeforeOrAfterRowLocking + * + * @access public + * + * @param string $initialStatus status in the ancestry read + * @param string $lockedStatus status after acquiring shared row locks + * + * @return void + */ + #[DataProvider('archivalChanges')] + public function testRetainsAnyArchiveObservedBeforeOrAfterRowLocking(string $initialStatus, string $lockedStatus): void + { + $connection = $this->createMock(Connection::class); + $connection->method('isTransactionActive')->willReturn(true); + $connection->expects(self::exactly(2))->method('fetchAllAssociative')->willReturnOnConsecutiveCalls( + self::ancestry($initialStatus), + [['id' => 'floor', 'status' => $lockedStatus], ['id' => 'site', 'status' => 'active']], + ); + $customers = $this->createStub(CustomerLookupPort::class); + $customers->method('find')->willReturn(new CustomerSnapshot('customer', 'Customer', [], null)); + $target = $this->adapter($connection, $customers)->find('organization', null, 'floor'); + self::assertNotNull($target); + self::assertTrue($target->archived); + } + + /** + * Method archivalChanges + * + * @access public + * + * @return iterable archive transitions + */ + public static function archivalChanges(): iterable + { + yield 'archived while locking' => ['active', 'archived']; + yield 'initial archive retained' => ['archived', 'active']; + } + + /** + * Method testUnavailableLockedAncestryStopsBeforeCustomerLookup + * + * @access public + * + * @return void + */ + public function testUnavailableLockedAncestryStopsBeforeCustomerLookup(): void + { + $connection = $this->createMock(Connection::class); + $connection->method('isTransactionActive')->willReturn(true); + $connection->expects(self::exactly(2))->method('fetchAllAssociative')->willReturnOnConsecutiveCalls( + self::ancestry(), + [['id' => 'floor', 'status' => 'active']], + ); + $customers = $this->createMock(CustomerLookupPort::class); + $customers->expects(self::never())->method('find'); + self::assertNull($this->adapter($connection, $customers)->find('organization', null, 'floor')); + } + + /** + * Method testNonTransactionalReadsRetainResolvedAncestryWithoutRowLocks + * + * @access public + * + * @return void + */ + public function testNonTransactionalReadsRetainResolvedAncestryWithoutRowLocks(): void + { + $connection = $this->createMock(Connection::class); + $connection->method('isTransactionActive')->willReturn(false); + $connection->expects(self::once())->method('fetchAllAssociative')->willReturn(self::ancestry()); + $customers = $this->createStub(CustomerLookupPort::class); + $customers->method('find')->willReturn(new CustomerSnapshot('customer', 'Customer', [], null)); + $target = $this->adapter($connection, $customers)->find('organization', null, 'floor'); + self::assertNotNull($target); + self::assertFalse($target->archived); + } + + /** + * Method ancestry + * + * @access private + * + * @param string $status observed descendant status + * + * @return list available rows + */ + private static function ancestry(string $status = 'active'): array + { + return [ + ['id' => 'floor', 'name' => 'Floor', 'type' => 'floor', 'parent_facility_id' => 'site', 'customer_id' => null, 'status' => $status], + ['id' => 'site', 'name' => 'Site', 'type' => 'site', 'parent_facility_id' => null, 'customer_id' => 'customer', 'status' => 'active'], + ]; + } + + /** + * Method adapter + * + * @access private + * + * @param Connection $connection main connection double + * @param CustomerLookupPort $customers scoped lookup double + * @param FacilityHierarchyPort|null $hierarchy shared lock double + * + * @return ServiceRequestSiteTargetAdapter the adapter under test + */ + private function adapter(Connection $connection, CustomerLookupPort $customers, ?FacilityHierarchyPort $hierarchy = null): ServiceRequestSiteTargetAdapter + { + $manager = $this->createStub(EntityManagerInterface::class); + $manager->method('getConnection')->willReturn($connection); + + return new ServiceRequestSiteTargetAdapter($manager, $customers, $hierarchy ?? $this->createStub(FacilityHierarchyPort::class)); + } + // #endregion +} diff --git a/tests/Unit/Inspection/Presentation/Api/Provider/Inspection/ListInspectionsProviderTest.php b/tests/Unit/Inspection/Presentation/Api/Provider/Inspection/ListInspectionsProviderTest.php index d9b3afaec..3fed3d1cd 100644 --- a/tests/Unit/Inspection/Presentation/Api/Provider/Inspection/ListInspectionsProviderTest.php +++ b/tests/Unit/Inspection/Presentation/Api/Provider/Inspection/ListInspectionsProviderTest.php @@ -4,7 +4,7 @@ namespace Tests\Unit\Inspection\Presentation\Api\Provider\Inspection; -use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\{GetCollection, Parameters, QueryParameter}; use ApiPlatform\State\Pagination\TraversablePaginator; use Auth\Infrastructure\Security\User\SecurityUser; use DateTimeImmutable; @@ -14,8 +14,9 @@ use Inspection\Presentation\Api\Factory\InspectionOutputFactory; use Inspection\Presentation\Api\Provider\Inspection\ListInspectionsProvider; use InvalidArgumentException; +use Organization\Application\Contract\Authorization\OrganizationAccessDecision; use Organization\Application\Port\Inbound\OrganizationAuthorizationPort; -use PHPUnit\Framework\Attributes\{CoversClass, Test}; +use PHPUnit\Framework\Attributes\{CoversClass, DataProvider, Test}; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -25,9 +26,10 @@ use Shared\Application\Port\Inbound\QueryBusPort; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\{Request, RequestStack}; -use Symfony\Component\HttpKernel\Exception\{AccessDeniedHttpException, BadRequestHttpException}; +use Symfony\Component\HttpKernel\Exception\{AccessDeniedHttpException, BadRequestHttpException, HttpException}; use User\Application\UseCase\Query\User\GetUser\GetUserResult; +use function count; use function iterator_to_array; #[CoversClass(ListInspectionsProvider::class)] @@ -332,6 +334,184 @@ public function testProvideUsesFacilityIdFromUriVariables(): void ); } + /** + * @param array $requestFilters + */ + #[Test] + #[DataProvider('scopedRequests')] + public function testRawScopeFiltersKeepBothPermissionChecksWhenParsedValuesAreCleared(array $requestFilters): void + { + $permissions = []; + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::never())->method('hasPermission'); + $authorization->expects(self::exactly(2))->method('resolveAccess') + ->willReturnCallback(static function (string $userId, string $organizationId, string $permission) use (&$permissions): OrganizationAccessDecision { + self::assertSame(self::USER_ID, $userId); + self::assertSame(self::ORG_ID, $organizationId); + $permissions[] = $permission; + + return OrganizationAccessDecision::GRANTED; + }); + $queryBus = $this->createMock(QueryBusPort::class); + $queryBus->expects(self::once())->method('ask') + ->with(self::callback(static function (ListInspectionsQuery $query): bool { + return null === $query->family && null === $query->customerId && !$query->includeDescendants; + })) + ->willReturn(new PaginatedResult(items: [], total: 0, limit: 30, offset: 0)); + + $provider = $this->createProvider($queryBus, $authorization, new Request($requestFilters)); + $provider->provide( + operation: self::collectionOperation(['family' => '', 'customerId' => '', 'includeDescendants' => false]), + uriVariables: ['organizationId' => self::ORG_ID], + ); + + self::assertSame(['organization.inspection.read', 'organization.equipment.read'], $permissions); + } + + /** + * @return array}> + */ + public static function scopedRequests(): array + { + return [ + 'family' => [['family' => 'fire']], + 'customer' => [['customerId' => '550e8400-e29b-41d4-a716-446655440099']], + 'descendants' => [['includeDescendants' => 'true']], + ]; + } + + /** + * @param array $requestFilters + * @param array $parsedFilters + */ + #[Test] + #[DataProvider('unscopedRequests')] + public function testDirectReadsKeepTheirLegacyPermissionGate(array $requestFilters, array $parsedFilters): void + { + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::once())->method('hasPermission') + ->with(self::USER_ID, self::ORG_ID, 'organization.inspection.read')->willReturn(true); + $authorization->expects(self::never())->method('resolveAccess'); + $queryBus = $this->createMock(QueryBusPort::class); + $queryBus->expects(self::once())->method('ask') + ->with(self::callback(static function (ListInspectionsQuery $query) use ($parsedFilters, $requestFilters): bool { + return ($parsedFilters['family'] ?? null) === $query->family + && ($requestFilters['facilityId'] ?? null) === $query->facilityId + && ($parsedFilters['includeDescendants'] ?? false) === $query->includeDescendants; + })) + ->willReturn(new PaginatedResult(items: [], total: 0, limit: 30, offset: 0)); + + $provider = $this->createProvider($queryBus, $authorization, new Request($requestFilters)); + $provider->provide( + operation: self::collectionOperation($parsedFilters), + uriVariables: ['organizationId' => self::ORG_ID], + ); + } + + /** + * @return array, array}> + */ + public static function unscopedRequests(): array + { + return [ + 'parsed family only' => [[], ['family' => 'fire']], + 'parsed descendants only' => [[], ['includeDescendants' => true]], + 'facility only' => [['facilityId' => '550e8400-e29b-41d4-a716-446655440099'], []], + 'empty scope values' => [['family' => '', 'customerId' => '', 'includeDescendants' => 'false'], []], + ]; + } + + /** + * @param list $decisions + */ + #[Test] + #[DataProvider('scopedDenials')] + public function testScopedDenialsStopBeforeReadingInspections(array $decisions, int $status, string $message): void + { + $checked = []; + $permissions = ['organization.inspection.read', 'organization.equipment.read']; + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects(self::never())->method('hasPermission'); + $authorization->expects(self::exactly(count($decisions)))->method('resolveAccess') + ->willReturnCallback(static function (string $userId, string $organizationId, string $permission) use ($decisions, $permissions, &$checked): OrganizationAccessDecision { + self::assertSame(self::USER_ID, $userId); + self::assertSame(self::ORG_ID, $organizationId); + $index = count($checked); + self::assertSame($permissions[$index], $permission); + $checked[] = $permission; + + return $decisions[$index]; + }); + $queryBus = $this->createMock(QueryBusPort::class); + $queryBus->expects(self::never())->method('ask'); + $provider = $this->createProvider($queryBus, $authorization, new Request(['family' => 'fire'])); + + try { + $provider->provide(new GetCollection(), ['organizationId' => self::ORG_ID]); + self::fail('Expected the scoped access denial.'); + } catch (HttpException $exception) { + self::assertSame($status, $exception->getStatusCode()); + self::assertSame($message, $exception->getMessage()); + } + } + + /** + * @return array, int, string}> + */ + public static function scopedDenials(): array + { + return [ + 'inspection outside scope' => [[OrganizationAccessDecision::OUTSIDE_SCOPE], 404, 'Organization not found.'], + 'inspection permission missing' => [[OrganizationAccessDecision::MISSING_PERMISSION], 403, 'Missing organization.inspection.read permission.'], + 'equipment outside scope' => [[OrganizationAccessDecision::GRANTED, OrganizationAccessDecision::OUTSIDE_SCOPE], 404, 'Organization not found.'], + 'equipment permission missing' => [[OrganizationAccessDecision::GRANTED, OrganizationAccessDecision::MISSING_PERMISSION], 403, 'Missing organization.equipment.read permission.'], + ]; + } + + /** + * @param array $filters + * @param array $parsedFilters + */ + #[Test] + #[DataProvider('paginationCases')] + public function testPaginationRetainsCoercionClampsAndParsedPrecedence(array $filters, array $parsedFilters, int $page, int $limit): void + { + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('hasPermission')->willReturn(true); + $queryBus = $this->createMock(QueryBusPort::class); + $queryBus->expects(self::once())->method('ask') + ->with(self::callback(static function (ListInspectionsQuery $query) use ($page, $limit): bool { + return ($page - 1) * $limit === $query->pagination->offset && $limit === $query->pagination->limit; + })) + ->willReturn(new PaginatedResult(items: [], total: 500, limit: $limit, offset: ($page - 1) * $limit)); + $provider = $this->createProvider($queryBus, $authorization); + + $result = $provider->provide( + operation: self::collectionOperation($parsedFilters), + uriVariables: ['organizationId' => self::ORG_ID], + context: ['filters' => $filters], + ); + + self::assertInstanceOf(TraversablePaginator::class, $result); + self::assertSame((float) $page, $result->getCurrentPage()); + self::assertSame((float) $limit, $result->getItemsPerPage()); + self::assertSame(500.0, $result->getTotalItems()); + } + + /** + * @return array, array, int, int}> + */ + public static function paginationCases(): array + { + return [ + 'lower clamps' => [['page' => -2, 'itemsPerPage' => 0], [], 1, 1], + 'nonnumeric defaults' => [['page' => 'invalid', 'itemsPerPage' => 'invalid'], [], 1, 30], + 'parsed overrides context' => [['page' => 9, 'itemsPerPage' => 50], ['page' => 3, 'itemsPerPage' => 7], 3, 7], + 'numeric coercion' => [['page' => '2.9', 'itemsPerPage' => '3.9'], [], 2, 3], + 'no upper clamp' => [['page' => 2, 'itemsPerPage' => 101], [], 2, 101], + ]; + } + #[Test] public function testProvideThrowsBadRequestOnInvalidArgument(): void { @@ -341,8 +521,9 @@ public function testProvideThrowsBadRequestOnInvalidArgument(): void $authorization = $this->createStub(OrganizationAuthorizationPort::class); $authorization->method('hasPermission')->willReturn(true); + $failure = new InvalidArgumentException('Invalid filter.'); $queryBus = $this->createStub(QueryBusPort::class); - $queryBus->method('ask')->willThrowException(new InvalidArgumentException('Invalid filter.')); + $queryBus->method('ask')->willThrowException($failure); $requestStack = new RequestStack(); $requestStack->push(new Request()); @@ -355,12 +536,13 @@ public function testProvideThrowsBadRequestOnInvalidArgument(): void requestStack: $requestStack, ); - $this->expectException(BadRequestHttpException::class); - - $provider->provide( - operation: new GetCollection(), - uriVariables: ['organizationId' => self::ORG_ID], - ); + try { + $provider->provide(new GetCollection(), ['organizationId' => self::ORG_ID]); + self::fail('Expected invalid filters to answer a bad request.'); + } catch (BadRequestHttpException $exception) { + self::assertSame('Invalid filter.', $exception->getMessage()); + self::assertSame($failure, $exception->getPrevious()); + } } #[Test] @@ -372,10 +554,9 @@ public function testProvideUnwrapsBadRequestFromMessengerException(): void $authorization = $this->createStub(OrganizationAuthorizationPort::class); $authorization->method('hasPermission')->willReturn(true); + $failure = MessengerRuntimeException::wrap(new InvalidArgumentException('Invalid filter.')); $queryBus = $this->createStub(QueryBusPort::class); - $queryBus->method('ask')->willThrowException( - MessengerRuntimeException::wrap(new InvalidArgumentException('Invalid filter.')), - ); + $queryBus->method('ask')->willThrowException($failure); $requestStack = new RequestStack(); $requestStack->push(new Request()); @@ -388,12 +569,13 @@ public function testProvideUnwrapsBadRequestFromMessengerException(): void requestStack: $requestStack, ); - $this->expectException(BadRequestHttpException::class); - - $provider->provide( - operation: new GetCollection(), - uriVariables: ['organizationId' => self::ORG_ID], - ); + try { + $provider->provide(new GetCollection(), ['organizationId' => self::ORG_ID]); + self::fail('Expected wrapped invalid filters to answer a bad request.'); + } catch (BadRequestHttpException $exception) { + self::assertSame('Invalid filter.', $exception->getMessage()); + self::assertSame($failure, $exception->getPrevious()); + } } #[Test] @@ -405,10 +587,9 @@ public function testProvideRethrowsAnUnrelatedMessengerFailure(): void $authorization = $this->createStub(OrganizationAuthorizationPort::class); $authorization->method('hasPermission')->willReturn(true); + $failure = MessengerRuntimeException::wrap(new RuntimeException('database is down')); $queryBus = $this->createStub(QueryBusPort::class); - $queryBus->method('ask')->willThrowException( - MessengerRuntimeException::wrap(new RuntimeException('database is down')), - ); + $queryBus->method('ask')->willThrowException($failure); $requestStack = new RequestStack(); $requestStack->push(new Request()); @@ -421,12 +602,39 @@ public function testProvideRethrowsAnUnrelatedMessengerFailure(): void requestStack: $requestStack, ); - $this->expectException(MessengerRuntimeException::class); + try { + $provider->provide(new GetCollection(), ['organizationId' => self::ORG_ID]); + self::fail('Expected an unrelated messenger failure to be rethrown.'); + } catch (MessengerRuntimeException $exception) { + self::assertSame($failure, $exception); + } + } - $provider->provide( - operation: new GetCollection(), - uriVariables: ['organizationId' => self::ORG_ID], - ); + /** + * @param array $values + */ + private static function collectionOperation(array $values): GetCollection + { + $parameters = []; + foreach ($values as $key => $value) { + $parameter = new QueryParameter(); + $parameter->setValue($value); + $parameters[$key] = $parameter; + } + + return new GetCollection(parameters: new Parameters($parameters)); + } + + private function createProvider(QueryBusPort $queryBus, OrganizationAuthorizationPort $authorization, ?Request $request = null): ListInspectionsProvider + { + $security = $this->createStub(Security::class); + $security->method('getUser')->willReturn($this->createSecurityUser()); + $requestStack = new RequestStack(); + if (null !== $request) { + $requestStack->push($request); + } + + return new ListInspectionsProvider($queryBus, $this->createOutputMapper(), $authorization, $security, $requestStack); } private function createSecurityUser(): SecurityUser diff --git a/tests/Unit/Inventory/Infrastructure/Persistence/Doctrine/Lock/InventoryTransactionLockTest.php b/tests/Unit/Inventory/Infrastructure/Persistence/Doctrine/Lock/InventoryTransactionLockTest.php new file mode 100644 index 000000000..1f2bd2a53 --- /dev/null +++ b/tests/Unit/Inventory/Infrastructure/Persistence/Doctrine/Lock/InventoryTransactionLockTest.php @@ -0,0 +1,65 @@ +createMock(Connection::class); + $connection->expects(self::once())->method('isTransactionActive')->willReturn(false); + $connection->expects(self::never())->method('executeQuery'); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Inventory write requires a main transaction.'); + + new InventoryTransactionLock($connection)->acquire('inventory-balance:org:warehouse:part'); + } + + /** + * Method acquiresTheExactIdentityOnTheSuppliedConnection + * + * Retains the scoped identity and transaction-scoped PostgreSQL lock expression. + * + * @access public + * + * @return void + */ + #[Test] + public function acquiresTheExactIdentityOnTheSuppliedConnection(): void + { + $connection = $this->createMock(Connection::class); + $connection->expects(self::once())->method('isTransactionActive')->willReturn(true); + $connection->expects(self::once())->method('executeQuery') + ->with('SELECT pg_advisory_xact_lock(hashtextextended(:identity,0))', ['identity' => 'inventory-operation:org:operation']) + ->willReturn($this->createStub(Result::class)); + + new InventoryTransactionLock($connection)->acquire('inventory-operation:org:operation'); + } + // #endregion +} diff --git a/tests/Unit/Inventory/Infrastructure/Persistence/Doctrine/Mapper/InventoryRowMapperTest.php b/tests/Unit/Inventory/Infrastructure/Persistence/Doctrine/Mapper/InventoryRowMapperTest.php new file mode 100644 index 000000000..b331a213a --- /dev/null +++ b/tests/Unit/Inventory/Infrastructure/Persistence/Doctrine/Mapper/InventoryRowMapperTest.php @@ -0,0 +1,373 @@ + 'part', 'organization_id' => 'org', 'code' => 'FILTER', 'label' => 'Replacement filter', 'unit' => 'piece', 'kind' => 'consumable', 'archived' => true]; + + $part = $mapper->reference('parts', $row); + + self::assertSame(['part', 'org', 'FILTER', 'Replacement filter', 'piece', 'consumable', true], [$part->id, $part->organizationId, $part->code, $part->label, $part->unit, $part->kind, $part->archived]); + + unset($row['unit'], $row['kind']); + $row['archived'] = false; + $warehouse = $mapper->reference('warehouses', $row); + + self::assertNull($warehouse->unit); + self::assertNull($warehouse->kind); + self::assertFalse($warehouse->archived); + } + + /** + * Method balancesRetainExactDecimalsAndUnknownValuation + * + * Keeps nullable valuations distinct from exact monetary zero. + * + * @access public + * + * @return void + */ + #[Test] + public function balancesRetainExactDecimalsAndUnknownValuation(): void + { + $mapper = new InventoryRowMapper(new DateTimeZone('UTC')); + $row = ['id' => 'balance', 'organization_id' => 'org', 'part_id' => 'part', 'warehouse_id' => 'warehouse', 'quantity' => '1.123456', 'total_value' => null, 'currency' => 'EUR']; + + $unknown = $mapper->balance($row); + + self::assertSame('1.123456', $unknown->quantity); + self::assertNull($unknown->totalValue); + self::assertSame(['balance', 'org', 'part', 'warehouse', 'EUR'], [$unknown->id, $unknown->organizationId, $unknown->partId, $unknown->warehouseId, $unknown->currency]); + + $row['total_value'] = '0.000001'; + self::assertSame('0.000001', $mapper->balance($row)->totalValue); + $row['total_value'] = '0.000000'; + self::assertSame('0.000000', $mapper->balance($row)->totalValue); + } + + /** + * Method movementsRetainNullableLinksAndStoredTimeZone + * + * Interprets a stored wall-clock time without discarding decimal precision or unknown values. + * + * @access public + * + * @return void + */ + #[Test] + public function movementsRetainNullableLinksAndStoredTimeZone(): void + { + $mapper = new InventoryRowMapper(new DateTimeZone('Europe/Paris')); + $row = self::movementRow('movement'); + $movement = $mapper->movement($row); + + self::assertSame(['movement', 'org', 'part', 'warehouse', 'consumption', '-0.123456', '0.000001', 'EUR', 'Original issue', 'actor'], [$movement->id, $movement->organizationId, $movement->partId, $movement->warehouseId, $movement->kind, $movement->quantity, $movement->totalValue, $movement->currency, $movement->reason, $movement->actorId]); + self::assertNull($movement->unitCost); + self::assertNull($movement->workItemId); + self::assertNull($movement->equipmentId); + self::assertNull($movement->correctionOf); + self::assertNull($movement->sourceReceiptId); + self::assertSame('intervention', $movement->interventionId); + self::assertTrue($movement->late); + self::assertSame('2026-10-08 12:34:56+02:00', $movement->occurredAt->format('Y-m-d H:i:sP')); + self::assertSame('2026-10-08 10:34:56', $movement->occurredAt->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s')); + + $row['total_value'] = null; + $row['unit_cost'] = '1.123456'; + $row['work_item_id'] = 'work'; + $row['equipment_id'] = 'equipment'; + $row['correction_of'] = 'original'; + $row['source_receipt_id'] = 'receipt'; + $knownLinks = $mapper->movement($row); + + self::assertNull($knownLinks->totalValue); + self::assertSame(['1.123456', 'work', 'equipment', 'original', 'receipt'], [$knownLinks->unitCost, $knownLinks->workItemId, $knownLinks->equipmentId, $knownLinks->correctionOf, $knownLinks->sourceReceiptId]); + } + + /** + * Method declarationsRetainContextAndResolutionFields + * + * Preserves pending and confirmed declaration restoration in the configured zone. + * + * @access public + * + * @return void + */ + #[Test] + public function declarationsRetainContextAndResolutionFields(): void + { + $mapper = new InventoryRowMapper(new DateTimeZone('Europe/Paris')); + $row = self::declarationRow(); + $pending = $mapper->declaration($row); + + self::assertSame(['declaration', 'org', 'part', 'warehouse', '0.123456', 'intervention', 'actor', 'received_pending', 'insufficient_stock'], [$pending->id, $pending->organizationId, $pending->partId, $pending->warehouseId, $pending->quantity, $pending->interventionId, $pending->actorId, $pending->status, $pending->reason]); + self::assertNull($pending->workItemId); + self::assertNull($pending->equipmentId); + self::assertNull($pending->movementId); + self::assertFalse($pending->late); + self::assertSame('2026-10-08 12:34:56+02:00', $pending->occurredAt->format('Y-m-d H:i:sP')); + + $row['work_item_id'] = 'work'; + $row['equipment_id'] = 'equipment'; + $row['status'] = 'confirmed'; + $row['reason'] = null; + $row['movement_id'] = 'movement'; + $row['late'] = true; + $confirmed = $mapper->declaration($row); + + self::assertSame(['work', 'equipment', 'confirmed', 'movement'], [$confirmed->workItemId, $confirmed->equipmentId, $confirmed->status, $confirmed->movementId]); + self::assertNull($confirmed->reason); + self::assertTrue($confirmed->late); + } + + /** + * Method operationReceiptsRestoreOriginalJsonSnapshot + * + * Keeps nested response values and the queried operation scope unchanged. + * + * @access public + * + * @return void + */ + #[Test] + public function operationReceiptsRestoreOriginalJsonSnapshot(): void + { + $mapper = new InventoryRowMapper(new DateTimeZone('UTC')); + $receipt = $mapper->operationReceipt('org', 'operation', ['payload_hash' => 'hash', 'response' => '{"status":"received_pending","quantity":"0.123456","movementId":null,"late":true,"links":["work","equipment"]}']); + + self::assertSame(['org', 'operation', 'hash'], [$receipt->organizationId, $receipt->clientOperationId, $receipt->payloadHash]); + self::assertSame(['status' => 'received_pending', 'quantity' => '0.123456', 'movementId' => null, 'late' => true, 'links' => ['work', 'equipment']], $receipt->response); + self::assertSame([], $mapper->operationReceipt('org', 'operation', ['payload_hash' => 'hash', 'response' => '[]'])->response); + } + + /** + * Method malformedReceiptJsonIsRejected + * + * Retains JSON parsing failures for corrupt stored snapshots. + * + * @access public + * + * @return void + */ + #[Test] + public function malformedReceiptJsonIsRejected(): void + { + $mapper = new InventoryRowMapper(new DateTimeZone('UTC')); + $this->expectException(JsonException::class); + + $mapper->operationReceipt('org', 'operation', ['payload_hash' => 'hash', 'response' => '{']); + } + + /** + * Method scalarReceiptJsonIsRejected + * + * Requires an array response rather than coercing a scalar snapshot. + * + * @access public + * + * @return void + */ + #[Test] + public function scalarReceiptJsonIsRejected(): void + { + $mapper = new InventoryRowMapper(new DateTimeZone('UTC')); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Invalid stored operation response.'); + + $mapper->operationReceipt('org', 'operation', ['payload_hash' => 'hash', 'response' => 'false']); + } + + /** + * Method linkedValuesCanonicalizePrecisionAndKeepUnknownAmounts + * + * Accepts PostgreSQL integer and string counts without fabricating a known valuation. + * + * @access public + * + * @return void + */ + #[Test] + public function linkedValuesCanonicalizePrecisionAndKeepUnknownAmounts(): void + { + $mapper = new InventoryRowMapper(new DateTimeZone('UTC')); + + self::assertSame('0.000001', $mapper->linkedValue(['amount' => '0.000001', 'unknown' => 0])); + self::assertSame('1.200000', $mapper->linkedValue(['amount' => '1.2', 'unknown' => '0'])); + self::assertSame('0.000000', $mapper->linkedValue(['amount' => '0', 'unknown' => 0])); + self::assertNull($mapper->linkedValue(['amount' => '2.000000', 'unknown' => 1])); + self::assertNull($mapper->linkedValue(['unknown' => '2'])); + } + + /** + * Method malformedLinkedValueCountIsRejected + * + * Retains validation of the aggregate count before amount restoration. + * + * @access public + * + * @return void + */ + #[Test] + public function malformedLinkedValueCountIsRejected(): void + { + $mapper = new InventoryRowMapper(new DateTimeZone('UTC')); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Invalid linked valuation count.'); + + $mapper->linkedValue(['amount' => '1.000000', 'unknown' => null]); + } + + /** + * Method malformedRequiredFieldIsRejected + * + * Refuses scalar coercion of required persisted identity fields. + * + * @access public + * + * @return void + */ + #[Test] + public function malformedRequiredFieldIsRejected(): void + { + $mapper = new InventoryRowMapper(new DateTimeZone('UTC')); + $row = self::movementRow('movement'); + $row['id'] = 42; + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Malformed inventory persisted field id'); + + $mapper->movement($row); + } + + /** + * Method malformedNullableFieldIsRejected + * + * Keeps nullable values type-checked when they are present. + * + * @access public + * + * @return void + */ + #[Test] + public function malformedNullableFieldIsRejected(): void + { + $mapper = new InventoryRowMapper(new DateTimeZone('UTC')); + $row = self::movementRow('movement'); + $row['unit_cost'] = 42; + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Malformed inventory persisted field unit_cost'); + + $mapper->movement($row); + } + + /** + * Method collectionsPreserveOrderAndSupportedProjections + * + * Retains input order rather than applying an additional sort during restoration. + * + * @access public + * + * @return void + */ + #[Test] + public function collectionsPreserveOrderAndSupportedProjections(): void + { + $mapper = new InventoryRowMapper(new DateTimeZone('UTC')); + $rows = [self::movementRow('movement-z'), self::movementRow('movement-a')]; + $collection = $mapper->collection('movements', $rows); + + self::assertSame(['movement-z', 'movement-a'], [$collection[0]->id, $collection[1]->id]); + self::assertInstanceOf(StockMovement::class, $collection[0]); + $movements = $mapper->movements($rows); + self::assertSame(['movement-z', 'movement-a'], [$movements[0]->id, $movements[1]->id]); + self::assertSame([], $mapper->movements([])); + self::assertSame([], $mapper->collection('unknown', [])); + + $reference = ['id' => 'part', 'organization_id' => 'org', 'code' => 'PART', 'label' => 'Part', 'unit' => 'piece', 'kind' => 'part', 'archived' => false]; + self::assertInstanceOf(InventoryReference::class, $mapper->collection('parts', [$reference])[0]); + self::assertInstanceOf(InventoryReference::class, $mapper->collection('warehouses', [$reference])[0]); + self::assertInstanceOf(StockBalance::class, $mapper->collection('balances', [['id' => 'balance', 'organization_id' => 'org', 'part_id' => 'part', 'warehouse_id' => 'warehouse', 'quantity' => '1.000000', 'currency' => 'EUR']])[0]); + self::assertInstanceOf(ConsumptionDeclaration::class, $mapper->collection('consumptions', [self::declarationRow()])[0]); + } + + /** + * Method unsupportedCollectionProjectionIsRejected + * + * Keeps the original failure for an unsupported nonempty projection. + * + * @access public + * + * @return void + */ + #[Test] + public function unsupportedCollectionProjectionIsRejected(): void + { + $mapper = new InventoryRowMapper(new DateTimeZone('UTC')); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Unknown inventory projection.'); + + $mapper->collection('unknown', [[]]); + } + + /** + * Method movementRow + * + * Supplies immutable facts with nullable price and operational links. + * + * @access private + * + * @param string $id the persisted movement identity + * + * @return array the persisted movement fixture + */ + private static function movementRow(string $id): array + { + return ['id' => $id, 'organization_id' => 'org', 'part_id' => 'part', 'warehouse_id' => 'warehouse', 'kind' => 'consumption', 'quantity' => '-0.123456', 'unit_cost' => null, 'total_value' => '0.000001', 'currency' => 'EUR', 'reason' => 'Original issue', 'actor_id' => 'actor', 'occurred_at' => '2026-10-08 12:34:56', 'intervention_id' => 'intervention', 'work_item_id' => null, 'equipment_id' => null, 'correction_of' => null, 'source_receipt_id' => null, 'late' => true]; + } + + /** + * Method declarationRow + * + * Supplies the original pending declaration snapshot. + * + * @access private + * + * @return array the persisted declaration fixture + */ + private static function declarationRow(): array + { + return ['id' => 'declaration', 'organization_id' => 'org', 'part_id' => 'part', 'warehouse_id' => 'warehouse', 'quantity' => '0.123456', 'intervention_id' => 'intervention', 'work_item_id' => null, 'equipment_id' => null, 'actor_id' => 'actor', 'occurred_at' => '2026-10-08 12:34:56', 'status' => 'received_pending', 'reason' => 'insufficient_stock', 'movement_id' => null, 'late' => false]; + } + // #endregion +} diff --git a/tests/Unit/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanHandlerTest.php b/tests/Unit/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanHandlerTest.php index d27daedcd..02dfbd929 100644 --- a/tests/Unit/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanHandlerTest.php +++ b/tests/Unit/Maintenance/Application/UseCase/Command/Plan/ManageMaintenancePlan/ManageMaintenancePlanHandlerTest.php @@ -16,6 +16,7 @@ use Maintenance\Application\Port\Outbound\Directory\{MaintenanceEquipmentDirectoryPort, MaintenanceFacilityLifecyclePort}; use Maintenance\Application\Port\Outbound\Plan\MaintenancePlanStorePort; use Maintenance\Application\Port\Outbound\Schedule\MaintenanceScheduleRepositoryPort; +use Maintenance\Application\Service\MaintenanceLegacyPlanService; use Maintenance\Application\UseCase\Command\Plan\ManageMaintenancePlan\{ManageMaintenancePlanCommand, ManageMaintenancePlanHandler}; use Maintenance\Domain\Exception\{MaintenanceAccessDeniedException, MaintenanceNotFoundException, MaintenanceValidationException}; use Organization\Application\Contract\Authorization\OrganizationAccessDecision; @@ -126,7 +127,8 @@ protected function setUp(): void $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); + $legacy = new MaintenanceLegacyPlanService($this->store, $this->schedules, $policy, $this->work, $clock, $ids); + $this->handler = new ManageMaintenancePlanHandler($this->store, $this->schedules, $this->equipment, $this->facilities, $policy, $this->drafts, $this->work, $authorization, $clock, $ids, $this->events, $legacy); } #[Test] diff --git a/tests/Unit/Maintenance/Domain/Model/MaintenanceOccurrenceTest.php b/tests/Unit/Maintenance/Domain/Model/MaintenanceOccurrenceTest.php index 25617bf36..e9adddd86 100644 --- a/tests/Unit/Maintenance/Domain/Model/MaintenanceOccurrenceTest.php +++ b/tests/Unit/Maintenance/Domain/Model/MaintenanceOccurrenceTest.php @@ -6,7 +6,7 @@ use DateTimeImmutable; use Maintenance\Domain\Model\MaintenanceOccurrence; -use Maintenance\Domain\ValueObject\MaintenanceOperationKind; +use Maintenance\Domain\ValueObject\{MaintenanceOccurrenceAttempt, MaintenanceOperationKind}; use PHPUnit\Framework\Attributes\{CoversClass, Test, UsesClass}; use PHPUnit\Framework\TestCase; use Shared\Domain\Exception\InvalidValueException; @@ -20,6 +20,7 @@ */ #[CoversClass(MaintenanceOccurrence::class)] #[UsesClass(MaintenanceOperationKind::class)] +#[UsesClass(MaintenanceOccurrenceAttempt::class)] final class MaintenanceOccurrenceTest extends TestCase { // #region Methods @@ -196,10 +197,12 @@ public function testRestorationKeepsAttemptAndCompletionReceipt(): void '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', + new MaintenanceOccurrenceAttempt( + 2, + '018fa004-1111-7111-8111-111111111111', + new DateTimeImmutable('2026-02-02'), + '018fa005-1111-7111-8111-111111111111', + ), ); self::assertSame('completed', $occurrence->state()); @@ -225,10 +228,75 @@ public function testRestorationRejectsUnlinkedCompletion(): void '018fa002-1111-7111-8111-111111111111', new DateTimeImmutable('2026-01-31'), new DateTimeImmutable('2026-01-01'), - 0, - null, - new DateTimeImmutable('2026-02-02'), - null, + new MaintenanceOccurrenceAttempt(0, null, new DateTimeImmutable('2026-02-02'), null), + ); + } + + /** + * Method testRestorationKeepsFailedReceiptAndRetryRetainsOriginalDueDate + * + * A failed receipt survives reload and replay until an explicit retry replaces + * the work, without replacing the occurrence or its originally reserved date. + * + * @access public + * + * @return void + */ + #[Test] + public function testRestorationKeepsFailedReceiptAndRetryRetainsOriginalDueDate(): void + { + $dueAt = new DateTimeImmutable('2026-01-31'); + $createdAt = new DateTimeImmutable('2026-01-01'); + $resultId = '018fa005-1111-7111-8111-111111111111'; + $occurrence = MaintenanceOccurrence::reconstitute( + '018fa001-1111-7111-8111-111111111111', + '018fa002-1111-7111-8111-111111111111', + $dueAt, + $createdAt, + new MaintenanceOccurrenceAttempt(2, '018fa004-1111-7111-8111-111111111111', null, $resultId), + ); + + self::assertSame('open', $occurrence->state()); + self::assertSame($resultId, $occurrence->resultId()); + self::assertNull($occurrence->completedAt()); + self::assertFalse($occurrence->validateResult(MaintenanceOperationKind::MAINTENANCE, true, $resultId, new DateTimeImmutable('2025-12-31'))); + + $occurrence->retryAttempt('018fa006-1111-7111-8111-111111111111'); + + self::assertSame(3, $occurrence->attempt()); + self::assertSame('018fa006-1111-7111-8111-111111111111', $occurrence->interventionId()); + self::assertNull($occurrence->resultId()); + self::assertSame('018fa001-1111-7111-8111-111111111111', $occurrence->id); + self::assertSame($dueAt, $occurrence->dueAt); + self::assertSame($createdAt, $occurrence->createdAt); + } + + /** + * Method testRestorationRejectsCompletionBeforeReservation + * + * The aggregate checks the receipt against its own reservation time after + * the attempt object validates the linked work and completion receipt. + * + * @access public + * + * @return void + */ + #[Test] + public function testRestorationRejectsCompletionBeforeReservation(): 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'), + new MaintenanceOccurrenceAttempt( + 1, + '018fa004-1111-7111-8111-111111111111', + new DateTimeImmutable('2025-12-31'), + '018fa005-1111-7111-8111-111111111111', + ), ); } diff --git a/tests/Unit/Maintenance/Domain/Model/MaintenancePlanTest.php b/tests/Unit/Maintenance/Domain/Model/MaintenancePlanTest.php index ccd6534c1..af1204b9a 100644 --- a/tests/Unit/Maintenance/Domain/Model/MaintenancePlanTest.php +++ b/tests/Unit/Maintenance/Domain/Model/MaintenancePlanTest.php @@ -5,8 +5,9 @@ namespace Tests\Unit\Maintenance\Domain\Model; use DateTimeImmutable; +use DateTimeZone; use Maintenance\Domain\Model\MaintenancePlan; -use Maintenance\Domain\ValueObject\{MaintenanceOperationKind, PlanCadence}; +use Maintenance\Domain\ValueObject\{MaintenanceOperationKind, MaintenancePlanCalendar, MaintenancePlanIdentity, PlanCadence}; use PHPUnit\Framework\Attributes\{CoversClass, Test, UsesClass}; use PHPUnit\Framework\TestCase; use Shared\Domain\Exception\InvalidValueException; @@ -22,6 +23,8 @@ */ #[CoversClass(MaintenancePlan::class)] #[UsesClass(PlanCadence::class)] +#[UsesClass(MaintenancePlanCalendar::class)] +#[UsesClass(MaintenancePlanIdentity::class)] final class MaintenancePlanTest extends TestCase { // #region Methods @@ -91,9 +94,7 @@ public function testEarlyCompletionAdvancesItsDueSlot(): void public function testLegacyUnscheduledPlanStartsFromFirstValidation(): void { $plan = MaintenancePlan::create( - '018fa001-1111-7111-8111-111111111111', - '018fa002-1111-7111-8111-111111111111', - '018fa003-1111-7111-8111-111111111111', + $this->identity(), 'Historical inspection', MaintenanceOperationKind::CONTROL, PlanCadence::legacyFromString('P1M'), @@ -150,17 +151,17 @@ public function testGenerationIsSuspendedBySiteEquipmentAndPlanLifecycle(): void public function testRestorationKeepsAnchorAndDueDateSeparate(): void { $plan = MaintenancePlan::reconstitute( - '018fa001-1111-7111-8111-111111111111', - '018fa002-1111-7111-8111-111111111111', - '018fa003-1111-7111-8111-111111111111', + $this->identity(), '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 MaintenancePlanCalendar( + PlanCadence::fromString('P1M'), + new DateTimeImmutable('2026-01-31T00:00:00+00:00'), + new DateTimeImmutable('2026-02-28T00:00:00+00:00'), + false, + ), 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())); @@ -179,17 +180,17 @@ 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', + $this->identity(), '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 MaintenancePlanCalendar( + PlanCadence::fromString('P1M'), + new DateTimeImmutable('2026-01-31T00:00:00+00:00'), + new DateTimeImmutable('2026-02-15T00:00:00+00:00'), + false, + ), new DateTimeImmutable('2026-01-01T00:00:00+00:00'), false, - false, ); } @@ -204,23 +205,63 @@ public function testRestorationRejectsDueDateOutsideAnchoredCalendar(): void public function testRestorationAcceptsLeapDayClampWithoutChangingAnchor(): void { $plan = MaintenancePlan::reconstitute( - '018fa001-1111-7111-8111-111111111111', - '018fa002-1111-7111-8111-111111111111', - '018fa003-1111-7111-8111-111111111111', + $this->identity(), '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 MaintenancePlanCalendar( + PlanCadence::fromString('P1Y'), + new DateTimeImmutable('2024-02-29T00:00:00+00:00'), + new DateTimeImmutable('2025-02-28T00:00:00+00:00'), + false, + ), 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 testRestorationRetainsArchivedStateAndRehydratedCalendarTimezone + * + * Restored plans preserve their exact persisted name, ownership and calendar + * after UTC dates have been returned to the plan's frozen timezone. + * + * @access public + * + * @return void + */ + #[Test] + public function testRestorationRetainsArchivedStateAndRehydratedCalendarTimezone(): void + { + $timezone = new DateTimeZone('Europe/Paris'); + $anchor = new DateTimeImmutable('2026-01-31T00:00:00', $timezone); + $due = new DateTimeImmutable('2026-03-31T00:00:00', $timezone); + $createdAt = new DateTimeImmutable('2026-01-01T00:00:00+00:00'); + $identity = $this->identity(); + $plan = MaintenancePlan::reconstitute( + $identity, + ' Saved operation ', + MaintenanceOperationKind::MAINTENANCE, + new MaintenancePlanCalendar(PlanCadence::fromString('P1M'), $anchor, $due, false), + $createdAt, + true, + ); + + self::assertSame($identity->id, $plan->id); + self::assertSame($identity->organizationId, $plan->organizationId); + self::assertSame($identity->equipmentId, $plan->equipmentId); + self::assertSame(' Saved operation ', $plan->name); + self::assertSame($createdAt, $plan->createdAt); + self::assertSame($anchor, $plan->anchorAt); + self::assertSame($due, $plan->nextDueAt()); + self::assertFalse($plan->legacy); + self::assertTrue($plan->isArchived()); + self::assertFalse($plan->canGenerate(false, false)); + self::assertSame(['2026-03-31T00:00:00+02:00', '2026-04-30T00:00:00+02:00', '2026-05-31T00:00:00+02:00'], array_map(static fn (DateTimeImmutable $date): string => $date->format('c'), $plan->preview())); + } + /** * Method testFixedPlanRequiresFirstDueDate * @@ -234,9 +275,7 @@ 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', + $this->identity(), 'Inspection', MaintenanceOperationKind::CONTROL, PlanCadence::fromString('P1Y'), @@ -258,9 +297,7 @@ 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', + $this->identity(), 'Inspection', MaintenanceOperationKind::CONTROL, PlanCadence::legacyFromString('P1M'), @@ -284,9 +321,7 @@ public function testLegacyFlagMustMatchCadence(): void 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', + $this->identity(), 'Monthly operation', $kind, PlanCadence::fromString($interval), @@ -294,5 +329,23 @@ private function plan(string $interval, MaintenanceOperationKind $kind = Mainten new DateTimeImmutable('2026-01-01T00:00:00+00:00'), ); } + + /** + * Method identity + * + * Returns the same validated ownership scope for independent operation tests. + * + * @access private + * + * @return MaintenancePlanIdentity the plan ownership scope + */ + private function identity(): MaintenancePlanIdentity + { + return new MaintenancePlanIdentity( + '018fa001-1111-7111-8111-111111111111', + '018fa002-1111-7111-8111-111111111111', + '018fa003-1111-7111-8111-111111111111', + ); + } // #endregion } diff --git a/tests/Unit/Maintenance/Domain/ValueObject/MaintenanceControlDatesTest.php b/tests/Unit/Maintenance/Domain/ValueObject/MaintenanceControlDatesTest.php new file mode 100644 index 000000000..483e420b0 --- /dev/null +++ b/tests/Unit/Maintenance/Domain/ValueObject/MaintenanceControlDatesTest.php @@ -0,0 +1,80 @@ +including($late, new DateTimeImmutable('2026-09-01'))->including($early, $completed); + + self::assertFalse($empty->tracked); + self::assertTrue($dates->tracked); + self::assertSame($early, $dates->nextDueAt); + self::assertSame($completed, $dates->lastCompletedAt); + } + + /** + * Method missingDueDateDominatesEveryPageOrder + * + * @access public + * + * @param bool $missingFirst whether the missing due date arrives before a scheduled control + * + * @return void + */ + #[Test] + #[DataProvider('pageOrders')] + public function missingDueDateDominatesEveryPageOrder(bool $missingFirst): void + { + $due = new DateTimeImmutable('2026-11-01'); + $completed = new DateTimeImmutable('2026-10-01'); + $dates = MaintenanceControlDates::empty(); + $dates = $missingFirst ? $dates->including(null, $completed)->including($due, null) : $dates->including($due, null)->including(null, $completed); + + self::assertTrue($dates->tracked); + self::assertNull($dates->nextDueAt); + self::assertSame($completed, $dates->lastCompletedAt); + } + + /** + * Method pageOrders + * + * @access public + * + * @return iterable both source orders + */ + public static function pageOrders(): iterable + { + yield 'unscheduled first' => [true]; + yield 'unscheduled last' => [false]; + } + // #endregion +} diff --git a/tests/Unit/Maintenance/Domain/ValueObject/MaintenanceOccurrenceAttemptTest.php b/tests/Unit/Maintenance/Domain/ValueObject/MaintenanceOccurrenceAttemptTest.php new file mode 100644 index 000000000..7bfdb8268 --- /dev/null +++ b/tests/Unit/Maintenance/Domain/ValueObject/MaintenanceOccurrenceAttemptTest.php @@ -0,0 +1,388 @@ +number); + self::assertNull($attempt->interventionId); + self::assertNull($attempt->completedAt); + self::assertNull($attempt->resultId); + } + + /** + * Method testReservedWorkAndRetryNeedNoReceiptBeforePublication + * + * @access public + * + * @param int $number the initial or retry attempt number + * + * @return void + */ + #[Test] + #[DataProvider('reservedAttemptNumbers')] + public function testReservedWorkAndRetryNeedNoReceiptBeforePublication(int $number): void + { + $attempt = new MaintenanceOccurrenceAttempt($number, 'a1111111-1111-4111-8111-111111111111', null, null); + + self::assertSame($number, $attempt->number); + self::assertSame('a1111111-1111-4111-8111-111111111111', $attempt->interventionId); + self::assertNull($attempt->completedAt); + self::assertNull($attempt->resultId); + } + + /** + * Method reservedAttemptNumbers + * + * @access public + * + * @return iterable work reservation and retry examples + */ + public static function reservedAttemptNumbers(): iterable + { + yield 'first work reservation' => [1]; + yield 'retry' => [2]; + } + + /** + * Method testFailedServicingRetainsAReceiptWithoutCompletingTheOccurrence + * + * @access public + * + * @return void + */ + #[Test] + public function testFailedServicingRetainsAReceiptWithoutCompletingTheOccurrence(): void + { + $attempt = new MaintenanceOccurrenceAttempt( + 2, + 'a1111111-1111-4111-8111-111111111111', + null, + 'b2222222-2222-4222-9222-222222222222', + ); + + self::assertSame(2, $attempt->number); + self::assertSame('a1111111-1111-4111-8111-111111111111', $attempt->interventionId); + self::assertNull($attempt->completedAt); + self::assertSame('b2222222-2222-4222-9222-222222222222', $attempt->resultId); + } + + /** + * Method testCompletedAttemptRetainsItsReceiptAndExactCompletionInstant + * + * @access public + * + * @return void + */ + #[Test] + public function testCompletedAttemptRetainsItsReceiptAndExactCompletionInstant(): void + { + $completedAt = new DateTimeImmutable('2026-10-08T14:15:16.123456+02:00'); + $attempt = new MaintenanceOccurrenceAttempt( + 1, + 'a1111111-1111-4111-8111-111111111111', + $completedAt, + 'b2222222-2222-4222-9222-222222222222', + ); + + self::assertSame(1, $attempt->number); + self::assertSame('a1111111-1111-4111-8111-111111111111', $attempt->interventionId); + self::assertSame($completedAt, $attempt->completedAt); + self::assertSame('b2222222-2222-4222-9222-222222222222', $attempt->resultId); + } + + /** + * Method testInconsistentWorkAndReceiptStatesAreRejected + * + * @access public + * + * @param int $number the supplied attempt number + * @param ?string $interventionId the optional work identifier + * @param ?string $completedAt the optional completion instant + * @param ?string $resultId the optional publication receipt identifier + * + * @return void + */ + #[Test] + #[DataProvider('invalidAttemptStates')] + public function testInconsistentWorkAndReceiptStatesAreRejected(int $number, ?string $interventionId, ?string $completedAt, ?string $resultId): void + { + $this->expectException(InvalidValueException::class); + + new MaintenanceOccurrenceAttempt($number, $interventionId, null === $completedAt ? null : new DateTimeImmutable($completedAt), $resultId); + } + + /** + * Method invalidAttemptStates + * + * @access public + * + * @return iterable rejected attempt state combinations + */ + public static function invalidAttemptStates(): iterable + { + $work = 'a1111111-1111-4111-8111-111111111111'; + $receipt = 'b2222222-2222-4222-9222-222222222222'; + $completedAt = '2026-10-08T14:15:16+02:00'; + + yield 'negative number without work' => [-1, null, null, null]; + yield 'negative number with work' => [-1, $work, null, null]; + yield 'zero number with work' => [0, $work, null, null]; + yield 'positive number without work' => [1, null, null, null]; + yield 'retry number without work' => [2, null, null, null]; + yield 'receipt without work' => [0, null, null, $receipt]; + yield 'completion without work or receipt' => [0, null, $completedAt, null]; + yield 'completion and receipt without work' => [0, null, $completedAt, $receipt]; + yield 'completion without receipt' => [1, $work, $completedAt, null]; + yield 'malformed work identifier' => [1, 'not-a-uuid', null, null]; + yield 'empty work identifier' => [1, '', null, null]; + yield 'malformed failed receipt identifier' => [1, $work, null, 'not-a-uuid']; + yield 'empty failed receipt identifier' => [1, $work, null, '']; + yield 'malformed completed receipt identifier' => [1, $work, $completedAt, 'not-a-uuid']; + } + + /** + * Method testBeginningWorkPreservesTheUnattemptedSnapshotAndReplaysItsIdentity + * + * @access public + * + * @return void + */ + #[Test] + public function testBeginningWorkPreservesTheUnattemptedSnapshotAndReplaysItsIdentity(): void + { + $unattempted = MaintenanceOccurrenceAttempt::unattempted(); + + $started = $unattempted->beginAttempt('a1111111-1111-4111-8111-111111111111'); + + self::assertNotSame($unattempted, $started); + self::assertSame(0, $unattempted->number); + self::assertNull($unattempted->interventionId); + self::assertNull($unattempted->completedAt); + self::assertNull($unattempted->resultId); + self::assertSame(1, $started->number); + self::assertSame('a1111111-1111-4111-8111-111111111111', $started->interventionId); + self::assertNull($started->completedAt); + self::assertNull($started->resultId); + self::assertSame($started, $started->beginAttempt('a1111111-1111-4111-8111-111111111111')); + } + + /** + * Method testExistingWorkRequiresAnExplicitRetryToChangeIdentity + * + * @access public + * + * @return void + */ + #[Test] + public function testExistingWorkRequiresAnExplicitRetryToChangeIdentity(): void + { + $attempt = new MaintenanceOccurrenceAttempt(1, 'a1111111-1111-4111-8111-111111111111', null, null); + $this->expectException(InvalidValueException::class); + + $attempt->beginAttempt('b2222222-2222-4222-9222-222222222222'); + } + + /** + * Method testRetryClearsTheFailedReceiptAndPreservesThePreviousSnapshot + * + * @access public + * + * @return void + */ + #[Test] + public function testRetryClearsTheFailedReceiptAndPreservesThePreviousSnapshot(): void + { + $failed = new MaintenanceOccurrenceAttempt( + 1, + 'a1111111-1111-4111-8111-111111111111', + null, + 'c3333333-3333-4333-a333-333333333333', + ); + self::assertSame($failed, $failed->retryAttempt('a1111111-1111-4111-8111-111111111111')); + + $retry = $failed->retryAttempt('b2222222-2222-4222-9222-222222222222'); + + self::assertNotSame($failed, $retry); + self::assertSame(1, $failed->number); + self::assertSame('a1111111-1111-4111-8111-111111111111', $failed->interventionId); + self::assertSame('c3333333-3333-4333-a333-333333333333', $failed->resultId); + self::assertNull($failed->completedAt); + self::assertSame(2, $retry->number); + self::assertSame('b2222222-2222-4222-9222-222222222222', $retry->interventionId); + self::assertNull($retry->resultId); + self::assertNull($retry->completedAt); + self::assertSame($retry, $retry->retryAttempt('b2222222-2222-4222-9222-222222222222')); + } + + /** + * Method testValidationRetainsItsReceiptAndReplaysTheOriginalOutcome + * + * @access public + * + * @param MaintenanceOperationKind $kind the owning operation kind + * @param bool $successful the original publication result + * @param bool $completes whether that result completes the occurrence + * + * @return void + */ + #[Test] + #[DataProvider('validationOutcomes')] + public function testValidationRetainsItsReceiptAndReplaysTheOriginalOutcome(MaintenanceOperationKind $kind, bool $successful, bool $completes): void + { + $work = new MaintenanceOccurrenceAttempt(1, 'a1111111-1111-4111-8111-111111111111', null, null); + $validatedAt = new DateTimeImmutable('2026-10-08T14:15:16.123456+02:00'); + $receiptId = 'b2222222-2222-4222-9222-222222222222'; + + $validated = $work->validateResult($kind, $successful, $receiptId, $validatedAt); + + self::assertNotSame($work, $validated); + self::assertNull($work->resultId); + self::assertNull($work->completedAt); + self::assertSame($work->number, $validated->number); + self::assertSame($work->interventionId, $validated->interventionId); + self::assertSame($receiptId, $validated->resultId); + self::assertSame($completes ? $validatedAt : null, $validated->completedAt); + self::assertSame($validated, $validated->validateResult($kind, !$successful, $receiptId, $validatedAt->modify('+1 day'))); + self::assertSame($completes ? $validatedAt : null, $validated->completedAt); + } + + /** + * Method validationOutcomes + * + * @access public + * + * @return iterable receipt and completion outcomes + */ + public static function validationOutcomes(): iterable + { + yield 'successful control' => [MaintenanceOperationKind::CONTROL, true, true]; + yield 'adverse control still completes' => [MaintenanceOperationKind::CONTROL, false, true]; + yield 'successful servicing' => [MaintenanceOperationKind::MAINTENANCE, true, true]; + yield 'failed servicing stays open' => [MaintenanceOperationKind::MAINTENANCE, false, false]; + } + + /** + * Method testAnotherReceiptCannotReplaceAFailedOrCompletedResult + * + * @access public + * + * @param bool $completed whether the first receipt completed its occurrence + * + * @return void + */ + #[Test] + #[DataProvider('receiptStates')] + public function testAnotherReceiptCannotReplaceAFailedOrCompletedResult(bool $completed): void + { + $attempt = new MaintenanceOccurrenceAttempt( + 1, + 'a1111111-1111-4111-8111-111111111111', + $completed ? new DateTimeImmutable('2026-10-08T14:15:16+02:00') : null, + 'b2222222-2222-4222-9222-222222222222', + ); + $this->expectException(InvalidValueException::class); + + $attempt->validateResult(MaintenanceOperationKind::MAINTENANCE, true, 'c3333333-3333-4333-a333-333333333333', new DateTimeImmutable('2026-10-09T14:15:16+02:00')); + } + + /** + * Method receiptStates + * + * @access public + * + * @return iterable retained receipt states + */ + public static function receiptStates(): iterable + { + yield 'failed servicing receipt' => [false]; + yield 'completed receipt' => [true]; + } + + /** + * Method testUnattemptedWorkCannotBeValidatedOrRetried + * + * @access public + * + * @param bool $retry whether the attempted transition is a retry + * + * @return void + */ + #[Test] + #[DataProvider('unattemptedTransitions')] + public function testUnattemptedWorkCannotBeValidatedOrRetried(bool $retry): void + { + $attempt = MaintenanceOccurrenceAttempt::unattempted(); + $this->expectException(InvalidValueException::class); + + if ($retry) { + $attempt->retryAttempt('a1111111-1111-4111-8111-111111111111'); + } else { + $attempt->validateResult(MaintenanceOperationKind::CONTROL, true, 'b2222222-2222-4222-9222-222222222222', new DateTimeImmutable('2026-10-08T14:15:16+02:00')); + } + } + + /** + * Method unattemptedTransitions + * + * @access public + * + * @return iterable transitions requiring existing work + */ + public static function unattemptedTransitions(): iterable + { + yield 'retry' => [true]; + yield 'validation' => [false]; + } + + /** + * Method testCompletedWorkCannotBeRetriedEvenWithTheSameIdentity + * + * @access public + * + * @return void + */ + #[Test] + public function testCompletedWorkCannotBeRetriedEvenWithTheSameIdentity(): void + { + $attempt = new MaintenanceOccurrenceAttempt( + 1, + 'a1111111-1111-4111-8111-111111111111', + new DateTimeImmutable('2026-10-08T14:15:16+02:00'), + 'b2222222-2222-4222-9222-222222222222', + ); + $this->expectException(InvalidValueException::class); + + $attempt->retryAttempt('a1111111-1111-4111-8111-111111111111'); + } + // #endregion +} diff --git a/tests/Unit/Maintenance/Domain/ValueObject/MaintenancePlanCalendarTest.php b/tests/Unit/Maintenance/Domain/ValueObject/MaintenancePlanCalendarTest.php new file mode 100644 index 000000000..12f5cb0fc --- /dev/null +++ b/tests/Unit/Maintenance/Domain/ValueObject/MaintenancePlanCalendarTest.php @@ -0,0 +1,444 @@ +cadence); + self::assertSame($firstDueAt, $calendar->anchorAt); + self::assertSame($firstDueAt, $calendar->nextDueAt); + self::assertFalse($calendar->legacy); + } + + /** + * Method testLegacyStartRetainsTheHistoricalCadence + * + * @access public + * + * @return void + */ + #[Test] + public function testLegacyStartRetainsTheHistoricalCadence(): void + { + $cadence = PlanCadence::legacyFromString('P1Y6M'); + $firstDueAt = new DateTimeImmutable('2026-01-31T08:15:00+01:00'); + + $calendar = MaintenancePlanCalendar::start($cadence, $firstDueAt, true); + + self::assertSame($cadence, $calendar->cadence); + self::assertSame($firstDueAt, $calendar->anchorAt); + self::assertSame($firstDueAt, $calendar->nextDueAt); + self::assertTrue($calendar->legacy); + } + + /** + * Method testLegacyStartAllowsUnknownInitialDates + * + * @access public + * + * @return void + */ + #[Test] + public function testLegacyStartAllowsUnknownInitialDates(): void + { + $cadence = PlanCadence::legacyFromString('P1M'); + + $calendar = MaintenancePlanCalendar::start($cadence, null, true); + + self::assertSame($cadence, $calendar->cadence); + self::assertNull($calendar->anchorAt); + self::assertNull($calendar->nextDueAt); + self::assertTrue($calendar->legacy); + self::assertSame([], $calendar->preview()); + } + + /** + * Method testFixedStartRequiresAnExplicitFirstDueDate + * + * @access public + * + * @return void + */ + #[Test] + public function testFixedStartRequiresAnExplicitFirstDueDate(): void + { + $this->expectException(InvalidValueException::class); + + MaintenancePlanCalendar::start(PlanCadence::fromString('P1M'), null); + } + + /** + * Method testInvalidFixedCalendarAndLegacyFlagStatesAreRejected + * + * @access public + * + * @param bool $cadenceLegacy whether the duration is historical + * @param ?string $anchorAt the optional original anchor + * @param ?string $nextDueAt the optional next slot + * @param bool $legacy the calendar's persisted historical flag + * + * @return void + */ + #[Test] + #[DataProvider('invalidCalendarStates')] + public function testInvalidFixedCalendarAndLegacyFlagStatesAreRejected(bool $cadenceLegacy, ?string $anchorAt, ?string $nextDueAt, bool $legacy): void + { + $cadence = $cadenceLegacy ? PlanCadence::legacyFromString('P1M') : PlanCadence::fromString('P1M'); + $this->expectException(InvalidValueException::class); + + new MaintenancePlanCalendar( + $cadence, + null === $anchorAt ? null : new DateTimeImmutable($anchorAt), + null === $nextDueAt ? null : new DateTimeImmutable($nextDueAt), + $legacy, + ); + } + + /** + * Method invalidCalendarStates + * + * @access public + * + * @return iterable rejected flag and fixed calendar combinations + */ + public static function invalidCalendarStates(): iterable + { + $anchor = '2026-01-31T08:15:00.123456+00:00'; + $due = '2026-02-28T08:15:00.123456+00:00'; + + yield 'fixed cadence marked legacy' => [false, $anchor, $due, true]; + yield 'historical cadence marked fixed' => [true, $anchor, $due, false]; + yield 'fixed calendar without either date' => [false, null, null, false]; + yield 'fixed calendar without anchor' => [false, null, $due, false]; + yield 'fixed calendar without next due date' => [false, $anchor, null, false]; + yield 'fixed due date before anchor' => [false, $anchor, '2026-01-30T08:15:00.123456+00:00', false]; + yield 'fixed due date between slots' => [false, $anchor, '2026-02-27T08:15:00.123456+00:00', false]; + yield 'fixed due date one microsecond after slot' => [false, $anchor, '2026-02-28T08:15:00.123457+00:00', false]; + yield 'fixed due date one microsecond before slot' => [false, $anchor, '2026-02-28T08:15:00.123455+00:00', false]; + } + + /** + * Method testTheSameSlotInstantIsAcceptedInAnotherTimezone + * + * @access public + * + * @return void + */ + #[Test] + public function testTheSameSlotInstantIsAcceptedInAnotherTimezone(): void + { + $anchor = new DateTimeImmutable('2026-01-31 08:15:00.123456', new DateTimeZone('Europe/Paris')); + $due = new DateTimeImmutable('2026-02-28T07:15:00.123456+00:00'); + + $calendar = new MaintenancePlanCalendar(PlanCadence::fromString('P1M'), $anchor, $due, false); + + self::assertSame($anchor, $calendar->anchorAt); + self::assertSame($due->format('U.u'), $calendar->nextDueAt?->format('U.u')); + self::assertSame(['2026-02-28T08:15:00+01:00', '2026-03-31T08:15:00+02:00', '2026-04-30T08:15:00+02:00'], array_map(static fn (DateTimeImmutable $date): string => $date->format('c'), $calendar->preview())); + } + + /** + * Method testLegacyDatesRemainIndependentlyNullableAndUnaligned + * + * @access public + * + * @param ?string $anchorAt the persisted historical anchor + * @param ?string $nextDueAt the persisted historical due date + * + * @return void + */ + #[Test] + #[DataProvider('legacyCalendarStates')] + public function testLegacyDatesRemainIndependentlyNullableAndUnaligned(?string $anchorAt, ?string $nextDueAt): void + { + $anchor = null === $anchorAt ? null : new DateTimeImmutable($anchorAt); + $due = null === $nextDueAt ? null : new DateTimeImmutable($nextDueAt); + $cadence = PlanCadence::legacyFromString('P1M'); + + $calendar = new MaintenancePlanCalendar($cadence, $anchor, $due, true); + + self::assertSame($cadence, $calendar->cadence); + self::assertSame($anchor, $calendar->anchorAt); + self::assertSame($due, $calendar->nextDueAt); + self::assertTrue($calendar->legacy); + if (null === $due) { + self::assertSame([], $calendar->preview()); + } else { + self::assertSame($due->format('U.u'), $calendar->preview(1)[0]->format('U.u')); + } + } + + /** + * Method legacyCalendarStates + * + * @access public + * + * @return iterable historical dates that must not be synthesized or realigned + */ + public static function legacyCalendarStates(): iterable + { + yield 'uninitialized dates' => [null, null]; + yield 'anchor without due date' => ['2026-01-31T08:15:00+00:00', null]; + yield 'due date without anchor' => [null, '2026-02-14T08:15:00+00:00']; + yield 'sliding due date between fixed slots' => ['2026-01-31T08:15:00+00:00', '2026-02-14T08:15:00+00:00']; + yield 'due date before anchor' => ['2026-01-31T08:15:00+00:00', '2025-12-14T08:15:00+00:00']; + } + + /** + * Method testFixedPreviewRestoresTheOriginalDayAfterAShortMonth + * + * @access public + * + * @return void + */ + #[Test] + public function testFixedPreviewRestoresTheOriginalDayAfterAShortMonth(): void + { + $calendar = new MaintenancePlanCalendar( + PlanCadence::fromString('P1M'), + new DateTimeImmutable('2026-01-31T08:15:00.123456+00:00'), + new DateTimeImmutable('2026-02-28T08:15:00.123456+00:00'), + false, + ); + + self::assertSame( + ['2026-02-28T08:15:00.123456+00:00', '2026-03-31T08:15:00.123456+00:00', '2026-04-30T08:15:00.123456+00:00'], + array_map(static fn (DateTimeImmutable $date): string => $date->format('Y-m-d\TH:i:s.uP'), $calendar->preview()), + ); + } + + /** + * Method testFixedAdvanceSkipsMissedSlotsAndPreservesTheOriginalAnchor + * + * @access public + * + * @param string $validatedAt the validated completion instant + * @param string $expected the next anchored slot + * + * @return void + */ + #[Test] + #[DataProvider('fixedAdvanceCases')] + public function testFixedAdvanceSkipsMissedSlotsAndPreservesTheOriginalAnchor(string $validatedAt, string $expected): void + { + $anchor = new DateTimeImmutable('2026-01-31T08:15:00.123456+00:00'); + $due = new DateTimeImmutable('2026-02-28T08:15:00.123456+00:00'); + $cadence = PlanCadence::fromString('P1M'); + $calendar = new MaintenancePlanCalendar($cadence, $anchor, $due, false); + + $advanced = $calendar->advance(new DateTimeImmutable($validatedAt)); + + self::assertNotSame($calendar, $advanced); + self::assertSame($cadence, $advanced->cadence); + self::assertSame($anchor, $advanced->anchorAt); + self::assertSame($expected, $advanced->nextDueAt?->format('Y-m-d\TH:i:s.uP')); + self::assertFalse($advanced->legacy); + self::assertSame($due, $calendar->nextDueAt); + } + + /** + * Method fixedAdvanceCases + * + * @access public + * + * @return iterable strict anchored advancement examples + */ + public static function fixedAdvanceCases(): iterable + { + yield 'completion before current due date' => ['2026-02-15T08:15:00+00:00', '2026-03-31T08:15:00.123456+00:00']; + yield 'completion at current due date' => ['2026-02-28T08:15:00.123456+00:00', '2026-03-31T08:15:00.123456+00:00']; + yield 'completion after missed slots' => ['2026-06-14T08:15:00+00:00', '2026-06-30T08:15:00.123456+00:00']; + yield 'completion exactly at a later slot' => ['2026-06-30T08:15:00.123456+00:00', '2026-07-31T08:15:00.123456+00:00']; + } + + /** + * Method testFixedYearlyCalendarRestoresLeapDay + * + * @access public + * + * @return void + */ + #[Test] + public function testFixedYearlyCalendarRestoresLeapDay(): void + { + $calendar = new MaintenancePlanCalendar( + PlanCadence::fromString('P1Y'), + new DateTimeImmutable('2024-02-29T08:15:00+00:00'), + new DateTimeImmutable('2027-02-28T08:15:00+00:00'), + false, + ); + + self::assertSame(['2027-02-28', '2028-02-29', '2029-02-28'], array_map(static fn (DateTimeImmutable $date): string => $date->format('Y-m-d'), $calendar->preview())); + self::assertSame('2028-02-29', $calendar->advance(new DateTimeImmutable('2027-02-28T08:15:00+00:00'))->nextDueAt?->format('Y-m-d')); + } + + /** + * Method testCalendarDaysKeepLocalTimeAcrossDaylightSavingChanges + * + * @access public + * + * @param string $firstDueAt the local original anchor + * @param list $expected the calendar dates with expected UTC offsets + * + * @return void + */ + #[Test] + #[DataProvider('daylightSavingCases')] + public function testCalendarDaysKeepLocalTimeAcrossDaylightSavingChanges(string $firstDueAt, array $expected): void + { + $anchor = new DateTimeImmutable($firstDueAt, new DateTimeZone('Europe/Paris')); + $calendar = MaintenancePlanCalendar::start(PlanCadence::fromString('P1D'), $anchor); + + self::assertSame($expected, array_map(static fn (DateTimeImmutable $date): string => $date->format('c'), $calendar->preview())); + $advanced = $calendar->advance($anchor); + self::assertSame($anchor, $advanced->anchorAt); + self::assertSame($expected[1], $advanced->nextDueAt?->format('c')); + self::assertSame('Europe/Paris', $advanced->nextDueAt->getTimezone()->getName()); + } + + /** + * Method daylightSavingCases + * + * @access public + * + * @return iterable}> spring and autumn calendar transitions + */ + public static function daylightSavingCases(): iterable + { + yield 'spring forward' => ['2026-03-28 08:15:00', ['2026-03-28T08:15:00+01:00', '2026-03-29T08:15:00+02:00', '2026-03-30T08:15:00+02:00']]; + yield 'autumn backward' => ['2026-10-24 08:15:00', ['2026-10-24T08:15:00+02:00', '2026-10-25T08:15:00+01:00', '2026-10-26T08:15:00+01:00']]; + } + + /** + * Method testLegacyAdvanceSlidesFromCompletionAndPreservesTheStoredAnchor + * + * @access public + * + * @return void + */ + #[Test] + public function testLegacyAdvanceSlidesFromCompletionAndPreservesTheStoredAnchor(): void + { + $cadence = PlanCadence::legacyFromString('P1M'); + $anchor = new DateTimeImmutable('2025-11-30T08:15:00+00:00'); + $due = new DateTimeImmutable('2026-02-14T08:15:00+00:00'); + $calendar = new MaintenancePlanCalendar($cadence, $anchor, $due, true); + + $advanced = $calendar->advance(new DateTimeImmutable('2026-01-31T08:15:00+00:00')); + + self::assertSame($cadence, $advanced->cadence); + self::assertSame($anchor, $advanced->anchorAt); + self::assertSame('2026-03-03T08:15:00+00:00', $advanced->nextDueAt?->format('c')); + self::assertTrue($advanced->legacy); + self::assertSame($due, $calendar->nextDueAt); + self::assertSame(['2026-03-03', '2026-04-03', '2026-05-03'], array_map(static fn (DateTimeImmutable $date): string => $date->format('Y-m-d'), $advanced->preview())); + } + + /** + * Method testLegacyAdvanceCanInitializeDueDateWithoutSynthesizingAnAnchor + * + * @access public + * + * @return void + */ + #[Test] + public function testLegacyAdvanceCanInitializeDueDateWithoutSynthesizingAnAnchor(): void + { + $calendar = new MaintenancePlanCalendar(PlanCadence::legacyFromString('P1Y6M'), null, null, true); + + $advanced = $calendar->advance(new DateTimeImmutable('2026-01-31T08:15:00+00:00')); + + self::assertNull($advanced->anchorAt); + self::assertSame('2027-07-31T08:15:00+00:00', $advanced->nextDueAt?->format('c')); + self::assertTrue($advanced->legacy); + self::assertNull($calendar->nextDueAt); + } + + /** + * Method testLegacyPreviewRetainsHistoricalMonthOverflow + * + * @access public + * + * @return void + */ + #[Test] + public function testLegacyPreviewRetainsHistoricalMonthOverflow(): void + { + $calendar = new MaintenancePlanCalendar( + PlanCadence::legacyFromString('P1M'), + null, + new DateTimeImmutable('2026-01-31T08:15:00+00:00'), + true, + ); + + self::assertSame(['2026-01-31', '2026-03-03', '2026-04-03'], array_map(static fn (DateTimeImmutable $date): string => $date->format('Y-m-d'), $calendar->preview())); + } + + /** + * Method testPreviewCountRemainsBounded + * + * @access public + * + * @param int $count the invalid requested preview size + * + * @return void + */ + #[Test] + #[DataProvider('invalidPreviewCounts')] + public function testPreviewCountRemainsBounded(int $count): void + { + $calendar = MaintenancePlanCalendar::start(PlanCadence::fromString('P1D'), new DateTimeImmutable('2026-01-01T08:15:00+00:00')); + $this->expectException(InvalidValueException::class); + + $calendar->preview($count); + } + + /** + * Method invalidPreviewCounts + * + * @access public + * + * @return iterable rejected preview sizes + */ + public static function invalidPreviewCounts(): iterable + { + yield 'empty preview' => [0]; + yield 'excessive preview' => [101]; + } + // #endregion +} diff --git a/tests/Unit/Maintenance/Domain/ValueObject/MaintenancePlanIdentityTest.php b/tests/Unit/Maintenance/Domain/ValueObject/MaintenancePlanIdentityTest.php new file mode 100644 index 000000000..ac77bf328 --- /dev/null +++ b/tests/Unit/Maintenance/Domain/ValueObject/MaintenancePlanIdentityTest.php @@ -0,0 +1,81 @@ +id); + self::assertSame('A1111111-1111-4111-8111-111111111111', $identity->organizationId); + self::assertSame('b2222222-2222-4222-9222-222222222222', $identity->equipmentId); + } + + /** + * Method testEveryIdentifierMustBeAValidUuid + * + * @access public + * + * @param string $id the plan identifier + * @param string $organizationId the organization identifier + * @param string $equipmentId the equipment identifier + * + * @return void + */ + #[Test] + #[DataProvider('invalidIdentifiers')] + public function testEveryIdentifierMustBeAValidUuid(string $id, string $organizationId, string $equipmentId): void + { + $this->expectException(InvalidValueException::class); + + new MaintenancePlanIdentity($id, $organizationId, $equipmentId); + } + + /** + * Method invalidIdentifiers + * + * @access public + * + * @return iterable invalid identifiers at each ownership boundary + */ + public static function invalidIdentifiers(): iterable + { + $valid = 'a1111111-1111-4111-8111-111111111111'; + foreach (['empty' => '', 'malformed' => 'not-a-uuid', 'nil' => '00000000-0000-0000-0000-000000000000', 'invalid variant' => 'a1111111-1111-4111-7111-111111111111'] as $case => $invalid) { + yield 'plan ' . $case => [$invalid, $valid, $valid]; + yield 'organization ' . $case => [$valid, $invalid, $valid]; + yield 'equipment ' . $case => [$valid, $valid, $invalid]; + } + } + // #endregion +} diff --git a/tests/Unit/Maintenance/Domain/ValueObject/PlanCadenceTest.php b/tests/Unit/Maintenance/Domain/ValueObject/PlanCadenceTest.php index 4aab9f929..bea579b58 100644 --- a/tests/Unit/Maintenance/Domain/ValueObject/PlanCadenceTest.php +++ b/tests/Unit/Maintenance/Domain/ValueObject/PlanCadenceTest.php @@ -95,7 +95,7 @@ public function testInvalidCadencesAreRejected(string $interval): void */ public static function invalidCadences(): iterable { - foreach (['', 'P0D', 'P01D', 'P-1D', 'P1Y6M', 'PT1H', 'P1.5D', 'P3651D', 'P521W', 'P121M', 'P11Y', 'P1D\n', ' P1D'] as $value) { + foreach (['', 'P0D', 'P01D', 'P-1D', 'P1Y6M', 'PT1H', 'P1.5D', 'P3651D', 'P521W', 'P121M', 'P11Y', 'P1D\n', ' P1D', 'P1٢D', 'P12D'] as $value) { yield $value => [$value]; } } diff --git a/tests/Unit/MaintenanceCost/Application/Service/MaintenanceCostProjectionTest.php b/tests/Unit/MaintenanceCost/Application/Service/MaintenanceCostProjectionTest.php index 35abbc2ea..7159dadeb 100644 --- a/tests/Unit/MaintenanceCost/Application/Service/MaintenanceCostProjectionTest.php +++ b/tests/Unit/MaintenanceCost/Application/Service/MaintenanceCostProjectionTest.php @@ -9,7 +9,7 @@ use Intervention\Application\Port\Inbound\InterventionCostSourceFactsPort; use Inventory\Application\Contract\Stock\InventoryCostFact; use Inventory\Application\Port\Inbound\InventoryInterventionResourcesPort; -use MaintenanceCost\Application\Contract\Cost\{MaintenanceCostItem, MaintenanceCostSnapshot, MaintenanceCostTotals}; +use MaintenanceCost\Application\Contract\Cost\{MaintenanceCostItem, MaintenanceCostSnapshot, MaintenanceCostTotals, MaintenanceExpense}; use MaintenanceCost\Application\Contract\Rate\MaintenanceRateSnapshot; use MaintenanceCost\Application\Port\Inbound\{MaintenanceCurrencyPort, MaintenanceRatePort}; use MaintenanceCost\Application\Port\Outbound\MaintenanceCostStorePort; @@ -66,11 +66,50 @@ public function testALaterRateCanCompleteCurrentCostsWithoutChangingAnIncomplete self::assertFalse($frozen->totals->complete); } + public function testChangingTheWorkedDateUsesItsEffectiveRateAndRetainsTheCapturedCorrectionAnchor(): 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])); + $changed = new InterventionTimeCostFact('entry', 'task', 'member', '2026-10-02', 30, 2, false, 'Corrected date', new DateTimeImmutable('2026-10-03T10:00:00Z')); + $current = $this->projection([$changed], new MaintenanceRateSnapshot('new-rate', 'member', '200.000000', 'EUR', '2026-10-02'))->current('org', 'order', 'EUR', $frozen); + + self::assertSame('100.000000', $current->total); + self::assertSame('200.000000', $current->items[0]->hourlyAmount); + self::assertSame('new-rate', $current->items[0]->rateId); + self::assertSame('time:entry:1', $current->items[0]->correctionOf); + self::assertSame('50.000000', $frozen->totals->total); + } + + public function testLateTimeRetainsItsPublicationAnchor(): void + { + $frozen = new MaintenanceCostSnapshot(1, '2026-10-02T10:00:00Z', 'publication', 4, 'EUR', new MaintenanceCostTotals('0.000000', '0.000000', true, [])); + $current = $this->projection([$this->time(1, 30)], new MaintenanceRateSnapshot('rate', 'member', '100.000000', 'EUR', '2026-09-01'))->current('org', 'order', 'EUR', $frozen); + + self::assertSame('50.000000', $current->total); + self::assertSame('publication:publication', $current->items[0]->correctionOf); + self::assertSame([], $frozen->totals->items); + } + + public function testAppendOnlyCorrectionsKeepTheirOriginalSourceInsteadOfTheLatePublicationAnchor(): void + { + $frozen = new MaintenanceCostSnapshot(1, '2026-10-02T10:00:00Z', 'publication', 4, 'EUR', new MaintenanceCostTotals('0.000000', '0.000000', true, [])); + $date = new DateTimeImmutable('2026-10-03T10:00:00Z'); + $material = new InventoryCostFact('return', 'part', '-1.000000', '5.000000', '-5.000000', 'EUR', $date, 'task', 'movement'); + $expense = new MaintenanceExpense('adjustment', 'org', 'order', 'task', 'client', '-2.000000', 'EUR', 'Correction', $date, 'original-expense', 'actor', 'payload-hash', $date); + $current = $this->projection([], null, [$material], [$expense])->current('org', 'order', 'EUR', $frozen); + + self::assertSame('-7.000000', $current->total); + self::assertSame('material:movement', $current->items[0]->correctionOf); + self::assertSame('expense:original-expense', $current->items[1]->correctionOf); + self::assertSame('0.000000', $frozen->totals->total); + } + /** * @param list $times * @param list $materials + * @param list $expenses */ - private function projection(array $times, ?MaintenanceRateSnapshot $rate, array $materials = []): MaintenanceCostProjection + private function projection(array $times, ?MaintenanceRateSnapshot $rate, array $materials = [], array $expenses = []): MaintenanceCostProjection { $work = $this->createStub(InterventionCostSourceFactsPort::class); $work->method('timeFacts')->willReturn($times); @@ -79,7 +118,7 @@ private function projection(array $times, ?MaintenanceRateSnapshot $rate, array $rates = $this->createStub(MaintenanceRatePort::class); $rates->method('forMember')->willReturn($rate); $store = $this->createStub(MaintenanceCostStorePort::class); - $store->method('expenses')->willReturn([]); + $store->method('expenses')->willReturn($expenses); return new MaintenanceCostProjection($work, $inventory, $this->createStub(MaintenanceCurrencyPort::class), $rates, $store, new MaintenanceCostCalculator()); } diff --git a/tests/Unit/MaintenanceCost/Application/UseCase/Reporting/MaintenanceEconomicHandlersTest.php b/tests/Unit/MaintenanceCost/Application/UseCase/Reporting/MaintenanceEconomicHandlersTest.php index bca01bb06..93e6b7c9e 100644 --- a/tests/Unit/MaintenanceCost/Application/UseCase/Reporting/MaintenanceEconomicHandlersTest.php +++ b/tests/Unit/MaintenanceCost/Application/UseCase/Reporting/MaintenanceEconomicHandlersTest.php @@ -5,7 +5,7 @@ namespace Tests\Unit\MaintenanceCost\Application\UseCase\Reporting; use DateTimeImmutable; -use Intervention\Application\Contract\Publication\{InterventionEconomicContext, InterventionEconomicContextPage}; +use Intervention\Application\Contract\Publication\{InterventionEconomicContext, InterventionEconomicContextPage, InterventionEconomicSourceFilter}; use Intervention\Application\Port\Inbound\InterventionPublicationFactsPort; use Intervention\Application\Port\Outbound\{InterventionEconomicScopePort, InterventionEquipmentSnapshotPort}; use Inventory\Application\Port\Inbound\InventoryInterventionResourcesPort; @@ -99,7 +99,7 @@ public function testExactly500DossiersArePermittedWithoutPartialKnownTotals(): v 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)); + $work->expects(self::once())->method('economicPage')->with(self::ORG, 2, 10, new InterventionEconomicSourceFilter(search: 'repair'))->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); diff --git a/tests/Unit/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportHandlerTest.php b/tests/Unit/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportHandlerTest.php new file mode 100644 index 000000000..14765341e --- /dev/null +++ b/tests/Unit/MaintenanceExport/Application/UseCase/Query/ReadMaintenanceExport/ReadMaintenanceExportHandlerTest.php @@ -0,0 +1,193 @@ +createMock(InterventionPublicationFactsPort::class); + $publications->expects(self::once())->method('publishedPage')->with(self::ORG, 2, 4, 'Équipement = literal')->willReturn(new InterventionPublicationFactsPage([$source], 7, 2, 4)); + $repository = $this->createMock(MaintenanceExportRepositoryPort::class); + $repository->expects(self::never())->method('document'); + $result = new ReadMaintenanceExportHandler($repository, $this->authorization(false), $publications)(new ReadMaintenanceExportQuery(self::ACTOR, self::ORG, 'sources', page:2, itemsPerPage:4, search:'Équipement = literal')); + + self::assertSame('source', $result->kind); + self::assertSame(7, $result->total); + self::assertSame(2, $result->page); + self::assertSame(4, $result->itemsPerPage); + self::assertTrue($result->collection); + self::assertSame($blocked, $result->items[0]['blockedReason']); + self::assertSame(null === $blocked, $result->items[0]['ready']); + self::assertSame($publicationId, $result->items[0]['publicationId']); + self::assertSame($state, $result->items[0]['snapshotState']); + self::assertNull($result->items[0]['site']); + self::assertNull($result->items[0]['customer']); + self::assertFalse($result->items[0]['identityComplete']); + self::assertSame($now->format('c'), $result->items[0]['publishedAt']); + } + + /** + * Method sourceAvailability + * + * @access public + * + * @return iterable retained source availability and expected reason + */ + public static function sourceAvailability(): iterable + { + yield 'ready publication' => ['available', self::PUBLICATION, true, null]; + yield 'missing snapshot despite validated work' => ['snapshot_missing', self::PUBLICATION, true, 'snapshot_missing']; + yield 'missing publication despite validated work' => ['available', null, true, 'snapshot_missing']; + yield 'available without validated work' => ['available', self::PUBLICATION, false, 'no_validated_work']; + yield 'missing snapshot without validated work' => ['snapshot_missing', null, false, 'snapshot_missing']; + } + + #[Test] + public function financialDenialPrecedesMalformedFormatAndFileDisclosure(): void + { + $repository = $this->createMock(MaintenanceExportRepositoryPort::class); + $repository->expects(self::once())->method('document')->with(self::ORG, self::DOCUMENT)->willReturn($this->document()); + $publications = $this->createMock(InterventionPublicationFactsPort::class); + $publications->expects(self::never())->method('publishedPage'); + $handler = new ReadMaintenanceExportHandler($repository, $this->authorization(false), $publications); + + $this->expectException(MaintenanceExportException::class); + $this->expectExceptionMessage('permission'); + $handler(new ReadMaintenanceExportQuery(self::ACTOR, self::ORG, 'file', self::DOCUMENT, 'pdf')); + } + + #[Test] + #[DataProvider('retainedFormats')] + public function fileReadsReturnTheRetainedBytesAndTheirExactHash(string $format, string $mediaType): void + { + $document = $this->document(); + $repository = $this->createMock(MaintenanceExportRepositoryPort::class); + $repository->expects(self::once())->method('document')->with(self::ORG, self::DOCUMENT)->willReturn($document); + $publications = $this->createStub(InterventionPublicationFactsPort::class); + $result = new ReadMaintenanceExportHandler($repository, $this->authorization(true), $publications)(new ReadMaintenanceExportQuery(self::ACTOR, self::ORG, 'file', self::DOCUMENT, $format)); + $bytes = 'json' === $format ? $document->jsonBytes : $document->csvBytes; + + self::assertSame('file', $result->kind); + self::assertSame($bytes, $result->bytes); + self::assertSame($mediaType, $result->mediaType); + self::assertSame('fireguard-maintenance-' . self::DOCUMENT . '.' . $format, $result->fileName); + self::assertSame(hash('sha256', $bytes), $result->sha256); + } + + /** + * Method retainedFormats + * + * @access public + * + * @return iterable exact retained file formats + */ + public static function retainedFormats(): iterable + { + yield 'JSON' => ['json', 'application/json']; + yield 'CSV' => ['csv', 'text/csv']; + } + + /** + * Method authorization + * + * @access private + * + * @param bool $financial independent financial entitlement + * + * @return OrganizationAuthorizationPort granted export read with explicit cost visibility + */ + private function authorization(bool $financial): OrganizationAuthorizationPort + { + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('resolveAccess')->willReturnCallback(static fn (string $actor, string $org, string $permission): OrganizationAccessDecision => 'organization.maintenance_cost.read' === $permission && !$financial ? OrganizationAccessDecision::MISSING_PERMISSION : OrganizationAccessDecision::GRANTED); + + return $authorization; + } + + /** + * Method document + * + * @access private + * + * @return ExportDocument original financial bytes with exact decimals and Unicode text + */ + private function document(): ExportDocument + { + $rows = [['id' => 'source-row', 'kind' => 'prestation', 'equipmentName' => 'Équipement, "réserve"', 'amount' => '12.500001']]; + + return new ExportDocument(self::DOCUMENT, self::ORG, self::ACTOR, 'initial', 'erp', true, [self::WORK], null, null, null, new DateTimeImmutable('2026-10-07T12:00:00Z'), $rows, ['work' => $rows[0]], ExportArtifact::json(['rows' => $rows]), ExportArtifact::csv($rows, true), true, 0); + } + // #endregion +} diff --git a/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportArtifactTest.php b/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportArtifactTest.php index 6620765b2..61a8b56a9 100644 --- a/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportArtifactTest.php +++ b/tests/Unit/MaintenanceExport/Domain/ValueObject/ExportArtifactTest.php @@ -95,6 +95,29 @@ public function formulaLikeFinancialTextIsNotMistakenForAnExactNegativeAmount(): self::assertSame("'=1+1", $cells['minutes']); } + #[Test] + #[DataProvider('nonAsciiNumericText')] + public function onlyAsciiDecimalDigitsCanBypassCsvFormulaNeutralization(string $value): void + { + $cells = $this->csvRow(ExportArtifact::csv([['amount' => $value, 'minutes' => $value]], true)); + self::assertSame("'" . $value, $cells['amount']); + self::assertSame("'" . $value, $cells['minutes']); + } + + /** + * Method nonAsciiNumericText + * + * @access public + * + * @return iterable textual numeric lookalikes retain formula protection + */ + public static function nonAsciiNumericText(): iterable + { + yield 'Arabic digits' => ['-١٢.٣٤']; + yield 'full width digits' => ['-12.34']; + yield 'mixed digits' => ['-1.٢']; + } + #[Test] public function rowIdentityIsStableAndDifferentForDistinctSourceKeys(): void { diff --git a/tests/Unit/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandlerTest.php b/tests/Unit/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandlerTest.php index 770d373bc..31208217a 100644 --- a/tests/Unit/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandlerTest.php +++ b/tests/Unit/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandlerTest.php @@ -22,7 +22,7 @@ use Procurement\Application\UseCase\Command\ManageProcurement\{ManageProcurementCommand, ManageProcurementHandler}; use Procurement\Domain\Exception\ProcurementException; use Procurement\Domain\Model\{PurchaseOrder, Supplier}; -use Procurement\Domain\ValueObject\ProcurementLine; +use Procurement\Domain\ValueObject\{ProcurementGoodsIdentity, ProcurementLine, SupplierDetails}; use Shared\Application\Port\Outbound\{ClockPort, EventDispatcherPort, UuidGeneratorPort}; use function array_key_exists; @@ -94,7 +94,7 @@ final class ManageProcurementHandlerTest extends TestCase 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->supplier = Supplier::create(self::SUPPLIER, self::ORG, new SupplierDetails('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); @@ -140,6 +140,7 @@ public function supplierCreationRetryUsesTheCommittedIdentityAndEmitsOnce(): voi $this->events->expects(self::once())->method('dispatch'); $input = ['clientOperationId' => self::OPERATION, 'name' => 'Supplier', 'contacts' => []]; $first = ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'create_supplier', payload: $input)); + self::assertSame('6b36cf32ff25ba666e12d79fd5c8c2f6c3eb1be57636bbfd282d19da0b6de127', $this->operations[self::OPERATION]->fingerprint); $retry = ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'create_supplier', payload: ['contacts' => [], 'name' => 'Supplier', 'clientOperationId' => strtoupper(self::OPERATION)])); self::assertSame(self::RECEIPT, $first->data['id']); self::assertSame($first->data, $retry->data); @@ -168,6 +169,7 @@ public function draftCreationRetryRetainsGeneratedLineIdsAndDoesNotSaveOrEmitTwi $this->events->expects(self::once())->method('dispatch'); $input = ['clientOperationId' => self::OPERATION, 'name' => 'Draft', 'supplierId' => self::SUPPLIER, 'lines' => [['kind' => 'part', 'partId' => self::PART, 'quantity' => '2']]]; $first = ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'create_order', payload: $input)); + self::assertSame('b052703bfdd39e7f299ec9d711a3db1097fc2cb33b2894f631c47cc6f330ee8e', $this->operations[self::OPERATION]->fingerprint); $input['supplierId'] = strtoupper(self::SUPPLIER); $input['lines'][0]['partId'] = strtoupper(self::PART); $retry = ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'create_order', payload: $input)); @@ -195,6 +197,7 @@ public function partialDecimalReceiptReplaysWithoutAnotherStockMovementOrRevisio { $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()); + self::assertSame('1ffcd427a7f93355ea6b3d672ffa7a1b2b5b2e230d190b5375261fa48a9142fa', $this->operations[self::OPERATION]->fingerprint); $replay = ($this->handler)($this->receive()); self::assertSame('1.250000', $first->data['quantity']); self::assertTrue($replay->replayed); @@ -373,12 +376,12 @@ public function physicalReturnSurvivesStockShortageAndReconcilesWithoutChangingI private function partLine(): ProcurementLine { - return ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '2.500000', '4.250000'); + return ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('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); + $line = ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('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); diff --git a/tests/Unit/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementHandlerTest.php b/tests/Unit/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementHandlerTest.php index 773ac6633..e0fc5e13d 100644 --- a/tests/Unit/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementHandlerTest.php +++ b/tests/Unit/Procurement/Application/UseCase/Query/ReadProcurement/ReadProcurementHandlerTest.php @@ -16,7 +16,7 @@ use Procurement\Application\UseCase\Query\ReadProcurement\{ReadProcurementHandler, ReadProcurementQuery}; use Procurement\Domain\Exception\ProcurementException; use Procurement\Domain\Model\{PurchaseOrder, Supplier}; -use Procurement\Domain\ValueObject\ProcurementLine; +use Procurement\Domain\ValueObject\{ProcurementGoodsIdentity, ProcurementLine, SupplierDetails}; use function array_key_exists; @@ -477,7 +477,7 @@ private function handler(): ReadProcurementHandler */ private function supplier(): Supplier { - return Supplier::create(self::SUPPLIER, self::ORGANIZATION, 'Supplier', 'SUP', 'sales@example.com', null, [], $this->now()); + return Supplier::create(self::SUPPLIER, self::ORGANIZATION, new SupplierDetails('Supplier', 'SUP', 'sales@example.com', null, []), $this->now()); } /** @@ -489,7 +489,7 @@ private function supplier(): Supplier */ private function order(): PurchaseOrder { - $line = ProcurementLine::create(self::LINE, 'part', '018fa007-1111-7111-8111-111111111111', null, [], '0.25', '12.123456'); + $line = ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('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()); } diff --git a/tests/Unit/Procurement/Domain/Model/PurchaseOrderTest.php b/tests/Unit/Procurement/Domain/Model/PurchaseOrderTest.php index 23d2b0f40..fb7cef1ec 100644 --- a/tests/Unit/Procurement/Domain/Model/PurchaseOrderTest.php +++ b/tests/Unit/Procurement/Domain/Model/PurchaseOrderTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase; use Procurement\Domain\Exception\ProcurementException; use Procurement\Domain\Model\PurchaseOrder; +use Procurement\Domain\ValueObject\{ProcurementGoodsIdentity, ProcurementLineAmounts, PurchaseOrderHistory, PurchaseOrderIdentity, PurchaseOrderLines}; use Procurement\Domain\ValueObject\{ProcurementLine, PurchaseOrderStatus}; /** @@ -112,7 +113,7 @@ public function testOnlyDraftCanChangeSupplierCurrencyNameAndLines(): void public function testPartialReceiptsAdvanceLifecycleAcrossAllLines(): void { $equipmentLineId = '018fa006-1111-7111-8111-111111111111'; - $equipment = ProcurementLine::create($equipmentLineId, 'equipment_to_individualize', null, 'fire_extinguisher', ['name' => 'Extinguisher'], '2', '100'); + $equipment = ProcurementLine::create($equipmentLineId, new ProcurementGoodsIdentity('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()); @@ -370,10 +371,10 @@ public function testInvalidDraftChangeLeavesExistingOrderIntact(): void #[DataProvider('inconsistentHistory')] public function testRestorationRejectsStateNotMatchingGrossReceipts(PurchaseOrderStatus $status, string $received): void { - $line = ProcurementLine::reconstitute(self::LINE, 'part', self::PART, null, [], '5', null, $received, '0'); + $line = ProcurementLine::reconstitute(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), new ProcurementLineAmounts('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()); + PurchaseOrder::reconstitute(self::ORDER, self::ORGANIZATION, new PurchaseOrderIdentity(self::SUPPLIER, 'EUR', 'Restored order'), new PurchaseOrderLines([$line]), new PurchaseOrderHistory($status, 3, $this->now(), $this->now())); } /** @@ -402,8 +403,8 @@ public static function inconsistentHistory(): iterable #[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()); + $line = ProcurementLine::reconstitute(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), new ProcurementLineAmounts('5', '10', '2', '0.5')); + $order = PurchaseOrder::reconstitute(self::ORDER, self::ORGANIZATION, new PurchaseOrderIdentity(self::SUPPLIER, 'EUR', 'Restored order'), new PurchaseOrderLines([$line]), new PurchaseOrderHistory(PurchaseOrderStatus::CANCELLED, 7, $this->now(), $this->now())); self::assertSame(7, $order->revision()); self::assertTrue($order->cancelledRemaining()); @@ -490,7 +491,7 @@ private function purchaseOrder(?array $lines = null): PurchaseOrder */ private function line(string $quantity = '5', string $id = self::LINE): ProcurementLine { - return ProcurementLine::create($id, 'part', self::PART, null, [], $quantity, '10.123456'); + return ProcurementLine::create($id, new ProcurementGoodsIdentity('part', self::PART, null, []), $quantity, '10.123456'); } /** diff --git a/tests/Unit/Procurement/Domain/Model/SupplierTest.php b/tests/Unit/Procurement/Domain/Model/SupplierTest.php index 09608168b..f4d12c2d1 100644 --- a/tests/Unit/Procurement/Domain/Model/SupplierTest.php +++ b/tests/Unit/Procurement/Domain/Model/SupplierTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase; use Procurement\Domain\Exception\ProcurementException; use Procurement\Domain\Model\Supplier; +use Procurement\Domain\ValueObject\{SupplierDetails, SupplierHistory}; use Shared\Domain\Exception\InvalidValueException; use function array_fill; @@ -46,7 +47,7 @@ final class SupplierTest extends TestCase #[Test] public function testCreationNormalizesContactFieldsAndPreservesIdentity(): void { - $supplier = Supplier::create(self::SUPPLIER, self::ORGANIZATION, ' Supplier Example ', ' SUP-1 ', ' purchases@example.com ', ' 01 23 45 67 89 ', [['name' => ' Alice ', 'email' => ' alice@example.com ', 'role' => ' Sales ']], $this->now()); + $supplier = Supplier::create(self::SUPPLIER, self::ORGANIZATION, new SupplierDetails(' Supplier Example ', ' SUP-1 ', ' purchases@example.com ', ' 01 23 45 67 89 ', [['name' => ' Alice ', 'email' => ' alice@example.com ', 'role' => ' Sales ']]), $this->now()); self::assertSame(self::SUPPLIER, $supplier->id); self::assertSame(self::ORGANIZATION, $supplier->organizationId); @@ -72,7 +73,7 @@ public function testCreationNormalizesContactFieldsAndPreservesIdentity(): void #[Test] public function testBlankOptionalFieldsBecomeNull(): void { - $supplier = Supplier::create(self::SUPPLIER, self::ORGANIZATION, 'Example', ' ', ' ', ' ', [], $this->now()); + $supplier = Supplier::create(self::SUPPLIER, self::ORGANIZATION, new SupplierDetails('Example', ' ', ' ', ' ', []), $this->now()); self::assertNull($supplier->code()); self::assertNull($supplier->email()); @@ -193,7 +194,7 @@ public function testContactStructureAndValuesAreValidated(array $contacts): void { $this->expectException(ProcurementException::class); - Supplier::create(self::SUPPLIER, self::ORGANIZATION, 'Example', null, null, null, $contacts, $this->now()); + Supplier::create(self::SUPPLIER, self::ORGANIZATION, new SupplierDetails('Example', null, null, null, $contacts), $this->now()); } /** @@ -226,7 +227,7 @@ public function testInvalidUuidIsRejected(): void { $this->expectException(InvalidValueException::class); - Supplier::create('invalid', self::ORGANIZATION, 'Example', null, null, null, [], $this->now()); + Supplier::create('invalid', self::ORGANIZATION, new SupplierDetails('Example', null, null, null, []), $this->now()); } /** @@ -240,7 +241,7 @@ public function testInvalidUuidIsRejected(): void 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); + $supplier = Supplier::reconstitute(self::SUPPLIER, self::ORGANIZATION, new SupplierDetails('Supplier', 'SUP', 'supplier@example.com', null, []), new SupplierHistory($archivedAt, $this->now(), $archivedAt, 4)); self::assertFalse($supplier->isActive()); self::assertSame(4, $supplier->revision()); @@ -261,7 +262,7 @@ public function testRestorationRejectsNonPositiveRevision(): void { $this->expectException(ProcurementException::class); - Supplier::reconstitute(self::SUPPLIER, self::ORGANIZATION, 'Supplier', null, null, null, [], null, $this->now(), $this->now(), 0); + Supplier::reconstitute(self::SUPPLIER, self::ORGANIZATION, new SupplierDetails('Supplier', null, null, null, []), new SupplierHistory(null, $this->now(), $this->now(), 0)); } /** @@ -289,7 +290,7 @@ public function testUpdateCannotMoveHistoryBackwards(): void */ private function supplier(): Supplier { - return Supplier::create(self::SUPPLIER, self::ORGANIZATION, 'Original supplier', null, 'supplier@example.com', null, [], $this->now()); + return Supplier::create(self::SUPPLIER, self::ORGANIZATION, new SupplierDetails('Original supplier', null, 'supplier@example.com', null, []), $this->now()); } /** diff --git a/tests/Unit/Procurement/Domain/ValueObject/ProcurementLineTest.php b/tests/Unit/Procurement/Domain/ValueObject/ProcurementLineTest.php index f2f8079da..ee9d48099 100644 --- a/tests/Unit/Procurement/Domain/ValueObject/ProcurementLineTest.php +++ b/tests/Unit/Procurement/Domain/ValueObject/ProcurementLineTest.php @@ -7,13 +7,15 @@ use PHPUnit\Framework\Attributes\{CoversClass, DataProvider, Test}; use PHPUnit\Framework\TestCase; use Procurement\Domain\Exception\ProcurementException; -use Procurement\Domain\ValueObject\ProcurementLine; +use Procurement\Domain\ValueObject\{ProcurementGoodsIdentity, ProcurementLine, ProcurementLineAmounts}; use Shared\Domain\Exception\InvalidValueException; use stdClass; +use function json_encode; use function str_repeat; use const INF; +use const JSON_THROW_ON_ERROR; /** * Class ProcurementLineTest @@ -39,7 +41,7 @@ final class ProcurementLineTest extends TestCase #[Test] public function articleCodeLabelAndUnitStayFrozenThroughReceiptsAndReturns(): void { - $line = ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '2.500000', null, 'REF', 'Foam concentrate', 'litre'); + $line = ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, [], 'REF', 'Foam concentrate', 'litre'), '2.500000', null); $received = $line->receive('1.250000'); $returned = $received->returnReceived('0.250000'); self::assertSame('REF', $returned->partCode); @@ -48,6 +50,23 @@ public function articleCodeLabelAndUnitStayFrozenThroughReceiptsAndReturns(): vo self::assertSame('1.250000', $returned->receivedQuantity); self::assertSame('0.250000', $returned->returnedQuantity); } + + /** + * Method retainedLineSnapshotKeepsItsPersistedJsonShape + * + * Prevents typed internal state from changing historical JSON columns or their exact quantities. + * + * @access public + * + * @return void + */ + #[Test] + public function retainedLineSnapshotKeepsItsPersistedJsonShape(): void + { + $line = ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, [], 'REF', 'Foam concentrate', 'litre'), '2.5', null)->receive('1.25')->returnReceived('0.25'); + + self::assertSame('{"id":"018fa001-1111-7111-8111-111111111111","kind":"part","partId":"018fa002-1111-7111-8111-111111111111","typeCode":null,"identityTemplate":[],"quantity":"2.500000","unitCost":null,"receivedQuantity":"1.250000","returnedQuantity":"0.250000","partCode":"REF","partLabel":"Foam concentrate","partUnit":"litre"}', json_encode($line, JSON_THROW_ON_ERROR)); + } // #endregion // #region Methods @@ -61,7 +80,7 @@ public function articleCodeLabelAndUnitStayFrozenThroughReceiptsAndReturns(): vo #[Test] public function testConsumableFractionsAndCostsAreCanonicalExactStrings(): void { - $line = ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '0001.25', '00012.123456'); + $line = ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), '0001.25', '00012.123456'); self::assertSame(self::LINE, $line->id); self::assertSame('part', $line->kind); @@ -85,8 +104,8 @@ public function testConsumableFractionsAndCostsAreCanonicalExactStrings(): 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'); + $unknown = ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), '1', null); + $knownZero = ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), '1', '0'); self::assertNull($unknown->unitCost); self::assertSame('0.000000', $knownZero->unitCost); @@ -103,7 +122,7 @@ public function testUnknownUnitCostStaysDifferentFromKnownZero(): void 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'); + $line = ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('equipment_to_individualize', null, ' fire_extinguisher ', $template), '3.000000', '120.5'); self::assertSame('equipment_to_individualize', $line->kind); self::assertNull($line->partId); @@ -124,7 +143,7 @@ public function testEquipmentKeepsDeclarativeTemplateAndWholeUnits(): void #[Test] public function testReceivedAndReturnedSnapshotsDoNotMutateOriginalLine(): void { - $original = ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '1.25', '10'); + $original = ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), '1.25', '10'); $received = $original->receive('0.75'); $returned = $received->returnReceived('0.25'); @@ -154,7 +173,7 @@ public function testInvalidQuantitiesAreRejected(string $quantity): void { $this->expectException(ProcurementException::class); - ProcurementLine::create(self::LINE, 'part', self::PART, null, [], $quantity, null); + ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), $quantity, null); } /** @@ -181,8 +200,8 @@ public static function invalidQuantities(): iterable #[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); + self::assertSame('100000.000000', ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), '100000', null)->quantity); + self::assertSame('0.000001', ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), '0.000001', null)->quantity); } /** @@ -203,7 +222,7 @@ public function testIdentitiesCannotMixStockAndIndividualEquipment(string $kind, { $this->expectException(ProcurementException::class); - ProcurementLine::create(self::LINE, $kind, $partId, $typeCode, $template, '2', null); + ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity($kind, $partId, $typeCode, $template), '2', null); } /** @@ -241,7 +260,7 @@ public function testIndividualEquipmentRejectsFractionalCreationReceiptAndReturn { $this->expectException(ProcurementException::class); - ProcurementLine::reconstitute(self::LINE, 'equipment_to_individualize', null, 'fire_extinguisher', [], $ordered, null, $received, $returned); + ProcurementLine::reconstitute(self::LINE, new ProcurementGoodsIdentity('equipment_to_individualize', null, 'fire_extinguisher', []), new ProcurementLineAmounts($ordered, null, $received, $returned)); } /** @@ -274,7 +293,7 @@ public function testRestorationRejectsImpossiblePhysicalHistory(string $received { $this->expectException(ProcurementException::class); - ProcurementLine::reconstitute(self::LINE, 'part', self::PART, null, [], '5', null, $received, $returned); + ProcurementLine::reconstitute(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), new ProcurementLineAmounts('5', null, $received, $returned)); } /** @@ -302,7 +321,7 @@ public static function invalidHistory(): iterable #[Test] public function testOverReceptionDoesNotPartiallyChangeLine(): void { - $line = ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '1.25', null)->receive('1'); + $line = ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), '1.25', null)->receive('1'); try { $line->receive('0.250001'); @@ -325,7 +344,7 @@ public function testOverReceptionDoesNotPartiallyChangeLine(): void #[Test] public function testAlreadyReturnedUnitsCannotBeReturnedAgain(): void { - $line = ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '2', null)->receive('2')->returnReceived('1.5'); + $line = ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), '2', null)->receive('2')->returnReceived('1.5'); $this->expectException(ProcurementException::class); $line->returnReceived('0.500001'); @@ -343,7 +362,7 @@ public function testNegativeUnitCostIsRejected(): void { $this->expectException(ProcurementException::class); - ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '1', '-0.000001'); + ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), '1', '-0.000001'); } /** @@ -361,7 +380,7 @@ public function testAmountsDoNotAcceptExponentCommaOrExcessPrecision(string $cos { $this->expectException(InvalidValueException::class); - ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '1', $cost); + ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('part', self::PART, null, []), '1', $cost); } /** @@ -394,7 +413,7 @@ public function testTemplatesAreDeclarativeAndBounded(array $template): void { $this->expectException(ProcurementException::class); - ProcurementLine::create(self::LINE, 'equipment_to_individualize', null, 'fire_extinguisher', $template, '1', null); + ProcurementLine::create(self::LINE, new ProcurementGoodsIdentity('equipment_to_individualize', null, 'fire_extinguisher', $template), '1', null); } /** diff --git a/tests/Unit/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestHandlerTest.php b/tests/Unit/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestHandlerTest.php index 3f214cd40..f4ce9d29d 100644 --- a/tests/Unit/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestHandlerTest.php +++ b/tests/Unit/ServiceRequest/Application/UseCase/Command/ChangeServiceRequest/ChangeServiceRequestHandlerTest.php @@ -17,6 +17,7 @@ use ServiceRequest\Domain\Event\ServiceRequestChangedEvent; use ServiceRequest\Domain\Exception\ServiceRequestException; use ServiceRequest\Domain\Model\ServiceRequest\ServiceRequest; +use ServiceRequest\Domain\ValueObject\{ServiceRequestContent, ServiceRequestTarget}; use Shared\Application\Port\Outbound\{ClockPort, EventDispatcherPort, TransactionManagerPort}; /** @@ -210,10 +211,10 @@ private function handler(ServiceRequestRepositoryPort $requests, ServiceRequestO private function request(?string $equipmentId = self::EQUIPMENT): ServiceRequest { - return ServiceRequest::create(self::REQUEST, self::ORGANIZATION, $equipmentId, self::SITE, [ + return ServiceRequest::create(self::REQUEST, self::ORGANIZATION, new ServiceRequestTarget($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); + ], self::INSPECTION, null), new ServiceRequestContent('Poignée cassée', 'Le contrôle signale une poignée cassée.', 'normal'), new DateTimeImmutable('2026-10-06T11:00:00+00:00')); } } diff --git a/tests/Unit/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestHandlerTest.php b/tests/Unit/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestHandlerTest.php index d9ff040e4..b3bb107c8 100644 --- a/tests/Unit/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestHandlerTest.php +++ b/tests/Unit/ServiceRequest/Application/UseCase/Command/ConvertServiceRequest/ConvertServiceRequestHandlerTest.php @@ -19,6 +19,7 @@ use ServiceRequest\Domain\Event\ServiceRequestChangedEvent; use ServiceRequest\Domain\Exception\ServiceRequestException; use ServiceRequest\Domain\Model\ServiceRequest\ServiceRequest; +use ServiceRequest\Domain\ValueObject\{ServiceRequestContent, ServiceRequestTarget}; use ServiceRequest\Domain\ValueObject\ServiceRequestConversionReceipt; use Shared\Application\Port\Outbound\{ClockPort, EventDispatcherPort, TransactionManagerPort}; @@ -403,7 +404,7 @@ private static function now(): DateTimeImmutable 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()); + return ServiceRequest::create(self::REQUEST, self::ORGANIZATION, new ServiceRequestTarget(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']], null, null), new ServiceRequestContent('Repair extinguisher', 'Pressure gauge damaged', 'normal'), self::now()); } private static function qualified(): ServiceRequest diff --git a/tests/Unit/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequestTest.php b/tests/Unit/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequestTest.php index 652841327..eacb96bb5 100644 --- a/tests/Unit/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequestTest.php +++ b/tests/Unit/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequestTest.php @@ -9,10 +9,12 @@ use PHPUnit\Framework\TestCase; use ServiceRequest\Domain\Exception\ServiceRequestException; use ServiceRequest\Domain\Model\ServiceRequest\ServiceRequest; +use ServiceRequest\Domain\ValueObject\{ServiceRequestContent, ServiceRequestLifecycle, ServiceRequestTarget, ServiceRequestTimeline}; use stdClass; use function fclose; use function fopen; +use function get_object_vars; use function str_repeat; use const INF; @@ -47,12 +49,52 @@ final class ServiceRequestTest extends TestCase private const string TASK_ID = '550e8400-e29b-41d4-a716-446655440008'; // #region Methods + #[Test] + public function reconstitutesEveryHistoricalFieldWithoutApplyingCreationNormalization(): void + { + $name = 'Retained equipment'; + $snapshot = ['equipment' => ['name' => &$name], 'site' => null, 'customer' => null, 'retainedNumericFact' => 1.0]; + $requestedAt = self::now(); + $updatedAt = $requestedAt->modify('+5 hours'); + $qualifiedAt = $requestedAt->modify('+1 hour'); + $rejectedAt = $requestedAt->modify('+2 hours'); + $cancelledAt = $requestedAt->modify('+3 hours'); + $convertedAt = $requestedAt->modify('+4 hours'); + $request = ServiceRequest::reconstitute(self::ID, self::ORGANIZATION_ID, new ServiceRequestTarget(self::EQUIPMENT_ID, self::SITE_ID, $snapshot, self::INSPECTION_ID, self::NON_CONFORMITY_ID), new ServiceRequestContent(' Historical title ', ' Historical description ', 'historical-priority'), new ServiceRequestLifecycle('converted', 19, new ServiceRequestTimeline($requestedAt, $updatedAt, $qualifiedAt, $rejectedAt, $cancelledAt, $convertedAt), 'Retained decision', 'Retained qualification', self::INTERVENTION_ID, self::TASK_ID)); + $name = 'Live renamed equipment'; + + self::assertSame([ + 'id' => self::ID, + 'organizationId' => self::ORGANIZATION_ID, + 'equipmentId' => self::EQUIPMENT_ID, + 'siteId' => self::SITE_ID, + 'targetSnapshot' => ['equipment' => ['name' => 'Retained equipment'], 'site' => null, 'customer' => null, 'retainedNumericFact' => 1.0], + 'title' => ' Historical title ', + 'description' => ' Historical description ', + 'priority' => 'historical-priority', + 'originInspectionId' => self::INSPECTION_ID, + 'originNonConformityId' => self::NON_CONFORMITY_ID, + 'status' => 'converted', + 'revision' => 19, + 'requestedAt' => $requestedAt, + 'updatedAt' => $updatedAt, + 'qualifiedAt' => $qualifiedAt, + 'rejectedAt' => $rejectedAt, + 'cancelledAt' => $cancelledAt, + 'convertedAt' => $convertedAt, + 'decisionReason' => 'Retained decision', + 'qualificationNote' => 'Retained qualification', + 'interventionId' => self::INTERVENTION_ID, + 'taskId' => self::TASK_ID, + ], get_object_vars($request)); + } + #[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); + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, new ServiceRequestTarget(self::EQUIPMENT_ID, self::SITE_ID, $snapshot, self::INSPECTION_ID, self::NON_CONFORMITY_ID), new ServiceRequestContent(' Repair extinguisher ', " Pressure gauge damaged\nCheck the seal. ", 'normal'), $now); self::assertSame(self::ID, $request->id); self::assertSame(self::ORGANIZATION_ID, $request->organizationId); @@ -95,7 +137,7 @@ public static function validTargets(): iterable #[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()); + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, new ServiceRequestTarget($equipmentId, $siteId, [], null, null), new ServiceRequestContent('Repair', 'Repair needed', 'normal'), self::now()); self::assertSame($equipmentId, $request->equipmentId); self::assertSame($siteId, $request->siteId); @@ -107,7 +149,7 @@ public function acceptsAnEquipmentOrASiteTarget(?string $equipmentId, ?string $s #[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()); + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, new ServiceRequestTarget(null, self::SITE_ID, ['site' => ['label' => 'Warehouse']], null, null), new ServiceRequestContent('Repair', 'Leak in the building', 'normal'), self::now()); $this->expectException(ServiceRequestException::class); $request->qualify('Repair approved', self::now()); @@ -116,7 +158,7 @@ public function requiresAnEquipmentBeforeQualifyingASiteRequest(): void #[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); + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, new ServiceRequestTarget(null, self::SITE_ID, ['site' => ['label' => 'Warehouse']], self::INSPECTION_ID, self::NON_CONFORMITY_ID), new ServiceRequestContent('Repair', 'Leak in the building', 'normal'), self::now()); $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); @@ -158,7 +200,7 @@ public static function invalidAssignments(): iterable #[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()); + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, new ServiceRequestTarget(null, self::SITE_ID, [], null, null), new ServiceRequestContent('Repair', 'Leak in the building', 'normal'), self::now()); $this->expectException(ServiceRequestException::class); $request->assignEquipment($equipmentId, $siteId, [], self::now()); @@ -187,7 +229,7 @@ public function refusesRetargetingAnExistingEquipment(string $state): void #[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')); + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, new ServiceRequestTarget(null, self::SITE_ID, [], null, null), new ServiceRequestContent('Repair', 'Leak in the building', 'normal'), self::now())->change(['priority' => 'high'], self::now()->modify('+1 hour')); $this->expectException(ServiceRequestException::class); $request->assignEquipment(self::EQUIPMENT_ID, self::SITE_ID, [], self::now()); @@ -213,7 +255,7 @@ public function refusesInvalidIdentifiersAndMissingTargets(string $id, string $o { $this->expectException(ServiceRequestException::class); - ServiceRequest::create($id, $organizationId, $equipmentId, $siteId, [], 'Repair', 'Repair needed', self::now(), originInspectionId: $inspectionId, originNonConformityId: $nonConformityId); + ServiceRequest::create($id, $organizationId, new ServiceRequestTarget($equipmentId, $siteId, [], $inspectionId, $nonConformityId), new ServiceRequestContent('Repair', 'Repair needed', 'normal'), self::now()); } /** @@ -231,7 +273,7 @@ public static function priorities(): iterable #[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); + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, new ServiceRequestTarget(self::EQUIPMENT_ID, null, [], null, null), new ServiceRequestContent('Repair', 'Repair needed', $priority), self::now()); self::assertSame($priority, $request->priority); } @@ -255,7 +297,7 @@ public function refusesInvalidTextAndPriority(string $title, string $description { $this->expectException(ServiceRequestException::class); - ServiceRequest::create(self::ID, self::ORGANIZATION_ID, self::EQUIPMENT_ID, null, [], $title, $description, self::now(), $priority); + ServiceRequest::create(self::ID, self::ORGANIZATION_ID, new ServiceRequestTarget(self::EQUIPMENT_ID, null, [], null, null), new ServiceRequestContent($title, $description, $priority), self::now()); } #[Test] @@ -263,7 +305,7 @@ 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()); + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, new ServiceRequestTarget(self::EQUIPMENT_ID, null, [], null, null), new ServiceRequestContent($title, $description, 'normal'), self::now()); self::assertSame($title, $request->title); self::assertSame($description, $request->description); @@ -296,7 +338,7 @@ public function refusesSnapshotsThatCannotBeRetainedAsBoundedJson(array $snapsho { $this->expectException(ServiceRequestException::class); - ServiceRequest::create(self::ID, self::ORGANIZATION_ID, self::EQUIPMENT_ID, null, $snapshot, 'Repair', 'Repair needed', self::now()); + ServiceRequest::create(self::ID, self::ORGANIZATION_ID, new ServiceRequestTarget(self::EQUIPMENT_ID, null, $snapshot, null, null), new ServiceRequestContent('Repair', 'Repair needed', 'normal'), self::now()); } #[Test] @@ -307,7 +349,7 @@ public function refusesAResourceInsideTheSnapshot(): void $this->expectException(ServiceRequestException::class); try { - ServiceRequest::create(self::ID, self::ORGANIZATION_ID, self::EQUIPMENT_ID, null, ['stream' => $stream], 'Repair', 'Repair needed', self::now()); + ServiceRequest::create(self::ID, self::ORGANIZATION_ID, new ServiceRequestTarget(self::EQUIPMENT_ID, null, ['stream' => $stream], null, null), new ServiceRequestContent('Repair', 'Repair needed', 'normal'), self::now()); } finally { fclose($stream); } @@ -318,7 +360,7 @@ 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()); + $request = ServiceRequest::create(self::ID, self::ORGANIZATION_ID, new ServiceRequestTarget(self::EQUIPMENT_ID, null, $snapshot, null, null), new ServiceRequestContent('Repair', 'Repair needed', 'normal'), self::now()); $label = 'Changed afterwards'; self::assertIsArray($request->targetSnapshot['nested']); @@ -605,7 +647,7 @@ private static function now(): DateTimeImmutable 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); + return ServiceRequest::create(self::ID, self::ORGANIZATION_ID, new ServiceRequestTarget(self::EQUIPMENT_ID, self::SITE_ID, ['equipment' => ['label' => 'Extinguisher'], 'site' => ['label' => 'Warehouse']], self::INSPECTION_ID, self::NON_CONFORMITY_ID), new ServiceRequestContent('Repair', 'Repair needed', 'normal'), self::now()); } private static function inState(string $state): ServiceRequest diff --git a/tests/Unit/ServiceRequest/Presentation/Api/Processor/ServiceRequestProcessorTest.php b/tests/Unit/ServiceRequest/Presentation/Api/Processor/ServiceRequestProcessorTest.php new file mode 100644 index 000000000..eed1426d4 --- /dev/null +++ b/tests/Unit/ServiceRequest/Presentation/Api/Processor/ServiceRequestProcessorTest.php @@ -0,0 +1,131 @@ + $changes expected supplied fields + * @param string|null $header supplied optimistic precondition + * @param int|null $revision expected parsed precondition + * + * @return void + */ + #[Test] + #[DataProvider('patchDeclarations')] + public function partialPatchesPreserveOmittedAndExplicitNullFields(string $body, ?string $title, ?string $priority, array $changes, ?string $header, ?int $revision): void + { + $commands = $this->createMock(CommandBusPort::class); + $commands->expects(self::once())->method('dispatch')->with(self::callback(static fn (ChangeServiceRequestCommand $command): bool => 'actor' === $command->actorId && 'organization' === $command->organizationId && 'request' === $command->requestId && 'patch' === $command->action && $revision === $command->expectedRevision && $changes === $command->changes && null === $command->note && null === $command->reason && null === $command->equipmentId))->willReturn($this->requestResult()); + $actor = $this->createStub(CurrentActorPort::class); + $actor->method('userId')->willReturn('actor'); + $input = new UpdateServiceRequestInput(); + $input->title = $title; + $input->priority = $priority; + $stack = $this->request($body, $header); + $output = new ServiceRequestProcessor($commands, $actor, $stack)->process($input, new Patch(name:ServiceRequestOperations::PATCH), ['organizationId' => 'organization', 'id' => 'request']); + + self::assertSame('Repair', $output->title); + self::assertSame(1, $output->revision); + } + + /** + * Method patchDeclarations + * + * @access public + * + * @return iterable,string|null,int|null}> exact declared patch and parsed precondition + */ + public static function patchDeclarations(): iterable + { + yield 'priority alone with current revision' => ['{"priority":"high"}', null, 'high', ['priority' => 'high'], '"revision-2"', 2]; + yield 'explicit null title' => ['{"title":null}', null, null, ['title' => null], '"revision-1"', 1]; + yield 'missing revision remains absent' => ['{}', null, null, [], null, null]; + yield 'malformed revision remains stale' => ['{"title":"Changed"}', 'Changed', null, ['title' => 'Changed'], 'revision-2', -1]; + yield 'revision zero remains explicit' => ['{}', null, null, [], '"revision-0"', 0]; + } + + #[Test] + public function unknownPatchFieldsNeverDispatch(): void + { + $commands = $this->createMock(CommandBusPort::class); + $commands->expects(self::never())->method('dispatch'); + $actor = $this->createStub(CurrentActorPort::class); + $actor->method('userId')->willReturn('actor'); + $processor = new ServiceRequestProcessor($commands, $actor, $this->request('{"equipmentId":"replacement"}', '"revision-1"')); + + $this->expectException(ServiceRequestException::class); + $this->expectExceptionMessage('Unknown repair request field.'); + $processor->process(new UpdateServiceRequestInput(), new Patch(name:ServiceRequestOperations::PATCH), ['organizationId' => 'organization', 'id' => 'request']); + } + + /** + * Method request + * + * @access private + * + * @param string $body exact merge-patch body + * @param string|null $header supplied optimistic precondition + * + * @return RequestStack current transport context + */ + private function request(string $body, ?string $header): RequestStack + { + $request = Request::create('/service-requests/request', 'PATCH', server:['CONTENT_TYPE' => 'application/merge-patch+json'], content:$body); + if (null !== $header) { + $request->headers->set('If-Match', $header); + } + $stack = new RequestStack(); + $stack->push($request); + + return $stack; + } + + /** + * Method requestResult + * + * @access private + * + * @return ChangeServiceRequestResult retained request view + */ + private function requestResult(): ChangeServiceRequestResult + { + $request = ServiceRequest::create('550e8400-e29b-41d4-a716-446655440001', '550e8400-e29b-41d4-a716-446655440002', new ServiceRequestTarget('550e8400-e29b-41d4-a716-446655440003', null, []), new ServiceRequestContent('Repair', 'Repair needed'), new DateTimeImmutable('2026-10-07T12:00:00Z')); + + return new ChangeServiceRequestResult(ServiceRequestView::fromRequest($request)); + } + // #endregion +} diff --git a/tests/Unit/Shared/Domain/ValueObject/UuidTest.php b/tests/Unit/Shared/Domain/ValueObject/UuidTest.php index 7e9363cba..b93850ad2 100644 --- a/tests/Unit/Shared/Domain/ValueObject/UuidTest.php +++ b/tests/Unit/Shared/Domain/ValueObject/UuidTest.php @@ -4,7 +4,7 @@ namespace Tests\Unit\Shared\Domain\ValueObject; -use PHPUnit\Framework\Attributes\{CoversClass, Test}; +use PHPUnit\Framework\Attributes\{CoversClass, DataProvider, Test}; use PHPUnit\Framework\TestCase; use Shared\Domain\Exception\InvalidValueException; use Shared\Domain\ValueObject\Uuid; @@ -78,5 +78,47 @@ public function testEquality(): void $this->assertFalse(condition: $u1->equals($u3)); } + /** + * Method testRejectsInvalidIdentifierWithoutCreatingAnObject. + * + * Preserves identifier rejection at validation-only call sites. + * + * @since 2.1.0 + * + * @param string $value the invalid identifier + * + * @return void no return value + */ + #[Test] + #[DataProvider(methodName: 'invalidIdentifiers')] + public function testRejectsInvalidIdentifierWithoutCreatingAnObject(string $value): void + { + $this->expectException(exception: InvalidValueException::class); + $this->expectExceptionMessage(message: 'Invalid UUID provided.'); + + Uuid::assertValid($value); + } + + /** + * Method invalidIdentifiers. + * + * Supplies malformed identifiers and unsupported UUID bits. + * + * @since 2.1.0 + * + * @return array invalid identifiers + */ + public static function invalidIdentifiers(): array + { + return [ + 'empty' => [''], + 'malformed' => ['invalid-uuid'], + 'unsupported zero version' => ['550e8400-e29b-01d4-a716-446655440000'], + 'unsupported eighth version' => ['550e8400-e29b-81d4-a716-446655440000'], + 'unsupported variant' => ['550e8400-e29b-41d4-7716-446655440000'], + 'trailing text' => ['550e8400-e29b-41d4-a716-446655440000x'], + ]; + } + // #endregion }