feat: implement service mode completion for nobat724_front

- Add task for completing service mode in clinicpro with detailed objectives and acceptance criteria.
- Create architecture documentation for task 00b, outlining involved components and necessary changes.
- Develop checklist for task 00b to ensure all requirements are met.
- Document implementation notes for task 00b, emphasizing API contract checks and design system adherence.
- Update task documentation for task 00b, specifying goals and current issues with service mode.
This commit is contained in:
hamed
2026-07-30 11:56:08 +03:30
parent 021d0eb6b2
commit 158dcb58aa
12 changed files with 1846 additions and 0 deletions
@@ -0,0 +1,204 @@
# نکات پیاده‌سازی — تسک ۰۰
## ۱. اول تست خط سرخ، بعد هر چیز دیگر
ترتیب کار:
```
۱. tests/Appointment/SlotModeFrozenTest.php + سه fixture ← اول این
۲. ddev exec php bin/phpunit --group=slot-mode-frozen ← باید سبز باشد قبل از هر تغییری
۳. بقیهٔ تسک
۴. دوباره گام ۲ — باید همچنان سبز باشد
```
اگر fixture را بعد از تغییرات بسازی، هیچ چیزی را تضمین نکرده‌ای — snapshot وضعیت
تغییریافته را گرفته‌ای.
## ۲. `isServiceMode` گِیت همه‌چیز است
هر خط کد جدید در مسیر مشترک باید داخل این شرط باشد:
```php
if ($this->serviceCalc->isServiceMode($doctor, $clinic)) {
// … منطق جدید
}
```
نه بیرونش، نه با `??`، نه با «اگر سرویس دارد». معیار **فقط** `booking_mode` است.
نوبت اسلاتی هم می‌تواند `service_item_id` داشته باشد (فیلدهای Figma نوبت‌ها) — آن دلیل
سرویسی بودن نیست.
اشتباه رایج:
```php
// ❌ نوبت اسلاتیِ دارای سرویس را وارد مسیر جدید می‌کند
if ($appointment->getServiceItems()->count() > 0) { }
```
## ۳. جمع سادهٔ مدت را همین‌جا اصلاح نکن
```php
$totalMinutes += $duration; // ← اشتباه است، ولی دست نزن
```
مستند بند ۵ می‌گوید این فرمول ظرفیت را الکی پر می‌کند و راه‌حلش «زمان تنها / زمان اضافه»
است — که تسک ۰۴ می‌سازد. اصلاحش اینجا یعنی:
- مدت همهٔ نوبت‌های چندسرویسیِ در حال رزرو یک‌شبه کم می‌شود
- سایت و اپ دسکتاپ عدد متفاوت می‌بینند بدون اینکه چیزی در build بشکند
- و هیچ داده‌ای برای «زمان اضافه» وجود ندارد، پس اصلاح بی‌ورودی غیرممکن است
کاری که این تسک می‌کند: محاسبه را به **یک نقطه** منتقل می‌کند تا تسک ۰۴ یک خط عوض کند.
## ۴. `excludeAppointmentId` — الگوی موجود را تکرار کن
`AppointmentRepository::isSlotTaken($doctor, $start, $end, $excludeId)` از قبل این پارامتر
را دارد. `findBusyIntervals` هم همان را بگیرد، با همان نام و همان جای پارامتر و همان
پیش‌فرض `null`.
دو الگوی متفاوت برای یک کار (مثلاً یکی `?int $excludeId`، دیگری `array $excludeIds`)
یعنی اولین کسی که هر دو را می‌بیند یکی را اشتباه صدا می‌زند.
## ۵. اعتبارسنجی زمان: عضویت در فهرست، نه «اشغال نبودن»
```php
// ❌ ناکافی
if ($this->appointmentRepo->isSlotTaken($doctor, $start, $end, $excludeId)) { /* 409 */ }
// ✅
$starts = $this->slotCalculator->getServiceStartTimes();
if (!in_array($req->start, array_column($starts, 'start'), true)) { /* 422 */ }
```
`isSlotTaken` فقط تداخل با نوبت دیگر را می‌گوید. `getServiceStartTimes` علاوه بر آن
شیفت، تعطیلی، `date_override`، پنجرهٔ رزرو و بافر را هم اعمال می‌کند. با شرط اول،
منشی می‌تواند نوبت را ساعت ۳ بامداد بگذارد.
## ۶. `warnings[]` به‌جای `422` برای سرویس غیرفعال در نوبت موجود
```php
$duration = $this->serviceCalc->calculate(, allowInactive: true);
// $duration->warnings === ['سرویس «لیزر صورت» دیگر برای نوبت‌دهی فعال نیست']
```
نوبت موجود با سرویسی که کلینیک غیرفعالش کرده، باید قابل جابه‌جایی و لغو بماند. `422`
یعنی آن نوبت برای همیشه قفل می‌شود و منشی هیچ کاری نمی‌تواند بکند.
ولی **افزودن** سرویس غیرفعال به نوبت → `422`. تفاوتش `allowInactive` است که فقط برای
uuid های موجودِ نوبت `true` می‌شود، نه برای uuid های تازه‌ی درخواست.
## ۷. `replaceServiceItems` باید `serviceItem` تکی را هم‌گام کند
```php
$this->serviceItem = $items[0] ?? null;
```
چهار مصرف‌کننده روی `service_item` تکی خوانده‌اند (`AppointmentsPage`،
`ReserveAppointmentsPage`، `nobat724_front/services/response.js`،
`clinic-pro-tauri/src/service/response.js`). این دقیقاً همان الگویی است که
`ServiceItem::setStaffMembers()` برای `staff` تکی دارد — تکرارش کن.
## ۸. `refreshActiveSlotKey` پس از `setIsReserve(false)`
```php
public function setIsReserve(bool $v): self
{
$this->isReserve = $v;
$this->refreshActiveSlotKey(); // ← اگر نیست، اضافه کن
$this->updatedAt = time();
return $this;
}
```
بدون آن، رزروِ تبدیل‌شده `active_slot_key = NULL` می‌ماند و دو نفر می‌توانند همان ساعت
را بگیرند. **این تنها تغییر مجاز در مکانیزم `active_slot_key` است** و فقط چون یک شرط
موجود را اعمال می‌کند، نه عوضش می‌کند. تست: `ConvertReserveSlotKeyTest`.
## ۹. تبدیل رزرو، اتمی
```php
$this->em->wrapInTransaction(function () use ($reserve, $req) {
$duration = ; // یا اسلات اسلاتی
$reserve->setIsReserve(false);
$reserve->reschedule($req->start, $end);
$reserve->replaceServiceItems($duration->serviceItems);
// UniqueConstraintViolationException روی active_slot_key → 409
});
```
`try/catch` روی `UniqueConstraintViolationException` و ترجمه به `409 ERR_SLOT_TAKEN`
همان چیزی که `SlotTakenException` موجود در `src/Appointment/Repository/` انجام می‌دهد.
از همان استفاده کن.
## ۱۰. `ReserveAppointmentsPage` → `DataTable`
صفحه امروز جدول خام با `<td style={td}>` دارد. چون در این تسک دستش می‌زنیم، همان‌جا به
`DataTable` مهاجرت کند: توکن inline خلاف [ui-conventions](../_shared/ui-conventions.md)
است و در دارک‌مود می‌شکند.
این «scope creep» نیست — قاعدهٔ پروژه است که صفحهٔ دست‌خورده باید با دیزاین‌سیستم بخواند.
## ۱۱. edge case ها
| حالت | رفتار درست |
|---|---|
| نوبت سرویسی بدون هیچ سرویس (داده قدیمی) | مدت موجود حفظ · `warnings[]` · **رد نمی‌شود** |
| `PATCH` فقط یادداشت روی نوبت سرویسی | بدون اعتبارسنجی مدت — مثل امروز |
| `service-reschedule` روی نوبت اسلاتی | `422 ERR_WRONG_BOOKING_MODE` |
| `service-reschedule` روی نوبت رزرو | `422` — مسیرش `convert-reserve` است |
| زمان فعلی نوبت با سرویس جدید | با `excludeAppointmentId` در فهرست می‌آید |
| بافر عوض شد بعد از ثبت | نوبت موجود سالم؛ فقط جابه‌جایی جدید بافر جدید می‌گیرد |
| سرویس محیط دیگر در `service_item_uuids[]` | `404``TenantOwnershipChecker` **پیش از** هر بررسی دیگر |
| `durations` override با مقدار ۰ یا منفی | نادیده گرفته شود (رفتار موجود `serviceSlots`) |
| نوبت گذشته | `service-reschedule``422` |
| دو درخواست جابه‌جایی هم‌زمان به یک زمان | `active_slot_key` → یکی `409` |
| نوبت در محیطی که وسط کار به اسلاتی برگشت | `booking_mode` قفل است پس رخ نمی‌دهد؛ ولی اگر داده دستی عوض شد → `422` روشن |
## ۱۲. تست
```
tests/Appointment/SlotModeFrozenTest.php ← ⭐ اول از همه
- قرارداد appointment-slots بیت‌به‌بیت
- قرارداد month-availability
- امضای متدهای عمومی SlotCalculatorService
tests/Appointment/ServiceBookingCalculatorTest.php
- جمع مدت چند سرویس (رفتار فعلی حفظ شود)
- override منشی
- سرویس بدون مدت → 422
- سرویس غیرفعال با allowInactive → warning نه خطا
- سرویس محیط دیگر → استثنا، بدون افشای وجود
tests/Appointment/ServiceRescheduleTest.php ← ⭐
- جابه‌جایی با همان سرویس‌ها → مدت یکسان
- حذف یک سرویس → مدت خودکار کم می‌شود، کلاینت عددی نفرستاده
- زمان بیرون getServiceStartTimes → 422
- زمان فعلی خود نوبت با excludeAppointmentId در فهرست است
- روی نوبت اسلاتی → 422 ERR_WRONG_BOOKING_MODE
tests/Appointment/PatchServiceDurationTest.php
- slot_end ناسازگار → 422 ERR_SERVICE_DURATION_MISMATCH با مدت درست در پیام
- service_item_uuids[] جایگزینی کامل می‌کند و serviceItem تکی هم‌گام می‌شود
- در حالت اسلاتی هیچ‌کدام از این‌ها اجرا نمی‌شود (رفتار امروز)
tests/Appointment/ConvertReserveTest.php
- رزرو سرویسی → نوبت زمان‌دار با مدت درست
- رزرو اسلاتی → مسیر اسلاتی، بدون تغییر
- active_slot_key بعد از تبدیل پر می‌شود
- دو تبدیل هم‌زمان → یکی 409
tests/Appointment/ServiceModeSectionDurationTest.php ← موجود، باید سبز بماند
tests/Appointment/BookingTenantTest.php ← موجود، باید سبز بماند
assets/admin/pages/AppointmentEditPage.test.tsx
- حالت اسلاتی: سه فیلد ساعت هست، ServiceSlotPicker نیست
- حالت سرویسی: ServiceSlotPicker هست، فیلد ساعت پنهان است
- تغییر سرویس‌ها، slot انتخابی را باطل می‌کند
```
## ۱۳. مستندات
`docs/api/appointment.md`:
- بخش «روش‌های نوبت‌دهی» با ماتریس «کدام endpoint در کدام حالت»
- دو endpoint جدید
- توسعهٔ `PATCH` و پارامتر `exclude_appointment_uuid`
- دو کد خطای جدید
`docs/api/appointment-settings.md`: یادآوری قفل بودن `booking_mode` پس از اولین ثبت.
و یک سند کوتاه `docs/architecture/booking-modes.md` که ماتریس کامل را ثبت کند —
تسک ۰۶ حالت سومی به همین ماتریس اضافه می‌کند.