Changes to holidays and the overlap between this year and the previous year
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Morilog\Jalali\Jalalian;
|
||||
|
||||
class RemindHolidayCalendarUpdate extends Command
|
||||
{
|
||||
protected $signature = 'holidays:remind';
|
||||
|
||||
protected $description = 'یادآوری ثبت تعطیلات رسمی سالهای بعد از اتمام پوشش تقویم پروژه';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$coveredUntil = SeedIranianHolidays::coveredUntilYear();
|
||||
if ($coveredUntil < 1300) {
|
||||
$this->warn('فایل تعطیلات خالی یا نامعتبر است.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$now = Jalalian::now();
|
||||
$currentYear = $now->getYear();
|
||||
|
||||
// از ۲۰ اسفند سال پوششدادهشده، یا هر زمان بعد از آن سال
|
||||
$shouldRemind = $currentYear > $coveredUntil
|
||||
|| ($currentYear === $coveredUntil && $now->getMonth() === 12 && $now->getDay() >= 20);
|
||||
|
||||
if (!$shouldRemind) {
|
||||
$this->info("هنوز نیازی به یادآوری نیست (پوشش تا {$coveredUntil}، الان {$currentYear}).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$yearsToRequest = [];
|
||||
for ($y = $coveredUntil + 1; $y <= max($currentYear + 1, $coveredUntil + 1); $y++) {
|
||||
$yearsToRequest[] = $y;
|
||||
}
|
||||
$yearsToRequest = array_values(array_unique($yearsToRequest));
|
||||
$yearsLabel = implode(' و ', $yearsToRequest);
|
||||
|
||||
$cacheKey = 'holidays_remind_' . implode('_', $yearsToRequest);
|
||||
if (Cache::has($cacheKey)) {
|
||||
$this->info('یادآوری اخیراً ارسال شده؛ این ماه دوباره ارسال نمیشود.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$message = "📅 یادآوری تعطیلات رسمی\n\n"
|
||||
. "پوشش تعطیلات در سیستم تا سال {$coveredUntil} است.\n"
|
||||
. "لطفاً تعطیلات رسمی سال {$yearsLabel} را طبق تقویم رسمی ایران به فایل\n"
|
||||
. "database/data/iranian_official_holidays.php\n"
|
||||
. "اضافه کنید و سپس اجرا کنید:\n"
|
||||
. "php artisan holidays:seed";
|
||||
|
||||
$this->notifyAdmins($message);
|
||||
Cache::put($cacheKey, true, now()->addDays(30));
|
||||
|
||||
$this->info("یادآوری برای سال(های) {$yearsLabel} به ادمینها ارسال شد.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function notifyAdmins(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,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Holiday;
|
||||
use Illuminate\Console\Command;
|
||||
use Morilog\Jalali\Jalalian;
|
||||
|
||||
class SeedIranianHolidays extends Command
|
||||
{
|
||||
protected $signature = 'holidays:seed {--year= : فقط یک سال شمسی مشخص}';
|
||||
|
||||
protected $description = 'ثبت/بهروزرسانی تعطیلات رسمی ایران از فایل تقویم پروژه';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$path = database_path('data/iranian_official_holidays.php');
|
||||
if (!is_file($path)) {
|
||||
$this->error('فایل تعطیلات یافت نشد: ' . $path);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
/** @var array<int, list<array{month:int, day:int, title:string}>> $years */
|
||||
$years = require $path;
|
||||
$onlyYear = $this->option('year') !== null ? (int) $this->option('year') : null;
|
||||
|
||||
$count = 0;
|
||||
foreach ($years as $jalaliYear => $days) {
|
||||
if ($onlyYear !== null && (int) $jalaliYear !== $onlyYear) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($days as $day) {
|
||||
try {
|
||||
$gregorian = (new Jalalian((int) $jalaliYear, (int) $day['month'], (int) $day['day']))
|
||||
->toCarbon()
|
||||
->format('Y-m-d');
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn("رد شد: {$jalaliYear}/{$day['month']}/{$day['day']} — {$e->getMessage()}");
|
||||
continue;
|
||||
}
|
||||
|
||||
Holiday::updateOrCreate(
|
||||
['date' => $gregorian],
|
||||
['title' => $day['title']]
|
||||
);
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->info("سال {$jalaliYear}: " . count($days) . ' روز');
|
||||
}
|
||||
|
||||
$this->info("جمع: {$count} تعطیل رسمی ثبت/بهروز شد.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** آخرین سال شمسی پوششدادهشده در فایل داده */
|
||||
public static function coveredUntilYear(): int
|
||||
{
|
||||
$path = database_path('data/iranian_official_holidays.php');
|
||||
if (!is_file($path)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$years = array_keys(require $path);
|
||||
|
||||
return $years ? (int) max($years) : 0;
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,17 @@ class AdminController extends Controller
|
||||
foreach ($pendingForUser->values() as $index => $attendance) {
|
||||
$raw = $rawValues[$index] ?? null;
|
||||
if (!$raw) {
|
||||
// Redis موجود است ولی کلید نیست → درخواست قبلاً تایید/رد شده؛ پرچم کهنه را پاک کن
|
||||
$attendance->requested = null;
|
||||
$attendance->save();
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw);
|
||||
if (($decoded->function ?? null) === 'rejected') {
|
||||
Redis::del("att-tempreq:{$user->user_name}:{$attendance->id}");
|
||||
$attendance->requested = null;
|
||||
$attendance->save();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -219,12 +230,15 @@ class AdminController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pendingForUser as $attendance) {
|
||||
if (isset($loadedIds[(string) $attendance->id])) {
|
||||
continue;
|
||||
}
|
||||
// فقط وقتی Redis در دسترس نیست از fallback استفاده کن
|
||||
if (!$redisAvailable) {
|
||||
foreach ($pendingForUser as $attendance) {
|
||||
if (isset($loadedIds[(string) $attendance->id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rec['attendances'][] = $this->buildFallbackRequestFromAttendance($attendance);
|
||||
$rec['attendances'][] = $this->buildFallbackRequestFromAttendance($attendance);
|
||||
}
|
||||
}
|
||||
|
||||
$pendingById = $pendingForUser->keyBy('id');
|
||||
@@ -239,6 +253,46 @@ class AdminController extends Controller
|
||||
return $records;
|
||||
}
|
||||
|
||||
private function removeCreateRequest(string $userid, object $data): void
|
||||
{
|
||||
$key = "att-tempreq:creates:{$userid}";
|
||||
try {
|
||||
$items = Redis::lrange($key, 0, -1);
|
||||
} catch (\Throwable) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
$decoded = json_decode($item);
|
||||
if (!$decoded) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sameUser = ($decoded->userid ?? null) === ($data->userid ?? null);
|
||||
$sameEnter = (string) ($decoded->oldenter ?? '') === (string) ($data->oldenter ?? '');
|
||||
$sameExit = (string) ($decoded->oldexit ?? '') === (string) ($data->oldexit ?? '');
|
||||
|
||||
if ($sameUser && $sameEnter && $sameExit) {
|
||||
Redis::lrem($key, 1, $item);
|
||||
}
|
||||
}
|
||||
|
||||
Redis::del("att-tempreq:{$userid}:new");
|
||||
}
|
||||
|
||||
private function clearAttendanceRequestFlag($attendanceId): void
|
||||
{
|
||||
if (!$attendanceId || $attendanceId === 'new') {
|
||||
return;
|
||||
}
|
||||
|
||||
$att = Attendance::find($attendanceId);
|
||||
if ($att) {
|
||||
$att->requested = null;
|
||||
$att->save();
|
||||
}
|
||||
}
|
||||
|
||||
private function enrichUsersForDashboard($users): void
|
||||
{
|
||||
$userNames = $users->pluck('user_name');
|
||||
@@ -579,7 +633,7 @@ class AdminController extends Controller
|
||||
}
|
||||
|
||||
$record->duration = $duration ?? $record->duration;
|
||||
$record->requested = '';
|
||||
$record->requested = null;
|
||||
$record->save();
|
||||
app(LatePenaltyService::class)->syncAttendance($record);
|
||||
|
||||
@@ -617,8 +671,7 @@ class AdminController extends Controller
|
||||
: 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->removeCreateRequest($data->userid, $data);
|
||||
$this->decrementUserRequestCount($data->userid);
|
||||
|
||||
tel_notif($data->userid, "تایید");
|
||||
@@ -633,18 +686,11 @@ class AdminController extends Controller
|
||||
if ($request->update == 'reject') {
|
||||
if ($data->function == 'delete' || $data->function == 'edit') {
|
||||
Redis::connection()->del("att-tempreq:{$data->userid}:{$data->id}");
|
||||
$this->clearAttendanceRequestFlag($data->id ?? null);
|
||||
} elseif ($data->function == 'new') {
|
||||
$create_requests_key = "att-tempreq:creates:{$data->userid}";
|
||||
Redis::connection()->lrem($create_requests_key, 1, json_encode($data));
|
||||
$this->removeCreateRequest($data->userid, $data);
|
||||
}
|
||||
|
||||
// dd($data->id);
|
||||
if ($data->id !== "new") {
|
||||
$att = Attendance::find($data->id);
|
||||
// dd($att);
|
||||
$att->requested = null;
|
||||
$att->save();
|
||||
}
|
||||
tel_notif($data->userid, "رد");
|
||||
$this->decrementUserRequestCount($data->userid);
|
||||
return redirect()->back()->with('message', [
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Holiday extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'date',
|
||||
'title',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date' => 'date',
|
||||
];
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -111,16 +111,17 @@ class User extends Authenticatable
|
||||
if (!$date) {
|
||||
continue;
|
||||
}
|
||||
$month = $date->format('%B');
|
||||
$key = sprintf('%04d-%02d', $date->getYear(), $date->getMonth());
|
||||
|
||||
if (!isset($result[$month])) {
|
||||
$result[$month] = [];
|
||||
if (!isset($result[$key])) {
|
||||
$result[$key] = [];
|
||||
}
|
||||
|
||||
$result[$month][] = $att;
|
||||
$result[$key][] = $att;
|
||||
}
|
||||
}
|
||||
|
||||
krsort($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Attendance;
|
||||
use App\Models\Holiday;
|
||||
use App\Models\LatePeriodFine;
|
||||
use App\Models\LeaveRequest;
|
||||
use App\Models\User;
|
||||
@@ -18,6 +19,22 @@ class LatePenaltyService
|
||||
public const WEEKLY_ALLOWANCE_MINUTES = 15;
|
||||
public const FINE_PER_MINUTE = 2000;
|
||||
|
||||
/** جمعه و تعطیلات رسمی روز کاری نیستند؛ ورود در این روزها اضافهکاری است. */
|
||||
public function isWorkDay(Carbon $date): bool
|
||||
{
|
||||
if ($date->isFriday()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return !Holiday::query()
|
||||
->whereDate('date', $date->toDateString())
|
||||
->exists();
|
||||
} catch (\Throwable $e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public function calculateFromEnteredAt(?string $enteredAt, ?string $userIdentity = null): array
|
||||
{
|
||||
if (!$enteredAt) {
|
||||
@@ -31,6 +48,17 @@ class LatePenaltyService
|
||||
|
||||
// فقط تا دقیقه؛ ثانیه نادیده گرفته میشود
|
||||
$entered = Carbon::parse($enteredAt)->startOfMinute();
|
||||
|
||||
// جمعه / تعطیل → بدون تأخیر و جریمه
|
||||
if (!$this->isWorkDay($entered)) {
|
||||
return [
|
||||
'late_minutes' => 0,
|
||||
'grace_late_minutes' => 0,
|
||||
'daily_fine_minutes' => 0,
|
||||
'daily_fine_amount' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$threshold = $entered->copy()->setTimeFromTimeString(self::WORK_START)->startOfMinute();
|
||||
|
||||
if ($userIdentity) {
|
||||
|
||||
Reference in New Issue
Block a user