- 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.
1284 lines
64 KiB
PHP
1284 lines
64 KiB
PHP
<?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,
|
||
);
|
||
}
|
||
}
|