Files
clinicpro/.claude/prompt/representation-identity-iban-verification.md
T
hamed c2c6ae4d02 feat(migrations): add national_code_verified flag to users and normalize bank_account representation
- 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.
2026-06-25 19:38:47 +03:30

17 KiB
Raw Blame History

تأیید هویت نماینده (کد ملی + شبا با استعلام 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):

#[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 را از 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: نماینده بدون کد ملی تأییدشده نتواند شبا اضافه کند؛ نماینده بدون شبای تأییدشده نتواند تسویه ثبت کند.