- 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.
17 KiB
تأیید هویت نماینده (کد ملی + شبا با استعلام 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 و نحوهی احراز (توکن) را از همین مستند بخوان.
مشکل / هدف
- پروفایل نماینده (صفحهی جدید در پنل): ورود و تأیید کد ملی با شاهکار. بعد از تأیید، پرچم «کد ملی تأییدشده» روی کاربر ست شود.
- مدیریت شبا: نماینده ۱ تا ۲ شبا اضافه/حذف کند؛ هر شبا با IbanMatch بررسی شود متعلق به کد ملیِ تأییدشدهی خودش است. فقط شبای تأییدشده قابل استفاده در تسویه.
- تسویه: در
POST /api/v1/settlementانتخاب یکی از شباهای تأییدشده الزامی. snapshot شبا داخل خود رکورد تسویه ذخیره شود. - پنل ادمین: در لیست و جزئیات تسویه، شبای مقصد (شماره شبا + بانک + صاحب حساب) نمایش داده شود.
- دکمه «تکمیل» در
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):
#[ORM\Column(name: 'bank_account', type: 'json', nullable: true)]
private ?array $bankAccount = null; // {account_number, iban, bank_name, owner_name}
User بدون پرچم تأیید:
#[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 اختیاری و آزاد، کسر فوری در همان لحظه:
$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() — مبلغ از قبل کسر شده، فقط نهاییسازی:
// مبلغ هنگام ثبتِ درخواست از کیفپول کسر (reserve) شده؛ اینجا فقط نهاییسازی میشود.
$settlement->markPaid($receipt);
RepresentationSettlementPage.tsx — فرم فعلی فقط مبلغ میگیرد، بدون شبا:
const createMut = useMutation({
mutationFn: (amountRials: number) =>
api.post<ApiResponse<SettlementRow>>('/api/v1/settlement', { amount_rials: amountRials }),
...
});
SettlementDetailPage.tsx interface از قبل فیلدهای شبا را دارد:
interface SettlementDetail {
...
bank_iban: string | null;
bank_name: string | null;
bank_owner: string | null;
receipt: string | null;
}
منوی نماینده در Sidebar.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:
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
#[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 آرایهای از ۰ تا ۲ آیتم:
// [{ 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):
$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:
<Route path="representation-profile" element={<RoleRoute roles={['representation']}><RepresentationProfilePage /></RoleRoute>} />
- آیتم منو در
Sidebar.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را از jsonsettlement.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.irpay-per-call است → فقط هنگام تأیید/افزودن صدا زده شود، نه در هر خواندن پروفایل؛ نتیجه (verified) روی Entity ذخیره شود. - منطق کیفپول تغییر نمیکند: کسر در
request()انجام میشود؛markPaidفقط وضعیت راpaidمیکند (نه کسر مجدد). «تکمیل» = ثبت نهایی + جلوگیری از برداشت مجدد. - snapshot شبا داخل
Settlement.bank_accountبماند؛ حذف شبای نماینده نباید رکورد تسویهی قبلی را خراب کند. - edge: نماینده بدون کد ملی تأییدشده نتواند شبا اضافه کند؛ نماینده بدون شبای تأییدشده نتواند تسویه ثبت کند.