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:
hamed
2026-07-31 10:44:28 +03:30
co-authored by Claude Opus 5
parent 56bd1b474a
commit bcfa87bfad
27 changed files with 2939 additions and 70 deletions
@@ -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;
}
}
+34 -2
View File
@@ -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;
}
}
+137
View File
@@ -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();
}
}
+20
View File
@@ -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]);
}
/**
* قانونی که دامنه‌اش با این درخواست نمی‌خواند اصلاً کاندید نیست.
*
+62 -4
View File
@@ -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],
],
+232
View File
@@ -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),
];
}
}
+119
View File
@@ -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'),
};
}
}