feat(events): domain event outbox and the two reports that close the loop

Tasks 07 through 13 each changed something the rest of the system might want
to know about, with no contract for saying so. And task 05 shipped a powerful
segment editor with no feedback on whether a clinic defined its segments right.

Events
- A closed list of names, because a consumer branches on the string and a
  one-letter typo would produce an event nobody hears and no error either
- Payloads carry uuids and scalars only; non-scalars are dropped, not
  serialised, so a consumer always fetches fresh rather than reading a stale
  detached entity
- record() deliberately does not flush: the event row commits with the change
  it describes, so a rolled-back transaction leaves no event behind. A test
  pins exactly that
- app:events:publish drains the outbox; five failed attempts park a row with
  its error rather than deleting it, because a silently dropped event is a
  loss with no trace. app:events:prune only ever removes published rows

Reports
- Resource utilisation separates available, occupied and active minutes.
  The gap between occupied and active is what exposes a bad segment
  definition, and available is multiplied by capacity so a three-chair room
  does not read as permanently over 100%
- A resource with no calendar reports utilization: null, not zero — dividing
  by zero means something different from being idle
- Plan accuracy compares planned against actual duration per service and
  flags both directions: running short wastes capacity that could have been
  sold. Its row links straight to editing that service's segments, because a
  report with no route to a fix does not get read

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 12:27:54 +03:30
co-authored by Claude Opus 5
parent a379111606
commit 3c43955800
32 changed files with 2313 additions and 73 deletions
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace App\Shared\Event\Entity;
use App\Shared\Event\Repository\DomainEventLogRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* صندوق خروجی رویدادهای دامنه (outbox).
*
* ردیف رویداد **در همان تراکنشی** نوشته می‌شود که خودِ تغییر را انجام می‌دهد، و یک
* worker بعداً منتشرش می‌کند. بدون این الگو دو حالت شکست ممکن است:
*
* | حالت | نتیجه |
* |---|---|
* | انتشار پیش از commit، بعد rollback | پیامک رفته، نوبتی وجود ندارد |
* | commit موفق، انتشار شکست خورد | نوبت هست، هیچ‌کس مطلع نشد |
*
* با outbox حداکثر **تأخیر** داریم، هرگز گم‌شدن.
*
* این جدول با `AppointmentEvent` موجود اشتباه نشود: آن تاریخچهٔ وضعیت یک نوبت است،
* این اعلان تغییر به بیرونِ دامنه.
*/
#[ORM\Entity(repositoryClass: DomainEventLogRepository::class)]
#[ORM\Table(name: 'domain_events')]
#[ORM\Index(columns: ['published_at', 'occurred_at'], name: 'idx_de_pending')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'occurred_at'], name: 'idx_de_tenant')]
#[ORM\Index(columns: ['name', 'occurred_at'], name: 'idx_de_name')]
class DomainEventLog
{
use TenantOwnedTrait;
/** سقف تلاش — ردیف مرده با خطایش می‌ماند تا دیده شود، حذف نمی‌شود. */
public const MAX_ATTEMPTS = 5;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'bigint')]
private ?string $id = null;
/** شناسهٔ idempotency برای مصرف‌کننده. */
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 60)]
private string $name;
/** @var array<string, scalar|null> */
#[ORM\Column(type: 'json')]
private array $payload;
/** زمان **وقوع**، نه انتشار. */
#[ORM\Column(name: 'occurred_at', type: 'integer')]
private int $occurredAt;
#[ORM\Column(name: 'published_at', type: 'integer', nullable: true)]
private ?int $publishedAt = null;
#[ORM\Column(type: 'smallint', options: ['default' => 0])]
private int $attempts = 0;
#[ORM\Column(name: 'last_error', type: 'string', length: 255, nullable: true)]
private ?string $lastError = null;
/** @param array<string, mixed> $payload */
public function __construct(string $entityType, int $entityId, string $name, array $payload, ?int $occurredAt = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->name = $name;
$this->payload = self::scalarsOnly($payload);
$this->occurredAt = $occurredAt ?? time();
$this->assignTenantPair($entityType, $entityId);
}
/**
* هیچ entity ای در رویداد نیست — فقط uuid و اسکالر.
*
* entity در پیام async یعنی سریال‌سازی، detach شدن، و دادهٔ کهنه؛ مصرف‌کننده باید
* خودش با uuid واکشی کند تا همیشه تازه‌ترین حالت را ببیند.
*
* @param array<string, mixed> $payload
* @return array<string, scalar|null>
*/
private static function scalarsOnly(array $payload): array
{
return array_filter($payload, static fn (mixed $v): bool => is_scalar($v) || $v === null);
}
public function getId(): ?string { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getName(): string { return $this->name; }
public function getPayload(): array { return $this->payload; }
public function getOccurredAt(): int { return $this->occurredAt; }
public function getPublishedAt(): ?int { return $this->publishedAt; }
public function getAttempts(): int { return $this->attempts; }
public function getLastError(): ?string { return $this->lastError; }
public function isPublished(): bool { return $this->publishedAt !== null; }
public function markPublished(?int $at = null): self
{
$this->publishedAt = $at ?? time();
$this->lastError = null;
return $this;
}
public function markFailed(string $error): self
{
$this->attempts++;
$this->lastError = mb_substr($error, 0, 255);
return $this;
}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'name' => $this->name,
'payload' => (object) $this->payload,
'occurred_at' => $this->occurredAt,
'published_at' => $this->publishedAt,
'attempts' => $this->attempts,
'last_error' => $this->lastError,
];
}
}