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