From f2600f9922a8f77a9dc1624e6392ecdfd71c6d2f Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 1 Aug 2026 15:56:50 +0330 Subject: [PATCH] refactor(policy): build the registries and six engines the architecture asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task 09 architecture specified FieldRegistry, OperatorRegistry, six engine classes and a stored specificity. What shipped was a single PolicySchema constant list, six operators, one resolver and a specificity recomputed on every booking. Each shortcut was defensible on its own; together they left the starred risk the task itself recorded — a field can be advertised in the form and supplied by nobody, and the rule silently never matches. OperatorRegistry now holds all eleven operators. The five that were missing are real capability, not ceremony: greater_or_equal and less_or_equal make boundary rules expressible without off-by-one, not_in is the natural way to write an exclusion, between stops "18 to 65" needing two clauses, and days_since is the documented operator for "more than N days since" — until now every caller computed that by hand. between is inclusive at both ends because that is what the Persian phrasing means and what the user will type. FieldRegistry is now the single source: it builds the form schema and extracts the value, so a field that exists in one and not the other is impossible. It also declares which categories each field belongs to, which is what the closed list per category used to do separately. Adding it immediately caught its own first case — last_visit_at was advertised and supplied nowhere, so the guard now populates it and days_since has something to read. The six engines are thin on purpose. They give the call site a type — "the pricing engine" rather than "the resolver with the string pricing" — and a place for evaluateIsolated, which the sandbox needs to answer "what would this one rule do". Conflict resolution and effect combination stay in PolicyResolver: six copies of that would be six places to break. specificity is a stored column now, computed on save with the documented weights, and the migration backfills existing rows with the same formula. Left at zero they would all have tied and the ordering would have changed overnight. Field names stay as they are rather than moving to the document's dotted names (patient.age). Stored condition_json rows point at the current names on live clinic policies; renaming them is a data migration, and the mapping is not one-to-one — implementation_notes.md says as much. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/policy.md | 39 ++++ migrations/Version20260801122211.php | 41 ++++ .../Plan/Service/AppointmentPlanBuilder.php | 12 +- .../Service/ServiceSelectionValidator.php | 7 +- src/Course/Service/CourseScheduler.php | 7 +- src/Policy/Engine/EligibilityPolicyEngine.php | 14 ++ src/Policy/Engine/PolicyEngine.php | 59 ++++++ src/Policy/Engine/PricingPolicyEngine.php | 14 ++ src/Policy/Engine/ResourcePolicyEngine.php | 14 ++ src/Policy/Engine/SelectionPolicyEngine.php | 14 ++ src/Policy/Engine/SpacingPolicyEngine.php | 14 ++ src/Policy/Engine/TimingPolicyEngine.php | 14 ++ src/Policy/Entity/Policy.php | 35 +++- src/Policy/Service/BookingPolicyGuard.php | 33 +++- src/Policy/Service/ConditionEvaluator.php | 34 +--- src/Policy/Service/FieldRegistry.php | 158 ++++++++++++++++ src/Policy/Service/OperatorRegistry.php | 176 ++++++++++++++++++ src/Policy/Service/PolicySchema.php | 84 +++------ src/Policy/Simulation/SimulationFacts.php | 2 +- src/Pricing/Service/PricingEngine.php | 7 +- tests/Policy/OperatorRegistryTest.php | 77 ++++++++ tests/Policy/PolicyEngineTest.php | 50 ++++- tests/Policy/PolicyFieldCoverageTest.php | 13 +- 23 files changed, 800 insertions(+), 118 deletions(-) create mode 100644 migrations/Version20260801122211.php create mode 100644 src/Policy/Engine/EligibilityPolicyEngine.php create mode 100644 src/Policy/Engine/PolicyEngine.php create mode 100644 src/Policy/Engine/PricingPolicyEngine.php create mode 100644 src/Policy/Engine/ResourcePolicyEngine.php create mode 100644 src/Policy/Engine/SelectionPolicyEngine.php create mode 100644 src/Policy/Engine/SpacingPolicyEngine.php create mode 100644 src/Policy/Engine/TimingPolicyEngine.php create mode 100644 src/Policy/Service/FieldRegistry.php create mode 100644 src/Policy/Service/OperatorRegistry.php create mode 100644 tests/Policy/OperatorRegistryTest.php 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; }