Files
clinicpro/tests/Policy/PolicyEngineTest.php
T
hamedandClaude Opus 5 584ea4067f feat(policy): six-category policy engine wired into the booking flow
Rules become data instead of code: a clinic can say "laser under 18 requires
parental consent" without a deploy.

Engine
- Policy / PolicyVersionLog entities, closed field/operator/effect lists per
  category (PolicySchema), condition validation at write time
- PolicyResolver: priority -> specificity -> age, combining effects by
  veto / max / sum / union
- A missing fact fails its clause instead of silently passing it
- Policies are drafts until activated, and are versioned rather than edited

Wiring
- selection -> ServiceSelectionValidator
- eligibility + spacing -> BookingPolicyGuard, at hold time not confirm time
- resource + timing -> AppointmentPlanBuilder, including template-less services
- pricing -> PricingEngine, alongside (not replacing) the manual discount

The condition column is named condition_json: `condition` is a MariaDB keyword
and broke every INSERT.

Tests: 17 in tests/Policy including NoPolicyRegressionTest, which pins that a
clinic with no policies sees byte-identical output to task 08.
Docs: docs/api/policy.md (real captured JSON) + docs/architecture/policy-engine.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:19:19 +03:30

426 lines
18 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'];
}
$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', $schema['timing']['operators']);
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']);
}
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 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']);
}
}