Files
clinicpro/docs/api/doctor.md
T
hamedandClaude Opus 5 5e87bbc18b feat(appointments): resources share the doctors' timeline
Three views become two. The resource lanes were a separate tab, which meant
reading a doctor's free hour on one screen and the laser's on another and
matching them by eye — while in the resource-first model it is the device and
the room that decide whether that hour is really free. They now sit under the
same "زمانبندی" view, below the doctor's slots.

Each lane says how much of its shift is still free, and that number respects
capacity: a minute counts as busy only once the overlapping bookings reach
the resource's capacity, so a three-bed room with two appointments is still
open. Treating it otherwise would silently turn every multi-capacity resource
into a single-capacity one. ResourceFreeTimeCalculator does the sweep and
carries nine cases of its own.

only_bookable=1 keeps resources with no service offering out of the view;
they could only ever render an empty lane. On the seeded clinic that is five
resources down to two.

Two ruler defects the screenshot caught: hours rendered in Latin digits, and
the last label was half-clipped by the container so 21 read as 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 16:14:04 +03:30

25 KiB
Raw Blame History

Doctor API

name در برابر display_name: name نامِ خام است (بدون عنوان — برای فرم ویرایش). display_name نامِ آمادهٔ نمایش با پیشوند «دکتر» است که بک‌اند تولید می‌کند (PersianText::withDoctorTitle) تا همهٔ کلاینت‌ها یکسان و بدون «دکتر دکتر …» نشان دهند؛ در لیست و جزئیات پزشک هر دو حاضرند. برای نمایش از display_name، برای ویرایش از name استفاده کنید.

فیلد owner_status (claimed | unclaimed | pending_transfer) به خروجی لیست و جزئیات پزشک اضافه شده است — پروفایل unclaimed (ایمپورت نظام پزشکی) در سایت دکمهٔ «تصاحب پروفایل» می‌گیرد (docs/api/doctor-claim.md) و نوبت‌دهی آنلاینش غیرفعال است.

امتیاز فقط برای پروفایل claimed منتشر می‌شود. برای هر owner_status غیر از claimed (یعنی unclaimed / pending_transfer) فیلدهای point و satisfaction مقدار null برمی‌گردند — مقدار پیش‌فرض انتیتی (3.5 / 60) نشتی نمی‌کند تا امتیاز جعلی/AggregateRating جعلی ساخته نشود. بعد از تصاحب و claimed شدن، امتیاز واقعی به‌صورت خودکار برمی‌گردد.

Prefix: /api/v1/doctor, /api/v1/doctors, /api/v1/clinic-pro/doctor-address*

Numeric path params on the address routes (doctor-address/{id}, doctor-addresses/{doctorId}) require \d+; a non-numeric value returns a clean 404 instead of a 500.


POST /api/v1/doctor

Create a doctor profile for the authenticated user.

Permission: AUTH — any authenticated user

Request Body (application/json)

{
  "title": "علی احمدی",
  "gender": "male",
  "medical_system_code": "12345",
  "degree": "متخصص",
  "info": "توضیحات درباره پزشک",
  "specialties": [1, 2],
  "doctor_services": [3, 4]
}
Field Type Required Description
title string نام پزشک بدون عنوان. پیشوند «دکتر» سمت سرور با PersianText::stripDoctorTitle() حذف می‌شود؛ نمایش عنوان کار لایهٔ UI است.
gender string "male" or "female"
medical_system_code string Nظام پزشکی code
degree string Academic degree
info string Bio/description
specialties integer[] Array of specialty IDs. تخصص‌ها درختی‌اند: هر شناسهٔ فرزند سمت سرور با تمام والدهایش تا ریشه گسترش می‌یابد، پس پاسخ ممکن است تخصص‌های بیشتری از ورودی داشته باشد (مثلاً ارسال «گوارش و کبد» → ذخیرهٔ «داخلی» + «گوارش و کبد»). شناسه‌های ناموجود نادیده گرفته می‌شوند.
doctor_services integer[] Array of doctor service IDs
activity_time integer Unix timestamp (ثانیه) تاریخ شروع فعالیت؛ مبنای محاسبهٔ experience (سال تجربه) در پاسخ

