fix(appointments): file the case file on every confirmation path
Confirming an appointment was supposed to create the patient's record and its session, and PatientService already knew how. Only two of the five paths that confirm an appointment ever called it, and the one that mattered most did not: a booking paid for online was confirmed inside the payment callback, which never ran the side-effects. Every Nobat724 booking therefore went unfiled — 7 confirmed appointments in dev had no session at all. The side-effects now run through AppointmentConfirmationService, which every path calls: the payment callback, both PATCH endpoints, and panel/admin bookings. Creating the record can no longer roll back a confirmation or a payment; a failure is logged and can be repaired with the new app:appointment:backfill-sessions command. Two related defects fixed along the way: - A doctor working at a clinic got two records for one appointment, one under the doctor and one under the clinic, so a single visit's revenue was counted twice. The booking context now decides, and it decides once. - That context was inferred from address_id, falling back to "the doctor's only clinic" — a guess that files an appointment under the wrong practice now that schedules are per-context. It is stored as appointments.clinic_id instead. Panel and admin bookings were left pending forever: nothing confirmed them and no payment was expected. They are created confirmed. Repeat confirmations no longer duplicate the session; an archived one still counts as filed, so archiving a mistaken visit does not resurrect it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
+41
-5
@@ -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` را از روی نوبتِ متناظر برمیگرداند؛ پس در پروندهی کلینیک مشخص است هر مراجعه برای کدام پزشک بوده است.
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Makes an appointment's booking context explicit.
|
||||
*
|
||||
* The clinic used to be inferred at read time from address_id, falling back to
|
||||
* "the doctor's only clinic" when the address was missing. With per-context
|
||||
* weekly schedules that guess silently files an appointment under the wrong
|
||||
* practice, so the context is now stored on the row.
|
||||
*/
|
||||
final class Version20260718132000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add appointments.clinic_id (booking context; NULL = doctor personal practice)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Command;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Appointment\Service\AppointmentConfirmationService;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Finds appointments that were confirmed without getting a patient session.
|
||||
*
|
||||
* Until the confirmation side-effects were funnelled through
|
||||
* AppointmentConfirmationService, only the two PATCH endpoints created records —
|
||||
* anything confirmed by an online payment never did. Those appointments are
|
||||
* still missing their case file, and completed ones need it just as much: a
|
||||
* visit that was never filed does not stop mattering because time passed.
|
||||
*/
|
||||
#[AsCommand(name: 'app:appointment:backfill-sessions', description: 'Report (and optionally create) missing patient sessions for confirmed appointments')]
|
||||
class BackfillAppointmentSessionsCommand extends Command
|
||||
{
|
||||
private const FILED_STATUSES = [
|
||||
Appointment::STATUS_CONFIRMED,
|
||||
Appointment::STATUS_COMPLETED,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly AppointmentRepository $appointmentRepo,
|
||||
private readonly PatientSessionRepository $sessionRepo,
|
||||
private readonly AppointmentConfirmationService $confirmation,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Patient\Service\PatientService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* عوارض جانبیِ قطعیشدن نوبت، در یک نقطه.
|
||||
*
|
||||
* قطعیشدن پنج مسیر دارد (پرداخت آنلاین، دو مسیر PATCH، رزرو پنل، رزرو ادمین) و
|
||||
* تا امروز فقط دو تای آنها پرونده میساختند — نوبتهای سایت عمومی که با پرداخت
|
||||
* قطعی میشوند هیچوقت پرونده نداشتند. هر مسیر جدیدی هم باید همین را صدا بزند.
|
||||
*/
|
||||
class AppointmentConfirmationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PatientService $patientService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* idempotent: فراخوانی دوباره برای همان نوبت چیزی نمیسازد.
|
||||
*
|
||||
* شکست ساخت پرونده نباید قطعیشدن نوبت یا تأیید پرداخت را برگرداند — نوبت
|
||||
* رزرو شده و پول پرداخت شده است؛ پرونده را میشود با
|
||||
* `app:appointment:backfill-sessions` ساخت، ولی رولبکِ پرداخت برگشتناپذیر است.
|
||||
*/
|
||||
public function onConfirmed(Appointment $appointment): void
|
||||
{
|
||||
// نوبت رزروِ روز-محور اسلات و ساعت مشخص ندارد؛ مراجعهٔ زماندار برایش معنا ندارد.
|
||||
if ($appointment->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Service\AppointmentConfirmationService;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Confirming an appointment files exactly one case file, in the practice where
|
||||
* the booking actually happened.
|
||||
*
|
||||
* Two records for one appointment means a single visit's revenue is counted
|
||||
* twice, and inferring the practice from the address (rather than the booking
|
||||
* context) files it under the wrong one.
|
||||
*/
|
||||
class AutoCreateSessionOnConfirmTest extends ApiTestCase
|
||||
{
|
||||
private function confirmation(): AppointmentConfirmationService
|
||||
{
|
||||
return static::getContainer()->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), 'نوبت رزروِ روز-محور ساعت ندارد؛ مراجعه نمیسازد');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Payment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Config\Entity\SiteConfig;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Paying for an online booking must file the case file, same as any other way
|
||||
* of confirming.
|
||||
*
|
||||
* This is the path every Nobat724 booking takes, and it was the one path that
|
||||
* never created a record: the payment callback confirmed the appointment
|
||||
* without running the confirmation side-effects.
|
||||
*/
|
||||
class AppointmentPaidConfirmFilesSessionTest extends ApiTestCase
|
||||
{
|
||||
private function enableTestMode(): void
|
||||
{
|
||||
$cfg = $this->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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user