feat(migrations): add clinic_id context to weekly_schedules, date_overrides, and holidays

- Introduced clinic_id to weekly_schedules, date_overrides, and holidays to differentiate between personal and clinic schedules.
- Updated unique constraints and indexes to accommodate the new clinic context.

feat(command): create AssignScheduleClinicCommand to move schedules

- Added a command to move a doctor's personal weekly schedule into a clinic context.
- Implemented checks to ensure sessions align with the target clinic.

feat(context): implement EntityContext and EntityContextResolver

- Created EntityContext to represent the effective working environment of a request (doctor or clinic).
- Developed EntityContextResolver to determine the execution context based on user roles and active contexts.

test: add ServiceModeContextTest for appointment scheduling

- Implemented tests to ensure service booking respects clinic and personal contexts.
- Verified that financial data is omitted in clinic contexts in InvitedDoctorDashboardScopeTest.
This commit is contained in:
hamed
2026-07-18 13:32:56 +03:30
parent 2553b45990
commit f1258d206d
28 changed files with 2126 additions and 276 deletions
@@ -0,0 +1,130 @@
<?php
namespace App\Tests\Appointment;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* نوبت‌دهی سرویسی، سرویس‌های همان محیط را می‌خواهد. باگ اصلی این بود که شمارش
* همیشه با entity_type='doctor' انجام می‌شد، پس کلینیکی که سرویس bookable داشت هم
* خطای «حداقل یک سرویس لازم است» می‌گرفت.
*/
class ServiceModeContextTest extends ApiTestCase
{
private function makeDoctorInClinic(): array
{
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($doctorUser, 'دکتر تست');
$doctor->setMobileNumber($doctorUser->getMobileNumber());
$this->em->persist($doctor);
$ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($ownerUser);
$clinic->setName('کلینیک تست');
$clinic->getDoctors()->add($doctor);
$this->em->persist($clinic);
$this->em->flush();
return [$doctor, $clinic, $ownerUser];
}
private function bookableServiceFor(string $entityType, int $entityId): void
{
$section = new ServiceSection($entityType, $entityId, 'بخش تست');
$this->em->persist($section);
$item = new ServiceItem($section, 'ویزیت', 500_000);
$item->setBookable(true);
$item->setDurationMinutes(20);
$this->em->persist($item);
$this->em->flush();
}
private function servicePayload(Doctor $doctor, int $locationId, ?Clinic $clinic): array
{
return array_filter([
'doctor_uuid' => $doctor->getUuid(),
'clinic_uuid' => $clinic?->getUuid(),
'schedule' => [
['day' => 'saturday', 'sessions' => [
['active' => true, 'location_id' => $locationId, 'start' => '09:00', 'end' => '12:00'],
]],
],
'meta' => ['booking_mode' => 'service'],
], fn($v) => $v !== null);
}
public function testClinicServiceModeAcceptsClinicOwnedService(): void
{
[$doctor, $clinic, $owner] = $this->makeDoctorInClinic();
$this->bookableServiceFor('clinic', $clinic->getId());
$address = DoctorAddress::forClinic($clinic->getId());
$this->em->persist($address);
$this->em->flush();
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->servicePayload($doctor, $address->getId(), $clinic));
self::assertSame(201, $this->responseCode());
}
public function testPersonalServiceModeIgnoresClinicServices(): void
{
[$doctor, $clinic] = $this->makeDoctorInClinic();
$this->bookableServiceFor('clinic', $clinic->getId());
$address = DoctorAddress::forDoctor($doctor);
$this->em->persist($address);
$this->em->flush();
$body = $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->servicePayload($doctor, $address->getId(), null));
self::assertSame(422, $this->responseCode(), 'سرویس کلینیک نباید مطب شخصی را راضی کند');
self::assertSame('booking_mode', $body['errors'][0]['field'] ?? null);
}
public function testPersonalServiceModeAcceptsOwnService(): void
{
[$doctor] = $this->makeDoctorInClinic();
$this->bookableServiceFor('doctor', $doctor->getId());
$address = DoctorAddress::forDoctor($doctor);
$this->em->persist($address);
$this->em->flush();
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->servicePayload($doctor, $address->getId(), null));
self::assertSame(201, $this->responseCode());
}
public function testBookingModeLocksPerContextNotGlobally(): void
{
[$doctor, $clinic, $owner] = $this->makeDoctorInClinic();
$this->bookableServiceFor('clinic', $clinic->getId());
$personal = DoctorAddress::forDoctor($doctor);
$inClinic = DoctorAddress::forClinic($clinic->getId());
$this->em->persist($personal);
$this->em->persist($inClinic);
$this->em->flush();
// مطب شخصی: اسلاتی
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), [
'doctor_uuid' => $doctor->getUuid(),
'schedule' => [['day' => 'saturday', 'sessions' => [
['active' => true, 'location_id' => $personal->getId(), 'start' => '09:00', 'end' => '12:00'],
]]],
'meta' => ['booking_mode' => 'slot'],
]);
self::assertSame(201, $this->responseCode());
// همان پزشک در کلینیک: سرویسی — قفلِ محیط دیگر نباید مانع شود
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->servicePayload($doctor, $inClinic->getId(), $clinic));
self::assertSame(201, $this->responseCode());
}
}