feat(practice-domain): add practice domains and let a clinic select one

A practice domain is the field a clinic operates in — beauty, dentistry —
and unlike Specialty it is configuration, not a label: treatment workflows
will bind to its code, so the code is immutable once created and only a
platform admin can mint one. A clinic that has not chosen a domain keeps
behaving exactly as it does today.

Assignment reuses PATCH /api/v1/clinic/{uuid} rather than adding a second
endpoint. An unknown domain uuid is rejected instead of silently dropped,
because a lost selection would only surface at the first protocol-driven
booking.

Also corrects ADR-0003: resource occupancy does not in fact guard the panel
booking path, which writes appointments.resource_id and no occupancy row at
all, so the doctor slot key cannot simply be dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-06 16:06:29 +03:30
co-authored by Claude Opus 5
parent 1d43475724
commit 85985b04a0
13 changed files with 736 additions and 23 deletions
+32 -9
View File
@@ -224,26 +224,49 @@ if ($doctorUuid === '' && $resource->getSupervisor() !== null) {
«این منبع پزشک ناظر ندارد؛ ابتدا در تنظیمات منابع پزشک ناظر را مشخص کنید».
پیام فعلی (`doctor_uuid یا resource_uuid ...`) گمراه‌کننده است.
### ۵. آزادسازی کلید اسلات برای نوبت‌های منبع‌دار
### ۵. رزرو منبع مستقل از پزشک
در `Appointment::refreshActiveSlotKey()`:
**این وظیفه بعد از بررسی داده واقعی بازنویسی شد. ADR-0003 را بخوان.**
آنچه با داده تأیید شد:
- دو مسیر رزرو داریم و هیچ‌کدام ردیف دیگری نمی‌سازد.
مسیر پنل روی `appointments.resource_id` می‌نشیند، مسیر hold روی `resource_occupancy`.
- `bookAtomically` روی **پزشک** قفل می‌گیرد و `isSlotTaken` تداخل بازه‌ای را فقط روی پزشک
می‌سنجد. منبع در آن کوئری نیست.
- در دیتابیس فعلی هر محیط چند منبع با یک پزشک ناظر مشترک دارد. کلینیک ۲ شش منبع با پزشک ۶،
کلینیک ۳ سه منبع با پزشک ۹. پس این باگ همین حالا فعال است.
- برنامه هفتگی پزشک مانع نیست؛ `resolveSlotLocationId` فقط `null` برمی‌گرداند.
پنج تغییر:
۱. مسیر پنل هنگام رزرو `ResourceOccupancy` بسازد، همان‌طور که `HoldService` می‌سازد.
منطق مشترک در یک سرویس باشد، در دو جا کپی نشود.
۲. لغو یا انقضای نوبت، ردیف اشغال را `released` کند.
۳. در `Appointment::refreshActiveSlotKey()` وقتی منبع هست کلید `null` بماند:
```php
$this->activeSlotKey = (!$this->isReserve
&& $this->resource === null // ← شرط جدید
&& $this->resource === null
&& in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true))
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
: null;
```
`setResource()` باید `refreshActiveSlotKey()` را صدا بزند، وگرنه نوبتی که اول ساخته
و بعد منبعش ست می‌شود کلیدش باقی می‌ماند.
`setResource()` باید `refreshActiveSlotKey()` را صدا بزند.
**قبل از این تغییر:** همه مسیرهای ساخت نوبت را فهرست کن و مشخص کن کدام‌ها `resource`
ست نمی‌کنند. آن‌ها بعد از این تغییر همچنان با کلید پزشک محافظت می‌شوند — این درست است،
ولی باید مستند شود که کدام‌ها هستند. ADR-0003 روی همین هشدار داده.
۴. `bookAtomically` وقتی نوبت منبع دارد روی پزشک قفل نگیرد و `isSlotTaken` را صدا نزند.
تضمین یکتایی از `uniq_bucket_resource_seat` می‌آید که ظرفیت و `seat` را می‌فهمد.
migration لازم نیست؛ ستون بدون تغییر می‌ماند و فقط منطق پرشدنش عوض می‌شود.
۵. شعبه نوبتِ منبع‌دار از `ClinicResource.getAddress()` بیاید، نه از برنامه پزشک.
**قبل از شروع:** همه مسیرهای ساخت نوبت را فهرست کن و بنویس کدام‌ها منبع ست نمی‌کنند.
آن‌ها کلید پزشک و قفل پزشک را نگه می‌دارند. این فهرست باید در گزارش بیاید.
migration برای `active_slot_key` لازم نیست. برای ردیف‌های اشغالِ گذشتهٔ مسیر پنل یک
migration داده‌ای لازم است تا نوبت‌های فعالِ منبع‌دار موجود ردیف اشغال بگیرند.
### ۶. تعریف فیلد روی نوع منبع