fix(booking): aggregate public booking state across all schedules
The public doctor payload built `active`/`free_turn`/`hours_of_work` from the personal schedule alone, so a doctor bookable only at a clinic was reported as "نوبتدهی غیرفعال". Aggregate over every schedule instead: any schedule with online booking on and an active day makes the doctor bookable, and the disabled label only appears when all of them are off. Three admin-panel fixes for the same class of bug: - AppointmentsPage took the selected doctor from `dbUuid`, which is the clinic's uuid inside a clinic context — the slots request 404'd. Use `doctorUuid`. - TurnsTimeline rendered any error or unknown empty_reason as "این روز شیفت کاری ندارد". Errors now surface as errors and unknown reasons get a neutral message; the day-off wording is reserved for an explicit day_off from the backend. - Admins have no clinic context, so slots fell back to the personal schedule. They now pick a location from `appointment-booking-locations` and that choice drives the slot, service and create-appointment requests. Adds `app:schedule:normalize-format` for legacy rows stored as a bare JSON list covering only Saturday, which read as day-off for the rest of the week. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
# وضعیت نوبتدهی عمومی از همهٔ برنامهها + context درست اسلاتها در پنل
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend + پنل ادمین React)
|
||||
|
||||
پرامپت همتا در سایت عمومی: `nobat724_front/.claude/prompt/doctor-profile-booking-state-from-locations.md`
|
||||
(**backend اول اجرا شود** — فیلدهای `active` / `free_turn` پاسخ عمومی تغییر میکنند.)
|
||||
|
||||
## زمینه — نتیجهٔ عیبیابی واقعی (curl + دیتابیس)
|
||||
|
||||
سه علامت گزارششده دوباره بررسی شد. **موتور اسلات سالم است** — هر سه علامت از این است که
|
||||
«چه کسی، با چه contextی میپرسد». شواهد:
|
||||
|
||||
```
|
||||
# دادهٔ دیتابیس — دکتر تست (doctor_id=3341, uuid bcabb3a8-…)، کلینیک 1003 (41e325c4-…):
|
||||
weekly_schedules:
|
||||
2505 clinic_id=NULL setting=[{"sessions":[{"active":false,…}]}] ← شخصی، غیرفعال، فرمت لیستِ legacy
|
||||
2509 clinic_id=1003 روزهای 0..4 فعال 09:00–13:00، location_id=2631، meta.online_booking_enabled=true
|
||||
|
||||
doctor_addresses: 2631 → type=clinic, clinic_id=1003 ✓
|
||||
|
||||
# تست مستقیم API (1405/04/27 = 2026-07-18):
|
||||
GET /api/v1/appointment-slots?doctor_uuid=bcabb3a8…&date=2026-07-18&clinic_uuid=41e325c4…
|
||||
→ sessions پر ✓
|
||||
GET /api/v1/appointment-slots?doctor_uuid=bcabb3a8…&date=2026-07-18 (بدون clinic_uuid)
|
||||
→ sessions=[] , empty_reason="day_off" ← برنامهٔ شخصیِ 2505 خوانده میشود
|
||||
GET /api/v1/appointment-booking-locations/bcabb3a8…
|
||||
→ یک محل کلینیکی معتبر با opening_hours و next_available_at ✓
|
||||
GET /api/v1/appointment-slots?doctor_uuid=<CLINIC-uuid>&…
|
||||
→ 404 «دکتر یافت نشد»
|
||||
```
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
### علامت ۱ — سایت عمومی: «نوبتدهی غیرفعال است» برای پزشکی که نوبتدهی فعال دارد
|
||||
|
||||
`GET /api/v1/doctor/{uuid}` فیلدهای `active` / `free_turn` / `hours_of_work` را از
|
||||
`scheduleRepo->findByDoctor($doctor)` میسازد که **فقط برنامهٔ شخصی** (`clinic_id IS NULL`)
|
||||
است. دکتر تست برنامهٔ شخصیِ غیرفعال دارد و برنامهٔ کلینیکش دیده نمیشود →
|
||||
`active=false` → سایت «نوبتدهی غیرفعال است» نشان میدهد.
|
||||
|
||||
### علامت ۲ — پنل با کاربر ادمین: «این روز شیفت کاری ندارد»
|
||||
|
||||
`useClinicContext()` برای `primaryRole === 'admin'` مقدار `null` برمیگرداند (ادمین context
|
||||
کلینیکی ندارد) → اسلاتها بدون `clinic_uuid` گرفته میشوند → برنامهٔ شخصیِ 2505 → `day_off`.
|
||||
|
||||
### علامت ۳ — پزشک دعوتشده در محیط کلینیک: همان پیام
|
||||
|
||||
`AppointmentsPage.tsx:341` مقدار اولیهٔ پزشکِ انتخابشده را از `dbUuid` میگیرد؛ برای پزشک
|
||||
دعوتشده در محیط کلینیک، `dbUuid` **uuid کلینیک** است نه پزشک → درخواست
|
||||
`appointment-slots?doctor_uuid=<clinic-uuid>` → 404 «دکتر یافت نشد» → و چون `TurnsTimeline`
|
||||
هر حالت ناشناخته/خطا را به `day_off` ترجمه میکند، پیام «این روز شیفت کاری ندارد» دیده میشود.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Doctor/Entity/Doctor.php:417-432` | `computeScheduleFields(?WeeklySchedule)` — تکبرنامهای |
|
||||
| `src/Doctor/Entity/Doctor.php:512-533` | `toListArray` / `toDetailArray` مصرفکننده |
|
||||
| `src/Doctor/Controller/DoctorController.php:126,179,204,351` | `findByDoctor` (فقط شخصی) |
|
||||
| `src/Doctor/Controller/DoctorController.php:259-265` | `/api/v1/doctors` — map با overwrite دلبخواهی |
|
||||
| `src/Appointment/Repository/WeeklyScheduleRepository.php:40` | `findAllByDoctor` (همهٔ contextها) |
|
||||
| `src/Appointment/Service/SlotCalculatorService.php:182` | `findNextAvailableStart` per-context |
|
||||
| `assets/admin/pages/AppointmentsPage.tsx:341` | `selectedDoctorUuid` از `dbUuid` |
|
||||
| `assets/admin/pages/AppointmentsPage.tsx:425-433` | slots query (خودش درست است) |
|
||||
| `assets/admin/hooks/useClinicContext.ts` | برای admin مقدار null |
|
||||
| `assets/admin/components/appointments/TurnsTimeline.tsx:149-185` | fallback به `day_off` |
|
||||
| `assets/admin/stores/authStore.ts` | `doctorUuid` (از `context.doctor_uuid` پر میشود) |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### ۱. فیلدهای عمومی فقط از برنامهٔ شخصی
|
||||
|
||||
`src/Doctor/Controller/DoctorController.php:179` (GET عمومی) و `:351` (PATCH):
|
||||
|
||||
```php
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor); // @deprecated — فقط clinic IS NULL
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), [...
|
||||
```
|
||||
|
||||
`/api/v1/doctors` (`:259-265`) — `findByDoctors` **همهٔ** برنامهها (شخصی + کلینیک) را
|
||||
برمیگرداند و map با overwrite، برنامهٔ «آخری» را نگه میدارد — نتیجه دلبخواهی است:
|
||||
|
||||
```php
|
||||
$scheduleMap = [];
|
||||
foreach ($this->scheduleRepo->findByDoctors($result['items']) as $schedule) {
|
||||
$scheduleMap[$schedule->getDoctor()->getId()] = $schedule; // آخری برنده میشود
|
||||
}
|
||||
```
|
||||
|
||||
### ۲. انتخاب پزشک در پنل از dbUuid
|
||||
|
||||
`assets/admin/pages/AppointmentsPage.tsx:341`:
|
||||
|
||||
```tsx
|
||||
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');
|
||||
```
|
||||
|
||||
برای پزشک دعوتشده در محیط کلینیک (`context = {type:'clinic', role:'doctor', scope:'clinic'}`)،
|
||||
`primaryRole='doctor'` و `dbUuid` = uuid **کلینیک** است. `authStore.doctorUuid`
|
||||
(از `context.doctor_uuid`) uuid درستِ پزشک را دارد و استفاده نمیشود.
|
||||
|
||||
### ۳. TurnsTimeline خطا را «روز بدون شیفت» نشان میدهد
|
||||
|
||||
`assets/admin/components/appointments/TurnsTimeline.tsx:180`:
|
||||
|
||||
```tsx
|
||||
if (!slots.length) {
|
||||
const reason = EMPTY_REASON_TEXT[emptyReason ?? ''] ?? EMPTY_REASON_TEXT.day_off;
|
||||
```
|
||||
|
||||
پاسخ 404، خطای شبکه، یا هر `empty_reason` ناشناخته → همیشه «این روز شیفت کاری ندارد».
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. تجمیع وضعیت نوبتدهی عمومی از همهٔ برنامهها
|
||||
|
||||
`Doctor::computeScheduleFields` آرایهای از برنامهها بگیرد (امضای جدید:
|
||||
`computeScheduleFields(WeeklySchedule[] $schedules)`؛ null-tolerant برای سازگاری):
|
||||
|
||||
قواعد تجمیع:
|
||||
|
||||
- **`has_schedule` / `active`**: حداقل یک برنامه (در هر context) که هم روز فعال دارد و هم
|
||||
`meta.online_booking_enabled === true` → true. برنامهٔ شخصیِ خاموش نباید برنامهٔ کلینیکی
|
||||
روشن را بپوشاند.
|
||||
- **`free_turn`**: نزدیکترین روز/ساعت در بین **همهٔ** برنامههای فعال (همان حلقهٔ فعلی
|
||||
`computeScheduleParts`، اجراشده روی هر برنامه، سپس min بر اساس فاصلهٔ روز ایرانی).
|
||||
- **`hours_of_work`**: از همان برنامهای که `free_turn` را داد ساخته شود (ترکیب ساعتهای دو
|
||||
محل در یک رشته گمراهکننده است). اگر تصمیم دیگری گرفتی در PR توضیح بده.
|
||||
|
||||
سپس چهار call site در `DoctorController` (`:126`، `:179`، `:204`، `:351`) از
|
||||
`findAllByDoctor($doctor)` استفاده کنند و `/api/v1/doctors` (`:259-265`) map را به
|
||||
`array<doctorId, WeeklySchedule[]>` تبدیل کند (`findByDoctors` از قبل همه را میآورد —
|
||||
فقط دیگر overwrite نکن).
|
||||
|
||||
**نکته:** `APPOINTMENT_DISABLED_LABEL` وقتی برگردد که **همهٔ** برنامهها
|
||||
`online_booking_enabled=false` باشند، نه فقط اولین برنامه (`Doctor.php:423`).
|
||||
|
||||
### ۲. uuid درست پزشک در AppointmentsPage
|
||||
|
||||
```tsx
|
||||
const doctorUuid = useAuthStore(s => s.doctorUuid); // از context.doctor_uuid
|
||||
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(
|
||||
isDoctor ? (doctorUuid ?? '') : ''
|
||||
);
|
||||
```
|
||||
|
||||
`dbUuid` فقط وقتی uuid پزشک است که `context.type === 'doctor'`؛ به آن اتکا نکن. بررسی کن
|
||||
`NewAppointmentDrawer` و بقیهٔ مصرفکنندههای `selectedDoctorUuid` هم از همین مقدار
|
||||
تغذیه میشوند (prop میگیرند، پس با همین فیکس درست میشوند).
|
||||
|
||||
### ۳. TurnsTimeline: خطا ≠ روز بدون شیفت
|
||||
|
||||
- `AppointmentsPage` باید `slotsQuery.isError` و پیام خطای API (`errors[0].message`) را به
|
||||
`TurnsTimeline` بدهد (prop جدید `errorMessage?: string | null`).
|
||||
- در `TurnsTimeline`: اول خطا (`errorMessage` → همان پیام + ظاهر خطا)، بعد
|
||||
`EMPTY_REASON_TEXT[emptyReason]`، و برای reason ناشناخته/غایب یک پیام خنثی:
|
||||
«برنامهٔ این روز در دسترس نیست» — **هرگز** پیشفرض `day_off` نگذار؛ آن پیام یعنی
|
||||
«backend صریحاً گفت این روز شیفت ندارد».
|
||||
- دقت: پاسخ خطای API با `success:false` میآید؛ `lib/api.ts` را ببین که آیا آن را throw
|
||||
میکند یا resolve — مسیر درست را بر همان اساس بنویس.
|
||||
|
||||
### ۴. انتخاب محل برای ادمین (و هر بینندهای بدون context کلینیک)
|
||||
|
||||
ادمین context کلینیکی ندارد و نباید هم `useClinicContext` برایش چیزی جعل کند. راه درست:
|
||||
همان منبع سایت عمومی — `GET /api/v1/appointment-booking-locations/{doctorUuid}`:
|
||||
|
||||
- در `AppointmentsPage`، وقتی `isAdmin` و پزشکی انتخاب شده، این endpoint را بگیر
|
||||
(query key شامل `selectedDoctorUuid`).
|
||||
- اگر بیش از یک محل بود، یک `SearchableSelect` (قانون پروژه — نه `<select>` بومی) برای
|
||||
انتخاب محل نمایش بده؛ پیشفرض = اولین آیتم (آرایه بر اساس `next_available_at` مرتب است).
|
||||
- `clinic_uuid` مؤثر برای slots/service-slots/booking-services در حالت ادمین از محل
|
||||
انتخابشده بیاید (`selected.clinic_uuid`، که برای مطب شخصی `null` است)، نه از
|
||||
`useClinicContext`. برای نقشهای clinic/doctor رفتار فعلی `useClinicContext` بماند.
|
||||
- endpoint عمومی است؛ نیازی به endpoint جدید نیست (قانون «اول توسعه، بعد ساخت»).
|
||||
|
||||
### ۵. عادیسازی فرمت legacy برنامهٔ شخصی
|
||||
|
||||
ردیف 2505 فرمت لیست دارد: `[{"sessions":[…]}]` — فقط ایندکس 0 (شنبه) تعریف است و `meta`
|
||||
ندارد. کد فعلی خطا نمیدهد ولی روزهای 1..6 برایش `null` است و meta از `DEFAULT_META` میآید.
|
||||
به console command موجود `app:schedule:audit-locations` (یا command جدید
|
||||
`app:schedule:normalize-format` اگر تفکیک مسئولیت تمیزتر است) حالت زیر را اضافه کن:
|
||||
|
||||
- شناسایی ردیفهایی که `setting` آنها آرایهٔ لیستی است یا کلیدهای `"0".."6"` کامل نیست.
|
||||
- با `--fix` به فرمت canonical (`{"0":…,"6":…,"meta":…}`) تبدیل کند؛ روزهای غایب
|
||||
`{"sessions":[]}` و meta غایب = `DEFAULT_META`. دادهٔ session موجود دست نخورد.
|
||||
|
||||
### ۶. تست و مستندات
|
||||
|
||||
تستها در `tests/Doctor/` و `tests/Appointment/`:
|
||||
|
||||
1. پزشک با برنامهٔ شخصی غیرفعال + برنامهٔ کلینیکی فعال → `GET /api/v1/doctor/{uuid}` باید
|
||||
`active=true` و `free_turn` غیرتهی بدهد. (بازتولید مستقیم علامت ۱)
|
||||
2. پزشک با هر دو برنامه، هر دو `online_booking_enabled=false` → `free_turn` =
|
||||
`APPOINTMENT_DISABLED_LABEL`.
|
||||
3. `/api/v1/doctors`: پزشک چندبرنامهای — نتیجه مستقل از ترتیب ردیفهای `findByDoctors`.
|
||||
4. (frontend) `TurnsTimeline` با `errorMessage` → پیام خطا؛ با `emptyReason` ناشناخته →
|
||||
پیام خنثی، نه day_off. (vitest موجود در `assets/admin`)
|
||||
|
||||
مستندات: `docs/api/doctor.md` — معنای جدید `active` / `free_turn` (تجمیع همهٔ محلها)
|
||||
صریح ثبت شود؛ قانون ثابت پروژه.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **هیچ منطق موازی اسلات نساز** — `SlotCalculatorService` سالم است (با curl تأیید شد)؛
|
||||
مشکل فقط انتخاب schedule/context در ورودیهاست.
|
||||
- `findByDoctor` از قبل `@deprecated` است؛ این تسک چهار مصرفکنندهٔ باقیمانده در
|
||||
`DoctorController` را حذف میکند — بعدش اگر مصرفکنندهٔ دیگری نماند، خود متد را حذف کن.
|
||||
- `free_turn` رشتهٔ فارسی نمایشی است (مثل «شنبه 09:00») — قراردادش را عوض نکن؛
|
||||
`nobat724_front` همین رشته را خام نمایش میدهد.
|
||||
- تجمیع باید ارزان بماند: `/api/v1/doctors` صفحهای ۱۰+ پزشک دارد؛ `findByDoctors` همین
|
||||
حالا همهٔ برنامهها را در یک کوئری میآورد — کوئری اضافه per-doctor نزن.
|
||||
- کاربران تست: ادمین `09390039833`، دکتر تست `09100652121`
|
||||
(uuid `bcabb3a8-cae3-45ec-876c-548f9c1e1569`)، مالک کلینیک `09024206041` (دو-نقشی)،
|
||||
کلینیک `41e325c4-e825-4067-8438-5d828ecaee09`. کد OTP در dev همیشه `12345`.
|
||||
- تست دستی پس از build (`yarn build`): سه سناریوی گزارششده —
|
||||
(۱) `/doctor/bcabb3a8…` در سایت، (۲) `/admin/appointments` با ادمین برای ۱۴۰۵/۰۴/۲۷،
|
||||
(۳) همان صفحه با دکتر تست در محیط کلینیک برای ۱۴۰۵/۰۴/۳۰.
|
||||
- پاسخها طبق `BaseController`؛ تاریخها timestamp صحیح؛ رشتههای جدید فارسی.
|
||||
@@ -18,15 +18,18 @@ export interface ServicePick { serviceUuids: string[]; durations: Record<string,
|
||||
* با اعمال همان override محاسبه میشوند. انتخاب را از طریق onSelect بالا میفرستد.
|
||||
*/
|
||||
export default function ServiceSlotPicker({
|
||||
doctorUuid, date, services, onSelect, editableDuration = true,
|
||||
doctorUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
|
||||
}: {
|
||||
doctorUuid: string;
|
||||
date: string;
|
||||
services: BookingService[];
|
||||
onSelect: (v: ServicePick) => void;
|
||||
editableDuration?: boolean;
|
||||
/** undefined = context محیط جاری؛ مقدار صریح (شامل null) = محل انتخابشده خارج از context */
|
||||
clinicUuidOverride?: string | null;
|
||||
}) {
|
||||
const clinicUuid = useClinicContext();
|
||||
const contextClinicUuid = useClinicContext();
|
||||
const clinicUuid = clinicUuidOverride === undefined ? contextClinicUuid : clinicUuidOverride;
|
||||
const [sectionUuid, setSectionUuid] = useState('');
|
||||
const [selected, setSelected] = useState<PickedService[]>([]);
|
||||
const [pickedSlot, setPickedSlot] = useState<ServiceSlot | null>(null);
|
||||
|
||||
@@ -40,13 +40,31 @@ describe('TurnsTimeline', () => {
|
||||
it('renders an empty slot as «افزودن نوبت» and fires onBook on click', () => {
|
||||
const onBook = vi.fn();
|
||||
renderWithProviders(<TurnsTimeline slots={[emptySlot]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={onBook} />);
|
||||
const add = screen.getByText('افزودن نوبت');
|
||||
const add = screen.getByText('افزودن نوبت سریع');
|
||||
fireEvent.click(add);
|
||||
expect(onBook).toHaveBeenCalledWith(emptySlot);
|
||||
});
|
||||
|
||||
it('shows the holiday/empty message when there are no slots', () => {
|
||||
renderWithProviders(<TurnsTimeline slots={[]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
|
||||
it('empty_reason=day_off → «این روز شیفت کاری ندارد»', () => {
|
||||
renderWithProviders(<TurnsTimeline slots={[]} emptyReason="day_off" loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
|
||||
expect(screen.getByText('این روز شیفت کاری ندارد')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('empty_reason=holiday → «این روز تعطیل است»', () => {
|
||||
renderWithProviders(<TurnsTimeline slots={[]} emptyReason="holiday" loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
|
||||
expect(screen.getByText('این روز تعطیل است')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('خطای API هرگز به «شیفت کاری ندارد» ترجمه نمیشود', () => {
|
||||
renderWithProviders(<TurnsTimeline slots={[]} errorMessage="دکتر یافت نشد" loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
|
||||
expect(screen.getByText('خطا در دریافت برنامهٔ این روز')).toBeInTheDocument();
|
||||
expect(screen.getByText('دکتر یافت نشد')).toBeInTheDocument();
|
||||
expect(screen.queryByText('این روز شیفت کاری ندارد')).toBeNull();
|
||||
});
|
||||
|
||||
it('دلیل ناشناخته/غایب → پیام خنثی، نه day_off', () => {
|
||||
renderWithProviders(<TurnsTimeline slots={[]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
|
||||
expect(screen.getByText('برنامهٔ این روز در دسترس نیست')).toBeInTheDocument();
|
||||
expect(screen.queryByText('این روز شیفت کاری ندارد')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,10 +154,11 @@ const EMPTY_REASON_TEXT: Record<string, { title: string; hint: string }> = {
|
||||
};
|
||||
|
||||
export default function TurnsTimeline({
|
||||
slots, loading, queryKey, onView, onBook, emptyReason,
|
||||
slots, loading, queryKey, onView, onBook, emptyReason, errorMessage,
|
||||
}: {
|
||||
slots: TimelineSlot[];
|
||||
emptyReason?: string | null;
|
||||
errorMessage?: string | null;
|
||||
loading: boolean;
|
||||
queryKey: unknown[];
|
||||
onView: (a: Appointment) => void;
|
||||
@@ -175,8 +176,19 @@ export default function TurnsTimeline({
|
||||
}, [activeIndex]);
|
||||
|
||||
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--danger)' }}>خطا در دریافت برنامهٔ این روز</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>{errorMessage}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!slots.length) {
|
||||
const reason = EMPTY_REASON_TEXT[emptyReason ?? ''] ?? EMPTY_REASON_TEXT.day_off;
|
||||
// «شیفت کاری ندارد» فقط وقتی که backend صریحاً day_off گفته باشد؛
|
||||
// دلیل ناشناخته/غایب نباید به تعطیلی تفسیر شود.
|
||||
const reason = EMPTY_REASON_TEXT[emptyReason ?? '']
|
||||
?? { title: 'برنامهٔ این روز در دسترس نیست', hint: 'اطلاعات برنامهٔ کاری برای این روز دریافت نشد' };
|
||||
return (
|
||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>{reason.title}</div>
|
||||
|
||||
@@ -20,9 +20,16 @@ interface BookingServicesData {
|
||||
/**
|
||||
* روش نوبتدهی و سرویسهای قابلانتخابِ یک پزشک — از endpoint عمومیِ
|
||||
* `appointment-booking-services`. برای سرویسمحور کردن فرمهای ثبت نوبت پنل.
|
||||
*
|
||||
* clinicUuidOverride: `undefined` یعنی context محیط جاری؛ مقدار صریح (شامل null =
|
||||
* مطب شخصی) وقتی که انتخاب محل خارج از context انجام شده (مثلاً ادمین).
|
||||
*/
|
||||
export function useDoctorBookingServices(doctorUuid: string | null | undefined) {
|
||||
const clinicUuid = useClinicContext();
|
||||
export function useDoctorBookingServices(
|
||||
doctorUuid: string | null | undefined,
|
||||
clinicUuidOverride?: string | null,
|
||||
) {
|
||||
const contextClinicUuid = useClinicContext();
|
||||
const clinicUuid = clinicUuidOverride === undefined ? contextClinicUuid : clinicUuidOverride;
|
||||
|
||||
const q = useQuery<ApiResponse<BookingServicesData>>({
|
||||
queryKey: ['booking-services', doctorUuid, clinicUuid],
|
||||
|
||||
@@ -15,10 +15,10 @@ const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1' } as any);
|
||||
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1', doctorUuid: 'doc1' } as any);
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 7, completed: 3, waiting: 2, cancelled: 1 } });
|
||||
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [] } });
|
||||
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
|
||||
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
@@ -64,7 +64,7 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
|
||||
if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: { data: [
|
||||
{ uuid: 'd1', name: 'دکتر محمدی' }, { uuid: 'd2', name: 'دکتر رضایی' },
|
||||
] } });
|
||||
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [] } });
|
||||
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
|
||||
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
|
||||
@@ -102,7 +102,7 @@ interface BookingSlot { start: number; end: number; start_time: string; end_time
|
||||
interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null }
|
||||
|
||||
export function NewAppointmentModal({
|
||||
slot, onClose, onSuccess, serviceMode = false, services = [], date,
|
||||
slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null,
|
||||
}: {
|
||||
slot: BookingSlot;
|
||||
onClose: () => void;
|
||||
@@ -110,6 +110,8 @@ export function NewAppointmentModal({
|
||||
serviceMode?: boolean;
|
||||
services?: import('../hooks/useDoctorBookingServices').BookingService[];
|
||||
date?: string;
|
||||
/** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت میدهد. */
|
||||
clinicUuid?: string | null;
|
||||
}) {
|
||||
const [mobile, setMobile] = useState('');
|
||||
const [lookup, setLookup] = useState<PatientLookup | null>(null);
|
||||
@@ -165,6 +167,7 @@ export function NewAppointmentModal({
|
||||
patient_mobile: mobile,
|
||||
patient_name: effectiveName,
|
||||
patient_national_code: effectiveNationalCode,
|
||||
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
|
||||
...(serviceMode ? { service_item_uuids: pick.serviceUuids } : {}),
|
||||
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
|
||||
}),
|
||||
@@ -218,6 +221,7 @@ export function NewAppointmentModal({
|
||||
date={date}
|
||||
services={services}
|
||||
onSelect={setPick}
|
||||
clinicUuidOverride={clinicUuid}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -328,6 +332,8 @@ export default function AppointmentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const dbUuid = useAuthStore(s => s.dbUuid);
|
||||
// در محیط کلینیک، dbUuid شناسهٔ کلینیک است نه پزشک؛ uuid پزشک فقط از doctorUuid میآید.
|
||||
const doctorUuid = useAuthStore(s => s.doctorUuid);
|
||||
const clinicUuid = useClinicContext();
|
||||
const isAdmin = primaryRole === 'admin';
|
||||
const isClinic = primaryRole === 'clinic';
|
||||
@@ -339,7 +345,7 @@ export default function AppointmentsPage() {
|
||||
// پس از ویرایش/ثبت، صفحه با ?date=... باز میشود تا همان روز نمایش داده شود.
|
||||
const [selectedDate, setSelectedDate] = useState(params.get('date') || today);
|
||||
const [viewMode, setViewMode] = useState<TurnsViewMode>('timeline');
|
||||
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');
|
||||
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor ? (doctorUuid ?? '') : '');
|
||||
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<AppointmentFilters>(EMPTY_FILTERS);
|
||||
@@ -421,20 +427,39 @@ export default function AppointmentsPage() {
|
||||
}
|
||||
}, [isClinic, selectedDoctorUuid, doctors]);
|
||||
|
||||
// ── محل نوبتدهی برای ادمین
|
||||
// ادمین context کلینیکی ندارد (useClinicContext → null)؛ بدون clinic_uuid فقط برنامهٔ
|
||||
// مطب شخصی خوانده میشود. محل از booking-locations همان پزشک انتخاب میشود.
|
||||
const adminLocationsQuery = useQuery<ApiResponse<any>>({
|
||||
queryKey: ['booking-locations', selectedDoctorUuid],
|
||||
queryFn: () => api.get(`/api/v1/appointment-booking-locations/${selectedDoctorUuid}`),
|
||||
enabled: isAdmin && !!selectedDoctorUuid,
|
||||
});
|
||||
const adminLocations: any[] = (adminLocationsQuery.data?.data as any)?.booking_locations ?? EMPTY_ARR;
|
||||
const [adminLocKey, setAdminLocKey] = useState<string | null>(null);
|
||||
useEffect(() => { setAdminLocKey(null); }, [selectedDoctorUuid]);
|
||||
const locKey = (l: any) => l.clinic_uuid ?? 'personal';
|
||||
// پیشفرض = اولین آیتم؛ backend بر اساس زودترین نوبت آزاد مرتب کرده است.
|
||||
const adminLocation = adminLocations.find(l => locKey(l) === adminLocKey) ?? adminLocations[0] ?? null;
|
||||
const effectiveClinicUuid: string | null = isAdmin ? (adminLocation?.clinic_uuid ?? null) : clinicUuid;
|
||||
|
||||
// ── Slots query (timeline)
|
||||
// clinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده میشود.
|
||||
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, clinicUuid];
|
||||
// effectiveClinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده میشود.
|
||||
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, effectiveClinicUuid];
|
||||
const slotsQuery = useQuery<ApiResponse<any>>({
|
||||
queryKey: slotsQueryKey,
|
||||
queryFn: () => api.get(
|
||||
`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}` +
|
||||
(clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
|
||||
(effectiveClinicUuid ? `&clinic_uuid=${encodeURIComponent(effectiveClinicUuid)}` : '')
|
||||
),
|
||||
enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
|
||||
});
|
||||
|
||||
// ── روش نوبتدهی پزشکِ انتخابشده (سرویسی/اسلاتی)
|
||||
const { bookingMode, services } = useDoctorBookingServices(selectedDoctorUuid);
|
||||
const { bookingMode, services } = useDoctorBookingServices(
|
||||
selectedDoctorUuid,
|
||||
isAdmin ? (adminLocation?.clinic_uuid ?? null) : undefined,
|
||||
);
|
||||
const serviceMode = bookingMode === 'service';
|
||||
|
||||
// بازهٔ کاری پزشک در این روز (برای هدرِ تایملاینِ سرویسی).
|
||||
@@ -564,6 +589,21 @@ export default function AppointmentsPage() {
|
||||
onChange={(v) => setFilters(f => ({ ...f, itemUuid: v }))}
|
||||
/>
|
||||
|
||||
{isAdmin && adminLocations.length > 1 && (
|
||||
<div style={{ minWidth: 220 }}>
|
||||
<SearchableSelect
|
||||
options={adminLocations.map((l: any) => ({
|
||||
value: locKey(l),
|
||||
label: l.type === 'personal' ? `مطب شخصی${l.title ? ` — ${l.title}` : ''}` : l.title,
|
||||
}))}
|
||||
value={adminLocation ? locKey(adminLocation) : null}
|
||||
onChange={v => setAdminLocKey(v ? String(v) : null)}
|
||||
placeholder="محل نوبتدهی..."
|
||||
height={44}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
@@ -647,6 +687,7 @@ export default function AppointmentsPage() {
|
||||
onView={openDetail}
|
||||
onBook={handleSlotClick}
|
||||
emptyReason={(slotsQuery.data?.data as any)?.empty_reason ?? null}
|
||||
errorMessage={slotsQuery.isError ? ((slotsQuery.error as Error)?.message || 'خطای نامشخص') : null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -663,6 +704,7 @@ export default function AppointmentsPage() {
|
||||
serviceMode={serviceMode}
|
||||
services={services}
|
||||
date={selectedDate}
|
||||
clinicUuid={effectiveClinicUuid}
|
||||
onClose={() => setBookingSlot(null)}
|
||||
onSuccess={() => {
|
||||
qc.invalidateQueries({ queryKey: apptQueryKey });
|
||||
|
||||
@@ -805,6 +805,12 @@ write time. Use `php bin/console app:schedule:audit-locations` to list the offen
|
||||
deactivates them (it never deletes — the hours are user data). If the schedule was in truth a
|
||||
clinic's, move it instead with `app:schedule:assign-clinic`.
|
||||
|
||||
A related legacy issue is the *shape* of the stored setting: very old rows are a bare JSON list
|
||||
(`[{"sessions": …}]`) covering only Saturday, so every other weekday reads as day-off and the
|
||||
`meta` block is missing. `php bin/console app:schedule:normalize-format` reports such rows;
|
||||
`--fix` rewrites them to the canonical `{"0"…"6", "meta"}` shape without touching the session
|
||||
data (absent days become `{"sessions": []}`, absent meta becomes the defaults).
|
||||
|
||||
#### `?date=YYYY-MM-DD`
|
||||
|
||||
Adds `available_on_date` to every entry and echoes `date` in the response. Use it to grey out
|
||||
|
||||
+16
-6
@@ -174,13 +174,23 @@ Get doctor detail for clinic owner — only doctors who are members of the authe
|
||||
|
||||
### Schedule Fields Notes
|
||||
|
||||
| Field | When schedule exists | When no schedule |
|
||||
|-------|---------------------|-----------------|
|
||||
| `free_turn` | نزدیکترین روز/ساعت کاری از امروز (مثلاً «دوشنبه ۹:۰۰–۱۳:۰۰») | «نوبت آزادی موجود نیست» |
|
||||
| `hours_of_work` | خلاصه ساعتهای روزهای فعال با `\|` جداشده | «برنامه کاری تنظیم نشده» |
|
||||
| `active` | `online_booking_enabled && has_active_sessions` | `false` — نوبتدهی غیرفعال |
|
||||
**تجمیع همهٔ برنامهها (2026-07):** این فیلدها روی **همهٔ** برنامههای هفتگی پزشک محاسبه
|
||||
میشوند — برنامهٔ مطب شخصی (`clinic_id IS NULL`) بهعلاوهٔ یک برنامه به ازای هر کلینیک.
|
||||
پزشکی که برنامهٔ شخصی خالی/خاموش ولی برنامهٔ کلینیکیِ فعال دارد، `active=true` میگیرد؛
|
||||
برنامهٔ یک محیط هرگز محیط دیگر را نمیپوشاند.
|
||||
|
||||
> **نوبتدهی آنلاین غیرفعال:** منبعِ فعال/غیرفعال بودن نوبتدهی آنلاین، فیلد `meta.online_booking_enabled` در `WeeklySchedule` پزشک است. اگر `false` باشد، صرفنظر از سشنهای برنامهی هفتگی، `free_turn` همیشه `"نوبتدهی آنلاین غیرفعال است"` و `active` برابر `false` برمیگردد؛ `hours_of_work` در صورت وجود برنامه حفظ میشود. چنین پزشکی در لیست عمومی `GET /api/v1/doctors` (پیشفرض `active=true`) نمایش داده نمیشود، ولی صفحهی تکی `GET /api/v1/doctor/{slug}` همچنان قابل دسترسی است.
|
||||
| Field | When at least one schedule is bookable | When none |
|
||||
|-------|---------------------------------------|-----------|
|
||||
| `free_turn` | نزدیکترین روز/ساعت کاری از امروز، بین همهٔ برنامههای روشن (مثلاً «دوشنبه ۹:۰۰–۱۳:۰۰») | «نوبت آزادی موجود نیست» |
|
||||
| `hours_of_work` | خلاصه ساعتهای همان برنامهای که `free_turn` را داده (ساعتهای دو محل با هم ترکیب نمیشوند) | «برنامه کاری تنظیم نشده» |
|
||||
| `active` | `activeDoctorAppointment && (∃ schedule: online_booking_enabled && has_active_sessions)` | `false` — نوبتدهی غیرفعال |
|
||||
|
||||
> **نوبتدهی آنلاین غیرفعال:** اگر `meta.online_booking_enabled` در **همهٔ** برنامههای پزشک
|
||||
> `false` باشد، `free_turn` برابر `"نوبتدهی آنلاین غیرفعال است"` و `active` برابر `false`
|
||||
> برمیگردد؛ `hours_of_work` در صورت وجود برنامه حفظ میشود. تا وقتی حتی یک برنامه روشن و
|
||||
> دارای روز فعال باشد، همان مبنا قرار میگیرد. چنین پزشکی (همه خاموش) در لیست عمومی
|
||||
> `GET /api/v1/doctors` (پیشفرض `active=true`) نمایش داده نمیشود، ولی صفحهی تکی
|
||||
> `GET /api/v1/doctor/{slug}` همچنان قابل دسترسی است.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Command;
|
||||
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Converts legacy weekly-schedule settings to the canonical shape.
|
||||
*
|
||||
* Canonical: keys 0..6 (0 = Saturday), each {"sessions": [...]}, plus a "meta"
|
||||
* key. Legacy rows are either a bare JSON list ([{"sessions": ...}]) or miss
|
||||
* some day keys entirely; readers treat the absent days as day-off, which does
|
||||
* not match what the owner configured and hides the row from per-day tooling.
|
||||
*/
|
||||
#[AsCommand(name: 'app:schedule:normalize-format', description: 'Report (and optionally rewrite) weekly schedules stored in a legacy setting format')]
|
||||
class NormalizeScheduleFormatCommand extends Command
|
||||
{
|
||||
private const DAY_COUNT = 7;
|
||||
|
||||
public function __construct(
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('fix', null, InputOption::VALUE_NONE, 'Rewrite offending rows to the canonical 7-day format');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$fix = (bool) $input->getOption('fix');
|
||||
|
||||
$rows = [];
|
||||
$touched = 0;
|
||||
|
||||
foreach ($this->scheduleRepo->findAll() as $schedule) {
|
||||
$setting = $schedule->getDaySchedule();
|
||||
// json_decode کلیدهای "0".."6" را به int تبدیل میکند؛ ردیف canonical کامل
|
||||
// هم list سرراست است. تنها نشانهٔ قابلاتکای فرمت legacy، غیبت روزهاست.
|
||||
$missing = [];
|
||||
for ($i = 0; $i < self::DAY_COUNT; $i++) {
|
||||
if (!isset($setting[$i]['sessions'])) {
|
||||
$missing[] = $i;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$clinic = $schedule->getClinic();
|
||||
$rows[] = [
|
||||
$schedule->getDoctor()->getUuid(),
|
||||
$clinic === null ? 'personal' : ($clinic->getName() ?? 'clinic'),
|
||||
implode(',', $missing),
|
||||
];
|
||||
|
||||
if (!$fix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
for ($i = 0; $i < self::DAY_COUNT; $i++) {
|
||||
$day = $setting[$i] ?? null;
|
||||
$normalized[$i] = is_array($day) && isset($day['sessions'])
|
||||
? $day
|
||||
: ['sessions' => []];
|
||||
}
|
||||
|
||||
// setSetting جای meta موجود را حفظ میکند؛ setMeta آن را (در نبودش با
|
||||
// DEFAULT_META) صریح در ردیف مینویسد تا فرمت canonical کامل شود.
|
||||
$schedule->setSetting($normalized);
|
||||
$schedule->setMeta($schedule->getMeta());
|
||||
$touched++;
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
$io->success('All weekly schedules already use the canonical 7-day format.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->table(['doctor', 'context', 'missing days'], $rows);
|
||||
|
||||
if (!$fix) {
|
||||
$io->warning(sprintf('%d legacy row(s). Re-run with --fix to rewrite them.', count($rows)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
$io->success(sprintf('Rewrote %d schedule(s) to the canonical format.', $touched));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -47,15 +47,6 @@ class WeeklyScheduleRepository extends ServiceEntityRepository
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated برنامهٔ context شخصی را برمیگرداند. برای کد جدید از
|
||||
* findByDoctorAndClinic() استفاده کن تا context صریح باشد.
|
||||
*/
|
||||
public function findByDoctor(Doctor $doctor): ?WeeklySchedule
|
||||
{
|
||||
return $this->findByDoctorAndClinic($doctor, null);
|
||||
}
|
||||
|
||||
/** @param Doctor[] $doctors @return WeeklySchedule[] */
|
||||
public function findByDoctors(array $doctors): array
|
||||
{
|
||||
|
||||
@@ -324,11 +324,11 @@ class ClinicController extends BaseController
|
||||
|
||||
$scheduleMap = [];
|
||||
foreach ($this->scheduleRepo->findByDoctors($clinicDoctors) as $schedule) {
|
||||
$scheduleMap[$schedule->getDoctor()->getId()] = $schedule;
|
||||
$scheduleMap[$schedule->getDoctor()->getId()][] = $schedule;
|
||||
}
|
||||
|
||||
$doctors = array_map(
|
||||
fn(Doctor $d) => $d->toListArray($scheduleMap[$d->getId()] ?? null),
|
||||
fn(Doctor $d) => $d->toListArray($scheduleMap[$d->getId()] ?? []),
|
||||
$clinicDoctors
|
||||
);
|
||||
|
||||
|
||||
@@ -123,8 +123,7 @@ class DoctorController extends BaseController
|
||||
$this->userRepo->save($user);
|
||||
}
|
||||
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
return $this->success(['data' => $doctor->toDetailArray($schedule)], 201);
|
||||
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))], 201);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
@@ -176,8 +175,8 @@ class DoctorController extends BaseController
|
||||
? ['id' => $rep->getId(), 'uuid' => $rep->getUuid(), 'full_name' => $rep->getFullName()]
|
||||
: null;
|
||||
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), [
|
||||
$schedules = $this->scheduleRepo->findAllByDoctor($doctor);
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), [
|
||||
'clinics' => $clinicData,
|
||||
'representation' => $representation,
|
||||
])]);
|
||||
@@ -201,8 +200,8 @@ class DoctorController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'این پزشک عضو کلینیک شما نیست', 403);
|
||||
}
|
||||
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), ['clinics' => [[
|
||||
$schedules = $this->scheduleRepo->findAllByDoctor($doctor);
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), ['clinics' => [[
|
||||
'id' => (string) $clinic->getId(),
|
||||
'uuid' => $clinic->getUuid(),
|
||||
'name' => $clinic->getName(),
|
||||
@@ -258,11 +257,11 @@ class DoctorController extends BaseController
|
||||
|
||||
$scheduleMap = [];
|
||||
foreach ($this->scheduleRepo->findByDoctors($result['items']) as $schedule) {
|
||||
$scheduleMap[$schedule->getDoctor()->getId()] = $schedule;
|
||||
$scheduleMap[$schedule->getDoctor()->getId()][] = $schedule;
|
||||
}
|
||||
|
||||
return $this->paginated(
|
||||
array_map(fn(Doctor $d) => $d->toListArray($scheduleMap[$d->getId()] ?? null), $result['items']),
|
||||
array_map(fn(Doctor $d) => $d->toListArray($scheduleMap[$d->getId()] ?? []), $result['items']),
|
||||
$result['total'],
|
||||
$result['page'],
|
||||
$result['limit']
|
||||
@@ -348,8 +347,7 @@ class DoctorController extends BaseController
|
||||
$this->hydrateDoctor($doctor, $data);
|
||||
$this->doctorRepo->save($doctor);
|
||||
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
return $this->success(['data' => $doctor->toDetailArray($schedule)]);
|
||||
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
|
||||
@@ -414,29 +414,52 @@ class Doctor
|
||||
|
||||
private const APPOINTMENT_DISABLED_LABEL = 'نوبتدهی آنلاین غیرفعال است';
|
||||
|
||||
private function computeScheduleFields(?WeeklySchedule $schedule): array
|
||||
/**
|
||||
* وضعیت نوبتدهی از دید سایت عمومی، تجمیعشده روی همهٔ برنامههای پزشک
|
||||
* (شخصی + هر کلینیک). برنامهٔ شخصیِ خاموش نباید برنامهٔ کلینیکیِ روشن را بپوشاند.
|
||||
*
|
||||
* @param WeeklySchedule[] $schedules
|
||||
*/
|
||||
private function computeScheduleFields(array $schedules): array
|
||||
{
|
||||
$parts = $this->computeScheduleParts($schedule);
|
||||
|
||||
// Online booking enabled flag lives in the weekly schedule meta.
|
||||
// When disabled, free_turn reflects that while hours_of_work is kept.
|
||||
if ($schedule !== null && !$schedule->getMeta()['online_booking_enabled']) {
|
||||
return [
|
||||
'free_turn' => self::APPOINTMENT_DISABLED_LABEL,
|
||||
'hours_of_work' => $parts['hours_of_work'],
|
||||
'has_schedule' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
private function computeScheduleParts(?WeeklySchedule $schedule): array
|
||||
{
|
||||
if ($schedule === null) {
|
||||
if ($schedules === []) {
|
||||
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false];
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
foreach ($schedules as $schedule) {
|
||||
if (!$schedule->getMeta()['online_booking_enabled']) {
|
||||
continue;
|
||||
}
|
||||
$parts = $this->computeScheduleParts($schedule);
|
||||
if ($parts['has_schedule']) {
|
||||
$candidates[] = $parts;
|
||||
}
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
$allDisabled = array_filter($schedules, fn(WeeklySchedule $s) => $s->getMeta()['online_booking_enabled']) === [];
|
||||
if ($allDisabled) {
|
||||
return [
|
||||
'free_turn' => self::APPOINTMENT_DISABLED_LABEL,
|
||||
'hours_of_work' => $this->computeScheduleParts($schedules[array_key_first($schedules)])['hours_of_work'],
|
||||
'has_schedule' => false,
|
||||
];
|
||||
}
|
||||
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false];
|
||||
}
|
||||
|
||||
// نزدیکترین نوبت بین همهٔ محلها؛ ساعت کاری همان محل نمایش داده میشود
|
||||
// تا ترکیب ساعتهای دو محل در یک رشته گمراهکننده نشود.
|
||||
usort($candidates, fn(array $a, array $b) => $a['rank'] <=> $b['rank']);
|
||||
$best = $candidates[0];
|
||||
unset($best['rank']);
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
private function computeScheduleParts(WeeklySchedule $schedule): array
|
||||
{
|
||||
$setting = $schedule->getSetting();
|
||||
|
||||
// استخراج ساعتهای هر روز — key: dayIdx، value: رشته ساعتها یا null
|
||||
@@ -453,7 +476,7 @@ class Doctor
|
||||
|
||||
$hasAnyDay = array_filter($dayTimes) !== [];
|
||||
if (!$hasAnyDay) {
|
||||
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false];
|
||||
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false, 'rank' => [7, '99:99']];
|
||||
}
|
||||
|
||||
// گروهبندی روزهای متوالی با ساعت یکسان
|
||||
@@ -485,11 +508,13 @@ class Doctor
|
||||
$iranDay = $phpDay === 0 ? 1 : ($phpDay === 6 ? 0 : $phpDay + 1);
|
||||
|
||||
$freeTurn = null;
|
||||
$rank = [7, '99:99'];
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
$idx = ($iranDay + $i) % 7;
|
||||
if ($dayTimes[$idx] !== null) {
|
||||
$firstTime = explode(' و ', $dayTimes[$idx])[0];
|
||||
$freeTurn = self::DAY_NAMES[$idx] . ' ' . $firstTime;
|
||||
$rank = [$i, explode('–', $firstTime)[0]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -498,6 +523,8 @@ class Doctor
|
||||
'free_turn' => $freeTurn ?? 'نوبت آزادی موجود نیست',
|
||||
'hours_of_work' => implode(' | ', $parts),
|
||||
'has_schedule' => true,
|
||||
// فاصله تا نزدیکترین روز کاری + ساعت شروع — برای مقایسهٔ بین برنامهها
|
||||
'rank' => $rank,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -509,9 +536,10 @@ class Doctor
|
||||
return max(0, (int)((time() - $this->activityTime) / (365.25 * 24 * 3600)));
|
||||
}
|
||||
|
||||
public function toListArray(?WeeklySchedule $schedule = null): array
|
||||
/** @param WeeklySchedule[] $schedules همهٔ برنامههای پزشک (شخصی + کلینیکها) */
|
||||
public function toListArray(array $schedules = []): array
|
||||
{
|
||||
$sf = $this->computeScheduleFields($schedule);
|
||||
$sf = $this->computeScheduleFields($schedules);
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
@@ -533,9 +561,10 @@ class Doctor
|
||||
];
|
||||
}
|
||||
|
||||
public function toDetailArray(?WeeklySchedule $schedule = null): array
|
||||
/** @param WeeklySchedule[] $schedules همهٔ برنامههای پزشک (شخصی + کلینیکها) */
|
||||
public function toDetailArray(array $schedules = []): array
|
||||
{
|
||||
$sf = $this->computeScheduleFields($schedule);
|
||||
$sf = $this->computeScheduleFields($schedules);
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Doctor;
|
||||
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* The public doctor payload must aggregate booking state across every weekly
|
||||
* schedule (personal + each clinic). A doctor whose personal schedule is empty
|
||||
* but who is bookable at a clinic used to be reported as «نوبتدهی غیرفعال».
|
||||
*/
|
||||
class DoctorBookingStateAggregationTest extends ApiTestCase
|
||||
{
|
||||
/** پاسخ عمومی doctor به شکل {data:{data:{…}}} است. */
|
||||
private function doctorPayload(): array
|
||||
{
|
||||
$json = json_decode($this->client->getResponse()->getContent(), true) ?? [];
|
||||
|
||||
return $json['data']['data'] ?? [];
|
||||
}
|
||||
|
||||
private function week(array $session): array
|
||||
{
|
||||
$days = array_fill_keys(range(0, 6), ['sessions' => []]);
|
||||
$days[0] = ['sessions' => [$session]];
|
||||
|
||||
return $days;
|
||||
}
|
||||
|
||||
private function session(bool $active, int $locationId): array
|
||||
{
|
||||
return [
|
||||
'active' => $active,
|
||||
'location_id' => $locationId,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '13:00',
|
||||
'duration_per_patient' => 20,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{doctor: Doctor, personal: WeeklySchedule, clinic: WeeklySchedule} */
|
||||
private function makeDoctorWithInactivePersonalAndClinic(): array
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'دکتر تجمیع');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
|
||||
$clinic->setName('کلینیک تجمیع');
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$personalAddress = DoctorAddress::forDoctor($doctor);
|
||||
$this->em->persist($personalAddress);
|
||||
$clinicAddress = DoctorAddress::forClinic($clinic->getId());
|
||||
$this->em->persist($clinicAddress);
|
||||
$this->em->flush();
|
||||
|
||||
$personal = new WeeklySchedule($doctor, $this->week($this->session(false, $personalAddress->getId())));
|
||||
$clinicSchedule = new WeeklySchedule(
|
||||
$doctor,
|
||||
$this->week($this->session(true, $clinicAddress->getId())),
|
||||
$clinic
|
||||
);
|
||||
$this->em->persist($personal);
|
||||
$this->em->persist($clinicSchedule);
|
||||
$this->em->flush();
|
||||
|
||||
return ['doctor' => $doctor, 'personal' => $personal, 'clinic' => $clinicSchedule];
|
||||
}
|
||||
|
||||
public function testClinicScheduleKeepsDoctorBookableDespiteEmptyPersonalSchedule(): void
|
||||
{
|
||||
['doctor' => $doctor] = $this->makeDoctorWithInactivePersonalAndClinic();
|
||||
|
||||
$this->client->request('GET', '/api/v1/doctor/' . $doctor->getUuid());
|
||||
$data = $this->doctorPayload();
|
||||
|
||||
$this->assertTrue($data['active']);
|
||||
$this->assertStringContainsString('شنبه', $data['free_turn']);
|
||||
}
|
||||
|
||||
public function testAllSchedulesDisabledReportsBookingDisabled(): void
|
||||
{
|
||||
['doctor' => $doctor, 'personal' => $personal, 'clinic' => $clinicSchedule]
|
||||
= $this->makeDoctorWithInactivePersonalAndClinic();
|
||||
|
||||
$personal->setMeta(['online_booking_enabled' => false]);
|
||||
$clinicSchedule->setMeta(['online_booking_enabled' => false]);
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->request('GET', '/api/v1/doctor/' . $doctor->getUuid());
|
||||
$data = $this->doctorPayload();
|
||||
|
||||
$this->assertFalse($data['active']);
|
||||
$this->assertSame('نوبتدهی آنلاین غیرفعال است', $data['free_turn']);
|
||||
}
|
||||
|
||||
public function testDisabledClinicScheduleDoesNotMaskActivePersonalSchedule(): void
|
||||
{
|
||||
['doctor' => $doctor, 'personal' => $personal, 'clinic' => $clinicSchedule]
|
||||
= $this->makeDoctorWithInactivePersonalAndClinic();
|
||||
|
||||
// برعکسِ سناریوی اول: شخصی فعال، کلینیکی خاموش — ترتیب ردیفها نباید مهم باشد.
|
||||
$personal->setSetting($this->week($this->session(true, 0)));
|
||||
$clinicSchedule->setMeta(['online_booking_enabled' => false]);
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->request('GET', '/api/v1/doctor/' . $doctor->getUuid());
|
||||
$data = $this->doctorPayload();
|
||||
|
||||
$this->assertTrue($data['active']);
|
||||
$this->assertStringContainsString('شنبه', $data['free_turn']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user