feat(appointment): store online-booking window in WeeklySchedule meta
Add a reserved meta key inside WeeklySchedule.setting (no migration) holding online_booking_enabled and a booking_window value+unit (week|month), with sane defaults. Expose meta separately in toArray() and keep it out of the day schedule map. setSetting now preserves meta when the day schedule is replaced; the weekly-schedule POST/PATCH endpoints accept an optional meta. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
|||||||
|
# تنظیمات نوبتدهی آنلاین دکتر: بازهی رزرو (booking window) + endpoint دردسترسبودن ماهانه
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`clinicpro` (Backend + Admin React panel). **این پرامپت اول اجرا شود.**
|
||||||
|
|
||||||
|
> **Cross-repo:** قرارداد API این پرامپت توسط سایت عمومی مصرف میشود. پرامپت همتا در سمت فرانت:
|
||||||
|
> `nobat724_front/.claude/prompt/appointment-calendar-disabled-dates.md`
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
پزشک در پنل ادمین (`assets/admin/pages/DoctorDetailPage.tsx`) از قبل میتواند **برنامهی هفتگی**، **date override** (روز تعطیل/سفارشی) و **تعطیلات (holidays)** را تنظیم کند. `SlotCalculatorService` هم اینها را با اولویت درست اعمال میکند (holiday → override → weekly). پس «۲۷/۰۳/۱۴۰۵ تعطیل است» همین حالا با یک date override (`active:false`) قابل تنظیم است و `appointment-slots` برای آن روز آرایهی خالی برمیگرداند.
|
||||||
|
|
||||||
|
اما **دو چیز وجود ندارد:**
|
||||||
|
|
||||||
|
1. **بازهی رزرو آنلاین (booking window):** پزشک نمیتواند تعیین کند «تا چند هفته/ماه جلوتر بیمار میتواند آنلاین نوبت بگیرد» و نمیتواند نوبتدهی آنلاین را کلاً خاموش کند. الان هیچ سقفی نیست و `appointment-slots` برای هر تاریخ آیندهای اسلات میدهد.
|
||||||
|
2. **دردسترسبودن ماهانه (month availability):** سایت عمومی برای خاکستریکردن روزهای تعطیل/خارجازبازه روی تقویم، باید بداند کدام روزهای یک ماه قابلانتخاباند. الان فقط endpoint تکروزه (`appointment-slots`) هست؛ صدازدن آن برای ۳۰ روز سنگین است. یک endpoint که برای یک ماه، آرایهی روزهای غیرفعال را برگرداند لازم است.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
۱. افزودن تنظیمات «نوبتدهی آنلاین» به پزشک: `online_booking_enabled` (boolean) و `booking_window` (عدد + واحد هفته/ماه). بدون migration — داخل همان `WeeklySchedule.setting` JSON ذخیره شود.
|
||||||
|
۲. اعمال این بازه در `SlotCalculatorService`: اگر نوبتدهی آنلاین خاموش است یا تاریخ خارج از بازه است → اسلات خالی.
|
||||||
|
۳. endpoint عمومی جدید: `GET /api/v1/appointment-settings/month-availability/{doctorUuid}?year=&month=` که برای یک ماه شمسی **یا** میلادی، روزهای غیرفعال (تعطیل/override بسته/خارجازبازه/بدون session) را برگرداند تا تقویم سایت آنها را غیرقابلانتخاب کند.
|
||||||
|
۴. UI در پنل ادمین (`DoctorDetailPage.tsx`) برای تنظیم این دو مقدار.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `src/Appointment/Entity/WeeklySchedule.php` | `setting` JSON — محل ذخیرهی booking window (بدون فیلد جدید DB) |
|
||||||
|
| `src/Appointment/Service/SlotCalculatorService.php` | منطق محاسبهی اسلات — اعمال window |
|
||||||
|
| `src/Appointment/Controller/AppointmentController.php` | `appointment-slots` (تکروز) — باید window را respect کند |
|
||||||
|
| `src/Appointment/Controller/AppointmentSettingsController.php` | weekly-schedule upsert + endpoint جدید month-availability |
|
||||||
|
| `src/Appointment/Repository/WeeklyScheduleRepository.php` | `findByDoctor` |
|
||||||
|
| `docs/api/appointment-settings.md` | مستندسازی window + endpoint جدید |
|
||||||
|
| `docs/api/appointment.md` | اگر رفتار `appointment-slots` تغییر کرد |
|
||||||
|
| `assets/admin/pages/DoctorDetailPage.tsx` | UI تنظیم booking window + toggle آنلاین |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
### `WeeklySchedule.php` — ذخیرهسازی در JSON
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $setting = [];
|
||||||
|
```
|
||||||
|
|
||||||
|
> `setting` الان فقط کلیدهای `"0".."6"` (روزهای هفته) را دارد. میتوان یک کلید رزروشدهی غیرعددی مثل `"meta"` اضافه کرد بدون اینکه `SlotCalculatorService` که فقط کلیدهای عددی روز را میخواند، بشکند.
|
||||||
|
|
||||||
|
### `SlotCalculatorService::buildAllSessions()` — ترتیب اولویت فعلی
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function buildAllSessions(Doctor $doctor, string $date): array
|
||||||
|
{
|
||||||
|
$dayStart = (int) strtotime($date . ' 00:00:00');
|
||||||
|
$dayEnd = $dayStart + 86400;
|
||||||
|
|
||||||
|
// 1. Blocked by holiday
|
||||||
|
if (!empty($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1))) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// 2. Date override
|
||||||
|
foreach ($this->overrideRepo->findByDoctor($doctor) as $override) {
|
||||||
|
if (date('Y-m-d', $override->getDate()) === $date) {
|
||||||
|
if (!$override->isActive()) return [];
|
||||||
|
return $this->buildSessionsFromOverride($override->getSetting() ?? [], $dayStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. Weekly schedule
|
||||||
|
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||||
|
if ($schedule === null) return [];
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `appointment-slots` controller — بدون چک window
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[Route('/api/v1/appointment-slots', methods: ['GET'])]
|
||||||
|
public function slots(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$doctorUuid = trim($request->query->get('doctor_uuid', ''));
|
||||||
|
$date = trim($request->query->get('date', ''));
|
||||||
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||||
|
// ... validation ...
|
||||||
|
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date);
|
||||||
|
return $this->success([...]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `DoctorDetailPage.tsx` — schedule از قبل ذخیره میشود
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const saveMut = useMutation({
|
||||||
|
mutationFn: () => existing
|
||||||
|
? api.patch(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap })
|
||||||
|
: api.post('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap }),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
اجرای مرحلهبهمرحله؛ بعد از هر قابلیت تست (PHP lint + migrate در صورت نیاز + admin build) و سپس commit جدا.
|
||||||
|
|
||||||
|
### ۱. مدل booking window در `WeeklySchedule.setting`
|
||||||
|
|
||||||
|
- ساختار جدید زیر کلید رزروشده `"meta"` (یا `"settings"`) در `setting`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"0": { "sessions": [...] },
|
||||||
|
"...": {},
|
||||||
|
"meta": {
|
||||||
|
"online_booking_enabled": true,
|
||||||
|
"booking_window_value": 2,
|
||||||
|
"booking_window_unit": "month"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `booking_window_unit`: یکی از `"week"` یا `"month"`. `booking_window_value`: عدد مثبت (مثلاً ۲ هفته یا ۲ ماه).
|
||||||
|
- پیشفرض وقتی `meta` نیست: نوبتدهی آنلاین **روشن**، بازهی پیشفرض (مثلاً ۱ ماه) — این پیشفرض را بهصورت ثابت در سرویس تعریف کن و در پاسخها هم برگردان تا فرانت بداند.
|
||||||
|
- weekly-schedule controller (POST/PATCH) باید فیلدهای `meta` را در صورت ارسال بپذیرد و ذخیره کند (validation: `unit ∈ {week, month}`، `value ≥ 1`). کلیدهای عددی روز دستنخورده بمانند.
|
||||||
|
|
||||||
|
### ۲. اعمال window در `SlotCalculatorService`
|
||||||
|
|
||||||
|
- یک متد private مثل `isWithinBookingWindow(Doctor $doctor, string $date): bool` بساز:
|
||||||
|
- `meta` را از `schedule->getSetting()['meta']` بخوان (با fallback پیشفرض).
|
||||||
|
- اگر `online_booking_enabled === false` → خارج از بازه (false).
|
||||||
|
- سقف را با `strtotime("+{$value} {$unit}", today)` حساب کن؛ اگر `dayStart` بعد از سقف بود → false. تاریخهای گذشته هم false (همین حالا فرانت گذشته را میبندد ولی backend هم باید مقاوم باشد).
|
||||||
|
- در `buildAllSessions` **قبل از** بازگشت اسلاتها این چک را اعمال کن: اگر خارج از بازه → `return []`.
|
||||||
|
- توجه: تاریخهای یونیکس صحیح (نه DateTime object).
|
||||||
|
|
||||||
|
### ۳. endpoint عمومی month-availability
|
||||||
|
|
||||||
|
- متد جدید در `AppointmentSettingsController` با route:
|
||||||
|
`GET /api/v1/appointment-settings/month-availability/{doctorUuid}` — **Permission: PUBLIC** (مثل `available-locations`).
|
||||||
|
- Query params: `year` و `month` (عددی). چون تقویم سایت شمسی است، **هر دو حالت را بپذیر**: اگر `calendar=jalali` آمد ورودی را شمسی تفسیر کن، در غیر این صورت میلادی. (یا سادهتر: همیشه میلادی بگیر و در پرامپت فرانت تبدیل شمسی→میلادی انجام شود — یکی را انتخاب کن و دقیق مستند کن.)
|
||||||
|
- برای هر روز ماه، با همان منطق `SlotCalculatorService` (holiday/override/window/weekly) تعیین کن آیا روز اسلات دارد یا نه. **برای پرهیز از سنگینی، یک متد سبک در سرویس اضافه کن** مثل `hasAnyAvailability(Doctor, string $date): bool` که فقط وجود session را چک کند (نه ساخت کامل اسلاتها).
|
||||||
|
- پاسخ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"year": 2026,
|
||||||
|
"month": 6,
|
||||||
|
"disabled_dates": ["2026-06-16", "2026-06-17", "2026-06-20"],
|
||||||
|
"enabled_dates": ["2026-06-21", "2026-06-22"],
|
||||||
|
"online_booking_enabled": true,
|
||||||
|
"booking_window": { "value": 2, "unit": "month" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `disabled_dates`: روزهایی که تعطیل/خارجازبازه/بدون sessionاند (غیرقابلانتخاب). فرمت `Y-m-d`. فرانت اینها را روی تقویم خاکستری میکند.
|
||||||
|
|
||||||
|
### ۴. UI در پنل ادمین (`DoctorDetailPage.tsx`)
|
||||||
|
|
||||||
|
- در بخش تنظیمات برنامهی هفتگی، یک کارت/سکشن «نوبتدهی آنلاین» اضافه کن:
|
||||||
|
- یک toggle برای `online_booking_enabled`.
|
||||||
|
- یک input عددی + select واحد (`هفته`/`ماه`) برای بازه.
|
||||||
|
- مقدار اولیه را از پاسخ `weekly-schedule` (`data?.data?.data?.meta`) بخوان (double-nested).
|
||||||
|
- در `saveMut` همان weekly-schedule، فیلد `meta` را هم به payload اضافه کن (`{ schedule: scheduleMap, meta: {...} }` یا داخل خود `scheduleMap` با کلید `meta`).
|
||||||
|
- از کلاسهای CSS موجود همان صفحه استفاده کن؛ RTL؛ بدون کتابخانهی جدید.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **بدون migration:** booking window در `WeeklySchedule.setting` JSON میرود. اگر ترجیح میدهی فیلد مجزا در DB باشد، **متوقف شو و بپرس** (نیاز به migration دارد).
|
||||||
|
- همهی controllerها از `BaseController`؛ پاسخها `$this->success()`/`$this->error()`. خطاها با `AppException(ErrorCodes::ERR_XXX, ...)`.
|
||||||
|
- `appointment-slots` تکروزه هم باید window را respect کند (وگرنه بیمار میتواند تاریخ خارجازبازه را مستقیم صدا بزند و اسلات بگیرد).
|
||||||
|
- `month-availability` باید **public** باشد (تقویم سایت قبل از لاگین لود میشود) — مثل `available-locations`.
|
||||||
|
- پاسخ weekly-schedule **double-nested** است (`data.data.data`) — UI ادمین با همین الگو میخواند.
|
||||||
|
- اولویت منطق دستنخورده بماند: holiday > override > window > weekly. window نباید روی اسلاتهای گذشتهی همین ماه تأثیر اشتباه بگذارد.
|
||||||
|
- بعد از تغییر API، **هم `docs/api/appointment-settings.md` و هم (در صورت تغییر رفتار) `docs/api/appointment.md`** را در همین session بهروز کن: endpoint جدید با method/path/permission/query/response، و فیلدهای `meta`.
|
||||||
|
- تست: `ddev exec php -l ...` روی فایلهای PHP؛ `ddev exec php bin/console debug:router | grep month-availability`؛ یکبار واقعی `curl` روی `month-availability` با پزشک تست `4a0594b1-008b-478a-a593-259b95d8c2dd`؛ و `ddev exec yarn dev` برای build پنل ادمین. سپس commit.
|
||||||
@@ -61,6 +61,10 @@ class AppointmentSettingsController extends BaseController
|
|||||||
$schedule = new WeeklySchedule($doctor, $data['schedule'] ?? []);
|
$schedule = new WeeklySchedule($doctor, $data['schedule'] ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($data['meta']) && is_array($data['meta'])) {
|
||||||
|
$schedule->setMeta($data['meta']);
|
||||||
|
}
|
||||||
|
|
||||||
$this->scheduleRepo->save($schedule);
|
$this->scheduleRepo->save($schedule);
|
||||||
|
|
||||||
return $this->success(['data' => $schedule->toArray()], 201);
|
return $this->success(['data' => $schedule->toArray()], 201);
|
||||||
@@ -88,6 +92,9 @@ class AppointmentSettingsController extends BaseController
|
|||||||
if (isset($data['schedule'])) {
|
if (isset($data['schedule'])) {
|
||||||
$schedule->setSetting($data['schedule']);
|
$schedule->setSetting($data['schedule']);
|
||||||
}
|
}
|
||||||
|
if (isset($data['meta']) && is_array($data['meta'])) {
|
||||||
|
$schedule->setMeta($data['meta']);
|
||||||
|
}
|
||||||
|
|
||||||
$this->scheduleRepo->save($schedule);
|
$this->scheduleRepo->save($schedule);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ class WeeklySchedule
|
|||||||
{
|
{
|
||||||
public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
|
public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
|
||||||
|
|
||||||
|
public const META_KEY = 'meta';
|
||||||
|
public const DEFAULT_META = [
|
||||||
|
'online_booking_enabled' => true,
|
||||||
|
'booking_window_value' => 1,
|
||||||
|
'booking_window_unit' => 'month',
|
||||||
|
];
|
||||||
|
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column(type: 'integer')]
|
#[ORM\Column(type: 'integer')]
|
||||||
@@ -48,14 +55,51 @@ class WeeklySchedule
|
|||||||
public function getDoctor(): Doctor { return $this->doctor; }
|
public function getDoctor(): Doctor { return $this->doctor; }
|
||||||
public function getSetting(): array { return $this->setting; }
|
public function getSetting(): array { return $this->setting; }
|
||||||
|
|
||||||
public function setSetting(array $setting): self { $this->setting = $setting; $this->updatedAt = time(); return $this; }
|
public function setSetting(array $setting): self
|
||||||
|
{
|
||||||
|
$meta = $this->setting[self::META_KEY] ?? null;
|
||||||
|
unset($setting[self::META_KEY]);
|
||||||
|
if ($meta !== null) {
|
||||||
|
$setting[self::META_KEY] = $meta;
|
||||||
|
}
|
||||||
|
$this->setting = $setting;
|
||||||
|
$this->updatedAt = time();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMeta(): array
|
||||||
|
{
|
||||||
|
return array_merge(self::DEFAULT_META, $this->setting[self::META_KEY] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMeta(array $meta): self
|
||||||
|
{
|
||||||
|
$current = $this->getMeta();
|
||||||
|
$this->setting[self::META_KEY] = [
|
||||||
|
'online_booking_enabled' => (bool)($meta['online_booking_enabled'] ?? $current['online_booking_enabled']),
|
||||||
|
'booking_window_value' => max(1, (int)($meta['booking_window_value'] ?? $current['booking_window_value'])),
|
||||||
|
'booking_window_unit' => in_array($meta['booking_window_unit'] ?? null, ['week', 'month'], true)
|
||||||
|
? $meta['booking_window_unit']
|
||||||
|
: $current['booking_window_unit'],
|
||||||
|
];
|
||||||
|
$this->updatedAt = time();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDaySchedule(): array
|
||||||
|
{
|
||||||
|
$schedule = $this->setting;
|
||||||
|
unset($schedule[self::META_KEY]);
|
||||||
|
return $schedule;
|
||||||
|
}
|
||||||
|
|
||||||
public function toArray(): array
|
public function toArray(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'uuid' => $this->uuid,
|
'uuid' => $this->uuid,
|
||||||
'doctor_uuid' => $this->doctor->getUuid(),
|
'doctor_uuid' => $this->doctor->getUuid(),
|
||||||
'schedule' => $this->setting,
|
'schedule' => $this->getDaySchedule(),
|
||||||
|
'meta' => $this->getMeta(),
|
||||||
'created_at' => $this->createdAt,
|
'created_at' => $this->createdAt,
|
||||||
'updated_at' => $this->updatedAt,
|
'updated_at' => $this->updatedAt,
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user