feat: create patient record without prior signup

Extend POST /api/v1/patient to resolve user by uuid or mobile, and
create a new ROLE_USER (name + optional national code, no password)
when no user exists. Admin modal shows a new-patient form on lookup miss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-23 15:20:56 +03:30
co-authored by Claude Opus 4.8
parent 59fb590d65
commit 51d7e24e04
4 changed files with 231 additions and 23 deletions
@@ -0,0 +1,82 @@
# تعریف بیمار جدید بدون ثبت‌نام قبلی در سایت
## پروژه
`clinicpro` (backend + admin frontend)
## زمینه
در حال حاضر برای ایجاد پرونده‌ی بیمار، کاربر باید **از قبل در سیستم ثبت‌نام کرده باشد**: مودال «ایجاد پرونده بیمار» با شماره موبایل جستجو می‌کند و اگر `User` پیدا نشد، خطا می‌دهد.
```php
// src/Patient/Controller/PatientController.php — searchUser
$patient = $this->userRepo->findByMobile($mobile);
if ($patient === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کاربری با این شماره یافت نشد', 404);
}
```
و `create` فقط با `user_uuid` کار می‌کند:
```php
$userUuid = trim($data['user_uuid'] ?? '');
$patient = $this->userRepo->findByUuid($userUuid);
```
## مشکل / هدف
کلینیک/مطب باید بتواند بیمار جدیدی تعریف کند که **هرگز در سایت ثبت‌نام نکرده**. یعنی اگر با شماره موبایل کاربری پیدا نشد، به‌جای خطا، امکان ساخت `User` جدید (به‌همراه پروفایل پایه: نام، کد ملی، جنسیت و …) و سپس ساخت `PatientRecord` فراهم شود.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Patient/Controller/PatientController.php` | `searchUser` + `create` |
| `src/Patient/Service/PatientService.php` | منطق ساخت پرونده |
| `src/Auth/Entity/User.php` | کاربر؛ دارای `nationalCode`, `realName`, `mobileNumber` |
| `src/UserProfile/Entity/UserProfile.php` | پروفایل کامل بیمار (family, gender, dateOfBirth, insurance ids و …) |
| `src/Auth/Repository/UserRepository.php` | `findByMobile`, `findByUuid`, `save` |
| `assets/admin/pages/MyPatientsPage.tsx` | مودال «ایجاد پرونده بیمار» (state: `createRecordOpen`, `searchMobile`, `foundUser`) |
| `docs/api/patient.md` | مستندات API بیمار |
## وضعیت فعلی
- مودال فعلی: شماره موبایل می‌گیرد → `GET /api/v1/patient/search-user?mobile=` → اگر یافت شد کارت نتیجه + دکمه‌ی «ایجاد پرونده» (`POST /api/v1/patient` با `user_uuid`).
- اگر یافت نشد: فقط پیغام خطا «کاربری با این شماره یافت نشد»؛ راهی برای ساخت بیمار جدید نیست.
- `User` ساختنی است با `new User($mobileNumber)` و `setRealName`, `setNationalCode`.
- `UserProfile` فیلدهای دموگرافیک کامل دارد (`family`, `gender`, `dateOfBirth`, `basicInsuranceId`, `supplementaryInsuranceId` …).
## وظایف
### ۱. Backend — endpoint ساخت بیمار جدید
دو رویکرد ممکن (یکی را انتخاب و توضیح بده):
- **الف)** گسترش `POST /api/v1/patient`: اگر `user_uuid` نبود ولی `mobile` + `name` (+ فیلدهای اختیاری پروفایل) آمد، `User` جدید بساز (با نقش `ROLE_USER`)، در صورت نیاز `UserProfile` بساز، سپس `PatientRecord`.
- **ب)** endpoint جدا `POST /api/v1/patient/new-user`.
> توصیه: رویکرد الف. در `create`: اول با `user_uuid`؛ اگر نبود، با `mobile` جستجو کن؛ اگر `User` نبود و `name` آمده بود، بساز.
نکات:
- اگر شماره موبایل قبلاً وجود دارد (کاربر ثبت‌نام‌کرده)، همان `User` استفاده شود (نه duplicate).
- validation: موبایل `^09\d{9}$`, کد ملی در صورت ارسال `^\d{10}$`.
- فیلدهای پروفایل اختیاری ذخیره شوند (در `User` یا `UserProfile` طبق مدل واقعی).
### ۲. Admin Frontend — مودال
مودال «ایجاد پرونده بیمار» را گسترش بده:
- بعد از جستجو، اگر کاربر **یافت نشد**، به‌جای خطای صرف، یک فرم «ساخت بیمار جدید» نشان بده (نام، کد ملی، جنسیت، و فیلدهای پایه) با موبایل از پیش پرشده.
- دکمه‌ی «ایجاد پرونده» این بار `User` جدید را هم می‌سازد.
- اگر یافت شد، رفتار فعلی حفظ شود.
### ۳. مستندات
`docs/api/patient.md` را با تغییر `POST /api/v1/patient` (یا endpoint جدید) به‌روز کن: body جدید، حالت‌های یافت‌شد/ساخت جدید، error codeها.
## نکات مهم
- entity owner از `#[CurrentUser]` resolve می‌شود (`PatientController::resolveEntity`).
- duplicate موبایل را مدیریت کن.
- این پرامپت با `patient-record-profile-binding.md` هم‌پوشانی دارد (هر دو روی پروفایل بیمار کار می‌کنند)؛ اگر آن اجرا شده، از همان ساختار `UserProfile` استفاده کن.
- بیمار ساخته‌شده بدون رمز عبور است (بعداً می‌تواند با OTP وارد شود)؛ مطمئن شو ساخت `User` بدون `passwordHash` مجاز است (هست — `nullable: true`).
+108 -8
View File
@@ -142,6 +142,8 @@ function MyPatientsPageInner() {
} | null>(null);
const [recordNationalCode, setRecordNationalCode] = useState("");
const [searchError, setSearchError] = useState("");
const [notFound, setNotFound] = useState(false);
const [newPatientName, setNewPatientName] = useState("");
const mobileInputRef = useRef<HTMLInputElement>(null);
const servicesTotal = selectedServices.reduce(
@@ -285,22 +287,28 @@ function MyPatientsPageInner() {
),
onSuccess: (res: any) => {
setFoundUser(res?.data);
setRecordNationalCode(res?.data?.national_code ?? "");
setNotFound(false);
setSearchError("");
},
onError: () => {
setFoundUser(null);
setSearchError("کاربری با این شماره در سیستم یافت نشد");
setNotFound(true);
setSearchError("");
},
});
const createRecordMut = useMutation({
mutationFn: (userUuid: string) =>
api.post("/api/v1/patient", { user_uuid: userUuid }),
mutationFn: (body: Record<string, unknown>) =>
api.post("/api/v1/patient", body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["patients"] });
setCreateRecordOpen(false);
setSearchMobile("");
setFoundUser(null);
setRecordNationalCode("");
setNotFound(false);
setNewPatientName("");
setSearchError("");
toast.success("پرونده بیمار ایجاد شد");
},
@@ -315,13 +323,32 @@ function MyPatientsPageInner() {
}
setSearchError("");
setFoundUser(null);
setNotFound(false);
searchUserMut.mutate(digits);
};
const handleCreateRecord = () => {
if (foundUser) {
createRecordMut.mutate({
user_uuid: foundUser.uuid,
national_code: recordNationalCode || undefined,
});
} else if (notFound) {
createRecordMut.mutate({
mobile: searchMobile.replace(/\D/g, ""),
name: newPatientName.trim(),
national_code: recordNationalCode || undefined,
});
}
};
const handleCreateRecordClose = () => {
setCreateRecordOpen(false);
setSearchMobile("");
setFoundUser(null);
setRecordNationalCode("");
setNotFound(false);
setNewPatientName("");
setSearchError("");
};
@@ -493,12 +520,11 @@ function MyPatientsPageInner() {
<button
className="btn primary sm"
disabled={
!foundUser || createRecordMut.isPending
}
onClick={() =>
foundUser &&
createRecordMut.mutate(foundUser.uuid)
createRecordMut.isPending ||
(!foundUser &&
!(notFound && newPatientName.trim()))
}
onClick={handleCreateRecord}
>
{createRecordMut.isPending
? "در حال ایجاد..."
@@ -685,6 +711,80 @@ function MyPatientsPageInner() {
</span>
</div>
)}
{notFound && (
<div
style={{
marginTop: 16,
padding: 14,
border: "1px solid var(--border)",
borderRadius: "var(--r)",
background: "var(--surface)",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<div
style={{
fontSize: 12.5,
color: "var(--text-3)",
lineHeight: 1.7,
}}
>
کاربری با این شماره یافت نشد. برای ثبت بیمار جدید،
نام را وارد کنید.
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 5,
}}
>
<label
style={{ fontSize: 12.5, fontWeight: 600 }}
>
نام و نام خانوادگی *
</label>
<input
className="input"
value={newPatientName}
onChange={(e) =>
setNewPatientName(e.target.value)
}
placeholder="مثال: محمد محمدی"
autoFocus
/>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 5,
}}
>
<label
style={{ fontSize: 12.5, fontWeight: 600 }}
>
کد ملی (اختیاری)
</label>
<input
className="input"
dir="ltr"
inputMode="numeric"
maxLength={10}
value={recordNationalCode}
onChange={(e) =>
setRecordNationalCode(
e.target.value.replace(/\D/g, ""),
)
}
placeholder="۱۰ رقم"
/>
</div>
</div>
)}
</Modal>
</>
);
+14 -3
View File
@@ -67,14 +67,26 @@ POST /api/v1/patient
Creates a patient record for a user under the current entity. If the record already exists, returns the existing record (idempotent).
سه حالت پشتیبانی می‌شود:
1. **کاربر ثبت‌نام‌کرده با uuid:** `user_uuid` ارسال شود.
2. **کاربر ثبت‌نام‌کرده با موبایل:** `mobile` ارسال شود (کاربر موجود پیدا می‌شود).
3. **بیمار جدید بدون ثبت‌نام:** `mobile` + `name` ارسال شود؛ اگر کاربری با آن موبایل نباشد، `User` جدید (نقش `ROLE_USER`، بدون رمز عبور) ساخته می‌شود سپس پرونده.
**Request body:**
```json
{
"user_uuid": "string (required)"
"user_uuid": "string (اختیاری)",
"mobile": "09xxxxxxxxx (اختیاری — برای جستجو یا ساخت بیمار جدید)",
"name": "string (الزامی فقط هنگام ساخت بیمار جدید)",
"national_code": "string (اختیاری، ۱۰ رقم)"
}
```
- اگر `user_uuid` و `mobile` هر دو خالی باشند → خطا.
- `national_code` فقط وقتی روی کاربر ست می‌شود که کاربر کد ملی نداشته باشد.
- موبایل تکراری duplicate نمی‌سازد؛ همان کاربر استفاده می‌شود.
**Response 201:**
```json
@@ -96,8 +108,7 @@ Creates a patient record for a user under the current entity. If the record alre
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_001` | 422 | `user_uuid` missing |
| `ERR_NOT_FOUND_001` | 404 | User not found |
| `ERR_VALIDATION_001` | 422 | `user_uuid`/`mobile` خالی، یا موبایل/کد ملی نامعتبر، یا نام برای بیمار جدید خالی |
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | No `patient_records` feature |
---
+27 -12
View File
@@ -92,21 +92,36 @@ class PatientController extends BaseController
$data = json_decode($request->getContent(), true) ?? [];
$userUuid = trim($data['user_uuid'] ?? '');
if ($userUuid === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'user_uuid الزامی است', 422);
}
$patient = $this->userRepo->findByUuid($userUuid);
if ($patient === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کاربر یافت نشد', 404);
}
$mobile = trim($data['mobile'] ?? '');
$name = trim($data['name'] ?? '');
$nationalCode = trim((string) ($data['national_code'] ?? ''));
if ($nationalCode !== '' && $patient->getNationalCode() === null) {
if (!preg_match('/^\d{10}$/', $nationalCode)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی باید ۱۰ رقم باشد', 422);
if ($nationalCode !== '' && !preg_match('/^\d{10}$/', $nationalCode)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی باید ۱۰ رقم باشد', 422);
}
$patient = null;
if ($userUuid !== '') {
$patient = $this->userRepo->findByUuid($userUuid);
} elseif ($mobile !== '') {
if (!preg_match('/^09\d{9}$/', $mobile)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422);
}
$patient = $this->userRepo->findByMobile($mobile);
}
// بیمار جدید بدون ثبت‌نام قبلی: موبایل + نام آمده ولی کاربری وجود ندارد
if ($patient === null) {
if ($mobile === '' || $name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای ساخت بیمار جدید، شماره موبایل و نام الزامی است', 422);
}
$patient = new User($mobile);
$patient->setRealName($name);
if ($nationalCode !== '') {
$patient->setNationalCode($nationalCode);
}
$this->userRepo->save($patient);
} elseif ($nationalCode !== '' && $patient->getNationalCode() === null) {
$patient->setNationalCode($nationalCode);
$this->userRepo->save($patient);
}