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.accountNumber' => '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.start_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.accountNumber' => '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.start_date' => 'nullable|date',
'contract_info.end_date' => 'nullable|date',

View File

@@ -45,6 +45,15 @@ class Attendance extends Model
'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
{
$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;
}
/**
* 퇴근 시간
* 퇴근 시간 (가장 늦은 퇴근)
* check_outs 배열이 있으면 가장 늦은 시간 반환, 없으면 레거시 check_out 사용
*/
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;
}
@@ -134,6 +169,50 @@ public function getWorkMinutesAttribute(): ?int
: 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 $tenant_id
* @property string $type rank(직급) | title(직책)
* @property string|null $key 영문 키 (tenant_user_profiles 연동용)
* @property string $name 명칭
* @property int $sort_order 정렬 순서
* @property bool $is_active 활성화 여부
@@ -26,6 +27,7 @@ class Position extends Model
protected $fillable = [
'tenant_id',
'type',
'key',
'name',
'sort_order',
'is_active',

View File

@@ -65,6 +65,26 @@ public function manager(): BelongsTo
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 헬퍼 메서드
// =========================================================================
@@ -151,36 +171,37 @@ public function getEmergencyContactAttribute(): ?string
}
/**
* 직 레이블 (position_key → 한글)
* 직 레이블 (position_key → positions 테이블에서 name 조회)
*/
public function getPositionLabelAttribute(): ?string
{
$labels = [
'EXECUTIVE' => '임원',
'DIRECTOR' => '부장',
'MANAGER' => '과장',
'SENIOR' => '대리',
'STAFF' => '사원',
'INTERN' => '인턴',
];
if (! $this->position_key || ! $this->tenant_id) {
return $this->position_key;
}
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
{
$labels = [
'CEO' => '대표이사',
'CTO' => '기술이사',
'CFO' => '재무이사',
'TEAM_LEAD' => '팀장',
'TEAM_MEMBER' => '팀원',
];
if (! $this->job_title_key || ! $this->tenant_id) {
return $this->job_title_key;
}
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
{
@@ -231,25 +233,32 @@ public function checkIn(array $data): Attendance
$checkInTime = $data['check_in'] ?? now()->format('H:i:s');
$gpsData = $data['gps_data'] ?? null;
// 출근 엔트리 생성
$entry = [
'time' => $checkInTime,
'recorded_at' => now()->toIso8601String(),
];
if ($gpsData) {
$entry['gps'] = $gpsData;
}
if ($attendance) {
// 기존 기록 업데이트
// 기존 기록에 출근 추가
$jsonDetails = $attendance->json_details ?? [];
$jsonDetails['check_in'] = $checkInTime;
if ($gpsData) {
$jsonDetails['gps_data'] = array_merge(
$jsonDetails['gps_data'] ?? [],
['check_in' => $gpsData]
);
}
$checkIns = $jsonDetails['check_ins'] ?? [];
$checkIns[] = $entry;
$jsonDetails['check_ins'] = $checkIns;
$attendance->json_details = $jsonDetails;
$attendance->status = $this->determineStatusFromEntries($jsonDetails);
$attendance->updated_by = $userId;
$attendance->save();
} else {
// 새 기록 생성
$jsonDetails = ['check_in' => $checkInTime];
if ($gpsData) {
$jsonDetails['gps_data'] = ['check_in' => $gpsData];
}
$jsonDetails = [
'check_ins' => [$entry],
'check_outs' => [],
];
$attendance = Attendance::create([
'tenant_id' => $tenantId,
@@ -268,6 +277,8 @@ public function checkIn(array $data): Attendance
/**
* 퇴근 기록 (체크아웃)
* - 모든 퇴근 기록을 check_outs 배열에 히스토리로 저장
* - check_out accessor는 가장 늦은 퇴근 시간 반환
*/
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');
$gpsData = $data['gps_data'] ?? null;
$jsonDetails = $attendance->json_details ?? [];
$jsonDetails['check_out'] = $checkOutTime;
// 근무 시간 계산
if (isset($jsonDetails['check_in'])) {
$checkIn = \Carbon\Carbon::createFromFormat('H:i:s', $jsonDetails['check_in']);
$checkOut = \Carbon\Carbon::createFromFormat('H:i:s', $checkOutTime);
$jsonDetails['work_minutes'] = $checkOut->diffInMinutes($checkIn);
// 퇴근 엔트리 생성
$entry = [
'time' => $checkOutTime,
'recorded_at' => now()->toIso8601String(),
];
if ($gpsData) {
$entry['gps'] = $gpsData;
}
if ($gpsData) {
$jsonDetails['gps_data'] = array_merge(
$jsonDetails['gps_data'] ?? [],
['check_out' => $gpsData]
);
$jsonDetails = $attendance->json_details ?? [];
$checkOuts = $jsonDetails['check_outs'] ?? [];
$checkOuts[] = $entry;
$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->status = $this->determineStatus(
$jsonDetails['check_in'] ?? null,
$checkOutTime
);
$attendance->status = $this->determineStatusFromEntries($jsonDetails);
$attendance->updated_by = $userId;
$attendance->save();
@@ -398,7 +435,8 @@ private function buildJsonDetails(array $data): array
/**
* 상태 자동 결정
* 실제 업무에서는 회사별 출근 시간 설정을 참조해야 함
* 근무 설정의 출근 시간 기준으로 지각 여부 판단
* 출근 시간 설정이 없으면 지각 개념 없음 (항상 정시)
*/
private function determineStatus(?string $checkIn, ?string $checkOut): string
{
@@ -406,13 +444,86 @@ private function determineStatus(?string $checkIn, ?string $checkOut): string
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 '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()
->where('tenant_id', $tenantId)
->with(['user', 'department', 'manager']);
->with(['user', 'department', 'manager', 'rankPosition', 'titlePosition']);
// 검색 (이름, 이메일, 사원코드)
if (! empty($params['q'])) {
@@ -77,7 +77,7 @@ public function show(int $id): TenantUserProfile
$profile = TenantUserProfile::query()
->where('tenant_id', $tenantId)
->with(['user', 'department', 'manager'])
->with(['user', 'department', 'manager', 'rankPosition', 'titlePosition'])
->find($id);
if (! $profile) {
@@ -155,7 +155,7 @@ public function store(array $data): TenantUserProfile
]);
$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();
}
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,
]);
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. 기존 토큰 무효화 (로그아웃 처리)
$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');
}
}