Response 201

{
  "success": true,
  "data": {
    "uuid": "550e8400-e29b-41d4-a716-446655440000",
    "title": "علی احمدی",
    "gender": "male",
    "medical_system_code": "12345",
    "degree": "متخصص",
    "info": "...",
    "doctor_rate": null,
    "active_doctor_appointment": false,
    "specialties": [],
    "doctor_services": [],
    "created_at": 1717000000
  }
}

Errors

Code HTTP Description
ERR_AUTH_001 401 Missing or invalid token
ERR_CONFLICT_001 409 Doctor profile already exists for this user
ERR_VALIDATION_002 422 Missing required field
ERR_VALIDATION_001 422 نام پزشک شماره‌تلفن یا مقدار آزمایشی است

GET /api/v1/doctor/{uuid}

Get doctor detail with clinics.

Permission: PUBLIC

Path Parameters

Param Type Description
uuid string (UUID) Doctor UUID

Response 200

{
  "success": true,
  "data": {
    "data": {
      "uuid": "550e8400-...",
      "name": "علی احمدی",
      "display_name": "دکتر علی احمدی",
      "gender": "man",
      "medical_system_code": "12345",
      "degree": "specialist",
      "detail": "...",
      "img": [],
      "social_media": {
        "instagram": "https://instagram.com/dr.example",
        "telegram": "https://t.me/dr_example",
        "aparat": null,
        "youtube": null,
        "linkedin": null
      },
      "satisfaction": "60",
      "point": "3.5",
      "free_turn": "دوشنبه 09:0013:00",
      "hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013:00",
      "active": true,
      "owner_status": "claimed",
      "specialties": [{ "uuid": "...", "id": "1", "name": "قلب و عروق", "parent_id": null }],
      "expertise": [{ "uuid": "...", "id": "3", "name": "نوار قلب" }],
      "address": [],
      "state": [],
      "city": [],
      "clinics": [{ "uuid": "...", "name": "کلینیک الوند", "address": "...", "telephone": "..." }],
      "representation": { "id": 12, "uuid": "9c1...", "full_name": "علی محمدی" }
    }
  }
}

⚠️ Double-nested: Frontend extracts with data?.data?.data

representation نماینده‌ی مالکِ پزشک است؛ برای پزشکِ بدون نماینده null.

Errors

Code HTTP Description
ERR_NOT_FOUND_001 404 Doctor not found

GET /api/v1/clinic/my-doctor/{doctorUuid}

Get doctor detail for clinic owner — only doctors who are members of the authenticated clinic.

Permission: ROLE_CLINIC

Path Parameters

Param Type Description
doctorUuid string (UUID) Doctor UUID

Response 200

{
  "success": true,
  "data": {
    "data": {
      "uuid": "...",
      "title": "علی احمدی",
      "specialties": [...],
      "clinics": [{ "uuid": "...", "name": "کلینیک نور", "address": "...", "telephone": "..." }]
    }
  }
}

⚠️ Double-nested: Frontend extracts with data?.data?.data

Side note: Returns only the authenticated clinic's data in the clinics array (not all clinics of the doctor).

Errors

Code HTTP Description
ERR_VALIDATION_002 404 Clinic or doctor not found
ERR_AUTH_006 403 Doctor is not a member of this clinic

Schedule Fields Notes

تجمیع همهٔ برنامه‌ها (2026-07): این فیلدها روی همهٔ برنامه‌های هفتگی پزشک محاسبه می‌شوند — برنامهٔ مطب شخصی (clinic_id IS NULL) به‌علاوهٔ یک برنامه به ازای هر کلینیک. پزشکی که برنامهٔ شخصی خالی/خاموش ولی برنامهٔ کلینیکیِ فعال دارد، active=true می‌گیرد؛ برنامهٔ یک محیط هرگز محیط دیگر را نمی‌پوشاند.

