Files
clinicpro/src/Patient/Controller/PatientController.php
T
hamedandClaude Fable 5 7da7d968b9 feat(patient): session edit + payment PATCH/DELETE + audit-log endpoints
updateSession now accepts services/consumables/visit_price/insurance and calls
updateSessionServices; discount paths pass the actor for audit. Add
PATCH/DELETE /session/{uuid}/payments/{paymentUuid} and GET
/session/{uuid}/audit-log (owner-scoped). Inject the payment + audit repos.
Verified end-to-end (edit visit price, payment edit-exceeds guard, delete +
recompute, audit trail). Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:14:49 +03:30

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