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:
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user