Field When at least one schedule is bookable When none
free_turn نزدیک‌ترین روز/ساعت کاری از امروز، بین همهٔ برنامه‌های روشن (مثلاً «دوشنبه ۹:۰۰–۱۳:۰۰») «نوبت آزادی موجود نیست»
hours_of_work خلاصه ساعت‌های همان برنامه‌ای که free_turn را داده (ساعت‌های دو محل با هم ترکیب نمی‌شوند) «برنامه کاری تنظیم نشده»
active activeDoctorAppointment && (∃ schedule: online_booking_enabled && has_active_sessions) false — نوبت‌دهی غیرفعال

نوبت‌دهی آنلاین غیرفعال: اگر meta.online_booking_enabled در همهٔ برنامه‌های پزشک false باشد، free_turn برابر "نوبت‌دهی آنلاین غیرفعال است" و active برابر false برمی‌گردد؛ hours_of_work در صورت وجود برنامه حفظ می‌شود. تا وقتی حتی یک برنامه روشن و دارای روز فعال باشد، همان مبنا قرار می‌گیرد.

چنین پزشکی (همه برنامه‌ها خاموش، ولی فلگ active_doctor_appointment روشن) در لیست عمومی GET /api/v1/doctors نمایش داده می‌شود — چون هنوز فعال است — ولی با bookableRank پایین‌تر از پزشکان دارای نوبت مرتب می‌شود و تنها با active=1 از نتایج حذف می‌گردد.

پزشک غیرفعال (active_doctor_appointment خاموش): با غیرفعال‌کردن پزشک از پنل ادمین، او دیگر در لیست عمومی GET /api/v1/doctors ظاهر نمی‌شود (پیش‌فرض این اندپوینت پزشکان غیرفعال را کنار می‌گذارد). صفحهٔ تکی GET /api/v1/doctor/{slug} همچنان پاسخ می‌دهد (ادمین/کلینیک از همین اندپوینت برای مشاهده/ویرایش استفاده می‌کنند) و در بدنهٔ پاسخ فیلد خام is_active (= active_doctor_appointment، مستقل از داشتن برنامه) را برمی‌گرداند؛ کلاینت عمومی مثل nobat724 با is_active === false صفحهٔ پزشک را 404 می‌کند. این با فیلد active (که active_doctor_appointment && has_schedule است) فرق دارد.


GET /api/v1/doctors

List doctors with pagination and filters.

Permission: PUBLIC

Query Parameters

Param Type Required Description
page integer Default: 1
limit integer Default: 10. حداکثر ۵۰ — مقادیر بزرگ‌تر بی‌صدا به ۵۰ کاهش می‌یابند. مقدار واقعاً اعمال‌شده در meta.limit برمی‌گردد؛ برای پیمایش کامل به meta.totalPages تکیه کن، نه به «تعداد آیتم کمتر از limit درخواستی»
search string Search in title
specialty_id integer Filter by specialty ID
city_id integer Filter by city ID — شامل دکترهایی که آدرس شخصی‌شان (doctor_addresses.city_id, با doctor_id مقداردار) در آن شهر است یا از طریق کلینیکی که آدرس آن در آن شهر است (doctor_addresses.clinic_id)
state_id integer Filter by province ID — بر اساس آدرس شخصی پزشک (doctor_addresses.province_id) یا آدرس کلینیک
domain string دامنه‌ی سایتِ درخواست‌کننده. اگر دامنه‌ی یک نماینده سراسری باشد، فقط پزشکانِ همان نماینده برمی‌گردند و city_id/state_id نادیده گرفته می‌شوند؛ دامنه شهری/ناشناخته اثری ندارد
gender string man یا woman
degree string expert, general, specialist, subspecialistplus
name string جستجوی LIKE روی نام پزشک
sort string ASC یا DESC (پیش‌فرض DESC) — مرتب‌سازی ثانویه بر اساس doctorRate
active 0/1 1 → فقط پزشکان دارای نوبت (تعریف پایین). 0 → فقط پزشکانی که فلگ active_doctor_appointment آن‌ها خاموش است (کاربرد ادمین). بدون این پارامتر → فقط پزشکان فعال (active_doctor_appointment روشن)؛ پزشکان غیرفعال هرگز در لیست عمومی نمی‌آیند

