feat(appointment): identify admin-booked patient by national code

Admin-side booking (POST /api/v1/my/appointment and
/api/v1/admin/appointment) resolved the patient User by mobile only, so
one person booked under two mobiles produced two User rows — and two
case-files, since PatientRecord is keyed on user_id. National code is the
real unique identity (User.national_code is already unique); a person may
have several mobiles.

Booking now requires + validates patient_national_code and resolves the
patient national-code-first (then mobile) via a shared PatientResolver, so
the case-file stays unique per national code even across mobiles. Reusing a
mobile already bound to a different national code returns 422
ERR_PROFILE_MOBILE_TAKEN. The admin create form and NewAppointmentDrawer
gain a national-code field and send it; both had a dead patient-picker URL
(/api/v1/patient) fixed to the real /api/v1/patients, whose payload already
carries user_national_code for autofill.

Docs (appointment.md, admin.md) and tests updated; new
AppointmentNationalCodeTest covers success, single-file reuse, missing,
invalid, and identity-conflict cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-15 18:11:40 +03:30
co-authored by Claude Opus 4.8
parent 141ce478a2
commit 5548d79d4c
14 changed files with 430 additions and 34 deletions
+14 -8
View File
@@ -41,6 +41,7 @@ class AdminApiController extends BaseController
private readonly \App\Insurance\Service\TenantInsuranceCleanupService $insuranceCleanup,
private readonly \App\Payment\Service\PaymentManager $paymentManager,
private readonly \App\Payment\Repository\PaymentRepository $paymentRepo,
private readonly \App\Patient\Service\PatientResolver $patientResolver,
) {}
// ── Users ─────────────────────────────────────────────────────────────────
@@ -857,25 +858,30 @@ class AdminApiController extends BaseController
$doctorUuid = trim($data['doctor_uuid'] ?? '');
$slotStart = (int) ($data['slot_start'] ?? 0);
$slotEnd = (int) ($data['slot_end'] ?? 0);
$mobile = trim($data['patient_mobile'] ?? '');
$mobile = InputValidator::toEnglishDigits(trim($data['patient_mobile'] ?? ''));
$patientName = trim($data['patient_name'] ?? '');
$nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? '')));
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) {
return $this->error(ErrorCodes::VALIDATION, 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 422);
}
if ($nationalCode === '') {
return $this->error(ErrorCodes::VALIDATION, 'کد ملی بیمار الزامی است', 422, 'patient_national_code');
}
if (!InputValidator::isValidIranNationalCode($nationalCode)) {
return $this->error(ErrorCodes::VALIDATION, 'کد ملی نامعتبر است', 422, 'patient_national_code');
}
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $doctorUuid]);
if (!$doctor) return $this->error(ErrorCodes::DOCTOR_NOT_FOUND, 'پزشک یافت نشد', 404);
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
if (!$patient) {
$patient = new User($mobile);
$patient->setRealName($patientName);
$patient->setRoles(['ROLE_USER']);
$this->em->persist($patient);
}
// Identity is keyed on the national code (unique) so the case-file stays
// single per person even when booked under a different mobile.
$patient = $this->patientResolver->resolveForBooking($nationalCode, $mobile, $patientName);
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
$appointment->setPatientNationalCode($nationalCode);
if (!empty($data['note'])) $appointment->setNote($data['note']);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
if ($locationId !== null) $appointment->setAddressId($locationId);
@@ -12,9 +12,11 @@ use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Repository\DoctorRepository;
use App\Patient\Service\PatientResolver;
use App\Secretary\Entity\DoctorSecretary;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Shared\Controller\BaseController;
use App\Shared\Service\InputValidator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -37,6 +39,7 @@ class MyAppointmentsController extends BaseController
private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo,
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo,
private readonly PatientResolver $patientResolver,
) {}
#[Route('/api/v1/my/appointment', methods: ['POST'])]
@@ -53,8 +56,9 @@ class MyAppointmentsController extends BaseController
$doctorUuid = trim($data['doctor_uuid'] ?? '');
$slotStart = (int) ($data['slot_start'] ?? 0);
$slotEnd = (int) ($data['slot_end'] ?? 0);
$mobile = trim($data['patient_mobile'] ?? '');
$mobile = InputValidator::toEnglishDigits(trim($data['patient_mobile'] ?? ''));
$patientName = trim($data['patient_name'] ?? '');
$nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? '')));
$isReserve = (bool) ($data['is_reserve'] ?? false);
// Reserve entries are day-level: only a date is picked in the UI, so
@@ -67,6 +71,13 @@ class MyAppointmentsController extends BaseController
return $this->error(ErrorCodes::VALIDATION, 'همه فیلدها الزامی است', 422);
}
if ($nationalCode === '') {
return $this->error(ErrorCodes::VALIDATION, 'کد ملی بیمار الزامی است', 422, 'patient_national_code');
}
if (!InputValidator::isValidIranNationalCode($nationalCode)) {
return $this->error(ErrorCodes::VALIDATION, 'کد ملی نامعتبر است', 422, 'patient_national_code');
}
if (!$isReserve && $slotStart < time()) {
return $this->error(ErrorCodes::SLOT_PAST, 'زمان این اسلات گذشته است', 422);
}
@@ -78,15 +89,12 @@ class MyAppointmentsController extends BaseController
return $this->error(ErrorCodes::FORBIDDEN, 'برای این پزشک مجاز به ثبت نوبت نیستید', 403);
}
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
if (!$patient) {
$patient = new User($mobile);
$patient->setRealName($patientName);
$patient->setRoles(['ROLE_USER']);
$this->em->persist($patient);
}
// Identity is keyed on the national code (unique) so the case-file stays
// single per person even when booked under a different mobile.
$patient = $this->patientResolver->resolveForBooking($nationalCode, $mobile, $patientName);
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
$appointment->setPatientNationalCode($nationalCode);
if (!empty($data['note'])) $appointment->setNote($data['note']);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
if ($locationId !== null) $appointment->setAddressId($locationId);
+5
View File
@@ -18,6 +18,11 @@ 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]);
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Patient\Service;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* 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
* 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.
*/
class PatientResolver
{
public function __construct(private readonly UserRepository $userRepo) {}
/**
* @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) {
$this->fillNameIfEmpty($user, $name);
return $user;
}
$user = $this->userRepo->findByMobile($mobile);
if ($user !== null) {
$existing = $user->getNationalCode();
if ($existing !== null && $existing !== $nationalCode) {
throw new AppException(ErrorCodes::ERR_PROFILE_MOBILE_TAKEN, 'این شماره موبایل با کد ملی دیگری ثبت شده است', 422, 'patient_mobile');
}
if ($existing === null) {
$user->setNationalCode($nationalCode);
}
$this->fillNameIfEmpty($user, $name);
return $user;
}
$user = new User($mobile);
$user->setRealName($name);
$user->setNationalCode($nationalCode);
$user->setRoles(['ROLE_USER']);
$this->userRepo->save($user, false);
return $user;
}
private function fillNameIfEmpty(User $user, string $name): void
{
if (($user->getRealName() ?? '') === '' && $name !== '') {
$user->setRealName($name);
}
}
}