feat(patients): phase B3 — medical records (پرونده پزشکی)
Add patient medical-exam entries: a new PatientMedicalRecord entity (record-scoped, CASCADE) + repository, and owner-scoped CRUD endpoints (GET list, POST create, PATCH, DELETE) under /api/v1/patient. Wire the "پرونده پزشکی" tab in PatientDetailPage (list + add/edit modal with title, date and notes + delete). PHPUnit covers CRUD + ownership + validation; Vitest covers the tab. API docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,4 +66,13 @@ describe('PatientDetailPage (پرونده تبدار)', () => {
|
|||||||
expect(await screen.findByRole('button', { name: /آپلود فایل جدید/ })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: /آپلود فایل جدید/ })).toBeInTheDocument();
|
||||||
expect(await screen.findByText('هنوز فایلی ضمیمه نشده است.')).toBeInTheDocument();
|
expect(await screen.findByText('هنوز فایلی ضمیمه نشده است.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens the add-exam modal on the medical-record tab', async () => {
|
||||||
|
renderDetail();
|
||||||
|
await screen.findByText('ساغر صابری');
|
||||||
|
fireEvent.click(screen.getByText('پرونده پزشکی'));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /ثبت معاینه جدید/ }));
|
||||||
|
// modal opens with a title field
|
||||||
|
expect(await screen.findByPlaceholderText('مثلاً: معاینه اولیه')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ import {
|
|||||||
PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon,
|
PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon,
|
||||||
ArrowUpTrayIcon, TrashIcon, DocumentIcon,
|
ArrowUpTrayIcon, TrashIcon, DocumentIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { ApiResponse } from '../lib/api';
|
import type { ApiResponse } from '../lib/api';
|
||||||
import type { PatientRecord } from '../types';
|
import type { PatientRecord } from '../types';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { formatDate } from '../lib/utils';
|
import { formatDate } from '../lib/utils';
|
||||||
|
import Modal from '../components/ui/Modal';
|
||||||
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||||
|
|
||||||
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'messages' | 'callcenter' | 'attach' | 'records';
|
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'messages' | 'callcenter' | 'attach' | 'records';
|
||||||
|
|
||||||
@@ -125,6 +129,8 @@ export default function PatientDetailPage() {
|
|||||||
row={(a) => ({ title: a.service_name || a.doctor_name || 'نوبت', meta: a.date ? formatDate(a.date) : (a.starts_at ? formatDate(a.starts_at) : ''), badge: a.status_label || a.status })} />
|
row={(a) => ({ title: a.service_name || a.doctor_name || 'نوبت', meta: a.date ? formatDate(a.date) : (a.starts_at ? formatDate(a.starts_at) : ''), badge: a.status_label || a.status })} />
|
||||||
) : tab === 'attach' ? (
|
) : tab === 'attach' ? (
|
||||||
<AttachmentsTab uuid={uuid!} />
|
<AttachmentsTab uuid={uuid!} />
|
||||||
|
) : tab === 'records' ? (
|
||||||
|
<MedicalRecordsTab uuid={uuid!} />
|
||||||
) : (
|
) : (
|
||||||
<Placeholder label={TABS.find((t) => t.key === tab)!.label} />
|
<Placeholder label={TABS.find((t) => t.key === tab)!.label} />
|
||||||
)}
|
)}
|
||||||
@@ -214,6 +220,110 @@ function AttachmentsTab({ uuid }: { uuid: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MedicalItem { uuid: string; title: string; body?: string | null; recorded_at: number }
|
||||||
|
|
||||||
|
/** پرونده پزشکی — medical exam entries: list + add/edit modal + delete. */
|
||||||
|
function MedicalRecordsTab({ uuid }: { uuid: string }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [modal, setModal] = useState<'create' | MedicalItem | null>(null);
|
||||||
|
const [delTarget, setDelTarget] = useState<MedicalItem | null>(null);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [date, setDate] = useState('');
|
||||||
|
const [body, setBody] = useState('');
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<ApiResponse<MedicalItem[]>>({
|
||||||
|
queryKey: ['patient-medical', uuid],
|
||||||
|
queryFn: () => api.get(`/api/v1/patient/${uuid}/medical-records`),
|
||||||
|
});
|
||||||
|
const items = data?.data ?? [];
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: ['patient-medical', uuid] });
|
||||||
|
|
||||||
|
const open = (m?: MedicalItem) => {
|
||||||
|
setTitle(m?.title ?? ''); setBody(m?.body ?? '');
|
||||||
|
setDate(m?.recorded_at ? new Date(m.recorded_at * 1000).toISOString().slice(0, 10) : '');
|
||||||
|
setModal(m ?? 'create');
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const payload = { title, body: body || null, recorded_at: date ? Math.floor(new Date(date).getTime() / 1000) : undefined };
|
||||||
|
return modal === 'create'
|
||||||
|
? api.post(`/api/v1/patient/${uuid}/medical-record`, payload)
|
||||||
|
: api.patch(`/api/v1/patient/medical-record/${(modal as MedicalItem).uuid}`, payload);
|
||||||
|
},
|
||||||
|
onSuccess: () => { invalidate(); setModal(null); toast.success('ذخیره شد'); },
|
||||||
|
onError: (e: any) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: (u: string) => api.delete(`/api/v1/patient/medical-record/${u}`),
|
||||||
|
onSuccess: () => { invalidate(); setDelTarget(null); toast.success('حذف شد'); },
|
||||||
|
onError: (e: any) => { toast.error(e.message); setDelTarget(null); },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<button className="btn primary" onClick={() => open()}><PlusIcon style={{ width: 16 }} /> ثبت معاینه جدید</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>معاینهای ثبت نشده است.</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{items.map((m) => (
|
||||||
|
<div key={m.uuid} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: '14px 16px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 14 }}>{m.title}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{formatDate(m.recorded_at)}</div>
|
||||||
|
{m.body && <div style={{ fontSize: 13, color: 'var(--text-2)', marginTop: 8, whiteSpace: 'pre-wrap' }}>{m.body}</div>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button className="btn sm ghost" aria-label="ویرایش" style={{ color: 'var(--accent)' }} onClick={() => open(m)}><PencilIcon style={{ width: 15 }} /></button>
|
||||||
|
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => setDelTarget(m)}><TrashIcon style={{ width: 15 }} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal open={modal !== null} onClose={() => setModal(null)} title={modal === 'create' ? 'ثبت معاینه جدید' : 'ویرایش معاینه'}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">عنوان *</label>
|
||||||
|
<div className="field"><input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="مثلاً: معاینه اولیه" autoFocus /></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">تاریخ</label>
|
||||||
|
<PersianDateInput value={date} onChange={setDate} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">شرح</label>
|
||||||
|
<div className="field" style={{ height: 'auto' }}><textarea value={body} onChange={(e) => setBody(e.target.value)} rows={4} placeholder="شرح معاینه" style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} /></div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button className="btn primary" disabled={!title.trim() || save.isPending} onClick={() => save.mutate()}>ذخیره</button>
|
||||||
|
<button className="btn" onClick={() => setModal(null)}>انصراف</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!delTarget}
|
||||||
|
title="حذف معاینه"
|
||||||
|
message={`آیا از حذف «${delTarget?.title}» مطمئن هستید؟`}
|
||||||
|
confirmLabel="حذف"
|
||||||
|
onConfirm={() => delTarget && del.mutate(delTarget.uuid)}
|
||||||
|
onCancel={() => setDelTarget(null)}
|
||||||
|
loading={del.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TabList({ q, emptyLabel, row }: {
|
function TabList({ q, emptyLabel, row }: {
|
||||||
q: { data?: ApiResponse<any[]>; isLoading: boolean };
|
q: { data?: ApiResponse<any[]>; isLoading: boolean };
|
||||||
emptyLabel: string;
|
emptyLabel: string;
|
||||||
|
|||||||
@@ -506,3 +506,32 @@ When an appointment's status changes to `confirmed` via `PATCH /api/v1/appointme
|
|||||||
|------|------|-------------|
|
|------|------|-------------|
|
||||||
| 404 | `ERR_PATIENT_001` / `ERR_NOT_FOUND_001` | رکورد/ضمیمه یافت نشد یا متعلق به tenant دیگر |
|
| 404 | `ERR_PATIENT_001` / `ERR_NOT_FOUND_001` | رکورد/ضمیمه یافت نشد یا متعلق به tenant دیگر |
|
||||||
| 422 | `ERR_VALIDATION_001` | فایل نامعتبر |
|
| 422 | `ERR_VALIDATION_001` | فایل نامعتبر |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## پرونده پزشکی (Medical Records)
|
||||||
|
|
||||||
|
معاینات/یادداشتهای پزشکیِ یک پرونده. scope به رکورد و tenant صاحب رکورد.
|
||||||
|
|
||||||
|
**Permission:** `IS_AUTHENTICATED_FULLY` (مالک رکورد)
|
||||||
|
|
||||||
|
### GET `/api/v1/patient/{uuid}/medical-records`
|
||||||
|
لیست (مرتب بر اساس `recorded_at` نزولی). Response: `{ success, data: [{ uuid, title, body, recorded_at, created_at }] }`
|
||||||
|
|
||||||
|
### POST `/api/v1/patient/{uuid}/medical-record`
|
||||||
|
```json
|
||||||
|
{ "title": "معاینه اولیه", "body": "شرح (اختیاری)", "recorded_at": 1700000000 }
|
||||||
|
```
|
||||||
|
`title` الزامی؛ `recorded_at` اختیاری (پیشفرض زمان ثبت). Response `201`.
|
||||||
|
|
||||||
|
### PATCH `/api/v1/patient/medical-record/{uuid}`
|
||||||
|
فیلدهای اختیاری `title` / `body` / `recorded_at`. فقط مالک؛ در غیر این صورت `404`.
|
||||||
|
|
||||||
|
### DELETE `/api/v1/patient/medical-record/{uuid}`
|
||||||
|
حذف. فقط مالک؛ در غیر این صورت `404`.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
| HTTP | Code | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| 422 | `ERR_VALIDATION_001` | عنوان خالی (`field: title`) |
|
||||||
|
| 404 | `ERR_PATIENT_001` / `ERR_NOT_FOUND_001` | رکورد/رکورد پزشکی یافت نشد یا tenant دیگر |
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260713115316 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE patient_medical_records (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, title VARCHAR(200) NOT NULL, body LONGTEXT DEFAULT NULL, recorded_at INT NOT NULL, created_at INT NOT NULL, record_id INT NOT NULL, UNIQUE INDEX UNIQ_3B2B3121D17F50A6 (uuid), INDEX idx_pmr_record (record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||||
|
$this->addSql('ALTER TABLE patient_medical_records ADD CONSTRAINT FK_3B2B31214DFD750C FOREIGN KEY (record_id) REFERENCES patient_records (id) ON DELETE CASCADE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE patient_medical_records DROP FOREIGN KEY FK_3B2B31214DFD750C');
|
||||||
|
$this->addSql('DROP TABLE patient_medical_records');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,10 +51,96 @@ class PatientController extends BaseController
|
|||||||
private readonly \App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository $invitationRepo,
|
private readonly \App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository $invitationRepo,
|
||||||
private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo,
|
private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo,
|
||||||
private readonly \App\Patient\Repository\PatientAttachmentRepository $attachmentRepo,
|
private readonly \App\Patient\Repository\PatientAttachmentRepository $attachmentRepo,
|
||||||
|
private readonly \App\Patient\Repository\PatientMedicalRecordRepository $medicalRepo,
|
||||||
private readonly \App\Shared\Service\FileUploadService $fileUpload,
|
private readonly \App\Shared\Service\FileUploadService $fileUpload,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// ── 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 (ضمیمه) ───────────────────────────────────────────────────
|
// ── Attachments (ضمیمه) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
#[Route('/api/v1/patient/{uuid}/attachments', methods: ['GET'])]
|
#[Route('/api/v1/patient/{uuid}/attachments', methods: ['GET'])]
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Patient\Entity;
|
||||||
|
|
||||||
|
use App\Patient\Repository\PatientMedicalRecordRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
/** A medical examination / note entry on a patient record (the «پرونده پزشکی» tab). */
|
||||||
|
#[ORM\Entity(repositoryClass: PatientMedicalRecordRepository::class)]
|
||||||
|
#[ORM\Table(name: 'patient_medical_records')]
|
||||||
|
#[ORM\Index(columns: ['record_id'], name: 'idx_pmr_record')]
|
||||||
|
class PatientMedicalRecord
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||||
|
private string $uuid;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'record_id', nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private PatientRecord $record;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 200)]
|
||||||
|
private string $title;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $body = null;
|
||||||
|
|
||||||
|
/** Exam date (Unix ts); defaults to creation time. */
|
||||||
|
#[ORM\Column(name: 'recorded_at', type: 'integer')]
|
||||||
|
private int $recordedAt;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||||
|
private int $createdAt;
|
||||||
|
|
||||||
|
public function __construct(PatientRecord $record, string $title, ?string $body = null, ?int $recordedAt = null)
|
||||||
|
{
|
||||||
|
$this->uuid = Uuid::v4()->toRfc4122();
|
||||||
|
$this->record = $record;
|
||||||
|
$this->title = $title;
|
||||||
|
$this->body = $body;
|
||||||
|
$this->createdAt = time();
|
||||||
|
$this->recordedAt = $recordedAt ?? $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int { return $this->id; }
|
||||||
|
public function getUuid(): string { return $this->uuid; }
|
||||||
|
public function getRecord(): PatientRecord { return $this->record; }
|
||||||
|
|
||||||
|
public function setTitle(string $v): self { $this->title = $v; return $this; }
|
||||||
|
public function setBody(?string $v): self { $this->body = $v; return $this; }
|
||||||
|
public function setRecordedAt(int $v): self { $this->recordedAt = $v; return $this; }
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => $this->uuid,
|
||||||
|
'title' => $this->title,
|
||||||
|
'body' => $this->body,
|
||||||
|
'recorded_at' => $this->recordedAt,
|
||||||
|
'created_at' => $this->createdAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Patient\Repository;
|
||||||
|
|
||||||
|
use App\Patient\Entity\PatientMedicalRecord;
|
||||||
|
use App\Patient\Entity\PatientRecord;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class PatientMedicalRecordRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, PatientMedicalRecord::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByUuid(string $uuid): ?PatientMedicalRecord
|
||||||
|
{
|
||||||
|
return $this->findOneBy(['uuid' => $uuid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return PatientMedicalRecord[] */
|
||||||
|
public function findByRecord(PatientRecord $record): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('m')
|
||||||
|
->where('m.record = :record')
|
||||||
|
->setParameter('record', $record)
|
||||||
|
->orderBy('m.recordedAt', 'DESC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(PatientMedicalRecord $m): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->persist($m);
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(PatientMedicalRecord $m): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->remove($m);
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Patient;
|
||||||
|
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Patient\Entity\PatientRecord;
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patient medical records: CRUD + tenant ownership scoping.
|
||||||
|
*/
|
||||||
|
class PatientMedicalRecordTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord} */
|
||||||
|
private function recordFor(): array
|
||||||
|
{
|
||||||
|
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||||
|
$doctor = new Doctor($owner, 'دکتر');
|
||||||
|
$this->em->persist($doctor);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$patient = $this->createUser(['ROLE_USER']);
|
||||||
|
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||||
|
$this->em->persist($record);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return [$owner, $record];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCrud(): void
|
||||||
|
{
|
||||||
|
[$owner, $record] = $this->recordFor();
|
||||||
|
|
||||||
|
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/medical-record', $owner, [
|
||||||
|
'title' => 'معاینه اولیه', 'body' => 'فشار خون طبیعی', 'recorded_at' => 1_700_000_000,
|
||||||
|
]);
|
||||||
|
self::assertSame(201, $this->responseCode());
|
||||||
|
self::assertSame('معاینه اولیه', $created['data']['title']);
|
||||||
|
$mUuid = $created['data']['uuid'];
|
||||||
|
|
||||||
|
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/medical-records', $owner);
|
||||||
|
self::assertCount(1, $list['data']);
|
||||||
|
|
||||||
|
$this->authJson('PATCH', '/api/v1/patient/medical-record/' . $mUuid, $owner, ['title' => 'معاینه دوم']);
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
|
||||||
|
$this->authJson('DELETE', '/api/v1/patient/medical-record/' . $mUuid, $owner);
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
|
||||||
|
$after = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/medical-records', $owner);
|
||||||
|
self::assertCount(0, $after['data']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRequiresTitle(): void
|
||||||
|
{
|
||||||
|
[$owner, $record] = $this->recordFor();
|
||||||
|
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/medical-record', $owner, ['title' => '']);
|
||||||
|
self::assertSame(422, $this->responseCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOwnershipScoped(): void
|
||||||
|
{
|
||||||
|
[$owner, $record] = $this->recordFor();
|
||||||
|
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/medical-record', $owner, ['title' => 'x']);
|
||||||
|
$mUuid = $created['data']['uuid'];
|
||||||
|
|
||||||
|
[$other] = $this->recordFor();
|
||||||
|
$this->authJson('DELETE', '/api/v1/patient/medical-record/' . $mUuid, $other);
|
||||||
|
self::assertSame(404, $this->responseCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user