Files
clinicpro/.claude/prompt/auto-create-record-and-session-on-confirm.md
T
hamedandClaude Opus 4.8 e6422014d1 fix(appointments): file the case file on every confirmation path
Confirming an appointment was supposed to create the patient's record and its
session, and PatientService already knew how. Only two of the five paths that
confirm an appointment ever called it, and the one that mattered most did not:
a booking paid for online was confirmed inside the payment callback, which
never ran the side-effects. Every Nobat724 booking therefore went unfiled — 7
confirmed appointments in dev had no session at all.

The side-effects now run through AppointmentConfirmationService, which every
path calls: the payment callback, both PATCH endpoints, and panel/admin
bookings. Creating the record can no longer roll back a confirmation or a
payment; a failure is logged and can be repaired with the new
app:appointment:backfill-sessions command.

Two related defects fixed along the way:

- A doctor working at a clinic got two records for one appointment, one under
  the doctor and one under the clinic, so a single visit's revenue was counted
  twice. The booking context now decides, and it decides once.
- That context was inferred from address_id, falling back to "the doctor's only
  clinic" — a guess that files an appointment under the wrong practice now that
  schedules are per-context. It is stored as appointments.clinic_id instead.

Panel and admin bookings were left pending forever: nothing confirmed them and
no payment was expected. They are created confirmed.

Repeat confirmations no longer duplicate the session; an archived one still
counts as filed, so archiving a mistaken visit does not resurrect it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:55:39 +03:30

22 KiB
Raw Blame History

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

زمینه

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

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

-- ۱۶ نوبت قطعی، ولی فقط ۱۲ مراجعه
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:

    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 (بخش مرتبط):

    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:

    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:

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

    /**
     * محیط رزرو: 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:

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 بازنویسی شود:

    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 اضافه کن:

    /** مراجعهٔ ساخته‌شده برای این نوبت در همین محیط، یا 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:

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

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

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

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

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

        $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.