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);
@@ -16,6 +16,9 @@ use App\Resource\Entity\ResourceType;
use App\Resource\Repository\ClinicResourceRepository;
use App\Resource\Repository\ResourceTypeRepository;
use App\Shared\Constant\ErrorCodes;
use App\Policy\Entity\Policy;
use App\Policy\Service\PolicySchema;
use App\Policy\Service\PolicyResolver;
use App\Shared\Exception\AppException;
/**
@@ -27,6 +30,7 @@ use App\Shared\Exception\AppException;
final class AppointmentPlanBuilder
{
public function __construct(
private readonly PolicyResolver $policies,
private readonly SegmentTemplateRepository $templates,
private readonly ClinicResourceRepository $resources,
private readonly ResourceTypeRepository $types,
@@ -49,7 +53,10 @@ final class AppointmentPlanBuilder
// سرویسی که الگوی بخش ندارد، همان رفتار امروز را می‌گیرد: یک بخش پیوسته که
// پزشک را می‌گیرد. بدون این، هر سرویس موجود بی‌برنامه می‌شد.
if ($templates === []) {
return $this->singleSegmentPlan($service, $address, $itemMinutes, $patientGender);
$segments = $this->singleSegment($service, $address, $itemMinutes);
$offset = $itemMinutes;
return $this->finish($service, $selectedItems, $address, $segments, $offset);
}
$segments = [];
@@ -82,16 +89,99 @@ final class AppointmentPlanBuilder
$offset += $duration;
}
if ($offset > SegmentTemplate::MAX_TOTAL_MINUTES) {
return $this->finish($service, $selectedItems, $address, $segments, $offset);
}
/**
* پایان مشترک هر دو مسیر — با الگو و بی‌الگو.
*
* قوانین باید روی سرویسِ بی‌الگو هم اجرا شوند: «حداقل ۶۰ دقیقه برای این دسته»
* ربطی به این ندارد که کلینیک برای آن سرویس بخش تعریف کرده باشد یا نه.
*
* @param ServiceItem[] $selectedItems
* @param list<PlannedSegment> $segments
*/
private function finish(
ServiceItem $service,
array $selectedItems,
DoctorAddress $address,
array $segments,
int $total,
): AppointmentPlan {
// ── قوانین دستهٔ «زمان» ────────────────────────────────────────────
// اثرها روی **مجموع** نوبت اعمال می‌شوند نه روی یک بخش: «حداقل ۶۰ دقیقه»
// یعنی کل جلسه، و کوتاه کردنِ یک بخش برای رسیدن به آن معنا ندارد.
$total = $this->applyTimingPolicies($service, $selectedItems, $address, $segments, $total);
// ── قوانین دستهٔ «منبع» ─────────────────────────────────────────────
$segments = $this->applyResourcePolicies($service, $selectedItems, $address, $segments);
if ($total > SegmentTemplate::MAX_TOTAL_MINUTES) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('مجموع مدت بخش‌ها (%d دقیقه) از سقف %d دقیقه بیشتر است', $offset, SegmentTemplate::MAX_TOTAL_MINUTES),
sprintf('مجموع مدت بخش‌ها (%d دقیقه) از سقف %d دقیقه بیشتر است', $total, SegmentTemplate::MAX_TOTAL_MINUTES),
422,
'segments',
);
}
return new AppointmentPlan($segments, $offset);
return new AppointmentPlan($segments, $total);
}
/**
* قوانین «زمان»: حداقل مدت (بیشترین برنده) و افزودن مدت (جمع).
*
* @param ServiceItem[] $selectedItems
* @param list<PlannedSegment> $segments به‌صورت ارجاع تغییر می‌کند
*/
private function applyTimingPolicies(
ServiceItem $service,
array $selectedItems,
DoctorAddress $address,
array &$segments,
int $total,
): int {
$outcome = $this->policies->resolve(
Policy::CATEGORY_TIMING,
$address->tenantEntityType(),
$address->tenantEntityId(),
[
'service_uuid' => $service->getUuid(),
'item_count' => count($selectedItems),
'catalog_category' => $service->getCatalogCategory()?->getUuid(),
],
$address,
$service,
);
if ($outcome->effects === []) {
return $total;
}
$extra = (int) $outcome->effect(PolicySchema::EFFECT_ADD_DURATION, 0);
$minimum = (int) $outcome->effect(PolicySchema::EFFECT_MIN_DURATION, 0);
$target = max($total + $extra, $minimum);
if ($target === $total || $segments === []) {
return $total;
}
// مدت اضافه به **آخرین** بخش می‌رود: آفست بخش‌های قبلی نباید عوض شود، وگرنه
// برنامه‌ای که کاربر تأیید کرده زیر پایش جابه‌جا می‌شود.
$last = $segments[count($segments) - 1];
$grown = $last->durationMinutes + ($target - $total);
$segments[count($segments) - 1] = new PlannedSegment(
sequence: $last->sequence,
name: $last->name,
offsetMinutes: $last->offsetMinutes,
durationMinutes: $grown,
patientPresent: $last->patientPresent,
mergeable: $last->mergeable,
requirements: $last->requirements,
);
return $target;
}
/**
@@ -126,6 +216,148 @@ final class AppointmentPlanBuilder
return $ordered;
}
/**
* قوانین «منبع»: نقشی که قانون لازم می‌داند، اگر الگو نداشته باشد، اضافه می‌شود.
*
* نقشِ اضافه‌شده به **اولین بخشی که بیمار حاضر است** می‌چسبد، نه به همهٔ بخش‌ها:
* «سرپرست لازم است» یعنی سرپرست در جلسه حضور داشته باشد، نه اینکه تمام مدتِ
* آماده‌سازی هم اشغال شود.
*
* ممنوعیت هم اینجا خوانده می‌شود: قانونی که می‌گوید این ترکیب در این شعبه انجام
* نمی‌شود، پیش از رسیدن به موتور دسترس‌پذیری جلوی کار را می‌گیرد.
*
* @param ServiceItem[] $selectedItems
* @param list<PlannedSegment> $segments
* @return list<PlannedSegment>
*/
private function applyResourcePolicies(
ServiceItem $service,
array $selectedItems,
DoctorAddress $address,
array $segments,
): array {
if ($segments === []) {
return $segments;
}
$outcome = $this->policies->resolve(
Policy::CATEGORY_RESOURCE,
$address->tenantEntityType(),
$address->tenantEntityId(),
[
'service_uuid' => $service->getUuid(),
'catalog_category' => $service->getCatalogCategory()?->getUuid(),
'item_count' => count($selectedItems),
],
$address,
$service,
);
if ($outcome->isForbidden()) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
implode(' ', $outcome->forbidReasons),
422,
'service_uuid',
);
}
$required = (array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_RESOURCE, []);
if ($required === []) {
return $segments;
}
$present = [];
foreach ($segments as $segment) {
foreach ($segment->requirements as $requirement) {
$present[$requirement->role] = true;
}
}
$targetIndex = $this->firstPatientPresentIndex($segments);
$extra = [];
foreach ($required as $code) {
if (!is_string($code) || isset($present[$code])) {
continue;
}
$extra[] = $this->requirementForRole($code, $address);
}
if ($extra === []) {
return $segments;
}
$target = $segments[$targetIndex];
$segments[$targetIndex] = new PlannedSegment(
sequence: $target->sequence,
name: $target->name,
offsetMinutes: $target->offsetMinutes,
durationMinutes: $target->durationMinutes,
patientPresent: $target->patientPresent,
mergeable: $target->mergeable,
requirements: [...$target->requirements, ...$extra],
);
return array_values($segments);
}
/** @param list<PlannedSegment> $segments */
private function firstPatientPresentIndex(array $segments): int
{
foreach ($segments as $index => $segment) {
if ($segment->patientPresent) {
return $index;
}
}
return 0;
}
/**
* قانونی که نقشِ ناشناخته یا بی‌منبع می‌خواهد **خطاست، نه بی‌اثر**: در سکوت رد
* کردنش یعنی کلینیک فکر کند قانونش اجرا می‌شود در حالی که هیچ‌وقت نشده.
*/
private function requirementForRole(string $code, DoctorAddress $address): PlannedRequirement
{
$type = $this->types->findByCode($address->tenantEntityType(), $address->tenantEntityId(), $code);
if ($type === null) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('قانون منبعی نقش «%s» را لازم دارد که در این محیط تعریف نشده است', $code),
422,
'requirements',
);
}
$eligible = array_values($this->resources->findEligible($address, $type, []));
if ($eligible === []) {
throw new AppException(
ErrorCodes::ERR_NO_ELIGIBLE_RESOURCE,
sprintf('هیچ %s در شعبهٔ «%s» موجود نیست', $type->getName(), $address->getName() ?? '—'),
422,
'requirements',
);
}
return new PlannedRequirement(
role: $type->getCode(),
roleName: $type->getName(),
count: 1,
occupancy: SegmentRequirement::OCCUPANCY_EXCLUSIVE,
constraints: [],
eligible: $eligible,
skillName: null,
setupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getSetupMinutes()),
cleanupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getCleanupMinutes()),
);
}
/** @return list<PlannedRequirement> */
private function planRequirements(
SegmentTemplate $template,
@@ -219,12 +451,12 @@ final class AppointmentPlanBuilder
/**
* رفتار امروز، بیان‌شده به زبان برنامه: یک بخش پیوسته که پزشک را می‌گیرد.
*/
private function singleSegmentPlan(
/** @return list<PlannedSegment> */
private function singleSegment(
ServiceItem $service,
DoctorAddress $address,
int $minutes,
?string $patientGender,
): AppointmentPlan {
): array {
if ($minutes <= 0) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
@@ -255,18 +487,15 @@ final class AppointmentPlanBuilder
cleanupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getCleanupMinutes()),
)];
return new AppointmentPlan(
[new PlannedSegment(
sequence: 1,
name: $service->getName(),
offsetMinutes: 0,
durationMinutes: $minutes,
patientPresent: true,
mergeable: false,
requirements: $requirements,
)],
$minutes,
);
return [new PlannedSegment(
sequence: 1,
name: $service->getName(),
offsetMinutes: 0,
durationMinutes: $minutes,
patientPresent: true,
mergeable: false,
requirements: $requirements,
)];
}
/** @param ClinicResource[] $resources */