مرتب‌سازی و تعریف «دارای نوبت»

پزشک دارای نوبت یعنی هر سه شرط برقرار باشد (همان تعریفی که فیلد active هر آیتم پاسخ را می‌سازد):

  1. فلگ active_doctor_appointment روشن،
  2. WeeklySchedule ثبت‌شده با حداقل یک سشن active: true،
  3. meta.online_booking_enabled برابر false نباشد.

لیست همیشه اول پزشکان دارای نوبت را نشان می‌دهد و بعد بقیه را؛ داخل هر گروه بر اساس doctorRate و پارامتر sort مرتب می‌شود.

Response 200

{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "name": "علی احمدی",
      "display_name": "دکتر علی احمدی",
      "gender": "man",
      "degree": "specialist",
      "img": [],
      "specialties": [{ "uuid": "...", "id": "1", "name": "قلب و عروق" }],
      "satisfaction": "60",
      "point": "3.5",
      "free_turn": "دوشنبه 09:0013:00",
      "hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013:00",
      "active": true,
      "owner_status": "claimed",
      "city": [
        { "uuid": "7bfb989e-...", "id": "123", "name": "یاسوج", "parent": "23" }
      ],
      "state": [
        { "uuid": "7bfb5705-...", "id": "23", "name": "کهگیلویه و بویراحمد" }
      ]
    }
  ],
  "meta": {
    "totalRecords": 50,
    "totalPages": 3,
    "currentPage": 1,
    "limit": 50
  }
}

point و satisfaction فقط برای owner_status="claimed" مقدار دارند؛ برای unclaimed/pending_transfer هر دو null هستند.

اعتبارسنجی نام پزشک

name نمی‌تواند شماره‌تلفن (^0?9\d{9}$) یا مقدار آزمایشی (test، تست، -، null) باشد. این مقادیر در هر مسیر نوشتن با 422 رد می‌شوند — API عمومی، پنل ادمین، import و دعوت کلینیک — چون گارد روی خودِ Entity نشسته است (App\Shared\Util\DisplayName).

دلیل: نام پزشک در <title> و نتایج جست‌وجوی سایت عمومی رندر می‌شود؛ رکوردی با نام «09390039833» یک صفحهٔ بی‌ارزش ایندکس‌شدنی می‌سازد.

دعوت پزشک توسط کلینیک، اگر نام ارسال نشود، دیگر شمارهٔ موبایل را به‌عنوان نام نمی‌نشاند — برچسب خنثای «پزشک دعوت‌شده» می‌گیرد تا خود پزشک پروفایلش را claim کند. (ریشهٔ آلودگی تولیدی همین بود.)

فرمان ممیزی رکوردهای موجود:

php bin/console app:audit-polluted-records          # فقط گزارش
php bin/console app:audit-polluted-records --force  # خارج‌کردن از انتشار (بدون حذف)

city / state در پاسخ لیست

آرایه با حداکثر یک عضو — هم‌شکل با city/state در پاسخ جزئیات پزشک و پاسخ لیست کلینیک‌ها.

  • منبع مکان دقیقاً همان قاعده‌ای است که فیلتر city_id/state_id اعمال می‌کند: اول آدرس شخصی پزشک (doctor_addresses با doctor_id مقداردار)، و اگر نداشت آدرس کلینیکی که عضو آن است (doctor_addresses با clinic_id مقداردار و doctor_id تهی). یعنی هر پزشکی که با city_id=X برگردد، در پاسخ هم همان شهر را اعلام می‌کند.
  • پزشک چند-مطبی یک شهر اصلی می‌گیرد (اولین مکان یافت‌شده) — نه فهرست همهٔ شهرها.
  • پزشک بدون هیچ آدرس: "city": [] و "state": [] (آرایهٔ خالی، نه null).
  • city[].parent شناسهٔ استان است.
  • استخراج مکان دسته‌ای انجام می‌شود (DoctorRepository::findLocationsByDoctors) — حداکثر دو کوئری ثابت، مستقل از تعداد پزشکان در صفحه.

