# ایجاد خودکار پرونده و سرویس در همهٔ مسیرهای قطعی‌شدن نوبت

## زمینه

منطق «قطعی شدن نوبت → ساخت پرونده و سرویس» **از قبل نوشته شده** است:
`PatientService::autoCreateOnAppointmentConfirm()`. مشکل این نیست که وجود ندارد — این است که
فقط به **دو** مسیر از پنج مسیرِ قطعی‌شدن وصل است، و در همان دو مسیر هم به‌جای یک پرونده، دو
پرونده (پزشک + کلینیک) می‌سازد.

شواهد از دیتابیس محیط توسعه:

```sql
-- ۱۶ نوبت قطعی، ولی فقط ۱۲ مراجعه
SELECT COUNT(*) FROM appointments WHERE status='confirmed';                    -- 16
SELECT COUNT(*) FROM appointments a JOIN patient_sessions ps ON ps.appointment_id=a.id
  WHERE a.status='confirmed';                                                  -- 12

-- نوبت 130043 دو مراجعهٔ تکراری دارد (یکی برای پزشک، یکی برای کلینیک):
-- id      status     address_id  session_id  entity_type  entity_id
-- 130043  confirmed  2631        30          doctor       3343
-- 130043  confirmed  2631        31          clinic       1003
-- و این‌ها هیچ مراجعه‌ای ندارند:
-- 130044, 130028, 130025, 130011, 130010  → session_id = NULL
```

## مشکل / هدف

هدف: هر نوبتی که قطعی می‌شود — **از هر مسیری** — دقیقاً **یک** پرونده و **یک** سرویس در
**همان محیطی** بسازد که نوبت در آن رزرو شده (کلینیک، یا مطب شخصی پزشک مستقل).

پنج ایراد مشخص که باید رفع شوند:

### ۱. مسیر پرداخت آنلاین (Nobat724) اصلاً صدا نمی‌زند — ریشهٔ اصلی

نوبتِ سایت عمومی `pending` ساخته می‌شود و بعد از پرداخت در `PaymentManager` قطعی می‌شود؛
آن‌جا هیچ فراخوانی‌ای وجود ندارد. یعنی **هیچ نوبتی که از Nobat724 و سایت‌های زیرمجموعه
رزرو و پرداخت شود، پرونده نمی‌سازد.**

### ۲. دو پرونده به‌جای یک پرونده

`autoCreateOnAppointmentConfirm` برای پزشکِ عضو کلینیک، هم پروندهٔ `doctor` می‌سازد و هم
`clinic` — دو مراجعهٔ جدا برای یک نوبت واحد (ردیف 130043 بالا). این یعنی درآمد یک نوبت در
دو جا شمرده می‌شود.

**قاعدهٔ درست:** نوبتی که در کلینیک رزرو شده → فقط پروندهٔ همان کلینیک. نوبتی که در مطب
شخصی رزرو شده → فقط پروندهٔ پزشک.

### ۳. تشخیص کلینیک حدسی است

کلینیک از `address_id` استنتاج می‌شود و اگر آدرس نبود، «اگر پزشک فقط عضو یک کلینیک باشد»
همان فرض می‌شود. با مدل per-context فعلی (هر پزشک یک برنامه برای مطب شخصی + یکی به ازای هر
کلینیک) این حدس غلط است و نوبت را بی‌سروصدا به پروندهٔ محیط اشتباه می‌چسباند. نوبت `130032`
با `address_id = NULL` دقیقاً روی همین شاخهٔ حدسی افتاده است.

### ۴. idempotent نیست

هیچ گاردی نیست که مراجعهٔ تکراری برای یک نوبت ساخته نشود. مسیر
`confirmed → cancelled → confirmed` یا دوبار PATCH، مراجعهٔ دوم می‌سازد.

### ۵. نوبت‌های پنل و ادمین اصلاً قطعی نمی‌شوند

`MyAppointmentsController` و `AdminApiController` نوبت را با وضعیت پیش‌فرض `pending`
می‌سازند و هیچ‌جا قطعی نمی‌کنند (`markPendingWithTtl` هم صدا نمی‌شود، پس نه منقضی می‌شود نه
قطعی). نوبت `130046` که همین امروز از پنل ساخته شده هنوز `pending` است. طبق نیاز، نوبتِ
ثبت‌شده توسط خودِ کلینیک/پزشک باید مستقیم `confirmed` باشد — پرداخت آنلاین ندارد.

## فایل‌های مرتبط

