diff --git a/app/Http/Controllers/Api/V1/AttendanceController.php b/app/Http/Controllers/Api/V1/AttendanceController.php new file mode 100644 index 0000000..fcbfc02 --- /dev/null +++ b/app/Http/Controllers/Api/V1/AttendanceController.php @@ -0,0 +1,124 @@ +service->index($request->validated()); + }, __('message.fetched')); + } + + /** + * 근태 상세 조회 + * GET /v1/attendances/{id} + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } + + /** + * 근태 등록 + * POST /v1/attendances + */ + public function store(StoreRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + }, __('message.created')); + } + + /** + * 근태 수정 + * PATCH /v1/attendances/{id} + */ + public function update(int $id, UpdateRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->update($id, $request->validated()); + }, __('message.updated')); + } + + /** + * 근태 삭제 + * DELETE /v1/attendances/{id} + */ + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->destroy($id); + }, __('message.deleted')); + } + + /** + * 근태 일괄 삭제 + * POST /v1/attendances/bulk-delete + */ + public function bulkDelete(Request $request): JsonResponse + { + $request->validate([ + 'ids' => 'required|array|min:1', + 'ids.*' => 'integer|min:1', + ]); + + return ApiResponse::handle(function () use ($request) { + return $this->service->bulkDelete($request->input('ids')); + }, __('message.bulk_deleted')); + } + + /** + * 출근 기록 (체크인) + * POST /v1/attendances/check-in + */ + public function checkIn(CheckInRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->checkIn($request->validated()); + }, __('message.created')); + } + + /** + * 퇴근 기록 (체크아웃) + * POST /v1/attendances/check-out + */ + public function checkOut(CheckOutRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->checkOut($request->validated()); + }, __('message.updated')); + } + + /** + * 월간 통계 조회 + * GET /v1/attendances/monthly-stats + */ + public function monthlyStats(MonthlyStatsRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->monthlyStats($request->validated()); + }, __('message.fetched')); + } +} diff --git a/app/Http/Controllers/Api/V1/DepartmentController.php b/app/Http/Controllers/Api/V1/DepartmentController.php index 00b4ae0..682295b 100644 --- a/app/Http/Controllers/Api/V1/DepartmentController.php +++ b/app/Http/Controllers/Api/V1/DepartmentController.php @@ -19,6 +19,14 @@ public function index(Request $request) }, '부서 목록 조회'); } + // GET /v1/departments/tree + public function tree(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->tree($request->all()); + }, '부서 트리 조회'); + } + // POST /v1/departments public function store(Request $request) { diff --git a/app/Http/Controllers/Api/V1/EmployeeController.php b/app/Http/Controllers/Api/V1/EmployeeController.php new file mode 100644 index 0000000..5f1a4f7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/EmployeeController.php @@ -0,0 +1,114 @@ +service->index($request->validated()); + }, __('message.fetched')); + } + + /** + * 사원 상세 조회 + * GET /v1/employees/{id} + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } + + /** + * 사원 등록 + * POST /v1/employees + */ + public function store(StoreRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + }, __('message.created')); + } + + /** + * 사원 수정 + * PATCH /v1/employees/{id} + */ + public function update(int $id, UpdateRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->update($id, $request->validated()); + }, __('message.updated')); + } + + /** + * 사원 삭제 (퇴직 처리) + * DELETE /v1/employees/{id} + */ + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->destroy($id); + }, __('message.deleted')); + } + + /** + * 사원 일괄 삭제 + * POST /v1/employees/bulk-delete + */ + public function bulkDelete(Request $request): JsonResponse + { + $request->validate([ + 'ids' => 'required|array|min:1', + 'ids.*' => 'integer|min:1', + ]); + + return ApiResponse::handle(function () use ($request) { + return $this->service->bulkDelete($request->input('ids')); + }, __('message.bulk_deleted')); + } + + /** + * 사원 통계 + * GET /v1/employees/stats + */ + public function stats(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->stats(); + }, __('message.fetched')); + } + + /** + * 시스템 계정 생성 + * POST /v1/employees/{id}/create-account + */ + public function createAccount(int $id, Request $request): JsonResponse + { + $request->validate([ + 'password' => 'required|string|min:8', + ]); + + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->createAccount($id, $request->input('password')); + }, __('message.updated')); + } +} diff --git a/app/Http/Controllers/Api/V1/UserController.php b/app/Http/Controllers/Api/V1/UserController.php index 7c77021..096e7c0 100644 --- a/app/Http/Controllers/Api/V1/UserController.php +++ b/app/Http/Controllers/Api/V1/UserController.php @@ -30,8 +30,10 @@ public function show($userNo) public function me(Request $request) { - return ApiResponse::handle(function () use ($request) { - return $this->service->getMyInfo($request); + return ApiResponse::handle(function () { + $userId = (int) app('api_user'); + + return MemberService::getUserInfoForLogin($userId); }, __('message.user.me_fetched')); } diff --git a/app/Http/Requests/Attendance/CheckInRequest.php b/app/Http/Requests/Attendance/CheckInRequest.php new file mode 100644 index 0000000..224c6d5 --- /dev/null +++ b/app/Http/Requests/Attendance/CheckInRequest.php @@ -0,0 +1,25 @@ + 'nullable|integer|exists:users,id', + 'check_in' => 'nullable|date_format:H:i:s', + 'gps_data' => 'nullable|array', + 'gps_data.latitude' => 'nullable|numeric', + 'gps_data.longitude' => 'nullable|numeric', + 'gps_data.accuracy' => 'nullable|numeric', + ]; + } +} diff --git a/app/Http/Requests/Attendance/CheckOutRequest.php b/app/Http/Requests/Attendance/CheckOutRequest.php new file mode 100644 index 0000000..c3bc130 --- /dev/null +++ b/app/Http/Requests/Attendance/CheckOutRequest.php @@ -0,0 +1,25 @@ + 'nullable|integer|exists:users,id', + 'check_out' => 'nullable|date_format:H:i:s', + 'gps_data' => 'nullable|array', + 'gps_data.latitude' => 'nullable|numeric', + 'gps_data.longitude' => 'nullable|numeric', + 'gps_data.accuracy' => 'nullable|numeric', + ]; + } +} diff --git a/app/Http/Requests/Attendance/IndexRequest.php b/app/Http/Requests/Attendance/IndexRequest.php new file mode 100644 index 0000000..0ae500d --- /dev/null +++ b/app/Http/Requests/Attendance/IndexRequest.php @@ -0,0 +1,29 @@ + 'nullable|integer|min:1', + 'date' => 'nullable|date', + 'date_from' => 'nullable|date', + 'date_to' => 'nullable|date|after_or_equal:date_from', + 'status' => 'nullable|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote', + 'department_id' => 'nullable|integer|min:1', + 'sort_by' => 'nullable|in:base_date,status,created_at', + 'sort_dir' => 'nullable|in:asc,desc', + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:100', + ]; + } +} diff --git a/app/Http/Requests/Attendance/MonthlyStatsRequest.php b/app/Http/Requests/Attendance/MonthlyStatsRequest.php new file mode 100644 index 0000000..604a88e --- /dev/null +++ b/app/Http/Requests/Attendance/MonthlyStatsRequest.php @@ -0,0 +1,22 @@ + 'nullable|integer|min:2000|max:2100', + 'month' => 'nullable|integer|min:1|max:12', + 'user_id' => 'nullable|integer|exists:users,id', + ]; + } +} diff --git a/app/Http/Requests/Attendance/StoreRequest.php b/app/Http/Requests/Attendance/StoreRequest.php new file mode 100644 index 0000000..b5bb91e --- /dev/null +++ b/app/Http/Requests/Attendance/StoreRequest.php @@ -0,0 +1,50 @@ + 'required|integer|exists:users,id', + 'base_date' => 'required|date', + 'status' => 'nullable|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote', + 'remarks' => 'nullable|string|max:500', + + // json_details 필드 + 'check_in' => 'nullable|date_format:H:i:s', + 'check_out' => 'nullable|date_format:H:i:s', + 'gps_data' => 'nullable|array', + 'gps_data.check_in' => 'nullable|array', + 'gps_data.check_in.latitude' => 'nullable|numeric', + 'gps_data.check_in.longitude' => 'nullable|numeric', + 'gps_data.check_out' => 'nullable|array', + 'gps_data.check_out.latitude' => 'nullable|numeric', + 'gps_data.check_out.longitude' => 'nullable|numeric', + 'external_work' => 'nullable|array', + 'external_work.location' => 'nullable|string|max:255', + 'external_work.purpose' => 'nullable|string|max:500', + 'work_minutes' => 'nullable|integer|min:0', + 'overtime_minutes' => 'nullable|integer|min:0', + 'late_minutes' => 'nullable|integer|min:0', + 'early_leave_minutes' => 'nullable|integer|min:0', + 'vacation_type' => 'nullable|string|max:50', + ]; + } + + public function messages(): array + { + return [ + 'user_id.required' => __('validation.required', ['attribute' => '사용자']), + 'base_date.required' => __('validation.required', ['attribute' => '기준일']), + ]; + } +} diff --git a/app/Http/Requests/Attendance/UpdateRequest.php b/app/Http/Requests/Attendance/UpdateRequest.php new file mode 100644 index 0000000..12fbfcc --- /dev/null +++ b/app/Http/Requests/Attendance/UpdateRequest.php @@ -0,0 +1,40 @@ + 'nullable|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote', + 'remarks' => 'nullable|string|max:500', + + // json_details 필드 + 'check_in' => 'nullable|date_format:H:i:s', + 'check_out' => 'nullable|date_format:H:i:s', + 'gps_data' => 'nullable|array', + 'gps_data.check_in' => 'nullable|array', + 'gps_data.check_in.latitude' => 'nullable|numeric', + 'gps_data.check_in.longitude' => 'nullable|numeric', + 'gps_data.check_out' => 'nullable|array', + 'gps_data.check_out.latitude' => 'nullable|numeric', + 'gps_data.check_out.longitude' => 'nullable|numeric', + 'external_work' => 'nullable|array', + 'external_work.location' => 'nullable|string|max:255', + 'external_work.purpose' => 'nullable|string|max:500', + 'work_minutes' => 'nullable|integer|min:0', + 'overtime_minutes' => 'nullable|integer|min:0', + 'late_minutes' => 'nullable|integer|min:0', + 'early_leave_minutes' => 'nullable|integer|min:0', + 'vacation_type' => 'nullable|string|max:50', + ]; + } +} diff --git a/app/Http/Requests/Employee/IndexRequest.php b/app/Http/Requests/Employee/IndexRequest.php new file mode 100644 index 0000000..2459c5a --- /dev/null +++ b/app/Http/Requests/Employee/IndexRequest.php @@ -0,0 +1,27 @@ + 'nullable|string|max:100', + 'status' => 'nullable|in:active,leave,resigned', + 'department_id' => 'nullable|integer|min:1', + 'has_account' => 'nullable|in:0,1,true,false', + 'sort_by' => 'nullable|in:created_at,name,employee_status,department_id', + 'sort_dir' => 'nullable|in:asc,desc', + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:100', + ]; + } +} diff --git a/app/Http/Requests/Employee/StoreRequest.php b/app/Http/Requests/Employee/StoreRequest.php new file mode 100644 index 0000000..7d5dcda --- /dev/null +++ b/app/Http/Requests/Employee/StoreRequest.php @@ -0,0 +1,68 @@ + 'nullable|string|max:50|unique:users,user_id', + 'name' => 'required|string|max:100', + 'email' => 'required|email|max:255|unique:users,email', + 'phone' => 'nullable|string|max:20', + 'password' => 'nullable|string|min:8', + 'is_active' => 'nullable|boolean', + + // tenant_user_profiles 테이블 필드 + 'department_id' => 'nullable|integer|exists:departments,id', + 'position_key' => 'nullable|string|max:50', + 'job_title_key' => 'nullable|string|max:50', + 'work_location_key' => 'nullable|string|max:50', + 'employment_type_key' => 'nullable|string|max:50', + 'employee_status' => 'nullable|in:active,leave,resigned', + 'manager_user_id' => 'nullable|integer|exists:users,id', + 'profile_photo_path' => 'nullable|string|max:255', + 'display_name' => 'nullable|string|max:100', + + // json_extra 필드 + 'employee_code' => 'nullable|string|max:50', + 'resident_number' => 'nullable|string|max:255', + 'gender' => 'nullable|in:male,female', + 'address' => 'nullable|array', + 'address.zipCode' => 'nullable|string|max:10', + 'address.address1' => 'nullable|string|max:255', + 'address.address2' => 'nullable|string|max:255', + 'salary' => 'nullable|numeric|min:0', + 'hire_date' => 'nullable|date', + 'rank' => 'nullable|string|max:50', + 'bank_account' => 'nullable|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', + 'contract_info' => 'nullable|array', + 'contract_info.start_date' => 'nullable|date', + 'contract_info.end_date' => 'nullable|date', + 'contract_info.external_company' => 'nullable|string|max:100', + ]; + } + + public function messages(): array + { + return [ + 'name.required' => __('validation.required', ['attribute' => '이름']), + 'email.required' => __('validation.required', ['attribute' => '이메일']), + 'email.email' => __('validation.email', ['attribute' => '이메일']), + 'email.unique' => __('validation.unique', ['attribute' => '이메일']), + ]; + } +} diff --git a/app/Http/Requests/Employee/UpdateRequest.php b/app/Http/Requests/Employee/UpdateRequest.php new file mode 100644 index 0000000..689f3c5 --- /dev/null +++ b/app/Http/Requests/Employee/UpdateRequest.php @@ -0,0 +1,79 @@ +route('id'); + + return [ + // users 테이블 필드 + 'name' => 'nullable|string|max:100', + 'email' => [ + 'nullable', + 'email', + 'max:255', + Rule::unique('users', 'email')->ignore($this->getOriginalUserId()), + ], + 'phone' => 'nullable|string|max:20', + 'is_active' => 'nullable|boolean', + + // tenant_user_profiles 테이블 필드 + 'department_id' => 'nullable|integer|exists:departments,id', + 'position_key' => 'nullable|string|max:50', + 'job_title_key' => 'nullable|string|max:50', + 'work_location_key' => 'nullable|string|max:50', + 'employment_type_key' => 'nullable|string|max:50', + 'employee_status' => 'nullable|in:active,leave,resigned', + 'manager_user_id' => 'nullable|integer|exists:users,id', + 'profile_photo_path' => 'nullable|string|max:255', + 'display_name' => 'nullable|string|max:100', + + // json_extra 필드 + 'employee_code' => 'nullable|string|max:50', + 'resident_number' => 'nullable|string|max:255', + 'gender' => 'nullable|in:male,female', + 'address' => 'nullable|array', + 'address.zipCode' => 'nullable|string|max:10', + 'address.address1' => 'nullable|string|max:255', + 'address.address2' => 'nullable|string|max:255', + 'salary' => 'nullable|numeric|min:0', + 'hire_date' => 'nullable|date', + 'rank' => 'nullable|string|max:50', + 'bank_account' => 'nullable|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', + 'contract_info' => 'nullable|array', + 'contract_info.start_date' => 'nullable|date', + 'contract_info.end_date' => 'nullable|date', + 'contract_info.external_company' => 'nullable|string|max:100', + ]; + } + + /** + * 현재 사원 프로필의 user_id 가져오기 + */ + private function getOriginalUserId(): ?int + { + $employeeId = $this->route('id'); + if (! $employeeId) { + return null; + } + + $profile = \App\Models\Tenants\TenantUserProfile::find($employeeId); + + return $profile?->user_id; + } +} diff --git a/app/Models/Tenants/Attendance.php b/app/Models/Tenants/Attendance.php new file mode 100644 index 0000000..064a66e --- /dev/null +++ b/app/Models/Tenants/Attendance.php @@ -0,0 +1,268 @@ + 'array', + 'base_date' => 'date', + ]; + + protected $fillable = [ + 'tenant_id', + 'user_id', + 'base_date', + 'status', + 'json_details', + 'remarks', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + /** + * 기본값 설정 + */ + protected $attributes = [ + 'status' => 'onTime', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 사용자 관계 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * 생성자 관계 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 관계 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ========================================================================= + // json_details 헬퍼 메서드 (Accessor) + // ========================================================================= + + /** + * 출근 시간 + */ + public function getCheckInAttribute(): ?string + { + return $this->json_details['check_in'] ?? null; + } + + /** + * 퇴근 시간 + */ + public function getCheckOutAttribute(): ?string + { + return $this->json_details['check_out'] ?? null; + } + + /** + * GPS 데이터 + */ + public function getGpsDataAttribute(): ?array + { + return $this->json_details['gps_data'] ?? null; + } + + /** + * 외근 정보 + */ + public function getExternalWorkAttribute(): ?array + { + return $this->json_details['external_work'] ?? null; + } + + /** + * 다중 출퇴근 기록 (여러 번 출퇴근) + */ + public function getMultipleEntriesAttribute(): ?array + { + return $this->json_details['multiple_entries'] ?? null; + } + + /** + * 근무 시간 (분 단위) + */ + public function getWorkMinutesAttribute(): ?int + { + return isset($this->json_details['work_minutes']) + ? (int) $this->json_details['work_minutes'] + : null; + } + + /** + * 초과 근무 시간 (분 단위) + */ + public function getOvertimeMinutesAttribute(): ?int + { + return isset($this->json_details['overtime_minutes']) + ? (int) $this->json_details['overtime_minutes'] + : null; + } + + /** + * 지각 시간 (분 단위) + */ + public function getLateMinutesAttribute(): ?int + { + return isset($this->json_details['late_minutes']) + ? (int) $this->json_details['late_minutes'] + : null; + } + + /** + * 조퇴 시간 (분 단위) + */ + public function getEarlyLeaveMinutesAttribute(): ?int + { + return isset($this->json_details['early_leave_minutes']) + ? (int) $this->json_details['early_leave_minutes'] + : null; + } + + /** + * 휴가 유형 (vacation 상태일 때) + */ + public function getVacationTypeAttribute(): ?string + { + return $this->json_details['vacation_type'] ?? null; + } + + // ========================================================================= + // json_details 업데이트 메서드 + // ========================================================================= + + /** + * json_details에서 특정 키 값 설정 + */ + public function setJsonDetailsValue(string $key, mixed $value): void + { + $jsonDetails = $this->json_details ?? []; + if ($value === null) { + unset($jsonDetails[$key]); + } else { + $jsonDetails[$key] = $value; + } + $this->json_details = $jsonDetails; + } + + /** + * json_details에서 특정 키 값 가져오기 + */ + public function getJsonDetailsValue(string $key, mixed $default = null): mixed + { + return $this->json_details[$key] ?? $default; + } + + /** + * 출퇴근 정보 일괄 업데이트 + */ + public function updateAttendanceDetails(array $data): void + { + $jsonDetails = $this->json_details ?? []; + $allowedKeys = [ + 'check_in', + 'check_out', + 'gps_data', + 'external_work', + 'multiple_entries', + 'work_minutes', + 'overtime_minutes', + 'late_minutes', + 'early_leave_minutes', + 'vacation_type', + ]; + + foreach ($allowedKeys as $key) { + if (array_key_exists($key, $data)) { + if ($data[$key] === null) { + unset($jsonDetails[$key]); + } else { + $jsonDetails[$key] = $data[$key]; + } + } + } + + $this->json_details = $jsonDetails; + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + /** + * 특정 날짜의 근태 조회 + */ + public function scopeOnDate($query, string $date) + { + return $query->whereDate('base_date', $date); + } + + /** + * 특정 기간의 근태 조회 + */ + public function scopeBetweenDates($query, string $startDate, string $endDate) + { + return $query->whereBetween('base_date', [$startDate, $endDate]); + } + + /** + * 특정 사용자의 근태 조회 + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + /** + * 특정 상태의 근태 조회 + */ + public function scopeWithStatus($query, string $status) + { + return $query->where('status', $status); + } +} diff --git a/app/Models/Tenants/TenantUserProfile.php b/app/Models/Tenants/TenantUserProfile.php index 44b9351..35c0ee6 100644 --- a/app/Models/Tenants/TenantUserProfile.php +++ b/app/Models/Tenants/TenantUserProfile.php @@ -4,8 +4,18 @@ use App\Models\Members\User; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** + * 테넌트별 사용자 프로필 (사원 정보) + * + * @property int $id + * @property int $tenant_id + * @property int $user_id + * @property int|null $department_id + * @property string $employee_status active|leave|resigned + * @property array|null $json_extra + * * @mixin IdeHelperTenantUserProfile */ class TenantUserProfile extends Model @@ -22,23 +32,133 @@ class TenantUserProfile extends Model 'job_title_key', 'work_location_key', 'employment_type_key', + 'employee_status', 'manager_user_id', 'json_extra', - 'created_at', - 'updated_at', 'profile_photo_path', 'display_name', ]; - // 관계: users 테이블은 전역이라 App\Models\User 로 연결 - public function user() + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } - // 조직, 직급 등은 옵션/코드 참조 가능 (필요시 추가) - public function department() + public function department(): BelongsTo { return $this->belongsTo(Department::class, 'department_id'); } + + public function manager(): BelongsTo + { + return $this->belongsTo(User::class, 'manager_user_id'); + } + + // ========================================================================= + // json_extra 헬퍼 메서드 + // ========================================================================= + + /** + * json_extra에서 특정 키 값 가져오기 + */ + public function getJsonExtraValue(string $key, mixed $default = null): mixed + { + return $this->json_extra[$key] ?? $default; + } + + /** + * json_extra에 특정 키 값 설정 + */ + public function setJsonExtraValue(string $key, mixed $value): void + { + $extra = $this->json_extra ?? []; + if ($value === null) { + unset($extra[$key]); + } else { + $extra[$key] = $value; + } + $this->json_extra = $extra; + } + + /** + * 사원 정보 일괄 업데이트 (json_extra) + */ + public function updateEmployeeInfo(array $data): void + { + $allowedKeys = [ + 'employee_code', + 'resident_number', + 'gender', + 'address', + 'salary', + 'hire_date', + 'rank', + 'bank_account', + 'work_type', + 'contract_info', + 'emergency_contact', + 'education', + 'certifications', + ]; + + $extra = $this->json_extra ?? []; + foreach ($allowedKeys as $key) { + if (array_key_exists($key, $data)) { + if ($data[$key] === null) { + unset($extra[$key]); + } else { + $extra[$key] = $data[$key]; + } + } + } + $this->json_extra = $extra; + $this->save(); + } + + // ========================================================================= + // json_extra Accessor (자주 사용하는 필드) + // ========================================================================= + + public function getEmployeeCodeAttribute(): ?string + { + return $this->json_extra['employee_code'] ?? null; + } + + public function getHireDateAttribute(): ?string + { + return $this->json_extra['hire_date'] ?? null; + } + + public function getAddressAttribute(): ?string + { + return $this->json_extra['address'] ?? null; + } + + public function getEmergencyContactAttribute(): ?string + { + return $this->json_extra['emergency_contact'] ?? null; + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('employee_status', 'active'); + } + + public function scopeOnLeave($query) + { + return $query->where('employee_status', 'leave'); + } + + public function scopeResigned($query) + { + return $query->where('employee_status', 'resigned'); + } } diff --git a/app/Services/AttendanceService.php b/app/Services/AttendanceService.php new file mode 100644 index 0000000..a4ef740 --- /dev/null +++ b/app/Services/AttendanceService.php @@ -0,0 +1,406 @@ +tenantId(); + + $query = Attendance::query() + ->where('tenant_id', $tenantId) + ->with(['user:id,name,email']); + + // 사용자 필터 + if (! empty($params['user_id'])) { + $query->where('user_id', $params['user_id']); + } + + // 날짜 필터 (단일) + if (! empty($params['date'])) { + $query->whereDate('base_date', $params['date']); + } + + // 날짜 범위 필터 + if (! empty($params['date_from'])) { + $query->whereDate('base_date', '>=', $params['date_from']); + } + if (! empty($params['date_to'])) { + $query->whereDate('base_date', '<=', $params['date_to']); + } + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 부서 필터 (사용자의 부서) + if (! empty($params['department_id'])) { + $query->whereHas('user.tenantProfile', function ($q) use ($params) { + $q->where('department_id', $params['department_id']); + }); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'base_date'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + // 페이지네이션 + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 근태 상세 조회 + */ + public function show(int $id): Attendance + { + $tenantId = $this->tenantId(); + + $attendance = Attendance::query() + ->where('tenant_id', $tenantId) + ->with(['user:id,name,email']) + ->findOrFail($id); + + return $attendance; + } + + /** + * 근태 등록 + */ + public function store(array $data): Attendance + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 기존 기록 확인 (같은 날 같은 사용자) + $existing = Attendance::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $data['user_id']) + ->whereDate('base_date', $data['base_date']) + ->first(); + + if ($existing) { + throw new \Exception(__('error.attendance.already_exists')); + } + + // json_details 구성 + // json_details 객체가 직접 전달된 경우 그대로 사용, 아니면 개별 필드에서 구성 + $jsonDetails = isset($data['json_details']) && is_array($data['json_details']) + ? $data['json_details'] + : $this->buildJsonDetails($data); + + $attendance = Attendance::create([ + 'tenant_id' => $tenantId, + 'user_id' => $data['user_id'], + 'base_date' => $data['base_date'], + 'status' => $data['status'] ?? 'onTime', + 'json_details' => $jsonDetails, + 'remarks' => $data['remarks'] ?? null, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + return $attendance->fresh(['user:id,name,email']); + }); + } + + /** + * 근태 수정 + */ + public function update(int $id, array $data): Attendance + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $attendance = Attendance::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 기본 필드 업데이트 + if (isset($data['status'])) { + $attendance->status = $data['status']; + } + if (array_key_exists('remarks', $data)) { + $attendance->remarks = $data['remarks']; + } + + // json_details 업데이트 + $jsonDetails = $attendance->json_details ?? []; + // json_details 객체가 직접 전달된 경우 그대로 병합, 아니면 개별 필드에서 구성 + $detailsUpdate = isset($data['json_details']) && is_array($data['json_details']) + ? $data['json_details'] + : $this->buildJsonDetails($data); + $attendance->json_details = array_merge($jsonDetails, $detailsUpdate); + + $attendance->updated_by = $userId; + $attendance->save(); + + return $attendance->fresh(['user:id,name,email']); + }); + } + + /** + * 근태 삭제 + */ + public function destroy(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $attendance = Attendance::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $attendance->deleted_by = $userId; + $attendance->save(); + $attendance->delete(); + + return true; + } + + /** + * 일괄 삭제 + */ + public function bulkDelete(array $ids): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $deletedCount = 0; + + DB::transaction(function () use ($ids, $tenantId, $userId, &$deletedCount) { + $attendances = Attendance::query() + ->where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->get(); + + foreach ($attendances as $attendance) { + $attendance->deleted_by = $userId; + $attendance->save(); + $attendance->delete(); + $deletedCount++; + } + }); + + return ['deleted_count' => $deletedCount]; + } + + /** + * 출근 기록 (체크인) + */ + public function checkIn(array $data): Attendance + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + $targetUserId = $data['user_id'] ?? $userId; + $today = now()->toDateString(); + + return DB::transaction(function () use ($data, $tenantId, $userId, $targetUserId, $today) { + // 오늘 기록 확인 + $attendance = Attendance::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $targetUserId) + ->whereDate('base_date', $today) + ->first(); + + $checkInTime = $data['check_in'] ?? now()->format('H:i:s'); + $gpsData = $data['gps_data'] ?? null; + + if ($attendance) { + // 기존 기록 업데이트 + $jsonDetails = $attendance->json_details ?? []; + $jsonDetails['check_in'] = $checkInTime; + if ($gpsData) { + $jsonDetails['gps_data'] = array_merge( + $jsonDetails['gps_data'] ?? [], + ['check_in' => $gpsData] + ); + } + $attendance->json_details = $jsonDetails; + $attendance->updated_by = $userId; + $attendance->save(); + } else { + // 새 기록 생성 + $jsonDetails = ['check_in' => $checkInTime]; + if ($gpsData) { + $jsonDetails['gps_data'] = ['check_in' => $gpsData]; + } + + $attendance = Attendance::create([ + 'tenant_id' => $tenantId, + 'user_id' => $targetUserId, + 'base_date' => $today, + 'status' => $this->determineStatus($checkInTime, null), + 'json_details' => $jsonDetails, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + } + + return $attendance->fresh(['user:id,name,email']); + }); + } + + /** + * 퇴근 기록 (체크아웃) + */ + public function checkOut(array $data): Attendance + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + $targetUserId = $data['user_id'] ?? $userId; + $today = now()->toDateString(); + + return DB::transaction(function () use ($data, $tenantId, $userId, $targetUserId, $today) { + $attendance = Attendance::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $targetUserId) + ->whereDate('base_date', $today) + ->first(); + + if (! $attendance) { + throw new \Exception(__('error.attendance.no_check_in')); + } + + $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); + } + + if ($gpsData) { + $jsonDetails['gps_data'] = array_merge( + $jsonDetails['gps_data'] ?? [], + ['check_out' => $gpsData] + ); + } + + $attendance->json_details = $jsonDetails; + $attendance->status = $this->determineStatus( + $jsonDetails['check_in'] ?? null, + $checkOutTime + ); + $attendance->updated_by = $userId; + $attendance->save(); + + return $attendance->fresh(['user:id,name,email']); + }); + } + + /** + * 월간 통계 조회 + */ + public function monthlyStats(array $params): array + { + $tenantId = $this->tenantId(); + $year = $params['year'] ?? now()->year; + $month = $params['month'] ?? now()->month; + $userId = $params['user_id'] ?? null; + + $startDate = \Carbon\Carbon::create($year, $month, 1)->startOfMonth(); + $endDate = $startDate->copy()->endOfMonth(); + + $query = Attendance::query() + ->where('tenant_id', $tenantId) + ->whereBetween('base_date', [$startDate, $endDate]); + + if ($userId) { + $query->where('user_id', $userId); + } + + $attendances = $query->get(); + + $stats = [ + 'year' => $year, + 'month' => $month, + 'total_days' => $attendances->count(), + 'by_status' => [ + 'onTime' => $attendances->where('status', 'onTime')->count(), + 'late' => $attendances->where('status', 'late')->count(), + 'absent' => $attendances->where('status', 'absent')->count(), + 'vacation' => $attendances->where('status', 'vacation')->count(), + 'businessTrip' => $attendances->where('status', 'businessTrip')->count(), + 'fieldWork' => $attendances->where('status', 'fieldWork')->count(), + 'overtime' => $attendances->where('status', 'overtime')->count(), + 'remote' => $attendances->where('status', 'remote')->count(), + ], + 'total_work_minutes' => $attendances->sum(function ($a) { + return $a->json_details['work_minutes'] ?? 0; + }), + 'total_overtime_minutes' => $attendances->sum(function ($a) { + return $a->json_details['overtime_minutes'] ?? 0; + }), + ]; + + return $stats; + } + + /** + * json_details 구성 + */ + private function buildJsonDetails(array $data): array + { + $details = []; + + $detailKeys = [ + 'check_in', + 'check_out', + 'gps_data', + 'external_work', + 'multiple_entries', + 'work_minutes', + 'overtime_minutes', + 'late_minutes', + 'early_leave_minutes', + 'vacation_type', + ]; + + foreach ($detailKeys as $key) { + if (isset($data[$key])) { + $details[$key] = $data[$key]; + } + } + + return $details; + } + + /** + * 상태 자동 결정 + * 실제 업무에서는 회사별 출근 시간 설정을 참조해야 함 + */ + private function determineStatus(?string $checkIn, ?string $checkOut): string + { + if (! $checkIn) { + return 'absent'; + } + + // 기본 출근 시간: 09:00 + $standardCheckIn = '09:00:00'; + + if ($checkIn > $standardCheckIn) { + return 'late'; + } + + return 'onTime'; + } +} diff --git a/app/Services/DepartmentService.php b/app/Services/DepartmentService.php index c3c9c83..d5bbaa2 100644 --- a/app/Services/DepartmentService.php +++ b/app/Services/DepartmentService.php @@ -60,6 +60,50 @@ public function index(array $params) return $q->paginate($perPage, ['*'], 'page', $page); } + /** 부서 트리 조회 */ + public function tree(array $params = []): array + { + $p = $this->v($params, [ + 'with_users' => 'nullable|in:0,1,true,false', + ]); + if (isset($p['error'])) { + return $p; + } + + $withUsers = filter_var($p['with_users'] ?? false, FILTER_VALIDATE_BOOLEAN); + + // 최상위 부서 조회 (parent_id가 null인 부서) + $query = Department::query() + ->whereNull('parent_id') + ->orderBy('sort_order') + ->orderBy('name'); + + // 재귀적으로 자식 부서 로드 + $query->with(['children' => function ($q) use ($withUsers) { + $q->orderBy('sort_order')->orderBy('name'); + $this->loadChildrenRecursive($q, $withUsers); + }]); + + if ($withUsers) { + $query->with(['users:id,name,email']); + } + + return $query->get()->toArray(); + } + + /** 재귀적으로 자식 부서 로드 */ + private function loadChildrenRecursive($query, bool $withUsers): void + { + $query->with(['children' => function ($q) use ($withUsers) { + $q->orderBy('sort_order')->orderBy('name'); + $this->loadChildrenRecursive($q, $withUsers); + }]); + + if ($withUsers) { + $query->with(['users:id,name,email']); + } + } + /** 생성 */ public function store(array $params) { diff --git a/app/Services/EmployeeService.php b/app/Services/EmployeeService.php new file mode 100644 index 0000000..903d1cf --- /dev/null +++ b/app/Services/EmployeeService.php @@ -0,0 +1,344 @@ +tenantId(); + + $query = TenantUserProfile::query() + ->where('tenant_id', $tenantId) + ->with(['user', 'department', 'manager']); + + // 검색 (이름, 이메일, 사원코드) + if (! empty($params['q'])) { + $search = $params['q']; + $query->where(function ($q) use ($search) { + $q->whereHas('user', function ($uq) use ($search) { + $uq->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }) + // json_extra에서 employee_code 검색 + ->orWhereJsonContains('json_extra->employee_code', $search); + }); + } + + // 고용 상태 필터 (employee_status) + if (! empty($params['status'])) { + $query->where('employee_status', $params['status']); + } + + // 부서 필터 + if (! empty($params['department_id'])) { + $query->where('department_id', $params['department_id']); + } + + // 시스템 계정 보유 여부 필터 + if (isset($params['has_account'])) { + $hasAccount = filter_var($params['has_account'], FILTER_VALIDATE_BOOLEAN); + $query->whereHas('user', function ($q) use ($hasAccount) { + if ($hasAccount) { + $q->whereNotNull('password'); + } else { + $q->whereNull('password'); + } + }); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'created_at'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 사원 상세 조회 + */ + public function show(int $id): TenantUserProfile + { + $tenantId = $this->tenantId(); + + $profile = TenantUserProfile::query() + ->where('tenant_id', $tenantId) + ->with(['user', 'department', 'manager']) + ->find($id); + + if (! $profile) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return $profile; + } + + /** + * 사원 등록 (users 테이블에 사용자 생성 + tenant_user_profiles 생성) + */ + public function store(array $data): TenantUserProfile + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 1. users 테이블에 사용자 생성 + $user = User::create([ + 'user_id' => $data['user_id'] ?? $this->generateUserId($data['email']), + 'name' => $data['name'], + 'email' => $data['email'], + 'phone' => $data['phone'] ?? null, + 'password' => Hash::make($data['password'] ?? Str::random(16)), + 'is_active' => $data['is_active'] ?? true, + 'created_by' => $userId, + ]); + + // 2. user_tenants pivot에 관계 추가 + $user->tenantsMembership()->attach($tenantId, [ + 'is_active' => true, + 'is_default' => true, + 'joined_at' => now(), + ]); + + // 3. tenant_user_profiles 생성 + $profile = TenantUserProfile::create([ + 'tenant_id' => $tenantId, + 'user_id' => $user->id, + 'department_id' => $data['department_id'] ?? null, + 'position_key' => $data['position_key'] ?? null, + 'job_title_key' => $data['job_title_key'] ?? null, + 'work_location_key' => $data['work_location_key'] ?? null, + 'employment_type_key' => $data['employment_type_key'] ?? null, + 'employee_status' => $data['employee_status'] ?? 'active', + 'manager_user_id' => $data['manager_user_id'] ?? null, + 'profile_photo_path' => $data['profile_photo_path'] ?? null, + 'display_name' => $data['display_name'] ?? null, + ]); + + // 4. json_extra 사원 정보 설정 + $profile->updateEmployeeInfo([ + 'employee_code' => $data['employee_code'] ?? null, + 'resident_number' => $data['resident_number'] ?? null, + 'gender' => $data['gender'] ?? null, + 'address' => $data['address'] ?? null, + 'salary' => $data['salary'] ?? null, + 'hire_date' => $data['hire_date'] ?? null, + 'rank' => $data['rank'] ?? null, + 'bank_account' => $data['bank_account'] ?? null, + 'work_type' => $data['work_type'] ?? 'regular', + 'contract_info' => $data['contract_info'] ?? null, + ]); + $profile->save(); + + return $profile->fresh(['user', 'department', 'manager']); + }); + } + + /** + * 사원 수정 + */ + public function update(int $id, array $data): TenantUserProfile + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $profile = TenantUserProfile::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $profile) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return DB::transaction(function () use ($profile, $data, $userId) { + // 1. users 테이블 업데이트 (기본 정보) + $user = $profile->user; + $userUpdates = []; + + if (isset($data['name'])) { + $userUpdates['name'] = $data['name']; + } + if (isset($data['email'])) { + $userUpdates['email'] = $data['email']; + } + if (isset($data['phone'])) { + $userUpdates['phone'] = $data['phone']; + } + if (isset($data['is_active'])) { + $userUpdates['is_active'] = $data['is_active']; + } + if (! empty($userUpdates)) { + $userUpdates['updated_by'] = $userId; + $user->update($userUpdates); + } + + // 2. tenant_user_profiles 업데이트 + $profileUpdates = []; + $profileFields = [ + 'department_id', + 'position_key', + 'job_title_key', + 'work_location_key', + 'employment_type_key', + 'employee_status', + 'manager_user_id', + 'profile_photo_path', + 'display_name', + ]; + + foreach ($profileFields as $field) { + if (array_key_exists($field, $data)) { + $profileUpdates[$field] = $data[$field]; + } + } + + if (! empty($profileUpdates)) { + $profile->update($profileUpdates); + } + + // 3. json_extra 사원 정보 업데이트 + $jsonExtraFields = [ + 'employee_code', + 'resident_number', + 'gender', + 'address', + 'salary', + 'hire_date', + 'rank', + 'bank_account', + 'work_type', + 'contract_info', + ]; + + $jsonExtraUpdates = []; + foreach ($jsonExtraFields as $field) { + if (array_key_exists($field, $data)) { + $jsonExtraUpdates[$field] = $data[$field]; + } + } + + if (! empty($jsonExtraUpdates)) { + $profile->updateEmployeeInfo($jsonExtraUpdates); + $profile->save(); + } + + return $profile->fresh(['user', 'department', 'manager']); + }); + } + + /** + * 사원 삭제 (soft delete) + */ + public function destroy(int $id): array + { + $tenantId = $this->tenantId(); + + $profile = TenantUserProfile::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $profile) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // tenant_user_profiles에는 SoftDeletes가 없으므로 hard delete + // 또는 employee_status를 resigned로 변경 + $profile->update(['employee_status' => 'resigned']); + + return [ + 'id' => $id, + 'deleted_at' => now()->toDateTimeString(), + ]; + } + + /** + * 일괄 삭제 + */ + public function bulkDelete(array $ids): array + { + $tenantId = $this->tenantId(); + + $updated = TenantUserProfile::query() + ->where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->update(['employee_status' => 'resigned']); + + return [ + 'processed' => count($ids), + 'updated' => $updated, + ]; + } + + /** + * 사원 통계 + */ + public function stats(): array + { + $tenantId = $this->tenantId(); + + $baseQuery = TenantUserProfile::query()->where('tenant_id', $tenantId); + + $total = (clone $baseQuery)->count(); + $active = (clone $baseQuery)->where('employee_status', 'active')->count(); + $leave = (clone $baseQuery)->where('employee_status', 'leave')->count(); + $resigned = (clone $baseQuery)->where('employee_status', 'resigned')->count(); + + return [ + 'total' => $total, + 'active' => $active, + 'leave' => $leave, + 'resigned' => $resigned, + ]; + } + + /** + * 시스템 계정 생성 (비밀번호 설정) + */ + public function createAccount(int $id, string $password): TenantUserProfile + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $profile = TenantUserProfile::query() + ->where('tenant_id', $tenantId) + ->with('user') + ->find($id); + + if (! $profile) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $profile->user->update([ + 'password' => Hash::make($password), + 'must_change_password' => true, + 'updated_by' => $userId, + ]); + + return $profile->fresh(['user', 'department', 'manager']); + } + + /** + * 사용자 ID 자동 생성 + */ + private function generateUserId(string $email): string + { + $prefix = explode('@', $email)[0]; + $suffix = Str::random(4); + + return strtolower($prefix.'_'.$suffix); + } +} diff --git a/app/Swagger/v1/AttendanceApi.php b/app/Swagger/v1/AttendanceApi.php new file mode 100644 index 0000000..17ec740 --- /dev/null +++ b/app/Swagger/v1/AttendanceApi.php @@ -0,0 +1,422 @@ +enum('employee_status', ['active', 'leave', 'resigned']) + ->default('active') + ->after('employment_type_key') + ->comment('고용상태: active(재직), leave(휴직), resigned(퇴직)'); + + // 복합 인덱스 추가 + $table->index(['tenant_id', 'employee_status'], 'idx_tenant_employee_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tenant_user_profiles', function (Blueprint $table) { + $table->dropIndex('idx_tenant_employee_status'); + $table->dropColumn('employee_status'); + }); + } +}; diff --git a/database/migrations/2025_12_09_084231_create_attendances_table.php b/database/migrations/2025_12_09_084231_create_attendances_table.php new file mode 100644 index 0000000..9f5abc1 --- /dev/null +++ b/database/migrations/2025_12_09_084231_create_attendances_table.php @@ -0,0 +1,72 @@ +id(); + + // 인덱싱 필요 필드 (컬럼) + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('user_id')->comment('사용자 ID'); + $table->date('base_date')->comment('기준일'); + $table->enum('status', [ + 'onTime', // 정시 출근 + 'late', // 지각 + 'absent', // 결근 + 'vacation', // 휴가 + 'businessTrip', // 출장 + 'fieldWork', // 외근 + 'overtime', // 야근 + 'remote', // 재택 + ])->default('onTime')->comment('근태 상태'); + + // 확장 가능한 상세 정보 (JSON) + // { + // "check_in": "09:00:00", + // "check_out": "18:00:00", + // "break_time": "01:00", + // "overtime_hours": "02:00", + // "reason": { "type": "vacationRequest", "label": "연차", "document_id": "DOC-001" }, + // "gps_data": { "check_in_location": {...}, "check_out_location": {...}, "is_auto_checked": true }, + // "external_work": { "type": "dispatch", "location": "협력사 A", "company": "ABC Corp" }, + // "multiple_entries": [{ "in": "09:00", "out": "12:00" }, { "in": "13:00", "out": "18:00" }] + // } + $table->json('json_details')->nullable()->comment('출퇴근 상세 정보 (JSON)'); + + // 감사 로그 + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자 ID'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자 ID'); + $table->timestamps(); + $table->softDeletes(); + + // 외래 키 + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + + // 유니크 제약: 같은 테넌트/사용자/날짜에 하나의 근태 레코드만 허용 + $table->unique(['tenant_id', 'user_id', 'base_date'], 'uq_attendance_user_date'); + + // 인덱스 + $table->index(['tenant_id', 'base_date', 'status'], 'idx_attendance_tenant_date_status'); + $table->index(['tenant_id', 'user_id'], 'idx_attendance_tenant_user'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('attendances'); + } +}; diff --git a/database/migrations/2025_12_09_110130_add_remarks_to_attendances_table.php b/database/migrations/2025_12_09_110130_add_remarks_to_attendances_table.php new file mode 100644 index 0000000..97a3ae4 --- /dev/null +++ b/database/migrations/2025_12_09_110130_add_remarks_to_attendances_table.php @@ -0,0 +1,28 @@ +string('remarks', 500)->nullable()->after('json_details')->comment('비고'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('attendances', function (Blueprint $table) { + $table->dropColumn('remarks'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index bbb0dc1..e17b625 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,8 @@ use App\Http\Controllers\Api\V1\ClientGroupController; use App\Http\Controllers\Api\V1\CommonController; use App\Http\Controllers\Api\V1\DepartmentController; +use App\Http\Controllers\Api\V1\EmployeeController; +use App\Http\Controllers\Api\V1\AttendanceController; use App\Http\Controllers\Api\V1\Design\AuditLogController as DesignAuditLogController; use App\Http\Controllers\Api\V1\Design\BomCalculationController; use App\Http\Controllers\Api\V1\Design\BomTemplateController as DesignBomTemplateController; @@ -30,6 +32,9 @@ use App\Http\Controllers\Api\V1\ItemMaster\ItemSectionController; use App\Http\Controllers\Api\V1\ItemMaster\SectionTemplateController; use App\Http\Controllers\Api\V1\ItemMaster\UnitOptionController; +use App\Http\Controllers\Api\V1\ItemsBomController; +use App\Http\Controllers\Api\V1\ItemsController; +use App\Http\Controllers\Api\V1\ItemsFileController; use App\Http\Controllers\Api\V1\MaterialController; use App\Http\Controllers\Api\V1\MenuController; use App\Http\Controllers\Api\V1\ModelSetController; @@ -194,6 +199,7 @@ Route::prefix('departments')->group(function () { Route::get('', [DepartmentController::class, 'index'])->name('v1.departments.index'); // 목록 Route::post('', [DepartmentController::class, 'store'])->name('v1.departments.store'); // 생성 + Route::get('/tree', [DepartmentController::class, 'tree'])->name('v1.departments.tree'); // 트리 Route::get('/{id}', [DepartmentController::class, 'show'])->name('v1.departments.show'); // 단건 Route::patch('/{id}', [DepartmentController::class, 'update'])->name('v1.departments.update'); // 수정 Route::delete('/{id}', [DepartmentController::class, 'destroy'])->name('v1.departments.destroy'); // 삭제(soft) @@ -210,6 +216,31 @@ Route::delete('/{id}/permissions/{permission}', [DepartmentController::class, 'revokePermissions'])->name('v1.departments.permissions.revoke'); // 권한 제거(해당 메뉴 범위까지) }); + // Employee API (사원 관리) + Route::prefix('employees')->group(function () { + Route::get('', [EmployeeController::class, 'index'])->name('v1.employees.index'); + Route::post('', [EmployeeController::class, 'store'])->name('v1.employees.store'); + Route::get('/stats', [EmployeeController::class, 'stats'])->name('v1.employees.stats'); + Route::get('/{id}', [EmployeeController::class, 'show'])->name('v1.employees.show'); + Route::patch('/{id}', [EmployeeController::class, 'update'])->name('v1.employees.update'); + Route::delete('/{id}', [EmployeeController::class, 'destroy'])->name('v1.employees.destroy'); + Route::post('/bulk-delete', [EmployeeController::class, 'bulkDelete'])->name('v1.employees.bulkDelete'); + Route::post('/{id}/create-account', [EmployeeController::class, 'createAccount'])->name('v1.employees.createAccount'); + }); + + // Attendance API (근태 관리) + Route::prefix('attendances')->group(function () { + Route::get('', [AttendanceController::class, 'index'])->name('v1.attendances.index'); + Route::post('', [AttendanceController::class, 'store'])->name('v1.attendances.store'); + Route::get('/monthly-stats', [AttendanceController::class, 'monthlyStats'])->name('v1.attendances.monthlyStats'); + Route::post('/check-in', [AttendanceController::class, 'checkIn'])->name('v1.attendances.checkIn'); + Route::post('/check-out', [AttendanceController::class, 'checkOut'])->name('v1.attendances.checkOut'); + Route::get('/{id}', [AttendanceController::class, 'show'])->name('v1.attendances.show'); + Route::patch('/{id}', [AttendanceController::class, 'update'])->name('v1.attendances.update'); + Route::delete('/{id}', [AttendanceController::class, 'destroy'])->name('v1.attendances.destroy'); + Route::post('/bulk-delete', [AttendanceController::class, 'bulkDelete'])->name('v1.attendances.bulkDelete'); + }); + // Permission API Route::prefix('permissions')->group(function () { Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('v1.permissions.deptMenuMatrix'); // 부서별 권한 메트릭스 @@ -408,33 +439,33 @@ // Items (통합 품목 조회 - materials + products UNION) Route::prefix('items')->group(function () { - Route::get('', [\App\Http\Controllers\Api\V1\ItemsController::class, 'index'])->name('v1.items.index'); // 통합 목록 - Route::post('', [\App\Http\Controllers\Api\V1\ItemsController::class, 'store'])->name('v1.items.store'); // 품목 생성 - Route::get('/code/{code}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'showByCode'])->name('v1.items.show_by_code'); // code 기반 조회 - Route::get('/{id}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'show'])->name('v1.items.show'); // 단건 (item_type 파라미터 필수) - Route::put('/{id}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'update'])->name('v1.items.update'); // 품목 수정 - Route::delete('/batch', [\App\Http\Controllers\Api\V1\ItemsController::class, 'batchDestroy'])->name('v1.items.batch_destroy'); // 품목 일괄 삭제 - Route::delete('/{id}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'destroy'])->name('v1.items.destroy'); // 품목 삭제 + Route::get('', [ItemsController::class, 'index'])->name('v1.items.index'); // 통합 목록 + Route::post('', [ItemsController::class, 'store'])->name('v1.items.store'); // 품목 생성 + Route::get('/code/{code}', [ItemsController::class, 'showByCode'])->name('v1.items.show_by_code'); // code 기반 조회 + Route::get('/{id}', [ItemsController::class, 'show'])->name('v1.items.show'); // 단건 (item_type 파라미터 필수) + Route::put('/{id}', [ItemsController::class, 'update'])->name('v1.items.update'); // 품목 수정 + Route::delete('/batch', [ItemsController::class, 'batchDestroy'])->name('v1.items.batch_destroy'); // 품목 일괄 삭제 + Route::delete('/{id}', [ItemsController::class, 'destroy'])->name('v1.items.destroy'); // 품목 삭제 }); // Items BOM (ID-based BOM API) Route::prefix('items/{id}/bom')->group(function () { - Route::get('', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'index'])->name('v1.items.bom.index'); // BOM 목록 (flat) - Route::get('/tree', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'tree'])->name('v1.items.bom.tree'); // BOM 트리 (계층) - Route::post('', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'store'])->name('v1.items.bom.store'); // BOM 추가 (bulk) - Route::put('/{lineId}', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'update'])->name('v1.items.bom.update'); // BOM 수정 - Route::delete('/{lineId}', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'destroy'])->name('v1.items.bom.destroy'); // BOM 삭제 - Route::get('/summary', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'summary'])->name('v1.items.bom.summary'); // BOM 요약 - Route::get('/validate', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'validate'])->name('v1.items.bom.validate'); // BOM 검증 - Route::post('/replace', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'replace'])->name('v1.items.bom.replace'); // BOM 전체 교체 - Route::post('/reorder', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'reorder'])->name('v1.items.bom.reorder'); // BOM 정렬 - Route::get('/categories', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'listCategories'])->name('v1.items.bom.categories'); // 카테고리 목록 + Route::get('', [ItemsBomController::class, 'index'])->name('v1.items.bom.index'); // BOM 목록 (flat) + Route::get('/tree', [ItemsBomController::class, 'tree'])->name('v1.items.bom.tree'); // BOM 트리 (계층) + Route::post('', [ItemsBomController::class, 'store'])->name('v1.items.bom.store'); // BOM 추가 (bulk) + Route::put('/{lineId}', [ItemsBomController::class, 'update'])->name('v1.items.bom.update'); // BOM 수정 + Route::delete('/{lineId}', [ItemsBomController::class, 'destroy'])->name('v1.items.bom.destroy'); // BOM 삭제 + Route::get('/summary', [ItemsBomController::class, 'summary'])->name('v1.items.bom.summary'); // BOM 요약 + Route::get('/validate', [ItemsBomController::class, 'validate'])->name('v1.items.bom.validate'); // BOM 검증 + Route::post('/replace', [ItemsBomController::class, 'replace'])->name('v1.items.bom.replace'); // BOM 전체 교체 + Route::post('/reorder', [ItemsBomController::class, 'reorder'])->name('v1.items.bom.reorder'); // BOM 정렬 + Route::get('/categories', [ItemsBomController::class, 'listCategories'])->name('v1.items.bom.categories'); // 카테고리 목록 }); // Items Files (ID-based File Upload API) Route::prefix('items/{id}/files')->group(function () { - Route::post('', [\App\Http\Controllers\Api\V1\ItemsFileController::class, 'upload'])->name('v1.items.files.upload'); // 파일 업로드 - Route::delete('/{type}', [\App\Http\Controllers\Api\V1\ItemsFileController::class, 'delete'])->name('v1.items.files.delete'); // 파일 삭제 (type: bending_diagram|specification|certification) + Route::post('', [ItemsFileController::class, 'upload'])->name('v1.items.files.upload'); // 파일 업로드 + Route::delete('/{type}', [ItemsFileController::class, 'delete'])->name('v1.items.files.delete'); // 파일 삭제 (type: bending_diagram|specification|certification) }); // BOM (product_components: ref_type=PRODUCT|MATERIAL)