diff --git a/docs/scenarios/irimc-doctor-import-ownership.html b/docs/scenarios/irimc-doctor-import-ownership.html new file mode 100644 index 00000000..37138ed0 --- /dev/null +++ b/docs/scenarios/irimc-doctor-import-ownership.html @@ -0,0 +1,385 @@ + + +
+ + +++نسخه: ۱.۰ — تاریخ: ۱۴۰۵/۰۴/۱۹ (۲۰۲۶-۰۷-۱۰) +دامنه:
+clinicpro(بکاند + پنل ادمین) ·nobat724_front(سایت عمومی) ·clinic-pro-tauri(اپ دسکتاپ) +وضعیت: پیشنویس طراحی برای پیادهسازی
یک دیتاست ۱۶۰ نفره از پزشکان از سامانه استعلام اعضای سازمان نظام پزشکی (membersearch.irimc.org) استخراج شده است. هدف، وارد کردن این پزشکان به Clinic Pro است تا در سایت عمومی Nobat724 نمایش داده شوند، پیش از آنکه پزشک واقعی در سیستم ثبتنام کرده باشد.
مشکل محوری: در مدل دادهی فعلی، هر پزشک (Doctor) بهصورت اجباری و یکبهیک و یکتا به یک کاربر (User) متصل است، و هر کاربر نیز الزاماً یک شماره موبایل یکتا و غیرتهی دارد. اما رکوردهای سازمان نظام پزشکی فاقد شماره موبایل هستند (mobileNumber: null). بنابراین نه میتوان کاربر ساخت (چون موبایل لازم است) و نه میتوان یک پزشک را بدون کاربر ذخیره کرد.
این سند یک مدل «مالکیت پروفایل» (Profile Ownership) طراحی میکند که در آن پزشکان ایمپورتشده در حالت «بدونمالک» (unclaimed) ذخیره و مدیریت میشوند، و بعداً از طریق یک فرایند احراز هویتشده در Nobat724 به پزشک واقعی منتقل (claim/transfer) میشوند.
+پیش از طراحی راهحل، محدودیتهای واقعی کد فعلی مستند میشوند (منبع: src/Doctor/Entity/Doctor.php, src/Auth/Entity/User.php, src/Doctor/Controller/DoctorController.php).
| محدودیت | +جزئیات کد فعلی | +پیامد برای ایمپورت | +
|---|---|---|
| کاربر برای پزشک اجباری است | +Doctor::$user → OneToOne، JoinColumn(nullable: false, onDelete: RESTRICT) |
+نمیتوان پزشک بدون کاربر ذخیره کرد. | +
| رابطه پزشک↔کاربر یکتاست | +UniqueConstraint idx_doctors_user (user_id) |
+نمیتوان چند پزشک را به یک کاربر مشترک وصل کرد — ایدهی «همه به یک کاربر سیستمی» با این قید نقض میشود. | +
| موبایل کاربر اجباری و یکتاست | +User::$mobileNumber → NOT NULL، UniqueConstraint uniq_mobile |
+بدون موبایل نمیتوان User ساخت؛ دادهی نظام پزشکی موبایل ندارد. |
+
| ساخت پزشک به کاربر لاگینشده گره خورده | +DoctorController::create() از #[CurrentUser] User $user استفاده میکند و اگر همان کاربر پزشک داشته باشد خطای ۴۰۹ میدهد |
+مسیر فعلی ساخت پزشک برای ایمپورت انبوه مناسب نیست. | +
medical_system_code یکتا نیست |
+Doctor::$medicalSystemCode → nullable, بدون unique |
+برای جلوگیری از ایمپورت تکراری و برای تطبیق هنگام claim، باید کلید طبیعی یکتا شود. | +
خواستهی اولیه («همهی پزشکان ایمپورتشده به یک کاربر سیستمی اختصاص یابند») بهدلیل قید یکتای user_id روی جدول doctors مستقیماً قابل اجرا نیست. بنابراین یکی از دو مسیر زیر لازم است، و این سند گزینه A را توصیه میکند:
Doctor.user اختیاری (nullable) میشود. یک کاربر سیستمی بهنام «مالک سیستمی» (System Owner) صرفاً بهعنوان مدیرِ منطقیِ پزشکان بدونمالک عمل میکند (نه از طریق ستون user_id، بلکه از طریق فیلد جدید managed_by). این کار قید یکتا را نقض نمیکند و مدل تمیزتری میسازد.User غیرفعال با شناسهی مصنوعی (مثلاً موبایل رزروشدهی IRIMC-<code>) ساخته میشود. اسکیمای doctors تقریباً دستنخورده میماند اما جدول users با ۱۶۰ کاربر جعلی شلوغ میشود و هنگام claim باید ادغام (merge) انجام شود.مقایسه و تصمیم نهایی در بخش ۱۳ آمده است.
+هر پروفایل پزشک یکی از این وضعیتهای مالکیت را دارد:
+unclaimed (بدونمالک): ایمپورتشده از نظام پزشکی، هنوز به پزشک واقعی وصل نشده. توسط «مالک سیستمی» مدیریت میشود. در Nobat724 نمایش داده میشود اما قابل ویرایش توسط عموم نیست و نوبتدهی آنلاین آن پیشفرض غیرفعال است.pending_transfer (در انتظار انتقال): پزشک واقعی درخواست تصاحب داده و در حال احراز هویت / انتظار تأیید ادمین است.claimed (تصاحبشده): مالکیت به پزشک واقعی منتقل شده؛ پروفایل به کاربر واقعی او متصل است و او کنترل کامل دارد.منبع پروفایل نیز ثبت میشود:
+source: irimc (نظام پزشکی) یا manual (ساخت دستی/ثبتنام عادی — رفتار فعلی).source_ref: شناسهی یکتای رکورد مبدأ (profile_url id یا medicalSystemCode) برای idempotency و ممیزی.یک کاربر ویژه یکبار ساخته میشود (از طریق دستور کنسول، همسبک CreateAdminCommand):
0000000000 (خارج از فضای شمارههای واقعی ایران، ۱۱ رقمی نامعتبر).['ROLE_USER', 'ROLE_ADMIN'] یا نقش اختصاصی ROLE_SYSTEM_OWNER.status = 0 (غیرفعال برای لاگین) تا امکان ورود با آن وجود نداشته باشد.real_name = 'مالک سیستمی نوبت۷۲۴'.این کاربر صاحب user_id پزشکان نیست (چون یکتاست)؛ بلکه شناسهاش در ستون جدید Doctor.managed_by قرار میگیرد تا مشخص باشد این پزشکان توسط پلتفرم مدیریت میشوند و بعداً قابل واگذاریاند.
doctors.jsonهر رکورد ورودی به این شکل به موجودیت Doctor نگاشت میشود:
| فیلد ورودی (JSON) | +مقصد در Doctor |
+توضیح | +
|---|---|---|
name |
+name |
+مثلاً «دکتر فرخنده حسینی» | +
gender (woman/man) |
+gender |
+با Doctor::GENDERS سازگار است |
+
medicalSystemCode |
+medical_system_code + source_ref |
+کلید طبیعی یکتا برای dedup | +
mobileNumber (null) |
+mobile_number = null |
+اجازه دارد null بماند | +
degree (general) |
+degree |
+با Doctor::DEGREES سازگار است |
+
info |
+info |
+«دکترای حرفهای پزشکی» | +
specialty_id / specialty_uuid |
+رابطه specialties |
+تطبیق با جدول specialties (fallback با uuid) |
+
state_id / state_uuid |
+رابطه provinces |
+استان محل فعالیت | +
city_id / city_uuid |
+رابطه cities |
+شهر محل فعالیت | +
images ([]) |
+images |
+خالی → null |
+
socialMedia |
+social_media |
+نگاشت به کلیدهای مجاز | +
profile_url / source_url |
+متادیتای ایمپورت | +برای ممیزی و لینک بازبینی | +
فیلدهای ثابت هنگام ایمپورت: owner_status = 'unclaimed'، source = 'irimc'، managed_by = <systemOwnerId>، active_doctor_appointment = false (تا وقتی مالک واقعی برنامهی کاری تعریف کند نوبتدهی روشن نشود).
(source = 'irimc', medical_system_code). اجرای مجدد ایمپورت رکورد موجود را بهروزرسانی میکند نه تکراریسازی.medicalSystemCode رد و در گزارش ایمپورت لاگ میشوند.*_id و در صورت نبود، با *_uuid انجام میشود؛ عدم تطبیق باعث رد کل رکورد نمیشود بلکه فقط آن رابطه خالی میماند و در گزارش ثبت میشود.دستور کنسول اختصاصی (همسبک SeedDemoDataCommand و SeedCategoriesCommand):
ddev exec php bin/console app:doctors:import-irimc var/import/doctors.json --dry-run
+ddev exec php bin/console app:doctors:import-irimc var/import/doctors.json
+
+--dry-run فقط گزارش میدهد و چیزی ذخیره نمیکند. ایمپورت درون یک تراکنش دیتابیس و بهصورت دستهای (batch/flush هر ۵۰ رکورد) انجام میشود.
تغییرات روی موجودیت Doctor (بههمراه یک migration در migrations/):
doctors:
+ user_id INT NULL -- تغییر از NOT NULL به NULL (گزینه A)
+ managed_by INT NULL -- FK به users.id؛ کاربر «مالک سیستمی»
+ owner_status VARCHAR(20) NOT NULL DEFAULT 'claimed'
+ -- unclaimed | pending_transfer | claimed
+ source VARCHAR(20) NOT NULL DEFAULT 'manual' -- irimc | manual
+ source_ref VARCHAR(100) NULL -- شناسه رکورد مبدأ
+ claimed_at INT NULL
+ medical_system_code VARCHAR(25) NULL -- (موجود) + ایندکس یکتای جزئی
+
+قیود و ایندکسها:
+UniqueConstraint idx_doctors_user: یکتایی فقط باید برای پزشکانِ دارای کاربر اعمال شود. چون MariaDB از partial unique index پشتیبانی مستقیم ندارد، یکتایی user_id در سطح اپلیکیشن (هنگام claim) تضمین میشود و ایندکس دیتابیس به INDEX ساده تبدیل میشود.UNIQUE (source, medical_system_code) برای idempotency ایمپورت.owner_status برای فیلتر سریع «پزشکان بدونمالک».سازگاری با دادهی موجود: تمام پزشکان فعلی هنگام migration مقدار owner_status = 'claimed' و source = 'manual' میگیرند تا رفتارشان تغییر نکند.
++نکته سازگاری: طبق
+CLAUDE.md،medical_system_codeتا الانnullableو بدون یکتایی بوده؛ پیش از افزودن ایندکس یکتا باید دادهی موجود از نظر تکراری بودن پاکسازی شود.
پاسخها از پوشش BaseController پیروی میکنند: { success, data } / { success, errors } / صفحهبندی { data, meta }. مطابق قانون پروژه، هر تغییر کنترلر باید در docs/api/* هم مستند شود.
معمولاً از طریق دستور کنسول انجام میشود؛ در صورت نیاز به تریگر از پنل ادمین:
+POST /api/v1/admin/doctors/import-irimc [ROLE_ADMIN]
+ body: { source_url?, dry_run?: bool, records: [...] }
+ → 200 { success, data: { created, updated, skipped, report_url } }
+
+اندپوینت موجود GET /api/v1/doctors با فیلتر جدید owner_status توسعه مییابد تا هم برای پنل ادمین و هم برای صفحهی «تصاحب پروفایل» در Nobat724 قابلاستفاده باشد:
GET /api/v1/doctors?owner_status=unclaimed&search=&city_id=&specialty_id=
+ → 200 { success, data: [...], meta }
+
+POST /api/v1/doctor/{uuid}/claim [IS_AUTHENTICATED_FULLY]
+ body: { national_code, medical_system_code, activity_time? }
+ قواعد:
+ - پروفایل باید owner_status = 'unclaimed' باشد، وگرنه 409.
+ - medical_system_code ورودی باید با رکورد پزشک مطابقت کند، وگرنه 422.
+ - کاربر لاگینشده (که موبایلش قبلاً با OTP تأیید شده) نباید از قبل پزشکِ دیگری داشته باشد.
+ - رکورد به pending_transfer میرود و یک ClaimRequest ثبت میشود.
+ → 202 { success, data: { claim_id, status: 'pending_transfer' } }
+
+GET /api/v1/admin/doctor-claims?status=pending [ROLE_ADMIN]
+POST /api/v1/admin/doctor-claims/{claimId}/approve [ROLE_ADMIN]
+POST /api/v1/admin/doctor-claims/{claimId}/reject [ROLE_ADMIN] { reason }
+
+هنگام approve، عملیات انتقال مالکیت (بخش ۷) بهصورت اتمیک اجرا میشود.
+++امکان «انتقال خودکار» (بدون ادمین) نیز قابل تعریف است: اگر
+national_codeکاربر تأییدشده باشد وmedical_system_codeو نام کاملاً منطبق باشند، سیستم میتواند مستقیماً claim را تأیید کند. تصمیم پیشفرض این سند: تأیید ادمین اجباری برای فاز اول (بخش ۱۳).
جریان کامل تصاحب پروفایل توسط پزشک واقعی:
+unclaimed) و روی «این پروفایل من است» کلیک میکند.User واقعی با موبایل واقعی ساخته میشود (مسیر عادی auth موجود).national_code و medical_system_code را میگیرد و با رکورد پزشک تطبیق میدهد (POST .../claim). پروفایل به pending_transfer میرود.profile_url سازمان نظام پزشکی بازبینی و approve/reject میکند.Doctor.user = کاربر واقعی پزشک (پر شدن ستونی که تا الان null بود).Doctor.managed_by = null؛ owner_status = 'claimed'؛ claimed_at = time().ROLE_DOCTOR به کاربر واقعی (همان منطق موجود در DoctorController::create).PATCH /api/v1/doctor/{uuid} مجاز است (چک مالکیت فعلی getUser()->getId() === user اکنون درست کار میکند).Sms موجود).قواعد یکتایی هنگام انتقال: چون یک کاربر واقعی نباید صاحب دو پزشک شود، پیش از اتصال باید بررسی شود که user_id مقصد در جدول doctors تکراری نشود (تضمین در سطح اپلیکیشن، جایگزین قید یکتای حذفشده).
nobat724_front)مصرفکنندهی API از طریق services/response.js است (که هماکنون getDoctors, postDoctor, ... را دارد). تغییرات لازم:
api/v1/doctors) بهطور خودکار پزشکان unclaimed را هم شامل میشود؛ در کارت پزشک، بهجای دکمهی «رزرو نوبت»، دکمهی «این پروفایل من است / تکمیل پروفایل» نمایش داده میشود چون active=false است.POST api/v1/doctor/{uuid}/claim پس از ورود با OTP. نگهداری access_token/refresh_token در کوکی (مطابق الگوی فعلی Nobat724).nobat724_front/services/response.js هم منعکس شود، چون در زمان build خطا نمیدهد.clinic-pro-tauriاپ دسکتاپ نیز کلاینت همان API است (src/service/response.js) و از CASL برای نقشها استفاده میکند (clinic, doctor, clinic_doctor, secretary).
owner_status و رفتار active=false را مدیریت کند (پزشک بدونمالک قابل رزرو آنلاین نیست).owner_status در دیتابیس محلی SQLite باید بررسی شود (طبق هشدار AGENTS.md).unclaimed را نشان دهد لازم میشود.[نظام پزشکی JSON] → دستور ایمپورت → پزشکِ unclaimed (managed_by = System Owner)
+ │
+ ▼
+ نمایش در Nobat724 (active=false، بدون نوبت آنلاین)
+ │ پزشک واقعی: «این پروفایل من است»
+ ▼
+ ورود با موبایل + OTP → ساخت User واقعی
+ │
+ ▼
+ فرم تصاحب (کد ملی + کد نظام پزشکی) → POST /claim → pending_transfer
+ │
+ ▼
+ بازبینی ادمین (approve) → انتقال اتمیک:
+ user_id=واقعی، owner_status=claimed، +ROLE_DOCTOR
+ │
+ ▼
+ پزشک پروفایل و برنامهی کاری را کامل میکند → active=true → نوبتدهی آنلاین فعال
+
+pending_transfer پذیرفته میشود؛ بقیه با ۴۰۹ رد میشوند تا تعیین تکلیف قبلی روشن شود.user_id).unclaimed بازمیگردد و برای تصاحب مجدد آزاد میشود.DELETE); اما پزشکِ claimed طبق رفتار فعلی محافظت میشود.unclaimed است، active_doctor_appointment=false و برنامهی کاری وجود ندارد؛ لذا در toListArray مقدار active=false میشود و رزرو ممکن نیست.مطابق CLAUDE.md: ابتدا بکاند clinicpro، سپس مستندسازی API، سپس کلاینتها.
الف) clinicpro (بکاند):
+1. افزودن فیلدهای managed_by, owner_status, source, source_ref, claimed_at و nullable کردن user_id در Doctor + migration در migrations/.
+2. پاکسازی داده و افزودن ایندکس یکتای (source, medical_system_code).
+3. دستور کنسول app:doctors:import-irimc (با --dry-run، گزارش، تراکنش).
+4. دستور/سیدر ساخت کاربر «مالک سیستمی».
+5. موجودیت/جدول DoctorClaim + اندپوینتهای claim و approve/reject.
+6. توسعهی فیلتر owner_status در GET /api/v1/doctors و بهروزرسانی چکهای مالکیت.
+7. بهروزرسانی docs/api/* (طبق قانون استاندارد پروژه) و افزودن این سند به مستندات.
ب) nobat724_front:
+8. همترازی services/response.js با قالبهای جدید.
+9. دکمهی «این پروفایل من است» روی کارت پزشکِ unclaimed + صفحهی فرم تصاحب (فارسی، جلالی، RTL).
ج) clinic-pro-tauri (در صورت نیاز):
+10. مدیریت owner_status/active=false در لیست پزشکان و بررسی مرز sync محلی.
د) بازبینی نهایی:
+11. تست ایمپورت روی نمونهی ۱۶۰ رکورد، تست جریان claim سرتاسری، و بازسازی کاربران تست (ddev exec php create_test_users.php).
user_id + managed_by) را توصیه میکند چون جدول users را با کاربران جعلی آلوده نمیکند و مدل مالکیت را صریح میسازد. هزینهاش: از دست رفتن قید یکتای دیتابیسی روی user_id و انتقال آن به سطح اپلیکیشن.specialty_id/city_id معتبر باشند؛ گزارش ایمپورت باید اینها را شفاف کند.نمونهی یک رکورد ورودی از doctors.json (۱۶۰ رکورد، همگی mobileNumber: null):
{
+ "name": "دکتر فرخنده حسینی",
+ "gender": "woman",
+ "medicalSystemCode": "145657",
+ "mobileNumber": null,
+ "degree": "general",
+ "info": "دکترای حرفهای پزشکی",
+ "specialty_id": 1,
+ "specialty_name": "پزشک عمومی",
+ "state_id": 23,
+ "state_name": "کهگیلویه و بویراحمد",
+ "city_id": 123,
+ "city_name": "یاسوج",
+ "profile_url": "https://membersearch.irimc.org/member/profile?id=02058131-...",
+ "source_url": "https://membersearch.irimc.org"
+}
+
+`) ساخته میشود. اسکیمای `doctors` تقریباً دستنخورده میماند اما جدول `users` با ۱۶۰ کاربر جعلی شلوغ میشود و هنگام claim باید ادغام (merge) انجام شود.
+
+مقایسه و تصمیم نهایی در بخش ۱۳ آمده است.
+
+---
+
+## ۳. مدل مفهومی مالکیت (Ownership Model)
+
+هر پروفایل پزشک یکی از این وضعیتهای مالکیت را دارد:
+
+- **`unclaimed` (بدونمالک):** ایمپورتشده از نظام پزشکی، هنوز به پزشک واقعی وصل نشده. توسط «مالک سیستمی» مدیریت میشود. در Nobat724 نمایش داده میشود اما قابل ویرایش توسط عموم نیست و نوبتدهی آنلاین آن پیشفرض **غیرفعال** است.
+- **`pending_transfer` (در انتظار انتقال):** پزشک واقعی درخواست تصاحب داده و در حال احراز هویت / انتظار تأیید ادمین است.
+- **`claimed` (تصاحبشده):** مالکیت به پزشک واقعی منتقل شده؛ پروفایل به کاربر واقعی او متصل است و او کنترل کامل دارد.
+
+منبع پروفایل نیز ثبت میشود:
+
+- **`source`**: `irimc` (نظام پزشکی) یا `manual` (ساخت دستی/ثبتنام عادی — رفتار فعلی).
+- **`source_ref`**: شناسهی یکتای رکورد مبدأ (`profile_url` id یا `medicalSystemCode`) برای idempotency و ممیزی.
+
+---
+
+## ۴. بخش اول — ایمپورت پزشکان نظام پزشکی
+
+### ۴.۱ کاربر «مالک سیستمی»
+
+یک کاربر ویژه یکبار ساخته میشود (از طریق دستور کنسول، همسبک `CreateAdminCommand`):
+
+- موبایل رزروشده و ثابت، مثلاً `0000000000` (خارج از فضای شمارههای واقعی ایران، ۱۱ رقمی نامعتبر).
+- نقشها: `['ROLE_USER', 'ROLE_ADMIN']` یا نقش اختصاصی `ROLE_SYSTEM_OWNER`.
+- `status = 0` (غیرفعال برای لاگین) تا امکان ورود با آن وجود نداشته باشد.
+- `real_name = 'مالک سیستمی نوبت۷۲۴'`.
+
+این کاربر **صاحب `user_id` پزشکان نیست** (چون یکتاست)؛ بلکه شناسهاش در ستون جدید `Doctor.managed_by` قرار میگیرد تا مشخص باشد این پزشکان توسط پلتفرم مدیریت میشوند و بعداً قابل واگذاریاند.
+
+### ۴.۲ نگاشت فیلدها از `doctors.json`
+
+هر رکورد ورودی به این شکل به موجودیت `Doctor` نگاشت میشود:
+
+| فیلد ورودی (JSON) | مقصد در `Doctor` | توضیح |
+|---|---|---|
+| `name` | `name` | مثلاً «دکتر فرخنده حسینی» |
+| `gender` (`woman`/`man`) | `gender` | با `Doctor::GENDERS` سازگار است |
+| `medicalSystemCode` | `medical_system_code` + `source_ref` | کلید طبیعی یکتا برای dedup |
+| `mobileNumber` (`null`) | `mobile_number` = `null` | اجازه دارد null بماند |
+| `degree` (`general`) | `degree` | با `Doctor::DEGREES` سازگار است |
+| `info` | `info` | «دکترای حرفهای پزشکی» |
+| `specialty_id` / `specialty_uuid` | رابطه `specialties` | تطبیق با جدول `specialties` (fallback با uuid) |
+| `state_id` / `state_uuid` | رابطه `provinces` | استان محل فعالیت |
+| `city_id` / `city_uuid` | رابطه `cities` | شهر محل فعالیت |
+| `images` (`[]`) | `images` | خالی → `null` |
+| `socialMedia` | `social_media` | نگاشت به کلیدهای مجاز |
+| `profile_url` / `source_url` | متادیتای ایمپورت | برای ممیزی و لینک بازبینی |
+
+فیلدهای ثابت هنگام ایمپورت: `owner_status = 'unclaimed'`، `source = 'irimc'`، `managed_by = `، `active_doctor_appointment = false` (تا وقتی مالک واقعی برنامهی کاری تعریف کند نوبتدهی روشن نشود).
+
+### ۴.۳ قواعد Idempotency و اعتبارسنجی
+
+- کلید یکتای ایمپورت: `(source = 'irimc', medical_system_code)`. اجرای مجدد ایمپورت رکورد موجود را **بهروزرسانی** میکند نه تکراریسازی.
+- رکوردهای بدون `medicalSystemCode` رد و در گزارش ایمپورت لاگ میشوند.
+- تطبیق تخصص/استان/شهر ابتدا با `*_id` و در صورت نبود، با `*_uuid` انجام میشود؛ عدم تطبیق باعث رد کل رکورد نمیشود بلکه فقط آن رابطه خالی میماند و در گزارش ثبت میشود.
+- خروجی دستور ایمپورت: تعداد ساختهشده / بهروزشده / ردشده + مسیر فایل گزارش.
+
+### ۴.۴ روش اجرا
+
+دستور کنسول اختصاصی (همسبک `SeedDemoDataCommand` و `SeedCategoriesCommand`):
+
+```bash
+ddev exec php bin/console app:doctors:import-irimc var/import/doctors.json --dry-run
+ddev exec php bin/console app:doctors:import-irimc var/import/doctors.json
+```
+
+`--dry-run` فقط گزارش میدهد و چیزی ذخیره نمیکند. ایمپورت درون یک تراکنش دیتابیس و بهصورت دستهای (batch/flush هر ۵۰ رکورد) انجام میشود.
+
+---
+
+## ۵. طراحی فنی دیتابیس
+
+تغییرات روی موجودیت `Doctor` (بههمراه یک migration در `migrations/`):
+
+```
+doctors:
+ user_id INT NULL -- تغییر از NOT NULL به NULL (گزینه A)
+ managed_by INT NULL -- FK به users.id؛ کاربر «مالک سیستمی»
+ owner_status VARCHAR(20) NOT NULL DEFAULT 'claimed'
+ -- unclaimed | pending_transfer | claimed
+ source VARCHAR(20) NOT NULL DEFAULT 'manual' -- irimc | manual
+ source_ref VARCHAR(100) NULL -- شناسه رکورد مبدأ
+ claimed_at INT NULL
+ medical_system_code VARCHAR(25) NULL -- (موجود) + ایندکس یکتای جزئی
+```
+
+قیود و ایندکسها:
+
+- حذف/تعدیل `UniqueConstraint idx_doctors_user`: یکتایی فقط باید برای پزشکانِ **دارای کاربر** اعمال شود. چون MariaDB از partial unique index پشتیبانی مستقیم ندارد، یکتایی `user_id` در سطح اپلیکیشن (هنگام claim) تضمین میشود و ایندکس دیتابیس به `INDEX` ساده تبدیل میشود.
+- ایندکس یکتای طبیعی: `UNIQUE (source, medical_system_code)` برای idempotency ایمپورت.
+- ایندکس `owner_status` برای فیلتر سریع «پزشکان بدونمالک».
+
+سازگاری با دادهی موجود: تمام پزشکان فعلی هنگام migration مقدار `owner_status = 'claimed'` و `source = 'manual'` میگیرند تا رفتارشان تغییر نکند.
+
+> نکته سازگاری: طبق `CLAUDE.md`، `medical_system_code` تا الان `nullable` و بدون یکتایی بوده؛ پیش از افزودن ایندکس یکتا باید دادهی موجود از نظر تکراری بودن پاکسازی شود.
+
+---
+
+## ۶. طراحی API (بکاند clinicpro)
+
+پاسخها از پوشش `BaseController` پیروی میکنند: `{ success, data }` / `{ success, errors }` / صفحهبندی `{ data, meta }`. مطابق قانون پروژه، هر تغییر کنترلر باید در `docs/api/*` هم مستند شود.
+
+### ۶.۱ ایمپورت (داخلی / ادمین)
+
+معمولاً از طریق دستور کنسول انجام میشود؛ در صورت نیاز به تریگر از پنل ادمین:
+
+```
+POST /api/v1/admin/doctors/import-irimc [ROLE_ADMIN]
+ body: { source_url?, dry_run?: bool, records: [...] }
+ → 200 { success, data: { created, updated, skipped, report_url } }
+```
+
+### ۶.۲ فهرست پزشکان بدونمالک
+
+اندپوینت موجود `GET /api/v1/doctors` با فیلتر جدید `owner_status` توسعه مییابد تا هم برای پنل ادمین و هم برای صفحهی «تصاحب پروفایل» در Nobat724 قابلاستفاده باشد:
+
+```
+GET /api/v1/doctors?owner_status=unclaimed&search=&city_id=&specialty_id=
+ → 200 { success, data: [...], meta }
+```
+
+### ۶.۳ درخواست تصاحب (Claim) — عمومی و احراز هویتشده
+
+```
+POST /api/v1/doctor/{uuid}/claim [IS_AUTHENTICATED_FULLY]
+ body: { national_code, medical_system_code, activity_time? }
+ قواعد:
+ - پروفایل باید owner_status = 'unclaimed' باشد، وگرنه 409.
+ - medical_system_code ورودی باید با رکورد پزشک مطابقت کند، وگرنه 422.
+ - کاربر لاگینشده (که موبایلش قبلاً با OTP تأیید شده) نباید از قبل پزشکِ دیگری داشته باشد.
+ - رکورد به pending_transfer میرود و یک ClaimRequest ثبت میشود.
+ → 202 { success, data: { claim_id, status: 'pending_transfer' } }
+```
+
+### ۶.۴ تأیید/رد توسط ادمین و نهاییسازی انتقال
+
+```
+GET /api/v1/admin/doctor-claims?status=pending [ROLE_ADMIN]
+POST /api/v1/admin/doctor-claims/{claimId}/approve [ROLE_ADMIN]
+POST /api/v1/admin/doctor-claims/{claimId}/reject [ROLE_ADMIN] { reason }
+```
+
+هنگام approve، عملیات انتقال مالکیت (بخش ۷) بهصورت اتمیک اجرا میشود.
+
+> امکان «انتقال خودکار» (بدون ادمین) نیز قابل تعریف است: اگر `national_code` کاربر تأییدشده باشد و `medical_system_code` و نام کاملاً منطبق باشند، سیستم میتواند مستقیماً claim را تأیید کند. تصمیم پیشفرض این سند: **تأیید ادمین اجباری برای فاز اول** (بخش ۱۳).
+
+---
+
+## ۷. بخش دوم — مکانیزم انتقال مالکیت
+
+جریان کامل تصاحب پروفایل توسط پزشک واقعی:
+
+1. **کشف:** پزشک در Nobat724 نام خود را میبیند (پروفایل `unclaimed`) و روی «این پروفایل من است» کلیک میکند.
+2. **احراز هویت پایه:** اگر لاگین نیست، با موبایل + OTP ثبتنام/ورود میکند. در این مرحله یک `User` واقعی با موبایل واقعی ساخته میشود (مسیر عادی auth موجود).
+3. **تطبیق هویت:** فرم تصاحب، `national_code` و `medical_system_code` را میگیرد و با رکورد پزشک تطبیق میدهد (`POST .../claim`). پروفایل به `pending_transfer` میرود.
+4. **بازبینی:** ادمین در پنل، درخواست را با `profile_url` سازمان نظام پزشکی بازبینی و approve/reject میکند.
+5. **نهاییسازی انتقال (اتمیک):**
+ - `Doctor.user` = کاربر واقعی پزشک (پر شدن ستونی که تا الان null بود).
+ - `Doctor.managed_by` = `null`؛ `owner_status = 'claimed'`؛ `claimed_at = time()`.
+ - افزودن `ROLE_DOCTOR` به کاربر واقعی (همان منطق موجود در `DoctorController::create`).
+ - از این پس ویرایش پروفایل توسط خود پزشک از طریق `PATCH /api/v1/doctor/{uuid}` مجاز است (چک مالکیت فعلی `getUser()->getId() === user` اکنون درست کار میکند).
+6. **اطلاعرسانی:** پیامک/نوتیف تأیید به پزشک (همسبک `Sms` موجود).
+
+قواعد یکتایی هنگام انتقال: چون یک کاربر واقعی نباید صاحب دو پزشک شود، پیش از اتصال باید بررسی شود که `user_id` مقصد در جدول `doctors` تکراری نشود (تضمین در سطح اپلیکیشن، جایگزین قید یکتای حذفشده).
+
+---
+
+## ۸. اتصال با Nobat724 (`nobat724_front`)
+
+مصرفکنندهی API از طریق `services/response.js` است (که هماکنون `getDoctors`, `postDoctor`, ... را دارد). تغییرات لازم:
+
+- **نمایش پزشکان بدونمالک:** فهرست فعلی پزشکان (`api/v1/doctors`) بهطور خودکار پزشکان `unclaimed` را هم شامل میشود؛ در کارت پزشک، بهجای دکمهی «رزرو نوبت»، دکمهی «این پروفایل من است / تکمیل پروفایل» نمایش داده میشود چون `active=false` است.
+- **صفحه/فرم تصاحب:** فراخوانی `POST api/v1/doctor/{uuid}/claim` پس از ورود با OTP. نگهداری `access_token`/`refresh_token` در کوکی (مطابق الگوی فعلی Nobat724).
+- **زبان/تقویم:** تمام رشتههای جدید فارسی و تاریخها جلالی (شمسی) بمانند.
+- رعایت قرارداد: تغییر قالب پاسخ در بکاند باید در `nobat724_front/services/response.js` هم منعکس شود، چون در زمان build خطا نمیدهد.
+
+---
+
+## ۹. اثر بر `clinic-pro-tauri`
+
+اپ دسکتاپ نیز کلاینت همان API است (`src/service/response.js`) و از CASL برای نقشها استفاده میکند (`clinic`, `doctor`, `clinic_doctor`, `secretary`).
+
+- اگر لیست پزشکان در اپ نمایش داده میشود، باید فیلد `owner_status` و رفتار `active=false` را مدیریت کند (پزشک بدونمالک قابل رزرو آنلاین نیست).
+- مرز sync آفلاین/آنلاین این اپ هنوز کامل نگاشت نشده؛ پیش از فرض همترازی، رفتار `owner_status` در دیتابیس محلی SQLite باید بررسی شود (طبق هشدار `AGENTS.md`).
+- برای فاز اول، تغییر در Tauri **اختیاری** است؛ فقط در صورتی که این اپ پزشکان `unclaimed` را نشان دهد لازم میشود.
+
+---
+
+## ۱۰. جریان کاربری (خلاصهی گامبهگام)
+
+```
+[نظام پزشکی JSON] → دستور ایمپورت → پزشکِ unclaimed (managed_by = System Owner)
+ │
+ ▼
+ نمایش در Nobat724 (active=false، بدون نوبت آنلاین)
+ │ پزشک واقعی: «این پروفایل من است»
+ ▼
+ ورود با موبایل + OTP → ساخت User واقعی
+ │
+ ▼
+ فرم تصاحب (کد ملی + کد نظام پزشکی) → POST /claim → pending_transfer
+ │
+ ▼
+ بازبینی ادمین (approve) → انتقال اتمیک:
+ user_id=واقعی، owner_status=claimed، +ROLE_DOCTOR
+ │
+ ▼
+ پزشک پروفایل و برنامهی کاری را کامل میکند → active=true → نوبتدهی آنلاین فعال
+```
+
+---
+
+## ۱۱. حالات مرزی و قواعد کسبوکار
+
+- **درخواست تصاحب همزمان دو نفر برای یک پروفایل:** فقط اولین `pending_transfer` پذیرفته میشود؛ بقیه با ۴۰۹ رد میشوند تا تعیین تکلیف قبلی روشن شود.
+- **کاربری که قبلاً پزشک دارد:** نمیتواند پروفایل دوم را تصاحب کند (قید یکتای منطقی `user_id`).
+- **عدم تطابق کد نظام پزشکی:** رد با ۴۲۲ و بدون تغییر وضعیت.
+- **رد توسط ادمین:** پروفایل به `unclaimed` بازمیگردد و برای تصاحب مجدد آزاد میشود.
+- **حذف پزشک بدونمالک:** مجاز برای ادمین (مسیر فعلی `DELETE`); اما پزشکِ `claimed` طبق رفتار فعلی محافظت میشود.
+- **ایمپورت مجدد یک پزشکِ از قبل claimed:** فیلدهای هویتی بهروز نمیشوند (مالک واقعی اولویت دارد)؛ فقط در گزارش «skipped/claimed» ثبت میشود.
+- **نوبتدهی:** تا زمانی که پروفایل `unclaimed` است، `active_doctor_appointment=false` و برنامهی کاری وجود ندارد؛ لذا در `toListArray` مقدار `active=false` میشود و رزرو ممکن نیست.
+
+---
+
+## ۱۲. مراحل پیادهسازی (بهترتیب و بهتفکیک ریپو)
+
+مطابق `CLAUDE.md`: ابتدا بکاند `clinicpro`، سپس مستندسازی API، سپس کلاینتها.
+
+**الف) `clinicpro` (بکاند):**
+1. افزودن فیلدهای `managed_by`, `owner_status`, `source`, `source_ref`, `claimed_at` و nullable کردن `user_id` در `Doctor` + migration در `migrations/`.
+2. پاکسازی داده و افزودن ایندکس یکتای `(source, medical_system_code)`.
+3. دستور کنسول `app:doctors:import-irimc` (با `--dry-run`، گزارش، تراکنش).
+4. دستور/سیدر ساخت کاربر «مالک سیستمی».
+5. موجودیت/جدول `DoctorClaim` + اندپوینتهای claim و approve/reject.
+6. توسعهی فیلتر `owner_status` در `GET /api/v1/doctors` و بهروزرسانی چکهای مالکیت.
+7. بهروزرسانی `docs/api/*` (طبق قانون استاندارد پروژه) و افزودن این سند به مستندات.
+
+**ب) `nobat724_front`:**
+8. همترازی `services/response.js` با قالبهای جدید.
+9. دکمهی «این پروفایل من است» روی کارت پزشکِ `unclaimed` + صفحهی فرم تصاحب (فارسی، جلالی، RTL).
+
+**ج) `clinic-pro-tauri` (در صورت نیاز):**
+10. مدیریت `owner_status`/`active=false` در لیست پزشکان و بررسی مرز sync محلی.
+
+**د) بازبینی نهایی:**
+11. تست ایمپورت روی نمونهی ۱۶۰ رکورد، تست جریان claim سرتاسری، و بازسازی کاربران تست (`ddev exec php create_test_users.php`).
+
+---
+
+## ۱۳. تصمیمات باز و ریسکها
+
+- **گزینه A در برابر B:** این سند گزینه A (nullable کردن `user_id` + `managed_by`) را توصیه میکند چون جدول `users` را با کاربران جعلی آلوده نمیکند و مدل مالکیت را صریح میسازد. هزینهاش: از دست رفتن قید یکتای دیتابیسی روی `user_id` و انتقال آن به سطح اپلیکیشن.
+- **تأیید ادمین در برابر انتقال خودکار:** پیشفرض فاز اول تأیید دستی ادمین است (امنتر برای هویت پزشک). خودکارسازی بعداً با اتکا به تأیید کد ملی افزوده میشود.
+- **کیفیت دادهی نظام پزشکی:** برخی رکوردها ممکن است فاقد `specialty_id`/`city_id` معتبر باشند؛ گزارش ایمپورت باید اینها را شفاف کند.
+- **حریم خصوصی:** نمایش عمومی نام و کد نظام پزشکی پیش از رضایت پزشک، ملاحظهی حقوقی دارد و باید با سیاست پلتفرم بررسی شود.
+- **یکتایی موبایل مالک سیستمی:** مقدار رزروشده باید تضمیناً هرگز با موبایل واقعی کاربر تداخل نکند.
+
+---
+
+## ۱۴. مرجع نمونهی داده
+
+نمونهی یک رکورد ورودی از `doctors.json` (۱۶۰ رکورد، همگی `mobileNumber: null`):
+
+```json
+{
+ "name": "دکتر فرخنده حسینی",
+ "gender": "woman",
+ "medicalSystemCode": "145657",
+ "mobileNumber": null,
+ "degree": "general",
+ "info": "دکترای حرفهای پزشکی",
+ "specialty_id": 1,
+ "specialty_name": "پزشک عمومی",
+ "state_id": 23,
+ "state_name": "کهگیلویه و بویراحمد",
+ "city_id": 123,
+ "city_name": "یاسوج",
+ "profile_url": "https://membersearch.irimc.org/member/profile?id=02058131-...",
+ "source_url": "https://membersearch.irimc.org"
+}
+```