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
+20 -2
View File
@@ -2,14 +2,19 @@
namespace App\Appointment\Entity;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use Doctrine\ORM\Mapping as ORM;
use App\Appointment\Repository\DateOverrideRepository;
use Symfony\Component\Uid\Uuid;
/**
* استثنای ساعت کاری یک روز خاص. چون خودِ ساعت کاری per-context است، استثنای آن هم
* per-context است: clinic باید همان clinic برنامهٔ هفتگی متناظر باشد (NULL = شخصی).
*/
#[ORM\Entity(repositoryClass: DateOverrideRepository::class)]
#[ORM\Table(name: 'date_overrides')]
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_date', columns: ['doctor_id', 'date'])]
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_clinic_date', columns: ['doctor_id', 'clinic_key', 'date'])]
class DateOverride
{
#[ORM\Id]
@@ -24,6 +29,15 @@ class DateOverride
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Doctor $doctor;
/** NULL = مطب شخصی پزشک. */
#[ORM\ManyToOne(targetEntity: Clinic::class)]
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Clinic $clinic = null;
/** ستون تولیدشده: IFNULL(clinic_id, 0) — تا یکتایی با clinic_id تهی هم برقرار بماند. */
#[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false, generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')]
private int $clinicKey = 0;
#[ORM\Column(type: 'integer')]
private int $date;
@@ -42,10 +56,11 @@ class DateOverride
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(Doctor $doctor, int $date, bool $active = false)
public function __construct(Doctor $doctor, int $date, bool $active = false, ?Clinic $clinic = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->doctor = $doctor;
$this->clinic = $clinic;
$this->date = $date;
$this->active = $active;
$this->createdAt = time();
@@ -55,6 +70,7 @@ class DateOverride
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getClinic(): ?Clinic { return $this->clinic; }
public function getDate(): int { return $this->date; }
public function isActive(): bool { return $this->active; }
public function getSetting(): ?array { return $this->setting; }
@@ -72,6 +88,8 @@ class DateOverride
return [
'uuid' => $this->uuid,
'doctor_uuid' => $this->doctor->getUuid(),
'clinic_uuid' => $this->clinic?->getUuid(),
'context' => $this->clinic === null ? 'personal' : 'clinic',
'date' => $this->date,
'active' => $this->active,
'reason' => $this->reason,
+17 -1
View File
@@ -2,11 +2,17 @@
namespace App\Appointment\Entity;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use Doctrine\ORM\Mapping as ORM;
use App\Appointment\Repository\HolidayRepository;
use Symfony\Component\Uid\Uuid;
/**
* تعطیلی پزشک. برخلاف WeeklySchedule و DateOverride، تعطیلی پیش‌فرضاً سراسری است:
* «پزشک آن روز نیست» یک واقعیت فیزیکی است و هم‌زمان روی مطب شخصی و همهٔ کلینیک‌ها
* اثر می‌گذارد (clinic = null). مقدار غیر-NULL یعنی پزشک فقط در همان کلینیک نیست.
*/
#[ORM\Entity(repositoryClass: HolidayRepository::class)]
#[ORM\Table(name: 'holidays')]
#[ORM\Index(columns: ['doctor_id', 'start_date', 'end_date'], name: 'idx_holidays_doctor_range')]
@@ -24,6 +30,11 @@ class Holiday
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Doctor $doctor;
/** NULL = پزشک در هیچ محلی نیست (همهٔ contextها). */
#[ORM\ManyToOne(targetEntity: Clinic::class)]
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Clinic $clinic = null;
#[ORM\Column(name: 'start_date', type: 'integer')]
private int $startDate;
@@ -42,10 +53,11 @@ class Holiday
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(Doctor $doctor, int $startDate, int $endDate)
public function __construct(Doctor $doctor, int $startDate, int $endDate, ?Clinic $clinic = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->doctor = $doctor;
$this->clinic = $clinic;
$this->startDate = $startDate;
$this->endDate = $endDate;
$this->createdAt = time();
@@ -55,6 +67,8 @@ class Holiday
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getClinic(): ?Clinic { return $this->clinic; }
public function isGlobal(): bool { return $this->clinic === null; }
public function getStartDate(): int { return $this->startDate; }
public function getEndDate(): int { return $this->endDate; }
public function isActive(): bool { return $this->active; }
@@ -72,6 +86,8 @@ class Holiday
return [
'uuid' => $this->uuid,
'doctor_uuid' => $this->doctor->getUuid(),
'clinic_uuid' => $this->clinic?->getUuid(),
'scope' => $this->clinic === null ? 'global' : 'clinic',
'start_date' => $this->startDate,
'end_date' => $this->endDate,
'active' => $this->active,
+38 -3
View File
@@ -2,14 +2,22 @@
namespace App\Appointment\Entity;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use Doctrine\ORM\Mapping as ORM;
use App\Appointment\Repository\WeeklyScheduleRepository;
use Symfony\Component\Uid\Uuid;
/**
* برنامهٔ هفتگی نوبت‌دهی یک پزشک در یک context مشخص.
*
* context با ستون clinic_id بیان می‌شود: NULL یعنی مطب شخصی پزشک، و مقدار غیر-NULL
* یعنی همان پزشک در آن کلینیک. یک پزشک می‌تواند هم‌زمان چند برنامه داشته باشد
* (شخصی + یکی به ازای هر کلینیک) و این برنامه‌ها کاملاً مستقل‌اند.
*/
#[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)]
#[ORM\Table(name: 'weekly_schedules')]
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor', columns: ['doctor_id'])]
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor_clinic', columns: ['doctor_id', 'clinic_key'])]
class WeeklySchedule
{
public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
@@ -35,10 +43,25 @@ class WeeklySchedule
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\OneToOne(targetEntity: Doctor::class)]
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Doctor $doctor;
/** NULL = مطب شخصی پزشک. */
#[ORM\ManyToOne(targetEntity: Clinic::class)]
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Clinic $clinic = null;
/**
* ستون تولیدشدهٔ پایگاه‌داده: IFNULL(clinic_id, 0).
*
* MySQL/MariaDB مقادیر NULL را در unique index متمایز می‌شمارند، پس
* UNIQUE(doctor_id, clinic_id) جلوی دو برنامهٔ شخصی برای یک پزشک را نمی‌گرفت.
* این ستون NULL را به 0 نگاشت می‌کند تا یکتایی در سطح دیتابیس تضمین شود.
*/
#[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false, generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')]
private int $clinicKey = 0;
#[ORM\Column(type: 'json')]
private array $setting = [];
@@ -48,10 +71,11 @@ class WeeklySchedule
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(Doctor $doctor, array $setting)
public function __construct(Doctor $doctor, array $setting, ?Clinic $clinic = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->doctor = $doctor;
$this->clinic = $clinic;
$this->setting = $setting;
$this->createdAt = time();
$this->updatedAt = time();
@@ -60,6 +84,15 @@ class WeeklySchedule
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getClinic(): ?Clinic { return $this->clinic; }
/** فقط برای انتقال دستی برنامه‌های قدیمی به محیط کلینیک (app:schedule:assign-clinic). */
public function setClinic(?Clinic $clinic): self
{
$this->clinic = $clinic;
$this->updatedAt = time();
return $this;
}
public function getSetting(): array { return $this->setting; }
public function setSetting(array $setting): self
@@ -119,6 +152,8 @@ class WeeklySchedule
return [
'uuid' => $this->uuid,
'doctor_uuid' => $this->doctor->getUuid(),
'clinic_uuid' => $this->clinic?->getUuid(),
'context' => $this->clinic === null ? 'personal' : 'clinic',
'schedule' => $this->getDaySchedule(),
'meta' => $this->getMeta(),
// نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود (پنل توگل را غیرفعال می‌کند).