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()); + } +}