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:
@@ -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).
|
||||
Reference in New Issue
Block a user