feat: implement service mode completion for nobat724_front

- Add task for completing service mode in clinicpro with detailed objectives and acceptance criteria.
- Create architecture documentation for task 00b, outlining involved components and necessary changes.
- Develop checklist for task 00b to ensure all requirements are met.
- Document implementation notes for task 00b, emphasizing API contract checks and design system adherence.
- Update task documentation for task 00b, specifying goals and current issues with service mode.
This commit is contained in:
hamed
2026-07-30 11:56:08 +03:30
parent 021d0eb6b2
commit 158dcb58aa
12 changed files with 1846 additions and 0 deletions
@@ -0,0 +1,332 @@
# معماری — تسک ۰۰
## ساختار فایل
```
src/Appointment/
├── Service/
│ ├── ServiceBookingCalculator.php # جدید — تنها مرجع «مدت مجاز یک ترکیب سرویس»
│ ├── ServiceRescheduleService.php # جدید — جابه‌جایی سرویس‌آگاه
│ ├── ReserveConversionService.php # جدید — تبدیل رزرو به نوبت
│ └── SlotCalculatorService.php # ⛔ فقط افزودن، بدون تغییر متدهای موجود
└── Controller/AppointmentController.php # توسعهٔ PATCH + دو route جدید
assets/admin/
├── pages/AppointmentEditPage.tsx # توسعه: حالت سرویسی
├── pages/ReserveAppointmentsPage.tsx # توسعه: سرویس‌ها + تبدیل
├── components/appointments/ServiceSlotPicker.tsx # موجود — استفادهٔ دوباره، بدون تغییر رفتار
└── hooks/useDoctorBookingServices.ts # موجود — استفادهٔ دوباره
```
## `ServiceBookingCalculator` — استخراج منطق تکرارشده
منطق «چند سرویس → مدت کل» امروز **داخل کنترلر** است
([AppointmentController::serviceSlots](../../../src/Appointment/Controller/AppointmentController.php#L184)):
```php
// وضعیت فعلی — درون کنترلر، تکرارشدنی
$totalMinutes = 0;
foreach ($uuids as $u) {
$item = $this->itemRepo->findByUuid($u);
if ($item === null) { return $this->error(, 'سرویس یافت نشد', 422, ); }
if (!$item->isBookable()) { return $this->error(, 'این سرویس برای نوبت‌دهی فعال نیست', 422, ); }
$duration = isset($overrides[$u]) && (int)$overrides[$u] > 0
? (int) $overrides[$u]
: (int) ($item->getDurationMinutes() ?? 0);
if ($duration <= 0) { return $this->error(, 'مدت سرویس تعریف نشده است', 422, ); }
$totalMinutes += $duration;
}
```
سه مصرف‌کنندهٔ جدید (PATCH، reschedule، convert-reserve) به همین محاسبه نیاز دارند.
کپی‌کردنش یعنی چهار نسخه با چهار رفتار مرزی متفاوت.
```php
final class ServiceBookingCalculator
{
public function __construct(
private readonly ServiceItemRepository $items,
private readonly WeeklyScheduleRepository $schedules,
private readonly TenantOwnershipChecker $ownership,
) {}
/**
* مدت و بافرِ یک ترکیب سرویس. ترتیب بررسی عمداً: مالکیت محیط اول، بعد بقیه —
* وگرنه پیام خطا وجود و مدت سرویسِ محیط دیگر را لو می‌دهد.
*
* @param string[] $serviceUuids
* @param array<string,int> $durationOverrides override منشی، فقط برای همین محاسبه
*/
public function calculate(
Doctor $doctor,
?Clinic $clinic,
array $serviceUuids,
array $durationOverrides = [],
bool $allowInactive = false,
): ServiceBookingDuration;
/** آیا این محیط در حالت سرویسی است. */
public function isServiceMode(Doctor $doctor, ?Clinic $clinic): bool;
}
final readonly class ServiceBookingDuration
{
public function __construct(
public int $totalMinutes,
public int $bufferMinutes,
public array $serviceItems, // ServiceItem[] — به ترتیب ورودی
public array $warnings = [], // مثلاً سرویس غیرفعال در نوبت موجود
) {}
public function endFor(int $start): int { return $start + $this->totalMinutes * 60; }
}
```
`serviceSlots()` موجود هم باید از همین سرویس استفاده کند — ولی **خروجی‌اش بیت‌به‌بیت
همان بماند**. این refactor بی‌خطر است چون رفتار جمع ساده حفظ می‌شود؛ تست موجود
`ServiceModeSectionDurationTest` تضمینش است.
> ⚠️ جمعِ سادهٔ `+=` اشتباه است (مستند بند ۵) ولی **در این تسک اصلاح نمی‌شود**.
> اصلاحش تسک ۰۴ است (`DurationCalculator` با «زمان تنها / زمان اضافه»). اینجا فقط
> جای منطق عوض می‌شود، نه خودش. `ServiceBookingCalculator` نقطهٔ واحدی است که تسک ۰۴
> بعداً یک خط در آن عوض می‌کند.
## `PATCH /appointment/{uuid}` — توسعه، نه بازنویسی
```php
// وضعیت فعلی حفظ می‌شود؛ فقط یک شاخه اضافه می‌شود
if ($hasStart || $hasEnd) {
if (!($hasStart && $hasEnd)) { /* 422 موجود */ }
$newStart = ; $newEnd = ;
if ($newEnd <= $newStart) { /* 422 موجود */ }
// ── جدید: فقط در حالت سرویسی ──
if ($this->serviceCalc->isServiceMode($doctor, $clinic)) {
$uuids = $data['service_item_uuids'] ?? $appointment->currentServiceUuids();
$duration = $this->serviceCalc->calculate($doctor, $clinic, $uuids, allowInactive: true);
if ($newEnd !== $duration->endFor($newStart)) {
return $this->error(
ErrorCodes::ERR_SERVICE_DURATION_MISMATCH,
sprintf('مدت این نوبت باید %d دقیقه باشد', $duration->totalMinutes),
422, 'slot_end'
);
}
$appointment->replaceServiceItems($duration->serviceItems);
}
// ── پایان بخش جدید ──
if ($this->appointmentRepo->isSlotTaken()) { /* 409 موجود */ }
}
```
شرط `isServiceMode` تضمین می‌کند مسیر اسلاتی **یک بایت هم** رفتارش عوض نشود: در حالت
`slot` هیچ‌کدام از خطوط جدید اجرا نمی‌شوند.
`allowInactive: true` عمدی است: نوبت موجودی که سرویسش غیرفعال شده باید قابل جابه‌جایی
بماند. غیرفعال بودن با `warnings[]` برگردانده می‌شود، نه با `422`.
## `POST /appointment/{uuid}/service-reschedule` — مسیر ترجیحی
`PATCH` برای سازگاری توسعه یافت، ولی مسیر درست این است: کلاینت **مدت نمی‌فرستد**.
```
درخواست:
{
"start": 1754…, // فقط زمان شروع
"service_item_uuids": ["…", "…"], // اختیاری؛ نبود = همان سرویس‌های فعلی
"durations": { "uuid": 25 } // اختیاری، override منشی
}
پاسخ:
{
"success": true,
"data": {
"uuid": "…",
"slot_start": 1754…, "slot_end": 1754…,
"total_duration_minutes": 35,
"buffer_minutes": 10,
"warnings": []
}
}
```
```php
final class ServiceRescheduleService
{
public function reschedule(Appointment $appt, ServiceRescheduleRequest $req): Appointment
{
return $this->em->wrapInTransaction(function () use ($appt, $req) {
$doctor = $appt->getDoctor();
$clinic = $appt->getClinic();
if (!$this->serviceCalc->isServiceMode($doctor, $clinic)) {
throw new AppException(ErrorCodes::ERR_WRONG_BOOKING_MODE,
'این نوبت در حالت نوبت‌دهی سرویسی نیست', 422);
}
$duration = $this->serviceCalc->calculate($doctor, $clinic,
$req->serviceUuids ?? $appt->currentServiceUuids(), $req->overrides, allowInactive: true);
// زمان باید واقعاً در فهرست زمان‌های ممکن باشد — نه فقط «اشغال نیست»
$starts = $this->slotCalculator->getServiceStartTimes(
$doctor, date('Y-m-d', $req->start), $duration->totalMinutes,
$clinic, forManagement: $req->forManagement,
excludeAppointmentId: $appt->getId(), // ← پارامتر جدید، پیش‌فرض null
);
if (!in_array($req->start, array_column($starts, 'start'), true)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001,
'این زمان برای مدت انتخابی در دسترس نیست', 422, 'start');
}
$appt->reschedule($req->start, $duration->endFor($req->start));
$appt->replaceServiceItems($duration->serviceItems);
$appt->setServiceTotalMinutes($duration->totalMinutes);
$appt->setServiceBufferMinutes($duration->bufferMinutes);
$this->events->recordReschedule($appt); // AppointmentEvent موجود
return $appt;
});
}
}
```
### پارامتر `excludeAppointmentId` — تنها تغییر مجاز در `SlotCalculatorService`
```php
public function getServiceStartTimes(
Doctor $doctor, string $date, int $durationMinutes,
?Clinic $clinic = null, bool $forManagement = false,
?int $excludeAppointmentId = null, // ← جدید، پیش‌فرض null
): array
```
**چرا مجاز است:** پارامتر اختیاری با پیش‌فرض `null` است، و متد `getServiceStartTimes`
فقط در مسیر **سرویسی** استفاده می‌شود — نه در اسلاتی. هیچ فراخوانی موجودی رفتارش عوض
نمی‌شود.
**چرا لازم است:** بدون آن، نوبت در حال جابه‌جایی خودش را اشغال می‌بیند و زمان فعلی‌اش
هرگز در فهرست نمی‌آید. کاربر نمی‌تواند «همان ساعت، سرویس متفاوت» را ثبت کند.
پیاده‌سازی: `AppointmentRepository::findBusyIntervals()` هم همان پارامتر را می‌گیرد —
دقیقاً همان الگویی که `isSlotTaken($doctor, $start, $end, $excludeId)` از قبل دارد.
پس این الگو در کدبیس ثابت‌شده است، نه تازه.
## نوبت رزرو در حالت سرویسی
امروز: `NewAppointmentDrawer.tsx:72``serviceMode = bookingMode === 'service' && !isReserve`
تغییر: نوبت رزرو **هم** سرویس می‌پذیرد، ولی زمان نمی‌گیرد.
```
نوبت رزرو در حالت سرویسی:
slot_start = slot_end = نیمه‌شب روز (رفتار موجود، دست‌نخورده)
is_reserve = true (رفتار موجود)
service_items = سرویس‌های انتخابی ← جدید
service_total_minutes = مدت محاسبه‌شده ← جدید، برای تبدیل بعدی
active_slot_key = NULL (رفتار موجود — رزرو اسلات نمی‌گیرد)
```
`POST /appointment/{uuid}/convert-reserve`:
```
{ "start": 1754…, "service_item_uuids": [...] } ← سرویس‌ها اختیاری، پیش‌فرض همان‌های رزرو
├─ حالت اسلاتی: زمان باید در getAvailableSlots باشد
└─ حالت سرویسی: زمان باید در getServiceStartTimes(مدت) باشد
is_reserve = false · slot_start/end واقعی · active_slot_key بازتولید می‌شود
```
`Appointment::refreshActiveSlotKey()` موجود این را خودکار انجام می‌دهد چون
`isReserve` را می‌خواند — **بدون تغییر آن متد**. فقط `setIsReserve(false)` باید
`refreshActiveSlotKey()` را صدا بزند (اگر نمی‌زند، این تنها یک خط اضافه است).
## `AppointmentEditPage` — دو حالت، یک صفحه
```tsx
const { bookingMode, services } = useDoctorBookingServices(doctorUuid, clinicUuid);
const isServiceMode = bookingMode === 'service' && !appointment?.is_reserve;
// حالت اسلاتی: دقیقاً همان سه فیلد امروز — بدون هیچ تغییر
{!isServiceMode && (
<>
<PersianDateInput value={date} onChange={setDate} label="تاریخ" />
<TimeField value={start} onChange={setStart} label="ساعت شروع" />
<TimeField value={end} onChange={setEnd} label="ساعت پایان" />
</>
)}
// حالت سرویسی: انتخاب چند سرویس + picker زمان
{isServiceMode && (
<ServiceSlotPicker
doctorUuid={doctorUuid}
clinicUuid={clinicUuid}
services={services}
selectedUuids={serviceUuids}
onServicesChange={setServiceUuids}
date={date}
onDateChange={setDate}
picked={pickedSlot}
onPick={setPickedSlot}
excludeAppointmentUuid={uuid} // ← prop جدید
management
/>
)}
```
`ServiceSlotPicker` موجود فقط یک prop اختیاری می‌گیرد. رفتار فعلی‌اش (در
`AppointmentCreatePage` و `AppointmentsPage`) با `excludeAppointmentUuid = undefined`
دست‌نخورده می‌ماند.
ورودی دستی ساعت در حالت سرویسی **پنهان** می‌شود، نه غیرفعال — فیلد disabled یعنی کاربر
فکر می‌کند باید کاری بکند.
## تست قرارداد اسلاتی — قلب خط سرخ
```php
// tests/Appointment/SlotModeFrozenTest.php
/** @group slot-mode-frozen */
final class SlotModeFrozenTest extends WebTestCase
{
public function testAppointmentSlotsContractIsFrozen(): void
{
$this->seedFixedSlotSchedule(); // برنامهٔ ثابت، تاریخ ثابت (از args، نه time())
$this->client->request('GET', '/api/v1/appointment-slots?doctor_uuid=…&date=…');
self::assertJsonStringEqualsJsonFile(
__DIR__ . '/fixtures/slot-mode-contract.json',
$this->client->getResponse()->getContent(),
);
}
public function testMonthAvailabilityContractIsFrozen(): void { /* همان الگو */ }
/** هیچ متد عمومیِ SlotCalculatorService امضایش عوض نشده. */
public function testSlotCalculatorPublicApiIsFrozen(): void
{
$expected = require __DIR__ . '/fixtures/slot-calculator-signatures.php';
$actual = $this->reflectPublicSignatures(SlotCalculatorService::class);
self::assertSame($expected, $actual);
}
}
```
متد سوم مهم‌ترین است: پارامتر اختیاری جدید `excludeAppointmentId` **یک بار** در fixture
ثبت می‌شود (در همین تسک) و بعد از آن هیچ تسکی اجازهٔ تغییرش را ندارد.
fixture ها با تاریخ ثابت ساخته می‌شوند، نه `time()` — وگرنه تست فردا قرمز می‌شود.
## UI — قواعد اجباری
رجوع: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
- `AppointmentEditPage` از قبل `PageHeader` با `backTo` دارد → حفظ شود
- `ServiceSlotPicker` موجود بازاستفاده می‌شود؛ نسخهٔ موازی ساخته نمی‌شود
- انتخاب چند سرویس با `SearchableSelect` چندانتخابی — نه `<select multiple>`
- تاریخ با `PersianDateInput` موجود
- `ReserveAppointmentsPage` جدول خام دارد (`<td style={td}>`) — در همین تسک به
`DataTable` مهاجرت کند، چون داریم دستش می‌زنیم و توکن‌های inline خلاف قاعده‌اند
- مدت و بافر با فارسی و واحد: «۳۵ دقیقه (+۱۰ دقیقه فاصله)»
@@ -0,0 +1,118 @@
# چک‌لیست — تسک ۰۰ (تکمیل نوبت‌دهی سرویسی در clinicpro)
**وضعیت کلی:** ⏳ شروع نشده
**آخرین بازبینی:**
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
خط سرخ‌ها: [_shared/red-lines.md](../_shared/red-lines.md) ·
UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
---
## ۰. خط سرخ — منطق اسلاتی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `SlotModeFrozenTest` + سه fixture ساخته شد **پیش از** هر تغییر کد | ⏳ | ترتیب مهم است |
| ۰.۲ | fixture ها با تاریخ ثابت‌اند، نه `time()` | ⏳ | |
| ۰.۳ | کامنت «read-only، هیچ تسکی به‌روزش نمی‌کند» بالای هر سه fixture | ⏳ | |
| ۰.۴ | هیچ متد موجود `SlotCalculatorService` ویرایش نشد | ⏳ | فقط پارامتر اختیاری `excludeAppointmentId` روی `getServiceStartTimes` |
| ۰.۵ | `GET /appointment-slots` بیت‌به‌بیت دست‌نخورده | ⏳ | |
| ۰.۶ | `GET /month-availability/{doctorUuid}` دست‌نخورده | ⏳ | |
| ۰.۷ | `active_slot_key` و `refreshActiveSlotKey()` دست‌نخورده | ⏳ | تنها استثنا: صدا زدنش در `setIsReserve` |
| ۰.۸ | `isSlotTaken` امضا و معنا دست‌نخورده | ⏳ | |
| ۰.۹ | `--group=slot-mode-frozen` در پایان سبز | ⏳ | |
## ۱. بک‌اند
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | `ServiceBookingCalculator` ساخته شد | ⏳ | |
| ۱.۲ | `serviceSlots()` موجود از آن استفاده می‌کند، **خروجی‌اش عوض نشده** | ⏳ | |
| ۱.۳ | جمع سادهٔ `+=` **حفظ شد** (اصلاحش تسک ۰۴ است) | ⏳ | |
| ۱.۴ | `ServiceRescheduleService` + `POST /appointment/{uuid}/service-reschedule` | ⏳ | |
| ۱.۵ | `PATCH /appointment/{uuid}` توسعه یافت — منطق جدید داخل `isServiceMode()` | ⏳ | |
| ۱.۶ | `PATCH` مقدار `service_item_uuids[]` می‌پذیرد | ⏳ | |
| ۱.۷ | `ReserveConversionService` + `POST /appointment/{uuid}/convert-reserve` | ⏳ | |
| ۱.۸ | نوبت رزرو در حالت سرویسی سرویس‌ها را ذخیره می‌کند | ⏳ | |
| ۱.۹ | `excludeAppointmentId` روی `getServiceStartTimes` و `findBusyIntervals` | ⏳ | همان الگوی `isSlotTaken` |
| ۱.۱۰ | `Appointment::replaceServiceItems()` + `currentServiceUuids()` | ⏳ | |
| ۱.۱۱ | `replaceServiceItems` مقدار `serviceItem` تکی را هم‌گام می‌کند | ⏳ | چهار مصرف‌کننده رویش خوانده‌اند |
| ۱.۱۲ | `setIsReserve()` صدا زدن `refreshActiveSlotKey()` | ⏳ | |
| ۱.۱۳ | اعتبارسنجی زمان با **عضویت در `getServiceStartTimes`**، نه فقط `isSlotTaken` | ⏳ | |
| ۱.۱۴ | `TenantOwnershipChecker` روی همهٔ uuid های سرویس، **پیش از** هر بررسی دیگر | ⏳ | |
| ۱.۱۵ | `allowInactive` فقط برای سرویس‌های موجود نوبت، نه uuid های تازه | ⏳ | |
| ۱.۱۶ | کنترلر نازک ماند — منطق در سرویس | ⏳ | |
## ۲. دیتابیس و مهاجرت
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | `service_total_minutes` و `service_buffer_minutes` (تهی‌پذیر) | ⏳ | |
| ۲.۲ | هیچ ستون موجودی حذف/تغییر نوع/تغییر معنا نداد | ⏳ | |
| ۲.۳ | دو کد خطای جدید در `ErrorCodes.php` با پیام فارسی | ⏳ | شمارهٔ واقعی از خود فایل |
| ۲.۴ | `app:appointment:backfill-service-duration` — dry-run پیش‌فرض، idempotent | ⏳ | |
| ۲.۵ | backfill مقدار را از خود نوبت می‌گیرد، نه بازمحاسبه از سرویس‌ها | ⏳ | |
| ۲.۶ | migration اجرا شد و `TenantSchemaCoverageTest` سبز | ⏳ | |
## ۳. UI — پنل ادمین
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `AppointmentEditPage`: حالت سرویسی `ServiceSlotPicker` نشان می‌دهد | ⏳ | |
| ۳.۲ | `AppointmentEditPage`: حالت اسلاتی **دقیقاً** رفتار امروز | ⏳ | سه فیلد ساعت |
| ۳.۳ | ورودی دستی ساعت در حالت سرویسی **پنهان**، نه disabled | ⏳ | |
| ۳.۴ | `ServiceSlotPicker` موجود بازاستفاده شد؛ نسخهٔ موازی ساخته نشد | ⏳ | فقط prop `excludeAppointmentUuid` |
| ۳.۵ | `ReserveAppointmentsPage`: سرویس‌ها + دکمهٔ تبدیل | ⏳ | |
| ۳.۶ | `ReserveAppointmentsPage` از جدول خام به `DataTable` مهاجرت کرد | ⏳ | `<td style={td}>` حذف شد |
| ۳.۷ | مدت و بافر فارسی با واحد: «۳۵ دقیقه (+۱۰ دقیقه فاصله)» | ⏳ | |
| ۳.۸ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکن‌های `styles.css` | ⏳ | |
| ۳.۹ | دارک‌مود (`data-theme="dark"`) بررسی شد | ⏳ | |
| ۳.۱۰ | حالت فشرده (`data-density="compact"`) بررسی شد | ⏳ | |
| ۳.۱۱ | انتخاب چند سرویس با `SearchableSelect`؛ هیچ `<select>` بومی | ⏳ | |
| ۳.۱۲ | `backTo`/`BackButton` روی هر دو صفحه | ⏳ | |
| ۳.۱۳ | وضعیت لیست رزروها در URL با `useUrlState` | ⏳ | |
| ۳.۱۴ | تاریخ با `PersianDateInput` · مبلغ با `formatRial` | ⏳ | |
| ۳.۱۵ | RTL بررسی شد (`ms/me` نه `ml/mr`) | ⏳ | |
| ۳.۱۶ | موبایل بررسی شد — بدون اسکرول افقی | ⏳ | |
| ۳.۱۷ | همهٔ رشته‌ها فارسی و از i18n | ⏳ | |
| ۳.۱۸ | داده با TanStack Query و استخراج envelope درست | ⏳ | |
## ۴. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | `SlotModeFrozenTest` — سه سنجه | ⏳ | |
| ۴.۲ | `ServiceBookingCalculatorTest` — موفق/خطا/مرزی | ⏳ | |
| ۴.۳ | `ServiceRescheduleTest` — شامل «حذف سرویس → مدت خودکار» | ⏳ | |
| ۴.۴ | `PatchServiceDurationTest` — شامل «در حالت اسلاتی هیچ‌کدام اجرا نمی‌شود» | ⏳ | |
| ۴.۵ | `ConvertReserveTest` — شامل `active_slot_key` و رقابت | ⏳ | |
| ۴.۶ | `ServiceModeSectionDurationTest` موجود سبز ماند | ⏳ | |
| ۴.۷ | `BookingTenantTest` موجود سبز ماند | ⏳ | |
| ۴.۸ | `AppointmentEditPage.test.tsx` — دو حالت | ⏳ | |
## ۵. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | `docs/api/appointment.md` — دو endpoint جدید + توسعهٔ PATCH | ⏳ | |
| ۵.۲ | ماتریس «کدام endpoint در کدام حالت» | ⏳ | |
| ۵.۳ | `docs/architecture/booking-modes.md` ساخته شد | ⏳ | تسک ۰۶ حالت سوم را اضافه می‌کند |
| ۵.۴ | دو کد خطای جدید مستند شد | ⏳ | |
## ۶. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | همهٔ ردیف‌های بالا وضعیت نهایی دارند (هیچ 🔄 و ⏳ بی‌دلیل) | ⏳ | |
| ۶.۲ | `ddev exec php bin/phpunit` کامل سبز | ⏳ | |
| ۶.۳ | `ddev exec php bin/phpunit --group=slot-mode-frozen` سبز | ⏳ | |
| ۶.۴ | `phpstan analyse` بدون خطای جدید | ⏳ | |
| ۶.۵ | `npx tsc --noEmit` بدون خطا | ⏳ | |
| ۶.۶ | `yarn test` سبز | ⏳ | |
| ۶.۷ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | ⏳ | |
| ۶.۸ | `docs/api/*` به‌روز شد | ⏳ | |
| ۶.۹ | چک‌لیست UI (بخش ۳) کامل شد | ⏳ | |
| ۶.۱۰ | `nobat724_front` و `clinic-pro-tauri` دستی بررسی شدند | ⏳ | `service_item` تکی هم‌گام است؟ |
| ۶.۱۱ | commit شد، سپس `graphify update .` | ⏳ | |
| ۶.۱۲ | موارد به‌تعویق‌افتاده با دلیل و تسک مقصد ثبت شدند | ⏳ | |
@@ -0,0 +1,123 @@
# دیتابیس — تسک ۰۰
## تغییر `appointments` — دو ستون تهی‌پذیر
```sql
ALTER TABLE appointments
ADD COLUMN service_total_minutes SMALLINT NULL,
ADD COLUMN service_buffer_minutes SMALLINT NULL;
```
| ستون | معنی | چرا لازم است |
|---|---|---|
| `service_total_minutes` | مدت محاسبه‌شدهٔ ترکیب سرویس‌ها در لحظهٔ ثبت | `slot_end - slot_start` عدد را دارد ولی نمی‌گوید عمدی بود یا دستی؛ و برای نوبت رزرو (که `slot_start = slot_end`) هیچ‌جا مدت را نگه نمی‌داریم |
| `service_buffer_minutes` | `buffer_minutes` مؤثر در لحظهٔ ثبت | تغییر بافر در تنظیمات نباید معنای نوبت‌های ثبت‌شده را عوض کند |
هر دو **تهی‌پذیر** و هر دو در حالت اسلاتی `NULL` می‌مانند. هیچ ستون موجودی حذف، تغییر
نوع یا تغییر معنا نمی‌دهد.
`slot_start` و `slot_end` و `active_slot_key` و `is_reserve` دست‌نخورده. خط سرخ.
### چرا نه یک ستون JSON
وسوسه: یک `service_meta JSON` با همه‌چیز. رد شد چون تسک ۱۴ (گزارش دقت برنامه) روی
`plan_total_minutes` تجمعی می‌زند و JSON را نمی‌تواند `AVG` کند. دو ستون `SMALLINT`
ارزان‌ترند و تسک ۰۷ ستون `plan_total_minutes` را کنارشان اضافه می‌کند
(اسم متفاوت، معنی متفاوت: آن یکی مدت برنامهٔ چندبخشی است).
## ایندکس
هیچ ایندکس جدیدی. `idx_appointments_doctor_slot` و `idx_appointments_tenant_slot` موجود
همهٔ کوئری‌های این تسک را پوشش می‌دهند.
## `appointment_service_items` — بدون تغییر schema
جدول واسط ManyToMany موجود. تنها تغییر، **رفتاری** است:
```php
// Appointment — متد جدید، بدون دست زدن به متدهای موجود
/** جایگزینی کامل سرویس‌ها؛ serviceItem تکی هم با اولی هم‌گام می‌شود. */
public function replaceServiceItems(array $items): self
{
$this->serviceItems->clear();
foreach ($items as $item) {
if (!$this->serviceItems->contains($item)) { $this->serviceItems->add($item); }
}
$this->serviceItem = $items[0] ?? null; // ← سازگاری با مصرف‌کنندهٔ تکی
$this->updatedAt = time();
return $this;
}
/** @return string[] uuid سرویس‌های فعلی، به ترتیب */
public function currentServiceUuids(): array
{
$uuids = array_map(fn($i) => $i->getUuid(), $this->serviceItems->toArray());
if ($uuids === [] && $this->serviceItem !== null) { $uuids = [$this->serviceItem->getUuid()]; }
return $uuids;
}
```
هم‌گام‌سازی `serviceItem` تکی اجباری است: `AppointmentsPage`، `ReserveAppointmentsPage`،
`nobat724_front` و `clinic-pro-tauri` هر چهار روی `service_item` تکی خوانده‌اند. رهاکردنش
یعنی نوبت با سرویس‌های جدید ولی نام سرویس قدیمی در لیست.
## کدهای خطای جدید
در `src/Shared/Constant/ErrorCodes.php`:
```php
public const ERR_SERVICE_DURATION_MISMATCH = 'ERR_APPOINTMENT_010';
// پیام: مدت این نوبت با مجموع مدت سرویس‌های انتخابی نمی‌خواند
public const ERR_WRONG_BOOKING_MODE = 'ERR_APPOINTMENT_011';
// پیام: این عملیات با روش نوبت‌دهی این محیط سازگار نیست
```
شمارهٔ بعدی دامنهٔ `APPOINTMENT` را از خود فایل بگیر، این دو عدد حدسی‌اند.
هر دو کد در تسک‌های ۰۶ و ۰۷ هم استفاده می‌شوند، پس نامشان عمومی است نه مخصوص این تسک.
## Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
## backfill
```bash
ddev exec php bin/console app:appointment:backfill-service-duration # dry-run
ddev exec php bin/console app:appointment:backfill-service-duration --force
```
برای هر نوبت `pending`/`confirmed` **آیندهٔ** یک محیط سرویسی که `service_total_minutes`
ندارد:
```
service_total_minutes = (slot_end - slot_start) / 60
service_buffer_minutes = buffer_minutes فعلیِ همان برنامه
```
مقدار از خودِ نوبت گرفته می‌شود، **نه از مدت سرویس‌ها** — چون نوبت موجود ممکن است با
مدت دستی ثبت شده باشد و بازمحاسبه یعنی تغییر گذشته.
نوبت‌های اسلاتی و نوبت‌های گذشته رد می‌شوند. idempotent.
## fixture های تست خط سرخ
```
tests/Appointment/fixtures/slot-mode-contract.json # پاسخ appointment-slots
tests/Appointment/fixtures/month-availability-contract.json # پاسخ month-availability
tests/Appointment/fixtures/slot-calculator-signatures.php # امضای متدهای عمومی
```
⛔ این سه فایل بعد از این تسک **read-only** اند. هیچ تسکی اجازهٔ به‌روزرسانی‌شان را ندارد.
اگر تستی قرمز شد، کد باید برگردد نه fixture. این جمله را در بالای هر سه فایل به‌عنوان
کامنت بنویس.
fixture ها با تاریخ ثابت ساخته می‌شوند (`2026-01-05` مثلاً)، نه `time()` — وگرنه فردا قرمز.
## طبقه‌بندی tenant
هیچ entity جدیدی. `appointments` از قبل جفت tenant دارد.
`TenantSchemaCoverageTest` باید بدون تغییر سبز بماند.
@@ -0,0 +1,204 @@
# نکات پیاده‌سازی — تسک ۰۰
## ۱. اول تست خط سرخ، بعد هر چیز دیگر
ترتیب کار:
```
۱. tests/Appointment/SlotModeFrozenTest.php + سه fixture ← اول این
۲. ddev exec php bin/phpunit --group=slot-mode-frozen ← باید سبز باشد قبل از هر تغییری
۳. بقیهٔ تسک
۴. دوباره گام ۲ — باید همچنان سبز باشد
```
اگر fixture را بعد از تغییرات بسازی، هیچ چیزی را تضمین نکرده‌ای — snapshot وضعیت
تغییریافته را گرفته‌ای.
## ۲. `isServiceMode` گِیت همه‌چیز است
هر خط کد جدید در مسیر مشترک باید داخل این شرط باشد:
```php
if ($this->serviceCalc->isServiceMode($doctor, $clinic)) {
// … منطق جدید
}
```
نه بیرونش، نه با `??`، نه با «اگر سرویس دارد». معیار **فقط** `booking_mode` است.
نوبت اسلاتی هم می‌تواند `service_item_id` داشته باشد (فیلدهای Figma نوبت‌ها) — آن دلیل
سرویسی بودن نیست.
اشتباه رایج:
```php
// ❌ نوبت اسلاتیِ دارای سرویس را وارد مسیر جدید می‌کند
if ($appointment->getServiceItems()->count() > 0) { }
```
## ۳. جمع سادهٔ مدت را همین‌جا اصلاح نکن
```php
$totalMinutes += $duration; // ← اشتباه است، ولی دست نزن
```
مستند بند ۵ می‌گوید این فرمول ظرفیت را الکی پر می‌کند و راه‌حلش «زمان تنها / زمان اضافه»
است — که تسک ۰۴ می‌سازد. اصلاحش اینجا یعنی:
- مدت همهٔ نوبت‌های چندسرویسیِ در حال رزرو یک‌شبه کم می‌شود
- سایت و اپ دسکتاپ عدد متفاوت می‌بینند بدون اینکه چیزی در build بشکند
- و هیچ داده‌ای برای «زمان اضافه» وجود ندارد، پس اصلاح بی‌ورودی غیرممکن است
کاری که این تسک می‌کند: محاسبه را به **یک نقطه** منتقل می‌کند تا تسک ۰۴ یک خط عوض کند.
## ۴. `excludeAppointmentId` — الگوی موجود را تکرار کن
`AppointmentRepository::isSlotTaken($doctor, $start, $end, $excludeId)` از قبل این پارامتر
را دارد. `findBusyIntervals` هم همان را بگیرد، با همان نام و همان جای پارامتر و همان
پیش‌فرض `null`.
دو الگوی متفاوت برای یک کار (مثلاً یکی `?int $excludeId`، دیگری `array $excludeIds`)
یعنی اولین کسی که هر دو را می‌بیند یکی را اشتباه صدا می‌زند.
## ۵. اعتبارسنجی زمان: عضویت در فهرست، نه «اشغال نبودن»
```php
// ❌ ناکافی
if ($this->appointmentRepo->isSlotTaken($doctor, $start, $end, $excludeId)) { /* 409 */ }
// ✅
$starts = $this->slotCalculator->getServiceStartTimes();
if (!in_array($req->start, array_column($starts, 'start'), true)) { /* 422 */ }
```
`isSlotTaken` فقط تداخل با نوبت دیگر را می‌گوید. `getServiceStartTimes` علاوه بر آن
شیفت، تعطیلی، `date_override`، پنجرهٔ رزرو و بافر را هم اعمال می‌کند. با شرط اول،
منشی می‌تواند نوبت را ساعت ۳ بامداد بگذارد.
## ۶. `warnings[]` به‌جای `422` برای سرویس غیرفعال در نوبت موجود
```php
$duration = $this->serviceCalc->calculate(, allowInactive: true);
// $duration->warnings === ['سرویس «لیزر صورت» دیگر برای نوبت‌دهی فعال نیست']
```
نوبت موجود با سرویسی که کلینیک غیرفعالش کرده، باید قابل جابه‌جایی و لغو بماند. `422`
یعنی آن نوبت برای همیشه قفل می‌شود و منشی هیچ کاری نمی‌تواند بکند.
ولی **افزودن** سرویس غیرفعال به نوبت → `422`. تفاوتش `allowInactive` است که فقط برای
uuid های موجودِ نوبت `true` می‌شود، نه برای uuid های تازه‌ی درخواست.
## ۷. `replaceServiceItems` باید `serviceItem` تکی را هم‌گام کند
```php
$this->serviceItem = $items[0] ?? null;
```
چهار مصرف‌کننده روی `service_item` تکی خوانده‌اند (`AppointmentsPage`،
`ReserveAppointmentsPage`، `nobat724_front/services/response.js`،
`clinic-pro-tauri/src/service/response.js`). این دقیقاً همان الگویی است که
`ServiceItem::setStaffMembers()` برای `staff` تکی دارد — تکرارش کن.
## ۸. `refreshActiveSlotKey` پس از `setIsReserve(false)`
```php
public function setIsReserve(bool $v): self
{
$this->isReserve = $v;
$this->refreshActiveSlotKey(); // ← اگر نیست، اضافه کن
$this->updatedAt = time();
return $this;
}
```
بدون آن، رزروِ تبدیل‌شده `active_slot_key = NULL` می‌ماند و دو نفر می‌توانند همان ساعت
را بگیرند. **این تنها تغییر مجاز در مکانیزم `active_slot_key` است** و فقط چون یک شرط
موجود را اعمال می‌کند، نه عوضش می‌کند. تست: `ConvertReserveSlotKeyTest`.
## ۹. تبدیل رزرو، اتمی
```php
$this->em->wrapInTransaction(function () use ($reserve, $req) {
$duration = ; // یا اسلات اسلاتی
$reserve->setIsReserve(false);
$reserve->reschedule($req->start, $end);
$reserve->replaceServiceItems($duration->serviceItems);
// UniqueConstraintViolationException روی active_slot_key → 409
});
```
`try/catch` روی `UniqueConstraintViolationException` و ترجمه به `409 ERR_SLOT_TAKEN`
همان چیزی که `SlotTakenException` موجود در `src/Appointment/Repository/` انجام می‌دهد.
از همان استفاده کن.
## ۱۰. `ReserveAppointmentsPage` → `DataTable`
صفحه امروز جدول خام با `<td style={td}>` دارد. چون در این تسک دستش می‌زنیم، همان‌جا به
`DataTable` مهاجرت کند: توکن inline خلاف [ui-conventions](../_shared/ui-conventions.md)
است و در دارک‌مود می‌شکند.
این «scope creep» نیست — قاعدهٔ پروژه است که صفحهٔ دست‌خورده باید با دیزاین‌سیستم بخواند.
## ۱۱. edge case ها
| حالت | رفتار درست |
|---|---|
| نوبت سرویسی بدون هیچ سرویس (داده قدیمی) | مدت موجود حفظ · `warnings[]` · **رد نمی‌شود** |
| `PATCH` فقط یادداشت روی نوبت سرویسی | بدون اعتبارسنجی مدت — مثل امروز |
| `service-reschedule` روی نوبت اسلاتی | `422 ERR_WRONG_BOOKING_MODE` |
| `service-reschedule` روی نوبت رزرو | `422` — مسیرش `convert-reserve` است |
| زمان فعلی نوبت با سرویس جدید | با `excludeAppointmentId` در فهرست می‌آید |
| بافر عوض شد بعد از ثبت | نوبت موجود سالم؛ فقط جابه‌جایی جدید بافر جدید می‌گیرد |
| سرویس محیط دیگر در `service_item_uuids[]` | `404``TenantOwnershipChecker` **پیش از** هر بررسی دیگر |
| `durations` override با مقدار ۰ یا منفی | نادیده گرفته شود (رفتار موجود `serviceSlots`) |
| نوبت گذشته | `service-reschedule``422` |
| دو درخواست جابه‌جایی هم‌زمان به یک زمان | `active_slot_key` → یکی `409` |
| نوبت در محیطی که وسط کار به اسلاتی برگشت | `booking_mode` قفل است پس رخ نمی‌دهد؛ ولی اگر داده دستی عوض شد → `422` روشن |
## ۱۲. تست
```
tests/Appointment/SlotModeFrozenTest.php ← ⭐ اول از همه
- قرارداد appointment-slots بیت‌به‌بیت
- قرارداد month-availability
- امضای متدهای عمومی SlotCalculatorService
tests/Appointment/ServiceBookingCalculatorTest.php
- جمع مدت چند سرویس (رفتار فعلی حفظ شود)
- override منشی
- سرویس بدون مدت → 422
- سرویس غیرفعال با allowInactive → warning نه خطا
- سرویس محیط دیگر → استثنا، بدون افشای وجود
tests/Appointment/ServiceRescheduleTest.php ← ⭐
- جابه‌جایی با همان سرویس‌ها → مدت یکسان
- حذف یک سرویس → مدت خودکار کم می‌شود، کلاینت عددی نفرستاده
- زمان بیرون getServiceStartTimes → 422
- زمان فعلی خود نوبت با excludeAppointmentId در فهرست است
- روی نوبت اسلاتی → 422 ERR_WRONG_BOOKING_MODE
tests/Appointment/PatchServiceDurationTest.php
- slot_end ناسازگار → 422 ERR_SERVICE_DURATION_MISMATCH با مدت درست در پیام
- service_item_uuids[] جایگزینی کامل می‌کند و serviceItem تکی هم‌گام می‌شود
- در حالت اسلاتی هیچ‌کدام از این‌ها اجرا نمی‌شود (رفتار امروز)
tests/Appointment/ConvertReserveTest.php
- رزرو سرویسی → نوبت زمان‌دار با مدت درست
- رزرو اسلاتی → مسیر اسلاتی، بدون تغییر
- active_slot_key بعد از تبدیل پر می‌شود
- دو تبدیل هم‌زمان → یکی 409
tests/Appointment/ServiceModeSectionDurationTest.php ← موجود، باید سبز بماند
tests/Appointment/BookingTenantTest.php ← موجود، باید سبز بماند
assets/admin/pages/AppointmentEditPage.test.tsx
- حالت اسلاتی: سه فیلد ساعت هست، ServiceSlotPicker نیست
- حالت سرویسی: ServiceSlotPicker هست، فیلد ساعت پنهان است
- تغییر سرویس‌ها، slot انتخابی را باطل می‌کند
```
## ۱۳. مستندات
`docs/api/appointment.md`:
- بخش «روش‌های نوبت‌دهی» با ماتریس «کدام endpoint در کدام حالت»
- دو endpoint جدید
- توسعهٔ `PATCH` و پارامتر `exclude_appointment_uuid`
- دو کد خطای جدید
`docs/api/appointment-settings.md`: یادآوری قفل بودن `booking_mode` پس از اولین ثبت.
و یک سند کوتاه `docs/architecture/booking-modes.md` که ماتریس کامل را ثبت کند —
تسک ۰۶ حالت سومی به همین ماتریس اضافه می‌کند.
@@ -0,0 +1,143 @@
# تسک ۰۰ — تکمیل نوبت‌دهی سرویسی در clinicpro
**فاز:** ۰ (تثبیت وضعیت فعلی) · **وابستگی:** — · **زمان:** ۱۴-۱۸ ساعت
**پیش‌نیاز همهٔ تسک‌های ۰۱ به بعد**
---
## ⛔ خط سرخ
منطق اسلاتی (`booking_mode = 'slot'`) در این تسک **به هیچ عنوان** دست‌کاری نمی‌شود.
فهرست کامل قفل‌شده‌ها: [_shared/red-lines.md](../_shared/red-lines.md).
این تسک fixture و تست `--group=slot-mode-frozen` را **می‌سازد** — همان تستی که همهٔ
تسک‌های بعدی باید سبز نگهش دارند.
---
## هدف
حالت `booking_mode = 'service'` در مسیر **رزرو** کار می‌کند، ولی در بقیهٔ چرخهٔ عمر نوبت
غایب است. این تسک آن را کامل می‌کند تا موتور چندمنبعی (تسک ۰۶) روی پایهٔ سالم ساخته شود.
## وضعیت فعلی — چه کار می‌کند و چه نمی‌کند
### ✅ کار می‌کند
| مسیر | فایل |
|---|---|
| انتخاب سرویس و اسلات در رزرو عمومی | `GET /api/v1/appointment-booking-services/{doctorUuid}` · `GET /api/v1/appointment-service-slots` |
| محاسبهٔ زمان‌های شروع بر اساس مدت سرویس | `SlotCalculatorService::getServiceStartTimes()` |
| ثبت نوبت با چند سرویس | `POST /api/v1/appointment` + `appointment_service_items` |
| توگل روش نوبت‌دهی در تنظیمات | `assets/admin/components/schedule/ScheduleSection.tsx` |
| ساخت نوبت از پنل | `assets/admin/pages/AppointmentCreatePage.tsx` + `components/appointments/ServiceSlotPicker.tsx` + `hooks/useDoctorBookingServices.ts` |
| ساخت سریع از drawer | `assets/admin/components/NewAppointmentDrawer.tsx` |
| رزرو از سایت | `nobat724_front/components/appointment/*` |
### ❌ کار نمی‌کند — شکاف‌های این تسک
**۱. ویرایش و جابه‌جایی نوبت، حالت سرویسی را نمی‌شناسد.**
`PATCH /api/v1/appointment/{uuid}` ([AppointmentController.php:1077](../../../src/Appointment/Controller/AppointmentController.php#L1077)):
```php
$hasStart = array_key_exists('slot_start', $data);
$hasEnd = array_key_exists('slot_end', $data);
// … فقط این دو بررسی می‌شوند:
if ($newEnd <= $newStart) { /* 422 */ }
if ($this->appointmentRepo->isSlotTaken($doctor, $newStart, $newEnd, $id)) { /* 409 */ }
```
سه مشکل:
- مدت دلخواه پذیرفته می‌شود؛ هیچ بررسی‌ای که `slot_end - slot_start` با مجموع مدت
سرویس‌های نوبت بخواند وجود ندارد
- `buffer_minutes` نادیده گرفته می‌شود — نوبت جدید می‌تواند چسبیده به نوبت بعدی بنشیند
- فقط `service_item_uuid` تکی به‌روز می‌شود؛ `service_items` (ManyToMany) دست‌نخورده
می‌ماند → نوبت با سرویس‌های قبلی و مدت جدید ناسازگار می‌شود
**۲. `AppointmentEditPage.tsx` ورودی دستی ساعت دارد.**
سه فیلد `date`/`start`/`end` آزاد + یک `SearchableSelect` تکی برای سرویس
([AppointmentEditPage.tsx:74-76](../../../assets/admin/pages/AppointmentEditPage.tsx#L74)).
هیچ `ServiceSlotPicker` ای نیست، هیچ چند-سرویسی نیست.
نتیجه: منشی نوبت سرویسیِ ۴۵ دقیقه‌ای را ویرایش می‌کند، ۲۰ دقیقه می‌گذارد، سیستم قبول
می‌کند، و بیمار بعدی روی نوبت اول می‌نشیند.
**۳. نوبت رزرو (`is_reserve`) در حالت سرویسی معنا ندارد.**
`NewAppointmentDrawer.tsx:72` صریح: `$serviceMode = bookingMode === 'service' && !isReserve`.
پس نوبت رزرو همیشه اسلاتی رفتار می‌کند و `ReserveAppointmentsPage.tsx` فقط
`service_item?.name` تکی نشان می‌دهد. تبدیل رزرو به نوبت واقعی هم مسیر سرویسی ندارد.
**۴. `patient_facing` بودن مدت جایی نمایش داده نمی‌شود.**
پاسخ `appointment-service-slots` مدت کل را می‌دهد ولی نوبت ثبت‌شده هیچ‌جا نگه نمی‌دارد
که این مدت از کدام سرویس‌ها و چه بافری آمده. لیست نوبت‌ها فقط `slot_start/slot_end` دارد.
## دامنه
**هست:**
- `ServiceBookingCalculator` — یک سرویس واحد که «مدت مجاز یک ترکیب سرویس» را حساب می‌کند
(استخراج منطق تکرارشدهٔ `serviceSlots()` از کنترلر)
- اعتبارسنجی حالت سرویسی در `PATCH /appointment/{uuid}`
- endpoint جابه‌جایی سرویس‌آگاه: `POST /api/v1/appointment/{uuid}/service-reschedule`
- `ServiceSlotPicker` در `AppointmentEditPage`
- حالت سرویسی برای نوبت رزرو + تبدیل رزرو به نوبت
- ستون‌های `service_total_minutes` و `service_buffer_minutes` روی `appointments`
- fixture و تست `--group=slot-mode-frozen`
**نیست:** بخش‌های نوبت، چند منبع، قوانین (تسک ۰۵ به بعد). `nobat724_front` (تسک ۰۰ب).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| POST | `/api/v1/appointment/{uuid}/service-reschedule` | جابه‌جایی سرویس‌آگاه: سرویس‌ها + زمان شروع؛ مدت را خودش حساب می‌کند |
| PATCH | `/api/v1/appointment/{uuid}` | **توسعه** — در حالت سرویسی مدت را اعتبارسنجی می‌کند و `service_item_uuids[]` می‌پذیرد |
| POST | `/api/v1/appointment/{uuid}/convert-reserve` | تبدیل نوبت رزرو به نوبت زمان‌دار (هر دو حالت) |
| GET | `/api/v1/appointment-service-slots` | **توسعه** — پارامتر `exclude_appointment_uuid` برای جابه‌جایی |
هیچ endpoint اسلاتی‌ای تغییر نمی‌کند. `GET /appointment-slots` دست‌نخورده.
## معیار پذیرش
- ✅ موفق: نوبت سرویسیِ «لیزر صورت (۲۰) + بیکینی (۱۵)» با مدت ۳۵ دقیقه.
`POST /appointment/{uuid}/service-reschedule` با زمان جدید و همان سرویس‌ها →
`200` و `slot_end - slot_start = 35 * 60` دقیقاً.
- ✅ موفق: همان endpoint با حذف بیکینی → مدت خودکار ۲۰ دقیقه می‌شود، بدون اینکه کلاینت
عددی بفرستد.
- ✅ موفق: `GET /appointment-service-slots?…&exclude_appointment_uuid={uuid}` بازهٔ خودِ
نوبت را اشغال حساب نمی‌کند، پس زمان فعلی‌اش در فهرست می‌آید.
- ✅ موفق: `AppointmentEditPage` برای نوبت سرویسی، `ServiceSlotPicker` نشان می‌دهد و
ورودی دستی ساعت را **پنهان** می‌کند؛ برای نوبت اسلاتی، دقیقاً رفتار امروز.
- ✅ موفق: نوبت رزرو در حالت سرویسی سرویس‌هایش را ذخیره می‌کند و
`POST /convert-reserve` با زمان انتخابی، نوبت زمان‌دار با مدت درست می‌سازد.
- ✅ موفق (**خط سرخ**): `ddev exec php bin/phpunit --group=slot-mode-frozen` سبز است و
fixture قرارداد اسلاتی بیت‌به‌بیت تغییر نکرده.
- ❌ خطا: `PATCH` با `slot_end - slot_start` ناسازگار با مدت سرویس‌ها →
`422` `ERR_SERVICE_DURATION_MISMATCH` با پیام فارسی شامل مدت درست.
- ❌ خطا: `service-reschedule` روی نوبت **اسلاتی**`422` `ERR_WRONG_BOOKING_MODE`.
- ❌ خطا: `service-reschedule` با زمان شروعی که در `getServiceStartTimes` نیست →
`422` با پیام «این زمان برای مدت انتخابی در دسترس نیست».
- ❌ خطا: سرویس محیط دیگر در `service_item_uuids[]``404` (بدون لو دادن وجودش).
- ⚠️ مرزی: نوبتی که سرویس‌هایش غیرفعال (`bookable=false`) شده‌اند → جابه‌جایی مجاز است
با `warnings[]`؛ افزودن سرویس غیرفعال ممنوع.
- ⚠️ مرزی: `buffer_minutes` تغییر کرد بعد از ثبت نوبت → نوبت موجود سالم می‌ماند؛
فقط جابه‌جایی جدید بافر جدید را می‌گیرد.
- ⚠️ مرزی: جابه‌جایی به روزی که برنامهٔ هفتگی آن محیط عوض شده → همان اعتبارسنجی
`getServiceStartTimes`، پس خودکار پوشش داده می‌شود.
- ⚠️ مرزی: نوبت سرویسی بدون هیچ سرویس (داده قدیمی) → مدت موجود حفظ می‌شود و
`warnings[]` می‌گوید سرویس ثبت نشده. **رد نمی‌شود.**
- ⚠️ مرزی: `PATCH` بدون `slot_start` روی نوبت سرویسی (فقط تغییر یادداشت) → بدون
اعتبارسنجی مدت، مثل امروز.
## خروجی
- `src/Appointment/Service/ServiceBookingCalculator.php` + توسعهٔ کنترلر
- `assets/admin/pages/AppointmentEditPage.tsx` توسعه‌یافته
- `assets/admin/pages/ReserveAppointmentsPage.tsx` توسعه‌یافته
- migration دو ستون تهی‌پذیر
- `tests/Appointment/SlotModeFrozenTest.php` + fixture
- `docs/api/appointment.md` به‌روزرسانی
- [checklist.md](checklist.md) کامل‌شده