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
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Shared\Context;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
/**
* محیط کاری مؤثر یک درخواست: یا مطب شخصی یک پزشک، یا یک کلینیک.
*
* سرویس‌ها، آدرس‌ها و برنامهٔ نوبت‌دهی همگی به یکی از این دو تعلق دارند و هرگز بین
* آن‌ها مشترک نمی‌شوند. type/id دقیقاً همان جفتی است که ServiceSection با
* entity_type/entity_id ذخیره می‌کند.
*/
final class EntityContext
{
public const TYPE_DOCTOR = 'doctor';
public const TYPE_CLINIC = 'clinic';
public const TYPE_UNKNOWN = 'unknown';
private function __construct(
public readonly string $type,
public readonly ?int $id,
public readonly ?Clinic $clinic = null,
public readonly ?Doctor $doctor = null,
) {}
public static function forDoctor(?Doctor $doctor): self
{
return new self(self::TYPE_DOCTOR, $doctor?->getId(), null, $doctor);
}
public static function forClinic(Clinic $clinic): self
{
return new self(self::TYPE_CLINIC, $clinic->getId(), $clinic);
}
public static function unknown(): self
{
return new self(self::TYPE_UNKNOWN, null);
}
public function isClinic(): bool { return $this->type === self::TYPE_CLINIC; }
public function isResolved(): bool { return $this->id !== null; }
/** @return array{0: string, 1: ?int} جفت (entity_type, entity_id) برای ServiceSection */
public function toEntityPair(): array
{
return [$this->type, $this->id];
}
}
@@ -0,0 +1,124 @@
<?php
namespace App\Shared\Context;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* تنها نقطهٔ تصمیم‌گیری دربارهٔ «این درخواست در کدام محیط اجرا می‌شود؟».
*
* اولویت: clinic_uuid صریحِ درخواست > محیط فعالِ ذخیره‌شدهٔ کاربر > نقش کاربر.
*
* نقش به‌تنهایی برای کاربری که هم پزشک است و هم مالک کلینیک جواب نمی‌دهد: چنین
* کاربری همیشه به‌عنوان پزشک حل می‌شد و هرگز به سرویس‌های کلینیک خودش نمی‌رسید.
* UserActiveContext تعیین‌کننده است و نقش فقط fallback آخر.
*/
class EntityContextResolver
{
public function __construct(
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly UserActiveContextRepository $activeContextRepo,
) {}
/**
* @param string|null $clinicUuid اگر داده شود، محیط کلینیک اجباری می‌شود و در
* صورت نداشتن دسترسی، خطای ۴۰۳ پرتاب می‌شود.
*/
public function resolve(User $user, ?string $clinicUuid = null): EntityContext
{
if ($clinicUuid !== null && $clinicUuid !== '') {
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
if ($clinic === null) {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
$this->assertCanActInClinic($user, $clinic);
return EntityContext::forClinic($clinic);
}
$fromActive = $this->fromActiveContext($user);
if ($fromActive !== null) {
return $fromActive;
}
return $this->fromRole($user);
}
/**
* محیط را بدون پرتاب خطا حل می‌کند؛ اگر کاربر به کلینیکِ خواسته‌شده دسترسی
* نداشته باشد null برمی‌گرداند. برای مسیرهایی که خودشان authorization جدا دارند.
*/
public function tryResolve(User $user, ?string $clinicUuid = null): ?EntityContext
{
try {
return $this->resolve($user, $clinicUuid);
} catch (AppException) {
return null;
}
}
/** مالک کلینیک، ادمین، یا پزشکِ عضو همان کلینیک. */
public function canActInClinic(User $user, Clinic $clinic): bool
{
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
return true;
}
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null && $clinic->hasDoctor($doctor);
}
public function assertCanActInClinic(User $user, Clinic $clinic): void
{
if (!$this->canActInClinic($user, $clinic)) {
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'به این کلینیک دسترسی ندارید', 403);
}
}
/**
* محیط فعالِ ذخیره‌شده. db_uuid یا uuid کلینیک است یا uuid پزشک؛ کلینیک اول
* بررسی می‌شود چون پزشکِ دعوت‌شده هم db_uuid کلینیک را ذخیره می‌کند.
*/
private function fromActiveContext(User $user): ?EntityContext
{
$active = $this->activeContextRepo->findByUser($user);
if ($active === null) {
return null;
}
$clinic = $this->clinicRepo->findByUuid($active->getDbUuid());
if ($clinic !== null) {
return $this->canActInClinic($user, $clinic) ? EntityContext::forClinic($clinic) : null;
}
$doctor = $this->doctorRepo->findByUuid($active->getDbUuid());
if ($doctor !== null && $doctor->getUser()->getId() === $user->getId()) {
return EntityContext::forDoctor($doctor);
}
return null;
}
private function fromRole(User $user): EntityContext
{
if ($user->hasRole('ROLE_DOCTOR')) {
return EntityContext::forDoctor($this->doctorRepo->findByUser($user));
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
}
return EntityContext::unknown();
}
}