Files
clinicpro/tests/Appointment/HoldAndBookTest.php
T
hamedandClaude Opus 5 4049daf071 feat: close the last four domain events, and the panel paths they describe
Every one of the fourteen named events now has an emit point. The four that
were missing all sat on paths owned by earlier tasks:

- AppointmentCompleted fires from both status-change routes, after the row is
  saved. A rejected transition or a version conflict leaves no event; otherwise
  the completed count runs ahead of the appointments themselves.
- AppointmentRescheduled is a third event, not a replacement. A rebook is a
  confirm plus a cancel, and a consumer that only hears the cancel messages a
  patient who still has an appointment.
- ResourceBlocked / ResourceReleased are a pair. Capacity coming back has to be
  as audible as capacity going away, or the resource reads as permanently taken.

Publishing is now on the scheduler rather than an unregistered command: the
logic moved out of PublishDomainEventsCommand into OutboxPublisher so the
recurring message and the manual command share it, and the existing
worker-scheduler container consumes it. The scheduler message carries no data
on purpose — what to publish is read from the table, so an event recorded
between two ticks is not skipped. DomainEventMessage routes to async, since a
slow consumer was otherwise slowing the drain itself and its failure marked a
row failed that had in fact been delivered.

Panel work that these paths made reachable:

- Cancelling from the appointment page now goes through the policy-aware
  endpoint and shows the penalty preview before the confirm, so the operator
  does not discover the patient's penalty after the fact. The cancellation
  service writes the timeline entry itself and accepts a reason, which that
  path previously dropped on the floor.
- Rescheduling reuses the booking page under ?rebook=<uuid> — the search and
  hold steps are identical and only the final step differs. The doctor picker
  is hidden there: a reschedule is not an invitation to change doctors.
- A new GET /appointment/{uuid}/segments exposes the recorded plan. An empty
  list is not an error, it means the appointment is slot-based, and that is
  exactly what gates the resource-mode reschedule button.

AppointmentInvoiceCard no longer crashes the whole detail page when an older
invoice has no discount breakdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:27:55 +03:30

