Task 09 shipped a powerful API that a non-technical clinic owner could not safely use. This closes that gap: activation now requires having seen what the rule actually does. - PolicySimulator runs a policy against real past appointments and writes nothing: evaluation works on facts (never entities), the whole run sits in a transaction rolled back and cleared in `finally`, and a test counts rows in five sensitive tables before and after - activate() now demands a simulation of the *same version* — a report for version 1 does not unlock version 2 - PolicyTemplateRegistry: six ready-made rules, so the common case never touches a raw condition - Severity from the affected ratio; 0% is a warning too, since a rule that changes nothing usually has a condition that never matches - An empty clinic still succeeds with a warning, otherwise a new clinic could never activate anything Admin: PoliciesPage, PolicyFormPage, PolicySimulationPage, and a PolicyConditionBuilder built entirely from GET /policy-schema — a test proves a field that exists only in the schema shows up with no frontend change, and that operators are filtered per field type. The schema response now carries per-field metadata (label, type, meaningful operators) so the form has one source of truth instead of two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
441 lines
19 KiB
PHP
441 lines
19 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', $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']);
|
|
|
|
// فرم باید عملگرها را per فیلد فیلتر کند، وگرنه کاربر «برچسب > ۵» میسازد و
|
|
// ۴۲۲ میگیرد بیآنکه بفهمد چرا.
|
|
$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(['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 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']);
|
|
}
|
|
}
|