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