Adding delay and penalty calculations

This commit is contained in:
2026-07-22 14:10:05 +03:30
parent 28bf82cde9
commit e1f86e6ffe
21 changed files with 1285 additions and 99 deletions
@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Morilog\Jalali\Jalalian;
class NotifyUpcomingBirthdays extends Command
{
protected $signature = 'birthdays:notify';
protected $description = 'Send Bale reminder to admins one day before employee birthdays';
public function handle(): int
{
$tomorrow = Jalalian::now()->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) {
}
}
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\LatePenaltyService;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Morilog\Jalali\Jalalian;
class SendDailyLateReport extends Command
{
protected $signature = 'late:daily-report {--date=}';
protected $description = 'Send daily late-penalty report to admins via Bale';
public function handle(LatePenaltyService $service): int
{
$date = $this->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;
}
}
@@ -0,0 +1,107 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\LatePenaltyService;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Morilog\Jalali\Jalalian;
class SendMonthlyLateReport extends Command
{
protected $signature = 'late:monthly-report {--date=}';
protected $description = 'Send monthly late-penalty report to admins via Bale';
public function handle(LatePenaltyService $service): int
{
$forcedDate = $this->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;
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\LatePenaltyService;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Morilog\Jalali\Jalalian;
class SendWeeklyLateReport extends Command
{
protected $signature = 'late:weekly-report {--date=}';
protected $description = 'Send weekly late-penalty report to admins via Bale';
public function handle(LatePenaltyService $service): int
{
$anchor = $this->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;
}
}
+8
View File
@@ -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);
+11 -22
View File
@@ -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'])) {
+236 -57
View File
@@ -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)
{
+3
View File
@@ -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)
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LatePeriodFine extends Model
{
protected $fillable = [
'user_identity',
'period_type',
'period_start',
'period_end',
'late_minutes',
'allowance_minutes',
'fine_minutes',
'fine_amount',
'daily_fine_amount',
'weekly_fine_amount',
'total_fine_amount',
];
protected function casts(): array
{
return [
'period_start' => 'date',
'period_end' => 'date',
];
}
}
+5 -2
View File
@@ -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');
+245
View File
@@ -0,0 +1,245 @@
<?php
namespace App\Services;
use App\Models\Attendance;
use App\Models\LatePeriodFine;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Morilog\Jalali\Jalalian;
class LatePenaltyService
{
public const WORK_START = '08:30';
public const DAILY_GRACE_MINUTES = 15;
public const WEEKLY_ALLOWANCE_MINUTES = 15;
public const FINE_PER_MINUTE = 2000;
public function calculateFromEnteredAt(?string $enteredAt): array
{
if (!$enteredAt) {
return [
'late_minutes' => 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;
}
}