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:
hamed
2026-07-31 10:19:19 +03:30
co-authored by Claude Opus 5
parent 281420ab4d
commit 584ea4067f
26 changed files with 2870 additions and 86 deletions
+158
View File
@@ -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);
}
}