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
@@ -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);