diff --git a/app/Console/Commands/NotifyUpcomingBirthdays.php b/app/Console/Commands/NotifyUpcomingBirthdays.php new file mode 100644 index 00000000..dab0c144 --- /dev/null +++ b/app/Console/Commands/NotifyUpcomingBirthdays.php @@ -0,0 +1,75 @@ +addDays(1); + $month = $tomorrow->getMonth(); + $day = $tomorrow->getDay(); + $jalaliDate = $tomorrow->format('j F'); + + $users = User::query() + ->whereNotNull('birthday') + ->whereNull('deleted_at') + ->get() + ->filter(function (User $user) use ($month, $day) { + $birth = Jalalian::fromCarbon(Carbon::parse($user->birthday)); + + return $birth->getMonth() === $month && $birth->getDay() === $day; + }); + + if ($users->isEmpty()) { + $this->info('No birthdays tomorrow.'); + + return self::SUCCESS; + } + + foreach ($users as $user) { + $name = $user->name ?: $user->user_name; + $message = "🌸 یک یادآوری لطیف از قلب تیم...\n\n" + . "فردا {$jalaliDate}، روز تولد «{$name}» است.\n\n" + . "فردایی که میتواند با یک پیام گرم، یک لبخند، یا یک «تولدت مبارک» ساده، برایش خاطرهای قشنگ بسازد.\n" + . "بیایید این روز را از یاد نبریم و بهش حس ارزشمند بودن بدهیم 💛"; + + $this->sendToAdmins($message); + $this->info("Birthday reminder sent for: {$name}"); + } + + return self::SUCCESS; + } + + private function sendToAdmins(string $message): void + { + $admins = User::query() + ->whereNotNull('is_admin') + ->whereNotNull('bale') + ->select('bale') + ->get(); + + foreach ($admins as $admin) { + try { + Http::withoutVerifying()->post( + 'https://tapi.bale.ai/1482838710:sYbLzby0tuLRzHvP4d3LsbcNwg2sjSDLTAU/sendMessage', + [ + 'chat_id' => $admin->bale, + 'text' => $message, + ] + ); + } catch (\Exception $e) { + } + } + } +} diff --git a/app/Console/Commands/SendDailyLateReport.php b/app/Console/Commands/SendDailyLateReport.php new file mode 100644 index 00000000..2db329a2 --- /dev/null +++ b/app/Console/Commands/SendDailyLateReport.php @@ -0,0 +1,64 @@ +option('date') + ? Carbon::parse($this->option('date'))->startOfDay() + : Carbon::yesterday()->startOfDay(); + + $start = $date->copy()->startOfDay(); + $end = $date->copy()->endOfDay(); + $jalali = Jalalian::fromCarbon($date)->format('Y/m/d'); + + $entries = $service->firstEntriesBetween($start, $end) + ->filter(fn ($att) => (int) ($att->late_minutes ?? 0) > 0 || (int) ($att->daily_fine_amount ?? 0) > 0) + ->values(); + + if ($entries->isEmpty()) { + $message = "📋 گزارش تأخیر روزانه\nتاریخ: {$jalali}\n\nامروز تأخیر مشمول جریمه ثبت نشده است."; + $service->sendToAdmins($message); + $this->info('Daily late report sent (empty).'); + + return self::SUCCESS; + } + + $lines = ["📋 گزارش تأخیر روزانه", "تاریخ: {$jalali}", ""]; + $totalFine = 0; + + foreach ($entries as $att) { + $name = $service->userName($att->user_identity); + $enter = Carbon::parse($att->entered_at)->format('H:i'); + $late = (int) $att->late_minutes; + $fineMin = (int) $att->daily_fine_minutes; + $fine = (int) $att->daily_fine_amount; + $totalFine += $fine; + + $lines[] = "• {$name}"; + $lines[] = " ورود: {$enter} | تأخیر: {$late} دقیقه"; + $lines[] = $fineMin > 0 + ? " جریمه روزانه: {$fineMin} دقیقه × " . number_format(LatePenaltyService::FINE_PER_MINUTE) . " = " . $service->formatAmount($fine) + : " جریمه روزانه: ۰ (داخل ارفاق ۱۵ دقیقهای)"; + $lines[] = ""; + } + + $lines[] = "جمع جریمه روز: " . $service->formatAmount($totalFine); + $service->sendToAdmins(implode("\n", $lines)); + $this->info('Daily late report sent.'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SendMonthlyLateReport.php b/app/Console/Commands/SendMonthlyLateReport.php new file mode 100644 index 00000000..5fd098ab --- /dev/null +++ b/app/Console/Commands/SendMonthlyLateReport.php @@ -0,0 +1,107 @@ +option('date'); + + // پیشفرض: ماه شمسیِ دیروز (برای اجرای روز اول ماه بعد) + $anchor = $forcedDate + ? Carbon::parse($forcedDate) + : Carbon::yesterday(); + + // بدون --date فقط وقتی دیروز آخرین روز ماه شمسی بوده اجرا شود + if (!$forcedDate) { + $jYesterday = Jalalian::fromCarbon($anchor); + $daysInMonth = $jYesterday->getMonth() <= 6 + ? 31 + : ($jYesterday->getMonth() <= 11 ? 30 : ($jYesterday->isLeapYear() ? 30 : 29)); + + if ($jYesterday->getDay() !== $daysInMonth) { + $this->info('Not end of Jalali month; skipped.'); + + return self::SUCCESS; + } + } + + [$start, $end] = $service->jalaliMonthBounds($anchor); + $monthName = Jalalian::fromCarbon($start)->format('%B %Y'); + + $users = User::query() + ->whereNotNull('user_name') + ->whereNull('deleted_at') + ->get(['user_name', 'name']); + + $lines = [ + "📅 گزارش تأخیر ماهانه", + "ماه: {$monthName}", + "", + ]; + + $any = false; + $monthTotal = 0; + + foreach ($users as $user) { + $summary = $service->summarizeUserPeriod($user->user_name, $start, $end); + $weeklyAmount = (int) \App\Models\LatePeriodFine::query() + ->where('user_identity', $user->user_name) + ->where('period_type', 'weekly') + ->whereBetween('period_start', [$start->toDateString(), $end->toDateString()]) + ->sum('fine_amount'); + + $total = $summary['daily_fine_amount'] + $weeklyAmount; + if ($summary['late_minutes'] <= 0 && $total <= 0) { + continue; + } + + $any = true; + $monthTotal += $total; + + $service->storePeriodFine([ + 'user_identity' => $user->user_name, + 'period_type' => 'monthly', + 'period_start' => $start->toDateString(), + 'period_end' => $end->toDateString(), + 'late_minutes' => $summary['late_minutes'], + 'allowance_minutes' => 0, + 'fine_minutes' => 0, + 'fine_amount' => $total, + 'daily_fine_amount' => $summary['daily_fine_amount'], + 'weekly_fine_amount' => $weeklyAmount, + 'total_fine_amount' => $total, + ]); + + $name = $user->name ?: $user->user_name; + $lines[] = "• {$name}"; + $lines[] = " مجموع تأخیر ماه: {$summary['late_minutes']} دقیقه"; + $lines[] = " جریمه روزانه: " . $service->formatAmount($summary['daily_fine_amount']); + $lines[] = " جریمه هفتگی: " . $service->formatAmount($weeklyAmount); + $lines[] = " جمع کل: " . $service->formatAmount($total); + $lines[] = ""; + } + + if (!$any) { + $lines[] = "در این ماه تأخیر مشمول جریمهای ثبت نشده است."; + } else { + $lines[] = "جمع کل جرایم ماه: " . $service->formatAmount($monthTotal); + } + + $service->sendToAdmins(implode("\n", $lines)); + $this->info('Monthly late report sent.'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SendWeeklyLateReport.php b/app/Console/Commands/SendWeeklyLateReport.php new file mode 100644 index 00000000..c63afdfb --- /dev/null +++ b/app/Console/Commands/SendWeeklyLateReport.php @@ -0,0 +1,98 @@ +option('date') + ? Carbon::parse($this->option('date')) + : Carbon::yesterday(); + + [$start, $end] = $service->weekBounds($anchor); + $startLabel = Jalalian::fromCarbon($start)->format('Y/m/d'); + $endLabel = Jalalian::fromCarbon($end)->format('Y/m/d'); + + $users = User::query() + ->whereNotNull('user_name') + ->whereNull('deleted_at') + ->get(['user_name', 'name']); + + $lines = [ + "📊 گزارش تأخیر هفتگی", + "بازه: {$startLabel} تا {$endLabel}", + "ارفاق هفتگی: " . LatePenaltyService::WEEKLY_ALLOWANCE_MINUTES . " دقیقه (از جمع تأخیرهای ۸:۳۰ تا ۸:۴۵)", + "", + ]; + + $any = false; + $sumWeeklyFine = 0; + $sumDailyFine = 0; + $sumReportTotal = 0; + + foreach ($users as $user) { + $summary = $service->summarizeUserPeriod($user->user_name, $start, $end); + if ($summary['grace_late_minutes'] <= 0 && $summary['daily_fine_amount'] <= 0) { + continue; + } + + $any = true; + // فقط تأخیر بازه ۸:۳۰–۸:۴۵ برای جریمه هفتگی + $weekly = $service->computeWeeklyFine($summary['grace_late_minutes']); + $reportTotal = $weekly['fine_amount'] + $summary['daily_fine_amount']; + + $sumWeeklyFine += $weekly['fine_amount']; + $sumDailyFine += $summary['daily_fine_amount']; + $sumReportTotal += $reportTotal; + + $service->storePeriodFine([ + 'user_identity' => $user->user_name, + 'period_type' => 'weekly', + 'period_start' => $start->toDateString(), + 'period_end' => $end->toDateString(), + 'late_minutes' => $summary['grace_late_minutes'], + 'allowance_minutes' => $weekly['allowance_minutes'], + 'fine_minutes' => $weekly['fine_minutes'], + 'fine_amount' => $weekly['fine_amount'], + 'daily_fine_amount' => $summary['daily_fine_amount'], + 'weekly_fine_amount' => $weekly['fine_amount'], + 'total_fine_amount' => $reportTotal, + ]); + + $name = $user->name ?: $user->user_name; + $lines[] = "• {$name}"; + $lines[] = " جمع تأخیر ۸:۳۰–۸:۴۵: {$summary['grace_late_minutes']} دقیقه"; + $lines[] = " ارفاق هفتگی: {$weekly['allowance_minutes']} دقیقه"; + $lines[] = $weekly['fine_minutes'] > 0 + ? " جریمه هفتگی: {$weekly['fine_minutes']} × " . number_format(LatePenaltyService::FINE_PER_MINUTE) . " = " . $service->formatAmount($weekly['fine_amount']) + : " جریمه هفتگی: ۰"; + $lines[] = " جریمه روزانه این هفته: " . $service->formatAmount($summary['daily_fine_amount']); + $lines[] = " جمع گزارش: " . $service->formatAmount($reportTotal); + $lines[] = ""; + } + + if (!$any) { + $lines[] = "در این هفته تأخیری ثبت نشده است."; + } else { + $lines[] = "جمع جریمه هفتگی: " . $service->formatAmount($sumWeeklyFine); + $lines[] = "جمع جریمه روزانه: " . $service->formatAmount($sumDailyFine); + $lines[] = "جمع کل گزارش هفته: " . $service->formatAmount($sumReportTotal); + } + + $service->sendToAdmins(implode("\n", $lines)); + $this->info('Weekly late report sent.'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index f9276a17..c1104b32 100755 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -18,6 +18,7 @@ use Morilog\Jalali\Jalalian; use GuzzleHttp\Client; use App\Models\Tasks; use App\Models\Pivot_Tasks; +use App\Services\LatePenaltyService; class AdminController extends Controller @@ -283,6 +284,9 @@ class AdminController extends Controller $hours = intdiv($totalSeconds, 3600); $minutes = intdiv($totalSeconds % 3600, 60); $user->totaltime = sprintf('%02d:%02d', $hours, $minutes); + [$jMonthStart, $jMonthEnd] = app(LatePenaltyService::class)->jalaliMonthBounds($now); + $user->monthly_late_fine = app(LatePenaltyService::class) + ->monthlyTotalFine($user->user_name, $jMonthStart, $jMonthEnd); $user->working = 0; $openForUser = $openAttendances->get($user->user_name, collect()); @@ -435,6 +439,7 @@ class AdminController extends Controller $attendance->exited_at = $nendt ? $date . ' ' . $nendt : null; $attendance->duration = $nendt !== null ? $attendance->duration($nstartt, $nendt) : null; $attendance->save(); + app(LatePenaltyService::class)->syncAttendance($attendance); return redirect()->back()->with('message', [ 'color' => '#6aff00ff', "text" => 'تغییرات با موفقیت ذخیره شد.✔️' @@ -461,6 +466,7 @@ class AdminController extends Controller $attendance->duration = $nendt ? $attendance->duration($nstartt, $nendt) : null; $attendance->save(); + app(LatePenaltyService::class)->syncAttendance($attendance); return redirect()->back()->with('message', [ 'color' => '#6aff00ff', "text" => 'تغییرات با موفقیت ذخیره شد.✔️' @@ -568,6 +574,7 @@ class AdminController extends Controller $record->duration = $duration ?? $record->duration; $record->requested = ''; $record->save(); + app(LatePenaltyService::class)->syncAttendance($record); Redis::connection()->del("att-tempreq:{$data->userid}:{$data->id}"); $this->decrementUserRequestCount($data->userid); @@ -602,6 +609,7 @@ class AdminController extends Controller ? $attendance->duration($starttime, $endtime) : null; $attendance->save(); + app(LatePenaltyService::class)->syncAttendance($attendance); $create_requests_key = "att-tempreq:creates:{$data->userid}"; Redis::connection()->lrem($create_requests_key, 1, json_encode($data)); $this->decrementUserRequestCount($data->userid); diff --git a/app/Http/Controllers/AttendanceController.php b/app/Http/Controllers/AttendanceController.php index 1c8da0b5..3196a666 100755 --- a/app/Http/Controllers/AttendanceController.php +++ b/app/Http/Controllers/AttendanceController.php @@ -19,6 +19,7 @@ use App\Models\User; use App\Models\Log; use Carbon\Carbon; use App\Models\ai; +use App\Services\LatePenaltyService; class AttendanceController extends Controller @@ -294,26 +295,9 @@ class AttendanceController extends Controller ->latest('entered_at') ->first(); - if ($attendance) { - // خروج - //end task - if (!empty($user->active_task)) { - $taskDu = TaskDuration::where('user_id', $user->id)->whereNull('end')->whereNull('duration')->get(); - if ($taskDu) { - foreach ($taskDu as $item) { - $fakeRequest = Request::create( - '/active_task', // مسیر فرضی (مهم نیست) - 'POST', - ['task_id' => $item->task_id] // شناسه تسک - ); - if ($item->end == null) { - (new TaskController())->activeTask($fakeRequest); - } - } - } - $user->active_task = null; - $user->save(); - } + if ($attendance) { + // خروج — همه تسکهای باز کاربر بسته شود + app(TaskController::class)->endOpenDurationsForUser($user); //end break if ($break = AttendanceBreak::where('attendance_id', $attendance->id)->whereNull('break_end')->latest('break_start')->first()) { @@ -322,6 +306,7 @@ class AttendanceController extends Controller $break->save(); } $break = AttendanceBreak::where('attendance_id', $attendance->id)->sum('duration'); + $attendance->exited_at = Carbon::now()->format('Y-m-d H:i:s'); $start = Carbon::parse($attendance->entered_at)->format('H:i:s'); $end = Carbon::parse($attendance->exited_at)->format('H:i:s'); $duration = $attendance->duration($start, $end); @@ -330,10 +315,9 @@ class AttendanceController extends Controller $breakSec = strtotime("1970-01-01 " . $break) - strtotime("1970-01-01 00:00:00"); $durationSec = strtotime("1970-01-01 " . $duration) - strtotime("1970-01-01 00:00:00"); $durationSec -= $breakSec; - $duration = gmdate("H:i:s", $durationSec); + $duration = gmdate("H:i:s", max(0, $durationSec)); } - $attendance->exited_at = Carbon::now()->format('Y-m-d H:i:s'); $attendance->exit_location = ['lat' => $userLat, 'lng' => $userLng]; $attendance->ip_exit = $userLng; $attendance->distance_exit = $request->input('distance'); @@ -359,6 +343,8 @@ class AttendanceController extends Controller $attendance->office_location = ['lat' => $officeLat, 'lng' => $officeLng]; $attendance->save(); + app(LatePenaltyService::class)->syncAttendance($attendance); + $this->send($user->name, $attendance, 'attendance'); return redirect()->back()->with('message', [ @@ -648,6 +634,9 @@ class AttendanceController extends Controller $user->email = $request->email; $user->number = $request->number; $user->chatid = $request->chatid; + $user->birthday = $request->filled('birthday') + ? $this->fa2en_numbers($request->birthday) + : null; // اگر کاربر ادمین باشه، میتونه نقش رو تغییر بده if (!empty($_COOKIE['ad'])) { diff --git a/app/Http/Controllers/TaskController.php b/app/Http/Controllers/TaskController.php index f96a5f2f..a55e9ed3 100755 --- a/app/Http/Controllers/TaskController.php +++ b/app/Http/Controllers/TaskController.php @@ -21,6 +21,93 @@ use Carbon\CarbonInterval; class TaskController extends Controller { + /** + * بستن همه نوبتهای باز تسک برای یک کاربر (مثلاً هنگام خروج) + */ + public function endOpenDurationsForUser(User $user, $endedAt = null): int + { + $endedAt = $endedAt ? Carbon::parse($endedAt) : Carbon::now(); + $openItems = TaskDuration::query() + ->where('user_id', $user->id) + ->whereNotNull('start') + ->whereNull('end') + ->get(); + + $closed = 0; + foreach ($openItems as $item) { + $this->closeDurationRecord($item, $endedAt); + $closed++; + + $taskTitle = Tasks::where('id', $item->task_id)->value('title') ?? ('#' . $item->task_id); + $this->send_level1_admins( + "پایان خودکار تسک (خروج): «{$taskTitle}»\nتوسط: {$user->name}\nمدت این نوبت: {$item->duration}" + ); + } + + if ($closed > 0 || !empty($user->active_task)) { + $user->active_task = null; + $user->save(); + } + + return $closed; + } + + private function closeDurationRecord(TaskDuration $taskDU, Carbon $endedAt): TaskDuration + { + $start = Carbon::parse($taskDU->start); + $seconds = max(0, (int) $start->diffInSeconds($endedAt, false)); + $diff = sprintf( + '%02d:%02d:%02d', + intdiv($seconds, 3600), + intdiv($seconds % 3600, 60), + $seconds % 60 + ); + + $duration = $taskDU->duration; + if ($duration !== null && $duration !== '') { + [$h1, $m1, $s1] = array_map('intval', explode(':', $duration)); + $durationSeconds = $h1 * 3600 + $m1 * 60 + $s1; + $totalSeconds = $durationSeconds + $seconds; + $duration = sprintf( + '%02d:%02d:%02d', + intdiv($totalSeconds, 3600), + intdiv($totalSeconds % 3600, 60), + $totalSeconds % 60 + ); + } else { + $duration = $diff; + } + + $taskDU->end = $endedAt; + $taskDU->duration = $duration; + $taskDU->save(); + + return $taskDU; + } + + private function secondsBetween(?string $start, ?string $end): int + { + if (!$start || !$end) { + return 0; + } + + $s = Carbon::parse($start); + $e = Carbon::parse($end); + return max(0, (int) $s->diffInSeconds($e, false)); + } + + private function formatSeconds(int $seconds): string + { + $seconds = max(0, $seconds); + + return sprintf( + '%02d:%02d:%02d', + intdiv($seconds, 3600), + intdiv($seconds % 3600, 60), + $seconds % 60 + ); + } + private function taskChangeMessage(Tasks $task, array $before, array $after, ?User $actor): string { $lines = []; @@ -167,6 +254,28 @@ class TaskController extends Controller return User::whereIn('id', $assignees)->pluck('name')->all(); } + private function normalizeTaskDate(?string $value): ?string + { + if ($value === null) { + return null; + } + + $value = trim($value); + if ($value === '' || str_starts_with($value, '0000-00-00')) { + return null; + } + + if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $m)) { + return $m[1]; + } + + try { + return Carbon::parse($value)->format('Y-m-d'); + } catch (\Throwable $e) { + return null; + } + } + private function buildTasksPayload($tasks, array $usersById = []): array { $payload = []; @@ -175,8 +284,8 @@ class TaskController extends Controller 'id' => $task->id, 'title' => $task->title, 'description' => $task->description, - 'start_date' => $task->start_date, - 'target_date' => $task->target_date, + 'start_date' => $this->normalizeTaskDate($task->start_date ? (string) $task->start_date : null), + 'target_date' => $this->normalizeTaskDate($task->target_date ? (string) $task->target_date : null), 'finished_at' => $task->finished_at ?? null, 'priority' => $task->priority, 'state' => $task->state, @@ -221,8 +330,8 @@ class TaskController extends Controller 'id' => $task->id, 'title' => $task->title, 'description' => $task->description, - 'start_date' => $task->start_date, - 'target_date' => $task->target_date, + 'start_date' => $this->normalizeTaskDate($task->start_date ? (string) $task->start_date : null), + 'target_date' => $this->normalizeTaskDate($task->target_date ? (string) $task->target_date : null), 'finished_at' => $this->resolveTaskFinishedAtByTaskId($task->id), 'priority' => $task->priority, 'state' => $task->state, @@ -516,6 +625,18 @@ class TaskController extends Controller $data['completed_at_display'] ); + if (array_key_exists('start_date', $data)) { + $data['start_date'] = $this->normalizeTaskDate($data['start_date'] ?: null); + // خالی فرستادن نباید تاریخ قبلی را پاک کند مگر صریح null نشود + if ($data['start_date'] === null) { + unset($data['start_date']); + } + } + if (array_key_exists('target_date', $data)) { + $normalizedTarget = $this->normalizeTaskDate($data['target_date'] ?: null); + $data['target_date'] = $normalizedTarget; + } + $task->update($data); $after = $task->toArray(); @@ -567,9 +688,25 @@ class TaskController extends Controller } $user = User::where('user_name', $_COOKIE['user_identity'])->first(); - $data = $request->all(); - $data['created_by'] = $user->id; - $data['assignees'] = !empty($data['assignees']) ? json_encode($data['assignees']) : null; + $startDate = $this->normalizeTaskDate($request->input('start_date')); + if (!$startDate) { + return back()->withInput()->with('message', [ + 'color' => 'red', + 'text' => 'تاریخ شروع الزامی است. لطفاً از تقویم انتخاب کنید.', + ]); + } + + $data = [ + 'title' => $request->input('title'), + 'description' => $request->input('description'), + 'priority' => $request->input('priority', 'none'), + 'state' => $request->input('state', 1), + 'app' => $request->input('app') ?: null, + 'start_date' => $startDate, + 'target_date' => $this->normalizeTaskDate($request->input('target_date')), + 'created_by' => $user->id, + 'assignees' => !empty($request->assignees) ? json_encode($request->assignees) : null, + ]; $task = Tasks::create($data); @@ -580,16 +717,16 @@ class TaskController extends Controller $message_admin .= !empty($app) ? "\nاپ {$app->name_fa}" : null; $message_admin .= !empty($deadline) ? "\nددلاین {$deadline}" : null; $message_admin .= !empty($data['start_date']) ? "\nتاریخ شروع {$data['start_date']}" : null; - $message_admin .= !empty($data['state_fa']) ? "\nوضعیت: {$data['state_fa']}" : null; + $message_admin .= !empty($request->input('state_fa')) ? "\nوضعیت: {$request->input('state_fa')}" : null; $message_admin .= !empty($data['description']) ? "\nبا توضیحات:\n" . $data['description'] : null; $message_admin .= !empty($data['assignees']) ? "\nتوسط " . $user->name . " ساخته شد و به" : null; - if (!empty($data['assignees'])) { + if (!empty($request->assignees)) { foreach ($request->assignees as $assignee) { - $user = User::where('id', $assignee)->first(); - $message_admin .= "\n$user->name"; - if ($user && $user->bale !== null) { - $this->send_bale($user->bale, $message); + $assigneeUser = User::where('id', $assignee)->first(); + $message_admin .= "\n" . ($assigneeUser->name ?? $assignee); + if ($assigneeUser && $assigneeUser->bale !== null) { + $this->send_bale($assigneeUser->bale, $message); } } $message_admin .= "\nاختصاص یافت."; @@ -628,50 +765,18 @@ class TaskController extends Controller // اگر رکورد باز داریم => پایان if (!empty($taskDU) && ($taskDU->start !== null && $taskDU->end == null)) { -// dd('in if'); - - $attendance = new Attendance(); - $s = Carbon::parse($taskDU->start)->format('H:i:s'); - $e = Carbon::parse(now())->format('H:i:s'); - $duration = $taskDU->duration; - if ($e > $s) { - $diff = $attendance->duration($s, $e); - if ($duration !== null) { - [$h1, $m1, $s1] = explode(':', $duration); - $durationSeconds = $h1 * 3600 + $m1 * 60 + $s1; - - [$h2, $m2, $s2] = explode(':', $diff); - $diffSeconds = $h2 * 3600 + $m2 * 60 + $s2; - - $totalSeconds = $durationSeconds + $diffSeconds; - - $hours = floor($totalSeconds / 3600); - $minutes = floor(($totalSeconds % 3600) / 60); - $seconds = $totalSeconds % 60; - - $duration = sprintf('%02d:%02d:%02d', $hours, $minutes, $seconds); - } else { - $duration = $diff; - } - $taskDU->end = now(); - $taskDU->duration = $duration; - $user->active_task = null; - $taskDU->save(); - $user->save(); - $this->send_level1_admins("پایان تسک: «{$task->title}»\nتوسط: {$user->name}\nمدت این نوبت: {$duration}"); - return response()->json([ - 'action' => 'ended', - 'status' => true, - 'duration' => $duration, - 'start' => $taskDU->start, - 'end' => $taskDU->end, - 'finished_at' => $this->resolveTaskFinishedAtByTaskId($task->id), - ]); - } else - return response()->json([ - 'action' => 'end', - 'status' => false - ]); + $this->closeDurationRecord($taskDU, Carbon::now()); + $user->active_task = null; + $user->save(); + $this->send_level1_admins("پایان تسک: «{$task->title}»\nتوسط: {$user->name}\nمدت این نوبت: {$taskDU->duration}"); + return response()->json([ + 'action' => 'ended', + 'status' => true, + 'duration' => $taskDU->duration, + 'start' => $taskDU->start, + 'end' => $taskDU->end, + 'finished_at' => $this->resolveTaskFinishedAtByTaskId($task->id), + ]); } else { // شروع => همیشه یک رکورد جدید بساز $new_task_duration = TaskDuration::create([ @@ -695,6 +800,80 @@ class TaskController extends Controller } + public function updateTaskDuration(Request $request) + { + if (!isset($_COOKIE['user_identity'])) { + return response()->json(['status' => false, 'message' => 'unauthorized'], 401); + } + + $admin = User::where('user_name', $_COOKIE['user_identity'])->first(); + if (!$admin || (int) $admin->is_admin !== 1) { + return response()->json(['status' => false, 'message' => 'فقط ادمین میتواند زمان را ویرایش کند.'], 403); + } + + $request->validate([ + 'duration_id' => 'required|integer', + 'start' => 'required|date', + 'end' => 'nullable|date', + ]); + + $taskDU = TaskDuration::find($request->duration_id); + if (!$taskDU) { + return response()->json(['status' => false, 'message' => 'رکورد زمان یافت نشد.'], 404); + } + + $start = Carbon::parse($request->start); + $end = $request->filled('end') ? Carbon::parse($request->end) : null; + + if ($end && $end->lt($start)) { + return response()->json([ + 'status' => false, + 'message' => 'زمان پایان نمیتواند قبل از شروع باشد.', + ], 422); + } + + $taskDU->start = $start; + $taskDU->end = $end; + $taskDU->duration = $end ? $this->formatSeconds($this->secondsBetween($start->toDateTimeString(), $end->toDateTimeString())) : null; + $taskDU->save(); + + // اگر این نوبت هنوز باز است و مال همان کاربر فعال است، active_task را همگام کن + $owner = User::find($taskDU->user_id); + if ($owner) { + $stillOpen = TaskDuration::query() + ->where('user_id', $owner->id) + ->whereNull('end') + ->exists(); + if (!$stillOpen) { + $owner->active_task = null; + $owner->save(); + } + } + + $task = Tasks::find($taskDU->task_id); + $this->send_level1_admins( + "ویرایش زمان تسک: «" . ($task->title ?? $taskDU->task_id) . "»\n" + . "توسط ادمین: {$admin->name}\n" + . "شروع: " . $start->format('Y-m-d H:i') . "\n" + . "پایان: " . ($end ? $end->format('Y-m-d H:i') : 'باز') . "\n" + . "مدت: " . ($taskDU->duration ?? '-') + ); + + return response()->json([ + 'status' => true, + 'message' => 'زمان با موفقیت ذخیره شد.', + 'duration' => [ + 'id' => $taskDU->id, + 'task_id' => $taskDU->task_id, + 'user_id' => $taskDU->user_id, + 'start' => $taskDU->start, + 'end' => $taskDU->end, + 'duration' => $taskDU->duration, + ], + 'finished_at' => $this->resolveTaskFinishedAtByTaskId($taskDU->task_id), + ]); + } + public function addTask(Request $request) { diff --git a/app/Models/Attendance.php b/app/Models/Attendance.php index 94ffa043..6820cf31 100755 --- a/app/Models/Attendance.php +++ b/app/Models/Attendance.php @@ -25,6 +25,9 @@ class Attendance extends Model 'entered_at', 'exited_at', 'requested', + 'late_minutes', + 'daily_fine_minutes', + 'daily_fine_amount', ]; public function setEntryLocationAttribute($value) diff --git a/app/Models/LatePeriodFine.php b/app/Models/LatePeriodFine.php new file mode 100644 index 00000000..4e27fe10 --- /dev/null +++ b/app/Models/LatePeriodFine.php @@ -0,0 +1,30 @@ + 'date', + 'period_end' => 'date', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index c17a1e4f..3297431c 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -29,6 +29,7 @@ class User extends Authenticatable 'chatid', 'name', 'number', + 'birthday', 'password', 'user_name', 'is_admin', @@ -53,7 +54,9 @@ class User extends Authenticatable */ protected function casts(): array { - return []; + return [ + 'birthday' => 'date', + ]; } public function task() @@ -78,7 +81,7 @@ class User extends Authenticatable { return $this->hasMany(Attendance::class, 'user_identity', 'user_name') - ->select('id', 'user_identity', 'entered_at', 'exited_at', 'duration','requested', 'distance', 'distance_exit') + ->select('id', 'user_identity', 'entered_at', 'exited_at', 'duration', 'requested', 'distance', 'distance_exit', 'late_minutes', 'daily_fine_minutes', 'daily_fine_amount') ->orderBy('entered_at', 'desc'); diff --git a/app/Services/LatePenaltyService.php b/app/Services/LatePenaltyService.php new file mode 100644 index 00000000..e4659974 --- /dev/null +++ b/app/Services/LatePenaltyService.php @@ -0,0 +1,245 @@ + 0, + 'grace_late_minutes' => 0, + 'daily_fine_minutes' => 0, + 'daily_fine_amount' => 0, + ]; + } + + $entered = Carbon::parse($enteredAt); + $threshold = $entered->copy()->setTimeFromTimeString(self::WORK_START); + + if ($entered->lte($threshold)) { + return [ + 'late_minutes' => 0, + 'grace_late_minutes' => 0, + 'daily_fine_minutes' => 0, + 'daily_fine_amount' => 0, + ]; + } + + $lateMinutes = (int) ceil($threshold->diffInSeconds($entered) / 60); + // تأخیر داخل بازه ارفاق روزانه (۸:۳۰ تا ۸:۴۵) — مبنای جریمه هفتگی + $graceLateMinutes = min($lateMinutes, self::DAILY_GRACE_MINUTES); + $dailyFineMinutes = max(0, $lateMinutes - self::DAILY_GRACE_MINUTES); + + return [ + 'late_minutes' => $lateMinutes, + 'grace_late_minutes' => $graceLateMinutes, + 'daily_fine_minutes' => $dailyFineMinutes, + 'daily_fine_amount' => $dailyFineMinutes * self::FINE_PER_MINUTE, + ]; + } + + /** دقایق تأخیر داخل بازه ۸:۳۰–۸:۴۵ برای یک رکورد */ + public function graceLateMinutes(Attendance $att): int + { + $late = (int) ($att->late_minutes ?? 0); + if ($late <= 0) { + return 0; + } + + return min($late, self::DAILY_GRACE_MINUTES); + } + + public function syncAttendance(Attendance $attendance): Attendance + { + if (!$attendance->entered_at) { + $attendance->late_minutes = 0; + $attendance->daily_fine_minutes = 0; + $attendance->daily_fine_amount = 0; + $attendance->save(); + + return $attendance; + } + + // فقط اولین ورود هر روز برای جریمه تأخیر محاسبه میشود + $day = Carbon::parse($attendance->entered_at)->toDateString(); + $firstOfDay = Attendance::query() + ->where('user_identity', $attendance->user_identity) + ->whereDate('entered_at', $day) + ->orderBy('entered_at') + ->orderBy('id') + ->first(); + + if ($firstOfDay && (int) $firstOfDay->id !== (int) $attendance->id) { + $attendance->late_minutes = 0; + $attendance->daily_fine_minutes = 0; + $attendance->daily_fine_amount = 0; + $attendance->save(); + + return $attendance; + } + + $calc = $this->calculateFromEnteredAt($attendance->entered_at); + $attendance->late_minutes = $calc['late_minutes']; + $attendance->daily_fine_minutes = $calc['daily_fine_minutes']; + $attendance->daily_fine_amount = $calc['daily_fine_amount']; + $attendance->save(); + + // اگر این رکورد اولین ورود شد، بقیه همان روز را صفر کن + Attendance::query() + ->where('user_identity', $attendance->user_identity) + ->whereDate('entered_at', $day) + ->where('id', '!=', $attendance->id) + ->update([ + 'late_minutes' => 0, + 'daily_fine_minutes' => 0, + 'daily_fine_amount' => 0, + ]); + + return $attendance; + } + + public function weekBounds(?Carbon $date = null): array + { + $date = ($date ?? Carbon::now())->copy()->startOfDay(); + // هفته ایرانی: شنبه تا جمعه + $dayOfWeek = (int) $date->dayOfWeek; // 0=Sun ... 6=Sat + $daysFromSaturday = ($dayOfWeek + 1) % 7; + $start = $date->copy()->subDays($daysFromSaturday)->startOfDay(); + $end = $start->copy()->addDays(6)->endOfDay(); + + return [$start, $end]; + } + + public function jalaliMonthBounds(?Carbon $date = null): array + { + $j = Jalalian::fromCarbon($date ?? Carbon::now()); + $start = (new Jalalian($j->getYear(), $j->getMonth(), 1))->toCarbon()->startOfDay(); + $endDay = $j->getMonth() <= 6 ? 31 : ($j->getMonth() <= 11 ? 30 : ($j->isLeapYear() ? 30 : 29)); + $end = (new Jalalian($j->getYear(), $j->getMonth(), $endDay))->toCarbon()->endOfDay(); + + return [$start, $end]; + } + + public function firstEntriesBetween(Carbon $start, Carbon $end, ?string $userIdentity = null): Collection + { + $query = Attendance::query() + ->whereNotNull('entered_at') + ->whereBetween('entered_at', [$start, $end]) + ->orderBy('entered_at') + ->orderBy('id'); + + if ($userIdentity) { + $query->where('user_identity', $userIdentity); + } + + return $query->get() + ->groupBy(fn (Attendance $att) => $att->user_identity . '|' . Carbon::parse($att->entered_at)->toDateString()) + ->map(fn (Collection $items) => $items->first()) + ->values(); + } + + public function summarizeUserPeriod(string $userIdentity, Carbon $start, Carbon $end): array + { + $entries = $this->firstEntriesBetween($start, $end, $userIdentity); + $lateMinutes = (int) $entries->sum(fn (Attendance $att) => (int) ($att->late_minutes ?? 0)); + $graceLateMinutes = (int) $entries->sum(fn (Attendance $att) => $this->graceLateMinutes($att)); + $dailyFineAmount = (int) $entries->sum(fn (Attendance $att) => (int) ($att->daily_fine_amount ?? 0)); + + return [ + 'late_minutes' => $lateMinutes, + 'grace_late_minutes' => $graceLateMinutes, + 'daily_fine_amount' => $dailyFineAmount, + 'entries' => $entries, + ]; + } + + /** + * جریمه هفتگی = (جمع تأخیرهای ۸:۳۰–۸:۴۵ − ۱۵ دقیقه ارفاق) × ۲۰۰۰ + */ + public function computeWeeklyFine(int $graceLateMinutes): array + { + $fineMinutes = max(0, $graceLateMinutes - self::WEEKLY_ALLOWANCE_MINUTES); + + return [ + 'allowance_minutes' => self::WEEKLY_ALLOWANCE_MINUTES, + 'fine_minutes' => $fineMinutes, + 'fine_amount' => $fineMinutes * self::FINE_PER_MINUTE, + ]; + } + + public function formatAmount(int $amount): string + { + return number_format($amount) . ' تومان'; + } + + public function sendToAdmins(string $message): void + { + $admins = User::query() + ->whereNotNull('is_admin') + ->whereNotNull('bale') + ->select('bale') + ->get(); + + foreach ($admins as $admin) { + try { + Http::withoutVerifying()->post( + 'https://tapi.bale.ai/1482838710:sYbLzby0tuLRzHvP4d3LsbcNwg2sjSDLTAU/sendMessage', + [ + 'chat_id' => $admin->bale, + 'text' => $message, + ] + ); + } catch (\Exception $e) { + } + } + } + + public function userName(string $userIdentity): string + { + return User::where('user_name', $userIdentity)->value('name') ?: $userIdentity; + } + + public function storePeriodFine(array $data): LatePeriodFine + { + return LatePeriodFine::updateOrCreate( + [ + 'user_identity' => $data['user_identity'], + 'period_type' => $data['period_type'], + 'period_start' => $data['period_start'], + 'period_end' => $data['period_end'], + ], + $data + ); + } + + public function monthlyTotalFine(string $userIdentity, Carbon $monthStart, Carbon $monthEnd): int + { + $daily = (int) Attendance::query() + ->where('user_identity', $userIdentity) + ->whereBetween('entered_at', [$monthStart, $monthEnd]) + ->sum('daily_fine_amount'); + + $weekly = (int) LatePeriodFine::query() + ->where('user_identity', $userIdentity) + ->where('period_type', 'weekly') + ->whereBetween('period_start', [$monthStart->toDateString(), $monthEnd->toDateString()]) + ->sum('fine_amount'); + + return $daily + $weekly; + } +} diff --git a/database/migrations/2026_07_22_000001_add_birthday_to_users_table.php b/database/migrations/2026_07_22_000001_add_birthday_to_users_table.php new file mode 100644 index 00000000..8f12a260 --- /dev/null +++ b/database/migrations/2026_07_22_000001_add_birthday_to_users_table.php @@ -0,0 +1,22 @@ +date('birthday')->nullable()->after('number'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('birthday'); + }); + } +}; diff --git a/database/migrations/2026_07_22_120000_add_late_penalty_fields.php b/database/migrations/2026_07_22_120000_add_late_penalty_fields.php new file mode 100644 index 00000000..aadb2717 --- /dev/null +++ b/database/migrations/2026_07_22_120000_add_late_penalty_fields.php @@ -0,0 +1,45 @@ +unsignedInteger('late_minutes')->default(0); + $table->unsignedInteger('daily_fine_minutes')->default(0); + $table->unsignedInteger('daily_fine_amount')->default(0); + }); + + Schema::create('late_period_fines', function (Blueprint $table) { + $table->id(); + $table->string('user_identity'); + $table->string('period_type'); // weekly | monthly + $table->date('period_start'); + $table->date('period_end'); + $table->unsignedInteger('late_minutes')->default(0); + $table->unsignedInteger('allowance_minutes')->default(0); + $table->unsignedInteger('fine_minutes')->default(0); + $table->unsignedInteger('fine_amount')->default(0); + $table->unsignedInteger('daily_fine_amount')->default(0); + $table->unsignedInteger('weekly_fine_amount')->default(0); + $table->unsignedInteger('total_fine_amount')->default(0); + $table->timestamps(); + + $table->unique(['user_identity', 'period_type', 'period_start', 'period_end'], 'late_period_unique'); + $table->index(['period_type', 'period_start']); + }); + } + + public function down(): void + { + Schema::dropIfExists('late_period_fines'); + + Schema::table('attendances', function (Blueprint $table) { + $table->dropColumn(['late_minutes', 'daily_fine_minutes', 'daily_fine_amount']); + }); + } +}; diff --git a/resources/views/admin/admin.blade.php b/resources/views/admin/admin.blade.php index 3a649518..b42e73af 100755 --- a/resources/views/admin/admin.blade.php +++ b/resources/views/admin/admin.blade.php @@ -140,6 +140,7 @@ $usersChunked = $users->chunk(3);
وظیفه: {{ $u->active_task }}
@endifکارکرد ماه جاری: {{ $u->totaltime }}
+جریمه تأخیر ماه: {{ number_format($u->monthly_late_fine ?? 0) }} تومان
تعداد درخواست ها: {{ $u->req_counts }}
@if($userType == 'admin') diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 10c705cd..4f3c8df1 100755 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -1390,8 +1390,67 @@ } [data-theme="dark"] .clockpicker-popover { - background-color: #334155 !important; + background-color: #1e293b !important; color: #f1f5f9 !important; + border: 1px solid #475569 !important; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45) !important; + } + + [data-theme="dark"] .clockpicker-popover .popover-title { + background-color: #0f172a !important; + color: #e2e8f0 !important; + border-bottom: 1px solid #334155 !important; + } + + [data-theme="dark"] .clockpicker-popover .popover-content { + background-color: #1e293b !important; + } + + [data-theme="dark"] .clockpicker-popover .clockpicker-plate { + background-color: #0f172a !important; + border-color: #475569 !important; + } + + [data-theme="dark"] .clockpicker-popover .clockpicker-tick { + color: #e2e8f0 !important; + } + + [data-theme="dark"] .clockpicker-popover .clockpicker-tick.active, + [data-theme="dark"] .clockpicker-popover .clockpicker-tick:hover { + background-color: rgba(34, 211, 238, 0.28) !important; + color: #fff !important; + } + + [data-theme="dark"] .clockpicker-popover .clockpicker-button, + [data-theme="dark"] .clockpicker-popover button.clockpicker-button, + [data-theme="dark"] .clockpicker-popover .btn.clockpicker-button, + [data-theme="dark"] .clockpicker-popover .btn-default { + background-color: #0f172a !important; + background-image: none !important; + color: #f8fafc !important; + border-color: #475569 !important; + border-top: 1px solid #475569 !important; + } + + [data-theme="dark"] .clockpicker-popover .clockpicker-button:hover, + [data-theme="dark"] .clockpicker-popover button.clockpicker-button:hover, + [data-theme="dark"] .clockpicker-popover .btn.clockpicker-button:hover, + [data-theme="dark"] .clockpicker-popover .btn-default:hover { + background-color: #334155 !important; + color: #ffffff !important; + } + + [data-theme="dark"] .clockpicker-popover .clockpicker-canvas-bearing, + [data-theme="dark"] .clockpicker-popover .clockpicker-canvas-fg { + fill: #22d3ee !important; + } + + [data-theme="dark"] .clockpicker-popover .clockpicker-canvas-bg { + fill: rgba(34, 211, 238, 0.35) !important; + } + + [data-theme="dark"] .clockpicker-popover .clockpicker-canvas line { + stroke: #22d3ee !important; } [data-theme="dark"] .datepicker-plot-area, diff --git a/resources/views/attendance/index.blade.php b/resources/views/attendance/index.blade.php index 48ecd3c8..d5613d1c 100755 --- a/resources/views/attendance/index.blade.php +++ b/resources/views/attendance/index.blade.php @@ -180,11 +180,33 @@ if (!empty($user->tasks)) {