Files
clinicpro/tests/Representation/OnlineAppointmentCommissionTest.php
T
hamedandClaude Opus 5 f863b39a74 fix(representation): count only online bookings in the agent panel
The agent's own dashboard (dashboard/summary and doctors/performance) counted
every appointment belonging to their doctors, so the bookings a secretary types
into the panel — which earn the agent nothing — sat next to a commission column
that ignored them. Both now count only bookings that came from the agent's own
site, matching the monthly/yearly report. The per-doctor income column is also
scoped to this agent, since a doctor may have been under another one before.

Adds a backfill for the bookings paid before city domains resolved to an agent:
they carry neither booking_representation_id nor a FinancialBreakdown, and
neither can be recovered by replaying the request. Both are derived from
payments.frontend_address, the address the payment was started from. The
recovered breakdown is dated to the payment, not to the run, or a year of
commission would land in "today". Dry-run by default; re-running is a no-op,
and a payment whose domain does not match the doctor's owner is reported and
skipped rather than retried forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:24:48 +03:30

188 lines
7.3 KiB
PHP

<?php
namespace App\Tests\Representation;
use App\Appointment\Entity\Appointment;
use App\Config\Entity\SiteConfig;
use App\Config\Repository\SiteConfigRepository;
use App\Doctor\Entity\Doctor;
use App\Location\Entity\City;
use App\Payment\Entity\Payment;
use App\Payment\Service\PaymentManager;
use App\Representation\Entity\Representation;
use App\Settlement\Repository\FinancialBreakdownRepository;
use App\Tests\ApiTestCase;
/**
* A representative earns on online bookings from their own city site, and earns
* it when the money arrives — not when the doctor gets round to confirming the
* appointment. Bookings a secretary types into the panel are not theirs at all.
*/
class OnlineAppointmentCommissionTest extends ApiTestCase
{
private function setConfig(string $key, string $value): void
{
$cfg = $this->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);
}
}