Files
clinicpro/src/Shared/Service/ApiIrService.php
T
hamedandClaude Opus 4.8 af125572c9 feat(doctor): complete IRIMC import feature — claim flow, least-privilege importer, unique import key
- Extract import logic from AdminApiController into DoctorImportService
  (thin DoctorImportController keeps the same route/contract)
- Surrogate users get marker role ROLE_UNCLAIMED_DOCTOR (+ backfill command
  app:doctors:backfill-surrogate-role) enabling safe deletion after claim
- DB-level UNIQUE (source, medical_system_code) + concurrent-import retry
- Doctor profile claim flow (climed.md): shahkar + PersonInfo identity checks
  via existing ApiIrService, Persian name normalization (PersianText),
  pessimistic-lock race protection, DoctorClaimRequest audit table
  (national code hashed, mobile masked), doctor_claim rate limiter,
  public claim-info endpoint, welcome SMS
- Admin support tools: manual transfer endpoint + paginated doctor-claims
  audit list + owner_status filter/fields in admin doctors list
- Least privilege: system owner now gets ROLE_IMPORTER (ROLE_ADMIN stripped),
  import endpoint accepts ADMIN|IMPORTER, isStaff includes IMPORTER
- Headless crawler login: X-Service-Token header bypasses captcha only
  (rate limit + password checks intact; empty env = no bypass)
- docs: doctor-claim.md (new), doctor-import.md, admin.md, doctor.md
- tests: DoctorImportTest (6), DoctorClaimTest (11), PersianTextTest (5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:39:15 +03:30

147 lines
5.5 KiB
PHP

<?php
namespace App\Shared\Service;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* کلاینت استعلام هویت api.ir (s.api.ir).
*
* دو سرویس: شاهکار (تطبیق کد ملی با موبایل) و IbanMatch (تطبیق شبا با کد ملی).
*
* نگاشت فیلدها بر اساس قرارداد متداول api.ir پیاده شده است. اگر پاسخ واقعی
* سرویس نام فیلد متفاوتی داشت، فقط همین کلاس (متدهای parse*) باید اصلاح شود.
*
* رفتار «fail-closed»: اگر توکن پیکربندی نشده باشد، استعلام انجام نمی‌شود و
* خطا برمی‌گردد — هرگز به‌صورت پیش‌فرض «تأییدشده» برنمی‌گرداند تا مسیر پولی
* (مالکیت شبا) به‌اشتباه تأیید نشود.
*/
class ApiIrService
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
private readonly string $baseUrl, // https://s.api.ir
private readonly string $token, // توکن api.ir — از .env
) {}
public function isConfigured(): bool
{
return $this->token !== '';
}
/**
* تطبیق کد ملی با موبایل (شاهکار). true یعنی هر دو متعلق به یک نفر است.
*/
public function shahkarMatch(string $nationalCode, string $mobile): bool
{
// پاسخ: {"data": true|false, "success": true, "code": 0, "message": "..."}
$data = $this->post('/api/sw1/ShahkarLite', [
'nationalCode' => $nationalCode,
'mobile' => $mobile,
]);
return $this->extractMatched($data);
}
/**
* تطبیق شبا با کد ملی و تاریخ تولد.
*
* پاسخ سرویس فقط نتیجه‌ی boolean (`data`) می‌دهد و نام بانک/صاحب حساب را برنمی‌گرداند.
*
* @param string $birthDate تاریخ تولد شمسی به فرمت Y/m/d (مثلاً 1370/01/01)
* @return array{matched: bool, bank_name: ?string, owner_name: ?string}
*/
public function ibanMatch(string $iban, string $nationalCode, string $birthDate): array
{
$data = $this->post('/api/sw1/IbanMatch', [
'iban' => $iban,
'nationalCode' => $nationalCode,
'birthDate' => $birthDate,
]);
return [
'matched' => $this->extractMatched($data),
'bank_name' => null,
'owner_name' => null,
];
}
/**
* استعلام هویت شخص از روی کد ملی و تاریخ تولد (PersonInfo).
*
* @param string $birthDateJalali تاریخ تولد شمسی به فرمت Y/m/d (مثلاً 1371/1/1)
* @return array{firstName: string, lastName: string, alive: bool}|null null یعنی رکوردی مطابقت نکرد.
*/
public function personInfo(string $nationalCode, string $birthDateJalali): ?array
{
$data = $this->post('/api/sw1/PersonInfo', [
'nationalCode' => $nationalCode,
'birthDate' => $birthDateJalali,
]);
$person = $data['data'] ?? null;
if (!is_array($person) || ($person['nationalCode'] ?? '') === '') {
return null;
}
return [
'firstName' => (string) ($person['firstName'] ?? ''),
'lastName' => (string) ($person['lastName'] ?? ''),
'alive' => filter_var($person['alive'] ?? false, FILTER_VALIDATE_BOOLEAN),
];
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
private function post(string $path, array $payload): array
{
if (!$this->isConfigured()) {
throw new AppException(ErrorCodes::ERR_EXTERNAL_NOT_CONFIGURED, null, 503);
}
try {
$response = $this->httpClient->request('POST', rtrim($this->baseUrl, '/') . $path, [
'json' => $payload,
'headers' => ['Authorization' => 'Bearer ' . $this->token],
'timeout' => 10,
]);
$status = $response->getStatusCode();
if ($status >= 500) {
throw new AppException(ErrorCodes::ERR_EXTERNAL_001, null, 502);
}
return $response->toArray(false);
} catch (AppException $e) {
throw $e;
} catch (\Throwable $e) {
$this->logger->error(sprintf('api.ir inquiry failed: %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'path' => $path]);
throw new AppException(ErrorCodes::ERR_EXTERNAL_001, null, 502);
}
}
/**
* استخراج نتیجه‌ی boolean از پاسخ api.ir.
*
* قالب پاسخ: {"data": true|false, "success": true, "code": 0, "message": "..."}
* - success=false یعنی ورودی نامعتبر/خطای درخواست → نتیجه «عدم تطبیق».
* - data همان نتیجه‌ی boolean تطبیق است.
*
* @param array<string,mixed> $data
*/
private function extractMatched(array $data): bool
{
if (($data['success'] ?? false) !== true) {
return false;
}
return filter_var($data['data'] ?? false, FILTER_VALIDATE_BOOLEAN);
}
}