@@ -285,15 +299,18 @@ export default function ResourceBookingPage() {
نمایش داده نمیشود.
-
- پزشک نوبت
- setDoctorUuid(String(v ?? ''))}
- options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
- placeholder="انتخاب پزشک"
- />
-
+ {/* در جابهجایی پزشک عوض نمیشود؛ پرسیدنش یعنی دعوت به تغییری که خواسته نشده. */}
+ {!rebookUuid && (
+
+ پزشک نوبت
+ setDoctorUuid(String(v ?? ''))}
+ options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
+ placeholder="انتخاب پزشک"
+ />
+
+ )}
{hold === null ? (
@@ -307,17 +324,34 @@ export default function ResourceBookingPage() {
) : (
<>
- {
- await confirm.mutateAsync({ hold_uuid: hold.hold_uuid, doctor_uuid: doctorUuid });
- navigate('/admin/appointments');
- }}
- >
- ثبت نهایی نوبت
-
+ {rebookUuid ? (
+ {
+ await rebook.mutateAsync({
+ appointmentUuid: rebookUuid,
+ holdUuid: hold.hold_uuid,
+ });
+ navigate(`/admin/appointments/${rebookUuid}`);
+ }}
+ >
+ جابهجایی به این زمان
+
+ ) : (
+ {
+ await confirm.mutateAsync({ hold_uuid: hold.hold_uuid, doctor_uuid: doctorUuid });
+ navigate('/admin/appointments');
+ }}
+ >
+ ثبت نهایی نوبت
+
+ )}
em->persist($block);
+
+ $this->domainEvents->record(
+ $resource->getEntityType(),
+ $resource->getEntityId(),
+ DomainEvents::RESOURCE_BLOCKED,
+ [
+ 'resource_uuid' => $resource->getUuid(),
+ 'block_uuid' => $block->getUuid(),
+ 'starts_at' => $startsAt,
+ 'ends_at' => $endsAt,
+ ],
+ );
+
$this->em->flush();
return $this->success($block->toArray(), 201);
@@ -120,6 +136,19 @@ class ResourceBlockController extends BaseController
);
}
+ // پیش از `remove` ثبت میشود چون بعد از آن، uuid و بازه فقط در حافظهاند و
+ // خواندنشان از یک entity حذفشده به رفتار Doctrine وابسته میماند.
+ $this->domainEvents->record(
+ $entityType,
+ $entityId,
+ DomainEvents::RESOURCE_RELEASED,
+ [
+ 'block_uuid' => $block->getUuid(),
+ 'starts_at' => $block->getStartsAt(),
+ 'ends_at' => $block->getEndsAt(),
+ ],
+ );
+
$this->em->remove($block);
$this->em->flush();
diff --git a/src/Appointment/Booking/Controller/BookingController.php b/src/Appointment/Booking/Controller/BookingController.php
index 1f5be5aa..71f80866 100644
--- a/src/Appointment/Booking/Controller/BookingController.php
+++ b/src/Appointment/Booking/Controller/BookingController.php
@@ -3,7 +3,9 @@
namespace App\Appointment\Booking\Controller;
use App\Appointment\Booking\Entity\AppointmentHold;
+use App\Appointment\Booking\Entity\AppointmentSegment;
use App\Appointment\Booking\Repository\AppointmentHoldRepository;
+use App\Appointment\Booking\Repository\AppointmentSegmentRepository;
use App\Appointment\Booking\Service\BookingService;
use App\Appointment\Booking\Service\HoldService;
use App\Appointment\Entity\Appointment;
@@ -23,6 +25,8 @@ use App\Resource\Entity\ClinicResource;
use App\Resource\Repository\ClinicResourceRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
+use App\Shared\Event\DomainEventPublisher;
+use App\Shared\Event\DomainEvents;
use App\Shared\Exception\AppException;
use App\Shared\Tenant\TenantOwnershipChecker;
use Doctrine\ORM\EntityManagerInterface;
@@ -55,6 +59,8 @@ class BookingController extends BaseController
private readonly BookingPolicyGuard $guard,
private readonly PackageConsumptionService $packages,
private readonly TenantOwnershipChecker $ownership,
+ private readonly AppointmentSegmentRepository $segments,
+ private readonly DomainEventPublisher $domainEvents,
private readonly EntityManagerInterface $em,
) {}
@@ -200,6 +206,29 @@ class BookingController extends BaseController
* ترتیب عمدی است — اگر رزرو جدید شکست بخورد، نوبت قدیمی دستنخورده میماند و
* بیمار بینوبت نمیشود. ترتیب برعکس، در بدترین حالت هر دو را از دست میداد.
*/
+ /**
+ * بخشهای ثبتشدهٔ یک نوبت — عکسِ لحظهٔ رزرو، نه الگوی امروزِ خدمت.
+ *
+ * فهرست خالی یعنی نوبت اسلاتی است؛ خطا نیست. پنل با همین تفاوت میفهمد کدام نوبت
+ * را میشود منبعمحور جابهجا کرد.
+ */
+ #[Route('/api/v1/appointment/{uuid}/segments', name: 'appointment_segments', methods: ['GET'])]
+ public function segments(#[CurrentUser] User $user, string $uuid): JsonResponse
+ {
+ $appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]);
+
+ [$entityType, $entityId] = $this->branches->pair($user);
+
+ if ($appointment === null || !$this->ownership->belongsToPair($entityType, $entityId, $appointment)) {
+ return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نوبت یافت نشد', 404);
+ }
+
+ return $this->success(array_map(
+ static fn (AppointmentSegment $s): array => $s->toArray(),
+ $this->segments->findForAppointment($appointment),
+ ));
+ }
+
#[Route('/api/v1/appointment/{uuid}/rebook', name: 'appointment_rebook', methods: ['POST'])]
public function rebook(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
@@ -220,10 +249,26 @@ class BookingController extends BaseController
$hold = $this->requireHold($user, $data['hold_uuid']);
+ $previousStart = $appointment->getSlotStart();
+
// رزرو جدید از قبل گرفته شده؛ اینجا فقط تأیید و سپس آزادسازی قدیم.
$this->booking->confirm($hold, $appointment);
$released = $this->booking->cancel($appointment);
+ // `confirm` و `cancel` هرکدام رویداد خودشان را ثبت کردهاند؛ این سومی میگوید آن دو
+ // یک جابهجایی بودهاند نه یک لغو و یک رزروِ بیربط. مصرفکنندهای که فقط
+ // `AppointmentCancelled` را بشنود، برای بیماری که هنوز نوبت دارد پیام لغو میفرستد.
+ $this->domainEvents->recordAndFlush(
+ $appointment->getEntityType(),
+ $appointment->getEntityId(),
+ DomainEvents::APPOINTMENT_RESCHEDULED,
+ [
+ 'appointment_uuid' => $appointment->getUuid(),
+ 'previous_start' => $previousStart,
+ 'new_start' => $hold->getStartsAt(),
+ ],
+ );
+
return $this->success([
'appointment_uuid' => $appointment->getUuid(),
'released_intervals' => $released,
diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php
index cfde7068..26481b96 100644
--- a/src/Appointment/Controller/AppointmentController.php
+++ b/src/Appointment/Controller/AppointmentController.php
@@ -45,6 +45,7 @@ class AppointmentController extends BaseController
private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance,
private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator,
private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService,
+ private readonly \App\Shared\Event\DomainEventPublisher $domainEvents,
private readonly \Psr\Log\LoggerInterface $logger,
) {}
@@ -72,6 +73,27 @@ class AppointmentController extends BaseController
));
}
+ /**
+ * ثبت رویداد دامنهٔ «نوبت انجام شد».
+ *
+ * جدا از `AppointmentEvent` است و جایگزینش نمیشود: آن، تایملاینِ خواندهشده توسط
+ * اپراتور است و این، صندوق خروجی برای مصرفکنندههای بیرونی. هر دو مسیرِ تغییر
+ * وضعیت (اندپوینت اختصاصی و `PATCH`) بعد از ذخیرهٔ موفق به اینجا میرسند، چون
+ * رویدادِ کاری که هنوز ذخیره نشده، دروغ است.
+ */
+ private function recordCompletion(Appointment $appointment): void
+ {
+ $this->domainEvents->recordAndFlush(
+ $appointment->getEntityType(),
+ $appointment->getEntityId(),
+ \App\Shared\Event\DomainEvents::APPOINTMENT_COMPLETED,
+ [
+ 'appointment_uuid' => $appointment->getUuid(),
+ 'slot_start' => $appointment->getSlotStart(),
+ ],
+ );
+ }
+
// ── Public: available slots ───────────────────────────────────────────────
#[OA\Get(
@@ -970,6 +992,10 @@ class AppointmentController extends BaseController
$this->recordCancellation($appointment, $newStatus, $reason !== '' ? $reason : null, $user);
}
+ if ($newStatus === Appointment::STATUS_COMPLETED) {
+ $this->recordCompletion($appointment);
+ }
+
return $this->success(['data' => $appointment->toArray()]);
}
@@ -1211,6 +1237,7 @@ class AppointmentController extends BaseController
// Optional status transition, same rules as the dedicated endpoint.
$newStatus = trim((string) ($data['status'] ?? ''));
$cancelledTo = null;
+ $completed = false;
if ($newStatus !== '' && $newStatus !== $appointment->getStatus()) {
if (!$appointment->canTransitionTo($newStatus)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
@@ -1224,6 +1251,7 @@ class AppointmentController extends BaseController
if (in_array($newStatus, self::CANCEL_STATUSES, true)) {
$cancelledTo = $newStatus;
}
+ $completed = $newStatus === Appointment::STATUS_COMPLETED;
}
try {
@@ -1240,6 +1268,10 @@ class AppointmentController extends BaseController
$this->recordCancellation($appointment, $cancelledTo, $reason !== '' ? $reason : null, $user);
}
+ if ($completed) {
+ $this->recordCompletion($appointment);
+ }
+
return $this->success(['data' => $appointment->toArray()]);
}
diff --git a/src/Cancellation/Controller/CancellationController.php b/src/Cancellation/Controller/CancellationController.php
index d9e95d7c..346b6f8f 100644
--- a/src/Cancellation/Controller/CancellationController.php
+++ b/src/Cancellation/Controller/CancellationController.php
@@ -113,7 +113,11 @@ class CancellationController extends BaseController
? Appointment::STATUS_CANCELLED_BY_DOCTOR
: Appointment::STATUS_CANCELLED_BY_USER;
- return $this->success($this->cancellation->cancel($appointment, $by, $user));
+ $reason = is_array($data) && is_string($data['reason'] ?? null) && trim($data['reason']) !== ''
+ ? trim($data['reason'])
+ : null;
+
+ return $this->success($this->cancellation->cancel($appointment, $by, $user, null, $reason));
}
/** ثبت عدم حضور — برچسب پرریسک اگر آستانه رد شود. */
diff --git a/src/Cancellation/Service/CancellationService.php b/src/Cancellation/Service/CancellationService.php
index 4bad92ce..cede13a9 100644
--- a/src/Cancellation/Service/CancellationService.php
+++ b/src/Cancellation/Service/CancellationService.php
@@ -4,6 +4,7 @@ namespace App\Cancellation\Service;
use App\Appointment\Booking\Service\BookingService;
use App\Appointment\Entity\Appointment;
+use App\Appointment\Entity\AppointmentEvent;
use App\Auth\Entity\User;
use App\Cancellation\ValueObject\PenaltyResult;
use App\Package\Service\CreditLedgerService;
@@ -35,7 +36,7 @@ final class CancellationService
* @return array
* @throws AppException ۴۲۲ روی نوبت گذشته، ۴۰۹ روی نوبتِ از قبل لغوشده
*/
- public function cancel(Appointment $appointment, string $status, ?User $actor = null, ?int $now = null): array
+ public function cancel(Appointment $appointment, string $status, ?User $actor = null, ?int $now = null, ?string $reason = null): array
{
$now = $now ?? time();
@@ -68,6 +69,8 @@ final class CancellationService
$notified = $this->waitlist->notifyForFreedSlot($appointment);
+ $this->recordTimelineEntry($appointment, $actor, $reason);
+
return [
'appointment_uuid' => $appointment->getUuid(),
'status' => $appointment->getStatus(),
@@ -77,6 +80,26 @@ final class CancellationService
] + $penalty->toArray();
}
+ /**
+ * ردیف تایملاین — همان چیزی که اپراتور در صفحهٔ نوبت میبیند.
+ *
+ * جدا از رویداد دامنه است و جایگزینش نمیشود: آن برای مصرفکنندهٔ بیرونی است و این
+ * برای آدمی که میخواهد بداند چه کسی و چرا لغو کرد. بدون این، لغو از مسیر سیاست
+ * هیچ ردی در تاریخچهٔ نوبت نمیگذاشت.
+ */
+ private function recordTimelineEntry(Appointment $appointment, ?User $actor, ?string $reason): void
+ {
+ $event = new AppointmentEvent($appointment, AppointmentEvent::TYPE_CANCELLED, 'نوبت لغو شد');
+ $event->setReason($reason);
+
+ if ($actor !== null) {
+ $event->setActor($actor->getId(), $actor->getRealName() ?: $actor->getMobileNumber());
+ }
+
+ $this->em->persist($event);
+ $this->em->flush();
+ }
+
/**
* جریمه از کیف پول کسر میشود، و اگر موجودی نبود **کسر نمیشود**.
*
diff --git a/src/Schedule.php b/src/Schedule.php
index e2dab6eb..47462393 100644
--- a/src/Schedule.php
+++ b/src/Schedule.php
@@ -4,6 +4,7 @@ namespace App;
use App\Appointment\Message\ExpireAppointmentsMessage;
use App\Blog\Message\PublishScheduledBlogsMessage;
+use App\Shared\Event\Message\PublishDomainEventsMessage;
use App\Shared\Logging\Message\PruneLogsMessage;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
@@ -32,6 +33,12 @@ class Schedule implements ScheduleProviderInterface
)
->add(
RecurringMessage::every('1 minute', new PublishScheduledBlogsMessage())
+ )
+ // صندوق خروجی رویدادها. `stateful` بالا یعنی تیکِ ازدسترفته بعد از ریاستارت
+ // جبران میشود، و چون خودِ publisher از جدول میخواند، یک اجرا کافی است تا
+ // هرچه در فاصله جمع شده برود.
+ ->add(
+ RecurringMessage::every('1 minute', new PublishDomainEventsMessage())
);
}
}
diff --git a/src/Shared/Event/Command/PublishDomainEventsCommand.php b/src/Shared/Event/Command/PublishDomainEventsCommand.php
index 122f6e54..8a78b860 100644
--- a/src/Shared/Event/Command/PublishDomainEventsCommand.php
+++ b/src/Shared/Event/Command/PublishDomainEventsCommand.php
@@ -4,28 +4,26 @@ namespace App\Shared\Event\Command;
use App\Shared\Event\Entity\DomainEventLog;
use App\Shared\Event\Repository\DomainEventLogRepository;
-use Doctrine\ORM\EntityManagerInterface;
+use App\Shared\Event\Service\OutboxPublisher;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
-use Symfony\Component\Messenger\MessageBusInterface;
/**
- * انتشار صندوق خروجی: ردیفهای `published_at IS NULL` به messenger میروند.
+ * اجرای دستیِ انتشار صندوق خروجی.
*
- * شکست انتشار ردیف را نمیکشد؛ `attempts` بالا میرود و خطا ثبت میشود. بعد از سقف
- * تلاش، ردیف با خطایش باقی میماند تا ادمین ببیند — حذف خاموش یعنی رویداد گمشدهٔ بیرد.
+ * منطقش در `OutboxPublisher` است چون زمانبند هم همان را هر دقیقه صدا میزند؛ این دستور
+ * برای وقتی میماند که صف عقب افتاده و باید همین حالا تخلیه شود.
*/
#[AsCommand(name: 'app:events:publish', description: 'Publish pending domain events from the outbox.')]
class PublishDomainEventsCommand extends Command
{
public function __construct(
+ private readonly OutboxPublisher $publisher,
private readonly DomainEventLogRepository $events,
- private readonly MessageBusInterface $bus,
- private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
@@ -37,36 +35,10 @@ class PublishDomainEventsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
- $io = new SymfonyStyle($input, $output);
- $pending = $this->events->findPending(max(1, (int) $input->getOption('limit')));
+ $io = new SymfonyStyle($input, $output);
+ $result = $this->publisher->publish((int) $input->getOption('limit'));
- $published = 0;
- $failed = 0;
-
- foreach ($pending as $event) {
- try {
- $this->bus->dispatch(new \App\Shared\Event\Message\DomainEventMessage(
- $event->getUuid(),
- $event->getName(),
- $event->getEntityType(),
- $event->getEntityId(),
- $event->getPayload(),
- $event->getOccurredAt(),
- ));
-
- $event->markPublished();
- $published++;
- } catch (\Throwable $e) {
- $event->markFailed($e->getMessage());
- $failed++;
- }
- }
-
- if ($pending !== []) {
- $this->em->flush();
- }
-
- $io->success(sprintf('%d رویداد منتشر شد، %d ناموفق.', $published, $failed));
+ $io->success(sprintf('%d رویداد منتشر شد، %d ناموفق.', $result['published'], $result['failed']));
return Command::SUCCESS;
}
diff --git a/src/Shared/Event/Message/PublishDomainEventsMessage.php b/src/Shared/Event/Message/PublishDomainEventsMessage.php
new file mode 100644
index 00000000..53096f41
--- /dev/null
+++ b/src/Shared/Event/Message/PublishDomainEventsMessage.php
@@ -0,0 +1,13 @@
+publisher->publish();
+ }
+}
diff --git a/src/Shared/Event/Service/OutboxPublisher.php b/src/Shared/Event/Service/OutboxPublisher.php
new file mode 100644
index 00000000..0efb16b5
--- /dev/null
+++ b/src/Shared/Event/Service/OutboxPublisher.php
@@ -0,0 +1,62 @@
+events->findPending(max(1, $limit));
+ $published = 0;
+ $failed = 0;
+
+ foreach ($pending as $event) {
+ try {
+ $this->bus->dispatch(new DomainEventMessage(
+ $event->getUuid(),
+ $event->getName(),
+ $event->getEntityType(),
+ $event->getEntityId(),
+ $event->getPayload(),
+ $event->getOccurredAt(),
+ ));
+
+ $event->markPublished();
+ $published++;
+ } catch (\Throwable $e) {
+ $event->markFailed($e->getMessage());
+ $failed++;
+ }
+ }
+
+ if ($pending !== []) {
+ $this->em->flush();
+ }
+
+ return ['published' => $published, 'failed' => $failed];
+ }
+}
diff --git a/tests/Appointment/HoldAndBookTest.php b/tests/Appointment/HoldAndBookTest.php
index b6eb2aa1..5bdea9f9 100644
--- a/tests/Appointment/HoldAndBookTest.php
+++ b/tests/Appointment/HoldAndBookTest.php
@@ -478,4 +478,44 @@ class HoldAndBookTest extends ApiTestCase
self::assertSame([], $roomRows, 'اتاق نباید از رزروِ شکستخورده قفل بماند');
}
+
+ /**
+ * جابهجایی از بیرون شبیه «لغو + رزرو» است، ولی برای بیمار یک اتفاق است.
+ *
+ * رویداد `AppointmentRescheduled` همین را میگوید؛ بدون آن، مصرفکنندهای که فقط
+ * `AppointmentCancelled` را میشنود برای بیماری که هنوز نوبت دارد پیام لغو میفرستد.
+ */
+ public function testRebookingMovesTheAppointmentAndRecordsTheEvent(): void
+ {
+ $s = $this->simpleSetup();
+ $start = $this->nextSaturdayAt(14);
+
+ $first = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
+ $book = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [
+ 'hold_uuid' => $first['data']['hold_uuid'],
+ 'doctor_uuid' => $s['doctor']->getUuid(),
+ ]);
+ self::assertSame(200, $this->responseCode(), json_encode($book, JSON_UNESCAPED_UNICODE));
+
+ $newStart = $start + 2 * 3600;
+ $second = $this->hold($s['user'], $s['service'], $s['address'], $newStart, ['room' => [$s['room']['uuid']]]);
+ self::assertSame(201, $this->responseCode());
+
+ $moved = $this->authJson(
+ 'POST',
+ "/api/v1/appointment/{$book['data']['appointment_uuid']}/rebook",
+ $s['user'],
+ ['hold_uuid' => $second['data']['hold_uuid']],
+ );
+
+ self::assertSame(200, $this->responseCode(), json_encode($moved, JSON_UNESCAPED_UNICODE));
+ self::assertSame($newStart, $moved['data']['starts_at']);
+
+ $event = $this->em->getRepository(\App\Shared\Event\Entity\DomainEventLog::class)
+ ->findOneBy(['name' => \App\Shared\Event\DomainEvents::APPOINTMENT_RESCHEDULED], ['id' => 'DESC']);
+
+ self::assertNotNull($event);
+ self::assertSame($start, $event->getPayload()['previous_start']);
+ self::assertSame($newStart, $event->getPayload()['new_start']);
+ }
}
diff --git a/tests/Appointment/ResourceBlockTest.php b/tests/Appointment/ResourceBlockTest.php
index da5cf29e..790bc482 100644
--- a/tests/Appointment/ResourceBlockTest.php
+++ b/tests/Appointment/ResourceBlockTest.php
@@ -8,6 +8,8 @@ use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceType;
+use App\Shared\Event\DomainEvents;
+use App\Shared\Event\Entity\DomainEventLog;
use App\Tests\ApiTestCase;
use Doctrine\ORM\EntityManagerInterface;
@@ -161,4 +163,40 @@ class ResourceBlockTest extends ApiTestCase
self::assertSame(404, $this->responseCode());
}
+
+ /**
+ * ظرفیتی که برمیگردد باید همانقدر شنیده شود که ظرفیتی که میرود: مصرفکنندهای که
+ * فقط مسدودسازی را بشنود، منبع را برای همیشه اشغال میبیند.
+ */
+ public function testBlockingAndReleasingEachRecordADomainEvent(): void
+ {
+ [$user, $resource] = $this->clinicWithResource();
+
+ $start = time() + 86400;
+
+ $created = $this->authJson('POST', "/api/v1/resource/{$resource->getUuid()}/blocks", $user, [
+ 'starts_at' => $start,
+ 'ends_at' => $start + 3600,
+ ]);
+
+ self::assertSame(201, $this->responseCode());
+
+ $blocked = $this->latestEvent(DomainEvents::RESOURCE_BLOCKED);
+ self::assertNotNull($blocked);
+ self::assertSame($resource->getUuid(), $blocked->getPayload()['resource_uuid']);
+ self::assertSame($start, $blocked->getPayload()['starts_at']);
+
+ $this->authJson('DELETE', "/api/v1/resource-block/{$created['data']['uuid']}", $user);
+ self::assertSame(200, $this->responseCode());
+
+ $released = $this->latestEvent(DomainEvents::RESOURCE_RELEASED);
+ self::assertNotNull($released);
+ self::assertSame($created['data']['uuid'], $released->getPayload()['block_uuid']);
+ }
+
+ private function latestEvent(string $name): ?DomainEventLog
+ {
+ return $this->em->getRepository(DomainEventLog::class)
+ ->findOneBy(['name' => $name], ['id' => 'DESC']);
+ }
}
diff --git a/tests/Report/AppointmentLifecycleEventTest.php b/tests/Report/AppointmentLifecycleEventTest.php
new file mode 100644
index 00000000..f432c16e
--- /dev/null
+++ b/tests/Report/AppointmentLifecycleEventTest.php
@@ -0,0 +1,87 @@
+createUser(['ROLE_DOCTOR']), 'دکتر رویداد');
+ $this->em->persist($doctor);
+ $this->em->flush();
+
+ return $doctor;
+ }
+
+ private function latest(string $name): ?DomainEventLog
+ {
+ return $this->em->getRepository(DomainEventLog::class)
+ ->findOneBy(['name' => $name], ['id' => 'DESC']);
+ }
+
+ public function testCompletingAnAppointmentRecordsTheEvent(): void
+ {
+ $doctor = $this->makeDoctor();
+ $start = time() + 86_400;
+ $appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800);
+ $this->em->persist($appointment);
+ $this->em->flush();
+
+ $this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $doctor->getUser(), [
+ 'status' => Appointment::STATUS_CONFIRMED,
+ 'version' => $appointment->getVersion(),
+ ]);
+ self::assertSame(200, $this->responseCode());
+
+ $this->em->clear();
+ $reloaded = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $appointment->getUuid()]);
+
+ $this->authJson('PATCH', "/api/v1/appointment/{$reloaded->getUuid()}/status", $doctor->getUser(), [
+ 'status' => Appointment::STATUS_COMPLETED,
+ 'version' => $reloaded->getVersion(),
+ ]);
+ self::assertSame(200, $this->responseCode());
+
+ $event = $this->latest(DomainEvents::APPOINTMENT_COMPLETED);
+ self::assertNotNull($event);
+ self::assertSame($appointment->getUuid(), $event->getPayload()['appointment_uuid']);
+ self::assertSame($start, $event->getPayload()['slot_start']);
+ }
+
+ /**
+ * انتقال ردشده نباید رویداد بگذارد؛ وگرنه گزارش «انجامشده»ها از خودِ نوبتها جلو میزند.
+ */
+ public function testARejectedTransitionRecordsNothing(): void
+ {
+ $doctor = $this->makeDoctor();
+ $start = time() + 86_400;
+ $appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800);
+ $this->em->persist($appointment);
+ $this->em->flush();
+
+ $before = $this->latest(DomainEvents::APPOINTMENT_COMPLETED);
+
+ // `pending → completed` در جدول انتقالها نیست.
+ $this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $doctor->getUser(), [
+ 'status' => Appointment::STATUS_COMPLETED,
+ 'version' => $appointment->getVersion(),
+ ]);
+
+ self::assertSame(422, $this->responseCode());
+
+ $after = $this->latest(DomainEvents::APPOINTMENT_COMPLETED);
+ self::assertSame($before?->getId(), $after?->getId());
+ }
+}