feat(session): implement auto-fill for PatientSession on appointment confirmation

This commit is contained in:
hamed
2026-07-17 15:15:14 +03:30
parent 7da7d968b9
commit 48579dc06f
2 changed files with 274 additions and 0 deletions
@@ -0,0 +1,107 @@
# ویرایش سرویس‌های مراجعه + ویرایش/حذف پرداخت + Audit Log مالی جامع
## پروژه
`clinicpro` (backend Symfony + پنل ادمین React). تک-ریپو.
> تست: پنل ادمین با `09390039833 / 09390039833`. اجرا داخل ddev. قرارداد پول: ذخیره/API ریال، UI تومان (`tomanToRial`/`rialToToman`). تاریخ Unix.
## زمینه
مراجعه (`PatientSession`) فقط **ایجاد** می‌شود؛ پس از ثبت، امکان ویرایش سرویس‌ها/کالاها/قیمت ویزیت/بیمه وجود ندارد و پرداخت‌های ثبت‌شده نه ویرایش می‌شوند نه حذف. کاربر می‌خواهد بتواند همه‌ی این‌ها را ویرایش کند، **اما** هر تغییر مالی/خدماتی باید در یک **Audit Log** ثبت و قابل‌مشاهده باشد (چه کسی، چه چیزی، کِی، مقدار قبل/بعد، نوع عملیات).
## مشکل / هدف
1. ویرایش کامل یک مراجعه پس از ثبت: سرویس‌ها (افزودن/حذف/تعداد)، کالاهای مصرفی، قیمت ویزیت، بیمه/درصدها، یادداشت، تاریخ مراجعه — با محاسبه‌ی مجدد `services_total_rials`/`final_price_rials`.
2. ویرایش و حذف پرداخت‌های ثبت‌شده، با نگه‌داشتن سازگاری `paid_total`/`remaining`/`payment_method`/`paid_at`.
3. **Audit Log جامع** برای همه‌ی تغییرات مالی/خدماتی: مقدار قبل، مقدار بعد، کاربر، تاریخ/زمان، نوع عملیات (create/update/delete). نمایش تاریخچه در UI.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Patient/Service/PatientService.php` | `createSession()` (create-only، L165-265)، `addSessionPayment()` (L338-386)، `calculateFinalPrice()` (L63-93) |
| `src/Patient/Controller/PatientController.php` | `updateSession` PATCH (L1037-1092، فیلدهای محدود)، `addSessionPayment` POST (L1100-1122)؛ **بدون** endpoint ویرایش/حذف payment و ویرایش services |
| `src/Patient/Entity/SessionService.php` | خط سرویس؛ **immutable** (بدون setter)؛ constructor snapshot قیمت |
| `src/Patient/Entity/SessionConsumable.php` | خط کالا؛ immutable |
| `src/Patient/Entity/SessionPayment.php` | پرداخت؛ فقط setter برای createdBy/Name؛ `METHODS` (L18)؛ toArray (L74-84) |
| `src/Patient/Repository/SessionServiceRepository.php` / `SessionConsumableRepository.php` / `SessionPaymentRepository.php` | فقط `save()`**بدون `remove()`** |
| `src/Patient/Entity/PatientSession.php` | `getPaidTotalRials()` (L164-170)، `getRemainingRials()` (L173-176)؛ collections با `cascade:['remove']` |
| `src/Appointment/Entity/AppointmentEvent.php` + Repository + endpoint | **الگوی مرجع audit** (id, uuid, FK, type, title, actor_user_id, actor_name, reason, created_at؛ `findByAppointmentUuid`؛ `GET /appointment/{uuid}/events`) |
| `src/Settlement/Service/WalletService.php` | `resolveActorName(?User)` (L34-43) — نام نمایشی کاربر |
| `src/Shared/Constant/ErrorCodes.php` | `ERR_SESSION_NOT_FOUND`, `ERR_SESSION_PAYMENT_INVALID`, `ERR_SESSION_PAYMENT_EXCEEDS` (L64-66) |
| `assets/admin/components/SessionServiceCard.tsx` | dropdown «...» (L101-114) — محل افزودن «ویرایش» + «تاریخچه تغییرات» |
| `assets/admin/pages/PatientDetailPage.tsx` | تب services (L225-248)؛ کارت‌ها؛ `sessionsQ` |
| `assets/admin/components/session/PaymentStep.tsx` | ردیف پرداخت‌ها (L250-270) — محل ویرایش/حذف + audit |
| `assets/admin/components/session/CreateStep.tsx` | فرم ثبت (submit body L233-245) — **الگوی فرم ویرایش** |
| `assets/admin/pages/NewSessionPage.tsx` | ویزارد ثبت — قابل بازاستفاده برای ویرایش |
| `docs/api/patient.md` | مستندات |
## وضعیت فعلی
`updateSession` فیلدهای محدود می‌پذیرد (notes/archived/discount/paid_at/payment_method) — نه services/consumables/visit_price:
```php
// PatientController::updateSession (خلاصه)
if (isset($data['notes'])) { $session->setNotes($data['notes']); }
if (array_key_exists('archived', $data)) { $session->setArchived((bool)$data['archived']); }
// discount_rule_uuid / discount_type / paid_at / payment_method ...
// ← هیچ services / consumables / visit_price_rials
```
`createSession` تنها جایی است که `SessionService`/`SessionConsumable` ساخته و مجموع محاسبه می‌شود (create-only). پرداخت فقط `POST` دارد؛ `grep payments/{` صفر → نه PATCH نه DELETE.
## وظایف
### ۱. Entity + migration — `SessionAuditLog` (الگوی AppointmentEvent)
`src/Patient/Entity/SessionAuditLog.php` (جدید):
- ستون‌ها: `id`, `uuid`, `session` (ManyToOne `PatientSession`, `onDelete: CASCADE`), `field` string (مثل `visit_price_rials`, `services`, `consumables`, `payment`, `discount`), `operation` string (`create`|`update`|`delete`), `old_value` text nullable, `new_value` text nullable, `actor_user_id` int nullable, `actor_name` string nullable, `note` string nullable, `created_at` int.
- constructor `(PatientSession $session, string $field, string $operation)`؛ fluent `setActor(?int,?string)`, `setValues(?string $old, ?string $new)`, `setNote(?string)`.
- `toArray()`: `field`, `operation`, `old_value`, `new_value`, `actor_name`, `note`, `created_at`.
- Repository `SessionAuditLogRepository` با `save()` و `findBySessionUuid(string $uuid): array` (array hydration، مرتب بر `created_at DESC`).
- migration.
> مقادیر قبل/بعد را به‌صورت رشته‌ی خوانا ذخیره کن (مثلاً برای پول ریال عددی؛ برای لیست سرویس‌ها یک خلاصه مثل «تزریق ژل ×۱، لیزر ×۲» یا JSON فشرده). ثبات مهم‌تر از فرمت است.
### ۲. Service — ثبت audit + منطق ویرایش/حذف
در `PatientService`:
- helper `logSessionChange(PatientSession $s, string $field, string $op, ?string $old, ?string $new, ?User $actor, ?string $note = null)` که `SessionAuditLog` می‌سازد و ذخیره می‌کند (`actor_name` با `walletService->resolveActorName`).
- **`updateSessionServices(PatientSession $s, array $data, User $actor)`**: سرویس‌ها/کالاها/قیمت ویزیت/بیمه را جایگزین کند:
- snapshot مقادیر قبل (visit_price, services خلاصه, consumables خلاصه, services_total, final_price).
- سرویس‌های قبلی را `remove` (به `SessionServiceRepository` متد `remove()` اضافه کن)، سپس از `$data['services']` دوباره بساز (مثل createSession).
- همین برای consumables (`SessionConsumableRepository::remove()`).
- `setVisitPriceRials`, بیمه/درصدها، `session_at`, notes را ست کن.
- با `calculateFinalPrice(...)` + مجموع کالاها، `services_total_rials`/`final_price_rials` را بازمحاسبه کن (دقیقاً مثل createSession L213-240).
- برای هر فیلدِ تغییرکرده یک `logSessionChange(... 'update' ...)` با old/new بزن.
- **`updatePayment(SessionPayment $p, array $data, User $actor)`**: `method`/`amount_rials`/`paid_at` را ویرایش کند (setterها را به `SessionPayment` اضافه کن). سقف: مجموع پرداخت‌ها نباید از `final - discount` بیشتر شود. audit با field=`payment`, op=`update`, old/new = مبلغ قبل/بعد.
- **`deletePayment(SessionPayment $p, User $actor)`**: پرداخت را `remove` (به `SessionPaymentRepository::remove()`). audit op=`delete`, old=مبلغ.
- **بازمحاسبه‌ی فیلدهای کش‌شده**: بعد از ویرایش/حذف پرداخت، اگر `getRemainingRials() > 0` بود `payment_method='pending'` و `paid_at=null`؛ اگر صفر شد `payment_method`/`paid_at` را ست کن. (چون `getPaidTotalRials()` از collection زنده جمع می‌زند ولی `payment_method`/`paid_at` کش‌اند.)
- **wallet edge**: اگر پرداخت `wallet` بود، ویرایش/حذف باید تراکنش کیف پول را جبران کند (بازگشت/کسر تفاوت). اگر جبران خارج از scope است، **حذف/ویرایش پرداخت wallet را مسدود کن** (خطای ۴۲۲ با پیام فارسی) تا مغایرت مالی ایجاد نشود — این ساده‌تر و امن‌تر است؛ در پرامپت این گزینه را انتخاب کن مگر بازگشت کیف پول ساده باشد.
### ۳. Controller — endpointهای جدید
در `PatientController` (extends BaseController، owner-scope مثل `updateSession`):
- **ویرایش services**: `updateSession` را گسترش بده تا اگر `services`/`consumables`/`visit_price_rials`/insurance آمد، `patientService->updateSessionServices()` صدا زده شود؛ یا یک route جداگانه `PATCH /api/v1/session/{uuid}/services`. (گسترش `updateSession` تمیزتر است.)
- **ویرایش پرداخت**: `PATCH /api/v1/session/{uuid}/payments/{paymentUuid}``updatePayment`.
- **حذف پرداخت**: `DELETE /api/v1/session/{uuid}/payments/{paymentUuid}``deletePayment`.
- **تاریخچه**: `GET /api/v1/session/{uuid}/audit-log``$this->success($auditRepo->findBySessionUuid($uuid))`.
- گارد: پرداخت باید متعلق به همان session باشد؛ session متعلق به owner (`ownsRecord`).
### ۴. Frontend — فرم ویرایش + کنترل پرداخت + نمایش تاریخچه
1. **منوی کارت** (`SessionServiceCard.tsx` dropdown L101-114): افزودن آیتم‌های «ویرایش» و «تاریخچه تغییرات» (props جدید `onEdit(session)`, `onViewAudit(session)`).
2. **فرم ویرایش**: از `CreateStep` بازاستفاده کن (یا کامپوننت مشترک) در حالت edit؛ با داده‌ی فعلی session پر شود و به `PATCH /session/{uuid}` (با body مثل CreateStep L233-245) بفرستد. مسیر `session/{uuid}/edit` یا مودال.
3. **ردیف پرداخت** (`PaymentStep.tsx` L250-270): برای هر پرداخت آیکون ویرایش (مودال کوچک: مبلغ تومان + روش + تاریخ → `PATCH .../payments/{uuid}`) و حذف (`ConfirmDialog``DELETE`). بعد از هر عملیات `invalidate()`.
4. **تاریخچه تغییرات**: یک مودال/بخش که `GET /session/{uuid}/audit-log` را می‌خواند و هر رکورد را نشان می‌دهد: نوع عملیات (ایجاد/ویرایش/حذف — رنگ‌بندی)، فیلد، مقدار قبل → بعد، کاربر، تاریخ/زمان شمسی (`formatDateTime`). مرتب نزولی.
## نکات مهم
- **همه‌ی مسیرهای تغییر باید audit بزنند**: ویرایش سرویس، کالا، قیمت ویزیت، مبلغ سرویس‌ها، ویرایش/حذف پرداخت، تخفیف. حتی `applyDiscount`/`applyDiscountRule` موجود را هم به `logSessionChange` مجهز کن (field=`discount`).
- تاریخ‌ها Unix؛ پول ریال (ذخیره) / تومان (UI). لیست‌های admin array hydration.
- Entity جدید + ستون‌ها → migration (diff سپس migrate؛ خطوط drift نامرتبط را از migration پاک کن).
- سازگاری مالی: بعد از هر ویرایش/حذف پرداخت، `paid_total`/`remaining`/`is_paid`/`paid_at` باید درست بمانند (بازمحاسبه‌ی فیلدهای کش‌شده).
- **wallet**: تصمیم امن = مسدودکردن ویرایش/حذف پرداخت `wallet` مگر جبران کیف پول پیاده شود.
- مستندات: `docs/api/patient.md` — endpointهای جدید (services edit، payment PATCH/DELETE، audit-log GET) با method/path/permission/body/response/errors.
- این فیچر بزرگ و حساس مالی است — هر وظیفه (۱..۴) جدا پیاده، تست (شامل مسیر خطا/مرزی) و کامیت شود. Backend اول. بعد از کد `graphify update .` (بعد کامیت).
@@ -0,0 +1,167 @@
# رفع باگ واحد پول پرداخت + اطلاعات پرداخت‌ها + منوی سرویس + آرشیو مراجعات
## پروژه
`clinicpro` (backend Symfony + پنل ادمین React). تک-ریپو.
> تست: پنل ادمین با `09390039833 / 09390039833`. اجرا داخل ddev.
> قرارداد واحد پول پروژه (`utils.ts`): **واحد ذخیره/API = ریال**، **واحد نمایش/ورودی UI = تومان**. تبدیل با `tomanToRial` (×۱۰) و `rialToToman` (÷۱۰). نمایش با `formatRial(rial)` که خودش ÷۱۰ می‌کند و « تومان» می‌چسباند.
## زمینه
صفحه پرداخت مراجعه (`/admin/patients/{uuid}/session/{sessionUuid}/pay`) و صفحه خدمات بیمار (`/admin/patients/{uuid}?tab=services`) چند مشکل/کمبود دارند: باگ واحد پول در ثبت پرداخت (تومان به‌عنوان ریال ذخیره می‌شود → یک صفر کم)، نمایش ناقص پرداخت‌های ثبت‌شده، نبود منوی عملیات روی هر مراجعه، و نبودِ قابلیت آرشیو مراجعات اشتباه.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/components/session/PaymentStep.tsx` | فرم پرداخت — **باگ واحد پول** (L83 discount fixed، L94 payment) + ردیف پرداخت‌ها (L252-260) |
| `assets/admin/components/session/DetailsStep.tsx` | خلاصه مراجعه — ردیف پرداخت‌ها (L103-111) |
| `assets/admin/lib/utils.ts` | `tomanToRial`/`rialToToman` (L4-11)، `formatRial` (L8)، `formatDateTime` (L38-46) |
| `src/Patient/Entity/SessionPayment.php` | `toArray()` (L74-84) از قبل `paid_at` + `created_by_name` دارد — backend درست است |
| `assets/admin/components/SessionServiceCard.tsx` | کارت مراجعه — آیکون «...» تزئینی (L81)، دکمه footer «مشاهده فاکتور»/«تکمیل پرداخت» (L99-119)، type `SessionPaymentEntry` (L5-11) |
| `assets/admin/pages/PatientDetailPage.tsx` | تب services (L213-235)، fetch لیست (L140-150)، `viewInvoice` (L85-93)، `InvoiceSummaryModal` (L254) |
| `src/Patient/Entity/PatientSession.php` | Entity مراجعه — **ستون `archived` ندارد** (باید افزوده شود)؛ `toArray()` (L221-265) |
| `src/Patient/Repository/PatientSessionRepository.php` | `findByRecord` (L22-32) + `countByRecord` (L34-42) — بدون فیلتر archived |
| `src/Patient/Controller/PatientController.php` | `sessions` GET (L912-934)، `updateSession` PATCH (L1036)، `sessionWithBilling` (L973-990) |
| `docs/api/patient.md` | به‌روزرسانی مستندات (Standing Rule) |
---
## تسک ۱ — رفع باگ واحد پول در ثبت پرداخت و تخفیف ثابت
### وضعیت فعلی (باگ — frontend خالص)
`PaymentStep.tsx` مبلغ تومانِ ورودی را **بدون** `tomanToRial` تحت کلید `amount_rials` می‌فرستد؛ backend همه‌جا ریال فرض می‌کند (`getRemainingRials`, wallet withdraw) و درست است. پس تومان خام به‌عنوان ریال ذخیره می‌شود → یک صفر کم (÷۱۰ در نمایش).
```tsx
// L91-94 — payment
const submitPayment = (method: string) => {
if (amount <= 0) return;
payMut.mutate({ method, amount_rials: amount, paid_at: isoToUnix(paymentDate) }); // ← amount تومان است
};
// L80-83 — discount (فقط حالت fixed مبلغ است؛ percent درصد است)
const applyDiscount = () => {
if (!discountType || discountValue <= 0) return;
discountMut.mutate({ discount_type: discountType, discount_value: discountValue }); // ← fixed تومان است
};
```
`utils.ts`: `tomanToRial = (t) => Math.round(t * 10)`.
### وظایف
1. در `submitPayment`، مبلغ را قبل از ارسال به ریال تبدیل کن:
```tsx
payMut.mutate({ method, amount_rials: tomanToRial(amount), paid_at: isoToUnix(paymentDate) });
```
2. در `applyDiscount`، فقط برای `discount_type === 'fixed'` مقدار را به ریال تبدیل کن (percent درصد است، تبدیل نشود):
```tsx
const value = discountType === 'fixed' ? tomanToRial(discountValue) : discountValue;
discountMut.mutate({ discount_type: discountType, discount_value: value });
```
3. `tomanToRial` را از `../../lib/utils` import کن.
4. **backend را تغییر نده** — تبدیل در backend باعث double-convert مسیر percent و سایر callerهای درست می‌شود (تخفیف دستی از قبل در `PatientService::applyDiscount` روی ریال کار می‌کند و از UI صفحه دیگر هم درست می‌آید؛ فقط این صفحه باگ دارد).
### نکات
- **edge case**: تخفیف بر اساس قانون (`discount_rule_uuid`) مبلغ را از backend می‌گیرد (نه UI) — دست نزن.
- بعد از fix، یک پرداخت ۵۰۰٬۰۰۰ تومانی ثبت کن و تأیید کن در «پرداخت‌شده‌ها» و مانده، مبلغ درست (۵۰۰٬۰۰۰ تومان) نمایش داده می‌شود، نه ۵۰٬۰۰۰.
---
## تسک ۲ — نمایش کامل پرداخت‌های ثبت‌شده (تاریخ/ساعت + ثبت‌کننده)
### وضعیت فعلی
`SessionPayment::toArray()` از قبل `paid_at` (Unix) و `created_by_name` را می‌دهد و type frontend (`SessionPaymentEntry`) هم دارد. اما ردیف نمایش فقط روش + مبلغ را نشان می‌دهد:
```tsx
// PaymentStep.tsx L252-260 و DetailsStep.tsx L103-111 (مشابه)
{payments.map((p) => (
<div key={p.uuid} ...>
<span>...<span>{METHOD_LABELS[p.method] ?? p.method}</span></span>
<span>مبلغ : {formatRial(p.amount_rials)}</span>
</div>
))}
```
### وظایف
در **هر دو** `PaymentStep.tsx` و `DetailsStep.tsx`، ردیف پرداخت را کامل کن تا علاوه بر روش و مبلغ، این‌ها را هم نشان دهد:
- **تاریخ و ساعت پرداخت**: `formatDateTime(p.paid_at)` (شمسی + HH:MM؛ از `utils.ts` import کن). اگر `paid_at` خالی بود، از `p.created_at`.
- **ثبت‌کننده**: `p.created_by_name` (اگر موجود) — مثلاً «ثبت: {created_by_name}».
چیدمان تمیز بماند (مثلاً خط دوم کوچک‌تر و کم‌رنگ زیر روش/مبلغ).
### نکات
- `formatDateTime` ورودی Unix ثانیه می‌گیرد؛ `paid_at`/`created_at` هر دو Unix صحیح‌اند.
- backend تغییر نمی‌کند — داده از قبل موجود است.
---
## تسک ۳ — منوی «...» روی هر مراجعه: «مشاهده فاکتور» + «آرشیو»
### وضعیت فعلی
در `SessionServiceCard.tsx` آیکون `FilesServiceMore` (L81) **تزئینی** است — بدون onClick/منو. «مشاهده فاکتور» فقط به‌صورت دکمه footer وقتی `paid` است وجود دارد؛ «آرشیو» اصلاً نیست.
### وظایف
1. آیکون «...» را به **dropdown trigger** تبدیل کن (منوی کوچک با کلیک، بسته‌شدن با کلیک بیرون). آیتم‌ها:
- **مشاهده فاکتور** → همان `onViewInvoice(session)` که کارت از prop می‌گیرد (منطق `viewInvoice` در `PatientDetailPage` L85-93؛ اگر `invoice_uuid` نبود، ابتدا صادر و بعد باز می‌شود).
- **آرشیو** (یا «خروج از آرشیو» اگر `session.archived`) → یک prop جدید `onArchive(session, archived: boolean)` که کارت صدا می‌زند؛ در `PatientDetailPage` به mutation آرشیو (تسک ۴) وصل شود.
2. props جدید کارت: `onArchive?: (session: SessionCardData, archived: boolean) => void`. type `SessionCardData` را با `archived?: boolean` گسترش بده.
3. از الگوی dropdown موجود پروژه استفاده کن (اگر کامپوننت منوی مشترک هست از آن؛ وگرنه یک منوی ساده با `Portal`/absolute + بستن با کلیک بیرون، هم‌راستا با بقیه).
### نکات
- «مشاهده فاکتور» در منو نباید دکمه footer را حذف کند مگر بخواهی یکدست کنی — کافی است در منو هم باشد.
- برای مراجعه‌ی بدون فاکتور، «مشاهده فاکتور» همان مسیر صدور idempotent را طی می‌کند (رفتار فعلی `viewInvoice`).
---
## تسک ۴ — آرشیو مراجعات (backend + UI فیلتر)
### وضعیت فعلی
`PatientSession` هیچ ستون `archived`/`status`/`deleted_at` ندارد. `findByRecord`/`countByRecord` بدون فیلتر همه را برمی‌گردانند. `sessions` GET پارامتر فیلتر ندارد.
### وظایف (Backend اول)
1. **Entity + migration**: به `PatientSession` ستون `archived` (bool، default false) و `archived_at` (int nullable، Unix) اضافه کن؛ getter/setter (`isArchived`, `setArchived(bool)` که `archived_at = archived ? time() : null` را ست کند). در `toArray()` کلید `archived` را expose کن. `make:migration` + `migrate`.
2. **Repository**: `findByRecord`/`countByRecord` یک پارامتر فیلتر بگیرند: `all` | `active` | `archived` (پیش‌فرض `active`). `active``s.archived = false`، `archived``s.archived = true`، `all` → بدون شرط.
3. **`sessions` GET**: پارامتر query `filter` (پیش‌فرض `active`) را بخوان و به repo بده. پس **پیش‌فرض آرشیوها نمایش داده نشوند**.
4. **endpoint آرشیو**: در `updateSession` (`PATCH /api/v1/session/{uuid}`) پذیرش فیلد `archived` (bool) → `session->setArchived((bool)$data['archived'])`. (یا اگر تمیزتر است یک route اختصاصی `PATCH /api/v1/session/{uuid}/archive`.) owner-scope مثل بقیه‌ی `updateSession`.
### وظایف (Frontend)
5. **دکمه/فیلتر نمایش آرشیو**: در تب services (`PatientDetailPage.tsx` L213-235) دکمه فیلتر تزئینی موجود (`TurnsFilter`) را فعال کن یا یک segmented/دکمه «نمایش آرشیو» اضافه کن؛ یک state `filter: 'active' | 'all' | 'archived'` (پیش‌فرض `active`). query key و URL شامل filter شود:
```tsx
const [filter, setFilter] = useState<'active'|'all'|'archived'>('active');
const sessionsQ = useQuery({
queryKey: ['patient-sessions', uuid, filter],
queryFn: () => api.get(`/api/v1/patient/${uuid}/sessions?filter=${filter}`),
enabled: !!uuid,
});
```
6. **اکشن آرشیو**: `onArchive` (تسک ۳) به یک mutation وصل شود که `PATCH /api/v1/session/{uuid}` با `{ archived: true/false }` می‌زند و `['patient-sessions', uuid]` را invalidate می‌کند. toast مناسب («مراجعه آرشیو شد» / «از آرشیو خارج شد»).
7. کارت آرشیوشده در حالت نمایش آرشیو یک نشانه‌ی بصری داشته باشد (مثلاً badge «آرشیو» یا کم‌رنگ).
### نکات
- تاریخ‌ها Unix صحیح؛ لیست‌های admin طبق قانون. تغییر Entity → migration.
- **سوابق حفظ شود**: آرشیو فقط مخفی می‌کند (soft)، حذف نیست؛ فاکتور و پرداخت‌ها دست‌نخورده می‌مانند.
- بعد از تغییر API، `docs/api/patient.md` را به‌روز کن (پارامتر `filter` روی `sessions`، فیلد `archived` روی `updateSession`/entity).
- edge case: آرشیو کردن مراجعه‌ی تسویه‌شده مجاز است (فقط مخفی می‌شود)؛ گزارش‌های مالی نباید آرشیوها را از سابقه حذف کنند (فقط لیست پیش‌فرض این صفحه فیلتر شود).
---
## قوانین عمومی
- کنترلرها از `BaseController`؛ پاسخ‌ها `$this->success()`/`$this->paginated()`/`$this->error()`.
- تاریخ‌ها Unix؛ قیمت‌ها ریال (ذخیره/API)، تومان (UI) با `tomanToRial`/`rialToToman`.
- TanStack Query + الگوهای موجود؛ selectها `SearchableSelect`.
- هر تسک جدا تست و کامیت شود. Backend اول در تسک ۴. بعد از کد، `graphify update .` (بعد کامیت).