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) <noreply@anthropic.com>
166 lines
6.2 KiB
PHP
166 lines
6.2 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 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);
|
|
}
|
|
}
|