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>
618 lines
28 KiB
PHP
618 lines
28 KiB
PHP
<?php
|
||
|
||
namespace App\Tests\Policy;
|
||
|
||
use App\Auth\Entity\User;
|
||
use App\Clinic\Entity\Clinic;
|
||
use App\ClinicService\Entity\ServiceItem;
|
||
use App\ClinicService\Entity\ServiceSection;
|
||
use App\Doctor\Entity\DoctorAddress;
|
||
use App\Tests\ApiTestCase;
|
||
|
||
/**
|
||
* موتور قوانین ششدستهای — بند ۸ مستند.
|
||
*
|
||
* تأکید تستها روی سه چیز است که خرابیشان بیصداست: ترکیب اثرها (max/sum/veto)،
|
||
* ترتیب حل تناقض (اولویت ← اختصاصیبودن ← قدمت)، و نسخهپذیری (قانون ویرایش
|
||
* نمیشود، نسخهٔ تازه میگیرد).
|
||
*/
|
||
class PolicyEngineTest extends ApiTestCase
|
||
{
|
||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress} */
|
||
private function clinicWithBranch(): array
|
||
{
|
||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||
$clinic = new Clinic($user);
|
||
$clinic->setName('کلینیک قوانین');
|
||
$this->em->persist($clinic);
|
||
$this->em->flush();
|
||
|
||
$section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی');
|
||
$this->em->persist($section);
|
||
|
||
$address = DoctorAddress::forClinic($clinic->getId());
|
||
$address->setName('شعبهٔ مرکزی');
|
||
$this->em->persist($address);
|
||
$this->em->flush();
|
||
|
||
return [$user, $section, $address];
|
||
}
|
||
|
||
private function service(ServiceSection $section, string $name, int $solo = 20, int $price = 1_000_000): ServiceItem
|
||
{
|
||
$item = new ServiceItem($section, $name);
|
||
$item->setSoloDurationMinutes($solo);
|
||
$item->setPriceRials($price);
|
||
$this->em->persist($item);
|
||
$this->em->flush();
|
||
|
||
return $item;
|
||
}
|
||
|
||
/**
|
||
* قانون تازه **پیشنویس** است؛ تا فعال نشود اجرا نمیشود.
|
||
*
|
||
* @param array<string, mixed> $body
|
||
*/
|
||
private function policy(User $user, array $body, bool $activate = true): array
|
||
{
|
||
$created = $this->authJson('POST', '/api/v1/policy', $user, $body);
|
||
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
|
||
|
||
if (!$activate) {
|
||
return $created['data'];
|
||
}
|
||
|
||
// فعالسازی از تسک ۱۰ به بعد یک اجرای آزمایشی از **همین نسخه** میخواهد.
|
||
$this->authJson('POST', "/api/v1/policy/{$created['data']['uuid']}/simulate", $user);
|
||
self::assertSame(201, $this->responseCode());
|
||
|
||
$active = $this->authJson('POST', "/api/v1/policy/{$created['data']['uuid']}/activate", $user);
|
||
self::assertSame(200, $this->responseCode(), json_encode($active, JSON_UNESCAPED_UNICODE));
|
||
|
||
return $active['data'];
|
||
}
|
||
|
||
/** پیشنویس ماندنِ قانون تازه عمدی است: نوشتن قانون نباید یعنی اجرای آن. */
|
||
public function testANewPolicyIsADraftUntilActivated(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'خدمت پیشنویس', 20);
|
||
|
||
$draft = $this->policy($user, [
|
||
'category' => 'timing',
|
||
'name' => 'قانون پیشنویس',
|
||
'effects' => [['type' => 'add_duration_minutes', 'value' => 30]],
|
||
], activate: false);
|
||
|
||
self::assertFalse($draft['active']);
|
||
self::assertSame(20, $this->preview($user, $service, $address)['data']['total_minutes']);
|
||
}
|
||
|
||
/** @param array<string, mixed> $extra */
|
||
private function preview(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array
|
||
{
|
||
return $this->authJson('POST', '/api/v1/appointment-plan/preview', $user, $extra + [
|
||
'service_uuid' => $service->getUuid(),
|
||
'branch_uuid' => $address->getUuid(),
|
||
]);
|
||
}
|
||
|
||
/** @param array<string, mixed> $extra */
|
||
private function quote(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array
|
||
{
|
||
return $this->authJson('POST', '/api/v1/pricing/quote', $user, $extra + [
|
||
'service_uuid' => $service->getUuid(),
|
||
'branch_uuid' => $address->getUuid(),
|
||
]);
|
||
}
|
||
|
||
// ── شِما ────────────────────────────────────────────────────────────────
|
||
|
||
public function testSchemaIsAClosedListPerCategory(): void
|
||
{
|
||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||
$body = $this->authJson('GET', '/api/v1/policy-schema', $user);
|
||
|
||
self::assertSame(200, $this->responseCode());
|
||
|
||
$schema = $body['data'];
|
||
|
||
self::assertArrayHasKey('timing', $schema);
|
||
self::assertContains('equals', array_column($schema['timing']['operators'], 'value'));
|
||
|
||
self::assertSame(
|
||
['min_duration_minutes', 'add_duration_minutes'],
|
||
array_column($schema['timing']['effects'], 'type'),
|
||
);
|
||
self::assertSame(
|
||
['max', 'sum'],
|
||
array_column($schema['timing']['effects'], 'combination'),
|
||
);
|
||
|
||
// فیلد قیمتی در دستهٔ زمان جایی ندارد — همین بستهبودن نکتهٔ اصلی شِماست.
|
||
self::assertNotContains('subtotal_rials', $schema['timing']['fields']);
|
||
|
||
// فرم باید عملگرها را per فیلد فیلتر کند، وگرنه کاربر «برچسب > ۵» میسازد و
|
||
// ۴۲۲ میگیرد بیآنکه بفهمد چرا.
|
||
$meta = array_column($schema['eligibility']['field_meta'], null, 'key');
|
||
|
||
self::assertSame('int', $meta['patient_age']['type']);
|
||
// عدد یازده عملگر ندارد؛ فقط آنهایی که روی عدد معنا دارند.
|
||
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']);
|
||
}
|
||
|
||
public function testFieldOutsideTheCategoryIsRejectedAtCreateTime(): void
|
||
{
|
||
[$user] = $this->clinicWithBranch();
|
||
|
||
$body = $this->authJson('POST', '/api/v1/policy', $user, [
|
||
'category' => 'timing',
|
||
'name' => 'قانون بیربط',
|
||
'condition' => ['match' => 'all', 'conditions' => [['field' => 'subtotal_rials', 'operator' => 'greater_than', 'value' => 10]]],
|
||
]);
|
||
|
||
self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||
}
|
||
|
||
public function testEffectOutsideTheCategoryIsRejectedAtCreateTime(): void
|
||
{
|
||
[$user] = $this->clinicWithBranch();
|
||
|
||
$this->authJson('POST', '/api/v1/policy', $user, [
|
||
'category' => 'timing',
|
||
'name' => 'تخفیف در دستهٔ زمان',
|
||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||
]);
|
||
|
||
self::assertSame(422, $this->responseCode());
|
||
}
|
||
|
||
// ── ترکیب اثرها ─────────────────────────────────────────────────────────
|
||
|
||
/** «حداقل مدت» با max ترکیب میشود: سختگیرترین قانون برنده است. */
|
||
public function testMinDurationTakesTheLargestNotTheLast(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'لیزر', 20);
|
||
|
||
$this->policy($user, [
|
||
'category' => 'timing',
|
||
'name' => 'حداقل ۴۵ دقیقه',
|
||
'effects' => [['type' => 'min_duration_minutes', 'value' => 45]],
|
||
]);
|
||
|
||
$this->policy($user, [
|
||
'category' => 'timing',
|
||
'name' => 'حداقل ۶۰ دقیقه',
|
||
'effects' => [['type' => 'min_duration_minutes', 'value' => 60]],
|
||
]);
|
||
|
||
$plan = $this->preview($user, $service, $address);
|
||
|
||
self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE));
|
||
self::assertSame(60, $plan['data']['total_minutes']);
|
||
}
|
||
|
||
/** «افزودن مدت» با sum ترکیب میشود — دو قانون ۱۰ دقیقهای یعنی ۲۰ دقیقه. */
|
||
public function testAddDurationSumsAcrossPolicies(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'پاکسازی', 20);
|
||
|
||
foreach (['ضدعفونی اضافه', 'آمادهسازی اضافه'] as $name) {
|
||
$this->policy($user, [
|
||
'category' => 'timing',
|
||
'name' => $name,
|
||
'effects' => [['type' => 'add_duration_minutes', 'value' => 10]],
|
||
]);
|
||
}
|
||
|
||
$plan = $this->preview($user, $service, $address);
|
||
|
||
self::assertSame(40, $plan['data']['total_minutes']);
|
||
}
|
||
|
||
/** یک ممنوعیت کافی است؛ ممنوعیت رأی اکثریت نیست. */
|
||
public function testOneForbidVetoesTheSelection(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'بوتاکس', 20);
|
||
|
||
$this->policy($user, [
|
||
'category' => 'selection',
|
||
'name' => 'این خدمت فعلاً ارائه نمیشود',
|
||
'service_uuid' => $service->getUuid(),
|
||
'effects' => [['type' => 'forbid', 'reason' => 'این خدمت موقتاً متوقف است']],
|
||
]);
|
||
|
||
$body = $this->authJson('POST', '/api/v1/service-selection/validate', $user, [
|
||
'item_uuids' => [$service->getUuid()],
|
||
'branch_uuid' => $address->getUuid(),
|
||
]);
|
||
|
||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||
self::assertFalse($body['data']['valid']);
|
||
self::assertSame('policy_forbidden', $body['data']['errors'][0]['code']);
|
||
self::assertSame('این خدمت موقتاً متوقف است', $body['data']['errors'][0]['message']);
|
||
}
|
||
|
||
// ── شرطها ──────────────────────────────────────────────────────────────
|
||
|
||
public function testConditionThatDoesNotMatchLeavesThePlanAlone(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'مشاوره', 20);
|
||
|
||
$this->policy($user, [
|
||
'category' => 'timing',
|
||
'name' => 'فقط برای انتخابهای پرتعداد',
|
||
'condition' => ['match' => 'all', 'conditions' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => 3]]],
|
||
'effects' => [['type' => 'add_duration_minutes', 'value' => 30]],
|
||
]);
|
||
|
||
$plan = $this->preview($user, $service, $address);
|
||
|
||
self::assertSame(20, $plan['data']['total_minutes']);
|
||
}
|
||
|
||
/** حقیقتِ غایب یعنی شرط **برقرار نیست** — نه اینکه بیصدا رد شود. */
|
||
public function testMissingFactFailsTheClauseInsteadOfPassingIt(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'لیزر بدن', 20);
|
||
|
||
$this->policy($user, [
|
||
'category' => 'timing',
|
||
'name' => 'وابسته به سن',
|
||
'condition' => ['match' => 'all', 'conditions' => [['field' => 'patient_age', 'operator' => 'less_than', 'value' => 18]]],
|
||
'effects' => [['type' => 'add_duration_minutes', 'value' => 15]],
|
||
]);
|
||
|
||
// پیشنمایش برنامه سن بیمار را نمیفرستد.
|
||
$plan = $this->preview($user, $service, $address);
|
||
|
||
self::assertSame(20, $plan['data']['total_minutes']);
|
||
}
|
||
|
||
public function testExpiredPolicyIsIgnored(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'میکرونیدلینگ', 20);
|
||
|
||
$this->policy($user, [
|
||
'category' => 'timing',
|
||
'name' => 'کمپین نوروز',
|
||
'valid_from' => time() - 86400 * 30,
|
||
'valid_to' => time() - 86400,
|
||
'effects' => [['type' => 'add_duration_minutes', 'value' => 25]],
|
||
]);
|
||
|
||
$plan = $this->preview($user, $service, $address);
|
||
|
||
self::assertSame(20, $plan['data']['total_minutes']);
|
||
}
|
||
|
||
public function testDeactivatedPolicyIsIgnored(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'هیدرافیشیال', 20);
|
||
|
||
$policy = $this->policy($user, [
|
||
'category' => 'timing',
|
||
'name' => 'قانون خاموششدنی',
|
||
'effects' => [['type' => 'add_duration_minutes', 'value' => 20]],
|
||
]);
|
||
|
||
self::assertSame(40, $this->preview($user, $service, $address)['data']['total_minutes']);
|
||
|
||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/deactivate", $user);
|
||
self::assertSame(200, $this->responseCode());
|
||
|
||
self::assertSame(20, $this->preview($user, $service, $address)['data']['total_minutes']);
|
||
}
|
||
|
||
// ── ترتیب و اختصاصیبودن ────────────────────────────────────────────────
|
||
|
||
/**
|
||
* در تساوی اولویت، قانونِ اختصاصیتر اول مینشیند — همان که برچسبش روی فاکتور
|
||
* میرود.
|
||
*/
|
||
public function testMoreSpecificPolicyIsRankedFirstOnEqualPriority(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'فیلر', 20, 2_000_000);
|
||
|
||
$this->policy($user, [
|
||
'category' => 'pricing',
|
||
'name' => 'تخفیف عمومی محیط',
|
||
'effects' => [['type' => 'discount_percent', 'value' => 5]],
|
||
]);
|
||
|
||
$this->policy($user, [
|
||
'category' => 'pricing',
|
||
'name' => 'تخفیف همین سرویس',
|
||
'service_uuid' => $service->getUuid(),
|
||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||
]);
|
||
|
||
$quote = $this->quote($user, $service, $address);
|
||
|
||
self::assertSame(200, $this->responseCode(), json_encode($quote, JSON_UNESCAPED_UNICODE));
|
||
|
||
$applied = $quote['data']['breakdown']['sources']['applied_policies'];
|
||
|
||
self::assertSame('تخفیف همین سرویس', $applied[0]['name']);
|
||
// درصدها جمع میشوند: ۵٪ + ۱۰٪ روی ۲٬۰۰۰٬۰۰۰
|
||
self::assertSame(300_000, $quote['data']['discount_rials']);
|
||
}
|
||
|
||
public function testHigherPriorityBeatsSpecificity(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'مزوتراپی', 20, 1_000_000);
|
||
|
||
$this->policy($user, [
|
||
'category' => 'pricing',
|
||
'name' => 'قانون محیطی با اولویت بالا',
|
||
'priority' => 100,
|
||
'effects' => [['type' => 'discount_percent', 'value' => 5]],
|
||
]);
|
||
|
||
$this->policy($user, [
|
||
'category' => 'pricing',
|
||
'name' => 'قانون سرویسی با اولویت پایین',
|
||
'service_uuid' => $service->getUuid(),
|
||
'priority' => 1,
|
||
'effects' => [['type' => 'discount_percent', 'value' => 5]],
|
||
]);
|
||
|
||
$applied = $this->quote($user, $service, $address)['data']['breakdown']['sources']['applied_policies'];
|
||
|
||
self::assertSame('قانون محیطی با اولویت بالا', $applied[0]['name']);
|
||
}
|
||
|
||
// ── نسخه ────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* قانون **ویرایش نمیشود**: تغییر یعنی نسخهٔ تازه، و شمارهٔ نسخه در فاکتور ثبت
|
||
* میشود تا سه ماه بعد بشود گفت کدام متن اعمال شده بود (قانون پنجم مستند).
|
||
*/
|
||
public function testEditingAPolicyCreatesANewVersionAndTheQuoteRecordsIt(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'لیزر صورت', 20, 1_000_000);
|
||
|
||
$policy = $this->policy($user, [
|
||
'category' => 'pricing',
|
||
'name' => 'تخفیف پاییز',
|
||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||
]);
|
||
|
||
self::assertSame(1, $policy['version']);
|
||
|
||
$first = $this->quote($user, $service, $address);
|
||
self::assertSame(100_000, $first['data']['discount_rials']);
|
||
self::assertSame(1, $first['data']['breakdown']['sources']['applied_policies'][0]['version']);
|
||
|
||
$updated = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
|
||
'effects' => [['type' => 'discount_percent', 'value' => 20]],
|
||
]);
|
||
|
||
self::assertSame(200, $this->responseCode(), json_encode($updated, JSON_UNESCAPED_UNICODE));
|
||
self::assertSame(2, $updated['data']['version']);
|
||
|
||
// نسخهٔ تازه فعال میماند؛ آزمایش دوباره لازم نیست چون قانون از قبل فعال بود.
|
||
|
||
$second = $this->quote($user, $service, $address);
|
||
self::assertSame(200_000, $second['data']['discount_rials']);
|
||
self::assertSame(2, $second['data']['breakdown']['sources']['applied_policies'][0]['version']);
|
||
|
||
// هر دو نسخه در تاریخچه میمانند.
|
||
$show = $this->authJson('GET', "/api/v1/policy/{$policy['uuid']}", $user);
|
||
self::assertSame([1, 2], array_column($show['data']['versions'], 'version'));
|
||
}
|
||
|
||
/**
|
||
* ⭐ نسخهٔ تازه نمیتواند ادعا کند از دیروز برقرار بوده.
|
||
*
|
||
* نوبتهای دیروز با متن قبلی حساب شدهاند؛ اعتبار عقبرونده یعنی ردپای قیمتها با
|
||
* قانونی توضیح داده شود که آن روز وجود نداشت.
|
||
*/
|
||
public function testANewVersionCannotStartInThePast(): void
|
||
{
|
||
[$user, , ] = $this->clinicWithBranch();
|
||
|
||
$policy = $this->policy($user, [
|
||
'category' => 'pricing',
|
||
'name' => 'تخفیف پاییز',
|
||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||
], false);
|
||
|
||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
|
||
'valid_from' => time() - 7 * 86400,
|
||
]);
|
||
|
||
self::assertSame(422, $this->responseCode());
|
||
|
||
// آینده مجاز است.
|
||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
|
||
'valid_from' => time() + 86400,
|
||
]);
|
||
|
||
self::assertSame(200, $this->responseCode());
|
||
}
|
||
|
||
/**
|
||
* ⭐ شش عملگر، هر کدام جدا. عملگری که غلط بسنجد، قانونی میسازد که یا همیشه
|
||
* میگیرد یا هرگز — و هیچکدام خطا نمیدهند.
|
||
*
|
||
*/
|
||
#[\PHPUnit\Framework\Attributes\DataProvider('operatorCases')]
|
||
public function testEachOperatorDecidesOnItsOwn(array $clause, bool $expected): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'خدمت عملگر', 20, 1_000_000);
|
||
|
||
$this->policy($user, [
|
||
'category' => 'pricing',
|
||
'name' => 'آزمون عملگر',
|
||
'condition' => ['match' => 'all', 'conditions' => [$clause]],
|
||
'effects' => [['type' => 'discount_percent', 'value' => 50]],
|
||
]);
|
||
|
||
$quote = $this->quote($user, $service, $address);
|
||
|
||
self::assertSame(
|
||
$expected ? 500_000 : 0,
|
||
$quote['data']['discount_rials'],
|
||
json_encode($clause, JSON_UNESCAPED_UNICODE),
|
||
);
|
||
}
|
||
|
||
/** بدون `item_uuids` هیچ آیتم اضافهای انتخاب نشده، پس `item_count` صفر است. */
|
||
public static function operatorCases(): array
|
||
{
|
||
return [
|
||
'equals میگیرد' => [['field' => 'item_count', 'operator' => 'equals', 'value' => 0], true],
|
||
'equals نمیگیرد' => [['field' => 'item_count', 'operator' => 'equals', 'value' => 9], false],
|
||
'not_equals میگیرد' => [['field' => 'item_count', 'operator' => 'not_equals', 'value' => 9], true],
|
||
'not_equals نمیگیرد' => [['field' => 'item_count', 'operator' => 'not_equals', 'value' => 0], false],
|
||
'greater_than میگیرد' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => -1], true],
|
||
'greater_than نمیگیرد' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => 5], false],
|
||
'less_than میگیرد' => [['field' => 'item_count', 'operator' => 'less_than', 'value' => 5], true],
|
||
'less_than نمیگیرد' => [['field' => 'item_count', 'operator' => 'less_than', 'value' => 0], false],
|
||
'in میگیرد' => [['field' => 'item_count', 'operator' => 'in', 'value' => [0, 2]], true],
|
||
'in نمیگیرد' => [['field' => 'item_count', 'operator' => 'in', 'value' => [7, 8]], false],
|
||
'contains نمیگیرد' => [['field' => 'patient_tags', 'operator' => 'contains', 'value' => 'vip'], false],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* ⭐ قانون `spacing` در لحظهٔ **رزرو موقت** اجرا میشود، نه هنگام تولید کاندید.
|
||
*
|
||
* هزینهاش یک اسلات است که نمایش داده میشود و بعد رد میشود؛ سودش این است که
|
||
* جستجوی وقت بهازای هر کاندید یک کوئری تاریخچهٔ بیمار نمیزند. این تست همان مرز را
|
||
* پین میکند: نوبت نزدیک رد میشود، نوبت دور میگذرد.
|
||
*/
|
||
public function testSpacingRejectsABookingTooCloseToTheLastOne(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'لیزر', 20, 1_000_000);
|
||
|
||
$this->policy($user, [
|
||
'category' => 'spacing',
|
||
'name' => 'حداقل ۲۱ روز بین جلسات',
|
||
'effects' => [['type' => 'min_days_between', 'value' => 21]],
|
||
]);
|
||
|
||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||
$doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر فاصله');
|
||
$this->em->persist($doctor);
|
||
$this->em->flush();
|
||
|
||
$patient = $this->createUser(['ROLE_USER']);
|
||
$last = time() - 5 * 86400;
|
||
|
||
$previous = new \App\Appointment\Entity\Appointment($doctor, $patient, $last, $last + 1200);
|
||
$previous->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId());
|
||
$previous->setServiceItem($this->em->getRepository(ServiceItem::class)->find($service->getId()));
|
||
$previous->setAddressId($address->getId());
|
||
$previous->setPatientName('بیمار فاصله');
|
||
$previous->transitionTo(\App\Appointment\Entity\Appointment::STATUS_CONFIRMED);
|
||
$this->em->persist($previous);
|
||
$this->em->flush();
|
||
|
||
$guard = static::getContainer()->get(\App\Policy\Service\BookingPolicyGuard::class);
|
||
|
||
// پنج روز بعد از جلسهٔ قبلی → رد.
|
||
$rejected = false;
|
||
try {
|
||
$guard->assertSpacing($patient, $service, $address, $last + 5 * 86400);
|
||
} catch (\App\Shared\Exception\AppException $e) {
|
||
$rejected = true;
|
||
self::assertStringContainsString('۲۱', str_replace(
|
||
['0','1','2','3','4','5','6','7','8','9'],
|
||
['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'],
|
||
$e->getMessage(),
|
||
));
|
||
}
|
||
self::assertTrue($rejected, 'فاصلهٔ کمتر از قانون باید رد شود');
|
||
|
||
// سی روز بعد → میگذرد.
|
||
$guard->assertSpacing($patient, $service, $address, $last + 30 * 86400);
|
||
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
|
||
{
|
||
[$owner, , ] = $this->clinicWithBranch();
|
||
[$other, $section, $address] = $this->clinicWithBranch();
|
||
|
||
$service = $this->service($section, 'خدمت کلینیک دوم', 20, 1_000_000);
|
||
|
||
$foreign = $this->policy($owner, [
|
||
'category' => 'pricing',
|
||
'name' => 'تخفیف کلینیک اول',
|
||
'effects' => [['type' => 'discount_percent', 'value' => 50]],
|
||
]);
|
||
|
||
$this->authJson('GET', "/api/v1/policy/{$foreign['uuid']}", $other);
|
||
self::assertSame(404, $this->responseCode());
|
||
|
||
$quote = $this->quote($other, $service, $address);
|
||
|
||
self::assertSame(0, $quote['data']['discount_rials']);
|
||
self::assertArrayNotHasKey('applied_policies', $quote['data']['breakdown']['sources']);
|
||
}
|
||
}
|