feat: enrich patient record with full profile

GET /api/v1/patient/{uuid} now returns a `profile` object from UserProfile
(demographics, contact, insurance names resolved). Admin record detail shows
a "patient info" section. Empty fields render as "ثبت نشده".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-23 17:21:33 +03:30
co-authored by Claude Opus 4.8
parent af881231d0
commit 2206f02396
5 changed files with 204 additions and 6 deletions
@@ -0,0 +1,93 @@
# اتصال پرونده‌ی بیمار به پروفایل بیمار
## پروژه
`clinicpro` (backend + admin frontend)
## زمینه
پرونده‌ی بیمار (`PatientRecord`) فعلاً فقط به `User` وصل است و در پنل صرفاً نام و موبایل (و اخیراً کد ملی) نمایش داده می‌شود. اطلاعات کامل دموگرافیک بیمار که در `UserProfile` ذخیره شده، برای کلینیک/مطب قابل مشاهده نیست.
## مشکل / هدف
وقتی کاربری به یک مطب یا کلینیک اضافه می‌شود، **اطلاعات پروفایل او باید برای آن کلینیک مشخص باشد**: نام و نام خانوادگی، کد ملی، جنسیت، تاریخ تولد، بیمه‌ی پایه/مکمل، تماس و … . یعنی پرونده‌ی بیمار باید بر اساس `UserProfile` غنی شود.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/UserProfile/Entity/UserProfile.php` | پروفایل کامل بیمار |
| `src/Patient/Entity/PatientRecord.php` | پرونده؛ `toArray()` فعلاً فقط `user_name/user_mobile/user_national_code` |
| `src/Patient/Controller/PatientController.php` | `list` + `show` |
| `src/Patient/Repository/PatientRecordRepository.php` | کوئری‌ها |
| `assets/admin/pages/MyPatientsPage.tsx` | لیست کارتی + صفحه‌ی جزئیات پرونده (بنر بالای پرونده) |
| `assets/admin/types/index.ts` | `PatientRecord` type |
| `docs/api/patient.md` | مستندات |
## وضعیت فعلی
`UserProfile` فیلدهای موجود (نمونه):
```php
private ?string $family;
private ?string $fathersName;
private ?string $nationalCode;
private ?string $gender;
private ?int $dateOfBirth; // unix
private ?string $bloodType;
private ?string $maritalStatus;
private ?string $job;
private ?string $address;
private ?string $homePhone;
private ?int $basicInsuranceId;
private ?int $supplementaryInsuranceId;
```
`PatientRecord::toArray()`:
```php
return [
'uuid' => $this->uuid,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'user_uuid' => $this->user->getUuid(),
'user_name' => $this->user->getRealName(),
'user_mobile' => $this->user->getMobileNumber(),
'user_national_code' => $this->user->getNationalCode(),
'created_by_type' => $this->createdByType,
'created_at' => $this->createdAt,
];
```
پروفایل بیمار اصلاً در پاسخ نیست.
## وظایف
### ۱. تحلیل رابطه User ↔ UserProfile
`UserProfile` را کامل بخوان: رابطه به `User`، repository، و اینکه آیا یک‌به‌یک است. روش گرفتن پروفایل از روی `User` را پیدا کن.
### ۲. غنی‌سازی پاسخ پرونده
- در `show` (و در صورت نیاز `list`) اطلاعات پروفایل بیمار را به پاسخ اضافه کن. توصیه: یک کلید `profile` در پاسخِ `show` (جزئیات کامل)، و در `list` فقط فیلدهای سبک (نام، جنسیت، کد ملی) برای کارت.
- نام بیمه‌ها از id به نام resolve شود (LEFT JOIN یا lookup روی `insurances`).
- اگر پروفایل وجود نداشت، مقادیر null برگردد (نه خطا).
> برای `list` از kept-light استفاده کن تا کوئری سنگین نشود؛ برای `show` می‌توان پروفایل کامل را join کرد.
### ۳. Admin Frontend — نمایش پروفایل در پرونده
- در صفحه‌ی جزئیات پرونده (بنر بالای تاریخچه‌ی مراجعات)، یک بخش «اطلاعات بیمار» با فیلدهای پروفایل (نام کامل، کد ملی، جنسیت، تاریخ تولد شمسی با `formatDate`، بیمه پایه/مکمل، تماس) اضافه کن.
- `PatientRecord` type در `types/index.ts` را با `profile` به‌روز کن.
- اگر فیلدی خالی بود، «ثبت نشده» نشان بده.
### ۴. مستندات
`docs/api/patient.md` را با ساختار جدید پاسخ (`profile`) برای `show` و فیلدهای سبک `list` به‌روز کن.
## نکات مهم
- تاریخ تولد unix timestamp است؛ با `formatDate()` شمسی نمایش بده.
- `entity_type` می‌تواند `doctor` یا `clinic` باشد؛ نمایش پروفایل برای هر دو یکسان.
- این پرامپت مکمل `create-patient-without-signup.md` است؛ اگر بیمار جدید بدون ثبت‌نام ساخته می‌شود، همان فیلدهای پروفایل باید قابل ذخیره و سپس قابل نمایش باشند — ساختار `UserProfile` را یکدست نگه‌دار.
- کوئری list نباید N+1 شود؛ از join استفاده کن.
+36
View File
@@ -169,6 +169,13 @@ function MyPatientsPageInner() {
),
});
const { data: recordDetail } = useQuery<ApiResponse<PatientRecord>>({
queryKey: ["patient-detail", selectedRecord?.uuid],
queryFn: () => api.get(`/api/v1/patient/${selectedRecord!.uuid}`),
enabled: !!selectedRecord,
});
const patientProfile = (recordDetail?.data as PatientRecord | undefined)?.profile ?? null;
const { data: sessionsData, isLoading: sessionsLoading } = useQuery<
PaginatedResponse<PatientSession>
>({
@@ -885,6 +892,35 @@ function MyPatientsPageInner() {
</div>
</div>
{patientProfile && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>اطلاعات بیمار</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 12 }}>
{([
["نام کامل", patientProfile.full_name],
["کد ملی", patientProfile.national_code],
["جنسیت", patientProfile.gender === "male" ? "مرد" : patientProfile.gender === "female" ? "زن" : patientProfile.gender],
["تاریخ تولد", patientProfile.date_of_birth ? formatDate(patientProfile.date_of_birth) : null],
["گروه خونی", patientProfile.blood_type],
["وضعیت تأهل", patientProfile.marital_status],
["شغل", patientProfile.job],
["موبایل", patientProfile.mobile],
["تلفن منزل", patientProfile.home_phone],
["بیمه پایه", patientProfile.basic_insurance_name],
["بیمه تکمیلی", patientProfile.supplementary_insurance_name],
["آدرس", patientProfile.address],
] as [string, string | null | undefined][]).map(([label, value]) => (
<div key={label}>
<div style={{ fontSize: 11.5, color: "var(--text-3)", marginBottom: 3 }}>{label}</div>
<div style={{ fontSize: 13, fontWeight: 500 }} dir={label === "موبایل" || label === "کد ملی" || label === "تلفن منزل" ? "ltr" : undefined}>
{value ? value : <span style={{ color: "var(--text-3)", fontWeight: 400 }}>ثبت نشده</span>}
</div>
</div>
))}
</div>
</div>
)}
<div className="card">
<div
style={{
+17
View File
@@ -424,6 +424,22 @@ export interface SmsSettings {
post_visit_text_reject_reason?: string | null;
}
export interface PatientProfile {
full_name?: string | null;
national_code?: string | null;
gender?: string | null;
date_of_birth?: number | null;
blood_type?: string | null;
marital_status?: string | null;
job?: string | null;
address?: string | null;
home_phone?: string | null;
work_phone?: string | null;
mobile?: string | null;
basic_insurance_name?: string | null;
supplementary_insurance_name?: string | null;
}
export interface PatientRecord {
uuid: string;
entity_type: string;
@@ -433,6 +449,7 @@ export interface PatientRecord {
user_mobile?: string | null;
user_national_code?: string | null;
created_at: number;
profile?: PatientProfile | null;
}
export interface PatientSession {
+25 -5
View File
@@ -119,7 +119,7 @@ Creates a patient record for a user under the current entity. If the record alre
GET /api/v1/patient/{uuid}
```
Returns a single patient record.
Returns a single patient record, enriched with the patient's full profile (`profile`) درون‌خطی از `UserProfile`. اگر پروفایل وجود نداشت، فیلدها `null` برمی‌گردند (نه خطا). نام بیمه‌ها از روی id resolve می‌شوند.
**Response 200:**
@@ -130,14 +130,34 @@ Returns a single patient record.
"uuid": "...",
"entity_type": "doctor",
"entity_id": 5,
"user": { "uuid": "...", "fullName": "...", "phone": "..." },
"created_by_type": "doctor",
"created_by_id": 5,
"created_at": 1718375000
"user_uuid": "...",
"user_name": "...",
"user_mobile": "0912...",
"user_national_code": "...",
"created_at": 1718375000,
"profile": {
"full_name": "محمد محمدی",
"national_code": "0012345678",
"gender": "male",
"date_of_birth": 700000000,
"blood_type": "O+",
"marital_status": "single",
"job": "...",
"address": "...",
"home_phone": "...",
"work_phone": "...",
"mobile": "0912...",
"basic_insurance_id": 3,
"basic_insurance_name": "تأمین اجتماعی",
"supplementary_insurance_id": 9,
"supplementary_insurance_name": "دانا"
}
}
}
```
> `date_of_birth` یک Unix timestamp است؛ سمت کلاینت با `formatDate()` شمسی نمایش داده می‌شود. `profile` برای هر دو `entity_type` (doctor/clinic) یکسان است.
**Errors:**
| Code | HTTP | Description |
+33 -1
View File
@@ -37,8 +37,37 @@ class PatientController extends BaseController
private readonly ClinicRepository $clinicRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo,
private readonly \App\Insurance\Repository\InsuranceRepository $insuranceRepo,
) {}
private function buildPatientProfile(User $patient): array
{
$p = $this->profileRepo->findByUser($patient);
$insName = function (?int $id): ?string {
if ($id === null) { return null; }
return $this->insuranceRepo->find($id)?->getName();
};
return [
'full_name' => trim(($patient->getRealName() ?? '') . ' ' . ($p?->getFamily() ?? '')) ?: $patient->getRealName(),
'national_code' => $p?->getNationalCode() ?? $patient->getNationalCode(),
'gender' => $p?->getGender(),
'date_of_birth' => $p?->getDateOfBirth(),
'blood_type' => $p?->getBloodType(),
'marital_status' => $p?->getMaritalStatus(),
'job' => $p?->getJob(),
'address' => $p?->getAddress(),
'home_phone' => $p?->getHomePhone(),
'work_phone' => $p?->getWorkPhone(),
'mobile' => $patient->getMobileNumber(),
'basic_insurance_id' => $p?->getBasicInsuranceId(),
'basic_insurance_name' => $insName($p?->getBasicInsuranceId()),
'supplementary_insurance_id' => $p?->getSupplementaryInsuranceId(),
'supplementary_insurance_name' => $insName($p?->getSupplementaryInsuranceId()),
];
}
#[Route('/api/v1/patient/search-user', methods: ['GET'])]
public function searchUser(Request $request, #[CurrentUser] User $user): JsonResponse
{
@@ -148,7 +177,10 @@ class PatientController extends BaseController
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
return $this->success($record->toArray());
$data = $record->toArray();
$data['profile'] = $this->buildPatientProfile($record->getUser());
return $this->success($data);
}
#[Route('/api/v1/patient/{uuid}/sessions', methods: ['GET'])]