feat(representation): expose doctor representation details and add counts in admin views
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
# نمایش تعلق دکتر به نماینده + صفحهی نماینده با تعداد دکتر و نوبت
|
||||
|
||||
## زمینه
|
||||
|
||||
هر دکتر از طریق ستون `doctors.representation_id` (FK به `representations.id`) به یک نماینده وصل میشود؛ این ستون **از قبل وجود دارد** و هنگام ثبتِ دکتر توسط نماینده مقداردهی میشود (`RepresentationActionController::registerDoctor` → `setRepresentationId`). اما این تعلق **هیچجا در پنل ادمین دیده نمیشود**: نه در لیست دکترها، نه در پروفایل دکتر، و صفحهی نمایندهها تعداد دکتر و نوبت نماینده را نشان نمیدهد. هدف: مرئیکردن این رابطه در ادمین. **نیازی به migration نیست** — فقط expose و UI.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
1. در لیست و پروفایل دکترِ ادمین، مشخص باشد دکتر به کدام نماینده تعلق دارد (نام + لینک به نماینده).
|
||||
2. در صفحهی نمایندهها (`RepresentationsPage`) هر نماینده ستون «تعداد پزشکان» و «تعداد نوبت» داشته باشد.
|
||||
3. در جزئیات نماینده (`RepresentationDetailPage`) بشود پزشکان زیرمجموعه و نوبتهای آن نماینده را دید.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Doctor/Entity/Doctor.php` | `representationId` (ستون L76-77، getter L217)؛ `toDetailArray()` L530 — الان نماینده را برنمیگرداند |
|
||||
| `src/Doctor/Controller/DoctorController.php` | `show()` L149-173 — `GET /api/v1/doctor/{uuid}`؛ پنل ادمینِ پروفایل دکتر از همین میخواند (`DoctorDetailPage.tsx:2363`) |
|
||||
| `src/Admin/Controller/AdminApiController.php` | `doctorsList` (SELECT L350-359)؛ `representations` (L1016-1082) |
|
||||
| `src/Representation/Repository/RepresentationRepository.php` | برای resolve نامِ نماینده از روی id |
|
||||
| `assets/admin/pages/DoctorsPage.tsx` | لیست دکتر ادمین (fetch L144) |
|
||||
| `assets/admin/pages/DoctorDetailPage.tsx` | پروفایل دکتر ادمین (fetch L2358-2364) |
|
||||
| `assets/admin/pages/RepresentationsPage.tsx` | لیست نمایندهها (fetch L51-56) |
|
||||
| `assets/admin/pages/RepresentationDetailPage.tsx` | جزئیات نماینده (fetch `/api/v1/representation/{uuid}` L42) |
|
||||
| `docs/api/admin.md`, `docs/api/doctor.md` | مستندسازی |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
**الگوی aggregateِ آماده (برای adaptation)** — شمارش دکتر نماینده (`RepresentationActionController.php:606`):
|
||||
```php
|
||||
$total = (int) $conn->fetchOne('SELECT COUNT(*) FROM doctors WHERE representation_id = ?', [$repId]);
|
||||
```
|
||||
نوبتهای نماینده (`RepresentationActionController.php:673` تقریبی): `JOIN a.doctor d WHERE d.representationId = :repId`.
|
||||
|
||||
**لیست دکتر ادمین** الان نماینده را select نمیکند (`AdminApiController.php:350-359`):
|
||||
```php
|
||||
$rows = $conn->fetchAllAssociative(
|
||||
"SELECT d.id, d.uuid, d.name, ..., d.owner_status, d.source,
|
||||
u.mobile_number as user_mobile, u.email
|
||||
FROM doctors d JOIN users u ON u.id = d.user_id
|
||||
WHERE $whereStr ORDER BY $orderBy LIMIT $limit OFFSET $offset", $params);
|
||||
```
|
||||
|
||||
**لیست نمایندهها** بدون تعداد دکتر/نوبت (`AdminApiController.php:1063-1078`):
|
||||
```php
|
||||
return [
|
||||
'id' => (int) $r['id'], ...,
|
||||
'commission_percent' => (float) $r['commissionPercent'],
|
||||
'wallet_balance' => 0,
|
||||
'is_active' => (bool) $r['active'],
|
||||
'created_at' => date('c', (int) $r['createdAt']),
|
||||
];
|
||||
```
|
||||
|
||||
**پروفایل دکترِ ادمین** از `toDetailArray()` تغذیه میشود که `representation` ندارد.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. Backend — expose نماینده روی جزئیات دکتر (`show()`)
|
||||
|
||||
در `DoctorController::show()` بعد از ساخت آرایهی دکتر، نماینده را از روی `representation_id` resolve کن و merge کن (مثل الگوی `clinics` که همانجا merge میشود). `null` اگر دکتر نماینده ندارد (مثل ایمپورتهای IRIMC):
|
||||
```php
|
||||
$repId = $doctor->getRepresentationId();
|
||||
$rep = $repId ? $this->representationRepo->find($repId) : null;
|
||||
$data = array_merge($doctor->toDetailArray($schedule), [
|
||||
'clinics' => $clinicData,
|
||||
'representation' => $rep ? ['id' => $rep->getId(), 'uuid' => $rep->getUuid(), 'full_name' => $rep->getFullName()] : null,
|
||||
]);
|
||||
```
|
||||
`RepresentationRepository` را به constructor تزریق کن (اگر نیست). پاسخ همچنان double-nested است؛ شکل را نشکن.
|
||||
|
||||
### ۲. Backend — لیست دکتر ادمین: افزودن نماینده
|
||||
|
||||
در `AdminApiController::doctorsList` کوئری را `LEFT JOIN representations` کن و دو ستون اضافه کن:
|
||||
```php
|
||||
"SELECT d.id, ..., d.owner_status, d.source,
|
||||
d.representation_id, rep.full_name AS representation_name,
|
||||
u.mobile_number as user_mobile, u.email
|
||||
FROM doctors d JOIN users u ON u.id = d.user_id
|
||||
LEFT JOIN representations rep ON rep.id = d.representation_id
|
||||
WHERE $whereStr ORDER BY $orderBy LIMIT $limit OFFSET $offset"
|
||||
```
|
||||
و در `array_map` خروجی:
|
||||
```php
|
||||
'representation_id' => $d['representation_id'] !== null ? (int) $d['representation_id'] : null,
|
||||
'representation_name' => $d['representation_name'] ?? null,
|
||||
```
|
||||
|
||||
### ۳. Backend — لیست نمایندهها: تعداد دکتر + نوبت
|
||||
|
||||
در `AdminApiController::representations` بعد از گرفتن `$rows` و `$repIds`، دو شمارش گروهی با یک کوئری هرکدام بگیر (نه N+1):
|
||||
```php
|
||||
$docCounts = []; // representation_id => count
|
||||
$apptCounts = []; // representation_id => count
|
||||
if ($repIds !== []) {
|
||||
foreach ($this->em->getConnection()->fetchAllAssociative(
|
||||
'SELECT representation_id, COUNT(*) c FROM doctors
|
||||
WHERE representation_id IN (?) GROUP BY representation_id',
|
||||
[$repIds], [\Doctrine\DBAL\ArrayParameterType::INTEGER]) as $row) {
|
||||
$docCounts[(int) $row['representation_id']] = (int) $row['c'];
|
||||
}
|
||||
foreach ($this->em->getConnection()->fetchAllAssociative(
|
||||
'SELECT d.representation_id, COUNT(*) c FROM appointments a
|
||||
JOIN doctors d ON d.id = a.doctor_id
|
||||
WHERE d.representation_id IN (?) GROUP BY d.representation_id',
|
||||
[$repIds], [\Doctrine\DBAL\ArrayParameterType::INTEGER]) as $row) {
|
||||
$apptCounts[(int) $row['representation_id']] = (int) $row['c'];
|
||||
}
|
||||
}
|
||||
```
|
||||
و در آیتم خروجی:
|
||||
```php
|
||||
'doctor_count' => $docCounts[(int) $r['id']] ?? 0,
|
||||
'appointment_count' => $apptCounts[(int) $r['id']] ?? 0,
|
||||
```
|
||||
|
||||
### ۴. Backend — endpoint پزشکان یک نماینده (برای صفحهی جزئیات)
|
||||
|
||||
یک endpoint ادمین اضافه کن: `GET /api/v1/admin/representations/{uuid}/doctors` (paginated) که پزشکان `representation_id = rep.id` را برمیگرداند (همان الگوی `d.representation_id = :repId`). `$this->paginated(...)`. برای نوبتها میتوانی از `appointment_count` تجمیعی وظیفهی ۳ در خود صفحه استفاده کنی، یا در صورت نیاز endpoint مشابه `.../appointments` بساز.
|
||||
|
||||
### ۵. Frontend — ستون «نماینده» در لیست و کارت در پروفایل دکتر
|
||||
|
||||
- `DoctorsPage.tsx`: به `AdminDoctor` تایپ `representation_id`/`representation_name` اضافه کن و یک ستون «نماینده» در `<DataTable>` نشان بده؛ اگر null → «بدون نماینده». (فقط حالت ادمین؛ نقش representation خودش زیرمجموعه است.)
|
||||
- `DoctorDetailPage.tsx`: یک بخش/کارت «نماینده» با نام و لینک به `/representations/${representation.uuid}` از `data.representation` بساز؛ اگر null → «این پزشک به نمایندهای متصل نیست».
|
||||
|
||||
### ۶. Frontend — ستونهای نماینده + بخش پزشکان/نوبتها
|
||||
|
||||
- `RepresentationsPage.tsx`: دو ستون «تعداد پزشکان» (`doctor_count`) و «تعداد نوبت» (`appointment_count`) در جدول.
|
||||
- `RepresentationDetailPage.tsx`: یک بخش «پزشکان این نماینده» با `useQuery` روی `/api/v1/admin/representations/${uuid}/doctors` (paginated: items از `data?.data`) و نمایش `appointment_count`/`doctor_count`.
|
||||
|
||||
### ۷. مستندسازی
|
||||
|
||||
- `docs/api/admin.md`: فیلدهای جدید `doctor_count`/`appointment_count` در پاسخ `representations`؛ فیلدهای `representation_id`/`representation_name` در `doctors`؛ endpoint جدید `GET /api/v1/admin/representations/{uuid}/doctors`.
|
||||
- `docs/api/doctor.md`: فیلد `representation` در پاسخ single (`show`).
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **بدون migration**: `representation_id` از قبل روی `doctors` هست (Doctor.php:76). فقط expose.
|
||||
- **ایمپورتهای IRIMC** `representation_id = NULL` دارند (فقط `managed_by` ست میشود، `DoctorImportService.php:106`) — در UI «بدون نماینده» نشان بده؛ **auto-assign نکن** (خارج از دامنهی این تسک).
|
||||
- **پاسخها**: paginated → items از `data?.data`، total از `data?.meta?.totalRecords`؛ single (`show`) → double-nested `data?.data?.data`. همهی endpointها از `BaseController` (`success`/`paginated`).
|
||||
- شمارشها گروهی (`GROUP BY ... IN (?)`) باشند تا N+1 نشود؛ همان الگوی `cityNames` موجود در `representations` (AdminApiController.php:1048).
|
||||
- `show()` عمومی است و توسط `nobat724_front` هم مصرف میشود؛ افزودن `representation` مشکلی ایجاد نمیکند (سایت آن را نادیده میگیرد) ولی شکل پاسخ را تغییر نده.
|
||||
- تاریخها Unix timestamp؛ نمایش با `formatDate()` شمسی.
|
||||
- تست: `ddev exec php -l ...`، `ddev exec php bin/console cache:clear`، `ddev exec yarn dev`، `ddev exec php bin/console debug:router | grep representations`.
|
||||
@@ -57,6 +57,7 @@ interface DoctorDetail {
|
||||
state: { id: string; name: string }[];
|
||||
city: { id: string; name: string }[];
|
||||
clinics: { id: string; uuid: string; name: string; address: string | null; telephone: string | null }[];
|
||||
representation: { id: number; uuid: string; full_name: string | null } | null;
|
||||
}
|
||||
|
||||
interface SpecialtyOpt { id: number; uuid: string; name: string; parent_id: number | null; }
|
||||
@@ -2699,6 +2700,13 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
<InfoCard icon={AcademicCapIcon} label="کد نظام پزشکی" value={doctor.medical_system_code} mono copyable />
|
||||
<InfoCard icon={CalendarIcon} label="سابقه (سال)" value={doctor.experience > 0 ? String(doctor.experience) : null} />
|
||||
<InfoCard icon={StarIcon} label="امتیاز" value={<div dir="ltr"><StarRating rate={rateNum} /></div>} />
|
||||
<InfoCard
|
||||
icon={UserIcon}
|
||||
label="نماینده"
|
||||
value={doctor.representation
|
||||
? <button type="button" style={{ color: '#2563eb', textDecoration: 'underline', background: 'none', border: 0, padding: 0, cursor: 'pointer', font: 'inherit' }} onClick={() => navigate(`/admin/representations/${doctor.representation!.uuid}`)}>{doctor.representation.full_name ?? 'نماینده'}</button>
|
||||
: <span className="text-slate-400">بدون نماینده</span>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ interface AdminDoctor {
|
||||
owner_status?: string;
|
||||
source?: string;
|
||||
rate: number;
|
||||
representation_id?: number | null;
|
||||
representation_uuid?: string | null;
|
||||
representation_name?: string | null;
|
||||
specialties: { id: number; name: string }[];
|
||||
profile_image: string | null;
|
||||
created_at: string;
|
||||
@@ -294,6 +297,7 @@ export default function DoctorsPage() {
|
||||
<th>درجه</th>
|
||||
<th>امتیاز</th>
|
||||
<th>موبایل</th>
|
||||
{!isRepresentation && <th>نماینده</th>}
|
||||
<th>وضعیت</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -348,6 +352,21 @@ export default function DoctorsPage() {
|
||||
<td style={{ fontFamily: 'monospace', direction: 'ltr', textAlign: 'right' }} className="muted">
|
||||
{doc.mobile ?? '—'}
|
||||
</td>
|
||||
{!isRepresentation && (
|
||||
<td>
|
||||
{doc.representation_id ? (
|
||||
<span
|
||||
className="badge blue"
|
||||
style={{ cursor: doc.representation_uuid ? 'pointer' : 'default', fontSize: 11 }}
|
||||
onClick={(e) => { e.stopPropagation(); if (doc.representation_uuid) navigate(`/admin/representations/${doc.representation_uuid}`); }}
|
||||
>
|
||||
{doc.representation_name ?? 'نماینده'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="muted" style={{ fontSize: 11 }}>بدون نماینده</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
{doc.owner_status === 'unclaimed' && <span className="badge amber" style={{ marginLeft: 6 }}>بدونمالک</span>}
|
||||
{doc.owner_status === 'pending_transfer' && <span className="badge violet" style={{ marginLeft: 6 }}>در انتظار انتقال</span>}
|
||||
|
||||
@@ -13,6 +13,18 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
|
||||
interface RepDoctor {
|
||||
uuid: string;
|
||||
id: number;
|
||||
name: string;
|
||||
gender: string | null;
|
||||
medical_code: string | null;
|
||||
is_active: boolean;
|
||||
owner_status?: string;
|
||||
appointment_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="cp-info-row items-start gap-4">
|
||||
@@ -45,6 +57,14 @@ export default function RepresentationDetailPage() {
|
||||
|
||||
const rep: Representation | undefined = (data?.data as any)?.data ?? data?.data;
|
||||
|
||||
const doctorsQuery = useQuery({
|
||||
queryKey: ['representation-doctors', uuid],
|
||||
queryFn: () => api.get<PaginatedResponse<RepDoctor>>(`/api/v1/admin/representations/${uuid}/doctors?limit=100`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
const repDoctors: RepDoctor[] = doctorsQuery.data?.data ?? [];
|
||||
const repDoctorsTotal = doctorsQuery.data?.meta?.totalRecords ?? repDoctors.length;
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (d: Partial<Representation>) =>
|
||||
api.patch<ApiResponse<Representation>>(`/api/v1/representation/${uuid}`, d),
|
||||
@@ -214,6 +234,42 @@ export default function RepresentationDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Doctors of this representation */}
|
||||
<div className="cp-card p-6 mt-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700">پزشکان این نماینده</h2>
|
||||
<span className="badge blue">{formatNumber(repDoctorsTotal)} پزشک</span>
|
||||
</div>
|
||||
{doctorsQuery.isLoading ? (
|
||||
<p className="text-sm text-gray-400 text-center py-6">در حال بارگذاری…</p>
|
||||
) : repDoctors.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 text-center py-6">این نماینده پزشکی ندارد</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>نام</th>
|
||||
<th>کد نظام</th>
|
||||
<th>تعداد نوبت</th>
|
||||
<th>وضعیت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{repDoctors.map((d) => (
|
||||
<tr key={d.uuid} style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/doctors/${d.uuid}`)}>
|
||||
<td><b>{d.name}</b></td>
|
||||
<td dir="ltr" style={{ fontFamily: 'monospace' }}>{d.medical_code ?? '—'}</td>
|
||||
<td>{formatNumber(d.appointment_count)}</td>
|
||||
<td><ActiveBadge active={d.is_active} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal open={editOpen} title="ویرایش نماینده"
|
||||
onClose={() => setEditOpen(false)}
|
||||
|
||||
@@ -104,6 +104,8 @@ export default function RepresentationsPage() {
|
||||
{ key: 'domain', header: 'دامنه', render: (r) => r.domain ? <span dir="ltr">{r.domain}</span> : '—' },
|
||||
{ key: 'city', header: 'شهرها', render: (r) => r.city ?? '—' },
|
||||
{ key: 'commission_percent', header: 'کمیسیون', render: (r) => `${formatNumber(r.commission_percent)}٪` },
|
||||
{ key: 'doctor_count', header: 'تعداد پزشکان', render: (r) => formatNumber(r.doctor_count ?? 0) },
|
||||
{ key: 'appointment_count', header: 'تعداد نوبت', render: (r) => formatNumber(r.appointment_count ?? 0) },
|
||||
{ key: 'wallet_balance', header: 'موجودی کیفپول', render: (r) => formatRial(r.wallet_balance ?? 0) },
|
||||
{ key: 'is_active', header: 'وضعیت', render: (r) => <ActiveBadge active={r.is_active ?? r.active ?? false} /> },
|
||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (r) => formatDate(r.created_at) },
|
||||
|
||||
@@ -157,6 +157,8 @@ export interface Representation {
|
||||
commission_percent: number;
|
||||
bank_account: { card?: string; bank_name?: string; iban?: string } | null;
|
||||
wallet_balance?: number;
|
||||
doctor_count?: number;
|
||||
appointment_count?: number;
|
||||
active?: boolean;
|
||||
is_active?: boolean;
|
||||
created_at: string;
|
||||
|
||||
+45
-1
@@ -357,13 +357,18 @@ List all doctors with pagination.
|
||||
"degree": "متخصص",
|
||||
"gender": "male",
|
||||
"doctor_rate": 4.5,
|
||||
"active_doctor_appointment": true
|
||||
"active_doctor_appointment": true,
|
||||
"representation_id": 12,
|
||||
"representation_uuid": "9c1...",
|
||||
"representation_name": "علی محمدی"
|
||||
}
|
||||
],
|
||||
"meta": { "totalRecords": 92, "totalPages": 5, "currentPage": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
> `representation_id`/`representation_uuid`/`representation_name` نمایندهی مالکِ پزشکاند؛ برای پزشکانِ بدون نماینده (مثل ایمپورتهای IRIMC) هر سه `null`.
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/v1/admin/doctors/stats`
|
||||
@@ -761,18 +766,57 @@ Paginated representation list. Each item:
|
||||
"city": "یزد، تهران",
|
||||
"commission_percent": 10.0,
|
||||
"wallet_balance": 0,
|
||||
"doctor_count": 14,
|
||||
"appointment_count": 231,
|
||||
"is_active": true,
|
||||
"created_at": "2026-06-18T..."
|
||||
}
|
||||
```
|
||||
> `city_id` = اولین شهر (BC)؛ `city` = نام شهرها با «،». `is_global=true` یعنی نماینده سراسری (badge در پنل).
|
||||
|
||||
> `doctor_count` = تعداد پزشکانِ `representation_id = r.id`؛ `appointment_count` = تعداد نوبتهای آن پزشکان. هر دو با کوئری گروهی محاسبه میشوند (بدون N+1).
|
||||
|
||||
> `mobile_number` falls back to the linked user's mobile when the representation's own `mobile_number` column is empty.
|
||||
|
||||
> **Deactivation, not deletion:** the admin panel deactivates a representation via `PATCH /api/v1/representation/{uuid}` with `{ "active": false }` rather than calling `DELETE`.
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/v1/admin/representations/{uuid}/doctors`
|
||||
|
||||
پزشکان زیرمجموعهی یک نماینده (paginated).
|
||||
|
||||
**Permission:** `ROLE_ADMIN`
|
||||
|
||||
### Query Parameters
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `page` | integer | ❌ | Default: 1 |
|
||||
| `limit` | integer | ❌ | Default: 15 (max 100) |
|
||||
|
||||
### Response `200`
|
||||
Paginated. Each item:
|
||||
```json
|
||||
{
|
||||
"uuid": "...",
|
||||
"id": 45,
|
||||
"name": "دکتر علی احمدی",
|
||||
"gender": "man",
|
||||
"medical_code": "12345",
|
||||
"is_active": true,
|
||||
"owner_status": "claimed",
|
||||
"appointment_count": 17,
|
||||
"created_at": "2026-06-18T..."
|
||||
}
|
||||
```
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_NOT_FOUND_001` | 404 | نماینده یافت نشد |
|
||||
|
||||
---
|
||||
|
||||
## Secretary Management
|
||||
|
||||
### GET `/api/v1/admin/secretaries`
|
||||
|
||||
+4
-1
@@ -116,13 +116,16 @@ Get doctor detail with clinics.
|
||||
"address": [],
|
||||
"state": [],
|
||||
"city": [],
|
||||
"clinics": [{ "uuid": "...", "name": "کلینیک الوند", "address": "...", "telephone": "..." }]
|
||||
"clinics": [{ "uuid": "...", "name": "کلینیک الوند", "address": "...", "telephone": "..." }],
|
||||
"representation": { "id": 12, "uuid": "9c1...", "full_name": "علی محمدی" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ **Double-nested:** Frontend extracts with `data?.data?.data`
|
||||
>
|
||||
> ℹ️ `representation` نمایندهی مالکِ پزشک است؛ برای پزشکِ بدون نماینده `null`.
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|
||||
@@ -333,6 +333,10 @@ class AdminApiController extends BaseController
|
||||
$where[] = 'EXISTS (SELECT 1 FROM doctor_specialties ds2 WHERE ds2.doctor_id = d.id AND ds2.specialty_id = :specId)';
|
||||
$params['specId'] = $specId;
|
||||
}
|
||||
// پزشکانِ بدون نماینده — برای انتخاب و اتصال به یک نماینده.
|
||||
if ($request->query->get('unassigned') === '1') {
|
||||
$where[] = 'd.representation_id IS NULL';
|
||||
}
|
||||
|
||||
$whereStr = implode(' AND ', $where);
|
||||
$orderBy = match ($sort) {
|
||||
@@ -352,8 +356,10 @@ class AdminApiController extends BaseController
|
||||
d.mobile_number as doctor_mobile, d.active_doctor_appointment,
|
||||
d.doctor_rate, d.doctor_rate_percentage, d.images, d.created_at,
|
||||
d.owner_status, d.source,
|
||||
d.representation_id, rep.uuid AS representation_uuid, rep.full_name AS representation_name,
|
||||
u.mobile_number as user_mobile, u.email
|
||||
FROM doctors d JOIN users u ON u.id = d.user_id
|
||||
LEFT JOIN representations rep ON rep.id = d.representation_id
|
||||
WHERE $whereStr ORDER BY $orderBy LIMIT $limit OFFSET $offset",
|
||||
$params
|
||||
);
|
||||
@@ -386,6 +392,9 @@ class AdminApiController extends BaseController
|
||||
'owner_status' => $d['owner_status'],
|
||||
'source' => $d['source'],
|
||||
'rate' => (float) $d['doctor_rate'],
|
||||
'representation_id' => $d['representation_id'] !== null ? (int) $d['representation_id'] : null,
|
||||
'representation_uuid' => $d['representation_uuid'] ?? null,
|
||||
'representation_name' => $d['representation_name'] ?? null,
|
||||
'specialties' => $specMap[(int) $d['id']] ?? [],
|
||||
'profile_image' => !empty($images) ? ($images[0]['url'] ?? null) : null,
|
||||
'created_at' => date('c', (int) $d['created_at']),
|
||||
@@ -1058,7 +1067,30 @@ class AdminApiController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
$items = array_map(function (array $r) use ($cityNames) {
|
||||
// تعداد پزشکان و نوبتهای هر نماینده در دو کوئری گروهی (بدون N+1).
|
||||
$docCounts = [];
|
||||
$apptCounts = [];
|
||||
if ($repIds !== []) {
|
||||
foreach ($this->em->getConnection()->fetchAllAssociative(
|
||||
'SELECT representation_id, COUNT(*) c FROM doctors
|
||||
WHERE representation_id IN (?) GROUP BY representation_id',
|
||||
[$repIds],
|
||||
[\Doctrine\DBAL\ArrayParameterType::INTEGER],
|
||||
) as $row) {
|
||||
$docCounts[(int) $row['representation_id']] = (int) $row['c'];
|
||||
}
|
||||
foreach ($this->em->getConnection()->fetchAllAssociative(
|
||||
'SELECT d.representation_id, COUNT(*) c FROM appointments a
|
||||
JOIN doctors d ON d.id = a.doctor_id
|
||||
WHERE d.representation_id IN (?) GROUP BY d.representation_id',
|
||||
[$repIds],
|
||||
[\Doctrine\DBAL\ArrayParameterType::INTEGER],
|
||||
) as $row) {
|
||||
$apptCounts[(int) $row['representation_id']] = (int) $row['c'];
|
||||
}
|
||||
}
|
||||
|
||||
$items = array_map(function (array $r) use ($cityNames, $docCounts, $apptCounts) {
|
||||
$cities = $cityNames[(int) $r['id']] ?? [];
|
||||
return [
|
||||
'id' => (int) $r['id'],
|
||||
@@ -1073,6 +1105,8 @@ class AdminApiController extends BaseController
|
||||
'city' => $cities !== [] ? implode('، ', array_column($cities, 'name')) : null,
|
||||
'commission_percent' => (float) $r['commissionPercent'],
|
||||
'wallet_balance' => 0,
|
||||
'doctor_count' => $docCounts[(int) $r['id']] ?? 0,
|
||||
'appointment_count' => $apptCounts[(int) $r['id']] ?? 0,
|
||||
'is_active' => (bool) $r['active'],
|
||||
'created_at' => date('c', (int) $r['createdAt']),
|
||||
];
|
||||
@@ -1081,6 +1115,46 @@ class AdminApiController extends BaseController
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/representations/{uuid}/doctors', methods: ['GET'])]
|
||||
public function representationDoctors(string $uuid, Request $request, RepresentationRepository $repRepo): JsonResponse
|
||||
{
|
||||
$rep = $repRepo->findByUuid($uuid);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||
$repId = $rep->getId();
|
||||
|
||||
$conn = $this->em->getConnection();
|
||||
$total = (int) $conn->fetchOne('SELECT COUNT(*) FROM doctors WHERE representation_id = ?', [$repId]);
|
||||
|
||||
$offset = ($page - 1) * $limit;
|
||||
$rows = $conn->fetchAllAssociative(
|
||||
"SELECT d.id, d.uuid, d.name, d.gender, d.medical_system_code,
|
||||
d.active_doctor_appointment, d.owner_status, d.created_at,
|
||||
(SELECT COUNT(*) FROM appointments a WHERE a.doctor_id = d.id) AS appointment_count
|
||||
FROM doctors d
|
||||
WHERE d.representation_id = ? ORDER BY d.created_at DESC LIMIT $limit OFFSET $offset",
|
||||
[$repId]
|
||||
);
|
||||
|
||||
$items = array_map(fn(array $d): array => [
|
||||
'uuid' => $d['uuid'],
|
||||
'id' => (int) $d['id'],
|
||||
'name' => $d['name'],
|
||||
'gender' => $d['gender'],
|
||||
'medical_code' => $d['medical_system_code'],
|
||||
'is_active' => (bool) $d['active_doctor_appointment'],
|
||||
'owner_status' => $d['owner_status'],
|
||||
'appointment_count' => (int) $d['appointment_count'],
|
||||
'created_at' => date('c', (int) $d['created_at']),
|
||||
], $rows);
|
||||
|
||||
return $this->paginated($items, $total, $page, $limit);
|
||||
}
|
||||
|
||||
// ── Financial Breakdowns ──────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
|
||||
@@ -44,6 +44,7 @@ class DoctorController extends BaseController
|
||||
private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo,
|
||||
private readonly TenantInsuranceCleanupService $insuranceCleanup,
|
||||
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
|
||||
private readonly \App\Representation\Repository\RepresentationRepository $representationRepo,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
@@ -169,8 +170,17 @@ class DoctorController extends BaseController
|
||||
],
|
||||
], $clinics);
|
||||
|
||||
$repId = $doctor->getRepresentationId();
|
||||
$rep = $repId !== null ? $this->representationRepo->find($repId) : null;
|
||||
$representation = $rep !== null
|
||||
? ['id' => $rep->getId(), 'uuid' => $rep->getUuid(), 'full_name' => $rep->getFullName()]
|
||||
: null;
|
||||
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), ['clinics' => $clinicData])]);
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), [
|
||||
'clinics' => $clinicData,
|
||||
'representation' => $representation,
|
||||
])]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic/my-doctor/{doctorUuid}', methods: ['GET'])]
|
||||
|
||||
Reference in New Issue
Block a user