Files
clinicpro/docs/new_feture/taskes/task-00-service-mode-completion/architecture.md
T
hamed 158dcb58aa 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.
2026-07-30 11:56:08 +03:30

15 KiB

معماری — تسک ۰۰

ساختار فایل

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):

// وضعیت فعلی — درون کنترلر، تکرارشدنی
$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) به همین محاسبه نیاز دارند. کپی‌کردنش یعنی چهار نسخه با چهار رفتار مرزی متفاوت.

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} — توسعه، نه بازنویسی

// وضعیت فعلی حفظ می‌شود؛ فقط یک شاخه اضافه می‌شود
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": []
  }
}
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

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:72serviceMode = 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 — دو حالت، یک صفحه

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 یعنی کاربر فکر می‌کند باید کاری بکند.

تست قرارداد اسلاتی — قلب خط سرخ

// 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

  • AppointmentEditPage از قبل PageHeader با backTo دارد → حفظ شود
  • ServiceSlotPicker موجود بازاستفاده می‌شود؛ نسخهٔ موازی ساخته نمی‌شود
  • انتخاب چند سرویس با SearchableSelect چندانتخابی — نه <select multiple>
  • تاریخ با PersianDateInput موجود
  • ReserveAppointmentsPage جدول خام دارد (<td style={td}>) — در همین تسک به DataTable مهاجرت کند، چون داریم دستش می‌زنیم و توکن‌های inline خلاف قاعده‌اند
  • مدت و بافر با فارسی و واحد: «۳۵ دقیقه (+۱۰ دقیقه فاصله)»