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:
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 초과 근무 시간 (분 단위)
|
* 초과 근무 시간 (분 단위)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
76
database/seeders/PositionSeeder.php
Normal file
76
database/seeders/PositionSeeder.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user