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
@@ -9,6 +9,8 @@ use App\ClinicService\Entity\ServiceItemRelation;
use App\ClinicService\Repository\ItemGroupMemberRepository;
use App\ClinicService\Repository\ServiceItemRelationRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Policy\Entity\Policy;
use App\Policy\Service\PolicyResolver;
use App\ClinicService\Repository\ServiceBranchOverrideRepository;
/**
@@ -28,6 +30,7 @@ final class ServiceSelectionValidator
private readonly ServiceItemRelationRepository $relations,
private readonly ServiceBranchOverrideRepository $overrides,
private readonly DurationCalculator $durations,
private readonly PolicyResolver $policies,
) {}
/**
@@ -40,6 +43,7 @@ final class ServiceSelectionValidator
$errors = [
...$this->groupErrors($selected, $groups),
...$this->relationErrors($selected),
...$this->policyErrors($selected, $address),
];
$overrides = $address === null
@@ -58,6 +62,53 @@ final class ServiceSelectionValidator
];
}
/**
* ممنوعیت‌های دستهٔ «انتخاب» — لایه‌ای روی گروه و رابطه، نه جایگزینشان.
*
* گروه و رابطه ساختار ثابتِ کاتالوگ‌اند؛ قانون چیزی است که کلینیک بدون دست زدن
* به کاتالوگ روشن و خاموش می‌کند. بدون شعبه اجرا نمی‌شود چون محیط از آدرس
* می‌آید و بی‌آن هیچ محیطی برای جست‌وجو نیست.
*
* @param ServiceItem[] $selected
* @return list<array<string, mixed>>
*/
private function policyErrors(array $selected, ?DoctorAddress $address): array
{
if ($address === null || $selected === []) {
return [];
}
$facts = [
'item_count' => count($selected),
'item_uuids' => array_map(static fn (ServiceItem $i): string => $i->getUuid(), $selected),
];
$errors = [];
// هر آیتم جداگانه حل می‌شود: قانونی که دامنه‌اش یک سرویس خاص است فقط وقتی
// معنا دارد که همان سرویس در انتخاب باشد، و پیام خطا باید بگوید کدام.
foreach ($selected as $item) {
$outcome = $this->policies->resolve(
Policy::CATEGORY_SELECTION,
$address->tenantEntityType(),
$address->tenantEntityId(),
$facts + ['catalog_category' => $item->getCatalogCategory()?->getUuid()],
$address,
$item,
);
foreach ($outcome->forbidReasons as $reason) {
$errors[] = [
'code' => 'policy_forbidden',
'items' => [$item->getUuid()],
'message' => $reason,
];
}
}
return $errors;
}
/**
* @param ServiceItem[] $selected
* @param ItemGroup[] $groups