feat: section-based service picker + per-appointment duration override (service mode)
Service-booking mode now selects services by section like slot mode: appointment-booking-services returns service_section per item; ServiceSlotPicker groups by section (SearchableSelect), accumulates picks across sections into a removable 'section -> service' chip list. Secretaries can override a service's duration for a single appointment without changing the service default: appointment-service-slots accepts durations[uuid] and both create endpoints accept service_durations; the override drives total duration and slot_end. Online (patient) booking is unaffected — it never sends overrides. Backend + frontend tests and docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
# انتخاب سرویس بر اساس بخش + ویرایش مدت توسط منشی — حالت نوبتدهی سرویسی
|
||||
|
||||
## زمینه
|
||||
|
||||
صفحهٔ ایجاد نوبت پنل ادمین (`assets/admin/pages/AppointmentCreatePage.tsx`) دو حالت دارد که با `booking_mode` پزشک تعیین میشود (`useDoctorBookingServices`):
|
||||
|
||||
- **اسلاتی (`slot`)**: کاربر بخش را انتخاب میکند، سپس سرویسهای همان بخش بهصورت چکباکس نشان داده میشوند، انتخابها در یک لیستِ انباشته (chip قابل حذف) جمع میشوند و بین چند بخش انباشته میمانند. تاریخ/ساعت شروع/پایان دستی است. (این الگو **قبلاً پیاده شده** — state `selectedServices: {uuid,name}[]`، endpointهای `GET /api/v1/service-sections` و `GET /api/v1/service-items/{sectionUuid}`.)
|
||||
- **سرویسی (`service`)**: از کامپوننت `assets/admin/components/appointments/ServiceSlotPicker.tsx` استفاده میشود که سرویسها را **تخت** (بدون بخش) از `GET /api/v1/appointment-booking-services/{doctorUuid}` میگیرد؛ کاربر یک/چند سرویس را تیک میزند، مدت کل = مجموع `duration_minutes` سرویسها، و زمانهای خالیِ پیشنهادی از `GET /api/v1/appointment-service-slots` میآید.
|
||||
|
||||
پزشک نمونه: `ab747d75-2114-42b8-9e6d-abdaa338edbe` (سرویسها در بخشهای «زیبایی»، «لیزر»).
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
۱. در حالت **سرویسی**، انتخاب سرویس هم باید مثل حالت اسلاتی «بخش → سرویس» شود (نه لیست تخت):
|
||||
- انتخاب بخش (Select/Autocomplete) → نمایش فقط سرویسهای همان بخش → افزودن به لیست انباشته → انباشت بین چند بخش → حذف هر سرویس.
|
||||
- در chip سرویس انتخابشده، **نام بخش کنار نام سرویس** نشان داده شود (مثل «زیبایی → بوتاکس»).
|
||||
- محاسبهٔ مدت/اسلات سرویسی باید **حفظ** شود (`appointment-service-slots`).
|
||||
|
||||
۲. **زمان متوسط سرویس (duration) قابل ویرایش توسط منشی، فقط برای همان نوبت**:
|
||||
- هر سرویس `duration_minutes` پیشفرض از تنظیمات سرویس دارد.
|
||||
- نوبتدهی آنلاین (سایت عمومی، بیمار): غیرقابل تغییر.
|
||||
- نوبتدهی پنل (منشی/کلینیک/پزشک): منشی بتواند مدت هر سرویس را **فقط برای این نوبت** ویرایش کند؛ مقدار پیشفرض سرویس در تنظیمات (`ServiceItem.durationMinutes`) **نباید** تغییر کند.
|
||||
- کنار هر سرویس انتخابشده مدتش نمایش داده شود و در پنل قابل ویرایش باشد. مجموع مدت (و در نتیجه اسلاتهای پیشنهادی + `slot_end` نهایی) باید بر اساس مقدارِ override محاسبه شود.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Appointment/Controller/AppointmentController.php` | `bookingServices` (خط ۲۰۲) — افزودن `service_section` به هر سرویس؛ `serviceSlots` (خط ۱۴۵) — پذیرش override مدت |
|
||||
| `src/Appointment/Controller/MyAppointmentsController.php` | `createAppointment` (`POST /api/v1/my/appointment`) — پذیرش مدتِ override هنگام محاسبهٔ `slot_end` سرویسی |
|
||||
| `src/Admin/Controller/AdminApiController.php` | `createAppointment` (`POST /api/v1/admin/appointment`) — همان منطق override |
|
||||
| `assets/admin/hooks/useDoctorBookingServices.ts` | type `BookingService` + استخراج `section` |
|
||||
| `assets/admin/components/appointments/ServiceSlotPicker.tsx` | بازطراحی UI انتخاب سرویس به «بخش → سرویس + مدتِ قابلویرایش + chip» |
|
||||
| `assets/admin/pages/AppointmentCreatePage.tsx` | اتصال payload (مدت override) در `create` mutation |
|
||||
| `docs/api/appointment.md` | مستند تغییرات `appointment-booking-services`، `appointment-service-slots`، `my/appointment` |
|
||||
| `tests/Appointment/*` | تست backend (section در پاسخ، override مدت در اسلات و ثبت) |
|
||||
| `assets/admin/components/appointments/ServiceSlotPicker.test.tsx` + `assets/admin/pages/AppointmentCreatePage.test.tsx` | تست frontend |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### backend: `bookingServices` — سرویس تخت، بدون بخش
|
||||
```php
|
||||
// src/Appointment/Controller/AppointmentController.php:213
|
||||
$services = array_map(fn(\App\ClinicService\Entity\ServiceItem $i) => [
|
||||
'uuid' => $i->getUuid(),
|
||||
'name' => $i->getName(),
|
||||
'duration_minutes' => $i->getDurationMinutes(),
|
||||
'price_rials' => $i->getPriceRials(),
|
||||
], $this->itemRepo->findBookableByEntity('doctor', $doctor->getId()));
|
||||
```
|
||||
`ServiceItem::getSection(): ServiceSection` موجود است (`getUuid()`, `getName()`).
|
||||
|
||||
### backend: `serviceSlots` — مدت کل فقط از duration پیشفرض
|
||||
```php
|
||||
// src/Appointment/Controller/AppointmentController.php:170
|
||||
$totalMinutes = 0;
|
||||
foreach ($uuids as $u) {
|
||||
$item = $this->itemRepo->findByUuid($u);
|
||||
// ... اعتبارسنجی bookable/duration ...
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
}
|
||||
// ...
|
||||
'total_duration_minutes' => $totalMinutes,
|
||||
'start_times' => $this->slotCalculator->getServiceStartTimes($doctor, $date, $totalMinutes),
|
||||
```
|
||||
|
||||
### backend: `my/appointment` — بازمحاسبهٔ slot_end از duration پیشفرض
|
||||
```php
|
||||
// src/Appointment/Controller/MyAppointmentsController.php (createAppointment)
|
||||
$computeDuration = (bool) ($data['duration_from_services'] ?? false);
|
||||
if (!empty($serviceUuids) && !$isReserve) {
|
||||
$totalMinutes = 0;
|
||||
foreach ($serviceUuids as $u) {
|
||||
$item = $this->itemRepo->findByUuid($u);
|
||||
if ($computeDuration) {
|
||||
// ... اعتبارسنجی ...
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
}
|
||||
$serviceItems[] = $item;
|
||||
}
|
||||
if ($computeDuration) { $slotEnd = $slotStart + $totalMinutes * 60; }
|
||||
}
|
||||
```
|
||||
|
||||
### frontend: `BookingService` type — بدون section
|
||||
```ts
|
||||
// assets/admin/hooks/useDoctorBookingServices.ts
|
||||
export interface BookingService {
|
||||
uuid: string;
|
||||
name: string;
|
||||
duration_minutes: number | null;
|
||||
price_rials: number;
|
||||
}
|
||||
```
|
||||
|
||||
### frontend: `ServiceSlotPicker` — لیست تخت با تیک، بدون بخش، مدت غیرقابلویرایش
|
||||
```tsx
|
||||
// assets/admin/components/appointments/ServiceSlotPicker.tsx (خلاصه)
|
||||
const [serviceUuids, setServiceUuids] = useState<string[]>([]);
|
||||
// slotsQ: GET /api/v1/appointment-service-slots?doctor_uuid=..&date=..&service_item_uuids[]=..
|
||||
// services.map(...) → دکمهٔ تیکدار؛ چیدنِ start_times؛ onSelect({serviceUuids, slot})
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. backend — افزودن بخش به پاسخ `appointment-booking-services`
|
||||
|
||||
در `bookingServices`، هر سرویس `service_section` بگیرد:
|
||||
```php
|
||||
$services = array_map(function (\App\ClinicService\Entity\ServiceItem $i) {
|
||||
$section = $i->getSection();
|
||||
return [
|
||||
'uuid' => $i->getUuid(),
|
||||
'name' => $i->getName(),
|
||||
'duration_minutes' => $i->getDurationMinutes(),
|
||||
'price_rials' => $i->getPriceRials(),
|
||||
'service_section' => ['uuid' => $section->getUuid(), 'name' => $section->getName()],
|
||||
];
|
||||
}, $this->itemRepo->findBookableByEntity('doctor', $doctor->getId()));
|
||||
```
|
||||
- سازگاری عقبرو: افزودنِ فیلد است، مصرفکنندهٔ سایت عمومی (`nobat724_front`) نمیشکند.
|
||||
|
||||
### ۲. backend — پذیرش override مدت در `serviceSlots`
|
||||
|
||||
`serviceSlots` باید علاوه بر مدت پیشفرض، یک override اختیاری بپذیرد تا اسلاتها بر اساس مدتِ ویرایششدهٔ منشی چیده شوند. الگوی پیشنهادی: پارامتر `durations[<service_uuid>]=<minutes>` (map) یا `total_duration_minutes` مستقیم.
|
||||
```php
|
||||
// اگر durations[uuid] آمده و > 0 بود، بهجای getDurationMinutes همان استفاده شود
|
||||
$overrides = (array) $request->query->all('durations'); // uuid => minutes
|
||||
// در حلقه:
|
||||
$dur = isset($overrides[$u]) && (int)$overrides[$u] > 0
|
||||
? (int) $overrides[$u]
|
||||
: (int) $item->getDurationMinutes();
|
||||
if ($dur <= 0) { /* 422 مدت تعریف نشده */ }
|
||||
$totalMinutes += $dur;
|
||||
```
|
||||
- اعتبارسنجی: override باید عدد مثبت باشد؛ مقدار نامعتبر ⇒ `422`.
|
||||
- **مقدار پیشفرض سرویس تغییر نکند** — override فقط در محاسبهٔ همین درخواست استفاده شود (هیچ `set`/`save` روی `ServiceItem`).
|
||||
|
||||
### ۳. backend — اعمال override مدت هنگام ثبت نوبت
|
||||
|
||||
در `MyAppointmentsController::createAppointment` و `AdminApiController::createAppointment`، وقتی `duration_from_services=true`، بازمحاسبهٔ `slot_end` باید مدتِ override را لحاظ کند تا با اسلاتی که منشی انتخاب کرده همخوان بماند. یک فیلد جدید در payload، مثلاً `service_durations: { "<uuid>": <minutes> }`:
|
||||
```php
|
||||
$durations = (array) ($data['service_durations'] ?? []); // uuid => minutes
|
||||
// در حلقهٔ محاسبهٔ مدت:
|
||||
$dur = isset($durations[$u]) && (int)$durations[$u] > 0
|
||||
? (int) $durations[$u]
|
||||
: (int) $item->getDurationMinutes();
|
||||
$totalMinutes += $dur;
|
||||
```
|
||||
- منطق پیوستِ چند سرویس (`addServiceItem`) و پرچم `duration_from_services` که قبلاً پیاده شده، حفظ شود.
|
||||
- **مهم — سازگاری قیمت/گزارش**: بررسی شود آیا مدتِ override باید روی خودِ نوبت ذخیره شود (برای نمایش/گزارش بعدی). اگر بله، به Entity `Appointment` یک ستون/فیلد برای مدتِ مؤثر یا map مدتها اضافه شود (⇒ **migration**). اگر ذخیره لازم نیست و فقط `slot_end` کافی است، ذخیرهٔ اضافه لازم نیست — این تصمیم را در زمان اجرا بر اساس نیاز گزارشگیری مشخص کن و در پرامپتکننده تأیید بگیر.
|
||||
|
||||
### ۴. frontend — type و hook
|
||||
|
||||
`BookingService` را با بخش گسترش بده:
|
||||
```ts
|
||||
export interface BookingService {
|
||||
uuid: string;
|
||||
name: string;
|
||||
duration_minutes: number | null;
|
||||
price_rials: number;
|
||||
service_section: { uuid: string; name: string };
|
||||
}
|
||||
```
|
||||
|
||||
### ۵. frontend — بازطراحی `ServiceSlotPicker` به «بخش → سرویس»
|
||||
|
||||
منطق slot/مدت را نگه دار، فقط UIِ انتخاب سرویس را عوض کن — از همان الگوی حالت اسلاتیِ `AppointmentCreatePage.tsx` تقلید کن:
|
||||
- گروهبندی `services` بر اساس `service_section.uuid` (client-side؛ نیازی به endpoint جدید نیست چون همهٔ سرویسهای bookable یکجا آمدهاند).
|
||||
- Select/Autocomplete بخش (`SearchableSelect`) → نمایش سرویسهای همان بخش بهصورت چکباکس → افزودن به `selected: { uuid; name; section: string; duration: number }[]` (انباشته، بین چند بخش).
|
||||
- chip قابل حذف با نمایش «بخش → سرویس» و مدت؛ در حالت پنل (منشی) مدت با `DigitInput`/عدد قابل ویرایش.
|
||||
- مجموع مدت از `selected` (با override) محاسبه و در query `appointment-service-slots` بهصورت `durations[uuid]=minutes` ارسال شود تا `start_times` هماهنگ بماند.
|
||||
- `onSelect` باید `serviceUuids` + `durations` map + `slot` را بالا بفرستد.
|
||||
|
||||
### ۶. frontend — payload در `AppointmentCreatePage`
|
||||
|
||||
در `create` mutation، حالت سرویسی علاوه بر `service_item_uuids` و `duration_from_services:true`، در صورت override منشی `service_durations: { uuid: minutes }` هم بفرستد.
|
||||
- تشخیص «منشی/پنل بودن» برای فعالکردن ویرایش مدت: از نقش کاربر (`useAuthStore().primaryRole`) — همهٔ نقشهای پنل (admin/clinic/doctor/secretary) مجازند؛ این صفحه اصلاً پنل است، پس ویرایش مدت همیشه در این صفحه فعال است (محدودیت «غیرقابلتغییر» فقط مربوط به سایت عمومی `nobat724_front` است، نه این صفحه).
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **عدم تغییر پیشفرض سرویس**: override مدت هرگز نباید `ServiceItem.durationMinutes` را در دیتابیس تغییر دهد — نه در `serviceSlots`، نه در ثبت نوبت. فقط در محاسبهٔ همان درخواست/نوبت.
|
||||
- **حفظ منطق موجود**: پرچم `duration_from_services`, تابع `addServiceItem` (چند سرویس)، و جریان اسلاتیِ فعلی نباید بشکنند. حالت اسلاتی دستنخورده بماند.
|
||||
- **سازگاری مصرفکنندهها**: `appointment-booking-services` و `appointment-service-slots` توسط `nobat724_front` هم مصرف میشوند (`services/response.js`). افزودن فیلد (`service_section`) و پارامتر اختیاری (`durations`) عقبرو-سازگار است؛ سایت عمومی نباید override را فعال کند (بیمار مجاز به تغییر مدت نیست).
|
||||
- **پاسخها**: با `$this->success(...)` / `$this->error(ErrorCodes::..., msg, status, field)` مطابق `BaseController`.
|
||||
- **الگوی frontend**: `SearchableSelect` (نه `<select>` خام)، `TanStack Query` برای دیتا، state لوکال React برای انتخابها، `DigitInput` برای ورودی عددی مدت. chipها با توکنهای `--primary-soft`/`--primary` مطابق UIِ فعلی.
|
||||
- **edge caseها**: سرویس بدون `duration_minutes` (⇒ 422 یا فیلترشدن)؛ بخشِ بدون سرویس bookable؛ override صفر/منفی/غیرعدد (رد شود، به پیشفرض برگردد)؛ حذف همهٔ سرویسها (اسلات خالی، دکمهٔ ثبت غیرفعال)؛ انتخاب سرویس از دو بخش با مدتهای override متفاوت (مجموع درست).
|
||||
- **تست (اجباری، موفق + خطا + مرزی)**:
|
||||
- backend: `appointment-booking-services` فیلد `service_section` را برمیگرداند؛ `appointment-service-slots` با `durations[uuid]` مدت کل و `start_times` را بر اساس override میدهد؛ ثبت نوبت با `service_durations` مقدار `slot_end` را بر اساس override میسازد و پیشفرض سرویس در DB تغییر نمیکند.
|
||||
- frontend: انتخاب بخش → نمایش سرویسهای همان بخش؛ انباشت بین دو بخش؛ chip «بخش → سرویس»؛ ویرایش مدت یک سرویس و بازتاب در payload؛ حذف سرویس.
|
||||
- **مستندات**: `docs/api/appointment.md` برای هر سه endpoint بهروز شود (فیلد `service_section`، پارامتر `durations`، فیلد `service_durations` در body ثبت).
|
||||
- **بعد از اتمام**: `npx tsc --noEmit`، `ddev exec bin/console lint:container`, `ddev exec bin/phpunit tests/Appointment`, `npx vitest run` تستهای مربوطه، `ddev exec yarn dev` — همه سبز.
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
vi.mock('../../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../../lib/api';
|
||||
import ServiceSlotPicker, { type ServicePick } from './ServiceSlotPicker';
|
||||
import type { BookingService } from '../../hooks/useDoctorBookingServices';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const services: BookingService[] = [
|
||||
{ uuid: 'i1', name: 'بوتاکس', duration_minutes: 30, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } },
|
||||
{ uuid: 'i2', name: 'فیلر لب', duration_minutes: 20, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } },
|
||||
{ uuid: 'i3', name: 'لیزر موهای زائد', duration_minutes: 45, price_rials: 0, service_section: { uuid: 'sec2', name: 'لیزر' } },
|
||||
];
|
||||
|
||||
const pickSection = async (optionLabel: string) => {
|
||||
const input = document.getElementById('service-mode-section-select') as HTMLInputElement;
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByText(optionLabel));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
get.mockResolvedValue({ success: true, data: { total_duration_minutes: 50, buffer_minutes: 0, start_times: [] } });
|
||||
});
|
||||
|
||||
describe('ServiceSlotPicker — انتخاب سرویس بر اساس بخش + مدت قابلویرایش', () => {
|
||||
it('accumulates services across sections and reports section→service + durations', async () => {
|
||||
let last: ServicePick = { serviceUuids: [], durations: {}, slot: null };
|
||||
renderWithProviders(
|
||||
<ServiceSlotPicker doctorUuid="doc1" date="2026-07-20" services={services} onSelect={v => { last = v; }} />,
|
||||
);
|
||||
|
||||
// بخش زیبایی → دو سرویس
|
||||
await pickSection('زیبایی');
|
||||
fireEvent.click(await screen.findByText('بوتاکس'));
|
||||
fireEvent.click(await screen.findByText('فیلر لب'));
|
||||
|
||||
// بخش لیزر → یک سرویس؛ لیست انباشته حفظ میشود
|
||||
await pickSection('لیزر');
|
||||
fireEvent.click(await screen.findByText('لیزر موهای زائد'));
|
||||
|
||||
expect(screen.getByText('سرویسهای انتخابشده (3)')).toBeInTheDocument();
|
||||
// chip نام بخش را کنار سرویس نشان میدهد (دو chip از بخش زیبایی)
|
||||
expect(screen.getAllByText('زیبایی').length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await waitFor(() => expect(last.serviceUuids).toEqual(['i1', 'i2', 'i3']));
|
||||
expect(last.durations).toEqual({ i1: 30, i2: 20, i3: 45 });
|
||||
});
|
||||
|
||||
it('lets the secretary override a service duration for this appointment only', async () => {
|
||||
let last: ServicePick = { serviceUuids: [], durations: {}, slot: null };
|
||||
renderWithProviders(
|
||||
<ServiceSlotPicker doctorUuid="doc1" date="2026-07-20" services={services} onSelect={v => { last = v; }} />,
|
||||
);
|
||||
|
||||
await pickSection('زیبایی');
|
||||
fireEvent.click(await screen.findByText('بوتاکس'));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('مدت بوتاکس'), { target: { value: '90' } });
|
||||
await waitFor(() => expect(last.durations).toEqual({ i1: 90 }));
|
||||
});
|
||||
|
||||
it('removes a selected service from the accumulated list', async () => {
|
||||
let last: ServicePick = { serviceUuids: [], durations: {}, slot: null };
|
||||
renderWithProviders(
|
||||
<ServiceSlotPicker doctorUuid="doc1" date="2026-07-20" services={services} onSelect={v => { last = v; }} />,
|
||||
);
|
||||
|
||||
await pickSection('زیبایی');
|
||||
fireEvent.click(await screen.findByText('بوتاکس'));
|
||||
fireEvent.click(await screen.findByText('فیلر لب'));
|
||||
expect(screen.getByText('سرویسهای انتخابشده (2)')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('حذف بوتاکس'));
|
||||
expect(screen.getByText('سرویسهای انتخابشده (1)')).toBeInTheDocument();
|
||||
await waitFor(() => expect(last.serviceUuids).toEqual(['i2']));
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
import type { BookingService } from '../../hooks/useDoctorBookingServices';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import DigitInput from '../ui/DigitInput';
|
||||
|
||||
interface ServiceSlot { start: number; end: number; start_time: string }
|
||||
export interface PickedService { uuid: string; name: string; section: string; duration: number }
|
||||
export interface ServicePick { serviceUuids: string[]; durations: Record<string, number>; slot: ServiceSlot | null }
|
||||
|
||||
/**
|
||||
* انتخاب سرویس (یک/چند) + زمانهای خالیِ کافیِ پیشنهادی برای نوبتدهی سرویسی.
|
||||
* مدت نوبت از مجموع مدت سرویسها میآید؛ زمانها از `appointment-service-slots`.
|
||||
* انتخاب را از طریق onSelect بالا میفرستد تا فرمِ میزبان payload بسازد.
|
||||
* انتخاب سرویس بر اساس بخش (بخش → سرویس، انباشته از چند بخش) + زمانهای خالیِ پیشنهادی
|
||||
* برای نوبتدهی سرویسی. مدتِ هر سرویس در پنل قابل ویرایش است (فقط برای همین نوبت؛ پیشفرضِ
|
||||
* سرویس تغییر نمیکند). مدت کل = مجموع مدتها؛ زمانها از `appointment-service-slots`
|
||||
* با اعمال همان override محاسبه میشوند. انتخاب را از طریق onSelect بالا میفرستد.
|
||||
*/
|
||||
export default function ServiceSlotPicker({
|
||||
doctorUuid, date, services, onSelect,
|
||||
doctorUuid, date, services, onSelect, editableDuration = true,
|
||||
}: {
|
||||
doctorUuid: string;
|
||||
date: string;
|
||||
services: BookingService[];
|
||||
onSelect: (v: { serviceUuids: string[]; slot: ServiceSlot | null }) => void;
|
||||
onSelect: (v: ServicePick) => void;
|
||||
editableDuration?: boolean;
|
||||
}) {
|
||||
const [serviceUuids, setServiceUuids] = useState<string[]>([]);
|
||||
const [sectionUuid, setSectionUuid] = useState('');
|
||||
const [selected, setSelected] = useState<PickedService[]>([]);
|
||||
const [pickedSlot, setPickedSlot] = useState<ServiceSlot | null>(null);
|
||||
|
||||
useEffect(() => { setPickedSlot(null); }, [serviceUuids, date, doctorUuid]);
|
||||
useEffect(() => { onSelect({ serviceUuids, slot: pickedSlot }); }, [serviceUuids, pickedSlot]);
|
||||
// بخشهای یکتا از روی سرویسهای bookable (بدون endpoint اضافه — همه یکجا آمدهاند).
|
||||
const sections = useMemo(() => {
|
||||
const map = new Map<string, { uuid: string; name: string }>();
|
||||
services.forEach(s => { if (s.service_section) map.set(s.service_section.uuid, s.service_section); });
|
||||
return [...map.values()];
|
||||
}, [services]);
|
||||
const sectionServices = useMemo(
|
||||
() => services.filter(s => s.service_section?.uuid === sectionUuid),
|
||||
[services, sectionUuid],
|
||||
);
|
||||
|
||||
// تعویض پزشک ⇒ لیست سرویسها عوض میشود؛ انتخابها ریست شوند.
|
||||
useEffect(() => { setSelected([]); setSectionUuid(''); }, [doctorUuid]);
|
||||
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid]);
|
||||
|
||||
const serviceUuids = useMemo(() => selected.map(s => s.uuid), [selected]);
|
||||
const durations = useMemo(
|
||||
() => Object.fromEntries(selected.map(s => [s.uuid, s.duration])) as Record<string, number>,
|
||||
[selected],
|
||||
);
|
||||
|
||||
useEffect(() => { onSelect({ serviceUuids, durations, slot: pickedSlot }); }, [serviceUuids, durations, pickedSlot]);
|
||||
|
||||
const durationsQs = selected.map(s => `&durations[${encodeURIComponent(s.uuid)}]=${s.duration}`).join('');
|
||||
const slotsQ = useQuery<ApiResponse<any>>({
|
||||
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids],
|
||||
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations],
|
||||
queryFn: () => api.get(
|
||||
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
|
||||
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
|
||||
+ durationsQs,
|
||||
),
|
||||
enabled: !!doctorUuid && !!date && serviceUuids.length > 0,
|
||||
});
|
||||
@@ -38,51 +67,121 @@ export default function ServiceSlotPicker({
|
||||
|
||||
const label = { fontSize: 12.5, color: 'var(--text-3)', display: 'block' } as const;
|
||||
|
||||
const toggle = (uuid: string) =>
|
||||
setServiceUuids(prev => prev.includes(uuid) ? prev.filter(u => u !== uuid) : [...prev, uuid]);
|
||||
const toggle = (s: BookingService) =>
|
||||
setSelected(prev => prev.some(p => p.uuid === s.uuid)
|
||||
? prev.filter(p => p.uuid !== s.uuid)
|
||||
: [...prev, { uuid: s.uuid, name: s.name, section: s.service_section.name, duration: s.duration_minutes ?? 0 }]);
|
||||
const remove = (uuid: string) => setSelected(prev => prev.filter(p => p.uuid !== uuid));
|
||||
const setDuration = (uuid: string, minutes: number) =>
|
||||
setSelected(prev => prev.map(p => p.uuid === uuid ? { ...p, duration: minutes } : p));
|
||||
|
||||
if (services.length === 0) {
|
||||
return (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
|
||||
سرویسی با «نمایش در نوبتدهی» برای این پزشک تعریف نشده است.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label style={label}>سرویس (یک یا چند)</label>
|
||||
{services.length === 0 ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
|
||||
سرویسی با «نمایش در نوبتدهی» برای این پزشک تعریف نشده است.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '6px 0 10px' }}>
|
||||
{services.map(s => {
|
||||
const active = serviceUuids.includes(s.uuid);
|
||||
return (
|
||||
<button
|
||||
key={s.uuid}
|
||||
type="button"
|
||||
onClick={() => toggle(s.uuid)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
||||
padding: '8px 10px', borderRadius: 'var(--r-sm)', cursor: 'pointer', textAlign: 'right',
|
||||
fontFamily: 'inherit', fontSize: 13,
|
||||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: active ? 'var(--primary-soft)' : 'var(--surface)',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{
|
||||
width: 15, height: 15, borderRadius: 4, display: 'grid', placeItems: 'center', flexShrink: 0,
|
||||
border: active ? '1px solid var(--primary)' : '1px solid var(--border-2)',
|
||||
background: active ? 'var(--primary)' : 'transparent',
|
||||
}}>
|
||||
{active && <span style={{ width: 7, height: 7, background: '#fff', borderRadius: 2 }} />}
|
||||
</span>
|
||||
{s.name}
|
||||
{/* انتخاب بخش */}
|
||||
<label style={label}>بخش</label>
|
||||
<div style={{ margin: '6px 0 10px', maxWidth: 400 }}>
|
||||
<SearchableSelect
|
||||
inputId="service-mode-section-select"
|
||||
options={sections.map(s => ({ value: s.uuid, label: s.name }))}
|
||||
value={sectionUuid || null}
|
||||
onChange={v => setSectionUuid(v ? String(v) : '')}
|
||||
placeholder="ابتدا بخش را انتخاب کنید"
|
||||
isClearable
|
||||
height={44}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* سرویسهای بخشِ انتخابشده — چند انتخابی */}
|
||||
{sectionUuid && (
|
||||
<>
|
||||
<label style={label}>سرویسهای این بخش (یک یا چند)</label>
|
||||
{sectionServices.length === 0 ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>سرویسی در این بخش تعریف نشده است.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '6px 0 12px' }}>
|
||||
{sectionServices.map(s => {
|
||||
const active = selected.some(p => p.uuid === s.uuid);
|
||||
return (
|
||||
<button key={s.uuid} type="button" onClick={() => toggle(s)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
||||
padding: '9px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', textAlign: 'right',
|
||||
fontFamily: 'inherit', fontSize: 13,
|
||||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: active ? 'var(--primary-soft)' : 'var(--surface)',
|
||||
}}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{
|
||||
width: 16, height: 16, borderRadius: 4, display: 'grid', placeItems: 'center', flexShrink: 0,
|
||||
border: active ? '1px solid var(--primary)' : '1px solid var(--border-2)',
|
||||
background: active ? 'var(--primary)' : 'transparent',
|
||||
}}>
|
||||
{active && <span style={{ width: 8, height: 8, background: '#fff', borderRadius: 2 }} />}
|
||||
</span>
|
||||
{s.name}
|
||||
</span>
|
||||
{s.duration_minutes ? <span style={{ color: 'var(--text-3)', fontSize: 12 }}>{s.duration_minutes} دقیقه</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* لیستِ انباشتهٔ سرویسهای انتخابشده (از هر بخش) — «بخش → سرویس» + مدت قابلویرایش + حذف */}
|
||||
{selected.length > 0 && (
|
||||
<div style={{ margin: '4px 0 12px' }}>
|
||||
<label style={label}>سرویسهای انتخابشده ({selected.length})</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 6 }}>
|
||||
{selected.map(s => (
|
||||
<div key={s.uuid} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
|
||||
padding: '8px 10px', borderRadius: 'var(--r-sm)', fontSize: 13,
|
||||
background: 'var(--primary-soft)', border: '1px solid var(--primary)',
|
||||
}}>
|
||||
<span style={{ flex: 1, minWidth: 120, color: 'var(--primary-700)', fontWeight: 600 }}>
|
||||
<span style={{ color: 'var(--text-3)', fontWeight: 400 }}>{s.section}</span>
|
||||
{' ← '}{s.name}
|
||||
</span>
|
||||
{s.duration_minutes ? <span style={{ color: 'var(--text-3)', fontSize: 12 }}>{s.duration_minutes} دقیقه</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{editableDuration ? (
|
||||
<span className="field" style={{ height: 34, width: 92, padding: '0 8px' }}>
|
||||
<DigitInput
|
||||
aria-label={`مدت ${s.name}`}
|
||||
value={String(s.duration || '')}
|
||||
onChange={v => setDuration(s.uuid, Number(v) || 0)}
|
||||
maxDigits={3}
|
||||
placeholder="دقیقه"
|
||||
/>
|
||||
<span style={{ color: 'var(--text-3)', fontSize: 11.5 }}>دقیقه</span>
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-3)', fontSize: 12 }}>{s.duration} دقیقه</span>
|
||||
)}
|
||||
<button type="button" aria-label={`حذف ${s.name}`} onClick={() => remove(s.uuid)}
|
||||
style={{
|
||||
display: 'grid', placeItems: 'center', width: 18, height: 18, borderRadius: 999,
|
||||
border: 'none', cursor: 'pointer', background: 'var(--primary)', color: '#fff',
|
||||
fontSize: 13, lineHeight: 1, fontFamily: 'inherit',
|
||||
}}>×</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serviceUuids.length > 0 && (
|
||||
{/* زمانهای خالی پیشنهادی */}
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<label style={label}>زمانهای خالی پیشنهادی{totalMinutes != null ? ` (مدت کل: ${totalMinutes} دقیقه)` : ''}</label>
|
||||
{slotsQ.isLoading ? (
|
||||
@@ -96,17 +195,13 @@ export default function ServiceSlotPicker({
|
||||
{startTimes.map(s => {
|
||||
const active = pickedSlot?.start === s.start;
|
||||
return (
|
||||
<button
|
||||
key={s.start}
|
||||
type="button"
|
||||
dir="ltr"
|
||||
<button key={s.start} type="button" dir="ltr"
|
||||
onClick={() => setPickedSlot({ start: s.start, end: s.end, start_time: s.start_time })}
|
||||
style={{
|
||||
fontSize: 13, padding: '6px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: active ? 'var(--primary)' : 'var(--surface)', color: active ? '#fff' : 'var(--text)',
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
{s.start_time}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface BookingService {
|
||||
name: string;
|
||||
duration_minutes: number | null;
|
||||
price_rials: number;
|
||||
service_section: { uuid: string; name: string };
|
||||
}
|
||||
|
||||
interface BookingServicesData {
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||
it('service mode: picks service + suggested time and posts service_item_uuids', async () => {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.startsWith('/api/v1/appointment-booking-services/'))
|
||||
return Promise.resolve({ success: true, data: { booking_mode: 'service', buffer_minutes: 5, services: [{ uuid: 'sv1', name: 'عصبکشی', duration_minutes: 30, price_rials: 500000 }] } });
|
||||
return Promise.resolve({ success: true, data: { booking_mode: 'service', buffer_minutes: 5, services: [{ uuid: 'sv1', name: 'عصبکشی', duration_minutes: 30, price_rials: 500000, service_section: { uuid: 'sec1', name: 'دندان' } }] } });
|
||||
if (url.startsWith('/api/v1/appointment-service-slots'))
|
||||
return Promise.resolve({ success: true, data: { total_duration_minutes: 30, buffer_minutes: 5, start_times: [{ start: 1754000000, end: 1754001800, start_time: '15:00' }] } });
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
@@ -58,6 +58,11 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||
// حالت سرویس: ورودی ساعت شروع نباید باشد
|
||||
await waitFor(() => expect(screen.queryByLabelText('ساعت شروع')).toBeNull());
|
||||
|
||||
// حالت سرویسی هم «بخش → سرویس» است: ابتدا بخش، سپس سرویس
|
||||
const secInput = document.getElementById('service-mode-section-select') as HTMLInputElement;
|
||||
fireEvent.focus(secInput);
|
||||
fireEvent.keyDown(secInput, { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByText('دندان'));
|
||||
fireEvent.click(await screen.findByText('عصبکشی'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '15:00' }));
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function AppointmentCreatePage() {
|
||||
// ── روش نوبتدهی پزشک (سرویسی/اسلاتی)
|
||||
const { bookingMode, services } = useDoctorBookingServices(doctorUuid);
|
||||
const serviceMode = bookingMode === 'service';
|
||||
const [servicePick, setServicePick] = useState<{ serviceUuids: string[]; slot: { start: number; end: number } | null }>({ serviceUuids: [], slot: null });
|
||||
const [servicePick, setServicePick] = useState<{ serviceUuids: string[]; durations: Record<string, number>; slot: { start: number; end: number } | null }>({ serviceUuids: [], durations: {}, slot: null });
|
||||
|
||||
// ── زمان نوبت
|
||||
const [date, setDate] = useState(params.get('date') || today);
|
||||
@@ -124,7 +124,7 @@ export default function AppointmentCreatePage() {
|
||||
patient_mobile: effectiveMobile,
|
||||
patient_national_code: effectiveNationalCode,
|
||||
...(serviceMode
|
||||
? { service_item_uuids: servicePick.serviceUuids, duration_from_services: true }
|
||||
? { service_item_uuids: servicePick.serviceUuids, duration_from_services: true, service_durations: servicePick.durations }
|
||||
: {
|
||||
...(sectionUuid ? { service_section_uuid: sectionUuid } : {}),
|
||||
...(selectedServices.length ? { service_item_uuids: selectedServices.map(s => s.uuid) } : {}),
|
||||
|
||||
+2
-1
@@ -601,13 +601,14 @@ Create a new appointment for a patient. If no user exists with the given mobile,
|
||||
"patient_national_code": "0012345678",
|
||||
"service_item_uuids": ["service-uuid-1", "service-uuid-2"],
|
||||
"duration_from_services": false,
|
||||
"service_durations": { "service-uuid-1": 75 },
|
||||
"note": "optional note"
|
||||
}
|
||||
```
|
||||
|
||||
> `patient_mobile`، `patient_name` و `patient_national_code` هر سه اجباری هستند. کد ملی باید ۱۰ رقم معتبر باشد و روی **پروفایل** بیمار ذخیره میشود (`profiles.national_code`، یکتا). بیمار **اول با کد ملیِ پروفایل** و سپس با موبایل resolve میشود، تا پرونده برای هر کد ملی یکتا بماند (یک شخص میتواند چند موبایل داشته باشد). اگر بیماری یافت نشود، کاربر جدید (`ROLE_USER`) بههمراه پروفایلِ حاملِ همان کد ملی ساخته میشود.
|
||||
>
|
||||
> `service_item_uuids[]` (اختیاری): یک یا چند سرویس که به نوبت پیوست میشوند؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` پاسخ برمیگردند. UUID ناموجود ⇒ `422`. با `duration_from_services: true` مدت نوبت از مجموع `duration_minutes` سرویسها محاسبه و `slot_end` بازنویسی میشود (سرویسِ غیرbookable/بدون مدت ⇒ `422`)؛ بدون آن ساعت پایانِ دستی حفظ میماند.
|
||||
> `service_item_uuids[]` (اختیاری): یک یا چند سرویس که به نوبت پیوست میشوند؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` پاسخ برمیگردند. UUID ناموجود ⇒ `422`. با `duration_from_services: true` مدت نوبت از مجموع `duration_minutes` سرویسها محاسبه و `slot_end` بازنویسی میشود (سرویسِ غیرbookable/بدون مدت ⇒ `422`)؛ بدون آن ساعت پایانِ دستی حفظ میماند. `service_durations` (`{ "<uuid>": <minutes> }`، فقط با `duration_from_services=true`): override مدتِ هر سرویس برای همان نوبت؛ پیشفرضِ سرویس در تنظیمات تغییر نمیکند.
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
|
||||
@@ -78,6 +78,7 @@ Get all appointment slots (available and booked) for a doctor on a specific date
|
||||
| `doctor_uuid` | string (uuid) | ✅ | |
|
||||
| `date` | string `Y-m-d` | ✅ | |
|
||||
| `service_item_uuids[]` | string[] | ✅ | یک یا چند UUID سرویسِ bookable |
|
||||
| `durations[<service_uuid>]` | int | ❌ | override مدت (دقیقه) برای همان سرویس — فقط در این محاسبه استفاده میشود و مقدار پیشفرضِ سرویس در تنظیمات تغییر نمیکند. برای نوبتدهیِ منشی که مدت را برای یک نوبت تغییر میدهد. مقدار ≤ 0 یا غایب ⇒ مدت پیشفرض سرویس |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
@@ -117,7 +118,7 @@ Get all appointment slots (available and booked) for a doctor on a specific date
|
||||
"booking_mode": "service",
|
||||
"buffer_minutes": 5,
|
||||
"services": [
|
||||
{ "uuid": "…", "name": "عصبکشی", "duration_minutes": 30, "price_rials": 5000000 }
|
||||
{ "uuid": "…", "name": "عصبکشی", "duration_minutes": 30, "price_rials": 5000000, "service_section": { "uuid": "…", "name": "دندان" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -209,6 +210,7 @@ Book an appointment slot.
|
||||
| `slot_end` | integer | ⚠️ | Slot end (Unix timestamp). فقط وقتی `duration_from_services=true` باشد سرور آن را از `slot_start + Σ duration_minutes` بازمحاسبه میکند؛ در غیر این صورت مقدار کلاینت حفظ میشود |
|
||||
| `service_item_uuids` | string[] | ❌ | یک یا چند UUID سرویس که به نوبت **پیوست** میشوند (چند سرویس). اولین سرویس بهعنوان سرویسِ اصلی (`service_item`) ثبت و همه در `service_items` برمیگردند. UUID ناموجود ⇒ `422` |
|
||||
| `duration_from_services` | boolean | ❌ | `true` = حالت نوبتدهی سرویسی: مدت نوبت از مجموع `duration_minutes` سرویسها محاسبه و `slot_end` بازنویسی میشود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. پیشفرض `false` (حالت اسلاتی: فقط پیوست، ساعت پایانِ دستی حفظ میشود) |
|
||||
| `service_durations` | object | ❌ | override مدت هر سرویس بهصورت `{ "<service_uuid>": <minutes> }` — فقط وقتی `duration_from_services=true`. برای نوبتدهیِ منشی که مدت را برای همان نوبت تغییر میدهد؛ در محاسبهٔ `slot_end` لحاظ میشود و **مقدار پیشفرضِ سرویس تغییر نمیکند**. مقدار ≤ 0 یا غایب ⇒ مدت پیشفرض |
|
||||
| `for_self` | boolean | ❌ | `true` (default) = patient is the logged-in payer; `false` = booking for someone else |
|
||||
| `patient_name` | string | ⚠️ | Required when `for_self=false`; otherwise filled from the payer's profile |
|
||||
| `patient_mobile` | string | ⚠️ | Required when `for_self=false`; otherwise the payer's mobile |
|
||||
@@ -633,6 +635,9 @@ Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_
|
||||
`is_reserve: true` → day-level reserve entry: `slot_end` may equal `slot_start`, the past-slot rule is skipped, and the entry never occupies a slot (several reserves may share a day). Response `201` now also returns `is_reserve`.
|
||||
`service_item_uuids[]` (غیرِ رزرو): یک یا چند سرویس که به نوبت **پیوست** میشوند (چند سرویس)؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` برمیگردند. UUID ناموجود ⇒ `422`.
|
||||
`duration_from_services: true` (حالت نوبتدهی سرویسی): مدت نوبت از مجموع `duration_minutes` سرویسها محاسبه و `slot_end` بازنویسی میشود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. بدون این پرچم (حالت اسلاتی)، ساعت پایانِ دستی حفظ میشود.
|
||||
`service_durations` (فقط با `duration_from_services=true`): override مدت هر سرویس `{ "<uuid>": <minutes> }` برای همان نوبت (منشی)؛ در `slot_end` لحاظ میشود و مقدار پیشفرضِ سرویس تغییر نمیکند.
|
||||
|
||||
> **بخشِ سرویس در `appointment-booking-services`:** هر آیتم `services[]` علاوه بر `uuid/name/duration_minutes/price_rials`، فیلد `service_section: { uuid, name }` هم دارد تا فرمِ نوبتدهیِ سرویسی سرویسها را «بخش → سرویس» گروهبندی کند. عقبرو-سازگار (افزودنِ فیلد).
|
||||
|
||||
### GET `/api/v1/my/appointments` (extended)
|
||||
New query param `reserve=1` → returns only reserve-list entries; without it only regular slot bookings are returned. Each row now also includes: `patient_uuid`, `is_reserve`, `deposit_required`, `deposit_amount_rials`, `note`, `service_section`, `service_item`, `staff` (each `{uuid, name|full_name}` or null).
|
||||
|
||||
@@ -867,6 +867,8 @@ class AdminApiController extends BaseController
|
||||
// سرویسها صرفاً پیوست میشوند و ساعت پایانِ دستی حفظ میماند.
|
||||
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
||||
$computeDuration = (bool) ($data['duration_from_services'] ?? false);
|
||||
// مدتِ override منشی برای همین نوبت (پیشفرض سرویس تغییر نمیکند). { uuid: minutes }
|
||||
$durationOverrides = (array) ($data['service_durations'] ?? []);
|
||||
$serviceItems = [];
|
||||
if (!empty($serviceUuids)) {
|
||||
$itemRepo = $this->em->getRepository(\App\ClinicService\Entity\ServiceItem::class);
|
||||
@@ -880,10 +882,13 @@ class AdminApiController extends BaseController
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
}
|
||||
if (($item->getDurationMinutes() ?? 0) <= 0) {
|
||||
$duration = isset($durationOverrides[$u]) && (int) $durationOverrides[$u] > 0
|
||||
? (int) $durationOverrides[$u]
|
||||
: (int) ($item->getDurationMinutes() ?? 0);
|
||||
if ($duration <= 0) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
$totalMinutes += $duration;
|
||||
}
|
||||
$serviceItems[] = $item;
|
||||
}
|
||||
|
||||
@@ -167,6 +167,9 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids');
|
||||
}
|
||||
|
||||
// مدتِ override منشی (فقط برای همین محاسبه؛ پیشفرض سرویس تغییر نمیکند). durations[uuid]=minutes
|
||||
$overrides = (array) $request->query->all('durations');
|
||||
|
||||
$totalMinutes = 0;
|
||||
foreach ($uuids as $u) {
|
||||
$item = $this->itemRepo->findByUuid($u);
|
||||
@@ -176,10 +179,13 @@ class AppointmentController extends BaseController
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
}
|
||||
if (($item->getDurationMinutes() ?? 0) <= 0) {
|
||||
$duration = isset($overrides[$u]) && (int) $overrides[$u] > 0
|
||||
? (int) $overrides[$u]
|
||||
: (int) ($item->getDurationMinutes() ?? 0);
|
||||
if ($duration <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
$totalMinutes += $duration;
|
||||
}
|
||||
|
||||
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
|
||||
@@ -210,12 +216,16 @@ class AppointmentController extends BaseController
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
|
||||
|
||||
$services = array_map(fn(\App\ClinicService\Entity\ServiceItem $i) => [
|
||||
'uuid' => $i->getUuid(),
|
||||
'name' => $i->getName(),
|
||||
'duration_minutes' => $i->getDurationMinutes(),
|
||||
'price_rials' => $i->getPriceRials(),
|
||||
], $this->itemRepo->findBookableByEntity('doctor', $doctor->getId()));
|
||||
$services = array_map(function (\App\ClinicService\Entity\ServiceItem $i) {
|
||||
$section = $i->getSection();
|
||||
return [
|
||||
'uuid' => $i->getUuid(),
|
||||
'name' => $i->getName(),
|
||||
'duration_minutes' => $i->getDurationMinutes(),
|
||||
'price_rials' => $i->getPriceRials(),
|
||||
'service_section' => ['uuid' => $section->getUuid(), 'name' => $section->getName()],
|
||||
];
|
||||
}, $this->itemRepo->findBookableByEntity('doctor', $doctor->getId()));
|
||||
|
||||
return $this->success([
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
|
||||
@@ -76,6 +76,8 @@ class MyAppointmentsController extends BaseController
|
||||
// فقط به نوبت پیوست میشوند و ساعت پایانِ دستی حفظ میشود.
|
||||
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
||||
$computeDuration = (bool) ($data['duration_from_services'] ?? false);
|
||||
// مدتِ override منشی برای همین نوبت (پیشفرض سرویس تغییر نمیکند). { uuid: minutes }
|
||||
$durationOverrides = (array) ($data['service_durations'] ?? []);
|
||||
$serviceItems = [];
|
||||
if (!empty($serviceUuids) && !$isReserve) {
|
||||
$totalMinutes = 0;
|
||||
@@ -88,10 +90,13 @@ class MyAppointmentsController extends BaseController
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
}
|
||||
if (($item->getDurationMinutes() ?? 0) <= 0) {
|
||||
$duration = isset($durationOverrides[$u]) && (int) $durationOverrides[$u] > 0
|
||||
? (int) $durationOverrides[$u]
|
||||
: (int) ($item->getDurationMinutes() ?? 0);
|
||||
if ($duration <= 0) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
$totalMinutes += $duration;
|
||||
}
|
||||
$serviceItems[] = $item;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* حالت نوبتدهی سرویسی: پاسخ booking-services باید بخش هر سرویس را بدهد؛ اسلاتها و
|
||||
* ثبت نوبت باید مدتِ override منشی را (فقط برای همان نوبت) لحاظ کنند بدون تغییر پیشفرضِ سرویس.
|
||||
*/
|
||||
class ServiceModeSectionDurationTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0:\App\Auth\Entity\User,1:Doctor,2:string} */
|
||||
private function serviceDoctor(int $serviceMinutes = 30): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر سرویس');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$date = date('Y-m-d', strtotime('tomorrow'));
|
||||
$dayKey = (string) (((int) date('w', strtotime($date)) + 1) % 7);
|
||||
$schedule = new WeeklySchedule($doctor, [
|
||||
$dayKey => ['sessions' => [[
|
||||
'active' => true, 'start_time' => '15:00', 'end_time' => '19:00',
|
||||
'duration_per_patient' => 20, 'location_id' => 1,
|
||||
]]],
|
||||
]);
|
||||
$schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => 0]);
|
||||
$this->em->persist($schedule);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor, $date];
|
||||
}
|
||||
|
||||
private function service(Doctor $doctor, string $name, int $minutes): ServiceItem
|
||||
{
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name);
|
||||
$this->em->persist($section);
|
||||
$item = new ServiceItem($section, $name, 0);
|
||||
$item->setDurationMinutes($minutes)->setBookable(true);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
public function testBookingServicesReturnsSection(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->serviceDoctor();
|
||||
$svc = $this->service($doctor, 'بوتاکس', 30);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/appointment-booking-services/' . $doctor->getUuid(), $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$row = $res['data']['services'][0];
|
||||
self::assertSame($svc->getUuid(), $row['uuid']);
|
||||
self::assertArrayHasKey('service_section', $row);
|
||||
self::assertSame($svc->getSection()->getUuid(), $row['service_section']['uuid']);
|
||||
self::assertSame($svc->getSection()->getName(), $row['service_section']['name']);
|
||||
}
|
||||
|
||||
public function testServiceSlotsHonorsDurationOverride(): void
|
||||
{
|
||||
[$owner, $doctor, $date] = $this->serviceDoctor();
|
||||
$svc = $this->service($doctor, 'فیلر', 30);
|
||||
|
||||
// بدون override: مدت کل = ۳۰
|
||||
$base = $this->authJson('GET', sprintf(
|
||||
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s',
|
||||
$doctor->getUuid(), $date, $svc->getUuid()
|
||||
), $owner);
|
||||
self::assertSame(30, $base['data']['total_duration_minutes']);
|
||||
|
||||
// با override = ۹۰
|
||||
$over = $this->authJson('GET', sprintf(
|
||||
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s&durations[%s]=90',
|
||||
$doctor->getUuid(), $date, $svc->getUuid(), $svc->getUuid()
|
||||
), $owner);
|
||||
self::assertSame(90, $over['data']['total_duration_minutes']);
|
||||
|
||||
// پیشفرضِ سرویس در DB تغییر نکرده
|
||||
$this->em->clear();
|
||||
$reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $svc->getUuid()]);
|
||||
self::assertSame(30, $reloaded->getDurationMinutes());
|
||||
}
|
||||
|
||||
public function testCreateAppliesDurationOverrideToSlotEnd(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->serviceDoctor();
|
||||
$svc = $this->service($doctor, 'لیزر', 30);
|
||||
|
||||
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
||||
$nc = '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $start + 60, // نادیده گرفته میشود (بازمحاسبه)
|
||||
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
||||
'patient_name' => 'بیمار تست',
|
||||
'patient_national_code' => $nc,
|
||||
'service_item_uuids' => [$svc->getUuid()],
|
||||
'duration_from_services' => true,
|
||||
'service_durations' => [$svc->getUuid() => 75],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$appt = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $res['data']['uuid']]);
|
||||
// slot_end = start + 75 دقیقه (override)، نه ۳۰ پیشفرض
|
||||
self::assertSame($start + 75 * 60, $appt->getSlotEnd());
|
||||
|
||||
// پیشفرضِ سرویس دستنخورده
|
||||
$reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $svc->getUuid()]);
|
||||
self::assertSame(30, $reloaded->getDurationMinutes());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user