Add patient file attachments: a new PatientAttachment entity (record-scoped,
CASCADE) + repository, and endpoints GET /patient/{uuid}/attachments,
POST /patient/{uuid}/attachment (raw-body upload) and DELETE
/patient/attachment/{uuid} (owner-scoped). Factor the shared raw-body upload
logic into FileUploadService. Wire the "ضمیمه" tab in PatientDetailPage
(upload + list + delete). PHPUnit covers list/delete/ownership; Vitest covers
the tab. API docs updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
638 lines
29 KiB
PHP
638 lines
29 KiB
PHP
<?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\Shared\Service\FileUploadService $fileUpload,
|
|
private readonly LoggerInterface $logger,
|
|
) {}
|
|
|
|
// ── 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;
|
|
|
|
$records = $this->recordRepo->findByEntity($entityType, $entityId, $page, $limit, $search);
|
|
$total = $this->recordRepo->countByEntity($entityType, $entityId, $search);
|
|
|
|
return $this->paginated(
|
|
array_map(fn(PatientRecord $r) => $r->toArray(), $records),
|
|
$total,
|
|
$page,
|
|
$limit
|
|
);
|
|
}
|
|
|
|
#[Route('/api/v1/patient', methods: ['POST'])]
|
|
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
|
$this->assertPatientGate($entityType, $entityId);
|
|
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
$userUuid = trim($data['user_uuid'] ?? '');
|
|
$mobile = trim($data['mobile'] ?? '');
|
|
$name = trim($data['name'] ?? '');
|
|
|
|
$nationalCode = trim((string) ($data['national_code'] ?? ''));
|
|
if ($nationalCode !== '' && !preg_match('/^\d{10}$/', $nationalCode)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی باید ۱۰ رقم باشد', 422);
|
|
}
|
|
|
|
$patient = null;
|
|
if ($userUuid !== '') {
|
|
$patient = $this->userRepo->findByUuid($userUuid);
|
|
} elseif ($mobile !== '') {
|
|
if (!preg_match('/^09\d{9}$/', $mobile)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422);
|
|
}
|
|
$patient = $this->userRepo->findByMobile($mobile);
|
|
}
|
|
|
|
// کد ملی باید در سطح بیمار یکتا باشد: اگر به پروفایلِ کاربر دیگری تعلق دارد، رد کن
|
|
if ($nationalCode !== '') {
|
|
$owner = $this->profileRepo->findOneByNationalCode($nationalCode);
|
|
if ($owner !== null && ($patient === null || $owner->getUser()->getId() !== $patient->getId())) {
|
|
$masked = \App\Shared\Service\InputValidator::maskMobile($owner->getUser()->getMobileNumber());
|
|
return $this->error(
|
|
ErrorCodes::ERR_PROFILE_NATIONAL_CODE_TAKEN,
|
|
"این کد ملی قبلاً با شماره {$masked} ثبت شده است",
|
|
409,
|
|
'national_code'
|
|
);
|
|
}
|
|
}
|
|
|
|
// بیمار جدید بدون ثبتنام قبلی: موبایل + نام آمده ولی کاربری وجود ندارد
|
|
if ($patient === null) {
|
|
if ($mobile === '' || $name === '') {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای ساخت بیمار جدید، شماره موبایل و نام الزامی است', 422);
|
|
}
|
|
$patient = new User($mobile);
|
|
$patient->setRealName($name);
|
|
if ($nationalCode !== '') {
|
|
$patient->setNationalCode($nationalCode);
|
|
}
|
|
$this->userRepo->save($patient);
|
|
} elseif ($nationalCode !== '' && $patient->getNationalCode() === null) {
|
|
$patient->setNationalCode($nationalCode);
|
|
$this->userRepo->save($patient);
|
|
}
|
|
|
|
$existing = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
|
|
if ($existing !== null) {
|
|
return $this->success($existing->toArray());
|
|
}
|
|
|
|
$record = new PatientRecord($entityType, $entityId, $patient, $user->hasRole('ROLE_DOCTOR') ? 'doctor' : 'clinic', $entityId);
|
|
|
|
if (($rn = trim((string) ($data['record_number'] ?? ''))) !== '') {
|
|
$record->setRecordNumber($rn);
|
|
}
|
|
$tagError = $this->applyRecordTags($record, $data, $entityType, $entityId);
|
|
if ($tagError !== null) {
|
|
return $tagError;
|
|
}
|
|
|
|
$this->recordRepo->save($record);
|
|
|
|
return $this->success($record->toArray(), 201);
|
|
}
|
|
|
|
#[Route('/api/v1/patient/{uuid}', methods: ['GET'])]
|
|
public function show(string $uuid, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
|
$this->assertPatientGate($entityType, $entityId);
|
|
|
|
$record = $this->recordRepo->findByUuid($uuid);
|
|
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
|
|
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
|
}
|
|
|
|
$data = $record->toArray();
|
|
$data['profile'] = $this->buildPatientProfile($record->getUser());
|
|
|
|
return $this->success($data);
|
|
}
|
|
|
|
#[Route('/api/v1/patient/{uuid}', methods: ['PATCH'])]
|
|
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
|
$this->assertPatientGate($entityType, $entityId);
|
|
|
|
$record = $this->recordRepo->findByUuid($uuid);
|
|
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
|
|
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
|
}
|
|
|
|
$patient = $record->getUser();
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
|
|
if (array_key_exists('name', $data)) {
|
|
$name = trim((string) $data['name']);
|
|
if ($name !== '') {
|
|
$patient->setRealName($name);
|
|
}
|
|
}
|
|
|
|
// کد ملی باید ۱۰ رقم و در سطح بیمار یکتا باشد
|
|
if (array_key_exists('national_code', $data)) {
|
|
$nationalCode = trim((string) ($data['national_code'] ?? ''));
|
|
if ($nationalCode !== '') {
|
|
if (!preg_match('/^\d{10}$/', $nationalCode)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی باید ۱۰ رقم باشد', 422, 'national_code');
|
|
}
|
|
$owner = $this->profileRepo->findOneByNationalCode($nationalCode);
|
|
if ($owner !== null && $owner->getUser()->getId() !== $patient->getId()) {
|
|
$masked = \App\Shared\Service\InputValidator::maskMobile($owner->getUser()->getMobileNumber());
|
|
return $this->error(
|
|
ErrorCodes::ERR_PROFILE_NATIONAL_CODE_TAKEN,
|
|
"این کد ملی قبلاً با شماره {$masked} ثبت شده است",
|
|
409,
|
|
'national_code'
|
|
);
|
|
}
|
|
$patient->setNationalCode($nationalCode);
|
|
}
|
|
}
|
|
|
|
// موبایل = شناسه ورود کاربر؛ تغییر آن باید ۱۱ رقمی معتبر و در سطح کاربران یکتا باشد
|
|
if (array_key_exists('mobile', $data)) {
|
|
$mobile = trim((string) ($data['mobile'] ?? ''));
|
|
if ($mobile !== '' && $mobile !== $patient->getMobileNumber()) {
|
|
if (!preg_match('/^09\d{9}$/', $mobile)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422, 'mobile');
|
|
}
|
|
$owner = $this->userRepo->findByMobile($mobile);
|
|
if ($owner !== null && $owner->getId() !== $patient->getId()) {
|
|
return $this->error(
|
|
ErrorCodes::ERR_PROFILE_MOBILE_TAKEN,
|
|
ErrorCodes::message(ErrorCodes::ERR_PROFILE_MOBILE_TAKEN),
|
|
409,
|
|
'mobile'
|
|
);
|
|
}
|
|
$patient->setMobileNumber($mobile);
|
|
}
|
|
}
|
|
$this->userRepo->save($patient);
|
|
|
|
$profile = $this->profileRepo->findByUser($patient) ?? new UserProfile($patient);
|
|
|
|
$stringFields = [
|
|
'family' => 'setFamily',
|
|
'fathers_name' => 'setFathersName',
|
|
'gender' => 'setGender',
|
|
'blood_type' => 'setBloodType',
|
|
'marital_status' => 'setMaritalStatus',
|
|
'education' => 'setEducation',
|
|
'field_of_study' => 'setFieldOfStudy',
|
|
'job' => 'setJob',
|
|
'address' => 'setAddress',
|
|
'postal_code' => 'setPostalCode',
|
|
'referral_source' => 'setReferralSource',
|
|
'description' => 'setDescription',
|
|
'home_phone' => 'setHomePhone',
|
|
'work_phone' => 'setWorkPhone',
|
|
];
|
|
foreach ($stringFields as $key => $setter) {
|
|
if (array_key_exists($key, $data)) {
|
|
$v = is_string($data[$key]) ? trim($data[$key]) : $data[$key];
|
|
$profile->$setter($v === '' ? null : $v);
|
|
}
|
|
}
|
|
|
|
$intFields = [
|
|
'province_id' => 'setProvinceId',
|
|
'city_id' => 'setCityId',
|
|
];
|
|
foreach ($intFields as $key => $setter) {
|
|
if (array_key_exists($key, $data)) {
|
|
$v = $data[$key];
|
|
$profile->$setter(($v === null || $v === '') ? null : (int) $v);
|
|
}
|
|
}
|
|
|
|
if (array_key_exists('national_code', $data)) {
|
|
$nc = trim((string) ($data['national_code'] ?? ''));
|
|
$profile->setNationalCode($nc === '' ? null : $nc);
|
|
}
|
|
if (array_key_exists('date_of_birth', $data)) {
|
|
$dob = $data['date_of_birth'];
|
|
$profile->setDateOfBirth(($dob === null || $dob === '') ? null : (int) $dob);
|
|
}
|
|
if (array_key_exists('basic_insurance_id', $data)) {
|
|
$bi = $data['basic_insurance_id'];
|
|
$profile->setBasicInsuranceId(($bi === null || $bi === '') ? null : (int) $bi);
|
|
}
|
|
if (array_key_exists('supplementary_insurance_id', $data)) {
|
|
$si = $data['supplementary_insurance_id'];
|
|
$profile->setSupplementaryInsuranceId(($si === null || $si === '') ? null : (int) $si);
|
|
}
|
|
$this->profileRepo->save($profile);
|
|
|
|
if (array_key_exists('record_number', $data)) {
|
|
$rn = trim((string) ($data['record_number'] ?? ''));
|
|
$record->setRecordNumber($rn === '' ? null : $rn);
|
|
}
|
|
$tagError = $this->applyRecordTags($record, $data, $entityType, $entityId);
|
|
if ($tagError !== null) {
|
|
return $tagError;
|
|
}
|
|
$this->recordRepo->save($record);
|
|
|
|
$out = $record->toArray();
|
|
$out['profile'] = $this->buildPatientProfile($patient);
|
|
|
|
return $this->success($out);
|
|
}
|
|
|
|
#[Route('/api/v1/patient/{uuid}/sessions', methods: ['GET'])]
|
|
public function sessions(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
|
$this->assertPatientGate($entityType, $entityId);
|
|
|
|
$record = $this->recordRepo->findByUuid($uuid);
|
|
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
|
|
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
|
}
|
|
|
|
$page = max(1, (int) $request->query->get('page', 1));
|
|
$limit = min(50, max(10, (int) $request->query->get('limit', 20)));
|
|
$sessions = $this->sessionRepo->findByRecord($record, $page, $limit);
|
|
$total = $this->sessionRepo->countByRecord($record);
|
|
|
|
return $this->paginated(
|
|
array_map(fn($s) => $this->sessionWithBilling($s), $sessions),
|
|
$total,
|
|
$page,
|
|
$limit
|
|
);
|
|
}
|
|
|
|
#[Route('/api/v1/patient/{uuid}/appointments', methods: ['GET'])]
|
|
public function appointments(string $uuid, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
|
$this->assertPatientGate($entityType, $entityId);
|
|
|
|
$record = $this->recordRepo->findByUuid($uuid);
|
|
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
|
|
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
|
}
|
|
|
|
// نوبتهای این بیمار فقط با پزشک(های) همین ارائهدهنده نمایش داده میشوند تا
|
|
// نوبتهای او با کلینیکهای دیگر نشت نکند.
|
|
$doctorIds = $entityType === 'doctor'
|
|
? [$entityId]
|
|
: $this->invitationRepo->acceptedDoctorIdsByClinic($entityId);
|
|
|
|
$appointments = $this->appointmentRepo->findByUserAndDoctorIds($record->getUser(), $doctorIds);
|
|
|
|
return $this->success(array_map(fn(\App\Appointment\Entity\Appointment $a) => [
|
|
'uuid' => $a->getUuid(),
|
|
'starts_at' => $a->getSlotStart(),
|
|
'ends_at' => $a->getSlotEnd(),
|
|
'status' => $a->getStatus(),
|
|
'doctor_name' => $a->getDoctor()->getName(),
|
|
'service_name' => null,
|
|
'price_rials' => null,
|
|
'created_at' => $a->getSlotStart(),
|
|
], $appointments));
|
|
}
|
|
|
|
/**
|
|
* خروجی session بههمراه خلاصهی صورتحساب: uuid فاکتور (در صورت وجود) و
|
|
* ماندهی بدهیِ سهم بیمار. اگر session تسویه شده باشد (payment_method != pending)
|
|
* بدهی صفر است؛ در غیر این صورت سهم بیمار از فاکتور یا کل مبلغ نهایی.
|
|
*/
|
|
private function sessionWithBilling(\App\Patient\Entity\PatientSession $session): array
|
|
{
|
|
$data = $session->toArray();
|
|
$invoice = $session->getId() !== null ? $this->invoiceRepo->findBySession($session->getId()) : null;
|
|
|
|
$data['invoice_uuid'] = $invoice?->getUuid();
|
|
$data['invoice_status'] = $invoice?->getStatus();
|
|
|
|
if ($data['is_paid']) {
|
|
$data['patient_debt_rials'] = 0;
|
|
} elseif ($invoice !== null) {
|
|
$data['patient_debt_rials'] = $invoice->getPatientRials();
|
|
} else {
|
|
$data['patient_debt_rials'] = $session->getFinalPriceRials();
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
#[Route('/api/v1/patient/{uuid}/session', methods: ['POST'])]
|
|
public function createSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
|
$this->assertPatientGate($entityType, $entityId);
|
|
|
|
$record = $this->recordRepo->findByUuid($uuid);
|
|
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
|
|
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
|
}
|
|
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
$session = $this->patientService->createSession($record, $data, $entityType, $entityId);
|
|
|
|
$this->autoCreateClaim($session, $entityType, $entityId);
|
|
|
|
return $this->success($session->toArray(), 201);
|
|
}
|
|
|
|
/**
|
|
* اتصال خودکار مطالبهی بیمه: اگر session بیمهی پایه یا مکمل داشته باشد،
|
|
* صورتحساب ساخته و نهایی میشود و مطالبه (در وضعیت pending) ایجاد میگردد.
|
|
* ارسال مطالبه دستی از صفحهی مطالبات انجام میشود.
|
|
* خطا در این مرحله نباید ثبت session را خراب کند.
|
|
*/
|
|
private function autoCreateClaim(\App\Patient\Entity\PatientSession $session, string $entityType, int $entityId): void
|
|
{
|
|
if ($session->getInsuranceBaseId() === null && $session->getInsuranceSupplementaryId() === null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$invoice = $this->invoiceService->createFromSession($session, $entityType, $entityId);
|
|
$this->invoiceService->finalize($invoice);
|
|
$this->claimService->createFromInvoice($invoice);
|
|
} catch (\App\Shared\Exception\AppException $e) {
|
|
// سهم بیمهای صفر بود یا صورتحساب از قبل وجود داشت — قابل چشمپوشی.
|
|
$this->logger->info('auto claim skipped', ['session' => $session->getUuid(), 'reason' => $e->getMessage()]);
|
|
} catch (\Throwable $e) {
|
|
$this->logger->error('auto claim failed', ['session' => $session->getUuid(), 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
#[Route('/api/v1/session/{uuid}', methods: ['PATCH'])]
|
|
public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
|
$this->assertPatientGate($entityType, $entityId);
|
|
|
|
$session = $this->sessionRepo->findByUuid($uuid);
|
|
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) {
|
|
return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404);
|
|
}
|
|
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
|
|
if (isset($data['notes'])) { $session->setNotes($data['notes']); }
|
|
if (isset($data['payment_method'])) { $session->setPaymentMethod($data['payment_method']); }
|
|
|
|
$this->sessionRepo->save($session);
|
|
|
|
return $this->success($session->toArray());
|
|
}
|
|
|
|
private function resolveEntity(User $user): array
|
|
{
|
|
if ($user->hasRole('ROLE_DOCTOR')) {
|
|
$doctor = $this->doctorRepo->findByUser($user);
|
|
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
|
|
}
|
|
|
|
if ($user->hasRole('ROLE_CLINIC')) {
|
|
$clinic = $this->clinicRepo->findByUser($user);
|
|
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
|
|
}
|
|
|
|
if ($user->hasRole('ROLE_SECRETARY')) {
|
|
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
|
if ($dbUuid !== null) {
|
|
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
|
if ($clinic !== null) {
|
|
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
|
|
if ($rel !== null) {
|
|
return ['clinic', $clinic->getId()];
|
|
}
|
|
}
|
|
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
|
if ($doctor !== null) {
|
|
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
|
|
if ($rel !== null) {
|
|
return ['doctor', $doctor->getId()];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ['unknown', null];
|
|
}
|
|
|
|
private function assertPatientGate(string $entityType, ?int $entityId): void
|
|
{
|
|
if ($entityId === null) {
|
|
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
|
|
}
|
|
|
|
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
|
|
throw new AppException(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, null, 403);
|
|
}
|
|
}
|
|
|
|
private function ownsRecord($record, string $entityType, ?int $entityId): bool
|
|
{
|
|
return $entityId !== null
|
|
&& $record->getEntityType() === $entityType
|
|
&& $record->getEntityId() === $entityId;
|
|
}
|
|
}
|