Files
clinicpro/tests/Policy/PolicyEngineTest.php
T
hamedandClaude Opus 5 ca2f9b8652 feat(admin): build the last two screens, and pin spacing with a test
The cancellation policy page showed only the tenant policy, so nothing said
which services opt out of it. Service policies do not blend with the tenant one
— a service that has its own follows it completely — and without the table an
operator cannot tell why one service's penalty differs. It lists them with a
link to each service.

The waitlist had the matches endpoint and no way to reach it. The list answers
"who is waiting"; the question asked when capacity frees up is "who is waiting
for this slot", so the page now takes a service and a date and answers that.
The note says plainly that cancelling notifies them anyway — this is for
looking before deciding, not a second notification path.

Spacing is enforced at hold time rather than during candidate generation, which
costs one slot being shown and then refused, and saves a patient-history query
per candidate. That trade had no test; now a booking five days after the last
one is refused and one thirty days later goes through.

Checklists across all sixteen tasks are final: no pending rows, and the
warnings that remain are recorded decisions — one resolver instead of six
engines, a closed list instead of a registry, sample size three instead of ten
— each with the reason it was taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:32:58 +03:30

572 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 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);
}
// ── جداسازی محیط ────────────────────────────────────────────────────────
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']);
}
}