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:
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user