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
@@ -24,7 +24,7 @@ class SettlementController extends BaseController
public function __construct(
private readonly SettlementRepository $settlementRepo,
private readonly WalletTransactionRepository $walletRepo,
private readonly \App\Representation\Repository\RepresentationRepository $representationRepo,
private readonly \App\Settlement\Service\UserIbanResolver $ibanResolver,
private readonly \App\Shared\Service\FileValidatorService $fileValidator,
private readonly string $projectDir,
) {}
@@ -155,8 +155,8 @@ class SettlementController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'انتخاب شماره شبا الزامی است', 422, 'iban_id');
}
$rep = $this->representationRepo->findByUser($user);
$iban = $rep?->findVerifiedIban($ibanId);
// شبا از هر منبعی که کاربر دارد: نماینده یا پروفایل کاربر (منشی و بقیهٔ نقش‌ها).
$iban = $this->ibanResolver->findVerifiedIban($user, $ibanId);
if ($iban === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره شبا نامعتبر یا تأییدنشده است', 422, 'iban_id');
}
@@ -57,6 +57,13 @@ class FinancialBreakdown
#[ORM\Column(name: 'system_share_rials', type: 'integer')]
private int $systemShareRials;
/**
* مجموع سهم منشی‌ها از همین پرداخت. تفکیک هر منشی در
* {@see \App\Secretary\Entity\SecretaryEarning} ذخیره می‌شود (قابل کوئری برای گزارش).
*/
#[ORM\Column(name: 'secretary_share_rials', type: 'integer', options: ['default' => 0])]
private int $secretaryShareRials = 0;
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
private ?int $representationId = null;
@@ -112,6 +119,8 @@ class FinancialBreakdown
public function getPayment(): Payment { return $this->payment; }
public function getSource(): string { return $this->source; }
public function getRepresentationId(): ?int { return $this->representationId; }
public function getSecretaryShareRials(): int { return $this->secretaryShareRials; }
public function setSecretaryShareRials(int $v): self { $this->secretaryShareRials = $v; return $this; }
public function toArray(): array
{
@@ -127,6 +136,7 @@ class FinancialBreakdown
'net_after_tax_rials' => $this->netAfterTaxRials,
'commission_percent' => $this->commissionPercent,
'representation_share_rials' => $this->representationShareRials,
'secretary_share_rials' => $this->secretaryShareRials,
'system_share_rials' => $this->systemShareRials,
'representation_id' => $this->representationId,
'doctor_id' => $this->doctorId,
+100 -20
View File
@@ -2,16 +2,21 @@
namespace App\Settlement\Service;
use App\Auth\Entity\User;
use App\Config\Repository\SiteConfigRepository;
use App\Payment\Entity\Payment;
use App\Representation\Entity\Representation;
use App\Representation\Repository\RepresentationRepository;
use App\Secretary\Entity\SecretaryEarning;
use App\Secretary\Repository\SecretaryEarningRepository;
use App\Secretary\Service\SecretaryShareResolver;
use App\Settlement\Entity\FinancialBreakdown;
use App\Settlement\Entity\WalletTransaction;
use App\Settlement\Repository\FinancialBreakdownRepository;
use App\Settlement\Repository\SettlementRepository;
use App\Settlement\Repository\WalletTransactionRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* موتور تقسیم مالی پس از پرداخت موفق.
@@ -25,33 +30,49 @@ class CommissionService
private readonly SettlementRepository $settlementRepo,
private readonly WalletTransactionRepository $walletRepo,
private readonly FinancialBreakdownRepository $breakdownRepo,
private readonly SecretaryShareResolver $secretaryShares,
private readonly SecretaryEarningRepository $earningRepo,
private readonly LoggerInterface $logger,
private readonly EntityManagerInterface $em,
) {}
/**
* پورسانت نوبت: درصد = commission_percent همان نماینده.
* گاردِ دامنه: فقط وقتی که پزشک متعلق به نماینده باشد و نوبت هم از دامنه‌ی همان نماینده ثبت شده باشد.
* تقسیم مالی نوبت آنلاین: پورسانت نماینده (اگر گاردِ دامنه برقرار باشد) و سهم
* منشی‌های همان پزشک/کلینیک — هر کدام مستقل. سهم منشی به وجود نماینده گره نیست.
*/
public function processAppointment(Payment $payment, ?int $doctorRepId, ?int $bookingRepId, ?int $doctorId): void
{
if ($this->configRepo->get('appointment_commission_enabled') !== '1') return;
// پرداخت دوبار پردازش نشود — قبل از هر اعتبارِ کیف پول.
if ($this->breakdownRepo->existsForPayment($payment)) return;
// هر دو شرط لازم است و باید یکی باشند.
if ($doctorRepId === null || $bookingRepId === null || $doctorRepId !== $bookingRepId) return;
$rep = $this->eligibleAppointmentRep($doctorRepId, $bookingRepId);
$secretaries = $this->secretaryShares->for($payment);
$rep = $this->resolveRep($doctorRepId);
if ($rep === null) return;
if ($rep === null && $secretaries === []) return;
$this->settle(
$payment,
FinancialBreakdown::SOURCE_APPOINTMENT,
(float) $rep->getCommissionPercent(),
$rep !== null ? (float) $rep->getCommissionPercent() : 0.0,
$rep,
$doctorId,
null,
$secretaries,
);
}
/**
* نمایندهٔ واجد شرط برای پورسانت نوبت. گاردِ دامنه: پزشک باید متعلق به نماینده
* باشد و نوبت هم از دامنهٔ همان نماینده ثبت شده باشد.
*/
private function eligibleAppointmentRep(?int $doctorRepId, ?int $bookingRepId): ?Representation
{
if ($this->configRepo->get('appointment_commission_enabled') !== '1') return null;
if ($doctorRepId === null || $bookingRepId === null || $doctorRepId !== $bookingRepId) return null;
return $this->resolveRep($doctorRepId);
}
/**
* پورسانت ارتقاء اشتراک: درصد سراسری = upgrade_commission_percent.
* گاردِ دامنه (مثل نوبت): مالک پزشک/کلینیک و نماینده‌ی دامنه‌ی خرید باید یکی باشند.
@@ -72,6 +93,7 @@ class CommissionService
$rep,
$doctorId,
$clinicId,
[],
);
}
@@ -82,13 +104,17 @@ class CommissionService
return ($rep !== null && $rep->isActive()) ? $rep : null;
}
/**
* @param list<array{user: User, percent: float, relation_uuid: string}> $secretaries
*/
private function settle(
Payment $payment,
string $source,
float $commissionPercent,
Representation $rep,
?Representation $rep,
?int $doctorId,
?int $clinicId,
array $secretaries,
): void {
// پرداخت دوبار پردازش نشود.
if ($this->breakdownRepo->existsForPayment($payment)) return;
@@ -107,18 +133,24 @@ class CommissionService
: 0;
$netAfterTax = $afterSms - $taxRials;
// مرحله ۳: پورسانت نماینده از خالصِ پس از مالیات.
$repShare = (int) round($netAfterTax * $commissionPercent / 100);
$systemShare = $gross - $smsFee - $taxRials - $repShare;
// مرحله ۳: سهم‌ها، همه از «خالصِ پس از مالیات» — نه از مبلغ کل و نه از
// باقی‌ماندهٔ سهم دیگری، تا ترتیب اجرا روی مبالغ اثر نگذارد.
[$commissionPercent, $secretaries] = $this->clipPercents($payment, $commissionPercent, $secretaries);
$repUser = $rep->getUser();
$repShare = (int) round($netAfterTax * $commissionPercent / 100);
$secretaryTotal = 0;
$secretaryRows = [];
foreach ($secretaries as $secretary) {
$share = (int) round($netAfterTax * $secretary['percent'] / 100);
if ($share <= 0) continue;
$secretaryTotal += $share;
$secretaryRows[] = $secretary + ['share' => $share];
}
if ($repShare > 0) {
$balance = $this->settlementRepo->getWalletBalance($repUser);
$tx = new WalletTransaction($repUser, $repShare, WalletTransaction::TYPE_CREDIT, $balance + $repShare);
$tx->setPayment($payment);
$tx->setDescription(sprintf('پورسانت %s %s', $source, $payment->getOrderId()));
$this->walletRepo->save($tx, false);
$systemShare = $gross - $smsFee - $taxRials - $repShare - $secretaryTotal;
if ($rep !== null && $repShare > 0) {
$this->credit($rep->getUser(), $repShare, $payment, sprintf('پورسانت %s %s', $source, $payment->getOrderId()));
}
$breakdown = new FinancialBreakdown(
@@ -133,12 +165,60 @@ class CommissionService
number_format($commissionPercent, 2, '.', ''),
$repShare,
$systemShare,
$rep->getId(),
$rep?->getId(),
$doctorId,
$clinicId,
);
$breakdown->setSecretaryShareRials($secretaryTotal);
$this->breakdownRepo->save($breakdown, false);
foreach ($secretaryRows as $row) {
$this->credit($row['user'], $row['share'], $payment, sprintf('سهم نوبت آنلاین %s', $payment->getOrderId()));
$this->earningRepo->save(
new SecretaryEarning($breakdown, $row['user'], $row['relation_uuid'], $row['percent'], $row['share']),
false,
);
}
$this->em->flush();
}
private function credit(User $user, int $amountRials, Payment $payment, string $description): void
{
$balance = $this->settlementRepo->getWalletBalance($user);
$tx = new WalletTransaction($user, $amountRials, WalletTransaction::TYPE_CREDIT, $balance + $amountRials);
$tx->setPayment($payment);
$tx->setDescription($description);
$this->walletRepo->save($tx, false);
}
/**
* سهم سیستم نباید منفی شود: اگر مجموع درصدها از ۱۰۰ بگذرد، به نسبت کلیپ می‌شود و
* هشدار ثبت می‌گردد (سکوت نمی‌کنیم — تنظیمِ اشتباه باید دیده شود).
*
* @param list<array{user: User, percent: float, relation_uuid: string}> $secretaries
* @return array{0: float, 1: list<array{user: User, percent: float, relation_uuid: string}>}
*/
private function clipPercents(Payment $payment, float $commissionPercent, array $secretaries): array
{
$total = $commissionPercent + array_sum(array_column($secretaries, 'percent'));
if ($total <= 100.0 || $total <= 0.0) {
return [$commissionPercent, $secretaries];
}
$this->logger->warning('Commission + secretary shares exceed 100% — clipping proportionally', [
'payment_uuid' => $payment->getUuid(),
'total_percent' => $total,
]);
$factor = 100.0 / $total;
$clipped = [];
foreach ($secretaries as $secretary) {
$secretary['percent'] *= $factor;
$clipped[] = $secretary;
}
return [$commissionPercent * $factor, $clipped];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Settlement\Service;
use App\Auth\Entity\User;
use App\Representation\Repository\RepresentationRepository;
use App\UserProfile\Repository\UserProfileRepository;
/**
* شبای تأییدشدهٔ یک کاربر، مستقل از نقشش.
*
* تسویه پیش‌تر فقط شبای نماینده را می‌شناخت، پس هر نقش دیگری (منشی، …) با وجود
* موجودی کیف پول نمی‌توانست برداشت کند. ترتیب: نماینده (سازگاری با داده‌ی موجود)،
* سپس پروفایل کاربر.
*/
class UserIbanResolver
{
public function __construct(
private readonly RepresentationRepository $representationRepo,
private readonly UserProfileRepository $profileRepo,
) {}
/** @return array<string, mixed>|null */
public function findVerifiedIban(User $user, string $ibanId): ?array
{
return $this->representationRepo->findByUser($user)?->findVerifiedIban($ibanId)
?? $this->profileRepo->findByUser($user)?->findVerifiedIban($ibanId);
}
/** @return array<int, array<string, mixed>> همهٔ شباهای کاربر (تأییدشده و نشده) */
public function ibansOf(User $user): array
{
$representationIbans = $this->representationRepo->findByUser($user)?->getIbans() ?? [];
return $representationIbans !== []
? $representationIbans
: ($this->profileRepo->findByUser($user)?->getIbans() ?? []);
}
}