Files
clinicpro/src/Policy/Simulation/SimulationSampler.php
T
hamedandClaude Opus 5 bcfa87bfad 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>
2026-07-31 10:44:28 +03:30

60 lines
2.2 KiB
PHP

<?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();
}
}