diff --git a/TEST_USERS.md b/TEST_USERS.md index eb17eedd..d412ee5f 100644 --- a/TEST_USERS.md +++ b/TEST_USERS.md @@ -36,15 +36,15 @@ ## شناسه‌ها -| موجودیت | UUID | -|---|---| -| پزشک `doctor_solo` | `01e2a9b4-72f4-4a48-924c-0f95bb77a994` | -| پزشک `doctor_member` | `439c9935-77bc-4f72-b73d-2432712bb6f5` | -| پزشک `clinic_doctor` | `e3e4c2bf-170a-479c-a385-4af7d57fcbbe` | -| پزشک `doctor` | `c3311b98-86b7-4d8e-8538-1390c36c2a90` | -| پروفایل تصاحب‌نشده | `ded7a65d-d0fa-47e0-bc16-e801c5c75147` | -| کلینیک تست QA | `bcb00726-2343-4d63-90c6-d0175cc74591` | -| کلینیک تست چندنقشی | `e62f69a2-381b-4a6c-9235-7f9c173f3c46` | +| موجودیت | نام | UUID | +|---|---|---| +| پزشک `doctor_solo` | سارا مستقل | `01e2a9b4-72f4-4a48-924c-0f95bb77a994` | +| پزشک `doctor_member` | رضا عضوکلینیک | `439c9935-77bc-4f72-b73d-2432712bb6f5` | +| پزشک `clinic_doctor` | نیما چندنقشی | `e3e4c2bf-170a-479c-a385-4af7d57fcbbe` | +| پزشک `doctor` | کاوه قدیمی | `c3311b98-86b7-4d8e-8538-1390c36c2a90` | +| پروفایل تصاحب‌نشده | تصاحب نشده تست | `ded7a65d-d0fa-47e0-bc16-e801c5c75147` | +| کلینیک تست QA | — | `bcb00726-2343-4d63-90c6-d0175cc74591` | +| کلینیک تست چندنقشی | — | `e62f69a2-381b-4a6c-9235-7f9c173f3c46` | هر سه پزشکِ `doctor_solo` / `doctor_member` / `clinic_doctor` آدرس مطب، تخصص و برنامهٔ هفتگی (شنبه تا چهارشنبه، ۰۹:۰۰–۱۳:۰۰ و ۱۶:۰۰–۱۹:۰۰، اسلات ۲۰ دقیقه‌ای) @@ -99,3 +99,12 @@ purge و بازسازی می‌کند؛ بعد از آن `cities.representation_ - **رمز پس از ریست:** `ddev exec php bin/console security:hash-password 'QaTest@1234'` و هش را در `users.password_hash` بگذار. کاربرانی که از راه `POST /api/v1/admin/doctors` یا `/api/v1/admin/clinic` ساخته می‌شوند رمز نمی‌گیرند. + +## نام پزشک بدون عنوان + +نام ذخیره‌شدهٔ پزشک **هرگز** پیشوند «دکتر» ندارد؛ همهٔ مسیرهای ثبت و ویرایش آن را با +`PersianText::stripDoctorTitle()` حذف می‌کنند. افزودن عنوان کار لایهٔ نمایش است — +`nobat724_front` برای عنوان صفحه و JSON-LD از `doctorTitle()` استفاده می‌کند (idempotent)، +و پنل ادمین اصلاً عنوان اضافه نمی‌کند. + +پاک‌سازی دادهٔ قدیمی: `php bin/console app:doctors:fix-irimc-names --all --dry-run` diff --git a/assets/admin/components/dashboard/NewAppointmentsTable.tsx b/assets/admin/components/dashboard/NewAppointmentsTable.tsx index 547293fb..57cb6224 100644 --- a/assets/admin/components/dashboard/NewAppointmentsTable.tsx +++ b/assets/admin/components/dashboard/NewAppointmentsTable.tsx @@ -88,7 +88,7 @@ export function NewAppointmentsTable({ rows, loading, queryKey, emptyText }: Pro {formatTime(r.slot_start)} {formatTime(r.slot_end)} {r.service_name || '—'} - {r.doctor_name ? `دکتر ${r.doctor_name}` : '—'} + {r.doctor_name || '—'} {queryKey && r.version != null ? diff --git a/assets/admin/pages/AppointmentDetailPage.tsx b/assets/admin/pages/AppointmentDetailPage.tsx index a6030054..ae359e83 100644 --- a/assets/admin/pages/AppointmentDetailPage.tsx +++ b/assets/admin/pages/AppointmentDetailPage.tsx @@ -142,7 +142,7 @@ export default function AppointmentDetailPage() {

اطلاعات بیمار

