# معماری — تسک ۰۰ ## ساختار فایل ``` 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 $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 && ( <> )} // حالت سرویسی: انتخاب چند سرویس + picker زمان {isServiceMode && ( )} ``` `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` چندانتخابی — نه `