522 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Tests\Appointment;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Booking\Entity\AppointmentHold;
use App\Appointment\Booking\Entity\OccupancyBucket;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ResourceType;
use App\Tests\ApiTestCase;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
/**
* رزرو موقت و ثبت نهایی چندمنبعی — بند ۱۱ مستند.
*/
class HoldAndBookTest extends ApiTestCase
{
private const TEHRAN = 'Asia/Tehran';
private function nextSaturdayAt(int $hour): int
{
return (new \DateTimeImmutable('next saturday', new \DateTimeZone(self::TEHRAN)))
->setTime($hour, 0)
->getTimestamp();
}
/**
* اشغال‌های یک منبع مشخص.
*
* `db_test` هرگز ریست نمی‌شود و تست‌های دیگر هم روی همین ساعت ردیف می‌سازند، پس
* پرس‌وجو حتماً باید به منبعِ همین تست محدود شود — وگرنه تست، دادهٔ دیگران را
* می‌شمارد.
*
* @return list<ResourceOccupancy>
*/
private function occupancyOf(string $resourceUuid, ?int $startsAt = null): array
{
$qb = $this->em->createQueryBuilder()
->select('o')
->from(ResourceOccupancy::class, 'o')
->join('o.resource', 'r')
->where('r.uuid = :uuid')
->setParameter('uuid', $resourceUuid)
->orderBy('o.startsAt', 'ASC');
if ($startsAt !== null) {
$qb->andWhere('o.startsAt = :start')->setParameter('start', $startsAt);
}
return $qb->getQuery()->getResult();
}
/** @return array{user: User, doctor: Doctor, section: ServiceSection, address: DoctorAddress} */
private function clinic(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک رزرو');
$this->em->persist($clinic);
$this->em->flush();
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($doctorUser, 'دکتر رزرو');
$this->em->persist($doctor);
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$this->em->flush();
return ['user' => $user, 'doctor' => $doctor, 'section' => $section, 'address' => $address];
}
private function service(ServiceSection $section, string $name, int $solo): ServiceItem
{
$section = $this->em->getRepository(ServiceSection::class)->find($section->getId());
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes($solo);
$this->em->persist($item);
$this->em->flush();
return $item;
}
private function type(DoctorAddress $address, string $code, string $name): ResourceType
{
$type = new ResourceType($address->tenantEntityType(), $address->tenantEntityId(), $code, $name);
$this->em->persist($type);
$this->em->flush();
return $type;
}
/** @param array<string, mixed> $extra */
private function resource(User $user, DoctorAddress $address, ResourceType $type, string $name, array $extra = []): array
{
$created = $this->authJson('POST', '/api/v1/resource', $user, $extra + [
'address_uuid' => $address->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => $name,
]);
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
$this->authJson('PUT', "/api/v1/resource/{$created['data']['uuid']}/calendar", $user, [
'days' => array_fill_keys(range(0, 6), [['start_minute' => 0, 'end_minute' => 1440]]),
]);
return $created['data'];
}
/** @param list<array<string, mixed>> $segments */
private function segments(User $user, ServiceItem $service, array $segments): void
{
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, ['segments' => $segments]);
self::assertSame(200, $this->responseCode());
}
/** @param array<string, list<string>> $assignment */
private function hold(User $user, ServiceItem $service, DoctorAddress $address, int $start, array $assignment): array
{
return $this->authJson('POST', '/api/v1/appointment-hold', $user, [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
'start' => $start,
'assignment' => $assignment,
]);
}
/** یک سرویس بیست‌دقیقه‌ای که فقط یک اتاق می‌خواهد. */
private function simpleSetup(int $capacity = 1): array
{
$c = $this->clinic();
$service = $this->service($c['section'], 'ویزیت', 20);
$room = $this->type($c['address'], 'room', 'اتاق');
$created = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱', ['capacity' => $capacity]);
$this->segments($c['user'], $service, [
['sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]],
]);
return $c + ['service' => $service, 'room' => $created];
}
public function testHoldCreatesOccupancyRowsPerSegmentAndResource(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(10);
$body = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertNotEmpty($body['data']['hold_uuid']);
self::assertGreaterThan(time(), $body['data']['expires_at']);
$this->em->clear();
$rows = $this->occupancyOf($s['room']['uuid'], $start);
self::assertCount(1, $rows);
self::assertSame(ResourceOccupancy::STATUS_HOLD, $rows[0]->getStatus());
self::assertSame('ویزیت', $rows[0]->getSegmentName());
}
/** بعد از رزرو موقت، همان زمان دیگر پیشنهاد نمی‌شود. */
public function testHeldTimeDisappearsFromAvailability(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(10);
$saturday = $this->nextSaturdayAt(0);
$before = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [
'service_uuid' => $s['service']->getUuid(),
'branch_uuid' => $s['address']->getUuid(),
'from' => $saturday,
'to' => $saturday,
'step_minutes' => 20,
]);
self::assertContains($start, array_column($before['data']['slots'], 'start'));
$this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode());
$after = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [
'service_uuid' => $s['service']->getUuid(),
'branch_uuid' => $s['address']->getUuid(),
'from' => $saturday,
'to' => $saturday,
'step_minutes' => 20,
]);
self::assertNotContains($start, array_column($after['data']['slots'], 'start'));
}
/**
* ⭐ تست همزمانی — اصلی‌ترین تست این تسک.
*
* دو رزرو روی همان منبع و همان بازه: دقیقاً یکی ۲۰۱ و دیگری ۴۰۹. تضمین از قید
* یکتای دیتابیس می‌آید نه از بررسی کد، و همین تست آن قید را مستقیم هم می‌سنجد.
*/
public function testSecondHoldOnTheSameResourceAndIntervalIsRejected(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(11);
$first = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode(), json_encode($first, JSON_UNESCAPED_UNICODE));
$second = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(409, $this->responseCode());
self::assertSame('ERR_SLOT_TAKEN', $second['errors'][0]['code']);
}
/**
* خودِ قید دیتابیس، مستقل از هر کدِ PHP: نوشتن مستقیم دو ردیف یکسان باید با
* نقض کلید یکتا رد شود. اگر این تست بشکند، یعنی تضمین فقط در کد بوده است.
*/
public function testDatabaseItselfRefusesADuplicateBucketSeat(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(12);
$this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode());
$this->em->clear();
$occupancy = $this->occupancyOf($s['room']['uuid'], $start)[0];
$bucket = OccupancyBucket::bucketsFor($start, $start + 1200)[0];
// اتصال جدا، نه اتصال مشترکِ تست: هم به «درخواست دیگر» وفادارتر است و هم
// خطای عمدی، EntityManager مشترک را برای تست‌های بعدی خراب نمی‌کند.
$connection = DriverManager::getConnection(
$this->em->getConnection()->getParams(),
);
$this->expectException(UniqueConstraintViolationException::class);
try {
$connection->insert('resource_occupancy_buckets', [
'resource_id' => $occupancy->getResource()->getId(),
'occupancy_id' => $occupancy->getId(),
'bucket_at' => $bucket,
'seat' => 0,
]);
} finally {
$connection->close();
}
}
/** ظرفیت ۳: سه رزرو هم‌زمان می‌گذرند، چهارمی ۴۰۹. */
public function testCapacityThreeAllowsThreeConcurrentHoldsAndRefusesTheFourth(): void
{
$s = $this->simpleSetup(capacity: 3);
$start = $this->nextSaturdayAt(13);
foreach (range(1, 3) as $n) {
$this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode(), "رزرو $n باید بگذرد");
}
$fourth = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(409, $this->responseCode());
self::assertSame('ERR_SLOT_TAKEN', $fourth['errors'][0]['code']);
}
public function testConfirmMarksEverythingBooked(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(14);
$hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode());
$body = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [
'hold_uuid' => $hold['data']['hold_uuid'],
'doctor_uuid' => $s['doctor']->getUuid(),
]);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertNotEmpty($body['data']['appointment_uuid']);
$this->em->clear();
$rows = $this->occupancyOf($s['room']['uuid'], $start);
self::assertSame(ResourceOccupancy::STATUS_BOOKED, $rows[0]->getStatus());
self::assertNotNull($rows[0]->getAppointmentId());
}
/** رزرو منقضی ثبت نمی‌شود و آن زمان دوباره آزاد است. */
public function testExpiredHoldCannotBeConfirmed(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(15);
$hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode());
// مهلت را به عقب می‌بریم — همان کاری که گذر زمان می‌کند.
$this->em->clear();
$entity = $this->em->getRepository(AppointmentHold::class)->findOneBy(['uuid' => $hold['data']['hold_uuid']]);
$this->em->getConnection()->update(
'appointment_holds',
['expires_at' => time() - 60],
['id' => $entity->getId()],
);
$body = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [
'hold_uuid' => $hold['data']['hold_uuid'],
'doctor_uuid' => $s['doctor']->getUuid(),
]);
self::assertSame(409, $this->responseCode());
self::assertSame('ERR_HOLD_EXPIRED', $body['errors'][0]['code']);
}
/** آزادسازی زودهنگام: زمان دوباره در جستجو ظاهر می‌شود. */
public function testReleasingAHoldFreesTheTimeAgain(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(16);
$saturday = $this->nextSaturdayAt(0);
$hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode());
$this->authJson('DELETE', "/api/v1/appointment-hold/{$hold['data']['hold_uuid']}", $s['user']);
self::assertSame(200, $this->responseCode());
$after = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [
'service_uuid' => $s['service']->getUuid(),
'branch_uuid' => $s['address']->getUuid(),
'from' => $saturday,
'to' => $saturday,
'step_minutes' => 20,
]);
self::assertContains($start, array_column($after['data']['slots'], 'start'));
}
/** رزرو کاربر دیگر ۴۰۴ می‌گیرد، نه ۴۰۳ — وجودش نباید لو برود. */
public function testAnotherUsersHoldIsNotFound(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(17);
$hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode());
// مزاحم باید محیط معتبر خودش را داشته باشد، وگرنه ۴۰۳ «محیط انتخاب نشده»
// می‌گیرد و تست چیزی را که ادعا می‌کند نمی‌سنجد.
$intruder = $this->clinic()['user'];
$this->authJson('POST', '/api/v1/appointment-confirm', $intruder, [
'hold_uuid' => $hold['data']['hold_uuid'],
'doctor_uuid' => $s['doctor']->getUuid(),
]);
self::assertSame(404, $this->responseCode());
}
/** نیازمندی‌ای که در `assignment` منبع ندارد → ۴۲۲، پیش از هر رزروی. */
public function testAssignmentMissingARoleIsRejected(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'لیزر', 20);
$room = $this->type($c['address'], 'room', 'اتاق');
$operator = $this->type($c['address'], 'operator', 'اپراتور');
$roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱');
$this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱');
$this->segments($c['user'], $service, [
['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [
['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()],
]],
]);
$body = $this->hold($c['user'], $service, $c['address'], $this->nextSaturdayAt(18), [
'room' => [$roomRes['uuid']], // اپراتور نیامده
]);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('اپراتور', $body['errors'][0]['message']);
}
/**
* ⭐ آزادسازی ظرفیت حفظ می‌شود: اپراتور در بخش «انتظار» ردیف اشغال **ندارد**.
*/
public function testOperatorHasNoOccupancyDuringTheWaitingSegment(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'لیزر', 20);
$room = $this->type($c['address'], 'room', 'اتاق');
$operator = $this->type($c['address'], 'operator', 'اپراتور');
$roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱');
$opRes = $this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱');
$this->segments($c['user'], $service, [
['sequence' => 1, 'name' => 'بی‌حسی', 'duration_minutes' => 5, 'requirements' => [['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()]]],
['sequence' => 2, 'name' => 'انتظار', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $room->getUuid()]]],
['sequence' => 3, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()]]],
]);
$start = $this->nextSaturdayAt(19);
$body = $this->hold($c['user'], $service, $c['address'], $start, [
'room' => [$roomRes['uuid']],
'operator' => [$opRes['uuid']],
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
$this->em->clear();
$operatorRows = $this->occupancyOf($opRes['uuid']);
self::assertCount(2, $operatorRows, 'دو بخش، نه یک بازهٔ پیوستهٔ ۵۵ دقیقه‌ای');
self::assertSame($start, $operatorRows[0]->getStartsAt());
self::assertSame($start + 5 * 60, $operatorRows[0]->getEndsAt());
self::assertSame($start + 35 * 60, $operatorRows[1]->getStartsAt());
// اتاق برعکس: هر سه بخش را می‌گیرد.
self::assertCount(3, $this->occupancyOf($roomRes['uuid']));
}
/** رزرو نیمه‌کاره نمی‌ماند: اگر منبع دوم جا نداشت، اولی هم آزاد می‌شود. */
public function testPartialHoldIsRolledBack(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'لیزر', 20);
$room = $this->type($c['address'], 'room', 'اتاق');
$operator = $this->type($c['address'], 'operator', 'اپراتور');
$roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱');
$opRes = $this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱');
$this->segments($c['user'], $service, [
['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [
['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()],
]],
]);
$start = $this->nextSaturdayAt(20);
// اپراتور را از پیش می‌گیریم تا رزرو دوم روی او شکست بخورد.
$onlyOperator = $this->service($c['section'], 'کار اپراتور', 20);
$this->segments($c['user'], $onlyOperator, [
['sequence' => 1, 'name' => 'کار', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $operator->getUuid()]]],
]);
$this->hold($c['user'], $onlyOperator, $c['address'], $start, ['operator' => [$opRes['uuid']]]);
self::assertSame(201, $this->responseCode());
$body = $this->hold($c['user'], $service, $c['address'], $start, [
'room' => [$roomRes['uuid']],
'operator' => [$opRes['uuid']],
]);
self::assertSame(409, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
// اتاق نباید قفل مانده باشد.
$this->em->clear();
$roomRows = $this->em->createQuery(
'SELECT o FROM App\Appointment\Availability\Entity\ResourceOccupancy o
JOIN o.resource r WHERE r.uuid = :uuid AND o.status IN (:blocking)'
)
->setParameter('uuid', $roomRes['uuid'])
->setParameter('blocking', ResourceOccupancy::BLOCKING_STATUSES)
->getResult();
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']);
}
}