feat(appointment): update patient identification to use national code from profile and enhance appointment creation logic

This commit is contained in:
hamed
2026-07-15 18:46:56 +03:30
parent 0dc1ab949a
commit de6fe1bd56
8 changed files with 94 additions and 32 deletions
+1 -1
View File
@@ -603,7 +603,7 @@ Create a new appointment for a patient. If no user exists with the given mobile,
}
```
> `patient_mobile`، `patient_name` و `patient_national_code` هر سه اجباری هستند. کد ملی باید ۱۰ رقم معتبر باشد. بیمار **اول با کد ملی** و سپس با موبایل resolve می‌شود، تا پرونده برای هر کد ملی یکتا بماند (یک شخص می‌تواند چند موبایل داشته باشد). اگر کاربری یافت نشود، کاربر جدید با نقش `ROLE_USER` و همان کد ملی ساخته می‌شود.
> `patient_mobile`، `patient_name` و `patient_national_code` هر سه اجباری هستند. کد ملی باید ۱۰ رقم معتبر باشد و روی **پروفایل** بیمار ذخیره می‌شود (`profiles.national_code`، یکتا). بیمار **اول با کد ملیِ پروفایل** و سپس با موبایل resolve می‌شود، تا پرونده برای هر کد ملی یکتا بماند (یک شخص می‌تواند چند موبایل داشته باشد). اگر بیماری یافت نشود، کاربر جدید (`ROLE_USER`) به‌همراه پروفایلِ حاملِ همان کد ملی ساخته می‌شود.
### Response `201`
```json
+1 -1
View File
@@ -408,7 +408,7 @@ Create a new appointment for a patient. Used by doctor/clinic/secretary to book
| `patient_name` | string | ✅ | نام بیمار |
| `patient_national_code` | string | ✅ | کد ملی بیمار — باید ۱۰ رقم معتبر باشد (`isValidIranNationalCode`)؛ ارقام فارسی به انگلیسی تبدیل می‌شوند |
> **هویت بیمار بر پایه‌ی کد ملی:** بیمار **اول با کد ملی** پیدا می‌شود، سپس با موبایل. کد ملی یکتاست (`User.national_code unique`)، پس یک شخص می‌تواند چند موبایل داشته باشد ولی پرونده‌اش (`PatientRecord`) یکتا می‌ماند. اگر موبایلی که قبلاً با کد ملی دیگری ثبت شده دوباره با کد ملی متفاوت ارسال شود، خطای 422 برمی‌گردد. اگر هیچ کاربری یافت نشود، کاربر جدید با نقش `ROLE_USER` و همان کد ملی ساخته می‌شود.
> **هویت بیمار بر پایه‌ی کد ملی:** کد ملی روی **پروفایل** بیمار ذخیره می‌شود (`profiles.national_code`، یکتا). بیمار **اول با کد ملیِ پروفایل** پیدا می‌شود، سپس با موبایل. پس یک شخص می‌تواند چند موبایل داشته باشد ولی پرونده‌اش (`PatientRecord`) یکتا می‌ماند. اگر موبایلی که پروفایلش کد ملی دیگری دارد دوباره با کد ملی متفاوت ارسال شود، خطای 422 برمی‌گردد. اگر هیچ بیماری یافت نشود، کاربر جدید (`ROLE_USER`) به‌همراه پروفایلِ حاملِ همان کد ملی ساخته می‌شود. موبایلِ واردشده در هر نوبت به‌صورت snapshot روی خودِ نوبت (`patient_mobile`) هم ذخیره می‌شود.
### Response `201`
```json
@@ -882,6 +882,8 @@ class AdminApiController extends BaseController
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
$appointment->setPatientNationalCode($nationalCode);
$appointment->setPatientName($patientName);
$appointment->setPatientMobile($mobile);
if (!empty($data['note'])) $appointment->setNote($data['note']);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
if ($locationId !== null) $appointment->setAddressId($locationId);
@@ -41,6 +41,7 @@ class MyAppointmentsController extends BaseController
private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo,
private readonly PatientResolver $patientResolver,
private readonly \App\Auth\Repository\UserRepository $userRepo,
private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo,
) {}
#[Route('/api/v1/my/appointment', methods: ['POST'])]
@@ -123,6 +124,7 @@ class MyAppointmentsController extends BaseController
$appointment->setDepositAmountRials((int) $data['deposit_amount_rials']);
}
$appointment->setPatientName($patientName);
$appointment->setPatientMobile($mobile);
if ($isReserve) {
// Day-level reserve: no slot occupation, plain save (no atomic slot check).
@@ -170,11 +172,14 @@ class MyAppointmentsController extends BaseController
return $this->success(['found' => false]);
}
// National code lives on the profile (profiles.national_code), not on User.
$nationalCode = $this->profileRepo->findByUser($patient)?->getNationalCode();
return $this->success([
'found' => true,
'name' => $patient->getRealName(),
'mobile' => $patient->getMobileNumber(),
'national_code' => $patient->getNationalCode(),
'national_code' => $nationalCode,
]);
}
-5
View File
@@ -18,11 +18,6 @@ class UserRepository extends ServiceEntityRepository
return $this->findOneBy(['mobileNumber' => $mobile]);
}
public function findByNationalCode(string $nationalCode): ?User
{
return $this->findOneBy(['nationalCode' => $nationalCode]);
}
public function findByUuid(string $uuid): ?User
{
return $this->findOneBy(['uuid' => $uuid]);
+28 -10
View File
@@ -6,53 +6,71 @@ use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\UserProfile\Entity\UserProfile;
use App\UserProfile\Repository\UserProfileRepository;
/**
* Resolves (or creates) the patient User for an admin-side booking.
*
* Identity key is the national code, which is unique per person. Mobile is only
* a contact detail — one national code may be booked under several mobiles — so
* Identity key is the national code, which is stored on the patient's
* UserProfile (unique per person — profiles.national_code). Mobile is only a
* contact detail; one national code may be booked under several mobiles, so
* lookup prefers the national code and never overwrites an existing mobile.
* Keeping resolution here (not duplicated in each controller) keeps the case-file
* (PatientRecord, keyed on user_id) unique per national code.
* Keeping resolution here (not duplicated in each controller) keeps the
* case-file (PatientRecord, keyed on user_id) unique per national code.
*/
class PatientResolver
{
public function __construct(private readonly UserRepository $userRepo) {}
public function __construct(
private readonly UserRepository $userRepo,
private readonly UserProfileRepository $profileRepo,
) {}
/**
* @param string $nationalCode already normalized to English digits and validated
*/
public function resolveForBooking(string $nationalCode, string $mobile, string $name): User
{
$user = $this->userRepo->findByNationalCode($nationalCode);
if ($user !== null) {
// 1) National code lives on the profile and is unique — the real identity.
$profile = $this->profileRepo->findOneByNationalCode($nationalCode);
if ($profile !== null) {
$user = $profile->getUser();
$this->fillNameIfEmpty($user, $name);
return $user;
}
// 2) Fall back to mobile; bind the national code to that patient's profile.
$user = $this->userRepo->findByMobile($mobile);
if ($user !== null) {
$existing = $user->getNationalCode();
$profile = $this->profileRepo->findByUser($user);
$existing = $profile?->getNationalCode();
if ($existing !== null && $existing !== $nationalCode) {
throw new AppException(ErrorCodes::ERR_PROFILE_MOBILE_TAKEN, 'این شماره موبایل با کد ملی دیگری ثبت شده است', 422, 'patient_mobile');
}
if ($existing === null) {
$user->setNationalCode($nationalCode);
$this->bindNationalCode($profile ?? new UserProfile($user), $nationalCode);
}
$this->fillNameIfEmpty($user, $name);
return $user;
}
// 3) New patient: create the user and its profile carrying the national code.
$user = new User($mobile);
$user->setRealName($name);
$user->setNationalCode($nationalCode);
$user->setRoles(['ROLE_USER']);
$this->userRepo->save($user, false);
$this->bindNationalCode(new UserProfile($user), $nationalCode);
return $user;
}
private function bindNationalCode(UserProfile $profile, string $nationalCode): void
{
$profile->setNationalCode($nationalCode);
$this->profileRepo->save($profile, false);
}
private function fillNameIfEmpty(User $user, string $name): void
{
if (($user->getRealName() ?? '') === '' && $name !== '') {
@@ -2,16 +2,19 @@
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Shared\Constant\ErrorCodes;
use App\Tests\ApiTestCase;
use App\UserProfile\Entity\UserProfile;
/**
* POST /api/v1/my/appointment must identify the patient by national code:
* it is required + validated, and the patient User (hence the case-file) is
* resolved by national code first so one person keeps a single record even
* when booked under a different mobile.
* POST /api/v1/my/appointment must identify the patient by national code.
* National code is stored on the patient's UserProfile (profiles.national_code,
* unique), so the patient User — and thus the case-file — is resolved by that
* profile first. One person keeps a single record even when booked under
* several mobiles.
*/
class AppointmentNationalCodeTest extends ApiTestCase
{
@@ -50,17 +53,23 @@ class AppointmentNationalCodeTest extends ApiTestCase
];
}
public function testCreatesUserWithNationalCode(): void
private function profileByNationalCode(string $nc): ?UserProfile
{
return $this->em->getRepository(UserProfile::class)->findOneBy(['nationalCode' => $nc]);
}
public function testStoresNationalCodeOnProfile(): void
{
[$owner, $doctor] = $this->doctor();
$nc = $this->nationalCode();
$nc = $this->nationalCode();
$mobile = $this->mobile();
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $this->mobile(), $nc));
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $mobile, $nc));
self::assertSame(201, $this->responseCode());
$patient = $this->em->getRepository(User::class)->findOneBy(['nationalCode' => $nc]);
self::assertNotNull($patient);
self::assertSame($nc, $patient->getNationalCode());
$profile = $this->profileByNationalCode($nc);
self::assertNotNull($profile);
self::assertSame($mobile, $profile->getUser()->getMobileNumber());
}
public function testSameNationalCodeDifferentMobileReusesSinglePatient(): void
@@ -68,7 +77,7 @@ class AppointmentNationalCodeTest extends ApiTestCase
[$owner, $doctor] = $this->doctor();
$nc = $this->nationalCode();
// First booking under mobile A creates the patient.
// First booking under mobile A creates the patient + profile.
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $this->mobile(), $nc));
self::assertSame(201, $this->responseCode());
@@ -77,8 +86,38 @@ class AppointmentNationalCodeTest extends ApiTestCase
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $this->mobile(), $nc));
self::assertSame(201, $this->responseCode());
$patients = $this->em->getRepository(User::class)->findBy(['nationalCode' => $nc]);
self::assertCount(1, $patients);
$profiles = $this->em->getRepository(UserProfile::class)->findBy(['nationalCode' => $nc]);
self::assertCount(1, $profiles);
}
/**
* The reported bug: a patient already exists whose national code sits on
* their profile. Booking under a brand-new mobile with that same national
* code must attach to the existing patient, not stamp the code onto a new
* user.
*/
public function testExistingProfileNationalCodeIsReusedNotDuplicated(): void
{
[$owner, $doctor] = $this->doctor();
$nc = $this->nationalCode();
// Existing patient with the national code on their profile.
$existing = $this->createUser(['ROLE_USER'], $this->mobile());
$profile = new UserProfile($existing);
$profile->setNationalCode($nc);
$this->em->persist($profile);
$this->em->flush();
// Book under a different mobile but the same national code.
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $this->mobile(), $nc));
self::assertSame(201, $this->responseCode());
// No duplicate profile, and the appointment is attached to the existing user.
$profiles = $this->em->getRepository(UserProfile::class)->findBy(['nationalCode' => $nc]);
self::assertCount(1, $profiles);
$appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $res['data']['uuid']]);
self::assertSame($existing->getId(), $appointment->getUser()->getId());
}
public function testMissingNationalCodeIs422(): void
+4 -1
View File
@@ -4,6 +4,7 @@ namespace App\Tests\Appointment;
use App\Auth\Entity\User;
use App\Tests\ApiTestCase;
use App\UserProfile\Entity\UserProfile;
/**
* GET /api/v1/my/appointment/patient-lookup — mobile-first patient search used
@@ -32,7 +33,9 @@ class PatientLookupTest extends ApiTestCase
$nc = $this->nationalCode();
$patient = $this->createUser(['ROLE_USER'], $mobile);
$patient->setRealName('علی محمدی');
$patient->setNationalCode($nc);
$profile = new UserProfile($patient);
$profile->setNationalCode($nc);
$this->em->persist($profile);
$this->em->flush();
$res = $this->authJson('GET', '/api/v1/my/appointment/patient-lookup?mobile=' . $mobile, $this->booker());