feat: Add online share functionality for secretaries

- Introduced `online_share_enabled` and `online_share_percent` fields in the `doctor_secretaries` table to manage secretary shares from online appointments.
- Added `bank_account` field in the `profiles` table to store user-level IBANs for settlements.
- Created `secretary_earnings` table to track earnings per secretary from online appointments, including a foreign key relationship with `financial_breakdowns`.
- Implemented `SecretaryEarning` entity and repository for managing secretary earnings.
- Developed `SecretaryShareResolver` service to determine which secretaries earn from online payments.
- Added `UserIbanResolver` service to handle user IBAN retrieval and management.
- Created `HasIbansTrait` for entities to manage IBANs in a JSON format.
- Implemented tests for secretary earnings and API endpoints for managing secretary shares and IBANs.
This commit is contained in:
hamed
2026-07-25 18:34:18 +03:30
parent 73a608c3dd
commit 8d2b0d908a
33 changed files with 2564 additions and 76 deletions
@@ -0,0 +1,301 @@
# سهم درآمد منشی از نوبت‌های آنلاین (فعال‌سازی + درصد از مبلغ خالص + گزارش و شبا در پنل منشی)
## زمینه
نوبتی که از سایت عمومی به‌صورت **آنلاین** رزرو و با پرداخت موفق قطعی می‌شود، همین حالا از یک موتور تقسیم مالی عبور می‌کند: `CommissionService::settle()` ابتدا هزینهٔ پنل پیامک را کم می‌کند، بعد مالیات را از باقی‌مانده استخراج می‌کند و در آخر پورسانت **نماینده** را از «خالصِ پس از مالیات» می‌گیرد و بقیه سهم سیستم می‌شود. کل این تفکیک در `financial_breakdowns` ثبت و سهم نماینده به‌صورت `WalletTransaction` اعتبار می‌شود؛ نماینده بعداً با شبای تأییدشده‌اش درخواست تسویه می‌زند.
منشی امروز هیچ سهمی از این جریان ندارد: نه فیلدی برای فعال‌سازی/درصد دارد، نه صفحهٔ جزئیاتی در پنل ادمین (`/admin/secretaries` فقط لیست است و `/admin/secretaries/{uuid}` وجود ندارد)، نه راهی برای ثبت شبا و دیدن درآمد.
## هدف
۱) ادمین در `/admin/secretaries/{uuid}` (که `uuid` همان `DoctorSecretary.uuid` است) بتواند برای هر رابطهٔ منشی–پزشک/کلینیک:
- محاسبهٔ درآمد منشی از نوبت‌های آنلاین را **فعال/غیرفعال** کند
- **درصد سهم** منشی را تعیین کند
۲) سهم منشی از **مبلغ خالص** نوبت حساب شود — یعنی پس از کسر هزینهٔ پیامک، مالیات و کارمزد/کسورات؛ دقیقاً همان `netAfterTax` که پورسانت نماینده هم از آن گرفته می‌شود.
۳) اگر این قابلیت فعال باشد، منشی در پنل خودش:
- درآمد **روزانه / ماهانه** و گزارش سطر-به-سطر نوبت‌های آنلاین را ببیند
- **شماره شبا** ثبت و مدیریت کند (مثل پنل نماینده) تا برای تسویه استفاده شود
## فایل‌های مرتبط
| فایل | نقش |
|---|---|
| `src/Settlement/Service/CommissionService.php` | موتور تقسیم مالی؛ محل افزودن سهم منشی |
| `src/Settlement/Entity/FinancialBreakdown.php` | تفکیک مالی هر پرداخت (`grossRials`, `smsFeeRials`, `taxRials`, `netAfterTaxRials`, `commissionPercent`, `representationShareRials`, `systemShareRials`) |
| `src/Settlement/Repository/FinancialBreakdownRepository.php` | `existsForPayment()` — گاردِ پردازش دوباره |
| `src/Payment/Service/PaymentManager.php` | خط ۳۱۸: تنها فراخوانِ `processAppointment` پس از پرداخت موفق |
| `src/Secretary/Entity/DoctorSecretary.php` | رابطهٔ منشی–پزشک/کلینیک (`permissions`, `active`, `ownerType`, `clinic`) |
| `src/Secretary/Controller/SecretaryController.php` | اندپوینت‌های منشی |
| `src/Admin/Controller/AdminApiController.php` | `GET /api/v1/admin/secretaries` (خط ~۱۴۹۵) — لیست ادمین با `ds.uuid` |
| `src/Settlement/Controller/SettlementController.php` | `POST /api/v1/settlement` (خط ۱۴۴) — **شبا را فقط از `Representation` می‌خواند** |
| `src/Representation/Entity/Representation.php` | الگوی شبا: `bank_account` JSON + `addIban()/removeIban()/findVerifiedIban()` (حداکثر ۲) |
| `src/Representation/Controller/RepresentationActionController.php` | `POST/DELETE /api/v1/representation/iban` و `GET /api/v1/representation/finance/report` (خط ۷۸۸) — الگوی گزارش مالی |
| `src/UserProfile/Entity/UserProfile.php` | پروفایل کاربر (بدون فیلد بانکی) |
| `src/Config/Repository/SiteConfigRepository.php` | `sms_panel_fee_rials`, `tax_enabled`, `tax_percent`, `appointment_commission_enabled` |
| `assets/admin/pages/SecretariesPage.tsx` | لیست منشی‌های ادمین |
| `assets/admin/pages/RepresentationDetailPage.tsx` | الگوی صفحهٔ جزئیات ادمین |
| `assets/admin/pages/RepresentationFinancePage.tsx` · `RepresentationSettlementPage.tsx` | الگوی گزارش درآمد + تسویه/شبا در پنل |
| `assets/admin/App.tsx` | خط ۲۱۱ (`secretaries`) — محل افزودن route جزئیات و صفحات پنل منشی |
| `docs/api/secretary.md` · `docs/api/admin.md` · `docs/api/settlement.md` | مستندات |
## وضعیت فعلی
### ۱) سهم فقط برای نماینده محاسبه می‌شود — `src/Settlement/Service/CommissionService.php:85-143`
```php
$gross = $payment->getAmountRials();
// مرحله ۱: کسر هزینه ثابت پنل پیامک.
$smsFee = (int) $this->configRepo->get('sms_panel_fee_rials');
$afterSms = max(0, $gross - $smsFee);
// مرحله ۲: مالیاتِ استخراجی از مبلغِ شامل مالیات: tax = amount × p/(100+p).
$taxEnabled = $this->configRepo->get('tax_enabled') === '1';
$taxPercent = $taxEnabled ? (float) $this->configRepo->get('tax_percent') : 0.0;
$taxRials = ($taxEnabled && $taxPercent > 0)
? (int) round($afterSms * $taxPercent / (100 + $taxPercent))
: 0;
$netAfterTax = $afterSms - $taxRials;
// مرحله ۳: پورسانت نماینده از خالصِ پس از مالیات.
$repShare = (int) round($netAfterTax * $commissionPercent / 100);
$systemShare = $gross - $smsFee - $taxRials - $repShare;
```
و گاردِ ورودی — بدون نماینده، کل متد زودتر برمی‌گردد و **هیچ** تفکیکی ثبت نمی‌شود:
```php
public function processAppointment(Payment $payment, ?int $doctorRepId, ?int $bookingRepId, ?int $doctorId): void
{
if ($this->configRepo->get('appointment_commission_enabled') !== '1') return;
// هر دو شرط لازم است و باید یکی باشند.
if ($doctorRepId === null || $bookingRepId === null || $doctorRepId !== $bookingRepId) return;
$rep = $this->resolveRep($doctorRepId);
if ($rep === null) return;
...
```
### ۲) درخواست تسویه شبا را فقط از نماینده می‌خواند — `src/Settlement/Controller/SettlementController.php:158-162`
```php
$rep = $this->representationRepo->findByUser($user);
$iban = $rep?->findVerifiedIban($ibanId);
if ($iban === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره شبا نامعتبر یا تأییدنشده است', 422, 'iban_id');
}
```
یعنی منشی حتی با موجودی کیف پول، امکان ثبت درخواست تسویه ندارد.
### ۳) `DoctorSecretary` هیچ فیلد مالی ندارد — `src/Secretary/Entity/DoctorSecretary.php:57-80`
```php
#[ORM\Column(name: 'owner_type', type: 'string', length: 10, options: ['default' => 'doctor'])]
private string $ownerType;
#[ORM\Column(name: 'permission', type: 'json', nullable: true)]
private ?array $permissions = null;
// …
#[ORM\Column(type: 'boolean')]
private bool $active = true;
```
## وظایف
> ترتیب: ۱ → ۸ (بک‌اند اول، بعد فرانت، بعد مستندات/تست). هر گام مستقل تست‌شدنی باشد.
### ۱. فیلدهای سهم روی رابطهٔ منشی
`src/Secretary/Entity/DoctorSecretary.php`:
```php
/** محاسبهٔ سهم منشی از نوبت‌های آنلاینِ همین پزشک/کلینیک فعال است؟ */
#[ORM\Column(name: 'online_share_enabled', type: 'boolean', options: ['default' => false])]
private bool $onlineShareEnabled = false;
/** درصد سهم منشی از «خالصِ پس از مالیات» نوبت آنلاین. */
#[ORM\Column(name: 'online_share_percent', type: 'decimal', precision: 5, scale: 2)]
private string $onlineSharePercent = '0.00';
```
- getter/setter + کلیدهای `online_share_enabled` و `online_share_percent` در `toArray()`
- migration (پیش‌فرض‌ها طوری که رفتار موجود عوض نشود: غیرفعال و صفر)
- **گرین درست:** این تنظیم per-relation است (هر ردیف `DoctorSecretary` = یک منشی برای یک پزشک/کلینیک)، چون `uuid` در `/admin/secretaries/{uuid}` همان `DoctorSecretary.uuid` است.
### ۲. شبای منشی (سطح کاربر) + resolver مشترک تسویه
شبا به **کاربر** تعلق دارد نه به رابطه؛ پس روی `src/UserProfile/Entity/UserProfile.php`:
```php
/** ۰ تا ۲ شماره شبا؛ هر آیتم: { id, iban, bank_name, owner_name, verified, created_at } */
#[ORM\Column(name: 'bank_account', type: 'json', nullable: true)]
private ?array $bankAccount = null;
```
با همان سه متد الگوی نماینده: `getIbans()`, `addIban()` (سقف ۲، خطای `iban_limit``removeIban($id)`, `findVerifiedIban($id)` — کد را از `Representation` **کپی نکن**؛ یک trait مشترک `src/Shared/Entity/HasIbansTrait.php` بساز و هر دو entity از آن استفاده کنند (اجتناب از دو پیاده‌سازی واگرا).
سپس `src/Settlement/Service/UserIbanResolver.php`:
```php
/** شبای تأییدشدهٔ یک کاربر، از هر منبعی که دارد: نماینده، وگرنه پروفایل کاربر. */
public function findVerifiedIban(User $user, string $ibanId): ?array
```
و `SettlementController::request()` به‌جای `representationRepo->findByUser(...)` از همین resolver استفاده کند. بدونِ این تغییر، منشی نمی‌تواند تسویه بزند.
### ۳. سهم منشی در موتور تقسیم مالی
`src/Settlement/Entity/FinancialBreakdown.php`: دو ستون تازه + migration
```php
#[ORM\Column(name: 'secretary_share_percent', type: 'decimal', precision: 5, scale: 2, options: ['default' => '0.00'])]
private string $secretarySharePercent = '0.00';
#[ORM\Column(name: 'secretary_share_rials', type: 'integer', options: ['default' => 0])]
private int $secretaryShareRials = 0;
/** کاربرِ منشیِ اعتبارشده (برای گزارش پنل منشی). */
#[ORM\Column(name: 'secretary_user_id', type: 'integer', nullable: true)]
private ?int $secretaryUserId = null;
```
`src/Settlement/Service/CommissionService.php`:
- متد جدید عمومی برای نوبت آنلاین که **مستقل از نماینده** کار کند. الآن اگر نوبت نمایندهٔ منطبق نداشته باشد، `processAppointment` زودتر return می‌کند؛ سهم منشی نباید به وجود نماینده گره بخورد. بازآرایی پیشنهادی:
```php
public function processAppointment(Payment $payment, ?int $doctorRepId, ?int $bookingRepId, ?int $doctorId): void
{
if ($this->breakdownRepo->existsForPayment($payment)) return;
$rep = $this->eligibleRep($doctorRepId, $bookingRepId); // منطق و گاردهای فعلی، دست‌نخورده
$secretaries = $this->secretaryShares->for($payment); // آرایهٔ [{user, percent}]
if ($rep === null && $secretaries === []) return; // چیزی برای تقسیم نیست
$this->settle($payment, FinancialBreakdown::SOURCE_APPOINTMENT, $rep, $secretaries, $doctorId, null);
}
```
- در `settle()` پس از محاسبهٔ `netAfterTax` (بدون تغییر در دو مرحلهٔ اول):
```php
// سهم منشی — مثل نماینده از «خالصِ پس از مالیات»، نه از مبلغ کل.
$secretaryShare = (int) round($netAfterTax * $secretaryPercent / 100);
$systemShare = $gross - $smsFee - $taxRials - $repShare - $secretaryShare;
```
- برای هر منشیِ واجد شرط یک `WalletTransaction` از نوع `TYPE_CREDIT` با توضیح فارسی (`سهم نوبت آنلاین <orderId>`) ثبت شود — همان الگوی نماینده.
- `systemShare` هرگز نباید منفی شود: مجموع `repPercent + Σ secretaryPercent` را در `settle()` به ۱۰۰ کلیپ کن و اگر کلیپ شد یک `logger->warning` با `payment_uuid` بنویس (سکوت نکن).
سرویس جدید `src/Secretary/Service/SecretaryShareResolver.php`:
```php
/**
* منشی‌هایی که از این نوبت آنلاین سهم می‌برند: رابطهٔ فعالِ همان پزشک (یا کلینیکِ نوبت)
* با online_share_enabled و درصد > ۰.
*
* @return list<array{user: User, percent: float, relation_uuid: string}>
*/
public function for(Payment $payment): array
```
- مبنای انتساب: `payment->getAppointment()` → اگر `appointment->getClinic()` پر بود، رابطه‌های `ownerType='clinic'` همان کلینیک؛ وگرنه رابطه‌های همان پزشک.
- فقط `active = true` و `online_share_enabled = true`.
- «آنلاین» یعنی همین مسیر: `CommissionService::processAppointment` تنها از `PaymentManager` (پرداخت موفق درگاه) صدا زده می‌شود؛ نوبت‌های ثبت‌شده در پنل از این مسیر عبور نمی‌کنند و سهمی نمی‌سازند. این را در docblock بنویس.
### ۴. اندپوینت‌های ادمین برای مدیریت سهم
در `src/Admin/Controller/AdminApiController.php` (کنار `GET /api/v1/admin/secretaries`، همان `#[IsGranted('ROLE_ADMIN')]`):
```php
#[Route('/api/v1/admin/secretary/{uuid}', methods: ['GET'])]
public function secretaryDetail(string $uuid): JsonResponse
{
// { uuid, secretary_name, secretary_mobile, owner_type, doctor: {uuid,name}, clinic: {uuid,name}|null,
// permissions, active, online_share_enabled, online_share_percent,
// earnings: { total_rials, this_month_rials, appointments_count } }
}
#[Route('/api/v1/admin/secretary/{uuid}/online-share', methods: ['PUT'])]
public function saveSecretaryOnlineShare(string $uuid, Request $request): JsonResponse
{
// body: { enabled: true, percent: 5 }
// اعتبارسنجی: 0 ≤ percent ≤ 100 → وگرنه error(ERR_VALIDATION_001, …, 422, 'percent')
// enabled=true با percent=0 → 422 (فعال‌سازی بی‌درصد بی‌معناست)
}
```
- در پاسخ `GET /api/v1/admin/secretaries` هم دو کلید `online_share_enabled` / `online_share_percent` اضافه شود (همان DQL موجود، بدون کوئری اضافه).
### ۵. اندپوینت‌های پنل منشی
در `src/Secretary/Controller/SecretaryController.php` (گِیت: کاربر باید رابطهٔ فعال منشی داشته باشد):
```php
#[Route('/api/v1/secretary/earnings/summary', methods: ['GET'])]
public function earningsSummary(#[CurrentUser] User $user): JsonResponse
{
// { enabled: bool, share_percent: float, today_rials, this_month_rials,
// total_rials, wallet_balance_rials, appointments_count }
}
#[Route('/api/v1/secretary/earnings/report', methods: ['GET'])]
public function earningsReport(Request $request, #[CurrentUser] User $user): JsonResponse
{
// paginated؛ query: page, limit, from, to (Unix)
// هر ردیف: { uuid, appointment_uuid, doctor_name, created_at,
// gross_rials, sms_fee_rials, tax_rials, net_after_tax_rials,
// share_percent, share_rials }
}
```
- الگوی کوئری را از `RepresentationActionController::financeReport()` (خط ۷۸۸) بگیر: DQL روی `FinancialBreakdown` با `getArrayResult()`، فیلتر `b.secretaryUserId = :userId`، `paginated()` برای پاسخ.
- `enabled: false` وقتی هیچ رابطهٔ فعالِ دارای سهم ندارد؛ در این حالت گزارش خالی برگردد (نه ۴۰۳) تا فرانت بتواند پیام «این قابلیت برای شما فعال نیست» نشان دهد.
- شبا: اندپوینت‌های `POST /api/v1/secretary/iban` و `DELETE /api/v1/secretary/iban/{id}` با همان قواعد نماینده (سقف ۲، `verified` فقط توسط ادمین) روی `UserProfile` ذخیره شوند؛ `GET /api/v1/secretary/me` هم `bank_account` را برگرداند.
- کیف پول و تسویه اندپوینت تازه لازم ندارند: `/api/v1/wallet/balance`, `/api/v1/wallet/transactions`, `POST /api/v1/settlement` کاربر-محورند و با اصلاح گام ۲ برای منشی کار می‌کنند.
### ۶. پنل ادمین — صفحهٔ جزئیات منشی
- route جدید در `assets/admin/App.tsx` کنار خط ۲۱۱: `secretaries/:uuid``SecretaryDetailPage` با `RoleRoute roles={['admin']}`.
- `assets/admin/pages/SecretaryDetailPage.tsx` (جدید) با الگوی `RepresentationDetailPage.tsx``PageHeader` + کارت‌های موجود، بدون طراحی تازه:
- کارت «اطلاعات منشی» (نام، موبایل، پزشک/کلینیک، وضعیت، مجوزها)
- کارت «سهم درآمد نوبت‌های آنلاین»: سوییچ فعال/غیرفعال + ورودی درصد (`digitsOnly(value, 3)`) + دکمهٔ ذخیره → `PUT /api/v1/admin/secretary/{uuid}/online-share`؛ پس از موفقیت `queryKey` هم لیست و هم جزئیات invalidate شود
- کارت خلاصهٔ درآمد (`earnings` از پاسخ جزئیات) با `formatRial`
- در `assets/admin/pages/SecretariesPage.tsx`: ستون «سهم آنلاین» (`—` وقتی غیرفعال) + کلیک ردیف/اکشن به صفحهٔ جزئیات.
### ۷. پنل منشی — درآمد و شبا
- `assets/admin/pages/SecretaryEarningsPage.tsx` (جدید): کارت‌های «امروز / این ماه / کل» + `DataTable` گزارش با `Pagination` و فیلتر تاریخ (`PersianDateInput`)؛ ستون‌ها: تاریخ (`formatDate` شمسی)، پزشک، مبلغ نوبت، کسورات، خالص، درصد، سهم منشی.
- `assets/admin/pages/SecretarySettlementPage.tsx` (جدید): مثل `RepresentationSettlementPage.tsx` — موجودی کیف پول، مدیریت شبا (افزودن/حذف، حداکثر ۲، نمایش `verified`)، فرم درخواست تسویه با انتخاب شبای تأییدشده، و لیست درخواست‌ها.
- routeها با `RoleRoute roles={['secretary']}`؛ آیتم منو فقط وقتی `enabled` در `earnings/summary` درست است (منوی نقش منشی همان جایی که بقیهٔ آیتم‌های منشی تعریف شده‌اند).
- هیچ محاسبهٔ مالی در فرانت تکرار نشود: همهٔ اعداد از سرور می‌آیند.
### ۸. مستندات و تست
- `docs/api/admin.md`: `GET /api/v1/admin/secretary/{uuid}`، `PUT …/online-share` (بدنه، خطاها)، و فیلدهای تازه در لیست منشی‌ها.
- `docs/api/secretary.md`: `earnings/summary`، `earnings/report` (با پارامترهای query و مثال JSON)، اندپوینت‌های شبا، و اشاره به این‌که کیف پول/تسویه از `docs/api/settlement.md` می‌آید.
- `docs/api/settlement.md`: تسویه دیگر مخصوص نماینده نیست؛ شبا از نماینده **یا** پروفایل کاربر resolve می‌شود.
- PHPUnit:
- `CommissionService`: منشیِ فعال با ۵٪ → `secretary_share_rials = round(netAfterTax × 5 / 100)` و `system_share = gross sms tax rep secretary`؛ نوبت **بدون** نماینده ولی با منشیِ فعال → تفکیک ساخته شود (رگرسیونِ گاردِ فعلی)؛ منشیِ غیرفعال/درصد صفر → هیچ سهمی؛ پرداخت تکراری → دوباره پردازش نشود؛ مجموع درصدها > ۱۰۰ → کلیپ و لاگ، `systemShare >= 0`.
- `SecretaryShareResolver`: نوبت کلینیکی → منشی‌های همان کلینیک؛ نوبت مطب → منشی‌های همان پزشک؛ رابطهٔ غیرفعال حساب نشود.
- Controller ادمین: `percent = 101` → ۴۲۲ · `enabled=true, percent=0` → ۴۲۲ · کاربر غیرادمین → ۴۰۳.
- `SettlementController`: منشی با شبای تأییدشده در `UserProfile` می‌تواند تسویه بزند؛ شبای تأییدنشده → ۴۲۲.
- `SecretaryController`: منشیِ بدون سهم → `enabled: false` و گزارش خالی با ۲۰۰.
- Vitest: `SecretaryDetailPage` (ذخیرهٔ سوییچ+درصد، خطای درصد > ۱۰۰)، `SecretaryEarningsPage` (نمایش کارت‌ها و ردیف‌ها از پاسخ paginated).
- اجرای واقعی: `ddev exec php bin/phpunit` · `ddev exec php vendor/bin/phpstan analyse` · `ddev exec npx tsc --noEmit --project tsconfig.json` · `ddev exec yarn dev` · `npx vitest run` (⚠️ vitest داخل ddev اجرا نمی‌شود — روی host اجرا کن).
## نکات مهم
- **ترتیب کسورات تغییر نکند:** پیامک → مالیات → سهم‌ها. سهم منشی و پورسانت نماینده هر دو از **`netAfterTax`** گرفته می‌شوند؛ نه از `gross` و نه از باقی‌ماندهٔ پس از سهم دیگری (وگرنه ترتیبِ اجرا روی مبلغ اثر می‌گذارد).
- **گرد کردن:** `(int) round(...)` مثل کد فعلی؛ `systemShare` همیشه از تفریق حساب شود تا مجموع سهم‌ها با `gross` برابر بماند.
- **idempotency:** `existsForPayment()` باید **قبل از** هر اعتبارِ کیف پول چک شود؛ الآن داخل `settle()` است و با اضافه‌شدن مسیر منشی باید در `processAppointment` هم گارد شود.
- **فقط نوبت آنلاین:** نوبت‌های ثبت‌شده در پنل (`POST /api/v1/my/appointment` و مودال «قطعی کردن نوبت») از `PaymentManager` عبور نمی‌کنند و سهم نمی‌سازند. اگر بعداً لازم شد، مسیر جداگانه‌ای باشد نه تغییر این یکی.
- **چند منشی:** یک پزشک/کلینیک می‌تواند چند منشیِ دارای سهم داشته باشد؛ هر کدام درصد خودش را می‌گیرد (مستقل، نه تقسیم‌شده) و همه در یک `FinancialBreakdown` ثبت می‌شوند — پس `secretary_share_rials` مجموع است و `secretary_user_id` برای گزارش تک‌نفره کافی نیست. اگر بیش از یک منشی سهم‌بر است، به ازای هر منشی یک ردیف کمکی `secretary_shares` (JSON روی همان breakdown) ذخیره کن و گزارش پنل منشی را از همان بخوان؛ تصمیم را در docblock مستند کن.
- **`verified` شبا:** افزودن شبا توسط خود منشی، ولی `verified` فقط از سمت ادمین ست می‌شود (همان قاعدهٔ نماینده)؛ تسویه فقط با شبای تأییدشده.
- **الگوهای پروژه:** پاسخ‌ها با `$this->success()` / `$this->paginated()` / `$this->error()` · لیست‌ها با `getArrayResult()` · تاریخ‌ها Unix timestamp و نمایش با `formatDate()` شمسی · فرانت: TanStack Query v5، `SearchableSelect` به‌جای `<select>`، کامپوننت‌ها و توکن‌های موجود بدون طراحی تازه · رشته‌های UI فارسی.
- بعد از اتمام: `graphify update .` (پس از commit).