- 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>
147 lines
5.5 KiB
PHP
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);
|
|
}
|
|
}
|