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>
522 lines
23 KiB
PHP
522 lines
23 KiB
PHP
<?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']);
|
||
}
|
||
}
|