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(), ]); } private function priceList(User $user, string $name, int $from, int $to, ?string $addressUuid = null): array { $body = $this->authJson('POST', '/api/v1/price-lists', $user, array_filter([ 'name' => $name, 'starts_at' => $from, 'ends_at' => $to, 'address_uuid' => $addressUuid, ])); self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); return $body['data']; } /** بدون هیچ لیست قیمتی، قیمت خودِ سرویس برمی‌گردد — هرگز صفر یا خطا. */ 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()]); } /** لیست قیمت فقط در بازهٔ خودش حاکم است. */ public function testPriceListAppliesOnlyInsideItsRange(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'بوتاکس', 5_000_000); $from = strtotime('+10 days'); $to = strtotime('+40 days'); $list = $this->priceList($c['user'], 'نیمهٔ دوم', $from, $to); $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [ 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 8_000_000]], ]); self::assertSame(200, $this->responseCode()); $this->authJson('POST', "/api/v1/price-list/{$list['uuid']}/activate", $c['user']); self::assertSame(200, $this->responseCode()); $inside = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]); self::assertSame(8_000_000, $inside['data']['base_rials'], 'داخل بازه: قیمت جدید'); $before = $this->quote($c['user'], $service, $c['address'], ['at' => $from - 86400]); self::assertSame(5_000_000, $before['data']['base_rials'], 'پیش از بازه: قیمت قبلی'); } /** دو لیست فعالِ هم‌پوشان یعنی یک تاریخ دو قیمت — هنگام فعال‌سازی رد می‌شود. */ public function testOverlappingActiveListsAreRejected(): void { $c = $this->clinic(); $from = strtotime('+10 days'); $first = $this->priceList($c['user'], 'اول', $from, $from + 30 * 86400); $this->authJson('POST', "/api/v1/price-list/{$first['uuid']}/activate", $c['user']); self::assertSame(200, $this->responseCode()); $second = $this->priceList($c['user'], 'دوم', $from + 10 * 86400, $from + 50 * 86400); $body = $this->authJson('POST', "/api/v1/price-list/{$second['uuid']}/activate", $c['user']); self::assertSame(422, $this->responseCode()); self::assertStringContainsString('هم‌پوشانی', $body['errors'][0]['message']); } /** لیستِ یک شعبه با لیست عمومی تداخل ندارد و بر آن مقدم است. */ public function testBranchListWinsOverTheGeneralList(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'بوتاکس', 5_000_000); $from = strtotime('+10 days'); $to = $from + 30 * 86400; $general = $this->priceList($c['user'], 'عمومی', $from, $to); $this->authJson('PUT', "/api/v1/price-list/{$general['uuid']}/items", $c['user'], [ 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 7_000_000]], ]); $this->authJson('POST', "/api/v1/price-list/{$general['uuid']}/activate", $c['user']); self::assertSame(200, $this->responseCode()); $branch = $this->priceList($c['user'], 'شعبهٔ مرکزی', $from, $to, $c['address']->getUuid()); $this->authJson('PUT', "/api/v1/price-list/{$branch['uuid']}/items", $c['user'], [ 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 9_000_000]], ]); $this->authJson('POST', "/api/v1/price-list/{$branch['uuid']}/activate", $c['user']); self::assertSame(200, $this->responseCode(), 'لیست شعبه با لیست عمومی تداخل ندارد'); $body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]); self::assertSame(9_000_000, $body['data']['base_rials']); } /** override شعبه (تسک ۰۴) بر لیست قیمت مقدم است. */ public function testBranchOverrideBeatsThePriceList(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'بوتاکس', 5_000_000); $from = strtotime('+10 days'); $list = $this->priceList($c['user'], 'عمومی', $from, $from + 30 * 86400); $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [ 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 7_000_000]], ]); $this->authJson('POST', "/api/v1/price-list/{$list['uuid']}/activate", $c['user']); $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/branch-overrides", $c['user'], [ 'overrides' => [['address_uuid' => $c['address']->getUuid(), 'price_rials' => 11_000_000]], ]); self::assertSame(200, $this->responseCode()); $body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]); self::assertSame(11_000_000, $body['data']['base_rials']); self::assertSame('branch_override', $body['data']['breakdown']['sources'][$service->getUuid()]); } 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 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 testNegativePriceIsRejected(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'ویزیت', 1_000_000); $list = $this->priceList($c['user'], 'تست', strtotime('+1 day'), strtotime('+30 days')); $body = $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [ 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => -100]], ]); self::assertSame(422, $this->responseCode()); self::assertSame('price_rials', $body['errors'][0]['field']); } /** * ⭐ قانون پنجم مستند: «تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.» * * نوبت ثبت می‌شود، بعد قیمت سرویس دو برابر می‌شود، و فاکتور همان اعداد قبلی را * می‌دهد. بدون این تست، تسک تأییدشده نیست. */ 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' => 'اتاق ۱', ]); 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 testDraftListHasNoEffectUntilActivated(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'بوتاکس', 5_000_000); $from = strtotime('+2 days'); $list = $this->priceList($c['user'], 'پیش‌نویس', $from, $from + 30 * 86400); $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [ 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 9_999_999]], ]); $body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]); self::assertSame(5_000_000, $body['data']['base_rials'], 'پیش‌نویس نباید قیمت را عوض کند'); } }