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>
179 lines
6.6 KiB
PHP
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);
|
|
}
|
|
}
|