# تأیید هویت نماینده (کد ملی + شبا با استعلام s.api.ir) و انتخاب شبا در تسویه ## پروژه `clinicpro` (backend Symfony + پنل ادمین React). **کاملاً داخل همین پروژه است.** پنل نماینده همان پنل ادمین در `https://clinic-pro.ddev.site/admin` است (نقش `representation`، سورس در `assets/admin/`). ## زمینه نماینده (`ROLE_REPRESENTATION`) در پنل ادمین React (`assets/admin/`) پزشک/کلینیک اضافه می‌کند و از کمیسیون آن‌ها در کیف‌پول درآمد دارد. صفحه‌ی تسویه‌ی فعلی (`RepresentationSettlementPage.tsx`) فقط مبلغ می‌گیرد و درخواست می‌سازد؛ هیچ شبای مقصدی انتخاب نمی‌شود. هنگام واریز، ادمین نمی‌داند پول به کدام شبا برود. همچنین هیچ تأیید هویتی (کد ملی، مالکیت شبا) وجود ندارد. هدف: نماینده ابتدا کد ملی خود را با **شاهکار** (تطبیق کد ملی ↔ موبایلِ خودش) تأیید کند، سپس **یک یا دو شبا** ثبت کند که با **IbanMatch** بررسی شود متعلق به همان کد ملی است؛ در تسویه انتخاب شبا الزامی شود و در پنل ادمین شبای مقصد دیده شود. سرویس استعلام (s.api.ir): - شاهکار (کد ملی ↔ موبایل): `POST https://s.api.ir/api/sw1/ShahkarLite` - تطبیق شبا با کد ملی: `POST https://s.api.ir/api/sw1/IbanMatch` - مستندات: https://s.api.ir/scalar/v1 (زیر «لیست وب‌سرویس‌ها»). نام دقیق فیلدهای request/response و نحوه‌ی احراز (توکن) را از همین مستند بخوان. ## مشکل / هدف 1. **پروفایل نماینده** (صفحه‌ی جدید در پنل): ورود و تأیید کد ملی با شاهکار. بعد از تأیید، پرچم «کد ملی تأییدشده» روی کاربر ست شود. 2. **مدیریت شبا**: نماینده ۱ تا ۲ شبا اضافه/حذف کند؛ هر شبا با IbanMatch بررسی شود متعلق به کد ملیِ تأییدشده‌ی خودش است. فقط شبای تأییدشده قابل استفاده در تسویه. 3. **تسویه**: در `POST /api/v1/settlement` انتخاب یکی از شباهای تأییدشده **الزامی**. snapshot شبا داخل خود رکورد تسویه ذخیره شود. 4. **پنل ادمین**: در لیست و جزئیات تسویه، شبای مقصد (شماره شبا + بانک + صاحب حساب) نمایش داده شود. 5. **دکمه «تکمیل»** در `SettlementDetailPage.tsx`: فقط وقتی رسید آپلود شده و وضعیت `approved` است فعال شود؛ با زدن آن وضعیت `paid` شود. **کسر مبلغ از کیف‌پول همان لحظه‌ی ثبت درخواست انجام می‌شود (رفتار فعلی حفظ شود)**؛ «تکمیل» فقط نهایی‌سازی است و مبلغ را دوباره کسر نمی‌کند و درخواست دیگر قابل برداشت/بازگشت نیست. ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `src/Shared/Service/ApiIrService.php` | **جدید** — کلاینت `s.api.ir` (ShahkarLite + IbanMatch) | | `src/Auth/Entity/User.php` | افزودن پرچم `nationalCodeVerified` | | `src/Representation/Entity/Representation.php` | تبدیل `bankAccount` تکی به آرایه‌ی ۰..۲ شبای تأییدشده + متدهای کمکی | | `src/Representation/Controller/RepresentationActionController.php` | اکشن‌های تأیید کد ملی + افزودن/حذف شبا | | `src/Settlement/Controller/SettlementController.php` | الزام `iban_id` در `request()` | | `src/Settlement/Entity/Settlement.php` | (موجود) `bankAccount` json — snapshot شبای انتخابی | | `src/Admin/Controller/AdminApiController.php` | افزودن `bank_iban/bank_name/bank_owner` به خروجی لیست/جزئیات تسویه | | `src/Shared/Constant/ErrorCodes.php` | کدهای خطای جدید (`ERR_EXTERNAL_001`، …) | | `assets/admin/pages/RepresentationProfilePage.tsx` | **جدید** — تأیید کد ملی + مدیریت شبا | | `assets/admin/pages/RepresentationSettlementPage.tsx` | انتخاب شبای تأییدشده در فرم درخواست | | `assets/admin/pages/SettlementDetailPage.tsx` | نمایش شبای مقصد + دکمه «تکمیل» مشروط به رسید | | `assets/admin/App.tsx` | route جدید `representation-profile` (نقش `representation`) | | `assets/admin/components/layout/Sidebar.tsx` | افزودن آیتم «پروفایل» به منوی نماینده | | `config/services.yaml` + `.env` | پایه‌ی URL و توکن `s.api.ir` | | `migrations/` | migration پس از تغییر Entityها | | `docs/api/representation.md`, `docs/api/settlement.md` | به‌روزرسانی مستندات | ## وضعیت فعلی `Representation.bankAccount` تکی (json): ```php #[ORM\Column(name: 'bank_account', type: 'json', nullable: true)] private ?array $bankAccount = null; // {account_number, iban, bank_name, owner_name} ``` `User` بدون پرچم تأیید: ```php #[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true)] private ?string $nationalCode = null; public function setNationalCode(?string $code): self { $this->nationalCode = $code !== null && $code !== '' ? $code : null; $this->updatedAt = time(); return $this; } ``` `SettlementController::request()` — bank_account اختیاری و آزاد، کسر فوری در همان لحظه: ```php $bankAccount = $data['bank_account'] ?? null; // ... $balance = $this->settlementRepo->getWalletBalance($user); if ($amountRials > $balance) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'موجودی کافی نیست', 422); } $settlement = new Settlement($user, $amountRials, $bankAccount); $this->settlementRepo->save($settlement); // Reserve amount by debit transaction (کسر فوری — حفظ شود) $tx = new WalletTransaction($user, $amountRials, WalletTransaction::TYPE_DEBIT, $balance - $amountRials); ``` `SettlementController::markPaid()` — مبلغ از قبل کسر شده، فقط نهایی‌سازی: ```php // مبلغ هنگام ثبتِ درخواست از کیف‌پول کسر (reserve) شده؛ اینجا فقط نهایی‌سازی می‌شود. $settlement->markPaid($receipt); ``` `RepresentationSettlementPage.tsx` — فرم فعلی فقط مبلغ می‌گیرد، بدون شبا: ```tsx const createMut = useMutation({ mutationFn: (amountRials: number) => api.post>('/api/v1/settlement', { amount_rials: amountRials }), ... }); ``` `SettlementDetailPage.tsx` interface از قبل فیلدهای شبا را دارد: ```tsx interface SettlementDetail { ... bank_iban: string | null; bank_name: string | null; bank_owner: string | null; receipt: string | null; } ``` منوی نماینده در `Sidebar.tsx` (بدون «پروفایل»): ```tsx if (primaryRole === "representation") { return [ { label: "عمومی", items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }] }, { label: "مدیریت", items: [{ to: "/admin/doctors", ... }, { to: "/admin/clinics", ... }] }, { label: "مالی", items: [ { to: "/admin/representation-finance", icon: CreditCardIcon, label: "گزارش مالی" }, { to: "/admin/representation-settlement", icon: BanknotesIcon, label: "تسویه حساب" }, ]}, ]; } ``` ## وظایف ### ۱. سرویس استعلام `ApiIrService` `src/Shared/Service/ApiIrService.php`: ```php class ApiIrService { public function __construct( private readonly HttpClientInterface $http, private readonly string $apiIrBaseUrl, // %env(API_IR_BASE_URL)% → https://s.api.ir private readonly string $apiIrToken, // %env(API_IR_TOKEN)% ) {} /** تطبیق کد ملی با موبایل (شاهکار). true یعنی هر دو متعلق به یک نفر. */ public function shahkarMatch(string $nationalCode, string $mobile): bool { /* POST /api/sw1/ShahkarLite */ } /** تطبیق شبا با کد ملی. خروجی: ['matched'=>bool,'bank_name'=>?string,'owner_name'=>?string] */ public function ibanMatch(string $iban, string $nationalCode): array { /* POST /api/sw1/IbanMatch */ } } ``` - ساختار request/response و نام فیلدها از مستند `s.api.ir/scalar/v1`. توکن طبق مستند (هدر `Authorization`/`token`). - timeout کوتاه (~۱۰s). خطای شبکه/۵xx/توکن نامعتبر → `AppException(ErrorCodes::ERR_EXTERNAL_001, 'خطا در استعلام، بعداً تلاش کنید', 502)`. - در `config/services.yaml` آرگومان‌ها bind شوند؛ مقادیر در `.env` و `.env.local`. توکن هرگز hardcode نشود. ### ۲. پرچم تأیید کد ملی روی User ```php #[ORM\Column(name: 'national_code_verified', type: 'boolean')] private bool $nationalCodeVerified = false; public function isNationalCodeVerified(): bool { return $this->nationalCodeVerified; } public function setNationalCodeVerified(bool $v): self { $this->nationalCodeVerified = $v; $this->updatedAt = time(); return $this; } ``` اگر `setNationalCode()` مقدار **متفاوتی** نسبت به قبل گرفت، پرچم دوباره `false` شود (تغییر کد ملی تأیید را باطل کند). ### ۳. آرایه‌ی شباهای تأییدشده روی Representation `bankAccount` آرایه‌ای از ۰ تا ۲ آیتم: ```php // [{ id, iban, bank_name, owner_name, verified, created_at }] public function getIbans(): array { return $this->bankAccount ?? []; } public function addIban(array $iban): self { /* حداکثر ۲ */ } public function removeIban(string $id): self { } public function findVerifiedIban(string $id): ?array { /* آیتم با verified=true */ } ``` - `toArray()` کلید `bank_account` همان آرایه باشد. - **مهاجرت داده:** اگر مقدار قدیمیِ تکی موجود بود، در migration به آرایه‌ی تک‌عضوی `{id, iban, ..., verified:false}` تبدیل شود. در migration توضیح بده. ### ۴. اکشن‌های نماینده در `RepresentationActionController` سه route جدید (همگی زیر `#[IsGranted('ROLE_REPRESENTATION')]`، مالکیت از `#[CurrentUser]`): - `POST /api/v1/representation/verify-national-code` — body `{ national_code }`. موبایل از `user.getMobileNumber()`. → `shahkarMatch`. اگر true: `setNationalCode()` + `setNationalCodeVerified(true)`. اگر false: `ERR_VALIDATION_001` «کد ملی متعلق به این شماره موبایل نیست». - `POST /api/v1/representation/iban` — body `{ iban }`. شرط: کد ملی باید تأیید شده باشد (وگرنه `ERR_CONFLICT_001` «ابتدا کد ملی را تأیید کنید»)؛ حداکثر ۲ شبا. → `ibanMatch(iban, user.nationalCode)`. اگر matched: `addIban([... 'verified'=>true, 'bank_name', 'owner_name'])`. اگر نه: «شبا متعلق به شما نیست». - `DELETE /api/v1/representation/iban/{id}` → `removeIban`. همه با `$this->success(['data' => $rep->toArray()])`. شبا قبل از استعلام نرمال‌سازی شود (حذف فاصله، افزودن `IR`، طول ۲۶). ### ۵. الزام انتخاب شبا در تسویه در `SettlementController::request()` (قبل از ساخت Settlement): ```php $ibanId = trim($data['iban_id'] ?? ''); if ($ibanId === '') { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'انتخاب شماره شبا الزامی است', 422, 'iban_id'); } $rep = $this->representationRepo->findByUser($user); $iban = $rep?->findVerifiedIban($ibanId); if ($iban === null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره شبا نامعتبر یا تأییدنشده است', 422, 'iban_id'); } $settlement = new Settlement($user, $amountRials, [ 'iban' => $iban['iban'], 'bank_name' => $iban['bank_name'], 'owner_name' => $iban['owner_name'], ]); ``` `RepresentationRepository` به constructor تزریق شود (در حال حاضر نیست). منطق کسر فوری (debit transaction) دست‌نخورده بماند. ### ۶. صفحه‌ی پروفایل نماینده (پنل React) `assets/admin/pages/RepresentationProfilePage.tsx` بساز: - بخش «تأیید هویت»: نمایش کد ملی فعلی + وضعیت (تأییدشده/نشده). اگر تأییدنشده: input کد ملی + دکمه «تأیید» → `api.post('/api/v1/representation/verify-national-code', { national_code })`. - بخش «شماره‌های شبا»: لیست شباها (شبا، بانک، صاحب، وضعیت) + دکمه حذف؛ اگر کمتر از ۲ و کد ملی تأییدشده: فرم افزودن شبا → `api.post('/api/v1/representation/iban', { iban })`. - داده از `GET /api/v1/representation/me`. الگوی استخراج single: `data?.data` (ممکن است double-nested → `(data as any)?.data?.data ?? data?.data`). با TanStack Query و `invalidateQueries` بعد از هر mutation. - route در `App.tsx`: ```tsx } /> ``` - آیتم منو در `Sidebar.tsx` بخش نماینده (مثلاً گروه «حساب» یا کنار مالی): ```tsx { to: "/admin/representation-profile", icon: UserIcon, label: "پروفایل" }, ``` ### ۷. انتخاب شبا در فرم تسویه در `RepresentationSettlementPage.tsx`: - شباهای تأییدشده را از `GET /api/v1/representation/me` بگیر. - یک `