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:
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Secretary;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* اندپوینتهای مدیریت سهم منشی (ادمین) و گزارش/شبا (پنل منشی).
|
||||
*/
|
||||
class SecretaryShareApiTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: DoctorSecretary, 1: User} */
|
||||
private function makeRelation(): array
|
||||
{
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'دکتر تست API سهم');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$secretaryUser = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
|
||||
$relation = new DoctorSecretary($doctor, $secretaryUser);
|
||||
$this->em->persist($relation);
|
||||
$this->em->flush();
|
||||
|
||||
return [$relation, $secretaryUser];
|
||||
}
|
||||
|
||||
// ── ادمین ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testAdminSeesTheRelationDetailWithEarnings(): void
|
||||
{
|
||||
[$relation] = $this->makeRelation();
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/admin/secretary/' . $relation->getUuid(), $admin);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$row = $body['data']['data'];
|
||||
self::assertFalse($row['online_share_enabled']);
|
||||
// JSON عددِ گِرد را بدون ممیز میدهد، پس مقایسهی نوعآزاد.
|
||||
self::assertEquals(0, $row['online_share_percent']);
|
||||
self::assertSame(0, $row['earnings']['total_rials']);
|
||||
}
|
||||
|
||||
public function testAdminEnablesTheShareWithAPercent(): void
|
||||
{
|
||||
[$relation] = $this->makeRelation();
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
$body = $this->authJson('PUT', '/api/v1/admin/secretary/' . $relation->getUuid() . '/online-share', $admin, [
|
||||
'enabled' => true,
|
||||
'percent' => 5,
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($body['data']['data']['online_share_enabled']);
|
||||
self::assertEquals(5, $body['data']['data']['online_share_percent']);
|
||||
}
|
||||
|
||||
public function testPercentAbove100IsRejected(): void
|
||||
{
|
||||
[$relation] = $this->makeRelation();
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
$this->authJson('PUT', '/api/v1/admin/secretary/' . $relation->getUuid() . '/online-share', $admin, [
|
||||
'enabled' => true, 'percent' => 101,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testEnablingWithoutAPercentIsRejected(): void
|
||||
{
|
||||
[$relation] = $this->makeRelation();
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
$this->authJson('PUT', '/api/v1/admin/secretary/' . $relation->getUuid() . '/online-share', $admin, [
|
||||
'enabled' => true, 'percent' => 0,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testNonAdminIsForbidden(): void
|
||||
{
|
||||
[$relation, $secretaryUser] = $this->makeRelation();
|
||||
|
||||
$this->authJson('GET', '/api/v1/admin/secretary/' . $relation->getUuid(), $secretaryUser);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testUnknownRelationIsNotFound(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
$this->authJson('GET', '/api/v1/admin/secretary/00000000-0000-4000-8000-000000000000', $admin);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── پنل منشی ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function testSummaryReportsDisabledWhenNoRelationHasAShare(): void
|
||||
{
|
||||
[, $secretaryUser] = $this->makeRelation();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/secretary/earnings/summary', $secretaryUser);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertFalse($body['data']['data']['enabled']);
|
||||
self::assertSame(0, $body['data']['data']['total_rials']);
|
||||
}
|
||||
|
||||
public function testSummaryReportsEnabledWithThePercentOnceAdminTurnsItOn(): void
|
||||
{
|
||||
[$relation, $secretaryUser] = $this->makeRelation();
|
||||
$relation->setOnlineShareEnabled(true)->setOnlineSharePercent(7.5);
|
||||
$this->em->flush();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/secretary/earnings/summary', $secretaryUser);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($body['data']['data']['enabled']);
|
||||
self::assertSame(7.5, $body['data']['data']['share_percent']);
|
||||
}
|
||||
|
||||
public function testReportIsEmptyButSucceedsWithoutEarnings(): void
|
||||
{
|
||||
[, $secretaryUser] = $this->makeRelation();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/secretary/earnings/report', $secretaryUser);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame([], $body['data']);
|
||||
self::assertSame(0, $body['meta']['totalRecords']);
|
||||
}
|
||||
|
||||
public function testSecretaryManagesItsIbans(): void
|
||||
{
|
||||
[, $secretaryUser] = $this->makeRelation();
|
||||
|
||||
$added = $this->authJson('POST', '/api/v1/secretary/iban', $secretaryUser, [
|
||||
'iban' => 'IR' . str_pad((string) random_int(0, 999_999), 24, '0', STR_PAD_LEFT),
|
||||
'bank_name' => 'ملی',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
$ibans = $added['data']['data']['bank_account'];
|
||||
self::assertCount(1, $ibans);
|
||||
// شبای تازه تأییدنشده است؛ تسویه با آن مجاز نیست.
|
||||
self::assertFalse($ibans[0]['verified']);
|
||||
|
||||
$removed = $this->authJson('DELETE', '/api/v1/secretary/iban/' . $ibans[0]['id'], $secretaryUser);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame([], $removed['data']['data']['bank_account']);
|
||||
}
|
||||
|
||||
public function testInvalidIbanIsRejected(): void
|
||||
{
|
||||
[, $secretaryUser] = $this->makeRelation();
|
||||
|
||||
$this->authJson('POST', '/api/v1/secretary/iban', $secretaryUser, ['iban' => '12345']);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testThirdIbanIsRejected(): void
|
||||
{
|
||||
[, $secretaryUser] = $this->makeRelation();
|
||||
$iban = fn() => 'IR' . str_pad((string) random_int(0, 999_999_999), 24, '0', STR_PAD_LEFT);
|
||||
|
||||
$this->authJson('POST', '/api/v1/secretary/iban', $secretaryUser, ['iban' => $iban()]);
|
||||
$this->authJson('POST', '/api/v1/secretary/iban', $secretaryUser, ['iban' => $iban()]);
|
||||
$this->authJson('POST', '/api/v1/secretary/iban', $secretaryUser, ['iban' => $iban()]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testSettlementAcceptsAVerifiedSecretaryIban(): void
|
||||
{
|
||||
[, $secretaryUser] = $this->makeRelation();
|
||||
|
||||
$added = $this->authJson('POST', '/api/v1/secretary/iban', $secretaryUser, [
|
||||
'iban' => 'IR' . str_pad((string) random_int(0, 999_999), 24, '0', STR_PAD_LEFT),
|
||||
]);
|
||||
$ibanId = $added['data']['data']['bank_account'][0]['id'];
|
||||
|
||||
// تأیید شبا کارِ ادمین است؛ اینجا مستقیم روی پروفایل ست میکنیم.
|
||||
$profile = static::getContainer()->get(\App\UserProfile\Repository\UserProfileRepository::class)
|
||||
->findByUser($secretaryUser);
|
||||
$ibans = $profile->getIbans();
|
||||
$ibans[0]['verified'] = true;
|
||||
$profile->setBankAccount($ibans);
|
||||
$this->em->flush();
|
||||
|
||||
// بدون موجودی: خطای موجودی میگیرد، نه خطای شبا — یعنی شبا پذیرفته شده است.
|
||||
$body = $this->authJson('POST', '/api/v1/settlement', $secretaryUser, [
|
||||
'amount_rials' => 100_000,
|
||||
'iban_id' => $ibanId,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('موجودی کافی نیست', $body['errors'][0]['message']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user