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' => $user, 'section' => $section, 'address' => $address]; } private function service(ServiceSection $section, string $name, int $price): ServiceItem { $section = $this->em->getRepository(ServiceSection::class)->find($section->getId()); $item = new ServiceItem($section, $name); $item->setPriceRials($price); $item->setSoloDurationMinutes(20); $this->em->persist($item); $this->em->flush(); return $item; } /** @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 testFallsBackToTheServicePrice(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'بوتاکس', 5_000_000); $body = $this->quote($c['user'], $service, $c['address']); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); self::assertSame(5_000_000, $body['data']['base_rials']); self::assertSame(5_000_000, $body['data']['final_rials']); self::assertSame('service_item', $body['data']['breakdown']['sources'][$service->getUuid()]); } /** override شعبه فقط مدت را عوض می‌کند؛ قیمت همچنان از خودِ سرویس می‌آید. */ public function testBranchOverrideNoLongerChangesThePrice(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'بوتاکس', 5_000_000); $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/branch-overrides", $c['user'], [ 'overrides' => [[ 'address_uuid' => $c['address']->getUuid(), 'solo_duration_minutes' => 45, ]], ]); self::assertSame(200, $this->responseCode()); $body = $this->quote($c['user'], $service, $c['address']); self::assertSame(5_000_000, $body['data']['base_rials']); self::assertSame('service_item', $body['data']['breakdown']['sources'][$service->getUuid()]); } /** قیمتِ ارسالی برای override نادیده گرفته می‌شود — شعبه دیگر قیمت ندارد. */ public function testBranchOverridePayloadHasNoPriceField(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'لیزر', 3_000_000); $body = $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/branch-overrides", $c['user'], [ 'overrides' => [[ 'address_uuid' => $c['address']->getUuid(), 'price_rials' => 9_000_000, ]], ]); self::assertSame(200, $this->responseCode()); self::assertArrayNotHasKey('price_rials', $body['data'][0]); self::assertSame(3_000_000, $this->quote($c['user'], $service, $c['address'])['data']['base_rials']); } public function testFullChainAppliesInOrder(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'لیزر', 10_000_000); $extra = $this->service($c['section'], 'ناحیهٔ اضافه', 2_000_000); $body = $this->quote($c['user'], $service, $c['address'], [ 'item_uuids' => [$extra->getUuid()], 'policy' => [ 'discount_percent' => 10, 'insurance_base_percent' => 20, 'insurance_supplementary_percent' => 50, 'tax_percent' => 10, 'deposit_percent' => 30, ], ]); $d = $body['data']; self::assertSame(10_000_000, $d['base_rials']); self::assertSame(2_000_000, $d['items_rials']); self::assertSame(1_200_000, $d['discount_rials'], '۱۰٪ از ۱۲ میلیون'); self::assertSame(2_160_000, $d['insurance_base_rials'], '۲۰٪ از ۱۰٫۸ میلیون'); self::assertSame(4_320_000, $d['insurance_supplementary_rials'], '۵۰٪ از باقیمانده'); self::assertSame(432_000, $d['tax_rials'], '۱۰٪ روی سهم بیمار، نه روی کل'); self::assertSame(4_752_000, $d['final_rials']); self::assertSame(1_425_600, $d['deposit_rials']); } /** تخفیف بیشتر از مبلغ، مبلغ را صفر می‌کند نه منفی. */ public function testDiscountLargerThanTheAmountFloorsAtZero(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'ویزیت', 1_000_000); $body = $this->quote($c['user'], $service, $c['address'], [ 'policy' => ['discount_rials' => 5_000_000], ]); self::assertSame(0, $body['data']['final_rials']); self::assertGreaterThanOrEqual(0, $body['data']['discount_rials']); } /** سقف جمع تخفیف‌ها per محیط اعمال می‌شود. */ public function testTotalDiscountCapIsApplied(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'ویزیت', 10_000_000); $body = $this->quote($c['user'], $service, $c['address'], [ 'policy' => [ 'discount_percent' => 40, 'discount_rials' => 3_000_000, 'max_total_discount_percent' => 25, ], ]); self::assertSame(2_500_000, $body['data']['discount_rials'], 'سقف ۲۵٪ از ۱۰ میلیون'); self::assertSame(7_500_000, $body['data']['final_rials']); } /** * ⭐ جمعِ ردیف‌ها **باید** برابر مبلغ نهایی باشد. * * تست زنجیره عددها را تک‌تک می‌سنجد؛ این یکی خودِ رابطه را می‌سنجد. اگر روزی ردیف * تازه‌ای اضافه شود و در جمع نهایی حساب نشود، آن تست سبز می‌ماند و این قرمز. */ public function testTheRowsAlwaysAddUpToTheFinalAmount(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'لیزر', 9_500_000); $extra = $this->service($c['section'], 'ناحیهٔ اضافه', 3_300_000); $d = $this->quote($c['user'], $service, $c['address'], [ 'item_uuids' => [$extra->getUuid()], 'policy' => [ 'discount_percent' => 7, 'insurance_base_percent' => 15, 'insurance_supplementary_percent' => 25, 'tax_percent' => 9, ], ])['data']; $sum = $d['base_rials'] + $d['items_rials'] - $d['discount_rials'] - $d['insurance_base_rials'] - $d['insurance_supplementary_rials'] + $d['tax_rials']; self::assertSame($d['final_rials'], $sum, json_encode($d, JSON_UNESCAPED_UNICODE)); } /** بیعانهٔ مبلغی بر درصدی مقدم است و هرگز از مبلغ نهایی بیشتر نمی‌شود. */ public function testAFixedDepositOverridesThePercentageAndIsCapped(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'ویزیت', 2_000_000); $fixed = $this->quote($c['user'], $service, $c['address'], [ 'policy' => ['deposit_percent' => 50, 'deposit_rials' => 300_000], ])['data']; self::assertSame(300_000, $fixed['deposit_rials'], 'مبلغی برنده است، نه ۵۰٪'); $capped = $this->quote($c['user'], $service, $c['address'], [ 'policy' => ['deposit_rials' => 9_000_000], ])['data']; self::assertSame( $capped['final_rials'], $capped['deposit_rials'], 'بیعانهٔ بیشتر از مبلغ نهایی یعنی بدهکار کردن بیمار پیش از ویزیت', ); } public function testForeignServiceIsNotFound(): void { $c = $this->clinic(); $other = $this->clinic(); $foreign = $this->service($other['section'], 'سرویس بیگانه', 1_000_000); $this->quote($c['user'], $foreign, $c['address']); self::assertSame(404, $this->responseCode()); } /** * ⭐ قانون پنجم مستند: «تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.» * * نوبت ثبت می‌شود، بعد قیمت سرویس دو برابر می‌شود، و فاکتور همان اعداد قبلی را * می‌دهد. بدون این تست، تسک تأییدشده نیست. */ public function testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'لیزر', 4_000_000); $room = new \App\Resource\Entity\ResourceType( $c['address']->tenantEntityType(), $c['address']->tenantEntityId(), 'room', 'اتاق', ); $this->em->persist($room); $this->em->flush(); $resource = $this->authJson('POST', '/api/v1/resource', $c['user'], [ 'address_uuid' => $c['address']->getUuid(), 'type_uuid' => $room->getUuid(), 'name' => 'اتاق ۱', 'supervisor_doctor_uuid' => $this->supervisorFor($c['address'])->getUuid(), ]); self::assertSame(201, $this->responseCode()); $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $c['user'], [ 'days' => array_fill_keys(range(0, 6), [['start_minute' => 0, 'end_minute' => 1440]]), ]); $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $c['user'], [ 'segments' => [[ 'sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]], ]], ]); self::assertSame(200, $this->responseCode()); $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); $doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر فاکتور'); $this->em->persist($doctor); $this->em->flush(); $start = (new \DateTimeImmutable('next saturday', new \DateTimeZone('Asia/Tehran'))) ->setTime(9, 0) ->getTimestamp(); $hold = $this->authJson('POST', '/api/v1/appointment-hold', $c['user'], [ 'service_uuid' => $service->getUuid(), 'branch_uuid' => $c['address']->getUuid(), 'start' => $start, 'assignment' => ['room' => [$resource['data']['uuid']]], ]); self::assertSame(201, $this->responseCode(), json_encode($hold, JSON_UNESCAPED_UNICODE)); $confirmed = $this->authJson('POST', '/api/v1/appointment-confirm', $c['user'], [ 'hold_uuid' => $hold['data']['hold_uuid'], 'doctor_uuid' => $doctor->getUuid(), 'service_uuid' => $service->getUuid(), 'branch_uuid' => $c['address']->getUuid(), ]); self::assertSame(200, $this->responseCode(), json_encode($confirmed, JSON_UNESCAPED_UNICODE)); self::assertSame(4_000_000, $confirmed['data']['price_snapshot']['final_rials']); $appointmentUuid = $confirmed['data']['appointment_uuid']; // حالا قیمت دو برابر می‌شود. $this->em->clear(); $reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $service->getUuid()]); $reloaded->setPriceRials(8_000_000); $this->em->flush(); // قیمت جدید در quote دیده می‌شود… $fresh = $this->quote($c['user'], $reloaded, $c['address']); self::assertSame(8_000_000, $fresh['data']['final_rials']); // …ولی فاکتور نوبتِ ثبت‌شده دست‌نخورده است. $snapshot = $this->authJson('GET', "/api/v1/appointment/$appointmentUuid/price-snapshot", $c['user']); self::assertSame(200, $this->responseCode(), json_encode($snapshot, JSON_UNESCAPED_UNICODE)); self::assertSame(4_000_000, $snapshot['data']['final_rials'], 'قانون پنجم: فاکتور ثبت‌شده عوض نمی‌شود'); self::assertSame(4_000_000, $snapshot['data']['base_rials']); } /** * ⭐ نوبت بدون سرویس هم فاکتور می‌گیرد. * * حالت اسلاتی سرویس ندارد؛ اگر فاکتورش ساخته نمی‌شد، گزارش مالی یک ردیف کم داشت و * هیچ‌جا هم معلوم نمی‌شد کدام ردیف. */ public function testAnAppointmentWithoutAServiceStillGetsAnInvoice(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'ویزیت', 1_500_000); $room = new \App\Resource\Entity\ResourceType( $c['address']->tenantEntityType(), $c['address']->tenantEntityId(), 'room', 'اتاق', ); $this->em->persist($room); $this->em->flush(); $resource = $this->authJson('POST', '/api/v1/resource', $c['user'], [ 'address_uuid' => $c['address']->getUuid(), 'type_uuid' => $room->getUuid(), 'name' => 'اتاق ویزیت', 'supervisor_doctor_uuid' => $this->supervisorFor($c['address'])->getUuid(), ]); self::assertSame(201, $this->responseCode()); $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $c['user'], [ 'days' => array_fill_keys(range(0, 6), [['start_minute' => 0, 'end_minute' => 1440]]), ]); $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $c['user'], [ 'segments' => [[ 'sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 15, 'requirements' => [['type_uuid' => $room->getUuid()]], ]], ]); self::assertSame(200, $this->responseCode()); $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); $doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر ویزیت'); $this->em->persist($doctor); $this->em->flush(); $start = (new \DateTimeImmutable('next saturday', new \DateTimeZone('Asia/Tehran'))) ->setTime(11, 0) ->getTimestamp(); $hold = $this->authJson('POST', '/api/v1/appointment-hold', $c['user'], [ 'service_uuid' => $service->getUuid(), 'branch_uuid' => $c['address']->getUuid(), 'start' => $start, 'assignment' => ['room' => [$resource['data']['uuid']]], ]); self::assertSame(201, $this->responseCode(), json_encode($hold, JSON_UNESCAPED_UNICODE)); // ثبت **بدون** `service_uuid`: مسیر فاکتور تخت. $confirmed = $this->authJson('POST', '/api/v1/appointment-confirm', $c['user'], [ 'hold_uuid' => $hold['data']['hold_uuid'], 'doctor_uuid' => $doctor->getUuid(), ]); self::assertSame(200, $this->responseCode(), json_encode($confirmed, JSON_UNESCAPED_UNICODE)); $snapshot = $this->authJson( 'GET', "/api/v1/appointment/{$confirmed['data']['appointment_uuid']}/price-snapshot", $c['user'], ); self::assertSame(200, $this->responseCode(), 'فاکتور باید وجود داشته باشد'); self::assertSame(0, $snapshot['data']['items_rials']); self::assertSame($snapshot['data']['base_rials'], $snapshot['data']['final_rials']); } }