# ایجاد خودکار پرونده و سرویس در همهٔ مسیرهای قطعی‌شدن نوبت ## زمینه منطق «قطعی شدن نوبت → ساخت پرونده و سرویس» **از قبل نوشته شده** است: `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`.