From a1aa8726af1298321ed43302fa24d0a1408c6b20 Mon Sep 17 00:00:00 2001 From: kent Date: Thu, 15 Jan 2026 17:14:04 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(=EC=97=91=EC=85=80=20=EB=82=B4?= =?UTF-8?q?=EB=B3=B4=EB=82=B4=EA=B8=B0,=20=EA=B3=84=EC=A0=95=EA=B3=BC?= =?UTF-8?q?=EB=AA=A9=20=EC=9D=BC=EA=B4=84=EB=B3=80=EA=B2=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 - 공통 모듈: - ExportService.php 생성 (Maatwebsite/Excel 기반 엑셀 내보내기) - BulkUpdateAccountCodeRequest.php 생성 (계정과목 일괄변경 유효성 검사) Phase 1 - 계정과목 일괄변경: - WithdrawalController/Service: bulkUpdateAccountCode 메서드 추가 - DepositController/Service: bulkUpdateAccountCode 메서드 추가 - POST /v1/withdrawals/bulk-update-account-code - POST /v1/deposits/bulk-update-account-code Phase 2 - 엑셀 내보내기: - AttendanceController/Service: export, getExportData 메서드 추가 - SalaryController/Service: export, getExportData 메서드 추가 - GET /v1/attendances/export - GET /v1/salaries/export Co-Authored-By: Claude --- .../Api/V1/AttendanceController.php | 35 ++++- .../Controllers/Api/V1/DepositController.php | 17 +++ .../Controllers/Api/V1/SalaryController.php | 34 ++++- .../Api/V1/WithdrawalController.php | 17 +++ .../Requests/BulkUpdateAccountCodeRequest.php | 79 +++++++++++ app/Services/AttendanceService.php | 102 ++++++++++++++ app/Services/DepositService.php | 21 +++ app/Services/ExportService.php | 129 ++++++++++++++++++ app/Services/SalaryService.php | 107 +++++++++++++++ app/Services/WithdrawalService.php | 21 +++ routes/api.php | 4 + 11 files changed, 564 insertions(+), 2 deletions(-) create mode 100644 app/Http/Requests/BulkUpdateAccountCodeRequest.php create mode 100644 app/Services/ExportService.php diff --git a/app/Http/Controllers/Api/V1/AttendanceController.php b/app/Http/Controllers/Api/V1/AttendanceController.php index fcbfc02..65876c6 100644 --- a/app/Http/Controllers/Api/V1/AttendanceController.php +++ b/app/Http/Controllers/Api/V1/AttendanceController.php @@ -11,12 +11,17 @@ use App\Http\Requests\Attendance\StoreRequest; use App\Http\Requests\Attendance\UpdateRequest; use App\Services\AttendanceService; +use App\Services\ExportService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\BinaryFileResponse; class AttendanceController extends Controller { - public function __construct(private AttendanceService $service) {} + public function __construct( + private AttendanceService $service, + private ExportService $exportService + ) {} /** * 근태 목록 조회 @@ -121,4 +126,32 @@ public function monthlyStats(MonthlyStatsRequest $request): JsonResponse return $this->service->monthlyStats($request->validated()); }, __('message.fetched')); } + + /** + * 근태 엑셀 내보내기 + * GET /v1/attendances/export + */ + public function export(Request $request): BinaryFileResponse + { + $params = $request->only([ + 'user_id', + 'date', + 'date_from', + 'date_to', + 'status', + 'department_id', + 'sort_by', + 'sort_dir', + ]); + + $exportData = $this->service->getExportData($params); + $filename = '근태현황_'.date('Ymd_His'); + + return $this->exportService->download( + $exportData['data'], + $exportData['headings'], + $filename, + '근태현황' + ); + } } diff --git a/app/Http/Controllers/Api/V1/DepositController.php b/app/Http/Controllers/Api/V1/DepositController.php index 6436279..e5d3abd 100644 --- a/app/Http/Controllers/Api/V1/DepositController.php +++ b/app/Http/Controllers/Api/V1/DepositController.php @@ -4,6 +4,7 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\BulkUpdateAccountCodeRequest; use App\Http\Requests\V1\Deposit\StoreDepositRequest; use App\Http\Requests\V1\Deposit\UpdateDepositRequest; use App\Services\DepositService; @@ -94,4 +95,20 @@ public function summary(Request $request) return ApiResponse::success($summary, __('message.fetched')); } + + /** + * 계정과목 일괄 변경 + */ + public function bulkUpdateAccountCode(BulkUpdateAccountCodeRequest $request) + { + $updatedCount = $this->service->bulkUpdateAccountCode( + $request->getIds(), + $request->getAccountCode() + ); + + return ApiResponse::success( + ['updated_count' => $updatedCount], + __('message.bulk_updated') + ); + } } diff --git a/app/Http/Controllers/Api/V1/SalaryController.php b/app/Http/Controllers/Api/V1/SalaryController.php index ff0ea93..e613e2f 100644 --- a/app/Http/Controllers/Api/V1/SalaryController.php +++ b/app/Http/Controllers/Api/V1/SalaryController.php @@ -7,13 +7,16 @@ use App\Http\Requests\V1\Salary\BulkUpdateStatusRequest; use App\Http\Requests\V1\Salary\StoreSalaryRequest; use App\Http\Requests\V1\Salary\UpdateSalaryRequest; +use App\Services\ExportService; use App\Services\SalaryService; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\BinaryFileResponse; class SalaryController extends Controller { public function __construct( - private readonly SalaryService $service + private readonly SalaryService $service, + private readonly ExportService $exportService ) {} /** @@ -123,4 +126,33 @@ public function statistics(Request $request) return ApiResponse::success($stats, __('message.fetched')); } + + /** + * 급여 엑셀 내보내기 + * GET /v1/salaries/export + */ + public function export(Request $request): BinaryFileResponse + { + $params = $request->only([ + 'search', + 'year', + 'month', + 'status', + 'employee_id', + 'start_date', + 'end_date', + 'sort_by', + 'sort_dir', + ]); + + $exportData = $this->service->getExportData($params); + $filename = '급여현황_'.date('Ymd_His'); + + return $this->exportService->download( + $exportData['data'], + $exportData['headings'], + $filename, + '급여현황' + ); + } } diff --git a/app/Http/Controllers/Api/V1/WithdrawalController.php b/app/Http/Controllers/Api/V1/WithdrawalController.php index 06eea38..e26feb8 100644 --- a/app/Http/Controllers/Api/V1/WithdrawalController.php +++ b/app/Http/Controllers/Api/V1/WithdrawalController.php @@ -4,6 +4,7 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\BulkUpdateAccountCodeRequest; use App\Http\Requests\V1\Withdrawal\StoreWithdrawalRequest; use App\Http\Requests\V1\Withdrawal\UpdateWithdrawalRequest; use App\Services\WithdrawalService; @@ -94,4 +95,20 @@ public function summary(Request $request) return ApiResponse::success($summary, __('message.fetched')); } + + /** + * 계정과목 일괄 변경 + */ + public function bulkUpdateAccountCode(BulkUpdateAccountCodeRequest $request) + { + $updatedCount = $this->service->bulkUpdateAccountCode( + $request->getIds(), + $request->getAccountCode() + ); + + return ApiResponse::success( + ['updated_count' => $updatedCount], + __('message.bulk_updated') + ); + } } diff --git a/app/Http/Requests/BulkUpdateAccountCodeRequest.php b/app/Http/Requests/BulkUpdateAccountCodeRequest.php new file mode 100644 index 0000000..90fba00 --- /dev/null +++ b/app/Http/Requests/BulkUpdateAccountCodeRequest.php @@ -0,0 +1,79 @@ +> + */ + public function rules(): array + { + return [ + 'ids' => ['required', 'array', 'min:1'], + 'ids.*' => ['required', 'integer', 'min:1'], + 'account_code' => ['required', 'string', 'max:50'], + ]; + } + + /** + * 유효성 검사 메시지 + * + * @return array + */ + public function messages(): array + { + return [ + 'ids.required' => __('validation.required', ['attribute' => 'ID 목록']), + 'ids.array' => __('validation.array', ['attribute' => 'ID 목록']), + 'ids.min' => __('validation.min.array', ['attribute' => 'ID 목록', 'min' => 1]), + 'ids.*.required' => __('validation.required', ['attribute' => 'ID']), + 'ids.*.integer' => __('validation.integer', ['attribute' => 'ID']), + 'ids.*.min' => __('validation.min.numeric', ['attribute' => 'ID', 'min' => 1]), + 'account_code.required' => __('validation.required', ['attribute' => '계정과목']), + 'account_code.string' => __('validation.string', ['attribute' => '계정과목']), + 'account_code.max' => __('validation.max.string', ['attribute' => '계정과목', 'max' => 50]), + ]; + } + + /** + * 검증된 ID 배열 반환 + * + * @return array + */ + public function getIds(): array + { + return $this->validated('ids'); + } + + /** + * 검증된 계정과목 코드 반환 + */ + public function getAccountCode(): string + { + return $this->validated('account_code'); + } +} diff --git a/app/Services/AttendanceService.php b/app/Services/AttendanceService.php index eddd7c4..f4b444d 100644 --- a/app/Services/AttendanceService.php +++ b/app/Services/AttendanceService.php @@ -356,6 +356,108 @@ public function checkOut(array $data): Attendance }); } + /** + * 엑셀 내보내기용 데이터 조회 + * + * @return array{data: array>, headings: array} + */ + public function getExportData(array $params): array + { + $tenantId = $this->tenantId(); + + $query = Attendance::query() + ->where('tenant_id', $tenantId) + ->with([ + 'user:id,name,email', + 'user.tenantProfiles' => function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId) + ->with('department:id,name'); + }, + ]); + + // 사용자 필터 + 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); + + $attendances = $query->get(); + + // 상태 레이블 매핑 + $statusLabels = [ + 'onTime' => '정상출근', + 'late' => '지각', + 'absent' => '결근', + 'vacation' => '휴가', + 'businessTrip' => '출장', + 'fieldWork' => '외근', + 'overtime' => '야근', + 'remote' => '재택', + ]; + + // 엑셀 데이터 변환 + $data = $attendances->map(function ($attendance) use ($statusLabels) { + $profile = $attendance->user?->tenantProfiles?->first(); + $jsonDetails = $attendance->json_details ?? []; + + return [ + $attendance->base_date, + $attendance->user?->name ?? '-', + $profile?->department?->name ?? '-', + $statusLabels[$attendance->status] ?? $attendance->status, + $attendance->check_in ?? '-', + $attendance->check_out ?? '-', + isset($jsonDetails['work_minutes']) ? round($jsonDetails['work_minutes'] / 60, 1) : '-', + $attendance->remarks ?? '', + ]; + })->toArray(); + + $headings = [ + '날짜', + '직원명', + '부서', + '상태', + '출근시간', + '퇴근시간', + '근무시간(h)', + '비고', + ]; + + return [ + 'data' => $data, + 'headings' => $headings, + ]; + } + /** * 월간 통계 조회 */ diff --git a/app/Services/DepositService.php b/app/Services/DepositService.php index f574c7b..33f3d8a 100644 --- a/app/Services/DepositService.php +++ b/app/Services/DepositService.php @@ -179,6 +179,27 @@ public function destroy(int $id): bool }); } + /** + * 계정과목 일괄 변경 + * + * @param array $ids 변경할 입금 ID 목록 + * @param string $accountCode 새 계정과목 코드 + * @return int 변경된 레코드 수 + */ + public function bulkUpdateAccountCode(array $ids, string $accountCode): int + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return Deposit::query() + ->where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->update([ + 'account_code' => $accountCode, + 'updated_by' => $userId, + ]); + } + /** * 입금 요약 (기간별 합계) */ diff --git a/app/Services/ExportService.php b/app/Services/ExportService.php new file mode 100644 index 0000000..53d7b6b --- /dev/null +++ b/app/Services/ExportService.php @@ -0,0 +1,129 @@ +> $data 내보낼 데이터 배열 + * @param array $headings 컬럼 헤더 배열 + * @param string $filename 다운로드 파일명 (확장자 제외) + * @param string $sheetTitle 시트 제목 + */ + public function download( + array $data, + array $headings, + string $filename, + string $sheetTitle = 'Sheet1' + ): BinaryFileResponse { + $export = new GenericExport($data, $headings, $sheetTitle); + + return Excel::download($export, $filename.'.xlsx'); + } + + /** + * 엑셀 파일 저장 (서버에 저장) + * + * @param array> $data 내보낼 데이터 배열 + * @param array $headings 컬럼 헤더 배열 + * @param string $path 저장 경로 (storage/app 기준) + * @param string $sheetTitle 시트 제목 + */ + public function store( + array $data, + array $headings, + string $path, + string $sheetTitle = 'Sheet1' + ): bool { + $export = new GenericExport($data, $headings, $sheetTitle); + + return Excel::store($export, $path); + } +} + +/** + * 범용 엑셀 내보내기 클래스 + * + * ExportService에서 내부적으로 사용하는 Maatwebsite Excel 구현체 + */ +class GenericExport implements FromArray, ShouldAutoSize, WithHeadings, WithStyles, WithTitle +{ + /** + * @param array> $data 내보낼 데이터 + * @param array $headings 컬럼 헤더 + * @param string $sheetTitle 시트 제목 + */ + public function __construct( + private readonly array $data, + private readonly array $headings, + private readonly string $sheetTitle + ) {} + + /** + * 데이터 배열 반환 + * + * @return array> + */ + public function array(): array + { + return $this->data; + } + + /** + * 헤더 배열 반환 + * + * @return array + */ + public function headings(): array + { + return $this->headings; + } + + /** + * 시트 제목 반환 + */ + public function title(): string + { + return $this->sheetTitle; + } + + /** + * 스타일 적용 + * + * @return array> + */ + public function styles(Worksheet $sheet): array + { + return [ + // 헤더 행 스타일 (굵게, 배경색) + 1 => [ + 'font' => ['bold' => true], + 'fill' => [ + 'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, + 'startColor' => ['rgb' => 'E2E8F0'], + ], + ], + ]; + } +} diff --git a/app/Services/SalaryService.php b/app/Services/SalaryService.php index 9ddb47a..ef9316b 100644 --- a/app/Services/SalaryService.php +++ b/app/Services/SalaryService.php @@ -255,6 +255,113 @@ public function bulkUpdateStatus(array $ids, string $status): int }); } + /** + * 엑셀 내보내기용 데이터 조회 + * + * @return array{data: array>, headings: array} + */ + public function getExportData(array $params): array + { + $tenantId = $this->tenantId(); + + $query = Salary::query() + ->where('tenant_id', $tenantId) + ->with([ + 'employee:id,name,user_id,email', + 'employeeProfile' => fn ($q) => $q->where('tenant_id', $tenantId), + 'employeeProfile.department:id,name', + ]); + + // 검색 필터 (직원명) + if (! empty($params['search'])) { + $search = $params['search']; + $query->whereHas('employee', function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%"); + }); + } + + // 연도 필터 + if (! empty($params['year'])) { + $query->where('year', $params['year']); + } + + // 월 필터 + if (! empty($params['month'])) { + $query->where('month', $params['month']); + } + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 기간 필터 + if (! empty($params['start_date']) && ! empty($params['end_date'])) { + $query->whereBetween('payment_date', [$params['start_date'], $params['end_date']]); + } + + // 직원 ID 필터 + if (! empty($params['employee_id'])) { + $query->where('employee_id', $params['employee_id']); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'year'; + $sortDir = $params['sort_dir'] ?? 'desc'; + + if ($sortBy === 'year') { + $query->orderBy('year', $sortDir) + ->orderBy('month', $sortDir); + } else { + $query->orderBy($sortBy, $sortDir); + } + + $salaries = $query->get(); + + // 상태 레이블 매핑 + $statusLabels = [ + 'scheduled' => '지급예정', + 'completed' => '지급완료', + 'pending' => '보류', + ]; + + // 엑셀 데이터 변환 + $data = $salaries->map(function ($salary) use ($statusLabels) { + return [ + $salary->year.'년 '.$salary->month.'월', + $salary->employee?->name ?? '-', + $salary->employeeProfile?->department?->name ?? '-', + number_format($salary->base_salary), + number_format($salary->total_allowance), + number_format($salary->total_overtime), + number_format($salary->total_bonus), + number_format($salary->total_deduction), + number_format($salary->net_payment), + $statusLabels[$salary->status] ?? $salary->status, + $salary->payment_date ?? '-', + ]; + })->toArray(); + + $headings = [ + '급여월', + '직원명', + '부서', + '기본급', + '수당', + '야근수당', + '상여금', + '공제액', + '실지급액', + '상태', + '지급일', + ]; + + return [ + 'data' => $data, + 'headings' => $headings, + ]; + } + /** * 급여 통계 조회 */ diff --git a/app/Services/WithdrawalService.php b/app/Services/WithdrawalService.php index 6e9dd04..dcca08c 100644 --- a/app/Services/WithdrawalService.php +++ b/app/Services/WithdrawalService.php @@ -179,6 +179,27 @@ public function destroy(int $id): bool }); } + /** + * 계정과목 일괄 변경 + * + * @param array $ids 변경할 출금 ID 목록 + * @param string $accountCode 새 계정과목 코드 + * @return int 변경된 레코드 수 + */ + public function bulkUpdateAccountCode(array $ids, string $accountCode): int + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return Withdrawal::query() + ->where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->update([ + 'account_code' => $accountCode, + 'updated_by' => $userId, + ]); + } + /** * 출금 요약 (기간별 합계) */ diff --git a/routes/api.php b/routes/api.php index 070cc18..215edac 100644 --- a/routes/api.php +++ b/routes/api.php @@ -340,6 +340,7 @@ 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::get('/export', [AttendanceController::class, 'export'])->name('v1.attendances.export'); 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'); @@ -503,6 +504,7 @@ Route::get('', [DepositController::class, 'index'])->name('v1.deposits.index'); Route::post('', [DepositController::class, 'store'])->name('v1.deposits.store'); Route::get('/summary', [DepositController::class, 'summary'])->name('v1.deposits.summary'); + Route::post('/bulk-update-account-code', [DepositController::class, 'bulkUpdateAccountCode'])->name('v1.deposits.bulk-update-account-code'); Route::get('/{id}', [DepositController::class, 'show'])->whereNumber('id')->name('v1.deposits.show'); Route::put('/{id}', [DepositController::class, 'update'])->whereNumber('id')->name('v1.deposits.update'); Route::delete('/{id}', [DepositController::class, 'destroy'])->whereNumber('id')->name('v1.deposits.destroy'); @@ -513,6 +515,7 @@ Route::get('', [WithdrawalController::class, 'index'])->name('v1.withdrawals.index'); Route::post('', [WithdrawalController::class, 'store'])->name('v1.withdrawals.store'); Route::get('/summary', [WithdrawalController::class, 'summary'])->name('v1.withdrawals.summary'); + Route::post('/bulk-update-account-code', [WithdrawalController::class, 'bulkUpdateAccountCode'])->name('v1.withdrawals.bulk-update-account-code'); Route::get('/{id}', [WithdrawalController::class, 'show'])->whereNumber('id')->name('v1.withdrawals.show'); Route::put('/{id}', [WithdrawalController::class, 'update'])->whereNumber('id')->name('v1.withdrawals.update'); Route::delete('/{id}', [WithdrawalController::class, 'destroy'])->whereNumber('id')->name('v1.withdrawals.destroy'); @@ -538,6 +541,7 @@ Route::get('', [SalaryController::class, 'index'])->name('v1.salaries.index'); Route::post('', [SalaryController::class, 'store'])->name('v1.salaries.store'); Route::get('/statistics', [SalaryController::class, 'statistics'])->name('v1.salaries.statistics'); + Route::get('/export', [SalaryController::class, 'export'])->name('v1.salaries.export'); Route::post('/bulk-update-status', [SalaryController::class, 'bulkUpdateStatus'])->name('v1.salaries.bulk-update-status'); Route::get('/{id}', [SalaryController::class, 'show'])->whereNumber('id')->name('v1.salaries.show'); Route::put('/{id}', [SalaryController::class, 'update'])->whereNumber('id')->name('v1.salaries.update');