feat(API): 직책/직원/근태 관리 API 개선

- Position 모델: key 필드 추가 및 마이그레이션
- PositionSeeder: 기본 직책 시더 추가
- TenantUserProfile: 프로필 이미지 관련 필드 추가
- Employee Request: 직원 등록/수정 요청 검증 강화
- EmployeeService: 직원 관리 서비스 로직 개선
- AttendanceService: 근태 관리 서비스 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 17:25:13 +09:00
parent 08c9a74cbc
commit d6e18fb54e
9 changed files with 381 additions and 62 deletions

View File

@@ -48,7 +48,7 @@ public function rules(): array
'bank_account.bankName' => 'nullable|string|max:50', 'bank_account.bankName' => 'nullable|string|max:50',
'bank_account.accountNumber' => 'nullable|string|max:50', 'bank_account.accountNumber' => 'nullable|string|max:50',
'bank_account.accountHolder' => 'nullable|string|max:50', 'bank_account.accountHolder' => 'nullable|string|max:50',
'work_type' => 'nullable|in:regular,daily,temporary,external', 'work_type' => 'nullable|in:regular,contract,parttime,intern',
'contract_info' => 'nullable|array', 'contract_info' => 'nullable|array',
'contract_info.start_date' => 'nullable|date', 'contract_info.start_date' => 'nullable|date',
'contract_info.end_date' => 'nullable|date', 'contract_info.end_date' => 'nullable|date',

View File

@@ -54,7 +54,7 @@ public function rules(): array
'bank_account.bankName' => 'nullable|string|max:50', 'bank_account.bankName' => 'nullable|string|max:50',
'bank_account.accountNumber' => 'nullable|string|max:50', 'bank_account.accountNumber' => 'nullable|string|max:50',
'bank_account.accountHolder' => 'nullable|string|max:50', 'bank_account.accountHolder' => 'nullable|string|max:50',
'work_type' => 'nullable|in:regular,daily,temporary,external', 'work_type' => 'nullable|in:regular,contract,parttime,intern',
'contract_info' => 'nullable|array', 'contract_info' => 'nullable|array',
'contract_info.start_date' => 'nullable|date', 'contract_info.start_date' => 'nullable|date',
'contract_info.end_date' => 'nullable|date', 'contract_info.end_date' => 'nullable|date',

View File

