feat: 근태관리/직원관리 API 구현
- AttendanceController, AttendanceService 추가 - EmployeeController, EmployeeService 추가 - Attendance 모델 및 마이그레이션 추가 - TenantUserProfile에 employee_status 컬럼 추가 - DepartmentService 트리 조회 기능 개선 - Swagger 문서 추가 (AttendanceApi, EmployeeApi) - API 라우트 등록
This commit is contained in:
124
app/Http/Controllers/Api/V1/AttendanceController.php
Normal file
124
app/Http/Controllers/Api/V1/AttendanceController.php
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Helpers\ApiResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Attendance\CheckInRequest;
|
||||||
|
use App\Http\Requests\Attendance\CheckOutRequest;
|
||||||
|
use App\Http\Requests\Attendance\IndexRequest;
|
||||||
|
use App\Http\Requests\Attendance\MonthlyStatsRequest;
|
||||||
|
use App\Http\Requests\Attendance\StoreRequest;
|
||||||
|
use App\Http\Requests\Attendance\UpdateRequest;
|
||||||
|
use App\Services\AttendanceService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AttendanceController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private AttendanceService $service) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 근태 목록 조회
|
||||||
|
* GET /v1/attendances
|
||||||
|
*/
|
||||||
|
public function index(IndexRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($request) {
|
||||||
|
return $this->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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// POST /v1/departments
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
|
|||||||
114
app/Http/Controllers/Api/V1/EmployeeController.php
Normal file
114
app/Http/Controllers/Api/V1/EmployeeController.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Helpers\ApiResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Employee\IndexRequest;
|
||||||
|
use App\Http\Requests\Employee\StoreRequest;
|
||||||
|
use App\Http\Requests\Employee\UpdateRequest;
|
||||||
|
use App\Services\EmployeeService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class EmployeeController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private EmployeeService $service) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사원 목록 조회
|
||||||
|
* GET /v1/employees
|
||||||
|
*/
|
||||||
|
public function index(IndexRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($request) {
|
||||||
|
return $this->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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,8 +30,10 @@ public function show($userNo)
|
|||||||
|
|
||||||
public function me(Request $request)
|
public function me(Request $request)
|
||||||
{
|
{
|
||||||
return ApiResponse::handle(function () use ($request) {
|
return ApiResponse::handle(function () {
|
||||||
return $this->service->getMyInfo($request);
|
$userId = (int) app('api_user');
|
||||||
|
|
||||||
|
return MemberService::getUserInfoForLogin($userId);
|
||||||
}, __('message.user.me_fetched'));
|
}, __('message.user.me_fetched'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
app/Http/Requests/Attendance/CheckInRequest.php
Normal file
25
app/Http/Requests/Attendance/CheckInRequest.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Attendance;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class CheckInRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => '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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Http/Requests/Attendance/CheckOutRequest.php
Normal file
25
app/Http/Requests/Attendance/CheckOutRequest.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Attendance;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class CheckOutRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => '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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Requests/Attendance/IndexRequest.php
Normal file
29
app/Http/Requests/Attendance/IndexRequest.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Attendance;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class IndexRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => '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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Http/Requests/Attendance/MonthlyStatsRequest.php
Normal file
22
app/Http/Requests/Attendance/MonthlyStatsRequest.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Attendance;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class MonthlyStatsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'year' => 'nullable|integer|min:2000|max:2100',
|
||||||
|
'month' => 'nullable|integer|min:1|max:12',
|
||||||
|
'user_id' => 'nullable|integer|exists:users,id',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Requests/Attendance/StoreRequest.php
Normal file
50
app/Http/Requests/Attendance/StoreRequest.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Attendance;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => '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' => '기준일']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Http/Requests/Attendance/UpdateRequest.php
Normal file
40
app/Http/Requests/Attendance/UpdateRequest.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Attendance;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Requests/Employee/IndexRequest.php
Normal file
27
app/Http/Requests/Employee/IndexRequest.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Employee;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class IndexRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'q' => '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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Requests/Employee/StoreRequest.php
Normal file
68
app/Http/Requests/Employee/StoreRequest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Employee;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// users 테이블 필드
|
||||||
|
'user_id' => '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' => '이메일']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/Http/Requests/Employee/UpdateRequest.php
Normal file
79
app/Http/Requests/Employee/UpdateRequest.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Employee;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$employeeId = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
268
app/Models/Tenants/Attendance.php
Normal file
268
app/Models/Tenants/Attendance.php
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Tenants;
|
||||||
|
|
||||||
|
use App\Models\Members\User;
|
||||||
|
use App\Traits\BelongsToTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 근태 기록 모델
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property int $tenant_id
|
||||||
|
* @property int $user_id
|
||||||
|
* @property string $base_date
|
||||||
|
* @property string $status
|
||||||
|
* @property array|null $json_details
|
||||||
|
* @property string|null $remarks
|
||||||
|
* @property int|null $created_by
|
||||||
|
* @property int|null $updated_by
|
||||||
|
* @property int|null $deleted_by
|
||||||
|
*/
|
||||||
|
class Attendance extends Model
|
||||||
|
{
|
||||||
|
use BelongsToTenant, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'attendances';
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'json_details' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,18 @@
|
|||||||
|
|
||||||
use App\Models\Members\User;
|
use App\Models\Members\User;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
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
|
* @mixin IdeHelperTenantUserProfile
|
||||||
*/
|
*/
|
||||||
class TenantUserProfile extends Model
|
class TenantUserProfile extends Model
|
||||||
@@ -22,23 +32,133 @@ class TenantUserProfile extends Model
|
|||||||
'job_title_key',
|
'job_title_key',
|
||||||
'work_location_key',
|
'work_location_key',
|
||||||
'employment_type_key',
|
'employment_type_key',
|
||||||
|
'employee_status',
|
||||||
'manager_user_id',
|
'manager_user_id',
|
||||||
'json_extra',
|
'json_extra',
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
'profile_photo_path',
|
'profile_photo_path',
|
||||||
'display_name',
|
'display_name',
|
||||||
];
|
];
|
||||||
|
|
||||||
// 관계: users 테이블은 전역이라 App\Models\User 로 연결
|
// =========================================================================
|
||||||
public function user()
|
// 관계 정의
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'user_id');
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 조직, 직급 등은 옵션/코드 참조 가능 (필요시 추가)
|
public function department(): BelongsTo
|
||||||
public function department()
|
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Department::class, 'department_id');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
406
app/Services/AttendanceService.php
Normal file
406
app/Services/AttendanceService.php
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Tenants\Attendance;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class AttendanceService extends Service
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 근태 목록 조회
|
||||||
|
*/
|
||||||
|
public function index(array $params): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$tenantId = $this->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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,50 @@ public function index(array $params)
|
|||||||
return $q->paginate($perPage, ['*'], 'page', $page);
|
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)
|
public function store(array $params)
|
||||||
{
|
{
|
||||||
|
|||||||
344
app/Services/EmployeeService.php
Normal file
344
app/Services/EmployeeService.php
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Members\User;
|
||||||
|
use App\Models\Tenants\TenantUserProfile;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class EmployeeService extends Service
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 사원 목록 조회 (tenant_user_profiles + users 조인)
|
||||||
|
*/
|
||||||
|
public function index(array $params): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$tenantId = $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
422
app/Swagger/v1/AttendanceApi.php
Normal file
422
app/Swagger/v1/AttendanceApi.php
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Swagger\v1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Tag(name="Attendances", description="근태 관리 (HR)")
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="Attendance",
|
||||||
|
* type="object",
|
||||||
|
* description="근태 정보",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="id", type="integer", example=1, description="근태 ID"),
|
||||||
|
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
|
||||||
|
* @OA\Property(property="user_id", type="integer", example=10, description="사용자 ID"),
|
||||||
|
* @OA\Property(property="base_date", type="string", format="date", example="2024-01-15", description="기준일"),
|
||||||
|
* @OA\Property(property="status", type="string", enum={"onTime","late","absent","vacation","businessTrip","fieldWork","overtime","remote"}, example="onTime", description="근태 상태"),
|
||||||
|
* @OA\Property(property="remarks", type="string", example="외근으로 인한 지각", nullable=true, description="비고"),
|
||||||
|
* @OA\Property(property="check_in", type="string", example="09:00:00", nullable=true, description="출근 시간"),
|
||||||
|
* @OA\Property(property="check_out", type="string", example="18:00:00", nullable=true, description="퇴근 시간"),
|
||||||
|
* @OA\Property(property="work_minutes", type="integer", example=540, nullable=true, description="근무 시간(분)"),
|
||||||
|
* @OA\Property(property="overtime_minutes", type="integer", example=60, nullable=true, description="초과 근무 시간(분)"),
|
||||||
|
* @OA\Property(property="late_minutes", type="integer", example=15, nullable=true, description="지각 시간(분)"),
|
||||||
|
* @OA\Property(property="user", type="object", nullable=true, description="사용자 정보",
|
||||||
|
* @OA\Property(property="id", type="integer", example=10),
|
||||||
|
* @OA\Property(property="name", type="string", example="홍길동"),
|
||||||
|
* @OA\Property(property="email", type="string", example="hong@company.com")
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-15T09:00:00Z"),
|
||||||
|
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-15T18:00:00Z")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="AttendanceCreateRequest",
|
||||||
|
* type="object",
|
||||||
|
* required={"user_id", "base_date"},
|
||||||
|
* description="근태 등록 요청",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="user_id", type="integer", example=10, description="사용자 ID"),
|
||||||
|
* @OA\Property(property="base_date", type="string", format="date", example="2024-01-15", description="기준일"),
|
||||||
|
* @OA\Property(property="status", type="string", enum={"onTime","late","absent","vacation","businessTrip","fieldWork","overtime","remote"}, example="onTime", description="근태 상태"),
|
||||||
|
* @OA\Property(property="remarks", type="string", example="외근으로 인한 지각", description="비고"),
|
||||||
|
* @OA\Property(property="check_in", type="string", example="09:00:00", description="출근 시간"),
|
||||||
|
* @OA\Property(property="check_out", type="string", example="18:00:00", description="퇴근 시간"),
|
||||||
|
* @OA\Property(property="work_minutes", type="integer", example=540, description="근무 시간(분)"),
|
||||||
|
* @OA\Property(property="overtime_minutes", type="integer", example=60, description="초과 근무 시간(분)"),
|
||||||
|
* @OA\Property(property="vacation_type", type="string", example="annual", description="휴가 유형")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="AttendanceUpdateRequest",
|
||||||
|
* type="object",
|
||||||
|
* description="근태 수정 요청",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="status", type="string", enum={"onTime","late","absent","vacation","businessTrip","fieldWork","overtime","remote"}, example="onTime", description="근태 상태"),
|
||||||
|
* @OA\Property(property="remarks", type="string", example="수정된 비고", description="비고"),
|
||||||
|
* @OA\Property(property="check_in", type="string", example="09:00:00", description="출근 시간"),
|
||||||
|
* @OA\Property(property="check_out", type="string", example="18:00:00", description="퇴근 시간"),
|
||||||
|
* @OA\Property(property="work_minutes", type="integer", example=540, description="근무 시간(분)")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="AttendanceMonthlyStats",
|
||||||
|
* type="object",
|
||||||
|
* description="월간 근태 통계",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="year", type="integer", example=2024),
|
||||||
|
* @OA\Property(property="month", type="integer", example=1),
|
||||||
|
* @OA\Property(property="total_days", type="integer", example=22, description="총 기록 일수"),
|
||||||
|
* @OA\Property(property="by_status", type="object", description="상태별 일수",
|
||||||
|
* @OA\Property(property="onTime", type="integer", example=18),
|
||||||
|
* @OA\Property(property="late", type="integer", example=2),
|
||||||
|
* @OA\Property(property="absent", type="integer", example=0),
|
||||||
|
* @OA\Property(property="vacation", type="integer", example=2),
|
||||||
|
* @OA\Property(property="businessTrip", type="integer", example=0),
|
||||||
|
* @OA\Property(property="fieldWork", type="integer", example=0),
|
||||||
|
* @OA\Property(property="overtime", type="integer", example=0),
|
||||||
|
* @OA\Property(property="remote", type="integer", example=0)
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="total_work_minutes", type="integer", example=11880, description="총 근무 시간(분)"),
|
||||||
|
* @OA\Property(property="total_overtime_minutes", type="integer", example=300, description="총 초과 근무 시간(분)")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
class AttendanceApi
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/attendances",
|
||||||
|
* tags={"Attendances"},
|
||||||
|
* summary="근태 목록 조회",
|
||||||
|
* description="필터/검색/페이지네이션으로 근태 목록을 조회합니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(name="user_id", in="query", description="사용자 ID 필터", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Parameter(name="date", in="query", description="특정 날짜 (YYYY-MM-DD)", @OA\Schema(type="string", format="date")),
|
||||||
|
* @OA\Parameter(name="date_from", in="query", description="시작 날짜", @OA\Schema(type="string", format="date")),
|
||||||
|
* @OA\Parameter(name="date_to", in="query", description="종료 날짜", @OA\Schema(type="string", format="date")),
|
||||||
|
* @OA\Parameter(name="status", in="query", description="근태 상태", @OA\Schema(type="string", enum={"onTime","late","absent","vacation","businessTrip","fieldWork","overtime","remote"})),
|
||||||
|
* @OA\Parameter(name="department_id", in="query", description="부서 ID 필터", @OA\Schema(type="integer")),
|
||||||
|
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준", @OA\Schema(type="string", enum={"base_date","status","created_at"}, default="base_date")),
|
||||||
|
* @OA\Parameter(name="sort_dir", in="query", description="정렬 방향", @OA\Schema(type="string", enum={"asc","desc"}, default="desc")),
|
||||||
|
* @OA\Parameter(ref="#/components/parameters/Page"),
|
||||||
|
* @OA\Parameter(ref="#/components/parameters/Size"),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="조회 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="current_page", type="integer", example=1),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Attendance")),
|
||||||
|
* @OA\Property(property="per_page", type="integer", example=20),
|
||||||
|
* @OA\Property(property="total", type="integer", example=100)
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function index() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/attendances/monthly-stats",
|
||||||
|
* tags={"Attendances"},
|
||||||
|
* summary="월간 통계 조회",
|
||||||
|
* description="월간 근태 통계를 조회합니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(name="year", in="query", description="연도", @OA\Schema(type="integer", example=2024)),
|
||||||
|
* @OA\Parameter(name="month", in="query", description="월", @OA\Schema(type="integer", example=1, minimum=1, maximum=12)),
|
||||||
|
* @OA\Parameter(name="user_id", in="query", description="특정 사용자 ID (미지정시 전체)", @OA\Schema(type="integer")),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="조회 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/AttendanceMonthlyStats"))
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function monthlyStats() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/attendances/check-in",
|
||||||
|
* tags={"Attendances"},
|
||||||
|
* summary="출근 기록 (체크인)",
|
||||||
|
* description="출근 시간을 기록합니다. 당일 기록이 없으면 새로 생성, 있으면 업데이트합니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=false,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="user_id", type="integer", example=10, description="사용자 ID (미지정시 본인)"),
|
||||||
|
* @OA\Property(property="check_in", type="string", example="09:00:00", description="출근 시간 (미지정시 현재 시간)"),
|
||||||
|
* @OA\Property(property="gps_data", type="object", description="GPS 데이터",
|
||||||
|
* @OA\Property(property="latitude", type="number", format="float", example=37.5665),
|
||||||
|
* @OA\Property(property="longitude", type="number", format="float", example=126.9780)
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="출근 기록 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Attendance"))
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function checkIn() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/attendances/check-out",
|
||||||
|
* tags={"Attendances"},
|
||||||
|
* summary="퇴근 기록 (체크아웃)",
|
||||||
|
* description="퇴근 시간을 기록합니다. 출근 기록이 없으면 에러가 발생합니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=false,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="user_id", type="integer", example=10, description="사용자 ID (미지정시 본인)"),
|
||||||
|
* @OA\Property(property="check_out", type="string", example="18:00:00", description="퇴근 시간 (미지정시 현재 시간)"),
|
||||||
|
* @OA\Property(property="gps_data", type="object", description="GPS 데이터",
|
||||||
|
* @OA\Property(property="latitude", type="number", format="float", example=37.5665),
|
||||||
|
* @OA\Property(property="longitude", type="number", format="float", example=126.9780)
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="퇴근 기록 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Attendance"))
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="출근 기록 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function checkOut() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/attendances/{id}",
|
||||||
|
* tags={"Attendances"},
|
||||||
|
* summary="근태 상세 조회",
|
||||||
|
* description="ID 기준 근태 상세 정보를 조회합니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="근태 ID", @OA\Schema(type="integer", example=1)),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="조회 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Attendance"))
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="근태 기록을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function show() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/attendances",
|
||||||
|
* tags={"Attendances"},
|
||||||
|
* summary="근태 등록",
|
||||||
|
* description="새 근태 기록을 등록합니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/AttendanceCreateRequest")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=201,
|
||||||
|
* description="등록 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Attendance"))
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=409, description="같은 날 기록이 이미 존재함", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function store() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Patch(
|
||||||
|
* path="/api/v1/attendances/{id}",
|
||||||
|
* tags={"Attendances"},
|
||||||
|
* summary="근태 수정",
|
||||||
|
* description="근태 기록을 수정합니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="근태 ID", @OA\Schema(type="integer")),
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/AttendanceUpdateRequest")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="수정 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Attendance"))
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="근태 기록을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function update() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/api/v1/attendances/{id}",
|
||||||
|
* tags={"Attendances"},
|
||||||
|
* summary="근태 삭제",
|
||||||
|
* description="근태 기록을 삭제합니다 (소프트 삭제).",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="근태 ID", @OA\Schema(type="integer")),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="삭제 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="success", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="삭제 완료"),
|
||||||
|
* @OA\Property(property="data", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="근태 기록을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function destroy() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/attendances/bulk-delete",
|
||||||
|
* tags={"Attendances"},
|
||||||
|
* summary="근태 일괄 삭제",
|
||||||
|
* description="여러 근태 기록을 일괄 삭제합니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* required={"ids"},
|
||||||
|
*
|
||||||
|
* @OA\Property(property="ids", type="array", @OA\Items(type="integer"), example={1, 2, 3}, description="삭제할 근태 ID 목록")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="일괄 삭제 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="success", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="일괄 삭제 완료"),
|
||||||
|
* @OA\Property(property="data", type="object",
|
||||||
|
* @OA\Property(property="deleted_count", type="integer", example=3)
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function bulkDelete() {}
|
||||||
|
}
|
||||||
433
app/Swagger/v1/EmployeeApi.php
Normal file
433
app/Swagger/v1/EmployeeApi.php
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Swagger\v1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Tag(name="Employees", description="사원 관리 (HR)")
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="Employee",
|
||||||
|
* type="object",
|
||||||
|
* description="사원 정보",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="id", type="integer", example=1, description="사원 프로필 ID"),
|
||||||
|
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
|
||||||
|
* @OA\Property(property="user_id", type="integer", example=10, description="사용자 ID"),
|
||||||
|
* @OA\Property(property="name", type="string", example="홍길동", description="이름"),
|
||||||
|
* @OA\Property(property="email", type="string", example="hong@company.com", description="이메일"),
|
||||||
|
* @OA\Property(property="phone", type="string", example="010-1234-5678", description="연락처"),
|
||||||
|
* @OA\Property(property="department_id", type="integer", example=5, nullable=true, description="부서 ID"),
|
||||||
|
* @OA\Property(property="department_name", type="string", example="개발팀", nullable=true, description="부서명"),
|
||||||
|
* @OA\Property(property="position_key", type="string", example="manager", nullable=true, description="직급 키"),
|
||||||
|
* @OA\Property(property="job_title_key", type="string", example="developer", nullable=true, description="직책 키"),
|
||||||
|
* @OA\Property(property="work_location_key", type="string", example="seoul_hq", nullable=true, description="근무지 키"),
|
||||||
|
* @OA\Property(property="employment_type_key", type="string", example="fulltime", nullable=true, description="고용형태 키"),
|
||||||
|
* @OA\Property(property="employee_status", type="string", enum={"active", "leave", "resigned"}, example="active", description="고용상태"),
|
||||||
|
* @OA\Property(property="manager_user_id", type="integer", example=5, nullable=true, description="상급자 ID"),
|
||||||
|
* @OA\Property(property="display_name", type="string", example="홍길동 매니저", nullable=true, description="표시명"),
|
||||||
|
* @OA\Property(property="profile_photo_path", type="string", example="/photos/hong.jpg", nullable=true, description="프로필 사진 경로"),
|
||||||
|
* @OA\Property(property="employee_code", type="string", example="EMP-001", nullable=true, description="사원번호"),
|
||||||
|
* @OA\Property(property="hire_date", type="string", format="date", example="2020-03-15", nullable=true, description="입사일"),
|
||||||
|
* @OA\Property(property="rank", type="string", example="G3", nullable=true, description="호봉"),
|
||||||
|
* @OA\Property(property="work_type", type="string", enum={"regular", "daily", "temporary", "external"}, example="regular", nullable=true, description="근무형태"),
|
||||||
|
* @OA\Property(property="has_account", type="boolean", example=true, description="시스템 계정 보유 여부"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true, description="활성 상태"),
|
||||||
|
* @OA\Property(property="created_at", type="string", format="date-time", example="2020-03-15T09:00:00Z"),
|
||||||
|
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-10T14:30:00Z")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="EmployeeCreateRequest",
|
||||||
|
* type="object",
|
||||||
|
* required={"name", "email"},
|
||||||
|
* description="사원 등록 요청",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="user_id", type="string", example="hong001", description="사용자 ID (로그인 ID)"),
|
||||||
|
* @OA\Property(property="name", type="string", example="홍길동", description="이름"),
|
||||||
|
* @OA\Property(property="email", type="string", format="email", example="hong@company.com", description="이메일"),
|
||||||
|
* @OA\Property(property="phone", type="string", example="010-1234-5678", description="연락처"),
|
||||||
|
* @OA\Property(property="password", type="string", example="Password123!", description="초기 비밀번호 (미입력시 계정 미생성)"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true, description="활성 상태"),
|
||||||
|
* @OA\Property(property="department_id", type="integer", example=5, description="부서 ID"),
|
||||||
|
* @OA\Property(property="position_key", type="string", example="manager", description="직급 키"),
|
||||||
|
* @OA\Property(property="job_title_key", type="string", example="developer", description="직책 키"),
|
||||||
|
* @OA\Property(property="work_location_key", type="string", example="seoul_hq", description="근무지 키"),
|
||||||
|
* @OA\Property(property="employment_type_key", type="string", example="fulltime", description="고용형태 키"),
|
||||||
|
* @OA\Property(property="employee_status", type="string", enum={"active", "leave", "resigned"}, example="active", description="고용상태"),
|
||||||
|
* @OA\Property(property="manager_user_id", type="integer", example=5, description="상급자 ID"),
|
||||||
|
* @OA\Property(property="display_name", type="string", example="홍길동 매니저", description="표시명"),
|
||||||
|
* @OA\Property(property="employee_code", type="string", example="EMP-001", description="사원번호"),
|
||||||
|
* @OA\Property(property="resident_number", type="string", example="encrypted_value", description="주민번호 (암호화)"),
|
||||||
|
* @OA\Property(property="gender", type="string", enum={"male", "female"}, example="male", description="성별"),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="address",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="zipCode", type="string", example="06234"),
|
||||||
|
* @OA\Property(property="address1", type="string", example="서울시 강남구 테헤란로 123"),
|
||||||
|
* @OA\Property(property="address2", type="string", example="10층 1001호")
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="salary", type="number", format="float", example=50000000, description="연봉"),
|
||||||
|
* @OA\Property(property="hire_date", type="string", format="date", example="2020-03-15", description="입사일"),
|
||||||
|
* @OA\Property(property="rank", type="string", example="G3", description="호봉"),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="bank_account",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="bankName", type="string", example="국민은행"),
|
||||||
|
* @OA\Property(property="accountNumber", type="string", example="123-456-789012"),
|
||||||
|
* @OA\Property(property="accountHolder", type="string", example="홍길동")
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="work_type", type="string", enum={"regular", "daily", "temporary", "external"}, example="regular", description="근무형태"),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="contract_info",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="start_date", type="string", format="date", example="2024-01-01"),
|
||||||
|
* @OA\Property(property="end_date", type="string", format="date", example="2024-12-31"),
|
||||||
|
* @OA\Property(property="external_company", type="string", example="파트너사")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="EmployeeUpdateRequest",
|
||||||
|
* type="object",
|
||||||
|
* description="사원 수정 요청",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="name", type="string", example="홍길동"),
|
||||||
|
* @OA\Property(property="email", type="string", format="email", example="hong@company.com"),
|
||||||
|
* @OA\Property(property="phone", type="string", example="010-1234-5678"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="department_id", type="integer", example=5),
|
||||||
|
* @OA\Property(property="position_key", type="string", example="manager"),
|
||||||
|
* @OA\Property(property="job_title_key", type="string", example="developer"),
|
||||||
|
* @OA\Property(property="work_location_key", type="string", example="seoul_hq"),
|
||||||
|
* @OA\Property(property="employment_type_key", type="string", example="fulltime"),
|
||||||
|
* @OA\Property(property="employee_status", type="string", enum={"active", "leave", "resigned"}, example="active"),
|
||||||
|
* @OA\Property(property="manager_user_id", type="integer", example=5),
|
||||||
|
* @OA\Property(property="display_name", type="string", example="홍길동 매니저"),
|
||||||
|
* @OA\Property(property="employee_code", type="string", example="EMP-001"),
|
||||||
|
* @OA\Property(property="hire_date", type="string", format="date", example="2020-03-15"),
|
||||||
|
* @OA\Property(property="rank", type="string", example="G3"),
|
||||||
|
* @OA\Property(property="work_type", type="string", enum={"regular", "daily", "temporary", "external"}, example="regular")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="EmployeeStats",
|
||||||
|
* type="object",
|
||||||
|
* description="사원 통계",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="total", type="integer", example=150, description="전체 사원 수"),
|
||||||
|
* @OA\Property(property="active", type="integer", example=140, description="재직 중"),
|
||||||
|
* @OA\Property(property="leave", type="integer", example=5, description="휴직 중"),
|
||||||
|
* @OA\Property(property="resigned", type="integer", example=5, description="퇴직"),
|
||||||
|
* @OA\Property(property="has_account", type="integer", example=130, description="시스템 계정 보유"),
|
||||||
|
* @OA\Property(property="no_account", type="integer", example=20, description="시스템 계정 미보유")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
class EmployeeApi
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/employees",
|
||||||
|
* tags={"Employees"},
|
||||||
|
* summary="사원 목록 조회",
|
||||||
|
* description="필터/검색/페이지네이션으로 사원 목록을 조회합니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(name="q", in="query", description="이름/이메일/사원번호 검색어", @OA\Schema(type="string")),
|
||||||
|
* @OA\Parameter(name="status", in="query", description="고용상태 필터", @OA\Schema(type="string", enum={"active", "leave", "resigned"})),
|
||||||
|
* @OA\Parameter(name="department_id", in="query", description="부서 ID 필터", @OA\Schema(type="integer", example=5)),
|
||||||
|
* @OA\Parameter(name="has_account", in="query", description="시스템 계정 보유 여부", @OA\Schema(type="string", enum={"0", "1", "true", "false"})),
|
||||||
|
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준", @OA\Schema(type="string", enum={"created_at", "name", "employee_status", "department_id"}, default="created_at")),
|
||||||
|
* @OA\Parameter(name="sort_dir", in="query", description="정렬 방향", @OA\Schema(type="string", enum={"asc", "desc"}, default="desc")),
|
||||||
|
* @OA\Parameter(ref="#/components/parameters/Page"),
|
||||||
|
* @OA\Parameter(ref="#/components/parameters/Size"),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="조회 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="current_page", type="integer", example=1),
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Employee")),
|
||||||
|
* @OA\Property(property="first_page_url", type="string", example="/api/v1/employees?page=1"),
|
||||||
|
* @OA\Property(property="from", type="integer", example=1),
|
||||||
|
* @OA\Property(property="last_page", type="integer", example=8),
|
||||||
|
* @OA\Property(property="last_page_url", type="string", example="/api/v1/employees?page=8"),
|
||||||
|
* @OA\Property(property="next_page_url", type="string", nullable=true, example="/api/v1/employees?page=2"),
|
||||||
|
* @OA\Property(property="path", type="string", example="/api/v1/employees"),
|
||||||
|
* @OA\Property(property="per_page", type="integer", example=20),
|
||||||
|
* @OA\Property(property="prev_page_url", type="string", nullable=true, example=null),
|
||||||
|
* @OA\Property(property="to", type="integer", example=20),
|
||||||
|
* @OA\Property(property="total", type="integer", example=150)
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function index() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/employees/stats",
|
||||||
|
* tags={"Employees"},
|
||||||
|
* summary="사원 통계 조회",
|
||||||
|
* description="사원 현황 통계를 조회합니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="조회 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/EmployeeStats"))
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function stats() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/employees/{id}",
|
||||||
|
* tags={"Employees"},
|
||||||
|
* summary="사원 상세 조회",
|
||||||
|
* description="ID 기준 사원 상세 정보를 조회합니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="사원 프로필 ID", @OA\Schema(type="integer", example=1)),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="조회 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Employee"))
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="사원을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function show() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/employees",
|
||||||
|
* tags={"Employees"},
|
||||||
|
* summary="사원 등록",
|
||||||
|
* description="새 사원을 등록합니다. password를 입력하면 시스템 계정도 함께 생성됩니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/EmployeeCreateRequest")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=201,
|
||||||
|
* description="등록 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Employee"))
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=409, description="이메일 중복", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function store() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Patch(
|
||||||
|
* path="/api/v1/employees/{id}",
|
||||||
|
* tags={"Employees"},
|
||||||
|
* summary="사원 수정",
|
||||||
|
* description="사원 정보를 수정합니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="사원 프로필 ID", @OA\Schema(type="integer")),
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/EmployeeUpdateRequest")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="수정 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Employee"))
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="사원을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=409, description="이메일 중복", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function update() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/api/v1/employees/{id}",
|
||||||
|
* tags={"Employees"},
|
||||||
|
* summary="사원 삭제 (퇴직 처리)",
|
||||||
|
* description="사원을 퇴직 처리합니다. employee_status가 'resigned'로 변경되고 소프트 삭제됩니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="사원 프로필 ID", @OA\Schema(type="integer")),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="삭제 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="success", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="삭제 완료"),
|
||||||
|
* @OA\Property(property="data", type="boolean", example=true)
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="사원을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function destroy() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/employees/bulk-delete",
|
||||||
|
* tags={"Employees"},
|
||||||
|
* summary="사원 일괄 삭제",
|
||||||
|
* description="여러 사원을 일괄 퇴직 처리합니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* required={"ids"},
|
||||||
|
*
|
||||||
|
* @OA\Property(property="ids", type="array", @OA\Items(type="integer"), example={1, 2, 3}, description="삭제할 사원 ID 목록")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="일괄 삭제 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="success", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string", example="일괄 삭제 완료"),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="deleted_count", type="integer", example=3)
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function bulkDelete() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/employees/{id}/create-account",
|
||||||
|
* tags={"Employees"},
|
||||||
|
* summary="시스템 계정 생성",
|
||||||
|
* description="기존 사원에게 시스템 로그인 계정을 생성합니다.",
|
||||||
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, description="사원 프로필 ID", @OA\Schema(type="integer")),
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* type="object",
|
||||||
|
* required={"password"},
|
||||||
|
*
|
||||||
|
* @OA\Property(property="password", type="string", minLength=8, example="Password123!", description="계정 비밀번호")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="계정 생성 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Employee"))
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="사원을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=409, description="이미 계정이 존재함", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function createAccount() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?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('tenant_user_profiles', function (Blueprint $table) {
|
||||||
|
// 인덱싱 필요한 필드만 컬럼으로 추가
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?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::create('attendances', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?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('attendances', function (Blueprint $table) {
|
||||||
|
$table->string('remarks', 500)->nullable()->after('json_details')->comment('비고');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('attendances', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('remarks');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
use App\Http\Controllers\Api\V1\ClientGroupController;
|
use App\Http\Controllers\Api\V1\ClientGroupController;
|
||||||
use App\Http\Controllers\Api\V1\CommonController;
|
use App\Http\Controllers\Api\V1\CommonController;
|
||||||
use App\Http\Controllers\Api\V1\DepartmentController;
|
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\AuditLogController as DesignAuditLogController;
|
||||||
use App\Http\Controllers\Api\V1\Design\BomCalculationController;
|
use App\Http\Controllers\Api\V1\Design\BomCalculationController;
|
||||||
use App\Http\Controllers\Api\V1\Design\BomTemplateController as DesignBomTemplateController;
|
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\ItemSectionController;
|
||||||
use App\Http\Controllers\Api\V1\ItemMaster\SectionTemplateController;
|
use App\Http\Controllers\Api\V1\ItemMaster\SectionTemplateController;
|
||||||
use App\Http\Controllers\Api\V1\ItemMaster\UnitOptionController;
|
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\MaterialController;
|
||||||
use App\Http\Controllers\Api\V1\MenuController;
|
use App\Http\Controllers\Api\V1\MenuController;
|
||||||
use App\Http\Controllers\Api\V1\ModelSetController;
|
use App\Http\Controllers\Api\V1\ModelSetController;
|
||||||
@@ -194,6 +199,7 @@
|
|||||||
Route::prefix('departments')->group(function () {
|
Route::prefix('departments')->group(function () {
|
||||||
Route::get('', [DepartmentController::class, 'index'])->name('v1.departments.index'); // 목록
|
Route::get('', [DepartmentController::class, 'index'])->name('v1.departments.index'); // 목록
|
||||||
Route::post('', [DepartmentController::class, 'store'])->name('v1.departments.store'); // 생성
|
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::get('/{id}', [DepartmentController::class, 'show'])->name('v1.departments.show'); // 단건
|
||||||
Route::patch('/{id}', [DepartmentController::class, 'update'])->name('v1.departments.update'); // 수정
|
Route::patch('/{id}', [DepartmentController::class, 'update'])->name('v1.departments.update'); // 수정
|
||||||
Route::delete('/{id}', [DepartmentController::class, 'destroy'])->name('v1.departments.destroy'); // 삭제(soft)
|
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'); // 권한 제거(해당 메뉴 범위까지)
|
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
|
// Permission API
|
||||||
Route::prefix('permissions')->group(function () {
|
Route::prefix('permissions')->group(function () {
|
||||||
Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('v1.permissions.deptMenuMatrix'); // 부서별 권한 메트릭스
|
Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('v1.permissions.deptMenuMatrix'); // 부서별 권한 메트릭스
|
||||||
@@ -408,33 +439,33 @@
|
|||||||
|
|
||||||
// Items (통합 품목 조회 - materials + products UNION)
|
// Items (통합 품목 조회 - materials + products UNION)
|
||||||
Route::prefix('items')->group(function () {
|
Route::prefix('items')->group(function () {
|
||||||
Route::get('', [\App\Http\Controllers\Api\V1\ItemsController::class, 'index'])->name('v1.items.index'); // 통합 목록
|
Route::get('', [ItemsController::class, 'index'])->name('v1.items.index'); // 통합 목록
|
||||||
Route::post('', [\App\Http\Controllers\Api\V1\ItemsController::class, 'store'])->name('v1.items.store'); // 품목 생성
|
Route::post('', [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('/code/{code}', [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::get('/{id}', [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::put('/{id}', [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('/batch', [ItemsController::class, 'batchDestroy'])->name('v1.items.batch_destroy'); // 품목 일괄 삭제
|
||||||
Route::delete('/{id}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'destroy'])->name('v1.items.destroy'); // 품목 삭제
|
Route::delete('/{id}', [ItemsController::class, 'destroy'])->name('v1.items.destroy'); // 품목 삭제
|
||||||
});
|
});
|
||||||
|
|
||||||
// Items BOM (ID-based BOM API)
|
// Items BOM (ID-based BOM API)
|
||||||
Route::prefix('items/{id}/bom')->group(function () {
|
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('', [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::get('/tree', [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::post('', [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::put('/{lineId}', [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::delete('/{lineId}', [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('/summary', [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::get('/validate', [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('/replace', [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::post('/reorder', [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('/categories', [ItemsBomController::class, 'listCategories'])->name('v1.items.bom.categories'); // 카테고리 목록
|
||||||
});
|
});
|
||||||
|
|
||||||
// Items Files (ID-based File Upload API)
|
// Items Files (ID-based File Upload API)
|
||||||
Route::prefix('items/{id}/files')->group(function () {
|
Route::prefix('items/{id}/files')->group(function () {
|
||||||
Route::post('', [\App\Http\Controllers\Api\V1\ItemsFileController::class, 'upload'])->name('v1.items.files.upload'); // 파일 업로드
|
Route::post('', [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::delete('/{type}', [ItemsFileController::class, 'delete'])->name('v1.items.files.delete'); // 파일 삭제 (type: bending_diagram|specification|certification)
|
||||||
});
|
});
|
||||||
|
|
||||||
// BOM (product_components: ref_type=PRODUCT|MATERIAL)
|
// BOM (product_components: ref_type=PRODUCT|MATERIAL)
|
||||||
|
|||||||
Reference in New Issue
Block a user