{appt.patient_mobile}} /> - + diff --git a/assets/admin/pages/CommentsPage.tsx b/assets/admin/pages/CommentsPage.tsx index 3f4da498..9b4c75bb 100644 --- a/assets/admin/pages/CommentsPage.tsx +++ b/assets/admin/pages/CommentsPage.tsx @@ -62,7 +62,7 @@ export default function CommentsPage() { header: 'کاربر', render: (c) => {c.patient_name}, }, - { key: 'doctor_name', header: 'پزشک', render: (c) => `دکتر ${c.doctor_name}` }, + { key: 'doctor_name', header: 'پزشک', render: (c) => c.doctor_name }, { key: 'title', header: 'عنوان' }, { key: 'body', diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index 70d82aeb..b320eb63 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -400,7 +400,7 @@ function AdminDashboard() { if (!recent) return []; const evs: { title: string; sub: string; time: string; color: string }[] = []; (recent.appointments ?? []).slice(0, 4).forEach(a => { - evs.push({ title: `نوبت ${APPT_LABEL[a.status] ?? a.status}`, sub: `${a.user_name || a.user_mobile} · دکتر ${a.doctor_name}`, time: a.created_at, color: APPT_COLOR[a.status] ?? '#94a3b8' }); + evs.push({ title: `نوبت ${APPT_LABEL[a.status] ?? a.status}`, sub: `${a.user_name || a.user_mobile} · ${a.doctor_name}`, time: a.created_at, color: APPT_COLOR[a.status] ?? '#94a3b8' }); }); (recent.payments ?? []).slice(0, 3).forEach(p => { evs.push({ title: `پرداخت ${PAY_LABEL[p.status] ?? p.status}`, sub: `${p.user_name || p.user_mobile} · ${formatRial(p.amount)}`, time: p.created_at, color: PAY_COLOR[p.status] ?? '#94a3b8' }); @@ -413,7 +413,7 @@ function AdminDashboard() { const apptRows = useMemo(() => (recent?.appointments ?? []).slice(0, 5).map(a => ({ - title: a.user_name || a.user_mobile, sub: `دکتر ${a.doctor_name}`, meta: formatDateTime(a.slot_start), + title: a.user_name || a.user_mobile, sub: a.doctor_name, meta: formatDateTime(a.slot_start), badgeLabel: APPT_LABEL[a.status] ?? a.status, badgeCls: APPT_CLS[a.status] ?? 'gray', initials: (a.user_name || a.user_mobile).slice(0, 2), hue: 222, })), [recent]); diff --git a/assets/admin/pages/RatingsPage.tsx b/assets/admin/pages/RatingsPage.tsx index 94345d9e..22a28370 100644 --- a/assets/admin/pages/RatingsPage.tsx +++ b/assets/admin/pages/RatingsPage.tsx @@ -52,7 +52,7 @@ export default function RatingsPage() { const columns: Column[] = [ { key: 'patient_name', header: 'بیمار', render: (r) => {r.patient_name} }, - { key: 'doctor_name', header: 'پزشک', render: (r) => `دکتر ${r.doctor_name}` }, + { key: 'doctor_name', header: 'پزشک', render: (r) => r.doctor_name }, { key: 'overall', header: 'کلی', render: (r) => }, { key: 'ratings_detail', @@ -111,7 +111,7 @@ export default function RatingsPage() { [] = [ { key: 'user_name', header: 'نام', render: (s) => {s.user_name} }, { key: 'mobile_number', header: 'موبایل', render: (s) => {maskMobile(s.mobile_number)} }, - { key: 'doctor_name', header: 'پزشک', render: (s) => `دکتر ${s.doctor_name}` }, + { key: 'doctor_name', header: 'پزشک', render: (s) => s.doctor_name }, { key: 'is_active', header: 'وضعیت', render: (s) => }, { key: 'created_at', header: 'تاریخ ثبت', render: (s) => formatDate(s.created_at) }, ]; diff --git a/docs/api/admin.md b/docs/api/admin.md index 20ad16c6..c431b3e3 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -356,7 +356,7 @@ List all doctors with pagination. "data": [ { "uuid": "...", - "title": "دکتر علی احمدی", + "title": "علی احمدی", "degree": "متخصص", "gender": "male", "doctor_rate": 4.5, @@ -565,7 +565,7 @@ List appointments filtered by date and/or doctor. Sorted by `slot_start ASC`. "patient_name": "محمد رضایی", "patient_mobile": "09123456789", "doctor_uuid": "doctor-uuid", - "doctor_name": "دکتر علی احمدی", + "doctor_name": "علی احمدی", "slot_start": 1718438400, "slot_end": 1718439600, "appointment_date": "2025-06-15", @@ -764,7 +764,7 @@ List all settlement requests. "data": [ { "uuid": "...", - "user": { "uuid": "...", "real_name": "دکتر علی احمدی" }, + "user": { "uuid": "...", "real_name": "علی احمدی" }, "amount_rials": 1000000, "status": "pending", "bank_account": { "bank_name": "بانک ملت", "owner_name": "..." }, @@ -845,7 +845,7 @@ Paginated. Each item: { "uuid": "...", "id": 45, - "name": "دکتر علی احمدی", + "name": "علی احمدی", "gender": "man", "medical_code": "12345", "is_active": true, @@ -912,7 +912,7 @@ Paginated. Each item: "status": "confirmed", "patient_name": "علی رضایی", "doctor_uuid": "...", - "doctor_name": "دکتر علی احمدی" + "doctor_name": "علی احمدی" } ``` > `slot_start`/`slot_end` Unix timestamp (ثانیه). @@ -1160,7 +1160,7 @@ List pre-registration requests. **Permission:** `ROLE_ADMIN` { "uuid": "...", "type": "independent_doctor", - "name": "دکتر احمدی", + "name": "احمدی", "mobile": "09121234567", "info": "متخصص داخلی", "status": "pending", diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 39eacab5..3c4adc84 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -236,7 +236,7 @@ Book an appointment slot. "success": true, "data": { "uuid": "appt-uuid-...", - "doctor": { "uuid": "...", "name": "دکتر علی احمدی" }, + "doctor": { "uuid": "...", "name": "علی احمدی" }, "user": { "uuid": "...", "mobile": "..." }, "slot_start": 1718438400, "slot_end": 1718439600, @@ -321,7 +321,7 @@ Get appointment detail. "uuid": "appt-uuid-...", "doctor": { "uuid": "...", - "name": "دکتر علی احمدی", + "name": "علی احمدی", "specialties": [ { "uuid": "...", "name": "اورولوژی عمومی" } ] @@ -435,7 +435,7 @@ Get all appointments for the authenticated user. "data": [ { "uuid": "...", - "doctor": { "uuid": "...", "title": "دکتر علی احمدی" }, + "doctor": { "uuid": "...", "title": "علی احمدی" }, "slot_start": 1718438400, "slot_end": 1718439600, "status": "confirmed", diff --git a/docs/api/auth.md b/docs/api/auth.md index fbc408cb..d76293cf 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -580,7 +580,7 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut ```json { "type": "independent_doctor", - "name": "دکتر علی احمدی", + "name": "علی احمدی", "mobile": "09121234567", "info": "متخصص داخلی، ۱۰ سال سابقه" } diff --git a/docs/api/billing.md b/docs/api/billing.md index 40c6fa14..b598ebe6 100644 --- a/docs/api/billing.md +++ b/docs/api/billing.md @@ -252,7 +252,7 @@ "uuid": "b244b506-...", "invoice_uuid": "15498c14-...", "visit_date": 1784440200, - "doctor_name": "دکتر تست", + "doctor_name": "تست", "insurance_id": 176, "insurance_name": "تامین اجتماعی", "insurance_kind": "base", diff --git a/docs/api/blog.md b/docs/api/blog.md index 19644b9a..8d1c193d 100644 --- a/docs/api/blog.md +++ b/docs/api/blog.md @@ -29,7 +29,7 @@ List published blog posts. "slug": "ashnayi-ba-bimari-diabat", "summary": "خلاصه مطلب...", "image": "https://...", - "author": { "uuid": "...", "real_name": "دکتر احمدی" }, + "author": { "uuid": "...", "real_name": "احمدی" }, "tags": [{ "id": 1, "name": "دیابت" }], "status": "published", "city": { "id": "123", "name": "یاسوج" }, @@ -84,7 +84,7 @@ Get a single blog post by slug. "summary": "خلاصه...", "body": "

محتوای کامل...

", "image": "https://...", - "author": { "uuid": "...", "real_name": "دکتر احمدی" }, + "author": { "uuid": "...", "real_name": "احمدی" }, "tags": [{ "id": 1, "name": "دیابت" }], "status": "published", "created_at": 1717000000, diff --git a/docs/api/clinic-invitation.md b/docs/api/clinic-invitation.md index 1646967a..aac155e3 100644 --- a/docs/api/clinic-invitation.md +++ b/docs/api/clinic-invitation.md @@ -34,7 +34,7 @@ Send an invitation to a doctor (by mobile number) to join a clinic. ```json { "mobile": "09123456789", - "name": "دکتر علی احمدی", + "name": "علی احمدی", "specialty": "قلب و عروق" } ``` @@ -58,7 +58,7 @@ Send an invitation to a doctor (by mobile number) to join a clinic. "invited_at": 1717000000, "expires_at": 1717259200, "token_used": false, - "doctor": { "uuid": "...", "name": "دکتر علی احمدی" }, + "doctor": { "uuid": "...", "name": "علی احمدی" }, "clinic": { "uuid": "...", "name": "کلینیک الوند" } } } diff --git a/docs/api/clinic.md b/docs/api/clinic.md index 9ff6d638..a9e0ced3 100644 --- a/docs/api/clinic.md +++ b/docs/api/clinic.md @@ -8,7 +8,18 @@ Create a new clinic. -**Permission:** `AUTH` — any authenticated user becomes the clinic owner +**Permission:** `AUTH` — any authenticated user becomes the clinic owner, and is +granted `ROLE_CLINIC` on success. + +**One clinic per user.** `ClinicRepository::findByUser()` (which resolves the +caller's working context) is a `findOneBy`, so a second clinic would be +unreachable data — the request is rejected instead. + +**Errors:** +| Code | HTTP | توضیح | +|------|------|-------| +| ERR_VALIDATION_001 | 422 | `name` خالی است (field: `name`) | +| ERR_CONFLICT_001 | 409 | این کاربر از قبل مالک یک کلینیک است | ### Request Body (`application/json`) ```json @@ -295,7 +306,7 @@ Get doctors associated with a clinic. { "id": "1207", "uuid": "...", - "name": "دکتر آرمان رضایی", + "name": "آرمان رضایی", "gender": "man", "degree": "specialist", "img": [], @@ -401,7 +412,7 @@ List the permission rows of every doctor in the clinic. "uuid": "ce200cde-826d-11f1-b923-c282b864cdcc", "clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09", "doctor_uuid": "bcabb3a8-cae3-45ec-876c-548f9c1e1569", - "doctor_name": "دکتر تست", + "doctor_name": "تست", "active": true, "permissions": { "version": 1, "resources": { "...": {} } }, "created_at": 1784352916, diff --git a/docs/api/doctor-claim.md b/docs/api/doctor-claim.md index fcd6a383..e96417ac 100644 --- a/docs/api/doctor-claim.md +++ b/docs/api/doctor-claim.md @@ -82,7 +82,7 @@ unclaimed ──claim/transfer شروع──▶ pending_transfer ──موفق "data": { "status": "claimed", "claim": { "uuid": "…" }, - "doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" } + "doctor": { "uuid": "…", "name": "فرخنده حسینی" } } } ``` @@ -127,7 +127,7 @@ unclaimed ──claim/transfer شروع──▶ pending_transfer ──موفق { "uuid": "…", "status": "completed", - "doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" }, + "doctor": { "uuid": "…", "name": "فرخنده حسینی" }, "mobile_masked": "0912***4567", "verification_method": "apiir_personinfo+shahkar", "failure_reason": null, diff --git a/docs/api/doctor-import.md b/docs/api/doctor-import.md index 085c7c2a..ff1c86ce 100644 --- a/docs/api/doctor-import.md +++ b/docs/api/doctor-import.md @@ -50,7 +50,7 @@ ```json { - "name": "دکتر فرخنده حسینی", + "name": "فرخنده حسینی", "medical_system_code": "145657", "source": "irimc", "source_ref": "https://membersearch.irimc.org/member/profile?id=…", diff --git a/docs/api/doctor.md b/docs/api/doctor.md index f3c2fcd0..deb2596b 100644 --- a/docs/api/doctor.md +++ b/docs/api/doctor.md @@ -24,7 +24,7 @@ Create a doctor profile for the authenticated user. ### Request Body (`application/json`) ```json { - "title": "دکتر علی احمدی", + "title": "علی احمدی", "gender": "male", "medical_system_code": "12345", "degree": "متخصص", @@ -36,7 +36,7 @@ Create a doctor profile for the authenticated user. | Field | Type | Required | Description | |-------|------|----------|-------------| -| `title` | string | ✅ | Full name with title | +| `title` | string | ✅ | نام پزشک **بدون** عنوان. پیشوند «دکتر» سمت سرور با `PersianText::stripDoctorTitle()` حذف می‌شود؛ نمایش عنوان کار لایهٔ UI است. | | `gender` | string | ❌ | `"male"` or `"female"` | | `medical_system_code` | string | ❌ | Nظام پزشکی code | | `degree` | string | ❌ | Academic degree | @@ -51,7 +51,7 @@ Create a doctor profile for the authenticated user. "success": true, "data": { "uuid": "550e8400-e29b-41d4-a716-446655440000", - "title": "دکتر علی احمدی", + "title": "علی احمدی", "gender": "male", "medical_system_code": "12345", "degree": "متخصص", @@ -93,7 +93,7 @@ Get doctor detail with clinics. "data": { "data": { "uuid": "550e8400-...", - "name": "دکتر علی احمدی", + "name": "علی احمدی", "gender": "man", "medical_system_code": "12345", "degree": "specialist", @@ -153,7 +153,7 @@ Get doctor detail for clinic owner — only doctors who are members of the authe "data": { "data": { "uuid": "...", - "title": "دکتر علی احمدی", + "title": "علی احمدی", "specialties": [...], "clinics": [{ "uuid": "...", "name": "کلینیک نور", "address": "...", "telephone": "..." }] } @@ -219,7 +219,7 @@ List doctors with pagination and filters. "data": [ { "uuid": "...", - "name": "دکتر علی احمدی", + "name": "علی احمدی", "gender": "man", "degree": "specialist", "img": [], diff --git a/docs/api/patient.md b/docs/api/patient.md index afabfd90..c15f436d 100644 --- a/docs/api/patient.md +++ b/docs/api/patient.md @@ -437,7 +437,7 @@ GET /api/v1/patient/{uuid}/appointments "ends_at": 1754001800, "status": "confirmed", "version": 1, - "doctor_name": "دکتر ژیلا فتحی", + "doctor_name": "ژیلا فتحی", "service_name": null, "price_rials": null, "created_at": 1754000000 diff --git a/docs/api/rating.md b/docs/api/rating.md index f474767c..504ca3cb 100644 --- a/docs/api/rating.md +++ b/docs/api/rating.md @@ -271,7 +271,7 @@ Get all pending comments waiting for review. { "uuid": "...", "body": "...", - "doctor": { "uuid": "...", "title": "دکتر علی احمدی" }, + "doctor": { "uuid": "...", "title": "علی احمدی" }, "user": { "uuid": "...", "real_name": "..." }, "status": "pending", "created_at": 1717000000 diff --git a/docs/api/representation.md b/docs/api/representation.md index c66e754a..55ddd9bd 100644 --- a/docs/api/representation.md +++ b/docs/api/representation.md @@ -380,7 +380,7 @@ Get yearly earnings dashboard for a representation. #### Request Body ```json -{ "mobile": "0935...", "name": "دکتر ...", "gender": "man", "degree": "...", "medical_system_code": "...", "specialties": [1,2] } +{ "mobile": "0935...", "name": "...", "gender": "man", "degree": "...", "medical_system_code": "...", "specialties": [1,2] } ``` | Field | Type | Required | |-------|------|----------| @@ -452,7 +452,7 @@ Get yearly earnings dashboard for a representation. "patient_name": "...", "patient_mobile": "0912...", "doctor_uuid": "...", - "doctor_name": "دکتر ...", + "doctor_name": "...", "slot_start": 1718000000, "slot_end": 1718001800, "appointment_date": "2025-06-15", @@ -491,7 +491,7 @@ Get yearly earnings dashboard for a representation. "success": true, "data": [ { - "uuid": "...", "id": 12, "name": "دکتر ...", "gender": "man", "degree": "...", + "uuid": "...", "id": 12, "name": "...", "gender": "man", "degree": "...", "medical_code": "...", "mobile": "0912...", "email": null, "is_active": true, "rate": 3.5, "specialties": [], "profile_image": null, "created_at": "2026-06-18T..." @@ -621,7 +621,7 @@ Get yearly earnings dashboard for a representation. "success": true, "data": [ { - "uuid": "...", "name": "دکتر ...", + "uuid": "...", "name": "...", "appointments": { "today": 0, "week": 1, "month": 4, "total": 18 }, "representation_income_rials": 363636, "subscription_status": "active" @@ -642,7 +642,7 @@ Get yearly earnings dashboard for a representation. "success": true, "data": [ { - "uuid": "...", "appointment_uuid": "...", "doctor_name": "دکتر ...", + "uuid": "...", "appointment_uuid": "...", "doctor_name": "...", "gross_rials": 2000000, "tax_rials": 45455, "sms_fee_rials": 1500000, "commission_percent": 20, "representation_share_rials": 90909, "created_at": "2026-06-24T..." diff --git a/docs/api/secretary.md b/docs/api/secretary.md index b6f0aa43..50c8352c 100644 --- a/docs/api/secretary.md +++ b/docs/api/secretary.md @@ -160,7 +160,7 @@ Create a secretary for a doctor. "uuid": "sec-uuid-...", "user_name": "علی محمدی", "mobile_number": "09123456789", - "doctor_name": "دکتر احمد رضایی", + "doctor_name": "احمد رضایی", "doctor_uuid": "...", "owner_type": "doctor", "clinic_uuid": null, @@ -215,7 +215,7 @@ Get secretary detail. "mobile_number": "09123456789", "active": true, "permissions": { ... }, - "doctor": { "uuid": "...", "title": "دکتر علی احمدی" }, + "doctor": { "uuid": "...", "title": "علی احمدی" }, "created_at": 1717000000 } } @@ -325,7 +325,7 @@ Get all secretaries for a specific doctor. "uuid": "...", "user_name": "علی محمدی", "mobile_number": "09...", - "doctor_name": "دکتر احمد رضایی", + "doctor_name": "احمد رضایی", "doctor_uuid": "...", "is_active": true, "permissions": { ... }, @@ -368,7 +368,7 @@ Get all secretaries across **all doctors** of a clinic. "secretary_uuid": "...", "user_name": "علی محمدی", "mobile_number": "09...", - "doctor_name": "دکتر احمد رضایی", + "doctor_name": "احمد رضایی", "doctor_uuid": "...", "is_active": true, "permissions": { ... }, diff --git a/docs/api/sms.md b/docs/api/sms.md index b0f44140..707db336 100644 --- a/docs/api/sms.md +++ b/docs/api/sms.md @@ -62,7 +62,7 @@ Send an SMS using an approved template. "mobile": "09123456789", "template_uuid": "tmpl-uuid-...", "vars": { - "name": "دکتر علی احمدی", + "name": "علی احمدی", "date": "۱۵ خرداد ۱۴۰۴" }, "provider": "kavenegar" diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index 7a27b5a3..f717ef6b 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -30,6 +30,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +use App\Shared\Util\PersianText; #[OA\Tag(name: 'Admin')] #[IsGranted('ROLE_ADMIN')] @@ -457,7 +458,7 @@ class AdminApiController extends BaseController { $data = json_decode($request->getContent(), true) ?? []; $mobile = InputValidator::toEnglishDigits(trim((string) ($data['mobile'] ?? ''))); - $name = trim((string) ($data['name'] ?? '')); + $name = PersianText::stripDoctorTitle((string) ($data['name'] ?? '')); if ($mobile === '' || $name === '') { return $this->error(ErrorCodes::VALIDATION, 'موبایل و نام الزامی هستند', 422); diff --git a/src/Clinic/Controller/ClinicController.php b/src/Clinic/Controller/ClinicController.php index 3b9b9fd0..4f785f0c 100644 --- a/src/Clinic/Controller/ClinicController.php +++ b/src/Clinic/Controller/ClinicController.php @@ -99,6 +99,19 @@ class ClinicController extends BaseController if (($err = $this->validateGallerySize($data)) !== null) { return $err; } + + // بدون نام، کلینیکِ بی‌هویت ساخته می‌شد که در هیچ لیستی قابل‌تشخیص نیست. + if (trim((string) ($data['name'] ?? '')) === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام کلینیک الزامی است', 422, 'name'); + } + + // یک کلینیک به ازای هر کاربر: ClinicRepository::findByUser() — که محیط کاری + // کاربر از آن حل می‌شود — findOneBy است، پس کلینیک دوم به بعد هرگز انتخاب + // نمی‌شود و به دادهٔ یتیم تبدیل می‌شود. + if ($this->clinicRepo->findByUser($user) !== null) { + return $this->error(ErrorCodes::ERR_CONFLICT_001, 'برای این کاربر کلینیک ثبت شده است', 409); + } + $clinic = new Clinic($user); $this->hydrateClinic($clinic, $data); $this->clinicRepo->save($clinic); diff --git a/src/Doctor/Command/FixIrimcNamesCommand.php b/src/Doctor/Command/FixIrimcNamesCommand.php index 7a8ef8d5..5f6774fc 100644 --- a/src/Doctor/Command/FixIrimcNamesCommand.php +++ b/src/Doctor/Command/FixIrimcNamesCommand.php @@ -13,11 +13,14 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; /** - * اصلاح یک‌بارمصرف نامِ پزشکانِ ایمپورت‌شدهٔ IRIMC که با پیشوند «دکتر» ذخیره شده‌اند. - * فقط source='irimc' را دست می‌زند؛ پزشکان manual/seed را تغییر نمی‌دهد. + * حذف پیشوند «دکتر» از نام پزشکانی که با عنوان ذخیره شده‌اند. نام پزشک هرگز نباید + * عنوان داشته باشد؛ لایهٔ نمایش خودش تصمیم می‌گیرد چطور نشانش دهد. + * + * پیش‌فرض فقط source='irimc' است. `--all` هر منبعی (seed/manual) را هم پاک می‌کند — + * لازم است چون مسیرهای ثبت‌نام تا پیش از این عنوان را حذف نمی‌کردند. * * php bin/console app:doctors:fix-irimc-names --dry-run - * php bin/console app:doctors:fix-irimc-names + * php bin/console app:doctors:fix-irimc-names --all */ #[AsCommand( name: 'app:doctors:fix-irimc-names', @@ -33,19 +36,21 @@ class FixIrimcNamesCommand extends Command protected function configure(): void { $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing'); + $this->addOption('all', null, InputOption::VALUE_NONE, 'Every doctor, not just source=irimc'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $dryRun = (bool) $input->getOption('dry-run'); + $all = (bool) $input->getOption('all'); + $qb = $this->em->getRepository(Doctor::class)->createQueryBuilder('d'); + if (!$all) { + $qb->where('d.source = :src')->setParameter('src', 'irimc'); + } /** @var Doctor[] $doctors */ - $doctors = $this->em->getRepository(Doctor::class)->createQueryBuilder('d') - ->where('d.source = :src') - ->setParameter('src', 'irimc') - ->getQuery() - ->getResult(); + $doctors = $qb->getQuery()->getResult(); $fixed = 0; foreach ($doctors as $doctor) { @@ -63,7 +68,13 @@ class FixIrimcNamesCommand extends Command $this->em->flush(); } - $io->success(sprintf('%d نام %s (از %d پزشک IRIMC).', $fixed, $dryRun ? 'قابل اصلاح' : 'اصلاح شد', count($doctors))); + $io->success(sprintf( + '%d نام %s (از %d پزشک %s).', + $fixed, + $dryRun ? 'قابل اصلاح' : 'اصلاح شد', + count($doctors), + $all ? 'بررسی‌شده' : 'IRIMC', + )); return Command::SUCCESS; } diff --git a/src/Doctor/Controller/DoctorController.php b/src/Doctor/Controller/DoctorController.php index 3a67099b..712496fe 100644 --- a/src/Doctor/Controller/DoctorController.php +++ b/src/Doctor/Controller/DoctorController.php @@ -26,6 +26,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +use App\Shared\Util\PersianText; #[OA\Tag(name: 'Doctors')] class DoctorController extends BaseController @@ -105,7 +106,7 @@ class DoctorController extends BaseController } $data = json_decode($request->getContent(), true) ?? []; - $name = trim($data['title'] ?? $data['name'] ?? ''); + $name = PersianText::stripDoctorTitle($data['title'] ?? $data['name'] ?? ''); if ($name === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام دکتر الزامی است', 422, 'title'); @@ -350,7 +351,7 @@ class DoctorController extends BaseController } $data = json_decode($request->getContent(), true) ?? []; - if (!empty($data['title'])) $doctor->setName($data['title']); + if (!empty($data['title'])) $doctor->setName(PersianText::stripDoctorTitle($data['title'])); $this->hydrateDoctor($doctor, $data); $this->doctorRepo->save($doctor); diff --git a/src/Representation/Controller/RepresentationActionController.php b/src/Representation/Controller/RepresentationActionController.php index b8848efd..fcf11d61 100644 --- a/src/Representation/Controller/RepresentationActionController.php +++ b/src/Representation/Controller/RepresentationActionController.php @@ -19,6 +19,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +use App\Shared\Util\PersianText; /** * اکشن‌های پنل نماینده: افزودن پزشک/کلینیک، نوبت‌های پزشکانِ زیرمجموعه، و پروفایل نماینده‌ی جاری. @@ -258,7 +259,7 @@ class RepresentationActionController extends BaseController { $data = json_decode($request->getContent(), true) ?? []; $mobile = InputValidator::toEnglishDigits(trim((string) ($data['mobile'] ?? ''))); - $name = trim((string) ($data['name'] ?? '')); + $name = PersianText::stripDoctorTitle((string) ($data['name'] ?? '')); if ($mobile === '' || $name === '') { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'موبایل و نام الزامی هستند', 422); diff --git a/src/Shared/Command/SeedDemoDataCommand.php b/src/Shared/Command/SeedDemoDataCommand.php index 84b50e14..3a6c5d70 100644 --- a/src/Shared/Command/SeedDemoDataCommand.php +++ b/src/Shared/Command/SeedDemoDataCommand.php @@ -333,7 +333,7 @@ class SeedDemoDataCommand extends Command $userRows[] = [ 'uuid' => Uuid::v4()->toRfc4122(), 'mobile_number' => sprintf('09126%06d', $i), - 'real_name' => 'دکتر ' . $this->persianName($i), + 'real_name' => $this->persianName($i), 'roles' => json_encode(['ROLE_USER', 'ROLE_DOCTOR']), 'national_code_verified' => 0, 'status' => 1, @@ -359,7 +359,7 @@ class SeedDemoDataCommand extends Command $doctorRows[] = [ 'uuid' => Uuid::v4()->toRfc4122(), 'user_id' => $userId, - 'name' => 'دکتر ' . $this->persianName($i), + 'name' => $this->persianName($i), 'gender' => $i % 3 === 0 ? 'woman' : 'man', 'medical_system_code' => (string) (100000 + $i), 'mobile_number' => sprintf('09126%06d', $i), diff --git a/tests/Clinic/CreateClinicValidationTest.php b/tests/Clinic/CreateClinicValidationTest.php new file mode 100644 index 00000000..aff6c612 --- /dev/null +++ b/tests/Clinic/CreateClinicValidationTest.php @@ -0,0 +1,69 @@ +createUser(['ROLE_USER']); + + $body = $this->authJson('POST', '/api/v1/clinic', $user); + + $this->assertSame(422, $this->responseCode()); + $this->assertFalse($body['success']); + $this->assertSame('name', $body['errors'][0]['field'] ?? null); + } + + public function testBlankNameIsRejected(): void + { + $user = $this->createUser(['ROLE_USER']); + + $this->authJson('POST', '/api/v1/clinic', $user, ['name' => ' ']); + + $this->assertSame(422, $this->responseCode()); + } + + public function testNamedClinicIsCreatedAndGrantsClinicRole(): void + { + $user = $this->createUser(['ROLE_USER']); + + $this->authJson('POST', '/api/v1/clinic', $user, ['name' => 'کلینیک شفا']); + + $this->assertSame(201, $this->responseCode()); + + $this->em->refresh($user); + $this->assertContains('ROLE_CLINIC', $user->getRoles()); + + $clinic = $this->em->getRepository(Clinic::class)->findOneBy(['user' => $user]); + $this->assertNotNull($clinic); + $this->assertSame('کلینیک شفا', $clinic->getName()); + } + + public function testSecondClinicForTheSameUserIsRejected(): void + { + $user = $this->createUser(['ROLE_USER']); + + $this->authJson('POST', '/api/v1/clinic', $user, ['name' => 'کلینیک اول']); + $this->assertSame(201, $this->responseCode()); + + $body = $this->authJson('POST', '/api/v1/clinic', $user, ['name' => 'کلینیک دوم']); + + $this->assertSame(409, $this->responseCode()); + $this->assertSame('ERR_CONFLICT_001', $body['errors'][0]['code'] ?? null); + + $count = $this->em->getRepository(Clinic::class)->count(['user' => $user]); + $this->assertSame(1, $count, 'a user must never end up owning more than one clinic'); + } +} diff --git a/tests/Doctor/DoctorNameTitleTest.php b/tests/Doctor/DoctorNameTitleTest.php new file mode 100644 index 00000000..42be91a3 --- /dev/null +++ b/tests/Doctor/DoctorNameTitleTest.php @@ -0,0 +1,73 @@ +createUser(['ROLE_ADMIN']); + $mobile = $this->freshMobile(); + + $this->authJson('POST', '/api/v1/admin/doctors', $admin, [ + 'mobile' => $mobile, + 'name' => $name, + ]); + + $this->assertSame(201, $this->responseCode()); + + return $this->em->getRepository(Doctor::class) + ->createQueryBuilder('d') + ->join('d.user', 'u')->where('u.mobileNumber = :m')->setParameter('m', $mobile) + ->getQuery()->getOneOrNullResult(); + } + + public function testAdminCreateStripsTheTitle(): void + { + $doctor = $this->createDoctorAs('دکتر حامد حسینی'); + + $this->assertNotNull($doctor); + $this->assertSame('حامد حسینی', $doctor->getName()); + } + + public function testPlainNameIsLeftAlone(): void + { + $doctor = $this->createDoctorAs('حامد حسینی'); + + $this->assertNotNull($doctor); + $this->assertSame('حامد حسینی', $doctor->getName()); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('titleVariants')] + public function testStripDoctorTitle(string $input, string $expected): void + { + $this->assertSame($expected, PersianText::stripDoctorTitle($input)); + } + + public static function titleVariants(): array + { + return [ + 'plain' => ['حامد حسینی', 'حامد حسینی'], + 'single title' => ['دکتر حامد حسینی', 'حامد حسینی'], + 'repeated title' => ['دکتر دکتر حامد حسینی', 'حامد حسینی'], + 'extra whitespace' => [' دکتر حامد حسینی ', 'حامد حسینی'], + 'title inside' => ['حامد دکتر حسینی', 'حامد دکتر حسینی'], + ]; + } +}