@@ -45,6 +45,15 @@ class Attendance extends Model
'deleted_by', 'deleted_by',
]; ];
/**
* JSON 응답에 포함할 accessor
*/
protected $appends = [
'check_in',
'check_out',
'break_minutes',
];
/** /**
* 기본값 설정 * 기본값 설정
*/ */
@@ -85,18 +94,44 @@ public function updater(): BelongsTo
// ========================================================================= // =========================================================================
/** /**
* 출근 시간 * 출근 시간 (가장 빠른 출근)
* check_ins 배열이 있으면 가장 빠른 시간 반환, 없으면 레거시 check_in 사용
*/ */
public function getCheckInAttribute(): ?string public function getCheckInAttribute(): ?string
{ {
$checkIns = $this->json_details['check_ins'] ?? [];
if (! empty($checkIns)) {
$times = array_filter(array_map(fn ($entry) => $entry['time'] ?? null, $checkIns));
if (! empty($times)) {
sort($times);
return $times[0];
}
}
// 레거시 호환: 기존 check_in 필드
return $this->json_details['check_in'] ?? null; return $this->json_details['check_in'] ?? null;
} }
/** /**
* 퇴근 시간 * 퇴근 시간 (가장 늦은 퇴근)
* check_outs 배열이 있으면 가장 늦은 시간 반환, 없으면 레거시 check_out 사용
*/ */
public function getCheckOutAttribute(): ?string public function getCheckOutAttribute(): ?string
{ {
$checkOuts = $this->json_details['check_outs'] ?? [];
if (! empty($checkOuts)) {
$times = array_filter(array_map(fn ($entry) => $entry['time'] ?? null, $checkOuts));
if (! empty($times)) {
rsort($times);
return $times[0];
}
}
// 레거시 호환: 기존 check_out 필드
return $this->json_details['check_out'] ?? null; return $this->json_details['check_out'] ?? null;
} }
@@ -134,6 +169,50 @@ public function getWorkMinutesAttribute(): ?int
: null; : null;
} }
/**
* 휴게 시간 (분 단위)
ㅣ * 1. json_details에 저장된 값이 있으면 반환
* 2. 없으면 check_in/check_out 기준으로 WorkSetting에서 실시간 계산
*/
public function getBreakMinutesAttribute(): ?int
{
// 1. 이미 계산된 값이 있으면 사용
if (isset($this->json_details['break_minutes'])) {
return (int) $this->json_details['break_minutes'];
}
// 2. 레거시 데이터: check_in, check_out이 있으면 실시간 계산
$checkIn = $this->check_in;
$checkOut = $this->check_out;
if (! $checkIn || ! $checkOut) {
return null;
}
// WorkSetting에서 휴게시간 설정 조회
$workSetting = WorkSetting::where('tenant_id', $this->tenant_id)->first();
if (! $workSetting || ! $workSetting->break_start || ! $workSetting->break_end) {
return null;
}
try {
$checkInCarbon = \Carbon\Carbon::createFromFormat('H:i:s', $checkIn);
$checkOutCarbon = \Carbon\Carbon::createFromFormat('H:i:s', $checkOut);
$breakStart = \Carbon\Carbon::createFromFormat('H:i:s', $workSetting->break_start);
$breakEnd = \Carbon\Carbon::createFromFormat('H:i:s', $workSetting->break_end);
// 출근~퇴근 시간이 휴게시간을 포함하면 휴게시간 적용
if ($checkInCarbon->lte($breakStart) && $checkOutCarbon->gte($breakEnd)) {
return (int) $breakStart->diffInMinutes($breakEnd);
}
} catch (\Exception $e) {
return null;
}
return null;
}
/** /**
* 초과 근무 시간 (분 단위) * 초과 근무 시간 (분 단위)
*/ */

View File

