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
+60
View File
@@ -9,6 +9,9 @@ use App\Doctor\Entity\DoctorAddress;
use App\Pricing\Repository\PriceListItemRepository;
use App\Pricing\Repository\PriceListRepository;
use App\Pricing\ValueObject\PriceQuote;
use App\Policy\Entity\Policy;
use App\Policy\Service\PolicyResolver;
use App\Policy\Service\PolicySchema;
use App\Representation\Service\JalaliDateService;
/**
@@ -33,6 +36,7 @@ use App\Representation\Service\JalaliDateService;
final class PricingEngine
{
public function __construct(
private readonly PolicyResolver $policies,
private readonly PriceListRepository $priceLists,
private readonly PriceListItemRepository $priceListItems,
private readonly ServiceBranchOverrideRepository $overrides,
@@ -73,6 +77,10 @@ final class PricingEngine
$subtotal = $base + $itemsTotal;
// ── تخفیف ─────────────────────────────────────────────────────────────
// قوانین دستهٔ «قیمت» کنار سیاست دستیِ درخواست می‌نشینند، نه به‌جایش: تخفیفی
// که اپراتور دستی می‌دهد و تخفیفی که قانون می‌دهد هر دو واقعی‌اند.
$policy = $this->mergePolicyDiscounts($service, $items, $address, $at, $subtotal, $policy, $sources);
[$discount, $discounts] = $this->discountFor($subtotal, $policy);
// تخفیف بیشتر از مبلغ، مبلغ را **صفر** می‌کند نه منفی: بدهی منفی یعنی کلینیک
@@ -116,6 +124,58 @@ final class PricingEngine
);
}
/**
* اثر قوانین «قیمت» را به سیاست درخواست اضافه می‌کند.
*
* شناسه و **نسخهٔ** هر قانون در `sources` ثبت می‌شود تا فاکتور بتواند سه ماه بعد
* بگوید کدام نسخه رویش اعمال شده بود.
*
* @param ServiceItem[] $items
* @param array<string, mixed> $policy
* @param array<string, mixed> $sources
* @return array<string, mixed>
*/
private function mergePolicyDiscounts(
ServiceItem $service,
array $items,
DoctorAddress $address,
int $at,
int $subtotal,
array $policy,
array &$sources,
): array {
$outcome = $this->policies->resolve(
Policy::CATEGORY_PRICING,
$address->tenantEntityType(),
$address->tenantEntityId(),
[
'item_count' => count($items),
'subtotal_rials' => $subtotal,
'patient_tags' => $policy['patient_tags'] ?? [],
'visit_count' => $policy['visit_count'] ?? 0,
],
$address,
$service,
$at,
);
if ($outcome->appliedPolicies === []) {
return $policy;
}
$sources['applied_policies'] = $outcome->appliedPolicies;
$policy['discount_percent'] = (float) ($policy['discount_percent'] ?? 0)
+ (float) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_PERCENT, 0);
$policy['discount_rials'] = (int) ($policy['discount_rials'] ?? 0)
+ (int) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_RIALS, 0);
$policy['discount_label'] ??= $outcome->appliedPolicies[0]['name'];
return $policy;
}
/**
* @param array<string, string> $sources
*/