Files
clinicpro/src/Policy/Service/PolicyResolver.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

179 lines
6.6 KiB
PHP

<?php
namespace App\Policy\Service;
use App\ClinicService\Entity\ServiceItem;
use App\Doctor\Entity\DoctorAddress;
use App\Policy\Entity\Policy;
use App\Policy\Repository\PolicyRepository;
use App\Policy\ValueObject\PolicyOutcome;
/**
* انتخاب قانون‌های مرتبط، حل تناقض، و ترکیب اثرها.
*
* ## ترتیب حل تناقض (بند ۸ مستند)
*
* ۱. **اولویت** بزرگ‌تر
* ۲. در تساوی: **اختصاصی‌تر** (شعبه بر محیط، سرویس بر دسته)
* ۳. باز هم تساوی: قانون **قدیمی‌تر**
*
* قاعدهٔ سوم عمداً «قدیمی‌تر» است نه «تازه‌تر»: قانونی که مدت‌هاست کار می‌کند رفتار
* جاافتادهٔ کلینیک است و قانون تازه‌ای که تصادفاً هم‌اولویت شده نباید بی‌صدا عوضش کند.
*
* ## ترکیب اثرها
*
* از جدول {@see PolicySchema::COMBINATION} می‌آید — `veto`، `max`، `sum`، `union`.
* یک `forbid` کل عملیات را رد می‌کند حتی اگر ده قانون مجازکننده باشند؛ ممنوعیت رأی
* اکثریت نیست.
*/
final class PolicyResolver
{
public function __construct(
private readonly PolicyRepository $policies,
private readonly ConditionEvaluator $evaluator,
) {}
/**
* @param array<string, mixed> $facts
*/
public function resolve(
string $category,
string $entityType,
int $entityId,
array $facts,
?DoctorAddress $address = null,
?ServiceItem $service = null,
?int $at = null,
): PolicyOutcome {
$at = $at ?? time();
$candidates = $this->policies->findForCategory($entityType, $entityId, $category);
$matched = [];
foreach ($candidates as $policy) {
if (!$policy->appliesAt($at) || !$this->inScope($policy, $address, $service)) {
continue;
}
if ($this->evaluator->matches($policy, $facts)) {
$matched[] = $policy;
}
}
if ($matched === []) {
return new PolicyOutcome();
}
usort($matched, $this->comparator(...));
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]);
}
/**
* قانونی که دامنه‌اش با این درخواست نمی‌خواند اصلاً کاندید نیست.
*
* دامنهٔ تهی یعنی «همه» — قانون سطح محیط روی همه‌چیز اعمال می‌شود.
*/
private function inScope(Policy $policy, ?DoctorAddress $address, ?ServiceItem $service): bool
{
if ($policy->getAddress() !== null && $policy->getAddress()->getId() !== $address?->getId()) {
return false;
}
if ($policy->getServiceItem() !== null && $policy->getServiceItem()->getId() !== $service?->getId()) {
return false;
}
if ($policy->getCatalogCategory() !== null
&& $policy->getCatalogCategory()->getId() !== $service?->getCatalogCategory()?->getId()
) {
return false;
}
return true;
}
private function comparator(Policy $a, Policy $b): int
{
return [$b->getPriority(), $b->specificity(), $a->getCreatedAt()]
<=> [$a->getPriority(), $a->specificity(), $b->getCreatedAt()];
}
/** @param Policy[] $policies به ترتیب برنده‌ترین */
private function combine(array $policies): PolicyOutcome
{
$effects = [];
$applied = [];
$forbids = [];
foreach ($policies as $policy) {
$contributed = false;
foreach ($policy->getEffects() as $effect) {
$type = $effect['type'] ?? null;
if (!is_string($type)) {
continue;
}
$contributed = true;
$mode = PolicySchema::COMBINATION[$type] ?? 'max';
$value = $effect['value'] ?? true;
if ($mode === 'veto') {
// متن دلخواه با کلید `reason` می‌آید؛ نبودنش خطا نیست چون نام
// خودِ قانون همیشه یک توضیح قابل‌فهم است.
$reason = $effect['reason'] ?? null;
$forbids[] = is_string($reason) && trim($reason) !== ''
? $reason
: sprintf('قانون «%s» این عملیات را مجاز نمی‌داند', $policy->getName());
continue;
}
$effects[$type] = match ($mode) {
'sum' => (float) ($effects[$type] ?? 0) + (float) $value,
'union' => array_values(array_unique([...($effects[$type] ?? []), ...(array) $value])),
default => max($effects[$type] ?? $value, $value), // max
};
}
if ($contributed) {
$applied[] = [
'uuid' => $policy->getUuid(),
'name' => $policy->getName(),
'version' => $policy->getVersion(),
];
}
}
// جمع‌ها به عدد صحیح برمی‌گردند: دقیقه و ریال هر دو صحیح‌اند.
foreach ($effects as $type => $value) {
if (is_float($value)) {
$effects[$type] = $value == (int) $value ? (int) $value : $value;
}
}
return new PolicyOutcome($effects, $applied, $forbids);
}
}