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>
572 lines
26 KiB
PHP
572 lines
26 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],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* ⭐ قانون `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']);
|
||
}
|
||
}
|