feat(patient): implement record number pattern management

- Add RecordNumberSettingsController for managing patient record number patterns.
- Create RecordNumberPattern entity to represent the pattern configuration.
- Implement RecordNumberPatternRepository for database interactions.
- Develop RecordNumberGenerator service for generating and validating record numbers.
- Add tests for record number generation, backfilling, and API interactions.
- Ensure proper access control for viewing and updating patterns based on user roles.
This commit is contained in:
hamed
2026-08-04 12:30:17 +03:30
parent 9b8eef9598
commit 85a27812c7
24 changed files with 2009 additions and 19 deletions
@@ -0,0 +1,80 @@
<?php
namespace App\Tests\Patient;
use App\Auth\Entity\UserActiveContext;
use App\Clinic\Entity\Clinic;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\RecordNumberPattern;
use App\Tests\ApiTestCase;
/**
* پرونده‌های ساخته‌شده پیش از فعال‌شدن الگو، اولین بار که باز می‌شوند شماره می‌گیرند
* (`GET /api/v1/patient/{uuid}`) و از آن به بعد همان شماره را نگه می‌دارند.
*/
class RecordNumberBackfillTest extends ApiTestCase
{
/** @return array{0: \App\Auth\Entity\User, 1: Clinic, 2: PatientRecord} */
private function scenario(bool $patternEnabled): array
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$this->em->persist(new UserActiveContext($owner, $clinic->getUuid(), 'clinic'));
$this->em->flush();
$row = new RecordNumberPattern('clinic', $clinic->getId());
$row->setPattern('OLD-{SEQ:3}')->setResetPolicy(RecordNumberPattern::RESET_NONE)->setEnabled($patternEnabled);
$this->em->persist($row);
// پروندهٔ قدیمی: بدون شماره، دقیقاً مثل ردیف‌های امروزِ دیتابیس.
$patient = $this->createUser(['ROLE_USER']);
$patient->setRealName('بیمار قدیمی');
$record = new PatientRecord('clinic', $clinic->getId(), $patient, 'clinic', $clinic->getId());
$this->em->persist($record);
$this->em->flush();
self::assertNull($record->getRecordNumber());
return [$owner, $clinic, $record];
}
/** ✅ موفق + ⚠️ مرزی: بار اول شماره می‌گیرد، بار دوم همان شماره برمی‌گردد. */
public function testFirstOpenAssignsANumberAndTheSecondKeepsIt(): void
{
[$owner, , $record] = $this->scenario(true);
$uri = '/api/v1/patient/' . $record->getUuid();
$first = $this->authJson('GET', $uri, $owner);
self::assertSame(200, $this->responseCode());
self::assertSame('OLD-001', $first['data']['record_number']);
$second = $this->authJson('GET', $uri, $owner);
self::assertSame('OLD-001', $second['data']['record_number']);
}
/** ⚠️ مرزی: بدون الگوی فعال، `GET` هیچ چیزی نمی‌نویسد. */
public function testDisabledPatternLeavesTheRecordUntouched(): void
{
[$owner, , $record] = $this->scenario(false);
$res = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid(), $owner);
self::assertSame(200, $this->responseCode());
self::assertNull($res['data']['record_number']);
}
/** ⚠️ مرزی: پروندهٔ شماره‌دار دوباره شماره نمی‌گیرد و شمارنده مصرف نمی‌شود. */
public function testAlreadyNumberedRecordDoesNotConsumeTheCounter(): void
{
[$owner, $clinic, $record] = $this->scenario(true);
$record->setRecordNumber('MANUAL-7');
$this->em->flush();
$res = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid(), $owner);
self::assertSame('MANUAL-7', $res['data']['record_number']);
$pattern = $this->em->getRepository(RecordNumberPattern::class)->findForPair('clinic', $clinic->getId());
$this->em->refresh($pattern);
self::assertSame(0, $pattern->getCounter());
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
namespace App\Tests\Patient;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\RecordNumberPattern;
use App\Patient\Service\RecordNumberGenerator;
use App\Representation\Service\JalaliDateService;
use App\Tests\ApiTestCase;
/**
* تولید شمارهٔ پرونده: رندر توکن‌ها، اعتبارسنجی الگو، و شمارندهٔ تراکنشی.
*/
class RecordNumberGeneratorTest extends ApiTestCase
{
/**
* سرویس‌های دامنه در کانتینرِ تست public نیستند؛ ساختن مستقیم با همان
* وابستگی‌های تزریقی، هم قرارداد را حفظ می‌کند هم تست را از کانتینر جدا می‌کند.
*/
private function generator(): RecordNumberGenerator
{
return new RecordNumberGenerator($this->patternRepo(), $this->em, new JalaliDateService());
}
private function patternRepo(): \App\Patient\Repository\RecordNumberPatternRepository
{
return $this->em->getRepository(RecordNumberPattern::class);
}
/** @return array{0:int,1:int} [سال، ماه] شمسی امروز — تست به تاریخ اجرا وابسته نماند */
private function todayJalali(): array
{
$now = new \DateTimeImmutable('now', new \DateTimeZone(JalaliDateService::TIMEZONE));
[$jy, $jm] = (new JalaliDateService())->gregorianToJalali(
(int) $now->format('Y'),
(int) $now->format('m'),
(int) $now->format('d'),
);
return [$jy, $jm];
}
private function pattern(string $pattern, string $reset, bool $enabled = true): Doctor
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
$this->em->persist($doctor);
$this->em->flush();
$row = new RecordNumberPattern('doctor', $doctor->getId());
$row->setPattern($pattern)->setResetPolicy($reset)->setEnabled($enabled);
$this->em->persist($row);
$this->em->flush();
return $doctor;
}
public function testRendersEveryToken(): void
{
[$jy, $jm] = $this->todayJalali();
$g = $this->generator();
self::assertSame((string) $jy, $g->render('{YYYY}', 1));
self::assertSame(substr((string) $jy, -2), $g->render('{YY}', 1));
self::assertSame(str_pad((string) $jm, 2, '0', STR_PAD_LEFT), $g->render('{MM}', 1));
self::assertSame('7', $g->render('{SEQ}', 7));
self::assertSame('0007', $g->render('{SEQ:4}', 7));
self::assertSame('MD-' . substr((string) $jy, -2) . '-0012', $g->render('MD-{YY}-{SEQ:4}', 12));
}
/** ⚠️ مرزی: شمارندهٔ بلندتر از پدینگ نباید بریده شود. */
public function testCounterOverflowKeepsAllDigits(): void
{
self::assertSame('1000', $this->generator()->render('{SEQ:3}', 1000));
}
public function testPreviewDoesNotConsumeTheCounter(): void
{
$doctor = $this->pattern('P-{SEQ:3}', RecordNumberPattern::RESET_NONE);
$g = $this->generator();
self::assertSame('P-001', $g->preview('P-{SEQ:3}', 0));
// شمارنده هنوز صفر است، پس اولین شمارهٔ واقعی همان ۱ می‌ماند.
self::assertSame('P-001', $g->next('doctor', $doctor->getId()));
}
/** ✅ موفق: شماره‌ها پشت سر هم و بدون تکرار می‌آیند. */
public function testNextIncrementsSequentially(): void
{
$doctor = $this->pattern('P-{SEQ:3}', RecordNumberPattern::RESET_NONE);
$g = $this->generator();
self::assertSame('P-001', $g->next('doctor', $doctor->getId()));
self::assertSame('P-002', $g->next('doctor', $doctor->getId()));
self::assertSame('P-003', $g->next('doctor', $doctor->getId()));
}
/** ⚠️ مرزی: بدون الگو یا با الگوی خاموش، رفتار امروز حفظ می‌شود. */
public function testReturnsNullWhenPatternMissingOrDisabled(): void
{
$g = $this->generator();
$noPattern = new Doctor($this->createUser(['ROLE_DOCTOR']), 'بدون الگو');
$this->em->persist($noPattern);
$this->em->flush();
self::assertNull($g->next('doctor', $noPattern->getId()));
$disabled = $this->pattern('P-{SEQ}', RecordNumberPattern::RESET_NONE, false);
self::assertNull($g->next('doctor', $disabled->getId()));
}
/** ⚠️ مرزی: ورود به دورهٔ جدید شمارنده را صفر می‌کند. */
public function testCounterResetsWhenPeriodChanges(): void
{
$doctor = $this->pattern('{YY}-{SEQ:3}', RecordNumberPattern::RESET_YEARLY);
$g = $this->generator();
[$jy] = $this->todayJalali();
$yy = substr((string) $jy, -2);
self::assertSame("$yy-001", $g->next('doctor', $doctor->getId()));
self::assertSame("$yy-002", $g->next('doctor', $doctor->getId()));
// شبیه‌سازی سال قبل: مهرِ دوره را عقب می‌بریم، شمارنده باید صفر شود.
$row = $this->patternRepo()->findForPair('doctor', $doctor->getId());
$row->resetCounter((string) ($jy - 1));
$row->incrementCounter();
$row->incrementCounter();
$this->em->flush();
self::assertSame("$yy-001", $g->next('doctor', $doctor->getId()));
}
/** ❌ خطا: الگوهای نامعتبر. */
public function testValidatePatternRejectsBadInput(): void
{
$g = $this->generator();
self::assertNotEmpty($g->validatePattern('', RecordNumberPattern::RESET_NONE));
self::assertNotEmpty($g->validatePattern('MD-{FOO}-{SEQ}', RecordNumberPattern::RESET_NONE));
self::assertNotEmpty($g->validatePattern('MD-0001', RecordNumberPattern::RESET_NONE));
self::assertNotEmpty($g->validatePattern(str_repeat('x', 61) . '{SEQ}', RecordNumberPattern::RESET_NONE));
// {MM} بدون ریست ماهانه و {YY} بدون ریست، ترتیب را بی‌معنا می‌کنند.
self::assertNotEmpty($g->validatePattern('{MM}-{SEQ}', RecordNumberPattern::RESET_YEARLY));
self::assertNotEmpty($g->validatePattern('{YY}-{SEQ}', RecordNumberPattern::RESET_NONE));
self::assertSame([], $g->validatePattern('MD-{YY}-{SEQ:4}', RecordNumberPattern::RESET_YEARLY));
self::assertSame([], $g->validatePattern('P-{SEQ:5}', RecordNumberPattern::RESET_NONE));
self::assertSame([], $g->validatePattern('{YY}{MM}-{SEQ:3}', RecordNumberPattern::RESET_MONTHLY));
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace App\Tests\Patient;
use App\Auth\Entity\User;
use App\Auth\Entity\UserActiveContext;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\RecordNumberPattern;
use App\Secretary\Entity\DoctorSecretary;
use App\Tests\ApiTestCase;
/**
* شمارهٔ پرونده هنگام ساختِ پرونده از الگو ساخته می‌شود — و ورودِ دستی فقط از
* صاحب مجموعه پذیرفته می‌شود.
*/
class RecordNumberOnCreateTest extends ApiTestCase
{
/** @return array{0: User, 1: Clinic, 2: Doctor} */
private function clinicWithPattern(?string $pattern = 'MD-{SEQ:4}', bool $enabled = true): array
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر عضو');
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
$this->em->persist(new UserActiveContext($owner, $clinic->getUuid(), 'clinic'));
$this->em->flush();
if ($pattern !== null) {
$row = new RecordNumberPattern('clinic', $clinic->getId());
$row->setPattern($pattern)->setResetPolicy(RecordNumberPattern::RESET_NONE)->setEnabled($enabled);
$this->em->persist($row);
$this->em->flush();
}
// فیچرِ `patient_records` از پلنِ «free» می‌آید که ApiTestCase تضمینش می‌کند.
return [$owner, $clinic, $doctor];
}
private function createPatientPayload(string $suffix): array
{
return [
'mobile' => '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT),
'name' => 'بیمار ' . $suffix,
'national_code' => (string) random_int(1_000_000_000, 9_999_999_999),
];
}
/** ✅ موفق: بدون فرستادن شماره، سرور از الگو می‌سازد و دنباله جلو می‌رود. */
public function testCreateGeneratesSequentialNumbers(): void
{
[$owner] = $this->clinicWithPattern();
$first = $this->authJson('POST', '/api/v1/patient', $owner, $this->createPatientPayload('الف'));
self::assertSame(201, $this->responseCode());
self::assertSame('MD-0001', $first['data']['record_number']);
$second = $this->authJson('POST', '/api/v1/patient', $owner, $this->createPatientPayload('ب'));
self::assertSame('MD-0002', $second['data']['record_number']);
}
/** ⚠️ مرزی: بدون الگوی فعال، رفتار امروز حفظ می‌شود (شمارهٔ دستی، برای همه). */
public function testWithoutPatternTheManualNumberStillWins(): void
{
[$owner] = $this->clinicWithPattern(null);
$res = $this->authJson('POST', '/api/v1/patient', $owner, [
...$this->createPatientPayload('ج'),
'record_number' => 'کاغذی-77',
]);
self::assertSame(201, $this->responseCode());
self::assertSame('کاغذی-77', $res['data']['record_number']);
}
/** ✅ موفق: صاحب مجموعه می‌تواند خارج از دنباله شماره بگذارد. */
public function testOwnerMayOverrideTheGeneratedNumber(): void
{
[$owner] = $this->clinicWithPattern();
$res = $this->authJson('POST', '/api/v1/patient', $owner, [
...$this->createPatientPayload('د'),
'record_number' => 'ARCHIVE-1',
]);
self::assertSame(201, $this->responseCode());
self::assertSame('ARCHIVE-1', $res['data']['record_number']);
}
/** ❌ خطا: منشی حق ورود دستی ندارد — نه در ساخت، نه در ویرایش. */
public function testSecretaryCannotSetTheNumberManually(): void
{
[, $clinic, $doctor] = $this->clinicWithPattern();
$secretary = $this->createUser(['ROLE_SECRETARY']);
$rel = new DoctorSecretary($doctor, $secretary, $clinic);
$rel->mergePermissions(['resources' => ['patients' => ['view' => true, 'create' => true, 'update' => true]]]);
$this->em->persist($rel);
$this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic'));
$this->em->flush();
$this->authJson('POST', '/api/v1/patient', $secretary, [
...$this->createPatientPayload('ه'),
'record_number' => 'HAND-9',
]);
self::assertSame(403, $this->responseCode());
// بدون شمارهٔ دستی همان منشی می‌تواند پرونده بسازد و شماره از الگو می‌آید.
$ok = $this->authJson('POST', '/api/v1/patient', $secretary, $this->createPatientPayload('و'));
self::assertSame(201, $this->responseCode());
self::assertSame('MD-0001', $ok['data']['record_number']);
}
/** ⚠️ مرزی: پروندهٔ خودکارِ نوبت هم در همان دنباله می‌نشیند. */
public function testAutoCreatedRecordFromAppointmentGetsANumber(): void
{
[, $clinic, $doctor] = $this->clinicWithPattern();
$patient = $this->createUser(['ROLE_USER']);
$patient->setRealName('بیمار نوبت');
$this->em->flush();
$appointment = new \App\Appointment\Entity\Appointment($doctor, $patient, time() + 3_600, time() + 5_400);
$appointment->setClinic($clinic);
$appointment->assignTenant(\App\Shared\Context\EntityContext::forBooking($doctor, $clinic));
$this->em->persist($appointment);
$this->em->flush();
$service = static::getContainer()->get(\App\Patient\Service\PatientService::class);
$session = $service->autoCreateOnAppointmentConfirm($appointment);
self::assertNotNull($session);
self::assertSame('MD-0001', $session->getRecord()->getRecordNumber());
}
}
@@ -0,0 +1,152 @@
<?php
namespace App\Tests\Patient;
use App\Auth\Entity\User;
use App\Auth\Entity\UserActiveContext;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Secretary\Entity\DoctorSecretary;
use App\Tests\ApiTestCase;
/**
* GET/PUT /api/v1/patient-record-number-settings — الگوی شمارهٔ پروندهٔ محیط جاری.
* خواندن برای هر کسی که بیماران را می‌بیند؛ نوشتن فقط برای صاحب محیط.
*/
class RecordNumberSettingsApiTest extends ApiTestCase
{
private const URI = '/api/v1/patient-record-number-settings';
/** @return array{0: User, 1: Clinic, 2: Doctor} مالک کلینیک + کلینیک + پزشکِ عضو */
private function clinic(): array
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر عضو');
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
$this->em->persist(new UserActiveContext($owner, $clinic->getUuid(), 'clinic'));
$this->em->flush();
return [$owner, $clinic, $doctor];
}
public function testOwnerReadsDefaultsBeforeAnythingIsSaved(): void
{
[$owner] = $this->clinic();
$res = $this->authJson('GET', self::URI, $owner);
self::assertSame(200, $this->responseCode());
self::assertFalse($res['data']['enabled']);
self::assertSame('none', $res['data']['reset_policy']);
self::assertSame(0, $res['data']['counter']);
self::assertTrue($res['data']['can_edit']);
}
/** ✅ موفق: ذخیرهٔ الگو و پیش‌نمایش شمارهٔ بعدی. */
public function testOwnerSavesPatternAndGetsPreview(): void
{
[$owner] = $this->clinic();
$res = $this->authJson('PUT', self::URI, $owner, [
'enabled' => true,
'pattern' => 'MD-{SEQ:4}',
'reset_policy' => 'none',
]);
self::assertSame(200, $this->responseCode());
self::assertTrue($res['data']['enabled']);
self::assertSame('MD-{SEQ:4}', $res['data']['pattern']);
self::assertSame('MD-0001', $res['data']['next_preview']);
// GET بعدی همان مقدارِ ذخیره‌شده را می‌دهد (نه پیش‌فرض).
$read = $this->authJson('GET', self::URI, $owner);
self::assertSame('MD-{SEQ:4}', $read['data']['pattern']);
}
/** ❌ خطا: منشی نه می‌نویسد، نه `can_edit` می‌گیرد. */
public function testSecretaryCannotWriteButCanRead(): void
{
[, $clinic, $doctor] = $this->clinic();
$secretary = $this->createUser(['ROLE_SECRETARY']);
$this->em->persist(new DoctorSecretary($doctor, $secretary, $clinic));
$this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic'));
$this->em->flush();
$read = $this->authJson('GET', self::URI, $secretary);
self::assertSame(200, $this->responseCode());
self::assertFalse($read['data']['can_edit']);
$this->authJson('PUT', self::URI, $secretary, [
'enabled' => true, 'pattern' => 'X-{SEQ}', 'reset_policy' => 'none',
]);
self::assertSame(403, $this->responseCode());
}
/** ❌ خطا: پزشکِ مهمانِ کلینیک هم صاحب آن محیط نیست. */
public function testGuestDoctorCannotChangeTheClinicPattern(): void
{
[, $clinic, $doctor] = $this->clinic();
$this->em->persist(new UserActiveContext($doctor->getUser(), $clinic->getUuid(), 'clinic'));
$this->em->flush();
$this->authJson('PUT', self::URI, $doctor->getUser(), [
'enabled' => true, 'pattern' => 'X-{SEQ}', 'reset_policy' => 'none',
]);
self::assertSame(403, $this->responseCode());
}
/** ❌ خطا: الگوهای نامعتبر همگی ۴۲۲ با فیلد درست. */
public function testInvalidPatternsAreRejected(): void
{
[$owner] = $this->clinic();
foreach ([
['MD-{FOO}-{SEQ}', 'none'], // توکن ناشناخته
['MD-0001', 'none'], // بدون {SEQ}
['', 'none'], // خالی
['{YY}-{SEQ}', 'none'], // سال بدون ریست
['{MM}-{SEQ}', 'yearly'], // ماه بدون ریست ماهانه
] as [$pattern, $reset]) {
$res = $this->authJson('PUT', self::URI, $owner, [
'enabled' => true, 'pattern' => $pattern, 'reset_policy' => $reset,
]);
self::assertSame(422, $this->responseCode(), "pattern: $pattern");
self::assertSame('pattern', $res['errors'][0]['field'] ?? null);
}
// سیاست ریستِ ناشناخته هم رد می‌شود، ولی فیلدش reset_policy است.
$res = $this->authJson('PUT', self::URI, $owner, [
'enabled' => true, 'pattern' => 'X-{SEQ}', 'reset_policy' => 'weekly',
]);
self::assertSame(422, $this->responseCode());
self::assertSame('reset_policy', $res['errors'][0]['field'] ?? null);
}
/** ⚠️ مرزی: تغییر الگو شمارنده را صفر نمی‌کند — شماره‌های صادرشده وجود دارند. */
public function testChangingThePatternKeepsTheCounter(): void
{
[$owner, $clinic] = $this->clinic();
$this->authJson('PUT', self::URI, $owner, [
'enabled' => true, 'pattern' => 'A-{SEQ:3}', 'reset_policy' => 'none',
]);
$repo = $this->em->getRepository(\App\Patient\Entity\RecordNumberPattern::class);
$row = $repo->findForPair('clinic', $clinic->getId());
$row->incrementCounter();
$row->incrementCounter();
$this->em->flush();
$res = $this->authJson('PUT', self::URI, $owner, [
'enabled' => true, 'pattern' => 'B-{SEQ:3}', 'reset_policy' => 'none',
]);
self::assertSame(2, $res['data']['counter']);
self::assertSame('B-003', $res['data']['next_preview']);
}
}