@@ -13,6 +13,7 @@
* @property int $id * @property int $id
* @property int $tenant_id * @property int $tenant_id
* @property string $type rank(직급) | title(직책) * @property string $type rank(직급) | title(직책)
* @property string|null $key 영문 키 (tenant_user_profiles 연동용)
* @property string $name 명칭 * @property string $name 명칭
* @property int $sort_order 정렬 순서 * @property int $sort_order 정렬 순서
* @property bool $is_active 활성화 여부 * @property bool $is_active 활성화 여부
@@ -26,6 +27,7 @@ class Position extends Model
protected $fillable = [ protected $fillable = [
'tenant_id', 'tenant_id',
'type', 'type',
'key',
'name', 'name',
'sort_order', 'sort_order',
'is_active', 'is_active',

View File

@@ -65,6 +65,26 @@ public function manager(): BelongsTo
return $this->belongsTo(User::class, 'manager_user_id'); return $this->belongsTo(User::class, 'manager_user_id');
} }
/**
* 직급 (positions 테이블 type=rank)
*/
public function rankPosition(): BelongsTo
{
return $this->belongsTo(Position::class, 'position_key', 'key')
->where('type', Position::TYPE_RANK)
->where('tenant_id', $this->tenant_id);
}
/**
* 직책 (positions 테이블 type=title)
*/
public function titlePosition(): BelongsTo
{
return $this->belongsTo(Position::class, 'job_title_key', 'key')
->where('type', Position::TYPE_TITLE)
->where('tenant_id', $this->tenant_id);
}
// ========================================================================= // =========================================================================
// json_extra 헬퍼 메서드 // json_extra 헬퍼 메서드
// ========================================================================= // =========================================================================
@@ -151,36 +171,37 @@ public function getEmergencyContactAttribute(): ?string
} }
/** /**
* 직 레이블 (position_key → 한글) * 직 레이블 (position_key → positions 테이블에서 name 조회)
*/ */
public function getPositionLabelAttribute(): ?string public function getPositionLabelAttribute(): ?string
{ {
$labels = [ if (! $this->position_key || ! $this->tenant_id) {
'EXECUTIVE' => '임원', return $this->position_key;
'DIRECTOR' => '부장', }
'MANAGER' => '과장',
'SENIOR' => '대리',
'STAFF' => '사원',
'INTERN' => '인턴',
];
return $labels[$this->position_key] ?? $this->position_key; $position = Position::where('tenant_id', $this->tenant_id)
->where('type', Position::TYPE_RANK)
->where('key', $this->position_key)
->first();
return $position?->name ?? $this->position_key;
} }
/** /**
* 직 레이블 (job_title_key → 한글) * 직 레이블 (job_title_key → positions 테이블에서 name 조회)
*/ */
public function getJobTitleLabelAttribute(): ?string public function getJobTitleLabelAttribute(): ?string
{ {
$labels = [ if (! $this->job_title_key || ! $this->tenant_id) {
'CEO' => '대표이사', return $this->job_title_key;
'CTO' => '기술이사', }
'CFO' => '재무이사',
'TEAM_LEAD' => '팀장',
'TEAM_MEMBER' => '팀원',
];
return $labels[$this->job_title_key] ?? $this->job_title_key; $position = Position::where('tenant_id', $this->tenant_id)
->where('type', Position::TYPE_TITLE)
->where('key', $this->job_title_key)
->first();
return $position?->name ?? $this->job_title_key;
} }
/** /**

View File

@@ -212,6 +212,8 @@ public function bulkDelete(array $ids): array
/** /**
* 출근 기록 (체크인) * 출근 기록 (체크인)
* - 모든 출근 기록을 check_ins 배열에 히스토리로 저장
* - check_in accessor는 가장 빠른 출근 시간 반환
*/ */
public function checkIn(array $data): Attendance public function checkIn(array $data): Attendance
{ {
@@ -231,25 +233,32 @@ public function checkIn(array $data): Attendance
$checkInTime = $data['check_in'] ?? now()->format('H:i:s'); $checkInTime = $data['check_in'] ?? now()->format('H:i:s');
$gpsData = $data['gps_data'] ?? null; $gpsData = $data['gps_data'] ?? null;
// 출근 엔트리 생성
$entry = [
'time' => $checkInTime,
'recorded_at' => now()->toIso8601String(),
];
if ($gpsData) {
$entry['gps'] = $gpsData;
}
if ($attendance) { if ($attendance) {
// 기존 기록 업데이트 // 기존 기록에 출근 추가
$jsonDetails = $attendance->json_details ?? []; $jsonDetails = $attendance->json_details ?? [];
$jsonDetails['check_in'] = $checkInTime; $checkIns = $jsonDetails['check_ins'] ?? [];
if ($gpsData) { $checkIns[] = $entry;
$jsonDetails['gps_data'] = array_merge( $jsonDetails['check_ins'] = $checkIns;
$jsonDetails['gps_data'] ?? [],
['check_in' => $gpsData]
);
}
$attendance->json_details = $jsonDetails; $attendance->json_details = $jsonDetails;
$attendance->status = $this->determineStatusFromEntries($jsonDetails);
$attendance->updated_by = $userId; $attendance->updated_by = $userId;
$attendance->save(); $attendance->save();
} else { } else {
// 새 기록 생성 // 새 기록 생성
$jsonDetails = ['check_in' => $checkInTime]; $jsonDetails = [
if ($gpsData) { 'check_ins' => [$entry],
$jsonDetails['gps_data'] = ['check_in' => $gpsData]; 'check_outs' => [],
} ];
$attendance = Attendance::create([ $attendance = Attendance::create([
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
@@ -268,6 +277,8 @@ public function checkIn(array $data): Attendance
/** /**
* 퇴근 기록 (체크아웃) * 퇴근 기록 (체크아웃)
* - 모든 퇴근 기록을 check_outs 배열에 히스토리로 저장
* - check_out accessor는 가장 늦은 퇴근 시간 반환
*/ */
public function checkOut(array $data): Attendance public function checkOut(array $data): Attendance
{ {
@@ -290,28 +301,54 @@ public function checkOut(array $data): Attendance
$checkOutTime = $data['check_out'] ?? now()->format('H:i:s'); $checkOutTime = $data['check_out'] ?? now()->format('H:i:s');
$gpsData = $data['gps_data'] ?? null; $gpsData = $data['gps_data'] ?? null;
$jsonDetails = $attendance->json_details ?? []; // 퇴근 엔트리 생성
$jsonDetails['check_out'] = $checkOutTime; $entry = [
'time' => $checkOutTime,
// 근무 시간 계산 'recorded_at' => now()->toIso8601String(),
if (isset($jsonDetails['check_in'])) { ];
$checkIn = \Carbon\Carbon::createFromFormat('H:i:s', $jsonDetails['check_in']); if ($gpsData) {
$checkOut = \Carbon\Carbon::createFromFormat('H:i:s', $checkOutTime); $entry['gps'] = $gpsData;
$jsonDetails['work_minutes'] = $checkOut->diffInMinutes($checkIn);
} }
if ($gpsData) { $jsonDetails = $attendance->json_details ?? [];
$jsonDetails['gps_data'] = array_merge( $checkOuts = $jsonDetails['check_outs'] ?? [];
$jsonDetails['gps_data'] ?? [], $checkOuts[] = $entry;
['check_out' => $gpsData] $jsonDetails['check_outs'] = $checkOuts;
);
// 근무 시간 계산 (가장 빠른 출근 ~ 가장 늦은 퇴근)
$checkIns = $jsonDetails['check_ins'] ?? [];
if (! empty($checkIns)) {
$earliestIn = $this->getEarliestTime($checkIns);
$latestOut = $this->getLatestTime($checkOuts);
if ($earliestIn && $latestOut) {
$checkInCarbon = \Carbon\Carbon::createFromFormat('H:i:s', $earliestIn);
$checkOutCarbon = \Carbon\Carbon::createFromFormat('H:i:s', $latestOut);
$totalMinutes = $checkOutCarbon->diffInMinutes($checkInCarbon);
// 휴게시간 계산 (근무 설정에서 조회)
$workSettingService = app(WorkSettingService::class);
$workSetting = $workSettingService->getWorkSetting();
$breakMinutes = 0;
// 설정된 휴게시간이 있으면 적용
if ($workSetting->break_start && $workSetting->break_end) {
$breakStart = \Carbon\Carbon::createFromFormat('H:i:s', $workSetting->break_start);
$breakEnd = \Carbon\Carbon::createFromFormat('H:i:s', $workSetting->break_end);
// 출근~퇴근 시간이 휴게시간을 포함하면 휴게시간 적용
if ($checkInCarbon->lte($breakStart) && $checkOutCarbon->gte($breakEnd)) {
$breakMinutes = $breakEnd->diffInMinutes($breakStart);
$jsonDetails['break_minutes'] = $breakMinutes;
}
}
// 실제 근무시간 = 총 시간 - 휴게시간
$jsonDetails['work_minutes'] = $totalMinutes - $breakMinutes;
}
} }
$attendance->json_details = $jsonDetails; $attendance->json_details = $jsonDetails;
$attendance->status = $this->determineStatus( $attendance->status = $this->determineStatusFromEntries($jsonDetails);
$jsonDetails['check_in'] ?? null,
$checkOutTime
);
$attendance->updated_by = $userId; $attendance->updated_by = $userId;
$attendance->save(); $attendance->save();
@@ -398,7 +435,8 @@ private function buildJsonDetails(array $data): array
/** /**
* 상태 자동 결정 * 상태 자동 결정
* 실제 업무에서는 회사별 출근 시간 설정을 참조해야 함 * 근무 설정의 출근 시간 기준으로 지각 여부 판단
* 출근 시간 설정이 없으면 지각 개념 없음 (항상 정시)
*/ */
private function determineStatus(?string $checkIn, ?string $checkOut): string private function determineStatus(?string $checkIn, ?string $checkOut): string
{ {
@@ -406,13 +444,86 @@ private function determineStatus(?string $checkIn, ?string $checkOut): string
return 'absent'; return 'absent';
} }
// 기본 출근 시간: 09:00 // 근무 설정에서 출근 시간 조회
$standardCheckIn = '09:00:00'; $workSettingService = app(WorkSettingService::class);
$workSetting = $workSettingService->getWorkSetting();
if ($checkIn > $standardCheckIn) { // 출근 시간 설정이 없으면 지각 판정 안함
if (! $workSetting->start_time) {
return 'onTime';
}
if ($checkIn > $workSetting->start_time) {
return 'late'; return 'late';
} }
return 'onTime'; return 'onTime';
} }
/**
* 엔트리 기반 상태 결정
* check_ins 배열에서 가장 빠른 시간을 기준으로 상태 판단
*/
private function determineStatusFromEntries(array $jsonDetails): string
{
$checkIns = $jsonDetails['check_ins'] ?? [];
$checkOuts = $jsonDetails['check_outs'] ?? [];
if (empty($checkIns)) {
return 'absent';
}
$earliestIn = $this->getEarliestTime($checkIns);
$latestOut = ! empty($checkOuts) ? $this->getLatestTime($checkOuts) : null;
return $this->determineStatus($earliestIn, $latestOut);
}
/**
* 엔트리 배열에서 가장 빠른 시간 추출
*/
private function getEarliestTime(array $entries): ?string
{
if (empty($entries)) {
return null;
}
$times = array_map(function ($entry) {
return $entry['time'] ?? null;
}, $entries);
$times = array_filter($times);
if (empty($times)) {
return null;
}
sort($times);
return $times[0];
}
/**
* 엔트리 배열에서 가장 늦은 시간 추출
*/
private function getLatestTime(array $entries): ?string
{
if (empty($entries)) {
return null;
}
$times = array_map(function ($entry) {
return $entry['time'] ?? null;
}, $entries);
$times = array_filter($times);
if (empty($times)) {
return null;
}
rsort($times);
return $times[0];
}
} }

View File

@@ -21,7 +21,7 @@ public function index(array $params): LengthAwarePaginator
$query = TenantUserProfile::query() $query = TenantUserProfile::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->with(['user', 'department', 'manager']); ->with(['user', 'department', 'manager', 'rankPosition', 'titlePosition']);
// 검색 (이름, 이메일, 사원코드) // 검색 (이름, 이메일, 사원코드)
if (! empty($params['q'])) { if (! empty($params['q'])) {
@@ -77,7 +77,7 @@ public function show(int $id): TenantUserProfile
$profile = TenantUserProfile::query() $profile = TenantUserProfile::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->with(['user', 'department', 'manager']) ->with(['user', 'department', 'manager', 'rankPosition', 'titlePosition'])
->find($id); ->find($id);
if (! $profile) { if (! $profile) {
@@ -155,7 +155,7 @@ public function store(array $data): TenantUserProfile
]); ]);
$profile->save(); $profile->save();
return $profile->fresh(['user', 'department', 'manager']); return $profile->fresh(['user', 'department', 'manager', 'rankPosition', 'titlePosition']);
}); });
} }
@@ -247,7 +247,7 @@ public function update(int $id, array $data): TenantUserProfile
$profile->save(); $profile->save();
} }
return $profile->fresh(['user', 'department', 'manager']); return $profile->fresh(['user', 'department', 'manager', 'rankPosition', 'titlePosition']);
}); });
} }
@@ -339,7 +339,7 @@ public function createAccount(int $id, string $password): TenantUserProfile
'updated_by' => $userId, 'updated_by' => $userId,
]); ]);
return $profile->fresh(['user', 'department', 'manager']); return $profile->fresh(['user', 'department', 'manager', 'rankPosition', 'titlePosition']);
} }
/** /**
@@ -376,7 +376,7 @@ public function revokeAccount(int $id): TenantUserProfile
// 2. 기존 토큰 무효화 (로그아웃 처리) // 2. 기존 토큰 무효화 (로그아웃 처리)
$user->tokens()->delete(); $user->tokens()->delete();
return $profile->fresh(['user', 'department', 'manager']); return $profile->fresh(['user', 'department', 'manager', 'rankPosition', 'titlePosition']);
} }
/** /**

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('positions', function (Blueprint $table) {
$table->string('key', 64)->nullable()->after('type')->comment('영문 키 (tenant_user_profiles 연동용)');
$table->unique(['tenant_id', 'type', 'key'], 'positions_tenant_type_key_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('positions', function (Blueprint $table) {
$table->dropUnique('positions_tenant_type_key_unique');
$table->dropColumn('key');
});
}
};

View File

@@ -0,0 +1,76 @@
<?php
namespace Database\Seeders;
use App\Models\Tenants\Position;
use Illuminate\Database\Seeder;
class PositionSeeder extends Seeder
{
/**
* Run the database seeds.
* 직급(rank)과 직책(title) 기본 데이터 생성
*/
public function run(): void
{
$tenantId = 287; // 프론트_테스트회사
// 직급 (rank) - 기존 tenant_user_profiles.position_key와 매핑
$ranks = [
['key' => 'STAFF', 'name' => '사원', 'sort_order' => 1],
['key' => 'SENIOR', 'name' => '주임', 'sort_order' => 2],
['key' => 'ASSISTANT_MANAGER', 'name' => '대리', 'sort_order' => 3],
['key' => 'MANAGER', 'name' => '과장', 'sort_order' => 4],
['key' => 'DEPUTY_MANAGER', 'name' => '차장', 'sort_order' => 5],
['key' => 'DIRECTOR', 'name' => '부장', 'sort_order' => 6],
['key' => 'EXECUTIVE', 'name' => '이사', 'sort_order' => 7],
['key' => 'SENIOR_EXECUTIVE', 'name' => '상무', 'sort_order' => 8],
['key' => 'VICE_PRESIDENT', 'name' => '전무', 'sort_order' => 9],
['key' => 'CEO', 'name' => '대표', 'sort_order' => 10],
];
// 직책 (title)
$titles = [
['key' => 'MEMBER', 'name' => '팀원', 'sort_order' => 1],
['key' => 'PART_LEADER', 'name' => '파트장', 'sort_order' => 2],
['key' => 'TEAM_LEADER', 'name' => '팀장', 'sort_order' => 3],
['key' => 'DEPARTMENT_HEAD', 'name' => '실장', 'sort_order' => 4],
['key' => 'DIVISION_HEAD', 'name' => '본부장', 'sort_order' => 5],
['key' => 'CEO', 'name' => '대표이사', 'sort_order' => 6],
];
// 직급 생성
foreach ($ranks as $rank) {
Position::updateOrCreate(
[
'tenant_id' => $tenantId,
'type' => 'rank',
'key' => $rank['key'],
],
[
'name' => $rank['name'],
'sort_order' => $rank['sort_order'],
'is_active' => true,
]
);
}
// 직책 생성
foreach ($titles as $title) {
Position::updateOrCreate(
[
'tenant_id' => $tenantId,
'type' => 'title',
'key' => $title['key'],
],
[
'name' => $title['name'],
'sort_order' => $title['sort_order'],
'is_active' => true,
]
);
}
$this->command->info('Positions seeded: ' . count($ranks) . ' ranks, ' . count($titles) . ' titles');
}
}