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:
@@ -57,6 +57,9 @@ class PatientController extends BaseController
|
||||
private readonly \App\Patient\Repository\SessionAuditLogRepository $sessionAuditRepo,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
private readonly \App\Patient\Service\RecordNumberGenerator $recordNumbers,
|
||||
private readonly \App\Patient\Repository\RecordNumberPatternRepository $recordNumberPatterns,
|
||||
private readonly \App\Shared\Context\EntityContextResolver $contextResolver,
|
||||
) {}
|
||||
|
||||
// ── Financials (مالی: پرداخت / تراکنش / کیفپول) ────────────────────────────
|
||||
@@ -783,9 +786,17 @@ class PatientController extends BaseController
|
||||
|
||||
$record = new PatientRecord($entityType, $entityId, $patient, $user->hasRole('ROLE_DOCTOR') ? 'doctor' : 'clinic', $entityId);
|
||||
|
||||
if (($rn = trim((string) ($data['record_number'] ?? ''))) !== '') {
|
||||
$record->setRecordNumber($rn);
|
||||
// شمارهٔ دستی فقط از صاحب مجموعه پذیرفته میشود؛ بقیه شمارهٔ الگو را میگیرند.
|
||||
// در نبودِ الگوی فعال، `next()` مقدار null میدهد و رفتار قدیمی (ورود دستی
|
||||
// برای همه) دستنخورده میماند.
|
||||
$manualNumber = trim((string) ($data['record_number'] ?? ''));
|
||||
if ($manualNumber !== '' && !$this->canSetRecordNumberManually($user, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'ثبت دستی شماره پرونده مجاز نیست؛ شماره از الگوی مجموعه ساخته میشود', 403, 'record_number');
|
||||
}
|
||||
|
||||
$record->setRecordNumber(
|
||||
$manualNumber !== '' ? $manualNumber : $this->recordNumbers->next($entityType, $entityId),
|
||||
);
|
||||
$tagError = $this->applyRecordTags($record, $data, $entityType, $entityId);
|
||||
if ($tagError !== null) {
|
||||
return $tagError;
|
||||
@@ -807,12 +818,39 @@ class PatientController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$this->backfillRecordNumber($record, $entityType, $entityId);
|
||||
|
||||
$data = $record->toArray();
|
||||
$data['profile'] = $this->buildPatientProfile($record->getUser());
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* پروندههای ساختهشده پیش از الگو، اولین بار که باز میشوند شماره میگیرند.
|
||||
*
|
||||
* اثر جانبیِ عمدی روی یک `GET` است: جایگزینش یا backfill دستهای بود (که ترتیب
|
||||
* شمارهها را به ترتیب `id` گره میزد) یا رها کردن پروندههای قدیمی بیشماره.
|
||||
* بار دوم چون شماره پر است هیچ نوشتنی رخ نمیدهد، و بدون الگوی فعال `next()`
|
||||
* مقدار `null` میدهد و این متد بیاثر است.
|
||||
*
|
||||
* دو `GET` همزمان روی یک پروندهٔ بیشماره دو شماره میگیرند و آخری برنده است؛
|
||||
* یعنی نهایتاً یک شماره هدر میرود. قفلکردنِ خودِ رکورد برای همین، هزینهاش از
|
||||
* فایدهاش بیشتر بود.
|
||||
*/
|
||||
private function backfillRecordNumber(PatientRecord $record, string $entityType, ?int $entityId): void
|
||||
{
|
||||
if ($record->getRecordNumber() !== null || $entityId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$number = $this->recordNumbers->next($entityType, $entityId);
|
||||
if ($number !== null) {
|
||||
$record->setRecordNumber($number);
|
||||
$this->recordRepo->save($record);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/api/v1/patient/{uuid}', methods: ['PATCH'])]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
@@ -933,6 +971,9 @@ class PatientController extends BaseController
|
||||
$this->profileRepo->save($profile);
|
||||
|
||||
if (array_key_exists('record_number', $data)) {
|
||||
if (!$this->canSetRecordNumberManually($user, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'تغییر دستی شماره پرونده مجاز نیست؛ شماره از الگوی مجموعه ساخته میشود', 403, 'record_number');
|
||||
}
|
||||
$rn = trim((string) ($data['record_number'] ?? ''));
|
||||
$record->setRecordNumber($rn === '' ? null : $rn);
|
||||
}
|
||||
@@ -1252,6 +1293,23 @@ class PatientController extends BaseController
|
||||
return $this->scope($user)->toLegacyTuple();
|
||||
}
|
||||
|
||||
/**
|
||||
* ورود دستیِ شمارهٔ پرونده.
|
||||
*
|
||||
* تا وقتی الگویی فعال نشده، همهچیز مثل قبل است و شماره دستی وارد میشود. با فعال
|
||||
* شدن الگو، شماره قراردادِ کلِ مجموعه میشود و فقط صاحبش میتواند خارج از دنباله
|
||||
* شماره بگذارد (مثلاً پروندهٔ کاغذیِ قدیمی).
|
||||
*/
|
||||
private function canSetRecordNumberManually(User $user, string $entityType, ?int $entityId): bool
|
||||
{
|
||||
$pattern = $this->recordNumberPatterns->findForPair($entityType, (int) $entityId);
|
||||
if ($pattern === null || !$pattern->isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->contextResolver->owns($user, $entityType, $entityId);
|
||||
}
|
||||
|
||||
private function assertPatientGate(string $entityType, ?int $entityId): void
|
||||
{
|
||||
if ($entityId === null) {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Security\ClinicDoctorAccessChecker;
|
||||
use App\Patient\Entity\RecordNumberPattern;
|
||||
use App\Patient\Repository\RecordNumberPatternRepository;
|
||||
use App\Patient\Service\RecordNumberGenerator;
|
||||
use App\Secretary\Security\SecretaryAccessChecker;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* الگوی شمارهٔ پروندهٔ محیط جاری.
|
||||
*
|
||||
* خواندن برای هر کسی که بیماران را میبیند (فرم پرونده باید بداند شماره خودکار
|
||||
* میآید یا نه)، ولی نوشتن فقط برای **صاحب محیط**: شمارهٔ پرونده قراردادِ ثبتِ کل
|
||||
* مجموعه است و منشی نباید وسط کار دنباله را عوض کند.
|
||||
*/
|
||||
#[OA\Tag(name: 'Patient')]
|
||||
class RecordNumberSettingsController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RecordNumberPatternRepository $patternRepo,
|
||||
private readonly RecordNumberGenerator $generator,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
) {}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/patient-record-number-settings',
|
||||
summary: 'الگوی شمارهٔ پروندهٔ محیط جاری',
|
||||
security: [['bearerAuth' => []]],
|
||||
responses: [new OA\Response(response: 200, description: 'تنظیمات الگو')],
|
||||
)]
|
||||
#[Route('/api/v1/patient-record-number-settings', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function show(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'view');
|
||||
|
||||
[$entityType, $entityId] = $this->requireEnvironment($user);
|
||||
$pattern = $this->patternRepo->findForPair($entityType, $entityId);
|
||||
|
||||
return $this->success($this->present($pattern, $user, $entityType, $entityId));
|
||||
}
|
||||
|
||||
#[OA\Put(
|
||||
path: '/api/v1/patient-record-number-settings',
|
||||
summary: 'ثبت الگوی شمارهٔ پرونده — فقط صاحب محیط',
|
||||
security: [['bearerAuth' => []]],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'ذخیره شد'),
|
||||
new OA\Response(response: 403, description: 'فقط صاحب محیط'),
|
||||
new OA\Response(response: 422, description: 'الگوی نامعتبر'),
|
||||
],
|
||||
)]
|
||||
#[Route('/api/v1/patient-record-number-settings', methods: ['PUT'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function update(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->requireEnvironment($user);
|
||||
|
||||
if (!$this->contextResolver->owns($user, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'تغییر الگوی شماره پرونده فقط با حساب صاحب مجموعه ممکن است', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true);
|
||||
if (!is_array($data)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||||
}
|
||||
|
||||
$resetPolicy = (string) ($data['reset_policy'] ?? RecordNumberPattern::RESET_NONE);
|
||||
if (!in_array($resetPolicy, RecordNumberPattern::RESET_POLICIES, true)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سیاست ریست شمارنده نامعتبر است', 422, 'reset_policy');
|
||||
}
|
||||
|
||||
$patternText = trim((string) ($data['pattern'] ?? ''));
|
||||
$enabled = (bool) ($data['enabled'] ?? false);
|
||||
|
||||
// الگوی خاموش هم اعتبارسنجی میشود: ذخیرهٔ الگوی خرابِ خاموش یعنی روزی که
|
||||
// کاربر روشنش میکند، خطا سرِ ساختِ پرونده بیرون میزند نه سرِ فرم تنظیمات.
|
||||
$errors = $this->generator->validatePattern($patternText, $resetPolicy);
|
||||
if ($errors !== []) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, implode(' · ', $errors), 422, 'pattern');
|
||||
}
|
||||
|
||||
$pattern = $this->patternRepo->findForPair($entityType, $entityId)
|
||||
?? new RecordNumberPattern($entityType, $entityId);
|
||||
|
||||
// تغییر الگو شمارنده را صفر نمیکند: شمارههای صادرشده وجود دارند و شروع دوبارهٔ
|
||||
// دنباله، مستقیم به تداخل با آنها میخورد.
|
||||
$pattern->setPattern($patternText)->setResetPolicy($resetPolicy)->setEnabled($enabled);
|
||||
$this->patternRepo->save($pattern);
|
||||
|
||||
return $this->success($this->present($pattern, $user, $entityType, $entityId));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function present(?RecordNumberPattern $pattern, User $user, string $entityType, int $entityId): array
|
||||
{
|
||||
$patternText = $pattern?->getPattern() ?? RecordNumberPattern::DEFAULT_PATTERN;
|
||||
$counter = $pattern?->getCounter() ?? 0;
|
||||
|
||||
return [
|
||||
'enabled' => $pattern?->isEnabled() ?? false,
|
||||
'pattern' => $patternText,
|
||||
'reset_policy' => $pattern?->getResetPolicy() ?? RecordNumberPattern::RESET_NONE,
|
||||
'counter' => $counter,
|
||||
'next_preview' => $this->generator->preview($patternText, $counter),
|
||||
'can_edit' => $this->contextResolver->owns($user, $entityType, $entityId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: int}
|
||||
* @throws \App\Shared\Exception\AppException وقتی محیط حل نشده (۴۰۳)
|
||||
*/
|
||||
private function requireEnvironment(User $user): array
|
||||
{
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isResolved()) {
|
||||
throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
|
||||
}
|
||||
|
||||
[$type, $id] = $context->toEntityPair();
|
||||
|
||||
return [$type, (int) $id];
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ use Symfony\Component\Uid\Uuid;
|
||||
#[ORM\Entity(repositoryClass: PatientRecordRepository::class)]
|
||||
#[ORM\Table(name: 'patient_records')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_patient_record', columns: ['entity_type', 'entity_id', 'user_id'])]
|
||||
// شمارهٔ پرونده در هر محیط یکتاست. این تور ایمنیِ شمارندهٔ RecordNumberPattern است،
|
||||
// نه مکانیزم اصلی؛ چند `NULL` را MariaDB میپذیرد، پس پروندههای بیشماره مانع نیستند.
|
||||
#[ORM\UniqueConstraint(name: 'uniq_patient_record_number', columns: ['entity_type', 'entity_id', 'record_number'])]
|
||||
class PatientRecord
|
||||
{
|
||||
#[ORM\Id]
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Patient\Repository\RecordNumberPatternRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* الگوی شمارهٔ پروندهٔ یک محیط — یک ردیف به ازای هر مطب/کلینیک.
|
||||
*
|
||||
* شمارنده روی همین ردیف مینشیند، نه `MAX(record_number)+1`: شمارش از روی مقادیر
|
||||
* موجود با شمارهٔ دستیِ صاحب حساب یا با تغییر الگو میشکند.
|
||||
*
|
||||
* `counterPeriod` مهرِ دورهٔ فعلیِ شمارنده است (`1405` سالانه، `1405-05` ماهانه،
|
||||
* `null` بدون ریست). بدون این ستون، «آیا سال عوض شده؟» فقط با حدس از `updatedAt`
|
||||
* قابل جواب بود.
|
||||
*
|
||||
* جفت محیط را مثل {@see \App\Insurance\Entity\TenantServiceCategorySetting} خودش
|
||||
* اعلام میکند و `TenantOwnedTrait` نمیگیرد: آن trait برای `assignTenant()` یک
|
||||
* `EntityContext` میخواهد، ولی تولیدکنندهٔ شماره فقط جفت `(type, id)` را دارد.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: RecordNumberPatternRepository::class)]
|
||||
#[ORM\Table(name: 'record_number_patterns')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_record_number_pattern', columns: ['entity_type', 'entity_id'])]
|
||||
class RecordNumberPattern
|
||||
{
|
||||
public const RESET_NONE = 'none';
|
||||
public const RESET_YEARLY = 'yearly';
|
||||
public const RESET_MONTHLY = 'monthly';
|
||||
|
||||
public const RESET_POLICIES = [self::RESET_NONE, self::RESET_YEARLY, self::RESET_MONTHLY];
|
||||
|
||||
/** الگوی پیشفرضِ ردیفِ تازهساخته — تا فرمِ تنظیمات با فیلد خالی باز نشود. */
|
||||
public const DEFAULT_PATTERN = '{YY}-{SEQ:4}';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
private bool $enabled = false;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 60)]
|
||||
private string $pattern = self::DEFAULT_PATTERN;
|
||||
|
||||
#[ORM\Column(name: 'reset_policy', type: 'string', length: 10, options: ['default' => self::RESET_NONE])]
|
||||
private string $resetPolicy = self::RESET_NONE;
|
||||
|
||||
#[ORM\Column(type: 'integer', options: ['default' => 0])]
|
||||
private int $counter = 0;
|
||||
|
||||
#[ORM\Column(name: 'counter_period', type: 'string', length: 7, nullable: true)]
|
||||
private ?string $counterPeriod = null;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $entityType, int $entityId)
|
||||
{
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
public function isEnabled(): bool { return $this->enabled; }
|
||||
public function getPattern(): string { return $this->pattern; }
|
||||
public function getResetPolicy(): string { return $this->resetPolicy; }
|
||||
public function getCounter(): int { return $this->counter; }
|
||||
public function getCounterPeriod(): ?string { return $this->counterPeriod; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setEnabled(bool $v): self { $this->enabled = $v; $this->touch(); return $this; }
|
||||
public function setPattern(string $v): self { $this->pattern = $v; $this->touch(); return $this; }
|
||||
|
||||
public function setResetPolicy(string $v): self
|
||||
{
|
||||
if (!in_array($v, self::RESET_POLICIES, true)) {
|
||||
throw new \InvalidArgumentException(sprintf('Unknown reset policy "%s".', $v));
|
||||
}
|
||||
$this->resetPolicy = $v;
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** ورود به دورهٔ تازه: شمارنده از صفر شروع میشود. */
|
||||
public function resetCounter(?string $period): self
|
||||
{
|
||||
$this->counter = 0;
|
||||
$this->counterPeriod = $period;
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function incrementCounter(): int
|
||||
{
|
||||
$this->counter++;
|
||||
$this->touch();
|
||||
|
||||
return $this->counter;
|
||||
}
|
||||
|
||||
private function touch(): void
|
||||
{
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Patient\Entity\RecordNumberPattern;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\LockMode;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<RecordNumberPattern>
|
||||
*/
|
||||
class RecordNumberPatternRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, RecordNumberPattern::class);
|
||||
}
|
||||
|
||||
public function findForPair(string $entityType, int $entityId): ?RecordNumberPattern
|
||||
{
|
||||
return $this->findOneBy(['entityType' => $entityType, 'entityId' => $entityId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* همان ردیف، ولی با قفلِ نوشتن (`SELECT … FOR UPDATE`).
|
||||
*
|
||||
* شمارندهٔ بدون قفل، دو درخواستِ همزمان را به یک شماره میرساند: هر دو مقدار
|
||||
* قدیمی را میخوانند و همان را +۱ میکنند. فقط داخل تراکنش صدا زده میشود.
|
||||
*/
|
||||
public function lockForUpdate(string $entityType, int $entityId): ?RecordNumberPattern
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->where('p.entityType = :type')
|
||||
->andWhere('p.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->getQuery()
|
||||
->setLockMode(LockMode::PESSIMISTIC_WRITE)
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function save(RecordNumberPattern $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ use App\Patient\Repository\PatientSessionRepository;
|
||||
use App\Patient\Repository\SessionConsumableRepository;
|
||||
use App\Patient\Repository\SessionPaymentRepository;
|
||||
use App\Patient\Repository\SessionServiceRepository;
|
||||
use App\Patient\Service\RecordNumberGenerator;
|
||||
use App\Settlement\Service\WalletService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
@@ -58,6 +59,7 @@ class PatientService
|
||||
private readonly \App\Discount\Service\DiscountEngine $discountEngine,
|
||||
private readonly \App\Patient\Repository\SessionAuditLogRepository $auditRepo,
|
||||
private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership,
|
||||
private readonly RecordNumberGenerator $recordNumbers,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
@@ -209,6 +211,9 @@ class PatientService
|
||||
$record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
|
||||
if ($record === null) {
|
||||
$record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById);
|
||||
// پروندهٔ خودکار هم باید در همان دنبالهٔ شمارهها بنشیند؛ بیشترِ پروندهها
|
||||
// از همین مسیر ساخته میشوند، نه از فرم پنل.
|
||||
$record->setRecordNumber($this->recordNumbers->next($entityType, $entityId));
|
||||
$this->recordRepo->save($record);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Service;
|
||||
|
||||
use App\Patient\Entity\RecordNumberPattern;
|
||||
use App\Patient\Repository\RecordNumberPatternRepository;
|
||||
use App\Representation\Service\JalaliDateService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* تنها جایی که شمارهٔ پرونده ساخته میشود.
|
||||
*
|
||||
* سه مسئولیت جدا دارد: رندرِ خالصِ الگو، گرفتنِ شمارهٔ بعدی (تراکنشی)، و پیشنمایش
|
||||
* بدون مصرفِ شمارنده. هر سه مسیرِ ساختِ پرونده — فرم پنل، ساخت خودکار از نوبت، و
|
||||
* backfillِ پروندهٔ قدیمی — از همینجا شماره میگیرند تا منطق سهجا کپی نشود.
|
||||
*/
|
||||
class RecordNumberGenerator
|
||||
{
|
||||
/** توکنهای مجاز؛ هر چیز دیگری در اعتبارسنجی الگو رد میشود. */
|
||||
private const TOKEN_RE = '/\{(YYYY|YY|MM|SEQ(?::(\d+))?)\}/';
|
||||
|
||||
public function __construct(
|
||||
private readonly RecordNumberPatternRepository $repo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly JalaliDateService $jalali,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* شمارهٔ بعدیِ همین محیط، یا `null` وقتی الگویی تعریف/فعال نشده — یعنی «رفتار
|
||||
* امروز»: شماره دستی میماند.
|
||||
*
|
||||
* کل کار داخل یک تراکنش با قفلِ ردیفِ الگوست: خواندن-افزایش-نوشتنِ بدون قفل، دو
|
||||
* درخواستِ همزمان را به یک شماره میرساند.
|
||||
*/
|
||||
public function next(string $entityType, int $entityId): ?string
|
||||
{
|
||||
return $this->em->wrapInTransaction(function () use ($entityType, $entityId): ?string {
|
||||
$pattern = $this->repo->lockForUpdate($entityType, $entityId);
|
||||
if ($pattern === null || !$pattern->isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$period = $this->periodKey($pattern->getResetPolicy());
|
||||
if ($pattern->getCounterPeriod() !== $period) {
|
||||
$pattern->resetCounter($period);
|
||||
}
|
||||
|
||||
return $this->render($pattern->getPattern(), $pattern->incrementCounter());
|
||||
});
|
||||
}
|
||||
|
||||
/** شمارهٔ بعدی بدون مصرفکردنش — برای فرمِ تنظیمات. */
|
||||
public function preview(string $pattern, int $currentCounter = 0): string
|
||||
{
|
||||
return $this->render($pattern, $currentCounter + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* جایگذاری توکنها. تاریخ شمسی از {@see JalaliDateService} میآید، نه از محاسبهٔ
|
||||
* دستی — همان فایل توضیح میدهد نسخهٔ دستسازِ قبلی ۱۶۰۱ سال خطا داشت.
|
||||
*/
|
||||
public function render(string $pattern, int $counter): string
|
||||
{
|
||||
[$jy, $jm] = $this->today();
|
||||
|
||||
return preg_replace_callback(
|
||||
self::TOKEN_RE,
|
||||
static function (array $m) use ($jy, $jm, $counter): string {
|
||||
$token = $m[1];
|
||||
|
||||
if ($token === 'YYYY') return (string) $jy;
|
||||
if ($token === 'YY') return substr((string) $jy, -2);
|
||||
if ($token === 'MM') return str_pad((string) $jm, 2, '0', STR_PAD_LEFT);
|
||||
|
||||
// {SEQ} یا {SEQ:n} — پدینگ سقف نیست: شمارندهٔ بلندتر از n بریده نمیشود.
|
||||
$width = isset($m[2]) && $m[2] !== '' ? (int) $m[2] : 1;
|
||||
|
||||
return str_pad((string) $counter, $width, '0', STR_PAD_LEFT);
|
||||
},
|
||||
$pattern,
|
||||
) ?? $pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* خطاهای الگو بهصورت فهرست، تا فرم همه را یکجا نشان دهد.
|
||||
*
|
||||
* @return list<string> خالی یعنی معتبر
|
||||
*/
|
||||
public function validatePattern(string $pattern, string $resetPolicy): array
|
||||
{
|
||||
$errors = [];
|
||||
$trimmed = trim($pattern);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return ['الگوی شماره پرونده نمیتواند خالی باشد'];
|
||||
}
|
||||
if (mb_strlen($trimmed) > 60) {
|
||||
$errors[] = 'الگو نمیتواند بیش از ۶۰ نویسه باشد';
|
||||
}
|
||||
|
||||
// هر `{...}`ی که با توکنهای مجاز جور نباشد، یعنی توکن ناشناخته.
|
||||
$unknown = preg_replace(self::TOKEN_RE, '', $trimmed);
|
||||
if (preg_match('/\{[^}]*\}?/', (string) $unknown, $m) === 1) {
|
||||
$errors[] = sprintf('توکن ناشناخته: %s — مجاز: {YYYY}، {YY}، {MM}، {SEQ} یا {SEQ:n}', $m[0]);
|
||||
}
|
||||
|
||||
if (!str_contains($trimmed, '{SEQ')) {
|
||||
$errors[] = 'الگو باید {SEQ} یا {SEQ:n} داشته باشد، وگرنه همهٔ پروندهها یک شماره میگیرند';
|
||||
}
|
||||
|
||||
// {MM} بدون ریست ماهانه: پیشوندِ ماه عوض میشود ولی شمارنده ادامه پیدا میکند —
|
||||
// نتیجه ظاهراً منظم است و در واقع ترتیبِ ماه را نمیرساند.
|
||||
if (str_contains($trimmed, '{MM}') && $resetPolicy !== RecordNumberPattern::RESET_MONTHLY) {
|
||||
$errors[] = 'وقتی {MM} در الگو هست، ریست شمارنده باید ماهانه باشد';
|
||||
}
|
||||
if (str_contains($trimmed, '{YY}') || str_contains($trimmed, '{YYYY}')) {
|
||||
if ($resetPolicy === RecordNumberPattern::RESET_NONE) {
|
||||
$errors[] = 'وقتی سال در الگو هست، ریست شمارنده باید سالانه یا ماهانه باشد';
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/** مهرِ دورهٔ فعلی: `1405` سالانه، `1405-05` ماهانه، `null` بدون ریست. */
|
||||
private function periodKey(string $resetPolicy): ?string
|
||||
{
|
||||
[$jy, $jm] = $this->today();
|
||||
|
||||
return match ($resetPolicy) {
|
||||
RecordNumberPattern::RESET_YEARLY => (string) $jy,
|
||||
RecordNumberPattern::RESET_MONTHLY => sprintf('%04d-%02d', $jy, $jm),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array{0:int,1:int} [سال، ماه] شمسیِ امروز به وقت تهران */
|
||||
private function today(): array
|
||||
{
|
||||
$now = new \DateTimeImmutable('now', new \DateTimeZone(JalaliDateService::TIMEZONE));
|
||||
[$jy, $jm] = $this->jalali->gregorianToJalali(
|
||||
(int) $now->format('Y'),
|
||||
(int) $now->format('m'),
|
||||
(int) $now->format('d'),
|
||||
);
|
||||
|
||||
return [$jy, $jm];
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,24 @@ class EntityContextResolver
|
||||
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
|
||||
}
|
||||
|
||||
/**
|
||||
* آیا این کاربر **صاحبِ** همین محیط است؟ (ادمین همیشه بله.)
|
||||
*
|
||||
* «صاحب» با «میتواند در آن بایستد» فرق دارد: پزشکِ مهمانِ یک کلینیک در محیط آن
|
||||
* میایستد ولی صاحبش نیست. تصمیمهایی که قراردادِ کلِ مجموعه را عوض میکنند —
|
||||
* مثل الگوی شمارهٔ پرونده — به این پرسش وصلاند، نه به مجوزهای per-resource.
|
||||
*/
|
||||
public function owns(User $user, string $entityType, ?int $entityId): bool
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
[$ownedType, $ownedId] = $this->ownedEntity($user)->toEntityPair();
|
||||
|
||||
return $ownedId !== null && $ownedType === $entityType && $ownedId === $entityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* مالک کلینیک، ادمین، پزشکِ عضو همان کلینیک، منشیِ دارای رابطهٔ فعال در آن، یا
|
||||
* پرسنلِ فعالِ همان کلینیک.
|
||||
|
||||
Reference in New Issue
Block a user