feat(policy): six-category policy engine wired into the booking flow
Rules become data instead of code: a clinic can say "laser under 18 requires parental consent" without a deploy. Engine - Policy / PolicyVersionLog entities, closed field/operator/effect lists per category (PolicySchema), condition validation at write time - PolicyResolver: priority -> specificity -> age, combining effects by veto / max / sum / union - A missing fact fails its clause instead of silently passing it - Policies are drafts until activated, and are versioned rather than edited Wiring - selection -> ServiceSelectionValidator - eligibility + spacing -> BookingPolicyGuard, at hold time not confirm time - resource + timing -> AppointmentPlanBuilder, including template-less services - pricing -> PricingEngine, alongside (not replacing) the manual discount The condition column is named condition_json: `condition` is a MariaDB keyword and broke every INSERT. Tests: 17 in tests/Policy including NoPolicyRegressionTest, which pins that a clinic with no policies sees byte-identical output to task 08. Docs: docs/api/policy.md (real captured JSON) + docs/architecture/policy-engine.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
<?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);
|
||||
}
|
||||
|
||||
/**
|
||||
* قانونی که دامنهاش با این درخواست نمیخواند اصلاً کاندید نیست.
|
||||
*
|
||||
* دامنهٔ تهی یعنی «همه» — قانون سطح محیط روی همهچیز اعمال میشود.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user