feat(appointment): enhance booking window functionality with day/week/month options and update defaults
This commit is contained in:
@@ -56,13 +56,19 @@ type NewScheduleMap = Record<string, NewDayConfig>;
|
||||
interface BookingMeta {
|
||||
online_booking_enabled: boolean;
|
||||
booking_window_value: number;
|
||||
booking_window_unit: 'week' | 'month';
|
||||
booking_window_unit: BookingWindowUnit;
|
||||
booking_mode: 'slot' | 'service';
|
||||
buffer_minutes: number;
|
||||
}
|
||||
type BookingWindowUnit = 'day' | 'week' | 'month';
|
||||
const BOOKING_WINDOW_UNITS: { value: BookingWindowUnit; label: string }[] = [
|
||||
{ value: 'day', label: 'روز' },
|
||||
{ value: 'week', label: 'هفته' },
|
||||
{ value: 'month', label: 'ماه' },
|
||||
];
|
||||
const DEFAULT_BOOKING_META: BookingMeta = {
|
||||
online_booking_enabled: true,
|
||||
booking_window_value: 1,
|
||||
booking_window_value: 3,
|
||||
booking_window_unit: 'month',
|
||||
booking_mode: 'slot',
|
||||
buffer_minutes: 0,
|
||||
@@ -778,9 +784,9 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
|
||||
/>
|
||||
<div style={{ width: 110 }}>
|
||||
<GlobalSearchableSelect
|
||||
options={[{ value: 'week', label: 'هفته' }, { value: 'month', label: 'ماه' }]}
|
||||
options={BOOKING_WINDOW_UNITS}
|
||||
value={meta.booking_window_unit}
|
||||
onChange={(v) => setMeta(m => ({ ...m, booking_window_unit: (v as 'week' | 'month') }))}
|
||||
onChange={(v) => setMeta(m => ({ ...m, booking_window_unit: (v as BookingWindowUnit) }))}
|
||||
isDisabled={!meta.online_booking_enabled}
|
||||
height={38}
|
||||
/>
|
||||
|
||||
@@ -140,11 +140,11 @@ Create or update the weekly schedule for a doctor (upsert).
|
||||
|-------|------|----------|-------------|
|
||||
| `online_booking_enabled` | boolean | ❌ | `false` = no online booking; the public slot/month endpoints return no availability |
|
||||
| `booking_window_value` | integer | ❌ | How far ahead patients may book (≥ 1) |
|
||||
| `booking_window_unit` | string | ❌ | `"week"` or `"month"` |
|
||||
| `booking_window_unit` | string | ❌ | `"day"`, `"week"` or `"month"` (invalid value keeps the current one) |
|
||||
| `booking_mode` | string | ❌ | `"slot"` (پیشفرض) = نوبتدهی اسلاتی با مدت ثابت (`duration_per_patient`). `"service"` = مدت هر نوبت از `duration_minutes` سرویسِ انتخابشده؛ زمانها با `GET /api/v1/appointment-service-slots` گرفته میشوند. مقدار نامعتبر نادیده گرفته میشود |
|
||||
| `buffer_minutes` | integer | ❌ | فقط حالت سرویسی: فاصلهٔ بین نوبتها (دقیقه، ≥ 0). در `slot_end` ذخیره نمیشود؛ فقط فاصلهٔ بین زمانهای پیشنهادی |
|
||||
|
||||
> Defaults when `meta` is absent: `{ online_booking_enabled: true, booking_window_value: 1, booking_window_unit: "month", booking_mode: "slot", buffer_minutes: 0 }`. `meta` is stored inside the schedule `setting` JSON (no DB migration) and is **preserved** when only `schedule` is sent. `SlotCalculatorService` rejects any date in the past, beyond `today + value unit`, or when online booking is disabled — for the weekly schedule, date overrides, and `appointment-slots` alike.
|
||||
> Defaults when `meta` is absent: `{ online_booking_enabled: true, booking_window_value: 3, booking_window_unit: "month", booking_mode: "slot", buffer_minutes: 0 }`. `meta` is stored inside the schedule `setting` JSON (no DB migration) and is **preserved** when only `schedule` is sent. `SlotCalculatorService` rejects any date in the past, beyond `today + value unit`, or when online booking is disabled — for the weekly schedule, date overrides, and `appointment-slots` alike.
|
||||
>
|
||||
> **اجبار حالت سرویسی:** اگر `booking_mode = service` ذخیره شود ولی پزشک هیچ سرویسِ «نمایش در نوبتدهی» (`bookable = true`) نداشته باشد، `POST`/`PATCH` برنامهٔ هفتگی با `422` (`ERR_VALIDATION_001`, field `booking_mode`) رد میشود.
|
||||
>
|
||||
@@ -185,7 +185,7 @@ Create or update the weekly schedule for a doctor (upsert).
|
||||
},
|
||||
"meta": {
|
||||
"online_booking_enabled": true,
|
||||
"booking_window_value": 1,
|
||||
"booking_window_value": 3,
|
||||
"booking_window_unit": "month",
|
||||
"booking_mode": "slot",
|
||||
"buffer_minutes": 0
|
||||
|
||||
@@ -170,7 +170,7 @@ Which days of a month are bookable — used by the public calendar to grey out u
|
||||
"disabled_dates": ["2026-06-01", "2026-06-17", "2026-06-26"],
|
||||
"enabled_dates": ["2026-06-15", "2026-06-16", "2026-06-18"],
|
||||
"online_booking_enabled": true,
|
||||
"booking_window": { "value": 1, "unit": "month" }
|
||||
"booking_window": { "value": 3, "unit": "month" }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -180,7 +180,7 @@ Which days of a month are bookable — used by the public calendar to grey out u
|
||||
| `disabled_dates` | string[] | `Y-m-d` days with no bookable slot (holiday / closed override / non-working / past / out-of-window) |
|
||||
| `enabled_dates` | string[] | `Y-m-d` days with at least one slot |
|
||||
| `online_booking_enabled` | boolean | Doctor's online-booking flag |
|
||||
| `booking_window` | object | `{ value, unit }` — `unit` is `week` or `month` |
|
||||
| `booking_window` | object | `{ value, unit }` — `unit` is `day`, `week` or `month` (default `3 month`) |
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|
||||
@@ -27,9 +27,12 @@ class WeeklySchedule
|
||||
public const MODE_SLOT = 'slot'; // نوبتدهی اسلاتی (رفتار پیشفرض)
|
||||
public const MODE_SERVICE = 'service'; // نوبتدهی بر اساس مدت سرویس
|
||||
|
||||
/** واحدهای مجاز بازهٔ رزرو آنلاین؛ همان کلیدواژههای strtotime. */
|
||||
public const BOOKING_WINDOW_UNITS = ['day', 'week', 'month'];
|
||||
|
||||
public const DEFAULT_META = [
|
||||
'online_booking_enabled' => true,
|
||||
'booking_window_value' => 1,
|
||||
'booking_window_value' => 3,
|
||||
'booking_window_unit' => 'month',
|
||||
'booking_mode' => self::MODE_SLOT,
|
||||
'buffer_minutes' => 0,
|
||||
@@ -128,7 +131,7 @@ class WeeklySchedule
|
||||
$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)
|
||||
'booking_window_unit' => in_array($meta['booking_window_unit'] ?? null, self::BOOKING_WINDOW_UNITS, true)
|
||||
? $meta['booking_window_unit']
|
||||
: $current['booking_window_unit'],
|
||||
'booking_mode' => in_array($meta['booking_mode'] ?? null, [self::MODE_SLOT, self::MODE_SERVICE], true)
|
||||
|
||||
@@ -263,10 +263,13 @@ class SlotCalculatorService
|
||||
return false;
|
||||
}
|
||||
|
||||
/** آخرین روزی که رزرو عمومی روی آن مجاز است (شروعِ آن روز، 00:00). */
|
||||
private function bookingWindowEnd(array $meta): int
|
||||
{
|
||||
$value = max(1, (int) ($meta['booking_window_value'] ?? 1));
|
||||
$unit = ($meta['booking_window_unit'] ?? 'month') === 'week' ? 'week' : 'month';
|
||||
$value = max(1, (int) ($meta['booking_window_value'] ?? WeeklySchedule::DEFAULT_META['booking_window_value']));
|
||||
$unit = in_array($meta['booking_window_unit'] ?? null, WeeklySchedule::BOOKING_WINDOW_UNITS, true)
|
||||
? $meta['booking_window_unit']
|
||||
: WeeklySchedule::DEFAULT_META['booking_window_unit'];
|
||||
|
||||
return (int) strtotime("today +{$value} {$unit} 00:00:00");
|
||||
}
|
||||
@@ -309,11 +312,7 @@ class SlotCalculatorService
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = max(1, (int)($meta['booking_window_value'] ?? 1));
|
||||
$unit = ($meta['booking_window_unit'] ?? 'month') === 'week' ? 'week' : 'month';
|
||||
$maxStart = (int) strtotime("today +{$value} {$unit} 00:00:00");
|
||||
|
||||
return $dayStart <= $maxStart;
|
||||
return $dayStart <= $this->bookingWindowEnd($meta);
|
||||
}
|
||||
|
||||
private function getBookingMeta(Doctor $doctor, ?Clinic $clinic): array
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Appointment\Service\SlotCalculatorService;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* بازهٔ مجاز رزرو آنلاین (booking_window) با سه واحد روز/هفته/ماه و پیشفرض «۳ ماه».
|
||||
*/
|
||||
class BookingWindowUnitTest extends ApiTestCase
|
||||
{
|
||||
/** پزشکی با شیفت فعال در هر هفت روز هفته، تا هر تاریخِ آزمون اسلات داشته باشد. */
|
||||
private function makeAlwaysOpenDoctor(array $meta = []): Doctor
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر بازهٔ رزرو');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$days = [];
|
||||
foreach (range(0, 6) as $dayKey) {
|
||||
$days[(string) $dayKey] = ['sessions' => [[
|
||||
'active' => true,
|
||||
'start_time' => '10:00',
|
||||
'end_time' => '12:00',
|
||||
'duration_per_patient' => 30,
|
||||
'location_id' => 1,
|
||||
]]];
|
||||
}
|
||||
|
||||
$schedule = new WeeklySchedule($doctor, $days);
|
||||
if ($meta !== []) {
|
||||
$schedule->setMeta($meta);
|
||||
}
|
||||
$this->em->persist($schedule);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
private function assertBookable(Doctor $doctor, string $relative): void
|
||||
{
|
||||
$calc = static::getContainer()->get(SlotCalculatorService::class);
|
||||
$date = date('Y-m-d', strtotime($relative));
|
||||
|
||||
$this->assertNotSame([], $calc->getAllSlotsWithAvailability($doctor, $date), "باید داخل بازه باشد: {$relative}");
|
||||
}
|
||||
|
||||
private function assertOutsideWindow(Doctor $doctor, string $relative): void
|
||||
{
|
||||
$calc = static::getContainer()->get(SlotCalculatorService::class);
|
||||
$date = date('Y-m-d', strtotime($relative));
|
||||
|
||||
$this->assertSame([], $calc->getAllSlotsWithAvailability($doctor, $date), "باید بیرون بازه باشد: {$relative}");
|
||||
$this->assertSame(SlotCalculatorService::EMPTY_OUTSIDE_WINDOW, $calc->explainEmptyDay($doctor, $date));
|
||||
}
|
||||
|
||||
public function testDayUnitLimitsWindowToExactDays(): void
|
||||
{
|
||||
$doctor = $this->makeAlwaysOpenDoctor([
|
||||
'booking_window_value' => 2,
|
||||
'booking_window_unit' => 'day',
|
||||
]);
|
||||
|
||||
$this->assertBookable($doctor, '+2 days');
|
||||
$this->assertOutsideWindow($doctor, '+3 days');
|
||||
}
|
||||
|
||||
public function testWeekUnitStillSupported(): void
|
||||
{
|
||||
$doctor = $this->makeAlwaysOpenDoctor([
|
||||
'booking_window_value' => 1,
|
||||
'booking_window_unit' => 'week',
|
||||
]);
|
||||
|
||||
$this->assertBookable($doctor, '+7 days');
|
||||
$this->assertOutsideWindow($doctor, '+8 days');
|
||||
}
|
||||
|
||||
public function testDefaultWindowIsThreeMonths(): void
|
||||
{
|
||||
$this->assertSame(3, WeeklySchedule::DEFAULT_META['booking_window_value']);
|
||||
$this->assertSame('month', WeeklySchedule::DEFAULT_META['booking_window_unit']);
|
||||
|
||||
$doctor = $this->makeAlwaysOpenDoctor();
|
||||
|
||||
$this->assertBookable($doctor, '+2 months');
|
||||
$this->assertOutsideWindow($doctor, '+3 months +2 days');
|
||||
}
|
||||
|
||||
public function testInvalidUnitKeepsCurrentValue(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر واحد نامعتبر');
|
||||
$schedule = new WeeklySchedule($doctor, []);
|
||||
|
||||
$schedule->setMeta(['booking_window_unit' => 'day', 'booking_window_value' => 10]);
|
||||
$schedule->setMeta(['booking_window_unit' => 'year']);
|
||||
|
||||
$this->assertSame('day', $schedule->getMeta()['booking_window_unit']);
|
||||
$this->assertSame(10, $schedule->getMeta()['booking_window_value']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user