feat: Implement SMS sending functionality with KavehNegar and Rangineh providers

- Add SendSmsMessage class for encapsulating SMS message data.
- Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS.
- Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates.
- Develop SendSmsHandler for handling SMS sending messages.
- Create SmsService to manage SMS dispatching and logging.
- Add UserProfileController for managing user profiles with CRUD operations.
- Implement UserProfile entity and repository for user profile data management.
- Update symfony.lock and bootstrap.php for project dependencies and environment setup.
This commit is contained in:
hamed
2026-06-09 22:00:34 +03:30
commit de1a78a235
222 changed files with 36388 additions and 0 deletions
@@ -0,0 +1,201 @@
<?php
namespace App\Representation\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Representation\Entity\Representation;
use App\Representation\Repository\RepresentationRepository;
use App\Representation\Service\JalaliDateService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class RepresentationController extends BaseController
{
public function __construct(
private readonly RepresentationRepository $representationRepo,
private readonly UserRepository $userRepo,
private readonly EntityManagerInterface $em,
private readonly JalaliDateService $jalali,
) {}
// ── CRUD ──────────────────────────────────────────────────────────────────
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/representation', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$mobile = trim($data['mobile_number'] ?? '');
$fullName = trim($data['full_name'] ?? '');
if (empty($mobile) || empty($fullName)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'mobile_number و full_name الزامی است', 422);
}
$user = $this->userRepo->findByMobile($mobile);
if ($user === null) {
$user = new User($mobile);
$this->em->persist($user);
}
$user->addRole('ROLE_REPRESENTATION');
$this->em->flush();
if ($this->representationRepo->findByUser($user) !== null) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این کاربر قبلاً نماینده است', 409);
}
$rep = new Representation($user, $fullName);
if (!empty($data['city'])) $rep->setCity($data['city']);
if (!empty($data['commission_percent'])) $rep->setCommissionPercent((string)$data['commission_percent']);
if (!empty($data['bank_account'])) $rep->setBankAccount($data['bank_account']);
$this->representationRepo->save($rep);
return $this->success(['data' => $rep->toArray()], 201);
}
#[Route('/api/v1/representation/{uuid}', methods: ['GET'])]
public function get(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$rep = $this->representationRepo->findByUuid($uuid);
if ($rep === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404);
}
if ($rep->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
return $this->success(['data' => $rep->toArray()]);
}
#[Route('/api/v1/representation/{uuid}', methods: ['PATCH'])]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$rep = $this->representationRepo->findByUuid($uuid);
if ($rep === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404);
}
if ($rep->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('full_name', $data)) $rep->setFullName($data['full_name']);
if (array_key_exists('city', $data)) $rep->setCity($data['city']);
if (array_key_exists('bank_account', $data)) $rep->setBankAccount($data['bank_account']);
if (array_key_exists('commission_percent', $data)) $rep->setCommissionPercent((string)$data['commission_percent']);
if (array_key_exists('active', $data)) $rep->setActive((bool)$data['active']);
$this->representationRepo->save($rep);
return $this->success(['data' => $rep->toArray()]);
}
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/representation/{uuid}', methods: ['DELETE'])]
public function delete(string $uuid): JsonResponse
{
$rep = $this->representationRepo->findByUuid($uuid);
if ($rep === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404);
}
$this->representationRepo->remove($rep);
return $this->success(['message' => 'نماینده با موفقیت حذف شد']);
}
// ── Dashboard: monthly stats ──────────────────────────────────────────────
#[Route('/api/v1/representation/{uuid}/dashboard/monthly', methods: ['GET'])]
public function dashboardMonthly(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$rep = $this->representationRepo->findByUuid($uuid);
if ($rep === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404);
}
if ($rep->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$jYear = (int) ($request->query->get('year', $this->jalali->jalaliYear(time())));
$jMonth = (int) ($request->query->get('month', $this->jalali->jalaliMonth(time())));
[$startTs, $endTs] = $this->jalali->jalaliMonthRange($jYear, $jMonth);
return $this->success([
'period' => ['jalali_year' => $jYear, 'jalali_month' => $jMonth],
'stats' => $this->buildStats($startTs, $endTs),
]);
}
#[Route('/api/v1/representation/{uuid}/dashboard/yearly', methods: ['GET'])]
public function dashboardYearly(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$rep = $this->representationRepo->findByUuid($uuid);
if ($rep === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404);
}
if ($rep->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$jYear = (int) ($request->query->get('year', $this->jalali->jalaliYear(time())));
$months = [];
for ($m = 1; $m <= 12; $m++) {
[$mStart, $mEnd] = $this->jalali->jalaliMonthRange($jYear, $m);
$months[] = [
'jalali_month' => $m,
'stats' => $this->buildStats($mStart, $mEnd),
];
}
[$startTs, $endTs] = $this->jalali->jalaliYearRange($jYear);
return $this->success([
'period' => ['jalali_year' => $jYear],
'months' => $months,
'totals' => $this->buildStats($startTs, $endTs),
]);
}
// ── Private ───────────────────────────────────────────────────────────────
private function buildStats(int $startTs, int $endTs): array
{
$totalPayments = (int) $this->em->createQuery(
'SELECT COUNT(p.id) FROM App\Payment\Entity\Payment p
WHERE p.status = :status AND p.createdAt BETWEEN :start AND :end'
)->setParameters(['status' => 'success', 'start' => $startTs, 'end' => $endTs])
->getSingleScalarResult();
$totalRevenue = (int) ($this->em->createQuery(
'SELECT SUM(p.amountRials) FROM App\Payment\Entity\Payment p
WHERE p.status = :status AND p.createdAt BETWEEN :start AND :end'
)->setParameters(['status' => 'success', 'start' => $startTs, 'end' => $endTs])
->getSingleScalarResult() ?? 0);
$totalAppointments = (int) $this->em->createQuery(
'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.createdAt BETWEEN :start AND :end'
)->setParameters(['start' => $startTs, 'end' => $endTs])->getSingleScalarResult();
return [
'total_payments' => $totalPayments,
'total_revenue_rials' => $totalRevenue,
'total_appointments' => $totalAppointments,
];
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Representation\Entity;
use App\Auth\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'representations')]
class Representation
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\OneToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private User $user;
#[ORM\Column(type: 'string', length: 255)]
private string $fullName;
#[ORM\Column(type: 'string', length: 20, nullable: true)]
private ?string $mobileNumber = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $city = null;
#[ORM\Column(name: 'commission_percent', type: 'decimal', precision: 5, scale: 2)]
private string $commissionPercent = '10.00';
#[ORM\Column(name: 'bank_account', type: 'json', nullable: true)]
private ?array $bankAccount = null;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(User $user, string $fullName)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->user = $user;
$this->fullName = $fullName;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getUser(): User { return $this->user; }
public function getFullName(): string { return $this->fullName; }
public function getMobileNumber(): ?string { return $this->mobileNumber; }
public function getCity(): ?string { return $this->city; }
public function getCommissionPercent(): string { return $this->commissionPercent; }
public function getBankAccount(): ?array { return $this->bankAccount; }
public function isActive(): bool { return $this->active; }
public function setFullName(string $v): self { $this->fullName = $v; $this->touch(); return $this; }
public function setMobileNumber(?string $v): self { $this->mobileNumber = $v; $this->touch(); return $this; }
public function setCity(?string $v): self { $this->city = $v; $this->touch(); return $this; }
public function setCommissionPercent(string $v): self { $this->commissionPercent = $v; $this->touch(); return $this; }
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; }
private function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'full_name' => $this->fullName,
'mobile_number' => $this->mobileNumber,
'city' => $this->city,
'commission_percent' => $this->commissionPercent,
'bank_account' => $this->bankAccount,
'active' => $this->active,
'created_at' => $this->createdAt,
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Representation\Repository;
use App\Auth\Entity\User;
use App\Representation\Entity\Representation;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class RepresentationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Representation::class);
}
public function findByUuid(string $uuid): ?Representation
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findByUser(User $user): ?Representation
{
return $this->findOneBy(['user' => $user]);
}
public function save(Representation $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) $this->getEntityManager()->flush();
}
public function remove(Representation $entity, bool $flush = true): void
{
$this->getEntityManager()->remove($entity);
if ($flush) $this->getEntityManager()->flush();
}
}
@@ -0,0 +1,112 @@
<?php
namespace App\Representation\Service;
/**
* Gregorian ↔ Jalali (Solar Hijri) conversion.
*/
class JalaliDateService
{
public function toJalali(\DateTimeInterface $date): array
{
[$gy, $gm, $gd] = [(int)$date->format('Y'), (int)$date->format('m'), (int)$date->format('d')];
return $this->gregorianToJalali($gy, $gm, $gd);
}
/** Returns [year, month, day] in Jalali */
public function gregorianToJalali(int $gy, int $gm, int $gd): array
{
$g_d_no = 365 * $gy + (int)(($gy + 3) / 4) - (int)(($gy + 99) / 100) + (int)(($gy + 399) / 400);
$g_days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
for ($i = 1; $i < $gm; $i++) $g_d_no += $g_days[$i];
if ($gm > 2 && (($gy % 4 === 0 && $gy % 100 !== 0) || ($gy % 400 === 0))) $g_d_no++;
$j_d_no = $g_d_no - 79;
$j_np = (int)($j_d_no / 12053);
$j_d_no %= 12053;
$jy = 979 + 33 * $j_np + 4 * (int)($j_d_no / 1461);
$j_d_no %= 1461;
if ($j_d_no >= 366) {
$jy += (int)(($j_d_no - 1) / 365);
$j_d_no = ($j_d_no - 1) % 365;
}
$j_days = [0, 31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29];
$jm = 0;
for ($i = 1; $i <= 12; $i++) {
if ($j_d_no < $j_days[$i]) { $jm = $i; break; }
$j_d_no -= $j_days[$i];
}
$jd = $j_d_no + 1;
return [$jy, $jm, $jd];
}
public function jalaliYear(int $timestamp): int
{
return $this->gregorianToJalali(
(int)date('Y', $timestamp),
(int)date('m', $timestamp),
(int)date('d', $timestamp)
)[0];
}
public function jalaliMonth(int $timestamp): int
{
return $this->gregorianToJalali(
(int)date('Y', $timestamp),
(int)date('m', $timestamp),
(int)date('d', $timestamp)
)[1];
}
/** Returns [startTs, endTs] for a given Jalali month/year */
public function jalaliMonthRange(int $jYear, int $jMonth): array
{
// Convert first day of Jalali month to Gregorian
$start = $this->jalaliToGregorian($jYear, $jMonth, 1);
$daysInMonth = $jMonth <= 6 ? 31 : ($jMonth <= 11 ? 30 : 29);
$end = $this->jalaliToGregorian($jYear, $jMonth, $daysInMonth);
$startTs = mktime(0, 0, 0, $start[1], $start[2], $start[0]);
$endTs = mktime(23, 59, 59, $end[1], $end[2], $end[0]);
return [$startTs, $endTs];
}
public function jalaliYearRange(int $jYear): array
{
$start = $this->jalaliToGregorian($jYear, 1, 1);
$end = $this->jalaliToGregorian($jYear, 12, 29);
return [
mktime(0, 0, 0, $start[1], $start[2], $start[0]),
mktime(23, 59, 59, $end[1], $end[2], $end[0]),
];
}
public function jalaliToGregorian(int $jy, int $jm, int $jd): array
{
$jy += 1595;
$days = -355779 + 365 * $jy + (int)($jy / 33) * 8 + (int)((($jy % 33) + 3) / 4) + $jd;
$jm_days = [0, 31, 62, 93, 124, 155, 186, 216, 246, 276, 306, 336];
$days += $jm_days[$jm - 1];
$gy = 400 * (int)($days / 146097);
$days %= 146097;
if ($days > 36524) { $gy += 100 * (int)(--$days / 36524); $days %= 36524; if ($days >= 365) $days++; }
$gy += 4 * (int)($days / 1461);
$days %= 1461;
if ($days > 365) { $gy += (int)(($days - 1) / 365); $days = ($days - 1) % 365; }
$gd = $days + 1;
$gm_days = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
$gm = 0;
for ($i = 1; $i <= 12; $i++) {
if ($gd <= $gm_days[$i]) { $gm = $i; break; }
$gd -= $gm_days[$i];
}
return [$gy, $gm, $gd];
}
}