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>
22 KiB
ایجاد خودکار پرونده و سرویس در همهٔ مسیرهای قطعیشدن نوبت
زمینه
منطق «قطعی شدن نوبت → ساخت پرونده و سرویس» از قبل نوشته شده است:
PatientService::autoCreateOnAppointmentConfirm(). مشکل این نیست که وجود ندارد — این است که
فقط به دو مسیر از پنج مسیرِ قطعیشدن وصل است، و در همان دو مسیر هم بهجای یک پرونده، دو
پرونده (پزشک + کلینیک) میسازد.
شواهد از دیتابیس محیط توسعه:
-- ۱۶ نوبت قطعی، ولی فقط ۱۲ مراجعه
SELECT COUNT(*) FROM appointments WHERE status='confirmed'; -- 16
SELECT COUNT(*) FROM appointments a JOIN patient_sessions ps ON ps.appointment_id=a.id
WHERE a.status='confirmed'; -- 12
-- نوبت 130043 دو مراجعهٔ تکراری دارد (یکی برای پزشک، یکی برای کلینیک):
-- id status address_id session_id entity_type entity_id
-- 130043 confirmed 2631 30 doctor 3343
-- 130043 confirmed 2631 31 clinic 1003
-- و اینها هیچ مراجعهای ندارند:
-- 130044, 130028, 130025, 130011, 130010 → session_id = NULL
مشکل / هدف
هدف: هر نوبتی که قطعی میشود — از هر مسیری — دقیقاً یک پرونده و یک سرویس در همان محیطی بسازد که نوبت در آن رزرو شده (کلینیک، یا مطب شخصی پزشک مستقل).
پنج ایراد مشخص که باید رفع شوند:
۱. مسیر پرداخت آنلاین (Nobat724) اصلاً صدا نمیزند — ریشهٔ اصلی
نوبتِ سایت عمومی pending ساخته میشود و بعد از پرداخت در PaymentManager قطعی میشود؛
آنجا هیچ فراخوانیای وجود ندارد. یعنی هیچ نوبتی که از Nobat724 و سایتهای زیرمجموعه
رزرو و پرداخت شود، پرونده نمیسازد.
۲. دو پرونده بهجای یک پرونده
autoCreateOnAppointmentConfirm برای پزشکِ عضو کلینیک، هم پروندهٔ doctor میسازد و هم
clinic — دو مراجعهٔ جدا برای یک نوبت واحد (ردیف 130043 بالا). این یعنی درآمد یک نوبت در
دو جا شمرده میشود.
قاعدهٔ درست: نوبتی که در کلینیک رزرو شده → فقط پروندهٔ همان کلینیک. نوبتی که در مطب شخصی رزرو شده → فقط پروندهٔ پزشک.
۳. تشخیص کلینیک حدسی است
کلینیک از address_id استنتاج میشود و اگر آدرس نبود، «اگر پزشک فقط عضو یک کلینیک باشد»
همان فرض میشود. با مدل per-context فعلی (هر پزشک یک برنامه برای مطب شخصی + یکی به ازای هر
کلینیک) این حدس غلط است و نوبت را بیسروصدا به پروندهٔ محیط اشتباه میچسباند. نوبت 130032
با address_id = NULL دقیقاً روی همین شاخهٔ حدسی افتاده است.
۴. idempotent نیست
هیچ گاردی نیست که مراجعهٔ تکراری برای یک نوبت ساخته نشود. مسیر
confirmed → cancelled → confirmed یا دوبار PATCH، مراجعهٔ دوم میسازد.
۵. نوبتهای پنل و ادمین اصلاً قطعی نمیشوند
MyAppointmentsController و AdminApiController نوبت را با وضعیت پیشفرض pending
میسازند و هیچجا قطعی نمیکنند (markPendingWithTtl هم صدا نمیشود، پس نه منقضی میشود نه
قطعی). نوبت 130046 که همین امروز از پنل ساخته شده هنوز pending است. طبق نیاز، نوبتِ
ثبتشده توسط خودِ کلینیک/پزشک باید مستقیم confirmed باشد — پرداخت آنلاین ندارد.
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Patient/Service/PatientService.php:125-192 |
autoCreateOnAppointmentConfirm + autoCreateForEntity |
src/Payment/Service/PaymentManager.php:305-330 |
handleAppointmentConfirmation — مسیر پرداخت آنلاین |
src/Appointment/Controller/AppointmentController.php:871-881 |
PATCH status — تنها مسیر سالم فعلی |
src/Appointment/Controller/AppointmentController.php:975-988 |
PATCH نوبت (ویرایش کامل) |
src/Appointment/Controller/AppointmentController.php:495-537 |
رزرو عمومی — markPendingWithTtl سپس bookAtomically |
src/Appointment/Controller/MyAppointmentsController.php:142-198 |
رزرو از پنل — بدون قطعیکردن |
src/Admin/Controller/AdminApiController.php:925-947 |
رزرو از ادمین — بدون قطعیکردن |
src/Appointment/Entity/Appointment.php:37-42,101,178-189 |
ماشین وضعیت، status پیشفرض pending، سازنده |
src/Appointment/Service/BookingContextResolver.php |
تشخیص صریح کلینیکِ نوبت از clinic_uuid |
src/Patient/Entity/PatientRecord.php:15 |
unique روی (entity_type, entity_id, user_id) |
src/Patient/Entity/PatientSession.php:29-37 |
لینک اختیاری به Appointment |
src/Patient/Repository/PatientSessionRepository.php |
متد lookup بر اساس نوبت ندارد |
docs/api/patient.md:611-624 |
بخش «Auto-Creation on Appointment Confirm» |
وضعیت فعلی
دو پرونده + کلینیکِ حدسی
src/Patient/Service/PatientService.php:125-147:
public function autoCreateOnAppointmentConfirm(Appointment $appointment): void
{
$doctor = $appointment->getDoctor();
// پروندهی پزشک
$this->autoCreateForEntity('doctor', $doctor->getId(), $appointment, $doctor->getId());
// کلینیک نوبت را تعیین کن: اول از آدرس انتخابشده، وگرنه اگر دکتر فقط عضو یک کلینیک باشد.
$clinicId = null;
$addressId = $appointment->getAddressId();
if ($addressId !== null) {
$clinicId = $this->addressRepo->find($addressId)?->getClinicId();
}
if ($clinicId === null) {
$clinics = $this->clinicRepo->findByDoctor($doctor);
if (count($clinics) === 1) {
$clinicId = $clinics[0]->getId();
}
}
if ($clinicId !== null) {
$this->autoCreateForEntity('clinic', $clinicId, $appointment, $clinicId);
}
}
ساخت مراجعه — بدون گارد تکرار
src/Patient/Service/PatientService.php:149-192 (بخش مرتبط):
private function autoCreateForEntity(string $entityType, int $entityId, Appointment $appointment, int $createdById): void
{
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
return;
}
$patient = $appointment->getUser();
$record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
if ($record === null) {
$record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById);
$this->recordRepo->save($record);
}
$session = new PatientSession($record, $appointment); // ← هیچ چکی که قبلاً ساخته نشده باشد
$session->setSessionAt($appointment->getSlotStart());
...
مسیر پرداخت — بدون فراخوانی
src/Payment/Service/PaymentManager.php:305-314:
private function handleAppointmentConfirmation(Payment $payment): void
{
$appointment = $payment->getAppointment();
if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
return;
}
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appointment);
// ← اینجا هیچ ساختِ پروندهای نیست
رزرو پنل — وضعیت pending میماند
src/Appointment/Controller/MyAppointmentsController.php:190-197:
} else {
try {
$this->appointmentRepo->bookAtomically($appointment);
} catch (SlotTakenException) {
return $this->error(ErrorCodes::SLOT_TAKEN, 'این نوبت قبلاً رزرو شده است', 409);
}
}
Appointment::$status پیشفرض STATUS_PENDING است (Appointment.php:101) و هیچجای این
مسیر عوضش نمیکند.
وظایف
۱. context نوبت را صریح کن — ستون clinic_id روی appointments
حدسزدن محل، ریشهٔ ایراد ۳ است. نوبت باید بداند در کدام محیط رزرو شده، همانطور که
weekly_schedules میداند.
src/Appointment/Entity/Appointment.php:
/**
* محیط رزرو: null یعنی مطب شخصی پزشک، مقدار یعنی همان کلینیک. مبنای واحدِ
* تشخیص پرونده — از روی آدرس حدس زده نمیشود.
*/
#[ORM\ManyToOne(targetEntity: \App\Clinic\Entity\Clinic::class)]
#[ORM\JoinColumn(name: 'clinic_id', nullable: true, onDelete: 'SET NULL')]
private ?\App\Clinic\Entity\Clinic $clinic = null;
هر سه مسیر رزرو از قبل $bookingClinic را با BookingContextResolver حل میکنند و فقط برای
resolveSlotLocationId استفاده میکنند — همان را روی نوبت هم بنشان:
AppointmentController.php:~510(رزرو عمومی) — متغیر$bookingClinicموجود استMyAppointmentsController.php:~150—$bookingClinicموجود استAdminApiController.php:~937—$bookingClinicموجود است
Migration بنویس. برای ردیفهای موجود clinic_id را از address_id پر کن (همان استنتاجی که
امروز runtime انجام میدهد)، ولی فقط وقتی آدرس واقعاً کلینیکی است؛ شاخهٔ حدسیِ «تنها
کلینیک پزشک» را در migration تکرار نکن — ردیف بدون آدرس، مطب شخصی در نظر گرفته شود و در
توضیح migration ذکر شود.
۲. یک choke point برای قطعیشدن
بهجای پخشکردن فراخوانی در پنج جا، یک سرویس بساز که همه صدایش بزنند —
src/Appointment/Service/AppointmentConfirmationService.php:
final class AppointmentConfirmationService
{
/**
* عوارض جانبیِ قطعیشدن نوبت. هر مسیری که نوبت را confirmed میکند باید این را
* صدا بزند — پرداخت آنلاین، PATCH وضعیت، و رزروِ مستقیمِ پنل/ادمین.
* idempotent است: فراخوانی دوباره برای همان نوبت هیچ چیزی نمیسازد.
*/
public function onConfirmed(Appointment $appointment): void
{
$this->patientService->autoCreateOnAppointmentConfirm($appointment);
}
}
سپس در این پنج نقطه صدا زده شود:
| فایل | نقطه |
|---|---|
PaymentManager.php:312 |
بعد از transitionTo(STATUS_CONFIRMED) — مهمترین |
AppointmentController.php:879 |
جایگزین فراخوانی مستقیم فعلی |
AppointmentController.php:982 |
جایگزین فراخوانی مستقیم فعلی |
MyAppointmentsController.php:~193 |
بعد از bookAtomically (وظیفهٔ ۵) |
AdminApiController.php:~939 |
بعد از bookAtomically (وظیفهٔ ۵) |
دربارهٔ PaymentManager: آنجا داخل تراکنش پرداخت هستی. ساخت پرونده نباید تأیید پرداخت
را خراب کند — اگر شکست خورد، لاگ کن و پرداخت را نگه دار (همان الگویی که
docs/api/patient.md:446 برای مطالبهٔ بیمه توضیح داده: «خطا در این مرحله ثبت session را
خراب نمیکند»). ولی بیصدا رد نشو — لاگ سطح error با uuid نوبت.
۳. یک پرونده در context درست
autoCreateOnAppointmentConfirm بازنویسی شود:
public function autoCreateOnAppointmentConfirm(Appointment $appointment): void
{
$clinic = $appointment->getClinic();
// محیط رزرو تعیینکننده است: کلینیک، یا مطب شخصی پزشک. هرگز هر دو —
// دو پرونده برای یک نوبت یعنی درآمد یک ویزیت دو بار شمرده میشود.
[$entityType, $entityId] = $clinic !== null
? ['clinic', $clinic->getId()]
: ['doctor', $appointment->getDoctor()->getId()];
$this->autoCreateForEntity($entityType, $entityId, $appointment, $entityId);
}
سازگاری با ردیفهای قدیمیِ بدون clinic_id: بعد از migration وظیفهٔ ۱ همه پر شدهاند، پس
شاخهٔ fallback لازم نیست. اگر لازم دیدی نگهداری، از address_id → clinic_id استفاده کن و
شاخهٔ «تنها کلینیک پزشک» را حذف کن — همان حدسی است که باگ میسازد.
۴. idempotency
متد lookup به PatientSessionRepository اضافه کن:
/** مراجعهٔ ساختهشده برای این نوبت در همین محیط، یا null. */
public function findByAppointmentAndEntity(Appointment $appointment, string $entityType, int $entityId): ?PatientSession
{
return $this->createQueryBuilder('s')
->join('s.record', 'r')
->where('s.appointment = :appointment')
->andWhere('r.entityType = :entityType')
->andWhere('r.entityId = :entityId')
->setParameter('appointment', $appointment)
->setParameter('entityType', $entityType)
->setParameter('entityId', $entityId)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
و در ابتدای autoCreateForEntity بعد از گارد feature:
if ($this->sessionRepo->findByAppointmentAndEntity($appointment, $entityType, $entityId) !== null) {
return; // قبلاً ساخته شده — قطعیشدن دوباره نباید مراجعهٔ تکراری بسازد
}
نکتهٔ مرزی: مراجعهٔ آرشیوشده (PatientSession::$archived) هم باید «ساختهشده» حساب شود؛
وگرنه آرشیو کردنِ یک مراجعهٔ اشتباه باعث ساخت دوبارهٔ آن میشود. اگر تصمیم دیگری گرفتی در PR
بنویس.
۵. رزرو پنل و ادمین مستقیم confirmed شود
نوبتی که خودِ کلینیک یا پزشک از پنل ثبت میکند پرداخت آنلاین ندارد و منتظر چیزی نیست؛
pending ماندنش یعنی نه در تقویم درست شمرده میشود، نه پرونده میسازد.
در MyAppointmentsController و AdminApiController، قبل از bookAtomically:
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
سپس بعد از موفقیت bookAtomically، confirmationService->onConfirmed($appointment).
دقت: bookAtomically روی SLOT_OCCUPYING_STATUSES و active_slot_key حساب میکند و
confirmed جزو آنهاست (Appointment.php:52-55)، پس قفل اتمیک اسلات دستنخورده کار میکند.
transitionTo را قبل از bookAtomically بگذار تا refreshActiveSlotKey() با وضعیت
نهایی محاسبه شود.
استثنا: مسیر isReserve (نوبت رزروِ روز-محور، MyAppointmentsController:188-190) اسلات
اشغال نمیکند و مراجعهٔ زماندار برایش معنا ندارد — رفتار فعلیاش را عوض نکن و در
onConfirmed هم اگر isReserve() بود زود برگرد.
۶. گارد اشتراک — تصمیم صریح
autoCreateForEntity وقتی ویژگی patient_records فعال نباشد بیصدا برمیگردد. این درست
است (نباید به زور پرونده بسازد) ولی الان غیرقابلتشخیص است: نه لاگی، نه نشانهای.
- یک لاگ سطح
infoباentity_type/entity_id/appointment_uuidبگذار. - در
docs/api/patient.mdصریح بنویس که بدون این ویژگی، نوبت قطعی پرونده نمیسازد.
۷. Backfill نوبتهای قطعیِ بیپرونده
پنج نوبت قطعیِ فعلی مراجعه ندارند. یک console command بنویس —
app:appointment:backfill-sessions:
- نوبتهای
confirmed/completedکه مراجعهٔ متناظر ندارند را فهرست کند (uuid پزشک، تاریخ، context، دلیلِ نبودن). - با
--fixهمانonConfirmedرا برایشان اجرا کند. - خروجی تعداد ساختهشده و تعداد رد شده (بهخاطر گارد اشتراک) را جدا گزارش کند.
نوبتهای completed را هم پوشش بده: مراجعهای که هرگز ساخته نشده با گذشتِ زمان از بین
نمیرود، فقط دیرتر لازم میشود.
۸. تست و مستندات
تستها در tests/Patient/ و tests/Appointment/:
- نوبت رزروشده در کلینیک، قطعی میشود → یک پرونده با
entity_type='clinic'، هیچ پروندهٔdoctorی ساخته نمیشود. - نوبت مطب شخصی → یک پروندهٔ
doctor. - بیماری که از قبل پرونده دارد → پروندهٔ جدید ساخته نمیشود، فقط مراجعهٔ جدید به همان پرونده اضافه میشود.
- قطعیشدن دوباره (confirmed → cancelled → confirmed) → مراجعهٔ دوم ساخته نمیشود.
- مسیر پرداخت:
PaymentManagerنوبت را قطعی میکند → پرونده و مراجعه ساخته میشوند (بازتولید مستقیم باگ اصلی). - رزرو از پنل → نوبت
confirmedاست و مراجعه دارد. - نوبت
isReserve→ مراجعه ساخته نمیشود. - 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(uuidbcabb3a8-cae3-45ec-876c-548f9c1e1569) در کلینیک41e325c4-e825-4067-8438-5d828ecaee09، مالک کلینیک09024206041. کد OTP در dev همیشه12345.