feat: Add online share functionality for secretaries

- 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.
This commit is contained in:
hamed
2026-07-25 18:34:18 +03:30
parent 73a608c3dd
commit 8d2b0d908a
33 changed files with 2564 additions and 76 deletions
@@ -0,0 +1,202 @@
<?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()));
}
}