The doctor page listed where a doctor works but never when, so search engines had no working hours for any location. booking_locations now carries opening_hours per context, so each MedicalClinic in the Physician JSON-LD gets its own openingHoursSpecification, matched to the address by uuid. Verified against the rendered page: the clinic location emits five shifts with schema.org weekday URLs alongside the availableService entries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
356 lines
18 KiB
Markdown
356 lines
18 KiB
Markdown
# انتخاب محل نوبتدهی (مطب شخصی / کلینیک) در جریان رزرو
|
||
|
||
## پروژه
|
||
|
||
`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` قابل ساخت است.
|
||
|
||
**انجام شد:** `booking_locations` حالا فیلد `opening_hours` دارد (شیفتهای فعال هفته با نام
|
||
انگلیسی روز)، پس `openingHoursSpecification` مستقیماً از همان ساخته میشود و به endpoint
|
||
جدیدی نیاز نیست.
|
||
- هر صفحه باید `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` هر دو باید سبز باشند.
|