| فایل | نقش |
|------|-----|
| `src/Patient/Service/PatientService.php:125-192` | `autoCreateOnAppointmentConfirm` + `autoCreateForEntity` |
| `src/Payment/Service/PaymentManager.php:305-330` | `handleAppointmentConfirmation` — مسیر پرداخت آنلاین |
| `src/Appointment/Controller/AppointmentController.php:871-881` | PATCH status — تنها مسیر سالم فعلی |
| `src/Appointment/Controller/AppointmentController.php:975-988` | PATCH نوبت (ویرایش کامل) |
| `src/Appointment/Controller/AppointmentController.php:495-537` | رزرو عمومی — `markPendingWithTtl` سپس `bookAtomically` |
| `src/Appointment/Controller/MyAppointmentsController.php:142-198` | رزرو از پنل — بدون قطعی‌کردن |
| `src/Admin/Controller/AdminApiController.php:925-947` | رزرو از ادمین — بدون قطعی‌کردن |
| `src/Appointment/Entity/Appointment.php:37-42,101,178-189` | ماشین وضعیت، `status` پیش‌فرض `pending`، سازنده |
| `src/Appointment/Service/BookingContextResolver.php` | تشخیص صریح کلینیکِ نوبت از `clinic_uuid` |
| `src/Patient/Entity/PatientRecord.php:15` | unique روی `(entity_type, entity_id, user_id)` |
| `src/Patient/Entity/PatientSession.php:29-37` | لینک اختیاری به `Appointment` |
| `src/Patient/Repository/PatientSessionRepository.php` | متد lookup بر اساس نوبت **ندارد** |
| `docs/api/patient.md:611-624` | بخش «Auto-Creation on Appointment Confirm» |

## وضعیت فعلی

### دو پرونده + کلینیکِ حدسی

`src/Patient/Service/PatientService.php:125-147`:

```php
    public function autoCreateOnAppointmentConfirm(Appointment $appointment): void
    {
        $doctor = $appointment->getDoctor();

        // پرونده‌ی پزشک
        $this->autoCreateForEntity('doctor', $doctor->getId(), $appointment, $doctor->getId());

        // کلینیک نوبت را تعیین کن: اول از آدرس انتخاب‌شده، وگرنه اگر دکتر فقط عضو یک کلینیک باشد.
        $clinicId = null;
        $addressId = $appointment->getAddressId();
        if ($addressId !== null) {
            $clinicId = $this->addressRepo->find($addressId)?->getClinicId();
        }
        if ($clinicId === null) {
            $clinics = $this->clinicRepo->findByDoctor($doctor);
            if (count($clinics) === 1) {
                $clinicId = $clinics[0]->getId();
            }
        }

        if ($clinicId !== null) {
            $this->autoCreateForEntity('clinic', $clinicId, $appointment, $clinicId);
        }
    }
```

### ساخت مراجعه — بدون گارد تکرار

`src/Patient/Service/PatientService.php:149-192` (بخش مرتبط):

```php
    private function autoCreateForEntity(string $entityType, int $entityId, Appointment $appointment, int $createdById): void
    {
        if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
            return;
        }

        $patient = $appointment->getUser();

        $record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
        if ($record === null) {
            $record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById);
            $this->recordRepo->save($record);
        }

        $session = new PatientSession($record, $appointment);   // ← هیچ چکی که قبلاً ساخته نشده باشد
        $session->setSessionAt($appointment->getSlotStart());
        ...
```

### مسیر پرداخت — بدون فراخوانی

`src/Payment/Service/PaymentManager.php:305-314`:

```php
    private function handleAppointmentConfirmation(Payment $payment): void
    {
        $appointment = $payment->getAppointment();
        if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
            return;
        }

        $appointment->transitionTo(Appointment::STATUS_CONFIRMED);
        $this->em->persist($appointment);
        // ← اینجا هیچ ساختِ پرونده‌ای نیست
```

### رزرو پنل — وضعیت pending می‌ماند

`src/Appointment/Controller/MyAppointmentsController.php:190-197`:

```php
        } else {
            try {
                $this->appointmentRepo->bookAtomically($appointment);
            } catch (SlotTakenException) {
                return $this->error(ErrorCodes::SLOT_TAKEN, 'این نوبت قبلاً رزرو شده است', 409);
            }
        }
```

`Appointment::$status` پیش‌فرض `STATUS_PENDING` است (`Appointment.php:101`) و هیچ‌جای این
مسیر عوضش نمی‌کند.

## وظایف

