From 15dfbe61fdc0147d704157d4133f692335f4ef93 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 19 Aug 2026 23:14:22 +0330 Subject: [PATCH] fix(representation): pay commission on online bookings from city sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things kept a city-site booking from ever reaching its representative. The domain never resolved. City sites carry their own domain on cities.domain while a representative's coverage is a set of cities, and representations.domain is normally only filled for a global agent. The resolver looked at that column alone, so bookingRepId was always null and the commission guard rejected every booking made through a city site. It now falls back to the active representative covering that city, and stays null when two of them cover it — an ambiguous money assignment has to be resolved in the data, not guessed. Commission waited for confirmation. The money has already arrived when the gateway callback succeeds; confirming the appointment is the doctor's or secretary's job and may happen days later or never. It is now recorded on payment, with the appointment still pending. Recording is idempotent, so the confirmation path stays and creates nothing twice. The dashboard counted every appointment of the representative's doctors, including the ones a secretary typed into the panel. It now counts only bookings that came from the representative's own site. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/representation.md | 13 +- src/Payment/Service/PaymentManager.php | 11 ++ .../Controller/RepresentationController.php | 8 +- .../Repository/RepresentationRepository.php | 24 +++ .../Service/DomainContextResolver.php | 9 +- tests/Representation/DomainCommissionTest.php | 68 ++++++++ .../OnlineAppointmentCommissionTest.php | 165 ++++++++++++++++++ 7 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 tests/Representation/OnlineAppointmentCommissionTest.php 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); + } +}