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>
188 lines
7.3 KiB
PHP
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);
|
|
}
|
|
}
|