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 $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 $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 $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', array_column($schema['timing']['operators'], 'value')); 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', 'greater_or_equal', 'less_than', 'less_or_equal', 'between', 'in', 'not_in'], $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); } /** * ⭐ `specificity` هنگام **ذخیره** حساب می‌شود و در تساوی اولویت تصمیم می‌گیرد. * * محاسبه‌اش در زمان اجرا یعنی کاری که یک بار در عمر قانون کافی بود، در هر رزرو * تکرار شود؛ و ذخیره‌شدنش یعنی می‌شود روزی مرتب‌سازی را به SQL برد. */ public function testSpecificityIsStoredAndDecidesTiesAtEqualPriority(): void { [$user, $section, $address] = $this->clinicWithBranch(); $service = $this->service($section, 'لیزر', 20, 1_000_000); // قانون عام: بدون دامنه، بدون شرط. $broad = $this->policy($user, [ 'category' => 'pricing', 'name' => 'تخفیف عمومی', 'priority' => 5, 'effects' => [['type' => 'discount_percent', 'value' => 10]], ]); // قانون خاص: همان اولویت، ولی سرویس و یک شرط دارد. $narrow = $this->policy($user, [ 'category' => 'pricing', 'name' => 'تخفیف همین سرویس', 'priority' => 5, 'service_uuid' => $service->getUuid(), 'condition' => ['match' => 'all', 'conditions' => [ ['field' => 'item_count', 'operator' => 'greater_or_equal', 'value' => 0], ]], 'effects' => [['type' => 'discount_percent', 'value' => 40]], ]); self::assertSame(0, $broad['specificity'], 'قانون بی‌دامنه و بی‌شرط'); self::assertSame(5, $narrow['specificity'], 'سرویس ۴ + یک شرط ۱'); // هر دو اعمال می‌شوند (تخفیف درصدی جمع می‌شود)، ولی **ترتیب** مال specificity است: // اختصاصی‌تر اول می‌آید، و همان ترتیبی است که اثرهای «اولی برنده» را تعیین می‌کند. $quote = $this->quote($user, $service, $address); $names = array_column($quote['data']['breakdown']['sources']['applied_policies'], 'name'); self::assertSame(['تخفیف همین سرویس', 'تخفیف عمومی'], $names, 'اختصاصی‌تر باید اول باشد'); } // ── جداسازی محیط ──────────────────────────────────────────────────────── 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']); } }