Files
clinicpro/.claude/prompt/secretary-online-appointment-share.md
hamed 8d2b0d908a 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.
2026-07-25 18:34:18 +03:30

302 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# سهم درآمد منشی از نوبت‌های آنلاین (فعال‌سازی + درصد از مبلغ خالص + گزارش و شبا در پنل منشی)
## زمینه
نوبتی که از سایت عمومی به‌صورت **آنلاین** رزرو و با پرداخت موفق قطعی می‌شود، همین حالا از یک موتور تقسیم مالی عبور می‌کند: `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).