From af881231d03026173c9472fd7fe16a511862cf08 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 23 Jun 2026 16:10:36 +0330 Subject: [PATCH] fix: store appointment address from schedule and auto-add patient to clinic Appointments now persist address_id resolved from the weekly-schedule session (location_id) across all booking paths (online, secretary, admin). On confirm, the patient is added to the clinic owning that address, or to the doctor's single clinic as fallback. Weekly-schedule create/update now requires location_id on every active session. PatientSession exposes doctor_uuid/doctor_name so clinic records show which doctor each visit is for. Co-Authored-By: Claude Opus 4.8 --- .claude/prompt/fix-clinic-patient-auto-add.md | 83 +++++++++++++++++++ docs/api/appointment-settings.md | 2 + docs/api/appointment.md | 4 + docs/api/patient.md | 7 +- migrations/Version20260623115721.php | 31 +++++++ src/Admin/Controller/AdminApiController.php | 3 + .../Controller/AppointmentController.php | 12 +++ .../AppointmentSettingsController.php | 23 +++++ .../Controller/MyAppointmentsController.php | 4 + src/Appointment/Entity/Appointment.php | 6 ++ .../Service/SlotCalculatorService.php | 18 ++++ src/Patient/Entity/PatientSession.php | 2 + src/Patient/Service/PatientService.php | 33 +++++++- 13 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 .claude/prompt/fix-clinic-patient-auto-add.md create mode 100644 migrations/Version20260623115721.php diff --git a/.claude/prompt/fix-clinic-patient-auto-add.md b/.claude/prompt/fix-clinic-patient-auto-add.md new file mode 100644 index 00000000..fd2dba3c --- /dev/null +++ b/.claude/prompt/fix-clinic-patient-auto-add.md @@ -0,0 +1,83 @@ +# باگ: افزودن خودکار بیمار به کلینیک هنگام نوبت با آدرس کلینیک + +## پروژه + +`clinicpro` (backend) + +## زمینه + +وقتی یک نوبت برای پزشکی که در یک کلینیک عضو است ثبت و تأیید می‌شود، اگر آن نوبت با **آدرس کلینیک** باشد، بیمار باید **به‌صورت خودکار به پرونده‌های آن کلینیک** هم اضافه شود. در حال حاضر فقط به پرونده‌ی پزشک اضافه می‌شود. + +## مشکل / هدف + +در `PatientService::autoCreateOnAppointmentConfirm`، entity همیشه `doctor` فرض می‌شود: + +```php +public function autoCreateOnAppointmentConfirm(Appointment $appointment): void +{ + $doctor = $appointment->getDoctor(); + $entityType = 'doctor'; + $entityId = $doctor->getId(); + ... + $record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient); + if ($record === null) { + $record = new PatientRecord($entityType, $entityId, $patient, 'system', $doctor->getId()); + $this->recordRepo->save($record); + } + $session = new PatientSession($record, $appointment); + $this->sessionRepo->save($session); +} +``` + +کلینیک هیچ‌وقت پرونده نمی‌گیرد. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Patient/Service/PatientService.php` | `autoCreateOnAppointmentConfirm` | +| `src/Appointment/Entity/Appointment.php` | نوبت؛ **فعلاً آدرس/کلینیک انتخابی را ذخیره نمی‌کند** | +| `src/Appointment/Controller/AppointmentController.php` | فراخوانی auto-create در تأیید (خط ~۴۹۹) | +| `src/Doctor/Entity/DoctorAddress.php` | آدرس پزشک؛ دارای `clinicId` (`TYPE_CLINIC`, `forClinic($clinicId)`, `getClinicId()`) | +| `src/Patient/Entity/PatientRecord.php` | پرونده (polymorphic: `entityType` = `doctor|clinic`) | +| `docs/api/patient.md` + `docs/api/appointment.md` | مستندات | + +## وضعیت فعلی — یافته‌ی مهم + +**`Appointment` آدرس/کلینیکِ انتخاب‌شده را نگه نمی‌دارد.** فیلدهای موجود: `doctor`, `user`, `slotStart`, `slotEnd`, `status`, و فیلدهای مهمان (`patientName`, `patientMobile`, `patientNationalCode`, `patientGender`). در `toArray()` آدرس صرفاً «اولین آدرس پزشک» است: + +```php +$firstAddress = $this->doctor->getAddresses()->first() ?: null; +'address' => $firstAddress?->toArray(), +``` + +پس برای تشخیص اینکه نوبت با کدام آدرس/کلینیک بوده، **اول باید نوبت آدرس/کلینیک را ذخیره کند**. + +## وظایف + +### ۱. ذخیره‌ی آدرس/کلینیک روی نوبت + +- به `Appointment` فیلد `?int $addressId` یا `?int $clinicId` (یا رابطه به `DoctorAddress`) اضافه کن. +- جریان رزرو نوبت (آنجا که Appointment ساخته می‌شود) باید آدرس انتخاب‌شده‌ی کاربر را ست کند. کد رزرو را پیدا کن (`debug:router | grep appointment`، سپس controller/Service رزرو) و آدرس را propagate کن. +- migration بساز و اجرا کن. + +اگر در عمل کاربر در رزرو آدرس انتخاب می‌کند، آن انتخاب را تا Appointment دنبال کن؛ اگر فعلاً انتخاب آدرس وجود ندارد، حداقل آدرس نوع `clinic` پیش‌فرض پزشک را ست کن و در پرامپت یادداشت بگذار. + +### ۲. افزودن بیمار به کلینیک در تأیید + +`autoCreateOnAppointmentConfirm` را طوری تغییر بده که: + +1. همیشه پرونده‌ی `doctor` را بسازد (رفتار فعلی). +2. اگر نوبت آدرس کلینیک دارد (`DoctorAddress::getClinicId() !== null` برای آدرس نوبت)، یک `PatientRecord` با `entityType='clinic'`, `entityId=$clinicId` هم بساز (اگر نبود) و در صورت لزوم `PatientSession` متناظر را اضافه کن. +3. قبل از ساخت، `subscriptionService->hasFeature('clinic', $clinicId, 'patient_records')` را چک کن (مثل چک فعلی doctor). + +### ۳. مستندات + +اگر شکل پاسخ Appointment تغییر کرد (`address_id`/`clinic_id`)، `docs/api/appointment.md` را به‌روز کن. رفتار auto-add را در `docs/api/patient.md` مستند کن. + +## نکات مهم + +- `PatientRecord` از قبل polymorphic است؛ ساخت نسخه‌ی `clinic` نیاز به schema جدید ندارد. +- duplicate را با `findByEntityAndUser` چک کن (هم برای doctor هم clinic). +- اگر چند آدرس کلینیک متفاوت در نوبت‌های مختلف باشد، هر کلینیک پرونده‌ی خودش را بگیرد. +- منبع کلینیک از `DoctorAddress.clinicId` است، نه از خود پزشک. diff --git a/docs/api/appointment-settings.md b/docs/api/appointment-settings.md index edc74651..7d58d896 100644 --- a/docs/api/appointment-settings.md +++ b/docs/api/appointment-settings.md @@ -31,6 +31,8 @@ Create or update the weekly schedule for a doctor (upsert). **Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN` +> **الزام آدرس:** هر session با `active=true` باید `location_id` (آدرس مطب/کلینیک) داشته باشد. در غیر این صورت `422 ERR_VALIDATION_001` («برای هر شیفت فعال باید آدرس انتخاب شود»). این آدرس هنگام رزرو خودکار روی نوبت ذخیره می‌شود. + ### Request Body (`application/json`) ```json { diff --git a/docs/api/appointment.md b/docs/api/appointment.md index d3428e74..b40a4b4a 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -152,6 +152,10 @@ Book an appointment slot. | `patient_reason` | string | ❌ | Reason for visit | | `note` | string | ❌ | Patient note | +> **آدرس نوبت:** آدرس (`address_id`) ارسالی نیست؛ سرور آن را از روی `location_id` همان session در برنامه‌ی هفتگی که اسلات در آن قرار دارد، خودکار تعیین و ذخیره می‌کند. در پاسخ به‌صورت `address_id` برمی‌گردد. همه‌ی مسیرهای رزرو (آنلاین `POST /api/v1/appointment`، منشی `POST /api/v1/my/appointment`، ادمین) آدرس را به همین شکل ست می‌کنند. + +> **Auto-add to clinic:** هنگام تأیید نوبت، اگر آدرس نوبت متعلق به یک کلینیک باشد (`DoctorAddress.clinic_id`)، بیمار علاوه بر پرونده‌ی پزشک، به پرونده‌های آن کلینیک هم اضافه می‌شود. اگر آدرس کلینیک نداشت ولی دکتر فقط عضو یک کلینیک بود، به همان کلینیک اضافه می‌شود. هر شاخه مشروط به فعال‌بودن `patient_records`. جزئیات در `docs/api/patient.md`. + > **Payer vs patient:** the authenticated user (`user`) is always the payer; the `patient_*` fields describe who the visit is for and are stored separately. **Temporary lock:** the slot is held by the new `pending` booking for **15 minutes** (`expires_at = created_at + 900`). If payment is not completed in time, the booking is moved to `expired` and the slot is freed (see `app:cancel-expired-appointments`). An expired pending booking no longer blocks the slot even before the cron runs. ### Response `201` diff --git a/docs/api/patient.md b/docs/api/patient.md index 5a90fceb..ac3b42d5 100644 --- a/docs/api/patient.md +++ b/docs/api/patient.md @@ -285,5 +285,10 @@ When an appointment's status changes to `confirmed` via `PATCH /api/v1/appointme 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'`) هم تکرار می‌شود. اگر آدرس نوبت کلینیک نداشت ولی دکتر فقط عضو **یک** کلینیک بود، به همان کلینیک اضافه می‌شود. -This only runs if the doctor has the `patient_records` subscription feature active. +هر شاخه (doctor / clinic) مستقل و فقط در صورت فعال‌بودن ویژگی `patient_records` برای همان entity اجرا می‌شود. duplicate با `findByEntityAndUser` جلوگیری می‌شود. + +**انتساب پزشک:** هر `PatientSession` در پاسخ، `doctor_uuid` و `doctor_name` را از روی نوبتِ متناظر برمی‌گرداند؛ پس در پرونده‌ی کلینیک مشخص است هر مراجعه برای کدام پزشک بوده است. + +**آدرس نوبت:** هنگام رزرو، `address_id` خودکار از `location_id` همان session برنامه‌ی هفتگی ست می‌شود (در همه‌ی مسیرهای رزرو). ثبت `location_id` برای هر شیفت فعال در برنامه‌ی هفتگی الزامی است (`POST/PATCH /api/v1/appointment-settings/weekly-schedule`)؛ در غیر این صورت `422`. diff --git a/migrations/Version20260623115721.php b/migrations/Version20260623115721.php new file mode 100644 index 00000000..90afef06 --- /dev/null +++ b/migrations/Version20260623115721.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE appointments ADD address_id INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE appointments DROP address_id'); + } +} diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index a98c0c64..39ca7240 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -31,6 +31,7 @@ class AdminApiController extends BaseController { public function __construct( private readonly EntityManagerInterface $em, + private readonly \App\Appointment\Service\SlotCalculatorService $slotCalculator, ) {} // ── Users ───────────────────────────────────────────────────────────────── @@ -818,6 +819,8 @@ class AdminApiController extends BaseController $appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd); if (!empty($data['note'])) $appointment->setNote($data['note']); + $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart); + if ($locationId !== null) $appointment->setAddressId($locationId); $this->em->persist($appointment); $this->em->flush(); diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 6d9b1790..ce0ea5d1 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -8,6 +8,7 @@ use App\Appointment\Repository\AppointmentRepository; use App\Appointment\Repository\SlotTakenException; use App\Appointment\Repository\WeeklyScheduleRepository; use App\Appointment\Service\SlotCalculatorService; +use App\Doctor\Entity\Doctor; use App\Auth\Entity\User; use App\Doctor\Repository\DoctorRepository; use App\Patient\Service\PatientService; @@ -236,6 +237,12 @@ class AppointmentController extends BaseController $appointment = new Appointment($doctor, $user, $slotStart, $slotEnd); if (isset($data['note'])) $appointment->setNote($data['note']); + // آدرس نوبت از روی session متناظر در برنامه‌ی هفتگی تعیین می‌شود (location_id). + $locationId = $this->resolveSlotLocationId($doctor, $slotStart); + if ($locationId !== null) { + $appointment->setAddressId($locationId); + } + if ($forSelf) { $appointment->setPatientName($user->getRealName()); $appointment->setPatientMobile($user->getMobileNumber()); @@ -423,6 +430,11 @@ class AppointmentController extends BaseController || $user->hasRole('ROLE_ADMIN'); } + private function resolveSlotLocationId(Doctor $doctor, int $slotStart): ?int + { + return $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart); + } + #[OA\Patch( path: '/api/v1/appointment/{uuid}/status', summary: 'Update the status of an appointment', diff --git a/src/Appointment/Controller/AppointmentSettingsController.php b/src/Appointment/Controller/AppointmentSettingsController.php index 8eb7b37e..15a2f903 100644 --- a/src/Appointment/Controller/AppointmentSettingsController.php +++ b/src/Appointment/Controller/AppointmentSettingsController.php @@ -53,6 +53,10 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } + if (($err = $this->validateSessionsHaveLocation($data['schedule'] ?? [])) !== null) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, $err, 422); + } + // Only one schedule per doctor — upsert $schedule = $this->scheduleRepo->findByDoctor($doctor); if ($schedule !== null) { @@ -90,6 +94,9 @@ class AppointmentSettingsController extends BaseController $data = json_decode($request->getContent(), true) ?? []; if (isset($data['schedule'])) { + if (($err = $this->validateSessionsHaveLocation($data['schedule'])) !== null) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, $err, 422); + } $schedule->setSetting($data['schedule']); } if (isset($data['meta']) && is_array($data['meta'])) { @@ -356,4 +363,20 @@ class AppointmentSettingsController extends BaseController return $this->success(['data' => $result]); } + + /** + * هر session فعال در برنامه‌ی هفتگی باید آدرس (location_id) داشته باشد. + * در صورت نقص، پیام خطا برمی‌گرداند؛ در غیر این صورت null. + */ + private function validateSessionsHaveLocation(array $schedule): ?string + { + foreach ($schedule as $day) { + foreach (($day['sessions'] ?? []) as $session) { + if (($session['active'] ?? false) && empty($session['location_id'])) { + return 'برای هر شیفت فعال باید آدرس (مطب/کلینیک) انتخاب شود'; + } + } + } + return null; + } } diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index e0e6b47a..b81f426d 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -3,6 +3,7 @@ namespace App\Appointment\Controller; use App\Appointment\Entity\Appointment; +use App\Appointment\Service\SlotCalculatorService; use App\Auth\Entity\User; use App\Auth\Repository\UserActiveContextRepository; use App\Clinic\Repository\ClinicRepository; @@ -27,6 +28,7 @@ class MyAppointmentsController extends BaseController private readonly ClinicRepository $clinicRepo, private readonly DoctorSecretaryRepository $secretaryRepo, private readonly UserActiveContextRepository $contextRepo, + private readonly SlotCalculatorService $slotCalculator, ) {} #[Route('/api/v1/my/appointment', methods: ['POST'])] @@ -79,6 +81,8 @@ class MyAppointmentsController extends BaseController $appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd); if (!empty($data['note'])) $appointment->setNote($data['note']); + $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart); + if ($locationId !== null) $appointment->setAddressId($locationId); $this->em->persist($appointment); $this->em->flush(); diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index b3773798..473037df 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -83,6 +83,9 @@ class Appointment #[ORM\Column(name: 'patient_reason', type: 'text', nullable: true)] private ?string $patientReason = null; + #[ORM\Column(name: 'address_id', type: 'integer', nullable: true)] + private ?int $addressId = null; + #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; @@ -115,8 +118,10 @@ class Appointment public function getPatientNationalCode(): ?string { return $this->patientNationalCode; } public function getPatientGender(): ?string { return $this->patientGender; } public function getPatientReason(): ?string { return $this->patientReason; } + public function getAddressId(): ?int { return $this->addressId; } public function setNote(?string $v): self { $this->note = $v; return $this; } + public function setAddressId(?int $v): self { $this->addressId = $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; } @@ -166,6 +171,7 @@ class Appointment ), ], 'address' => $firstAddress?->toArray(), + 'address_id' => $this->addressId, 'user' => [ 'uuid' => $this->user->getUuid(), 'mobile' => $this->user->getMobileNumber(), diff --git a/src/Appointment/Service/SlotCalculatorService.php b/src/Appointment/Service/SlotCalculatorService.php index f9e0772f..8cdfe526 100644 --- a/src/Appointment/Service/SlotCalculatorService.php +++ b/src/Appointment/Service/SlotCalculatorService.php @@ -33,6 +33,24 @@ class SlotCalculatorService return $this->filterBookedSlots($doctor, $flat); } + /** + * آدرس (location_id) متناظر با اسلاتِ شروع‌شده در تاریخ مشخص. اگر پیدا نشد null. + */ + public function resolveSlotLocationId(Doctor $doctor, int $slotStart): ?int + { + $date = date('Y-m-d', $slotStart); + $sessions = $this->buildAllSessions($doctor, $date); + foreach ($sessions as $session) { + foreach (($session['slots'] ?? []) as $slot) { + if ((int) ($slot['start'] ?? 0) === $slotStart) { + $loc = $slot['location_id'] ?? null; + return $loc !== null ? (int) $loc : null; + } + } + } + return null; + } + /** * Returns sessions grouped by shift, each slot tagged with is_available. * Used by the schedule view to show real shift boundaries. diff --git a/src/Patient/Entity/PatientSession.php b/src/Patient/Entity/PatientSession.php index 95d34af5..d53435af 100644 --- a/src/Patient/Entity/PatientSession.php +++ b/src/Patient/Entity/PatientSession.php @@ -108,6 +108,8 @@ class PatientSession 'uuid' => $this->uuid, 'record_uuid' => $this->record->getUuid(), 'appointment_uuid' => $this->appointment?->getUuid(), + 'doctor_uuid' => $this->appointment?->getDoctor()->getUuid(), + 'doctor_name' => $this->appointment?->getDoctor()->getName(), 'insurance_base_id' => $this->insuranceBaseId, 'insurance_supplementary_id' => $this->insuranceSupplementaryId, 'visit_price_rials' => $this->visitPriceRials, diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php index f38d8d9d..f72808d9 100644 --- a/src/Patient/Service/PatientService.php +++ b/src/Patient/Service/PatientService.php @@ -5,6 +5,8 @@ namespace App\Patient\Service; use App\Appointment\Entity\Appointment; use App\Auth\Repository\UserRepository; use App\ClinicService\Repository\ServiceItemRepository; +use App\Clinic\Repository\ClinicRepository; +use App\Doctor\Repository\DoctorAddressRepository; use App\Patient\Entity\PatientRecord; use App\Patient\Entity\PatientSession; use App\Patient\Entity\SessionService; @@ -24,6 +26,8 @@ class PatientService private readonly ClinicStaffRepository $staffRepo, private readonly UserRepository $userRepo, private readonly SubscriptionService $subscriptionService, + private readonly DoctorAddressRepository $addressRepo, + private readonly ClinicRepository $clinicRepo, ) {} public function calculateFinalPrice(int $visitPrice, float $baseDiscount, float $suppDiscount, array $serviceItems): array @@ -40,10 +44,31 @@ class PatientService public function autoCreateOnAppointmentConfirm(Appointment $appointment): void { - $doctor = $appointment->getDoctor(); - $entityType = 'doctor'; - $entityId = $doctor->getId(); + $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); + } + } + + private function autoCreateForEntity(string $entityType, int $entityId, Appointment $appointment, int $createdById): void + { if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) { return; } @@ -52,7 +77,7 @@ class PatientService $record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient); if ($record === null) { - $record = new PatientRecord($entityType, $entityId, $patient, 'system', $doctor->getId()); + $record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById); $this->recordRepo->save($record); }