Three related fixes, all rooted in the same flaw: authorization and scoping
decided by the caller's role instead of by the environment the data belongs to.
1. Single-appointment access (clinic operations were entirely broken)
AppointmentController::canView/canManage only knew the patient, the owning
doctor and admin -- appointment.clinic was never consulted. A clinic user could
create an appointment through /my/appointment but got 403 on detail, edit,
move, reserve transfer/replace and status change, so nearly every appointment
operation failed in clinic mode.
AppointmentAccessChecker now decides from appointment.clinic: clinic owner,
member doctor (via ClinicDoctorPermissionChecker) and assigned secretary (via
active context + DoctorSecretary) are recognised. Actions reuse the existing
permission vocabulary, so active=false remains the single source of truth for
"collaboration ended". Cancellation is gated separately and an inline status on
PATCH /appointment/{uuid} cannot bypass that gate. The patient is narrowed to
view + cancel.
Also fixed alongside: listByDoctor now serves a clinic manager but scoped to
that clinic; todayStats gained an admin branch and no longer passes an array of
doctor ids as the clinic parameter; PatientController::appointments filters on
appointment.clinic instead of current membership, so deactivating a doctor no
longer erases clinic appointment history from the case file.
The doctor-only active_slot_key was reviewed and deliberately left alone -- a
doctor is one physical person, so adding clinic to the key would permit
double-booking, not fix a bug. Reasoning recorded on the entity.
2. Appointment registration and confirmation
Panel-created appointments are born pending ("ثبت شده") instead of confirmed.
Confirming is now an explicit act: POST /appointment/{uuid}/confirm transitions
the status, files the case file for the appointment's environment (reusing an
existing record or creating one) and registers full or partial payments on the
resulting visit -- all in one transaction.
AppointmentExpiryService would have expired those pending appointments the
moment their slot time passed; findExpiredPending is now limited to online
gateway holds, which are the only pendings carrying a TTL. A pending
appointment still occupies its slot, so the time stays reserved.
The admin panel gets a "قطعی کردن نوبت" modal showing the visit fee, each
selected service, the total, and paid/remaining/status. It is wired inside
AppointmentStatusDropdown, so picking "confirmed" anywhere (timeline, detail,
reserve list, info modal) goes through it and confirmation can never silently
skip the case file and payment.
3. Clinic case-file access
PatientRecordScopeResolver replaces the single-destination role mapping: the
active context decides, so a doctor invited into a clinic finally sees their
patients' records there. A clinic record is per-patient and shared by design,
so "their own patients" is derived from appointments with that doctor in that
clinic rather than from a new column. Clinic secretaries are limited to their
assigned doctors. Read and write share one rule, and out-of-scope records
report 404 so other environments are never disclosed.
Tests: 29 new cases across the three areas (clinic appointment access, confirm
flow, clinic record access). Full suite 466 tests, 2 pre-existing failures
unchanged. API docs updated for all three.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 KiB
فرآیند ثبت و قطعی کردن نوبت (مودال پرداخت + پرونده)
پروژه
clinicpro (backend + پنل ادمین) — پیشنیاز: clinic-appointment-operations-fix.md اجرا شده باشد.
زمینه
وضعیتها همین حالا وجود دارند: pending = «ثبت شده»، confirmed = «قطعی شده» (turnStatus.ts). زیرساخت پرونده هم هست: با confirm شدن نوبت، AppointmentConfirmationService::onConfirmed → PatientService::autoCreateOnAppointmentConfirm پرونده را بر اساس محیط (clinic اگر appointment.getClinic()!==null وگرنه doctor) پیدا یا ایجاد میکند و session با قیمت ویزیت + سرویسها میسازد — یعنی الزام «پرونده موجود استفاده شود / نبود ساخته شود» از قبل پیاده است. پرداخت چندبخشی هم روی session موجود است (SessionPayment، متدهای wallet/pos/cash/card).
آنچه کم است: (۱) نوبت پنلی الان مستقیم confirmed ساخته میشود؛ (۲) دکمه/مودال «قطعی کردن نوبت» با نمایش هزینهها و پرداخت کامل/جزئی وجود ندارد؛ (۳) ثبت پرداختها هنگام قطعی شدن در پرونده انجام نمیشود.
مشکل / هدف
- هر نوبت (آنلاین، سریع، عادی) با وضعیت اولیه «ثبتشده» (
pending) ایجاد شود. - روی کارت نوبتهای
pendingدر Timeline دکمه «قطعی کردن نوبت» باشد. - کلیک → مودال: مبلغ ویزیت + هزینه سرویسهای انتخابشده، پرداخت کامل یا جزئی، نمایش شفاف پرداختشده/باقیمانده/وضعیت پرداخت.
- تأیید مودال → وضعیت
confirmed+ ثبت سرویسها و پرداختها در پرونده (موجود یا جدید) نزد همان محیط.
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Appointment/Entity/Appointment.php |
وضعیتها (~25-33)، ALLOWED_TRANSITIONS (~37-42)، visitPriceRials، serviceItems |
src/Appointment/Controller/MyAppointmentsController.php |
ساخت پنلی — الان confirmed میگذارد (~192) |
src/Appointment/Controller/AppointmentController.php |
PATCH .../status (~850)؛ endpoint جدید confirm اینجا یا کنارش |
src/Appointment/Repository/AppointmentRepository.php |
expireLapsedPending (~154) — TTL پانزدهدقیقهای pending |
src/Appointment/Service/AppointmentConfirmationService.php |
onConfirmed (~30) — نقطه واحد confirm |
src/Patient/Service/PatientService.php |
autoCreateOnAppointmentConfirm (~133)، addSessionPayment (~552) |
src/Payment/Service/PaymentManager.php |
مسیر آنلاین: بعد از پرداخت درگاه → confirmed (~306-315) — دست نزن |
assets/admin/components/appointments/TurnsTimeline.tsx |
کارتها (OccupiedCard ~100) |
assets/admin/components/appointments/turnStatus.ts |
لیبلها (pending=«ثبت شده») |
assets/admin/components/ui/AppointmentStatusDropdown.tsx |
TRANSITIONS + PATCH status {status, version} |
assets/admin/components/session/PaymentStep.tsx |
الگوی پرداخت جزئی (METHODS, METHOD_LABELS, PriceInput, toman→rial) |
assets/admin/components/ui/Modal.tsx, ConfirmDialog.tsx |
پایه مودال |
assets/admin/pages/AppointmentCreatePage.tsx |
گزینههای status هنگام ساخت (~496) |
وضعیت فعلی
// MyAppointmentsController (~192): نوبت پنلی بلافاصله confirmed
$appointment->setStatus(Appointment::STATUS_CONFIRMED);
// PaymentManager (~313): نوبت سایت بعد از پرداخت درگاه confirmed میشود (درست است، حفظ شود)
// AppointmentRepository::expireLapsedPending: pending های کهنه را expire میکند (TTL رزرو آنلاین ۱۵ دقیقه)
// AppointmentStatusDropdown (~74): تنها مسیر فعلی قطعیکردن — بدون پرداخت/پرونده
api.patch(`/api/v1/appointment/${uuid}/status`, { status: newStatus, version })
وظایف
۱. Backend — ساخت پنلی با وضعیت pending بدون انقضا
- در
MyAppointmentsController::createوضعیت اولیه راSTATUS_PENDINGکن (نوبت سریع و عادی). - حیاتی:
expireLapsedPendingنباید نوبتهای پنلی را بعد از ۱۵ دقیقه منقضی کند. مکانیزم تفکیک اضافه کن — مثلاً فیلد/فلگsource/hold_expires_atروی Appointment (migration) یا شرط «pending فقط وقتی expire شود که از مسیر رزرو آنلاین با TTL ساخته شده». مسیر آنلاین (POST/api/v1/appointmentعمومی) رفتار فعلیاش (pending با TTL تا پرداخت درگاه) را حفظ کند. - گذار
pending → confirmedاز قبل درALLOWED_TRANSITIONSمجاز است — دست نزن.
۲. Backend — endpoint قطعیکردن اتمیک
POST /api/v1/appointment/{uuid}/confirm بساز (در AppointmentController، با canManage از checker پرامپت قبلی):
// Request:
// { "version": 3, "payments": [ { "method": "cash|pos|card|wallet", "amount_rials": 500000 } ], "discount"?: ... }
// در یک تراکنش:
// 1) transitionTo(STATUS_CONFIRMED) → از canTransitionTo عبور کند
// 2) AppointmentConfirmationService::onConfirmed($appointment) → record/session (منطق موجود reuse/create)
// 3) session ساخته/یافتهشده را بگیر و هر payment را با PatientService::addSessionPayment ثبت کن
// Response: success + { appointment: {...}, session: { uuid, final_price_rials, paid_total_rials, remaining_rials, is_paid } }
paymentsمیتواند خالی باشد (قطعی بدون پرداخت) یا جزئی — جمع نباید از مبلغ قابلپرداخت بیشتر شود (خطای موجودERR_SESSION_PAYMENT_EXCEEDSreuse شود).autoCreateOnAppointmentConfirmالان خطا را قورت میدهد (log-only). برای این endpoint نباید silent باشد: اگر پرونده/سرویسها ساخته نشد (مثلاً feature اشتراکpatient_recordsفعال نیست)، پاسخ باید صریح بگوید (confirm موفق ولیsession: null+ پیام، یا خطای کامل — تصمیم را مستند کن).- endpoint یک GET پیشنمایش هم لازم دارد یا همان detail کافی است: مودال باید مبلغ ویزیت (
visit_price_rials) + سرویسهای نوبت (serviceItemsبا قیمت) را قبل از تأیید نشان دهد — اگر detail فعلی قیمت آیتمها را نمیدهد، به پاسخ detail اضافه کن.
۳. Frontend — دکمه و مودال «قطعی کردن نوبت»
- در
TurnsTimeline.tsxرویOccupiedCardوقتیa.status === 'pending'دکمه «قطعی کردن نوبت» اضافه کن (کنار کلاستر dropdown/menu، باstopPropagation). - مودال جدید
components/appointments/ConfirmAppointmentModal.tsxبر پایهModal(نه ConfirmDialog — فرم دارد):- بخش هزینهها: ردیف «ویزیت» + ردیف هر سرویس انتخابشده + جمع کل (
formatRial، نمایش تومان مثلPaymentStep). - بخش پرداخت: همان الگوی
PaymentStep— روشها (METHODS/METHOD_LABELS)،PriceInputتومان، امکان چند ردیف پرداخت یا یک ردیف با مبلغ دلخواه؛ دکمه میانبر «پرداخت کامل». - خلاصه شفاف: پرداختشده / باقیمانده / وضعیت (تسویه کامل، پرداخت جزئی، بدون پرداخت).
- تأیید →
POST /api/v1/appointment/${uuid}/confirmباversion؛ بعدinvalidateQueries({ queryKey })؛ toast موفقیت با sonner؛ خطای 409 نسخه با پیام فارسی.
- بخش هزینهها: ردیف «ویزیت» + ردیف هر سرویس انتخابشده + جمع کل (
- همین دکمه/مودال را در
AppointmentDetailPage،ReserveAppointmentsPage(ردیفهای pending) وAppointmentInfoModalهم در دسترس بگذار. - در
AppointmentStatusDropdown، انتخاب مستقیمconfirmedاز dropdown باید همین مودال را باز کند (نه PATCH خام) تا مسیر دورزدن پرداخت/پرونده نماند — یا حداقل بعد از PATCH خام همonConfirmedسمت سرور اجرا میشود (الان میشود؛ ولی بدون پرداخت). تصمیم UX: dropdown → مودال. مستند کن. AppointmentCreatePage(~496): پیشفرض ساخت را «ثبت شده» بگذار؛ گزینه ساخت مستقیم confirmed را بردار یا به مودال وصل کن.
۴. تست و مستندات
- سناریوها: قطعی با پرداخت کامل / جزئی / بدون پرداخت؛ بیمار با پرونده قبلی نزد همان پزشک (reuse — session جدید در همان پرونده) و بیمار بدون پرونده (create)؛ همین دو حالت در محیط کلینیک (
entityType=clinic) با کاربر09024206041و در مطب شخصی با کاربر پزشک ازTEST_USERS.md. - رزرو آنلاین سایت: بدون رگرسیون — pending تا پرداخت درگاه، بعد confirmed + پرونده (مسیر
PaymentManagerدستنخورده). - نوبتهای
is_reserveمثل قبل ازonConfirmedرد میشوند (خط ~33) — دکمه قطعیکردن برای ردیف رزرو روزانه بعد از انتقال به slot معنا پیدا میکند. docs/api/*: endpoint جدید confirm + تغییر رفتار create مستند شود.
نکات مهم
- تاریخها Unix timestamp؛ نمایش شمسی با
formatDate(). مبالغ backend ریال، ورودی UI تومان (tomanToRial). - Optimistic lock: هر mutation نوبت
versionمیخواهد؛ فراموشش نکن (AppointmentDetailPage الان status را بدون version میفرستد — همانجا هم اصلاح کن). - envelope پاسخ: single ممکن است double-nested باشد (
data?.data?.data) — الگوی صفحات موجود را نگاه کن. - لیبلهای فارسی موجود را تغییر نده:
pending=«ثبت شده»،confirmed=«قطعی شده». دو map وضعیت موازی هست (turnStatus.tsوAppointmentStatusDropdown.STATUS_META) — اگر دست زدی هر دو را همگام نگه دار. - کامپوننت انتخابها فقط
SearchableSelect؛ طراحی مودال با تم/کلاسهای موجود پنل، بدون طراحی جدید.