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 */
@@ -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
+242
View File
@@ -0,0 +1,242 @@
<?php
namespace App\Policy\Controller;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\ClinicService\Repository\CatalogCategoryRepository;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Policy\Entity\Policy;
use App\Policy\Entity\PolicyVersionLog;
use App\Policy\Repository\PolicyRepository;
use App\Policy\Repository\PolicyVersionLogRepository;
use App\Policy\Service\ConditionEvaluator;
use App\Policy\Service\PolicySchema;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Shared\Tenant\TenantOwnershipChecker;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Policy')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class PolicyController extends BaseController
{
public function __construct(
private readonly PolicyRepository $policies,
private readonly PolicyVersionLogRepository $versions,
private readonly ConditionEvaluator $evaluator,
private readonly PolicySchema $schema,
private readonly ServiceItemRepository $items,
private readonly CatalogCategoryRepository $categories,
private readonly BranchResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
) {}
/**
* فهرست بستهٔ فیلدها، عملگرها و اثرها.
*
* فرم ساخت قانون در پنل از **همین** ساخته می‌شود، نه از فهرستی که در فرانت دوباره
* نوشته شده باشد — دو فهرست یعنی دو حقیقت و یکی از آن‌ها همیشه قدیمی است.
*/
#[Route('/api/v1/policy-schema', name: 'policy_schema', methods: ['GET'])]
public function schema(): JsonResponse
{
return $this->success($this->schema->describe());
}
#[Route('/api/v1/policies', name: 'policy_index', methods: ['GET'])]
public function index(#[CurrentUser] User $user, Request $request): JsonResponse
{
[$entityType, $entityId] = $this->branches->pair($user);
$category = $request->query->get('category');
if (is_string($category) && $category !== '' && !in_array($category, Policy::CATEGORIES, true)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دستهٔ قانون نامعتبر است', 422, 'category');
}
return $this->success(array_map(
static fn (Policy $p): array => $p->toArray(),
$this->policies->findForPair($entityType, $entityId, is_string($category) && $category !== '' ? $category : null),
));
}
#[Route('/api/v1/policy', name: 'policy_create', methods: ['POST'])]
public function create(#[CurrentUser] User $user, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_string($data['category'] ?? null) || !in_array($data['category'], Policy::CATEGORIES, true)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دستهٔ قانون نامعتبر است', 422, 'category');
}
if (!is_string($data['name'] ?? null) || trim($data['name']) === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام قانون الزامی است', 422, 'name');
}
[$entityType, $entityId] = $this->branches->pair($user);
$policy = new Policy($entityType, $entityId, $data['category'], trim($data['name']));
$this->apply($user, $policy, $data);
$this->em->persist($policy);
$this->em->flush();
$this->em->persist(new PolicyVersionLog($policy, $policy->getVersion(), $policy->toArray()));
$this->em->flush();
return $this->success($policy->toArray(), 201);
}
#[Route('/api/v1/policy/{uuid}', name: 'policy_show', methods: ['GET'])]
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$policy = $this->requirePolicy($user, $uuid);
return $this->success($policy->toArray() + [
'versions' => array_map(
static fn (PolicyVersionLog $l): array => $l->toArray(),
$this->versions->findForPolicy($policy),
),
]);
}
/**
* نسخهٔ جدید — قانون **ویرایش نمی‌شود**.
*
* نوبتی که دیروز ثبت شده نسخهٔ قبلی را در فاکتورش نگه داشته؛ بازنویسی درجا یعنی
* آن ارجاع به متنی اشاره کند که هرگز روی آن نوبت اعمال نشده بود.
*/
#[Route('/api/v1/policy/{uuid}/version', name: 'policy_version', methods: ['POST'])]
public function version(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$policy = $this->requirePolicy($user, $uuid);
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
$this->apply($user, $policy, $data);
$policy->bumpVersion();
$this->em->persist(new PolicyVersionLog($policy, $policy->getVersion(), $policy->toArray()));
$this->em->flush();
return $this->success($policy->toArray());
}
#[Route('/api/v1/policy/{uuid}/activate', name: 'policy_activate', methods: ['POST'])]
public function activate(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$policy = $this->requirePolicy($user, $uuid)->setActive(true);
$this->em->flush();
return $this->success($policy->toArray());
}
#[Route('/api/v1/policy/{uuid}/deactivate', name: 'policy_deactivate', methods: ['POST'])]
public function deactivate(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$policy = $this->requirePolicy($user, $uuid)->setActive(false);
$this->em->flush();
return $this->success($policy->toArray());
}
/** @param array<string, mixed> $data */
private function apply(User $user, Policy $policy, array $data): void
{
if (is_string($data['name'] ?? null) && trim($data['name']) !== '') {
$policy->setName(trim($data['name']));
}
if (is_array($data['condition'] ?? null)) {
// اعتبارسنجی در **زمان ساخت**: قانونی که موقع رزرو بیمار بترکد، بدترین
// جای ممکن برای شکستن است.
$this->evaluator->assertValid($policy->getCategory(), $data['condition']);
$policy->setCondition($data['condition']);
}
if (is_array($data['effects'] ?? null)) {
$this->evaluator->assertEffectsValid($policy->getCategory(), $data['effects']);
$policy->setEffects(array_values($data['effects']));
}
if (is_numeric($data['priority'] ?? null)) {
$policy->setPriority((int) $data['priority']);
}
if (array_key_exists('valid_from', $data) || array_key_exists('valid_to', $data)) {
try {
$policy->setValidity(
is_numeric($data['valid_from'] ?? null) ? (int) $data['valid_from'] : null,
is_numeric($data['valid_to'] ?? null) ? (int) $data['valid_to'] : null,
);
} catch (\InvalidArgumentException) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'پایان اعتبار باید بعد از شروع آن باشد', 422, 'valid_to');
}
}
if (array_key_exists('address_uuid', $data)) {
$policy->setAddress(is_string($data['address_uuid']) ? $this->branches->resolve($user, $data['address_uuid']) : null);
}
if (array_key_exists('service_uuid', $data)) {
$policy->setServiceItem(is_string($data['service_uuid']) ? $this->requireItem($user, $data['service_uuid']) : null);
}
if (array_key_exists('catalog_category_uuid', $data)) {
$policy->setCatalogCategory(
is_string($data['catalog_category_uuid']) ? $this->requireCategory($user, $data['catalog_category_uuid']) : null,
);
}
}
private function requirePolicy(User $user, string $uuid): Policy
{
$policy = $this->policies->findByUuid($uuid);
[$entityType, $entityId] = $this->branches->pair($user);
if ($policy === null || !$this->ownership->belongsToPair($entityType, $entityId, $policy)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'قانون یافت نشد', 404);
}
return $policy;
}
private function requireItem(User $user, string $uuid): \App\ClinicService\Entity\ServiceItem
{
$item = $this->items->findByUuid($uuid);
[$entityType, $entityId] = $this->branches->pair($user);
if ($item === null
|| $item->getSection()->getEntityType() !== $entityType
|| $item->getSection()->getEntityId() !== $entityId
) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404);
}
return $item;
}
private function requireCategory(User $user, string $uuid): \App\ClinicService\Entity\CatalogCategory
{
$category = $this->categories->findByUuid($uuid);
[$entityType, $entityId] = $this->branches->pair($user);
if ($category === null || !$this->ownership->belongsToPair($entityType, $entityId, $category)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'دسته یافت نشد', 404);
}
return $category;
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
namespace App\Policy\Entity;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\ServiceItem;
use App\Doctor\Entity\DoctorAddress;
use App\Policy\Repository\PolicyRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* یک قانون از شش دستهٔ بند ۸ مستند.
*
* سه چیز عمداً **نیست**: کد دلخواه، شرط آزاد، و ویرایش درجا.
*
* - شرط فقط از فهرست بستهٔ {@see \App\Policy\Service\PolicySchema} می‌آید. قانونی که
* بتواند هر عبارتی را ارزیابی کند، دیگر قابل تحلیل ایستا نیست و دستهٔ `spacing`
* هرگز به کوئری تبدیل نمی‌شود.
* - قانون **ویرایش نمی‌شود، نسخه می‌گیرد**. نوبتی که دیروز ثبت شده باید همان نسخه‌ای
* را که رویش اعمال شده نگه دارد (قانون پنجم مستند).
*/
#[ORM\Entity(repositoryClass: PolicyRepository::class)]
#[ORM\Table(name: 'policies')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'category', 'active'], name: 'idx_policy_tenant_category')]
#[ORM\Index(columns: ['valid_from', 'valid_to'], name: 'idx_policy_validity')]
class Policy
{
use TenantOwnedTrait;
public const CATEGORY_SELECTION = 'selection';
public const CATEGORY_ELIGIBILITY = 'eligibility';
public const CATEGORY_RESOURCE = 'resource';
public const CATEGORY_TIMING = 'timing';
public const CATEGORY_SPACING = 'spacing';
public const CATEGORY_PRICING = 'pricing';
public const CATEGORIES = [
self::CATEGORY_SELECTION,
self::CATEGORY_ELIGIBILITY,
self::CATEGORY_RESOURCE,
self::CATEGORY_TIMING,
self::CATEGORY_SPACING,
self::CATEGORY_PRICING,
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 20)]
private string $category;
#[ORM\Column(type: 'string', length: 200)]
private string $name;
/**
* نام ستون `condition` نیست چون در MariaDB کلمهٔ کلیدی است و هر INSERT را
* می‌شکند؛ نام فیلد در API همان `condition` می‌ماند.
*
* @var array{match?: string, conditions?: list<array<string, mixed>>}
*/
#[ORM\Column(name: 'condition_json', type: 'json')]
private array $condition = [];
/** @var list<array<string, mixed>> */
#[ORM\Column(type: 'json')]
private array $effects = [];
/** بزرگ‌تر یعنی مهم‌تر. اولین معیار حل تناقض. */
#[ORM\Column(type: 'smallint', options: ['default' => 0])]
private int $priority = 0;
// ── دامنه: هرچه باریک‌تر، در تساویِ اولویت برنده‌تر ──────────────────────
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?DoctorAddress $address = null;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_item_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?ServiceItem $serviceItem = null;
#[ORM\ManyToOne(targetEntity: CatalogCategory::class)]
#[ORM\JoinColumn(name: 'catalog_category_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?CatalogCategory $catalogCategory = null;
#[ORM\Column(name: 'valid_from', type: 'integer', nullable: true)]
private ?int $validFrom = null;
#[ORM\Column(name: 'valid_to', type: 'integer', nullable: true)]
private ?int $validTo = null;
#[ORM\Column(type: 'smallint', options: ['default' => 1])]
private int $version = 1;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $active = false;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $entityType, int $entityId, string $category, string $name)
{
if (!in_array($category, self::CATEGORIES, true)) {
throw new \InvalidArgumentException(sprintf('Unknown policy category "%s".', $category));
}
$this->uuid = Uuid::v4()->toRfc4122();
$this->category = $category;
$this->name = $name;
$this->createdAt = time();
$this->updatedAt = time();
$this->assignTenantPair($entityType, $entityId);
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getCategory(): string { return $this->category; }
public function getName(): string { return $this->name; }
public function getCondition(): array { return $this->condition; }
public function getEffects(): array { return $this->effects; }
public function getPriority(): int { return $this->priority; }
public function getVersion(): int { return $this->version; }
public function isActive(): bool { return $this->active; }
public function getValidFrom(): ?int { return $this->validFrom; }
public function getValidTo(): ?int { return $this->validTo; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getAddress(): ?DoctorAddress { return $this->address; }
public function getServiceItem(): ?ServiceItem { return $this->serviceItem; }
public function getCatalogCategory(): ?CatalogCategory { return $this->catalogCategory; }
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
public function setPriority(int $v): self { $this->priority = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
public function setAddress(?DoctorAddress $v): self { $this->address = $v; $this->touch(); return $this; }
public function setServiceItem(?ServiceItem $v): self { $this->serviceItem = $v; $this->touch(); return $this; }
public function setCatalogCategory(?CatalogCategory $v): self { $this->catalogCategory = $v; $this->touch(); return $this; }
/** @param array<string, mixed> $condition */
public function setCondition(array $condition): self { $this->condition = $condition; $this->touch(); return $this; }
/** @param list<array<string, mixed>> $effects */
public function setEffects(array $effects): self { $this->effects = $effects; $this->touch(); return $this; }
public function setValidity(?int $from, ?int $to): self
{
if ($from !== null && $to !== null && $to <= $from) {
throw new \InvalidArgumentException('Policy validity end must be after its start.');
}
$this->validFrom = $from;
$this->validTo = $to;
$this->touch();
return $this;
}
public function bumpVersion(): self { $this->version++; $this->touch(); return $this; }
public function appliesAt(int $at): bool
{
return $this->active
&& ($this->validFrom === null || $at >= $this->validFrom)
&& ($this->validTo === null || $at < $this->validTo);
}
/**
* هرچه باریک‌تر، بزرگ‌تر. در تساویِ اولویت، اختصاصی‌تر برنده است — «این سرویس» باید
* بتواند «همهٔ سرویس‌ها» را کنار بزند، وگرنه استثنا غیرقابل بیان می‌شود.
*/
public function specificity(): int
{
return ($this->address !== null ? 4 : 0)
+ ($this->serviceItem !== null ? 2 : 0)
+ ($this->catalogCategory !== null ? 1 : 0);
}
private function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'category' => $this->category,
'name' => $this->name,
'condition' => (object) $this->condition,
'effects' => $this->effects,
'priority' => $this->priority,
'version' => $this->version,
'active' => $this->active,
'valid_from' => $this->validFrom,
'valid_to' => $this->validTo,
'address_uuid' => $this->address?->getUuid(),
'service_uuid' => $this->serviceItem?->getUuid(),
'catalog_category_uuid' => $this->catalogCategory?->getUuid(),
'specificity' => $this->specificity(),
];
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Policy\Entity;
use App\Policy\Repository\PolicyVersionLogRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* عکس هر نسخهٔ یک قانون.
*
* نوبتی که دیروز ثبت شده، نسخه‌ای را در `applied_policies` نگه می‌دارد که ممکن است
* امروز دیگر متن فعلی قانون نباشد. بدون این جدول، «چرا آن نوبت این قیمت را گرفت؟»
* سه ماه بعد بی‌جواب می‌ماند.
*
* فرزند aggregate با ریشهٔ {@see Policy}؛ فقط نوشته می‌شود و هرگز ویرایش نمی‌شود.
*/
#[ORM\Entity(repositoryClass: PolicyVersionLogRepository::class)]
#[ORM\Table(name: 'policy_version_logs')]
#[ORM\UniqueConstraint(name: 'uniq_policy_version', columns: ['policy_id', 'version'])]
class PolicyVersionLog
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Policy::class)]
#[ORM\JoinColumn(name: 'policy_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Policy $policy;
#[ORM\Column(type: 'smallint')]
private int $version;
#[ORM\Column(type: 'json')]
private array $snapshot;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(Policy $policy, int $version, array $snapshot)
{
$this->policy = $policy;
$this->version = $version;
$this->snapshot = $snapshot;
$this->createdAt = time();
}
public function getId(): ?int { return $this->id; }
public function getPolicy(): Policy { return $this->policy; }
public function getVersion(): int { return $this->version; }
public function getSnapshot(): array { return $this->snapshot; }
public function toArray(): array
{
return [
'version' => $this->version,
'snapshot' => $this->snapshot,
'created_at' => $this->createdAt,
];
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Policy\Repository;
use App\Policy\Entity\Policy;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Policy>
*/
class PolicyRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Policy::class);
}
public function findByUuid(string $uuid): ?Policy
{
return $this->findOneBy(['uuid' => $uuid]);
}
/**
* قانون‌های فعالِ یک دسته — یک کوئری per دسته، نه per قانون.
*
* @return Policy[]
*/
public function findForCategory(string $entityType, int $entityId, string $category): array
{
return $this->createQueryBuilder('p')
->addSelect('a', 's', 'c')
->leftJoin('p.address', 'a')
->leftJoin('p.serviceItem', 's')
->leftJoin('p.catalogCategory', 'c')
->where('p.entityType = :type')
->andWhere('p.entityId = :id')
->andWhere('p.category = :category')
->andWhere('p.active = true')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->setParameter('category', $category)
->getQuery()
->getResult();
}
/** @return Policy[] */
public function findForPair(string $entityType, int $entityId, ?string $category = null): array
{
$qb = $this->createQueryBuilder('p')
->where('p.entityType = :type')
->andWhere('p.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->orderBy('p.priority', 'DESC')
->addOrderBy('p.createdAt', 'ASC');
if ($category !== null) {
$qb->andWhere('p.category = :category')->setParameter('category', $category);
}
return $qb->getQuery()->getResult();
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Policy\Repository;
use App\Policy\Entity\Policy;
use App\Policy\Entity\PolicyVersionLog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PolicyVersionLog>
*/
class PolicyVersionLogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PolicyVersionLog::class);
}
/** @return PolicyVersionLog[] */
public function findForPolicy(Policy $policy): array
{
return $this->createQueryBuilder('l')
->where('l.policy = :policy')
->setParameter('policy', $policy)
->orderBy('l.version', 'ASC')
->getQuery()
->getResult();
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
namespace App\Policy\Service;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\ClinicService\Entity\ServiceItem;
use App\Doctor\Entity\DoctorAddress;
use App\Policy\Entity\Policy;
use App\Policy\ValueObject\PolicyOutcome;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\UserProfile\Entity\UserProfile;
use Doctrine\ORM\EntityManagerInterface;
/**
* دو دستهٔ قانون که به **بیمار** وابسته‌اند، نه به سرویس: «صلاحیت» و «فاصله».
*
* جای اجرایشان لحظهٔ رزرو موقت است نه ثبت نهایی: کاربری که ده دقیقه صندلی گرفته و
* بعد می‌شنود «شما واجد شرایط نیستید» هم وقت خودش را تلف کرده هم صندلی را.
*
* حقایق از پروفایل و تاریخچهٔ بیمار خوانده می‌شوند نه از بدنهٔ درخواست — با یک
* استثنا: `has_parental_consent` چیزی است که اپراتور همان لحظه تأیید می‌کند و
* جایی برای ذخیره ندارد.
*/
final class BookingPolicyGuard
{
public function __construct(
private readonly PolicyResolver $policies,
private readonly EntityManagerInterface $em,
) {}
/**
* @param ServiceItem[] $items
* @param array<string, mixed> $extraFacts حقایقی که فقط در همین درخواست وجود دارند
*
* @throws AppException ۴۲۲ اگر قانونی این بیمار را ممنوع کند
*/
public function assertEligible(
User $patient,
ServiceItem $service,
array $items,
DoctorAddress $address,
array $extraFacts = [],
?int $at = null,
): PolicyOutcome {
$at = $at ?? time();
$outcome = $this->policies->resolve(
Policy::CATEGORY_ELIGIBILITY,
$address->tenantEntityType(),
$address->tenantEntityId(),
$this->patientFacts($patient, $address, $at) + $extraFacts + ['item_count' => count($items) + 1],
$address,
$service,
$at,
);
if ($outcome->isForbidden()) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
implode(' ', $outcome->forbidReasons),
422,
);
}
// `require_flag` ممنوعیت نیست، شرط است: تا وقتی اپراتور آن پرچم را نفرستاده
// درخواست ناقص است، و بعد از فرستادنش قانون راضی است.
$missing = array_values(array_filter(
(array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_FLAG, []),
static fn (string $flag): bool => empty($extraFacts[$flag]),
));
if ($missing !== []) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_002,
sprintf('برای این نوبت تأیید %s الزامی است', implode('، ', $missing)),
422,
$missing[0],
);
}
return $outcome;
}
/**
* حداقل فاصله تا نوبت قبلیِ **همان دسته** — بند «فاصلهٔ بین جلسات».
*
* مبنا نوبت قبلی است نه نوبت بعدی: قانون می‌گوید بعد از هر جلسه چقدر باید صبر
* کرد، پس رزرو آینده‌ای که هنوز انجام نشده معیار نیست.
*
* @throws AppException ۴۲۲ اگر فاصله کافی نباشد
*/
public function assertSpacing(
User $patient,
ServiceItem $service,
DoctorAddress $address,
int $startsAt,
?int $at = null,
): void {
$at = $at ?? time();
$outcome = $this->policies->resolve(
Policy::CATEGORY_SPACING,
$address->tenantEntityType(),
$address->tenantEntityId(),
[
'service_uuid' => $service->getUuid(),
'catalog_category' => $service->getCatalogCategory()?->getUuid(),
],
$address,
$service,
$at,
);
$minDays = (int) $outcome->effect(PolicySchema::EFFECT_MIN_DAYS_BETWEEN, 0);
if ($minDays <= 0) {
return;
}
$last = $this->lastAppointmentAt($patient, $service, $startsAt);
if ($last === null) {
return;
}
$gapDays = (int) floor(($startsAt - $last) / 86400);
if ($gapDays < $minDays) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('بین دو جلسهٔ این خدمت باید حداقل %d روز فاصله باشد', $minDays),
422,
);
}
}
/**
* @return array<string, mixed>
*/
private function patientFacts(User $patient, DoctorAddress $address, int $at): array
{
$profile = $this->em->getRepository(UserProfile::class)->findOneBy(['user' => $patient]);
return [
'patient_age' => $this->ageOf($profile?->getDateOfBirth(), $at),
'patient_gender' => $profile?->getGender(),
'patient_tags' => [],
'visit_count' => $this->visitCount($patient, $address),
];
}
/** سن با سال میانگین گریگوری حساب می‌شود؛ اختلافش با شمسی در مرز سن صفر است. */
private function ageOf(?int $dateOfBirth, int $at): ?int
{
if ($dateOfBirth === null || $dateOfBirth <= 0) {
return null;
}
return (int) floor(($at - $dateOfBirth) / 31556952);
}
private function visitCount(User $patient, DoctorAddress $address): int
{
return (int) $this->em->createQueryBuilder()
->select('COUNT(a.id)')
->from(Appointment::class, 'a')
->where('a.user = :user')
->andWhere('a.status = :status')
->setParameter('user', $patient)
->setParameter('status', Appointment::STATUS_COMPLETED)
->getQuery()
->getSingleScalarResult();
}
/**
* آخرین نوبتِ گذشتهٔ بیمار از همان دستهٔ کاتالوگ — یا از همان سرویس اگر دسته ندارد.
*/
private function lastAppointmentAt(User $patient, ServiceItem $service, int $before): ?int
{
$qb = $this->em->createQueryBuilder()
->select('MAX(a.slotStart)')
->from(Appointment::class, 'a')
->join('a.serviceItem', 'si')
->where('a.user = :user')
->andWhere('a.slotStart < :before')
->andWhere('a.status NOT IN (:dead)')
->setParameter('user', $patient)
->setParameter('before', $before)
->setParameter('dead', [
Appointment::STATUS_CANCELLED_BY_DOCTOR,
Appointment::STATUS_CANCELLED_BY_USER,
Appointment::STATUS_EXPIRED,
]);
$category = $service->getCatalogCategory();
if ($category !== null) {
$qb->andWhere('si.catalogCategory = :category')->setParameter('category', $category);
} else {
$qb->andWhere('si = :service')->setParameter('service', $service);
}
$result = $qb->getQuery()->getSingleScalarResult();
return $result === null ? null : (int) $result;
}
}
+187
View File
@@ -0,0 +1,187 @@
<?php
namespace App\Policy\Service;
use App\Policy\Entity\Policy;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* ارزیابی شرط یک قانون در برابر «حقایق» یک درخواست.
*
* هیچ کد دلخواهی اجرا نمی‌شود: فیلد باید در فهرست بستهٔ {@see PolicySchema} باشد و
* عملگر از شش عملگر ثابت. شرطی که فیلد ناشناخته دارد **در زمان ساخت** رد می‌شود، نه
* در زمان اجرا — قانونی که موقع رزرو بیمار بترکد، بدترین جای ممکن برای شکستن است.
*/
final class ConditionEvaluator
{
public function __construct(
private readonly PolicySchema $schema,
) {}
/**
* @param array<string, mixed> $facts
*/
public function matches(Policy $policy, array $facts): bool
{
$condition = $policy->getCondition();
$conditions = $condition['conditions'] ?? [];
// شرط خالی یعنی «همیشه» — قانونِ بی‌قید و شرط کاملاً معتبر است.
if ($conditions === []) {
return true;
}
$mode = ($condition['match'] ?? 'all') === 'any' ? 'any' : 'all';
foreach ($conditions as $clause) {
$result = $this->evaluateClause($clause, $facts);
if ($mode === 'any' && $result) {
return true;
}
if ($mode === 'all' && !$result) {
return false;
}
}
return $mode === 'all';
}
/** @param array<string, mixed> $facts */
private function evaluateClause(mixed $clause, array $facts): bool
{
if (!is_array($clause) || !is_string($clause['field'] ?? null)) {
return false;
}
$field = $clause['field'];
$operator = $clause['operator'] ?? PolicySchema::OP_EQUALS;
$expected = $clause['value'] ?? null;
// فیلدی که در حقایق این درخواست نیست، شرط را **رد** می‌کند نه اینکه نادیده
// بگیرد: قانون «سن زیر ۱۸» وقتی سن نامشخص است نباید بی‌صدا صادق شود.
if (!array_key_exists($field, $facts)) {
return false;
}
return $this->compare($facts[$field], $operator, $expected);
}
private function compare(mixed $actual, string $operator, mixed $expected): bool
{
return match ($operator) {
PolicySchema::OP_EQUALS => $this->looselyEqual($actual, $expected),
PolicySchema::OP_NOT_EQUALS => !$this->looselyEqual($actual, $expected),
PolicySchema::OP_GREATER_THAN => is_numeric($actual) && is_numeric($expected) && $actual > $expected,
PolicySchema::OP_LESS_THAN => is_numeric($actual) && is_numeric($expected) && $actual < $expected,
PolicySchema::OP_IN => is_array($expected) && in_array($actual, $expected, false),
PolicySchema::OP_CONTAINS => is_array($actual) && in_array($expected, $actual, false),
default => false,
};
}
private function looselyEqual(mixed $a, mixed $b): bool
{
// مقایسهٔ سست عمدی است: مقدارِ آمده از JSON ممکن است "18" باشد و حقیقت 18.
// مقایسهٔ سخت اینجا فقط باگ‌های خاموش می‌ساخت.
return is_scalar($a) && is_scalar($b) ? $a == $b : $a === $b;
}
/**
* اعتبارسنجی ساختار شرط در **زمان ساخت**.
*
* @param array<string, mixed> $condition
* @throws AppException
*/
public function assertValid(string $category, array $condition): void
{
// کلید ناشناس در ریشهٔ شرط **خطاست**: `{"all": [...]}` به‌جای
// `{"match": "all", "conditions": [...]}` شرطی خالی می‌سازد که همیشه صادق
// است — یعنی قانون روی همه‌چیز اجرا می‌شود بی‌آنکه کسی بفهمد.
$unknown = array_diff(array_keys($condition), ['match', 'conditions']);
if ($unknown !== []) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('کلید «%s» در شرط شناخته نمی‌شود؛ ساختار درست {match, conditions} است', (string) reset($unknown)),
422,
'condition',
);
}
if (isset($condition['match']) && !in_array($condition['match'], ['all', 'any'], true)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مقدار match باید all یا any باشد', 422, 'condition');
}
if (isset($condition['conditions']) && !is_array($condition['conditions'])) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'conditions باید فهرست باشد', 422, 'condition');
}
foreach (($condition['conditions'] ?? []) as $clause) {
if (!is_array($clause) || !is_string($clause['field'] ?? null)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'هر شرط باید فیلد داشته باشد', 422, 'condition');
}
if (!$this->schema->allowsField($category, $clause['field'])) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf(
'فیلد «%s» برای دستهٔ «%s» مجاز نیست. مجازها: %s',
$clause['field'],
$category,
implode('، ', PolicySchema::FIELDS[$category] ?? []),
),
422,
'condition',
);
}
$operator = $clause['operator'] ?? PolicySchema::OP_EQUALS;
if (!in_array($operator, PolicySchema::OPERATORS, true)) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('عملگر «%s» شناخته نمی‌شود', is_string($operator) ? $operator : '—'),
422,
'condition',
);
}
}
}
/**
* ورودی مستقیم از JSON کاربر می‌آید، پس نوعش `mixed` است نه آرایهٔ ساختاریافته —
* اعتبارسنجی همین‌جا همان چیزی است که ساختار را تضمین می‌کند.
*
* @param list<mixed> $effects
* @throws AppException
*/
public function assertEffectsValid(string $category, array $effects): void
{
if ($effects === []) {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'قانون باید حداقل یک اثر داشته باشد', 422, 'effects');
}
foreach ($effects as $effect) {
if (!is_array($effect) || !is_string($effect['type'] ?? null)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'هر اثر باید نوع داشته باشد', 422, 'effects');
}
if (!$this->schema->allowsEffect($category, $effect['type'])) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf(
'اثر «%s» با دستهٔ «%s» سازگار نیست. مجازها: %s',
$effect['type'],
$category,
implode('، ', PolicySchema::EFFECTS[$category] ?? []),
),
422,
'effects',
);
}
}
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
namespace App\Policy\Service;
use App\ClinicService\Entity\ServiceItem;
use App\Doctor\Entity\DoctorAddress;
use App\Policy\Entity\Policy;
use App\Policy\Repository\PolicyRepository;
use App\Policy\ValueObject\PolicyOutcome;
/**
* انتخاب قانون‌های مرتبط، حل تناقض، و ترکیب اثرها.
*
* ## ترتیب حل تناقض (بند ۸ مستند)
*
* ۱. **اولویت** بزرگ‌تر
* ۲. در تساوی: **اختصاصی‌تر** (شعبه بر محیط، سرویس بر دسته)
* ۳. باز هم تساوی: قانون **قدیمی‌تر**
*
* قاعدهٔ سوم عمداً «قدیمی‌تر» است نه «تازه‌تر»: قانونی که مدت‌هاست کار می‌کند رفتار
* جاافتادهٔ کلینیک است و قانون تازه‌ای که تصادفاً هم‌اولویت شده نباید بی‌صدا عوضش کند.
*
* ## ترکیب اثرها
*
* از جدول {@see PolicySchema::COMBINATION} می‌آید — `veto`، `max`، `sum`، `union`.
* یک `forbid` کل عملیات را رد می‌کند حتی اگر ده قانون مجازکننده باشند؛ ممنوعیت رأی
* اکثریت نیست.
*/
final class PolicyResolver
{
public function __construct(
private readonly PolicyRepository $policies,
private readonly ConditionEvaluator $evaluator,
) {}
/**
* @param array<string, mixed> $facts
*/
public function resolve(
string $category,
string $entityType,
int $entityId,
array $facts,
?DoctorAddress $address = null,
?ServiceItem $service = null,
?int $at = null,
): PolicyOutcome {
$at = $at ?? time();
$candidates = $this->policies->findForCategory($entityType, $entityId, $category);
$matched = [];
foreach ($candidates as $policy) {
if (!$policy->appliesAt($at) || !$this->inScope($policy, $address, $service)) {
continue;
}
if ($this->evaluator->matches($policy, $facts)) {
$matched[] = $policy;
}
}
if ($matched === []) {
return new PolicyOutcome();
}
usort($matched, $this->comparator(...));
return $this->combine($matched);
}
/**
* قانونی که دامنه‌اش با این درخواست نمی‌خواند اصلاً کاندید نیست.
*
* دامنهٔ تهی یعنی «همه» — قانون سطح محیط روی همه‌چیز اعمال می‌شود.
*/
private function inScope(Policy $policy, ?DoctorAddress $address, ?ServiceItem $service): bool
{
if ($policy->getAddress() !== null && $policy->getAddress()->getId() !== $address?->getId()) {
return false;
}
if ($policy->getServiceItem() !== null && $policy->getServiceItem()->getId() !== $service?->getId()) {
return false;
}
if ($policy->getCatalogCategory() !== null
&& $policy->getCatalogCategory()->getId() !== $service?->getCatalogCategory()?->getId()
) {
return false;
}
return true;
}
private function comparator(Policy $a, Policy $b): int
{
return [$b->getPriority(), $b->specificity(), $a->getCreatedAt()]
<=> [$a->getPriority(), $a->specificity(), $b->getCreatedAt()];
}
/** @param Policy[] $policies به ترتیب برنده‌ترین */
private function combine(array $policies): PolicyOutcome
{
$effects = [];
$applied = [];
$forbids = [];
foreach ($policies as $policy) {
$contributed = false;
foreach ($policy->getEffects() as $effect) {
$type = $effect['type'] ?? null;
if (!is_string($type)) {
continue;
}
$contributed = true;
$mode = PolicySchema::COMBINATION[$type] ?? 'max';
$value = $effect['value'] ?? true;
if ($mode === 'veto') {
// متن دلخواه با کلید `reason` می‌آید؛ نبودنش خطا نیست چون نام
// خودِ قانون همیشه یک توضیح قابل‌فهم است.
$reason = $effect['reason'] ?? null;
$forbids[] = is_string($reason) && trim($reason) !== ''
? $reason
: sprintf('قانون «%s» این عملیات را مجاز نمی‌داند', $policy->getName());
continue;
}
$effects[$type] = match ($mode) {
'sum' => (float) ($effects[$type] ?? 0) + (float) $value,
'union' => array_values(array_unique([...($effects[$type] ?? []), ...(array) $value])),
default => max($effects[$type] ?? $value, $value), // max
};
}
if ($contributed) {
$applied[] = [
'uuid' => $policy->getUuid(),
'name' => $policy->getName(),
'version' => $policy->getVersion(),
];
}
}
// جمع‌ها به عدد صحیح برمی‌گردند: دقیقه و ریال هر دو صحیح‌اند.
foreach ($effects as $type => $value) {
if (is_float($value)) {
$effects[$type] = $value == (int) $value ? (int) $value : $value;
}
}
return new PolicyOutcome($effects, $applied, $forbids);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Policy\Service;
use App\Policy\Entity\Policy;
/**
* فهرست **بستهٔ** فیلدها، عملگرها و اثرها per دسته.
*
* بند ۸ مستند صریح است: شرط از فهرست بسته می‌آید و کد دلخواه وجود ندارد. دو دلیل:
*
* ۱. قانونی که هر عبارتی را بتواند ارزیابی کند، قابل تحلیل ایستا نیست — و دستهٔ
* `spacing` باید به **کوئری** تبدیل شود، نه اینکه per اسلات در PHP اجرا شود
* (برای ۹۰ روز غیرقابل قبول است).
* ۲. فرم ساخت قانون در پنل از همین schema ساخته می‌شود، نه از فهرستی که در فرانت
* دوباره نوشته شده باشد. دو فهرست یعنی دو حقیقت.
*/
final class PolicySchema
{
public const OP_EQUALS = 'equals';
public const OP_NOT_EQUALS = 'not_equals';
public const OP_GREATER_THAN = 'greater_than';
public const OP_LESS_THAN = 'less_than';
public const OP_IN = 'in';
public const OP_CONTAINS = 'contains';
public const OPERATORS = [
self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_GREATER_THAN,
self::OP_LESS_THAN, self::OP_IN, self::OP_CONTAINS,
];
// ── اثرها ───────────────────────────────────────────────────────────────
public const EFFECT_FORBID = 'forbid';
public const EFFECT_REQUIRE_RESOURCE = 'require_resource';
public const EFFECT_MIN_DURATION = 'min_duration_minutes';
public const EFFECT_ADD_DURATION = 'add_duration_minutes';
public const EFFECT_MIN_DAYS_BETWEEN = 'min_days_between';
public const EFFECT_DISCOUNT_PERCENT = 'discount_percent';
public const EFFECT_DISCOUNT_RIALS = 'discount_rials';
public const EFFECT_REQUIRE_FLAG = 'require_flag';
/** فیلدهای مجاز per دسته. */
public const FIELDS = [
Policy::CATEGORY_SELECTION => ['item_count', 'item_uuids', 'catalog_category'],
Policy::CATEGORY_ELIGIBILITY => ['patient_age', 'patient_gender', 'patient_tags', 'has_parental_consent', 'visit_count'],
Policy::CATEGORY_RESOURCE => ['catalog_category', 'service_uuid', 'item_count'],
Policy::CATEGORY_TIMING => ['catalog_category', 'service_uuid', 'item_count', 'patient_age'],
Policy::CATEGORY_SPACING => ['catalog_category', 'service_uuid'],
Policy::CATEGORY_PRICING => ['patient_tags', 'visit_count', 'item_count', 'subtotal_rials'],
];
/** اثرهای مجاز per دسته — اثر ناسازگار با دسته پذیرفته نمی‌شود. */
public const EFFECTS = [
Policy::CATEGORY_SELECTION => [self::EFFECT_FORBID],
Policy::CATEGORY_ELIGIBILITY => [self::EFFECT_FORBID, self::EFFECT_REQUIRE_FLAG],
Policy::CATEGORY_RESOURCE => [self::EFFECT_REQUIRE_RESOURCE, self::EFFECT_FORBID],
Policy::CATEGORY_TIMING => [self::EFFECT_MIN_DURATION, self::EFFECT_ADD_DURATION],
Policy::CATEGORY_SPACING => [self::EFFECT_MIN_DAYS_BETWEEN],
Policy::CATEGORY_PRICING => [self::EFFECT_DISCOUNT_PERCENT, self::EFFECT_DISCOUNT_RIALS],
];
/**
* چگونه چند اثرِ هم‌نوع با هم ترکیب می‌شوند — جدول بند ۸.
*
* `forbid` هیچ‌وقت ترکیب نمی‌شود: یک ممنوعیت کل عملیات را رد می‌کند، حتی اگر ده
* قانون مجازکننده باشند.
*/
public const COMBINATION = [
self::EFFECT_FORBID => 'veto',
self::EFFECT_REQUIRE_RESOURCE => 'union',
self::EFFECT_REQUIRE_FLAG => 'union',
self::EFFECT_MIN_DURATION => 'max',
self::EFFECT_MIN_DAYS_BETWEEN => 'max',
self::EFFECT_ADD_DURATION => 'sum',
self::EFFECT_DISCOUNT_PERCENT => 'sum',
self::EFFECT_DISCOUNT_RIALS => 'sum',
];
/** @return array<string, mixed> */
public function describe(): array
{
$out = [];
foreach (Policy::CATEGORIES as $category) {
$out[$category] = [
'fields' => self::FIELDS[$category],
'operators' => self::OPERATORS,
'effects' => array_map(
static fn (string $effect): array => [
'type' => $effect,
'combination' => self::COMBINATION[$effect],
],
self::EFFECTS[$category],
),
];
}
return $out;
}
public function allowsField(string $category, string $field): bool
{
return in_array($field, self::FIELDS[$category] ?? [], true);
}
public function allowsEffect(string $category, string $effect): bool
{
return in_array($effect, self::EFFECTS[$category] ?? [], true);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Policy\ValueObject;
/**
* نتیجهٔ اعمال یک دسته قانون: اثرهای ترکیب‌شده + ردِ قانون‌هایی که اعمال شدند.
*
* `appliedPolicies` شناسه **و نسخه** را نگه می‌دارد. فقط شناسه کافی نیست: قانون فردا
* نسخهٔ ۲ می‌گیرد و آن‌وقت «چرا این نوبت این قیمت را گرفت؟» جواب اشتباه می‌دهد.
*/
final readonly class PolicyOutcome
{
/**
* @param array<string, mixed> $effects نوعِ اثر => مقدار ترکیب‌شده
* @param list<array<string, mixed>> $appliedPolicies
* @param list<string> $forbidReasons پیام‌های انسانیِ ممنوعیت
*/
public function __construct(
public array $effects = [],
public array $appliedPolicies = [],
public array $forbidReasons = [],
) {}
public function isForbidden(): bool
{
return $this->forbidReasons !== [];
}
public function effect(string $type, mixed $default = null): mixed
{
return $this->effects[$type] ?? $default;
}
public function toArray(): array
{
return [
'effects' => (object) $this->effects,
'applied_policies' => $this->appliedPolicies,
'forbidden' => $this->isForbidden(),
'forbid_reasons' => $this->forbidReasons,
];
}
}
+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
*/
+1
View File
@@ -110,6 +110,7 @@ final class GlobalTables
\App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\ItemGroupMember::class => \App\ClinicService\Entity\ItemGroup::class,
\App\Pricing\Entity\PriceListItem::class => \App\Pricing\Entity\PriceList::class,
\App\Policy\Entity\PolicyVersionLog::class => \App\Policy\Entity\Policy::class,
\App\Billing\Entity\ClaimItem::class => \App\Billing\Entity\Claim::class,
\App\Billing\Entity\ClaimStatusLog::class => \App\Billing\Entity\Claim::class,