### ۱. context نوبت را صریح کن — ستون `clinic_id` روی `appointments`

حدس‌زدن محل، ریشهٔ ایراد ۳ است. نوبت باید بداند در کدام محیط رزرو شده، همان‌طور که
`weekly_schedules` می‌داند.

`src/Appointment/Entity/Appointment.php`:

```php
    /**
     * محیط رزرو: null یعنی مطب شخصی پزشک، مقدار یعنی همان کلینیک. مبنای واحدِ
     * تشخیص پرونده — از روی آدرس حدس زده نمی‌شود.
     */
    #[ORM\ManyToOne(targetEntity: \App\Clinic\Entity\Clinic::class)]
    #[ORM\JoinColumn(name: 'clinic_id', nullable: true, onDelete: 'SET NULL')]
    private ?\App\Clinic\Entity\Clinic $clinic = null;
```

هر سه مسیر رزرو از قبل `$bookingClinic` را با `BookingContextResolver` حل می‌کنند و فقط برای
`resolveSlotLocationId` استفاده می‌کنند — همان را روی نوبت هم بنشان:

- `AppointmentController.php:~510` (رزرو عمومی) — متغیر `$bookingClinic` موجود است
- `MyAppointmentsController.php:~150` — `$bookingClinic` موجود است
- `AdminApiController.php:~937` — `$bookingClinic` موجود است

Migration بنویس. برای ردیف‌های موجود `clinic_id` را از `address_id` پر کن (همان استنتاجی که
امروز runtime انجام می‌دهد)، ولی **فقط وقتی آدرس واقعاً کلینیکی است**؛ شاخهٔ حدسیِ «تنها
کلینیک پزشک» را در migration تکرار نکن — ردیف بدون آدرس، مطب شخصی در نظر گرفته شود و در
توضیح migration ذکر شود.

### ۲. یک choke point برای قطعی‌شدن

به‌جای پخش‌کردن فراخوانی در پنج جا، یک سرویس بساز که همه صدایش بزنند —
`src/Appointment/Service/AppointmentConfirmationService.php`:

```php
final class AppointmentConfirmationService
{
    /**
     * عوارض جانبیِ قطعی‌شدن نوبت. هر مسیری که نوبت را confirmed می‌کند باید این را
     * صدا بزند — پرداخت آنلاین، PATCH وضعیت، و رزروِ مستقیمِ پنل/ادمین.
     * idempotent است: فراخوانی دوباره برای همان نوبت هیچ چیزی نمی‌سازد.
     */
    public function onConfirmed(Appointment $appointment): void
    {
        $this->patientService->autoCreateOnAppointmentConfirm($appointment);
    }
}
```

سپس در این پنج نقطه صدا زده شود:

| فایل | نقطه |
|---|---|
| `PaymentManager.php:312` | بعد از `transitionTo(STATUS_CONFIRMED)` — **مهم‌ترین** |
| `AppointmentController.php:879` | جایگزین فراخوانی مستقیم فعلی |
| `AppointmentController.php:982` | جایگزین فراخوانی مستقیم فعلی |
| `MyAppointmentsController.php:~193` | بعد از `bookAtomically` (وظیفهٔ ۵) |
| `AdminApiController.php:~939` | بعد از `bookAtomically` (وظیفهٔ ۵) |

**دربارهٔ `PaymentManager`:** آن‌جا داخل تراکنش پرداخت هستی. ساخت پرونده نباید تأیید پرداخت
را خراب کند — اگر شکست خورد، لاگ کن و پرداخت را نگه دار (همان الگویی که
`docs/api/patient.md:446` برای مطالبهٔ بیمه توضیح داده: «خطا در این مرحله ثبت session را
خراب نمی‌کند»). ولی **بی‌صدا رد نشو** — لاگ سطح `error` با uuid نوبت.

### ۳. یک پرونده در context درست

`autoCreateOnAppointmentConfirm` بازنویسی شود:

```php
    public function autoCreateOnAppointmentConfirm(Appointment $appointment): void
    {
        $clinic = $appointment->getClinic();

        // محیط رزرو تعیین‌کننده است: کلینیک، یا مطب شخصی پزشک. هرگز هر دو —
        // دو پرونده برای یک نوبت یعنی درآمد یک ویزیت دو بار شمرده می‌شود.
        [$entityType, $entityId] = $clinic !== null
            ? ['clinic', $clinic->getId()]
            : ['doctor', $appointment->getDoctor()->getId()];

        $this->autoCreateForEntity($entityType, $entityId, $appointment, $entityId);
    }
```

