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
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* کلید طبیعی ایمپورت IRIMC را در سطح دیتابیس یکتا می‌کند.
*
* ایندکس قبلی (source, medical_system_code) ساده بود؛ dedup فقط application-level
* بود و دو درخواست هم‌زمان می‌توانست رکورد تکراری بسازد. MariaDB چند NULL را در
* ایندکس یکتا مجاز می‌داند، پس پزشکان manual بدون کد تحت تأثیر نیستند.
*
* غیرمخرب: اگر دادهٔ تکراری وجود داشته باشد migration متوقف می‌شود (هیچ حذفی
* انجام نمی‌دهد) — تکراری‌ها باید جداگانه و دستی تعیین تکلیف شوند.
*/
final class Version20260711150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Unique index on doctors (source, medical_system_code) for concurrency-safe IRIMC import';
}
public function up(Schema $schema): void
{
$dupes = $this->connection->fetchAllAssociative(<<<'SQL'
SELECT source, medical_system_code, COUNT(*) c FROM doctors
WHERE medical_system_code IS NOT NULL AND medical_system_code <> ''
GROUP BY source, medical_system_code HAVING c > 1
SQL);
$this->abortIf(
$dupes !== [],
'Duplicate (source, medical_system_code) rows exist — resolve them manually before adding the unique index: '
. json_encode($dupes, JSON_UNESCAPED_UNICODE)
);
$this->addSql('DROP INDEX idx_doctors_source ON doctors');
$this->addSql('CREATE UNIQUE INDEX uniq_doctors_source_code ON doctors (source, medical_system_code)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX uniq_doctors_source_code ON doctors');
$this->addSql('CREATE INDEX idx_doctors_source ON doctors (source, medical_system_code)');
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* جدول ممیزی درخواست‌های تصاحب پروفایل پزشک (claim).
* دادهٔ حساس خام ذخیره نمی‌شود: کد ملی hash (sha256) و موبایل mask.
*/
final class Version20260711151000 extends AbstractMigration
{
public function getDescription(): string
{
return 'doctor_claim_requests audit table for the profile-claim flow';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE doctor_claim_requests (
id INT AUTO_INCREMENT NOT NULL,
uuid VARCHAR(36) NOT NULL,
doctor_id INT NOT NULL,
user_id INT DEFAULT NULL,
status VARCHAR(20) NOT NULL,
national_code_hash VARCHAR(64) NOT NULL,
mobile_masked VARCHAR(15) NOT NULL,
verification_method VARCHAR(40) NOT NULL,
failure_reason VARCHAR(100) DEFAULT NULL,
created_at INT NOT NULL,
completed_at INT DEFAULT NULL,
UNIQUE INDEX UNIQ_claim_uuid (uuid),
INDEX idx_claim_doctor_status (doctor_id, status),
INDEX IDX_claim_user (user_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql('ALTER TABLE doctor_claim_requests ADD CONSTRAINT FK_claim_doctor FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE doctor_claim_requests ADD CONSTRAINT FK_claim_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE doctor_claim_requests');
}
}