From 1f84bd62093670082b1d074fa0072d6d74b61f49 Mon Sep 17 00:00:00 2001 From: "Valentin F." <95174216+DevSkyLex@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:58:06 +0200 Subject: [PATCH] refactor(maintenance): isolate plan mapping and share snapshot validation text --- .../Mapper/MaintenancePlanStateMapper.php | 108 ++++++++++ .../Repository/MaintenancePlanRepository.php | 84 +++----- src/Maintenance/MODULE.md | 4 + .../Model/ServiceRequest/ServiceRequest.php | 11 +- .../Mapper/MaintenancePlanStateMapperTest.php | 199 ++++++++++++++++++ 5 files changed, 352 insertions(+), 54 deletions(-) create mode 100644 src/Maintenance/Infrastructure/Persistence/Doctrine/Mapper/MaintenancePlanStateMapper.php create mode 100644 tests/Unit/Maintenance/Infrastructure/Persistence/Doctrine/Mapper/MaintenancePlanStateMapperTest.php diff --git a/src/Maintenance/Infrastructure/Persistence/Doctrine/Mapper/MaintenancePlanStateMapper.php b/src/Maintenance/Infrastructure/Persistence/Doctrine/Mapper/MaintenancePlanStateMapper.php new file mode 100644 index 000000000..b2c1ca794 --- /dev/null +++ b/src/Maintenance/Infrastructure/Persistence/Doctrine/Mapper/MaintenancePlanStateMapper.php @@ -0,0 +1,108 @@ + column values without database side effects + */ + public function snapshot(MaintenancePlanState|MaintenanceOccurrenceState $state): array + { + $data = []; + foreach (get_object_vars($state) as $property => $value) { + $column = strtolower((string) preg_replace('/[A-Z]/', '_$0', $property)); + $data[$column] = $value instanceof DateTimeImmutable ? $this->date($value) : $value; + } + + return $data; + } + + /** + * Method date + * + * @access public + * + * @param DateTimeImmutable $date source instant without mutation + * + * @return string historical UTC storage representation at second precision + */ + public function date(DateTimeImmutable $date): string + { + return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'); + } + + /** + * Method plan + * + * Restores raw historical values and the frozen calendar independently of current plan validation. + * + * @access public + * + * @param array $row organization-scoped persisted plan + * + * @return MaintenancePlanState exact persisted state with calendar slots in their original timezone + */ + public function plan(array $row): MaintenancePlanState + { + return new MaintenancePlanState((string) $row['id'], (string) $row['organization_id'], (string) $row['equipment_id'], null === $row['facility_id'] ? null : (string) $row['facility_id'], (string) $row['equipment_type'], (string) $row['name'], (string) $row['operation_kind'], (string) $row['interval'], (string) $row['cadence_mode'], $this->parsed($row['anchor_at'])?->setTimezone(new DateTimeZone((string) $row['calendar_timezone'])), $this->parsed($row['next_due_at'])?->setTimezone(new DateTimeZone((string) $row['calendar_timezone'])), (bool) $row['active'], null === $row['legacy_schedule_id'] ? null : (string) $row['legacy_schedule_id'], $this->parsed($row['last_completed_at']), $this->parsed($row['archived_at']), new DateTimeImmutable((string) $row['created_at'], new DateTimeZone('UTC')), new DateTimeImmutable((string) $row['updated_at'], new DateTimeZone('UTC')), (string) $row['calendar_timezone']); + } + + /** + * Method occurrence + * + * Preserves the original due instant, retry lineage and historical result links. + * + * @access public + * + * @param array $row organization-scoped persisted occurrence + * + * @return MaintenanceOccurrenceState exact persisted attempt with UTC execution dates + */ + public function occurrence(array $row): MaintenanceOccurrenceState + { + return new MaintenanceOccurrenceState((string) $row['id'], (string) $row['plan_id'], (string) $row['organization_id'], new DateTimeImmutable((string) $row['due_at'], new DateTimeZone('UTC')), (string) $row['status'], (int) $row['attempt'], null === $row['intervention_id'] ? null : (string) $row['intervention_id'], $this->parsed($row['completed_at']), null === $row['result_id'] ? null : (string) $row['result_id'], new DateTimeImmutable((string) $row['created_at'], new DateTimeZone('UTC')), new DateTimeImmutable((string) $row['updated_at'], new DateTimeZone('UTC')), null === $row['number'] ? null : (int) $row['number']); + } + + /** + * Method parsed + * + * @access private + * + * @param bool|int|string|null $date nullable DBAL value using the historical scalar conversion + * + * @return DateTimeImmutable|null stored instant in UTC, or an absent historical date + */ + private function parsed(bool|int|string|null $date): ?DateTimeImmutable + { + return null === $date ? null : new DateTimeImmutable((string) $date, new DateTimeZone('UTC')); + } + // #endregion +} diff --git a/src/Maintenance/Infrastructure/Persistence/Doctrine/Repository/MaintenancePlanRepository.php b/src/Maintenance/Infrastructure/Persistence/Doctrine/Repository/MaintenancePlanRepository.php index 239cc70e5..40c5086f1 100644 --- a/src/Maintenance/Infrastructure/Persistence/Doctrine/Repository/MaintenancePlanRepository.php +++ b/src/Maintenance/Infrastructure/Persistence/Doctrine/Repository/MaintenancePlanRepository.php @@ -5,26 +5,46 @@ namespace Maintenance\Infrastructure\Persistence\Doctrine\Repository; use DateTimeImmutable; -use DateTimeZone; use Doctrine\DBAL\Connection; use Maintenance\Application\Contract\Plan\{MaintenanceOccurrenceState, MaintenanceOperationResult, MaintenancePlanState}; use Maintenance\Application\Port\Outbound\Plan\MaintenancePlanStorePort; +use Maintenance\Infrastructure\Persistence\Doctrine\Mapper\MaintenancePlanStateMapper; use function array_keys; use function array_map; -use function get_object_vars; use function implode; use function is_string; -use function preg_replace; use function strtolower; /** DBAL writes share the main connection with drafts and the transactional outbox. */ final readonly class MaintenancePlanRepository implements MaintenancePlanStorePort { + // #region Properties + /** + * Property mapper + * + * Preserves historical storage and calendar conversions independently of database operations. + */ + private MaintenancePlanStateMapper $mapper; + // #endregion + + // #region Constructor + /** + * Method __construct + * + * @access public + * + * @param Connection $connection explicitly configured main connection + * + * @return void + */ public function __construct(private Connection $connection) { + $this->mapper = new MaintenancePlanStateMapper(); } + // #endregion + // #region Methods public function synchronized(string $organizationId, callable $work): mixed { return $this->connection->transactional(function () use ($organizationId, $work): mixed { @@ -43,7 +63,7 @@ public function engineMode(string $organizationId): string public function activateEngine(string $organizationId, DateTimeImmutable $now): void { - $this->connection->executeStatement("INSERT INTO maintenance_engines (organization_id, mode, activated_at) VALUES (:org, 'plans', :now) ON CONFLICT (organization_id) DO NOTHING", ['org' => $organizationId, 'now' => $this->date($now)]); + $this->connection->executeStatement("INSERT INTO maintenance_engines (organization_id, mode, activated_at) VALUES (:org, 'plans', :now) ON CONFLICT (organization_id) DO NOTHING", ['org' => $organizationId, 'now' => $this->mapper->date($now)]); } public function find(string $organizationId, string $id): ?MaintenancePlanState @@ -51,7 +71,7 @@ public function find(string $organizationId, string $id): ?MaintenancePlanState /** @var array|false $row */ $row = $this->connection->fetchAssociative('SELECT * FROM maintenance_plans WHERE id = :id AND organization_id = :org', ['id' => $id, 'org' => $organizationId]); - return false === $row ? null : $this->plan($row); + return false === $row ? null : $this->mapper->plan($row); } public function findByLegacySchedule(string $organizationId, string $scheduleId): ?MaintenancePlanState @@ -59,7 +79,7 @@ public function findByLegacySchedule(string $organizationId, string $scheduleId) /** @var array|false $row */ $row = $this->connection->fetchAssociative('SELECT * FROM maintenance_plans WHERE legacy_schedule_id = :id AND organization_id = :org', ['id' => $scheduleId, 'org' => $organizationId]); - return false === $row ? null : $this->plan($row); + return false === $row ? null : $this->mapper->plan($row); } public function list(string $organizationId, int $limit, int $offset, ?string $equipmentId = null, ?string $operationKind = null, ?string $search = null, bool $includeArchived = false): array @@ -68,7 +88,7 @@ public function list(string $organizationId, int $limit, int $offset, ?string $e /** @var list> $rows */ $rows = $this->connection->fetchAllAssociative('SELECT * FROM maintenance_plans WHERE ' . $where . ' ORDER BY next_due_at ASC NULLS LAST, id ASC LIMIT ' . $limit . ' OFFSET ' . $offset, $parameters); - return array_map($this->plan(...), $rows); + return array_map($this->mapper->plan(...), $rows); } public function count(string $organizationId, ?string $equipmentId = null, ?string $operationKind = null, ?string $search = null): int @@ -83,7 +103,7 @@ public function count(string $organizationId, ?string $equipmentId = null, ?stri public function save(MaintenancePlanState $plan): void { - $this->upsert('maintenance_plans', $this->snapshot($plan)); + $this->upsert('maintenance_plans', $this->mapper->snapshot($plan)); } public function openOccurrence(string $organizationId, string $planId): ?MaintenanceOccurrenceState @@ -91,7 +111,7 @@ public function openOccurrence(string $organizationId, string $planId): ?Mainten /** @var array|false $row */ $row = $this->connection->fetchAssociative("SELECT * FROM maintenance_occurrences WHERE organization_id = :org AND plan_id = :plan AND status = 'open'", ['org' => $organizationId, 'plan' => $planId]); - return false === $row ? null : $this->occurrence($row); + return false === $row ? null : $this->mapper->occurrence($row); } public function findOccurrence(string $organizationId, string $id): ?MaintenanceOccurrenceState @@ -99,12 +119,12 @@ public function findOccurrence(string $organizationId, string $id): ?Maintenance /** @var array|false $row */ $row = $this->connection->fetchAssociative('SELECT * FROM maintenance_occurrences WHERE organization_id = :org AND id = :id', ['org' => $organizationId, 'id' => $id]); - return false === $row ? null : $this->occurrence($row); + return false === $row ? null : $this->mapper->occurrence($row); } public function saveOccurrence(MaintenanceOccurrenceState $occurrence): void { - $this->upsert('maintenance_occurrences', $this->snapshot($occurrence)); + $this->upsert('maintenance_occurrences', $this->mapper->snapshot($occurrence)); } public function hasReceipt(string $resultId, string $occurrenceId): bool @@ -114,7 +134,7 @@ public function hasReceipt(string $resultId, string $occurrenceId): bool public function saveReceipt(MaintenanceOperationResult $result): void { - $this->connection->insert('maintenance_operation_receipts', ['id' => $result->resultId, 'occurrence_id' => $result->occurrenceId, 'organization_id' => $result->organizationId, 'outcome' => $result->outcome, 'performed_at' => $this->date($result->performedAt)]); + $this->connection->insert('maintenance_operation_receipts', ['id' => $result->resultId, 'occurrence_id' => $result->occurrenceId, 'organization_id' => $result->organizationId, 'outcome' => $result->outcome, 'performed_at' => $this->mapper->date($result->performedAt)]); } /** @@ -138,20 +158,6 @@ private function criteria(string $organizationId, ?string $equipmentId, ?string return [$where, $parameters]; } - /** - * @return array - */ - private function snapshot(MaintenancePlanState|MaintenanceOccurrenceState $state): array - { - $data = []; - foreach (get_object_vars($state) as $property => $value) { - $column = strtolower((string) preg_replace('/[A-Z]/', '_$0', $property)); - $data[$column] = $value instanceof DateTimeImmutable ? $this->date($value) : $value; - } - - return $data; - } - /** * @param array $data */ @@ -168,29 +174,5 @@ private function upsert(string $table, array $data): void $this->connection->executeStatement('INSERT INTO ' . $table . ' (' . implode(', ', $columns) . ') VALUES (' . implode(', ', array_map(static fn (string $column): string => ':' . $column, $columns)) . ') ON CONFLICT (id) DO UPDATE SET ' . implode(', ', $updates), $data, $types); } - private function date(DateTimeImmutable $date): string - { - return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'); - } - - private function parsed(bool|int|string|null $date): ?DateTimeImmutable - { - return null === $date ? null : new DateTimeImmutable((string) $date, new DateTimeZone('UTC')); - } - - /** - * @param array $row - */ - private function plan(array $row): MaintenancePlanState - { - return new MaintenancePlanState((string) $row['id'], (string) $row['organization_id'], (string) $row['equipment_id'], null === $row['facility_id'] ? null : (string) $row['facility_id'], (string) $row['equipment_type'], (string) $row['name'], (string) $row['operation_kind'], (string) $row['interval'], (string) $row['cadence_mode'], $this->parsed($row['anchor_at'])?->setTimezone(new DateTimeZone((string) $row['calendar_timezone'])), $this->parsed($row['next_due_at'])?->setTimezone(new DateTimeZone((string) $row['calendar_timezone'])), (bool) $row['active'], null === $row['legacy_schedule_id'] ? null : (string) $row['legacy_schedule_id'], $this->parsed($row['last_completed_at']), $this->parsed($row['archived_at']), new DateTimeImmutable((string) $row['created_at'], new DateTimeZone('UTC')), new DateTimeImmutable((string) $row['updated_at'], new DateTimeZone('UTC')), (string) $row['calendar_timezone']); - } - - /** - * @param array $row - */ - private function occurrence(array $row): MaintenanceOccurrenceState - { - return new MaintenanceOccurrenceState((string) $row['id'], (string) $row['plan_id'], (string) $row['organization_id'], new DateTimeImmutable((string) $row['due_at'], new DateTimeZone('UTC')), (string) $row['status'], (int) $row['attempt'], null === $row['intervention_id'] ? null : (string) $row['intervention_id'], $this->parsed($row['completed_at']), null === $row['result_id'] ? null : (string) $row['result_id'], new DateTimeImmutable((string) $row['created_at'], new DateTimeZone('UTC')), new DateTimeImmutable((string) $row['updated_at'], new DateTimeZone('UTC')), null === $row['number'] ? null : (int) $row['number']); - } + // #endregion } diff --git a/src/Maintenance/MODULE.md b/src/Maintenance/MODULE.md index 252ec30f4..c3c104eae 100644 --- a/src/Maintenance/MODULE.md +++ b/src/Maintenance/MODULE.md @@ -408,6 +408,10 @@ automatically — no backfill migration is needed. - Service wiring: `config/modules/maintenance.yaml` - Plan repository and authority locks: explicit `doctrine.dbal.main_connection`; plan-generated events use `TransactionalEventDispatcher` on that same connection. +- Plan and occurrence storage mapping preserves every historical scalar, nullable + link and UTC timestamp independently of creation validation. Calendar anchors + and due slots are restored in the plan's frozen timezone; SQL, authority locks + and transactions remain owned by the repository. - Facility lifecycle suspension uses the owner-provided ancestry adapter on main; organization calendar context uses the published workforce directory port. - Additive main migrations `Version20261006102000`, `Version20261006102001` diff --git a/src/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequest.php b/src/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequest.php index 62173d40f..b877a1981 100644 --- a/src/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequest.php +++ b/src/ServiceRequest/Domain/Model/ServiceRequest/ServiceRequest.php @@ -39,6 +39,11 @@ { private const array PRIORITIES = ['low', 'normal', 'high', 'urgent']; + /** + * Constant INVALID_TARGET_SNAPSHOT_MESSAGE + */ + private const string INVALID_TARGET_SNAPSHOT_MESSAGE = 'Target snapshot must be a JSON object.'; + // #region Properties /** * Property id. Stable request identity. @@ -405,7 +410,7 @@ private static function snapshot(array $snapshot): array { foreach ($snapshot as $key => $value) { if (!is_string($key)) { - throw ServiceRequestException::invalid('Target snapshot must be a JSON object.'); + throw ServiceRequestException::invalid(self::INVALID_TARGET_SNAPSHOT_MESSAGE); } self::snapshotValue($value, 1); } @@ -436,12 +441,12 @@ private static function snapshot(array $snapshot): array private static function snapshotObject(mixed $decoded): array { if (!is_array($decoded)) { - throw ServiceRequestException::invalid('Target snapshot must be a JSON object.'); + throw ServiceRequestException::invalid(self::INVALID_TARGET_SNAPSHOT_MESSAGE); } $snapshot = []; foreach ($decoded as $key => $value) { if (!is_string($key)) { - throw ServiceRequestException::invalid('Target snapshot must be a JSON object.'); + throw ServiceRequestException::invalid(self::INVALID_TARGET_SNAPSHOT_MESSAGE); } $snapshot[$key] = $value; } diff --git a/tests/Unit/Maintenance/Infrastructure/Persistence/Doctrine/Mapper/MaintenancePlanStateMapperTest.php b/tests/Unit/Maintenance/Infrastructure/Persistence/Doctrine/Mapper/MaintenancePlanStateMapperTest.php new file mode 100644 index 000000000..707094d22 --- /dev/null +++ b/tests/Unit/Maintenance/Infrastructure/Persistence/Doctrine/Mapper/MaintenancePlanStateMapperTest.php @@ -0,0 +1,199 @@ +planRow(); + $mapper = new MaintenancePlanStateMapper(); + $plan = $mapper->plan($row); + + self::assertNotNull($plan->anchorAt); + self::assertNotNull($plan->nextDueAt); + self::assertSame('2027-01-31T00:00:00+01:00', $plan->anchorAt->format(DATE_ATOM)); + self::assertSame('2027-03-31T00:00:00+02:00', $plan->nextDueAt->format(DATE_ATOM)); + self::assertSame('Europe/Paris', $plan->anchorAt->getTimezone()->getName()); + self::assertSame('Europe/Paris', $plan->nextDueAt->getTimezone()->getName()); + self::assertSame('UTC', $plan->lastCompletedAt?->getTimezone()->getName()); + self::assertSame('UTC', $plan->archivedAt?->getTimezone()->getName()); + self::assertSame('UTC', $plan->createdAt->getTimezone()->getName()); + self::assertSame('UTC', $plan->updatedAt->getTimezone()->getName()); + self::assertSame($row, $mapper->snapshot($plan)); + self::assertSame('2027-01-31T00:00:00+01:00', $plan->anchorAt->format(DATE_ATOM)); + self::assertSame('2027-03-31T00:00:00+02:00', $plan->nextDueAt->format(DATE_ATOM)); + } + + /** + * Method historicalUninitializedPlanKeepsRawConfigurationAndAbsentDates + * + * @access public + * + * @return void + */ + #[Test] + public function historicalUninitializedPlanKeepsRawConfigurationAndAbsentDates(): void + { + $row = $this->planRow(); + $row['facility_id'] = null; + $row['operation_kind'] = 'historical_control'; + $row['interval'] = 'P40Y'; + $row['cadence_mode'] = 'legacy'; + $row['anchor_at'] = null; + $row['next_due_at'] = null; + $row['active'] = false; + $row['legacy_schedule_id'] = null; + $row['last_completed_at'] = null; + $row['archived_at'] = null; + $row['calendar_timezone'] = 'UTC'; + $mapper = new MaintenancePlanStateMapper(); + $plan = $mapper->plan($row); + + self::assertSame('historical_control', $plan->operationKind); + self::assertSame('P40Y', $plan->interval); + self::assertFalse($plan->active); + self::assertNull($plan->facilityId); + self::assertNull($plan->anchorAt); + self::assertNull($plan->nextDueAt); + self::assertNull($plan->legacyScheduleId); + self::assertNull($plan->lastCompletedAt); + self::assertNull($plan->archivedAt); + self::assertSame($row, $mapper->snapshot($plan)); + } + + /** + * Method occurrencePreservesCompletedAttemptResultAndOriginalDueInstant + * + * @access public + * + * @return void + */ + #[Test] + public function occurrencePreservesCompletedAttemptResultAndOriginalDueInstant(): void + { + $row = $this->occurrenceRow(); + $mapper = new MaintenancePlanStateMapper(); + $occurrence = $mapper->occurrence($row); + + self::assertSame('2027-03-30T22:00:00+00:00', $occurrence->dueAt->format(DATE_ATOM)); + self::assertSame('UTC', $occurrence->dueAt->getTimezone()->getName()); + self::assertSame('UTC', $occurrence->completedAt?->getTimezone()->getName()); + self::assertSame('UTC', $occurrence->createdAt->getTimezone()->getName()); + self::assertSame('UTC', $occurrence->updatedAt->getTimezone()->getName()); + self::assertSame(3, $occurrence->attempt); + self::assertSame('intervention-historical', $occurrence->interventionId); + self::assertSame('result-historical', $occurrence->resultId); + self::assertSame(0, $occurrence->number); + self::assertSame($row, $mapper->snapshot($occurrence)); + } + + /** + * Method uninitializedOccurrenceKeepsAbsentWorkResultAndNumber + * + * @access public + * + * @return void + */ + #[Test] + public function uninitializedOccurrenceKeepsAbsentWorkResultAndNumber(): void + { + $row = $this->occurrenceRow(); + $row['status'] = 'open'; + $row['attempt'] = 0; + $row['intervention_id'] = null; + $row['completed_at'] = null; + $row['result_id'] = null; + $row['number'] = null; + $mapper = new MaintenancePlanStateMapper(); + $occurrence = $mapper->occurrence($row); + + self::assertSame(0, $occurrence->attempt); + self::assertNull($occurrence->interventionId); + self::assertNull($occurrence->completedAt); + self::assertNull($occurrence->resultId); + self::assertNull($occurrence->number); + self::assertSame($row, $mapper->snapshot($occurrence)); + } + + /** + * Method planRow + * + * @access private + * + * @return array complete accepted historical storage row + */ + private function planRow(): array + { + return [ + 'id' => 'plan-historical', + 'organization_id' => 'organization-historical', + 'equipment_id' => 'equipment-historical', + 'facility_id' => 'facility-historical', + 'equipment_type' => 'fire_extinguisher', + 'name' => 'Original annual control', + 'operation_kind' => 'control', + 'interval' => 'P1M', + 'cadence_mode' => 'fixed', + 'anchor_at' => '2027-01-30 23:00:00', + 'next_due_at' => '2027-03-30 22:00:00', + 'active' => true, + 'legacy_schedule_id' => 'schedule-historical', + 'last_completed_at' => '2027-02-28 12:34:56', + 'archived_at' => '2027-03-01 01:02:03', + 'created_at' => '2026-10-06 09:08:07', + 'updated_at' => '2027-03-01 04:05:06', + 'calendar_timezone' => 'Europe/Paris', + ]; + } + + /** + * Method occurrenceRow + * + * @access private + * + * @return array complete historical attempt and lineage + */ + private function occurrenceRow(): array + { + return [ + 'id' => 'occurrence-historical', + 'plan_id' => 'plan-historical', + 'organization_id' => 'organization-historical', + 'due_at' => '2027-03-30 22:00:00', + 'status' => 'completed', + 'attempt' => 3, + 'intervention_id' => 'intervention-historical', + 'completed_at' => '2027-04-01 11:22:33', + 'result_id' => 'result-historical', + 'created_at' => '2027-03-20 09:08:07', + 'updated_at' => '2027-04-01 11:22:34', + 'number' => 0, + ]; + } + // #endregion +}