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>
This commit is contained in:
hamed
2026-07-11 11:39:15 +03:30
co-authored by Claude Opus 4.8
parent 83c872bb78
commit af125572c9
29 changed files with 1944 additions and 303 deletions
+25
View File
@@ -70,6 +70,31 @@ class ApiIrService
];
}
/**
* استعلام هویت شخص از روی کد ملی و تاریخ تولد (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>