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
@@ -11,6 +11,7 @@ use App\Auth\Repository\UserRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Pricing\Entity\PriceSnapshot;
use App\Pricing\Service\PriceSnapshotService;
use App\Policy\Service\BookingPolicyGuard;
use App\Pricing\Service\PricingEngine;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
use App\Auth\Entity\User;
@@ -50,10 +51,35 @@ class BookingController extends BaseController
private readonly PricingEngine $pricing,
private readonly PriceSnapshotService $snapshots,
private readonly BranchResolver $branches,
private readonly BookingPolicyGuard $guard,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
) {}
/**
* پرچم‌هایی که فقط در همین درخواست وجود دارند و جایی ذخیره نمی‌شوند
* (مثل رضایت والدین که اپراتور همان لحظه می‌گیرد).
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function requestFlags(array $data): array
{
$flags = [];
foreach (['has_parental_consent'] as $flag) {
if (isset($data[$flag])) {
$flags[$flag] = (bool) $data[$flag];
}
}
if (is_string($data['patient_gender'] ?? null)) {
$flags['patient_gender'] = $data['patient_gender'];
}
return $flags;
}
#[Route('/api/v1/appointment-hold', name: 'appointment_hold_create', methods: ['POST'])]
public function create(#[CurrentUser] User $user, Request $request): JsonResponse
{
@@ -94,6 +120,10 @@ class BookingController extends BaseController
is_string($data['patient_gender'] ?? null) ? $data['patient_gender'] : null,
);
// قوانین وابسته به بیمار پیش از گرفتن صندلی اجرا می‌شوند، نه هنگام ثبت نهایی.
$this->guard->assertEligible($user, $service, $selected, $address, $this->requestFlags($data));
$this->guard->assertSpacing($user, $service, $address, (int) $data['start']);
$assignment = $this->resolveAssignment($user, $data['assignment']);
$this->assertAssignmentCoversPlan($plan, $assignment);