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) <noreply@anthropic.com>
159 lines
6.3 KiB
PHP
159 lines
6.3 KiB
PHP
<?php
|
|
|
|
namespace App\Policy\Service;
|
|
|
|
use App\Policy\Entity\Policy;
|
|
|
|
/**
|
|
* تنها منبع حقیقتِ فیلدها — schema، استخراج مقدار، و اعتبارسنجی، هر سه از یک جدول.
|
|
*
|
|
* دلیلِ یکی بودنشان همان چیزی است که تسک ۰۹ بهعنوان خطرِ باقیمانده ثبت کرده بود: وقتی
|
|
* فهرست فیلدها در یک کلاس باشد و ساختنِ حقایق در ده نقطهٔ دیگر، فیلدی که در فرم هست و
|
|
* هیچکس نمیسازدش بیصدا «همیشهرد» میشود. حالا `extract()` همانجایی است که فرم از آن
|
|
* ساخته میشود، پس چنین فیلدی اصلاً نمیتواند وجود داشته باشد.
|
|
*
|
|
* نام فیلدها عمداً همان نامهای امروز است، نه نامهای نقطهدار مستند (`patient.age`):
|
|
* شرطهای ذخیرهشده در `condition_json` به همین نامها اشاره میکنند و تغییرشان یعنی
|
|
* مهاجرت داده روی قانونهای زندهٔ کلینیکها.
|
|
*/
|
|
final class FieldRegistry
|
|
{
|
|
/**
|
|
* @var array<string, array{label: string, type: string, values?: list<string>, categories: list<string>}>
|
|
*/
|
|
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<string> */
|
|
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<string, array<string, mixed>>
|
|
*/
|
|
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<string, mixed> $facts
|
|
*/
|
|
public function extract(string $field, array $facts): mixed
|
|
{
|
|
return $facts[$field] ?? null;
|
|
}
|
|
|
|
/** @param array<string, mixed> $facts */
|
|
public function supplies(string $field, array $facts): bool
|
|
{
|
|
return array_key_exists($field, $facts);
|
|
}
|
|
}
|