Compare commits

...

14 Commits

Author SHA1 Message Date
mehrab 0d16d8f512 feat: add comprehensive tests for Medium, OtpCode, Shift models and various Enums 2026-09-21 05:47:44 +03:30
mehrab 6e92ebaada Add feature tests for Doctor-related functionalities 2026-09-21 05:41:06 +03:30
mehrab e987a528ef Add form request validation tests for various client and admin requests 2026-09-21 05:24:50 +03:30
mehrab f77ff527e7 Add feature tests for Document, FastRequest, FileStorage, Shift, and SMS services 2026-09-21 05:17:45 +03:30
mehrab fd0d445b6e feat: add OtpCodeFactory and comprehensive tests for AuthService and RegistrationService 2026-09-21 05:06:46 +03:30
mehrab 9718fb8c7c feat: add comprehensive tests for ZibalPaymentService functionality 2026-09-21 05:03:39 +03:30
mehrab 792387804c feat: add BankCard and WithdrawRequest factories with comprehensive methods and tests 2026-09-21 05:03:07 +03:30
mehrab b4b1fdc288 feat: implement wallet and invoice factories, enhance wallet service methods, and add comprehensive tests 2026-09-21 05:01:19 +03:30
mehrab 45da0103c0 feat: enhance FastRequest functionality with shift scheduling and improve payment callback handling 2026-09-21 04:45:57 +03:30
mehrab 5f19f82f86 feat: add landing page components including FAQ, features, footer, header, hero, modalities, steps, and section title 2026-09-21 04:32:59 +03:30
mehrab 1e599b917b feat: implement SMS notification service and integrate with existing services 2026-09-21 02:47:12 +03:30
mehrab 51b13631a3 feat: implement media download functionality and enhance case handling in reports 2026-09-21 02:20:10 +03:30
mehrab 4ce663c669 feat: add withdraw request functionality and UI components 2026-08-27 02:39:57 +03:30
mehrab aeef95339d feat: add shift management functionality 2026-08-26 18:53:15 +03:30
260 changed files with 14914 additions and 546 deletions
+23
View File
@@ -0,0 +1,23 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev"
],
"port": 5173
},
{
"name": "serve",
"runtimeExecutable": "php",
"runtimeArgs": [
"artisan",
"serve"
],
"port": 8000
}
]
}
+14
View File
@@ -0,0 +1,14 @@
.git
node_modules
vendor
public/build
public/hot
public/storage
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
.env
.env.*
!.env.example
graphify-out
+11
View File
@@ -11,4 +11,15 @@ enum DocumentModalityEnum: string
case Sonography = 'sonography'; case Sonography = 'sonography';
case Echo = 'echo'; case Echo = 'echo';
case Radiology = 'radiology'; case Radiology = 'radiology';
public function price(): int
{
return match ($this) {
self::CtScan => 1000000,
self::Mri => 1200000,
self::Sonography => 500000,
self::Echo => 700000,
self::Radiology => 400000,
};
}
} }
+8
View File
@@ -8,5 +8,13 @@ enum DocumentPriorityEnum: string
case Medium = 'عادی'; case Medium = 'عادی';
case High = 'اورژانسی'; case High = 'اورژانسی';
public function processingHours(): int
{
return match ($this) {
self::Medium => 24,
self::High => 3,
};
}
} }
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum InvoicePaymentMethodEnum: string
{
use EnumHasOptions;
case Wallet = 'کیف پول';
case Gateway = 'درگاه پرداخت';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum InvoiceStatusEnum: string
{
use EnumHasOptions;
case Pending = 'در انتظار پرداخت';
case Paid = 'پرداخت شده';
case Canceled = 'لغو شده';
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum PaymentPurposeEnum: string
{
use EnumHasOptions;
case Invoice = 'پرداخت فاکتور';
case WalletTopUp = 'شارژ کیف پول';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum PaymentStatusEnum: string
{
use EnumHasOptions;
case Pending = 'در انتظار پرداخت';
case Paid = 'پرداخت شده';
case Failed = 'ناموفق';
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Enums;
use Carbon\Carbon;
enum ShiftDayEnum: string
{
use EnumHasOptions;
case Saturday = 'شنبه';
case Sunday = 'یکشنبه';
case Monday = 'دوشنبه';
case Tuesday = 'سه‌شنبه';
case Wednesday = 'چهارشنبه';
case Thursday = 'پنج‌شنبه';
case Friday = 'جمعه';
public function carbonDay(): int
{
return match ($this) {
self::Saturday => Carbon::SATURDAY,
self::Sunday => Carbon::SUNDAY,
self::Monday => Carbon::MONDAY,
self::Tuesday => Carbon::TUESDAY,
self::Wednesday => Carbon::WEDNESDAY,
self::Thursday => Carbon::THURSDAY,
self::Friday => Carbon::FRIDAY,
};
}
public static function fromDate(Carbon $date): self
{
return match ($date->dayOfWeek) {
Carbon::SATURDAY => self::Saturday,
Carbon::SUNDAY => self::Sunday,
Carbon::MONDAY => self::Monday,
Carbon::TUESDAY => self::Tuesday,
Carbon::WEDNESDAY => self::Wednesday,
Carbon::THURSDAY => self::Thursday,
Carbon::FRIDAY => self::Friday,
};
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Enums;
enum WalletTransactionReasonEnum: string
{
use EnumHasOptions;
case TopUp = 'شارژ کیف پول';
case Commission = 'کمیسیون پزشک';
case InvoicePayment = 'پرداخت فاکتور';
case Refund = 'بازگشت وجه';
case Withdraw = 'برداشت وجه';
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum WalletTransactionTypeEnum: string
{
use EnumHasOptions;
case Deposit = 'واریز';
case Withdrawal = 'برداشت';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum WithdrawRequestStatusEnum: string
{
use EnumHasOptions;
case Pending = 'در انتظار بررسی';
case Approved = 'تایید شده';
case Rejected = 'رد شده';
}
@@ -0,0 +1,145 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Shift;
use App\Http\Resources\Admin\ShiftResource;
use App\Http\Requests\Admin\ShiftRequest;
use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Inertia\Inertia;
class ShiftController extends Controller
{
use AuthorizesRequests;
public function index(Request $request)
{
$this->authorize('shifts.index');
$lists = Shift::with(['doctor'])
->when(isset($request->search) , function ($query) use ($request){
$query->where(function ($q) use ($request) {
$q->whereHas('doctor', function ($doctorQuery) use ($request) {
$doctorQuery->where('fullname', 'LIKE', "%{$request->search}%");
})->orWhere('id', $request->search);
});
})
->when(isset($request->day_of_week) && $request->day_of_week != 'همه روزها', function ($query) use ($request) {
$query->whereIn('day_of_week' , (array) $request->day_of_week);
})
->when(isset($request->status) && $request->status != 'همه وضعیت ها', function ($query) use ($request) {
$query->where('is_active' , $request->status == 'فعال');
})
->when(isset($request->occupancy) && $request->occupancy != 'همه شیفت ها', function ($query) use ($request) {
$request->occupancy == 'آزاد'
? $query->whereNull('user_id')
: $query->whereNotNull('user_id');
})
->latest()
->paginate(20)
->withQueryString()
->onEachSide(1);
$shifts = ShiftResource::collection($lists);
return Inertia::admin('Shifts/index', [
'shifts' => $shifts,
'search' => $request->search,
'day_of_week' => $request->day_of_week,
'status' => $request->status,
'occupancy' => $request->occupancy,
'permissions_edit' => auth()->user()->can('shifts.edit') ? false : true,
'permissions_delete' => auth()->user()->can('shifts.delete') ? false : true,
]);
}
public function create()
{
$this->authorize('shifts.create');
return Inertia::admin('Shifts/create_and_edit');
}
public function store(ShiftRequest $request)
{
$this->authorize('shifts.create');
Shift::create([
'user_id' => $request->user_id,
'day_of_week' => $request->day_of_week,
'date' => $request->date,
'start_time' => $request->start_time,
'end_time' => $request->end_time,
'is_active' => $request->is_active,
'created_by' => auth()->id(),
]);
session()->flash('success', 'شیفت با موفقیت ثبت شد');
return response(null , 200);
}
public function show(Shift $shift)
{
//
}
public function edit(Shift $shift)
{
$this->authorize('shifts.edit');
return Inertia::admin('Shifts/create_and_edit', [
'shift' => ShiftResource::make($shift->load('doctor'))->resolve(),
'is_edit' => true,
]);
}
public function update(ShiftRequest $request, Shift $shift)
{
$this->authorize('shifts.edit');
$shift->update([
'user_id' => $request->user_id,
'day_of_week' => $request->day_of_week,
'date' => $request->date,
'start_time' => $request->start_time,
'end_time' => $request->end_time,
'is_active' => $request->is_active,
]);
session()->flash('success', 'شیفت با موفقیت بروزرسانی شد');
return response(null , 200);
}
public function destroy(Shift $shift)
{
$this->authorize('shifts.delete');
$shift->delete();
session()->flash('success', 'شیفت با موفقیت حذف شد');
return response(null , 200);
}
public function calendarData()
{
$this->authorize('shifts.index');
$lists = Shift::with('doctor')->get();
return response()->json([
'success' => true,
'data' => ShiftResource::collection($lists),
]);
}
public function bulkDelete(Request $request)
{
$this->authorize('shifts.delete');
Shift::destroy($request->ids);
session()->flash('success', 'شیفت های مورد نظر با موفقیت حذف شدند.');
return response(null , 200);
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\WithdrawRequestStatusEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\WithdrawRejectRequest;
use App\Http\Resources\Admin\WithdrawRequestResource;
use App\Models\WithdrawRequest;
use App\Services\Wallet\WalletService;
use App\Services\Wallet\WithdrawRequestService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class WithdrawRequestController extends Controller
{
protected WithdrawRequestService $withdrawRequestService;
protected WalletService $walletService;
public function __construct(WithdrawRequestService $withdrawRequestService, WalletService $walletService)
{
$this->withdrawRequestService = $withdrawRequestService;
$this->walletService = $walletService;
}
public function index(Request $request)
{
$lists = WithdrawRequest::with(['user', 'bankCard', 'reviewedBy'])
->when(isset($request->search), function ($query) use ($request) {
$query->whereHas('user', function ($userQuery) use ($request) {
$userQuery->where('fullname', 'LIKE', "%{$request->search}%")
->orWhere('phone', 'LIKE', "%{$request->search}%")
->orWhere('email', 'LIKE', "%{$request->search}%");
});
})
->when(isset($request->status) && $request->status != 'همه وضعیت ها', function ($query) use ($request) {
$query->where('status', $request->status);
})
->latest()
->paginate(20)
->withQueryString()
->onEachSide(1);
$pending = WithdrawRequest::where('status', WithdrawRequestStatusEnum::Pending->value);
return Inertia::admin('WithdrawRequests/index', [
'withdrawRequests' => WithdrawRequestResource::collection($lists),
'stats' => [
'pending_count' => (clone $pending)->count(),
'pending_amount' => (int) (clone $pending)->sum('amount'),
'system_wallet_balance' => $this->walletService->system()->balance,
],
'search' => $request->search,
'status' => $request->status,
]);
}
public function approve(WithdrawRequest $withdrawRequest)
{
$this->withdrawRequestService->approve($withdrawRequest, Auth::user());
session()->flash('success', 'درخواست برداشت تایید شد و مبلغ به ولت سایت منتقل شد.');
return response(null, 200);
}
public function reject(WithdrawRejectRequest $request, WithdrawRequest $withdrawRequest)
{
$this->withdrawRequestService->reject($withdrawRequest, Auth::user(), $request->validated('note'));
session()->flash('success', 'درخواست برداشت رد شد.');
return response(null, 200);
}
}
@@ -3,7 +3,10 @@
namespace App\Http\Controllers\Client\Doctor; namespace App\Http\Controllers\Client\Doctor;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Client\CaseRejectRequest;
use App\Http\Resources\Client\CaseDetailResource;
use App\Http\Resources\Client\CaseResource; use App\Http\Resources\Client\CaseResource;
use App\Models\Document;
use App\Services\Document\CaseService; use App\Services\Document\CaseService;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Inertia\Inertia; use Inertia\Inertia;
@@ -28,4 +31,25 @@ class CaseController extends Controller
'cases' => CaseResource::collection($cases), 'cases' => CaseResource::collection($cases),
]); ]);
} }
public function show(Document $case)
{
abort_unless($this->caseService->canAccess($case, Auth::user()), 403);
$case->load(['pet', 'requester', 'media', 'reports']);
return Inertia::client('Dashboard/Cases/show', [
'case' => (new CaseDetailResource($case))->resolve(),
'canReport' => $this->caseService->canReport($case, Auth::user()),
]);
}
public function reject(CaseRejectRequest $request, Document $case)
{
$this->caseService->reject($case, Auth::user(), $request->validated('reason'));
session()->flash('success', 'درخواست رد شد و مبلغ فاکتور به کیف پول درخواست‌کننده بازگردانده شد.');
return redirect()->route('dashboard.cases.index');
}
} }
@@ -23,6 +23,7 @@ class DashboardController extends Controller
return Inertia::client('Dashboard/Dashboard', [ return Inertia::client('Dashboard/Dashboard', [
'cases' => CaseResource::collection($cases), 'cases' => CaseResource::collection($cases),
'stats' => $this->caseService->statsForUser(Auth::user()),
]); ]);
} }
} }
@@ -32,8 +32,6 @@ class DocumentController extends Controller
public function show(Document $document) public function show(Document $document)
{ {
return Inertia::client('Dashboard/Documents/show', [ return redirect()->route('dashboard.cases.show', $document);
'document' => $document,
]);
} }
} }
@@ -3,13 +3,59 @@
namespace App\Http\Controllers\Client\Doctor; namespace App\Http\Controllers\Client\Doctor;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use App\Http\Requests\Client\ReportSubmitRequest;
use App\Http\Resources\Client\CaseResource;
use App\Http\Resources\Client\ReportResource;
use App\Models\Document;
use App\Models\Report;
use App\Services\Document\CaseService;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia; use Inertia\Inertia;
class ReportController extends Controller class ReportController extends Controller
{ {
protected CaseService $caseService;
public function __construct(CaseService $caseService)
{
$this->caseService = $caseService;
}
public function index() public function index()
{ {
return Inertia::client('Dashboard/Reports/Index'); $reports = Report::with(['document', 'doctor'])
->where(function ($query) {
$query->where('doctor_id', Auth::id())
->orWhereHas('document', fn ($documentQuery) => $documentQuery->where('requester_id', Auth::id()));
})
->latest('submitted_at')
->paginate(10)
->withQueryString()
->onEachSide(1);
$pendingCases = $this->caseService->isDoctor(Auth::user())
? CaseResource::collection($this->caseService->pendingForUser(Auth::user())->get())
: null;
return Inertia::client('Dashboard/Reports/Index', [
'reports' => ReportResource::collection($reports),
'pendingCases' => $pendingCases,
]);
}
public function store(ReportSubmitRequest $request)
{
$document = Document::findOrFail($request->validated('document_id'));
$this->caseService->submitReport(
$document,
Auth::user(),
$request->validated(),
$request->file('files', [])
);
session()->flash('success', 'گزارش با موفقیت ثبت شد.');
return redirect()->route('dashboard.cases.index');
} }
} }
@@ -2,14 +2,77 @@
namespace App\Http\Controllers\Client\Doctor; namespace App\Http\Controllers\Client\Doctor;
use App\Enums\ShiftDayEnum;
use App\Enums\UserEnum;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use App\Http\Resources\Client\ShiftResource;
use App\Models\Shift;
use App\Services\Shift\ShiftScheduleService;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia; use Inertia\Inertia;
class ShiftController extends Controller class ShiftController extends Controller
{ {
protected ShiftScheduleService $shiftScheduleService;
public function __construct(ShiftScheduleService $shiftScheduleService)
{
$this->shiftScheduleService = $shiftScheduleService;
}
public function index() public function index()
{ {
return Inertia::client('Dashboard/Shifts/Index'); $this->ensureDoctor();
$myShifts = Shift::where('user_id', Auth::id())->get();
$openShifts = Shift::active()
->open()
->where(function ($query) {
$query->whereNull('date')
->orWhereDate('date', '>=', now()->toDateString());
})
->get();
return Inertia::client('Dashboard/Shifts/Index', [
'myShifts' => ShiftResource::collection($this->sortSchedule($myShifts)),
'openShifts' => ShiftResource::collection($this->sortSchedule($openShifts)),
]);
}
public function update(Shift $shift)
{
$this->ensureDoctor();
$this->shiftScheduleService->claim($shift->id, Auth::user());
return response(null, 200);
}
public function destroy(Shift $shift)
{
$this->ensureDoctor();
$this->shiftScheduleService->release($shift, Auth::user());
return response(null, 200);
}
protected function ensureDoctor(): void
{
abort_unless(Auth::user()->level === UserEnum::Doctor->value, 403);
}
protected function sortSchedule($shifts)
{
$dayOrder = array_flip(ShiftDayEnum::values());
return $shifts
->sortBy([
fn ($a, $b) => strcmp($a->date?->toDateString() ?? '', $b->date?->toDateString() ?? ''),
fn ($a, $b) => ($dayOrder[$a->day_of_week] ?? 0) <=> ($dayOrder[$b->day_of_week] ?? 0),
fn ($a, $b) => strcmp($a->start_time, $b->start_time),
])
->values();
} }
} }
@@ -2,32 +2,32 @@
namespace App\Http\Controllers\Client; namespace App\Http\Controllers\Client;
use App\Enums\UserEnum;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Client\FastRequestRequest; use App\Http\Requests\Client\FastRequestRequest;
use App\Http\Resources\Client\DoctorResource; use App\Http\Resources\Client\DoctorResource;
use App\Http\Resources\Client\FastRequestResource; use App\Http\Resources\Client\FastRequestResource;
use App\Http\Resources\Client\PetArchiveResource; use App\Http\Resources\Client\PetArchiveResource;
use App\Models\User;
use App\Services\Document\FastRequestService; use App\Services\Document\FastRequestService;
use App\Services\Shift\ShiftScheduleService;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
class FastRequestController extends Controller class FastRequestController extends Controller
{ {
protected FastRequestService $fastRequestService; protected FastRequestService $fastRequestService;
public function __construct(FastRequestService $fastRequestService) protected ShiftScheduleService $shiftScheduleService;
public function __construct(FastRequestService $fastRequestService, ShiftScheduleService $shiftScheduleService)
{ {
$this->fastRequestService = $fastRequestService; $this->fastRequestService = $fastRequestService;
$this->shiftScheduleService = $shiftScheduleService;
} }
public function create() public function create()
{ {
$pets = Auth::user()->pets()->latest()->get(); $pets = Auth::user()->pets()->latest()->get();
$doctors = User::where('level', UserEnum::Doctor->value) $doctors = $this->shiftScheduleService->availableDoctorsAt(now());
->latest()
->get();
return response()->json([ return response()->json([
'pets' => PetArchiveResource::collection($pets), 'pets' => PetArchiveResource::collection($pets),
@@ -45,7 +45,7 @@ class FastRequestController extends Controller
); );
return response()->json([ return response()->json([
'document' => new FastRequestResource($document->load('pet')), 'document' => new FastRequestResource($document->refresh()->load(['pet', 'invoice'])),
]); ]);
} }
} }
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Client;
use App\Http\Controllers\Controller;
use App\Http\Resources\Client\InvoiceResource;
use App\Models\Invoice;
use App\Services\Wallet\WalletService;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class InvoiceController extends Controller
{
protected WalletService $walletService;
public function __construct(WalletService $walletService)
{
$this->walletService = $walletService;
}
public function index()
{
$invoices = Auth::user()->invoices()
->with('document')
->latest()
->paginate(10)
->withQueryString()
->onEachSide(1);
return Inertia::client('Dashboard/Invoices/Index', [
'invoices' => InvoiceResource::collection($invoices),
]);
}
public function payWithWallet(Invoice $invoice)
{
abort_unless($invoice->user_id === Auth::id(), 403);
$invoice = $this->walletService->payInvoice(Auth::user(), $invoice);
return response()->json([
'invoice' => new InvoiceResource($invoice),
]);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Client;
use App\Http\Controllers\Controller;
use App\Models\Medium;
use App\Services\Document\CaseService;
use App\Services\FileStorageService;
use Illuminate\Support\Facades\Auth;
class MediaController extends Controller
{
protected CaseService $caseService;
protected FileStorageService $fileStorageService;
public function __construct(CaseService $caseService, FileStorageService $fileStorageService)
{
$this->caseService = $caseService;
$this->fileStorageService = $fileStorageService;
}
public function download(Medium $medium)
{
abort_unless($this->caseService->canAccessMedium($medium, Auth::user()), 403);
return $this->fileStorageService->downloadFile($medium);
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Client;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use App\Enums\PaymentPurposeEnum;
use App\Enums\PaymentStatusEnum;
use App\Enums\WalletTransactionReasonEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Client\PaymentRequest;
use App\Http\Resources\Client\PaymentResource;
use App\Models\Invoice;
use App\Models\Payment;
use App\Services\Invoice\InvoiceService;
use App\Services\Payment\ZibalPaymentService;
use App\Services\Wallet\WalletService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class PaymentController extends Controller
{
protected ZibalPaymentService $zibalPaymentService;
protected WalletService $walletService;
protected InvoiceService $invoiceService;
public function __construct(ZibalPaymentService $zibalPaymentService, WalletService $walletService, InvoiceService $invoiceService)
{
$this->zibalPaymentService = $zibalPaymentService;
$this->walletService = $walletService;
$this->invoiceService = $invoiceService;
}
public function store(PaymentRequest $request)
{
$invoice = Invoice::where('id', $request->invoice_id)
->where('user_id', Auth::id())
->where('status', InvoiceStatusEnum::Pending->value)
->firstOrFail();
$payment = $this->zibalPaymentService->request(Auth::user(), $invoice->amount, $invoice->number, [
'purpose' => PaymentPurposeEnum::Invoice->value,
'invoice_id' => $invoice->id,
]);
return response()->json([
'payment' => new PaymentResource($payment),
'url' => $this->zibalPaymentService->paymentUrl($payment),
]);
}
public function callback(Request $request)
{
$payment = Payment::where('track_id', $request->trackId)->first();
if (!$payment) {
return redirect()->route('dashboard.index');
}
$wasPending = $payment->status === PaymentStatusEnum::Pending;
if ($request->success == 1) {
$payment = $this->zibalPaymentService->verify($payment);
} else {
$payment = $this->zibalPaymentService->markFailed($payment);
}
if ($wasPending && $payment->status === PaymentStatusEnum::Paid) {
DB::transaction(fn () => $this->settle($payment));
}
session()->flash('payment_result', [
'status' => $payment->status === PaymentStatusEnum::Paid ? 'paid' : 'failed',
'track_id' => $payment->track_id,
'purpose' => $payment->purpose->name,
]);
return redirect()->route('dashboard.index');
}
protected function settle(Payment $payment): void
{
if ($payment->purpose === PaymentPurposeEnum::WalletTopUp) {
$this->walletService->deposit($payment->payer, $payment->amount, WalletTransactionReasonEnum::TopUp, $payment);
return;
}
$invoice = $payment->invoice;
if ($invoice && $invoice->status === InvoiceStatusEnum::Pending) {
$this->invoiceService->markPaid($invoice, InvoicePaymentMethodEnum::Gateway);
$this->walletService->recordGatewayInvoicePayment($invoice, $payment);
}
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Client;
use App\Http\Controllers\Controller;
use App\Http\Requests\Client\ProfileUpdateRequest;
use App\Models\Medium;
use App\Services\FileStorageService;
use Illuminate\Support\Facades\Auth;
class ProfileController extends Controller
{
protected FileStorageService $fileStorageService;
public function __construct(FileStorageService $fileStorageService)
{
$this->fileStorageService = $fileStorageService;
}
public function update(ProfileUpdateRequest $request)
{
$user = Auth::user();
$data = $request->safe()->except('avatar');
if ($request->hasFile('avatar')) {
if (!empty($user->profile_url)) {
if ($medium = Medium::where('url', $user->profile_url)->first()) {
$this->fileStorageService->deleteMedium($medium);
}
}
$medium = $this->fileStorageService->storeFile($request->file('avatar'), 'public', 'profile');
$data['profile_url'] = $medium->url;
}
$user->update($data);
session()->flash('success', 'حساب کاربری شما با موفقیت ویرایش شد.');
return redirect()->back();
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Client;
use App\Enums\PaymentPurposeEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Client\BankCardStoreRequest;
use App\Http\Requests\Client\WalletDepositRequest;
use App\Http\Requests\Client\WithdrawRequestStoreRequest;
use App\Http\Resources\Client\BankCardResource;
use App\Http\Resources\Client\PaymentResource;
use App\Http\Resources\Client\WalletTransactionResource;
use App\Http\Resources\Client\WithdrawRequestResource;
use App\Models\BankCard;
use App\Services\Payment\ZibalPaymentService;
use App\Services\Wallet\WalletService;
use App\Services\Wallet\WithdrawRequestService;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class WalletController extends Controller
{
protected WalletService $walletService;
protected ZibalPaymentService $zibalPaymentService;
protected WithdrawRequestService $withdrawRequestService;
public function __construct(WalletService $walletService, ZibalPaymentService $zibalPaymentService, WithdrawRequestService $withdrawRequestService)
{
$this->walletService = $walletService;
$this->zibalPaymentService = $zibalPaymentService;
$this->withdrawRequestService = $withdrawRequestService;
}
public function index()
{
$wallet = $this->walletService->forUser(Auth::user());
$transactions = $wallet->transactions()
->with('invoice')
->latest()
->paginate(10)
->withQueryString()
->onEachSide(1);
$withdrawRequests = Auth::user()->withdrawRequests()
->with('bankCard')
->latest()
->take(10)
->get();
return Inertia::client('Dashboard/Wallet/Index', [
'transactions' => WalletTransactionResource::collection($transactions),
'withdrawRequests' => WithdrawRequestResource::collection($withdrawRequests)->resolve(),
'bankCards' => BankCardResource::collection(Auth::user()->bankCards()->latest()->get())->resolve(),
]);
}
public function deposit(WalletDepositRequest $request)
{
$payment = $this->zibalPaymentService->request(
Auth::user(),
$request->validated('amount'),
'wallet-' . Auth::id() . '-' . now()->timestamp,
['purpose' => PaymentPurposeEnum::WalletTopUp->value]
);
return response()->json([
'payment' => new PaymentResource($payment),
'url' => $this->zibalPaymentService->paymentUrl($payment),
]);
}
public function storeWithdrawRequest(WithdrawRequestStoreRequest $request)
{
$bankCard = BankCard::findOrFail($request->validated('bank_card_id'));
$this->withdrawRequestService->request(Auth::user(), $request->validated('amount'), $bankCard);
session()->flash('success', 'درخواست برداشت شما ثبت شد و در انتظار بررسی است.');
return redirect()->route('dashboard.wallet.index');
}
public function storeBankCard(BankCardStoreRequest $request)
{
Auth::user()->bankCards()->create($request->validated());
session()->flash('success', 'کارت بانکی با موفقیت ثبت شد.');
return redirect()->route('dashboard.wallet.index');
}
public function destroyBankCard(BankCard $bankCard)
{
abort_unless($bankCard->user_id === Auth::id(), 403);
$bankCard->delete();
return redirect()->route('dashboard.wallet.index');
}
}
+2
View File
@@ -38,8 +38,10 @@ class PetRequest extends FormRequest
public function prepareForValidation() public function prepareForValidation()
{ {
if ($this->filled('weight')) {
$this->merge([ $this->merge([
'weight' => PersianToEnglish::toEnglish($this->weight), 'weight' => PersianToEnglish::toEnglish($this->weight),
]); ]);
} }
} }
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Requests\Admin;
use App\Enums\ShiftDayEnum;
use App\Enums\UserEnum;
use App\Services\Shift\ShiftScheduleService;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class ShiftRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'user_id' => ['nullable', Rule::exists('users', 'id')->where('level', UserEnum::Doctor->value)],
'day_of_week' => ['nullable', 'required_without:date', 'string', Rule::in(ShiftDayEnum::values())],
'date' => ['nullable', 'required_without:day_of_week', 'date'],
'start_time' => ['required', 'date_format:H:i'],
'end_time' => ['required', 'date_format:H:i', 'different:start_time'],
'is_active' => ['required', 'boolean'],
];
}
public function prepareForValidation()
{
if ($this->filled('date')) {
$this->merge(['day_of_week' => null]);
}
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
if ($validator->errors()->isNotEmpty()) {
return;
}
$scheduleService = app(ShiftScheduleService::class);
$ignoreId = $this->route('shift')?->id;
if ($scheduleService->hasConflict($this->validated(), $ignoreId)) {
$validator->errors()->add('start_time', 'این شیفت با شیفت دیگری از این پزشک تداخل دارد.');
}
});
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ class UserRequest extends FormRequest
'phone' => ['required', 'max:255', new PhoneNumber], 'phone' => ['required', 'max:255', new PhoneNumber],
'fullname' => 'nullable | max:255', 'fullname' => 'nullable | max:255',
'role' => ['nullable', 'array', 'exists:roles,id'], 'role' => ['nullable', 'array', 'exists:roles,id'],
'profile_url' => ['nullable', 'exists:medium,url'], 'profile_url' => ['nullable', 'exists:media,url'],
'email' => ['nullable', 'email', 'max:255', 'email' => ['nullable', 'email', 'max:255',
isset($toBeEditedUser) isset($toBeEditedUser)
? Rule::unique('users')->ignore($toBeEditedUser->id) ? Rule::unique('users')->ignore($toBeEditedUser->id)
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class WithdrawRejectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'note' => ['nullable', 'string', 'max:1000'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class BankCardStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'card_number' => ['required', 'digits:16'],
'holder_name' => ['required', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class CaseRejectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'reason' => ['required', 'string', 'max:1000'],
];
}
}
@@ -8,6 +8,7 @@ use App\Enums\DocumentModalityEnum;
use App\Enums\DocumentPriorityEnum; use App\Enums\DocumentPriorityEnum;
use App\Enums\PetGenderEnum; use App\Enums\PetGenderEnum;
use App\Enums\PetTypeEnum; use App\Enums\PetTypeEnum;
use App\Enums\UserEnum;
class FastRequestRequest extends FormRequest class FastRequestRequest extends FormRequest
{ {
@@ -22,7 +23,7 @@ class FastRequestRequest extends FormRequest
'modality' => ['required', 'string', Rule::in(DocumentModalityEnum::values())], 'modality' => ['required', 'string', Rule::in(DocumentModalityEnum::values())],
'priority' => ['required', 'string', Rule::in(DocumentPriorityEnum::keys())], 'priority' => ['required', 'string', Rule::in(DocumentPriorityEnum::keys())],
'note' => ['nullable', 'string', 'max:1000'], 'note' => ['nullable', 'string', 'max:1000'],
'pet_id' => ['nullable', 'exists:pets,id'], 'pet_id' => ['nullable', Rule::exists('pets', 'id')->where('owner_id', $this->user()->id)],
'pet' => ['required_without:pet_id', 'array'], 'pet' => ['required_without:pet_id', 'array'],
'pet.name' => ['required_without:pet_id', 'nullable', 'string', 'max:255'], 'pet.name' => ['required_without:pet_id', 'nullable', 'string', 'max:255'],
'pet.type' => ['required_without:pet_id', 'nullable', 'string', Rule::in(PetTypeEnum::keys())], 'pet.type' => ['required_without:pet_id', 'nullable', 'string', Rule::in(PetTypeEnum::keys())],
@@ -31,7 +32,7 @@ class FastRequestRequest extends FormRequest
'pet.age' => ['nullable', 'numeric', 'min:0', 'max:100'], 'pet.age' => ['nullable', 'numeric', 'min:0', 'max:100'],
'pet.weight' => ['nullable', 'numeric', 'min:0', 'max:1000'], 'pet.weight' => ['nullable', 'numeric', 'min:0', 'max:1000'],
'pet.sterilized' => ['nullable', 'boolean'], 'pet.sterilized' => ['nullable', 'boolean'],
'doctor_id' => ['nullable', 'exists:users,id'], 'doctor_id' => ['nullable', Rule::exists('users', 'id')->where('level', UserEnum::Doctor->value)],
'history_files' => ['nullable', 'array'], 'history_files' => ['nullable', 'array'],
'history_files.*' => ['file', 'image', 'max:10240'], 'history_files.*' => ['file', 'image', 'max:10240'],
'scan_files' => ['nullable', 'array'], 'scan_files' => ['nullable', 'array'],
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class PaymentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'invoice_id' => ['required', 'integer', 'exists:invoices,id'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class ProfileUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'fullname' => ['required', 'string', 'max:255'],
'birthdate' => ['nullable', 'date'],
'national_code' => ['required', 'string', 'max:20'],
'email' => ['nullable', 'email', 'max:255'],
'province' => ['nullable', 'string', 'max:255'],
'city' => ['nullable', 'string', 'max:255'],
'address' => ['nullable', 'string', 'max:500'],
'avatar' => ['nullable', 'image', 'mimes:png,jpg,jpeg', 'max:10240'],
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class ReportSubmitRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'document_id' => ['required', 'exists:documents,id'],
'technique' => ['nullable', 'string', 'max:10000'],
'finding' => ['required', 'string', 'max:10000'],
'diagnose' => ['nullable', 'string', 'max:10000'],
'recommendation' => ['nullable', 'string', 'max:10000'],
'files' => ['nullable', 'array'],
'files.*' => ['file', 'image', 'max:10240'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class WalletDepositRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:10000', 'max:500000000'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class WithdrawRequestStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:10000', 'max:500000000'],
'bank_card_id' => ['required', 'exists:bank_cards,id'],
];
}
}
+2 -2
View File
@@ -17,10 +17,10 @@ class ReportResource extends JsonResource
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'document' => $this->relationLoaded('document') ? $this->document->only(['id','code']) : null, 'document' => $this->relationLoaded('document') ? optional($this->document)->only(['id','code']) : null,
'status' => $this->status, 'status' => $this->status,
'body' => $this->body, 'body' => $this->body,
'doctor' => $this->relationLoaded('doctor') ? $this->doctor->only(['id','fullname']) : null, 'doctor' => $this->relationLoaded('doctor') ? optional($this->doctor)->only(['id','fullname']) : null,
'submitted_at' => verta($this->submitted_at)->format('%Y/%m/%d - H:i'), 'submitted_at' => verta($this->submitted_at)->format('%Y/%m/%d - H:i'),
'submitted_at_form' => $this->submitted_at, 'submitted_at_form' => $this->submitted_at,
]; ];
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Resources\Admin;
use App\Enums\ShiftDayEnum;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ShiftResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'user_id' => $this->user_id,
'doctor' => $this->relationLoaded('doctor') && $this->doctor ? $this->doctor->only(['id', 'fullname']) : null,
'day_of_week' => $this->day_of_week,
'day_index' => $this->day_of_week ? ShiftDayEnum::from($this->day_of_week)->carbonDay() : null,
'date' => $this->date ? verta($this->date)->format('%Y/%m/%d') : null,
'date_raw' => $this->date?->format('Y-m-d'),
'start_time' => substr($this->start_time, 0, 5),
'end_time' => substr($this->end_time, 0, 5),
'is_active' => $this->is_active,
'created_at' => verta($this->created_at)->format('%Y/%m/%d - H:i'),
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Resources\Admin;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WithdrawRequestResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'amount' => $this->amount,
'status' => $this->status,
'admin_note' => $this->admin_note,
'user' => $this->relationLoaded('user') ? $this->user->only(['id', 'fullname', 'level', 'phone']) : null,
'bank_card' => $this->relationLoaded('bankCard') && $this->bankCard
? $this->bankCard->only(['id', 'card_number', 'holder_name'])
: null,
'reviewed_by' => $this->relationLoaded('reviewedBy') && $this->reviewedBy ? $this->reviewedBy->only(['id', 'fullname']) : null,
'created_at' => verta($this->created_at)->format('%Y/%m/%d - H:i'),
'reviewed_at' => $this->reviewed_at ? verta($this->reviewed_at)->format('%Y/%m/%d - H:i') : null,
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BankCardResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'card_number' => $this->card_number,
'holder_name' => $this->holder_name,
];
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CaseDetailResource extends JsonResource
{
public function toArray(Request $request): array
{
$report = $this->reports->sortByDesc('submitted_at')->first();
return [
'id' => $this->id,
'code' => $this->code,
'status' => $this->status,
'modality' => $this->modality,
'priority' => $this->priority,
'note' => $this->note,
'rejection_reason' => $this->rejection_reason,
'submitted_at' => $this->submitted_at ? verta($this->submitted_at)->format('%Y/%m/%d - H:i') : null,
'processing_deadline' => $this->processing_deadline?->toIso8601String(),
'pet' => $this->whenLoaded('pet', fn () => [
'name' => $this->pet->name,
'type' => $this->pet->type,
'gender' => $this->pet->gender,
'breed' => $this->pet->breed,
'age' => $this->pet->birthday ? (int) $this->pet->birthday->diffInYears(now()) : null,
'weight' => $this->pet->weight,
'sterilized' => $this->pet->sterilized,
]),
'requester' => $this->whenLoaded('requester', fn () => [
'level' => $this->requester->level,
'fullname' => $this->requester->fullname,
'phone' => $this->requester->phone,
'specialty' => $this->requester->specialty,
'clinic_name' => $this->requester->clinic_name,
'clinic_phone' => $this->requester->clinic_phone,
'province' => $this->requester->province,
'city' => $this->requester->city,
]),
'primary_files' => $this->mediaFiles('primary'),
'history_files' => $this->mediaFiles('history'),
'report' => $report ? [
'id' => $report->id,
'status' => $report->status,
'technique' => $report->technique,
'finding' => $report->finding,
'diagnose' => $report->diagnose,
'recommendation' => $report->recommendation,
'submitted_at' => $report->submitted_at ? verta($report->submitted_at)->format('%Y/%m/%d - H:i') : null,
'attachments' => $report->attachments->map(fn ($medium) => $this->mediumPayload($medium))->values(),
] : null,
];
}
protected function mediaFiles(string $collection): array
{
return $this->media
->filter(fn ($medium) => $medium->pivot->collection === $collection)
->map(fn ($medium) => $this->mediumPayload($medium))
->values()
->all();
}
protected function mediumPayload($medium): array
{
return [
'id' => $medium->id,
'filename' => $medium->filename,
'url' => $medium->isPrivate() ? route('dashboard.media.download', $medium) : $medium->url,
'mime_type' => $medium->mime_type,
];
}
}
@@ -16,6 +16,7 @@ class FastRequestResource extends JsonResource
'status' => $this->status, 'status' => $this->status,
'priority' => $this->priority, 'priority' => $this->priority,
'pet' => new PetArchiveResource($this->whenLoaded('pet')), 'pet' => new PetArchiveResource($this->whenLoaded('pet')),
'invoice' => new InvoiceResource($this->whenLoaded('invoice')),
]; ];
} }
} }
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class InvoiceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'number' => $this->number,
'amount' => $this->amount,
'status' => $this->status,
'payment_method' => $this->payment_method,
'paid_at' => $this->paid_at,
'created_at' => $this->created_at,
'document' => $this->whenLoaded('document', fn () => [
'id' => $this->document->id,
'code' => $this->document->code,
'modality' => $this->document->modality,
]),
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PaymentResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'track_id' => $this->track_id,
'amount' => $this->amount,
'status' => $this->status,
'ref_number' => $this->ref_number,
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ReportResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'status' => $this->status,
'submitted_at' => $this->submitted_at ? verta($this->submitted_at)->format('%Y/%m/%d - H:i') : null,
'document' => $this->whenLoaded('document', fn () => [
'id' => $this->document->id,
'code' => $this->document->code,
'modality' => $this->document->modality,
'status' => $this->document->status,
]),
'doctor' => $this->whenLoaded('doctor', fn () => optional($this->doctor)->fullname),
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources\Client;
use App\Enums\ShiftDayEnum;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ShiftResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'day_of_week' => $this->day_of_week,
'day_index' => $this->day_of_week ? ShiftDayEnum::from($this->day_of_week)->carbonDay() : null,
'date' => $this->date ? verta($this->date)->format('%Y/%m/%d') : null,
'date_raw' => $this->date?->format('Y-m-d'),
'start_time' => substr($this->start_time, 0, 5),
'end_time' => substr($this->end_time, 0, 5),
'is_active' => $this->is_active,
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WalletTransactionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'type' => $this->type,
'reason' => $this->reason,
'amount' => $this->amount,
'balance_after' => $this->balance_after,
'description' => $this->description,
'via_gateway' => $this->payment_id !== null,
'invoice_number' => $this->whenLoaded('invoice', fn () => $this->invoice?->number),
'created_at' => verta($this->created_at)->format('%Y/%m/%d - H:i'),
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WithdrawRequestResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'amount' => $this->amount,
'status' => $this->status,
'admin_note' => $this->admin_note,
'bank_card' => $this->relationLoaded('bankCard') && $this->bankCard
? $this->bankCard->only(['id', 'card_number', 'holder_name'])
: null,
'created_at' => verta($this->created_at)->format('%Y/%m/%d - H:i'),
'reviewed_at' => $this->reviewed_at ? verta($this->reviewed_at)->format('%Y/%m/%d - H:i') : null,
];
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Jobs;
use App\Services\Sms\IranPayamakService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SendPatternSms implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public array $backoff = [60, 300, 900];
public function __construct(
protected string $phone,
protected string $patternKey,
protected array $attributes
) {
$this->afterCommit();
}
public function handle(IranPayamakService $iranPayamakService): void
{
$iranPayamakService->sendPattern($this->phone, $this->patternKey, $this->attributes);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class BankCard extends Model
{
use HasFactory;
protected $fillable = [
'card_number',
'holder_name',
'user_id',
];
protected $casts = [
'user_id' => 'integer',
];
public function user()
{
return $this->belongsTo(User::class);
}
}
+5
View File
@@ -56,6 +56,11 @@ class Document extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function invoice()
{
return $this->hasOne(Invoice::class);
}
public function assignments() public function assignments()
{ {
return $this->hasMany(DocumentAssignment::class); return $this->hasMany(DocumentAssignment::class);
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Invoice extends Model
{
use HasFactory;
protected $fillable = [
'number',
'amount',
'status',
'payment_method',
'paid_at',
'document_id',
'user_id',
];
protected $casts = [
'status' => InvoiceStatusEnum::class,
'payment_method' => InvoicePaymentMethodEnum::class,
'amount' => 'integer',
'paid_at' => 'datetime',
'document_id' => 'integer',
'user_id' => 'integer',
];
public function document()
{
return $this->belongsTo(Document::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function payments()
{
return $this->hasMany(Payment::class);
}
}
+3
View File
@@ -2,10 +2,13 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class OtpCode extends Model class OtpCode extends Model
{ {
use HasFactory;
protected $fillable = [ protected $fillable = [
'phone', 'phone',
'code', 'code',
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use App\Enums\PaymentPurposeEnum;
use App\Enums\PaymentStatusEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Payment extends Model
{
use HasFactory;
protected $fillable = [
'track_id',
'amount',
'status',
'purpose',
'ref_number',
'verified_at',
'invoice_id',
'payer_id',
];
protected $casts = [
'status' => PaymentStatusEnum::class,
'purpose' => PaymentPurposeEnum::class,
'amount' => 'integer',
'verified_at' => 'datetime',
'invoice_id' => 'integer',
'payer_id' => 'integer',
];
public function invoice()
{
return $this->belongsTo(Invoice::class);
}
public function payer()
{
return $this->belongsTo(User::class, 'payer_id');
}
}
+5 -1
View File
@@ -17,6 +17,10 @@ class Report extends Model
'medium_id', 'medium_id',
'status', 'status',
'body', 'body',
'technique',
'finding',
'diagnose',
'recommendation',
'submitted_at', 'submitted_at',
]; ];
@@ -43,7 +47,7 @@ class Report extends Model
'media_assignments', 'media_assignments',
'mediable_id', 'mediable_id',
'media_id' 'media_id'
)->withPivot('collection')->withTimestamps(); )->withPivot('collection');
} }
public function medium() public function medium()
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Shift extends Model
{
use HasFactory;
protected $fillable = [
'day_of_week',
'date',
'start_time',
'end_time',
'is_active',
'user_id',
'created_by',
];
protected $casts = [
'date' => 'date',
'is_active' => 'boolean',
'user_id' => 'integer',
'created_by' => 'integer',
];
public function doctor()
{
return $this->belongsTo(User::class, 'user_id');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeOpen($query)
{
return $query->whereNull('user_id');
}
}
+27
View File
@@ -22,6 +22,7 @@ class User extends Authenticatable
protected $fillable = [ protected $fillable = [
'username', 'username',
'fullname', 'fullname',
'birthdate',
'national_code', 'national_code',
'level', 'level',
'email', 'email',
@@ -56,6 +57,7 @@ class User extends Authenticatable
{ {
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'birthdate' => 'date:Y-m-d',
'password' => 'hashed', 'password' => 'hashed',
]; ];
} }
@@ -71,4 +73,29 @@ class User extends Authenticatable
{ {
return $this->hasMany(Pet::class, 'owner_id'); return $this->hasMany(Pet::class, 'owner_id');
} }
public function shifts()
{
return $this->hasMany(Shift::class);
}
public function wallet()
{
return $this->hasOne(Wallet::class);
}
public function invoices()
{
return $this->hasMany(Invoice::class);
}
public function bankCards()
{
return $this->hasMany(BankCard::class);
}
public function withdrawRequests()
{
return $this->hasMany(WithdrawRequest::class);
}
} }
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Wallet extends Model
{
use HasFactory;
protected $fillable = [
'balance',
'user_id',
];
protected $casts = [
'balance' => 'integer',
'user_id' => 'integer',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function transactions()
{
return $this->hasMany(WalletTransaction::class);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Enums\WalletTransactionReasonEnum;
use App\Enums\WalletTransactionTypeEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class WalletTransaction extends Model
{
use HasFactory;
protected $fillable = [
'type',
'reason',
'amount',
'balance_after',
'description',
'wallet_id',
'invoice_id',
'payment_id',
'withdraw_request_id',
];
protected $casts = [
'type' => WalletTransactionTypeEnum::class,
'reason' => WalletTransactionReasonEnum::class,
'amount' => 'integer',
'balance_after' => 'integer',
'wallet_id' => 'integer',
'invoice_id' => 'integer',
'payment_id' => 'integer',
'withdraw_request_id' => 'integer',
];
public function wallet()
{
return $this->belongsTo(Wallet::class);
}
public function invoice()
{
return $this->belongsTo(Invoice::class);
}
public function payment()
{
return $this->belongsTo(Payment::class);
}
public function withdrawRequest()
{
return $this->belongsTo(WithdrawRequest::class);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Enums\WithdrawRequestStatusEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class WithdrawRequest extends Model
{
use HasFactory;
protected $fillable = [
'amount',
'status',
'admin_note',
'reviewed_at',
'reviewed_by',
'user_id',
'bank_card_id',
];
protected $casts = [
'status' => WithdrawRequestStatusEnum::class,
'amount' => 'integer',
'reviewed_at' => 'datetime',
'reviewed_by' => 'integer',
'user_id' => 'integer',
'bank_card_id' => 'integer',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function bankCard()
{
return $this->belongsTo(BankCard::class);
}
public function reviewedBy()
{
return $this->belongsTo(User::class, 'reviewed_by');
}
}
+2
View File
@@ -45,6 +45,7 @@ class AppServiceProvider extends ServiceProvider
return [ return [
'User' => $user, 'User' => $user,
'permissions' => $permissions, 'permissions' => $permissions,
'wallet_balance' => (int) ($user?->wallet?->balance ?? 0),
]; ];
}, },
]); ]);
@@ -52,6 +53,7 @@ class AppServiceProvider extends ServiceProvider
return [ return [
'success' => Session::get('success'), 'success' => Session::get('success'),
'error' => Session::get('error'), 'error' => Session::get('error'),
'payment_result' => Session::get('payment_result'),
]; ];
}); });
} }
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Providers;
use App\Enums\UserEnum;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
protected function gate(): void
{
Gate::define('viewHorizon', function ($user = null) {
return $user?->level === UserEnum::Admin->value;
});
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ class AuthService
Auth::login($user); Auth::login($user);
session()->regenerate(); session()->regenerate();
session()->forget('register_phone' , 'verified_phone' , 'otp_verified' , 'login_phone' , 'login_level' , 'available_levels'); session()->forget(['register_phone', 'verified_phone', 'otp_verified', 'login_phone', 'login_level', 'available_levels']);
return $user; return $user;
} }
+11 -41
View File
@@ -3,10 +3,17 @@
namespace App\Services\Auth; namespace App\Services\Auth;
use App\Models\OtpCode; use App\Models\OtpCode;
use Illuminate\Support\Facades\Http; use App\Services\Sms\IranPayamakService;
class OtpService class OtpService
{ {
protected IranPayamakService $iranPayamakService;
public function __construct(IranPayamakService $iranPayamakService)
{
$this->iranPayamakService = $iranPayamakService;
}
public function generateCode(): string public function generateCode(): string
{ {
return (string) random_int(1000, 9999); return (string) random_int(1000, 9999);
@@ -45,51 +52,14 @@ class OtpService
->exists(); ->exists();
} }
private function sendSms(string $phone, string $code): array private function sendSms(string $phone, string $code): void
{ {
try { try {
$formattedRecipient = $this->formatPhoneForSms($phone); $this->iranPayamakService->sendPattern($phone, 'verify_code', [
$response = Http::withHeaders([
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'Api-Key' => config('services.iranpayamak.api_key'),
])
->post(config('services.iranpayamak.api_url'), [
'code' => config('services.iranpayamak.patterns.verify_code'),
'attributes' => [
'otp' => $code, 'otp' => $code,
'status' => 'بازنشانی رمز عبور' 'status' => 'بازنشانی رمز عبور',
],
'recipient' => $formattedRecipient,
'line_number' => config('services.iranpayamak.line_number'),
'number_format' => 'english',
]); ]);
if ($response->successful()) {
return $response->json();
} else {
return [
'status' => 'error',
'message' => 'SMS sending failed: ' . $response->status(),
'data' => $response->body()
];
}
} catch (\Exception $e) { } catch (\Exception $e) {
return [
'status' => 'error',
'message' => 'SMS sending exception: ' . $e->getMessage(),
'data' => null
];
} }
} }
private function formatPhoneForSms(string $phone): string
{
if (str_starts_with($phone, '+98')) {
return '0' . substr($phone, 3);
}
return $phone;
}
} }
+1 -1
View File
@@ -40,7 +40,7 @@ class RegistrationService
Auth::login($user); Auth::login($user);
session()->regenerate(); session()->regenerate();
session()->forget('register_phone' , 'verified_phone' , 'otp_verified' , 'login_phone' , 'login_level' , 'available_levels'); session()->forget(['register_phone', 'verified_phone', 'otp_verified', 'login_phone', 'login_level', 'available_levels']);
return $user; return $user;
} }
+168 -10
View File
@@ -3,25 +3,183 @@
namespace App\Services\Document; namespace App\Services\Document;
use App\Enums\DocumentStatusEnum; use App\Enums\DocumentStatusEnum;
use App\Enums\InvoiceStatusEnum;
use App\Enums\MediaCollectionEnum;
use App\Enums\ReportStatusEnum;
use App\Enums\UserEnum;
use App\Enums\WalletTransactionReasonEnum;
use App\Models\Document; use App\Models\Document;
use App\Models\Medium;
use App\Models\Report;
use App\Models\User; use App\Models\User;
use App\Services\FileStorageService;
use App\Services\Shift\ShiftScheduleService;
use App\Services\Sms\SmsNotificationService;
use App\Services\Wallet\WalletService;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CaseService class CaseService
{ {
protected FileStorageService $fileStorageService;
protected WalletService $walletService;
protected ShiftScheduleService $shiftScheduleService;
protected SmsNotificationService $smsNotificationService;
public function __construct(FileStorageService $fileStorageService, WalletService $walletService, ShiftScheduleService $shiftScheduleService, SmsNotificationService $smsNotificationService)
{
$this->fileStorageService = $fileStorageService;
$this->walletService = $walletService;
$this->shiftScheduleService = $shiftScheduleService;
$this->smsNotificationService = $smsNotificationService;
}
public function pendingForUser(User $user): Builder public function pendingForUser(User $user): Builder
{ {
$statuses = $this->isDoctor($user)
? [DocumentStatusEnum::InProgress->value]
: [DocumentStatusEnum::Registered->value, DocumentStatusEnum::InProgress->value];
return Document::with(['pet', 'requester']) return Document::with(['pet', 'requester'])
->whereIn('status', [ ->whereIn('status', $statuses)
DocumentStatusEnum::Registered->value, ->where(fn ($query) => $this->scopeVisibleTo($query, $user))
DocumentStatusEnum::InProgress->value,
])
->where(function ($query) use ($user) {
$query->where('requester_id', $user->id)
->orWhereHas('assignments', function ($assignmentQuery) use ($user) {
$assignmentQuery->where('user_id', $user->id);
});
})
->latest('submitted_at'); ->latest('submitted_at');
} }
public function canAccess(Document $document, User $user): bool
{
return $document->requester_id === $user->id
|| $document->assignments()->where('user_id', $user->id)->exists();
}
public function canAccessMedium(Medium $medium, User $user): bool
{
$documents = $medium->documents()->get()
->merge($medium->reports()->with('document')->get()->pluck('document'));
return $documents->contains(fn ($document) => $this->canAccess($document, $user));
}
public function canReport(Document $document, User $user): bool
{
return $this->isDoctor($user)
&& $document->status === DocumentStatusEnum::InProgress
&& $document->assignments()->where('user_id', $user->id)->exists();
}
public function submitReport(Document $document, User $doctor, array $data, array $files = []): Report
{
return DB::transaction(function () use ($document, $doctor, $data, $files) {
if (!$this->canReport($document, $doctor)) {
throw ValidationException::withMessages(['document' => 'امکان ثبت گزارش برای این پرونده وجود ندارد.']);
}
$report = $document->reports()->create([
'doctor_id' => $doctor->id,
'status' => ReportStatusEnum::Approved->value,
'technique' => $data['technique'] ?? null,
'finding' => $data['finding'] ?? null,
'diagnose' => $data['diagnose'] ?? null,
'recommendation' => $data['recommendation'] ?? null,
'submitted_at' => now(),
]);
foreach ($files as $file) {
$medium = $this->fileStorageService->storeFile($file);
$report->media()->attach($medium->id, ['collection' => MediaCollectionEnum::Attachments->value]);
}
$document->update([
'status' => DocumentStatusEnum::Completed->value,
'finalized_at' => now(),
'finalized_by' => $doctor->id,
]);
$this->smsNotificationService->reportReady($document);
return $report;
});
}
public function reject(Document $document, User $doctor, string $reason): Document
{
return DB::transaction(function () use ($document, $doctor, $reason) {
if (!$this->canReport($document, $doctor)) {
throw ValidationException::withMessages(['document' => 'امکان رد این پرونده وجود ندارد.']);
}
$document->update([
'status' => DocumentStatusEnum::Rejected->value,
'finalized_at' => now(),
'finalized_by' => $doctor->id,
'rejection_reason' => $reason,
]);
$invoice = $document->invoice;
if ($invoice && $invoice->status === InvoiceStatusEnum::Paid) {
$invoice->update(['status' => InvoiceStatusEnum::Canceled->value]);
$this->walletService->deposit($document->requester, $invoice->amount, WalletTransactionReasonEnum::Refund);
}
return $document;
});
}
public function statsForUser(User $user): array
{
$monthStart = now()->startOfMonth();
$isDoctor = $this->isDoctor($user);
$reviewedQuery = Document::where('status', DocumentStatusEnum::Completed->value)
->where('finalized_at', '>=', $monthStart);
if ($isDoctor) {
$reviewedQuery->where('finalized_by', $user->id);
} else {
$reviewedQuery->where('requester_id', $user->id);
}
$amountQuery = $isDoctor
? Document::where('documents.finalized_by', $user->id)
->where('documents.status', DocumentStatusEnum::Completed->value)
->where('documents.finalized_at', '>=', $monthStart)
->join('invoices', 'invoices.document_id', '=', 'documents.id')
->where('invoices.status', InvoiceStatusEnum::Paid->value)
: Document::where('documents.requester_id', $user->id)
->join('invoices', 'invoices.document_id', '=', 'documents.id')
->where('invoices.status', InvoiceStatusEnum::Paid->value)
->where('invoices.paid_at', '>=', $monthStart);
$nextShift = $isDoctor ? $this->shiftScheduleService->nextShiftStart($user, now()) : null;
$onShift = $isDoctor && $this->shiftScheduleService->isOnShift($user, now());
return [
'pending_cases' => $this->pendingForUser($user)->count(),
'monthly_reviewed' => $reviewedQuery->count(),
'monthly_amount' => (int) $amountQuery->sum('invoices.amount'),
'next_shift_hours' => $onShift ? 0 : ($nextShift ? (int) ceil(now()->diffInMinutes($nextShift, true) / 60) : null),
'is_doctor' => $isDoctor,
];
}
protected function scopeVisibleTo($query, User $user): void
{
if ($this->isDoctor($user)) {
$query->whereHas('assignments', fn ($assignmentQuery) => $assignmentQuery->where('user_id', $user->id));
return;
}
$query->where('requester_id', $user->id)
->orWhereHas('assignments', fn ($assignmentQuery) => $assignmentQuery->where('user_id', $user->id));
}
public function isDoctor(User $user): bool
{
return $user->level === UserEnum::Doctor->value;
}
} }
+23 -4
View File
@@ -10,15 +10,27 @@ use App\Models\Document;
use App\Models\Pet; use App\Models\Pet;
use App\Models\User; use App\Models\User;
use App\Services\FileStorageService; use App\Services\FileStorageService;
use App\Services\Invoice\InvoiceService;
use App\Services\Shift\ShiftScheduleService;
use App\Services\Sms\SmsNotificationService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class FastRequestService class FastRequestService
{ {
protected FileStorageService $fileStorageService; protected FileStorageService $fileStorageService;
public function __construct(FileStorageService $fileStorageService) protected ShiftScheduleService $shiftScheduleService;
protected InvoiceService $invoiceService;
protected SmsNotificationService $smsNotificationService;
public function __construct(FileStorageService $fileStorageService, ShiftScheduleService $shiftScheduleService, InvoiceService $invoiceService, SmsNotificationService $smsNotificationService)
{ {
$this->fileStorageService = $fileStorageService; $this->fileStorageService = $fileStorageService;
$this->shiftScheduleService = $shiftScheduleService;
$this->invoiceService = $invoiceService;
$this->smsNotificationService = $smsNotificationService;
} }
public function create(User $requester, array $data, array $historyFiles = [], array $scanFiles = []): Document public function create(User $requester, array $data, array $historyFiles = [], array $scanFiles = []): Document
@@ -35,16 +47,23 @@ class FastRequestService
'requester_id' => $requester->id, 'requester_id' => $requester->id,
]); ]);
$this->invoiceService->createForDocument($document);
$this->attachFiles($document, $historyFiles, MediaCollectionEnum::History); $this->attachFiles($document, $historyFiles, MediaCollectionEnum::History);
$this->attachFiles($document, $scanFiles, MediaCollectionEnum::Primary); $this->attachFiles($document, $scanFiles, MediaCollectionEnum::Primary);
if (!empty($data['doctor_id'])) { $doctorId = $data['doctor_id']
?? $this->shiftScheduleService->autoSelectDoctor(now())?->id;
if (!empty($doctorId)) {
$document->assignments()->create([ $document->assignments()->create([
'user_id' => $data['doctor_id'], 'user_id' => $doctorId,
'role' => 'رادیولوژیست', 'role' => 'رادیولوژیست',
]); ]);
} }
$this->smsNotificationService->requestRegistered($document);
return $document; return $document;
}); });
} }
@@ -52,7 +71,7 @@ class FastRequestService
protected function resolvePet(User $requester, array $data): Pet protected function resolvePet(User $requester, array $data): Pet
{ {
if (!empty($data['pet_id'])) { if (!empty($data['pet_id'])) {
return Pet::findOrFail($data['pet_id']); return $requester->pets()->findOrFail($data['pet_id']);
} }
$pet = $data['pet']; $pet = $data['pet'];
+1 -1
View File
@@ -49,7 +49,7 @@ class FileStorageService
$new = Storage::disk($newDisk); $new = Storage::disk($newDisk);
$stream = $old->readStream($medium->path); $stream = $old->readStream($medium->path);
if ($stream === false) return false; if (! is_resource($stream)) return false;
$new->put($medium->path, $stream); $new->put($medium->path, $stream);
if (is_resource($stream)) fclose($stream); if (is_resource($stream)) fclose($stream);
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Services\Invoice;
use App\Enums\DocumentModalityEnum;
use App\Enums\DocumentStatusEnum;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use App\Models\Document;
use App\Models\Invoice;
use App\Services\Sms\SmsNotificationService;
class InvoiceService
{
protected SmsNotificationService $smsNotificationService;
public function __construct(SmsNotificationService $smsNotificationService)
{
$this->smsNotificationService = $smsNotificationService;
}
public function createForDocument(Document $document): Invoice
{
$invoice = Invoice::create([
'number' => 'pending',
'amount' => DocumentModalityEnum::from($document->modality)->price(),
'status' => InvoiceStatusEnum::Pending->value,
'document_id' => $document->id,
'user_id' => $document->requester_id,
]);
$invoice->update(['number' => str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT)]);
return $invoice;
}
public function markPaid(Invoice $invoice, InvoicePaymentMethodEnum $method): Invoice
{
$invoice->update([
'status' => InvoiceStatusEnum::Paid->value,
'payment_method' => $method->value,
'paid_at' => now(),
]);
$document = $invoice->document;
if ($document && $document->status === DocumentStatusEnum::Registered) {
$document->update([
'status' => DocumentStatusEnum::InProgress->value,
'processing_deadline' => now()->addHours($document->priority->processingHours()),
]);
}
$this->smsNotificationService->paymentConfirmed($invoice);
return $invoice;
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Services\Payment;
use App\Enums\PaymentStatusEnum;
use App\Models\Payment;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class ZibalPaymentService
{
protected const API_URL = 'https://gateway.zibal.ir/v1';
protected const START_URL = 'https://gateway.zibal.ir/start';
public function request(User $payer, int $amount, string $orderId, array $attributes = []): Payment
{
$response = Http::post(self::API_URL . '/request', [
'merchant' => $this->merchant(),
'amount' => $amount * 10,
'callbackUrl' => $this->callbackUrl(),
'orderId' => $orderId,
])->json();
if (($response['result'] ?? null) !== 100) {
throw new RuntimeException('خطا در ایجاد درخواست پرداخت: ' . ($response['message'] ?? 'پاسخ نامعتبر از درگاه'));
}
return Payment::create(array_merge([
'track_id' => (string) $response['trackId'],
'amount' => $amount,
'status' => PaymentStatusEnum::Pending->value,
'payer_id' => $payer->id,
], $attributes));
}
public function paymentUrl(Payment $payment): string
{
return self::START_URL . '/' . $payment->track_id;
}
public function verify(Payment $payment): Payment
{
if ($payment->status !== PaymentStatusEnum::Pending) {
return $payment;
}
$response = Http::post(self::API_URL . '/verify', [
'merchant' => $this->merchant(),
'trackId' => $payment->track_id,
])->json();
if (in_array($response['result'] ?? null, [100, 201], true)) {
$payment->update([
'status' => PaymentStatusEnum::Paid->value,
'ref_number' => $response['refNumber'] ?? null,
'verified_at' => now(),
]);
} else {
$payment->update(['status' => PaymentStatusEnum::Failed->value]);
}
return $payment;
}
public function markFailed(Payment $payment): Payment
{
if ($payment->status === PaymentStatusEnum::Pending) {
$payment->update(['status' => PaymentStatusEnum::Failed->value]);
}
return $payment;
}
protected function merchant(): string
{
return config('services.zibal.sandbox') ? 'zibal' : config('services.zibal.merchant');
}
protected function callbackUrl(): string
{
return config('services.zibal.callback_url') ?: route('dashboard.payments.callback');
}
}
+246
View File
@@ -0,0 +1,246 @@
<?php
namespace App\Services\Shift;
use App\Enums\DocumentStatusEnum;
use App\Enums\ShiftDayEnum;
use App\Enums\UserEnum;
use App\Models\Shift;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ShiftScheduleService
{
public function shiftsCoveringMoment(Carbon $moment): Collection
{
$minutes = $moment->hour * 60 + $moment->minute;
$today = ShiftDayEnum::fromDate($moment)->value;
$yesterday = ShiftDayEnum::fromDate($moment->copy()->subDay())->value;
return Shift::active()
->where(function ($query) use ($moment, $today, $yesterday) {
$query->whereDate('date', $moment->toDateString())
->orWhereDate('date', $moment->copy()->subDay()->toDateString())
->orWhere(function ($subQuery) use ($today, $yesterday) {
$subQuery->whereNull('date')->whereIn('day_of_week', [$today, $yesterday]);
});
})
->get()
->filter(function (Shift $shift) use ($moment, $minutes) {
$start = $this->toMinutes($shift->start_time);
$end = $this->toMinutes($shift->end_time);
if ($end > $start) {
return $this->coversDate($shift, $moment) && $minutes >= $start && $minutes < $end;
}
// overnight shift: covers [start, 24:00) on its day and [00:00, end) on the next day
if ($this->coversDate($shift, $moment) && $minutes >= $start) {
return true;
}
return $this->coversDate($shift, $moment->copy()->subDay()) && $minutes < $end;
})
->values();
}
public function availableDoctorsAt(Carbon $moment): Collection
{
$doctorIds = $this->shiftsCoveringMoment($moment)->pluck('user_id')->filter()->unique();
if ($doctorIds->isEmpty()) {
return collect();
}
return User::where('level', UserEnum::Doctor->value)
->whereIn('id', $doctorIds)
->get();
}
public function isOnShift(User $doctor, Carbon $moment): bool
{
return $this->shiftsCoveringMoment($moment)->contains('user_id', $doctor->id);
}
public function autoSelectDoctor(Carbon $moment): ?User
{
$doctorIds = $this->shiftsCoveringMoment($moment)->pluck('user_id')->filter()->unique();
if ($doctorIds->isEmpty()) {
return $this->doctorWithNearestShift($moment);
}
return User::where('level', UserEnum::Doctor->value)
->whereIn('id', $doctorIds)
->withCount(['documents as open_documents_count' => function ($query) {
$query->whereIn('status', [
DocumentStatusEnum::Registered->value,
DocumentStatusEnum::InProgress->value,
]);
}])
->orderBy('open_documents_count')
->orderBy('id')
->first();
}
// fallback when nobody is on shift: the doctor whose next shift starts soonest
public function doctorWithNearestShift(Carbon $from): ?User
{
$doctorIds = Shift::active()->whereNotNull('user_id')->distinct()->pluck('user_id');
return User::where('level', UserEnum::Doctor->value)
->whereIn('id', $doctorIds)
->get()
->map(fn (User $doctor) => ['doctor' => $doctor, 'start' => $this->nextShiftStart($doctor, $from)])
->filter(fn (array $item) => $item['start'] !== null)
->sortBy('start')
->first()['doctor'] ?? null;
}
public function nextShiftStart(User $doctor, Carbon $from): ?Carbon
{
$shifts = Shift::active()->where('user_id', $doctor->id)->get();
return $shifts
->map(function (Shift $shift) use ($from) {
for ($offset = 0; $offset <= 7; $offset++) {
$day = $from->copy()->addDays($offset)->startOfDay();
if (!$this->coversDate($shift, $day)) {
continue;
}
$start = $day->copy()->addMinutes($this->toMinutes($shift->start_time));
if ($start->greaterThanOrEqualTo($from)) {
return $start;
}
}
return null;
})
->filter()
->sort()
->first();
}
public function hasConflict(array $data, ?int $ignoreId = null): bool
{
if (empty($data['user_id'])) {
return false;
}
$candidate = new Shift($data);
return Shift::active()
->where('user_id', $data['user_id'])
->when($ignoreId, fn ($query) => $query->where('id', '!=', $ignoreId))
->get()
->contains(fn (Shift $existing) => $this->overlaps($existing, $candidate));
}
public function claim(int $shiftId, User $doctor): Shift
{
return DB::transaction(function () use ($shiftId, $doctor) {
$shift = Shift::whereKey($shiftId)->lockForUpdate()->firstOrFail();
if (!$shift->is_active || $shift->user_id !== null) {
throw ValidationException::withMessages([
'shift' => 'این شیفت قابل انتخاب نیست.',
]);
}
$data = array_merge(
$shift->only(['day_of_week', 'date', 'start_time', 'end_time']),
['user_id' => $doctor->id]
);
if ($this->hasConflict($data, $shift->id)) {
throw ValidationException::withMessages([
'shift' => 'این شیفت با برنامه فعلی شما تداخل دارد.',
]);
}
$shift->update(['user_id' => $doctor->id]);
return $shift;
});
}
public function release(Shift $shift, User $doctor): Shift
{
if ($shift->user_id !== $doctor->id) {
throw ValidationException::withMessages([
'shift' => 'شما مجاز به تغییر این شیفت نیستید.',
]);
}
$shift->update(['user_id' => null]);
return $shift;
}
public function overlaps(Shift $first, Shift $second): bool
{
$byWeekday = !$first->date || !$second->date;
foreach ($this->daySegments($first, $byWeekday) as [$dayA, $startA, $endA]) {
foreach ($this->daySegments($second, $byWeekday) as [$dayB, $startB, $endB]) {
if ($dayA === $dayB && $startA < $endB && $startB < $endA) {
return true;
}
}
}
return false;
}
protected function coversDate(Shift $shift, Carbon $date): bool
{
if ($shift->date) {
return $shift->date->isSameDay($date);
}
return $shift->day_of_week === ShiftDayEnum::fromDate($date)->value;
}
protected function daySegments(Shift $shift, bool $byWeekday): array
{
$start = $this->toMinutes($shift->start_time);
$end = $this->toMinutes($shift->end_time);
if ($shift->date) {
$day = $byWeekday ? ShiftDayEnum::fromDate($shift->date)->value : $shift->date->toDateString();
$nextDay = $byWeekday
? ShiftDayEnum::fromDate($shift->date->copy()->addDay())->value
: $shift->date->copy()->addDay()->toDateString();
} else {
$day = $shift->day_of_week;
$nextDay = $this->nextDay($shift->day_of_week);
}
if ($end > $start) {
return [[$day, $start, $end]];
}
return [[$day, $start, 1440], [$nextDay, 0, $end]];
}
protected function nextDay(string $day): string
{
$days = ShiftDayEnum::values();
$index = array_search($day, $days);
return $days[($index + 1) % count($days)];
}
protected function toMinutes(string $time): int
{
[$hours, $minutes] = explode(':', $time);
return ((int) $hours) * 60 + ((int) $minutes);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Services\Sms;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class IranPayamakService
{
public function sendPattern(string $phone, string $patternKey, array $attributes): array
{
$response = Http::withHeaders([
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'Api-Key' => config('services.iranpayamak.api_key'),
])->post(config('services.iranpayamak.api_url'), [
'code' => config("services.iranpayamak.patterns.{$patternKey}"),
'attributes' => $attributes,
'recipient' => $this->formatRecipient($phone),
'line_number' => config('services.iranpayamak.line_number'),
'number_format' => 'english',
]);
if (!$response->successful()) {
throw new RuntimeException('SMS sending failed: ' . $response->status() . ' ' . $response->body());
}
return $response->json() ?? [];
}
protected function formatRecipient(string $phone): string
{
if (str_starts_with($phone, '+98')) {
return '0' . substr($phone, 3);
}
return $phone;
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Services\Sms;
use App\Jobs\SendPatternSms;
use App\Models\Document;
use App\Models\Invoice;
use App\Models\User;
class SmsNotificationService
{
public function requestRegistered(Document $document): void
{
$this->dispatch($document->requester, 'request_registered', [
'code' => $document->code,
]);
}
public function paymentConfirmed(Invoice $invoice): void
{
$this->dispatch($invoice->user, 'payment_confirmed', [
'invoice' => $invoice->number,
'amount' => number_format($invoice->amount),
]);
}
public function reportReady(Document $document): void
{
$this->dispatch($document->requester, 'report_ready', [
'code' => $document->code,
]);
}
protected function dispatch(?User $user, string $patternKey, array $attributes): void
{
if (empty($user?->phone) || empty(config("services.iranpayamak.patterns.{$patternKey}"))) {
return;
}
SendPatternSms::dispatch($user->phone, $patternKey, $attributes);
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace App\Services\Wallet;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use App\Enums\WalletTransactionReasonEnum;
use App\Enums\WalletTransactionTypeEnum;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\User;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use App\Models\WithdrawRequest;
use App\Services\Invoice\InvoiceService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class WalletService
{
protected InvoiceService $invoiceService;
public function __construct(InvoiceService $invoiceService)
{
$this->invoiceService = $invoiceService;
}
public function forUser(User $user): Wallet
{
return Wallet::firstOrCreate(['user_id' => $user->id], ['balance' => 0]);
}
public function system(): Wallet
{
return Wallet::firstOrCreate(['user_id' => null], ['balance' => 0]);
}
public function deposit(User $user, int $amount, WalletTransactionReasonEnum $reason, ?Payment $payment = null): WalletTransaction
{
return $this->depositToWallet($this->forUser($user), $amount, $reason, $payment);
}
public function depositToWallet(Wallet $wallet, int $amount, WalletTransactionReasonEnum $reason, ?Payment $payment = null, ?WithdrawRequest $withdrawRequest = null): WalletTransaction
{
return DB::transaction(function () use ($wallet, $amount, $reason, $payment, $withdrawRequest) {
$wallet = $this->lockedWallet($wallet);
$wallet->update(['balance' => $wallet->balance + $amount]);
return $wallet->transactions()->create([
'type' => WalletTransactionTypeEnum::Deposit->value,
'reason' => $reason->value,
'amount' => $amount,
'balance_after' => $wallet->balance,
'payment_id' => $payment?->id,
'withdraw_request_id' => $withdrawRequest?->id,
]);
});
}
public function withdraw(User $user, int $amount, WalletTransactionReasonEnum $reason, ?Invoice $invoice = null, ?WithdrawRequest $withdrawRequest = null): WalletTransaction
{
return DB::transaction(function () use ($user, $amount, $reason, $invoice, $withdrawRequest) {
$wallet = $this->lockedWallet($this->forUser($user));
if ($wallet->balance < $amount) {
throw ValidationException::withMessages(['amount' => 'موجودی کیف پول کافی نیست']);
}
$wallet->update(['balance' => $wallet->balance - $amount]);
return $wallet->transactions()->create([
'type' => WalletTransactionTypeEnum::Withdrawal->value,
'reason' => $reason->value,
'amount' => $amount,
'balance_after' => $wallet->balance,
'invoice_id' => $invoice?->id,
'withdraw_request_id' => $withdrawRequest?->id,
]);
});
}
public function payInvoice(User $user, Invoice $invoice): Invoice
{
return DB::transaction(function () use ($user, $invoice) {
if ($invoice->status !== InvoiceStatusEnum::Pending) {
throw ValidationException::withMessages(['invoice' => 'این فاکتور قابل پرداخت نیست']);
}
$this->withdraw($user, $invoice->amount, WalletTransactionReasonEnum::InvoicePayment, $invoice);
return $this->invoiceService->markPaid($invoice, InvoicePaymentMethodEnum::Wallet);
});
}
public function recordGatewayInvoicePayment(Invoice $invoice, Payment $payment): WalletTransaction
{
$wallet = $this->forUser($invoice->user);
return $wallet->transactions()->create([
'type' => WalletTransactionTypeEnum::Withdrawal->value,
'reason' => WalletTransactionReasonEnum::InvoicePayment->value,
'amount' => $invoice->amount,
'balance_after' => $wallet->balance,
'invoice_id' => $invoice->id,
'payment_id' => $payment->id,
]);
}
protected function lockedWallet(Wallet $wallet): Wallet
{
return Wallet::whereKey($wallet->id)->lockForUpdate()->first();
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Services\Wallet;
use App\Enums\WalletTransactionReasonEnum;
use App\Enums\WithdrawRequestStatusEnum;
use App\Models\BankCard;
use App\Models\User;
use App\Models\WithdrawRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class WithdrawRequestService
{
protected WalletService $walletService;
public function __construct(WalletService $walletService)
{
$this->walletService = $walletService;
}
public function request(User $user, int $amount, BankCard $bankCard): WithdrawRequest
{
if ($bankCard->user_id !== $user->id) {
throw ValidationException::withMessages(['bank_card_id' => 'کارت بانکی انتخاب‌شده معتبر نیست.']);
}
$balance = $this->walletService->forUser($user)->balance;
$pendingAmount = WithdrawRequest::where('user_id', $user->id)
->where('status', WithdrawRequestStatusEnum::Pending->value)
->sum('amount');
if ($amount + $pendingAmount > $balance) {
throw ValidationException::withMessages(['amount' => 'مجموع مبلغ درخواست‌های برداشت از موجودی کیف پول شما بیشتر است.']);
}
return WithdrawRequest::create([
'amount' => $amount,
'status' => WithdrawRequestStatusEnum::Pending->value,
'user_id' => $user->id,
'bank_card_id' => $bankCard->id,
]);
}
public function approve(WithdrawRequest $withdrawRequest, User $admin): WithdrawRequest
{
return DB::transaction(function () use ($withdrawRequest, $admin) {
$this->ensurePending($withdrawRequest);
$this->walletService->withdraw(
$withdrawRequest->user,
$withdrawRequest->amount,
WalletTransactionReasonEnum::Withdraw,
null,
$withdrawRequest
);
$this->walletService->depositToWallet(
$this->walletService->system(),
$withdrawRequest->amount,
WalletTransactionReasonEnum::Withdraw,
null,
$withdrawRequest
);
$withdrawRequest->update([
'status' => WithdrawRequestStatusEnum::Approved->value,
'reviewed_at' => now(),
'reviewed_by' => $admin->id,
]);
return $withdrawRequest;
});
}
public function reject(WithdrawRequest $withdrawRequest, User $admin, ?string $note = null): WithdrawRequest
{
$this->ensurePending($withdrawRequest);
$withdrawRequest->update([
'status' => WithdrawRequestStatusEnum::Rejected->value,
'admin_note' => $note,
'reviewed_at' => now(),
'reviewed_by' => $admin->id,
]);
return $withdrawRequest;
}
protected function ensurePending(WithdrawRequest $withdrawRequest): void
{
if ($withdrawRequest->status !== WithdrawRequestStatusEnum::Pending) {
throw ValidationException::withMessages(['withdraw_request' => 'این درخواست قبلا بررسی شده است.']);
}
}
}
+1
View File
@@ -2,4 +2,5 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
]; ];
+1
View File
@@ -10,6 +10,7 @@
"hekmatinasser/verta": "^8.5", "hekmatinasser/verta": "^8.5",
"inertiajs/inertia-laravel": "^2.0", "inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/horizon": "^5.49",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"maatwebsite/excel": "^3.1", "maatwebsite/excel": "^3.1",
"spatie/laravel-permission": "^6.17", "spatie/laravel-permission": "^6.17",
Generated
+137 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "7aaff316edab008067e96caa26142a24", "content-hash": "2f1cb367cfe5d850b1483797ac0810ee",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -1741,6 +1741,86 @@
}, },
"time": "2025-04-08T19:58:59+00:00" "time": "2025-04-08T19:58:59+00:00"
}, },
{
"name": "laravel/horizon",
"version": "v5.49.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/horizon.git",
"reference": "6dce8a97b426be5305adad537ad4b6a99de61b31"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/horizon/zipball/6dce8a97b426be5305adad537ad4b6a99de61b31",
"reference": "6dce8a97b426be5305adad537ad4b6a99de61b31",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-pcntl": "*",
"ext-posix": "*",
"illuminate/contracts": "^9.21|^10.0|^11.0|^12.0|^13.0",
"illuminate/queue": "^9.21|^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0",
"laravel/sentinel": "^1.0",
"nesbot/carbon": "^2.17|^3.0",
"php": "^8.0",
"ramsey/uuid": "^4.0",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/error-handler": "^6.0|^7.0|^8.0",
"symfony/polyfill-php83": "^1.28",
"symfony/process": "^6.0|^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
"orchestra/testbench": "^7.56|^8.37|^9.16|^10.9|^11.0",
"phpstan/phpstan": "^1.10|^2.0",
"predis/predis": "^1.1|^2.0|^3.0"
},
"suggest": {
"ext-redis": "Required to use the Redis PHP driver.",
"predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Horizon": "Laravel\\Horizon\\Horizon"
},
"providers": [
"Laravel\\Horizon\\HorizonServiceProvider"
]
},
"branch-alias": {
"dev-master": "6.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Horizon\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Dashboard and code-driven configuration for Laravel queues.",
"keywords": [
"laravel",
"queue"
],
"support": {
"issues": "https://github.com/laravel/horizon/issues",
"source": "https://github.com/laravel/horizon/tree/v5.49.0"
},
"time": "2026-09-07T12:00:27+00:00"
},
{ {
"name": "laravel/prompts", "name": "laravel/prompts",
"version": "v0.3.5", "version": "v0.3.5",
@@ -1800,6 +1880,62 @@
}, },
"time": "2025-02-11T13:34:40+00:00" "time": "2025-02-11T13:34:40+00:00"
}, },
{
"name": "laravel/sentinel",
"version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sentinel.git",
"reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sentinel/zipball/972d9885d9d14312a118e9565c4e6ecc5e751ea1",
"reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/container": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0"
},
"require-dev": {
"laravel/pint": "^1.27",
"orchestra/testbench": "^6.47.1|^7.56|^8.37|^9.16|^10.9|^11.0",
"phpstan/phpstan": "^2.1.33"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sentinel\\SentinelServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sentinel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Mior Muhammad Zaki",
"email": "mior@laravel.com"
}
],
"support": {
"source": "https://github.com/laravel/sentinel/tree/v1.1.0"
},
"time": "2026-03-24T14:03:38+00:00"
},
{ {
"name": "laravel/serializable-closure", "name": "laravel/serializable-closure",
"version": "v2.0.4", "version": "v2.0.4",
+254
View File
@@ -0,0 +1,254 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Horizon Name
|--------------------------------------------------------------------------
|
| This name appears in notifications and in the Horizon UI. Unique names
| can be useful while running multiple instances of Horizon within an
| application, allowing you to identify the Horizon you're viewing.
|
*/
'name' => env('HORIZON_NAME'),
/*
|--------------------------------------------------------------------------
| Horizon Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Horizon will be accessible from. If this
| setting is null, Horizon will reside under the same domain as the
| application. Otherwise, this value will serve as the subdomain.
|
*/
'domain' => env('HORIZON_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Horizon Path
|--------------------------------------------------------------------------
|
| This is the URI path where Horizon will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('HORIZON_PATH', 'admin/horizon'),
/*
|--------------------------------------------------------------------------
| Horizon Redis Connection
|--------------------------------------------------------------------------
|
| This is the name of the Redis connection where Horizon will store the
| meta information required for it to function. It includes the list
| of supervisors, failed jobs, job metrics, and other information.
|
*/
'use' => 'default',
/*
|--------------------------------------------------------------------------
| Horizon Redis Prefix
|--------------------------------------------------------------------------
|
| This prefix will be used when storing all Horizon data in Redis. You
| may modify the prefix when you are running multiple installations
| of Horizon on the same server so that they don't have problems.
|
*/
'prefix' => env(
'HORIZON_PREFIX',
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
),
/*
|--------------------------------------------------------------------------
| Horizon Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will get attached onto each Horizon route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => ['web', 'admin.access'],
/*
|--------------------------------------------------------------------------
| Queue Wait Time Thresholds
|--------------------------------------------------------------------------
|
| This option allows you to configure when the LongWaitDetected event
| will be fired. Every connection / queue combination may have its
| own, unique threshold (in seconds) before this event is fired.
|
*/
'waits' => [
'redis:default' => 60,
],
/*
|--------------------------------------------------------------------------
| Job Trimming Times
|--------------------------------------------------------------------------
|
| Here you can configure for how long (in minutes) you desire Horizon to
| persist the recent and failed jobs. Typically, recent jobs are kept
| for one hour while all failed jobs are stored for an entire week.
|
*/
'trim' => [
'recent' => 60,
'pending' => 60,
'completed' => 60,
'recent_failed' => 10080,
'failed' => 10080,
'monitored' => 10080,
],
/*
|--------------------------------------------------------------------------
| Silenced Jobs
|--------------------------------------------------------------------------
|
| Silencing a job will instruct Horizon to not place the job in the list
| of completed jobs within the Horizon dashboard. This setting may be
| used to fully remove any noisy jobs from the completed jobs list.
|
*/
'silenced' => [
// App\Jobs\ExampleJob::class,
],
'silenced_tags' => [
// 'notifications',
],
/*
|--------------------------------------------------------------------------
| Metrics
|--------------------------------------------------------------------------
|
| Here you can configure how many snapshots should be kept to display in
| the metrics graph. This will get used in combination with Horizon's
| `horizon:snapshot` schedule to define how long to retain metrics.
|
*/
'metrics' => [
'trim_snapshots' => [
'job' => 24,
'queue' => 24,
],
],
/*
|--------------------------------------------------------------------------
| Fast Termination
|--------------------------------------------------------------------------
|
| When this option is enabled, Horizon's "terminate" command will not
| wait on all of the workers to terminate unless the --wait option
| is provided. Fast termination can shorten deployment delay by
| allowing a new instance of Horizon to start while the last
| instance will continue to terminate each of its workers.
|
*/
'fast_termination' => false,
/*
|--------------------------------------------------------------------------
| Memory Limit (MB)
|--------------------------------------------------------------------------
|
| This value describes the maximum amount of memory the Horizon master
| supervisor may consume before it is terminated and restarted. For
| configuring these limits on your workers, see the next section.
|
*/
'memory_limit' => 64,
/*
|--------------------------------------------------------------------------
| Queue Worker Configuration
|--------------------------------------------------------------------------
|
| Here you may define the queue worker settings used by your application
| in all environments. These supervisors and settings handle all your
| queued jobs and will be provisioned by Horizon during deployment.
|
*/
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 60,
'nice' => 0,
],
],
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
],
'local' => [
'supervisor-1' => [
'maxProcesses' => 3,
],
],
],
/*
|--------------------------------------------------------------------------
| File Watcher Configuration
|--------------------------------------------------------------------------
|
| The following list of directories and files will be watched when using
| the `horizon:listen` command. Whenever any directories or files are
| changed, Horizon will automatically restart to apply all changes.
|
*/
'watch' => [
'app',
'bootstrap',
'config/**/*.php',
'database/**/*.php',
'public/**/*.php',
'resources/**/*.php',
'routes',
'composer.lock',
'composer.json',
'.env',
],
];
+12 -4
View File
@@ -35,14 +35,22 @@ return [
], ],
], ],
'zibal' => [
'merchant' => env('ZIBAL_MERCHANT', 'zibal'),
'sandbox' => env('ZIBAL_SANDBOX', true),
'callback_url' => env('ZIBAL_CALLBACK_URL'),
],
'iranpayamak' => [ 'iranpayamak' => [
'api_url' => env('IRANPAYAMAK_SMS_API_URL', 'https://api.iranpayamak.com/ws/v1/sms/pattern'), 'api_url' => env('IRANPAYAMAK_SMS_API_URL', 'https://api.iranpayamak.com/ws/v1/sms/pattern'),
'line_number' => env('IRANPAYAMAK_SMS_API_URL', '50002178584000'), 'line_number' => env('IRANPAYAMAK_SMS_LINE_NUMBER', '50002178584000'),
'api_key' => env('IRANPAYAMAK_SMS_API_URL', 'jnCyVq4ein7uGsgIGjHsVRrWz0112IA2v2qbZMHq0kHtEXubsh'), 'api_key' => env('IRANPAYAMAK_SMS_API_KEY', 'jnCyVq4ein7uGsgIGjHsVRrWz0112IA2v2qbZM5q0kHtEXubsh'),
'patterns' => [ 'patterns' => [
'verify_code' => 'EvYDvfcUSk', 'verify_code' => env('IRANPAYAMAK_SMS_PATTERN_VERIFY_CODE', 'EvYDvfcUSk'),
'request_registered' => env('IRANPAYAMAK_SMS_PATTERN_REQUEST_REGISTERED' , 'GO2xzefAO8'),
'payment_confirmed' => env('IRANPAYAMAK_SMS_PATTERN_PAYMENT_CONFIRMED' , 'bl3k2UZDPe'),
'report_ready' => env('IRANPAYAMAK_SMS_PATTERN_REPORT_READY' , 'ImUrYHVWTx'),
], ],
], ],
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BankCard>
*/
class BankCardFactory extends Factory
{
public function definition(): array
{
return [
'card_number' => fake()->numerify('################'),
'holder_name' => fake()->firstName() . ' ' . fake()->lastName(),
'user_id' => User::factory(),
];
}
}
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Database\Factories; namespace Database\Factories;
use App\Enums\DocumentModalityEnum;
use App\Enums\DocumentStatusEnum; use App\Enums\DocumentStatusEnum;
use App\Enums\DocumentPriorityEnum; use App\Enums\DocumentPriorityEnum;
use App\Models\Pet; use App\Models\Pet;
@@ -18,7 +19,7 @@ class DocumentFactory extends Factory
{ {
return [ return [
'code' => fake()->unique()->numberBetween(100000, 999999), 'code' => fake()->unique()->numberBetween(100000, 999999),
'modality' => fake()->randomElement(['CT', 'MRI', 'X-Ray', 'Ultrasound', 'PET']), 'modality' => fake()->randomElement(DocumentModalityEnum::cases())->value,
'status' => fake()->randomElement(DocumentStatusEnum::cases()), 'status' => fake()->randomElement(DocumentStatusEnum::cases()),
'priority' => fake()->randomElement(DocumentPriorityEnum::cases()), 'priority' => fake()->randomElement(DocumentPriorityEnum::cases()),
'note' => fake()->optional()->sentence(), 'note' => fake()->optional()->sentence(),
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use App\Enums\DocumentModalityEnum;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Invoice>
*/
class InvoiceFactory extends Factory
{
public function definition(): array
{
return [
'number' => fake()->unique()->numerify('#####'),
'amount' => fake()->randomElement(DocumentModalityEnum::cases())->price(),
'status' => InvoiceStatusEnum::Pending,
'payment_method' => null,
'paid_at' => null,
'document_id' => null,
'user_id' => User::factory(),
];
}
public function paid(InvoicePaymentMethodEnum $method = InvoicePaymentMethodEnum::Wallet): static
{
return $this->state(fn (array $attributes) => [
'status' => InvoiceStatusEnum::Paid,
'payment_method' => $method,
'paid_at' => now(),
]);
}
public function canceled(): static
{
return $this->state(fn (array $attributes) => [
'status' => InvoiceStatusEnum::Canceled,
]);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\OtpCode>
*/
class OtpCodeFactory extends Factory
{
public function definition(): array
{
return [
'phone' => '09' . fake()->numerify('#########'),
'code' => (string) fake()->numberBetween(1000, 9999),
'expires_at' => now()->addMinutes(3),
];
}
public function expired(): static
{
return $this->state(fn (array $attributes) => [
'expires_at' => now()->subMinute(),
]);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use App\Enums\PaymentPurposeEnum;
use App\Enums\PaymentStatusEnum;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Payment>
*/
class PaymentFactory extends Factory
{
public function definition(): array
{
return [
'track_id' => fake()->unique()->numerify('##########'),
'amount' => fake()->numberBetween(100000, 2000000),
'status' => PaymentStatusEnum::Pending,
'purpose' => PaymentPurposeEnum::Invoice,
'ref_number' => null,
'verified_at' => null,
'invoice_id' => null,
'payer_id' => User::factory(),
];
}
public function paid(): static
{
return $this->state(fn (array $attributes) => [
'status' => PaymentStatusEnum::Paid,
'ref_number' => fake()->numerify('############'),
'verified_at' => now(),
]);
}
public function walletTopUp(): static
{
return $this->state(fn (array $attributes) => [
'purpose' => PaymentPurposeEnum::WalletTopUp,
]);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace Database\Factories;
use App\Enums\ShiftDayEnum;
use App\Enums\UserEnum;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Shift>
*/
class ShiftFactory extends Factory
{
public function definition(): array
{
return [
'day_of_week' => fake()->randomElement(ShiftDayEnum::cases())->value,
'date' => null,
'start_time' => '08:00',
'end_time' => '16:00',
'is_active' => true,
'user_id' => null,
'created_by' => null,
];
}
public function onDate(string $date): static
{
return $this->state(fn (array $attributes) => [
'day_of_week' => null,
'date' => $date,
]);
}
public function weekly(ShiftDayEnum $day): static
{
return $this->state(fn (array $attributes) => [
'day_of_week' => $day->value,
'date' => null,
]);
}
public function between(string $start, string $end): static
{
return $this->state(fn (array $attributes) => [
'start_time' => $start,
'end_time' => $end,
]);
}
public function forDoctor(?User $doctor = null): static
{
return $this->state(fn (array $attributes) => [
'user_id' => $doctor?->id ?? User::factory()->state(['level' => UserEnum::Doctor->value]),
]);
}
public function inactive(): static
{
return $this->state(fn (array $attributes) => [
'is_active' => false,
]);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Wallet>
*/
class WalletFactory extends Factory
{
public function definition(): array
{
return [
'balance' => 0,
'user_id' => User::factory(),
];
}
public function system(): static
{
return $this->state(fn (array $attributes) => [
'user_id' => null,
]);
}
}
@@ -0,0 +1,39 @@
<?php
namespace Database\Factories;
use App\Enums\WalletTransactionReasonEnum;
use App\Enums\WalletTransactionTypeEnum;
use App\Models\Wallet;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WalletTransaction>
*/
class WalletTransactionFactory extends Factory
{
public function definition(): array
{
$amount = fake()->numberBetween(10000, 1000000);
return [
'type' => WalletTransactionTypeEnum::Deposit,
'reason' => WalletTransactionReasonEnum::TopUp,
'amount' => $amount,
'balance_after' => $amount,
'description' => null,
'wallet_id' => Wallet::factory(),
'invoice_id' => null,
'payment_id' => null,
'withdraw_request_id' => null,
];
}
public function withdrawal(WalletTransactionReasonEnum $reason = WalletTransactionReasonEnum::InvoicePayment): static
{
return $this->state(fn (array $attributes) => [
'type' => WalletTransactionTypeEnum::Withdrawal,
'reason' => $reason,
]);
}
}
@@ -0,0 +1,46 @@
<?php
namespace Database\Factories;
use App\Enums\WithdrawRequestStatusEnum;
use App\Models\BankCard;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WithdrawRequest>
*/
class WithdrawRequestFactory extends Factory
{
public function definition(): array
{
return [
'amount' => fake()->numberBetween(100000, 2000000),
'status' => WithdrawRequestStatusEnum::Pending,
'admin_note' => null,
'reviewed_at' => null,
'reviewed_by' => null,
'user_id' => User::factory(),
'bank_card_id' => fn (array $attributes) => BankCard::factory()->create(['user_id' => $attributes['user_id']])->id,
];
}
public function approved(): static
{
return $this->state(fn (array $attributes) => [
'status' => WithdrawRequestStatusEnum::Approved,
'reviewed_at' => now(),
'reviewed_by' => User::factory(),
]);
}
public function rejected(): static
{
return $this->state(fn (array $attributes) => [
'status' => WithdrawRequestStatusEnum::Rejected,
'admin_note' => fake()->sentence(),
'reviewed_at' => now(),
'reviewed_by' => User::factory(),
]);
}
}
@@ -0,0 +1,54 @@
<?php
use App\Enums\DocumentModalityEnum;
use App\Enums\InvoiceStatusEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->string('number')->unique();
$table->unsignedBigInteger('amount');
$table->string('status')->default(InvoiceStatusEnum::Pending->value);
$table->string('payment_method')->nullable();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
$table->foreignId('document_id')->nullable()->constrained('documents')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
});
DB::table('documents')->orderBy('id')->each(function ($document) {
$modality = DocumentModalityEnum::tryFrom($document->modality);
if (!$modality) {
return;
}
$id = DB::table('invoices')->insertGetId([
'number' => 'pending',
'amount' => $modality->price(),
'status' => InvoiceStatusEnum::Pending->value,
'document_id' => $document->id,
'user_id' => $document->requester_id,
'created_at' => $document->created_at,
'updated_at' => $document->created_at,
]);
DB::table('invoices')->where('id', $id)->update([
'number' => str_pad((string) $id, 5, '0', STR_PAD_LEFT),
]);
});
}
public function down(): void
{
Schema::dropIfExists('invoices');
}
};
@@ -0,0 +1,32 @@
<?php
use App\Enums\PaymentPurposeEnum;
use App\Enums\PaymentStatusEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->string('track_id')->unique();
$table->unsignedBigInteger('amount');
$table->string('status')->default(PaymentStatusEnum::Pending->value);
$table->string('purpose')->default(PaymentPurposeEnum::Invoice->value);
$table->string('ref_number')->nullable();
$table->timestamp('verified_at')->nullable();
$table->timestamps();
$table->foreignId('invoice_id')->nullable()->constrained('invoices')->cascadeOnDelete();
$table->foreignId('payer_id')->constrained('users')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('payments');
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('shifts', function (Blueprint $table) {
$table->id();
$table->string('day_of_week')->nullable();
$table->date('date')->nullable();
$table->time('start_time');
$table->time('end_time');
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->index(['day_of_week', 'is_active']);
$table->index(['date', 'is_active']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('shifts');
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('wallets', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('balance')->default(0);
$table->timestamps();
$table->foreignId('user_id')->unique()->constrained('users')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('wallets');
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->date('birthdate')->nullable()->after('fullname');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('birthdate');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('wallet_transactions', function (Blueprint $table) {
$table->id();
$table->string('type');
$table->string('reason');
$table->unsignedBigInteger('amount');
$table->unsignedBigInteger('balance_after');
$table->string('description')->nullable();
$table->timestamps();
$table->foreignId('wallet_id')->constrained('wallets')->cascadeOnDelete();
$table->foreignId('invoice_id')->nullable()->constrained('invoices')->nullOnDelete();
$table->foreignId('payment_id')->nullable()->constrained('payments')->nullOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('wallet_transactions');
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('reports', function (Blueprint $table) {
$table->text('technique')->nullable()->after('body');
$table->text('finding')->nullable()->after('technique');
$table->text('diagnose')->nullable()->after('finding');
$table->text('recommendation')->nullable()->after('diagnose');
});
}
public function down(): void
{
Schema::table('reports', function (Blueprint $table) {
$table->dropColumn(['technique', 'finding', 'diagnose', 'recommendation']);
});
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wallets', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->foreignId('user_id')->nullable()->change();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::table('wallets', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->foreignId('user_id')->nullable(false)->change();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('bank_cards', function (Blueprint $table) {
$table->id();
$table->string('card_number', 16);
$table->string('holder_name');
$table->timestamps();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('bank_cards');
}
};
@@ -0,0 +1,30 @@
<?php
use App\Enums\WithdrawRequestStatusEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('withdraw_requests', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('amount');
$table->string('status')->default(WithdrawRequestStatusEnum::Pending->value)->index();
$table->text('admin_note')->nullable();
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
$table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('bank_card_id')->nullable()->constrained('bank_cards')->nullOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('withdraw_requests');
}
};

Some files were not shown because too many files have changed in this diff Show More