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
+60 -2
View File
@@ -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];
}
}
+3
View File
@@ -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]
+118
View File
@@ -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();
}
}
}
+5
View File
@@ -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;
}
/**
* مالک کلینیک، ادمین، پزشکِ عضو همان کلینیک، منشیِ دارای رابطهٔ فعال در آن، یا
* پرسنلِ فعالِ همان کلینیک.