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>
60 lines
2.2 KiB
PHP
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();
|
|
}
|
|
}
|