# سهم درآمد منشی از نوبت‌های آنلاین (فعال‌سازی + درصد از مبلغ خالص + گزارش و شبا در پنل منشی)

## زمینه

نوبتی که از سایت عمومی به‌صورت **آنلاین** رزرو و با پرداخت موفق قطعی می‌شود، همین حالا از یک موتور تقسیم مالی عبور می‌کند: `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).
