Initial commit - Physiotherapy Clinic Management System

This commit is contained in:
Deploy committed 2026-03-10 00:38:51 +03:00
commit dbb326f2cc
300 files changed
+144285

No files matched your search

+105
View File
@@ -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();
}
}
+130
View File
@@ -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'],
];
}
}
+137
View File
@@ -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;
}
}
+83
View File
@@ -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'],
];
}
}