367 lines
13 KiB
PHP
367 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Attendance;
|
|
use App\Models\Holiday;
|
|
use App\Models\LatePeriodFine;
|
|
use App\Models\LeaveRequest;
|
|
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;
|
|
|
|
/** @var array<string, string> */
|
|
private array $workStartCache = [];
|
|
|
|
/** ساعت شروع کار کاربر (H:i)؛ اگر ادمین تعیین نکرده باشد ۰۸:۳۰ */
|
|
public function workStartFor(?string $userIdentity): string
|
|
{
|
|
if (!$userIdentity) {
|
|
return self::WORK_START;
|
|
}
|
|
|
|
if (!array_key_exists($userIdentity, $this->workStartCache)) {
|
|
$value = null;
|
|
try {
|
|
$value = User::where('user_name', $userIdentity)->value('work_start_time');
|
|
} catch (\Throwable $e) {
|
|
}
|
|
|
|
$this->workStartCache[$userIdentity] = $value ? substr((string) $value, 0, 5) : self::WORK_START;
|
|
}
|
|
|
|
return $this->workStartCache[$userIdentity];
|
|
}
|
|
|
|
public function forgetWorkStart(string $userIdentity): void
|
|
{
|
|
unset($this->workStartCache[$userIdentity]);
|
|
}
|
|
|
|
/** جمعه و تعطیلات رسمی روز کاری نیستند؛ ورود در این روزها اضافهکاری است. */
|
|
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) {
|
|
return [
|
|
'late_minutes' => 0,
|
|
'grace_late_minutes' => 0,
|
|
'daily_fine_minutes' => 0,
|
|
'daily_fine_amount' => 0,
|
|
];
|
|
}
|
|
|
|
// فقط تا دقیقه؛ ثانیه نادیده گرفته میشود
|
|
$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($this->workStartFor($userIdentity))->startOfMinute();
|
|
|
|
if ($userIdentity) {
|
|
$leaveThreshold = $this->approvedLeaveWorkThreshold($userIdentity, $entered);
|
|
if ($leaveThreshold === false) {
|
|
return [
|
|
'late_minutes' => 0,
|
|
'grace_late_minutes' => 0,
|
|
'daily_fine_minutes' => 0,
|
|
'daily_fine_amount' => 0,
|
|
];
|
|
}
|
|
if ($leaveThreshold instanceof Carbon) {
|
|
$threshold = $leaveThreshold->copy()->startOfMinute();
|
|
}
|
|
}
|
|
|
|
if ($entered->lte($threshold)) {
|
|
return [
|
|
'late_minutes' => 0,
|
|
'grace_late_minutes' => 0,
|
|
'daily_fine_minutes' => 0,
|
|
'daily_fine_amount' => 0,
|
|
];
|
|
}
|
|
|
|
$lateMinutes = (int) $threshold->diffInMinutes($entered);
|
|
// تأخیر داخل بازه ارفاق روزانه (۸:۳۰ تا ۸:۴۵) — مبنای جریمه هفتگی
|
|
$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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* مرخصی تأییدشده: false = معاف کامل، Carbon = شروع مؤثر کار، null = بدون تغییر.
|
|
*/
|
|
public function approvedLeaveWorkThreshold(string $userIdentity, Carbon $entered): Carbon|false|null
|
|
{
|
|
$day = $entered->toDateString();
|
|
$workStart = $entered->copy()->setTimeFromTimeString($this->workStartFor($userIdentity))->startOfMinute();
|
|
|
|
$leaves = LeaveRequest::query()
|
|
->where('user_identity', $userIdentity)
|
|
->where('status', 'approved')
|
|
->whereDate('date_from', '<=', $day)
|
|
->whereDate('date_to', '>=', $day)
|
|
->get();
|
|
|
|
$latestMorningLeaveEnd = null;
|
|
|
|
foreach ($leaves as $leave) {
|
|
if ($leave->type === 'daily') {
|
|
return false;
|
|
}
|
|
|
|
if ($leave->type !== 'hourly' || !$leave->time_from || !$leave->time_to) {
|
|
continue;
|
|
}
|
|
|
|
$leaveStart = Carbon::parse($day . ' ' . $leave->time_from)->startOfMinute();
|
|
$leaveEnd = Carbon::parse($day . ' ' . $leave->time_to)->startOfMinute();
|
|
|
|
// مرخصی صبح که شروع کار را پوشش میدهد
|
|
if ($leaveStart->lte($workStart) && $leaveEnd->gt($workStart)) {
|
|
if ($entered->lte($leaveEnd)) {
|
|
return false;
|
|
}
|
|
if ($latestMorningLeaveEnd === null || $leaveEnd->gt($latestMorningLeaveEnd)) {
|
|
$latestMorningLeaveEnd = $leaveEnd;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $latestMorningLeaveEnd;
|
|
}
|
|
|
|
/** دقایق تأخیر داخل بازه ۸:۳۰–۸:۴۵ برای یک رکورد */
|
|
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->user_identity);
|
|
$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
|
|
{
|
|
$carbon = $date ?? Carbon::now();
|
|
if ($carbon->year < 1000 || $carbon->year > 3000) {
|
|
$carbon = Carbon::now();
|
|
}
|
|
|
|
$j = Jalalian::fromCarbon($carbon);
|
|
$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;
|
|
}
|
|
}
|