سازگاری با ردیف‌های قدیمیِ بدون `clinic_id`: بعد از migration وظیفهٔ ۱ همه پر شده‌اند، پس
شاخهٔ fallback لازم نیست. اگر لازم دیدی نگه‌داری، از `address_id → clinic_id` استفاده کن و
**شاخهٔ «تنها کلینیک پزشک» را حذف کن** — همان حدسی است که باگ می‌سازد.

### ۴. idempotency

متد lookup به `PatientSessionRepository` اضافه کن:

```php
    /** مراجعهٔ ساخته‌شده برای این نوبت در همین محیط، یا null. */
    public function findByAppointmentAndEntity(Appointment $appointment, string $entityType, int $entityId): ?PatientSession
    {
        return $this->createQueryBuilder('s')
            ->join('s.record', 'r')
            ->where('s.appointment = :appointment')
            ->andWhere('r.entityType = :entityType')
            ->andWhere('r.entityId = :entityId')
            ->setParameter('appointment', $appointment)
            ->setParameter('entityType', $entityType)
            ->setParameter('entityId', $entityId)
            ->setMaxResults(1)
            ->getQuery()
            ->getOneOrNullResult();
    }
```

و در ابتدای `autoCreateForEntity` بعد از گارد feature:

```php
        if ($this->sessionRepo->findByAppointmentAndEntity($appointment, $entityType, $entityId) !== null) {
            return;   // قبلاً ساخته شده — قطعی‌شدن دوباره نباید مراجعهٔ تکراری بسازد
        }
```

**نکتهٔ مرزی:** مراجعهٔ آرشیوشده (`PatientSession::$archived`) هم باید «ساخته‌شده» حساب شود؛
وگرنه آرشیو کردنِ یک مراجعهٔ اشتباه باعث ساخت دوبارهٔ آن می‌شود. اگر تصمیم دیگری گرفتی در PR
بنویس.

### ۵. رزرو پنل و ادمین مستقیم `confirmed` شود

نوبتی که خودِ کلینیک یا پزشک از پنل ثبت می‌کند پرداخت آنلاین ندارد و منتظر چیزی نیست؛
`pending` ماندنش یعنی نه در تقویم درست شمرده می‌شود، نه پرونده می‌سازد.

در `MyAppointmentsController` و `AdminApiController`، قبل از `bookAtomically`:

```php
        $appointment->transitionTo(Appointment::STATUS_CONFIRMED);
```

سپس بعد از موفقیت `bookAtomically`، `confirmationService->onConfirmed($appointment)`.

**دقت:** `bookAtomically` روی `SLOT_OCCUPYING_STATUSES` و `active_slot_key` حساب می‌کند و
`confirmed` جزو آن‌هاست (`Appointment.php:52-55`)، پس قفل اتمیک اسلات دست‌نخورده کار می‌کند.
`transitionTo` را **قبل** از `bookAtomically` بگذار تا `refreshActiveSlotKey()` با وضعیت
نهایی محاسبه شود.

**استثنا:** مسیر `isReserve` (نوبت رزروِ روز-محور، `MyAppointmentsController:188-190`) اسلات
اشغال نمی‌کند و مراجعهٔ زمان‌دار برایش معنا ندارد — رفتار فعلی‌اش را عوض نکن و در
`onConfirmed` هم اگر `isReserve()` بود زود برگرد.

### ۶. گارد اشتراک — تصمیم صریح

`autoCreateForEntity` وقتی ویژگی `patient_records` فعال نباشد بی‌صدا برمی‌گردد. این درست
است (نباید به زور پرونده بسازد) ولی الان **غیرقابل‌تشخیص** است: نه لاگی، نه نشانه‌ای.

- یک لاگ سطح `info` با `entity_type`/`entity_id`/`appointment_uuid` بگذار.
- در `docs/api/patient.md` صریح بنویس که بدون این ویژگی، نوبت قطعی پرونده نمی‌سازد.

### ۷. Backfill نوبت‌های قطعیِ بی‌پرونده

پنج نوبت قطعیِ فعلی مراجعه ندارند. یک console command بنویس —
`app:appointment:backfill-sessions`:

- نوبت‌های `confirmed`/`completed` که مراجعهٔ متناظر ندارند را فهرست کند (uuid پزشک، تاریخ،
  context، دلیلِ نبودن).
