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:
@@ -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 استفاده کن.
|
||||||
@@ -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<
|
const { data: sessionsData, isLoading: sessionsLoading } = useQuery<
|
||||||
PaginatedResponse<PatientSession>
|
PaginatedResponse<PatientSession>
|
||||||
>({
|
>({
|
||||||
@@ -885,6 +892,35 @@ function MyPatientsPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
</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 className="card">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -424,6 +424,22 @@ export interface SmsSettings {
|
|||||||
post_visit_text_reject_reason?: string | null;
|
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 {
|
export interface PatientRecord {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
entity_type: string;
|
entity_type: string;
|
||||||
@@ -433,6 +449,7 @@ export interface PatientRecord {
|
|||||||
user_mobile?: string | null;
|
user_mobile?: string | null;
|
||||||
user_national_code?: string | null;
|
user_national_code?: string | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
|
profile?: PatientProfile | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatientSession {
|
export interface PatientSession {
|
||||||
|
|||||||
+25
-5
@@ -119,7 +119,7 @@ Creates a patient record for a user under the current entity. If the record alre
|
|||||||
GET /api/v1/patient/{uuid}
|
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:**
|
**Response 200:**
|
||||||
|
|
||||||
@@ -130,14 +130,34 @@ Returns a single patient record.
|
|||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"entity_type": "doctor",
|
"entity_type": "doctor",
|
||||||
"entity_id": 5,
|
"entity_id": 5,
|
||||||
"user": { "uuid": "...", "fullName": "...", "phone": "..." },
|
"user_uuid": "...",
|
||||||
"created_by_type": "doctor",
|
"user_name": "...",
|
||||||
"created_by_id": 5,
|
"user_mobile": "0912...",
|
||||||
"created_at": 1718375000
|
"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:**
|
**Errors:**
|
||||||
|
|
||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
|
|||||||
@@ -37,8 +37,37 @@ class PatientController extends BaseController
|
|||||||
private readonly ClinicRepository $clinicRepo,
|
private readonly ClinicRepository $clinicRepo,
|
||||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||||
private readonly UserActiveContextRepository $contextRepo,
|
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'])]
|
#[Route('/api/v1/patient/search-user', methods: ['GET'])]
|
||||||
public function searchUser(Request $request, #[CurrentUser] User $user): JsonResponse
|
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->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'])]
|
#[Route('/api/v1/patient/{uuid}/sessions', methods: ['GET'])]
|
||||||
|
|||||||
Reference in New Issue
Block a user