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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user