🔗 مصرف‌کننده: nobat724_front/app/sitemap.js با این فیلد تشخیص می‌دهد هر پزشک به کدام دامنهٔ شهری تعلق دارد (canonical). تغییر شکل این فیلد قرارداد آن را می‌شکند.


PATCH /api/v1/doctor/{uuid}

Update doctor profile.

Permission: AUTH — must be the owner (or ROLE_ADMIN)

Path Parameters

Param Type Description
uuid string (UUID) Doctor UUID

Request Body (application/json)

Same fields as POST (all optional), plus:

Field Type Description
social_media object Keys: instagram, telegram, aparat, youtube, linkedin. Each value must be a full valid URL or null. Any value that fails FILTER_VALIDATE_URL is silently stored as null.
{
  "social_media": {
    "instagram": "https://instagram.com/dr.example",
    "telegram": "https://t.me/dr_example",
    "aparat": null,
    "youtube": null,
    "linkedin": null
  }
}

Response 200

Updated doctor object (same structure as GET single).

Errors

Code HTTP Description
ERR_AUTH_001 401 Missing token
ERR_FORBIDDEN_001 403 Not the owner
ERR_NOT_FOUND_001 404 Doctor not found

DELETE /api/v1/doctor/{uuid}

Delete a doctor profile.

Permission: IS_AUTHENTICATED_FULLYadmin (any profile) or the owner of a claimed profile (doctor.user === current user). Other users get 403.

Side effect: the doctor's insurance configuration (tenant_insurances, entity_insurance_pricing, and their tenant_service_coverages) is purged in the same request — these reference the doctor via a polymorphic entity_id with no DB FK, so the cleanup is enforced in the application.

Guard: a doctor with existing appointments cannot be deleted (the appointments.doctor_id FK would otherwise raise a 500). The endpoint pre-checks and returns 409 ERR_CONFLICT_001 instead.

Path Parameters

Param Type Description
uuid string (UUID) Doctor UUID

Response 200

{
  "success": true,
  "data": { "message": "پزشک با موفقیت حذف شد" }
}

Errors

Code HTTP Description
ERR_AUTH_001 401 Missing token
ERR_FORBIDDEN_001 403 Not admin and not the owner of this claimed profile
ERR_VALIDATION_002 404 Doctor not found
ERR_CONFLICT_001 409 Doctor has existing appointments and cannot be deleted

POST /file/upload/clinic_pro/doctor/field_image

Upload doctor profile image.

Permission: AUTH

Request

Content-Type: multipart/form-data

Field Type Required Description
file binary Image file (max 5MB)

Response 200

{
  "success": true,
  "data": {
    "url": "https://clinic-pro.ddev.site/uploads/doctor/abc123.jpg",
    "uuid": "...",
    "filename": "abc123.jpg",
    "filemime": "image/jpeg",
    "filesize": 204800
  }
}

Errors

Code HTTP Description
ERR_FILE_001 422 Invalid file type
ERR_AUTH_001 401 Missing token

GET /api/v1/clinic-pro/doctor-addresses/{doctorId}

Get all practice addresses for a doctor, including addresses of clinics the doctor is a member of.

Permission: PUBLIC

Path Parameters

Param Type Description
doctorId integer Doctor's numeric ID (route requires \d+; non-numeric → 404)

Response 200

{
  "success": true,
  "data": [
    {
      "id": "1",
      "uuid": "...",
      "type": "personal",
      "clinic_id": null,
      "clinic_name": null,
      "name": "مطب تهران",
      "address": "تهران، خیابان...",
      "telephone": "02112345678",
      "active": true,
      "timezone": "Asia/Tehran",
      "map": { "latitude": "35.6892", "longitude": "51.3890" },
      "city": { "id": "1", "name": "تهران" },
      "province": { "id": "1", "name": "تهران" }
    },
    {
      "id": "5",
      "uuid": "...",
      "type": "clinic",
      "clinic_id": 12,
      "clinic_name": "کلینیک الوند",
      "name": null,
      "address": "اصفهان، خیابان...",
      "telephone": "03112345678",
      "active": true,
      "timezone": "Asia/Tehran",
      "map": { "latitude": null, "longitude": null },
      "city": { "id": "3", "name": "اصفهان" },
      "province": { "id": "2", "name": "اصفهان" }
    }
  ]
}

