feat: 근무/출퇴근 설정 및 현장 관리 API 구현
- 근무 설정 API (GET/PUT /settings/work) - 근무유형, 소정근로시간, 연장근로시간, 근무요일, 출퇴근시간, 휴게시간 - 출퇴근 설정 API (GET/PUT /settings/attendance) - GPS 출퇴근, 허용 반경, 본사 위치 설정 - 현장 관리 API (CRUD /sites) - 현장 등록/수정/삭제, 활성화된 현장 목록(셀렉트박스용) - GPS 좌표 기반 위치 관리 마이그레이션: work_settings, attendance_settings, sites 테이블 모델: WorkSetting, AttendanceSetting, Site (BelongsToTenant, SoftDeletes) 서비스: WorkSettingService, SiteService Swagger 문서 및 i18n 메시지 키 추가
This commit is contained in:
100
app/Models/Tenants/AttendanceSetting.php
Normal file
100
app/Models/Tenants/AttendanceSetting.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* 출퇴근 설정 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property bool $use_gps
|
||||
* @property int $allowed_radius
|
||||
* @property string|null $hq_address
|
||||
* @property float|null $hq_latitude
|
||||
* @property float|null $hq_longitude
|
||||
*/
|
||||
class AttendanceSetting extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'attendance_settings';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'use_gps',
|
||||
'allowed_radius',
|
||||
'hq_address',
|
||||
'hq_latitude',
|
||||
'hq_longitude',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'use_gps' => 'boolean',
|
||||
'allowed_radius' => 'integer',
|
||||
'hq_latitude' => 'decimal:8',
|
||||
'hq_longitude' => 'decimal:8',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'use_gps' => false,
|
||||
'allowed_radius' => 100,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* GPS 설정 완료 여부
|
||||
*/
|
||||
public function isGpsConfigured(): bool
|
||||
{
|
||||
return $this->use_gps
|
||||
&& $this->hq_latitude !== null
|
||||
&& $this->hq_longitude !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표가 허용 범위 내인지 확인
|
||||
*/
|
||||
public function isWithinRadius(float $latitude, float $longitude): bool
|
||||
{
|
||||
if (! $this->isGpsConfigured()) {
|
||||
return true; // GPS 미설정 시 항상 허용
|
||||
}
|
||||
|
||||
$distance = $this->calculateDistance(
|
||||
$this->hq_latitude,
|
||||
$this->hq_longitude,
|
||||
$latitude,
|
||||
$longitude
|
||||
);
|
||||
|
||||
return $distance <= $this->allowed_radius;
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 좌표 간 거리 계산 (미터)
|
||||
* Haversine 공식 사용
|
||||
*/
|
||||
private function calculateDistance(float $lat1, float $lon1, float $lat2, float $lon2): float
|
||||
{
|
||||
$earthRadius = 6371000; // 지구 반경 (미터)
|
||||
|
||||
$lat1Rad = deg2rad($lat1);
|
||||
$lat2Rad = deg2rad($lat2);
|
||||
$deltaLat = deg2rad($lat2 - $lat1);
|
||||
$deltaLon = deg2rad($lon2 - $lon1);
|
||||
|
||||
$a = sin($deltaLat / 2) * sin($deltaLat / 2) +
|
||||
cos($lat1Rad) * cos($lat2Rad) *
|
||||
sin($deltaLon / 2) * sin($deltaLon / 2);
|
||||
|
||||
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||
|
||||
return $earthRadius * $c;
|
||||
}
|
||||
}
|
||||
125
app/Models/Tenants/Site.php
Normal file
125
app/Models/Tenants/Site.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Models\Members\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 현장 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property string $name
|
||||
* @property string|null $address
|
||||
* @property float|null $latitude
|
||||
* @property float|null $longitude
|
||||
* @property bool $is_active
|
||||
* @property int|null $created_by
|
||||
* @property int|null $updated_by
|
||||
* @property int|null $deleted_by
|
||||
*/
|
||||
class Site extends Model
|
||||
{
|
||||
use BelongsToTenant, ModelTrait, SoftDeletes;
|
||||
|
||||
protected $table = 'sites';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'name',
|
||||
'address',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'is_active',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'latitude' => 'decimal:8',
|
||||
'longitude' => 'decimal:8',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'is_active' => true,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 생성자
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정자
|
||||
*/
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* GPS 좌표 설정 여부
|
||||
*/
|
||||
public function hasCoordinates(): bool
|
||||
{
|
||||
return $this->latitude !== null && $this->longitude !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표가 허용 범위 내인지 확인
|
||||
*/
|
||||
public function isWithinRadius(float $latitude, float $longitude, int $radiusMeters = 100): bool
|
||||
{
|
||||
if (! $this->hasCoordinates()) {
|
||||
return true; // 좌표 미설정 시 항상 허용
|
||||
}
|
||||
|
||||
$distance = $this->calculateDistance(
|
||||
$this->latitude,
|
||||
$this->longitude,
|
||||
$latitude,
|
||||
$longitude
|
||||
);
|
||||
|
||||
return $distance <= $radiusMeters;
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 좌표 간 거리 계산 (미터)
|
||||
*/
|
||||
private function calculateDistance(float $lat1, float $lon1, float $lat2, float $lon2): float
|
||||
{
|
||||
$earthRadius = 6371000;
|
||||
|
||||
$lat1Rad = deg2rad($lat1);
|
||||
$lat2Rad = deg2rad($lat2);
|
||||
$deltaLat = deg2rad($lat2 - $lat1);
|
||||
$deltaLon = deg2rad($lon2 - $lon1);
|
||||
|
||||
$a = sin($deltaLat / 2) * sin($deltaLat / 2) +
|
||||
cos($lat1Rad) * cos($lat2Rad) *
|
||||
sin($deltaLon / 2) * sin($deltaLon / 2);
|
||||
|
||||
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||
|
||||
return $earthRadius * $c;
|
||||
}
|
||||
}
|
||||
110
app/Models/Tenants/WorkSetting.php
Normal file
110
app/Models/Tenants/WorkSetting.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* 근무 설정 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property string $work_type
|
||||
* @property int $standard_hours
|
||||
* @property int $overtime_hours
|
||||
* @property int $overtime_limit
|
||||
* @property array|null $work_days
|
||||
* @property string $start_time
|
||||
* @property string $end_time
|
||||
* @property int $break_minutes
|
||||
* @property string|null $break_start
|
||||
* @property string|null $break_end
|
||||
*/
|
||||
class WorkSetting extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'work_settings';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'work_type',
|
||||
'standard_hours',
|
||||
'overtime_hours',
|
||||
'overtime_limit',
|
||||
'work_days',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'break_minutes',
|
||||
'break_start',
|
||||
'break_end',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'work_days' => 'array',
|
||||
'standard_hours' => 'integer',
|
||||
'overtime_hours' => 'integer',
|
||||
'overtime_limit' => 'integer',
|
||||
'break_minutes' => 'integer',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'work_type' => 'fixed',
|
||||
'standard_hours' => 40,
|
||||
'overtime_hours' => 12,
|
||||
'overtime_limit' => 52,
|
||||
'start_time' => '09:00:00',
|
||||
'end_time' => '18:00:00',
|
||||
'break_minutes' => 60,
|
||||
'break_start' => '12:00:00',
|
||||
'break_end' => '13:00:00',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 상수 정의
|
||||
// =========================================================================
|
||||
|
||||
public const TYPE_FIXED = 'fixed'; // 고정 근무
|
||||
|
||||
public const TYPE_FLEXIBLE = 'flexible'; // 유연 근무
|
||||
|
||||
public const TYPE_CUSTOM = 'custom'; // 커스텀 근무
|
||||
|
||||
public const WORK_TYPES = [
|
||||
self::TYPE_FIXED,
|
||||
self::TYPE_FLEXIBLE,
|
||||
self::TYPE_CUSTOM,
|
||||
];
|
||||
|
||||
public const DEFAULT_WORK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri'];
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 근무 유형 라벨
|
||||
*/
|
||||
public function getWorkTypeLabelAttribute(): string
|
||||
{
|
||||
return match ($this->work_type) {
|
||||
self::TYPE_FIXED => '고정근무',
|
||||
self::TYPE_FLEXIBLE => '유연근무',
|
||||
self::TYPE_CUSTOM => '커스텀',
|
||||
default => $this->work_type,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 근무시간 계산 (분)
|
||||
*/
|
||||
public function getDailyWorkMinutesAttribute(): int
|
||||
{
|
||||
$start = strtotime($this->start_time);
|
||||
$end = strtotime($this->end_time);
|
||||
$totalMinutes = ($end - $start) / 60;
|
||||
|
||||
return (int) ($totalMinutes - $this->break_minutes);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user