Initial commit - Physiotherapy Clinic Management System
This commit is contained in:
commit
dbb326f2cc
300 files changed
+144285
No files matched your search
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Appointment;
|
||||
use App\Models\Patient;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AppointmentService
|
||||
{
|
||||
/**
|
||||
* Check for conflicting appointments for a therapist
|
||||
*/
|
||||
public function hasConflict(int $therapistId, Carbon $startTime, int $duration, ?int $excludeId = null): bool
|
||||
{
|
||||
$endTime = $startTime->copy()->addMinutes($duration);
|
||||
|
||||
$query = Appointment::where('user_id', $therapistId)
|
||||
->whereIn('status', ['scheduled', 'confirmed', 'in_progress'])
|
||||
->where(function ($q) use ($startTime, $endTime) {
|
||||
// Check if any appointment overlaps with the requested time
|
||||
$q->whereBetween('appointment_date', [$startTime, $endTime->copy()->subSecond()])
|
||||
->orWhereRaw("datetime(appointment_date, '+' || duration_minutes || ' minutes') BETWEEN ? AND ?", [$startTime, $endTime]);
|
||||
});
|
||||
|
||||
if ($excludeId) {
|
||||
$query->where('id', '!=', $excludeId);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for patient double-booking
|
||||
*/
|
||||
public function hasPatientConflict(int $patientId, Carbon $startTime, int $duration, ?int $excludeId = null): bool
|
||||
{
|
||||
$endTime = $startTime->copy()->addMinutes($duration);
|
||||
|
||||
$query = Appointment::where('patient_id', $patientId)
|
||||
->whereIn('status', ['scheduled', 'confirmed', 'in_progress'])
|
||||
->whereBetween('appointment_date', [$startTime, $endTime->copy()->subSecond()]);
|
||||
|
||||
if ($excludeId) {
|
||||
$query->where('id', '!=', $excludeId);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available time slots for a therapist on a given date
|
||||
*/
|
||||
public function getAvailableSlots(int $therapistId, string $date, int $duration = 60): array
|
||||
{
|
||||
$workingHours = ['09:00', '17:00']; // 9 AM to 5 PM
|
||||
$slotInterval = 30; // minutes
|
||||
|
||||
$slots = [];
|
||||
$start = Carbon::parse("$date {$workingHours[0]}");
|
||||
$end = Carbon::parse("$date {$workingHours[1]}");
|
||||
|
||||
while ($start->lt($end)) {
|
||||
$slotEnd = $start->copy()->addMinutes($duration);
|
||||
|
||||
if ($slotEnd->lte($end) && !$this->hasConflict($therapistId, $start, $duration)) {
|
||||
$slots[] = $start->format('H:i');
|
||||
}
|
||||
|
||||
$start->addMinutes($slotInterval);
|
||||
}
|
||||
|
||||
return $slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get calendar feed for a date range
|
||||
*/
|
||||
public function getCalendarFeed(string $startDate, string $endDate, ?int $therapistId = null): array
|
||||
{
|
||||
$query = Appointment::with(['patient', 'user'])
|
||||
->whereBetween('appointment_date', [$startDate, $endDate]);
|
||||
|
||||
if ($therapistId) {
|
||||
$query->where('user_id', $therapistId);
|
||||
}
|
||||
|
||||
return $query->orderBy('appointment_date')->get()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a package session for an appointment
|
||||
*/
|
||||
public function consumePackageSession(Appointment $appointment, int $patientPackageId): bool
|
||||
{
|
||||
$patientPackage = \App\Models\PatientPackage::find($patientPackageId);
|
||||
|
||||
if (!$patientPackage || $patientPackage->patient_id !== $appointment->patient_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $patientPackage->consumeSession();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\LedgerEntry;
|
||||
use App\Models\ChartOfAccount;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Wage;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class LedgerService
|
||||
{
|
||||
/**
|
||||
* Create a ledger entry from a payment (income)
|
||||
*/
|
||||
public function recordPayment(Payment $payment): LedgerEntry
|
||||
{
|
||||
$incomeAccount = ChartOfAccount::where('type', 'income')
|
||||
->where('category', 'revenue')
|
||||
->first();
|
||||
|
||||
return LedgerEntry::create([
|
||||
'entry_date' => $payment->payment_date,
|
||||
'chart_of_account_id' => $incomeAccount?->id,
|
||||
'currency_id' => $payment->invoice->currency_id,
|
||||
'amount' => $payment->amount,
|
||||
'description' => "Payment received - {$payment->payment_number}",
|
||||
'source_type' => 'payment',
|
||||
'source_id' => $payment->id,
|
||||
'created_by' => $payment->received_by,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ledger entry for a wage (expense)
|
||||
*/
|
||||
public function recordWage(Wage $wage): LedgerEntry
|
||||
{
|
||||
$expenseAccount = ChartOfAccount::where('type', 'expense')
|
||||
->where('category', 'operating_expense')
|
||||
->first();
|
||||
|
||||
return LedgerEntry::create([
|
||||
'entry_date' => $wage->period_end,
|
||||
'chart_of_account_id' => $expenseAccount?->id,
|
||||
'currency_id' => $wage->currency_id,
|
||||
'amount' => -$wage->total_amount, // negative for expense
|
||||
'description' => "Wage payment - {$wage->user->name} ({$wage->period_start} to {$wage->period_end})",
|
||||
'source_type' => 'wage',
|
||||
'source_id' => $wage->id,
|
||||
'created_by' => $wage->created_by,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get income summary for a period
|
||||
*/
|
||||
public function getIncomeSummary(string $startDate, string $endDate): array
|
||||
{
|
||||
$entries = LedgerEntry::income()
|
||||
->whereBetween('entry_date', [$startDate, $endDate])
|
||||
->with('chartOfAccount')
|
||||
->get();
|
||||
|
||||
$byAccount = $entries->groupBy('chart_of_account_id')
|
||||
->map(fn($group) => [
|
||||
'account_name' => $group->first()->chartOfAccount->name,
|
||||
'total' => $group->sum('amount'),
|
||||
])
|
||||
->values();
|
||||
|
||||
return [
|
||||
'total_income' => $entries->sum('amount'),
|
||||
'by_account' => $byAccount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expense summary for a period
|
||||
*/
|
||||
public function getExpenseSummary(string $startDate, string $endDate): array
|
||||
{
|
||||
$entries = LedgerEntry::expense()
|
||||
->whereBetween('entry_date', [$startDate, $endDate])
|
||||
->with('chartOfAccount')
|
||||
->get();
|
||||
|
||||
$byAccount = $entries->groupBy('chart_of_account_id')
|
||||
->map(fn($group) => [
|
||||
'account_name' => $group->first()->chartOfAccount->name,
|
||||
'total' => abs($group->sum('amount')),
|
||||
])
|
||||
->values();
|
||||
|
||||
return [
|
||||
'total_expenses' => abs($entries->sum('amount')),
|
||||
'by_account' => $byAccount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get net profit for a period
|
||||
*/
|
||||
public function getNetProfit(string $startDate, string $endDate): float
|
||||
{
|
||||
$income = $this->getIncomeSummary($startDate, $endDate)['total_income'];
|
||||
$expenses = $this->getExpenseSummary($startDate, $endDate)['total_expenses'];
|
||||
|
||||
return $income - $expenses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full P&L report
|
||||
*/
|
||||
public function getProfitLossReport(string $startDate, string $endDate): array
|
||||
{
|
||||
$income = $this->getIncomeSummary($startDate, $endDate);
|
||||
$expenses = $this->getExpenseSummary($startDate, $endDate);
|
||||
|
||||
return [
|
||||
'period' => [
|
||||
'start' => $startDate,
|
||||
'end' => $endDate,
|
||||
],
|
||||
'income' => $income,
|
||||
'expenses' => $expenses,
|
||||
'net_profit' => $income['total_income'] - $expenses['total_expenses'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Reminder;
|
||||
use App\Models\ReminderTemplate;
|
||||
use App\Models\Appointment;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class ReminderService
|
||||
{
|
||||
/**
|
||||
* Generate reminders for an appointment
|
||||
*/
|
||||
public function generateReminders(Appointment $appointment): void
|
||||
{
|
||||
$templates = ReminderTemplate::where('is_active', true)->get();
|
||||
|
||||
foreach ($templates as $template) {
|
||||
$sendAt = $appointment->appointment_date->copy()->subHours($template->hours_before);
|
||||
|
||||
// Don't create if send time is in the past
|
||||
if ($sendAt->isPast()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$placeholders = [
|
||||
'patient_name' => $appointment->patient->fullName(),
|
||||
'date' => $appointment->appointment_date->format('Y-m-d'),
|
||||
'time' => $appointment->appointment_date->format('H:i'),
|
||||
'therapist_name' => $appointment->user->name,
|
||||
];
|
||||
|
||||
Reminder::create([
|
||||
'appointment_id' => $appointment->id,
|
||||
'template_id' => $template->id,
|
||||
'channel' => $template->channel,
|
||||
'content' => $template->parseContent($placeholders),
|
||||
'send_at' => $sendAt,
|
||||
'status' => 'queued',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get due reminders
|
||||
*/
|
||||
public function getDueReminders(int $limit = 50): array
|
||||
{
|
||||
return Reminder::with('appointment.patient')
|
||||
->where('status', 'queued')
|
||||
->where('send_at', '<=', now())
|
||||
->limit($limit)
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and send reminders
|
||||
*/
|
||||
public function processReminders(): array
|
||||
{
|
||||
$reminders = Reminder::with('appointment.patient')
|
||||
->where('status', 'queued')
|
||||
->where('send_at', '<=', now())
|
||||
->get();
|
||||
|
||||
$results = [
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
|
||||
foreach ($reminders as $reminder) {
|
||||
try {
|
||||
$this->sendReminder($reminder);
|
||||
$reminder->markAsSent();
|
||||
$results['sent']++;
|
||||
} catch (\Exception $e) {
|
||||
$reminder->markAsFailed($e->getMessage());
|
||||
$results['failed']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single reminder (stub - implement with actual SMS/WhatsApp provider)
|
||||
*/
|
||||
public function sendReminder(Reminder $reminder): void
|
||||
{
|
||||
// Stub implementation - integrate with SMS/WhatsApp provider
|
||||
$channel = $reminder->channel;
|
||||
$content = $reminder->content;
|
||||
|
||||
switch ($channel) {
|
||||
case 'sms':
|
||||
// TODO: Integrate with SMS provider
|
||||
break;
|
||||
case 'whatsapp':
|
||||
// TODO: Integrate with WhatsApp Business API
|
||||
break;
|
||||
case 'email':
|
||||
// TODO: Send email
|
||||
break;
|
||||
case 'internal':
|
||||
// Internal notification - logged only
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reminder configuration
|
||||
*/
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return ReminderTemplate::where('is_active', true)
|
||||
->get()
|
||||
->map(fn($t) => [
|
||||
'id' => $t->id,
|
||||
'name' => $t->name,
|
||||
'channel' => $t->channel,
|
||||
'hours_before' => $t->hours_before,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update reminder configuration
|
||||
*/
|
||||
public function updateConfiguration(int $templateId, array $data): ReminderTemplate
|
||||
{
|
||||
$template = ReminderTemplate::findOrFail($templateId);
|
||||
$template->update($data);
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Wage;
|
||||
use App\Models\User;
|
||||
use App\Models\Appointment;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class WageService
|
||||
{
|
||||
/**
|
||||
* Calculate wage for a therapist in a given period
|
||||
*/
|
||||
public function calculateWage(int $therapistId, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$therapist = User::find($therapistId);
|
||||
|
||||
$completedAppointments = Appointment::where('user_id', $therapistId)
|
||||
->where('status', 'completed')
|
||||
->whereBetween('appointment_date', [$periodStart, $periodEnd . ' 23:59:59'])
|
||||
->get();
|
||||
|
||||
$sessionsCount = $completedAppointments->count();
|
||||
$hoursWorked = $completedAppointments->sum('duration_minutes') / 60;
|
||||
|
||||
$baseAmount = 0;
|
||||
|
||||
if ($therapist->session_rate) {
|
||||
$baseAmount = $sessionsCount * $therapist->session_rate;
|
||||
} elseif ($therapist->hourly_rate) {
|
||||
$baseAmount = $hoursWorked * $therapist->hourly_rate;
|
||||
}
|
||||
|
||||
return [
|
||||
'therapist_id' => $therapistId,
|
||||
'period_start' => $periodStart,
|
||||
'period_end' => $periodEnd,
|
||||
'sessions_count' => $sessionsCount,
|
||||
'hours_worked' => round($hoursWorked, 2),
|
||||
'base_amount' => round($baseAmount, 2),
|
||||
'bonus' => 0,
|
||||
'deductions' => 0,
|
||||
'total_amount' => round($baseAmount, 2),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create wage record
|
||||
*/
|
||||
public function createWage(array $data, int $createdBy): Wage
|
||||
{
|
||||
$wage = Wage::create(array_merge($data, [
|
||||
'created_by' => $createdBy,
|
||||
]));
|
||||
|
||||
// Auto-create ledger entry
|
||||
$ledgerService = new LedgerService();
|
||||
$ledgerService->recordWage($wage);
|
||||
|
||||
return $wage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get therapist performance metrics
|
||||
*/
|
||||
public function getPerformanceMetrics(int $therapistId, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$wage = $this->calculateWage($therapistId, $periodStart, $periodEnd);
|
||||
$therapist = User::find($therapistId);
|
||||
|
||||
$targetSessions = $therapist->target_sessions_per_month ?? 0;
|
||||
$sessionsAchieved = $targetSessions > 0 ? ($wage['sessions_count'] / $targetSessions) * 100 : 0;
|
||||
|
||||
return [
|
||||
'sessions_count' => $wage['sessions_count'],
|
||||
'hours_worked' => $wage['hours_worked'],
|
||||
'target_sessions' => $targetSessions,
|
||||
'sessions_achieved_percentage' => round($sessionsAchieved, 2),
|
||||
'base_amount' => $wage['base_amount'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user