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
@@ -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];
}
}