diff --git a/docs/api/admin.md b/docs/api/admin.md index ce229917..fe4630b4 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -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 diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 405c491a..75896c42 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -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 diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index fc49fcd0..ec1baac1 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -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); diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 1cf64e22..bf6d8997 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -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, ]); } diff --git a/src/Auth/Repository/UserRepository.php b/src/Auth/Repository/UserRepository.php index 10c5391d..ef059888 100644 --- a/src/Auth/Repository/UserRepository.php +++ b/src/Auth/Repository/UserRepository.php @@ -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]); diff --git a/src/Patient/Service/PatientResolver.php b/src/Patient/Service/PatientResolver.php index 3f64dd51..18b5ee82 100644 --- a/src/Patient/Service/PatientResolver.php +++ b/src/Patient/Service/PatientResolver.php @@ -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 !== '') { diff --git a/tests/Appointment/AppointmentNationalCodeTest.php b/tests/Appointment/AppointmentNationalCodeTest.php index 3803e2a7..d4e2c4e0 100644 --- a/tests/Appointment/AppointmentNationalCodeTest.php +++ b/tests/Appointment/AppointmentNationalCodeTest.php @@ -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 diff --git a/tests/Appointment/PatientLookupTest.php b/tests/Appointment/PatientLookupTest.php index 54c64868..9f7daea1 100644 --- a/tests/Appointment/PatientLookupTest.php +++ b/tests/Appointment/PatientLookupTest.php @@ -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());