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
@@ -35,9 +35,175 @@ class SecretaryController extends BaseController
private readonly SubscriptionService $subscriptionService,
private readonly SmsService $smsService,
private readonly SecretaryService $secretaryService,
private readonly \App\Secretary\Repository\SecretaryEarningRepository $earningRepo,
private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo,
private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo,
private readonly string $appUrl,
) {}
// ── درآمد منشی از نوبت‌های آنلاین ─────────────────────────────────────────
/**
* رابطه‌های فعالی که سهم نوبت آنلاین برایشان روشن است.
*
* @return DoctorSecretary[]
*/
private function shareRelationsOf(User $user): array
{
return array_values(array_filter(
$this->secretaryRepo->findAllActiveBySecretary($user),
static fn(DoctorSecretary $r) => $r->effectiveOnlineSharePercent() > 0,
));
}
#[OA\Get(
path: '/api/v1/secretary/earnings/summary',
summary: 'Secretary earnings summary (today / last 30 days / total) from online appointments',
security: [['bearerAuth' => []]],
responses: [new OA\Response(response: 200, description: 'Earnings summary')]
)]
#[Route('/api/v1/secretary/earnings/summary', methods: ['GET'])]
public function earningsSummary(#[CurrentUser] User $user): JsonResponse
{
$relations = $this->shareRelationsOf($user);
$today = strtotime('today');
return $this->success([
'data' => [
// false یعنی این قابلیت برای هیچ رابطه‌ای فعال نیست؛ پنل پیام مناسب نشان می‌دهد.
'enabled' => $relations !== [],
'share_percent' => $relations !== [] ? $relations[0]->effectiveOnlineSharePercent() : 0.0,
'relations' => array_map(static fn(DoctorSecretary $r) => [
'relation_uuid' => $r->getUuid(),
'doctor_name' => $r->getDoctor()->getName(),
'clinic_name' => $r->getClinic()?->getName(),
'share_percent' => $r->effectiveOnlineSharePercent(),
], $relations),
'today_rials' => $this->earningRepo->sumFor($user, $today),
'this_month_rials' => $this->earningRepo->sumFor($user, time() - 30 * 86_400),
'total_rials' => $this->earningRepo->sumFor($user),
'appointments_count' => $this->earningRepo->countFor($user),
'wallet_balance_rials' => $this->settlementRepo->getWalletBalance($user),
],
]);
}
#[OA\Get(
path: '/api/v1/secretary/earnings/report',
summary: 'Paginated per-appointment earnings report of the current secretary',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
new OA\Parameter(name: 'from', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'to', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
],
responses: [new OA\Response(response: 200, description: 'Paginated earnings rows')]
)]
#[Route('/api/v1/secretary/earnings/report', methods: ['GET'])]
public function earningsReport(Request $request, #[CurrentUser] User $user): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
$from = $request->query->get('from');
$to = $request->query->get('to');
$report = $this->earningRepo->reportFor(
$user,
$page,
$limit,
($from !== null && $from !== '') ? (int) $from : null,
($to !== null && $to !== '') ? (int) $to : null,
);
return $this->paginated($report['items'], $report['total'], $page, $limit);
}
// ── شماره شبای منشی (برای تسویه) ──────────────────────────────────────────
#[OA\Post(
path: '/api/v1/secretary/iban',
summary: 'Add an IBAN (max 2) to the current secretary profile',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(response: 201, description: 'IBAN added'),
new OA\Response(response: 422, description: 'Invalid IBAN or limit reached'),
]
)]
#[Route('/api/v1/secretary/iban', methods: ['POST'])]
public function addIban(Request $request, #[CurrentUser] User $user): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$iban = strtoupper(preg_replace('/\s+/', '', (string) ($data['iban'] ?? '')));
if (!preg_match('/^IR\d{24}$/', $iban)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره شبا نامعتبر است (IR و ۲۴ رقم)', 422, 'iban');
}
$profile = $this->profileRepo->findByUser($user) ?? new \App\UserProfile\Entity\UserProfile($user);
try {
// verified فقط از سمت ادمین ست می‌شود؛ تسویه تنها با شبای تأییدشده مجاز است.
$profile->addIban([
'iban' => $iban,
'bank_name' => isset($data['bank_name']) ? trim((string) $data['bank_name']) : null,
'owner_name' => isset($data['owner_name']) ? trim((string) $data['owner_name']) : null,
]);
} catch (\DomainException) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'حداکثر دو شماره شبا مجاز است', 422, 'iban');
}
$this->profileRepo->save($profile);
return $this->success(['data' => ['bank_account' => $profile->getIbans()]], 201);
}
#[OA\Delete(
path: '/api/v1/secretary/iban/{id}',
summary: 'Remove one of the current secretary IBANs',
security: [['bearerAuth' => []]],
responses: [new OA\Response(response: 200, description: 'IBAN removed')]
)]
#[Route('/api/v1/secretary/iban/{id}', methods: ['DELETE'])]
public function removeIban(string $id, #[CurrentUser] User $user): JsonResponse
{
$profile = $this->profileRepo->findByUser($user);
if ($profile === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'شماره شبا یافت نشد', 404);
}
$this->profileRepo->save($profile->removeIban($id));
return $this->success(['data' => ['bank_account' => $profile->getIbans()]]);
}
#[OA\Get(
path: '/api/v1/secretary/me',
summary: 'Current secretary profile: relations, share settings and IBANs',
security: [['bearerAuth' => []]],
responses: [new OA\Response(response: 200, description: 'Secretary profile')]
)]
#[Route('/api/v1/secretary/me', methods: ['GET'])]
public function me(#[CurrentUser] User $user): JsonResponse
{
$relations = $this->secretaryRepo->findAllActiveBySecretary($user);
return $this->success([
'data' => [
'full_name' => $user->getRealName(),
'mobile' => $user->getMobileNumber(),
'bank_account' => $this->profileRepo->findByUser($user)?->getIbans() ?? [],
'relations' => array_map(static fn(DoctorSecretary $r) => [
'relation_uuid' => $r->getUuid(),
'doctor_name' => $r->getDoctor()->getName(),
'clinic_name' => $r->getClinic()?->getName(),
'online_share_enabled' => $r->isOnlineShareEnabled(),
'online_share_percent' => $r->getOnlineSharePercent(),
], $relations),
],
]);
}
#[Route('/api/v1/secretary', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $currentUser): JsonResponse
{
+22
View File
@@ -73,6 +73,17 @@ class DoctorSecretary
#[ORM\Column(type: 'boolean')]
private bool $active = true;
/**
* سهم منشی از نوبت‌های آنلاینِ همین پزشک/کلینیک فعال است؟ تنظیم per-relation است:
* یک منشی می‌تواند برای یک پزشک سهم داشته باشد و برای دیگری نه.
*/
#[ORM\Column(name: 'online_share_enabled', type: 'boolean', options: ['default' => false])]
private bool $onlineShareEnabled = false;
/** درصد سهم منشی از «خالصِ پس از مالیات» نوبت آنلاین. */
#[ORM\Column(name: 'online_share_percent', type: 'decimal', precision: 5, scale: 2, options: ['default' => '0.00'])]
private string $onlineSharePercent = '0.00';
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -101,6 +112,13 @@ class DoctorSecretary
public function getNationalCode(): ?string { return $this->nationalCode; }
public function getAddress(): ?string { return $this->address; }
public function isActive(): bool { return $this->active; }
public function isOnlineShareEnabled(): bool { return $this->onlineShareEnabled; }
public function getOnlineSharePercent(): float { return (float) $this->onlineSharePercent; }
/** سهم مؤثر: درصد فقط وقتی معنا دارد که رابطه فعال و سهم روشن باشد. */
public function effectiveOnlineSharePercent(): float
{
return ($this->active && $this->onlineShareEnabled) ? (float) $this->onlineSharePercent : 0.0;
}
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
@@ -108,6 +126,8 @@ class DoctorSecretary
public function setPermissions(array $v): self { $this->permissions = $v; $this->touch(); return $this; }
public function setNationalCode(?string $v): self { $this->nationalCode = $v; $this->touch(); return $this; }
public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; }
public function setOnlineShareEnabled(bool $v): self { $this->onlineShareEnabled = $v; $this->touch(); return $this; }
public function setOnlineSharePercent(float $v): self { $this->onlineSharePercent = (string) $v; $this->touch(); return $this; }
/** Deep merge: only provided resources/actions are updated */
public function mergePermissions(array $patch): void
@@ -144,6 +164,8 @@ class DoctorSecretary
'owner_type' => $this->ownerType,
'clinic_uuid' => $this->clinic?->getUuid(),
'is_active' => $this->active,
'online_share_enabled' => $this->onlineShareEnabled,
'online_share_percent' => (float) $this->onlineSharePercent,
'national_code' => $this->nationalCode,
'address' => $this->address,
'permissions' => $this->getPermissions()['resources'] ?? $this->getPermissions(),
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Secretary\Entity;
use App\Auth\Entity\User;
use App\Secretary\Repository\SecretaryEarningRepository;
use App\Settlement\Entity\FinancialBreakdown;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* سهم یک منشی از یک نوبت آنلاین.
*
* جدول جداست (نه JSON روی FinancialBreakdown) چون گزارش پنل منشی باید per-user
* فیلتر و جمع‌بندی شود؛ یک پرداخت می‌تواند چند منشیِ سهم‌بر داشته باشد.
*/
#[ORM\Entity(repositoryClass: SecretaryEarningRepository::class)]
#[ORM\Table(name: 'secretary_earnings')]
#[ORM\Index(columns: ['secretary_user_id', 'created_at'], name: 'idx_secretary_earnings_user_time')]
class SecretaryEarning
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: FinancialBreakdown::class)]
#[ORM\JoinColumn(name: 'breakdown_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private FinancialBreakdown $breakdown;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'secretary_user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private User $secretary;
/** رابطهٔ منشی–پزشک/کلینیکی که سهم از آن آمده (snapshot، برای ردگیری). */
#[ORM\Column(name: 'relation_uuid', type: 'string', length: 36)]
private string $relationUuid;
#[ORM\Column(name: 'share_percent', type: 'decimal', precision: 5, scale: 2)]
private string $sharePercent;
#[ORM\Column(name: 'share_rials', type: 'integer')]
private int $shareRials;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(
FinancialBreakdown $breakdown,
User $secretary,
string $relationUuid,
float $sharePercent,
int $shareRials,
) {
$this->uuid = Uuid::v4()->toRfc4122();
$this->breakdown = $breakdown;
$this->secretary = $secretary;
$this->relationUuid = $relationUuid;
$this->sharePercent = number_format($sharePercent, 2, '.', '');
$this->shareRials = $shareRials;
$this->createdAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getBreakdown(): FinancialBreakdown { return $this->breakdown; }
public function getSecretary(): User { return $this->secretary; }
public function getRelationUuid(): string { return $this->relationUuid; }
public function getSharePercent(): float { return (float) $this->sharePercent; }
public function getShareRials(): int { return $this->shareRials; }
public function getCreatedAt(): int { return $this->createdAt; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'relation_uuid' => $this->relationUuid,
'share_percent' => (float) $this->sharePercent,
'share_rials' => $this->shareRials,
'created_at' => $this->createdAt,
];
}
}
@@ -163,6 +163,36 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
->getResult();
}
/**
* رابطه‌های فعالی که سهم درآمد نوبت آنلاین برایشان روشن است — برای کلینیک همهٔ
* منشی‌های همان کلینیک، برای مطب شخصی منشی‌های همان پزشک.
*
* @return DoctorSecretary[]
*/
public function findOnlineShareRows(Doctor $doctor, ?Clinic $clinic): array
{
$qb = $this->createQueryBuilder('s')
->addSelect('sec')
->join('s.secretary', 'sec')
->where('s.active = true')
->andWhere('s.onlineShareEnabled = true')
->andWhere('s.onlineSharePercent > 0');
if ($clinic !== null) {
$qb->andWhere('s.clinic = :clinic')
->andWhere('s.ownerType = :type')
->setParameter('clinic', $clinic)
->setParameter('type', DoctorSecretary::OWNER_CLINIC);
} else {
$qb->andWhere('s.doctor = :doctor')
->andWhere('s.ownerType = :type')
->setParameter('doctor', $doctor)
->setParameter('type', DoctorSecretary::OWNER_DOCTOR);
}
return $qb->getQuery()->getResult();
}
public function save(DoctorSecretary $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
@@ -0,0 +1,102 @@
<?php
namespace App\Secretary\Repository;
use App\Auth\Entity\User;
use App\Secretary\Entity\SecretaryEarning;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SecretaryEarningRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SecretaryEarning::class);
}
public function save(SecretaryEarning $earning, bool $flush = true): void
{
$this->getEntityManager()->persist($earning);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/** مجموع سهم یک منشی در یک بازه؛ بدون بازه = کل. */
public function sumFor(User $secretary, ?int $from = null, ?int $to = null): int
{
$qb = $this->createQueryBuilder('e')
->select('COALESCE(SUM(e.shareRials), 0)')
->where('e.secretary = :user')
->setParameter('user', $secretary);
if ($from !== null) {
$qb->andWhere('e.createdAt >= :from')->setParameter('from', $from);
}
if ($to !== null) {
$qb->andWhere('e.createdAt <= :to')->setParameter('to', $to);
}
return (int) $qb->getQuery()->getSingleScalarResult();
}
public function countFor(User $secretary): int
{
return (int) $this->createQueryBuilder('e')
->select('COUNT(e.id)')
->where('e.secretary = :user')
->setParameter('user', $secretary)
->getQuery()
->getSingleScalarResult();
}
/**
* گزارش سطر-به-سطر برای پنل منشی: هر ردیف با تفکیک مالیِ همان پرداخت و نوبت.
*
* @return array{items: list<array<string, mixed>>, total: int}
*/
public function reportFor(User $secretary, int $page, int $limit, ?int $from = null, ?int $to = null): array
{
$qb = $this->createQueryBuilder('e')
->select(
'e.uuid, e.sharePercent, e.shareRials, e.createdAt,
b.grossRials, b.smsFeeRials, b.taxRials, b.netAfterTaxRials,
a.uuid AS appointment_uuid, doc.name AS doctor_name'
)
->join('e.breakdown', 'b')
->join('b.payment', 'p')
->leftJoin('p.appointment', 'a')
->leftJoin('a.doctor', 'doc')
->where('e.secretary = :user')
->setParameter('user', $secretary)
->orderBy('e.createdAt', 'DESC');
if ($from !== null) {
$qb->andWhere('e.createdAt >= :from')->setParameter('from', $from);
}
if ($to !== null) {
$qb->andWhere('e.createdAt <= :to')->setParameter('to', $to);
}
$total = (int) (clone $qb)->select('COUNT(e.id)')->resetDQLPart('orderBy')
->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
->getQuery()->getArrayResult();
$items = array_map(static fn(array $r) => [
'uuid' => $r['uuid'],
'appointment_uuid' => $r['appointment_uuid'] ?? null,
'doctor_name' => $r['doctor_name'] ?? null,
'gross_rials' => (int) $r['grossRials'],
'sms_fee_rials' => (int) $r['smsFeeRials'],
'tax_rials' => (int) $r['taxRials'],
'net_after_tax_rials' => (int) $r['netAfterTaxRials'],
'share_percent' => (float) $r['sharePercent'],
'share_rials' => (int) $r['shareRials'],
'created_at' => (int) $r['createdAt'],
], $rows);
return ['items' => $items, 'total' => $total];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Secretary\Service;
use App\Payment\Entity\Payment;
use App\Secretary\Repository\DoctorSecretaryRepository;
/**
* منشی‌هایی که از یک نوبت **آنلاین** سهم می‌برند.
*
* «آنلاین» یعنی همین مسیر: تقسیم مالی تنها از `PaymentManager` (پرداخت موفق درگاه)
* صدا زده می‌شود؛ نوبتی که در پنل ثبت و قطعی می‌شود از این مسیر عبور نمی‌کند و سهمی
* نمی‌سازد. انتساب بر پایهٔ محیط نوبت است: کلینیک نوبت، وگرنه خودِ پزشک.
*/
class SecretaryShareResolver
{
public function __construct(
private readonly DoctorSecretaryRepository $secretaryRepo,
) {}
/**
* @return list<array{user: \App\Auth\Entity\User, percent: float, relation_uuid: string}>
*/
public function for(Payment $payment): array
{
$appointment = $payment->getAppointment();
if ($appointment === null) {
return [];
}
$rows = $this->secretaryRepo->findOnlineShareRows($appointment->getDoctor(), $appointment->getClinic());
return array_values(array_map(static fn($row) => [
'user' => $row->getSecretary(),
'percent' => $row->effectiveOnlineSharePercent(),
'relation_uuid' => $row->getUuid(),
], $rows));
}
}