Changes to holidays and the overlap between this year and the previous year

This commit is contained in:
2026-09-23 12:20:39 +03:30
parent 5e306564b2
commit 0fd5867701
15 changed files with 653 additions and 62 deletions
@@ -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;
}
}
+63 -17
View File
@@ -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', [
+20
View File
@@ -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
View File
@@ -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;
}
+28
View File
@@ -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) {