em->getRepository(SiteConfig::class)->findOneBy(['configKey' => $key]); if ($cfg === null) { $this->em->persist(new SiteConfig($key, $value)); } else { $cfg->setValue($value); } $this->em->flush(); } /** @return array{Representation, City, Doctor} */ private function makeRepWithCityAndDoctor(): array { $city = new City('شهر تست ' . substr(uniqid(), -6)); $city->setDomain('city-' . substr(uniqid(), -6) . '-nobat.ir'); $this->em->persist($city); $rep = new Representation($this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']), 'نمایندهٔ شهری'); $rep->setCommissionPercent('30'); $rep->setActive(true); $rep->setCities([$city]); $this->em->persist($rep); $this->em->flush(); $doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'دکتر تست'); $doctor->setRepresentationId($rep->getId()); $this->em->persist($doctor); $this->em->flush(); return [$rep, $city, $doctor]; } private function makePaidOnlineAppointment(Representation $rep, City $city, Doctor $doctor): Payment { $start = time() + 86400; $appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900); $appointment->setBookingRepresentationId($rep->getId()); $this->em->persist($appointment); $payment = new Payment( $this->createUser(), 2_000_000, 'mock', Payment::TYPE_APPOINTMENT, 'https://' . $city->getDomain() . '/payment/result', ); $payment->setAppointment($appointment); $this->stampTenant($payment); $this->em->persist($payment); $this->em->flush(); return $payment; } private function fireSuccessfulCallback(Payment $payment): void { $this->client->request('POST', PaymentManager::CALLBACK_PATH . '?' . http_build_query([ 'order_id' => $payment->getOrderId(), 'gateway' => 'mock', 'mock' => '1', 'ResCode' => '0', 'mock_amount' => (string) $payment->getAmountRials(), ])); } private function breakdowns(): FinancialBreakdownRepository { return static::getContainer()->get(FinancialBreakdownRepository::class); } public function testCommissionIsRecordedOnPaymentWhileTheAppointmentIsStillPending(): void { $this->setConfig('payment_test_mode', '1'); $this->setConfig('appointment_commission_enabled', '1'); [$rep, $city, $doctor] = $this->makeRepWithCityAndDoctor(); $payment = $this->makePaidOnlineAppointment($rep, $city, $doctor); $this->fireSuccessfulCallback($payment); $this->em->clear(); $fresh = $this->em->getRepository(Payment::class)->find($payment->getId()); self::assertSame(Payment::STATUS_SUCCESS, $fresh->getStatus()); self::assertSame(Appointment::STATUS_PENDING, $fresh->getAppointment()->getStatus(), 'تأیید نوبت شرط پورسانت نیست'); self::assertTrue($this->breakdowns()->existsForPayment($fresh), 'پورسانت باید در لحظهٔ پرداخت ثبت شود'); } public function testCommissionIsNotRecordedTwiceWhenTheAppointmentIsLaterConfirmed(): void { $this->setConfig('payment_test_mode', '1'); $this->setConfig('appointment_commission_enabled', '1'); [$rep, $city, $doctor] = $this->makeRepWithCityAndDoctor(); $payment = $this->makePaidOnlineAppointment($rep, $city, $doctor); $this->fireSuccessfulCallback($payment); $this->em->clear(); $fresh = $this->em->getRepository(Payment::class)->find($payment->getId()); $appointment = $fresh->getAppointment(); $appointment->transitionTo(Appointment::STATUS_CONFIRMED); $this->em->flush(); static::getContainer()->get(\App\Appointment\Service\AppointmentConfirmationService::class) ->onConfirmed($appointment); $rows = $this->em->getRepository(\App\Settlement\Entity\FinancialBreakdown::class) ->findBy(['payment' => $fresh]); self::assertCount(1, $rows, 'ثبت باید idempotent بماند'); } /** پنل خودِ نماینده هم همان قاعده را دارد، نه فقط گزارش ماهانه/سالانه. */ public function testRepresentationPanelSummaryCountsOnlyOnlineAppointments(): void { [$rep, , $doctor] = $this->makeRepWithCityAndDoctor(); $start = time() + 86400; $online = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900); $online->setBookingRepresentationId($rep->getId()); $this->em->persist($online); $manual = $this->newAppointment($doctor, $this->createUser(), $start + 3600, $start + 4500); $this->em->persist($manual); $this->em->flush(); $body = $this->authJson('GET', '/api/v1/representation/dashboard/summary', $rep->getUser()); self::assertSame(1, $body['data']['appointments']['total']); $perf = $this->authJson('GET', '/api/v1/representation/doctors/performance?limit=100', $rep->getUser()); $row = current(array_filter($perf['data'], fn(array $r) => $r['uuid'] === $doctor->getUuid())); self::assertSame(1, $row['appointments']['total']); } public function testDashboardCountsOnlyOnlineAppointments(): void { [$rep, , $doctor] = $this->makeRepWithCityAndDoctor(); $start = time() + 86400; $online = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900); $online->setBookingRepresentationId($rep->getId()); $this->em->persist($online); // ثبت‌شده در پنل توسط منشی: مبدأ سایت نماینده نیست. $manual = $this->newAppointment($doctor, $this->createUser(), $start + 3600, $start + 4500); $this->em->persist($manual); $this->em->flush(); $admin = $this->createUser(['ROLE_ADMIN']); $body = $this->authJson( 'GET', '/api/v1/representation/' . $rep->getUuid() . '/dashboard/yearly?year=' . $this->jalaliYearOf(time()), $admin, ); self::assertSame(1, $body['data']['totals']['total_appointments']); } private function jalaliYearOf(int $ts): int { return static::getContainer()->get(\App\Representation\Service\JalaliDateService::class)->jalaliYear($ts); } }