resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { 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(100, max(1, (int) $request->query->get('limit', 20))); $status = $request->query->get('status') ?: null; $patient = $record->getUser(); $payments = array_map( fn(\App\Payment\Entity\Payment $p) => $p->toArray(), $this->paymentRepo->findByUser($patient, $status, $page, $limit) ); return $this->paginated($payments, $this->paymentRepo->countByUser($patient, $status), $page, $limit); } /** Patient wallet balance + the 10 most recent transactions (کیف‌پول tab). */ #[Route('/api/v1/patient/{uuid}/wallet', methods: ['GET'])] public function walletBalance(string $uuid, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } $patient = $record->getUser(); // موجودی سراسری است (پول مالِ بیمار است، نه محیط) ولی سطرهای دفتر به همین // محیط محدودند؛ وگرنه اینجا هم پیدا بود بیمار در محیط دیگر چه پرداخت کرده. return $this->success([ 'balance_rials' => $this->settlementRepo->getWalletBalance($patient), 'recent_transactions' => array_map( fn(\App\Settlement\Entity\WalletTransaction $t) => $t->toArray(), $this->walletRepo->findByUserForEnvironment($patient, $entityType, (int) $entityId, 10) ), ]); } /** Full paginated wallet-transaction ledger for the patient (تراکنش tab). */ #[Route('/api/v1/patient/{uuid}/wallet/transactions', methods: ['GET'])] public function walletTransactions(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, $user)) { 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(100, max(1, (int) $request->query->get('limit', 50))); $patient = $record->getUser(); // دفتر به محیط جاری محدود می‌شود: موجودی مالِ شخص است ولی تاریخچهٔ او در // کلینیک دیگر به این محیط ربطی ندارد. $txns = array_map( fn(\App\Settlement\Entity\WalletTransaction $t) => $t->toArray(), $this->walletRepo->findByUserForEnvironment($patient, $entityType, (int) $entityId, $limit, ($page - 1) * $limit) ); return $this->paginated( $txns, $this->walletRepo->countByUserForEnvironment($patient, $entityType, (int) $entityId), $page, $limit, ); } /** * Manual wallet top-up (شارژ کیف پول) — e.g. a deposit taken at the desk. * Creates a credit WalletTransaction for the record's owner User; balance * is derived (credit − debit), so balance_after is computed here. */ #[Route('/api/v1/patient/{uuid}/wallet/charge', methods: ['POST'])] public function chargeWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'create'); [$entityType, $entityId] = $this->resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } $data = json_decode($request->getContent(), true) ?? []; $amount = (int) ($data['amount_rials'] ?? 0); if ($amount <= 0) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مبلغ شارژ باید بزرگ‌تر از صفر باشد', 422, 'amount_rials'); } $patient = $record->getUser(); $txn = $this->walletService->charge( $patient, $amount, $user, trim((string) ($data['description'] ?? '')) ?: null, $this->normalizeMethod($data['payment_method'] ?? null), trim((string) ($data['reference'] ?? '')) ?: null, $entityType, (int) $entityId, ); return $this->success([ 'transaction' => $txn->toArray(), 'balance_rials' => $txn->getBalanceAfter(), ], 201); } /** * Manual wallet withdrawal (برداشت از کیف پول) — e.g. a refund or cash * hand-back at the desk. Creates a debit WalletTransaction for the record's * owner User (with acting user + payment method recorded). Rejected (422) * when the amount exceeds the current balance, mirroring the offline app. */ #[Route('/api/v1/patient/{uuid}/wallet/withdraw', methods: ['POST'])] public function withdrawWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'create'); [$entityType, $entityId] = $this->resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } $data = json_decode($request->getContent(), true) ?? []; $amount = (int) ($data['amount_rials'] ?? 0); if ($amount <= 0) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مبلغ برداشت باید بزرگ‌تر از صفر باشد', 422, 'amount_rials'); } // Insufficient balance → WalletService throws AppException (422), handled globally. $patient = $record->getUser(); $txn = $this->walletService->withdraw( $patient, $amount, $user, trim((string) ($data['description'] ?? '')) ?: null, $this->normalizeMethod($data['payment_method'] ?? null), trim((string) ($data['reference'] ?? '')) ?: null, $entityType, (int) $entityId, ); return $this->success([ 'transaction' => $txn->toArray(), 'balance_rials' => $txn->getBalanceAfter(), ], 201); } /** روش پرداختِ مجاز برای کیف پول؛ ورودیِ ناشناخته نادیده گرفته می‌شود. */ private function normalizeMethod(mixed $method): ?string { $method = is_string($method) ? trim($method) : ''; return in_array($method, ['card', 'pos', 'cash', 'gateway', 'wallet'], true) ? $method : null; } // ── Call center (کال سنتر) ───────────────────────────────────────────────── /** List the patient's call log (newest first, optional ?outcome=success|missed). */ #[Route('/api/v1/patient/{uuid}/calls', methods: ['GET'])] public function listCalls(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, $user)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } $outcome = $request->query->get('outcome'); $outcome = in_array($outcome, ['success', 'missed'], true) ? $outcome : null; return $this->success(array_map( fn(\App\Patient\Entity\PatientCall $c) => $c->toArray(), $this->callRepo->findByRecord($record, $outcome) )); } /** Log a new call. `subject` required; `outcome` defaults to success; `personnel`/`summary`/`called_at` optional. */ #[Route('/api/v1/patient/{uuid}/call', methods: ['POST'])] public function createCall(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } $data = json_decode($request->getContent(), true) ?? []; $subject = trim((string) ($data['subject'] ?? '')); if ($subject === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'موضوع تماس الزامی است', 422, 'subject'); } $outcome = (string) ($data['outcome'] ?? 'success'); if (!in_array($outcome, ['success', 'missed'], true)) { $outcome = 'success'; } $calledAt = isset($data['called_at']) ? (int) $data['called_at'] : null; $call = new \App\Patient\Entity\PatientCall($record, $subject, $outcome, $calledAt); $summary = trim((string) ($data['summary'] ?? '')); if ($summary !== '') { $call->setSummary($summary); } $personnel = trim((string) ($data['personnel'] ?? '')); if ($personnel !== '') { $call->setPersonnel($personnel); } $this->callRepo->save($call); return $this->success($call->toArray(), 201); } /** Delete a call log entry. Owner-scoped; otherwise 404. */ #[Route('/api/v1/patient/call/{uuid}', methods: ['DELETE'])] public function deleteCall(string $uuid, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete'); [$entityType, $entityId] = $this->resolveEntity($user); $call = $this->callRepo->findByUuid($uuid); if ($call === null || !$this->ownsRecord($call->getRecord(), $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } $this->callRepo->remove($call); return $this->success(['deleted' => true]); } // ── Messages (پیام‌ها) ───────────────────────────────────────────────────── #[Route('/api/v1/patient/{uuid}/messages', methods: ['GET'])] public function listMessages(string $uuid, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { 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\PatientMessage $m) => $m->toArray(), $this->messageRepo->findByRecord($record) )); } #[Route('/api/v1/patient/{uuid}/message', methods: ['POST'])] public function createMessage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } $data = json_decode($request->getContent(), true) ?? []; $body = trim((string) ($data['body'] ?? '')); if ($body === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'متن پیام الزامی است', 422, 'body'); } $channel = (string) ($data['channel'] ?? 'sms'); if (!in_array($channel, ['sms', 'note', 'call', 'email'], true)) { $channel = 'sms'; } $message = new \App\Patient\Entity\PatientMessage($record, $body, $channel); $this->messageRepo->save($message); return $this->success($message->toArray(), 201); } #[Route('/api/v1/patient/message/{uuid}', methods: ['DELETE'])] public function deleteMessage(string $uuid, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete'); [$entityType, $entityId] = $this->resolveEntity($user); $message = $this->messageRepo->findByUuid($uuid); if ($message === null || !$this->ownsRecord($message->getRecord(), $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پیام یافت نشد', 404); } $this->messageRepo->remove($message); return $this->success(['message' => 'پیام حذف شد']); } // ── Notes (یادداشت‌ها) ───────────────────────────────────────────────────── // // Personal staff memos on a record; shared with everyone who owns the record. // The author's display name is captured at write time (survives user deletion). #[Route('/api/v1/patient/{uuid}/notes', methods: ['GET'])] public function listNotes(string $uuid, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { 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\PatientNote $n) => $n->toArray(), $this->noteRepo->findByRecord($record) )); } #[Route('/api/v1/patient/{uuid}/note', methods: ['POST'])] public function createNote(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } $data = json_decode($request->getContent(), true) ?? []; $body = trim((string) ($data['body'] ?? '')); if ($body === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'متن یادداشت الزامی است', 422, 'body'); } $note = new \App\Patient\Entity\PatientNote($record, $body, (bool) ($data['pinned'] ?? false)); $note->setAuthor($user, $user->getRealName() ?? $user->getMobileNumber()); $this->noteRepo->save($note); return $this->success($note->toArray(), 201); } #[Route('/api/v1/patient/note/{uuid}', methods: ['PATCH'])] public function updateNote(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); $note = $this->noteRepo->findByUuid($uuid); if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'یادداشت یافت نشد', 404); } $data = json_decode($request->getContent(), true) ?? []; if (array_key_exists('body', $data)) { $body = trim((string) $data['body']); if ($body === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'متن یادداشت الزامی است', 422, 'body'); } $note->setBody($body); } if (array_key_exists('pinned', $data)) { $note->setPinned((bool) $data['pinned']); } $this->noteRepo->save($note); return $this->success($note->toArray()); } #[Route('/api/v1/patient/note/{uuid}', methods: ['DELETE'])] public function deleteNote(string $uuid, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete'); [$entityType, $entityId] = $this->resolveEntity($user); $note = $this->noteRepo->findByUuid($uuid); if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'یادداشت یافت نشد', 404); } $this->noteRepo->remove($note); return $this->success(['message' => 'یادداشت حذف شد']); } // ── Medical records (پرونده پزشکی) ──────────────────────────────────────── #[Route('/api/v1/patient/{uuid}/medical-records', methods: ['GET'])] public function listMedicalRecords(string $uuid, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { 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\PatientMedicalRecord $m) => $m->toArray(), $this->medicalRepo->findByRecord($record) )); } #[Route('/api/v1/patient/{uuid}/medical-record', methods: ['POST'])] public function createMedicalRecord(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } $data = json_decode($request->getContent(), true) ?? []; $title = trim((string) ($data['title'] ?? '')); if ($title === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'عنوان معاینه الزامی است', 422, 'title'); } $body = isset($data['body']) ? trim((string) $data['body']) : null; $recordedAt = isset($data['recorded_at']) && $data['recorded_at'] !== '' ? (int) $data['recorded_at'] : null; $medical = new \App\Patient\Entity\PatientMedicalRecord($record, $title, $body ?: null, $recordedAt); $this->medicalRepo->save($medical); return $this->success($medical->toArray(), 201); } #[Route('/api/v1/patient/medical-record/{uuid}', methods: ['PATCH'])] public function updateMedicalRecord(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); $medical = $this->medicalRepo->findByUuid($uuid); if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'رکورد پزشکی یافت نشد', 404); } $data = json_decode($request->getContent(), true) ?? []; if (isset($data['title'])) { $t = trim((string) $data['title']); if ($t === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'عنوان معاینه الزامی است', 422, 'title'); } $medical->setTitle($t); } if (array_key_exists('body', $data)) { $b = trim((string) ($data['body'] ?? '')); $medical->setBody($b === '' ? null : $b); } if (isset($data['recorded_at']) && $data['recorded_at'] !== '') { $medical->setRecordedAt((int) $data['recorded_at']); } $this->medicalRepo->save($medical); return $this->success($medical->toArray()); } #[Route('/api/v1/patient/medical-record/{uuid}', methods: ['DELETE'])] public function deleteMedicalRecord(string $uuid, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete'); [$entityType, $entityId] = $this->resolveEntity($user); $medical = $this->medicalRepo->findByUuid($uuid); if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'رکورد پزشکی یافت نشد', 404); } $this->medicalRepo->remove($medical); return $this->success(['message' => 'رکورد پزشکی حذف شد']); } // ── Attachments (ضمیمه) ─────────────────────────────────────────────────── #[Route('/api/v1/patient/{uuid}/attachments', methods: ['GET'])] public function listAttachments(string $uuid, #[CurrentUser] User $user): JsonResponse { [$entityType, $entityId] = $this->resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { 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 { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { 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 { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete'); [$entityType, $entityId] = $this->resolveEntity($user); $attachment = $this->attachmentRepo->findByUuid($uuid); if ($attachment === null || !$this->ownsRecord($attachment->getRecord(), $entityType, $entityId, $user)) { 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; $tags = $request->query->get('tags'); $filters = [ 'tags' => $tags ? array_filter(array_map('trim', explode(',', $tags))) : null, 'gender' => $request->query->get('gender') ?: null, 'insurance_id' => $request->query->get('insurance_id') ?: null, 'admitted_from' => $request->query->get('admitted_from') ?: null, 'admitted_to' => $request->query->get('admitted_to') ?: null, 'service_status' => $request->query->get('service_status') ?: null, 'has_debt' => $request->query->getBoolean('has_debt'), ]; $restrictTo = $this->scope($user)->restrictToDoctorIds; $records = $this->recordRepo->findByEntity($entityType, $entityId, $page, $limit, $search, $filters, $restrictTo); $total = $this->recordRepo->countByEntity($entityType, $entityId, $search, $filters, $restrictTo); // کد ملی روی profiles ذخیره می‌شود نه users؛ اگر روی user خالی بود از پروفایل پر کن. $userIds = array_map(fn(PatientRecord $r) => $r->getUser()->getId(), $records); $profileCodes = $this->profileRepo->nationalCodesByUserIds($userIds); $rows = array_map(function (PatientRecord $r) use ($profileCodes) { $row = $r->toArray(); if (empty($row['user_national_code'])) { $row['user_national_code'] = $profileCodes[$r->getUser()->getId()] ?? null; } return $row; }, $records); return $this->paginated($rows, $total, $page, $limit); } #[Route('/api/v1/patient', methods: ['POST'])] public function create(Request $request, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'create'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'create'); [$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, $user)) { 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 { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { 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, $user)) { 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))); // فیلتر آرشیو: active (پیش‌فرض — آرشیوها مخفی) | all | archived $filter = in_array($request->query->get('filter'), ['all', 'archived'], true) ? $request->query->get('filter') : 'active'; $sessions = $this->sessionRepo->findByRecord($record, $page, $limit, $filter); $total = $this->sessionRepo->countByRecord($record, $filter); 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, $user)) { return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } // محیط نوبت با appointment.clinic بیان می‌شود. تکیه بر عضویت فعلیِ پزشک // یعنی با پایان همکاری، تاریخچهٔ نوبت‌های همان کلینیک از پرونده ناپدید شود. $appointments = $entityType === 'doctor' ? $this->appointmentRepo->findByUserAndDoctorIds($record->getUser(), [$entityId]) : $this->appointmentRepo->findByUserAndClinic($record->getUser(), $entityId); 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(), 'version' => $a->getVersion(), 'doctor_name' => $a->getDoctor()->getName(), 'service_name' => null, 'price_rials' => null, 'created_at' => $a->getSlotStart(), ], $appointments)); } /** * خروجی session به‌همراه خلاصه‌ی صورتحساب: uuid فاکتور (در صورت وجود) و * مانده‌ی بدهیِ سهم بیمار. اگر session تسویه شده باشد (payment_method != pending) * بدهی صفر است؛ در غیر این صورت سهم بیمار از فاکتور یا کل مبلغ نهایی. */ /** @var array نام بیمه‌ها، یک‌بار در هر درخواست (لیست مراجعه‌ها N+1 نشود). */ private array $insuranceNameCache = []; private function insuranceNameById(?int $id): ?string { if ($id === null) { return null; } return $this->insuranceNameCache[$id] ??= $this->insuranceRepo->find($id)?->getName(); } private function sessionWithBilling(\App\Patient\Entity\PatientSession $session): array { $data = $session->toArray(); $data['insurance_base_name'] = $this->insuranceNameById($session->getInsuranceBaseId()); $data['insurance_supplementary_name'] = $this->insuranceNameById($session->getInsuranceSupplementaryId()); $invoice = $session->getId() !== null ? $this->invoiceRepo->findBySession($session->getId()) : null; $data['invoice_uuid'] = $invoice?->getUuid(); $data['invoice_status'] = $invoice?->getStatus(); // مانده‌ی بدهی همیشه از خودِ مراجعه (سازگار با is_paid): مبلغ نهایی منهای تخفیف // و پرداخت‌ها. اگر مراجعه پس از صدور فاکتور ویرایش شود، همین منبعِ واحد ملاک است. $data['patient_debt_rials'] = $session->getRemainingRials(); return $data; } #[Route('/api/v1/patient/{uuid}/session', methods: ['POST'])] public function createSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $record = $this->recordRepo->findByUuid($uuid); if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) { 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->sessionBilling->ensureFinalizedInvoice($session, $entityType, $entityId); return $this->success($this->sessionWithBilling($session), 201); } #[Route('/api/v1/session/{uuid}', methods: ['PATCH'])] public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $session = $this->sessionRepo->findByUuid($uuid); if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId, $user)) { 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 (array_key_exists('archived', $data)) { $session->setArchived((bool) $data['archived']); } // ویرایش سرویس‌ها/کالاها/قیمت ویزیت/بیمه — با بازمحاسبه و ثبت تاریخچه. if (array_key_exists('services', $data) || array_key_exists('consumables', $data) || array_key_exists('visit_price_rials', $data) || array_key_exists('insurance_base_id', $data) || array_key_exists('base_insurance_discount_percent', $data)) { $this->patientService->updateSessionServices($session, $data, $entityType, $entityId, $user); // بیمه ممکن است همین حالا به مراجعه اضافه شده باشد؛ بدون این، مراجعه‌ای که // بیمه‌اش بعداً ثبت می‌شود هرگز صورتحساب و مطالبه نمی‌گیرد. $this->sessionBilling->ensureFinalizedInvoice($session, $entityType, $entityId); } // تخفیف بر اساس قانون (discount_rule_uuid): مقدار از خود قانون، با ثبت منبع. // '' یا null → حذف تخفیف. اولویت بر تخفیف دستی. if (array_key_exists('discount_rule_uuid', $data)) { $ruleUuid = $data['discount_rule_uuid']; if ($ruleUuid === null || $ruleUuid === '') { $this->patientService->applyDiscount($session, null, 0, null, null, $user); } else { $rule = $this->discountRuleRepo->findByUuidForOwner((string) $ruleUuid, $entityType, $entityId); if ($rule === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'قانون تخفیف یافت نشد', 404, 'discount_rule_uuid'); } $this->patientService->applyDiscountRule($session, $rule, $user); } } elseif (array_key_exists('discount_type', $data)) { // تخفیف دستی: discount_type = percent|fixed|null (null = حذف تخفیف) $type = $data['discount_type'] !== null ? (string) $data['discount_type'] : null; $this->patientService->applyDiscount($session, $type, (int) ($data['discount_value'] ?? 0), null, null, $user); } if (isset($data['paid_at'])) { $session->setPaidAt((int) $data['paid_at']); } if (isset($data['payment_method'])) { $method = (string) $data['payment_method']; // پرداخت از کیف پول: مانده را از موجودی کسر کن و همزمان SessionPayment ثبت کن. // از addSessionPayment استفاده می‌شود تا مبلغ = مانده (پس از تخفیف و پرداخت‌های // قبلی) باشد و is_paid/paid_at درست ست شود؛ موجودی ناکافی → AppException (۴۲۲). if ($method === 'wallet' && $session->getRemainingRials() > 0) { $this->patientService->addSessionPayment( $session, 'wallet', $session->getRemainingRials(), isset($data['paid_at']) ? (int) $data['paid_at'] : null, $user, ); } else { $session->setPaymentMethod($method); } } $this->sessionRepo->save($session); return $this->success($this->sessionWithBilling($session)); } /** * ثبت پرداخت جزئی روی مراجعه (تسویه چندتکه). * body: { method: wallet|pos|cash|card, amount_rials: int, paid_at?: int } * روش wallet همان مبلغ را از کیف پول بیمار کسر می‌کند. وقتی مانده صفر شود * مراجعه تسویه‌شده (is_paid) می‌شود. */ #[Route('/api/v1/session/{uuid}/payments', methods: ['POST'])] public function addSessionPayment(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'create'); [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $session = $this->sessionRepo->findByUuid($uuid); if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404); } $data = json_decode($request->getContent(), true) ?? []; $this->patientService->addSessionPayment( $session, (string) ($data['method'] ?? ''), (int) ($data['amount_rials'] ?? 0), isset($data['paid_at']) ? (int) $data['paid_at'] : null, $user, ); return $this->success($this->sessionWithBilling($session), 201); } /** ویرایش یک پرداخت ثبت‌شده. body: { method?, amount_rials?, paid_at? }. */ #[Route('/api/v1/session/{uuid}/payments/{paymentUuid}', methods: ['PATCH'])] public function updateSessionPayment(string $uuid, string $paymentUuid, Request $request, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'payments', 'update'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $session = $this->sessionRepo->findByUuid($uuid); if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404); } $payment = $this->sessionPaymentRepo->findByUuid($paymentUuid); if ($payment === null || $payment->getSession()->getUuid() !== $uuid) { return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, 'پرداخت یافت نشد', 404, 'paymentUuid'); } $this->patientService->updatePayment($payment, json_decode($request->getContent(), true) ?? [], $user); return $this->success($this->sessionWithBilling($session)); } /** حذف یک پرداخت ثبت‌شده. */ #[Route('/api/v1/session/{uuid}/payments/{paymentUuid}', methods: ['DELETE'])] public function deleteSessionPayment(string $uuid, string $paymentUuid, #[CurrentUser] User $user): JsonResponse { $this->secretaryAccess->denyUnlessGranted($user, 'payments', 'delete'); $this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'delete'); [$entityType, $entityId] = $this->resolveEntity($user); $this->assertPatientGate($entityType, $entityId); $session = $this->sessionRepo->findByUuid($uuid); if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId, $user)) { return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404); } $payment = $this->sessionPaymentRepo->findByUuid($paymentUuid); if ($payment === null || $payment->getSession()->getUuid() !== $uuid) { return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, 'پرداخت یافت نشد', 404, 'paymentUuid'); } $this->patientService->deletePayment($payment, $user); return $this->success($this->sessionWithBilling($session)); } /** تاریخچه‌ی تغییرات مالی/خدماتی مراجعه (Audit Log). */ #[Route('/api/v1/session/{uuid}/audit-log', methods: ['GET'])] public function sessionAuditLog(string $uuid, #[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, $user)) { return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404); } return $this->success($this->sessionAuditRepo->findBySessionUuid($uuid)); } /** @var array حل‌شده یک‌بار در هر درخواست، نه یک‌بار به‌ازای هر چک. */ private array $scopeCache = []; private function scope(User $user): PatientRecordScope { return $this->scopeCache[$user->getId()] ??= $this->resolveScope($user); } /** منشیِ بدون مجوز patients.view هیچ پرونده‌ای نمی‌بیند؛ سایر نقش‌ها از رزولور. */ private function resolveScope(User $user): PatientRecordScope { if ($user->hasRole('ROLE_SECRETARY') && !$this->secretaryAccess->can($user, 'patients', 'view')) { return PatientRecordScope::unknown(); } return $this->scopeResolver->resolve($user); } /** @return array{0: string, 1: int|null} */ private function resolveEntity(User $user): array { return $this->scope($user)->toLegacyTuple(); } 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, User $user): bool { if ($entityId === null || $record->getEntityType() !== $entityType || $record->getEntityId() !== $entityId) { return false; } return $this->recordRepo->isVisibleToDoctors( $record, $this->scope($user)->restrictToDoctorIds, ); } }