diff --git a/docs/api/representation.md b/docs/api/representation.md index 689d1c5f..90f91863 100644 --- a/docs/api/representation.md +++ b/docs/api/representation.md @@ -165,6 +165,8 @@ Delete a representation. Get monthly earnings dashboard for a representation. +`total_appointments` فقط نوبت‌های **آنلاین** را می‌شمارد — یعنی نوبت‌هایی که از سایتِ همین نماینده رزرو شده‌اند (`appointments.booking_representation_id` برابر همین نماینده). نوبتی که منشی در پنل ثبت می‌کند از سایت نیامده و در آمار نماینده نمی‌آید. + **Permission:** `AUTH` — must be the representation's user or `ROLE_ADMIN` ### Path Parameters @@ -264,11 +266,20 @@ Get yearly earnings dashboard for a representation. ## قانون کمیسیون دامنه‌محور کمیسیون (نوبت **و** اشتراک) فقط وقتی ثبت می‌شود که **هر دو** شرط برقرار باشد: -1. دامنه‌ی مبدأ خرید (`payment.frontend_address`) متعلق به یک نماینده‌ی فعال باشد (`representations.domain`). +1. دامنه‌ی مبدأ خرید (`payment.frontend_address`) به یک نماینده‌ی فعال برسد. 2. پزشک/کلینیکِ موضوع خرید، `representation_id` همان نماینده را داشته باشد. +دامنه به نماینده به این ترتیب می‌رسد: + +- نماینده‌ای که همان دامنه را در `representations.domain` ثبت کرده (نماینده‌ی سراسری). +- وگرنه اگر دامنه، دامنه‌ی یک شهر باشد (`cities.domain`)، نماینده‌ی فعالِ همان شهر از `representation_cities`. + +اگر دو نماینده‌ی فعال یک شهر را پوشش دهند، نماینده‌ای انتخاب نمی‌شود: انتساب پول مبهم است و باید در داده صریح شود. + در غیر این صورت هیچ کمیسیونی برای هیچ نماینده‌ای ثبت نمی‌شود (پرداخت بدون `frontend_address` هم کمیسیون ندارد). درصد: نوبت = `commission_percent` نماینده؛ اشتراک = تنظیم سراسری `upgrade_commission_percent`. نگاشت دامنه فقط از طریق `DomainContextResolver` انجام می‌شود. +**زمان ثبت:** کمیسیون نوبت در لحظه‌ی **پرداخت موفق** ثبت می‌شود، نه در لحظه‌ی تأیید نوبت. نوبتِ `pending` هم کمیسیون دارد؛ تأیید کارِ پزشک/منشی است و ممکن است هرگز انجام نشود. ثبت idempotent است و مسیر تأیید دوباره چیزی نمی‌سازد. + --- ## پنل نماینده (ROLE_REPRESENTATION) diff --git a/src/Payment/Service/PaymentManager.php b/src/Payment/Service/PaymentManager.php index 6b240846..5ec937db 100644 --- a/src/Payment/Service/PaymentManager.php +++ b/src/Payment/Service/PaymentManager.php @@ -328,6 +328,17 @@ final class PaymentManager $this->em->persist($appointment); $doctor = $appointment->getDoctor(); + + // تقسیم مالی همین‌جا انجام می‌شود، نه در لحظهٔ تأیید نوبت: پول از سایت آمده و + // سهم نماینده باید همان لحظه ثبت شود. تأیید نوبت کارِ پزشک/منشی است و ممکن + // است روزها بعد یا هرگز انجام نشود. ثبت idempotent است، پس مسیر تأیید + // (`AppointmentConfirmationService`) دوباره چیزی نمی‌سازد. + $this->commissionService->processAppointment( + $payment, + $doctor->getRepresentationId(), + $this->bookingRepresentationIdFor($payment), + $doctor->getId(), + ); $mobile = $appointment->getPatientMobile(); if ($mobile) { $this->smsService->dispatchTemplate(SmsLog::TAG_PAYMENT, $mobile, [ diff --git a/src/Representation/Controller/RepresentationController.php b/src/Representation/Controller/RepresentationController.php index 6541cf96..59e5afea 100644 --- a/src/Representation/Controller/RepresentationController.php +++ b/src/Representation/Controller/RepresentationController.php @@ -493,11 +493,15 @@ class RepresentationController extends BaseController { $repId = $rep->getId(); - // نوبت‌های پزشکانِ همین نماینده در بازه. + // فقط نوبت‌های **آنلاینِ** همین نماینده: نوبتی که منشی در پنل ثبت می‌کند از + // سایتِ نماینده نیامده و سهمی هم نمی‌سازد، پس در آمار نماینده هم نباید بیاید. + // `bookingRepresentationId` تنها در مسیر رزرو سایت عمومی پر می‌شود. $totalAppointments = (int) $this->em->createQuery( 'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a JOIN a.doctor d - WHERE d.representationId = :repId AND a.createdAt BETWEEN :start AND :end' + WHERE d.representationId = :repId + AND a.bookingRepresentationId = :repId + AND a.createdAt BETWEEN :start AND :end' )->setParameters(['repId' => $repId, 'start' => $startTs, 'end' => $endTs]) ->getSingleScalarResult(); diff --git a/src/Representation/Repository/RepresentationRepository.php b/src/Representation/Repository/RepresentationRepository.php index 036ee33a..c66905e2 100644 --- a/src/Representation/Repository/RepresentationRepository.php +++ b/src/Representation/Repository/RepresentationRepository.php @@ -29,6 +29,30 @@ class RepresentationRepository extends ServiceEntityRepository return $this->findOneBy(['domain' => $domain, 'active' => true]); } + /** + * نمایندهٔ فعالِ یک شهر، از نگاشت چندشهریِ `representation_cities`. + * + * سایت‌های شهری دامنهٔ خودشان را دارند (`cities.domain`) و نماینده معمولاً ستون + * `domain` ندارد؛ بدون این، دامنهٔ شهری به هیچ نماینده‌ای نمی‌رسید و پورسانتِ + * نوبت‌های آن سایت هرگز حساب نمی‌شد. + * + * فقط وقتی نتیجه یکتاست برمی‌گردد: اگر دو نماینده یک شهر را پوشش دهند، انتساب + * پول مبهم است و باید صریح حل شود، نه با حدس. + */ + public function findActiveByCityId(int $cityId): ?Representation + { + $rows = $this->createQueryBuilder('r') + ->join('r.cities', 'c') + ->where('r.active = true') + ->andWhere('c.id = :cityId') + ->setParameter('cityId', $cityId) + ->setMaxResults(2) + ->getQuery() + ->getResult(); + + return count($rows) === 1 ? $rows[0] : null; + } + /** * تطبیق بر اساس اولین label دامنه (TLD-agnostic) — هم‌راستا با تطبیق subdomainِ * شهرها؛ لازم برای dev (مثلاً host=`x-nobat.localhost` باید نماینده‌ی `x-nobat.ir` diff --git a/src/Representation/Service/DomainContextResolver.php b/src/Representation/Service/DomainContextResolver.php index 67d66018..73010aa8 100644 --- a/src/Representation/Service/DomainContextResolver.php +++ b/src/Representation/Service/DomainContextResolver.php @@ -50,9 +50,12 @@ class DomainContextResolver $city = $this->cityRepo->findByDomain($domain); if ($city !== null) { - // دامنه‌ی شهری؛ نماینده‌ای که دامنه‌ی اختصاصی‌اش این باشد وجود ندارد، - // ولی ممکن است نماینده‌ای دامنه‌ی شهر را به‌عنوان دامنه‌ی خودش ثبت کرده باشد. - $rep = $this->representationRepo->findActiveByDomain($domain); + // دامنه‌ی شهری. اول نماینده‌ای که همین دامنه را به‌عنوان دامنه‌ی خودش ثبت + // کرده، بعد نماینده‌ی خودِ شهر: مدل پوشش نماینده شهری است، نه دامنه‌ای، و + // ستون `domain` معمولاً فقط برای نماینده‌ی سراسری پر می‌شود. + $rep = $this->representationRepo->findActiveByDomain($domain) + ?? $this->representationRepo->findActiveByCityId((int) $city->getId()); + return new DomainContext($rep, $city, false); } diff --git a/tests/Representation/DomainCommissionTest.php b/tests/Representation/DomainCommissionTest.php index b50a6c22..f464c9cb 100644 --- a/tests/Representation/DomainCommissionTest.php +++ b/tests/Representation/DomainCommissionTest.php @@ -3,6 +3,7 @@ namespace App\Tests\Representation; use App\Config\Repository\SiteConfigRepository; +use App\Location\Entity\City; use App\Payment\Entity\Payment; use App\Representation\Entity\Representation; use App\Representation\Service\DomainContextResolver; @@ -90,6 +91,73 @@ class DomainCommissionTest extends ApiTestCase $this->assertTrue($ctx->isGlobalRepresentation); } + /** + * سایت‌های شهری دامنهٔ خودشان را دارند و نماینده ستون `domain` ندارد؛ پوشش + * نماینده شهری است. بدون این نگاشت، دامنهٔ شهری به هیچ نماینده‌ای نمی‌رسید و + * پورسانتِ همهٔ نوبت‌های آن سایت‌ها ساکت از بین می‌رفت. + */ + private function makeCity(string $domain): City + { + $city = new City('شهر تست ' . substr(uniqid(), -6)); + $city->setDomain($domain); + $this->em->persist($city); + $this->em->flush(); + + return $city; + } + + public function testResolverFallsBackToTheCityRepresentationForACityDomain(): void + { + $domain = 'city-' . substr(uniqid(), -6) . '-nobat.ir'; + $city = $this->makeCity($domain); + $rep = $this->makeRep(); + $rep->setCities([$city]); + $this->em->flush(); + + $ctx = $this->resolver()->resolve('https://' . $domain . '/payment/result'); + + $this->assertSame($rep->getId(), $ctx->representationId()); + $this->assertSame($city->getId(), $ctx->city?->getId()); + $this->assertFalse($ctx->isGlobalRepresentation, 'نمایندهٔ شهری سراسری نیست'); + } + + public function testRepresentationOwnDomainWinsOverTheCityRepresentation(): void + { + $domain = 'city-' . substr(uniqid(), -6) . '-nobat.ir'; + $city = $this->makeCity($domain); + $cityRep = $this->makeRep(); + $cityRep->setCities([$city]); + $domainRep = $this->makeRep($domain); + $this->em->flush(); + + $this->assertSame($domainRep->getId(), $this->resolver()->resolve($domain)->representationId()); + } + + /** دو نمایندهٔ فعال روی یک شهر یعنی انتساب پول مبهم است؛ حدس زده نمی‌شود. */ + public function testAmbiguousCityCoverageResolvesToNoRepresentation(): void + { + $domain = 'city-' . substr(uniqid(), -6) . '-nobat.ir'; + $city = $this->makeCity($domain); + $this->makeRep()->setCities([$city]); + $this->makeRep()->setCities([$city]); + $this->em->flush(); + + $ctx = $this->resolver()->resolve($domain); + + $this->assertNull($ctx->representation); + $this->assertSame($city->getId(), $ctx->city?->getId()); + } + + public function testInactiveCityRepresentationIsIgnored(): void + { + $domain = 'city-' . substr(uniqid(), -6) . '-nobat.ir'; + $city = $this->makeCity($domain); + $this->makeRep(active: false)->setCities([$city]); + $this->em->flush(); + + $this->assertNull($this->resolver()->resolve($domain)->representation); + } + // ── CommissionService: گارد دوشرطی ─────────────────────────────────────── public function testAppointmentCommissionOnlyWhenDomainOwnerMatchesDoctorOwner(): void diff --git a/tests/Representation/OnlineAppointmentCommissionTest.php b/tests/Representation/OnlineAppointmentCommissionTest.php new file mode 100644 index 00000000..bcdcf9f0 --- /dev/null +++ b/tests/Representation/OnlineAppointmentCommissionTest.php @@ -0,0 +1,165 @@ +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 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); + } +}