resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } return $this->success(array_map( fn(\App\Patient\Entity\PatientAttachment $a) => $a->toArray(), $this->attachmentRepo->findByRecord($record) )); } #[Route('/api/v1/patient/{uuid}/attachment', methods: ['POST'])] public function uploadAttachment(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } try { $stored = $this->fileUpload->storeFromRequest($request, 'patients/attachments'); } catch (\RuntimeException $e) { return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422); } $name = trim((string) $request->query->get('name', '')) ?: $stored['filename']; $attachment = new \App\Patient\Entity\PatientAttachment($record, $name, $stored['url'], $stored['filemime'], $stored['size']); $this->attachmentRepo->save($attachment); return $this->success($attachment->toArray(), 201); } #[Route('/api/v1/patient/attachment/{uuid}', methods: ['DELETE'])] public function deleteAttachment(string $uuid, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $attachment = $this->attachmentRepo->findByUuid($uuid); if ($attachment === null || !$this->ownsRecord($attachment->getRecord(), $entityType, $entityId)) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'ضمیمه یافت نشد', 404); } $this->attachmentRepo->remove($attachment); return $this->success(['message' => 'ضمیمه حذف شد']); } /** * Assign record labels from the payload (`tags` = array of TenantTag uuids), * scoped to the caller's entity. Returns a 422 response on a foreign tag, * otherwise null. Does nothing when `tags` is absent. */ private function applyRecordTags(PatientRecord $record, array $data, string $entityType, int $entityId): ?JsonResponse { if (!array_key_exists('tags', $data)) { return null; } $uuids = is_array($data['tags']) ? $data['tags'] : []; $tags = []; foreach (array_values(array_unique(array_filter($uuids))) as $uuid) { $tag = $this->tenantTagRepo->findByUuid((string) $uuid); if ($tag === null || $tag->getEntityType() !== $entityType || $tag->getEntityId() !== $entityId) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برچسب انتخاب‌شده متعلق به شما نیست', 422, 'tags'); } $tags[] = $tag; } $record->setTags($tags); return null; } private function buildPatientProfile(User $patient): array { $p = $this->profileRepo->findByUser($patient); $insName = function (?int $id): ?string { if ($id === null) { return null; } return $this->insuranceRepo->find($id)?->getName(); }; return [ 'full_name' => trim(($patient->getRealName() ?? '') . ' ' . ($p?->getFamily() ?? '')) ?: $patient->getRealName(), 'name' => $patient->getRealName(), 'family' => $p?->getFamily(), 'fathers_name' => $p?->getFathersName(), 'national_code' => $p?->getNationalCode() ?? $patient->getNationalCode(), 'gender' => $p?->getGender(), 'date_of_birth' => $p?->getDateOfBirth(), 'blood_type' => $p?->getBloodType(), 'marital_status' => $p?->getMaritalStatus(), 'education' => $p?->getEducation(), 'field_of_study' => $p?->getFieldOfStudy(), 'job' => $p?->getJob(), 'address' => $p?->getAddress(), 'province_id' => $p?->getProvinceId(), 'city_id' => $p?->getCityId(), 'postal_code' => $p?->getPostalCode(), 'referral_source' => $p?->getReferralSource(), 'description' => $p?->getDescription(), 'home_phone' => $p?->getHomePhone(), 'work_phone' => $p?->getWorkPhone(), 'mobile' => $patient->getMobileNumber(), 'basic_insurance_id' => $p?->getBasicInsuranceId(), 'basic_insurance_name' => $insName($p?->getBasicInsuranceId()), 'supplementary_insurance_id' => $p?->getSupplementaryInsuranceId(), 'supplementary_insurance_name' => $insName($p?->getSupplementaryInsuranceId()), ]; } #[Route('/api/v1/patient/search-user', methods: ['GET'])] public function searchUser(Request $request, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $mobile = trim($request->query->get('mobile', '')); if ($mobile === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'mobile الزامی است', 422); } $patient = $this->userRepo->findByMobile($mobile); if ($patient === null) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کاربری با این شماره یافت نشد', 404); } return $this->success([ 'uuid' => $patient->getUuid(), 'name' => $patient->getRealName(), 'mobile' => $patient->getMobileNumber(), 'national_code' => $patient->getNationalCode(), ]); } #[Route('/api/v1/patients', methods: ['GET'])] public function list(Request $request, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $page = max(1, (int) $request->query->get('page', 1)); $limit = min(50, max(10, (int) $request->query->get('limit', 20))); $search = $request->query->get('search') ?: null; $records = $this->recordRepo->findByEntity($entityType, $entityId, $page, $limit, $search); $total = $this->recordRepo->countByEntity($entityType, $entityId, $search); return $this->paginated( array_map(fn(PatientRecord $r) => $r->toArray(), $records), $total, $page, $limit ); } #[Route('/api/v1/patient', methods: ['POST'])] public function create(Request $request, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $data = json_decode($request->getContent(), true) ?? []; $userUuid = trim($data['user_uuid'] ?? ''); $mobile = trim($data['mobile'] ?? ''); $name = trim($data['name'] ?? ''); $nationalCode = trim((string) ($data['national_code'] ?? '')); if ($nationalCode !== '' && !preg_match('/^\d{10}$/', $nationalCode)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی باید ۱۰ رقم باشد', 422); } $patient = null; if ($userUuid !== '') { $patient = $this->userRepo->findByUuid($userUuid); } elseif ($mobile !== '') { if (!preg_match('/^09\d{9}$/', $mobile)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422); } $patient = $this->userRepo->findByMobile($mobile); } // کد ملی باید در سطح بیمار یکتا باشد: اگر به پروفایلِ کاربر دیگری تعلق دارد، رد کن if ($nationalCode !== '') { $owner = $this->profileRepo->findOneByNationalCode($nationalCode); if ($owner !== null && ($patient === null || $owner->getUser()->getId() !== $patient->getId())) { $masked = \App\Shared\Service\InputValidator::maskMobile($owner->getUser()->getMobileNumber()); return $this->error( ErrorCodes::ERR_PROFILE_NATIONAL_CODE_TAKEN, "این کد ملی قبلاً با شماره {$masked} ثبت شده است", 409, 'national_code' ); } } // بیمار جدید بدون ثبت‌نام قبلی: موبایل + نام آمده ولی کاربری وجود ندارد if ($patient === null) { if ($mobile === '' || $name === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای ساخت بیمار جدید، شماره موبایل و نام الزامی است', 422); } $patient = new User($mobile); $patient->setRealName($name); if ($nationalCode !== '') { $patient->setNationalCode($nationalCode); } $this->userRepo->save($patient); } elseif ($nationalCode !== '' && $patient->getNationalCode() === null) { $patient->setNationalCode($nationalCode); $this->userRepo->save($patient); } $existing = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient); if ($existing !== null) { return $this->success($existing->toArray()); } $record = new PatientRecord($entityType, $entityId, $patient, $user->hasRole('ROLE_DOCTOR') ? 'doctor' : 'clinic', $entityId); if (($rn = trim((string) ($data['record_number'] ?? ''))) !== '') { $record->setRecordNumber($rn); } $tagError = $this->applyRecordTags($record, $data, $entityType, $entityId); if ($tagError !== null) { return $tagError; } $this->recordRepo->save($record); return $this->success($record->toArray(), 201); } #[Route('/api/v1/patient/{uuid}', methods: ['GET'])] public function show(string $uuid, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } $data = $record->toArray(); $data['profile'] = $this->buildPatientProfile($record->getUser()); return $this->success($data); } #[Route('/api/v1/patient/{uuid}', methods: ['PATCH'])] public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } $patient = $record->getUser(); $data = json_decode($request->getContent(), true) ?? []; if (array_key_exists('name', $data)) { $name = trim((string) $data['name']); if ($name !== '') { $patient->setRealName($name); } } // کد ملی باید ۱۰ رقم و در سطح بیمار یکتا باشد if (array_key_exists('national_code', $data)) { $nationalCode = trim((string) ($data['national_code'] ?? '')); if ($nationalCode !== '') { if (!preg_match('/^\d{10}$/', $nationalCode)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی باید ۱۰ رقم باشد', 422, 'national_code'); } $owner = $this->profileRepo->findOneByNationalCode($nationalCode); if ($owner !== null && $owner->getUser()->getId() !== $patient->getId()) { $masked = \App\Shared\Service\InputValidator::maskMobile($owner->getUser()->getMobileNumber()); return $this->error( ErrorCodes::ERR_PROFILE_NATIONAL_CODE_TAKEN, "این کد ملی قبلاً با شماره {$masked} ثبت شده است", 409, 'national_code' ); } $patient->setNationalCode($nationalCode); } } // موبایل = شناسه ورود کاربر؛ تغییر آن باید ۱۱ رقمی معتبر و در سطح کاربران یکتا باشد if (array_key_exists('mobile', $data)) { $mobile = trim((string) ($data['mobile'] ?? '')); if ($mobile !== '' && $mobile !== $patient->getMobileNumber()) { if (!preg_match('/^09\d{9}$/', $mobile)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422, 'mobile'); } $owner = $this->userRepo->findByMobile($mobile); if ($owner !== null && $owner->getId() !== $patient->getId()) { return $this->error( ErrorCodes::ERR_PROFILE_MOBILE_TAKEN, ErrorCodes::message(ErrorCodes::ERR_PROFILE_MOBILE_TAKEN), 409, 'mobile' ); } $patient->setMobileNumber($mobile); } } $this->userRepo->save($patient); $profile = $this->profileRepo->findByUser($patient) ?? new UserProfile($patient); $stringFields = [ 'family' => 'setFamily', 'fathers_name' => 'setFathersName', 'gender' => 'setGender', 'blood_type' => 'setBloodType', 'marital_status' => 'setMaritalStatus', 'education' => 'setEducation', 'field_of_study' => 'setFieldOfStudy', 'job' => 'setJob', 'address' => 'setAddress', 'postal_code' => 'setPostalCode', 'referral_source' => 'setReferralSource', 'description' => 'setDescription', 'home_phone' => 'setHomePhone', 'work_phone' => 'setWorkPhone', ]; foreach ($stringFields as $key => $setter) { if (array_key_exists($key, $data)) { $v = is_string($data[$key]) ? trim($data[$key]) : $data[$key]; $profile->$setter($v === '' ? null : $v); } } $intFields = [ 'province_id' => 'setProvinceId', 'city_id' => 'setCityId', ]; foreach ($intFields as $key => $setter) { if (array_key_exists($key, $data)) { $v = $data[$key]; $profile->$setter(($v === null || $v === '') ? null : (int) $v); } } if (array_key_exists('national_code', $data)) { $nc = trim((string) ($data['national_code'] ?? '')); $profile->setNationalCode($nc === '' ? null : $nc); } if (array_key_exists('date_of_birth', $data)) { $dob = $data['date_of_birth']; $profile->setDateOfBirth(($dob === null || $dob === '') ? null : (int) $dob); } if (array_key_exists('basic_insurance_id', $data)) { $bi = $data['basic_insurance_id']; $profile->setBasicInsuranceId(($bi === null || $bi === '') ? null : (int) $bi); } if (array_key_exists('supplementary_insurance_id', $data)) { $si = $data['supplementary_insurance_id']; $profile->setSupplementaryInsuranceId(($si === null || $si === '') ? null : (int) $si); } $this->profileRepo->save($profile); if (array_key_exists('record_number', $data)) { $rn = trim((string) ($data['record_number'] ?? '')); $record->setRecordNumber($rn === '' ? null : $rn); } $tagError = $this->applyRecordTags($record, $data, $entityType, $entityId); if ($tagError !== null) { return $tagError; } $this->recordRepo->save($record); $out = $record->toArray(); $out['profile'] = $this->buildPatientProfile($patient); return $this->success($out); } #[Route('/api/v1/patient/{uuid}/sessions', methods: ['GET'])] public function sessions(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } $page = max(1, (int) $request->query->get('page', 1)); $limit = min(50, max(10, (int) $request->query->get('limit', 20))); $sessions = $this->sessionRepo->findByRecord($record, $page, $limit); $total = $this->sessionRepo->countByRecord($record); return $this->paginated( array_map(fn($s) => $this->sessionWithBilling($s), $sessions), $total, $page, $limit ); } #[Route('/api/v1/patient/{uuid}/appointments', methods: ['GET'])] public function appointments(string $uuid, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } // نوبت‌های این بیمار فقط با پزشک(های) همین ارائه‌دهنده نمایش داده می‌شوند تا // نوبت‌های او با کلینیک‌های دیگر نشت نکند. $doctorIds = $entityType === 'doctor' ? [$entityId] : $this->invitationRepo->acceptedDoctorIdsByClinic($entityId); $appointments = $this->appointmentRepo->findByUserAndDoctorIds($record->getUser(), $doctorIds); return $this->success(array_map(fn(\App\Appointment\Entity\Appointment $a) => [ 'uuid' => $a->getUuid(), 'starts_at' => $a->getSlotStart(), 'ends_at' => $a->getSlotEnd(), 'status' => $a->getStatus(), 'doctor_name' => $a->getDoctor()->getName(), 'service_name' => null, 'price_rials' => null, 'created_at' => $a->getSlotStart(), ], $appointments)); } /** * خروجی session به‌همراه خلاصه‌ی صورتحساب: uuid فاکتور (در صورت وجود) و * مانده‌ی بدهیِ سهم بیمار. اگر session تسویه شده باشد (payment_method != pending) * بدهی صفر است؛ در غیر این صورت سهم بیمار از فاکتور یا کل مبلغ نهایی. */ private function sessionWithBilling(\App\Patient\Entity\PatientSession $session): array { $data = $session->toArray(); $invoice = $session->getId() !== null ? $this->invoiceRepo->findBySession($session->getId()) : null; $data['invoice_uuid'] = $invoice?->getUuid(); $data['invoice_status'] = $invoice?->getStatus(); if ($data['is_paid']) { $data['patient_debt_rials'] = 0; } elseif ($invoice !== null) { $data['patient_debt_rials'] = $invoice->getPatientRials(); } else { $data['patient_debt_rials'] = $session->getFinalPriceRials(); } return $data; } #[Route('/api/v1/patient/{uuid}/session', methods: ['POST'])] public function createSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } $data = json_decode($request->getContent(), true) ?? []; $session = $this->patientService->createSession($record, $data, $entityType, $entityId); $this->autoCreateClaim($session, $entityType, $entityId); return $this->success($session->toArray(), 201); } /** * اتصال خودکار مطالبه‌ی بیمه: اگر session بیمه‌ی پایه یا مکمل داشته باشد، * صورتحساب ساخته و نهایی می‌شود و مطالبه (در وضعیت pending) ایجاد می‌گردد. * ارسال مطالبه دستی از صفحه‌ی مطالبات انجام می‌شود. * خطا در این مرحله نباید ثبت session را خراب کند. */ private function autoCreateClaim(\App\Patient\Entity\PatientSession $session, string $entityType, int $entityId): void { if ($session->getInsuranceBaseId() === null && $session->getInsuranceSupplementaryId() === null) { return; } try { $invoice = $this->invoiceService->createFromSession($session, $entityType, $entityId); $this->invoiceService->finalize($invoice); $this->claimService->createFromInvoice($invoice); } catch (\App\Shared\Exception\AppException $e) { // سهم بیمه‌ای صفر بود یا صورتحساب از قبل وجود داشت — قابل چشم‌پوشی. $this->logger->info('auto claim skipped', ['session' => $session->getUuid(), 'reason' => $e->getMessage()]); } catch (\Throwable $e) { $this->logger->error('auto claim failed', ['session' => $session->getUuid(), 'error' => $e->getMessage()]); } } #[Route('/api/v1/session/{uuid}', methods: ['PATCH'])] public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $session = $this->sessionRepo->findByUuid($uuid); if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) { return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404); } $data = json_decode($request->getContent(), true) ?? []; if (isset($data['notes'])) { $session->setNotes($data['notes']); } if (isset($data['payment_method'])) { $session->setPaymentMethod($data['payment_method']); } $this->sessionRepo->save($session); return $this->success($session->toArray()); } private function resolveEntity(User $user): array { if ($user->hasRole('ROLE_DOCTOR')) { $doctor = $this->doctorRepo->findByUser($user); return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null]; } if ($user->hasRole('ROLE_CLINIC')) { $clinic = $this->clinicRepo->findByUser($user); return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null]; } if ($user->hasRole('ROLE_SECRETARY')) { $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); if ($dbUuid !== null) { $clinic = $this->clinicRepo->findByUuid($dbUuid); if ($clinic !== null) { $rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic); if ($rel !== null) { return ['clinic', $clinic->getId()]; } } $doctor = $this->doctorRepo->findByUuid($dbUuid); if ($doctor !== null) { $rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor); if ($rel !== null) { return ['doctor', $doctor->getId()]; } } } } return ['unknown', null]; } private function assertPatientGate(string $entityType, ?int $entityId): void { if ($entityId === null) { throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403); } if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) { throw new AppException(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, null, 403); } } private function ownsRecord($record, string $entityType, ?int $entityId): bool { return $entityId !== null && $record->getEntityType() === $entityType && $record->getEntityId() === $entityId; } }