From e6422014d1fad5b02d15b9d161a9b00fc6d5db85 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 18 Jul 2026 16:55:39 +0330 Subject: [PATCH] fix(appointments): file the case file on every confirmation path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...to-create-record-and-session-on-confirm.md | 385 ++++++++++++++++++ docs/api/appointment.md | 18 + docs/api/patient.md | 46 ++- migrations/Version20260718132000.php | 49 +++ src/Admin/Controller/AdminApiController.php | 7 + .../BackfillAppointmentSessionsCommand.php | 118 ++++++ .../Controller/AppointmentController.php | 9 +- .../Controller/MyAppointmentsController.php | 9 + src/Appointment/Entity/Appointment.php | 11 + .../AppointmentConfirmationService.php | 46 +++ .../Repository/PatientSessionRepository.php | 23 ++ src/Patient/Service/PatientService.php | 44 +- src/Payment/Service/PaymentManager.php | 2 + .../AutoCreateSessionOnConfirmTest.php | 150 +++++++ ...AppointmentPaidConfirmFilesSessionTest.php | 106 +++++ 15 files changed, 995 insertions(+), 28 deletions(-) create mode 100644 .claude/prompt/auto-create-record-and-session-on-confirm.md create mode 100644 migrations/Version20260718132000.php create mode 100644 src/Appointment/Command/BackfillAppointmentSessionsCommand.php create mode 100644 src/Appointment/Service/AppointmentConfirmationService.php create mode 100644 tests/Patient/AutoCreateSessionOnConfirmTest.php create mode 100644 tests/Payment/AppointmentPaidConfirmFilesSessionTest.php diff --git a/.claude/prompt/auto-create-record-and-session-on-confirm.md b/.claude/prompt/auto-create-record-and-session-on-confirm.md new file mode 100644 index 00000000..a24490c9 --- /dev/null +++ b/.claude/prompt/auto-create-record-and-session-on-confirm.md @@ -0,0 +1,385 @@ +# ایجاد خودکار پرونده و سرویس در همهٔ مسیرهای قطعی‌شدن نوبت + +## زمینه + +منطق «قطعی شدن نوبت → ساخت پرونده و سرویس» **از قبل نوشته شده** است: +`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`. diff --git a/docs/api/appointment.md b/docs/api/appointment.md index c1eba18d..a9ec9bc3 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -712,6 +712,24 @@ On `POST /api/v1/appointment`, any `service_item_uuids` must belong to the same `422 ERR_VALIDATION_001` («سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد»). The appointment's `address_id` is resolved from that context's schedule. +**The context is stored on the row.** All three booking paths persist it as +`appointments.clinic_id` (`NULL` = personal practice). Downstream consumers — above all the +automatic case-file creation documented in [patient.md](patient.md#auto-creation-on-appointment-confirm) — +read that column instead of inferring the clinic from `address_id`. The old inference had a +fallback of "the doctor's only clinic", which silently filed appointments under the wrong practice +once per-context schedules existed. + +### وضعیت اولیهٔ نوبت + +| مسیر | وضعیت هنگام ثبت | +|---|---| +| `POST /api/v1/appointment` (سایت عمومی) | `pending` با TTL پرداخت (`Appointment::PAYMENT_TTL` = ۱۵ دقیقه)؛ با پرداخت موفق `confirmed` می‌شود | +| `POST /api/v1/my/appointment` (پنل) | مستقیم `confirmed` | +| admin booking | مستقیم `confirmed` | + +نوبتی که خودِ کلینیک/پزشک ثبت می‌کند پرداخت آنلاین ندارد و منتظر چیزی نیست؛ `pending` ماندنش +یعنی نه در تقویم درست شمرده می‌شود و نه پرونده می‌سازد. + > **Silent-failure warning:** before this change the location was inferred from the doctor's single > schedule. A client that does not send `clinic_uuid` will now book into the personal practice — > which is correct, but is a behaviour change for any doctor who also works in a clinic. Update diff --git a/docs/api/patient.md b/docs/api/patient.md index a04cbf27..51df4fa1 100644 --- a/docs/api/patient.md +++ b/docs/api/patient.md @@ -611,13 +611,49 @@ GET /api/v1/session/{uuid}/audit-log ## Auto-Creation on Appointment Confirm -When an appointment's status changes to `confirmed` via `PATCH /api/v1/appointment/{uuid}/status`, the system automatically: +هر نوبتی که قطعی می‌شود — **از هر مسیری** — به‌صورت خودکار: -1. Creates a `PatientRecord` for the appointment's user (if not already existing) under the doctor entity -2. Creates a blank `PatientSession` linked to the appointment -3. **اگر نوبت با آدرس کلینیک ثبت شده باشد** (`appointment.address_id` → `DoctorAddress.clinic_id`)، همان دو مرحله برای آن **کلینیک** (`entity_type='clinic'`) هم تکرار می‌شود. اگر آدرس نوبت کلینیک نداشت ولی دکتر فقط عضو **یک** کلینیک بود، به همان کلینیک اضافه می‌شود. +1. اگر بیمار در آن محیط پرونده نداشته باشد، یک `PatientRecord` می‌سازد +2. یک `PatientSession` گره‌خورده به همان نوبت می‌سازد (زمان مراجعه = زمان نوبت، هزینه ویزیت + و خطوط سرویس از خود نوبت snapshot می‌شوند) -هر شاخه (doctor / clinic) مستقل و فقط در صورت فعال‌بودن ویژگی `patient_records` برای همان entity اجرا می‌شود. duplicate با `findByEntityAndUser` جلوگیری می‌شود. +### محیط پرونده — یکی، نه هر دو (2026-07) + +محیطِ **رزرو** تعیین می‌کند پرونده کجا ساخته شود، و مبنای آن ستون صریح +`appointments.clinic_id` است (نه استنتاج از آدرس): + +| `appointment.clinic_id` | پرونده | +|---|---| +| مقدار دارد | فقط `entity_type='clinic'` همان کلینیک | +| `NULL` | فقط `entity_type='doctor'` مطب شخصی پزشک | + +> **تغییر رفتار:** پیش از این برای پزشکِ عضو کلینیک **هر دو** پرونده ساخته می‌شد و یک نوبت دو +> مراجعهٔ جدا داشت — یعنی درآمد یک ویزیت دو بار شمرده می‌شد. حالا دقیقاً یکی ساخته می‌شود. +> دادهٔ تاریخیِ تکراری حذف نشده است؛ پاک‌سازی آن کار جداگانه‌ای است. + +### مسیرهای قطعی‌شدن + +همهٔ این‌ها از `AppointmentConfirmationService::onConfirmed()` عبور می‌کنند: + +| مسیر | توضیح | +|---|---| +| `POST /api/v1/payment/callback/{gateway}` | پرداخت آنلاین سایت عمومی (Nobat724 و سایت‌های وابسته) | +| `PATCH /api/v1/appointment/{uuid}/status` | تغییر وضعیت به `confirmed` | +| `PATCH /api/v1/appointment/{uuid}` | ویرایش نوبت همراه با تغییر وضعیت | +| `POST /api/v1/my/appointment` | رزرو از پنل — نوبت مستقیم `confirmed` ثبت می‌شود | +| `POST /api/v1/admin/appointment` | رزرو از ادمین — نوبت مستقیم `confirmed` ثبت می‌شود | + +### قواعد + +- **idempotent:** قطعی‌شدن دوباره (`confirmed → cancelled → confirmed`) مراجعهٔ تکراری + نمی‌سازد. مراجعهٔ آرشیوشده هم «ساخته‌شده» حساب می‌شود. +- **نوبت رزروِ روز-محور** (`is_reserve=true`) ساعت مشخص ندارد و مراجعه نمی‌سازد. +- **گارد اشتراک:** بدون ویژگی `patient_records` برای آن tenant، پرونده ساخته نمی‌شود و خطا هم + برنمی‌گردد (فقط لاگ سطح `info`). +- **شکست ساخت پرونده، قطعی‌شدن نوبت یا تأیید پرداخت را برنمی‌گرداند** — لاگ سطح `error` ثبت + می‌شود و پرونده را می‌توان بعداً با + `php bin/console app:appointment:backfill-sessions --fix` ساخت. این command نوبت‌های + `confirmed`/`completed` بدون مراجعه را فهرست (و با `--fix` تکمیل) می‌کند. **انتساب پزشک:** هر `PatientSession` در پاسخ، `doctor_uuid` و `doctor_name` را از روی نوبتِ متناظر برمی‌گرداند؛ پس در پرونده‌ی کلینیک مشخص است هر مراجعه برای کدام پزشک بوده است. diff --git a/migrations/Version20260718132000.php b/migrations/Version20260718132000.php new file mode 100644 index 00000000..f7f4e685 --- /dev/null +++ b/migrations/Version20260718132000.php @@ -0,0 +1,49 @@ +addSql('ALTER TABLE appointments ADD clinic_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE appointments ADD CONSTRAINT FK_6A41727ACC22AD4 FOREIGN KEY (clinic_id) REFERENCES clinics (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_6A41727ACC22AD4 ON appointments (clinic_id)'); + + // Backfill only where the booked address genuinely belongs to a clinic. + // The old "doctor's only clinic" fallback is deliberately not reproduced: + // a row without a clinic address is a personal-practice appointment, and + // guessing here would bake the very bug this column removes into history. + $this->addSql(<<<'SQL' + UPDATE appointments a + JOIN doctor_addresses da ON da.id = a.address_id + SET a.clinic_id = da.clinic_id + WHERE da.clinic_id IS NOT NULL + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE appointments DROP FOREIGN KEY FK_6A41727ACC22AD4'); + $this->addSql('DROP INDEX IDX_6A41727ACC22AD4 ON appointments'); + $this->addSql('ALTER TABLE appointments DROP clinic_id'); + } +} diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index 10df0608..7a27b5a3 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -44,6 +44,7 @@ class AdminApiController extends BaseController private readonly \App\Payment\Repository\PaymentRepository $paymentRepo, private readonly \App\Patient\Service\PatientResolver $patientResolver, private readonly \App\Insurance\Service\VisitPriceRequirementResolver $visitPriceResolver, + private readonly \App\Appointment\Service\AppointmentConfirmationService $appointmentConfirmation, ) {} // ── Users ───────────────────────────────────────────────────────────────── @@ -932,15 +933,21 @@ class AdminApiController extends BaseController $appointment->addServiceItem($si); } $bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null); + $appointment->setClinic($bookingClinic); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic); if ($locationId !== null) $appointment->setAddressId($locationId); + // نوبتِ ثبت‌شده توسط ادمین پرداخت آنلاین ندارد و منتظر چیزی نیست؛ قطعی است. + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + try { $this->em->getRepository(Appointment::class)->bookAtomically($appointment); } catch (SlotTakenException) { return $this->error(ErrorCodes::SLOT_TAKEN, 'این نوبت قبلاً رزرو شده است', 409); } + $this->appointmentConfirmation->onConfirmed($appointment); + return $this->success([ 'uuid' => $appointment->getUuid(), 'slot_start' => $slotStart, diff --git a/src/Appointment/Command/BackfillAppointmentSessionsCommand.php b/src/Appointment/Command/BackfillAppointmentSessionsCommand.php new file mode 100644 index 00000000..2e76d865 --- /dev/null +++ b/src/Appointment/Command/BackfillAppointmentSessionsCommand.php @@ -0,0 +1,118 @@ +addOption('fix', null, InputOption::VALUE_NONE, 'Create the missing sessions instead of only reporting them'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $fix = (bool) $input->getOption('fix'); + + $appointments = $this->appointmentRepo->createQueryBuilder('a') + ->where('a.status IN (:statuses)') + ->andWhere('a.isReserve = false') + ->setParameter('statuses', self::FILED_STATUSES) + ->orderBy('a.id', 'ASC') + ->getQuery() + ->getResult(); + + $rows = []; + $missing = []; + + foreach ($appointments as $appointment) { + $clinic = $appointment->getClinic(); + [$entityType, $entityId] = $clinic !== null + ? ['clinic', (int) $clinic->getId()] + : ['doctor', (int) $appointment->getDoctor()->getId()]; + + if ($this->sessionRepo->findByAppointmentAndEntity($appointment, $entityType, $entityId) !== null) { + continue; + } + + $missing[] = [$appointment, $entityType, $entityId]; + $rows[] = [ + $appointment->getUuid(), + $appointment->getStatus(), + date('Y-m-d H:i', $appointment->getSlotStart()), + $appointment->getDoctor()->getName(), + $clinic !== null ? ($clinic->getName() ?? 'clinic') : 'personal', + ]; + } + + if ($rows === []) { + $io->success('Every confirmed appointment already has its patient session.'); + + return Command::SUCCESS; + } + + $io->table(['appointment', 'status', 'slot', 'doctor', 'context'], $rows); + + if (!$fix) { + $io->warning(sprintf('%d appointment(s) without a session. Re-run with --fix to create them.', count($rows))); + + return Command::SUCCESS; + } + + $created = 0; + foreach ($missing as [$appointment, $entityType, $entityId]) { + $this->confirmation->onConfirmed($appointment); + $this->em->flush(); + + if ($this->sessionRepo->findByAppointmentAndEntity($appointment, $entityType, $entityId) !== null) { + $created++; + } + } + + // آن‌هایی که ساخته نشدند عمداً رد شده‌اند (نبودِ ویژگی patient_records برای آن + // tenant)؛ جدا گزارش می‌شوند تا با شکست اشتباه گرفته نشوند. + $skipped = count($missing) - $created; + $io->success(sprintf('Created %d session(s).', $created)); + if ($skipped > 0) { + $io->note(sprintf('%d skipped — their tenant has no patient_records feature.', $skipped)); + } + + return Command::SUCCESS; + } +} diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 22a87d16..4c117905 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -7,12 +7,12 @@ use App\Appointment\Entity\WeeklySchedule; use App\Appointment\Repository\AppointmentRepository; use App\Appointment\Repository\SlotTakenException; use App\Appointment\Repository\WeeklyScheduleRepository; +use App\Appointment\Service\AppointmentConfirmationService; use App\Appointment\Service\SlotCalculatorService; use App\Clinic\Entity\Clinic; use App\Doctor\Entity\Doctor; use App\Auth\Entity\User; use App\Doctor\Repository\DoctorRepository; -use App\Patient\Service\PatientService; use App\Shared\Service\InputValidator; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; @@ -31,7 +31,7 @@ class AppointmentController extends BaseController private readonly AppointmentRepository $appointmentRepo, private readonly DoctorRepository $doctorRepo, private readonly SlotCalculatorService $slotCalculator, - private readonly PatientService $patientService, + private readonly AppointmentConfirmationService $appointmentConfirmation, private readonly WeeklyScheduleRepository $scheduleRepo, private readonly \App\Appointment\Service\BookingContextResolver $bookingContext, private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo, @@ -506,6 +506,7 @@ class AppointmentController extends BaseController } // آدرس نوبت از روی session متناظر در برنامه‌ی هفتگی تعیین می‌شود (location_id). + $appointment->setClinic($bookingClinic); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic); if ($locationId !== null) { $appointment->setAddressId($locationId); @@ -877,7 +878,7 @@ class AppointmentController extends BaseController } if ($newStatus === Appointment::STATUS_CONFIRMED) { - $this->patientService->autoCreateOnAppointmentConfirm($appointment); + $this->appointmentConfirmation->onConfirmed($appointment); } if (in_array($newStatus, self::CANCEL_STATUSES, true)) { @@ -980,7 +981,7 @@ class AppointmentController extends BaseController } $appointment->transitionTo($newStatus); if ($newStatus === Appointment::STATUS_CONFIRMED) { - $this->patientService->autoCreateOnAppointmentConfirm($appointment); + $this->appointmentConfirmation->onConfirmed($appointment); } if (in_array($newStatus, self::CANCEL_STATUSES, true)) { $cancelledTo = $newStatus; diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 92dc3499..ce6982e1 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -45,6 +45,7 @@ class MyAppointmentsController extends BaseController private readonly \App\Auth\Repository\UserRepository $userRepo, private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo, private readonly VisitPriceRequirementResolver $visitPriceResolver, + private readonly \App\Appointment\Service\AppointmentConfirmationService $appointmentConfirmation, ) {} #[Route('/api/v1/my/appointment', methods: ['POST'])] @@ -145,6 +146,7 @@ class MyAppointmentsController extends BaseController // محل نوبت باید از همان محیطی بیاید که نوبت در آن ثبت می‌شود؛ بدون clinic_uuid // یعنی مطب شخصی، نه «هر برنامه‌ای که پیدا شد». $bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null); + $appointment->setClinic($bookingClinic); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic); if ($locationId !== null) $appointment->setAddressId($locationId); @@ -184,6 +186,11 @@ class MyAppointmentsController extends BaseController $appointment->setPatientName($patient->getRealName() ?: $patientName); $appointment->setPatientMobile($mobile); + // نوبتی که خودِ کلینیک/پزشک ثبت می‌کند پرداخت آنلاین ندارد و منتظر چیزی نیست؛ + // قطعی است. transitionTo قبل از ذخیره می‌آید تا active_slot_key با وضعیت نهایی + // محاسبه شود. + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + if ($isReserve) { // Day-level reserve: no slot occupation, plain save (no atomic slot check). $appointment->rescheduleTo($slotStart, $slotEnd, true); @@ -196,6 +203,8 @@ class MyAppointmentsController extends BaseController } } + $this->appointmentConfirmation->onConfirmed($appointment); + return $this->success([ 'uuid' => $appointment->getUuid(), 'slot_start' => $slotStart, diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index d7c9468d..b05d1260 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -127,6 +127,15 @@ class Appointment #[ORM\Column(name: 'address_id', type: 'integer', nullable: true)] private ?int $addressId = null; + /** + * محیط رزرو: 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; + #[ORM\Column(name: 'booking_representation_id', type: 'integer', nullable: true)] private ?int $bookingRepresentationId = null; @@ -217,11 +226,13 @@ class Appointment public function getPatientGender(): ?string { return $this->patientGender; } public function getPatientReason(): ?string { return $this->patientReason; } public function getAddressId(): ?int { return $this->addressId; } + public function getClinic(): ?\App\Clinic\Entity\Clinic { return $this->clinic; } public function getBookingRepresentationId(): ?int { return $this->bookingRepresentationId; } public function setNote(?string $v): self { $this->note = $v; return $this; } public function setBookingRepresentationId(?int $v): self { $this->bookingRepresentationId = $v; return $this; } public function setAddressId(?int $v): self { $this->addressId = $v; return $this; } + public function setClinic(?\App\Clinic\Entity\Clinic $v): self { $this->clinic = $v; return $this; } public function setPatientName(?string $v): self { $this->patientName = $v; return $this; } public function setPatientMobile(?string $v): self { $this->patientMobile = $v; return $this; } public function setPatientNationalCode(?string $v): self { $this->patientNationalCode = $v; return $this; } diff --git a/src/Appointment/Service/AppointmentConfirmationService.php b/src/Appointment/Service/AppointmentConfirmationService.php new file mode 100644 index 00000000..08f8ba5e --- /dev/null +++ b/src/Appointment/Service/AppointmentConfirmationService.php @@ -0,0 +1,46 @@ +isReserve()) { + return; + } + + try { + $this->patientService->autoCreateOnAppointmentConfirm($appointment); + } catch (\Throwable $e) { + $this->logger->error('Auto-creating the patient record on confirm failed', [ + 'appointment_uuid' => $appointment->getUuid(), + 'exception' => $e, + ]); + } + } +} diff --git a/src/Patient/Repository/PatientSessionRepository.php b/src/Patient/Repository/PatientSessionRepository.php index f7c8c315..f46c03af 100644 --- a/src/Patient/Repository/PatientSessionRepository.php +++ b/src/Patient/Repository/PatientSessionRepository.php @@ -2,6 +2,7 @@ namespace App\Patient\Repository; +use App\Appointment\Entity\Appointment; use App\Patient\Entity\PatientRecord; use App\Patient\Entity\PatientSession; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; @@ -19,6 +20,28 @@ class PatientSessionRepository extends ServiceEntityRepository return $this->findOneBy(['uuid' => $uuid]); } + /** + * مراجعهٔ ساخته‌شده برای این نوبت در همین محیط، یا null. + * + * گاردِ ساختِ خودکار: قطعی‌شدنِ دوباره (confirmed → cancelled → confirmed) نباید + * مراجعهٔ تکراری بسازد. مراجعهٔ آرشیوشده هم «ساخته‌شده» حساب می‌شود، وگرنه آرشیو + * کردنِ یک مراجعهٔ اشتباه باعث ساخت دوباره‌اش می‌شود. + */ + 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(); + } + /** $filter: all | active | archived. پیش‌فرض all برای حفظ رفتار callerهای موجود. */ public function findByRecord(PatientRecord $record, int $page = 1, int $limit = 20, string $filter = 'all'): array { diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php index 6aafbcc5..143e28c3 100644 --- a/src/Patient/Service/PatientService.php +++ b/src/Patient/Service/PatientService.php @@ -29,6 +29,7 @@ use App\Shared\Constant\ErrorCodes; use App\Shared\Exception\AppException; use App\Staff\Repository\ClinicStaffRepository; use App\Subscription\Service\SubscriptionService; +use Psr\Log\LoggerInterface; class PatientService { @@ -52,6 +53,7 @@ class PatientService private readonly EntityInsurancePricingRepository $pricingRepo, private readonly \App\Discount\Service\DiscountEngine $discountEngine, private readonly \App\Patient\Repository\SessionAuditLogRepository $auditRepo, + private readonly LoggerInterface $logger, ) {} /** ثبت یک رکورد تاریخچه‌ی تغییر مالی/خدماتی روی مراجعه. */ @@ -122,34 +124,38 @@ class PatientService ]; } + /** + * پرونده و مراجعهٔ خودکار برای یک نوبت قطعی‌شده. + * + * محیط رزرو تعیین‌کننده است: کلینیک، یا مطب شخصی پزشک — هرگز هر دو. دو پرونده + * برای یک نوبت یعنی درآمد یک ویزیت دو بار شمرده می‌شود. + */ public function autoCreateOnAppointmentConfirm(Appointment $appointment): void { - $doctor = $appointment->getDoctor(); + $clinic = $appointment->getClinic(); - // پرونده‌ی پزشک - $this->autoCreateForEntity('doctor', $doctor->getId(), $appointment, $doctor->getId()); + [$entityType, $entityId] = $clinic !== null + ? ['clinic', (int) $clinic->getId()] + : ['doctor', (int) $appointment->getDoctor()->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); - } + $this->autoCreateForEntity($entityType, $entityId, $appointment, $entityId); } private function autoCreateForEntity(string $entityType, int $entityId, Appointment $appointment, int $createdById): void { if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) { + // به‌زور پرونده نمی‌سازیم، ولی بی‌نشانه هم رد نمی‌شویم: بدون این لاگ، + // «چرا این نوبت پرونده ندارد» غیرقابل‌تشخیص است. + $this->logger->info('Skipped auto-creating the patient record: the tenant has no patient_records feature', [ + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'appointment_uuid' => $appointment->getUuid(), + ]); + + return; + } + + if ($this->sessionRepo->findByAppointmentAndEntity($appointment, $entityType, $entityId) !== null) { return; } diff --git a/src/Payment/Service/PaymentManager.php b/src/Payment/Service/PaymentManager.php index 2fc0e5c4..09b09087 100644 --- a/src/Payment/Service/PaymentManager.php +++ b/src/Payment/Service/PaymentManager.php @@ -42,6 +42,7 @@ final class PaymentManager private readonly CommissionService $commissionService, private readonly \App\Representation\Service\DomainContextResolver $domainResolver, private readonly JalaliDateService $jalali, + private readonly \App\Appointment\Service\AppointmentConfirmationService $appointmentConfirmation, private readonly string $appBaseUrl, ) {} @@ -311,6 +312,7 @@ final class PaymentManager $appointment->transitionTo(Appointment::STATUS_CONFIRMED); $this->em->persist($appointment); + $this->appointmentConfirmation->onConfirmed($appointment); $doctor = $appointment->getDoctor(); $this->commissionService->processAppointment( diff --git a/tests/Patient/AutoCreateSessionOnConfirmTest.php b/tests/Patient/AutoCreateSessionOnConfirmTest.php new file mode 100644 index 00000000..b76066b6 --- /dev/null +++ b/tests/Patient/AutoCreateSessionOnConfirmTest.php @@ -0,0 +1,150 @@ +get(AppointmentConfirmationService::class); + } + + private function makeDoctor(): Doctor + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر آزمون'); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + private function makeClinic(Doctor $doctor): Clinic + { + $clinic = new Clinic($this->createUser(['ROLE_CLINIC'])); + $clinic->setName('کلینیک آزمون'); + $clinic->getDoctors()->add($doctor); + $this->em->persist($clinic); + $this->em->flush(); + + return $clinic; + } + + private function makeAppointment(Doctor $doctor, ?Clinic $clinic = null): Appointment + { + $appointment = new Appointment($doctor, $this->createUser(['ROLE_USER']), 1_790_000_000, 1_790_001_800); + $appointment->setClinic($clinic); + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + $this->em->persist($appointment); + $this->em->flush(); + + return $appointment; + } + + /** @return PatientRecord[] */ + private function recordsFor(Appointment $appointment): array + { + return $this->em->getRepository(PatientRecord::class) + ->findBy(['user' => $appointment->getUser()]); + } + + /** @return PatientSession[] */ + private function sessionsFor(Appointment $appointment): array + { + return $this->em->getRepository(PatientSession::class) + ->findBy(['appointment' => $appointment]); + } + + public function testClinicBookingFilesOnlyTheClinicRecord(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinic($doctor); + $appointment = $this->makeAppointment($doctor, $clinic); + + $this->confirmation()->onConfirmed($appointment); + $this->em->flush(); + + $records = $this->recordsFor($appointment); + self::assertCount(1, $records, 'یک نوبت باید دقیقاً یک پرونده بسازد'); + self::assertSame('clinic', $records[0]->getEntityType()); + self::assertSame($clinic->getId(), $records[0]->getEntityId()); + self::assertCount(1, $this->sessionsFor($appointment)); + } + + public function testPersonalBookingFilesOnlyTheDoctorRecord(): void + { + $doctor = $this->makeDoctor(); + $this->makeClinic($doctor); // عضویت کلینیک نباید نوبت شخصی را بدزدد + $appointment = $this->makeAppointment($doctor, null); + + $this->confirmation()->onConfirmed($appointment); + $this->em->flush(); + + $records = $this->recordsFor($appointment); + self::assertCount(1, $records); + self::assertSame('doctor', $records[0]->getEntityType()); + self::assertSame($doctor->getId(), $records[0]->getEntityId()); + } + + public function testExistingRecordGetsAnotherSessionInsteadOfAnotherRecord(): void + { + $doctor = $this->makeDoctor(); + $first = $this->makeAppointment($doctor, null); + $patient = $first->getUser(); + + $this->confirmation()->onConfirmed($first); + $this->em->flush(); + + $second = new Appointment($doctor, $patient, 1_790_100_000, 1_790_101_800); + $second->transitionTo(Appointment::STATUS_CONFIRMED); + $this->em->persist($second); + $this->em->flush(); + + $this->confirmation()->onConfirmed($second); + $this->em->flush(); + + self::assertCount(1, $this->recordsFor($first), 'بیمار قبلاً پرونده دارد؛ پروندهٔ دوم ساخته نشود'); + self::assertCount(1, $this->sessionsFor($second), 'ولی مراجعهٔ جدید باید ثبت شود'); + } + + public function testConfirmingTwiceDoesNotDuplicateTheSession(): void + { + $doctor = $this->makeDoctor(); + $appointment = $this->makeAppointment($doctor, null); + + $this->confirmation()->onConfirmed($appointment); + $this->em->flush(); + $this->confirmation()->onConfirmed($appointment); + $this->em->flush(); + + self::assertCount(1, $this->sessionsFor($appointment)); + } + + public function testDayLevelReserveIsNotFiled(): void + { + $doctor = $this->makeDoctor(); + $appointment = $this->makeAppointment($doctor, null); + $appointment->rescheduleTo(1_790_000_000, 1_790_001_800, true); + $this->em->flush(); + + $this->confirmation()->onConfirmed($appointment); + $this->em->flush(); + + self::assertCount(0, $this->sessionsFor($appointment), 'نوبت رزروِ روز-محور ساعت ندارد؛ مراجعه نمی‌سازد'); + } +} diff --git a/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php b/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php new file mode 100644 index 00000000..b8cd620e --- /dev/null +++ b/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php @@ -0,0 +1,106 @@ +em->getRepository(SiteConfig::class)->findOneBy(['configKey' => 'payment_test_mode']); + if ($cfg === null) { + $this->em->persist(new SiteConfig('payment_test_mode', '1')); + } else { + $cfg->setValue('1'); + } + $this->em->flush(); + } + + /** @return array{0: Payment, 1: Appointment} */ + private function pendingPaidBooking(?callable $withClinic = null): array + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر پرداخت'); + $this->em->persist($doctor); + $this->em->flush(); + + $appointment = new Appointment($doctor, $this->createUser(['ROLE_USER']), 1_790_200_000, 1_790_201_800); + if ($withClinic !== null) { + $appointment->setClinic($withClinic($doctor)); + } + $this->em->persist($appointment); + + $payment = new Payment($appointment->getUser(), 50_000, 'mock', Payment::TYPE_APPOINTMENT); + $payment->setAppointment($appointment); + $this->em->persist($payment); + $this->em->flush(); + + return [$payment, $appointment]; + } + + private function fireCallback(Payment $payment): void + { + $this->client->request('POST', '/api/v1/payment/callback/mock?' . http_build_query([ + 'order_id' => $payment->getOrderId(), + 'mock' => '1', + 'ResCode' => '0', + 'mock_amount' => '50000', + ])); + } + + public function testPaidPersonalBookingIsConfirmedAndFiled(): void + { + $this->enableTestMode(); + [$payment, $appointment] = $this->pendingPaidBooking(); + + $this->fireCallback($payment); + $this->em->clear(); + + $reloaded = $this->em->getRepository(Appointment::class)->find($appointment->getId()); + self::assertSame(Appointment::STATUS_CONFIRMED, $reloaded->getStatus()); + + $sessions = $this->em->getRepository(PatientSession::class)->findBy(['appointment' => $reloaded]); + self::assertCount(1, $sessions, 'پرداخت آنلاین هم باید پرونده بسازد'); + + $record = $sessions[0]->getRecord(); + self::assertSame('doctor', $record->getEntityType()); + } + + public function testPaidClinicBookingIsFiledUnderTheClinic(): void + { + $this->enableTestMode(); + [$payment, $appointment] = $this->pendingPaidBooking(function (Doctor $doctor): Clinic { + $clinic = new Clinic($this->createUser(['ROLE_CLINIC'])); + $clinic->setName('کلینیک پرداخت'); + $clinic->getDoctors()->add($doctor); + $this->em->persist($clinic); + $this->em->flush(); + + return $clinic; + }); + + $this->fireCallback($payment); + $this->em->clear(); + + $reloaded = $this->em->getRepository(Appointment::class)->find($appointment->getId()); + $records = $this->em->getRepository(PatientRecord::class)->findBy(['user' => $reloaded->getUser()]); + + self::assertCount(1, $records, 'یک نوبت، یک پرونده — نه یکی برای پزشک و یکی برای کلینیک'); + self::assertSame('clinic', $records[0]->getEntityType()); + } +}