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<
|
||||
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={{
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
Reference in New Issue
Block a user