نکته: آدرس‌های با type: "clinic" از کلینیک‌هایی که پزشک عضو آن‌هاست می‌آیند و clinic_name نام کلینیک را نشان می‌دهد.

active و timezone (افزوده‌شده در تسک شعبه): هر آدرس یک «شعبه» است و این دو ویژگی روی خروجی همهٔ اندپوینت‌های آدرس ظاهر می‌شوند، چون از DoctorAddress::toArray() می‌آیند. هر دو ستون NOT NULL DEFAULT دارند، پس ردیف‌های قدیمی هم active: true و timezone: "Asia/Tehran" می‌دهند؛ تغییر additive است.

active فقط ذخیره می‌شود و اثرش بر دسترس‌پذیری فقط همین است که منبعِ روی یک آدرسِ غیرفعال، روزش با دلیل address_inactive خالی برمی‌گردد. اندپوینت اختصاصیِ نوشتنِ این دو فیلد با حذف دامنهٔ شعبه برداشته شد.


POST /api/v1/clinic-pro/doctor-address

Add a new practice address.

Permission: AUTH — must own the doctor profile

Request Body

{
  "name": "مطب تهران",
  "address": "تهران، خیابان ولیعصر",
  "telephone": "02112345678",
  "province_id": 1,
  "city_id": 3,
  "latitude": 35.6892,
  "longitude": 51.3890
}
Field Type Required
name string
address string (frontend validation)
telephone string (frontend validation)
province_id integer (frontend validation)
city_id integer (frontend validation)
latitude float
longitude float

Response 201

{
  "success": true,
  "data": {
    "id": 1,
    "name": "مطب تهران",
    "address": "تهران، خیابان ولیعصر",
    "telephone": "02112345678",
    "latitude": 35.6892,
    "longitude": 51.3890
  }
}

Errors

Code HTTP Description
ERR_AUTH_001 401 Missing token
ERR_FORBIDDEN_001 403 Not the doctor owner
ERR_NOT_FOUND_001 404 Doctor not found

PATCH /api/v1/clinic-pro/doctor-address/{id}

Update a practice address.

Permission: AUTH — must own the doctor profile

Path Parameters

Param Type Description
id integer Address ID

Request Body

Same fields as POST — all optional.

Response 200

Updated address object.

Errors

Code HTTP Description
ERR_AUTH_001 401 Missing token
ERR_FORBIDDEN_001 403 Not the owner
ERR_NOT_FOUND_001 404 Address not found

DELETE /api/v1/clinic-pro/doctor-address/{id}

Delete a practice address.

Permission: AUTH — must own the doctor profile

Response 200

{ "success": true, "data": { "message": "آدرس حذف شد" } }

Errors

Code HTTP Description
ERR_AUTH_001 401 Missing token
ERR_FORBIDDEN_001 403 Not the owner
ERR_NOT_FOUND_001 404 Address not found

POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}

Create a doctor address automatically from a clinic's location.

Permission: AUTH — must own the doctor profile and be associated with the clinic

Path Parameters

Param Type Description
clinicUuid string (UUID) Clinic UUID

Response 201

Address object created from clinic data.

Errors

Code HTTP Description
ERR_FORBIDDEN_001 403 Not associated with this clinic
ERR_NOT_FOUND_001 404 Clinic not found

GET /api/v1/addresses

مجوز: appointment_settings.view. فهرست محل‌های نوبت‌دهی محیط جاری، فقط برای انتخاب.

جانشین GET /api/v1/branches است که با حذف دامنهٔ شعبه برداشته شد. خودِ آدرس نمی‌رود: هر منبع، لیست قیمت و نوبت به یکی از این‌ها بسته است. ساخت و ویرایش آدرس همان‌جایی است که همیشه بود (ClinicController و AppointmentSettingsController).

{
  "success": true,
  "data": [
    { "uuid": "…", "name": "کلینیک تخصصی مهر", "active": true }
  ]
}

۴۰۳: منشیِ بدون مجوز appointment_settings.view.