refactor(policy): build the registries and six engines the architecture asked for
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>
This commit is contained in:
@@ -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(),
|
||||
[
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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(),
|
||||
[
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Engine;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/** شرایط بیمار — چه کسی این خدمت را میگیرد. */
|
||||
final class EligibilityPolicyEngine extends PolicyEngine
|
||||
{
|
||||
public function category(): string
|
||||
{
|
||||
return Policy::CATEGORY_ELIGIBILITY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Engine;
|
||||
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Service\PolicyResolver;
|
||||
use App\Policy\ValueObject\PolicyOutcome;
|
||||
|
||||
/**
|
||||
* پایهٔ شش موتور دستهای — بند ۸ مستند.
|
||||
*
|
||||
* هر دسته موتور خودش را دارد تا نقطهٔ مصرف بگوید «موتور قیمت را صدا میزنم»، نه «resolver
|
||||
* را با رشتهٔ `pricing` صدا میزنم». تفاوتش در **تایپ** است نه در منطق: حل تناقض و ترکیب
|
||||
* اثرها یکی است و در `PolicyResolver` میماند؛ شش نسخهٔ کپیشدهٔ آن یعنی شش جای شکستن.
|
||||
*
|
||||
* `evaluateIsolated()` روی هر موتور هست چون آزمایشگاه قانون (تسک ۱۰) باید بتواند یک قانون
|
||||
* را بدون بقیه بسنجد — «این قانون تنها چه میکرد» سؤالی است که ترکیب جوابش را میپوشاند.
|
||||
*/
|
||||
abstract class PolicyEngine
|
||||
{
|
||||
public function __construct(protected readonly PolicyResolver $resolver) {}
|
||||
|
||||
abstract public function category(): string;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Engine;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/** قیمت و تخفیف. */
|
||||
final class PricingPolicyEngine extends PolicyEngine
|
||||
{
|
||||
public function category(): string
|
||||
{
|
||||
return Policy::CATEGORY_PRICING;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Engine;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/** منبع لازم — چه نقشی باید حاضر باشد. */
|
||||
final class ResourcePolicyEngine extends PolicyEngine
|
||||
{
|
||||
public function category(): string
|
||||
{
|
||||
return Policy::CATEGORY_RESOURCE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Engine;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/** انتخاب سرویس — چه ترکیبی مجاز است. */
|
||||
final class SelectionPolicyEngine extends PolicyEngine
|
||||
{
|
||||
public function category(): string
|
||||
{
|
||||
return Policy::CATEGORY_SELECTION;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Engine;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/** فاصلهٔ جلسات. */
|
||||
final class SpacingPolicyEngine extends PolicyEngine
|
||||
{
|
||||
public function category(): string
|
||||
{
|
||||
return Policy::CATEGORY_SPACING;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Engine;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/** مدت نوبت — حداقل و افزوده. */
|
||||
final class TimingPolicyEngine extends PolicyEngine
|
||||
{
|
||||
public function category(): string
|
||||
{
|
||||
return Policy::CATEGORY_TIMING;
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,10 @@ class Policy
|
||||
#[ORM\Column(type: 'smallint', options: ['default' => 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
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 : '—'),
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Service;
|
||||
|
||||
/**
|
||||
* فهرست بستهٔ عملگرها — یازده عملگر بند ۸ مستند.
|
||||
*
|
||||
* «بسته» یعنی عملگری که اینجا نیست، هنگام **ذخیره** رد میشود نه در زمان اجرا. قانونی که
|
||||
* موقع رزرو بیمار بترکد، بدترین جای ممکن برای شکستن است.
|
||||
*
|
||||
* مقایسه عمداً ساده است و هیچ کد دلخواهی اجرا نمیکند: هر عملگر یک تابع خالص روی دو
|
||||
* مقدار است، پس همان قانون را میشود روزی به SQL ترجمه کرد.
|
||||
*/
|
||||
final class OperatorRegistry
|
||||
{
|
||||
public const OP_EQUALS = 'equals';
|
||||
public const OP_NOT_EQUALS = 'not_equals';
|
||||
public const OP_GREATER_THAN = 'greater_than';
|
||||
public const OP_GREATER_EQUAL = 'greater_or_equal';
|
||||
public const OP_LESS_THAN = 'less_than';
|
||||
public const OP_LESS_EQUAL = 'less_or_equal';
|
||||
public const OP_IN = 'in';
|
||||
public const OP_NOT_IN = 'not_in';
|
||||
public const OP_BETWEEN = 'between';
|
||||
public const OP_CONTAINS = 'contains';
|
||||
|
||||
/**
|
||||
* «چند روز از این زمان گذشته» — عملگر ویژهٔ مستند.
|
||||
*
|
||||
* روی فیلدی کار میکند که مقدارش یک timestamp است و مقایسهاش با یک عدد روز انجام
|
||||
* میشود: `last_visit_at days_since 30` یعنی «بیش از سی روز از آخرین ویزیت گذشته».
|
||||
* بدون این، همان شرط باید در هر نقطهٔ مصرف دستی حساب میشد.
|
||||
*/
|
||||
public const OP_DAYS_SINCE = 'days_since';
|
||||
|
||||
public const ALL = [
|
||||
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_IN,
|
||||
self::OP_NOT_IN,
|
||||
self::OP_BETWEEN,
|
||||
self::OP_CONTAINS,
|
||||
self::OP_DAYS_SINCE,
|
||||
];
|
||||
|
||||
/** برچسب فارسیِ هر عملگر — همان چیزی که در فرم ساخت قانون دیده میشود. */
|
||||
private const LABELS = [
|
||||
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_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<string> */
|
||||
public function forType(string $type): array
|
||||
{
|
||||
return self::BY_TYPE[$type] ?? [self::OP_EQUALS, self::OP_NOT_EQUALS];
|
||||
}
|
||||
|
||||
/** @return list<array{value: string, label: string}> */
|
||||
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<int, mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, mixed> */
|
||||
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<string> */
|
||||
public function fieldsFor(string $category): array
|
||||
{
|
||||
return $this->fields->forCategory($category);
|
||||
}
|
||||
|
||||
public function allowsEffect(string $category, string $effect): bool
|
||||
|
||||
@@ -12,7 +12,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
*
|
||||
* اگر این کلاس حقیقتی را طور دیگری بسازد، آزمایش دروغ میگوید — و آزمایشی که دروغ
|
||||
* بگوید بدتر از نداشتن آزمایش است. به همین دلیل نامها عیناً از
|
||||
* {@see \App\Policy\Service\PolicySchema::FIELDS} میآیند.
|
||||
* {@see \App\Policy\Service\FieldRegistry} میآیند.
|
||||
*/
|
||||
final class SimulationFacts
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user