feat(secretary): implement multi-doctor assignment for clinic secretaries
- Added functionality to assign a single secretary to multiple doctors within a clinic, allowing for scoped access to appointments. - Introduced `SecretaryService` to handle the logic for assigning and syncing doctors for a secretary. - Updated `SecretaryController` to support multi-doctor assignment via new endpoints and modified existing ones. - Enhanced `DoctorSecretary` entity to include secretary UUID in its serialized output. - Implemented repository methods to facilitate the retrieval and management of doctor-secretary relationships. - Adjusted appointment filtering in `MyAppointmentsController` to ensure secretaries only see appointments for assigned doctors. - Created tests to validate the new multi-doctor assignment functionality and appointment access restrictions. - Updated frontend components to support multi-select for doctors in the secretary management UI.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
# منشیِ مشترک کلینیک: تخصیص یک منشی به چند پزشک با دسترسی محدود
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend Symfony + پنل ادمین React — همان ریپو).
|
||||
|
||||
## زمینه
|
||||
|
||||
در یک کلینیک که چند پزشک دارد، مدیر کلینیک میخواهد **یک منشی را به یک یا چند پزشکِ همان کلینیک** تخصیص دهد، بهطوریکه دسترسی آن منشی **فقط به پزشکانِ تعیینشده** محدود باشد (نه همهی پزشکان کلینیک). این ارتباط باید many-to-many، و بعداً قابل افزودن/حذف بدون تغییر ساختاری باشد.
|
||||
|
||||
**مهم — schema از قبل آماده است:** موجودیت `DoctorSecretary` (`src/Secretary/Entity/DoctorSecretary.php`) یک join row است: `(doctor + secretary User + owner_type[doctor|clinic] + clinic? + permissions json)` با unique روی `(doctor_id, secretary_id, owner_type)`. یعنی یک منشیِ user همین حالا میتواند **چند ردیف** داشته باشد (یکی per پزشک). Repository هم متد `findDoctorsBySecretaryInClinic($user, $clinic)` را دارد که دقیقاً «پزشکانِ تخصیصیافتهی این منشی در کلینیک» را برمیگرداند. **هیچ migration/تغییر schema لازم نیست.**
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
سه شکاف وجود دارد که باید پر شود:
|
||||
|
||||
1. **نوشتن تکپزشکی:** هر مسیر نوشتن فقط یک پزشک میگیرد. `POST /api/v1/secretary` فقط یک `doctor_uuid` میپذیرد؛ برای تخصیص به N پزشک باید N بار صدا زد. هیچ سرویس/endpoint اتمیک برای چند پزشک یا برای «همگامسازی مجموعهی پزشکانِ یک منشی» وجود ندارد. اصلاً پوشهی `src/Secretary/Service/` نیست (منطق داخل کنترلر).
|
||||
2. **UI تکانتخابی:** فرم کلینیک در `MySecretariesPage.tsx` پزشک را تکانتخابی میگیرد؛ multi-select و ویرایش لیست پزشکانِ منشیِ موجود نیست.
|
||||
3. **⚠️ عدم اعمال scope (هستهی خواسته):** منشیِ کلینیک الان **همهی پزشکان کلینیک** را میبیند. لیست نوبت با `d MEMBER OF c.doctors` فیلتر میشود و گیت رزرو فقط عضویت در کلینیک را چک میکند — نه پزشکانِ تخصیصیافته. فقط داشبورد درست scope میشود (`findDoctorsBySecretaryInClinic`). بدون رفع این، «دسترسی محدود به پزشکان تعیینشده» فقط ظاهری است.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Secretary/Entity/DoctorSecretary.php` | join row؛ `OWNER_CLINIC`، `DEFAULT_PERMISSIONS`، `mergePermissions()` |
|
||||
| `src/Secretary/Controller/SecretaryController.php` | `create` (خط ۳۹، تکپزشکی)، `update`/`show`/`deactivate`، `listByClinic` (خط ۲۴۱)، `canManage` (خط ۲۶۲) |
|
||||
| `src/Secretary/Repository/DoctorSecretaryRepository.php` | `findDoctorsBySecretaryInClinic` (خط ۹۲)، `findByClinic` (خط ۱۲۶)، `findActiveBySecretaryForClinic` (خط ۷۶)، `countActiveByDoctor` (خط ۲۴) |
|
||||
| `src/Appointment/Controller/MyAppointmentsController.php` | `resolveSecretaryFilter` (خط ۵۰۴)، اعمال فیلتر clinic (خط ۳۰۳)، `secretaryCanBookForDoctor` (خط ۴۷۳) |
|
||||
| `src/Dashboard/Controller/DashboardController.php` | خط ۴۱۶–۴۳۱ — الگوی درستِ scope با `findDoctorsBySecretaryInClinic` (مرجع کپی) |
|
||||
| `src/Patient/Controller/PatientController.php` | `resolveEntity()` خط ۱۱۹۸ — scope بیمار (clinic vs doctor) |
|
||||
| `assets/admin/pages/MySecretariesPage.tsx` | فرم مدیریت منشی؛ شاخهی clinic (خط ۶۰۴)، picker تکانتخابی (خط ۷۳۷)، create mutation (خط ۶۵۴) |
|
||||
| `assets/admin/types/index.ts` | تایپ `Secretary`/`SecretaryPermissions` (خط ۳۲۶) — بدون آرایهی پزشک |
|
||||
| `docs/api/secretary.md` | مستندات endpointها |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### create تکپزشکی — `SecretaryController.php:39`
|
||||
|
||||
```php
|
||||
#[Route('/api/v1/secretary', methods: ['POST'])]
|
||||
public function create(Request $request, #[CurrentUser] User $currentUser): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? ''); // ← فقط یک پزشک
|
||||
// ...
|
||||
$ownerClinic = null;
|
||||
if ($currentUser->hasRole('ROLE_CLINIC')) {
|
||||
$ownerClinic = $this->clinicRepo->findByUser($currentUser);
|
||||
if ($ownerClinic === null || !$this->secretaryRepo->isDoctorInClinic($doctor, $ownerClinic)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
} // ...
|
||||
// find-or-create secretary user، duplicate guard روی (doctor, secretary, ownerType)
|
||||
$secretary = new DoctorSecretary($doctor, $secretaryUser, $ownerType, $ownerClinic); // ← یک ردیف
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### scope ناقص برای نوبت — `MyAppointmentsController.php:303` و `:504`
|
||||
|
||||
```php
|
||||
// اعمال فیلتر منشیِ کلینیک — همهی پزشکان کلینیک، نه تخصیصیافتهها:
|
||||
if ($filterType === 'clinic') {
|
||||
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
|
||||
->andWhere('c = :clinic')->setParameter('clinic', $filterValue);
|
||||
}
|
||||
```
|
||||
```php
|
||||
private function resolveSecretaryFilter(User $user): ?array {
|
||||
// ...
|
||||
if ($clinic !== null) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic); // فقط «آیا در کلینیک هست»
|
||||
if ($rel === null) return null;
|
||||
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
|
||||
return ['clinic', $clinic, $canView]; // ← مجموعهی پزشکانِ مجاز را حمل نمیکند
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### الگوی درست (داشبورد) — `DashboardController.php:~416`
|
||||
|
||||
```php
|
||||
// scope کلینیکِ منشی را به پزشکانِ تخصیصیافته محدود میکند:
|
||||
$doctors = $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic);
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
> هر وظیفه: پیادهسازی → تست (موفق/خطا/مرزی) → مستند → گزارش. یک وظیفه در هر مرحله.
|
||||
|
||||
### ۱. سرویس منشی + تخصیص چندپزشکیِ اتمیک (Backend)
|
||||
|
||||
- پوشه/کلاس جدید `src/Secretary/Service/SecretaryService.php` بساز و منطق چاق فعلیِ `create` را به آن منتقل کن (SOLID؛ کنترلر فقط HTTP).
|
||||
- متد `assignToDoctors(User $currentUser, string $mobile, array $doctorUuids, array $meta): array` که برای **مدیر کلینیک**:
|
||||
- کلینیکِ مالک را از `$currentUser` میگیرد؛ هر `doctorUuid` باید عضو همان کلینیک باشد (`isDoctorInClinic`) وگرنه ۴۰۳/۴۲۲.
|
||||
- منشیِ user را find-or-create میکند (مثل کد فعلی: نقش `ROLE_SECRETARY`، نام، پسورد اختیاری).
|
||||
- برای هر پزشک یک `DoctorSecretary(doctor, user, OWNER_CLINIC, clinic)` میسازد؛ ردیف تکراری `(doctor, secretary, ownerType)` را **skip** کند (نه خطا).
|
||||
- permissions ورودی را روی همهی ردیفهای ساختهشده اعمال کند (`mergePermissions`).
|
||||
- همه در یک تراکنش؛ خروجی: لیست ردیفهای نهایی + پزشکانِ skipشده.
|
||||
- endpointها:
|
||||
- `POST /api/v1/secretary` را طوری توسعه بده که **علاوه بر** `doctor_uuid` (سازگاری قدیمی)، آرایهی `doctor_uuids: string[]` را هم بپذیرد؛ اگر آرایه آمد و کاربر `ROLE_CLINIC` است → `assignToDoctors`. رفتار تکپزشکیِ فعلی نشکند.
|
||||
- `PUT /api/v1/secretaries/clinic/{clinicUuid}/secretary/{secretaryUuid}/doctors` (یا مسیر مشابه) برای **همگامسازی**: بدنه `doctor_uuids: string[]`؛ ردیفهای owner=clinicِ این منشی در این کلینیک را با مجموعهی جدید sync میکند (افزودن نبودها، حذف/غیرفعالسازیِ اضافهها). گارد: مالک کلینیک یا ادمین (`canManage`).
|
||||
- **سقف پلن:** توجه کن `countActiveByDoctor` per-doctor است؛ تخصیص یک منشی به N پزشک روی سقفِ هر پزشک حساب میشود. همین منطق per-doctor را برای هر پزشک در حلقه چک کن (اگر پزشکی به سقف رسید، همان پزشک را skip و در خروجی گزارش کن، بقیه ادامه یابند).
|
||||
- مستند: `docs/api/secretary.md` — بدنهی جدید، مسیر sync، خطاها، مثال JSON واقعی.
|
||||
- تست PHPUnit (`tests/Secretary/...`): تخصیص چندپزشکی موفق، skipِ تکراری، ۴۰۳ برای پزشکِ خارج از کلینیک، sync (افزودن+حذف)، غیرمالک ۴۰۳.
|
||||
|
||||
### ۲. اعمال scope دسترسی به پزشکانِ تخصیصیافته (Backend) — هسته
|
||||
|
||||
- `resolveSecretaryFilter` (`MyAppointmentsController.php:504`) در شاخهی clinic: بهجای بازگرداندن فقط Clinic، **مجموعهی پزشکانِ تخصیصیافته** را با `findDoctorsBySecretaryInClinic($user, $clinic)` بگیر و در خروجی حمل کن (مثلاً `['clinic', $clinic, $canView, $doctorIds]`).
|
||||
- اعمال فیلتر (`:303`): بهجای `d MEMBER OF c.doctors` برای همهی کلینیک، `a.doctor IN (:doctorIds)` با آن مجموعه. اگر مجموعه خالی بود → صفحهی خالی.
|
||||
- `secretaryCanBookForDoctor` (`:473`) در شاخهی clinic: علاوه بر عضویت در کلینیک و permission، چک کن `$doctor` در `findDoctorsBySecretaryInClinic` باشد.
|
||||
- مسیرهای مشابه را هم همسو کن: `PatientController::resolveEntity` (`:1198`) و Billing clinic-scope (اگر منشیِ کلینیک بیمار/مالی میبیند) باید به همان مجموعهی پزشکان محدود شوند. الگو را از `DashboardController.php:~416` (که درست است) کپی کن — منطق مشترک را در سرویس/متد کمکی بگذار، نه کپیِ پراکنده.
|
||||
- تست: منشیِ تخصیصیافته به پزشک A (نه B) در کلینیکِ دارای A و B → لیست نوبت فقط A؛ رزرو برای B ممنوع؛ داشبورد و لیست همخوان.
|
||||
|
||||
### ۳. Frontend — انتخاب چند پزشک و ویرایش تخصیص
|
||||
|
||||
- در `MySecretariesPage.tsx` شاخهی `isClinic`:
|
||||
- picker پزشک را از تکانتخابی به **multi-select** تبدیل کن (چکلیستِ پزشکانِ کلینیک؛ از الگوی `MultiCheckList`/`SearchableSelect` موجود استفاده کن — طبق قانون پروژه از `SearchableSelect` استفاده شود، نه `<select>` بومی).
|
||||
- create mutation بهجای `doctor_uuid` تکی، `doctor_uuids: string[]` بفرستد.
|
||||
- برای منشیِ موجود، امکان ویرایش مجموعهی پزشکان (فراخوانی endpoint sync وظیفهی ۱). نمایش پزشکانِ فعلیِ هر منشی (از `listByClinic` که ردیفها را per پزشک میدهد — گروهبندی بر اساس منشی/موبایل).
|
||||
- نمایش پیام برای پزشکانِ skipشده بهخاطر سقف پلن.
|
||||
- تایپ `Secretary` در `types/index.ts`: افزودن `doctors?: { uuid: string; name: string }[]` یا `doctor_uuids`.
|
||||
- تست vitest: رندر multi-select، ارسال آرایه در mutation، گروهبندی منشی با چند پزشک. (تستها روی host اجرا شوند: `npx vitest --run <file>` — node_modules داخل ddev برای esbuild لینوکسی نیست.)
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **بدون تغییر schema.** فقط ردیفهای `DoctorSecretary` اضافه/حذف میشوند. اگر لازم شد ردیف حذف شود، هماهنگ با رفتار فعلی `deactivate` (soft `setActive(false)`) تصمیم بگیر — برای sync، حذف واقعی یا غیرفعالسازی را یکدست انتخاب کن و مستند کن.
|
||||
- **سازگاری قدیمی:** مسیر تکپزشکیِ `doctor_uuid` و جریان منشیِ owner=doctor نباید بشکند.
|
||||
- **envelope:** پاسخها با `$this->success(...)`/`$this->paginated(...)`؛ لیستهای admin با array hydration. سمت فرانت single = `data?.data` (ممکن double-nested)، paginated items = `data?.data`.
|
||||
- **context منشی:** ورود منشی یک context per کلینیک میسازد (`AuthController.php:~737`)؛ scope در هر request از `UserActiveContext` خوانده میشود. تغییرات وظیفهی ۲ در همان لایهی resolve اعمال شود، نه در ساخت context.
|
||||
- **permissionها:** ماتریس دسترسی منشی (`DEFAULT_PERMISSIONS`) دستنخورده؛ scopeِ پزشک یک لایهی مستقل و مقدم بر permission است.
|
||||
- **RTL/فارسی، تاریخها Unix + شمسی.**
|
||||
- بعد از اتمام: `graphify update .` (طبق قانون؛ اول commit).
|
||||
@@ -5,7 +5,6 @@ import { toast } from "sonner";
|
||||
import SettingsLayout from "../components/layout/SettingsLayout";
|
||||
import ConfirmDialog from "../components/ui/ConfirmDialog";
|
||||
import Modal from "../components/ui/Modal";
|
||||
import SearchableSelect from "../components/ui/SearchableSelect";
|
||||
import type { ApiResponse } from "../lib/api";
|
||||
import { api } from "../lib/api";
|
||||
import { formatDate } from "../lib/utils";
|
||||
@@ -284,6 +283,8 @@ function SecretaryModal({
|
||||
mode,
|
||||
data,
|
||||
saving,
|
||||
isClinic,
|
||||
clinicDoctors,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
@@ -291,8 +292,10 @@ function SecretaryModal({
|
||||
mode: ModalMode;
|
||||
data: Secretary | null;
|
||||
saving: boolean;
|
||||
isClinic: boolean;
|
||||
clinicDoctors: ClinicDoctor[];
|
||||
onClose: () => void;
|
||||
onSubmit: (form: FormState) => void;
|
||||
onSubmit: (form: FormState, doctorUuids: string[]) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<FormState>({
|
||||
name: "",
|
||||
@@ -302,9 +305,14 @@ function SecretaryModal({
|
||||
address: "",
|
||||
permission: EMPTY_PERMISSIONS,
|
||||
});
|
||||
const [doctorUuids, setDoctorUuids] = useState<string[]>([]);
|
||||
|
||||
// نمایش انتخاب چند پزشک فقط هنگام افزودنِ منشیِ کلینیک
|
||||
const showDoctorPicker = isClinic && mode === "add";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDoctorUuids([]);
|
||||
if ((mode === "edit" || mode === "view") && data) {
|
||||
const parts = (data.user_name ?? "").split(" ");
|
||||
setForm({
|
||||
@@ -357,7 +365,9 @@ function SecretaryModal({
|
||||
if (!form.telephone.trim()) return toast.error("لطفاً شماره تلفن را وارد کنید");
|
||||
if (!/^09\d{9}$/.test(form.telephone))
|
||||
return toast.error("شماره تلفن باید 11 رقم و با 09 شروع شود");
|
||||
onSubmit(form);
|
||||
if (showDoctorPicker && doctorUuids.length === 0)
|
||||
return toast.error("حداقل یک پزشک را انتخاب کنید");
|
||||
onSubmit(form, doctorUuids);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -388,6 +398,22 @@ function SecretaryModal({
|
||||
}
|
||||
>
|
||||
<div dir="rtl" className="flex flex-col justify-start items-start gap-[24px] w-full">
|
||||
{/* انتخاب پزشکان (فقط افزودن منشیِ کلینیک) */}
|
||||
{showDoctorPicker && (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-[16px]">
|
||||
<p className="text-[#525252] dark:text-[#D7D8ED] text-[16px] font-bold">
|
||||
پزشکانِ این منشی
|
||||
</p>
|
||||
<span className="text-[13px] text-[#7E7E7E]">{doctorUuids.length} انتخابشده</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-[#7E7E7E] mb-[12px]">
|
||||
منشی فقط به نوبتها و اطلاعاتِ پزشکانِ انتخابشده دسترسی خواهد داشت.
|
||||
</p>
|
||||
<DoctorMultiSelect doctors={clinicDoctors} selected={doctorUuids} onChange={setDoctorUuids} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* اطلاعات پایه */}
|
||||
<div className="w-full">
|
||||
<p className="text-[#525252] dark:text-[#D7D8ED] text-[16px] font-bold mb-[16px]">
|
||||
@@ -599,6 +625,97 @@ interface ClinicDoctor {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// چکلیستِ چند-انتخابی پزشکان کلینیک برای تخصیص یک منشیِ مشترک
|
||||
function DoctorMultiSelect({
|
||||
doctors,
|
||||
selected,
|
||||
onChange,
|
||||
}: {
|
||||
doctors: ClinicDoctor[];
|
||||
selected: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const filtered = q ? doctors.filter((d) => d.name.includes(q)) : doctors;
|
||||
const toggle = (uuid: string) =>
|
||||
onChange(selected.includes(uuid) ? selected.filter((x) => x !== uuid) : [...selected, uuid]);
|
||||
const allSelected = doctors.length > 0 && selected.length === doctors.length;
|
||||
const toggleAll = () => onChange(allSelected ? [] : doctors.map((d) => d.uuid));
|
||||
|
||||
return (
|
||||
<div className="w-full border border-[#EFEFEF] dark:border-[#343645] rounded-[8px] overflow-hidden">
|
||||
{/* هدر: جستجو + انتخاب همه */}
|
||||
<div className="flex items-center gap-[8px] p-[10px] border-b border-[#EFEFEF] dark:border-[#343645] bg-[#FAFAFC] dark:bg-[#222433]">
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" className="flex-shrink-0">
|
||||
<path d="M9 16A7 7 0 109 2a7 7 0 000 14zM18 18l-3.5-3.5" stroke="#9A9AB0" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="جستجوی پزشک..."
|
||||
className="flex-1 text-[13px] bg-transparent outline-none text-[#525252] dark:text-[#D7D8ED]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className="text-[12px] text-[#5559CE] font-medium whitespace-nowrap cursor-pointer"
|
||||
>
|
||||
{allSelected ? "لغو همه" : "انتخاب همه"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* چیپهای انتخابشده */}
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-[6px] p-[10px] border-b border-[#EFEFEF] dark:border-[#343645]">
|
||||
{selected.map((uuid) => {
|
||||
const d = doctors.find((x) => x.uuid === uuid);
|
||||
if (!d) return null;
|
||||
return (
|
||||
<span
|
||||
key={uuid}
|
||||
onClick={() => toggle(uuid)}
|
||||
className="inline-flex items-center gap-[4px] bg-[#EEF0FF] dark:bg-[#33365A] text-[#5559CE] dark:text-[#C7CEF4] text-[12px] px-[8px] py-[3px] rounded-full cursor-pointer"
|
||||
>
|
||||
{d.name}
|
||||
<span className="text-[14px] leading-none">×</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* لیست پزشکان */}
|
||||
<div className="max-h-[220px] overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-center text-[12px] text-[#7E7E7E] py-[14px]">نتیجهای یافت نشد</p>
|
||||
) : (
|
||||
filtered.map((d) => {
|
||||
const checked = selected.includes(d.uuid);
|
||||
return (
|
||||
<label
|
||||
key={d.uuid}
|
||||
className={
|
||||
"flex items-center gap-[10px] px-[12px] py-[9px] cursor-pointer border-b border-[#F2F2F6] dark:border-[#2A2C3A] last:border-b-0 " +
|
||||
(checked ? "bg-[#F5F6FF] dark:bg-[#2A2D45]" : "hover:bg-[#FAFAFC] dark:hover:bg-[#2A2C3A]")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-[#5559CE] w-[16px] h-[16px]"
|
||||
checked={checked}
|
||||
onChange={() => toggle(d.uuid)}
|
||||
/>
|
||||
<Avatar name={d.name} size={26} />
|
||||
<span className="text-[13px] text-[#525252] dark:text-[#D7D8ED]">{d.name}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MySecretariesPageContent() {
|
||||
const qc = useQueryClient();
|
||||
const { doctorUuid, dbUuid, primaryRole } = useAuthStore();
|
||||
@@ -606,8 +723,7 @@ function MySecretariesPageContent() {
|
||||
const { maxSecretaries } = useSubscription();
|
||||
|
||||
const [tab, setTab] = useState(0); // 0: منشی های فعلی، 1: منشی های قبلی
|
||||
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>("");
|
||||
const activeDoctorUuid = isClinic ? selectedDoctorUuid : (doctorUuid ?? "");
|
||||
const activeDoctorUuid = doctorUuid ?? "";
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<ModalMode>("add");
|
||||
@@ -615,7 +731,7 @@ function MySecretariesPageContent() {
|
||||
const [deactivateTarget, setDeactivateTarget] = useState<Secretary | null>(null);
|
||||
|
||||
// clinic: list of doctors
|
||||
const { data: clinicDoctorsData, isLoading: clinicDoctorsLoading } = useQuery<
|
||||
const { data: clinicDoctorsData } = useQuery<
|
||||
ApiResponse<{ data: ClinicDoctor[] }>
|
||||
>({
|
||||
queryKey: ["clinic-doctors", dbUuid],
|
||||
@@ -652,17 +768,28 @@ function MySecretariesPageContent() {
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (form: FormState) =>
|
||||
api.post("/api/v1/secretary", {
|
||||
doctor_uuid: activeDoctorUuid,
|
||||
mutationFn: ({ form, doctorUuids }: { form: FormState; doctorUuids: string[] }) => {
|
||||
const base = {
|
||||
mobile_number: form.telephone,
|
||||
name: `${form.name} ${form.family}`.trim(),
|
||||
national_code: form.national_code || null,
|
||||
address: form.address || null,
|
||||
permissions: { version: 1, resources: form.permission },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("منشی با موفقیت اضافه شد");
|
||||
};
|
||||
return api.post<ApiResponse<any>>(
|
||||
"/api/v1/secretary",
|
||||
isClinic
|
||||
? { ...base, doctor_uuids: doctorUuids }
|
||||
: { ...base, doctor_uuid: activeDoctorUuid },
|
||||
);
|
||||
},
|
||||
onSuccess: (res: ApiResponse<any>) => {
|
||||
const skippedLimit = res?.data?.skipped_limit?.length ?? 0;
|
||||
if (isClinic && skippedLimit > 0) {
|
||||
toast.warning(`${skippedLimit} پزشک بهدلیل محدودیت پلن اضافه نشد`);
|
||||
} else {
|
||||
toast.success("منشی با موفقیت اضافه شد");
|
||||
}
|
||||
setModalOpen(false);
|
||||
invalidate();
|
||||
},
|
||||
@@ -699,7 +826,6 @@ function MySecretariesPageContent() {
|
||||
const saving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const handleAddClick = () => {
|
||||
if (isClinic && !selectedDoctorUuid) return toast.error("ابتدا یک پزشک را انتخاب کنید");
|
||||
if (atLimit) return toast.error(`حداکثر ${maxSecretaries} منشی مجاز است؛ برای افزودن، پنل را ارتقا دهید`);
|
||||
setModalMode("add");
|
||||
setSelected(null);
|
||||
@@ -712,8 +838,8 @@ function MySecretariesPageContent() {
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModalSubmit = (form: FormState) => {
|
||||
if (modalMode === "add") createMutation.mutate(form);
|
||||
const handleModalSubmit = (form: FormState, doctorUuids: string[]) => {
|
||||
if (modalMode === "add") createMutation.mutate({ form, doctorUuids });
|
||||
else if (modalMode === "edit" && selected) updateMutation.mutate({ uuid: selected.uuid, form });
|
||||
};
|
||||
|
||||
@@ -723,27 +849,6 @@ function MySecretariesPageContent() {
|
||||
<p className="text-[#525252] dark:text-[#D7D8ED] text-[20px] font-bold">لیست منشی ها</p>
|
||||
</div>
|
||||
|
||||
{/* کلینیک: انتخاب پزشک */}
|
||||
{isClinic && (
|
||||
<div className="mt-[16px] max-w-[360px]">
|
||||
<label className="text-[#525252] dark:text-[#D7D8ED] text-[14px] font-medium block mb-[8px]">
|
||||
پزشک مورد نظر برای افزودن منشی جدید
|
||||
</label>
|
||||
{clinicDoctorsLoading ? (
|
||||
<p className="text-[13px] text-[#7E7E7E]">در حال بارگذاری...</p>
|
||||
) : clinicDoctors.length === 0 ? (
|
||||
<p className="text-[13px] text-[#7E7E7E]">هیچ پزشکی در این کلینیک تعریف نشده است.</p>
|
||||
) : (
|
||||
<SearchableSelect
|
||||
options={clinicDoctors.map((d) => ({ value: d.uuid, label: d.name }))}
|
||||
value={selectedDoctorUuid}
|
||||
onChange={(v) => setSelectedDoctorUuid(v ? String(v) : "")}
|
||||
placeholder="یک پزشک را انتخاب کنید"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* تبها + دکمه افزودن */}
|
||||
<div className="w-full flex items-end justify-between mt-[20px]">
|
||||
<div className="flex items-center gap-[8px] border-b border-[#EFEFEF] dark:border-[#343645]">
|
||||
@@ -764,7 +869,6 @@ function MySecretariesPageContent() {
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddClick}
|
||||
disabled={isClinic && !selectedDoctorUuid}
|
||||
className="shadow-none gap-[8px] bg-[#5559CE] text-[#EFEFEF] text-[14px] md:text-[15px] lg:text-[16px]
|
||||
font-medium py-[10px] px-[16px] h-[43px] md:h-[45px] lg:h-[48px] rounded-[4px] cursor-pointer
|
||||
flex items-center disabled:opacity-60"
|
||||
@@ -813,6 +917,8 @@ function MySecretariesPageContent() {
|
||||
mode={modalMode}
|
||||
data={selected}
|
||||
saving={saving}
|
||||
isClinic={isClinic}
|
||||
clinicDoctors={clinicDoctors}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSubmit={handleModalSubmit}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { screen, fireEvent } from "@testing-library/react";
|
||||
import { renderWithProviders } from "../test/utils";
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } }));
|
||||
vi.mock("../lib/api", () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
vi.mock("../stores/authStore", () => ({
|
||||
useAuthStore: () => ({ doctorUuid: null, dbUuid: "clinic-1", primaryRole: "clinic" }),
|
||||
}));
|
||||
vi.mock("../hooks/useSubscription", () => ({
|
||||
useSubscription: () => ({ maxSecretaries: 5 }),
|
||||
}));
|
||||
|
||||
import { api } from "../lib/api";
|
||||
import MySecretariesPage from "./MySecretariesPage";
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.includes("/clinic/doctor-list/"))
|
||||
return Promise.resolve({ success: true, data: { data: [
|
||||
{ uuid: "doc-a", name: "دکتر الف" },
|
||||
{ uuid: "doc-b", name: "دکتر ب" },
|
||||
] } });
|
||||
if (url.includes("/secretaries/clinic/")) return Promise.resolve({ success: true, data: [] });
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("MySecretariesPage — clinic multi-doctor", () => {
|
||||
it("shows the doctor multi-select inside the add modal", async () => {
|
||||
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
|
||||
// پیکر روی صفحه نیست تا وقتی مودال باز شود
|
||||
expect(screen.queryByText("پزشکانِ این منشی")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(await screen.findByText("اضافه کردن منشی"));
|
||||
|
||||
expect(await screen.findByText("پزشکانِ این منشی")).toBeInTheDocument();
|
||||
expect(screen.getByText("دکتر الف")).toBeInTheDocument();
|
||||
expect(screen.getByText("دکتر ب")).toBeInTheDocument();
|
||||
expect(screen.getByText("انتخاب همه")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting all picks every clinic doctor", async () => {
|
||||
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
|
||||
fireEvent.click(await screen.findByText("اضافه کردن منشی"));
|
||||
await screen.findByText("پزشکانِ این منشی");
|
||||
|
||||
fireEvent.click(screen.getByText("انتخاب همه"));
|
||||
expect(screen.getByText(/[۲2] انتخابشده/)).toBeInTheDocument();
|
||||
expect(screen.getByText("لغو همه")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -383,6 +383,7 @@ export interface Blog {
|
||||
|
||||
export interface Secretary {
|
||||
uuid: string;
|
||||
secretary_uuid?: string;
|
||||
user_name: string;
|
||||
mobile_number: string;
|
||||
doctor_name: string;
|
||||
|
||||
@@ -133,6 +133,10 @@ services:
|
||||
arguments:
|
||||
$appUrl: '%env(APP_BASE_URL)%'
|
||||
|
||||
App\Secretary\Service\SecretaryService:
|
||||
arguments:
|
||||
$appUrl: '%env(APP_BASE_URL)%'
|
||||
|
||||
App\Auth\Controller\PreRegistrationController:
|
||||
arguments:
|
||||
$appUrl: '%env(APP_BASE_URL)%'
|
||||
|
||||
+81
-2
@@ -14,6 +14,7 @@
|
||||
- یک منشی میتواند هم در مطب شخصی یک دکتر و هم در کلینیک همان دکتر فعال باشد (دو ردیف مجزا)
|
||||
- منشی کلینیک میتواند به چند دکتر در همان کلینیک متصل باشد
|
||||
- scope فعال در runtime از جدول `user_active_context` (db_uuid) خوانده میشود
|
||||
- **محدودسازی به پزشکانِ تخصیصیافته:** منشیِ کلینیک فقط نوبتهای پزشکانی را میبیند/رزرو میکند که واقعاً به او تخصیص داده شدهاند — نه همهی پزشکان کلینیک. لیست نوبت (`GET /api/v1/my/appointments`) با `a.doctor IN (پزشکانِ تخصیصیافته)` فیلتر میشود و گیت رزرو (`POST /api/v1/my/appointment`) رابطهی فعالِ همان (منشی، کلینیک، پزشک) را چک میکند. permission رزرو از همان ردیفِ پزشک خوانده میشود
|
||||
|
||||
Secretaries are linked to a doctor and have granular permissions controlling what they can do on behalf of the doctor.
|
||||
|
||||
@@ -76,7 +77,8 @@ Create a secretary for a doctor.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------------- | ------------- | -------- | -------------------------------------------- |
|
||||
| `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to |
|
||||
| `doctor_uuid` | string (UUID) | ✅\* | Single doctor to assign (legacy/doctor flow) |
|
||||
| `doctor_uuids` | string[] (UUID) | ✅\* | **Clinic only** — assign one secretary to several clinic doctors at once. When present (non-empty) and caller is `ROLE_CLINIC`, this multi-doctor path is used instead of `doctor_uuid` |
|
||||
| `mobile_number` | string | ✅ | Secretary's login mobile |
|
||||
| `name` | string | ❌ | Full name (نام + نام خانوادگی) → `user_name` |
|
||||
| `national_code` | string | ❌ | کد ملی منشی (nullable) |
|
||||
@@ -84,6 +86,25 @@ Create a secretary for a doctor.
|
||||
| `password` | string | ❌ | Initial password (auto-generated if omitted) |
|
||||
| `permissions` | object | ❌ | Permission set (see structure below) |
|
||||
|
||||
\* یکی از `doctor_uuid` (تکپزشکی) یا `doctor_uuids` (چندپزشکیِ کلینیک) الزامی است.
|
||||
|
||||
**پاسخِ حالت چندپزشکی (`doctor_uuids` + `ROLE_CLINIC`) — `201`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"secretary_uuid": "550e8400-...",
|
||||
"created": [ { "uuid": "...", "secretary_uuid": "...", "doctor_uuid": "...", "...": "..." } ],
|
||||
"skipped_duplicate": [],
|
||||
"skipped_limit": [],
|
||||
"skipped_not_in_clinic": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `created`: ردیفهای تازهساخته/فعالشده · `skipped_duplicate`: قبلاً متصل بوده · `skipped_limit`: سقفِ پلنِ آن پزشک پر است · `skipped_not_in_clinic`: پزشک عضو کلینیک نیست. حلقه اتمیک است و بقیهی پزشکان ادامه مییابند.
|
||||
|
||||
**Permissions Structure:**
|
||||
|
||||
مجموعهٔ منابع (resources) بر اساس صفحات موجود پنل ادمین است. `mergePermissions` هر منبع/اکشن ارسالشده را deep-merge میکند؛ فقط `appointments` در بکاند enforce میشود (`MyAppointmentsController`, `DashboardController`)، بقیه UI/ذخیرهای هستند.
|
||||
@@ -344,6 +365,7 @@ Get all secretaries across **all doctors** of a clinic.
|
||||
"data": [
|
||||
{
|
||||
"uuid": "...",
|
||||
"secretary_uuid": "...",
|
||||
"user_name": "علی محمدی",
|
||||
"mobile_number": "09...",
|
||||
"doctor_name": "دکتر احمد رضایی",
|
||||
@@ -360,7 +382,7 @@ Get all secretaries across **all doctors** of a clinic.
|
||||
|
||||
- این endpoint فقط منشی های را برمیگرداند که با `owner_type='clinic'` تعریف شدهاند
|
||||
- منشی های که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند
|
||||
- یک منشی میتواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر میشود (یک ردیف به ازای هر دکتر)
|
||||
- یک منشی میتواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر میشود (یک ردیف به ازای هر دکتر). برای گروهبندی «یک منشی، چند پزشک» از `secretary_uuid` (uuid کاربرِ منشی) استفاده کنید
|
||||
|
||||
### Errors
|
||||
|
||||
@@ -372,6 +394,63 @@ Get all secretaries across **all doctors** of a clinic.
|
||||
|
||||
---
|
||||
|
||||
## PUT `/api/v1/secretaries/clinic/{clinicUuid}/doctors`
|
||||
|
||||
همگامسازی مجموعهی پزشکانِ یک منشیِ کلینیک (owner_type='clinic'): پزشکانِ خواستهشده افزوده/فعال و بقیه غیرفعال میشوند. برای «افزودن/حذف پزشک از یک منشی موجود» بدون تغییر ساختاری.
|
||||
|
||||
**Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN`
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Param | Type | Description |
|
||||
| ------------ | ------------- | ----------- |
|
||||
| `clinicUuid` | string (UUID) | Clinic UUID |
|
||||
|
||||
### Request Body (`application/json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"secretary_uuid": "550e8400-...",
|
||||
"doctor_uuids": ["uuid-doc-a", "uuid-doc-b"]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ---------------- | --------------- | -------- | ------------------------------------------------------------ |
|
||||
| `secretary_uuid` | string (UUID) | ✅ | uuid کاربرِ منشی (همان `secretary_uuid` خروجی لیست/ساخت) |
|
||||
| `doctor_uuids` | string[] (UUID) | ✅ | مجموعهی نهاییِ پزشکان؛ نبودها افزوده، اضافهها غیرفعال میشوند |
|
||||
|
||||
### Response `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"added": 1,
|
||||
"removed": 1,
|
||||
"skipped_limit": [],
|
||||
"skipped_not_in_clinic": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------------- | -------- | ------------------------------------------------ |
|
||||
| `added` | int | تعداد ردیفهای افزوده/فعالشده |
|
||||
| `removed` | int | تعداد ردیفهای غیرفعالشده |
|
||||
| `skipped_limit` | string[] | uuid پزشکانی که به سقفِ پلن رسیدهاند (نادیده گرفته) |
|
||||
| `skipped_not_in_clinic` | string[] | uuid پزشکانی که عضو این کلینیک نیستند |
|
||||
|
||||
### Errors
|
||||
|
||||
| Code | HTTP | Description |
|
||||
| -------------------- | ---- | ------------------------------------ |
|
||||
| `ERR_AUTH_006` | 403 | Not clinic owner nor admin |
|
||||
| `ERR_VALIDATION_001` | 422 | `secretary_uuid`/`doctor_uuids` missing |
|
||||
| `ERR_VALIDATION_002` | 404 | Clinic or secretary not found |
|
||||
|
||||
---
|
||||
|
||||
## محدودیت پنل اشتراکی
|
||||
|
||||
تعداد منشیهای مجاز بر اساس پنل فعال doctor تعیین میشود:
|
||||
|
||||
@@ -301,9 +301,12 @@ class MyAppointmentsController extends BaseController
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
if ($filterType === 'clinic') {
|
||||
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
|
||||
->andWhere('c = :clinic')
|
||||
->setParameter('clinic', $filterValue);
|
||||
// $filterValue = آرایهی idهای پزشکانِ تخصیصیافته به این منشی در کلینیک
|
||||
if (empty($filterValue)) {
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
$qb->andWhere('a.doctor IN (:doctorIds)')
|
||||
->setParameter('doctorIds', $filterValue);
|
||||
} else {
|
||||
$qb->andWhere('a.doctor = :doctor')
|
||||
->setParameter('doctor', $filterValue);
|
||||
@@ -479,10 +482,8 @@ class MyAppointmentsController extends BaseController
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
if (!$clinic->getDoctors()->contains($doctor)) {
|
||||
return false;
|
||||
}
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
|
||||
// منشی فقط برای پزشکانِ تخصیصیافتهی خودش میتواند رزرو کند، نه کل کلینیک
|
||||
$rel = $this->secretaryRepo->findActiveClinicRow($user, $clinic, $doctor);
|
||||
return $rel !== null && (bool) ($rel->getPermissions()['resources']['appointments']['create'] ?? false);
|
||||
}
|
||||
|
||||
@@ -510,13 +511,17 @@ class MyAppointmentsController extends BaseController
|
||||
return null;
|
||||
}
|
||||
|
||||
// بررسی scope کلینیک
|
||||
// بررسی scope کلینیک — فقط پزشکانِ تخصیصیافته به این منشی، نه کل کلینیک
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
|
||||
if ($rel === null) return null;
|
||||
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
|
||||
return ['clinic', $clinic, $canView];
|
||||
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
|
||||
$doctorIds = array_map(
|
||||
fn(Doctor $d) => $d->getId(),
|
||||
$this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic)
|
||||
);
|
||||
return ['clinic', $doctorIds, $canView];
|
||||
}
|
||||
|
||||
// بررسی scope مطب شخصی
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Secretary\Service\SecretaryService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Sms\Entity\SmsLog;
|
||||
@@ -33,6 +34,7 @@ class SecretaryController extends BaseController
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly SecretaryService $secretaryService,
|
||||
private readonly string $appUrl,
|
||||
) {}
|
||||
|
||||
@@ -43,6 +45,12 @@ class SecretaryController extends BaseController
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$mobile = trim($data['mobile_number'] ?? '');
|
||||
|
||||
// تخصیص چندپزشکی توسط مدیر کلینیک
|
||||
$doctorUuids = $data['doctor_uuids'] ?? null;
|
||||
if (is_array($doctorUuids) && $doctorUuids !== []) {
|
||||
return $this->createForClinicDoctors($currentUser, $mobile, $doctorUuids, $data);
|
||||
}
|
||||
|
||||
if (empty($doctorUuid) || empty($mobile)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid و mobile_number الزامی است', 422);
|
||||
}
|
||||
@@ -258,6 +266,71 @@ class SecretaryController extends BaseController
|
||||
return $this->success($secretaries);
|
||||
}
|
||||
|
||||
/** تخصیص یک منشی به چند پزشکِ کلینیک (فقط مدیر کلینیک) */
|
||||
private function createForClinicDoctors(User $currentUser, string $mobile, array $doctorUuids, array $data): JsonResponse
|
||||
{
|
||||
if (empty($mobile)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'mobile_number الزامی است', 422);
|
||||
}
|
||||
if (!$currentUser->hasRole('ROLE_CLINIC')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
$clinic = $this->clinicRepo->findByUser($currentUser);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$result = $this->secretaryService->assignToClinicDoctors($clinic, $mobile, $doctorUuids, [
|
||||
'name' => $data['name'] ?? null,
|
||||
'password' => $data['password'] ?? null,
|
||||
'national_code' => $data['national_code'] ?? null,
|
||||
'address' => $data['address'] ?? null,
|
||||
'permissions' => $data['permissions'] ?? null,
|
||||
]);
|
||||
|
||||
return $this->success([
|
||||
'secretary_uuid' => $result['secretary']->getUuid(),
|
||||
'created' => array_map(fn(DoctorSecretary $s) => $s->toArray(), $result['created']),
|
||||
'skipped_duplicate' => $result['skipped_duplicate'],
|
||||
'skipped_limit' => $result['skipped_limit'],
|
||||
'skipped_not_in_clinic' => $result['skipped_not_in_clinic'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
/** همگامسازی مجموعهی پزشکانِ یک منشیِ کلینیک */
|
||||
#[Route('/api/v1/secretaries/clinic/{clinicUuid}/doctors', methods: ['PUT'])]
|
||||
public function syncClinicDoctors(string $clinicUuid, Request $request, #[CurrentUser] User $currentUser): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
if ($clinic->getUser()->getId() !== $currentUser->getId() && !$currentUser->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$secretaryUuid = trim($data['secretary_uuid'] ?? '');
|
||||
$doctorUuids = $data['doctor_uuids'] ?? null;
|
||||
if (empty($secretaryUuid) || !is_array($doctorUuids)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'secretary_uuid و doctor_uuids الزامی است', 422);
|
||||
}
|
||||
|
||||
$secretaryUser = $this->userRepo->findByUuid($secretaryUuid);
|
||||
if ($secretaryUser === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منشی یافت نشد', 404);
|
||||
}
|
||||
|
||||
$result = $this->secretaryService->syncClinicDoctors($clinic, $secretaryUser, $doctorUuids);
|
||||
|
||||
return $this->success([
|
||||
'added' => count($result['added']),
|
||||
'removed' => count($result['removed']),
|
||||
'skipped_limit' => $result['skipped_limit'],
|
||||
'skipped_not_in_clinic' => $result['skipped_not_in_clinic'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** بررسی دسترسی برای ویرایش/حذف یک رابطه منشی — scope-aware */
|
||||
private function canManage(DoctorSecretary $secretary, User $user): bool
|
||||
{
|
||||
|
||||
@@ -127,6 +127,7 @@ class DoctorSecretary
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'secretary_uuid' => $this->secretary->getUuid(),
|
||||
'user_name' => $this->secretary->getRealName(),
|
||||
'mobile_number' => $this->secretary->getMobileNumber(),
|
||||
'doctor_name' => $this->doctor->getName(),
|
||||
|
||||
@@ -88,12 +88,25 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/** رابطه فعالِ owner=clinic برای یک (منشی، کلینیک، پزشک) مشخص */
|
||||
public function findActiveClinicRow(User $user, Clinic $clinic, Doctor $doctor): ?DoctorSecretary
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'secretary' => $user,
|
||||
'clinic' => $clinic,
|
||||
'doctor' => $doctor,
|
||||
'ownerType' => DoctorSecretary::OWNER_CLINIC,
|
||||
'active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/** همه دکترهای کلینیک که این منشی به آنها متصل است */
|
||||
public function findDoctorsBySecretaryInClinic(User $user, Clinic $clinic): array
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
return $this->getEntityManager()->createQueryBuilder()
|
||||
->select('d')
|
||||
->join('s.doctor', 'd')
|
||||
->from(Doctor::class, 'd')
|
||||
->join(DoctorSecretary::class, 's', 'WITH', 's.doctor = d')
|
||||
->where('s.secretary = :user')
|
||||
->andWhere('s.clinic = :clinic')
|
||||
->andWhere('s.ownerType = :type')
|
||||
@@ -122,6 +135,22 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
|
||||
return $clinic->getDoctors()->contains($doctor);
|
||||
}
|
||||
|
||||
/** روابط owner_type='clinic' یک منشی مشخص در یک کلینیک (فعال و غیرفعال، برای sync) */
|
||||
public function findByClinicAndSecretary(Clinic $clinic, User $secretary): array
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->addSelect('doc')
|
||||
->join('s.doctor', 'doc')
|
||||
->where('s.clinic = :clinic')
|
||||
->andWhere('s.secretary = :secretary')
|
||||
->andWhere('s.ownerType = :type')
|
||||
->setParameter('clinic', $clinic)
|
||||
->setParameter('secretary', $secretary)
|
||||
->setParameter('type', DoctorSecretary::OWNER_CLINIC)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** همه منشی ها کلینیک (owner_type='clinic') */
|
||||
public function findByClinic(Clinic $clinic): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Secretary\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Sms\Entity\SmsLog;
|
||||
use App\Sms\Service\SmsService;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Secretary assignment logic shared by single- and multi-doctor flows.
|
||||
*
|
||||
* A secretary is a User linked to one or more doctors via DoctorSecretary rows
|
||||
* (one row per doctor). Clinic-owned assignment lets a clinic manager attach the
|
||||
* same secretary to several of the clinic's doctors at once and later re-sync
|
||||
* that set; the secretary's access is scoped to exactly those rows.
|
||||
*/
|
||||
class SecretaryService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly string $appUrl,
|
||||
) {}
|
||||
|
||||
/** Find the secretary User by mobile or create it; ensure ROLE_SECRETARY, apply name/password. */
|
||||
public function resolveSecretaryUser(string $mobile, ?string $name = null, ?string $password = null): User
|
||||
{
|
||||
$user = $this->userRepo->findByMobile($mobile);
|
||||
if ($user === null) {
|
||||
$user = new User($mobile);
|
||||
if (!empty($password)) {
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
|
||||
}
|
||||
}
|
||||
if (!empty($name)) {
|
||||
$user->setRealName(trim($name));
|
||||
}
|
||||
|
||||
$roles = $user->getRoles();
|
||||
if (!in_array('ROLE_SECRETARY', $roles, true)) {
|
||||
$roles[] = 'ROLE_SECRETARY';
|
||||
$user->setRoles(array_values(array_unique($roles)));
|
||||
}
|
||||
$this->userRepo->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function doctorAtSecretaryLimit(Doctor $doctor): bool
|
||||
{
|
||||
$limit = $this->subscriptionService->getSecretaryLimit('doctor', $doctor->getId());
|
||||
|
||||
return $this->secretaryRepo->countActiveByDoctor($doctor) >= $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a secretary (by mobile) to several clinic doctors atomically.
|
||||
*
|
||||
* @param string[] $doctorUuids
|
||||
* @return array{secretary: User, created: DoctorSecretary[], skipped_duplicate: string[], skipped_limit: string[], skipped_not_in_clinic: string[]}
|
||||
*/
|
||||
public function assignToClinicDoctors(Clinic $clinic, string $mobile, array $doctorUuids, array $meta = []): array
|
||||
{
|
||||
$secretary = $this->resolveSecretaryUser($mobile, $meta['name'] ?? null, $meta['password'] ?? null);
|
||||
|
||||
$created = $skippedDup = $skippedLimit = $skippedNotInClinic = [];
|
||||
|
||||
foreach (array_unique($doctorUuids) as $doctorUuid) {
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null || !$this->secretaryRepo->isDoctorInClinic($doctor, $clinic)) {
|
||||
$skippedNotInClinic[] = $doctorUuid;
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = $this->secretaryRepo->findOneBy([
|
||||
'doctor' => $doctor,
|
||||
'secretary' => $secretary,
|
||||
'ownerType' => DoctorSecretary::OWNER_CLINIC,
|
||||
]);
|
||||
if ($existing !== null) {
|
||||
if ($existing->isActive()) {
|
||||
$skippedDup[] = $doctorUuid;
|
||||
continue;
|
||||
}
|
||||
$existing->setActive(true);
|
||||
$this->applyMeta($existing, $meta);
|
||||
$created[] = $existing;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->doctorAtSecretaryLimit($doctor)) {
|
||||
$skippedLimit[] = $doctorUuid;
|
||||
continue;
|
||||
}
|
||||
$row = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic);
|
||||
$this->applyMeta($row, $meta);
|
||||
$this->secretaryRepo->save($row, false);
|
||||
$created[] = $row;
|
||||
}
|
||||
|
||||
$this->secretaryRepo->getEntityManager()->flush(); // flush the batch of new rows
|
||||
|
||||
if (!empty($created)) {
|
||||
$this->sendWelcomeSms($mobile, $clinic->getName() ?? 'کلینیک');
|
||||
}
|
||||
|
||||
return [
|
||||
'secretary' => $secretary,
|
||||
'created' => $created,
|
||||
'skipped_duplicate' => $skippedDup,
|
||||
'skipped_limit' => $skippedLimit,
|
||||
'skipped_not_in_clinic' => $skippedNotInClinic,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sync a clinic secretary's assigned doctors to exactly $doctorUuids:
|
||||
* activate/create the wanted set, deactivate the rest.
|
||||
*
|
||||
* @param string[] $doctorUuids
|
||||
* @return array{added: DoctorSecretary[], removed: DoctorSecretary[], skipped_limit: string[], skipped_not_in_clinic: string[]}
|
||||
*/
|
||||
public function syncClinicDoctors(Clinic $clinic, User $secretary, array $doctorUuids): array
|
||||
{
|
||||
$wanted = array_values(array_unique($doctorUuids));
|
||||
$existing = $this->secretaryRepo->findByClinicAndSecretary($clinic, $secretary);
|
||||
$byDoctorUuid = [];
|
||||
foreach ($existing as $row) {
|
||||
$byDoctorUuid[$row->getDoctor()->getUuid()] = $row;
|
||||
}
|
||||
|
||||
$added = $removed = $skippedLimit = $skippedNotInClinic = [];
|
||||
|
||||
foreach ($wanted as $doctorUuid) {
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null || !$this->secretaryRepo->isDoctorInClinic($doctor, $clinic)) {
|
||||
$skippedNotInClinic[] = $doctorUuid;
|
||||
continue;
|
||||
}
|
||||
$current = $byDoctorUuid[$doctorUuid] ?? null;
|
||||
if ($current !== null) {
|
||||
if (!$current->isActive()) {
|
||||
$current->setActive(true);
|
||||
$added[] = $current;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($this->doctorAtSecretaryLimit($doctor)) {
|
||||
$skippedLimit[] = $doctorUuid;
|
||||
continue;
|
||||
}
|
||||
$row = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic);
|
||||
$this->secretaryRepo->save($row, false);
|
||||
$added[] = $row;
|
||||
}
|
||||
|
||||
foreach ($existing as $row) {
|
||||
if ($row->isActive() && !in_array($row->getDoctor()->getUuid(), $wanted, true)) {
|
||||
$row->setActive(false);
|
||||
$removed[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$this->secretaryRepo->getEntityManager()->flush();
|
||||
|
||||
return [
|
||||
'added' => $added,
|
||||
'removed' => $removed,
|
||||
'skipped_limit' => $skippedLimit,
|
||||
'skipped_not_in_clinic' => $skippedNotInClinic,
|
||||
];
|
||||
}
|
||||
|
||||
private function applyMeta(DoctorSecretary $row, array $meta): void
|
||||
{
|
||||
if (array_key_exists('national_code', $meta)) {
|
||||
$row->setNationalCode($meta['national_code'] !== null ? trim((string) $meta['national_code']) : null);
|
||||
}
|
||||
if (array_key_exists('address', $meta)) {
|
||||
$row->setAddress($meta['address'] !== null ? trim((string) $meta['address']) : null);
|
||||
}
|
||||
if (!empty($meta['permissions'])) {
|
||||
$row->mergePermissions($meta['permissions']);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendWelcomeSms(string $mobile, string $ownerName): void
|
||||
{
|
||||
$this->smsService->dispatchTemplate(SmsLog::TAG_SECRETARY, $mobile, [
|
||||
'owner' => $ownerName,
|
||||
'username' => $mobile,
|
||||
'link' => rtrim($this->appUrl, '/') . '/login',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Secretary;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* A clinic manager can assign one secretary to several of the clinic's doctors
|
||||
* at once (many DoctorSecretary rows for one secretary User) and later re-sync
|
||||
* that set. Doctors outside the clinic are rejected; foreign owners are 403.
|
||||
*/
|
||||
class ClinicSharedSecretaryTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Clinic, 2: Doctor[]} */
|
||||
private function makeClinicWithDoctors(int $count): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$this->em->persist($clinic);
|
||||
|
||||
$doctors = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), "دکتر $i");
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$doctors[] = $doctor;
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $clinic, $doctors];
|
||||
}
|
||||
|
||||
private function mobile(): string
|
||||
{
|
||||
return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
public function testClinicOwnerAssignsSecretaryToMultipleDoctors(): void
|
||||
{
|
||||
[$owner, , $doctors] = $this->makeClinicWithDoctors(3);
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/secretary', $owner, [
|
||||
'mobile_number' => $this->mobile(),
|
||||
'name' => 'منشی مشترک',
|
||||
'doctor_uuids' => [$doctors[0]->getUuid(), $doctors[1]->getUuid()],
|
||||
]);
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$this->assertCount(2, $body['data']['created']);
|
||||
$this->assertSame([], $body['data']['skipped_not_in_clinic']);
|
||||
$this->assertNotEmpty($body['data']['secretary_uuid']);
|
||||
}
|
||||
|
||||
public function testSkipsDuplicatesOnReassign(): void
|
||||
{
|
||||
[$owner, , $doctors] = $this->makeClinicWithDoctors(2);
|
||||
$mobile = $this->mobile();
|
||||
$uuids = [$doctors[0]->getUuid(), $doctors[1]->getUuid()];
|
||||
|
||||
$this->authJson('POST', '/api/v1/secretary', $owner, ['mobile_number' => $mobile, 'doctor_uuids' => $uuids]);
|
||||
$body = $this->authJson('POST', '/api/v1/secretary', $owner, ['mobile_number' => $mobile, 'doctor_uuids' => $uuids]);
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$this->assertCount(0, $body['data']['created']);
|
||||
$this->assertCount(2, $body['data']['skipped_duplicate']);
|
||||
}
|
||||
|
||||
public function testRejectsDoctorOutsideClinic(): void
|
||||
{
|
||||
[$owner, , $doctors] = $this->makeClinicWithDoctors(1);
|
||||
$foreignDoctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'خارج از کلینیک');
|
||||
$this->em->persist($foreignDoctor);
|
||||
$this->em->flush();
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/secretary', $owner, [
|
||||
'mobile_number' => $this->mobile(),
|
||||
'doctor_uuids' => [$doctors[0]->getUuid(), $foreignDoctor->getUuid()],
|
||||
]);
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$this->assertCount(1, $body['data']['created']);
|
||||
$this->assertContains($foreignDoctor->getUuid(), $body['data']['skipped_not_in_clinic']);
|
||||
}
|
||||
|
||||
public function testSyncAddsAndRemovesDoctors(): void
|
||||
{
|
||||
[$owner, $clinic, $doctors] = $this->makeClinicWithDoctors(3);
|
||||
$mobile = $this->mobile();
|
||||
|
||||
$assigned = $this->authJson('POST', '/api/v1/secretary', $owner, [
|
||||
'mobile_number' => $mobile,
|
||||
'doctor_uuids' => [$doctors[0]->getUuid(), $doctors[1]->getUuid()],
|
||||
]);
|
||||
$secretaryUuid = $assigned['data']['secretary_uuid'];
|
||||
|
||||
// sync to {doctor1, doctor2} → drop doctor0, add doctor2
|
||||
$body = $this->authJson('PUT', '/api/v1/secretaries/clinic/' . $clinic->getUuid() . '/doctors', $owner, [
|
||||
'secretary_uuid' => $secretaryUuid,
|
||||
'doctor_uuids' => [$doctors[1]->getUuid(), $doctors[2]->getUuid()],
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertSame(1, $body['data']['added']); // doctor2
|
||||
$this->assertSame(1, $body['data']['removed']); // doctor0
|
||||
}
|
||||
|
||||
public function testForeignOwnerCannotSync(): void
|
||||
{
|
||||
[, $clinic, ] = $this->makeClinicWithDoctors(1);
|
||||
$intruder = $this->createUser(['ROLE_CLINIC']);
|
||||
|
||||
$this->authJson('PUT', '/api/v1/secretaries/clinic/' . $clinic->getUuid() . '/doctors', $intruder, [
|
||||
'secretary_uuid' => 'whatever',
|
||||
'doctor_uuids' => [],
|
||||
]);
|
||||
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Secretary;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\UserActiveContext;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* A clinic-owned secretary must only see the appointments of the doctors they
|
||||
* are actually assigned to — not every doctor in the clinic.
|
||||
*/
|
||||
class SecretaryAppointmentScopeTest extends ApiTestCase
|
||||
{
|
||||
public function testSecretarySeesOnlyAssignedDoctorsAppointments(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$this->em->persist($clinic);
|
||||
|
||||
$doctorA = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر A');
|
||||
$doctorB = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر B');
|
||||
$this->em->persist($doctorA);
|
||||
$this->em->persist($doctorB);
|
||||
$clinic->getDoctors()->add($doctorA);
|
||||
$clinic->getDoctors()->add($doctorB);
|
||||
|
||||
// منشی فقط به دکتر A تخصیص داده شده
|
||||
$secretaryUser = $this->createUser(['ROLE_SECRETARY']);
|
||||
$rel = new DoctorSecretary($doctorA, $secretaryUser, DoctorSecretary::OWNER_CLINIC, $clinic);
|
||||
$this->em->persist($rel);
|
||||
|
||||
// scope فعالِ منشی = این کلینیک
|
||||
$this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid()));
|
||||
|
||||
// یک نوبت برای هر پزشک
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$start = time() + 3600;
|
||||
$this->em->persist(new Appointment($doctorA, $patient, $start, $start + 900));
|
||||
$this->em->persist(new Appointment($doctorB, $patient, $start + 1800, $start + 2700));
|
||||
$this->em->flush();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
// فقط نوبت دکتر A دیده میشود، نه دکتر B
|
||||
$this->assertSame(1, $body['meta']['totalRecords']);
|
||||
}
|
||||
|
||||
public function testSecretaryWithNoAssignmentSeesNothing(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$this->em->persist($clinic);
|
||||
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تنها');
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
|
||||
// منشی context کلینیک دارد ولی رابطهی فعال ندارد
|
||||
$secretaryUser = $this->createUser(['ROLE_SECRETARY']);
|
||||
$this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid()));
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$start = time() + 3600;
|
||||
$this->em->persist(new Appointment($doctor, $patient, $start, $start + 900));
|
||||
$this->em->flush();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser);
|
||||
|
||||
// بدون رابطهی فعال → resolveSecretaryFilter=null → لیست خالی
|
||||
$this->assertSame(0, $body['meta']['totalRecords']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user