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:
@@ -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`).
|
||||||
@@ -142,6 +142,8 @@ function MyPatientsPageInner() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [recordNationalCode, setRecordNationalCode] = useState("");
|
const [recordNationalCode, setRecordNationalCode] = useState("");
|
||||||
const [searchError, setSearchError] = useState("");
|
const [searchError, setSearchError] = useState("");
|
||||||
|
const [notFound, setNotFound] = useState(false);
|
||||||
|
const [newPatientName, setNewPatientName] = useState("");
|
||||||
const mobileInputRef = useRef<HTMLInputElement>(null);
|
const mobileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const servicesTotal = selectedServices.reduce(
|
const servicesTotal = selectedServices.reduce(
|
||||||
@@ -285,22 +287,28 @@ function MyPatientsPageInner() {
|
|||||||
),
|
),
|
||||||
onSuccess: (res: any) => {
|
onSuccess: (res: any) => {
|
||||||
setFoundUser(res?.data);
|
setFoundUser(res?.data);
|
||||||
|
setRecordNationalCode(res?.data?.national_code ?? "");
|
||||||
|
setNotFound(false);
|
||||||
setSearchError("");
|
setSearchError("");
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
setFoundUser(null);
|
setFoundUser(null);
|
||||||
setSearchError("کاربری با این شماره در سیستم یافت نشد");
|
setNotFound(true);
|
||||||
|
setSearchError("");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createRecordMut = useMutation({
|
const createRecordMut = useMutation({
|
||||||
mutationFn: (userUuid: string) =>
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
api.post("/api/v1/patient", { user_uuid: userUuid }),
|
api.post("/api/v1/patient", body),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["patients"] });
|
qc.invalidateQueries({ queryKey: ["patients"] });
|
||||||
setCreateRecordOpen(false);
|
setCreateRecordOpen(false);
|
||||||
setSearchMobile("");
|
setSearchMobile("");
|
||||||
setFoundUser(null);
|
setFoundUser(null);
|
||||||
|
setRecordNationalCode("");
|
||||||
|
setNotFound(false);
|
||||||
|
setNewPatientName("");
|
||||||
setSearchError("");
|
setSearchError("");
|
||||||
toast.success("پرونده بیمار ایجاد شد");
|
toast.success("پرونده بیمار ایجاد شد");
|
||||||
},
|
},
|
||||||
@@ -315,13 +323,32 @@ function MyPatientsPageInner() {
|
|||||||
}
|
}
|
||||||
setSearchError("");
|
setSearchError("");
|
||||||
setFoundUser(null);
|
setFoundUser(null);
|
||||||
|
setNotFound(false);
|
||||||
searchUserMut.mutate(digits);
|
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 = () => {
|
const handleCreateRecordClose = () => {
|
||||||
setCreateRecordOpen(false);
|
setCreateRecordOpen(false);
|
||||||
setSearchMobile("");
|
setSearchMobile("");
|
||||||
setFoundUser(null);
|
setFoundUser(null);
|
||||||
|
setRecordNationalCode("");
|
||||||
|
setNotFound(false);
|
||||||
|
setNewPatientName("");
|
||||||
setSearchError("");
|
setSearchError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -493,12 +520,11 @@ function MyPatientsPageInner() {
|
|||||||
<button
|
<button
|
||||||
className="btn primary sm"
|
className="btn primary sm"
|
||||||
disabled={
|
disabled={
|
||||||
!foundUser || createRecordMut.isPending
|
createRecordMut.isPending ||
|
||||||
}
|
(!foundUser &&
|
||||||
onClick={() =>
|
!(notFound && newPatientName.trim()))
|
||||||
foundUser &&
|
|
||||||
createRecordMut.mutate(foundUser.uuid)
|
|
||||||
}
|
}
|
||||||
|
onClick={handleCreateRecord}
|
||||||
>
|
>
|
||||||
{createRecordMut.isPending
|
{createRecordMut.isPending
|
||||||
? "در حال ایجاد..."
|
? "در حال ایجاد..."
|
||||||
@@ -685,6 +711,80 @@ function MyPatientsPageInner() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+14
-3
@@ -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).
|
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:**
|
**Request body:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"user_uuid": "string (required)"
|
"user_uuid": "string (اختیاری)",
|
||||||
|
"mobile": "09xxxxxxxxx (اختیاری — برای جستجو یا ساخت بیمار جدید)",
|
||||||
|
"name": "string (الزامی فقط هنگام ساخت بیمار جدید)",
|
||||||
|
"national_code": "string (اختیاری، ۱۰ رقم)"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- اگر `user_uuid` و `mobile` هر دو خالی باشند → خطا.
|
||||||
|
- `national_code` فقط وقتی روی کاربر ست میشود که کاربر کد ملی نداشته باشد.
|
||||||
|
- موبایل تکراری duplicate نمیسازد؛ همان کاربر استفاده میشود.
|
||||||
|
|
||||||
**Response 201:**
|
**Response 201:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -96,8 +108,7 @@ Creates a patient record for a user under the current entity. If the record alre
|
|||||||
|
|
||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
|------|------|-------------|
|
|------|------|-------------|
|
||||||
| `ERR_VALIDATION_001` | 422 | `user_uuid` missing |
|
| `ERR_VALIDATION_001` | 422 | `user_uuid`/`mobile` خالی، یا موبایل/کد ملی نامعتبر، یا نام برای بیمار جدید خالی |
|
||||||
| `ERR_NOT_FOUND_001` | 404 | User not found |
|
|
||||||
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | No `patient_records` feature |
|
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | No `patient_records` feature |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -92,21 +92,36 @@ class PatientController extends BaseController
|
|||||||
|
|
||||||
$data = json_decode($request->getContent(), true) ?? [];
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
$userUuid = trim($data['user_uuid'] ?? '');
|
$userUuid = trim($data['user_uuid'] ?? '');
|
||||||
|
$mobile = trim($data['mobile'] ?? '');
|
||||||
if ($userUuid === '') {
|
$name = trim($data['name'] ?? '');
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
$nationalCode = trim((string) ($data['national_code'] ?? ''));
|
$nationalCode = trim((string) ($data['national_code'] ?? ''));
|
||||||
if ($nationalCode !== '' && $patient->getNationalCode() === null) {
|
if ($nationalCode !== '' && !preg_match('/^\d{10}$/', $nationalCode)) {
|
||||||
if (!preg_match('/^\d{10}$/', $nationalCode)) {
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی باید ۱۰ رقم باشد', 422);
|
||||||
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);
|
$patient->setNationalCode($nationalCode);
|
||||||
$this->userRepo->save($patient);
|
$this->userRepo->save($patient);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user