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