- با `--fix` همان `onConfirmed` را برایشان اجرا کند.
- خروجی تعداد ساخته‌شده و تعداد رد شده (به‌خاطر گارد اشتراک) را جدا گزارش کند.

نوبت‌های `completed` را هم پوشش بده: مراجعه‌ای که هرگز ساخته نشده با گذشتِ زمان از بین
نمی‌رود، فقط دیرتر لازم می‌شود.

### ۸. تست و مستندات

تست‌ها در `tests/Patient/` و `tests/Appointment/`:

1. نوبت رزروشده در کلینیک، قطعی می‌شود → **یک** پرونده با `entity_type='clinic'`، هیچ
   پروندهٔ `doctor`ی ساخته نمی‌شود.
2. نوبت مطب شخصی → **یک** پروندهٔ `doctor`.
3. بیماری که از قبل پرونده دارد → پروندهٔ جدید ساخته نمی‌شود، فقط مراجعهٔ جدید به همان
   پرونده اضافه می‌شود.
4. قطعی‌شدن دوباره (confirmed → cancelled → confirmed) → مراجعهٔ دوم ساخته نمی‌شود.
5. مسیر پرداخت: `PaymentManager` نوبت را قطعی می‌کند → پرونده و مراجعه ساخته می‌شوند
   (بازتولید مستقیم باگ اصلی).
6. رزرو از پنل → نوبت `confirmed` است و مراجعه دارد.
7. نوبت `isReserve` → مراجعه ساخته نمی‌شود.
8. tenant بدون ویژگی `patient_records` → چیزی ساخته نمی‌شود و خطا هم نمی‌دهد.

مستندات: `docs/api/patient.md` بخش «Auto-Creation on Appointment Confirm» بازنویسی شود —
الان صراحتاً رفتار دوپرونده‌ای را به‌عنوان رفتار درست مستند کرده (`:613-617`) که با این تغییر
باطل می‌شود. فهرست همهٔ مسیرهای قطعی‌شدن، قاعدهٔ تک‌پرونده، و idempotency را بنویس.
`docs/api/appointment.md` هم برای `clinic_id` نوبت و وضعیت اولیهٔ `confirmed` در رزرو
پنل/ادمین به‌روز شود.

## نکات مهم

- **این تغییر رفتار مالی دارد.** حذف پروندهٔ دوم یعنی نوبت‌هایی که تا امروز در داشبورد پزشک
  *و* کلینیک شمرده می‌شدند، از این به بعد فقط در یکی شمرده می‌شوند. دادهٔ تاریخیِ تکراری
  (مثل دو مراجعهٔ نوبت 130043) را **حذف نکن** — تصمیم پاک‌سازی جدا از این تسک است؛ فقط در
  `docs/` به‌عنوان کار بعدی ثبت کن.
- ترتیب پیشنهادی: (۱) ستون `clinic_id` + migration → (۲) choke point → (۳) تک‌پرونده →
  (۴) idempotency → (۵) پنل/ادمین → (۷) backfill → (۸) تست و docs. هر مرحله جدا تست شود.
- `PatientRecord` روی `(entity_type, entity_id, user_id)` unique است؛ ساخت هم‌زمانِ دو نوبتِ
  یک بیمار می‌تواند به `UniqueConstraintViolationException` بخورد. `findByEntityAndUser` +
  `save` اتمیک نیست — این حالت مسابقه را در نظر بگیر (retry یا catch).
- هویت بیمار در مسیر پنل/ادمین با `PatientResolver::resolveForBooking` بر اساس کد ملی حل
  می‌شود، ولی در مسیر عمومی `$appointment->getUser()` مستقیم کاربر لاگین‌شده است. پرونده به
  `User` وصل می‌شود، پس رزرو «برای شخص دیگر» (`for_self=false`) پرونده را به نام **کاربر
  رزروکننده** می‌سازد، نه بیمار واقعی. این یک ایراد جداست — **در scope این تسک نیست**، ولی
  اگر با آن برخورد کردی در `docs/` ثبتش کن.
- تاریخ‌ها Unix timestamp صحیح؛ مبالغ ریال؛ رشته‌های جدید فارسی.
- پاسخ‌ها طبق `BaseController` با `$this->success()` / `$this->error()`.
- کاربران تست: ادمین `09390039833`، دکتر تست `09100652121`
  (uuid `bcabb3a8-cae3-45ec-876c-548f9c1e1569`) در کلینیک
  `41e325c4-e825-4067-8438-5d828ecaee09`، مالک کلینیک `09024206041`. کد OTP در dev همیشه
  `12345`.
