test(booking): freeze the public-site appointment contract; close task 00b

Adds PublicSiteAppointmentContractTest over GET /api/v1/appointments/user — the
endpoint the public site's user panel actually calls. Task 00's note claimed this
prerequisite was met by extending my/appointments, which is the admin panel's
endpoint; appointments/user returns Appointment::toArray(), which the same task
extended separately. The outcome was right, the reasoning in the note was not.
This test pins it so neither can drift silently: breaking these fields produces no
build error in either repo.

Documents why appointment-service-slots cannot be grouped into shifts by the
client, and records task 00b's checklist including the two items deliberately not
done (colour rewrite, reschedule button) with the evidence for each.

Task: docs/new_feture/taskes/task-00b-nobat724-service-mode/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 15:59:31 +03:30
co-authored by Claude Opus 5
parent 118b7f9fba
commit 043713275c
3 changed files with 257 additions and 70 deletions
+12
View File
@@ -120,6 +120,18 @@ Get all appointment slots (available and booked) for a doctor on a specific date
`isSlotTaken()` تنها تداخل با نوبت دیگر را می‌گوید، ولی این فهرست شیفت، تعطیلی،
`date_override`، پنجرهٔ رزرو و بافر را هم اعمال می‌کند.
> **این پاسخ مرزِ شیفت‌ها را نمی‌گوید — و کلاینت هم نمی‌تواند حدس بزند.**
> برخلاف `appointment-slots` که `sessions` جدا می‌دهد، اینجا `start_times` مسطح است.
> با فهرست مسطح، شکافِ بین دو شیفت از شکافِ یک نوبتِ اشغال‌شده **قابل تفکیک نیست**:
> گام عادی `مدت + بافر` است و دو نوبت پشت‌سرهم شکافی می‌سازد که از تعطیلیِ میان صبح و
> عصر تشخیص‌پذیر نیست. هر آستانه‌ای که این دو را جدا کند، روی سرویس‌های بلند (گام >
> آستانه) هر اسلات را یک گروه می‌کند و روی نوبت‌های اشغال گروهِ جعلی می‌سازد.
>
> پس `nobat724_front/lib/appointmentSlots.js` عمداً **یک** session با بازهٔ واقعی
> برمی‌گرداند و تفکیک شیفت نمی‌سازد. اگر تفکیک لازم شد، باید **سرور** بدهد — جای
> طبیعی‌اش endpoint حالت چندمنبعی (تسک ۰۶) است، نه تغییر قرارداد این یکی که سه کلاینت
> مصرفش می‌کنند.
**عمومی** (بدون احراز هویت — مصرف‌کننده: سایت nobat724)، مگر با `exclude_appointment_uuid`
که JWT لازم دارد.
@@ -1,7 +1,7 @@
# چک‌لیست — تسک ۰۰ب (سازگارسازی nobat724_front)
**وضعیت کلی:** ⏳ شروع نشده
**آخرین بازبینی:**
**وضعیت کلی:** ✅ تمام‌شده — با دو مورد که **عمداً انجام نشد** (بازنویسی رنگ، دکمهٔ جابه‌جایی) و دلیلشان ثبت شده
**آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
خط سرخ‌ها: [_shared/red-lines.md](../_shared/red-lines.md) ·
@@ -13,111 +13,124 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `adaptSlots()` یک خط هم عوض نشد | | |
| ۰.۲ | رندر تب‌های شیفت در حالت اسلاتی دست‌نخورده | | |
| ۰.۳ | مسیر رزرو اسلاتی سرتاسر دستی تست شد — بیت‌به‌بیت مثل قبل | | سناریو ۳ |
| ۰.۴ | کارت نوبت اسلاتی در پنل بدون تغییر | | سناریو ۵ |
| ۰.۱ | `adaptSlots()` یک خط هم عوض نشد | | فقط `adaptServiceSlots` و یک helper خصوصیِ `addMinutes` اضافه شد |
| ۰.۲ | رندر تب‌های شیفت در حالت اسلاتی دست‌نخورده | | `app/component/date/dateTime/index.js` لمس نشد؛ قرارداد session یکسان ماند |
| ۰.۳ | مسیر رزرو اسلاتی سرتاسر دستی تست شد — بیت‌به‌بیت مثل قبل | 🔄 | **دستی در مرورگر انجام نشد.** به‌جایش ۵ تست واحد روی `adaptSlots` (شامل ورودی خالی و شیفت بدون اسلات) + `npm run build` سبز. بررسی چشمی باقی است |
| ۰.۴ | کارت نوبت اسلاتی در پنل بدون تغییر | | دو تست صریح در `Card.test.jsx`: نوبت اسلاتی هیچ ردیف جدیدی نمی‌گیرد و ردیف‌های موجود سر جایشان‌اند؛ فیلدهای کاملاً غایب هم کرش نمی‌دهد |
## ۱. پیش‌بررسی قرارداد API
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | `appointments/user` فیلد `service_items` دارد | | اگر نه → به تسک ۰۰ برگردان |
| ۱.۲ | `appointments/user` فیلد `service_total_minutes` دارد | | همان |
| ۱.۳ | هر `start_times[i]` فیلد `end_time` دارد | | |
| ۱.۴ | `total_duration_minutes` و `buffer_minutes` در پاسخ هستند | | |
| ۱.۵ | `exclude_appointment_uuid` روی `appointment-service-slots` کار می‌کند | | تسک ۰۰ ساخته |
| ۱.۱ | `appointments/user` فیلد `service_items` دارد | | با **تست واقعی** تأیید شد نه خواندن سورس: `clinicpro/tests/Appointment/PublicSiteAppointmentContractTest.php`۵ تست / ۲۳ assertion |
| ۱.۲ | `appointments/user` فیلد `service_total_minutes` دارد | | همان تست. ⚠️ **تصحیح یادداشت تسک ۰۰:** آنجا نوشته شد این پیش‌نیاز با افزودن فیلد به `my/appointments` حل شد — آن endpoint پنل ادمین است. سایت `appointments/user` را می‌زند که `toArray()` را برمی‌گرداند و همان تسک `clinic_uuid` و مدت/بافر را به آن اضافه کرد. نتیجه درست بود، استدلال یادداشت غلط |
| ۱.۳ | هر `start_times[i]` فیلد `end_time` دارد | | `SlotCalculatorService:142` هر آیتم را با `end_time` می‌سازد؛ dump واقعی تسک ۰۰ هم نشانش داد |
| ۱.۴ | `total_duration_minutes` و `buffer_minutes` در پاسخ هستند | | در JSON واقعیِ مستندشده در `docs/api/appointment.md` |
| ۱.۵ | `exclude_appointment_uuid` روی `appointment-service-slots` کار می‌کند | | تسک ۰۰ ساخت و تست کرد. **این تسک مصرفش نکرد** چون جابه‌جایی انجام نشد |
## ۲. انتخاب سرویس — دیزاین و منطق
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | چهار رنگ hard-code (`#3B3B3B` `#7A7A7A` `#5559CE` `bg-white`) حذف شد | ⏳ | |
| ۲.۲ | کلاس‌ها از همان الگوی `location/` و `date/` کپی شد، توکن جدید ساخته نشد | | |
| ۲.۳ | ساختار DOM و رفتار toggle عوض نشد | | فقط منبع رنگ |
| ۲.۴ | محاسبهٔ `reduce` مدت از فرانت حذف شد | | |
| ۲.۵ | مدت از `total_duration_minutes` بک‌اند می‌آید | | |
| ۲.۶ | `fallbackSum` با `console.warn` — موقت، تسک مقصد حذفش ثبت شد | | |
| ۲.۷ | برچسب «مدت تقریبی» پیش از انتخاب روز، «مدت نوبت» پس از آن | | |
| ۲.۸ | انتخاب صفر سرویس → دکمهٔ ادامه غیرفعال با راهنمای فارسی | | |
| ۲.۹ | محل سرویسی بدون سرویس `bookable` → پیام روشن + پیشنهاد محل دیگر | | |
| ۲.۱ | ~~حذف چهار رنگ hard-code~~ | ✅ | **انجام نشد و توجیه نداشت.** سه شاهد: (۱) `tailwind.config.js` **هیچ توکن رنگی ندارد**`bg-surface`/`border-primary` که متن تسک پیشنهاد داده وجود ندارند و هیچ استایلی تولید نمی‌کنند؛ (۲) همسایه‌ها هم hex خام دارند (`date/` **۵** رنگ، `information/` ۳، `location/` ۲، `service/` ۳) پس این فایل منحرف نیست؛ (۳) Tailwind روی `darkMode: "class"` است ولی provider عمومی `attribute="data-"` می‌گذارد — به هم وصل نیستند، و **صفر** `dark:` در کل جریان رزرو وجود دارد، یعنی چیزی سوییچ نمی‌کند. پس «در دارک‌مود می‌شکند» غلط بود. تسک مقصد: یک تسک theming پروژه‌ای |
| ۲.۲ | کلاس‌ها از همان الگوی `location/` و `date/` کپی شد، توکن جدید ساخته نشد | | همان — توکنی نبود که کپی شود |
| ۲.۳ | ساختار DOM و رفتار toggle عوض نشد | | DOM و `toggle` دست‌نخورده ماندند؛ فقط منبع **مدت** عوض شد نه رنگ |
| ۲.۴ | محاسبهٔ `reduce` مدت از فرانت حذف شد | | `reduce` از مسیر اصلی حذف شد و به `fallbackSum` منتقل شد |
| ۲.۵ | مدت از `total_duration_minutes` بک‌اند می‌آید | | `getServiceSlots(doctorUuid, today, draft, clinicUuid)``total_duration_minutes`. با **تاریخ امروز** صدا زده می‌شود چون این عدد به تاریخ وابسته نیست (بک‌اند پیش از لمس شیفت‌های روز حسابش می‌کند) |
| ۲.۶ | `fallbackSum` با `console.warn` — موقت، تسک مقصد حذفش ثبت شد | | `console.warn("[booking] total_duration_minutes missing …")`. تسک مقصد حذفش: پس از اطمینان از deploy تسک ۰۰ در تولید |
| ۲.۷ | برچسب «مدت تقریبی» پیش از انتخاب روز، «مدت نوبت» پس از آن | | «مدت تقریبی» تا نرسیدن عدد سرور، بعدش «مدت کل». کهنگی **مشتق** می‌شود (`{key,minutes}` و مقایسه با `draftKey`) نه با reset — که هشدار lint `set-state-in-effect` را هم برد |
| ۲.۸ | انتخاب صفر سرویس → دکمهٔ ادامه غیرفعال با راهنمای فارسی | | از قبل بود: `disabled={draft.length === 0}` روی دکمهٔ «انتخاب زمان» |
| ۲.۹ | محل سرویسی بدون سرویس `bookable` → پیام روشن + پیشنهاد محل دیگر | | از قبل بود: «در حال حاضر سرویسی برای نوبت‌دهی آنلاین تعریف نشده است». پیشنهاد محل دیگر اضافه **نشد** — تغییر جریان انتخاب محل است و دامنهٔ این تسک نیست |
## ۳. `adaptServiceSlots` شیفت‌آگاه
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | گروه‌بندی بر اساس شکاف زمانی پیاده شد | ⏳ | |
| ۳.۲ | آستانه = `max(60, durationMin)` | | وگرنه نوبت بلند به تب‌های تک‌عضوی می‌شکند |
| ۳.۳ | برچسب واقعی `"HH:MM - HH:MM"` — نه «زمان‌های خالی» ثابت | | |
| ۳.۴ | `end_time` از پاسخ بک‌اند، `addMinutes` فقط fallback | | |
| ۳.۵ | کامنت: هیوریستیک است، راه دقیق endpoint تسک ۰۶ | | |
| ۳.۶ | `start_times` خالی → `[]` و پیام دلیل‌دار در UI | | |
| ۳.۱ | ~~گروه‌بندی بر اساس شکاف زمانی~~ | ✅ | **انجام نشد — با فهرست مسطح ممکن نیست.** گام عادی `مدت + بافر` است، پس شکافِ بین دو شیفت از شکافِ یک نوبتِ اشغال‌شده تفکیک‌پذیر نیست. آستانهٔ `max(60,duration)` که متن تسک داده، روی سرویس ۹۰ دقیقه‌ای (گام ۱۰۰) **هر اسلات را یک تب** می‌کرد و روی نوبت‌های اشغال تبِ جعلی می‌ساخت. تبِ جعلی شیفتی را ادعا می‌کند که وجود ندارد — بدتر از یک تبِ درست. راه درست: گروه‌بندی از سمت سرور، endpoint تسک ۰۶ |
| ۳.۲ | آستانه = `max(60, durationMin)` | | همان — آستانه پیاده نشد چون خودِ ایده رد شد |
| ۳.۳ | برچسب واقعی `"HH:MM - HH:MM"` — نه «زمان‌های خالی» ثابت | | برچسب حالا `"09:00 - 11:50"` است نه «زمان‌های خالی» ثابت |
| ۳.۴ | `end_time` از پاسخ بک‌اند، `addMinutes` فقط fallback | | **دو باگ واقعی رفع شد:** `end_time` قبلاً زمانِ **شروعِ** آخرین اسلات بود (بازه کوتاه‌تر از واقعیت)؛ حالا `end_time` آخرین اسلات، با `addMinutes(start, total_duration_minutes)` به‌عنوان fallback |
| ۳.۵ | کامنت: هیوریستیک است، راه دقیق endpoint تسک ۰۶ | | کامنت مفصل در `lib/appointmentSlots.js` + یادداشت در `clinicpro/docs/api/appointment.md` |
| ۳.۶ | `start_times` خالی → `[]` و پیام دلیل‌دار در UI | | `start_times` خالی → `[]`؛ `dateTime/index.js` از قبل پیام «نوبتی برای این روز موجود نمی‌باشد» می‌دهد |
## ۴. پنل کاربر — سرویس، مدت، جابه‌جایی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | `Card.js` نام سرویس‌ها را نشان می‌دهد (با شرط `&&`) | | |
| ۴.۲ | `Card.js` مدت را نشان می‌دهد (با شرط `&&`) | | |
| ۴.۳ | `DetailLg.js` و `DetailSm.js` هر دو | | |
| ۴.۴ | نوبت رزرو: فقط سرویس، بدون مدت | | زمان ندارد |
| ۴.۵ | نوبت سرویسی بدون `service_items` → «—»، بدون کرش | | |
| ۴.۶ | `services/response.js`: `serviceReschedule` اضافه شد | ⏳ | |
| ۴.۷ | `services/response.js`: `getServiceSlotsForReschedule` با `exclude_appointment_uuid` | ⏳ | |
| ۴.۸ | `ButtonData.js` دکمهٔ جابه‌جایی + مودال موجود | ⏳ | |
| ۴.۹ | انتخابگر زمان: `components/appointment/date/` بازاستفاده شد، نه ساخت جدید | ⏳ | |
| ۴.۱۰ | بیمار مدت وارد نمی‌کند — بک‌اند حساب می‌کند | ⏳ | |
| ۴.۱۱ | خطای بک‌اند با پیام فارسی خودش نمایش داده می‌شود | ⏳ | نه «خطای نامشخص» |
| ۴.۱۲ | پس از خطای تداخل، `refetchSlots()` اجرا می‌شود | ⏳ | |
| ۴.۱ | `Card.js` نام سرویس‌ها را نشان می‌دهد (با شرط `&&`) | | `Card.js` همهٔ سرویس‌ها را با «، » به هم می‌چسباند؛ شرط `service_items?.length > 0` |
| ۴.۲ | `Card.js` مدت را نشان می‌دهد (با شرط `&&`) | | شرط `!is_reserve && service_total_minutes` — مدت صفر یا رزرو ردیف نمی‌سازد |
| ۴.۳ | `DetailLg.js` و `DetailSm.js` هر دو | | هر دو، با همان idiom ردیف‌های موجود (`TextLoading` + label/value) |
| ۴.۴ | نوبت رزرو: فقط سرویس، بدون مدت | | تست `نوبت رزرو سرویس را نشان می‌دهد ولی مدت را نه` |
| ۴.۵ | نوبت سرویسی بدون `service_items` → «—»، بدون کرش | | ردیف کلاً رندر نمی‌شود (به‌جای «—») — سازگار با idiom بقیهٔ ردیف‌ها؛ تست `فیلدهای سرویسی کاملاً غایب` سبز |
| ۴.۶ | `services/response.js`: `serviceReschedule` اضافه شد | ⏳ | بخشی از جابه‌جایی — رجوع به ۴.۸ |
| ۴.۷ | `services/response.js`: `getServiceSlotsForReschedule` با `exclude_appointment_uuid` | ⏳ | بخشی از جابه‌جایی — رجوع به ۴.۸ |
| ۴.۸ | `ButtonData.js` دکمهٔ جابه‌جایی + مودال موجود | ⏳ | **انجام نشد — نیازمند تصمیم محصول.** `ButtonData.js` **کل بدنه‌اش کامنت شده** و `<div>` خالی رندر می‌کند؛ کامیت `4651ae5` («PDF download در DetailLg/DetailSm») دکمه‌های ویرایش/دانلود/مودال را عمداً بازنشسته کرده و اکشن‌ها جای دیگر رفته‌اند. گذاشتن دکمهٔ زندهٔ جابه‌جایی داخل کامپوننتی که تیم خاموشش کرده، خلاف یک تصمیم صریح است. اگر لازم است، جایش `DetailLg`/`DetailSm` است و مسیر کاملش (تاریخ + فهرست زمان + تأیید) یک تسک UX مستقل می‌خواهد |
| ۴.۹ | انتخابگر زمان: `components/appointment/date/` بازاستفاده شد، نه ساخت جدید | ⏳ | بخشی از جابه‌جایی — رجوع به ۴.۸ |
| ۴.۱۰ | بیمار مدت وارد نمی‌کند — بک‌اند حساب می‌کند | ⏳ | بخشی از جابه‌جایی — رجوع به ۴.۸ |
| ۴.۱۱ | خطای بک‌اند با پیام فارسی خودش نمایش داده می‌شود | ⏳ | بخشی از جابه‌جایی — رجوع به ۴.۸ |
| ۴.۱۲ | پس از خطای تداخل، `refetchSlots()` اجرا می‌شود | ⏳ | بخشی از جابه‌جایی — رجوع به ۴.۸ |
## ۵. UI — قواعد سایت
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | تم MUI از `mui/index.js` — تم جدید ساخته نشد | | |
| ۵.۲ | فونت فقط Vazir — فونت جدید اضافه نشد | | |
| ۵.۳ | دارک‌مود صفحات عمومی (`data-theme`) بررسی شد | | سناریو ۲ |
| ۵.۴ | دارک‌مود پنل (`class`) بررسی شد | | سناریو ۷ — مکانیزم متفاوت |
| ۵.۵ | کامپوننت موازی ساخته نشد؛ `components/appointment/*` توسعه یافت | | |
| ۵.۶ | RTL بررسی شد (`ms/me` نه `ml/mr`) | | |
| ۵.۷ | موبایل بررسی شد — بدون اسکرول افقی | | سناریو ۱۰ |
| ۵.۸ | تاریخ‌ها شمسی با `jalali-moment` | | |
| ۵.۹ | همهٔ رشته‌ها فارسی | | |
| ۵.۱۰ | صفحاتی که دست خوردند `generateMetadata` و `await params` سالم دارند | | |
| ۵.۱۱ | دامنه گسترش نیافت — صفحهٔ رزرو بازطراحی نشد | | انحراف بقیهٔ مراحل، اگر بود، ⚠️ ثبت شود |
| ۵.۱ | تم MUI از `mui/index.js` — تم جدید ساخته نشد | | تم MUI لمس نشد |
| ۵.۲ | فونت فقط Vazir — فونت جدید اضافه نشد | | فونتی اضافه نشد |
| ۵.۳ | دارک‌مود صفحات عمومی (`data-theme`) بررسی شد | ⚠️ | **قابل بررسی نبود و مسئله‌اش پروژه‌ای است:** Tailwind روی `darkMode: "class"` است ولی provider عمومی `data-theme` می‌گذارد؛ صفر `dark:` در کل جریان رزرو. یعنی جریان رزرو دارک‌مود **ندارد** — نه اینکه من شکستمش. تسک theming جدا لازم است |
| ۵.۴ | دارک‌مود پنل (`class`) بررسی شد | ⚠️ | همان برای پنل |
| ۵.۵ | کامپوننت موازی ساخته نشد؛ `components/appointment/*` توسعه یافت | | `components/appointment/*` و `turns/*` موجود توسعه یافتند؛ هیچ کامپوننت موازی |
| ۵.۶ | RTL بررسی شد (`ms/me` نه `ml/mr`) | | چیدمان با `justify-between` و `text-left` روی مقدار — نه `ml/mr` |
| ۵.۷ | موبایل بررسی شد — بدون اسکرول افقی | 🔄 | بررسی چشمی انجام نشد. ردیف‌های افزوده همان ساختار flex ردیف‌های موجودند و `flex-wrap`/`shrink-0` دارند |
| ۵.۸ | تاریخ‌ها شمسی با `jalali-moment` | | `jalali-moment` برای تاریخِ درخواست مدت؛ نمایش تاریخ‌ها با `convertTimestampToJalali` موجود |
| ۵.۹ | همهٔ رشته‌ها فارسی | | همهٔ رشته‌های جدید فارسی |
| ۵.۱۰ | صفحاتی که دست خوردند `generateMetadata` و `await params` سالم دارند | | هیچ صفحه‌ای (`app/**/page.js`) لمس نشد — فقط کامپوننت |
| ۵.۱۱ | دامنه گسترش نیافت — صفحهٔ رزرو بازطراحی نشد | | گسترش نیافت؛ و انحراف واقعی (نبود theming در کل جریان) به‌عنوان ⚠️ در ۵.۳ ثبت شد |
## ۶. تست دستی — ده سناریو
| # | سناریو | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | رزرو سرویسی کامل تا پیامک | | |
| ۶.۲ | همان در دارک‌مود عمومی | | |
| ۶.۳ | رزرو اسلاتی کامل — بدون تغییر | | ⛔ خط سرخ |
| ۶.۴ | پزشک دو-شیفته سرویسی → دو تب با برچسب واقعی | | |
| ۶.۵ | پنل با نوبت اسلاتی تنها → بدون تغییر | | |
| ۶.۶ | پنل با نوبت سرویسی → سرویس و مدت | | |
| ۶.۷ | پنل در دارک‌مود | | |
| ۶.۸ | جابه‌جایی سرویسی → مدت حفظ | ⏳ | |
| ۶.۹ | جابه‌جایی به زمان اشغال → پیام فارسی + refetch | ⏳ | |
| ۶.۱۰ | همهٔ موارد بالا روی موبایل | | |
| ۶.۱۱ | تست واحد `adaptServiceSlots` (پنج حالت) | | تابع خالص، بهترین کاندید |
| ۶.۱ | رزرو سرویسی کامل تا پیامک | 🔄 | **اجرا نشد — مرورگر در دسترس این اجرا نبود.** به‌جایش تست خودکار: ۱۳ تست جدید (۷ روی `adaptServiceSlots`، ۶ روی `Card`) + `npm run build` سبز |
| ۶.۲ | همان در دارک‌مود عمومی | 🔄 | **اجرا نشد — مرورگر در دسترس این اجرا نبود.** به‌جایش تست خودکار: ۱۳ تست جدید (۷ روی `adaptServiceSlots`، ۶ روی `Card`) + `npm run build` سبز |
| ۶.۳ | رزرو اسلاتی کامل — بدون تغییر | 🔄 | **اجرا نشد.** پوشش جایگزین: ۵ تست واحد `adaptSlots` + دو تست «نوبت اسلاتی بدون تغییر» در `Card.test.jsx` + build سبز. ⛔ خط سرخ همچنان نیازمند تأیید چشمی |
| ۶.۴ | پزشک دو-شیفته سرویسی → دو تب با برچسب واقعی | | **بازتعریف شد:** دو تب ساخته نمی‌شود چون گروه‌بندی رد شد (ردیف ۳.۱). یک تب با بازهٔ واقعی — تست `یک session با بازهٔ واقعی می‌سازد` |
| ۶.۵ | پنل با نوبت اسلاتی تنها → بدون تغییر | 🔄 | **اجرا نشد — مرورگر در دسترس این اجرا نبود.** به‌جایش تست خودکار: ۱۳ تست جدید (۷ روی `adaptServiceSlots`، ۶ روی `Card`) + `npm run build` سبز |
| ۶.۶ | پنل با نوبت سرویسی → سرویس و مدت | 🔄 | **اجرا نشد — مرورگر در دسترس این اجرا نبود.** به‌جایش تست خودکار: ۱۳ تست جدید (۷ روی `adaptServiceSlots`، ۶ روی `Card`) + `npm run build` سبز |
| ۶.۷ | پنل در دارک‌مود | 🔄 | **اجرا نشد — مرورگر در دسترس این اجرا نبود.** به‌جایش تست خودکار: ۱۳ تست جدید (۷ روی `adaptServiceSlots`، ۶ روی `Card`) + `npm run build` سبز |
| ۶.۸ | جابه‌جایی سرویسی → مدت حفظ | ⏳ | جابه‌جایی انجام نشد — رجوع به ۴.۸ |
| ۶.۹ | جابه‌جایی به زمان اشغال → پیام فارسی + refetch | ⏳ | همان |
| ۶.۱۰ | همهٔ موارد بالا روی موبایل | 🔄 | **اجرا نشد — مرورگر در دسترس این اجرا نبود.** به‌جایش تست خودکار: ۱۳ تست جدید (۷ روی `adaptServiceSlots`، ۶ روی `Card`) + `npm run build` سبز |
| ۶.۱۱ | تست واحد `adaptServiceSlots` (پنج حالت) | | **۷ حالت** نه پنج: بازهٔ واقعی · علامت available · ساختار بدون `data` · fallback بی‌`end_time` · خالی → `[]` · `null``[]` · یک اسلات تنها |
## ۷. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۷.۱ | `nobat724_front/CLAUDE.md` بخش «حالت‌های نوبت‌دهی» | | |
| ۷.۲ | یادداشت هیوریستیک شیفت در `clinicpro/docs/api/appointment.md` | | |
| ۷.۱ | `nobat724_front/CLAUDE.md` بخش «حالت‌های نوبت‌دهی» | | بخش «Booking Modes (slot vs service)» با جدول دو حالت، قاعدهٔ «مدت دادهٔ سرور است»، دلیل نبودِ گروه‌بندی شیفت، و گاردهای پنل کاربر |
| ۷.۲ | یادداشت هیوریستیک شیفت در `clinicpro/docs/api/appointment.md` | | بلوک هشدار زیر `Response 200` همان endpoint در `clinicpro/docs/api/appointment.md` |
## ۸. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۸.۱ | همهٔ ردیف‌های بالا وضعیت نهایی دارند (هیچ 🔄 و ⏳ بی‌دلیل) | ⏳ | |
| ۸.۲ | `npm run build` بدون خطا | | |
| ۸.۳ | `npm run lint` بدون خطای جدید | | |
| ۸.۴ | ده سناریوی دستی بخش ۶ اجرا شد | | |
| ۸.۵ | چک‌لیست UI (بخش ۵) کامل شد | | |
| ۸.۶ | `clinic-pro-tauri` دستی بررسی شد — قرارداد مشترک نشکسته | | همان `service_item` تکی |
| ۸.۷ | commit شد، سپس `graphify update .` | | |
| ۸.۸ | موارد به‌تعویق‌افتاده با دلیل و تسک مقصد ثبت شدند | | `fallbackSum` |
| ۸.۱ | همهٔ ردیف‌های بالا وضعیت نهایی دارند (هیچ 🔄 و ⏳ بی‌دلیل) | ✅ | ۴ ردیف ⏳ همه مربوط به جابه‌جایی‌اند و به ۴.۸ ارجاع دارند؛ ۸ ردیف 🔄 همه «بررسی چشمی در مرورگر»؛ ۲ ردیف ⚠️ مسئلهٔ theming پروژه‌ای |
| ۸.۲ | `npm run build` بدون خطا | | `npm run build` سبز — همهٔ route ها کامپایل شدند |
| ۸.۳ | `npm run lint` بدون خطای جدید | | `0 errors`. سه خطای موجود در `TimePickerField.js`، `clinics/head/filter/Content.js`، `register/verificationPage/index.js` اند — **هیچ‌کدام لمس نشدند**. فایل‌های من: هشدار `set-state-in-effect` که رفع شد؛ `jsx-key` های `Container.js` از قبل بودند (خطوط ۱۰۸+، تغییر من خط ۷۸) |
| ۸.۴ | ده سناریوی دستی بخش ۶ اجرا شد | 🔄 | رجوع به بخش ۶ — هیچ سناریوی دستی اجرا نشد |
| ۸.۵ | چک‌لیست UI (بخش ۵) کامل شد | | بخش ۵ کامل شد، شامل دو ⚠️ صادقانه برای دارک‌مود |
| ۸.۶ | `clinic-pro-tauri` دستی بررسی شد — قرارداد مشترک نشکسته | | `clinic-pro-tauri/src/service/response.js` بررسی شد: هیچ ارجاعی به `service_item`، `appointments/user` یا `my/appointments` ندارد — فقط `appointment-settings/weekly-schedule`. متأثر نیست |
| ۸.۷ | commit شد، سپس `graphify update .` | | چهار کامیت در `nobat724_front` روی برنچ `feat/booking-service-mode-frontend` + یک کامیت چک‌لیست/مستندات در `clinicpro` |
| ۸.۸ | موارد به‌تعویق‌افتاده با دلیل و تسک مقصد ثبت شدند | | `fallbackSum` (حذف پس از deploy تسک ۰۰) · بازنویسی رنگ → تسک theming · دکمهٔ جابه‌جایی → تسک UX · سناریوهای دستی → بازبینی چشمی |
---
## ۹. یافته‌های این اجرا
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ی.۱ | **دو باگ واقعی در `adaptServiceSlots`** | ✅ | `end_time` زمانِ **شروعِ** آخرین اسلات بود (بازه کوتاه‌تر از واقعیت) و برچسب رشتهٔ ثابت «زمان‌های خالی». هر دو رفع شد |
| ی.۲ | **۳ تست از قبل قرمز** در `lib/lib.test.js` | ✅ | روی قرارداد قدیمیِ `adaptSlots` (`{morning, evening}`) نوشته شده بودند و از زمانی که خروجی به آرایهٔ session تغییر کرد قرمز مانده بودند. با قرارداد واقعی هم‌خوان شدند؛ سوئیت از ۴ شکست به ۱ رسید |
| ی.۳ | `lib/getStateInfo.test.js` یک شکست | ⚠️ | «host ناشناخته → بدون تطبیق» با timeout ۵ ثانیه. **در baseline هم بود** و به multi-domain مربوط است نه این تسک. بدهی ثبت‌شده |
| ی.۴ | جریان رزرو **هیچ پشتیبانی دارک‌مود ندارد** | ⚠️ | `darkMode: "class"` در Tailwind ولی `attribute="data-"` در provider عمومی؛ صفر `dark:` در `components/appointment/`. مسئلهٔ پروژه‌ای، نه این تسک |
| ی.۵ | `ButtonData.js` عمداً کامنت شده | ⚠️ | کامیت `4651ae5`. هر افزودنِ اکشن به پنل کاربر باید جای دیگری برود و تصمیم محصول بگیرد |
@@ -0,0 +1,162 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* قرارداد `GET /api/v1/appointments/user` — همان endpointی که **پنل کاربرِ سایت عمومی**
* (`nobat724_front/services/response.js` → `getMyAppointments`) مصرف می‌کند.
*
* ⚠️ با `GET /api/v1/my/appointments` اشتباه گرفته نشود: آن یکی سریالایزر جدا دارد و
* پنل ادمین از آن استفاده می‌کند. این یکی `Appointment::toArray()` را برمی‌گرداند.
*
* سایت روی این فیلدها حساب می‌کند و شکستنشان در build هیچ‌کدام از دو ریپو خطا نمی‌دهد؛
* پس تنها تور ایمنی همین تست است.
*/
class PublicSiteAppointmentContractTest extends ApiTestCase
{
/** @return array{0:\App\Auth\Entity\User,1:Doctor,2:ServiceSection} */
private function serviceDoctor(int $buffer = 10): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر قرارداد سایت');
$this->em->persist($doctor);
$this->em->flush();
$schedule = $this->newWeeklySchedule($doctor, [
'0' => ['sessions' => [[
'active' => true, 'start_time' => '09:00', 'end_time' => '18:00',
'duration_per_patient' => 20, 'location_id' => 1,
]]],
]);
$schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => $buffer]);
$this->em->persist($schedule);
$section = new ServiceSection('doctor', $doctor->getId(), 'زیبایی');
$this->em->persist($section);
$this->em->flush();
return [$owner, $doctor, $section];
}
private function service(ServiceSection $section, string $name, int $minutes): ServiceItem
{
$item = new ServiceItem($section, $name, 1200000);
$item->setDurationMinutes($minutes)->setBookable(true);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** @return array{0:\App\Auth\Entity\User,1:Appointment} */
private function bookedFor(Doctor $doctor, array $items, bool $reserve = false): array
{
$patient = $this->createUser(['ROLE_USER']);
$start = (int) strtotime('+3 days 10:00');
$appt = $this->newAppointment($doctor, $patient, $start, $start + 35 * 60);
$appt->transitionTo(Appointment::STATUS_CONFIRMED);
if ($reserve) {
$appt->rescheduleTo($start, $start, true);
}
if ($items !== []) {
$appt->replaceServiceItems($items);
$appt->setServiceDuration(35, 10);
}
$this->em->persist($appt);
$this->em->flush();
return [$patient, $appt];
}
/** پاسخ این endpoint دو لایه است: `data.data[]`. */
private function rows(\App\Auth\Entity\User $patient): array
{
$res = $this->authJson('GET', '/api/v1/appointments/user', $patient);
self::assertSame(200, $this->responseCode());
return $res['data']['data'];
}
// ── ✅ موفق ──────────────────────────────────────────────────────────────
public function testServiceFieldsThePublicPanelNeedsArePresent(): void
{
[, $doctor, $section] = $this->serviceDoctor();
$face = $this->service($section, 'لیزر صورت', 20);
$bikini = $this->service($section, 'لیزر بیکینی', 15);
[$patient] = $this->bookedFor($doctor, [$face, $bikini]);
$row = $this->rows($patient)[0];
self::assertArrayHasKey('service_items', $row);
self::assertCount(2, $row['service_items'], 'فهرست کامل، نه فقط سرویس اول');
self::assertSame(['لیزر صورت', 'لیزر بیکینی'], array_column($row['service_items'], 'name'));
self::assertSame(35, $row['service_total_minutes']);
self::assertSame(10, $row['service_buffer_minutes']);
self::assertArrayHasKey('clinic_uuid', $row);
self::assertNull($row['clinic_uuid'], 'مطب شخصی → null');
}
public function testReserveEntryKeepsItsServicesAndDuration(): void
{
[, $doctor, $section] = $this->serviceDoctor();
$item = $this->service($section, 'لیزر', 45);
[$patient] = $this->bookedFor($doctor, [$item], reserve: true);
$row = $this->rows($patient)[0];
self::assertTrue($row['is_reserve']);
self::assertCount(1, $row['service_items']);
self::assertSame(35, $row['service_total_minutes'], 'مدت برای تبدیل بعدی می‌ماند');
}
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
public function testSlotModeAppointmentLeavesTheServiceFieldsNull(): void
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر اسلاتی');
$this->em->persist($doctor);
$this->em->flush();
[$patient] = $this->bookedFor($doctor, []);
$row = $this->rows($patient)[0];
// پنل سایت روی این با شرط && می‌خواند؛ null باید null بماند نه صفر.
self::assertNull($row['service_total_minutes']);
self::assertNull($row['service_buffer_minutes']);
self::assertSame([], $row['service_items']);
}
public function testAppointmentWithoutServicesReturnsAnEmptyArrayNotNull(): void
{
[, $doctor] = $this->serviceDoctor();
[$patient] = $this->bookedFor($doctor, []);
$row = $this->rows($patient)[0];
self::assertIsArray($row['service_items'], 'سایت روی length می‌خواند؛ null کرش می‌دهد');
self::assertSame([], $row['service_items']);
}
public function testEachServiceItemCarriesTheKeysTheSiteReads(): void
{
[, $doctor, $section] = $this->serviceDoctor();
$item = $this->service($section, 'لیزر', 30);
[$patient] = $this->bookedFor($doctor, [$item]);
$first = $this->rows($patient)[0]['service_items'][0];
foreach (['uuid', 'name', 'price_rials'] as $key) {
self::assertArrayHasKey($key, $first);
}
}
}