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()));
}
}
+207
View File
@@ -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']);
}
}