Refactor doctor name handling across the application
- Removed the "دکتر" prefix from doctor names in various components and API responses to ensure consistency and clarity. - Updated the AppointmentDetailPage, CommentsPage, DashboardPage, RatingsPage, SecretariesPage, and other relevant files to reflect the changes in doctor name formatting. - Adjusted API documentation to align with the new naming conventions. - Implemented validation to prevent the creation of clinics without a name and restricted users to a single clinic. - Added tests to verify that doctor names are stored without titles and that clinic creation adheres to the new validation rules.
This commit is contained in:
+18
-9
@@ -36,15 +36,15 @@
|
|||||||
|
|
||||||
## شناسهها
|
## شناسهها
|
||||||
|
|
||||||
| موجودیت | UUID |
|
| موجودیت | نام | UUID |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| پزشک `doctor_solo` | `01e2a9b4-72f4-4a48-924c-0f95bb77a994` |
|
| پزشک `doctor_solo` | سارا مستقل | `01e2a9b4-72f4-4a48-924c-0f95bb77a994` |
|
||||||
| پزشک `doctor_member` | `439c9935-77bc-4f72-b73d-2432712bb6f5` |
|
| پزشک `doctor_member` | رضا عضوکلینیک | `439c9935-77bc-4f72-b73d-2432712bb6f5` |
|
||||||
| پزشک `clinic_doctor` | `e3e4c2bf-170a-479c-a385-4af7d57fcbbe` |
|
| پزشک `clinic_doctor` | نیما چندنقشی | `e3e4c2bf-170a-479c-a385-4af7d57fcbbe` |
|
||||||
| پزشک `doctor` | `c3311b98-86b7-4d8e-8538-1390c36c2a90` |
|
| پزشک `doctor` | کاوه قدیمی | `c3311b98-86b7-4d8e-8538-1390c36c2a90` |
|
||||||
| پروفایل تصاحبنشده | `ded7a65d-d0fa-47e0-bc16-e801c5c75147` |
|
| پروفایل تصاحبنشده | تصاحب نشده تست | `ded7a65d-d0fa-47e0-bc16-e801c5c75147` |
|
||||||
| کلینیک تست QA | `bcb00726-2343-4d63-90c6-d0175cc74591` |
|
| کلینیک تست QA | — | `bcb00726-2343-4d63-90c6-d0175cc74591` |
|
||||||
| کلینیک تست چندنقشی | `e62f69a2-381b-4a6c-9235-7f9c173f3c46` |
|
| کلینیک تست چندنقشی | — | `e62f69a2-381b-4a6c-9235-7f9c173f3c46` |
|
||||||
|
|
||||||
هر سه پزشکِ `doctor_solo` / `doctor_member` / `clinic_doctor` آدرس مطب، تخصص و
|
هر سه پزشکِ `doctor_solo` / `doctor_member` / `clinic_doctor` آدرس مطب، تخصص و
|
||||||
برنامهٔ هفتگی (شنبه تا چهارشنبه، ۰۹:۰۰–۱۳:۰۰ و ۱۶:۰۰–۱۹:۰۰، اسلات ۲۰ دقیقهای)
|
برنامهٔ هفتگی (شنبه تا چهارشنبه، ۰۹:۰۰–۱۳:۰۰ و ۱۶:۰۰–۱۹:۰۰، اسلات ۲۰ دقیقهای)
|
||||||
@@ -99,3 +99,12 @@ purge و بازسازی میکند؛ بعد از آن `cities.representation_
|
|||||||
- **رمز پس از ریست:** `ddev exec php bin/console security:hash-password 'QaTest@1234'`
|
- **رمز پس از ریست:** `ddev exec php bin/console security:hash-password 'QaTest@1234'`
|
||||||
و هش را در `users.password_hash` بگذار. کاربرانی که از راه `POST /api/v1/admin/doctors`
|
و هش را در `users.password_hash` بگذار. کاربرانی که از راه `POST /api/v1/admin/doctors`
|
||||||
یا `/api/v1/admin/clinic` ساخته میشوند رمز نمیگیرند.
|
یا `/api/v1/admin/clinic` ساخته میشوند رمز نمیگیرند.
|
||||||
|
|
||||||
|
## نام پزشک بدون عنوان
|
||||||
|
|
||||||
|
نام ذخیرهشدهٔ پزشک **هرگز** پیشوند «دکتر» ندارد؛ همهٔ مسیرهای ثبت و ویرایش آن را با
|
||||||
|
`PersianText::stripDoctorTitle()` حذف میکنند. افزودن عنوان کار لایهٔ نمایش است —
|
||||||
|
`nobat724_front` برای عنوان صفحه و JSON-LD از `doctorTitle()` استفاده میکند (idempotent)،
|
||||||
|
و پنل ادمین اصلاً عنوان اضافه نمیکند.
|
||||||
|
|
||||||
|
پاکسازی دادهٔ قدیمی: `php bin/console app:doctors:fix-irimc-names --all --dry-run`
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function NewAppointmentsTable({ rows, loading, queryKey, emptyText }: Pro
|
|||||||
<Cell><bdi dir="ltr">{formatTime(r.slot_start)}</bdi></Cell>
|
<Cell><bdi dir="ltr">{formatTime(r.slot_start)}</bdi></Cell>
|
||||||
<Cell><bdi dir="ltr">{formatTime(r.slot_end)}</bdi></Cell>
|
<Cell><bdi dir="ltr">{formatTime(r.slot_end)}</bdi></Cell>
|
||||||
<Cell>{r.service_name || '—'}</Cell>
|
<Cell>{r.service_name || '—'}</Cell>
|
||||||
<Cell>{r.doctor_name ? `دکتر ${r.doctor_name}` : '—'}</Cell>
|
<Cell>{r.doctor_name || '—'}</Cell>
|
||||||
<Cell>
|
<Cell>
|
||||||
{queryKey && r.version != null
|
{queryKey && r.version != null
|
||||||
? <AppointmentStatusDropdown uuid={r.uuid} currentStatus={r.status} version={r.version} queryKey={queryKey} />
|
? <AppointmentStatusDropdown uuid={r.uuid} currentStatus={r.status} version={r.version} queryKey={queryKey} />
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export default function AppointmentDetailPage() {
|
|||||||
<h3 className="font-semibold text-gray-800 mb-4">اطلاعات بیمار</h3>
|
<h3 className="font-semibold text-gray-800 mb-4">اطلاعات بیمار</h3>
|
||||||
<InfoRow label="نام بیمار" value={appt.patient_name} />
|
<InfoRow label="نام بیمار" value={appt.patient_name} />
|
||||||
<InfoRow label="موبایل" value={<span dir="ltr">{appt.patient_mobile}</span>} />
|
<InfoRow label="موبایل" value={<span dir="ltr">{appt.patient_mobile}</span>} />
|
||||||
<InfoRow label="پزشک" value={appt.doctor?.name ? `دکتر ${appt.doctor.name}` : null} />
|
<InfoRow label="پزشک" value={appt.doctor?.name ?? null} />
|
||||||
<InfoRow label="تاریخ نوبت" value={formatDate(appt.slot_start)} />
|
<InfoRow label="تاریخ نوبت" value={formatDate(appt.slot_start)} />
|
||||||
<InfoRow label="ساعت شروع" value={timeOf(appt.slot_start)} />
|
<InfoRow label="ساعت شروع" value={timeOf(appt.slot_start)} />
|
||||||
<InfoRow label="ساعت پایان" value={timeOf(appt.slot_end)} />
|
<InfoRow label="ساعت پایان" value={timeOf(appt.slot_end)} />
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function CommentsPage() {
|
|||||||
header: 'کاربر',
|
header: 'کاربر',
|
||||||
render: (c) => <b>{c.patient_name}</b>,
|
render: (c) => <b>{c.patient_name}</b>,
|
||||||
},
|
},
|
||||||
{ key: 'doctor_name', header: 'پزشک', render: (c) => `دکتر ${c.doctor_name}` },
|
{ key: 'doctor_name', header: 'پزشک', render: (c) => c.doctor_name },
|
||||||
{ key: 'title', header: 'عنوان' },
|
{ key: 'title', header: 'عنوان' },
|
||||||
{
|
{
|
||||||
key: 'body',
|
key: 'body',
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ function AdminDashboard() {
|
|||||||
if (!recent) return [];
|
if (!recent) return [];
|
||||||
const evs: { title: string; sub: string; time: string; color: string }[] = [];
|
const evs: { title: string; sub: string; time: string; color: string }[] = [];
|
||||||
(recent.appointments ?? []).slice(0, 4).forEach(a => {
|
(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 => {
|
(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' });
|
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<MiniRow[]>(() =>
|
const apptRows = useMemo<MiniRow[]>(() =>
|
||||||
(recent?.appointments ?? []).slice(0, 5).map(a => ({
|
(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',
|
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,
|
initials: (a.user_name || a.user_mobile).slice(0, 2), hue: 222,
|
||||||
})), [recent]);
|
})), [recent]);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function RatingsPage() {
|
|||||||
|
|
||||||
const columns: Column<Rating>[] = [
|
const columns: Column<Rating>[] = [
|
||||||
{ key: 'patient_name', header: 'بیمار', render: (r) => <b>{r.patient_name}</b> },
|
{ key: 'patient_name', header: 'بیمار', render: (r) => <b>{r.patient_name}</b> },
|
||||||
{ key: 'doctor_name', header: 'پزشک', render: (r) => `دکتر ${r.doctor_name}` },
|
{ key: 'doctor_name', header: 'پزشک', render: (r) => r.doctor_name },
|
||||||
{ key: 'overall', header: 'کلی', render: (r) => <Stars value={r.overall} /> },
|
{ key: 'overall', header: 'کلی', render: (r) => <Stars value={r.overall} /> },
|
||||||
{
|
{
|
||||||
key: 'ratings_detail',
|
key: 'ratings_detail',
|
||||||
@@ -111,7 +111,7 @@ export default function RatingsPage() {
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
title="حذف امتیاز"
|
title="حذف امتیاز"
|
||||||
message={`آیا از حذف امتیاز ${deleteTarget?.patient_name} برای دکتر ${deleteTarget?.doctor_name} اطمینان دارید؟`}
|
message={`آیا از حذف امتیاز ${deleteTarget?.patient_name} برای ${deleteTarget?.doctor_name} اطمینان دارید؟`}
|
||||||
confirmLabel="حذف"
|
confirmLabel="حذف"
|
||||||
danger
|
danger
|
||||||
loading={deleteMutation.isPending}
|
loading={deleteMutation.isPending}
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default function SecretariesPage() {
|
|||||||
const columns: Column<Secretary>[] = [
|
const columns: Column<Secretary>[] = [
|
||||||
{ key: 'user_name', header: 'نام', render: (s) => <b>{s.user_name}</b> },
|
{ key: 'user_name', header: 'نام', render: (s) => <b>{s.user_name}</b> },
|
||||||
{ key: 'mobile_number', header: 'موبایل', render: (s) => <span dir="ltr">{maskMobile(s.mobile_number)}</span> },
|
{ key: 'mobile_number', header: 'موبایل', render: (s) => <span dir="ltr">{maskMobile(s.mobile_number)}</span> },
|
||||||
{ key: 'doctor_name', header: 'پزشک', render: (s) => `دکتر ${s.doctor_name}` },
|
{ key: 'doctor_name', header: 'پزشک', render: (s) => s.doctor_name },
|
||||||
{ key: 'is_active', header: 'وضعیت', render: (s) => <ActiveBadge active={s.is_active} /> },
|
{ key: 'is_active', header: 'وضعیت', render: (s) => <ActiveBadge active={s.is_active} /> },
|
||||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (s) => formatDate(s.created_at) },
|
{ key: 'created_at', header: 'تاریخ ثبت', render: (s) => formatDate(s.created_at) },
|
||||||
];
|
];
|
||||||
|
|||||||
+6
-6
@@ -356,7 +356,7 @@ List all doctors with pagination.
|
|||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"title": "دکتر علی احمدی",
|
"title": "علی احمدی",
|
||||||
"degree": "متخصص",
|
"degree": "متخصص",
|
||||||
"gender": "male",
|
"gender": "male",
|
||||||
"doctor_rate": 4.5,
|
"doctor_rate": 4.5,
|
||||||
@@ -565,7 +565,7 @@ List appointments filtered by date and/or doctor. Sorted by `slot_start ASC`.
|
|||||||
"patient_name": "محمد رضایی",
|
"patient_name": "محمد رضایی",
|
||||||
"patient_mobile": "09123456789",
|
"patient_mobile": "09123456789",
|
||||||
"doctor_uuid": "doctor-uuid",
|
"doctor_uuid": "doctor-uuid",
|
||||||
"doctor_name": "دکتر علی احمدی",
|
"doctor_name": "علی احمدی",
|
||||||
"slot_start": 1718438400,
|
"slot_start": 1718438400,
|
||||||
"slot_end": 1718439600,
|
"slot_end": 1718439600,
|
||||||
"appointment_date": "2025-06-15",
|
"appointment_date": "2025-06-15",
|
||||||
@@ -764,7 +764,7 @@ List all settlement requests.
|
|||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"user": { "uuid": "...", "real_name": "دکتر علی احمدی" },
|
"user": { "uuid": "...", "real_name": "علی احمدی" },
|
||||||
"amount_rials": 1000000,
|
"amount_rials": 1000000,
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"bank_account": { "bank_name": "بانک ملت", "owner_name": "..." },
|
"bank_account": { "bank_name": "بانک ملت", "owner_name": "..." },
|
||||||
@@ -845,7 +845,7 @@ Paginated. Each item:
|
|||||||
{
|
{
|
||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"id": 45,
|
"id": 45,
|
||||||
"name": "دکتر علی احمدی",
|
"name": "علی احمدی",
|
||||||
"gender": "man",
|
"gender": "man",
|
||||||
"medical_code": "12345",
|
"medical_code": "12345",
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
@@ -912,7 +912,7 @@ Paginated. Each item:
|
|||||||
"status": "confirmed",
|
"status": "confirmed",
|
||||||
"patient_name": "علی رضایی",
|
"patient_name": "علی رضایی",
|
||||||
"doctor_uuid": "...",
|
"doctor_uuid": "...",
|
||||||
"doctor_name": "دکتر علی احمدی"
|
"doctor_name": "علی احمدی"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
> `slot_start`/`slot_end` Unix timestamp (ثانیه).
|
> `slot_start`/`slot_end` Unix timestamp (ثانیه).
|
||||||
@@ -1160,7 +1160,7 @@ List pre-registration requests. **Permission:** `ROLE_ADMIN`
|
|||||||
{
|
{
|
||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"type": "independent_doctor",
|
"type": "independent_doctor",
|
||||||
"name": "دکتر احمدی",
|
"name": "احمدی",
|
||||||
"mobile": "09121234567",
|
"mobile": "09121234567",
|
||||||
"info": "متخصص داخلی",
|
"info": "متخصص داخلی",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ Book an appointment slot.
|
|||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
"uuid": "appt-uuid-...",
|
"uuid": "appt-uuid-...",
|
||||||
"doctor": { "uuid": "...", "name": "دکتر علی احمدی" },
|
"doctor": { "uuid": "...", "name": "علی احمدی" },
|
||||||
"user": { "uuid": "...", "mobile": "..." },
|
"user": { "uuid": "...", "mobile": "..." },
|
||||||
"slot_start": 1718438400,
|
"slot_start": 1718438400,
|
||||||
"slot_end": 1718439600,
|
"slot_end": 1718439600,
|
||||||
@@ -321,7 +321,7 @@ Get appointment detail.
|
|||||||
"uuid": "appt-uuid-...",
|
"uuid": "appt-uuid-...",
|
||||||
"doctor": {
|
"doctor": {
|
||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"name": "دکتر علی احمدی",
|
"name": "علی احمدی",
|
||||||
"specialties": [
|
"specialties": [
|
||||||
{ "uuid": "...", "name": "اورولوژی عمومی" }
|
{ "uuid": "...", "name": "اورولوژی عمومی" }
|
||||||
]
|
]
|
||||||
@@ -435,7 +435,7 @@ Get all appointments for the authenticated user.
|
|||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"doctor": { "uuid": "...", "title": "دکتر علی احمدی" },
|
"doctor": { "uuid": "...", "title": "علی احمدی" },
|
||||||
"slot_start": 1718438400,
|
"slot_start": 1718438400,
|
||||||
"slot_end": 1718439600,
|
"slot_end": 1718439600,
|
||||||
"status": "confirmed",
|
"status": "confirmed",
|
||||||
|
|||||||
+1
-1
@@ -580,7 +580,7 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "independent_doctor",
|
"type": "independent_doctor",
|
||||||
"name": "دکتر علی احمدی",
|
"name": "علی احمدی",
|
||||||
"mobile": "09121234567",
|
"mobile": "09121234567",
|
||||||
"info": "متخصص داخلی، ۱۰ سال سابقه"
|
"info": "متخصص داخلی، ۱۰ سال سابقه"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -252,7 +252,7 @@
|
|||||||
"uuid": "b244b506-...",
|
"uuid": "b244b506-...",
|
||||||
"invoice_uuid": "15498c14-...",
|
"invoice_uuid": "15498c14-...",
|
||||||
"visit_date": 1784440200,
|
"visit_date": 1784440200,
|
||||||
"doctor_name": "دکتر تست",
|
"doctor_name": "تست",
|
||||||
"insurance_id": 176,
|
"insurance_id": 176,
|
||||||
"insurance_name": "تامین اجتماعی",
|
"insurance_name": "تامین اجتماعی",
|
||||||
"insurance_kind": "base",
|
"insurance_kind": "base",
|
||||||
|
|||||||
+2
-2
@@ -29,7 +29,7 @@ List published blog posts.
|
|||||||
"slug": "ashnayi-ba-bimari-diabat",
|
"slug": "ashnayi-ba-bimari-diabat",
|
||||||
"summary": "خلاصه مطلب...",
|
"summary": "خلاصه مطلب...",
|
||||||
"image": "https://...",
|
"image": "https://...",
|
||||||
"author": { "uuid": "...", "real_name": "دکتر احمدی" },
|
"author": { "uuid": "...", "real_name": "احمدی" },
|
||||||
"tags": [{ "id": 1, "name": "دیابت" }],
|
"tags": [{ "id": 1, "name": "دیابت" }],
|
||||||
"status": "published",
|
"status": "published",
|
||||||
"city": { "id": "123", "name": "یاسوج" },
|
"city": { "id": "123", "name": "یاسوج" },
|
||||||
@@ -84,7 +84,7 @@ Get a single blog post by slug.
|
|||||||
"summary": "خلاصه...",
|
"summary": "خلاصه...",
|
||||||
"body": "<p>محتوای کامل...</p>",
|
"body": "<p>محتوای کامل...</p>",
|
||||||
"image": "https://...",
|
"image": "https://...",
|
||||||
"author": { "uuid": "...", "real_name": "دکتر احمدی" },
|
"author": { "uuid": "...", "real_name": "احمدی" },
|
||||||
"tags": [{ "id": 1, "name": "دیابت" }],
|
"tags": [{ "id": 1, "name": "دیابت" }],
|
||||||
"status": "published",
|
"status": "published",
|
||||||
"created_at": 1717000000,
|
"created_at": 1717000000,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Send an invitation to a doctor (by mobile number) to join a clinic.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mobile": "09123456789",
|
"mobile": "09123456789",
|
||||||
"name": "دکتر علی احمدی",
|
"name": "علی احمدی",
|
||||||
"specialty": "قلب و عروق"
|
"specialty": "قلب و عروق"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -58,7 +58,7 @@ Send an invitation to a doctor (by mobile number) to join a clinic.
|
|||||||
"invited_at": 1717000000,
|
"invited_at": 1717000000,
|
||||||
"expires_at": 1717259200,
|
"expires_at": 1717259200,
|
||||||
"token_used": false,
|
"token_used": false,
|
||||||
"doctor": { "uuid": "...", "name": "دکتر علی احمدی" },
|
"doctor": { "uuid": "...", "name": "علی احمدی" },
|
||||||
"clinic": { "uuid": "...", "name": "کلینیک الوند" }
|
"clinic": { "uuid": "...", "name": "کلینیک الوند" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-3
@@ -8,7 +8,18 @@
|
|||||||
|
|
||||||
Create a new clinic.
|
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`)
|
### Request Body (`application/json`)
|
||||||
```json
|
```json
|
||||||
@@ -295,7 +306,7 @@ Get doctors associated with a clinic.
|
|||||||
{
|
{
|
||||||
"id": "1207",
|
"id": "1207",
|
||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"name": "دکتر آرمان رضایی",
|
"name": "آرمان رضایی",
|
||||||
"gender": "man",
|
"gender": "man",
|
||||||
"degree": "specialist",
|
"degree": "specialist",
|
||||||
"img": [],
|
"img": [],
|
||||||
@@ -401,7 +412,7 @@ List the permission rows of every doctor in the clinic.
|
|||||||
"uuid": "ce200cde-826d-11f1-b923-c282b864cdcc",
|
"uuid": "ce200cde-826d-11f1-b923-c282b864cdcc",
|
||||||
"clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09",
|
"clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09",
|
||||||
"doctor_uuid": "bcabb3a8-cae3-45ec-876c-548f9c1e1569",
|
"doctor_uuid": "bcabb3a8-cae3-45ec-876c-548f9c1e1569",
|
||||||
"doctor_name": "دکتر تست",
|
"doctor_name": "تست",
|
||||||
"active": true,
|
"active": true,
|
||||||
"permissions": { "version": 1, "resources": { "...": {} } },
|
"permissions": { "version": 1, "resources": { "...": {} } },
|
||||||
"created_at": 1784352916,
|
"created_at": 1784352916,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ unclaimed ──claim/transfer شروع──▶ pending_transfer ──موفق
|
|||||||
"data": {
|
"data": {
|
||||||
"status": "claimed",
|
"status": "claimed",
|
||||||
"claim": { "uuid": "…" },
|
"claim": { "uuid": "…" },
|
||||||
"doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" }
|
"doctor": { "uuid": "…", "name": "فرخنده حسینی" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -127,7 +127,7 @@ unclaimed ──claim/transfer شروع──▶ pending_transfer ──موفق
|
|||||||
{
|
{
|
||||||
"uuid": "…",
|
"uuid": "…",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" },
|
"doctor": { "uuid": "…", "name": "فرخنده حسینی" },
|
||||||
"mobile_masked": "0912***4567",
|
"mobile_masked": "0912***4567",
|
||||||
"verification_method": "apiir_personinfo+shahkar",
|
"verification_method": "apiir_personinfo+shahkar",
|
||||||
"failure_reason": null,
|
"failure_reason": null,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "دکتر فرخنده حسینی",
|
"name": "فرخنده حسینی",
|
||||||
"medical_system_code": "145657",
|
"medical_system_code": "145657",
|
||||||
"source": "irimc",
|
"source": "irimc",
|
||||||
"source_ref": "https://membersearch.irimc.org/member/profile?id=…",
|
"source_ref": "https://membersearch.irimc.org/member/profile?id=…",
|
||||||
|
|||||||
+6
-6
@@ -24,7 +24,7 @@ Create a doctor profile for the authenticated user.
|
|||||||
### Request Body (`application/json`)
|
### Request Body (`application/json`)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "دکتر علی احمدی",
|
"title": "علی احمدی",
|
||||||
"gender": "male",
|
"gender": "male",
|
||||||
"medical_system_code": "12345",
|
"medical_system_code": "12345",
|
||||||
"degree": "متخصص",
|
"degree": "متخصص",
|
||||||
@@ -36,7 +36,7 @@ Create a doctor profile for the authenticated user.
|
|||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `title` | string | ✅ | Full name with title |
|
| `title` | string | ✅ | نام پزشک **بدون** عنوان. پیشوند «دکتر» سمت سرور با `PersianText::stripDoctorTitle()` حذف میشود؛ نمایش عنوان کار لایهٔ UI است. |
|
||||||
| `gender` | string | ❌ | `"male"` or `"female"` |
|
| `gender` | string | ❌ | `"male"` or `"female"` |
|
||||||
| `medical_system_code` | string | ❌ | Nظام پزشکی code |
|
| `medical_system_code` | string | ❌ | Nظام پزشکی code |
|
||||||
| `degree` | string | ❌ | Academic degree |
|
| `degree` | string | ❌ | Academic degree |
|
||||||
@@ -51,7 +51,7 @@ Create a doctor profile for the authenticated user.
|
|||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"title": "دکتر علی احمدی",
|
"title": "علی احمدی",
|
||||||
"gender": "male",
|
"gender": "male",
|
||||||
"medical_system_code": "12345",
|
"medical_system_code": "12345",
|
||||||
"degree": "متخصص",
|
"degree": "متخصص",
|
||||||
@@ -93,7 +93,7 @@ Get doctor detail with clinics.
|
|||||||
"data": {
|
"data": {
|
||||||
"data": {
|
"data": {
|
||||||
"uuid": "550e8400-...",
|
"uuid": "550e8400-...",
|
||||||
"name": "دکتر علی احمدی",
|
"name": "علی احمدی",
|
||||||
"gender": "man",
|
"gender": "man",
|
||||||
"medical_system_code": "12345",
|
"medical_system_code": "12345",
|
||||||
"degree": "specialist",
|
"degree": "specialist",
|
||||||
@@ -153,7 +153,7 @@ Get doctor detail for clinic owner — only doctors who are members of the authe
|
|||||||
"data": {
|
"data": {
|
||||||
"data": {
|
"data": {
|
||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"title": "دکتر علی احمدی",
|
"title": "علی احمدی",
|
||||||
"specialties": [...],
|
"specialties": [...],
|
||||||
"clinics": [{ "uuid": "...", "name": "کلینیک نور", "address": "...", "telephone": "..." }]
|
"clinics": [{ "uuid": "...", "name": "کلینیک نور", "address": "...", "telephone": "..." }]
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ List doctors with pagination and filters.
|
|||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"name": "دکتر علی احمدی",
|
"name": "علی احمدی",
|
||||||
"gender": "man",
|
"gender": "man",
|
||||||
"degree": "specialist",
|
"degree": "specialist",
|
||||||
"img": [],
|
"img": [],
|
||||||
|
|||||||
+1
-1
@@ -437,7 +437,7 @@ GET /api/v1/patient/{uuid}/appointments
|
|||||||
"ends_at": 1754001800,
|
"ends_at": 1754001800,
|
||||||
"status": "confirmed",
|
"status": "confirmed",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"doctor_name": "دکتر ژیلا فتحی",
|
"doctor_name": "ژیلا فتحی",
|
||||||
"service_name": null,
|
"service_name": null,
|
||||||
"price_rials": null,
|
"price_rials": null,
|
||||||
"created_at": 1754000000
|
"created_at": 1754000000
|
||||||
|
|||||||
+1
-1
@@ -271,7 +271,7 @@ Get all pending comments waiting for review.
|
|||||||
{
|
{
|
||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"body": "...",
|
"body": "...",
|
||||||
"doctor": { "uuid": "...", "title": "دکتر علی احمدی" },
|
"doctor": { "uuid": "...", "title": "علی احمدی" },
|
||||||
"user": { "uuid": "...", "real_name": "..." },
|
"user": { "uuid": "...", "real_name": "..." },
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"created_at": 1717000000
|
"created_at": 1717000000
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ Get yearly earnings dashboard for a representation.
|
|||||||
|
|
||||||
#### Request Body
|
#### Request Body
|
||||||
```json
|
```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 |
|
| Field | Type | Required |
|
||||||
|-------|------|----------|
|
|-------|------|----------|
|
||||||
@@ -452,7 +452,7 @@ Get yearly earnings dashboard for a representation.
|
|||||||
"patient_name": "...",
|
"patient_name": "...",
|
||||||
"patient_mobile": "0912...",
|
"patient_mobile": "0912...",
|
||||||
"doctor_uuid": "...",
|
"doctor_uuid": "...",
|
||||||
"doctor_name": "دکتر ...",
|
"doctor_name": "...",
|
||||||
"slot_start": 1718000000,
|
"slot_start": 1718000000,
|
||||||
"slot_end": 1718001800,
|
"slot_end": 1718001800,
|
||||||
"appointment_date": "2025-06-15",
|
"appointment_date": "2025-06-15",
|
||||||
@@ -491,7 +491,7 @@ Get yearly earnings dashboard for a representation.
|
|||||||
"success": true,
|
"success": true,
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"uuid": "...", "id": 12, "name": "دکتر ...", "gender": "man", "degree": "...",
|
"uuid": "...", "id": 12, "name": "...", "gender": "man", "degree": "...",
|
||||||
"medical_code": "...", "mobile": "0912...", "email": null,
|
"medical_code": "...", "mobile": "0912...", "email": null,
|
||||||
"is_active": true, "rate": 3.5, "specialties": [],
|
"is_active": true, "rate": 3.5, "specialties": [],
|
||||||
"profile_image": null, "created_at": "2026-06-18T..."
|
"profile_image": null, "created_at": "2026-06-18T..."
|
||||||
@@ -621,7 +621,7 @@ Get yearly earnings dashboard for a representation.
|
|||||||
"success": true,
|
"success": true,
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"uuid": "...", "name": "دکتر ...",
|
"uuid": "...", "name": "...",
|
||||||
"appointments": { "today": 0, "week": 1, "month": 4, "total": 18 },
|
"appointments": { "today": 0, "week": 1, "month": 4, "total": 18 },
|
||||||
"representation_income_rials": 363636,
|
"representation_income_rials": 363636,
|
||||||
"subscription_status": "active"
|
"subscription_status": "active"
|
||||||
@@ -642,7 +642,7 @@ Get yearly earnings dashboard for a representation.
|
|||||||
"success": true,
|
"success": true,
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"uuid": "...", "appointment_uuid": "...", "doctor_name": "دکتر ...",
|
"uuid": "...", "appointment_uuid": "...", "doctor_name": "...",
|
||||||
"gross_rials": 2000000, "tax_rials": 45455, "sms_fee_rials": 1500000,
|
"gross_rials": 2000000, "tax_rials": 45455, "sms_fee_rials": 1500000,
|
||||||
"commission_percent": 20, "representation_share_rials": 90909,
|
"commission_percent": 20, "representation_share_rials": 90909,
|
||||||
"created_at": "2026-06-24T..."
|
"created_at": "2026-06-24T..."
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ Create a secretary for a doctor.
|
|||||||
"uuid": "sec-uuid-...",
|
"uuid": "sec-uuid-...",
|
||||||
"user_name": "علی محمدی",
|
"user_name": "علی محمدی",
|
||||||
"mobile_number": "09123456789",
|
"mobile_number": "09123456789",
|
||||||
"doctor_name": "دکتر احمد رضایی",
|
"doctor_name": "احمد رضایی",
|
||||||
"doctor_uuid": "...",
|
"doctor_uuid": "...",
|
||||||
"owner_type": "doctor",
|
"owner_type": "doctor",
|
||||||
"clinic_uuid": null,
|
"clinic_uuid": null,
|
||||||
@@ -215,7 +215,7 @@ Get secretary detail.
|
|||||||
"mobile_number": "09123456789",
|
"mobile_number": "09123456789",
|
||||||
"active": true,
|
"active": true,
|
||||||
"permissions": { ... },
|
"permissions": { ... },
|
||||||
"doctor": { "uuid": "...", "title": "دکتر علی احمدی" },
|
"doctor": { "uuid": "...", "title": "علی احمدی" },
|
||||||
"created_at": 1717000000
|
"created_at": 1717000000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,7 +325,7 @@ Get all secretaries for a specific doctor.
|
|||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"user_name": "علی محمدی",
|
"user_name": "علی محمدی",
|
||||||
"mobile_number": "09...",
|
"mobile_number": "09...",
|
||||||
"doctor_name": "دکتر احمد رضایی",
|
"doctor_name": "احمد رضایی",
|
||||||
"doctor_uuid": "...",
|
"doctor_uuid": "...",
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"permissions": { ... },
|
"permissions": { ... },
|
||||||
@@ -368,7 +368,7 @@ Get all secretaries across **all doctors** of a clinic.
|
|||||||
"secretary_uuid": "...",
|
"secretary_uuid": "...",
|
||||||
"user_name": "علی محمدی",
|
"user_name": "علی محمدی",
|
||||||
"mobile_number": "09...",
|
"mobile_number": "09...",
|
||||||
"doctor_name": "دکتر احمد رضایی",
|
"doctor_name": "احمد رضایی",
|
||||||
"doctor_uuid": "...",
|
"doctor_uuid": "...",
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"permissions": { ... },
|
"permissions": { ... },
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@ Send an SMS using an approved template.
|
|||||||
"mobile": "09123456789",
|
"mobile": "09123456789",
|
||||||
"template_uuid": "tmpl-uuid-...",
|
"template_uuid": "tmpl-uuid-...",
|
||||||
"vars": {
|
"vars": {
|
||||||
"name": "دکتر علی احمدی",
|
"name": "علی احمدی",
|
||||||
"date": "۱۵ خرداد ۱۴۰۴"
|
"date": "۱۵ خرداد ۱۴۰۴"
|
||||||
},
|
},
|
||||||
"provider": "kavenegar"
|
"provider": "kavenegar"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
use App\Shared\Util\PersianText;
|
||||||
|
|
||||||
#[OA\Tag(name: 'Admin')]
|
#[OA\Tag(name: 'Admin')]
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_ADMIN')]
|
||||||
@@ -457,7 +458,7 @@ class AdminApiController extends BaseController
|
|||||||
{
|
{
|
||||||
$data = json_decode($request->getContent(), true) ?? [];
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
$mobile = InputValidator::toEnglishDigits(trim((string) ($data['mobile'] ?? '')));
|
$mobile = InputValidator::toEnglishDigits(trim((string) ($data['mobile'] ?? '')));
|
||||||
$name = trim((string) ($data['name'] ?? ''));
|
$name = PersianText::stripDoctorTitle((string) ($data['name'] ?? ''));
|
||||||
|
|
||||||
if ($mobile === '' || $name === '') {
|
if ($mobile === '' || $name === '') {
|
||||||
return $this->error(ErrorCodes::VALIDATION, 'موبایل و نام الزامی هستند', 422);
|
return $this->error(ErrorCodes::VALIDATION, 'موبایل و نام الزامی هستند', 422);
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ class ClinicController extends BaseController
|
|||||||
if (($err = $this->validateGallerySize($data)) !== null) {
|
if (($err = $this->validateGallerySize($data)) !== null) {
|
||||||
return $err;
|
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);
|
$clinic = new Clinic($user);
|
||||||
$this->hydrateClinic($clinic, $data);
|
$this->hydrateClinic($clinic, $data);
|
||||||
$this->clinicRepo->save($clinic);
|
$this->clinicRepo->save($clinic);
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
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 --dry-run
|
||||||
* php bin/console app:doctors:fix-irimc-names
|
* php bin/console app:doctors:fix-irimc-names --all
|
||||||
*/
|
*/
|
||||||
#[AsCommand(
|
#[AsCommand(
|
||||||
name: 'app:doctors:fix-irimc-names',
|
name: 'app:doctors:fix-irimc-names',
|
||||||
@@ -33,19 +36,21 @@ class FixIrimcNamesCommand extends Command
|
|||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing');
|
$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
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
{
|
{
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
$dryRun = (bool) $input->getOption('dry-run');
|
$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 */
|
/** @var Doctor[] $doctors */
|
||||||
$doctors = $this->em->getRepository(Doctor::class)->createQueryBuilder('d')
|
$doctors = $qb->getQuery()->getResult();
|
||||||
->where('d.source = :src')
|
|
||||||
->setParameter('src', 'irimc')
|
|
||||||
->getQuery()
|
|
||||||
->getResult();
|
|
||||||
|
|
||||||
$fixed = 0;
|
$fixed = 0;
|
||||||
foreach ($doctors as $doctor) {
|
foreach ($doctors as $doctor) {
|
||||||
@@ -63,7 +68,13 @@ class FixIrimcNamesCommand extends Command
|
|||||||
$this->em->flush();
|
$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;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use Symfony\Component\HttpFoundation\Request;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
use App\Shared\Util\PersianText;
|
||||||
|
|
||||||
#[OA\Tag(name: 'Doctors')]
|
#[OA\Tag(name: 'Doctors')]
|
||||||
class DoctorController extends BaseController
|
class DoctorController extends BaseController
|
||||||
@@ -105,7 +106,7 @@ class DoctorController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$data = json_decode($request->getContent(), true) ?? [];
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
$name = trim($data['title'] ?? $data['name'] ?? '');
|
$name = PersianText::stripDoctorTitle($data['title'] ?? $data['name'] ?? '');
|
||||||
|
|
||||||
if ($name === '') {
|
if ($name === '') {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام دکتر الزامی است', 422, 'title');
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام دکتر الزامی است', 422, 'title');
|
||||||
@@ -350,7 +351,7 @@ class DoctorController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$data = json_decode($request->getContent(), true) ?? [];
|
$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->hydrateDoctor($doctor, $data);
|
||||||
$this->doctorRepo->save($doctor);
|
$this->doctorRepo->save($doctor);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use Symfony\Component\HttpFoundation\Request;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
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) ?? [];
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
$mobile = InputValidator::toEnglishDigits(trim((string) ($data['mobile'] ?? '')));
|
$mobile = InputValidator::toEnglishDigits(trim((string) ($data['mobile'] ?? '')));
|
||||||
$name = trim((string) ($data['name'] ?? ''));
|
$name = PersianText::stripDoctorTitle((string) ($data['name'] ?? ''));
|
||||||
|
|
||||||
if ($mobile === '' || $name === '') {
|
if ($mobile === '' || $name === '') {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'موبایل و نام الزامی هستند', 422);
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'موبایل و نام الزامی هستند', 422);
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ class SeedDemoDataCommand extends Command
|
|||||||
$userRows[] = [
|
$userRows[] = [
|
||||||
'uuid' => Uuid::v4()->toRfc4122(),
|
'uuid' => Uuid::v4()->toRfc4122(),
|
||||||
'mobile_number' => sprintf('09126%06d', $i),
|
'mobile_number' => sprintf('09126%06d', $i),
|
||||||
'real_name' => 'دکتر ' . $this->persianName($i),
|
'real_name' => $this->persianName($i),
|
||||||
'roles' => json_encode(['ROLE_USER', 'ROLE_DOCTOR']),
|
'roles' => json_encode(['ROLE_USER', 'ROLE_DOCTOR']),
|
||||||
'national_code_verified' => 0,
|
'national_code_verified' => 0,
|
||||||
'status' => 1,
|
'status' => 1,
|
||||||
@@ -359,7 +359,7 @@ class SeedDemoDataCommand extends Command
|
|||||||
$doctorRows[] = [
|
$doctorRows[] = [
|
||||||
'uuid' => Uuid::v4()->toRfc4122(),
|
'uuid' => Uuid::v4()->toRfc4122(),
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'name' => 'دکتر ' . $this->persianName($i),
|
'name' => $this->persianName($i),
|
||||||
'gender' => $i % 3 === 0 ? 'woman' : 'man',
|
'gender' => $i % 3 === 0 ? 'woman' : 'man',
|
||||||
'medical_system_code' => (string) (100000 + $i),
|
'medical_system_code' => (string) (100000 + $i),
|
||||||
'mobile_number' => sprintf('09126%06d', $i),
|
'mobile_number' => sprintf('09126%06d', $i),
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Clinic;
|
||||||
|
|
||||||
|
use App\Clinic\Entity\Clinic;
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/clinic is a self-service signup: any authenticated user may
|
||||||
|
* register their own clinic. Two invariants it used to break —
|
||||||
|
* · an empty body created a nameless clinic that no list can identify;
|
||||||
|
* · a user could create unlimited clinics, while ClinicRepository::findByUser()
|
||||||
|
* (which resolves their working context) is a findOneBy, so every clinic past
|
||||||
|
* the first was unreachable data.
|
||||||
|
*/
|
||||||
|
class CreateClinicValidationTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
public function testEmptyBodyIsRejected(): void
|
||||||
|
{
|
||||||
|
$user = $this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Doctor;
|
||||||
|
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Shared\Util\PersianText;
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A doctor's stored name never carries the «دکتر» title — the display layer
|
||||||
|
* decides how to present it. DoctorImportService and DoctorClaimService already
|
||||||
|
* enforced this; the ordinary registration paths did not, so a name typed as
|
||||||
|
* «دکتر حامد حسینی» was stored verbatim and then rendered «دکتر دکتر حامد حسینی».
|
||||||
|
*/
|
||||||
|
class DoctorNameTitleTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
/** db_test is never reset, so a fixed mobile collides across runs. */
|
||||||
|
private function freshMobile(): string
|
||||||
|
{
|
||||||
|
return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createDoctorAs(string $name): ?Doctor
|
||||||
|
{
|
||||||
|
$admin = $this->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' => ['حامد دکتر حسینی', 'حامد دکتر حسینی'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user