- Introduced `online_share_enabled` and `online_share_percent` fields in the `doctor_secretaries` table to manage secretary shares from online appointments. - Added `bank_account` field in the `profiles` table to store user-level IBANs for settlements. - Created `secretary_earnings` table to track earnings per secretary from online appointments, including a foreign key relationship with `financial_breakdowns`. - Implemented `SecretaryEarning` entity and repository for managing secretary earnings. - Developed `SecretaryShareResolver` service to determine which secretaries earn from online payments. - Added `UserIbanResolver` service to handle user IBAN retrieval and management. - Created `HasIbansTrait` for entities to manage IBANs in a JSON format. - Implemented tests for secretary earnings and API endpoints for managing secretary shares and IBANs.
203 lines
8.6 KiB
PHP
203 lines
8.6 KiB
PHP
<?php
|
||
|
||
namespace App\Tests\Secretary;
|
||
|
||
use App\Appointment\Entity\Appointment;
|
||
use App\Auth\Entity\User;
|
||
use App\Clinic\Entity\Clinic;
|
||
use App\Config\Repository\SiteConfigRepository;
|
||
use App\Doctor\Entity\Doctor;
|
||
use App\Payment\Entity\Payment;
|
||
use App\Secretary\Entity\DoctorSecretary;
|
||
use App\Secretary\Repository\SecretaryEarningRepository;
|
||
use App\Settlement\Repository\FinancialBreakdownRepository;
|
||
use App\Settlement\Repository\SettlementRepository;
|
||
use App\Settlement\Service\CommissionService;
|
||
use App\Tests\ApiTestCase;
|
||
|
||
/**
|
||
* سهم منشی از نوبت آنلاین: درصدِ رابطهٔ منشی روی «خالصِ پس از مالیات» — همان مبنایی
|
||
* که پورسانت نماینده از آن گرفته میشود — و مستقل از وجود نماینده.
|
||
*/
|
||
class SecretaryOnlineShareTest extends ApiTestCase
|
||
{
|
||
private const GROSS = 10_000_000;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$config = static::getContainer()->get(SiteConfigRepository::class);
|
||
// کسورات را قطعی میکنیم تا انتظارِ عددی تست پایدار بماند.
|
||
$config->set('sms_panel_fee_rials', '0');
|
||
$config->set('tax_enabled', '0');
|
||
$config->set('tax_percent', '0');
|
||
}
|
||
|
||
private function makeDoctor(): Doctor
|
||
{
|
||
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||
$doctor = new Doctor($user, 'دکتر تست سهم منشی');
|
||
$this->em->persist($doctor);
|
||
$this->em->flush();
|
||
|
||
return $doctor;
|
||
}
|
||
|
||
private function makeSecretary(Doctor $doctor, float $percent, bool $enabled = true, ?Clinic $clinic = null): DoctorSecretary
|
||
{
|
||
$user = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
|
||
$relation = new DoctorSecretary(
|
||
$doctor,
|
||
$user,
|
||
$clinic !== null ? DoctorSecretary::OWNER_CLINIC : DoctorSecretary::OWNER_DOCTOR,
|
||
$clinic,
|
||
);
|
||
$relation->setOnlineShareEnabled($enabled)->setOnlineSharePercent($percent);
|
||
$this->em->persist($relation);
|
||
$this->em->flush();
|
||
|
||
return $relation;
|
||
}
|
||
|
||
private function makeOnlinePayment(Doctor $doctor, ?Clinic $clinic = null): Payment
|
||
{
|
||
$start = strtotime('+30 days') + random_int(0, 500_000) * 7;
|
||
$appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900);
|
||
$appointment->setClinic($clinic);
|
||
$this->em->persist($appointment);
|
||
|
||
$payment = new Payment($this->createUser(), self::GROSS, 'mock', Payment::TYPE_APPOINTMENT, '');
|
||
$payment->setAppointment($appointment);
|
||
$this->em->persist($payment);
|
||
$this->em->flush();
|
||
|
||
return $payment;
|
||
}
|
||
|
||
private function commission(): CommissionService
|
||
{
|
||
return static::getContainer()->get(CommissionService::class);
|
||
}
|
||
|
||
private function earnings(): SecretaryEarningRepository
|
||
{
|
||
return static::getContainer()->get(SecretaryEarningRepository::class);
|
||
}
|
||
|
||
public function testSecretaryEarnsItsPercentWithoutAnyRepresentation(): void
|
||
{
|
||
$doctor = $this->makeDoctor();
|
||
$relation = $this->makeSecretary($doctor, 5.0);
|
||
$payment = $this->makeOnlinePayment($doctor);
|
||
|
||
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
|
||
|
||
$breakdown = static::getContainer()->get(FinancialBreakdownRepository::class)->findOneBy(['payment' => $payment]);
|
||
self::assertNotNull($breakdown, 'نوبتِ بدون نماینده هم باید تفکیک مالی بسازد');
|
||
self::assertSame(500_000, $breakdown->getSecretaryShareRials()); // ۵٪ از ۱۰,۰۰۰,۰۰۰
|
||
self::assertSame(self::GROSS - 500_000, $breakdown->toArray()['system_share_rials']);
|
||
|
||
self::assertSame(500_000, $this->earnings()->sumFor($relation->getSecretary()));
|
||
self::assertSame(
|
||
500_000,
|
||
static::getContainer()->get(SettlementRepository::class)->getWalletBalance($relation->getSecretary()),
|
||
);
|
||
}
|
||
|
||
public function testDisabledOrZeroPercentEarnsNothing(): void
|
||
{
|
||
$doctor = $this->makeDoctor();
|
||
$off = $this->makeSecretary($doctor, 5.0, enabled: false);
|
||
$zero = $this->makeSecretary($doctor, 0.0);
|
||
$payment = $this->makeOnlinePayment($doctor);
|
||
|
||
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
|
||
|
||
self::assertFalse(static::getContainer()->get(FinancialBreakdownRepository::class)->existsForPayment($payment));
|
||
self::assertSame(0, $this->earnings()->sumFor($off->getSecretary()));
|
||
self::assertSame(0, $this->earnings()->sumFor($zero->getSecretary()));
|
||
}
|
||
|
||
public function testEachSecretaryGetsItsOwnPercent(): void
|
||
{
|
||
$doctor = $this->makeDoctor();
|
||
$first = $this->makeSecretary($doctor, 5.0);
|
||
$second = $this->makeSecretary($doctor, 10.0);
|
||
$payment = $this->makeOnlinePayment($doctor);
|
||
|
||
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
|
||
|
||
self::assertSame(500_000, $this->earnings()->sumFor($first->getSecretary()));
|
||
self::assertSame(1_000_000, $this->earnings()->sumFor($second->getSecretary()));
|
||
}
|
||
|
||
public function testPaymentIsNeverSplitTwice(): void
|
||
{
|
||
$doctor = $this->makeDoctor();
|
||
$relation = $this->makeSecretary($doctor, 5.0);
|
||
$payment = $this->makeOnlinePayment($doctor);
|
||
|
||
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
|
||
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
|
||
|
||
self::assertSame(500_000, $this->earnings()->sumFor($relation->getSecretary()));
|
||
self::assertSame(1, $this->earnings()->countFor($relation->getSecretary()));
|
||
}
|
||
|
||
/** مجموع درصدها > ۱۰۰ → کلیپ میشود و سهم سیستم منفی نمیشود. */
|
||
public function testSharesAreClippedSoTheSystemShareStaysPositive(): void
|
||
{
|
||
$doctor = $this->makeDoctor();
|
||
$a = $this->makeSecretary($doctor, 80.0);
|
||
$b = $this->makeSecretary($doctor, 60.0);
|
||
$payment = $this->makeOnlinePayment($doctor);
|
||
|
||
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
|
||
|
||
$breakdown = static::getContainer()->get(FinancialBreakdownRepository::class)->findOneBy(['payment' => $payment]);
|
||
self::assertGreaterThanOrEqual(0, $breakdown->toArray()['system_share_rials']);
|
||
self::assertSame(self::GROSS, $breakdown->getSecretaryShareRials() + $breakdown->toArray()['system_share_rials']);
|
||
self::assertGreaterThan(0, $this->earnings()->sumFor($a->getSecretary()));
|
||
self::assertGreaterThan(0, $this->earnings()->sumFor($b->getSecretary()));
|
||
}
|
||
|
||
public function testClinicAppointmentCreditsTheClinicSecretaries(): void
|
||
{
|
||
$doctor = $this->makeDoctor();
|
||
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||
$clinic = new Clinic($owner);
|
||
$clinic->setName('کلینیک تست سهم منشی');
|
||
$clinic->getDoctors()->add($doctor);
|
||
$this->em->persist($clinic);
|
||
$this->em->flush();
|
||
|
||
$clinicSecretary = $this->makeSecretary($doctor, 7.0, clinic: $clinic);
|
||
$officeSecretary = $this->makeSecretary($doctor, 5.0); // مطب شخصی — نباید سهم بگیرد
|
||
|
||
$payment = $this->makeOnlinePayment($doctor, $clinic);
|
||
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
|
||
|
||
self::assertSame(700_000, $this->earnings()->sumFor($clinicSecretary->getSecretary()));
|
||
self::assertSame(0, $this->earnings()->sumFor($officeSecretary->getSecretary()));
|
||
}
|
||
|
||
/** سهم از «خالص» گرفته میشود: با مالیات و هزینهٔ پیامک، مبنا کمتر است. */
|
||
public function testShareIsCalculatedOnTheNetAmount(): void
|
||
{
|
||
$config = static::getContainer()->get(SiteConfigRepository::class);
|
||
$config->set('sms_panel_fee_rials', '1000000');
|
||
$config->set('tax_enabled', '1');
|
||
$config->set('tax_percent', '10');
|
||
|
||
$doctor = $this->makeDoctor();
|
||
$relation = $this->makeSecretary($doctor, 10.0);
|
||
$payment = $this->makeOnlinePayment($doctor);
|
||
|
||
$this->commission()->processAppointment($payment, null, null, $doctor->getId());
|
||
|
||
// ۱۰,۰۰۰,۰۰۰ − ۱,۰۰۰,۰۰۰ = ۹,۰۰۰,۰۰۰ ؛ مالیات = 9,000,000×10/110 = 818,182
|
||
// خالص = 8,181,818 ؛ سهم ۱۰٪ = 818,182
|
||
self::assertSame(818_182, $this->earnings()->sumFor($relation->getSecretary()));
|
||
}
|
||
}
|