docs: prompt and checklist for the resource-first booking model
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
# نوبتدهی بر پایهٔ منبع: رابطهٔ منبع↔سرویس، گزینهٔ سرویس، و حل مدت/قیمت
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (backend + پنل ادمین).
|
||||
**cross-repo:** قرارداد `POST /api/v1/appointment` و `GET /api/v1/appointment-booking-locations/{doctorUuid}` را سایت عمومی مصرف میکند
|
||||
(`nobat724_front/services/response.js` → `getBookingLocations`, `getServiceSlots`, `postAppointment`).
|
||||
تغییر قرارداد در build سایت خطا نمیدهد؛ باید دستی دنبال شود.
|
||||
|
||||
## قواعد غیرقابلمذاکره
|
||||
|
||||
این سه بند شرط پذیرشاند، نه توصیه. کاری که اینها را نقض کند «تمامشده» نیست حتی اگر تستهایش سبز باشد.
|
||||
|
||||
**۱. هر UI جدید داخل تم فعلی، در حد یک متخصص UI/UX.** هیچ تم، پالت، فونت یا کتابخانهٔ CSS تازهای
|
||||
ساخته نمیشود. مشخصاً:
|
||||
|
||||
- رنگها فقط از توکنهای `assets/admin/styles.css` (`var(--primary)`، `var(--surface)`، `var(--border)`،
|
||||
`var(--text-2)`، …). هیچ hex خامی در کامپوننت جدید.
|
||||
- کامپوننت از `assets/admin/components/ui/` استفاده شود، نه نسخهٔ دستساز: `DataTable`، `Modal`،
|
||||
`ConfirmDialog`، `PageHeader` (با `backTo`)، `SearchableSelect`، `StatusBadge`، `Pagination`،
|
||||
`PersianDateInput`. **`<select>` خام ممنوع** — همیشه `SearchableSelect`.
|
||||
- سه حالت نمایش باید سالم باشند: دارکمود (`[data-theme="dark"]`)، حالت فشرده
|
||||
(`[data-density="compact"]`) و موبایل ۳۹۰px بدون اسکرول افقی.
|
||||
- دو تلهٔ شناختهشدهٔ همین CSS: `.card` **padding ندارد** (برای فاصله `card-pad` اضافه کن) و
|
||||
`.field` خودش جعبهٔ ورودی است (برچسبِ بالای فیلد با `.field-block` میآید، نه داخل `.field`).
|
||||
- RTL و متن فارسی؛ تاریخها شمسی با `formatDate()`.
|
||||
- صفحهٔ زیرمجموعه بدون دکمهٔ بازگشت پذیرفته نیست: `PageHeader backTo=…` یا `<BackButton fallback=…/>`.
|
||||
- وضعیت لیستها (جستجو، فیلتر، صفحه) در query string با `hooks/useUrlState.ts`، نه در `useState`.
|
||||
|
||||
**۲. هر چیزِ اضافه حذف میشود.** فقط منبع، سرویس، گزینهٔ سرویس و دستهبندی میماند. وظیفهٔ ۸ فهرست
|
||||
حذف را دارد؛ ولی قاعده کلیتر است: در همین کار هم صفحه، فیلد، endpoint یا گزینهای که سند
|
||||
نخواسته اضافه نکن.
|
||||
|
||||
**۳. ساختار ساده.** بدون abstraction برای آینده:
|
||||
|
||||
- جدول جدید فقط وقتی هیچ جدول موجودی — حتی با یک ستون تازه — کافی نباشد؛ دلیلش نوشته شود.
|
||||
(به همین دلیل «گزینهٔ سرویس» جدول جدید نمیگیرد — پایینتر.)
|
||||
- Controller نازک، منطق در Service، کوئری در Repository، وابستگی با constructor injection.
|
||||
- interface و کلاس پایه فقط وقتی **الان** بیش از یک پیادهسازی دارد.
|
||||
- نامگذاری و سبک کد دقیقاً مثل فایلهای همسایه.
|
||||
|
||||
## زمینه
|
||||
|
||||
تسکهای `docs/new_feture/taskes/` لایهٔ منبع را ساختهاند — `ResourceType`، `ClinicResource`،
|
||||
تقویم منبع، استثنا، استخر، مهارت، موتور دسترسپذیری و اشغال واقعی. ولی **واحد رزرو هنوز پزشک است**:
|
||||
نوبت به `Doctor` گره خورده، و «کدام منبع این سرویس را میدهد» و «مدت/قیمت این سرویس برای این منبع»
|
||||
هیچجا داده نیست. سند مالک محصول میگوید مدل باید بر پایهٔ منبع باشد، و هر چیزی خارج از
|
||||
منبع/سرویس/گزینه از محصول حذف شود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
سه شکاف واقعی نسبت به سند:
|
||||
|
||||
1. **رابطهٔ منبع↔سرویس وجود ندارد.** انتخاب منبع فقط با `type + skill` انجام میشود، پس
|
||||
«دستگاه لیزر ۱ این سرویس را میدهد ولی دستگاه ۲ نه» قابل بیان نیست.
|
||||
2. **مدت و قیمت بُعد منبع ندارند.** override موجود per **شعبه** است؛ «دکتر احمدی ۳۰ دقیقه /
|
||||
دکتر رضایی ۴۵ دقیقه برای همان گزینه» نمایشدادنی نیست.
|
||||
3. **نوبت منبع را نگه نمیدارد.** `Appointment.doctor` غیرتهی است و رزرو بدون پزشک ممکن نیست،
|
||||
پس نوبتِ «دستگاه لیزر ۲» در مدل جا ندارد.
|
||||
|
||||
هدف: منبع واحد رزرو شود، مدت و قیمت با زنجیرهٔ **منبع+گزینه → منبع+سرویس → پیشفرض** حل شود،
|
||||
و نوبت منبع و snapshot را نگه دارد.
|
||||
|
||||
> **گزینهٔ سرویس در این کدبیس جدول جدید نمیخواهد.** «گزینه» همان `ServiceItem` عضو یک
|
||||
> `ItemGroup` است (`select_min`/`select_max` از قبل هست) و «سرویس» همان آیتم والد. ساختن
|
||||
> جدول سوم `service_options` یعنی دو منبع حقیقت برای یک چیز، و همهٔ مسیرهای امروز
|
||||
> (`appointment-booking-services`، `appointment-service-slots`، `PriceListItem`، `Tariff`)
|
||||
> باید دوباره نوشته شوند. دلیل انتخاب در «نکات مهم» ثبت شده است.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ **موفق:** برای سرویس «لیزر» و گزینهٔ «پا» دو ردیف منبع ثبت شود (دستگاه ۱: ۲۰ دقیقه/۸ میلیون ریال،
|
||||
دستگاه ۲: ۱۵ دقیقه/۹٫۵ میلیون ریال). `GET /api/v1/resource/{uuid}/services` هر دو را برگرداند و
|
||||
`POST /api/v1/appointment` با `resource_uuid` دستگاه ۲ → `201` با
|
||||
`service_total_minutes: 15` و `price_snapshot.final_rials: 9500000`.
|
||||
- ❌ **خطا:** رزرو با `resource_uuid` منبعی که آن سرویس را ندارد → `422` با
|
||||
`ERR_VALIDATION_001` و پیام «این منبع این سرویس را ارائه نمیدهد»؛ بدون توکن → `401` با envelope خطا.
|
||||
- ✅ **دستهبندی:** دستهٔ «تمام بدن» شامل «دست» و «پا» تعریف شود؛ انتخاب همزمان «لیزر تمام بدن» و
|
||||
«لیزر دست» → `422` با پیام فارسی، و یک دستگاه بتواند همزمان به چند دسته وصل باشد.
|
||||
- ⚠️ **مرزی:** منبعی که برای گزینه مقدار ندارد ولی برای سرویس دارد → مقدار سطح سرویس استفاده شود؛
|
||||
منبعی که هیچکدام را ندارد → پیشفرض خودِ آیتم؛ و نوبتهای **قبلاً ثبتشده** (بدون `resource_id`)
|
||||
باید همچنان در لیستها و پنل بدون خطا نمایش داده شوند.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|---|---|
|
||||
| `src/Resource/Entity/ClinicResource.php` | منبع؛ امروز به `Doctor`/`ClinicStaff`/`Room` لینک میشود |
|
||||
| `src/Resource/Repository/ClinicResourceRepository.php` | `findEligible()` — انتخاب کاندید با type+skill |
|
||||
| `src/ClinicService/Entity/ServiceItem.php` | سرویس و گزینه (هر دو item) با `solo/additional` و قیمت |
|
||||
| `src/ClinicService/Entity/ItemGroup.php` · `ItemGroupMember.php` | گروه گزینهها با بازهٔ انتخاب |
|
||||
| `src/ClinicService/Entity/CatalogCategory.php` | دستهٔ سراسری محیط؛ `parent` تکوالدی و `MAX_DEPTH = 4` |
|
||||
| `src/ClinicService/Service/ServiceSelectionValidator.php` | اعتبارسنجی ترکیب انتخابها |
|
||||
| `src/ClinicService/Entity/ServiceBranchOverride.php` | override per شعبه (باید در زنجیره بماند) |
|
||||
| `src/ClinicService/Service/DurationCalculator.php` | جمع solo/additional |
|
||||
| `src/Pricing/Service/PricingEngine.php` | `quote()` — بدون بُعد منبع |
|
||||
| `src/Appointment/Entity/Appointment.php` | `private Doctor $doctor` غیرتهی |
|
||||
| `src/Appointment/Controller/AppointmentController.php` | `book()` — `doctor_uuid` الزامی |
|
||||
| `src/Appointment/Plan/Service/AppointmentPlanBuilder.php` | ساخت برنامه و نیازمندیها |
|
||||
| `src/Appointment/Availability/Service/AvailabilityEngine.php` | جستجوی وقت آزاد روی منابع |
|
||||
| `assets/admin/pages/ResourcesPage.tsx` | صفحهٔ منابع پنل |
|
||||
| `docs/api/appointment.md` · `docs/api/clinic.md` | سند endpointها |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
انتخاب کاندید هیچ ربطی به سرویس ندارد — `src/Resource/Repository/ClinicResourceRepository.php:108`:
|
||||
|
||||
```php
|
||||
public function findEligible(DoctorAddress $address, ResourceType $type, array $skillIds = []): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('r')
|
||||
->where('r.address = :address')
|
||||
->andWhere('r.type = :type')
|
||||
->andWhere('r.active = true')
|
||||
// … فیلتر مهارت
|
||||
```
|
||||
|
||||
override فقط per شعبه است — `src/ClinicService/Entity/ServiceBranchOverride.php:41`:
|
||||
|
||||
```php
|
||||
#[ORM\Column(name: 'price_rials', type: 'bigint', nullable: true)]
|
||||
private ?int $priceRials = null;
|
||||
|
||||
#[ORM\Column(name: 'solo_duration_minutes', type: 'smallint', nullable: true)]
|
||||
private ?int $soloDurationMinutes = null;
|
||||
```
|
||||
|
||||
نوبت بدون پزشک ساخته نمیشود — `src/Appointment/Entity/Appointment.php:101` و `:242`:
|
||||
|
||||
```php
|
||||
private Doctor $doctor;
|
||||
|
||||
public function __construct(Doctor $doctor, User $user, int $slotStart, int $slotEnd)
|
||||
```
|
||||
|
||||
قیمتگذاری بُعد منبع ندارد — `src/Pricing/Service/PricingEngine.php:59`:
|
||||
|
||||
```php
|
||||
public function quote(
|
||||
ServiceItem $service,
|
||||
array $items,
|
||||
DoctorAddress $address,
|
||||
int $at,
|
||||
array $policy = [],
|
||||
?PatientRecord $patient = null,
|
||||
): PriceQuote {
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. جدول رابطهٔ منبع↔سرویس
|
||||
|
||||
`src/Resource/Entity/ResourceServiceOffering.php` — رابطهٔ چندبهچند با تنظیمات اختصاصی.
|
||||
همان الگوی `ResourceSkill`/`ResourcePoolMember`: entity رابطهای با جفت یکتا.
|
||||
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: ResourceServiceOfferingRepository::class)]
|
||||
#[ORM\Table(name: 'resource_service_offerings')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_resource_service', columns: ['resource_id', 'service_item_id'])]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'service_item_id'], name: 'idx_offering_tenant_service')]
|
||||
class ResourceServiceOffering
|
||||
{
|
||||
use TenantOwnedTrait; // جفت از خود منبع مشتق میشود، نه از بدنهٔ درخواست
|
||||
|
||||
public function __construct(ClinicResource $resource, ServiceItem $serviceItem) { … }
|
||||
|
||||
private ?int $durationMinutes = null; // null = ارث از سطح بالاتر
|
||||
private ?int $priceRials = null; // null = ارث از سطح بالاتر
|
||||
private bool $active = true;
|
||||
}
|
||||
```
|
||||
|
||||
نکته: چون «گزینه» هم `ServiceItem` است، همین یک جدول هر دو سطحِ سند را پوشش میدهد —
|
||||
ردیف با آیتمِ والد = «منبع + سرویس»، ردیف با آیتمِ عضو گروه = «منبع + گزینه».
|
||||
|
||||
**نحوه تست:** migration ساخته و اجرا شود؛ سپس
|
||||
`ddev exec php bin/phpunit tests/Resource/ResourceServiceOfferingTest.php` با سه تست:
|
||||
ثبت ردیف، جفت تکراری → خطای یکتایی، و اینکه جفت محیط از منبع گرفته میشود نه از ورودی.
|
||||
|
||||
### ۲. Resolver مدت و قیمت
|
||||
|
||||
`src/ClinicService/Service/ResourceServiceResolver.php` — الگوی **Chain of Responsibility**،
|
||||
چون سند صریحاً ترتیب اولویت تعریف کرده و افزودن سطح بعدی (مثلاً قرارداد بیمه) نباید
|
||||
`if` تازه در دل موتور بگذارد.
|
||||
|
||||
ترتیب (از خاص به عام) — سطح شعبه عمداً در زنجیره میماند چون دادهاش امروز وجود دارد:
|
||||
|
||||
```
|
||||
۱. منبع + گزینه → ResourceServiceOffering(resource, optionItem)
|
||||
۲. منبع + سرویس → ResourceServiceOffering(resource, parentItem)
|
||||
۳. شعبه + آیتم → ServiceBranchOverride(item, address)
|
||||
۴. پیشفرض خودِ آیتم → ServiceItem::getSoloDurationMinutes() / getPriceRials()
|
||||
```
|
||||
|
||||
```php
|
||||
public function resolve(ClinicResource $resource, ServiceItem $item, DoctorAddress $address): ResolvedServiceSpec
|
||||
{
|
||||
// اولین سطحی که مقدارِ غیرnull دارد برنده است — مدت و قیمت **جدا** حل میشوند:
|
||||
// منبعی که فقط مدت را override کرده نباید قیمتش هم از همان سطح بیاید.
|
||||
}
|
||||
```
|
||||
|
||||
`ResolvedServiceSpec` باید بگوید هر مقدار از کدام سطح آمده (`durationSource`, `priceSource`) —
|
||||
بدون این، دیباگِ «چرا این عدد؟» در پنل غیرممکن است.
|
||||
|
||||
**نحوه تست:** `tests/ClinicService/ResourceServiceResolverTest.php` — چهار تست، هر سطح یکی، بهعلاوهٔ
|
||||
تستِ مرزی «مدت از سطح ۱ و قیمت از سطح ۳».
|
||||
|
||||
### ۳. فیلتر کاندیدها بر اساس سرویس
|
||||
|
||||
`ClinicResourceRepository::findEligible()` یک آرگومان اختیاری `?ServiceItem $service` بگیرد و
|
||||
وقتی داده شد، فقط منابعی برگردد که ردیف فعال در `resource_service_offerings` دارند.
|
||||
|
||||
**قاعدهٔ سازگاری عقبرو:** اگر برای آن سرویس **هیچ** ردیفی ثبت نشده باشد، فیلتر اعمال نشود
|
||||
(همان رفتار امروز). وگرنه هر محیطی که هنوز رابطهها را پر نکرده، یکشبه بدون وقت آزاد میشود.
|
||||
|
||||
`AppointmentPlanBuilder::planRequirements()` سرویس را به `eligibleFor()` پاس بدهد.
|
||||
|
||||
**نحوه تست:** دو منبع از یک نوع بساز، فقط یکی را به سرویس وصل کن، `POST /api/v1/appointment-availability`
|
||||
بزن و مطمئن شو `assignment` همیشه همان یک منبع است. سپس ردیف را غیرفعال کن → `slots` خالی با `reason`.
|
||||
|
||||
### ۴. منبع و snapshot روی نوبت
|
||||
|
||||
- `Appointment::$resource` (`ManyToOne`, **nullable**) + `Appointment::$serviceOptionItem` (nullable).
|
||||
nullable بودن اجباری است: ۷۲ نوبت موجود منبع ندارند و migration نباید آنها را بشکند.
|
||||
- `service_total_minutes` و `PriceSnapshot` از قبل هستند — فقط باید از خروجی resolver پر شوند،
|
||||
نه از `ServiceItem` مستقیم.
|
||||
- `Appointment::toArray()` باید `resource` (`uuid`, `name`, `type`) و `service_option` را برگرداند.
|
||||
|
||||
**نحوه تست:** `tests/Appointment/ResourceBookingTest.php` — رزرو با منبع، سپس تغییر قیمت سرویس و
|
||||
اطمینان از اینکه `price_snapshot` نوبت قبلی تکان نمیخورد (همان قاعدهٔ snapshot سند).
|
||||
|
||||
### ۵. رزرو با منبع در endpointها
|
||||
|
||||
- `POST /api/v1/appointment`: `resource_uuid` پذیرفته شود. اگر آمد، `doctor_uuid` اختیاری است و
|
||||
پزشک از `resource->subject()` استنتاج میشود (اگر منبع پزشک باشد). اگر منبع دستگاه باشد و
|
||||
نوبت پزشک ندارد، `Appointment.doctor` باید nullable شود — این تغییر schema است و migration جدا میخواهد.
|
||||
- `GET /api/v1/appointment-booking-services/{doctorUuid}` مکمل بگیرد:
|
||||
`GET /api/v1/resource/{uuid}/services` → سرویسهای آن منبع با مدت و قیمتِ **حلشده**.
|
||||
- `POST /api/v1/appointment` بدون هیچکدام → `422`.
|
||||
|
||||
**نحوه تست:** با `curl` و توکن بیمار روی دادهٔ `app:seed-scenarios`: یکبار با `resource_uuid` دستگاه،
|
||||
یکبار با `doctor_uuid` (مسیر قدیمی باید سالم بماند)، یکبار با منبعِ بیارتباط → `422`.
|
||||
|
||||
### ۶. پنل ادمین: تب «سرویسهای این منبع»
|
||||
|
||||
در `assets/admin/pages/ResourcesPage.tsx` (یا صفحهٔ جزئیات منبع) جدولی با ستونهای
|
||||
سرویس/گزینه · مدت · قیمت · فعال، با ویرایش inline. خالیگذاشتن مدت یا قیمت یعنی «ارث از سطح بالاتر» و
|
||||
باید مقدار مؤثر را بهصورت placeholder با برچسب منبعش نشان دهد (`از شعبه`، `پیشفرض سرویس`).
|
||||
|
||||
از `SearchableSelect` استفاده شود، نه `<select>` خام؛ فرم با React Hook Form + Zod؛ داده با TanStack Query.
|
||||
|
||||
**صفحهٔ تازه ساخته نشود** — این یک تب روی صفحهٔ منبع موجود است. جدول با `DataTable` و ستون وضعیت با
|
||||
`StatusBadge`؛ حذف رابطه با `ConfirmDialog`. طبق قاعدهٔ ۱، هیچ رنگ خام و هیچ کامپوننت موازی.
|
||||
|
||||
**نحوه تست:** `npx vitest run assets/admin/pages/ResourceServicesTab.test.tsx` — سه تست:
|
||||
نمایش مقدار مؤثر، ذخیرهٔ override، پاککردن override → بازگشت به ارث.
|
||||
سپس **بازبینی چشمی** در مرورگر: دارکمود، حالت فشرده و موبایل ۳۹۰px — هر سه بدون شکستگی و
|
||||
بدون اسکرول افقی. بدون این سه اسکرینشات، وظیفه تمامشده نیست.
|
||||
|
||||
### ۷. دستهبندی سراسری کلینیک، مشترک بین سرویس و منبع، با «شامل بودن»
|
||||
|
||||
سند مالک محصول: دستهبندی باید در سطح کلینیک تعریف شود و **هم سرویسها هم منابع** از همان
|
||||
استفاده کنند؛ و یک دسته میتواند شامل دستههای دیگر باشد («تمام بدن» شامل دست و پا و …).
|
||||
|
||||
**وضعیت امروز:** `CatalogCategory` از قبل سراسریِ محیط است (جفت `(entity_type, entity_id)` +
|
||||
`parent` + `MAX_DEPTH = 4`) و روی `ServiceItem::$catalogCategory` مینشیند. دو چیز کم است:
|
||||
|
||||
الف) `ClinicResource` هیچ فیلد دستهای ندارد، پس «این دستگاه برای دست و پا است» گفتنی نیست.
|
||||
|
||||
ب) **`parent` برای «شامل بودن» کافی نیست.** درخت تکوالدی است: «دست» نمیتواند همزمان زیر
|
||||
«تمام بدن» و زیر «اندام فوقانی» باشد، در حالی که در لیزر مجموعهها روی هم میافتند. پس
|
||||
containment یک **گراف جهتدار بدون دور** است، جدا از سلسلهمراتب نمایشی.
|
||||
|
||||
سه تغییر:
|
||||
|
||||
```php
|
||||
// ۱) عضویت چندگانهٔ منبع در دستهها — m2m، چون یک دستگاه چند ناحیه را پوشش میدهد
|
||||
#[ORM\Table(name: 'resource_catalog_categories')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_resource_category', columns: ['resource_id', 'category_id'])]
|
||||
|
||||
// ۲) یال «شامل بودن» بین دستهها — DAG، نه درخت
|
||||
#[ORM\Table(name: 'catalog_category_includes')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_category_include', columns: ['parent_category_id', 'child_category_id'])]
|
||||
class CatalogCategoryInclude
|
||||
{
|
||||
// «تمام بدن» → «دست» ، «تمام بدن» → «پا» …
|
||||
// parent === child ممنوع، و بستارِ گذرا نباید به خودش برگردد.
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// ۳) بستار گذرا: «تمام بدن» شامل «نیمتنهٔ پایین» و آن شامل «پا» ⇒ تمام بدن شامل پا
|
||||
final class CategoryClosureResolver
|
||||
{
|
||||
/** @return int[] شناسهٔ همهٔ دستههای زیرمجموعه، با پیمایش عمقی و محافظ دور */
|
||||
public function descendants(CatalogCategory $category): array { … }
|
||||
|
||||
public function overlaps(CatalogCategory $a, CatalogCategory $b): bool { … }
|
||||
}
|
||||
```
|
||||
|
||||
مصرفش در دو نقطه:
|
||||
|
||||
- **تعارض انتخاب:** `ServiceSelectionValidator` وقتی دو آیتم انتخابشده دستههایی دارند که یکی
|
||||
دیگری را شامل میشود → `422` با پیام «تمام بدن شامل دست است؛ هر دو با هم انتخاب نمیشوند».
|
||||
این جای رابطهٔ دستیِ `incompatible_with` را برای این حالت میگیرد — یک بار در دسته تعریف
|
||||
میشود، نه بهازای هر جفت آیتم.
|
||||
- **فیلتر منبع:** در `findEligible()` (وظیفهٔ ۳) اگر سرویس دسته دارد، منابعی که آن دسته یا یکی از
|
||||
اجدادش را پوشش میدهند مقدماند.
|
||||
|
||||
**حلقه ممنوع:** پیش از ذخیرهٔ یال، `descendants($child)` بررسی شود و اگر `$parent` در آن بود
|
||||
`422` برگردد. بدون این، `descendants()` تا سرریز استک میرود.
|
||||
|
||||
**نحوه تست:** `tests/ClinicService/CategoryClosureTest.php` —
|
||||
✅ «تمام بدن» → دست/پا ثبت شود و `descendants` هر دو را بدهد ·
|
||||
✅ زنجیرهٔ سهسطحی بستار گذرا را درست بدهد ·
|
||||
❌ یال دوری («دست شامل تمام بدن») → `422` ·
|
||||
⚠️ دستهای که هیچ یالی ندارد → آرایهٔ خالی، نه خطا.
|
||||
سپس با `curl`: انتخاب همزمان «لیزر تمام بدن» و «لیزر دست» در `POST /api/v1/appointment` → `422`.
|
||||
|
||||
### ۸. حذف زیرسیستمهای خارج از این مدل
|
||||
|
||||
**تصمیم مالک محصول (۱۴۰۵/۰۵/۱۰):** هر چیزی خارج از منبع/سرویس/گزینه از محصول حذف شود.
|
||||
ریسکش گفته شد (جریمهٔ لغو و پکیج معمولاً نیاز واقعی کلینیکاند) و مالک محصول تصمیم را تکرار کرد.
|
||||
|
||||
حذف کامل این پنج زیرسیستم — کد، جدول، endpoint، تست، صفحهٔ پنل، سند:
|
||||
|
||||
| دامنه | مسیر | حجم |
|
||||
|---|---|---|
|
||||
| موتور سیاست | `src/Policy/` | ۲۷ فایل · ۶ فایل تست |
|
||||
| پکیج و دفتر اعتبار | `src/Package/` | ۱۳ فایل · ۲ تست |
|
||||
| دورهٔ درمان | `src/Course/` | ۱۴ فایل · ۲ تست |
|
||||
| لغو/جریمه/لیست انتظار | `src/Cancellation/` + `src/Waitlist/` | ۱۹ فایل · ۲ تست |
|
||||
| رویداد و گزارش | `src/Report/` + `src/Shared/Event/` | ۳ فایل · ۳ تست |
|
||||
|
||||
صفحات پنل: `PolicyFormPage`, `PolicySimulationPage`, `PackagesPage`, `PatientPackageLedgerPage`,
|
||||
`CourseProtocolsPage`, `CancellationPolicyPage`, `ResourceUtilizationPage` (+ تستهایشان) و مسیرهایشان در `App.tsx`.
|
||||
|
||||
**قلابهایی که باید از کد باقیمانده کنده شوند** (اینجا کامپایل میشکند، پس ترتیب مهم است):
|
||||
|
||||
```
|
||||
src/Appointment/Plan/Service/AppointmentPlanBuilder.php → applyTimingPolicies() و applyResourcePolicies()
|
||||
src/Pricing/Service/PricingEngine.php → PricingPolicyEngine و PackageConsumptionService
|
||||
src/Appointment/Booking/Controller/BookingController.php → BookingPolicyGuard
|
||||
src/ClinicService/Service/ServiceSelectionValidator.php → SelectionPolicyEngine
|
||||
src/Appointment/Booking/Service/BookingService.php → PackageConsumptionService، CreditLedgerService، CourseSessionLinker
|
||||
src/Shared/Tenant/GlobalTables.php → ردیفهای همین دامنهها
|
||||
src/Shared/Command/BookingEngineSeeder.php → متدهای policies/packagesAndCourses/cancellationAndWaitlist
|
||||
```
|
||||
|
||||
migration جدا برای `DROP TABLE` جدولهای این دامنهها با `down()` واقعی.
|
||||
|
||||
**نحوه تست:** بعد از حذف: `ddev exec php bin/phpunit` کامل سبز ·
|
||||
`ddev exec php vendor/bin/phpstan analyse` روی همان baseline ۱۴ خطا ·
|
||||
`ddev exec npx tsc --noEmit` بدون خطا · `ddev exec php bin/console debug:router | grep -cE "policy|package|course|waitlist|cancellation"` → `0` ·
|
||||
`ddev exec php bin/console app:seed-scenarios --reset -n` بدون خطا.
|
||||
|
||||
### ۹. مستندات
|
||||
|
||||
- `docs/api/appointment.md`: فیلد `resource_uuid` در رزرو + پاسخ `resource`/`service_option`، و حذف
|
||||
بخشهای سیاست/پکیج/دوره/لغو.
|
||||
- `docs/api/clinic.md`: endpoint جدید `resource/{uuid}/services`.
|
||||
- `docs/architecture/`: سند مدل منبعمحور با همان چهار سطح زنجیره.
|
||||
- `docs/new_feture/taskes/`: چکلیست تسکهای ۹ تا ۱۴ با وضعیت «حذفشده به تصمیم مالک محصول» و تاریخ.
|
||||
- `TEST_USERS.md`: جدول «موتور نوبتدهی» باید سطرهای حذفشده را از دست بدهد.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **خط قرمز:** منطق نوبتدهی اسلاتی نباید تغییر کند. `ddev exec php bin/phpunit --group=slot-mode-frozen`
|
||||
باید در هر مرحله سبز بماند.
|
||||
- **چرا جدول `service_options` جدید نمیسازیم:** گزینه از قبل `ServiceItem` است و `ItemGroup`
|
||||
قواعد «حداقل یکی، حداکثر سهتا» را دارد. جدول سوم یعنی `PriceListItem`، `Tariff`،
|
||||
`appointment_service_items`، `SegmentTemplate` و کل مسیر `appointment-service-slots` باید دو نوع
|
||||
ورودی بشناسند — دو منبع حقیقت برای یک مفهوم.
|
||||
- **مدت و قیمت جدا حل میشوند.** منبعی که فقط مدت را override کرده نباید قیمتش هم از همان سطح بیاید.
|
||||
- **جفت محیط (`entity_type`,`entity_id`) از خود منبع مشتق شود**، نه از بدنهٔ درخواست — همان قاعدهای که
|
||||
`ClinicResource` و `ServiceItem` رعایت میکنند. `TenantSchemaCoverageTest` entity طبقهبندینشده را قرمز میکند.
|
||||
- **سازگاری داده:** `resource_id` و `service_option_item_id` روی نوبت nullable؛ فیلتر سرویس در
|
||||
`findEligible` فقط وقتی رابطهای ثبت شده باشد.
|
||||
- **cross-repo:** بعد از تغییر قرارداد، مصرف واقعی در `nobat724_front/services/response.js` و
|
||||
`nobat724_front/components/appointment/` دستی بررسی شود؛ سایت امروز فقط `doctor_uuid` میفرستد و
|
||||
با اختیاریشدنش نمیشکند، ولی برای رزرو دستگاه باید بهروز شود.
|
||||
- **ترتیب اجرا:** اول وظیفهٔ ۷ (حذف) یا اول ۱ تا ۶؟ حذف اول انجام شود — وگرنه resolver و
|
||||
`PlanBuilder` را دوبار مینویسی: یکبار با قلاب سیاست، یکبار بدون آن.
|
||||
@@ -0,0 +1,155 @@
|
||||
# چکلیست تسک ۱۵ — نوبتدهی بر پایهٔ منبع
|
||||
|
||||
پرامپت: [`.claude/prompt/resource-first-booking-model.md`](../../../../.claude/prompt/resource-first-booking-model.md)
|
||||
|
||||
وضعیتها: ✅ انجام شد · 🔄 در حال انجام · ⏳ در صف · ⛔ خارج از محدوده با دلیل · ⚠️ ناقص با دلیل
|
||||
|
||||
---
|
||||
|
||||
## ۰. قواعد غیرقابلمذاکره
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۰.۱ | هیچ تم/پالت/فونت/کتابخانهٔ CSS تازهای ساخته نشد | ⏳ | |
|
||||
| ۰.۲ | رنگها فقط از توکنهای `styles.css` — هیچ hex خام در کد جدید | ⏳ | |
|
||||
| ۰.۳ | کامپوننت از `components/ui/` — `<select>` خام صفر | ⏳ | |
|
||||
| ۰.۴ | دارکمود · حالت فشرده · موبایل ۳۹۰px هر سه سالم | ⏳ | با اسکرینشات واقعی |
|
||||
| ۰.۵ | `.card` با `card-pad` و برچسب با `.field-block` | ⏳ | |
|
||||
| ۰.۶ | دکمهٔ بازگشت در صفحات زیرمجموعه | ⏳ | |
|
||||
| ۰.۷ | وضعیت لیست در query string با `useUrlState` | ⏳ | |
|
||||
| ۰.۸ | هیچ صفحه/فیلد/endpointی خارج از سند اضافه نشد | ⏳ | |
|
||||
| ۰.۹ | هیچ interface/کلاس پایه بدون بیش از یک پیادهسازیِ فعلی | ⏳ | |
|
||||
| ۰.۱۰ | Controller نازک · منطق در Service · کوئری در Repository | ⏳ | |
|
||||
|
||||
## ۱. جدول رابطهٔ منبع↔سرویس
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۱.۱ | entity `ResourceServiceOffering` با `TenantOwnedTrait` | ⏳ | |
|
||||
| ۱.۲ | قید یکتای `(resource_id, service_item_id)` | ⏳ | |
|
||||
| ۱.۳ | ایندکس `(entity_type, entity_id, service_item_id)` | ⏳ | |
|
||||
| ۱.۴ | `durationMinutes` و `priceRials` تهیپذیر = ارث | ⏳ | |
|
||||
| ۱.۵ | migration ساخته و اجرا شد | ⏳ | |
|
||||
| ۱.۶ | تست: ثبت ردیف | ⏳ | |
|
||||
| ۱.۷ | تست: جفت تکراری → خطای یکتایی | ⏳ | |
|
||||
| ۱.۸ | تست: جفت محیط از منبع مشتق میشود نه از ورودی | ⏳ | |
|
||||
|
||||
## ۲. Resolver مدت و قیمت
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۲.۱ | `ResourceServiceResolver` با زنجیرهٔ چهارسطحی | ⏳ | |
|
||||
| ۲.۲ | `ResolvedServiceSpec` منبعِ هر مقدار را میگوید | ⏳ | |
|
||||
| ۲.۳ | مدت و قیمت **جدا** حل میشوند | ⏳ | |
|
||||
| ۲.۴ | تست سطح ۱ (منبع+گزینه) | ⏳ | |
|
||||
| ۲.۵ | تست سطح ۲ (منبع+سرویس) | ⏳ | |
|
||||
| ۲.۶ | تست سطح ۳ (شعبه) | ⏳ | |
|
||||
| ۲.۷ | تست سطح ۴ (پیشفرض آیتم) | ⏳ | |
|
||||
| ۲.۸ | تست مرزی: مدت از سطح ۱، قیمت از سطح ۳ | ⏳ | |
|
||||
|
||||
## ۳. فیلتر کاندیدها بر اساس سرویس
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۳.۱ | `findEligible()` آرگومان `?ServiceItem` گرفت | ⏳ | |
|
||||
| ۳.۲ | سازگاری عقبرو: بدون ردیف = بدون فیلتر | ⏳ | |
|
||||
| ۳.۳ | `AppointmentPlanBuilder` سرویس را پاس میدهد | ⏳ | |
|
||||
| ۳.۴ | تست: دو منبع، یکی وصل → assignment همان یکی | ⏳ | |
|
||||
| ۳.۵ | تست: غیرفعالکردن ردیف → slots خالی با reason | ⏳ | |
|
||||
|
||||
## ۴. منبع و snapshot روی نوبت
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۴.۱ | `Appointment::$resource` تهیپذیر | ⏳ | |
|
||||
| ۴.۲ | `Appointment::$serviceOptionItem` تهیپذیر | ⏳ | |
|
||||
| ۴.۳ | `service_total_minutes` از resolver پر میشود | ⏳ | |
|
||||
| ۴.۴ | `PriceSnapshot` از resolver پر میشود | ⏳ | |
|
||||
| ۴.۵ | `toArray()` منبع و گزینه را برمیگرداند | ⏳ | |
|
||||
| ۴.۶ | migration بدون شکستن نوبتهای موجود | ⏳ | |
|
||||
| ۴.۷ | تست: تغییر قیمت سرویس، snapshot قدیمی ثابت | ⏳ | |
|
||||
|
||||
## ۵. رزرو با منبع در endpointها
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۵.۱ | `POST /api/v1/appointment` فیلد `resource_uuid` میگیرد | ⏳ | |
|
||||
| ۵.۲ | با منبعِ پزشک، پزشک استنتاج میشود | ⏳ | |
|
||||
| ۵.۳ | `Appointment.doctor` تهیپذیر شد (منبع دستگاهی) | ⏳ | |
|
||||
| ۵.۴ | `GET /api/v1/resource/{uuid}/services` با مقادیر حلشده | ⏳ | |
|
||||
| ۵.۵ | curl: رزرو با `resource_uuid` → ۲۰۱ | ⏳ | |
|
||||
| ۵.۶ | curl: مسیر قدیمی `doctor_uuid` سالم ماند | ⏳ | |
|
||||
| ۵.۷ | curl: منبع بیارتباط → ۴۲۲ | ⏳ | |
|
||||
| ۵.۸ | curl: بدون هیچکدام → ۴۲۲ | ⏳ | |
|
||||
|
||||
## ۶. پنل ادمین — تب سرویسهای منبع
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۶.۱ | تب روی صفحهٔ منبع موجود، بدون صفحهٔ جدید | ⏳ | |
|
||||
| ۶.۲ | `DataTable` + `StatusBadge` + `ConfirmDialog` | ⏳ | |
|
||||
| ۶.۳ | مقدار مؤثر بهصورت placeholder با برچسب منبعش | ⏳ | |
|
||||
| ۶.۴ | فرم با React Hook Form + Zod، داده با TanStack Query | ⏳ | |
|
||||
| ۶.۵ | تست: نمایش مقدار مؤثر | ⏳ | |
|
||||
| ۶.۶ | تست: ذخیرهٔ override | ⏳ | |
|
||||
| ۶.۷ | تست: پاککردن override → بازگشت به ارث | ⏳ | |
|
||||
| ۶.۸ | اسکرینشات دارکمود | ⏳ | |
|
||||
| ۶.۹ | اسکرینشات حالت فشرده | ⏳ | |
|
||||
| ۶.۱۰ | اسکرینشات موبایل ۳۹۰px بدون اسکرول افقی | ⏳ | |
|
||||
|
||||
## ۷. دستهبندی سراسری با «شامل بودن»
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۷.۱ | `resource_catalog_categories` (m2m منبع↔دسته) | ⏳ | |
|
||||
| ۷.۲ | `catalog_category_includes` (یال DAG) | ⏳ | |
|
||||
| ۷.۳ | `CategoryClosureResolver::descendants()` با محافظ دور | ⏳ | |
|
||||
| ۷.۴ | تعارض انتخاب در `ServiceSelectionValidator` → ۴۲۲ فارسی | ⏳ | |
|
||||
| ۷.۵ | تقدم منابعِ پوششدهندهٔ دسته در `findEligible` | ⏳ | |
|
||||
| ۷.۶ | تست: «تمام بدن» → دست/پا | ⏳ | |
|
||||
| ۷.۷ | تست: بستار گذرا سهسطحی | ⏳ | |
|
||||
| ۷.۸ | تست: یال دوری → ۴۲۲ | ⏳ | |
|
||||
| ۷.۹ | تست: دستهٔ بییال → آرایهٔ خالی | ⏳ | |
|
||||
| ۷.۱۰ | curl: «تمام بدن + دست» با هم → ۴۲۲ | ⏳ | |
|
||||
|
||||
## ۸. حذف زیرسیستمهای خارج از مدل
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۸.۱ | `src/Policy/` حذف شد | ⏳ | |
|
||||
| ۸.۲ | `src/Package/` حذف شد | ⏳ | |
|
||||
| ۸.۳ | `src/Course/` حذف شد | ⏳ | |
|
||||
| ۸.۴ | `src/Cancellation/` + `src/Waitlist/` حذف شد | ⏳ | |
|
||||
| ۸.۵ | `src/Report/` + `src/Shared/Event/` حذف شد | ⏳ | |
|
||||
| ۸.۶ | قلابها از `AppointmentPlanBuilder` کنده شد | ⏳ | |
|
||||
| ۸.۷ | قلابها از `PricingEngine` کنده شد | ⏳ | |
|
||||
| ۸.۸ | قلابها از `BookingController` و `BookingService` کنده شد | ⏳ | |
|
||||
| ۸.۹ | قلابها از `ServiceSelectionValidator` کنده شد | ⏳ | |
|
||||
| ۸.۱۰ | `GlobalTables` و seeder پاکسازی شد | ⏳ | |
|
||||
| ۸.۱۱ | صفحات پنل و مسیرهای `App.tsx` حذف شد | ⏳ | |
|
||||
| ۸.۱۲ | migration `DROP TABLE` با `down()` واقعی | ⏳ | |
|
||||
| ۸.۱۳ | `debug:router` صفر مسیر حذفشده | ⏳ | |
|
||||
| ۸.۱۴ | تستهای دامنههای حذفشده پاک شد | ⏳ | |
|
||||
|
||||
## ۹. مستندات
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۹.۱ | `docs/api/appointment.md` — `resource_uuid` و پاسخ جدید | ⏳ | |
|
||||
| ۹.۲ | `docs/api/appointment.md` — بخشهای حذفشده پاک شد | ⏳ | |
|
||||
| ۹.۳ | `docs/api/clinic.md` — endpoint سرویسهای منبع | ⏳ | |
|
||||
| ۹.۴ | سند معماری مدل منبعمحور | ⏳ | |
|
||||
| ۹.۵ | چکلیست تسکهای ۹ تا ۱۴ با وضعیت «حذفشده» | ⏳ | |
|
||||
| ۹.۶ | `TEST_USERS.md` بهروز شد | ⏳ | |
|
||||
|
||||
## ۱۰. تأیید نهایی
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۱۰.۱ | `ddev exec php bin/phpunit` کامل سبز | ⏳ | |
|
||||
| ۱۰.۲ | `--group=slot-mode-frozen` سبز | ⏳ | خط قرمز |
|
||||
| ۱۰.۳ | `phpstan` روی baseline ۱۴ خطا | ⏳ | |
|
||||
| ۱۰.۴ | `npx tsc --noEmit` بدون خطا | ⏳ | |
|
||||
| ۱۰.۵ | `npx vitest run assets/admin` سبز | ⏳ | |
|
||||
| ۱۰.۶ | `app:seed-scenarios --reset -n` بدون خطا | ⏳ | |
|
||||
| ۱۰.۷ | کامیت + `graphify update` + کامیت گراف | ⏳ | |
|
||||
Reference in New Issue
Block a user