feat: enhance staff management and payment gateway features

- Fix national code handling in staff creation and updates to support Persian digits.
- Update ClinicStaff entity to allow longer national codes (up to 15 characters).
- Implement support for clinic secretaries in SecretaryController, allowing creation without a doctor UUID.
- Add a new endpoint to retrieve doctors associated with a clinic for secretary management.
- Improve appointment management by ensuring doctors are selectable even when no appointments exist.
- Extend PatientController to allow secretaries to create patient records if they have the appropriate permissions.
- Introduce a PriceInput component for better price formatting in forms, supporting Persian digits.
- Add a MockGateway for testing payment processes without real transactions.
- Enhance SMS settings management with an approval flow for post-visit text messages, including new fields for pending text and status.
- Update migrations to reflect changes in database schema for national codes and SMS settings.
This commit is contained in:
hamed
2026-06-15 11:03:56 +03:30
parent 55f646e2d4
commit 5cdcec23a9
32 changed files with 1487 additions and 128 deletions
+66 -6
View File
@@ -8,6 +8,7 @@ use App\Config\Repository\SiteConfigRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Payment\Entity\Payment;
use App\Payment\Gateway\MellatGateway;
use App\Payment\Gateway\MockGateway;
use App\Payment\Gateway\SepGateway;
use App\Payment\Repository\PaymentRepository;
use App\Shared\Constant\ErrorCodes;
@@ -35,6 +36,7 @@ class SmsWalletController extends BaseController
private readonly SiteConfigRepository $configRepo,
private readonly MellatGateway $mellat,
private readonly SepGateway $sep,
private readonly MockGateway $mock,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly string $appBaseUrl,
@@ -75,11 +77,15 @@ class SmsWalletController extends BaseController
return $this->error(ErrorCodes::ERR_PAYMENT_002, 'مبلغ شارژ نامعتبر است', 422);
}
$gateway = match ($gatewayName) {
'mellat' => $this->mellat,
'sep' => $this->sep,
default => null,
};
if ($this->configRepo->get('payment_test_mode') === '1') {
$gateway = $this->mock;
} else {
$gateway = match ($gatewayName) {
'mellat' => $this->mellat,
'sep' => $this->sep,
default => null,
};
}
if ($gateway === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر است', 422);
@@ -172,13 +178,67 @@ class SmsWalletController extends BaseController
if (isset($data['reminder_enabled'])) { $settings->setReminderEnabled((bool) $data['reminder_enabled']); }
if (isset($data['reminder_hours_before'])) { $settings->setReminderHoursBefore((int) $data['reminder_hours_before']); }
if (isset($data['post_visit_enabled'])) { $settings->setPostVisitEnabled((bool) $data['post_visit_enabled']); }
if (array_key_exists('post_visit_text', $data)) { $settings->setPostVisitText($data['post_visit_text']); }
if (array_key_exists('post_visit_text', $data) && $data['post_visit_text'] !== null) {
$text = trim((string) $data['post_visit_text']);
if ($text !== '') {
$settings->submitPostVisitText($text);
}
}
$this->settingsRepo->save($settings);
return $this->success($settings->toArray());
}
#[Route('/api/v1/admin/sms/settings/review', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminReviewList(): JsonResponse
{
$pending = $this->settingsRepo->createQueryBuilder('s')
->where('s.postVisitTextStatus = :status')
->setParameter('status', SmsSettings::TEXT_STATUS_PENDING)
->getQuery()
->getResult();
return $this->success(['data' => array_map(fn(SmsSettings $s) => $s->toArray(), $pending)]);
}
#[Route('/api/v1/admin/sms/settings/{id}/approve', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function adminApprove(int $id): JsonResponse
{
$settings = $this->settingsRepo->find($id);
if ($settings === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تنظیمات یافت نشد', 404);
}
$settings->approvePostVisitText();
$this->settingsRepo->save($settings);
return $this->success($settings->toArray());
}
#[Route('/api/v1/admin/sms/settings/{id}/reject', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function adminReject(int $id, Request $request): JsonResponse
{
$settings = $this->settingsRepo->find($id);
if ($settings === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تنظیمات یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$reason = trim($data['reason'] ?? '');
if ($reason === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دلیل رد الزامی است', 422);
}
$settings->rejectPostVisitText($reason);
$this->settingsRepo->save($settings);
return $this->success($settings->toArray());
}
#[Route('/api/v1/admin/sms/wallet-report', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminReport(Request $request): JsonResponse
+56 -7
View File
@@ -33,9 +33,23 @@ class SmsSettings
#[ORM\Column(name: 'post_visit_text', type: 'text', nullable: true)]
private ?string $postVisitText = null;
#[ORM\Column(name: 'post_visit_text_pending', type: 'text', nullable: true)]
private ?string $postVisitTextPending = null;
#[ORM\Column(name: 'post_visit_text_status', type: 'string', length: 20)]
private string $postVisitTextStatus = 'none';
#[ORM\Column(name: 'post_visit_text_reject_reason', type: 'text', nullable: true)]
private ?string $postVisitTextRejectReason = null;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public const TEXT_STATUS_NONE = 'none';
public const TEXT_STATUS_PENDING = 'pending';
public const TEXT_STATUS_APPROVED = 'approved';
public const TEXT_STATUS_REJECTED = 'rejected';
public function __construct(string $entityType, int $entityId)
{
$this->entityType = $entityType;
@@ -51,21 +65,56 @@ class SmsSettings
public function isPostVisitEnabled(): bool { return $this->postVisitEnabled; }
public function getPostVisitText(): ?string { return $this->postVisitText; }
public function getPostVisitTextPending(): ?string { return $this->postVisitTextPending; }
public function getPostVisitTextStatus(): string { return $this->postVisitTextStatus; }
public function getPostVisitTextRejectReason(): ?string { return $this->postVisitTextRejectReason; }
public function setReminderEnabled(bool $v): self { $this->reminderEnabled = $v; $this->updatedAt = time(); return $this; }
public function setReminderHoursBefore(int $v): self { $this->reminderHoursBefore = $v; $this->updatedAt = time(); return $this; }
public function setPostVisitEnabled(bool $v): self { $this->postVisitEnabled = $v; $this->updatedAt = time(); return $this; }
public function setPostVisitText(?string $v): self { $this->postVisitText = $v; $this->updatedAt = time(); return $this; }
public function submitPostVisitText(string $text): self
{
$this->postVisitTextPending = $text;
$this->postVisitTextStatus = self::TEXT_STATUS_PENDING;
$this->postVisitTextRejectReason = null;
$this->updatedAt = time();
return $this;
}
public function approvePostVisitText(): self
{
if ($this->postVisitTextPending !== null) {
$this->postVisitText = $this->postVisitTextPending;
}
$this->postVisitTextPending = null;
$this->postVisitTextStatus = self::TEXT_STATUS_APPROVED;
$this->updatedAt = time();
return $this;
}
public function rejectPostVisitText(string $reason): self
{
$this->postVisitTextStatus = self::TEXT_STATUS_REJECTED;
$this->postVisitTextRejectReason = $reason;
$this->updatedAt = time();
return $this;
}
public function toArray(): array
{
return [
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'reminder_enabled' => $this->reminderEnabled,
'reminder_hours_before' => $this->reminderHoursBefore,
'post_visit_enabled' => $this->postVisitEnabled,
'post_visit_text' => $this->postVisitText,
'updated_at' => $this->updatedAt,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'reminder_enabled' => $this->reminderEnabled,
'reminder_hours_before' => $this->reminderHoursBefore,
'post_visit_enabled' => $this->postVisitEnabled,
'post_visit_text' => $this->postVisitText,
'post_visit_text_pending' => $this->postVisitTextPending,
'post_visit_text_status' => $this->postVisitTextStatus,
'post_visit_text_reject_reason' => $this->postVisitTextRejectReason,
'updated_at' => $this->updatedAt,
];
}
}
+11 -6
View File
@@ -2,6 +2,7 @@
namespace App\Sms\Provider;
use App\Config\Repository\SiteConfigRepository;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class KavehNegarProvider implements SmsProviderInterface
@@ -9,22 +10,26 @@ class KavehNegarProvider implements SmsProviderInterface
private const BASE = 'https://api.kavenegar.com/v1';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $apiKey,
private readonly string $sender,
private readonly HttpClientInterface $httpClient,
private readonly SiteConfigRepository $configRepo,
private readonly string $apiKey = '',
private readonly string $sender = '',
) {}
private function key(): string { return $this->configRepo->get('kavenegar_api_key') ?: $this->apiKey; }
private function sender(): string { return $this->configRepo->get('kavenegar_sender') ?: $this->sender; }
public function getName(): string { return 'kavenegar'; }
public function send(string $mobile, string $message): bool
{
try {
$resp = $this->httpClient->request('POST',
self::BASE . '/' . $this->apiKey . '/sms/send.json', [
self::BASE . '/' . $this->key() . '/sms/send.json', [
'body' => http_build_query([
'receptor' => $mobile,
'message' => $message,
'sender' => $this->sender,
'sender' => $this->sender(),
]),
'timeout' => 10,
]
@@ -44,7 +49,7 @@ class KavehNegarProvider implements SmsProviderInterface
$params['token' . ($i > 0 ? $i + 1 : '')] = $v;
}
$resp = $this->httpClient->request('POST',
self::BASE . '/' . $this->apiKey . '/verify/lookup.json', [
self::BASE . '/' . $this->key() . '/verify/lookup.json', [
'body' => http_build_query($params),
'timeout' => 10,
]