Files
clinicpro/src/Patient/Controller/PatientController.php
T
hamed e6267080b2 feat: Enhance insurance billing system to support supplementary insurance
- Updated CoverageRule and related entities to include franchise_percent instead of franchise_rials.
- Modified Appointment entity to carry supplementary insurance ID alongside base insurance.
- Implemented SessionBillingService to ensure finalized invoices for insured patient sessions.
- Created InvoiceFinalized event to trigger claims creation upon invoice finalization.
- Added BackfillMissingClaimsCommand to generate claims for finalized invoices without existing claims.
- Developed tests to validate the new functionality for supplementary insurance handling in appointments and claims.
2026-07-29 19:57:02 +03:30

1284 lines
64 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Patient\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Billing\Repository\InvoiceRepository;
use App\Billing\Service\SessionBillingService;
use App\Patient\Entity\PatientRecord;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Patient\Security\PatientRecordScope;
use App\Patient\Security\PatientRecordScopeResolver;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Patient\Service\PatientService;
use App\UserProfile\Entity\UserProfile;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Subscription\Service\SubscriptionService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use OpenApi\Attributes as OA;
#[OA\Tag(name: 'Patients')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class PatientController extends BaseController
{
public function __construct(
private readonly PatientRecordRepository $recordRepo,
private readonly PatientSessionRepository $sessionRepo,
private readonly PatientService $patientService,
private readonly SubscriptionService $subscriptionService,
private readonly UserRepository $userRepo,
private readonly PatientRecordScopeResolver $scopeResolver,
private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo,
private readonly \App\Insurance\Repository\InsuranceRepository $insuranceRepo,
private readonly SessionBillingService $sessionBilling,
private readonly InvoiceRepository $invoiceRepo,
private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo,
private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo,
private readonly \App\Patient\Repository\PatientAttachmentRepository $attachmentRepo,
private readonly \App\Patient\Repository\PatientMedicalRecordRepository $medicalRepo,
private readonly \App\Patient\Repository\PatientMessageRepository $messageRepo,
private readonly \App\Patient\Repository\PatientNoteRepository $noteRepo,
private readonly \App\Patient\Repository\PatientCallRepository $callRepo,
private readonly \App\Shared\Service\FileUploadService $fileUpload,
private readonly \App\Payment\Repository\PaymentRepository $paymentRepo,
private readonly \App\Settlement\Repository\WalletTransactionRepository $walletRepo,
private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo,
private readonly \App\Settlement\Service\WalletService $walletService,
private readonly \App\Discount\Repository\DiscountRuleRepository $discountRuleRepo,
private readonly \App\Patient\Repository\SessionPaymentRepository $sessionPaymentRepo,
private readonly \App\Patient\Repository\SessionAuditLogRepository $sessionAuditRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
) {}
// ── Financials (مالی: پرداخت / تراکنش / کیف‌پول) ────────────────────────────
//
// The patient's money is scoped to the record's owner User. The generic
// wallet/payment endpoints are bound to #[CurrentUser] (the requester's own
// finances), so a doctor/secretary viewing a record needs these
// record-owner-gated reads to see the *patient's* finances instead.
/** List gateway payments made by the patient (paginated, optional ?status). */
#[Route('/api/v1/patient/{uuid}/payments', methods: ['GET'])]
public function listPayments(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', 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<int, string|null> نام بیمه‌ها، یک‌بار در هر درخواست (لیست مراجعه‌ها 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<int, PatientRecordScope> حل‌شده یک‌بار در هر درخواست، نه یک‌بار به‌ازای هر چک. */
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,
);
}
}