Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

declare(strict_types=1);

namespace Maintenance\Infrastructure\Persistence\Doctrine\Mapper;

use DateTimeImmutable;
use DateTimeZone;
use Maintenance\Application\Contract\Plan\{MaintenanceOccurrenceState, MaintenancePlanState};

use function get_object_vars;
use function preg_replace;
use function strtolower;

/**
* Class MaintenancePlanStateMapper
*
* Owns exact persisted plan and occurrence restoration without applying current creation policy.
* Calendar slots retain the plan's frozen timezone; historical execution instants remain UTC.
*
* @category Mapper
*/
final readonly class MaintenancePlanStateMapper
{
// #region Methods
/**
* Method snapshot
*
* Preserves every public persisted field and writes dates in the historical UTC storage format.
*
* @access public
*
* @param MaintenancePlanState|MaintenanceOccurrenceState $state accepted persisted snapshot
*
* @return array<string, mixed> 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<string, bool|int|string|null> $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<string, bool|int|string|null> $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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -43,23 +63,23 @@ 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
{
/** @var array<string, bool|int|string|null>|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
{
/** @var array<string, bool|int|string|null>|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
Expand All @@ -68,7 +88,7 @@ public function list(string $organizationId, int $limit, int $offset, ?string $e
/** @var list<array<string, bool|int|string|null>> $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
Expand All @@ -83,28 +103,28 @@ 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
{
/** @var array<string, bool|int|string|null>|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
{
/** @var array<string, bool|int|string|null>|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
Expand All @@ -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)]);
}

/**
Expand All @@ -138,20 +158,6 @@ private function criteria(string $organizationId, ?string $equipmentId, ?string
return [$where, $parameters];
}

/**
* @return array<string, mixed>
*/
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<string, mixed> $data
*/
Expand All @@ -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<string, bool|int|string|null> $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<string, bool|int|string|null> $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
}
4 changes: 4 additions & 0 deletions src/Maintenance/MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading