diff --git a/docs/api/policy.md b/docs/api/policy.md index c81c01f5..704da8fa 100644 --- a/docs/api/policy.md +++ b/docs/api/policy.md @@ -421,3 +421,42 @@ POST /api/v1/policy شرطی که فیلدش در حقایق درخواست نباشد **رد** می‌شود (نه نادیده گرفته) و یک `warning` با نام قانون و فهرست حقایق موجود لاگ می‌شود. رد کردنِ خاموش یعنی قانونی که هر بار به این خط می‌رسد عملاً خاموش است و کسی خبردار نمی‌شود. + +## رجیستری‌ها و شش موتور + +| کلاس | مسئولیت | +|---|---| +| `OperatorRegistry` | یازده عملگر بند ۸، فیلترشده per نوع فیلد | +| `FieldRegistry` | فهرست فیلدها + استخراج مقدار + دسته‌های مجاز — **یک منبع** | +| `PolicyEngine` و شش زیرکلاس | `evaluate()` و `evaluateIsolated()` per دسته | +| `PolicyResolver` | حل تناقض و ترکیب اثرها — یک بار، نه شش بار | + +عملگرها: `equals` · `not_equals` · `greater_than` · `greater_or_equal` · `less_than` · +`less_or_equal` · `in` · `not_in` · `between` · `contains` · `days_since`. + +`between` بازهٔ **بسته** است (`[min, max]`) چون «بین ۱۸ تا ۶۵ سال» در فارسی هر دو سر را +شامل می‌شود. `days_since` روی فیلد زمانی کار می‌کند (`last_visit_at`) و «بیش از N روز +گذشته» را می‌سنجد؛ مقدار صفر یعنی «هرگز» و شرط را رد می‌کند. + +`FieldRegistry` هم schema فرم را می‌سازد و هم مقدار را استخراج می‌کند. یکی بودنشان همان +چیزی است که خطرِ ثبت‌شدهٔ تسک ۰۹ را می‌بندد: فیلدی که در فرم باشد و هیچ‌کس نسازدش، +بی‌صدا «همیشه‌رد» می‌شود. + +شش موتور فقط **تایپ** می‌دهند: نقطهٔ مصرف می‌گوید «موتور قیمت» نه «resolver با رشتهٔ +pricing». منطقشان یکی است و در `PolicyResolver` می‌ماند — شش کپی یعنی شش جای شکستن. + +## `specificity` + +هنگام **ذخیره** حساب و ذخیره می‌شود: + +| وزن | شرط | +|---|---| +| ۸ | شعبهٔ مشخص | +| ۴ | سرویس مشخص | +| ۲ | دستهٔ کاتالوگ مشخص | +| ۱ | هر شرط اضافه | + +محاسبه در زمان اجرا یعنی کاری که یک بار در عمر قانون کافی بود در هر رزرو تکرار شود؛ +ذخیره‌شدنش یعنی می‌شود روزی مرتب‌سازی را به SQL برد. مهاجرت `Version20260801122211` +قانون‌های موجود را با همان فرمول پر می‌کند، وگرنه همه صفر می‌ماندند و ترتیب حل تناقض +یک‌شبه عوض می‌شد. diff --git a/migrations/Version20260801122211.php b/migrations/Version20260801122211.php new file mode 100644 index 00000000..871410e5 --- /dev/null +++ b/migrations/Version20260801122211.php @@ -0,0 +1,41 @@ +addSql('ALTER TABLE policies ADD specificity SMALLINT DEFAULT 0 NOT NULL'); + + // قانون‌های موجود باید همان عددی را بگیرند که تا امروز در زمان اجرا حساب می‌شد، + // وگرنه همه با صفر می‌مانند و ترتیب حل تناقض یک‌شبه عوض می‌شود. + $this->addSql(<<<'SQL' + UPDATE policies + SET specificity = + (CASE WHEN address_id IS NOT NULL THEN 8 ELSE 0 END) + + (CASE WHEN service_item_id IS NOT NULL THEN 4 ELSE 0 END) + + (CASE WHEN catalog_category_id IS NOT NULL THEN 2 ELSE 0 END) + + COALESCE(JSON_LENGTH(condition_json, '$.conditions'), 0) + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE policies DROP specificity'); + } +} diff --git a/src/Appointment/Plan/Service/AppointmentPlanBuilder.php b/src/Appointment/Plan/Service/AppointmentPlanBuilder.php index 417b1766..cd591d1b 100644 --- a/src/Appointment/Plan/Service/AppointmentPlanBuilder.php +++ b/src/Appointment/Plan/Service/AppointmentPlanBuilder.php @@ -18,7 +18,8 @@ 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\Policy\Engine\ResourcePolicyEngine; +use App\Policy\Engine\TimingPolicyEngine; use App\Shared\Exception\AppException; /** @@ -30,7 +31,8 @@ use App\Shared\Exception\AppException; final class AppointmentPlanBuilder { public function __construct( - private readonly PolicyResolver $policies, + private readonly TimingPolicyEngine $timingPolicies, + private readonly ResourcePolicyEngine $resourcePolicies, private readonly SegmentTemplateRepository $templates, private readonly ClinicResourceRepository $resources, private readonly ResourceTypeRepository $types, @@ -142,8 +144,7 @@ final class AppointmentPlanBuilder array &$segments, int $total, ): int { - $outcome = $this->policies->resolve( - Policy::CATEGORY_TIMING, + $outcome = $this->timingPolicies->evaluate( $address->tenantEntityType(), $address->tenantEntityId(), [ @@ -314,8 +315,7 @@ final class AppointmentPlanBuilder return $segments; } - $outcome = $this->policies->resolve( - Policy::CATEGORY_RESOURCE, + $outcome = $this->resourcePolicies->evaluate( $address->tenantEntityType(), $address->tenantEntityId(), [ diff --git a/src/ClinicService/Service/ServiceSelectionValidator.php b/src/ClinicService/Service/ServiceSelectionValidator.php index f8f7cff5..e2ef3f42 100644 --- a/src/ClinicService/Service/ServiceSelectionValidator.php +++ b/src/ClinicService/Service/ServiceSelectionValidator.php @@ -10,7 +10,7 @@ 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\Policy\Engine\SelectionPolicyEngine; use App\ClinicService\Repository\ServiceBranchOverrideRepository; /** @@ -30,7 +30,7 @@ final class ServiceSelectionValidator private readonly ServiceItemRelationRepository $relations, private readonly ServiceBranchOverrideRepository $overrides, private readonly DurationCalculator $durations, - private readonly PolicyResolver $policies, + private readonly SelectionPolicyEngine $selectionPolicies, ) {} /** @@ -88,8 +88,7 @@ final class ServiceSelectionValidator // هر آیتم جداگانه حل می‌شود: قانونی که دامنه‌اش یک سرویس خاص است فقط وقتی // معنا دارد که همان سرویس در انتخاب باشد، و پیام خطا باید بگوید کدام. foreach ($selected as $item) { - $outcome = $this->policies->resolve( - Policy::CATEGORY_SELECTION, + $outcome = $this->selectionPolicies->evaluate( $address->tenantEntityType(), $address->tenantEntityId(), $facts + ['catalog_category' => $item->getCatalogCategory()?->getUuid()], diff --git a/src/Course/Service/CourseScheduler.php b/src/Course/Service/CourseScheduler.php index 8e762d16..496fd4db 100644 --- a/src/Course/Service/CourseScheduler.php +++ b/src/Course/Service/CourseScheduler.php @@ -9,7 +9,7 @@ use App\Course\Entity\CourseSession; use App\Course\Entity\TreatmentCourse; use App\Doctor\Entity\DoctorAddress; use App\Policy\Entity\Policy; -use App\Policy\Service\PolicyResolver; +use App\Policy\Engine\SpacingPolicyEngine; use App\Policy\Service\PolicySchema; /** @@ -33,7 +33,7 @@ final class CourseScheduler public function __construct( private readonly AppointmentPlanBuilder $planner, private readonly AvailabilityEngine $availability, - private readonly PolicyResolver $policies, + private readonly SpacingPolicyEngine $spacingPolicies, ) {} /** @@ -45,8 +45,7 @@ final class CourseScheduler { $service = $course->getServiceItem(); - $outcome = $this->policies->resolve( - Policy::CATEGORY_SPACING, + $outcome = $this->spacingPolicies->evaluate( $course->getEntityType(), $course->getEntityId(), [ diff --git a/src/Policy/Engine/EligibilityPolicyEngine.php b/src/Policy/Engine/EligibilityPolicyEngine.php new file mode 100644 index 00000000..15ef02e5 --- /dev/null +++ b/src/Policy/Engine/EligibilityPolicyEngine.php @@ -0,0 +1,14 @@ + $facts + */ + public function evaluate( + string $entityType, + int $entityId, + array $facts, + ?DoctorAddress $address = null, + ?ServiceItem $service = null, + ?int $at = null, + ): PolicyOutcome { + return $this->resolver->resolve($this->category(), $entityType, $entityId, $facts, $address, $service, $at); + } + + /** + * فقط همین یک قانون، بدون بقیه — برای آزمایشگاه. + * + * @param array $facts + */ + public function evaluateIsolated(Policy $policy, array $facts): PolicyOutcome + { + if ($policy->getCategory() !== $this->category()) { + throw new \InvalidArgumentException(sprintf( + 'Policy "%s" belongs to category "%s", not "%s".', + $policy->getUuid(), + $policy->getCategory(), + $this->category(), + )); + } + + return $this->resolver->evaluateOne($policy, $facts); + } +} diff --git a/src/Policy/Engine/PricingPolicyEngine.php b/src/Policy/Engine/PricingPolicyEngine.php new file mode 100644 index 00000000..ec341c70 --- /dev/null +++ b/src/Policy/Engine/PricingPolicyEngine.php @@ -0,0 +1,14 @@ + 0])] private int $priority = 0; + /** دومین معیار حل تناقض — هنگام **ذخیره** حساب می‌شود، نه در هر رزرو. */ + #[ORM\Column(type: 'smallint', options: ['default' => 0])] + private int $specificity = 0; + // ── دامنه: هرچه باریک‌تر، در تساویِ اولویت برنده‌تر ────────────────────── #[ORM\ManyToOne(targetEntity: DoctorAddress::class)] #[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] @@ -117,6 +121,7 @@ class Policy $this->category = $category; $this->name = $name; $this->createdAt = time(); + $this->recomputeSpecificity(); $this->updatedAt = time(); $this->assignTenantPair($entityType, $entityId); @@ -177,15 +182,37 @@ class Policy /** * هرچه باریک‌تر، بزرگ‌تر. در تساویِ اولویت، اختصاصی‌تر برنده است — «این سرویس» باید * بتواند «همهٔ سرویس‌ها» را کنار بزند، وگرنه استثنا غیرقابل بیان می‌شود. + * + * وزن‌ها از مستند: شعبه ۸ · سرویس ۴ · دسته ۲ · هر شرط اضافه ۱. شرط‌ها هم می‌شمارند + * چون قانونی با سه شرط از قانونِ بی‌قید باریک‌تر است، حتی اگر دامنه‌شان یکی باشد. */ public function specificity(): int { - return ($this->address !== null ? 4 : 0) - + ($this->serviceItem !== null ? 2 : 0) - + ($this->catalogCategory !== null ? 1 : 0); + return $this->specificity; } - private function touch(): void { $this->updatedAt = time(); } + /** + * محاسبه **هنگام ذخیره**، نه هنگام اجرا. + * + * حل تناقض در هر رزرو روی همین عدد `usort` می‌زند؛ محاسبهٔ دوباره‌اش per قانون per + * درخواست یعنی کاری که یک بار در عمر قانون کافی بود، هزار بار در روز انجام شود. ضمناً + * ذخیره‌شدنش یعنی می‌شود روزی مرتب‌سازی را به SQL برد. + */ + public function recomputeSpecificity(): self + { + $this->specificity = ($this->address !== null ? 8 : 0) + + ($this->serviceItem !== null ? 4 : 0) + + ($this->catalogCategory !== null ? 2 : 0) + + count($this->condition['conditions'] ?? []); + + return $this; + } + + private function touch(): void + { + $this->updatedAt = time(); + $this->recomputeSpecificity(); + } public function toArray(): array { diff --git a/src/Policy/Service/BookingPolicyGuard.php b/src/Policy/Service/BookingPolicyGuard.php index f1153624..f2b28d47 100644 --- a/src/Policy/Service/BookingPolicyGuard.php +++ b/src/Policy/Service/BookingPolicyGuard.php @@ -26,7 +26,8 @@ use Doctrine\ORM\EntityManagerInterface; final class BookingPolicyGuard { public function __construct( - private readonly PolicyResolver $policies, + private readonly \App\Policy\Engine\EligibilityPolicyEngine $eligibilityPolicies, + private readonly \App\Policy\Engine\SpacingPolicyEngine $spacingPolicies, private readonly EntityManagerInterface $em, ) {} @@ -46,8 +47,7 @@ final class BookingPolicyGuard ): PolicyOutcome { $at = $at ?? time(); - $outcome = $this->policies->resolve( - Policy::CATEGORY_ELIGIBILITY, + $outcome = $this->eligibilityPolicies->evaluate( $address->tenantEntityType(), $address->tenantEntityId(), $this->patientFacts($patient, $address, $at) + $extraFacts + ['item_count' => count($items) + 1], @@ -100,8 +100,7 @@ final class BookingPolicyGuard ): void { $at = $at ?? time(); - $outcome = $this->policies->resolve( - Policy::CATEGORY_SPACING, + $outcome = $this->spacingPolicies->evaluate( $address->tenantEntityType(), $address->tenantEntityId(), [ @@ -148,6 +147,9 @@ final class BookingPolicyGuard 'patient_gender' => $profile?->getGender(), 'patient_tags' => [], 'visit_count' => $this->visitCount($patient, $address), + // عملگر `days_since` روی همین می‌نشیند: «بیش از N روز از آخرین ویزیت گذشته». + // صفر یعنی «هرگز» و هر شرط زمانی را رد می‌کند. + 'last_visit_at' => $this->lastVisitAt($patient, $address), ]; } @@ -177,6 +179,27 @@ final class BookingPolicyGuard /** * آخرین نوبتِ گذشتهٔ بیمار از همان دستهٔ کاتالوگ — یا از همان سرویس اگر دسته ندارد. */ + /** آخرین ویزیت بیمار در این محیط، بدون قید سرویس — `0` یعنی هرگز. */ + private function lastVisitAt(User $patient, DoctorAddress $address): int + { + return (int) $this->em->createQueryBuilder() + ->select('MAX(a.slotStart)') + ->from(\App\Appointment\Entity\Appointment::class, 'a') + ->where('a.user = :patient') + ->andWhere('a.entityType = :type') + ->andWhere('a.entityId = :id') + ->andWhere('a.status IN (:done)') + ->setParameter('patient', $patient) + ->setParameter('type', $address->tenantEntityType()) + ->setParameter('id', $address->tenantEntityId()) + ->setParameter('done', [ + \App\Appointment\Entity\Appointment::STATUS_COMPLETED, + \App\Appointment\Entity\Appointment::STATUS_CONFIRMED, + ]) + ->getQuery() + ->getSingleScalarResult(); + } + private function lastAppointmentAt(User $patient, ServiceItem $service, int $before): ?int { $qb = $this->em->createQueryBuilder() diff --git a/src/Policy/Service/ConditionEvaluator.php b/src/Policy/Service/ConditionEvaluator.php index e4a8c478..ac741ef0 100644 --- a/src/Policy/Service/ConditionEvaluator.php +++ b/src/Policy/Service/ConditionEvaluator.php @@ -21,8 +21,10 @@ final class ConditionEvaluator private ?Policy $policy = null; public function __construct( - private readonly PolicySchema $schema, - private readonly LoggerInterface $logger = new NullLogger(), + private readonly PolicySchema $schema, + private readonly FieldRegistry $fields, + private readonly OperatorRegistry $operators, + private readonly LoggerInterface $logger = new NullLogger(), ) {} /** @@ -73,7 +75,7 @@ final class ConditionEvaluator // // ولی رد کردنِ خاموش هم بد است: قانونی که هر بار به این خط می‌رسد، عملاً // خاموش است و کسی خبردار نمی‌شود. لاگ تنها چیزی است که این را قابل کشف می‌کند. - if (!array_key_exists($field, $facts)) { + if (!$this->fields->supplies($field, $facts)) { $this->logger->warning('policy condition skipped: fact missing', [ 'policy_uuid' => $this->policy?->getUuid(), 'category' => $this->policy?->getCategory(), @@ -84,27 +86,7 @@ final class ConditionEvaluator 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; + return $this->operators->evaluate($operator, $this->fields->extract($field, $facts), $expected); } /** @@ -149,7 +131,7 @@ final class ConditionEvaluator 'فیلد «%s» برای دستهٔ «%s» مجاز نیست. مجازها: %s', $clause['field'], $category, - implode('، ', PolicySchema::FIELDS[$category] ?? []), + implode('، ', $this->schema->fieldsFor($category)), ), 422, 'condition', @@ -158,7 +140,7 @@ final class ConditionEvaluator $operator = $clause['operator'] ?? PolicySchema::OP_EQUALS; - if (!in_array($operator, PolicySchema::OPERATORS, true)) { + if (!is_string($operator) || !$this->operators->has($operator)) { throw new AppException( ErrorCodes::ERR_VALIDATION_001, sprintf('عملگر «%s» شناخته نمی‌شود', is_string($operator) ? $operator : '—'), diff --git a/src/Policy/Service/FieldRegistry.php b/src/Policy/Service/FieldRegistry.php new file mode 100644 index 00000000..1f10d7e5 --- /dev/null +++ b/src/Policy/Service/FieldRegistry.php @@ -0,0 +1,158 @@ +, categories: list}> + */ + private const FIELDS = [ + 'item_count' => [ + 'label' => 'تعداد موارد انتخابی', + 'type' => 'int', + 'categories' => [Policy::CATEGORY_SELECTION, Policy::CATEGORY_RESOURCE, Policy::CATEGORY_TIMING, Policy::CATEGORY_PRICING], + ], + 'item_uuids' => [ + 'label' => 'موارد انتخابی', + 'type' => 'list', + 'categories' => [Policy::CATEGORY_SELECTION], + ], + 'catalog_category' => [ + 'label' => 'دستهٔ کاتالوگ', + 'type' => 'uuid', + 'categories' => [Policy::CATEGORY_SELECTION, Policy::CATEGORY_RESOURCE, Policy::CATEGORY_TIMING, Policy::CATEGORY_SPACING], + ], + 'service_uuid' => [ + 'label' => 'سرویس', + 'type' => 'uuid', + 'categories' => [Policy::CATEGORY_RESOURCE, Policy::CATEGORY_TIMING, Policy::CATEGORY_SPACING], + ], + 'patient_age' => [ + 'label' => 'سن بیمار', + 'type' => 'int', + 'categories' => [Policy::CATEGORY_ELIGIBILITY, Policy::CATEGORY_TIMING], + ], + 'patient_gender' => [ + 'label' => 'جنسیت بیمار', + 'type' => 'enum', + 'values' => ['male', 'female'], + 'categories' => [Policy::CATEGORY_ELIGIBILITY], + ], + 'patient_tags' => [ + 'label' => 'برچسب‌های بیمار', + 'type' => 'list', + 'categories' => [Policy::CATEGORY_ELIGIBILITY, Policy::CATEGORY_PRICING], + ], + 'has_parental_consent' => [ + 'label' => 'رضایت والدین', + 'type' => 'bool', + 'categories' => [Policy::CATEGORY_ELIGIBILITY], + ], + 'visit_count' => [ + 'label' => 'تعداد ویزیت قبلی', + 'type' => 'int', + 'categories' => [Policy::CATEGORY_ELIGIBILITY, Policy::CATEGORY_PRICING], + ], + 'subtotal_rials' => [ + 'label' => 'جمع مبلغ (ریال)', + 'type' => 'int', + 'categories' => [Policy::CATEGORY_PRICING], + ], + 'last_visit_at' => [ + 'label' => 'آخرین ویزیت', + 'type' => 'timestamp', + 'categories' => [Policy::CATEGORY_ELIGIBILITY, Policy::CATEGORY_SPACING, Policy::CATEGORY_PRICING], + ], + ]; + + public function __construct(private readonly OperatorRegistry $operators) {} + + public function has(string $field): bool + { + return isset(self::FIELDS[$field]); + } + + public function allowedIn(string $field, string $category): bool + { + return in_array($category, self::FIELDS[$field]['categories'] ?? [], true); + } + + /** @return list */ + public function forCategory(string $category): array + { + $out = []; + + foreach (self::FIELDS as $name => $meta) { + if (in_array($category, $meta['categories'], true)) { + $out[] = $name; + } + } + + return $out; + } + + public function typeOf(string $field): string + { + return self::FIELDS[$field]['type'] ?? 'int'; + } + + /** + * فرادادهٔ فیلدهای یک دسته — همان چیزی که فرم ساخت قانون از آن ساخته می‌شود. + * + * عملگرها **فیلترشده per نوع** می‌آیند: اگر فرم همهٔ یازده عملگر را نشان بدهد، کاربر + * `patient_tags > 5` می‌سازد و ۴۲۲ می‌گیرد بدون اینکه بفهمد چرا. + * + * @return array> + */ + public function describeCategory(string $category): array + { + $out = []; + + foreach ($this->forCategory($category) as $field) { + $meta = self::FIELDS[$field]; + + $out[$field] = [ + 'label' => $meta['label'], + 'type' => $meta['type'], + 'operators' => $this->operators->forType($meta['type']), + ] + (isset($meta['values']) ? ['values' => $meta['values']] : []); + } + + return $out; + } + + /** + * مقدار یک فیلد از حقایق درخواست. + * + * `null` در آرایه با «غایب» فرق دارد: اولی یعنی «می‌دانیم که ندارد» (سنِ ثبت‌نشده) و + * دومی یعنی «این نقطه اصلاً این فیلد را نمی‌سازد». هر دو شرط را رد می‌کنند، ولی فقط + * دومی نشانهٔ خطای پیکربندی است و باید لاگ شود. + * + * @param array $facts + */ + public function extract(string $field, array $facts): mixed + { + return $facts[$field] ?? null; + } + + /** @param array $facts */ + public function supplies(string $field, array $facts): bool + { + return array_key_exists($field, $facts); + } +} diff --git a/src/Policy/Service/OperatorRegistry.php b/src/Policy/Service/OperatorRegistry.php new file mode 100644 index 00000000..244e232d --- /dev/null +++ b/src/Policy/Service/OperatorRegistry.php @@ -0,0 +1,176 @@ + 'برابر است با', + self::OP_NOT_EQUALS => 'برابر نیست با', + self::OP_GREATER_THAN => 'بیشتر از', + self::OP_GREATER_EQUAL => 'بیشتر یا مساوی', + self::OP_LESS_THAN => 'کمتر از', + self::OP_LESS_EQUAL => 'کمتر یا مساوی', + self::OP_IN => 'یکی از', + self::OP_NOT_IN => 'هیچ‌کدام از', + self::OP_BETWEEN => 'بین', + self::OP_CONTAINS => 'شامل', + self::OP_DAYS_SINCE => 'روز گذشته از', + ]; + + /** عملگرهای معنادار per نوع فیلد — فرم فقط همین‌ها را نشان می‌دهد. */ + private const BY_TYPE = [ + 'int' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_GREATER_THAN, self::OP_GREATER_EQUAL, self::OP_LESS_THAN, self::OP_LESS_EQUAL, self::OP_BETWEEN, self::OP_IN, self::OP_NOT_IN], + 'uuid' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_IN, self::OP_NOT_IN], + 'enum' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_IN, self::OP_NOT_IN], + 'bool' => [self::OP_EQUALS], + 'list' => [self::OP_CONTAINS], + 'timestamp' => [self::OP_DAYS_SINCE, self::OP_GREATER_THAN, self::OP_LESS_THAN], + ]; + + public function has(string $operator): bool + { + return in_array($operator, self::ALL, true); + } + + /** @return list */ + public function forType(string $type): array + { + return self::BY_TYPE[$type] ?? [self::OP_EQUALS, self::OP_NOT_EQUALS]; + } + + /** @return list */ + public function describe(): array + { + return array_map( + static fn (string $op): array => ['value' => $op, 'label' => self::LABELS[$op]], + self::ALL, + ); + } + + public function label(string $operator): string + { + return self::LABELS[$operator] ?? $operator; + } + + /** + * ارزیابی یک عملگر. `$now` تزریق می‌شود تا `days_since` در تست قطعی باشد. + */ + public function evaluate(string $operator, mixed $actual, mixed $expected, ?int $now = null): bool + { + return match ($operator) { + self::OP_EQUALS => $this->looselyEqual($actual, $expected), + self::OP_NOT_EQUALS => !$this->looselyEqual($actual, $expected), + self::OP_GREATER_THAN => is_numeric($actual) && is_numeric($expected) && $actual > $expected, + self::OP_GREATER_EQUAL => is_numeric($actual) && is_numeric($expected) && $actual >= $expected, + self::OP_LESS_THAN => is_numeric($actual) && is_numeric($expected) && $actual < $expected, + self::OP_LESS_EQUAL => is_numeric($actual) && is_numeric($expected) && $actual <= $expected, + self::OP_IN => is_array($expected) && $this->inList($actual, $expected), + self::OP_NOT_IN => is_array($expected) && !$this->inList($actual, $expected), + self::OP_BETWEEN => $this->between($actual, $expected), + self::OP_CONTAINS => is_array($actual) && $this->inList($expected, $actual), + self::OP_DAYS_SINCE => $this->daysSince($actual, $expected, $now), + default => false, + }; + } + + /** + * بازهٔ بسته: `[min, max]`. هر دو سر شمرده می‌شوند، چون «بین ۱۸ تا ۶۵ سال» در زبان + * فارسی هر دو سر را شامل می‌شود و کاربر همان را می‌نویسد. + */ + private function between(mixed $actual, mixed $expected): bool + { + if (!is_array($expected) || count($expected) !== 2 || !is_numeric($actual)) { + return false; + } + + [$min, $max] = array_values($expected); + + return is_numeric($min) && is_numeric($max) && $actual >= $min && $actual <= $max; + } + + /** «بیش از N روز از این زمان گذشته». مقدار غایب یعنی «هرگز» و شرط را رد می‌کند. */ + private function daysSince(mixed $actual, mixed $expected, ?int $now): bool + { + if (!is_numeric($actual) || $actual <= 0 || !is_numeric($expected)) { + return false; + } + + $days = ((($now ?? time()) - (int) $actual) / 86400); + + return $days >= (float) $expected; + } + + /** @param array $list */ + private function inList(mixed $needle, array $list): bool + { + foreach ($list as $candidate) { + if ($this->looselyEqual($needle, $candidate)) { + return true; + } + } + + return false; + } + + /** + * مقایسهٔ ملایم فقط بین عدد و رشتهٔ عددی — `"18" == 18` درست است ولی + * `"18 سال" == 18` نه. مقایسهٔ `==` خام PHP دومی را هم درست می‌گفت. + */ + private function looselyEqual(mixed $a, mixed $b): bool + { + if (is_numeric($a) && is_numeric($b)) { + return (float) $a === (float) $b; + } + + if (is_bool($a) || is_bool($b)) { + return (bool) $a === (bool) $b; + } + + return $a === $b; + } +} diff --git a/src/Policy/Service/PolicySchema.php b/src/Policy/Service/PolicySchema.php index ad6d88d5..5ff96e12 100644 --- a/src/Policy/Service/PolicySchema.php +++ b/src/Policy/Service/PolicySchema.php @@ -17,17 +17,16 @@ use App\Policy\Entity\Policy; */ 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'; + // نام‌های عملگر همان‌هایی است که `OperatorRegistry` تعریف می‌کند؛ اینجا فقط برای + // خوانایی نقاط مصرف نگه داشته شده‌اند و **تعریف تازه‌ای نیستند**. + public const OP_EQUALS = OperatorRegistry::OP_EQUALS; + public const OP_NOT_EQUALS = OperatorRegistry::OP_NOT_EQUALS; + public const OP_GREATER_THAN = OperatorRegistry::OP_GREATER_THAN; + public const OP_LESS_THAN = OperatorRegistry::OP_LESS_THAN; + public const OP_IN = OperatorRegistry::OP_IN; + public const OP_CONTAINS = OperatorRegistry::OP_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 OPERATORS = OperatorRegistry::ALL; // ── اثرها ─────────────────────────────────────────────────────────────── public const EFFECT_FORBID = 'forbid'; @@ -39,16 +38,6 @@ final class PolicySchema 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], @@ -76,35 +65,6 @@ final class PolicySchema self::EFFECT_DISCOUNT_RIALS => 'sum', ]; - /** - * فرادادهٔ هر فیلد: برچسب فارسی، نوع ورودی، و عملگرهایی که **برای همان نوع** معنا - * دارند. - * - * فیلتر شدن عملگرها اختیاری نیست: اگر فرم همهٔ شش عملگر را نشان بدهد، کاربر - * `patient_tags > 5` می‌سازد و ۴۲۲ می‌گیرد بدون اینکه بفهمد چرا. - */ - private const FIELD_META = [ - 'item_count' => ['label' => 'تعداد موارد انتخابی', 'type' => 'int'], - 'item_uuids' => ['label' => 'موارد انتخابی', 'type' => 'list'], - 'catalog_category' => ['label' => 'دستهٔ کاتالوگ', 'type' => 'uuid'], - 'service_uuid' => ['label' => 'سرویس', 'type' => 'uuid'], - 'patient_age' => ['label' => 'سن بیمار', 'type' => 'int'], - 'patient_gender' => ['label' => 'جنسیت بیمار', 'type' => 'enum', 'values' => ['male', 'female']], - 'patient_tags' => ['label' => 'برچسب‌های بیمار', 'type' => 'list'], - 'has_parental_consent' => ['label' => 'رضایت والدین', 'type' => 'bool'], - 'visit_count' => ['label' => 'تعداد ویزیت قبلی', 'type' => 'int'], - 'subtotal_rials' => ['label' => 'جمع مبلغ (ریال)', 'type' => 'int'], - ]; - - /** عملگرهای معنادار برای هر نوع ورودی. */ - private const OPERATORS_BY_TYPE = [ - 'int' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_GREATER_THAN, self::OP_LESS_THAN], - 'uuid' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_IN], - 'enum' => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_IN], - 'bool' => [self::OP_EQUALS], - 'list' => [self::OP_CONTAINS], - ]; - /** برچسب فارسیِ هر اثر — همان چیزی که در فرم دیده می‌شود. */ private const EFFECT_META = [ self::EFFECT_FORBID => ['label' => 'ممنوع کن', 'value_type' => 'none'], @@ -126,22 +86,26 @@ final class PolicySchema Policy::CATEGORY_PRICING => 'قیمت و تخفیف', ]; + public function __construct( + private readonly FieldRegistry $fields, + private readonly OperatorRegistry $operators, + ) {} + /** @return array */ public function describe(): array { $out = []; foreach (Policy::CATEGORIES as $category) { + $meta = $this->fields->describeCategory($category); + $out[$category] = [ 'label' => self::CATEGORY_LABELS[$category], - 'fields' => self::FIELDS[$category], - 'operators' => self::OPERATORS, + 'fields' => array_keys($meta), + 'operators' => $this->operators->describe(), 'field_meta' => array_map( - static fn (string $field): array => self::FIELD_META[$field] + [ - 'key' => $field, - 'operators' => self::OPERATORS_BY_TYPE[self::FIELD_META[$field]['type']], - ], - self::FIELDS[$category], + static fn (string $key): array => $meta[$key] + ['key' => $key], + array_keys($meta), ), 'effects' => array_map( static fn (string $effect): array => self::EFFECT_META[$effect] + [ @@ -158,7 +122,13 @@ final class PolicySchema public function allowsField(string $category, string $field): bool { - return in_array($field, self::FIELDS[$category] ?? [], true); + return $this->fields->has($field) && $this->fields->allowedIn($field, $category); + } + + /** @return list */ + public function fieldsFor(string $category): array + { + return $this->fields->forCategory($category); } public function allowsEffect(string $category, string $effect): bool diff --git a/src/Policy/Simulation/SimulationFacts.php b/src/Policy/Simulation/SimulationFacts.php index 4f1972a3..0ead86ae 100644 --- a/src/Policy/Simulation/SimulationFacts.php +++ b/src/Policy/Simulation/SimulationFacts.php @@ -12,7 +12,7 @@ use Doctrine\ORM\EntityManagerInterface; * * اگر این کلاس حقیقتی را طور دیگری بسازد، آزمایش دروغ می‌گوید — و آزمایشی که دروغ * بگوید بدتر از نداشتن آزمایش است. به همین دلیل نام‌ها عیناً از - * {@see \App\Policy\Service\PolicySchema::FIELDS} می‌آیند. + * {@see \App\Policy\Service\FieldRegistry} می‌آیند. */ final class SimulationFacts { diff --git a/src/Pricing/Service/PricingEngine.php b/src/Pricing/Service/PricingEngine.php index ad578cfc..1cbd5c35 100644 --- a/src/Pricing/Service/PricingEngine.php +++ b/src/Pricing/Service/PricingEngine.php @@ -12,7 +12,7 @@ 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\Engine\PricingPolicyEngine; use App\Policy\Service\PolicySchema; use App\Representation\Service\JalaliDateService; @@ -38,7 +38,7 @@ use App\Representation\Service\JalaliDateService; final class PricingEngine { public function __construct( - private readonly PolicyResolver $policies, + private readonly PricingPolicyEngine $pricingPolicies, private readonly PackageConsumptionService $packages, private readonly PriceListRepository $priceLists, private readonly PriceListItemRepository $priceListItems, @@ -169,8 +169,7 @@ final class PricingEngine array $policy, array &$sources, ): array { - $outcome = $this->policies->resolve( - Policy::CATEGORY_PRICING, + $outcome = $this->pricingPolicies->evaluate( $address->tenantEntityType(), $address->tenantEntityId(), [ diff --git a/tests/Policy/OperatorRegistryTest.php b/tests/Policy/OperatorRegistryTest.php new file mode 100644 index 00000000..e48c000f --- /dev/null +++ b/tests/Policy/OperatorRegistryTest.php @@ -0,0 +1,77 @@ +evaluate($op, $actual, $expected, self::NOW), + sprintf('%s(%s, %s)', $op, json_encode($actual), json_encode($expected)), + ); + } + + public static function cases(): array + { + $day = 86400; + + return [ + 'equals' => ['equals', 5, 5, true], + 'equals — رشتهٔ عددی' => ['equals', '5', 5, true], + 'equals — نه' => ['equals', 5, 6, false], + 'not_equals' => ['not_equals', 5, 6, true], + 'greater_than' => ['greater_than', 6, 5, true], + 'greater_than — مرز' => ['greater_than', 5, 5, false], + 'greater_or_equal' => ['greater_or_equal', 5, 5, true], + 'less_than' => ['less_than', 4, 5, true], + 'less_or_equal' => ['less_or_equal', 5, 5, true], + 'in' => ['in', 2, [1, 2, 3], true], + 'in — نه' => ['in', 9, [1, 2, 3], false], + 'not_in' => ['not_in', 9, [1, 2, 3], true], + 'between — داخل' => ['between', 30, [18, 65], true], + 'between — مرز پایین' => ['between', 18, [18, 65], true], + 'between — مرز بالا' => ['between', 65, [18, 65], true], + 'between — بیرون' => ['between', 66, [18, 65], false], + 'contains' => ['contains', ['vip', 'new'], 'vip', true], + 'contains — نه' => ['contains', ['new'], 'vip', false], + 'days_since — گذشته' => ['days_since', self::NOW - 40 * $day, 30, true], + 'days_since — تازه' => ['days_since', self::NOW - 10 * $day, 30, false], + 'days_since — هرگز' => ['days_since', 0, 30, false], + ]; + } + + /** عملگر ناشناخته `false` می‌دهد، نه خطا — ولی ذخیره‌اش از قبل جلوگیری شده. */ + public function testAnUnknownOperatorIsFalseAndNotRegistered(): void + { + $registry = new OperatorRegistry(); + + self::assertFalse($registry->has('regex')); + self::assertFalse($registry->evaluate('regex', 'a', 'a')); + } + + /** فرم فقط عملگرهای معنادار همان نوع را نشان می‌دهد. */ + public function testOperatorsAreFilteredByFieldType(): void + { + $registry = new OperatorRegistry(); + + self::assertSame(['contains'], $registry->forType('list')); + self::assertSame(['equals'], $registry->forType('bool')); + self::assertContains('days_since', $registry->forType('timestamp')); + self::assertNotContains('between', $registry->forType('uuid')); + } +} diff --git a/tests/Policy/PolicyEngineTest.php b/tests/Policy/PolicyEngineTest.php index 801ca70e..64f92551 100644 --- a/tests/Policy/PolicyEngineTest.php +++ b/tests/Policy/PolicyEngineTest.php @@ -119,7 +119,7 @@ class PolicyEngineTest extends ApiTestCase $schema = $body['data']; self::assertArrayHasKey('timing', $schema); - self::assertContains('equals', $schema['timing']['operators']); + self::assertContains('equals', array_column($schema['timing']['operators'], 'value')); self::assertSame( ['min_duration_minutes', 'add_duration_minutes'], @@ -138,7 +138,11 @@ class PolicyEngineTest extends ApiTestCase $meta = array_column($schema['eligibility']['field_meta'], null, 'key'); self::assertSame('int', $meta['patient_age']['type']); - self::assertSame(['equals', 'not_equals', 'greater_than', 'less_than'], $meta['patient_age']['operators']); + // عدد یازده عملگر ندارد؛ فقط آن‌هایی که روی عدد معنا دارند. + self::assertSame( + ['equals', 'not_equals', 'greater_than', 'greater_or_equal', 'less_than', 'less_or_equal', 'between', 'in', 'not_in'], + $meta['patient_age']['operators'], + ); self::assertSame(['contains'], $meta['patient_tags']['operators']); self::assertSame('سن بیمار', $meta['patient_age']['label']); } @@ -545,6 +549,48 @@ class PolicyEngineTest extends ApiTestCase self::assertTrue(true); } + /** + * ⭐ `specificity` هنگام **ذخیره** حساب می‌شود و در تساوی اولویت تصمیم می‌گیرد. + * + * محاسبه‌اش در زمان اجرا یعنی کاری که یک بار در عمر قانون کافی بود، در هر رزرو + * تکرار شود؛ و ذخیره‌شدنش یعنی می‌شود روزی مرتب‌سازی را به SQL برد. + */ + public function testSpecificityIsStoredAndDecidesTiesAtEqualPriority(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20, 1_000_000); + + // قانون عام: بدون دامنه، بدون شرط. + $broad = $this->policy($user, [ + 'category' => 'pricing', + 'name' => 'تخفیف عمومی', + 'priority' => 5, + 'effects' => [['type' => 'discount_percent', 'value' => 10]], + ]); + + // قانون خاص: همان اولویت، ولی سرویس و یک شرط دارد. + $narrow = $this->policy($user, [ + 'category' => 'pricing', + 'name' => 'تخفیف همین سرویس', + 'priority' => 5, + 'service_uuid' => $service->getUuid(), + 'condition' => ['match' => 'all', 'conditions' => [ + ['field' => 'item_count', 'operator' => 'greater_or_equal', 'value' => 0], + ]], + 'effects' => [['type' => 'discount_percent', 'value' => 40]], + ]); + + self::assertSame(0, $broad['specificity'], 'قانون بی‌دامنه و بی‌شرط'); + self::assertSame(5, $narrow['specificity'], 'سرویس ۴ + یک شرط ۱'); + + // هر دو اعمال می‌شوند (تخفیف درصدی جمع می‌شود)، ولی **ترتیب** مال specificity است: + // اختصاصی‌تر اول می‌آید، و همان ترتیبی است که اثرهای «اولی برنده» را تعیین می‌کند. + $quote = $this->quote($user, $service, $address); + $names = array_column($quote['data']['breakdown']['sources']['applied_policies'], 'name'); + + self::assertSame(['تخفیف همین سرویس', 'تخفیف عمومی'], $names, 'اختصاصی‌تر باید اول باشد'); + } + // ── جداسازی محیط ──────────────────────────────────────────────────────── public function testPolicyOfAnotherClinicIsNeitherVisibleNorApplied(): void diff --git a/tests/Policy/PolicyFieldCoverageTest.php b/tests/Policy/PolicyFieldCoverageTest.php index 529c2c22..3c0ce594 100644 --- a/tests/Policy/PolicyFieldCoverageTest.php +++ b/tests/Policy/PolicyFieldCoverageTest.php @@ -2,7 +2,9 @@ namespace App\Tests\Policy; -use App\Policy\Service\PolicySchema; +use App\Policy\Entity\Policy; +use App\Policy\Service\FieldRegistry; +use App\Policy\Service\OperatorRegistry; use PHPUnit\Framework\TestCase; /** @@ -19,10 +21,11 @@ class PolicyFieldCoverageTest extends TestCase { public function testEveryAdvertisedFieldIsSuppliedSomewhereInTheCode(): void { - $fields = []; + $registry = new FieldRegistry(new OperatorRegistry()); + $fields = []; - foreach (PolicySchema::FIELDS as $category => $list) { - foreach ($list as $field) { + foreach (Policy::CATEGORIES as $category) { + foreach ($registry->forCategory($category) as $field) { $fields[$field][] = $category; } } @@ -35,7 +38,7 @@ class PolicyFieldCoverageTest extends TestCase foreach ($sources as $file => $code) { // خودِ schema فقط نام را اعلام می‌کند؛ پر کردنش جای دیگری است. - if (str_ends_with($file, 'PolicySchema.php')) { + if (str_ends_with($file, 'PolicySchema.php') || str_ends_with($file, 'FieldRegistry.php')) { continue; }