feat: 근태관리/직원관리 API 구현

- AttendanceController, AttendanceService 추가
- EmployeeController, EmployeeService 추가
- Attendance 모델 및 마이그레이션 추가
- TenantUserProfile에 employee_status 컬럼 추가
- DepartmentService 트리 조회 기능 개선
- Swagger 문서 추가 (AttendanceApi, EmployeeApi)
- API 라우트 등록
This commit is contained in:
2025-12-09 20:27:44 +09:00
parent 33010f1916
commit f1f4c52c31
24 changed files with 2844 additions and 27 deletions

View 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'));
}
}

View File

@@ -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)
{ {

View 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'));
}
}

View File

@@ -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'));
} }

View 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',
];
}
}

View 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',
];
}
}

View 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',
];
}
}

View 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',
];
}
}

View 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' => '기준일']),
];
}
}

View 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',
];
}
}

View 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',
];
}
}

View 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' => '이메일']),
];
}
}

View 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;
}
}

View 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);
}
}

View File

@@ -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');
}
} }

View 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';
}
}

View File

@@ -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)
{ {

View 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);
}
}

View 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() {}
}

View 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() {}
}

View File

@@ -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');
});
}
};

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View File

@@ -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)