Files
clinicpro/tests/Policy/PolicyEngineTest.php
T
hamedandClaude Opus 5 fe48b10fb5 fix(plan): stop the segment replace from destroying segments when it rejects
PUT /service-item/{uuid}/segments deletes and rewrites. deleteForService issues
a DQL DELETE that runs immediately, and three validations — duration, occupancy
and constraints — only ran afterwards, while building the new rows. A rejected
request therefore deleted the service's segments and saved nothing, and the
service silently fell back to "one continuous block": different duration,
different resources, on every future appointment, with a 422 as the only clue.

Validation now happens before the delete, and the delete plus rewrite are one
transaction. A test pins it: an unknown constraint is refused and the previous
two segments are still there afterwards.

While in there, the caps the task asked for and never got: 20 segments and 10
requirements per segment. The availability engine evaluates resource
combinations per segment per requirement, so the numbers protect the search
rather than the table. They are generous — no real service reaches them, but a
bad payload does.

The plan response now carries patient_facing_minutes. "Set aside 90 minutes"
is wrong for an appointment where 40 of them are waiting for anaesthetic to
take effect, and computing it once in the backend stops each client summing it
differently.

A condition on a fact the request never supplies still evaluates to false —
that part was right — but it now logs a warning naming the policy and listing
the facts that were available. A rule that hits that line every time is
effectively switched off, and nothing said so.

A new policy version can no longer start in the past: yesterday's appointments
were priced under the previous text, and their price trace points at the
version. Backdating makes that trace describe a rule that did not exist.

require_resource errors name the policy that demanded the role. Knowing a room
is missing does not tell an operator which of ten active rules to look at.

Six operators now have a test each. An operator that compares wrongly produces
a rule that always matches or never does, and neither raises anything.

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

516 lines
23 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 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],
];
}
// ── جداسازی محیط ────────────────────────────────────────────────────────
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']);
}
}