feat(policy): rule builder and mandatory dry-run sandbox
Task 09 shipped a powerful API that a non-technical clinic owner could not safely use. This closes that gap: activation now requires having seen what the rule actually does. - PolicySimulator runs a policy against real past appointments and writes nothing: evaluation works on facts (never entities), the whole run sits in a transaction rolled back and cleared in `finally`, and a test counts rows in five sensitive tables before and after - activate() now demands a simulation of the *same version* — a report for version 1 does not unlock version 2 - PolicyTemplateRegistry: six ready-made rules, so the common case never touches a raw condition - Severity from the affected ratio; 0% is a warning too, since a rule that changes nothing usually has a condition that never matches - An empty clinic still succeeds with a warning, otherwise a new clinic could never activate anything Admin: PoliciesPage, PolicyFormPage, PolicySimulationPage, and a PolicyConditionBuilder built entirely from GET /policy-schema — a test proves a field that exists only in the schema shows up with no frontend change, and that operators are filtered per field type. The schema response now carries per-field metadata (label, type, meaningful operators) so the form has one source of truth instead of two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Command;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* اجراهای آزمایشیِ قدیمی ارزشی ندارند — جز **آخرینِ هر (قانون، نسخه)**.
|
||||
*
|
||||
* آن یکی حذفنشدنی است چون `activate` به وجودش وابسته است: پاک کردنش یعنی قانونی که
|
||||
* دیروز آزمایش شده امروز دیگر فعالشدنی نیست، بدون هیچ توضیحی برای کاربر.
|
||||
*/
|
||||
#[AsCommand(name: 'app:policy:prune-simulations', description: 'Delete old policy simulation runs, keeping the latest per policy version.')]
|
||||
class PruneSimulationsCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly Connection $connection)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('days', null, InputOption::VALUE_REQUIRED, 'Delete runs older than this many days', '90')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would be deleted without deleting');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$days = max(1, (int) $input->getOption('days'));
|
||||
$before = time() - $days * 86400;
|
||||
|
||||
$sql = <<<'SQL'
|
||||
SELECT r.id
|
||||
FROM policy_simulation_runs r
|
||||
WHERE r.created_at < :before
|
||||
AND r.id NOT IN (
|
||||
SELECT keep_id FROM (
|
||||
SELECT MAX(id) AS keep_id
|
||||
FROM policy_simulation_runs
|
||||
GROUP BY policy_id, policy_version
|
||||
) AS keepers
|
||||
)
|
||||
SQL;
|
||||
|
||||
$ids = $this->connection->fetchFirstColumn($sql, ['before' => $before]);
|
||||
|
||||
if ($ids === []) {
|
||||
$io->success('هیچ اجرای آزمایشیِ قابل حذفی نیست.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($input->getOption('dry-run')) {
|
||||
$io->note(sprintf('%d اجرای آزمایشی حذف میشد.', count($ids)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM policy_simulation_runs WHERE id IN (:ids)',
|
||||
['ids' => $ids],
|
||||
['ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER],
|
||||
);
|
||||
|
||||
$io->success(sprintf('%d اجرای آزمایشی حذف شد.', count($ids)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@ use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Entity\PolicyVersionLog;
|
||||
use App\Policy\Repository\PolicyRepository;
|
||||
use App\Policy\Repository\PolicySimulationRunRepository;
|
||||
use App\Policy\Repository\PolicyVersionLogRepository;
|
||||
use App\Policy\Template\PolicyTemplateRegistry;
|
||||
use App\Policy\Service\ConditionEvaluator;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
@@ -31,6 +33,8 @@ class PolicyController extends BaseController
|
||||
public function __construct(
|
||||
private readonly PolicyRepository $policies,
|
||||
private readonly PolicyVersionLogRepository $versions,
|
||||
private readonly PolicySimulationRunRepository $simulations,
|
||||
private readonly PolicyTemplateRegistry $templates,
|
||||
private readonly ConditionEvaluator $evaluator,
|
||||
private readonly PolicySchema $schema,
|
||||
private readonly ServiceItemRepository $items,
|
||||
@@ -74,7 +78,17 @@ class PolicyController extends BaseController
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data) || !is_string($data['category'] ?? null) || !in_array($data['category'], Policy::CATEGORIES, true)) {
|
||||
if (!is_array($data)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||||
}
|
||||
|
||||
// الگو فقط `category`/`condition`/`effects` را از پیش پر میکند؛ اعتبارسنجی
|
||||
// بعد از آن همان مسیر عادی است، پس الگو نمیتواند قانونِ نامعتبر بسازد.
|
||||
if (is_string($data['template'] ?? null)) {
|
||||
$data = array_merge($data, $this->templates->build($data['template'], $data['values'] ?? []));
|
||||
}
|
||||
|
||||
if (!is_string($data['category'] ?? null) || !in_array($data['category'], Policy::CATEGORIES, true)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دستهٔ قانون نامعتبر است', 422, 'category');
|
||||
}
|
||||
|
||||
@@ -134,10 +148,28 @@ class PolicyController extends BaseController
|
||||
return $this->success($policy->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* فعالسازی — فقط بعد از یک اجرای آزمایشیِ **همین نسخه**.
|
||||
*
|
||||
* آزمایش نسخهٔ ۱ اجازهٔ فعالسازی نسخهٔ ۲ را نمیدهد: کاربر متن قانون را عوض کرده و
|
||||
* گزارشی که دیده دیگر توصیف این قانون نیست.
|
||||
*/
|
||||
#[Route('/api/v1/policy/{uuid}/activate', name: 'policy_activate', methods: ['POST'])]
|
||||
public function activate(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$policy = $this->requirePolicy($user, $uuid)->setActive(true);
|
||||
$policy = $this->requirePolicy($user, $uuid);
|
||||
$run = $this->simulations->latestFor($policy);
|
||||
|
||||
if ($run === null || $run->getPolicyVersion() !== $policy->getVersion()) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
'ابتدا قانون را آزمایش کنید و نتیجه را ببینید',
|
||||
422,
|
||||
'simulation',
|
||||
);
|
||||
}
|
||||
|
||||
$policy->setActive(true);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($policy->toArray());
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Entity\PolicySimulationRun;
|
||||
use App\Policy\Repository\PolicyRepository;
|
||||
use App\Policy\Repository\PolicySimulationRunRepository;
|
||||
use App\Policy\Simulation\PolicySimulator;
|
||||
use App\Policy\Simulation\SimulationSampler;
|
||||
use App\Policy\Template\PolicyTemplateRegistry;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
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: 'Policy')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class PolicySimulationController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyRepository $policies,
|
||||
private readonly PolicySimulationRunRepository $runs,
|
||||
private readonly PolicySimulator $simulator,
|
||||
private readonly PolicyTemplateRegistry $templates,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
) {}
|
||||
|
||||
/** الگوهای آمادهٔ قانون — ورودیِ فرم ساخت. */
|
||||
#[Route('/api/v1/policy-templates', name: 'policy_templates', methods: ['GET'])]
|
||||
public function templates(): JsonResponse
|
||||
{
|
||||
return $this->success($this->templates->describe());
|
||||
}
|
||||
|
||||
/**
|
||||
* اجرای آزمایشی روی نوبتهای واقعی گذشته. هیچ چیزی جز خودِ نتیجه ثبت نمیشود.
|
||||
*/
|
||||
#[Route('/api/v1/policy/{uuid}/simulate', name: 'policy_simulate', methods: ['POST'])]
|
||||
public function simulate(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$policy = $this->requirePolicy($user, $uuid);
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
$size = is_array($data) && is_numeric($data['sample_size'] ?? null)
|
||||
? (int) $data['sample_size']
|
||||
: SimulationSampler::DEFAULT_SIZE;
|
||||
|
||||
// سقف صریح است نه بیصدا: کاربری که ۵۰۰ خواسته باید بداند ۵۰ گرفته.
|
||||
if ($size < 1 || $size > SimulationSampler::MAX_SIZE) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('اندازهٔ نمونه باید بین ۱ و %d باشد', SimulationSampler::MAX_SIZE),
|
||||
422,
|
||||
'sample_size',
|
||||
);
|
||||
}
|
||||
|
||||
$run = $this->simulator->simulate($policy, $size, $user);
|
||||
|
||||
return $this->success($run->toArray(), 201);
|
||||
}
|
||||
|
||||
/** تاریخچهٔ اجراهای آزمایشی یک قانون. */
|
||||
#[Route('/api/v1/policy/{uuid}/simulations', name: 'policy_simulations', methods: ['GET'])]
|
||||
public function history(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$policy = $this->requirePolicy($user, $uuid);
|
||||
|
||||
return $this->success(array_map(
|
||||
static fn (PolicySimulationRun $r): array => $r->toArray(),
|
||||
$this->runs->historyFor($policy),
|
||||
));
|
||||
}
|
||||
|
||||
private function requirePolicy(User $user, string $uuid): Policy
|
||||
{
|
||||
$policy = $this->policies->findByUuid($uuid);
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if ($policy === null || !$this->ownership->belongsToPair($entityType, $entityId, $policy)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'قانون یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $policy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Policy\Repository\PolicySimulationRunRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* نتیجهٔ یک اجرای آزمایشی — تنها چیزی که شبیهسازی مینویسد.
|
||||
*
|
||||
* وجودش دو کار میکند: به کاربر نشان میدهد قانونش چه میکند، و به `activate` اجازهٔ
|
||||
* فعالسازی میدهد. بدون اجرای آزمایشیِ **همین نسخه**، قانون فعال نمیشود.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: PolicySimulationRunRepository::class)]
|
||||
#[ORM\Table(name: 'policy_simulation_runs')]
|
||||
#[ORM\Index(columns: ['policy_id', 'policy_version', 'created_at'], name: 'idx_psr_policy')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'created_at'], name: 'idx_psr_tenant')]
|
||||
class PolicySimulationRun
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const SEVERITY_NONE = 'none';
|
||||
public const SEVERITY_LOW = 'low';
|
||||
public const SEVERITY_MEDIUM = 'medium';
|
||||
public const SEVERITY_HIGH = 'high';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Policy::class)]
|
||||
#[ORM\JoinColumn(name: 'policy_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Policy $policy;
|
||||
|
||||
#[ORM\Column(name: 'policy_version', type: 'smallint')]
|
||||
private int $policyVersion;
|
||||
|
||||
#[ORM\Column(name: 'sample_size', type: 'smallint')]
|
||||
private int $sampleSize;
|
||||
|
||||
#[ORM\Column(name: 'affected_count', type: 'smallint')]
|
||||
private int $affectedCount;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 10)]
|
||||
private string $severity;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $report;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'run_by', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $runBy = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
/** @param array<string, mixed> $report */
|
||||
public function __construct(
|
||||
Policy $policy,
|
||||
int $sampleSize,
|
||||
int $affectedCount,
|
||||
string $severity,
|
||||
array $report,
|
||||
?User $runBy = null,
|
||||
) {
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->policy = $policy;
|
||||
$this->policyVersion = $policy->getVersion();
|
||||
$this->sampleSize = $sampleSize;
|
||||
$this->affectedCount = $affectedCount;
|
||||
$this->severity = $severity;
|
||||
$this->report = $report;
|
||||
$this->runBy = $runBy;
|
||||
$this->createdAt = time();
|
||||
|
||||
$this->entityType = $policy->getEntityType();
|
||||
$this->entityId = $policy->getEntityId();
|
||||
}
|
||||
|
||||
/**
|
||||
* شدت از **نسبت** میآید نه از تعداد: ۷ نوبت از ۱۰ فاجعه است و ۷ از ۵۰۰ عادی.
|
||||
*
|
||||
* صفر هم هشدار است، نه موفقیت: قانونی که روی هیچ نوبتی اثر ندارد یا شرطش هرگز
|
||||
* برقرار نمیشود یا نمونه اشتباه انتخاب شده — هر دو باید دیده شوند.
|
||||
*/
|
||||
public static function severityFor(int $sampleSize, int $affected): string
|
||||
{
|
||||
if ($affected === 0) {
|
||||
return self::SEVERITY_NONE;
|
||||
}
|
||||
|
||||
$ratio = $sampleSize === 0 ? 0.0 : $affected / $sampleSize;
|
||||
|
||||
return match (true) {
|
||||
$ratio > 0.60 => self::SEVERITY_HIGH,
|
||||
$ratio > 0.20 => self::SEVERITY_MEDIUM,
|
||||
default => self::SEVERITY_LOW,
|
||||
};
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getPolicy(): Policy { return $this->policy; }
|
||||
public function getPolicyVersion(): int { return $this->policyVersion; }
|
||||
public function getSampleSize(): int { return $this->sampleSize; }
|
||||
public function getAffectedCount(): int { return $this->affectedCount; }
|
||||
public function getSeverity(): string { return $this->severity; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function getReport(): array { return $this->report; }
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'policy_uuid' => $this->policy->getUuid(),
|
||||
'policy_version' => $this->policyVersion,
|
||||
'sample_size' => $this->sampleSize,
|
||||
'affected_count' => $this->affectedCount,
|
||||
'affected_percent' => $this->sampleSize === 0
|
||||
? 0
|
||||
: (int) round($this->affectedCount * 100 / $this->sampleSize),
|
||||
'severity' => $this->severity,
|
||||
'created_at' => $this->createdAt,
|
||||
] + $this->report;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Repository;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Entity\PolicySimulationRun;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<PolicySimulationRun>
|
||||
*/
|
||||
class PolicySimulationRunRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PolicySimulationRun::class);
|
||||
}
|
||||
|
||||
/** آخرین اجرای آزمایشی این قانون، از هر نسخهای. */
|
||||
public function latestFor(Policy $policy): ?PolicySimulationRun
|
||||
{
|
||||
return $this->createQueryBuilder('r')
|
||||
->where('r.policy = :policy')
|
||||
->setParameter('policy', $policy)
|
||||
->orderBy('r.createdAt', 'DESC')
|
||||
->addOrderBy('r.id', 'DESC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/** @return PolicySimulationRun[] */
|
||||
public function historyFor(Policy $policy, int $limit = 10): array
|
||||
{
|
||||
return $this->createQueryBuilder('r')
|
||||
->where('r.policy = :policy')
|
||||
->setParameter('policy', $policy)
|
||||
->orderBy('r.createdAt', 'DESC')
|
||||
->addOrderBy('r.id', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(PolicySimulationRun $run): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($run);
|
||||
$em->flush();
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,26 @@ final class PolicyResolver
|
||||
return $this->combine($matched);
|
||||
}
|
||||
|
||||
/**
|
||||
* ارزیابی **یک** قانون، بدون رقابت و بدون ترکیب با بقیه.
|
||||
*
|
||||
* سؤال آزمایشگاه این است که «این قانون چه میکند»، نه «نتیجهٔ نهایی با همهٔ قوانین
|
||||
* چه میشود». دومی مفید است ولی چیزی نیست که کاربرِ در حال نوشتن قانون میپرسد.
|
||||
*
|
||||
* دامنه و اعتبار زمانی هم عمداً نادیده گرفته میشوند: کاربر دارد قانونِ **پیشنویس**
|
||||
* را روی نمونهٔ گذشته میآزماید؛ رد کردنش بهخاطر اینکه هنوز فعال نیست بیمعناست.
|
||||
*
|
||||
* @param array<string, mixed> $facts
|
||||
*/
|
||||
public function evaluateOne(Policy $policy, array $facts): PolicyOutcome
|
||||
{
|
||||
if (!$this->evaluator->matches($policy, $facts)) {
|
||||
return new PolicyOutcome();
|
||||
}
|
||||
|
||||
return $this->combine([$policy]);
|
||||
}
|
||||
|
||||
/**
|
||||
* قانونی که دامنهاش با این درخواست نمیخواند اصلاً کاندید نیست.
|
||||
*
|
||||
|
||||
@@ -76,6 +76,56 @@ final class PolicySchema
|
||||
self::EFFECT_DISCOUNT_RIALS => 'sum',
|
||||
];
|
||||
|
||||
/**
|
||||
* فرادادهٔ هر فیلد: برچسب فارسی، نوع ورودی، و عملگرهایی که **برای همان نوع** معنا
|
||||
* دارند.
|
||||
*
|
||||
* فیلتر شدن عملگرها اختیاری نیست: اگر فرم همهٔ شش عملگر را نشان بدهد، کاربر
|
||||
* `patient_tags > 5` میسازد و ۴۲۲ میگیرد بدون اینکه بفهمد چرا.
|
||||
*/
|
||||
private const FIELD_META = [
|
||||
'item_count' => ['label' => 'تعداد موارد انتخابی', 'type' => 'int'],
|
||||
'item_uuids' => ['label' => 'موارد انتخابی', 'type' => 'list'],
|
||||
'catalog_category' => ['label' => 'دستهٔ کاتالوگ', 'type' => 'uuid'],
|
||||
'service_uuid' => ['label' => 'سرویس', 'type' => 'uuid'],
|
||||
'patient_age' => ['label' => 'سن بیمار', 'type' => 'int'],
|
||||
'patient_gender' => ['label' => 'جنسیت بیمار', 'type' => 'enum', 'values' => ['male', 'female']],
|
||||
'patient_tags' => ['label' => 'برچسبهای بیمار', 'type' => 'list'],
|
||||
'has_parental_consent' => ['label' => 'رضایت والدین', 'type' => 'bool'],
|
||||
'visit_count' => ['label' => 'تعداد ویزیت قبلی', 'type' => 'int'],
|
||||
'subtotal_rials' => ['label' => 'جمع مبلغ (ریال)', 'type' => 'int'],
|
||||
];
|
||||
|
||||
/** عملگرهای معنادار برای هر نوع ورودی. */
|
||||
private const OPERATORS_BY_TYPE = [
|
||||
'int' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_GREATER_THAN, self::OP_LESS_THAN],
|
||||
'uuid' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_IN],
|
||||
'enum' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_IN],
|
||||
'bool' => [self::OP_EQUALS],
|
||||
'list' => [self::OP_CONTAINS],
|
||||
];
|
||||
|
||||
/** برچسب فارسیِ هر اثر — همان چیزی که در فرم دیده میشود. */
|
||||
private const EFFECT_META = [
|
||||
self::EFFECT_FORBID => ['label' => 'ممنوع کن', 'value_type' => 'none'],
|
||||
self::EFFECT_REQUIRE_RESOURCE => ['label' => 'نیاز به نقش', 'value_type' => 'string'],
|
||||
self::EFFECT_REQUIRE_FLAG => ['label' => 'نیاز به تأیید', 'value_type' => 'string'],
|
||||
self::EFFECT_MIN_DURATION => ['label' => 'حداقل مدت (دقیقه)', 'value_type' => 'int'],
|
||||
self::EFFECT_ADD_DURATION => ['label' => 'افزودن مدت (دقیقه)', 'value_type' => 'int'],
|
||||
self::EFFECT_MIN_DAYS_BETWEEN => ['label' => 'حداقل فاصله (روز)', 'value_type' => 'int'],
|
||||
self::EFFECT_DISCOUNT_PERCENT => ['label' => 'تخفیف درصدی', 'value_type' => 'int'],
|
||||
self::EFFECT_DISCOUNT_RIALS => ['label' => 'تخفیف مبلغی (ریال)', 'value_type' => 'int'],
|
||||
];
|
||||
|
||||
private const CATEGORY_LABELS = [
|
||||
Policy::CATEGORY_SELECTION => 'انتخاب خدمات',
|
||||
Policy::CATEGORY_ELIGIBILITY => 'صلاحیت بیمار',
|
||||
Policy::CATEGORY_RESOURCE => 'منابع لازم',
|
||||
Policy::CATEGORY_TIMING => 'مدت نوبت',
|
||||
Policy::CATEGORY_SPACING => 'فاصلهٔ جلسات',
|
||||
Policy::CATEGORY_PRICING => 'قیمت و تخفیف',
|
||||
];
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function describe(): array
|
||||
{
|
||||
@@ -83,10 +133,18 @@ final class PolicySchema
|
||||
|
||||
foreach (Policy::CATEGORIES as $category) {
|
||||
$out[$category] = [
|
||||
'fields' => self::FIELDS[$category],
|
||||
'operators' => self::OPERATORS,
|
||||
'effects' => array_map(
|
||||
static fn (string $effect): array => [
|
||||
'label' => self::CATEGORY_LABELS[$category],
|
||||
'fields' => self::FIELDS[$category],
|
||||
'operators' => self::OPERATORS,
|
||||
'field_meta' => array_map(
|
||||
static fn (string $field): array => self::FIELD_META[$field] + [
|
||||
'key' => $field,
|
||||
'operators' => self::OPERATORS_BY_TYPE[self::FIELD_META[$field]['type']],
|
||||
],
|
||||
self::FIELDS[$category],
|
||||
),
|
||||
'effects' => array_map(
|
||||
static fn (string $effect): array => self::EFFECT_META[$effect] + [
|
||||
'type' => $effect,
|
||||
'combination' => self::COMBINATION[$effect],
|
||||
],
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Simulation;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Entity\PolicySimulationRun;
|
||||
use App\Policy\Repository\PolicySimulationRunRepository;
|
||||
use App\Policy\Service\PolicyResolver;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
use App\Policy\ValueObject\PolicyOutcome;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* اجرای آزمایشی یک قانون روی نوبتهای واقعیِ گذشته — بدون نوشتن هیچ چیز.
|
||||
*
|
||||
* ## تضمین «چیزی ثبت نمیشود»، سه لایه
|
||||
*
|
||||
* ۱. ارزیابی روی **حقایق** انجام میشود نه روی entity؛ هیچ entity ای تغییر نمیکند.
|
||||
* ۲. کل اجرا داخل تراکنشی است که در `finally` **همیشه** rollback و `clear` میشود —
|
||||
* حتی اگر روزی کسی سهواً یک `flush` اضافه کند.
|
||||
* ۳. `PolicySimulationRunTest` تعداد ردیف جدولهای حساس را قبل و بعد میشمارد.
|
||||
*
|
||||
* ثبت خودِ `PolicySimulationRun` **بعد** از این بلوک و در تراکنش خودش انجام میشود.
|
||||
*/
|
||||
final class PolicySimulator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SimulationSampler $sampler,
|
||||
private readonly SimulationFacts $facts,
|
||||
private readonly PolicyResolver $policies,
|
||||
private readonly PolicySimulationRunRepository $runs,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function simulate(Policy $policy, int $size = SimulationSampler::DEFAULT_SIZE, ?User $runBy = null): PolicySimulationRun
|
||||
{
|
||||
$this->em->beginTransaction();
|
||||
|
||||
try {
|
||||
$report = $this->runInternal($policy, $size);
|
||||
} finally {
|
||||
$this->em->rollback();
|
||||
// بدون `clear`، entity های لمسشده در identity map میمانند و اولین flushِ
|
||||
// بعدی در همین request آنها را ثبت میکند — باگی که پیدا کردنش روزها میبرد.
|
||||
$this->em->clear();
|
||||
}
|
||||
|
||||
// `clear` ارجاعهای قبلی را از EM جدا کرده؛ قانون باید دوباره خوانده شود.
|
||||
$policy = $this->em->getRepository(Policy::class)->find($policy->getId());
|
||||
|
||||
if ($policy === null) {
|
||||
throw new \LogicException('Policy vanished during simulation.');
|
||||
}
|
||||
|
||||
$run = new PolicySimulationRun(
|
||||
$policy,
|
||||
$report['sample_size'],
|
||||
count($report['rows']),
|
||||
PolicySimulationRun::severityFor($report['sample_size'], count($report['rows'])),
|
||||
['rows' => $report['rows'], 'warning' => $report['warning']],
|
||||
$runBy === null ? null : $this->em->getRepository(User::class)->find($runBy->getId()),
|
||||
);
|
||||
|
||||
$this->runs->save($run);
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{sample_size: int, rows: list<array<string, mixed>>, warning: string|null}
|
||||
*/
|
||||
private function runInternal(Policy $policy, int $size): array
|
||||
{
|
||||
$sample = $this->sampler->recentAppointments($policy, $size);
|
||||
|
||||
if ($sample === []) {
|
||||
// کلینیک تازه هیچ نوبت گذشتهای ندارد؛ اگر این حالت خطا بود، هرگز
|
||||
// نمیتوانست قانونی فعال کند.
|
||||
return ['sample_size' => 0, 'rows' => [], 'warning' => 'دادهای برای آزمایش نیست'];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($sample as $appointment) {
|
||||
$outcome = $this->policies->evaluateOne(
|
||||
$policy,
|
||||
$this->facts->forAppointment($appointment, $policy->getCategory()),
|
||||
);
|
||||
|
||||
if ($outcome->appliedPolicies === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = $this->describe($policy, $appointment, $outcome);
|
||||
|
||||
if ($row !== null) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return ['sample_size' => count($sample), 'rows' => $rows, 'warning' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* تفاوت «وضعیت فعلی → با این قانون» به زبان کاربر.
|
||||
*
|
||||
* تنها ستونی است که کاربر غیرفنی میفهمد، پس عمداً متن است نه ساختار خام اثر.
|
||||
*
|
||||
* @return array<string, mixed>|null `null` یعنی این نوبت عملاً تغییری نمیکرد
|
||||
*/
|
||||
private function describe(Policy $policy, Appointment $appointment, PolicyOutcome $outcome): ?array
|
||||
{
|
||||
$base = [
|
||||
'appointment_uuid' => $appointment->getUuid(),
|
||||
'patient_name' => $appointment->getPatientName() ?? '—',
|
||||
'slot_start' => $appointment->getSlotStart(),
|
||||
];
|
||||
|
||||
if ($outcome->isForbidden()) {
|
||||
return $base + [
|
||||
'before' => 'مجاز',
|
||||
'after' => 'رد میشد',
|
||||
'reason' => implode(' ', $outcome->forbidReasons),
|
||||
];
|
||||
}
|
||||
|
||||
return match ($policy->getCategory()) {
|
||||
Policy::CATEGORY_TIMING => $this->describeTiming($base, $appointment, $outcome),
|
||||
Policy::CATEGORY_PRICING => $this->describePricing($base, $appointment, $outcome),
|
||||
Policy::CATEGORY_RESOURCE => $this->describeList(
|
||||
$base,
|
||||
'منبع لازم',
|
||||
(array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_RESOURCE, []),
|
||||
),
|
||||
Policy::CATEGORY_ELIGIBILITY => $this->describeList(
|
||||
$base,
|
||||
'تأیید لازم',
|
||||
(array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_FLAG, []),
|
||||
),
|
||||
Policy::CATEGORY_SPACING => $this->describeSpacing($base, $outcome),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $base
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function describeTiming(array $base, Appointment $appointment, PolicyOutcome $outcome): ?array
|
||||
{
|
||||
$current = $this->facts->durationOf($appointment);
|
||||
$target = max(
|
||||
$current + (int) $outcome->effect(PolicySchema::EFFECT_ADD_DURATION, 0),
|
||||
(int) $outcome->effect(PolicySchema::EFFECT_MIN_DURATION, 0),
|
||||
);
|
||||
|
||||
if ($target === $current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $base + [
|
||||
'before' => sprintf('%d دقیقه', $current),
|
||||
'after' => sprintf('%d دقیقه', $target),
|
||||
'reason' => sprintf('%+d دقیقه', $target - $current),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $base
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function describePricing(array $base, Appointment $appointment, PolicyOutcome $outcome): ?array
|
||||
{
|
||||
$subtotal = $this->facts->subtotalOf($appointment);
|
||||
|
||||
$discount = (int) floor($subtotal * (float) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_PERCENT, 0) / 100)
|
||||
+ (int) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_RIALS, 0);
|
||||
|
||||
$discount = min($discount, $subtotal);
|
||||
|
||||
if ($discount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $base + [
|
||||
'before' => sprintf('%s ریال', number_format($subtotal)),
|
||||
'after' => sprintf('%s ریال', number_format($subtotal - $discount)),
|
||||
'reason' => sprintf('%s ریال تخفیف', number_format($discount)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $base
|
||||
* @param array<int, mixed> $values
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function describeList(array $base, string $label, array $values): ?array
|
||||
{
|
||||
$values = array_values(array_filter($values, 'is_string'));
|
||||
|
||||
if ($values === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $base + [
|
||||
'before' => 'بدون قید',
|
||||
'after' => sprintf('%s: %s', $label, implode('، ', $values)),
|
||||
'reason' => $label,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $base
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function describeSpacing(array $base, PolicyOutcome $outcome): ?array
|
||||
{
|
||||
$days = (int) $outcome->effect(PolicySchema::EFFECT_MIN_DAYS_BETWEEN, 0);
|
||||
|
||||
if ($days <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $base + [
|
||||
'before' => 'بدون حداقل فاصله',
|
||||
'after' => sprintf('حداقل %d روز فاصله', $days),
|
||||
'reason' => sprintf('%d روز', $days),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Simulation;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\UserProfile\Entity\UserProfile;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* حقایق یک نوبتِ ثبتشده، به همان شکلی که نقاط اجرای زنده میسازند.
|
||||
*
|
||||
* اگر این کلاس حقیقتی را طور دیگری بسازد، آزمایش دروغ میگوید — و آزمایشی که دروغ
|
||||
* بگوید بدتر از نداشتن آزمایش است. به همین دلیل نامها عیناً از
|
||||
* {@see \App\Policy\Service\PolicySchema::FIELDS} میآیند.
|
||||
*/
|
||||
final class SimulationFacts
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function forAppointment(Appointment $appointment, string $category): array
|
||||
{
|
||||
$service = $appointment->getServiceItem();
|
||||
$items = $appointment->getServiceItems()->count();
|
||||
|
||||
$common = [
|
||||
'service_uuid' => $service?->getUuid(),
|
||||
'catalog_category' => $service?->getCatalogCategory()?->getUuid(),
|
||||
'item_count' => max(1, $items),
|
||||
];
|
||||
|
||||
return match ($category) {
|
||||
Policy::CATEGORY_SELECTION => $common + [
|
||||
'item_uuids' => $this->itemUuids($appointment),
|
||||
],
|
||||
Policy::CATEGORY_ELIGIBILITY => $common + $this->patientFacts($appointment),
|
||||
Policy::CATEGORY_TIMING => $common + [
|
||||
'patient_age' => $this->patientFacts($appointment)['patient_age'],
|
||||
],
|
||||
Policy::CATEGORY_PRICING => $common + [
|
||||
'subtotal_rials' => $this->subtotalOf($appointment),
|
||||
'patient_tags' => [],
|
||||
'visit_count' => $this->visitCount($appointment),
|
||||
],
|
||||
default => $common,
|
||||
};
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function itemUuids(Appointment $appointment): array
|
||||
{
|
||||
$uuids = [];
|
||||
|
||||
foreach ($appointment->getServiceItems() as $item) {
|
||||
$uuids[] = $item->getUuid();
|
||||
}
|
||||
|
||||
if ($uuids === [] && $appointment->getServiceItem() !== null) {
|
||||
$uuids[] = $appointment->getServiceItem()->getUuid();
|
||||
}
|
||||
|
||||
return $uuids;
|
||||
}
|
||||
|
||||
/** @return array{patient_age: int|null, patient_gender: string|null, patient_tags: list<string>, visit_count: int, has_parental_consent: bool} */
|
||||
private function patientFacts(Appointment $appointment): array
|
||||
{
|
||||
/** @var UserProfile|null $profile */
|
||||
$profile = $this->em->getRepository(UserProfile::class)
|
||||
->findOneBy(['user' => $appointment->getUser()]);
|
||||
|
||||
$dob = $profile?->getDateOfBirth();
|
||||
|
||||
return [
|
||||
'patient_age' => $dob === null || $dob <= 0
|
||||
? null
|
||||
: (int) floor(($appointment->getSlotStart() - $dob) / 31556952),
|
||||
'patient_gender' => $profile?->getGender() ?? $appointment->getPatientGender(),
|
||||
'patient_tags' => [],
|
||||
'visit_count' => $this->visitCount($appointment),
|
||||
// نوبت گذشته پرچمِ لحظهای ندارد؛ فرضِ «نگرفته» محافظهکارانه است و
|
||||
// باعث میشود قانون `require_flag` در گزارش **دیده** شود نه پنهان.
|
||||
'has_parental_consent' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private function visitCount(Appointment $appointment): int
|
||||
{
|
||||
return (int) $this->em->createQueryBuilder()
|
||||
->select('COUNT(a.id)')
|
||||
->from(Appointment::class, 'a')
|
||||
->where('a.user = :user')
|
||||
->andWhere('a.slotStart < :before')
|
||||
->andWhere('a.status = :status')
|
||||
->setParameter('user', $appointment->getUser())
|
||||
->setParameter('before', $appointment->getSlotStart())
|
||||
->setParameter('status', Appointment::STATUS_COMPLETED)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/** مبلغ ثبتشدهٔ همان نوبت؛ نه قیمت امروزِ سرویس. */
|
||||
public function subtotalOf(Appointment $appointment): int
|
||||
{
|
||||
return (int) ($appointment->getVisitPriceRials()
|
||||
?? $appointment->getServiceItem()?->getPriceRials()
|
||||
?? 0);
|
||||
}
|
||||
|
||||
/** مدت ثبتشدهٔ همان نوبت، با بازگشت به طول بازهٔ اسلات. */
|
||||
public function durationOf(Appointment $appointment): int
|
||||
{
|
||||
return (int) ($appointment->getServiceTotalMinutes()
|
||||
?? max(0, intdiv($appointment->getSlotEnd() - $appointment->getSlotStart(), 60)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Simulation;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Policy\Entity\Policy;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* نمونهٔ نوبتهای واقعیِ گذشته برای آزمایش یک قانون.
|
||||
*
|
||||
* نمونه به **دامنهٔ خود قانون** محدود میشود: قانون لیزر روی ۵۰ نوبت دندانپزشکی
|
||||
* «۰٪ تحت تأثیر» میدهد، و آن عدد گمراهکنندهتر از نداشتن گزارش است.
|
||||
*/
|
||||
final class SimulationSampler
|
||||
{
|
||||
public const DEFAULT_SIZE = 50;
|
||||
/** سقف نمونه — گزارش بزرگتر نه خوانده میشود نه در `report` جا میشود. */
|
||||
public const MAX_SIZE = 50;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/** @return Appointment[] جدیدترین اول */
|
||||
public function recentAppointments(Policy $policy, int $size = self::DEFAULT_SIZE): array
|
||||
{
|
||||
$size = max(1, min($size, self::MAX_SIZE));
|
||||
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select('a')
|
||||
->from(Appointment::class, 'a')
|
||||
->where('a.entityType = :type')
|
||||
->andWhere('a.entityId = :id')
|
||||
->andWhere('a.status IN (:statuses)')
|
||||
->setParameter('type', $policy->getEntityType())
|
||||
->setParameter('id', $policy->getEntityId())
|
||||
->setParameter('statuses', [Appointment::STATUS_CONFIRMED, Appointment::STATUS_COMPLETED])
|
||||
->orderBy('a.slotStart', 'DESC')
|
||||
->setMaxResults($size);
|
||||
|
||||
if ($policy->getAddress() !== null) {
|
||||
$qb->andWhere('a.addressId = :address')->setParameter('address', $policy->getAddress()->getId());
|
||||
}
|
||||
|
||||
if ($policy->getServiceItem() !== null) {
|
||||
$qb->andWhere('a.serviceItem = :service')->setParameter('service', $policy->getServiceItem());
|
||||
}
|
||||
|
||||
if ($policy->getCatalogCategory() !== null) {
|
||||
$qb->join('a.serviceItem', 'si')
|
||||
->andWhere('si.catalogCategory = :category')
|
||||
->setParameter('category', $policy->getCatalogCategory());
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Template;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* الگوهای آمادهٔ قانون — راهِ ۹۰٪ کاربران.
|
||||
*
|
||||
* کاربر غیرفنی نباید شرط خام بنویسد: الگو را انتخاب میکند، دو-سه مقدار پر میکند، و
|
||||
* `condition`/`effects` درست از همینجا ساخته میشود. حالت پیشرفته برای بقیه است.
|
||||
*
|
||||
* الگو **جایگزین** اعتبارسنجی نیست؛ خروجیاش هم از همان `ConditionEvaluator` رد میشود.
|
||||
*/
|
||||
final class PolicyTemplateRegistry
|
||||
{
|
||||
private const TEMPLATES = [
|
||||
'min_days_between_sessions' => [
|
||||
'title' => 'حداقل فاصله بین جلسات',
|
||||
'description' => 'بین دو جلسهٔ یک خدمت، حداقل چند روز فاصله باشد.',
|
||||
'category' => Policy::CATEGORY_SPACING,
|
||||
'inputs' => [
|
||||
['key' => 'days', 'type' => 'int', 'label' => 'حداقل روز', 'min' => 1, 'max' => 365],
|
||||
],
|
||||
],
|
||||
'complex_min_duration' => [
|
||||
'title' => 'حداقل مدت نوبت',
|
||||
'description' => 'نوبت این خدمت کمتر از این مقدار نباشد.',
|
||||
'category' => Policy::CATEGORY_TIMING,
|
||||
'inputs' => [
|
||||
['key' => 'minutes', 'type' => 'int', 'label' => 'حداقل دقیقه', 'min' => 5, 'max' => 480],
|
||||
],
|
||||
],
|
||||
'extra_time_for_many_items' => [
|
||||
'title' => 'زمان اضافه برای انتخابهای پرتعداد',
|
||||
'description' => 'وقتی بیمار بیش از N مورد انتخاب کند، به مدت نوبت اضافه شود.',
|
||||
'category' => Policy::CATEGORY_TIMING,
|
||||
'inputs' => [
|
||||
['key' => 'item_count', 'type' => 'int', 'label' => 'بیشتر از چند مورد', 'min' => 1, 'max' => 20],
|
||||
['key' => 'minutes', 'type' => 'int', 'label' => 'دقیقهٔ اضافه', 'min' => 5, 'max' => 120],
|
||||
],
|
||||
],
|
||||
'surgery_needs_surgeon' => [
|
||||
'title' => 'نیاز به نقش خاص',
|
||||
'description' => 'این خدمت بدون حضور نقش مشخصی انجام نشود.',
|
||||
'category' => Policy::CATEGORY_RESOURCE,
|
||||
'inputs' => [
|
||||
['key' => 'role', 'type' => 'resource_type_select', 'label' => 'نقش لازم'],
|
||||
],
|
||||
],
|
||||
'minor_needs_consent' => [
|
||||
'title' => 'رضایت والدین برای زیر سن قانونی',
|
||||
'description' => 'بیمار زیر سن مشخص، بدون تأیید رضایت والدین نوبت نگیرد.',
|
||||
'category' => Policy::CATEGORY_ELIGIBILITY,
|
||||
'inputs' => [
|
||||
['key' => 'age', 'type' => 'int', 'label' => 'سن مرزی', 'min' => 1, 'max' => 100],
|
||||
],
|
||||
],
|
||||
'vip_discount' => [
|
||||
'title' => 'تخفیف بیمار وفادار',
|
||||
'description' => 'بیمارانی که بیش از N ویزیت داشتهاند، درصدی تخفیف بگیرند.',
|
||||
'category' => Policy::CATEGORY_PRICING,
|
||||
'inputs' => [
|
||||
['key' => 'visit_count', 'type' => 'int', 'label' => 'بیشتر از چند ویزیت', 'min' => 1, 'max' => 100],
|
||||
['key' => 'percent', 'type' => 'int', 'label' => 'درصد تخفیف', 'min' => 1, 'max' => 100],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
public function describe(): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach (self::TEMPLATES as $key => $template) {
|
||||
$out[] = ['key' => $key] + $template;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values
|
||||
* @return array{category: string, condition: array<string, mixed>, effects: list<array<string, mixed>>}
|
||||
*/
|
||||
public function build(string $key, array $values): array
|
||||
{
|
||||
$template = self::TEMPLATES[$key] ?? null;
|
||||
|
||||
if ($template === null) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'الگوی قانون شناخته نمیشود', 422, 'template');
|
||||
}
|
||||
|
||||
foreach ($template['inputs'] as $input) {
|
||||
if ($input['type'] === 'int' && !is_numeric($values[$input['key']] ?? null)) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_002,
|
||||
sprintf('مقدار «%s» الزامی است', $input['label']),
|
||||
422,
|
||||
$input['key'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return ['category' => $template['category']] + $this->contentFor($key, $values);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $v
|
||||
* @return array{condition: array<string, mixed>, effects: list<array<string, mixed>>}
|
||||
*/
|
||||
private function contentFor(string $key, array $v): array
|
||||
{
|
||||
return match ($key) {
|
||||
'min_days_between_sessions' => [
|
||||
'condition' => [],
|
||||
'effects' => [['type' => PolicySchema::EFFECT_MIN_DAYS_BETWEEN, 'value' => (int) $v['days']]],
|
||||
],
|
||||
'complex_min_duration' => [
|
||||
'condition' => [],
|
||||
'effects' => [['type' => PolicySchema::EFFECT_MIN_DURATION, 'value' => (int) $v['minutes']]],
|
||||
],
|
||||
'extra_time_for_many_items' => [
|
||||
'condition' => ['match' => 'all', 'conditions' => [
|
||||
['field' => 'item_count', 'operator' => PolicySchema::OP_GREATER_THAN, 'value' => (int) $v['item_count']],
|
||||
]],
|
||||
'effects' => [['type' => PolicySchema::EFFECT_ADD_DURATION, 'value' => (int) $v['minutes']]],
|
||||
],
|
||||
'surgery_needs_surgeon' => [
|
||||
'condition' => [],
|
||||
'effects' => [['type' => PolicySchema::EFFECT_REQUIRE_RESOURCE, 'value' => (string) ($v['role'] ?? '')]],
|
||||
],
|
||||
'minor_needs_consent' => [
|
||||
'condition' => ['match' => 'all', 'conditions' => [
|
||||
['field' => 'patient_age', 'operator' => PolicySchema::OP_LESS_THAN, 'value' => (int) $v['age']],
|
||||
]],
|
||||
'effects' => [['type' => PolicySchema::EFFECT_REQUIRE_FLAG, 'value' => 'has_parental_consent']],
|
||||
],
|
||||
'vip_discount' => [
|
||||
'condition' => ['match' => 'all', 'conditions' => [
|
||||
['field' => 'visit_count', 'operator' => PolicySchema::OP_GREATER_THAN, 'value' => (int) $v['visit_count']],
|
||||
]],
|
||||
'effects' => [['type' => PolicySchema::EFFECT_DISCOUNT_PERCENT, 'value' => (int) $v['percent']]],
|
||||
],
|
||||
default => throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'الگوی قانون شناخته نمیشود', 422, 'template'),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user