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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-23 16:10:36 +03:30
co-authored by Claude Opus 4.8
parent 51d7e24e04
commit af881231d0
13 changed files with 223 additions and 5 deletions
@@ -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` است، نه از خود پزشک.
+2
View File
@@ -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
{
+4
View File
@@ -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`
+6 -1
View File
@@ -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`.
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260623115721 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
@@ -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();
@@ -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',
@@ -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;
}
}
@@ -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();
+6
View File
@@ -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(),
@@ -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.
+2
View File
@@ -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,
+29 -4
View File
@@ -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);
}