feat: unify doctor title handling and enhance specialty selection
- Implemented a helper function `displayDoctorName` to prepend "دکتر" to doctor names for consistent display across the application. - Updated various components (InviteDoctorModal, DashboardPage, DoctorDetailPage, DoctorsPage, etc.) to utilize the new helper for rendering doctor names. - Modified the DoctorFormPage to automatically add the "دکتر" title in the UI without requiring user input. - Fixed the EditSpecialtyPicker component to allow multiple specialty selections, resolving a UI bug where only one specialty could be selected at a time. - Ensured that the backend strips the "دکتر" title from the name during pre-registration and doctor creation processes. - Added tests for the new functionality, including checks for title handling and specialty selection logic. - Updated API documentation to reflect changes in name handling and display logic.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
# رفع قفل ویرایش تخصص پزشک + یکپارچهسازی عنوان «دکتر» + ترمیم کامل داده خزنده
|
||||
|
||||
## زمینه
|
||||
|
||||
پزشکان زیادی توسط خزندهٔ نظام پزشکی (`clinicpro-crawler/`) وارد شدهاند
|
||||
(`source='irimc'`, ~۲۳۴۰ رکورد). یک دور ترمیم قبلاً انجام شده و کامند
|
||||
`app:doctors:repair` ساخته شده (درجه، حذف پیشوند «دکتر» از نام، افزودن تخصص والد،
|
||||
نقش جانشین). اما مشکل اصلیِ گزارششدهٔ کاربر هنوز باقی است: در پروفایل ادمینِ یک پزشک
|
||||
(`/admin/doctors/1303ec61-6d5f-4ab5-a4ff-86dbafbd8974`، ارتوپدی) **امکان تغییر/حذف/افزودن
|
||||
تخصص وجود ندارد** — و این باگ UI است، نه داده.
|
||||
|
||||
علاوه بر آن دو کار سیستمی خواسته شده: (۱) سیاست واحد نمایش عنوان «دکتر» در کل محصول،
|
||||
(۲) یک بازبینی کامل روی همهٔ پزشکان خزندهای برای صحت داده و قابلویرایش بودن بینقص.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
سه محور مستقل:
|
||||
|
||||
1. **باگ قفل تخصص (اولویت اصلی):** کامپوننت `EditSpecialtyPicker` در مودال ویرایش پزشک
|
||||
عملاً **تکانتخابی** است. با انتخاب اولین تخصص، همهٔ گروههای تخصصی دیگر `disabled`
|
||||
میشوند و انتخاب هر تخصص کل آرایه را جایگزین میکند. نتیجه: کاربر حس میکند فیلد قفل
|
||||
است و نمیتواند تخصص دوم اضافه کند یا آسان تخصص را عوض کند.
|
||||
|
||||
2. **سیاست عنوان «دکتر»:** باید یک قانون واحد در کل سیستم اعمال شود — **عنوان «دکتر»
|
||||
هرگز در فیلد `name` ذخیره نشود و فقط در لایهٔ نمایش افزوده شود.** بررسی شود که همهٔ
|
||||
مسیرهای ذخیره این را رعایت کنند و همهٔ مسیرهای نمایش عنوان را یکجا اضافه کنند.
|
||||
|
||||
3. **ترمیم و صحت کامل داده خزنده:** اطمینان از اینکه همهٔ پزشکان `source='irimc'` داده
|
||||
درست دارند و بدون خطا قابل ویرایشاند.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `assets/admin/pages/DoctorDetailPage.tsx` | مودال ویرایش پزشک + `EditSpecialtyPicker` (باگ اصلی) |
|
||||
| `src/Doctor/Controller/DoctorController.php` | `update()` (PATCH `/api/v1/doctor/{uuid}`) + `hydrateDoctor()` |
|
||||
| `src/Doctor/Entity/Doctor.php` | `toListArray()` / `toDetailArray()` — `name` بدون عنوان برمیگردد |
|
||||
| `src/Doctor/Command/RepairImportedDoctorsCommand.php` | کامند ترمیم (`app:doctors:repair`) — قبلاً ساخته شده |
|
||||
| `src/Doctor/Service/Repair/` | گامهای ترمیم (names, degrees, specialty-parents, surrogate-role) |
|
||||
| `src/Shared/Util/PersianText.php` | `stripDoctorTitle()` — حذف پیشوند هنگام ذخیره |
|
||||
| `assets/admin/lib/utils.ts` | محل مناسب برای helper نمایش عنوان (`displayDoctorName`) |
|
||||
| `docs/api/doctor.md` / `docs/api/doctor-import.md` | مستندات |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### باگ ۱ — `EditSpecialtyPicker` تکانتخابی (DoctorDetailPage.tsx:831-997)
|
||||
|
||||
```tsx
|
||||
// خط ۹۲۳ — با انتخاب یک تخصص، بقیهٔ گروهها disabled میشوند
|
||||
const isDisabled = hasSelection && !isMarked;
|
||||
// ...
|
||||
<button disabled={isDisabled} style={{ opacity: isDisabled ? 0.35 : 1, cursor: isDisabled ? 'not-allowed' : ... }}>
|
||||
|
||||
// خط ۸۵۹-۸۶۶ — انتخاب فرزند: کل آرایه را جایگزین میکند
|
||||
const selectChild = (child: SpecialtyOpt) => {
|
||||
const parentId = child.parent_id!;
|
||||
if (selected.includes(child.id)) { onChange([]); }
|
||||
else { onChange([parentId, child.id]); } // فقط همین یک تخصص + والدش
|
||||
};
|
||||
|
||||
// خط ۸۶۸-۸۷۵ — انتخاب ریشه هم کل آرایه را جایگزین میکند
|
||||
const selectRoot = (root: SpecialtyOpt) => {
|
||||
if (selected.includes(root.id)) { onChange([]); }
|
||||
else { setActiveParentId(null); onChange([root.id]); }
|
||||
};
|
||||
|
||||
// خط ۹۶۷ — حتی سرتیتر میگوید «یک مورد»
|
||||
انتخاب تخصص — یک مورد
|
||||
```
|
||||
|
||||
نکته: در همان فایل یک پیکر **چندانتخابی درست** بهنام `HierarchicalSpecialtyPicker`
|
||||
(خط ۳۴۷-۴۵۱) وجود دارد که با checkbox و `toggleSelect` کار میکند و
|
||||
`onChange(selected.includes(id) ? selected.filter(...) : [...selected, id])` دارد —
|
||||
یعنی الگوی درست قبلاً در همین فایل هست، فقط مودال از پیکر اشتباه استفاده میکند.
|
||||
|
||||
### Backend سالم است — باگ فقط UI است
|
||||
|
||||
```php
|
||||
// DoctorController::update() خط ۳۴۹ — ادمین اجازهٔ کامل دارد
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
// hydrateDoctor خط ۷۹۳ — آرایهٔ چندتایی تخصص را کامل میپذیرد و با والدها گسترش میدهد
|
||||
if (array_key_exists('specialties', $data) && is_array($data['specialties'])) {
|
||||
foreach ($this->specialtyRepo->expandWithAncestors(array_map('intval', $data['specialties'])) as $id) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
PATCH با آرایهٔ چند تخصص از قبل کار میکند؛ **هیچ تغییری در backend برای رفع باگ لازم نیست.**
|
||||
|
||||
### سیاست عنوان — وضعیت فعلی
|
||||
|
||||
`Doctor::toListArray()` / `toDetailArray()` مقدار `name` را **بدون** «دکتر» برمیگردانند
|
||||
(خط ۵۵۵ و ۵۸۴). ذخیره هم با `PersianText::stripDoctorTitle()` عنوان را حذف میکند
|
||||
(`DoctorController::update()` خط ۳۵۴، و `DoctorImportService`). یعنی **سیاست درست
|
||||
همین است**: name خام، «دکتر» فقط در نمایش. اما در فرانتاند این نمایش یکجا/سازگار نیست
|
||||
(مثلاً placeholder فیلد نام `"دکتر ..."` در خط ۱۷۲۳ کاربر را گمراه میکند که انگار باید
|
||||
«دکتر» تایپ کند).
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. رفع باگ قفل تخصص (اصلی)
|
||||
|
||||
مودال ویرایش (خط ۱۸۲۴) بهجای `EditSpecialtyPicker` تکانتخابی، از منطق چندانتخابی
|
||||
استفاده کند. دو گزینه — **گزینهٔ الف ترجیح داده میشود**:
|
||||
|
||||
**الف) `EditSpecialtyPicker` را چندانتخابی کن** (تغییر کمتر، حفظ ظاهر دوستونی master-detail):
|
||||
- خط ۹۲۳: `isDisabled` را حذف کن — هیچ گروهی نباید غیرفعال شود.
|
||||
- `selectChild`: بهجای جایگزینی، toggle کن و والد را حفظ کن:
|
||||
```tsx
|
||||
const selectChild = (child: SpecialtyOpt) => {
|
||||
const parentId = child.parent_id!;
|
||||
if (selected.includes(child.id)) {
|
||||
// حذف فرزند؛ والد را هم اگر فرزند دیگری از او انتخاب نیست حذف کن
|
||||
const siblings = childMap[parentId] ?? [];
|
||||
const otherSelected = siblings.some(k => k.id !== child.id && selected.includes(k.id));
|
||||
onChange(selected.filter(id => id !== child.id && (otherSelected || id !== parentId)));
|
||||
} else {
|
||||
onChange([...new Set([...selected, parentId, child.id])]);
|
||||
}
|
||||
};
|
||||
```
|
||||
- `selectRoot`: toggle بهجای جایگزینی — `onChange(selected.includes(root.id) ? selected.filter(id => id !== root.id) : [...selected, root.id])`.
|
||||
- `removeEntry(childId)`: فقط همان chip را حذف کند نه کل انتخابها (الان `onChange([])`).
|
||||
- سرتیتر خط ۹۶۷ از «یک مورد» به «انتخاب تخصص» تغییر کند.
|
||||
- `chips` باید همهٔ تخصصهای انتخابشده را نشان دهد (منطق فعلی چندتایی را پشتیبانی میکند، فقط با toggleِ درست تغذیه شود).
|
||||
|
||||
**ب) جایگزینی با `HierarchicalSpecialtyPicker`** (اگر master-detail ارزش نگهداری ندارد):
|
||||
همان کامپوننت خط ۳۴۷ که از قبل چندانتخابی است را در مودال استفاده کن و
|
||||
`EditSpecialtyPicker` را حذف کن.
|
||||
|
||||
**پس از هر دو:** `setValue('specialties', ids, { shouldDirty: true })` تا فرم dirty شود.
|
||||
|
||||
بعد از رفع، دقیقاً همان پزشک تست شود: افزودن تخصص دوم، حذف تخصص، تعویض تخصص — و ذخیره.
|
||||
|
||||
### ۲. یکپارچهسازی سیاست عنوان «دکتر»
|
||||
|
||||
سیاست نهایی (تثبیت وضع موجود، نه تغییر مدل داده):
|
||||
**«دکتر» هرگز در `name` ذخیره نمیشود؛ فقط لایهٔ نمایش آن را میافزاید.**
|
||||
|
||||
- یک helper واحد در `assets/admin/lib/utils.ts` بساز:
|
||||
```ts
|
||||
/** عنوان «دکتر» فقط در نمایش؛ هرگز در دیتابیس ذخیره نمیشود. */
|
||||
export const displayDoctorName = (name?: string | null): string => {
|
||||
const n = (name ?? '').trim();
|
||||
if (!n) return '';
|
||||
return n.startsWith('دکتر') ? n : `دکتر ${n}`;
|
||||
};
|
||||
```
|
||||
- همهٔ جاهایی که نام پزشک با «دکتر …» رندر میشود (DoctorDetailPage هدر، DoctorsPage لیست،
|
||||
کارتها، …) از این helper استفاده کنند — نه الحاق دستی `\`دکتر ${name}\``. مکانهای فعلی
|
||||
را با grep `دکتر ` در `assets/admin/` پیدا و یکسان کن.
|
||||
- placeholder فیلد نام در مودال (خط ۱۷۲۳) از `"دکتر ..."` به `"مثلاً: حامد حسینی"` تغییر کند
|
||||
تا کاربر عنوان تایپ نکند. زیر فیلد یک hint: «عنوان «دکتر» خودکار نمایش داده میشود».
|
||||
- سمت ذخیره: مطمئن شو **همهٔ** مسیرهای ساخت/ویرایش پزشک `stripDoctorTitle` را صدا میزنند
|
||||
(admin create در `AdminApiController`, `DoctorController::update`, `DoctorImportService`,
|
||||
claim, pre-registration). اگر مسیری این کار را نمیکند، اضافه کن.
|
||||
- cross-repo (خارج از این تسک، فقط یادداشت در گزارش): سایت عمومی `nobat724_front` هم نام را
|
||||
از همین API میگیرد؛ چون API نام خام میدهد، آنجا هم باید در نمایش «دکتر» بگذارد. اگر لازم
|
||||
شد پرامپت جدا برای nobat724 نوشته شود.
|
||||
|
||||
### ۳. ترمیم و بازبینی کامل داده خزنده
|
||||
|
||||
کامند `app:doctors:repair` از قبل هست. این وظیفه = اجرای کامل + گزارش صحت:
|
||||
|
||||
- `ddev exec php bin/console app:doctors:repair --dry-run` → بررسی همهٔ گامها.
|
||||
- سپس بدون `--dry-run` اعمال کن (شامل `specialty-parents` که ~۱۵۹ تغییر معلق دارد).
|
||||
- کوئریهای صحتسنجی روی `source='irimc'` و گزارش بده:
|
||||
- پزشکان بدون هیچ تخصص (`doctor_specialties` خالی) — چند مورد؟ علت؟
|
||||
- `degree` نامعتبر (خارج از `Doctor::DEGREES`) یا NULL که `info` قابل نگاشت دارد.
|
||||
- نامهای هنوز دارای پیشوند «دکتر».
|
||||
- رکوردهای تکراری: همان `medical_system_code` با بیش از یک ردیف — گزارش کن (ادغام فقط با
|
||||
تأیید، چون idempotency خزنده روی `(source, medical_system_code)` است).
|
||||
- آدرس/شهر/استان ناقص.
|
||||
- اگر نقص جدیدی پیدا شد که گام موجود پوشش نمیدهد، **یک گام جدید `DoctorRepairStep`** در
|
||||
`src/Doctor/Service/Repair/` اضافه کن (خودکار کشف میشود؛ کامند نیازی به تغییر ندارد) و
|
||||
برایش تست بنویس.
|
||||
- تأیید قابلویرایش بودن: با `driver.mjs` یا دستی، چند پزشک irimc را در مودال ادمین باز کن،
|
||||
تخصص/نام/درجه را تغییر بده و ذخیرهٔ موفق را تأیید کن.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **باگ اصلی صرفاً فرانتاند است.** backend و PATCH از قبل چندتخصص را میپذیرند؛ برای رفع
|
||||
قفل هیچ migration و تغییر controller لازم نیست. وقت را صرف backend نکن.
|
||||
- **تخصصها درختیاند:** `expandWithAncestors` سمت سرور والدها را اضافه میکند، پس UI میتواند
|
||||
فقط برگها را بفرستد؛ ولی نمایش chip باید والد+فرزند را نشان دهد (منطق فعلی `chips` درست است).
|
||||
- **سیاست عنوان = تثبیت وضع موجود.** مدل داده را عوض نکن؛ فقط نمایش را یکجا و ذخیره را
|
||||
همهجا `stripDoctorTitle` کن. از تغییری که باعث «دکتر دکتر» یا نام بدون عنوان شود بپرهیز.
|
||||
- **گامهای repair باید idempotent بمانند** — اجرای دوم صفر تغییر. تست موجود
|
||||
`RepairImportedDoctorsCommandTest` این را قفل کرده؛ گام جدید هم باید همین را رعایت کند.
|
||||
- **قوانین پروژه:** SPA فقط از `SearchableSelect` (نه `<select>` خام)؛ TanStack Query برای
|
||||
fetch؛ RHF + Zod برای فرم؛ رشتههای UI فارسی. بعد از تغییر API، `docs/api/doctor.md`
|
||||
بهروز شود. هر تغییر با تست (موفق + خطا + مرزی) و اجرای موفق `ddev exec php bin/phpunit`.
|
||||
- **دیتابیس فعلی داده واقعی production است** (import شده)؛ قبل از اجرای repair بدون dry-run،
|
||||
از بکاپ `db/` مطمئن شو.
|
||||
|
||||
## خروجی نهایی (گزارش خواستهشده)
|
||||
|
||||
در پایان گزارش کامل بده: (۱) مشکلات شناساییشده، (۲) root cause هرکدام، (۳) فایلهای
|
||||
تغییرکرده، (۴) اصلاحات، (۵) تستها و نتیجه، (۶) موارد باقیمانده/نیازمند تصمیم (مثل ادغام
|
||||
رکوردهای تکراری یا پرامپت جداگانهٔ nobat724).
|
||||
@@ -56,7 +56,7 @@ export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Pr
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام پزشک (اختیاری)</label>
|
||||
<input className="input" placeholder="دکتر نام و نام خانوادگی" {...register('name')} />
|
||||
<input className="input" placeholder="نام و نام خانوادگی" {...register('name')} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تخصص (اختیاری)</label>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { displayDoctorName } from './utils';
|
||||
|
||||
describe('displayDoctorName', () => {
|
||||
it('prepends the title to a bare name', () => {
|
||||
expect(displayDoctorName('حامد حسینی')).toBe('دکتر حامد حسینی');
|
||||
});
|
||||
|
||||
it('does not double the title when the name already starts with it', () => {
|
||||
expect(displayDoctorName('دکتر حامد حسینی')).toBe('دکتر حامد حسینی');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before deciding', () => {
|
||||
expect(displayDoctorName(' حامد حسینی ')).toBe('دکتر حامد حسینی');
|
||||
});
|
||||
|
||||
it('returns empty string for empty/nullish input (no lone «دکتر»)', () => {
|
||||
expect(displayDoctorName('')).toBe('');
|
||||
expect(displayDoctorName(null)).toBe('');
|
||||
expect(displayDoctorName(undefined)).toBe('');
|
||||
expect(displayDoctorName(' ')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { toggleSpecialtyChild, toggleSpecialtyRoot, removeSpecialtyEntry, type ChildMap } from './specialtySelection';
|
||||
|
||||
// درخت نمونه: والد ۱۰ با فرزندهای ۱۱ و ۱۲؛ والد ۲۰ با فرزند ۲۱؛ ریشهٔ بیفرزند ۳۰.
|
||||
const childMap: ChildMap = {
|
||||
10: [{ id: 11 }, { id: 12 }],
|
||||
20: [{ id: 21 }],
|
||||
};
|
||||
|
||||
describe('toggleSpecialtyChild', () => {
|
||||
it('adds a child together with its parent', () => {
|
||||
expect(toggleSpecialtyChild([], 11, 10, childMap)).toEqual([10, 11]);
|
||||
});
|
||||
|
||||
it('allows a second specialty from another group — the bug that was fixed', () => {
|
||||
expect(toggleSpecialtyChild([10, 11], 21, 20, childMap)).toEqual([10, 11, 20, 21]);
|
||||
});
|
||||
|
||||
it('adds a sibling and keeps the shared parent', () => {
|
||||
expect(toggleSpecialtyChild([10, 11], 12, 10, childMap)).toEqual([10, 11, 12]);
|
||||
});
|
||||
|
||||
it('removing one sibling keeps the parent while another sibling stays', () => {
|
||||
expect(toggleSpecialtyChild([10, 11, 12], 11, 10, childMap)).toEqual([10, 12]);
|
||||
});
|
||||
|
||||
it('removing the last child of a group also drops the parent', () => {
|
||||
expect(toggleSpecialtyChild([10, 11], 11, 10, childMap)).toEqual([]);
|
||||
});
|
||||
|
||||
it('never duplicates the parent id', () => {
|
||||
const out = toggleSpecialtyChild([10, 11], 12, 10, childMap);
|
||||
expect(out.filter(id => id === 10)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleSpecialtyRoot', () => {
|
||||
it('adds a childless root', () => {
|
||||
expect(toggleSpecialtyRoot([], 30)).toEqual([30]);
|
||||
});
|
||||
|
||||
it('adds a root next to an existing selection', () => {
|
||||
expect(toggleSpecialtyRoot([10, 11], 30)).toEqual([10, 11, 30]);
|
||||
});
|
||||
|
||||
it('toggles a root off without touching the rest', () => {
|
||||
expect(toggleSpecialtyRoot([10, 11, 30], 30)).toEqual([10, 11]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeSpecialtyEntry', () => {
|
||||
it('removes only the targeted child+parent, not the whole selection', () => {
|
||||
expect(removeSpecialtyEntry([10, 11, 20, 21], 11, 10, childMap)).toEqual([20, 21]);
|
||||
});
|
||||
|
||||
it('keeps the parent when a sibling remains', () => {
|
||||
expect(removeSpecialtyEntry([10, 11, 12], 11, 10, childMap)).toEqual([10, 12]);
|
||||
});
|
||||
|
||||
it('removes a childless root entry', () => {
|
||||
expect(removeSpecialtyEntry([10, 11, 30], 30, null, childMap)).toEqual([10, 11]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// منطق خالص انتخاب چندتخصصی برای پیکر درختی پروفایل پزشک.
|
||||
// تخصصها درختیاند: انتخاب یک فرزند، والدش را هم نگه میدارد (والد صرفاً برای
|
||||
// گسترش درختی سمت سرور است) و والد فقط وقتی حذف میشود که هیچ فرزند دیگری از او
|
||||
// انتخاب نمانده باشد. جدا از کامپوننت نگه داشته شده تا مستقل تست شود.
|
||||
|
||||
/** id فرزندهای هر والد. */
|
||||
export type ChildMap = Record<number, { id: number }[]>;
|
||||
|
||||
const uniq = (ids: number[]): number[] => [...new Set(ids)];
|
||||
|
||||
/** toggle یک تخصصِ فرزند؛ والد را در صورت لزوم اضافه/حذف میکند. */
|
||||
export function toggleSpecialtyChild(
|
||||
selected: number[],
|
||||
childId: number,
|
||||
parentId: number,
|
||||
childMap: ChildMap,
|
||||
): number[] {
|
||||
if (selected.includes(childId)) {
|
||||
const siblings = childMap[parentId] ?? [];
|
||||
const otherSelected = siblings.some(k => k.id !== childId && selected.includes(k.id));
|
||||
return selected.filter(id => id !== childId && (otherSelected || id !== parentId));
|
||||
}
|
||||
return uniq([...selected, parentId, childId]);
|
||||
}
|
||||
|
||||
/** toggle یک تخصصِ ریشهایِ بدون فرزند. */
|
||||
export function toggleSpecialtyRoot(selected: number[], rootId: number): number[] {
|
||||
return selected.includes(rootId)
|
||||
? selected.filter(id => id !== rootId)
|
||||
: uniq([...selected, rootId]);
|
||||
}
|
||||
|
||||
/** حذف یک chip — فقط همان تخصص (و والدِ بیفرزندش)، نه پاککردن همه. */
|
||||
export function removeSpecialtyEntry(
|
||||
selected: number[],
|
||||
childId: number,
|
||||
parentId: number | null,
|
||||
childMap: ChildMap,
|
||||
): number[] {
|
||||
if (parentId !== null) {
|
||||
const siblings = childMap[parentId] ?? [];
|
||||
const otherSelected = siblings.some(k => k.id !== childId && selected.includes(k.id));
|
||||
return selected.filter(id => id !== childId && (otherSelected || id !== parentId));
|
||||
}
|
||||
return selected.filter(id => id !== childId);
|
||||
}
|
||||
@@ -98,6 +98,15 @@ export function unixToIso(ts: number | null | undefined): string {
|
||||
return toGregorianDate(new Date(ts * 1000));
|
||||
}
|
||||
|
||||
// عنوان «دکتر» فقط در نمایش افزوده میشود و هرگز در فیلد name دیتابیس ذخیره نمیشود
|
||||
// (backend با stripDoctorTitle حذف میکند). این helper تنها نقطهٔ افزودن عنوان است تا
|
||||
// در کل پنل یکسان باشد و از «دکتر دکتر …» یا نمایش بدون عنوان جلوگیری شود.
|
||||
export function displayDoctorName(name?: string | null): string {
|
||||
const n = (name ?? '').trim();
|
||||
if (!n) return '';
|
||||
return n.startsWith('دکتر') ? n : `دکتر ${n}`;
|
||||
}
|
||||
|
||||
export function maskMobile(mobile: string): string {
|
||||
if (mobile.length < 7) return mobile;
|
||||
return mobile.slice(0, 4) + '***' + mobile.slice(-3);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { formatNumber, formatRial, formatDateTime } from '../lib/utils';
|
||||
import { formatNumber, formatRial, formatDateTime, displayDoctorName } from '../lib/utils';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { TauriDashboardView } from '../components/dashboard/TauriDashboardView';
|
||||
|
||||
@@ -901,7 +901,7 @@ function SecretaryDashboard() {
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">داشبورد منشی</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · منشی دکتر {d?.doctor.name ?? ''}</div>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · منشی {displayDoctorName(d?.doctor.name)}</div>
|
||||
</div>
|
||||
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
@@ -912,7 +912,7 @@ function SecretaryDashboard() {
|
||||
<div className="card card-pad" style={{ marginBottom: 'var(--gap)', display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<AvatarEl initials={(d?.doctor.name ?? 'D').slice(0, 1)} hue={256} size="lg" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 16 }}>دکتر {d?.doctor.name ?? '—'}</div>
|
||||
<div style={{ fontWeight: 700, fontSize: 16 }}>{displayDoctorName(d?.doctor.name) || '—'}</div>
|
||||
{d?.doctor.degree && <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{d.doctor.degree}</div>}
|
||||
</div>
|
||||
<div style={{ marginRight: 'auto', display: 'flex', gap: 8 }}>
|
||||
|
||||
@@ -23,7 +23,7 @@ import 'leaflet/dist/leaflet.css';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { formatNumber, iranMobileOptionalSchema, toDate, toGregorianDate } from '../lib/utils';
|
||||
import { formatNumber, iranMobileOptionalSchema, toDate, toGregorianDate, displayDoctorName } from '../lib/utils';
|
||||
import MobileInput from '../components/ui/MobileInput';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
@@ -34,6 +34,7 @@ import ImageCropModal from '../components/ImageCropModal';
|
||||
import { ScheduleSection } from '../components/schedule/ScheduleSection';
|
||||
import type { AddressData } from '../components/schedule/ScheduleSection';
|
||||
import { latinDigitsField } from '../lib/forms';
|
||||
import { toggleSpecialtyChild, toggleSpecialtyRoot, removeSpecialtyEntry } from '../lib/specialtySelection';
|
||||
|
||||
// Fix leaflet default marker icons
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
@@ -856,25 +857,13 @@ function EditSpecialtyPicker({ selected, onChange, specialties }: {
|
||||
const isRootSelected = (parentId: number): boolean =>
|
||||
!(childMap[parentId]?.length) && selected.includes(parentId);
|
||||
|
||||
const selectChild = (child: SpecialtyOpt) => {
|
||||
const parentId = child.parent_id!;
|
||||
if (selected.includes(child.id)) {
|
||||
onChange([]);
|
||||
} else {
|
||||
onChange([parentId, child.id]);
|
||||
}
|
||||
};
|
||||
const selectChild = (child: SpecialtyOpt) =>
|
||||
onChange(toggleSpecialtyChild(selected, child.id, child.parent_id!, childMap));
|
||||
|
||||
const selectRoot = (root: SpecialtyOpt) => {
|
||||
if (selected.includes(root.id)) {
|
||||
onChange([]);
|
||||
} else {
|
||||
setActiveParentId(null);
|
||||
onChange([root.id]);
|
||||
}
|
||||
};
|
||||
const selectRoot = (root: SpecialtyOpt) => onChange(toggleSpecialtyRoot(selected, root.id));
|
||||
|
||||
const removeEntry = () => { onChange([]); setActiveParentId(null); };
|
||||
const removeEntry = ({ parentId, childId }: EditSelectedEntry) =>
|
||||
onChange(removeSpecialtyEntry(selected, childId, parentId, childMap));
|
||||
|
||||
const chips: EditSelectedEntry[] = useMemo(() => {
|
||||
return selected
|
||||
@@ -901,7 +890,7 @@ function EditSpecialtyPicker({ selected, onChange, specialties }: {
|
||||
return (
|
||||
<span key={childId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, padding: '3px 10px 3px 6px', borderRadius: 999, background: 'var(--primary-soft2)', color: 'var(--primary-700)', fontWeight: 600 }}>
|
||||
{label}
|
||||
<button type="button" onClick={() => removeEntry()}
|
||||
<button type="button" onClick={() => removeEntry({ parentId, childId })}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 16, height: 16, borderRadius: '50%', background: 'var(--primary-soft)', border: 'none', cursor: 'pointer', color: 'var(--primary-600)', padding: 0, fontSize: 12, fontWeight: 700 }}>×</button>
|
||||
</span>
|
||||
);
|
||||
@@ -919,19 +908,15 @@ function EditSpecialtyPicker({ selected, onChange, specialties }: {
|
||||
const rootSel = !hasChildren && isRootSelected(p.id);
|
||||
const isMarked = childSel !== null || rootSel;
|
||||
const isActive = activeParentId === p.id;
|
||||
const hasSelection = selected.length > 0;
|
||||
const isDisabled = hasSelection && !isMarked;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
onClick={() => hasChildren ? setActiveParentId(p.id) : selectRoot(p)}
|
||||
style={{
|
||||
width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 14px', fontSize: 13, border: 'none',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.35 : 1,
|
||||
cursor: 'pointer',
|
||||
background: isActive ? 'var(--primary-soft)' : rootSel ? 'var(--primary-soft)' : 'transparent',
|
||||
color: (isActive || rootSel) ? 'var(--primary-700)' : 'var(--text)',
|
||||
fontWeight: (isActive || rootSel) ? 700 : 400,
|
||||
@@ -964,7 +949,7 @@ function EditSpecialtyPicker({ selected, onChange, specialties }: {
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: '8px 14px', fontSize: 11, fontWeight: 700, color: 'var(--text-3)', background: 'var(--surface-2)', borderBottom: '1px solid var(--border)', letterSpacing: '.2px' }}>
|
||||
انتخاب تخصص — یک مورد
|
||||
انتخاب تخصص — چند مورد مجاز
|
||||
</div>
|
||||
{activeChildren.map(s => {
|
||||
const checked = selected.includes(s.id);
|
||||
@@ -1442,7 +1427,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 sm:mb-1">
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">دکتر {doctor.name}</h1>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">{displayDoctorName(doctor.name)}</h1>
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
{/* Active status badge */}
|
||||
{doctor.active
|
||||
@@ -1720,7 +1705,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
<EditSectionHeader icon={<PhoneIcon style={{ width: 16, height: 16 }} />} title="اطلاعات حساب" />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||||
<EditField label="نام کامل" required error={(register('name') as any)?.formState?.errors?.name?.message}>
|
||||
<input type="text" className="cp-input" placeholder="دکتر ..." {...register('name')} />
|
||||
<input type="text" className="cp-input" placeholder="مثلاً: حامد حسینی" {...register('name')} />
|
||||
</EditField>
|
||||
<EditField label="شماره موبایل مطب">
|
||||
<MobileInput className="cp-input" {...register('mobile_number')} />
|
||||
@@ -1823,7 +1808,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
) : (
|
||||
<EditSpecialtyPicker
|
||||
selected={watchedSpecialties}
|
||||
onChange={ids => setValue('specialties', ids)}
|
||||
onChange={ids => setValue('specialties', ids, { shouldDirty: true })}
|
||||
specialties={specialties}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -327,9 +327,19 @@ export default function DoctorFormPage() {
|
||||
<Field label="شماره موبایل" required error={errors.mobile?.message} hint="اگر کاربر با این شماره وجود دارد پروفایل به او متصل میشود">
|
||||
<MobileInput hasError={!!errors.mobile} {...register('mobile')} />
|
||||
</Field>
|
||||
<Field label="نام کامل" required error={errors.name?.message}>
|
||||
<input type="text" className="cp-input" placeholder="دکتر ..." {...register('name')}
|
||||
style={errors.name ? { borderColor: 'var(--danger)' } : {}} />
|
||||
<Field label="نام کامل" required error={errors.name?.message} hint="عنوان «دکتر» خودکار اضافه میشود؛ لازم نیست تایپ کنید">
|
||||
<div className="cp-input" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: 0, overflow: 'hidden',
|
||||
...(errors.name ? { borderColor: 'var(--danger)' } : {}),
|
||||
}}>
|
||||
<span style={{
|
||||
flexShrink: 0, alignSelf: 'stretch', display: 'flex', alignItems: 'center',
|
||||
padding: '0 12px', background: 'var(--surface-2)', color: 'var(--text-2)',
|
||||
fontWeight: 600, fontSize: 14, borderInlineEnd: '1px solid var(--border)',
|
||||
}}>دکتر</span>
|
||||
<input type="text" placeholder="مثلاً: حامد حسینی" {...register('name')}
|
||||
style={{ flex: 1, minWidth: 0, border: 'none', outline: 'none', background: 'transparent', padding: '0 12px', height: '100%', color: 'inherit', font: 'inherit' }} />
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import { formatDate, formatNumber } from '../lib/utils';
|
||||
import { formatDate, formatNumber, displayDoctorName } from '../lib/utils';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
@@ -376,7 +376,7 @@ export default function DoctorsPage() {
|
||||
<div className="cell-user">
|
||||
<DoctorAvatar name={doc.name} id={doc.id} image={doc.profile_image} />
|
||||
<div>
|
||||
<b>دکتر {doc.name}</b>
|
||||
<b>{displayDoctorName(doc.name)}</b>
|
||||
<br /><small>{doc.gender ?? '—'}</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,7 +478,7 @@ export default function DoctorsPage() {
|
||||
<DoctorAvatar name={doc.name} id={doc.id} image={doc.profile_image} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<b style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
دکتر {doc.name}
|
||||
{displayDoctorName(doc.name)}
|
||||
</b>
|
||||
<span className="muted" style={{ fontSize: 12.5 }}>
|
||||
{doc.specialties[0]?.name ?? '—'}
|
||||
|
||||
+1
-1
@@ -589,7 +589,7 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `type` | string | ✅ | `independent_doctor` \| `doctor_with_clinic` \| `clinic_manager` |
|
||||
| `name` | string | ✅ | min 2 chars |
|
||||
| `name` | string | ✅ | min 2 chars. عنوان «دکتر» ابتدای نام (و پیشوندهای تکراری «دکتر دکتر …») هنگام ثبت حذف میشود؛ نام تمیز ذخیره و در نمایش دوباره «دکتر» افزوده میشود. |
|
||||
| `mobile` | string | ✅ | 10–15 chars |
|
||||
| `info` | string | ❌ | specialty, address, etc. |
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Doctor API
|
||||
|
||||
> **`name` در برابر `display_name`:** `name` نامِ خام است (بدون عنوان — برای فرم ویرایش).
|
||||
> `display_name` نامِ آمادهٔ نمایش با پیشوند «دکتر» است که بکاند تولید میکند
|
||||
> (`PersianText::withDoctorTitle`) تا همهٔ کلاینتها یکسان و بدون «دکتر دکتر …» نشان دهند؛
|
||||
> در لیست و جزئیات پزشک هر دو حاضرند. برای نمایش از `display_name`، برای ویرایش از `name` استفاده کنید.
|
||||
|
||||
> فیلد `owner_status` (`claimed` | `unclaimed` | `pending_transfer`) به خروجی لیست و جزئیات پزشک
|
||||
> اضافه شده است — پروفایل `unclaimed` (ایمپورت نظام پزشکی) در سایت دکمهٔ «تصاحب پروفایل» میگیرد
|
||||
> (`docs/api/doctor-claim.md`) و نوبتدهی آنلاینش غیرفعال است.
|
||||
@@ -94,6 +99,7 @@ Get doctor detail with clinics.
|
||||
"data": {
|
||||
"uuid": "550e8400-...",
|
||||
"name": "علی احمدی",
|
||||
"display_name": "دکتر علی احمدی",
|
||||
"gender": "man",
|
||||
"medical_system_code": "12345",
|
||||
"degree": "specialist",
|
||||
@@ -237,6 +243,7 @@ List doctors with pagination and filters.
|
||||
{
|
||||
"uuid": "...",
|
||||
"name": "علی احمدی",
|
||||
"display_name": "دکتر علی احمدی",
|
||||
"gender": "man",
|
||||
"degree": "specialist",
|
||||
"img": [],
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Captcha\CaptchaGuard;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Util\PersianText;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Sms\Service\SmsService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -46,7 +47,9 @@ class PreRegistrationController extends BaseController
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$type = trim($data['type'] ?? '');
|
||||
$name = trim($data['name'] ?? '');
|
||||
// نام هرگز عنوان «دکتر» را در خود ندارد؛ لایهٔ نمایش آن را میافزاید. اینجا تمیز
|
||||
// میشود تا realName، پزشک و کلینیکِ ساختهشده از این درخواست همگی سازگار باشند.
|
||||
$name = PersianText::stripDoctorTitle(trim($data['name'] ?? ''));
|
||||
$mobile = trim($data['mobile'] ?? '');
|
||||
$info = trim($data['info'] ?? '') ?: null;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\DoctorService\Entity\DoctorService;
|
||||
use App\Location\Entity\City;
|
||||
use App\Location\Entity\Province;
|
||||
use App\Shared\Util\DisplayName;
|
||||
use App\Shared\Util\PersianText;
|
||||
use App\Specialty\Entity\Specialty;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -553,6 +554,8 @@ class Doctor
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
// نامِ آمادهٔ نمایش با عنوان «دکتر»؛ name خام میماند (برای فرم ویرایش).
|
||||
'display_name' => PersianText::withDoctorTitle($this->name),
|
||||
'gender' => $this->gender,
|
||||
'degree' => $this->degree,
|
||||
'img' => $this->images ?? [],
|
||||
@@ -582,6 +585,8 @@ class Doctor
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
// نامِ آمادهٔ نمایش با عنوان «دکتر»؛ name خام میماند (برای فرم ویرایش).
|
||||
'display_name' => PersianText::withDoctorTitle($this->name),
|
||||
'gender' => $this->gender,
|
||||
'experience' => $this->getExperience(),
|
||||
'activity_time' => $this->activityTime !== null ? (string) $this->activityTime : null,
|
||||
|
||||
@@ -63,4 +63,16 @@ final class PersianText
|
||||
// پیشوندهای متوالی «دکتر دکتر …» را هم کامل حذف میکند.
|
||||
return trim(preg_replace('/^(?:دکتر\s+)+/u', '', $normalized) ?? $normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* نامِ نمایشیِ پزشک با عنوان «دکتر» — تنها نقطهٔ افزودن عنوان در بکاند تا همهٔ
|
||||
* مصرفکنندهها (سایت عمومی، پنل، اپ) یکسان و بدون «دکتر دکتر …» ببینند.
|
||||
* name در دیتابیس خام میماند؛ این فقط برای serialization است.
|
||||
*/
|
||||
public static function withDoctorTitle(?string $name): string
|
||||
{
|
||||
$clean = self::stripDoctorTitle((string) ($name ?? ''));
|
||||
|
||||
return $clean === '' ? '' : 'دکتر ' . $clean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Auth;
|
||||
|
||||
use App\Auth\Entity\PreRegistration;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* نام ثبتشده در پیشثبتنام هرگز نباید عنوان «دکتر» را در خود نگه دارد — وگرنه پس از
|
||||
* تأیید، پزشک/کلینیک/realName ساختهشده «دکتر دکتر …» نمایش داده میشوند. عنوان فقط در
|
||||
* لایهٔ نمایش افزوده میشود (displayDoctorName سمت فرانت).
|
||||
*/
|
||||
class PreRegistrationNameTitleTest extends ApiTestCase
|
||||
{
|
||||
private function freshMobile(): string
|
||||
{
|
||||
return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function submit(string $name, string $mobile): array
|
||||
{
|
||||
$this->client->request(
|
||||
'POST',
|
||||
'/api/v1/pre-registration',
|
||||
server: ['CONTENT_TYPE' => 'application/json'],
|
||||
content: json_encode(['type' => 'independent_doctor', 'name' => $name, 'mobile' => $mobile]),
|
||||
);
|
||||
|
||||
return json_decode($this->client->getResponse()->getContent(), true) ?? [];
|
||||
}
|
||||
|
||||
private function storedName(string $mobile): ?string
|
||||
{
|
||||
$this->em->clear();
|
||||
$preReg = $this->em->getRepository(PreRegistration::class)->findOneBy(['mobile' => $mobile]);
|
||||
|
||||
return $preReg?->getName();
|
||||
}
|
||||
|
||||
public function testTitleIsStrippedOnSubmit(): void
|
||||
{
|
||||
$mobile = $this->freshMobile();
|
||||
$this->submit('دکتر حامد حسینی', $mobile);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('حامد حسینی', $this->storedName($mobile));
|
||||
}
|
||||
|
||||
public function testPlainNameIsLeftAlone(): void
|
||||
{
|
||||
$mobile = $this->freshMobile();
|
||||
$this->submit('حامد حسینی', $mobile);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('حامد حسینی', $this->storedName($mobile));
|
||||
}
|
||||
|
||||
public function testRepeatedTitlePrefixIsCollapsed(): void
|
||||
{
|
||||
$mobile = $this->freshMobile();
|
||||
$this->submit('دکتر دکتر حامد حسینی', $mobile);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('حامد حسینی', $this->storedName($mobile));
|
||||
}
|
||||
}
|
||||
@@ -38,4 +38,16 @@ class PersianTextTest extends TestCase
|
||||
{
|
||||
$this->assertFalse(PersianText::sameName('علی اکبری', 'ولی اکبری'));
|
||||
}
|
||||
|
||||
public function testWithDoctorTitlePrependsExactlyOnce(): void
|
||||
{
|
||||
$this->assertSame('دکتر فرخنده حسینی', PersianText::withDoctorTitle('فرخنده حسینی'));
|
||||
// نامِ از پیش عنواندار «دکتر دکتر …» نمیشود
|
||||
$this->assertSame('دکتر فرخنده حسینی', PersianText::withDoctorTitle('دکتر فرخنده حسینی'));
|
||||
$this->assertSame('دکتر فرخنده حسینی', PersianText::withDoctorTitle('دکتر دکتر فرخنده حسینی'));
|
||||
// خالی/نال → رشتهٔ خالی (نه «دکتر» تنها)
|
||||
$this->assertSame('', PersianText::withDoctorTitle(''));
|
||||
$this->assertSame('', PersianText::withDoctorTitle(null));
|
||||
$this->assertSame('', PersianText::withDoctorTitle(' '));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user