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:
@@ -46,6 +46,8 @@ class AdminApiController extends BaseController
|
||||
private readonly \App\Patient\Service\PatientResolver $patientResolver,
|
||||
private readonly \App\Insurance\Service\VisitPriceRequirementResolver $visitPriceResolver,
|
||||
private readonly \App\Appointment\Service\AppointmentConfirmationService $appointmentConfirmation,
|
||||
private readonly \App\Secretary\Repository\DoctorSecretaryRepository $doctorSecretaryRepo,
|
||||
private readonly \App\Secretary\Repository\SecretaryEarningRepository $secretaryEarningRepo,
|
||||
) {}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────
|
||||
@@ -1500,6 +1502,7 @@ class AdminApiController extends BaseController
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'ds.uuid, ds.permissions, ds.active, ds.createdAt',
|
||||
'ds.onlineShareEnabled, ds.onlineSharePercent',
|
||||
'u.mobileNumber as mobile, u.realName as user_name',
|
||||
'd.name as doctor_name, d.uuid as doctor_uuid',
|
||||
)
|
||||
@@ -1525,6 +1528,8 @@ class AdminApiController extends BaseController
|
||||
'doctor_name' => $ds['doctor_name'],
|
||||
'doctor_uuid' => $ds['doctor_uuid'],
|
||||
'is_active' => (bool) $ds['active'],
|
||||
'online_share_enabled' => (bool) $ds['onlineShareEnabled'],
|
||||
'online_share_percent' => (float) $ds['onlineSharePercent'],
|
||||
'permissions' => $ds['permissions'] ?? DoctorSecretary::DEFAULT_PERMISSIONS,
|
||||
'created_at' => date('c', (int) $ds['createdAt']),
|
||||
], $rows);
|
||||
@@ -1532,6 +1537,75 @@ class AdminApiController extends BaseController
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
|
||||
// ── Secretary detail + online-appointment share ───────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/admin/secretary/{uuid}',
|
||||
summary: 'Secretary relation detail with its online-appointment share settings and earnings',
|
||||
security: [['bearerAuth' => []]],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Secretary detail'),
|
||||
new OA\Response(response: 404, description: 'Secretary not found'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/admin/secretary/{uuid}', methods: ['GET'])]
|
||||
public function secretaryDetail(string $uuid): JsonResponse
|
||||
{
|
||||
$relation = $this->doctorSecretaryRepo->findByUuid($uuid);
|
||||
if ($relation === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'منشی یافت نشد', 404);
|
||||
}
|
||||
|
||||
$secretary = $relation->getSecretary();
|
||||
$monthStart = time() - 30 * 86_400;
|
||||
|
||||
return $this->success([
|
||||
'data' => $relation->toArray() + [
|
||||
'clinic_name' => $relation->getClinic()?->getName(),
|
||||
'earnings' => [
|
||||
'total_rials' => $this->secretaryEarningRepo->sumFor($secretary),
|
||||
'this_month_rials' => $this->secretaryEarningRepo->sumFor($secretary, $monthStart),
|
||||
'appointments_count' => $this->secretaryEarningRepo->countFor($secretary),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Put(
|
||||
path: '/api/v1/admin/secretary/{uuid}/online-share',
|
||||
summary: 'Enable/disable the secretary share of online appointments and set its percent',
|
||||
security: [['bearerAuth' => []]],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Saved'),
|
||||
new OA\Response(response: 404, description: 'Secretary not found'),
|
||||
new OA\Response(response: 422, description: 'Invalid percent'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/admin/secretary/{uuid}/online-share', methods: ['PUT'])]
|
||||
public function saveSecretaryOnlineShare(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$relation = $this->doctorSecretaryRepo->findByUuid($uuid);
|
||||
if ($relation === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'منشی یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$enabled = (bool) ($data['enabled'] ?? false);
|
||||
$percent = (float) ($data['percent'] ?? 0);
|
||||
|
||||
if ($percent < 0 || $percent > 100) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درصد سهم باید بین ۰ تا ۱۰۰ باشد', 422, 'percent');
|
||||
}
|
||||
if ($enabled && $percent <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای فعالسازی، درصد سهم باید بیشتر از صفر باشد', 422, 'percent');
|
||||
}
|
||||
|
||||
$relation->setOnlineShareEnabled($enabled)->setOnlineSharePercent($percent);
|
||||
$this->doctorSecretaryRepo->save($relation);
|
||||
|
||||
return $this->success(['data' => $relation->toArray()]);
|
||||
}
|
||||
|
||||
// ── Ratings ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
|
||||
@@ -8,12 +8,15 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use App\Representation\Repository\RepresentationRepository;
|
||||
use App\Shared\Entity\HasIbansTrait;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: RepresentationRepository::class)]
|
||||
#[ORM\Table(name: 'representations')]
|
||||
class Representation
|
||||
{
|
||||
use HasIbansTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
@@ -124,53 +127,6 @@ class Representation
|
||||
public function setBankAccount(?array $v): self { $this->bankAccount = $v; $this->touch(); return $this; }
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
public function getIbans(): array { return $this->bankAccount ?? []; }
|
||||
|
||||
/**
|
||||
* افزودن یک شبا (حداکثر ۲). id خودکار تولید میشود.
|
||||
* @param array{iban:string,bank_name:?string,owner_name:?string,verified?:bool} $iban
|
||||
*/
|
||||
public function addIban(array $iban): self
|
||||
{
|
||||
$ibans = $this->getIbans();
|
||||
if (count($ibans) >= 2) {
|
||||
throw new \DomainException('iban_limit');
|
||||
}
|
||||
$ibans[] = [
|
||||
'id' => Uuid::v4()->toRfc4122(),
|
||||
'iban' => $iban['iban'],
|
||||
'bank_name' => $iban['bank_name'] ?? null,
|
||||
'owner_name' => $iban['owner_name'] ?? null,
|
||||
'verified' => $iban['verified'] ?? false,
|
||||
'created_at' => time(),
|
||||
];
|
||||
$this->bankAccount = $ibans;
|
||||
$this->touch();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeIban(string $id): self
|
||||
{
|
||||
$this->bankAccount = array_values(array_filter(
|
||||
$this->getIbans(),
|
||||
fn(array $i) => ($i['id'] ?? null) !== $id
|
||||
));
|
||||
$this->touch();
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null یک شبای تأییدشده با این id */
|
||||
public function findVerifiedIban(string $id): ?array
|
||||
{
|
||||
foreach ($this->getIbans() as $iban) {
|
||||
if (($iban['id'] ?? null) === $id && ($iban['verified'] ?? false)) {
|
||||
return $iban;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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() ?? []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Entity;
|
||||
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* شمارههای شبای یک موجودیت (نماینده، پروفایل کاربر، …) روی ستون JSON `bank_account`.
|
||||
* حداکثر دو شبا؛ `verified` فقط از سمت ادمین ست میشود و تسویه تنها با شبای تأییدشده
|
||||
* انجام میگیرد. استفادهکننده باید ستون `bank_account` و متد `touch()` را داشته باشد.
|
||||
*/
|
||||
trait HasIbansTrait
|
||||
{
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public function getIbans(): array
|
||||
{
|
||||
return $this->bankAccount ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{iban: string, bank_name?: ?string, owner_name?: ?string, verified?: bool} $iban
|
||||
* @throws \DomainException `iban_limit` وقتی از سقف دو شبا بگذرد
|
||||
*/
|
||||
public function addIban(array $iban): static
|
||||
{
|
||||
$ibans = $this->getIbans();
|
||||
if (count($ibans) >= 2) {
|
||||
throw new \DomainException('iban_limit');
|
||||
}
|
||||
|
||||
$ibans[] = [
|
||||
'id' => Uuid::v4()->toRfc4122(),
|
||||
'iban' => $iban['iban'],
|
||||
'bank_name' => $iban['bank_name'] ?? null,
|
||||
'owner_name' => $iban['owner_name'] ?? null,
|
||||
'verified' => $iban['verified'] ?? false,
|
||||
'created_at' => time(),
|
||||
];
|
||||
$this->bankAccount = $ibans;
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeIban(string $id): static
|
||||
{
|
||||
$this->bankAccount = array_values(array_filter(
|
||||
$this->getIbans(),
|
||||
static fn(array $i) => ($i['id'] ?? null) !== $id,
|
||||
));
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null یک شبای تأییدشده با این id */
|
||||
public function findVerifiedIban(string $id): ?array
|
||||
{
|
||||
foreach ($this->getIbans() as $iban) {
|
||||
if (($iban['id'] ?? null) === $id && ($iban['verified'] ?? false)) {
|
||||
return $iban;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\UserProfile\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Shared\Entity\HasIbansTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use App\UserProfile\Repository\UserProfileRepository;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
@@ -13,6 +14,8 @@ use Symfony\Component\Uid\Uuid;
|
||||
#[ORM\UniqueConstraint(name: 'uniq_profiles_national_code', columns: ['national_code'])]
|
||||
class UserProfile
|
||||
{
|
||||
use HasIbansTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
@@ -103,6 +106,14 @@ class UserProfile
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $avatar = null;
|
||||
|
||||
/**
|
||||
* ۰ تا ۲ شماره شبای کاربر برای تسویه — همان ساختار نماینده
|
||||
* ({@see \App\Shared\Entity\HasIbansTrait}). محلِ درستِ شبا کاربر است نه نقش،
|
||||
* چون یک کاربر میتواند چند رابطهٔ منشی داشته باشد.
|
||||
*/
|
||||
#[ORM\Column(name: 'bank_account', type: 'json', nullable: true)]
|
||||
private ?array $bankAccount = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -177,6 +188,9 @@ class UserProfile
|
||||
public function setDescription(?string $v): self { $this->description = $v; $this->touch(); return $this; }
|
||||
public function setAvatar(?string $v): self { $this->avatar = $v; $this->touch(); return $this; }
|
||||
|
||||
public function getBankAccount(): ?array { return $this->bankAccount; }
|
||||
public function setBankAccount(?array $v): self { $this->bankAccount = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
|
||||
Reference in New Issue
Block a user