docs(prompt): add booking-locations multi-context prompt

Companion to the clinicpro context-separation change. A doctor now has one
booking schedule per context (personal practice + one per clinic), and every
booking endpoint takes an optional clinic_uuid where omitting it means the
personal practice — not a wildcard.

This site sends no clinic_uuid anywhere, so today it shows no availability at
all for clinic-only doctors and silently books into the wrong location for
doctors who work in both. The prompt covers the new
/api/v1/appointment-booking-locations contract, threading the selected location
through the booking state, and the JSON-LD follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 13:38:02 +03:30
co-authored by Claude Opus 4.8
parent 6f9640cea6
commit effa264027
@@ -0,0 +1,355 @@
# انتخاب محل نوبت‌دهی (مطب شخصی / کلینیک) در جریان رزرو
## پروژه
`nobat724_front` — پرامپت همتای backend که **قبلاً اجرا شده** است:
`clinicpro/.claude/prompt/context-separation-clinic-vs-doctor-booking.md`
قرارداد API از سمت backend آماده است؛ این پرامپت فقط سمت مصرف‌کننده را می‌نویسد.
## زمینه
تا پیش از این، هر پزشک در کل سیستم **یک** برنامهٔ نوبت‌دهی داشت (`weekly_schedules` با
`UNIQUE(doctor_id)`). حالا برنامه per-context است: یک برنامهٔ مطب شخصی + یکی به ازای هر کلینیکی
که پزشک در آن عضو است. ستون `clinic_id` روی `weekly_schedules`، `date_overrides` و `holidays`
اضافه شده (`NULL` = مطب شخصی).
سرویس‌ها هم polymorphic‌اند و بین این دو محیط **هرگز** مشترک نمی‌شوند: سرویس‌های کلینیک فقط در
نوبت‌دهی کلینیک و سرویس‌های شخصی فقط در مطب شخصی. قیمت و مدت سرویس بین محل‌ها متفاوت است.
## مشکل / هدف
**این یک شکست خاموش است، نه یک قابلیت جدید.**
همهٔ endpointهای رزرو حالا پارامتر اختیاری `clinic_uuid` می‌گیرند و **نبودِ آن یعنی «مطب شخصی»**
نه «هر محلی که پیدا شد». سایت الان هیچ‌جا `clinic_uuid` نمی‌فرستد، پس:
- برای پزشکی که فقط در کلینیک کار می‌کند، سایت **هیچ نوبتی نشان نمی‌دهد** (برنامهٔ شخصی ندارد).
- برای پزشکی که هر دو را دارد، سایت فقط ظرفیت مطب شخصی را نشان می‌دهد و بیمار بدون اطلاع نوبت
را در محل اشتباه رزرو می‌کند.
- در حالت سرویسی، `POST /api/v1/appointment` با سرویسی که به آن محل تعلق ندارد `422` می‌گیرد.
هدف: بیمار **محل** را آگاهانه انتخاب کند و آن انتخاب تا لحظهٔ ثبت نوبت حمل شود.
## قرارداد API (آمادهٔ مصرف)
### endpoint جدید
```
GET /api/v1/appointment-booking-locations/{doctorUuid} (عمومی، بدون auth)
```
```json
{
"success": true,
"data": {
"doctor_uuid": "550e8400-…",
"booking_locations": [
{
"location_uuid": "0f0b…",
"type": "personal",
"title": "مطب شخصی",
"address": "یزد، خیابان …",
"clinic_uuid": null,
"booking_mode": "slot",
"buffer_minutes": 0,
"services": [],
"next_available_at": 1755000000
},
{
"location_uuid": "7c21…",
"type": "clinic",
"title": "کلینیک علی بهروزی",
"address": "یزد، بلوار …",
"clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09",
"booking_mode": "service",
"buffer_minutes": 10,
"services": [
{ "uuid": "…", "name": "ویزیت", "duration_minutes": 20, "price_rials": 500000,
"service_section": { "uuid": "…", "name": "عمومی" } }
],
"next_available_at": 1754900000
}
]
}
}
```
نکات قرارداد:
- آرایه **از قبل مرتب است** بر اساس `next_available_at` صعودی؛ محل‌های بدون ظرفیت آخر می‌آیند.
پس `booking_locations[0]` پیش‌فرضِ درست است — دوباره مرتب نکن.
- `next_available_at` می‌تواند `null` باشد (تا ۳۰ روز آینده ظرفیتی نیست).
- `location_uuid` می‌تواند `null` باشد (آن محیط هنوز آدرس ثبت‌شده ندارد) — برای انتخاب از
`clinic_uuid` استفاده کن، نه `location_uuid`.
- `booking_mode` **per-location** است: یک پزشک می‌تواند در مطب شخصی اسلاتی و در کلینیک سرویسی باشد.
- `services` فقط در حالت `service` پُر است.
- پاسخ double-nested است (`json.data.data`) مثل بقیهٔ endpointها.
### پارامتر جدید روی endpointهای موجود
| endpoint | محل پارامتر |
|---|---|
| `GET /api/v1/appointment-slots` | query `clinic_uuid` |
| `GET /api/v1/appointment-service-slots` | query `clinic_uuid` |
| `GET /api/v1/appointment-booking-services/{doctorUuid}` | query `clinic_uuid` |
| `GET /api/v1/appointment-settings/month-availability/{doctorUuid}` | query `clinic_uuid` |
| `POST /api/v1/appointment` | body `clinic_uuid` |
هر چهار `GET` مقدار `clinic_uuid` را در پاسخ echo می‌کنند تا کلاینت بفهمد کدام محیط جواب داده.
اگر پزشک عضو آن کلینیک نباشد → `404 ERR_VALIDATION_002` («محل نوبت‌دهی یافت نشد»).
سرویسِ متعلق به محیط دیگر در `POST``422 ERR_VALIDATION_001`
(«سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد»).
مستندات کامل: `clinicpro/docs/api/appointment.md` → بخش «Booking context (2026-07)».
## فایل‌های مرتبط
| فایل | نقش |
|---|---|
| `services/response.js:52-72` | همهٔ فراخوانی‌های رزرو |
| `components/appointment/index.js:45` | `AppointmentPage` — نگه‌دارندهٔ کل state رزرو |
| `components/appointment/Container.js:15-113` | مسیریابی مرحله‌ها (prop drilling) |
| `components/appointment/service/index.js:11` | `ServiceSelect` |
| `components/appointment/date/index.js` | wrapper مرحلهٔ تاریخ (`locateVisit` مرده) |
| `app/component/date/datePicker/index.js:24` | `DatePicker` — تقویم ماه + `getMonthAvailability` |
| `app/component/date/dateTime/index.js:11` | `DateTime` — اسلات‌ها + resolve آدرس |
| `app/component/date/dateTime/hours/index.js:17-26` | کارت «آدرس مطب:» |
| `components/appointment/detail/SubmitData.js:138-160` | payload نهایی `postAppointment` |
| `components/appointment/location/Address.js` | سایدبار آدرس‌ها (نمایشی) |
| `app/doctor/[slug]/page.js:107-157` | JSON-LD صفحهٔ پزشک |
| `components/doctor/appointmentList/index.js:33` | CTA ورود به رزرو |
| `lib/appointmentSlots.js` | `adaptSlots` / `adaptServiceSlots` |
## وضعیت فعلی
### ۱. هیچ فراخوانی‌ای محل را نمی‌فرستد
`services/response.js:52-72`:
```js
getAppointmentSlots: (doctor_uuid, date) =>
api.get(
`api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}`,
removeTokenHead
),
getMonthAvailability: (doctor_uuid, year, month) =>
api.get(`api/v1/appointment-settings/month-availability/${doctor_uuid}`, {
params: { year, month },
...removeTokenHead,
}),
getBookingServices: (doctor_uuid) =>
api.get(`api/v1/appointment-booking-services/${doctor_uuid}`, removeTokenHead),
getServiceSlots: (doctor_uuid, date, serviceItemUuids = []) =>
api.get(
`api/v1/appointment-service-slots?doctor_uuid=${doctor_uuid}&date=${date}` +
serviceItemUuids
.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`)
.join(""),
removeTokenHead
),
postAppointment: (data) => api.post(`api/v1/appointment`, data, { requireAuth: true }),
```
### ۲. آدرس فقط نمایشی است و از `doctor.address` می‌آید
`app/component/date/dateTime/index.js:33-35` — تنها جایی که آدرس یک اسلات حل می‌شود:
```js
const locationAddress = hour?.location_id
? doctor?.address?.find((a) => String(a.id) === String(hour.location_id))?.address ?? null
: null;
```
هیچ محلی قابل انتخاب نیست و هیچ id محلی در state رزرو ذخیره نمی‌شود.
### ۳. payload نهایی
`components/appointment/detail/SubmitData.js:138-158`:
```js
const appointmentPayload = {
doctor_uuid: doctor.uuid,
slot_start: selectedSlot.start,
slot_end: selectedSlot.end,
for_self: !isForAnother,
note: data?.patient_reason?.value || "",
patient_national_code: data?.national_code?.value || "",
patient_gender: data?.gender?.value?.id || data?.gender?.value || "",
city_id: cityId ?? null,
...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}),
...(isForAnother ? { patient_name, patient_mobile, patient_reason } : {}),
};
```
### ۴. state مرده
`components/appointment/date/index.js:15``locateVisit` با `doctor?.multiwork` گیت شده، مقدار
اولیه‌اش `true` است و هیچ فرزندی `false` نمی‌کند. عملاً کد مرده است. **حذفش کن** و جای آن انتخاب
واقعی محل بنشان.
### ۵. JSON-LD بدون ساعات کاری
`app/doctor/[slug]/page.js:107-157``workLocation` وجود دارد ولی هیچ
`openingHoursSpecification` و هیچ `availableService` ندارد.
## وظایف
### ۱. لایهٔ سرویس — `services/response.js`
یک helper بساز تا `clinic_uuid` تکرار نشود:
```js
const withClinic = (params, clinicUuid) =>
clinicUuid ? { ...params, clinic_uuid: clinicUuid } : params;
```
و امضاها را گسترش بده (پارامتر آخر، اختیاری، تا هیچ call site موجودی نشکند):
```js
getBookingLocations: (doctor_uuid) =>
api.get(`api/v1/appointment-booking-locations/${doctor_uuid}`, removeTokenHead),
getAppointmentSlots: (doctor_uuid, date, clinic_uuid = null) =>
api.get("api/v1/appointment-slots", {
params: withClinic({ doctor_uuid, date }, clinic_uuid),
...removeTokenHead,
}),
getMonthAvailability: (doctor_uuid, year, month, clinic_uuid = null) =>
api.get(`api/v1/appointment-settings/month-availability/${doctor_uuid}`, {
params: withClinic({ year, month }, clinic_uuid),
...removeTokenHead,
}),
getBookingServices: (doctor_uuid, clinic_uuid = null) =>
api.get(`api/v1/appointment-booking-services/${doctor_uuid}`, {
params: withClinic({}, clinic_uuid),
...removeTokenHead,
}),
getServiceSlots: (doctor_uuid, date, serviceItemUuids = [], clinic_uuid = null) =>
api.get("api/v1/appointment-service-slots", {
params: withClinic({ doctor_uuid, date, "service_item_uuids[]": serviceItemUuids }, clinic_uuid),
...removeTokenHead,
}),
```
**دقت:** `getAppointmentSlots` و `getServiceSlots` الان query را دستی می‌چسبانند. با انتقال به
`params`، سریال‌سازی آرایهٔ `service_item_uuids[]` توسط axios انجام می‌شود — بررسی کن خروجی همچنان
`?service_item_uuids[]=a&service_item_uuids[]=b` باشد (نه `service_item_uuids[0]=a`). اگر نبود،
`paramsSerializer` بده یا همان روش دستی را با افزودن `clinic_uuid` نگه دار.
### ۲. state انتخاب محل — `components/appointment/index.js`
`AppointmentPage` نگه‌دارندهٔ state است؛ محل هم همان‌جا بنشیند:
```js
const [bookingLocations, setBookingLocations] = useState([]);
const [selectedLocation, setSelectedLocation] = useState(null); // یک آیتم از booking_locations
```
هنگام mount، `getBookingLocations(doctor.uuid)` را صدا بزن. سپس:
- اگر `?clinic_uuid=` یا `?location=` در URL بود → همان را انتخاب کن.
- وگرنه `booking_locations[0]` (آرایه از قبل بر اساس زودترین نوبت آزاد مرتب است).
- اگر آرایه خالی بود → پیام «برای این پزشک نوبت‌دهی آنلاین فعال نیست» و ادامه نده.
**بحرانی:** `booking_mode` و `services` را از `selectedLocation` بخوان، نه از فراخوانی جدای
`getBookingServices`. `booking_locations` هر دو را از قبل دارد و یک درخواست کمتر می‌زند. `state`های
`bookingMode` و `bookingServices` موجود (`index.js:46-67`) باید از `selectedLocation` مشتق شوند:
```js
const bookingMode = selectedLocation?.booking_mode ?? "slot";
const bookingServices = selectedLocation?.services ?? [];
```
`getBookingServices` را فقط برای سازگاری در `response.js` نگه دار ولی از این جریان حذفش کن.
### ۳. تعویض محل باید state وابسته را پاک کند
وقتی کاربر محل را عوض می‌کند، این‌ها **باید** reset شوند وگرنه ترکیب نامعتبر ساخته می‌شود
(سرویس کلینیک A + اسلات کلینیک B → خطای ۴۲۲ در انتها):
```js
const changeLocation = (loc) => {
setSelectedLocation(loc);
setSelectedServiceUuids([]);
setSelectedSlot(null);
setSelectedDate(null);
setStep(loc.booking_mode === "service" ? SERVICE_STEP : DATE_STEP);
};
```
### ۴. UI انتخاب محل
اگر `booking_locations.length > 1`، یک مرحله/کارت انتخاب محل **قبل از** انتخاب سرویس و تاریخ
نشان بده. اگر فقط یک محل بود، مرحله را نشان نده و مستقیم انتخابش کن.
هر کارت: `title`، `address`، و برچسب زودترین نوبت. برای `next_available_at` از `jalali-moment`
استفاده کن (همان الگوی بقیهٔ پروژه) و اگر `null` بود «فعلاً نوبت خالی ندارد» با ظاهر غیرفعال.
`components/appointment/location/Address.js` (سایدبار نمایشی) باید محل انتخاب‌شده را برجسته کند.
`components/appointment/date/index.js``locateVisit` مرده را حذف کن.
**الزامی:** از کامپوننت‌ها، تم، فونت (Vazir) و MUI v5 + Tailwind موجود استفاده کن — طراحی جدید
نساز. RTL رعایت شود.
### ۵. حمل محل تا لحظهٔ ثبت
`app/component/date/datePicker/index.js` و `app/component/date/dateTime/index.js` باید
`clinicUuid` را prop بگیرند و به فراخوانی‌ها بدهند. `DatePicker` الان `doctorUuid` را از
`useParams().doctorId` می‌خواند (`index.js:25-26`) — `clinicUuid` را به‌صورت prop بده، نه از URL.
`components/appointment/detail/SubmitData.js:138`:
```js
const appointmentPayload = {
doctor_uuid: doctor.uuid,
clinic_uuid: selectedLocation?.clinic_uuid ?? null,
slot_start: selectedSlot.start,
...
};
```
آدرس نمایشی در `app/component/date/dateTime/index.js:33-35` را از `selectedLocation.address`
بگیر — نه از `doctor.address.find(...)`، چون `location_id` عددی است و در محیط کلینیک ممکن است در
`doctor.address` نباشد.
### ۶. لینک‌پذیری و SEO
- CTA در `components/doctor/appointmentList/index.js:33` باید محل را حمل کند وقتی صفحهٔ پزشک
محلی را برجسته کرده: `/appointment/${doctorSlug}?clinic_uuid=${clinicUuid}`.
- `app/appointment/[doctorId]/page.js` باید `searchParams` را بخواند (و `await` کند طبق App Router).
- `app/doctor/[slug]/page.js` — JSON-LD را با ساعات کاری هر محل غنی کن. `booking_locations` را
server-side بگیر (با `fetchReq` از `lib/req.js` و `revalidate` مشابه `getDoctorAddresses`) و برای
هر محل یک `MedicalClinic` با `openingHoursSpecification` بساز و به `workLocation` وصل کن.
در حالت سرویسی، `availableService` هم از `services` قابل ساخت است.
اگر endpoint ساعات هفتگی عمومی در دسترس نبود، این بند را محدود کن به افزودن
`availableService` و در PR ذکر کن که `openingHoursSpecification` نیاز به یک endpoint عمومی
جدید در `clinicpro` دارد.
- هر صفحه باید `generateMetadata` داشته باشد و `params` همیشه `await` شود.
### ۷. سازگاری و حالت‌های مرزی
- پزشکی که فقط مطب شخصی دارد → یک آیتم با `type: "personal"`؛ UI نباید تغییری حس شود.
- پزشکی که فقط کلینیک دارد → قبلاً هیچ نوبتی نشان داده نمی‌شد؛ حالا باید کار کند. **این را حتماً
دستی تست کن.**
- `booking_locations` خالی → پیام روشن، نه صفحهٔ خالی.
- `422` سرویسِ ناسازگار → پیام فارسی خطای backend را نشان بده (پیام آماده است).
## نکات مهم
- **این تغییر بدون به‌روزرسانی این سایت، ظرفیت واقعی پزشکان کلینیکی را از سایت حذف می‌کند.**
اولویت بالا.
- پاسخ‌ها double-nested هستند: `json?.data?.data ?? json?.data`.
- برای auth از `{ requireAuth: true }` استفاده کن (کوکی `access_token` → Bearer). endpointهای
اسلات عمومی‌اند و `removeTokenHead` می‌گیرند.
- slug پزشک = `uuid`.
- تاریخ‌ها شمسی (`jalali-moment`)، رشته‌های جدید فارسی.
- شهر از subdomain: server با `lib/getStateInfo.js`، client با `useProvince()`. `city_id` موجود در
payload را دست نزن.
- تست دستی با کلینیک `41e325c4-e825-4067-8438-5d828ecaee09` و «دکتر تست» (`09100652121`) که در
همان کلینیک سرویس bookable دارد.
- بعد از تغییرات: `npm run lint` و `npm run build` هر دو باید سبز باشند.