- Added a new column `national_code_verified` to the `users` table. - Normalized the `bank_account` field in the `representations` table from a single object to an array of IBANs with a default `verified` status of false. feat(ApiIrService): implement identity verification client for api.ir - Created `ApiIrService` to handle identity verification via api.ir. - Implemented methods for matching national code with mobile and IBAN with national code and birth date. - Added error handling and logging for external API requests.
254 lines
17 KiB
Markdown
254 lines
17 KiB
Markdown
# تأیید هویت نماینده (کد ملی + شبا با استعلام 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<ApiResponse<SettlementRow>>('/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
|
|
<Route path="representation-profile" element={<RoleRoute roles={['representation']}><RepresentationProfilePage /></RoleRoute>} />
|
|
```
|
|
|
|
- آیتم منو در `Sidebar.tsx` بخش نماینده (مثلاً گروه «حساب» یا کنار مالی):
|
|
|
|
```tsx
|
|
{ to: "/admin/representation-profile", icon: UserIcon, label: "پروفایل" },
|
|
```
|
|
|
|
### ۷. انتخاب شبا در فرم تسویه
|
|
|
|
در `RepresentationSettlementPage.tsx`:
|
|
- شباهای تأییدشده را از `GET /api/v1/representation/me` بگیر.
|
|
- یک `<select>` (یا `SearchableSelect`) برای انتخاب شبا اضافه کن؛ اگر هیچ شبای تأییدشدهای نبود، فرم غیرفعال + پیام «ابتدا در پروفایل یک شبای تأییدشده اضافه کنید» با لینک به `representation-profile`.
|
|
- `createMut` بدنهی `{ amount_rials, iban_id }` بفرستد. در ستون درخواستهای قبلی، شبای مقصد را هم نشان بده.
|
|
|
|
### ۸. پنل ادمین — نمایش شبا و دکمه «تکمیل»
|
|
|
|
- `AdminApiController`: خروجی لیست/جزئیات تسویه باید `bank_iban/bank_name/bank_owner` را از json `settlement.bank_account` بدهد (با DQL array hydration، استخراج json در PHP).
|
|
- `SettlementDetailPage.tsx`:
|
|
- ردیفهای «شماره شبا / بانک / صاحب حساب» از `bank_iban/bank_name/bank_owner`.
|
|
- دکمه «تکمیل»: فقط وقتی `status === 'approved'` **و** `receipt` موجود باشد فعال؛ → `api.post('/api/v1/settlement/{uuid}/paid', { receipt })`؛ سپس `invalidateQueries` و نمایش وضعیت `paid`. اگر رسید نیست: disable + راهنما «ابتدا رسید را آپلود کنید».
|
|
|
|
### ۹. Migration و مستندات
|
|
|
|
- `ddev exec php bin/console doctrine:migrations:diff` پس از تغییر Entityها؛ بازبینی دستی برای نرمالسازی `bank_account` قدیمی و `national_code_verified` پیشفرض `false`.
|
|
- `docs/api/representation.md`: سه endpoint جدید + ساختار آرایهای `bank_account`.
|
|
- `docs/api/settlement.md`: فیلد `iban_id` در request و الزام آن.
|
|
|
|
## نکات مهم
|
|
|
|
- همه controllerها از `BaseController`؛ پاسخها `$this->success()/$this->error()/$this->paginated()`؛ خطاهای دامنه با `AppException` + `ErrorCodes`.
|
|
- تاریخها Unix timestamp صحیح (نه DateTime).
|
|
- پنل React: JWT از `localStorage['clinicpro-auth']`؛ single response → `data?.data` (مراقب double-nested)؛ لیستهای admin → `data?.data` + `data?.meta?.totalRecords`.
|
|
- استعلام `s.api.ir` pay-per-call است → فقط هنگام تأیید/افزودن صدا زده شود، نه در هر خواندن پروفایل؛ نتیجه (`verified`) روی Entity ذخیره شود.
|
|
- منطق کیفپول تغییر نمیکند: کسر در `request()` انجام میشود؛ `markPaid` فقط وضعیت را `paid` میکند (نه کسر مجدد). «تکمیل» = ثبت نهایی + جلوگیری از برداشت مجدد.
|
|
- snapshot شبا داخل `Settlement.bank_account` بماند؛ حذف شبای نماینده نباید رکورد تسویهی قبلی را خراب کند.
|
|
- edge: نماینده بدون کد ملی تأییدشده نتواند شبا اضافه کند؛ نماینده بدون شبای تأییدشده نتواند تسویه ثبت کند.
|