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']; } $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', $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']); } 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']); } }