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:
hamed
2026-07-18 16:55:39 +03:30
co-authored by Claude Opus 4.8
parent 7baa4df3d4
commit e6422014d1
15 changed files with 995 additions and 28 deletions
+18
View File
@@ -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
View File
@@ -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` را از روی نوبتِ متناظر برمی‌گرداند؛ پس در پرونده‌ی کلینیک مشخص است هر مراجعه برای کدام پزشک بوده است.