feat: 공통 모듈 추가 (엑셀 내보내기, 계정과목 일괄변경)

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 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 17:14:04 +09:00
parent e3630c6196
commit a1aa8726af
11 changed files with 564 additions and 2 deletions

View File

@@ -11,12 +11,17 @@
use App\Http\Requests\Attendance\StoreRequest; use App\Http\Requests\Attendance\StoreRequest;
use App\Http\Requests\Attendance\UpdateRequest; use App\Http\Requests\Attendance\UpdateRequest;
use App\Services\AttendanceService; use App\Services\AttendanceService;
use App\Services\ExportService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class AttendanceController extends Controller 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()); return $this->service->monthlyStats($request->validated());
}, __('message.fetched')); }, __('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,
'근태현황'
);
}
} }

View File

@@ -4,6 +4,7 @@
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\BulkUpdateAccountCodeRequest;
use App\Http\Requests\V1\Deposit\StoreDepositRequest; use App\Http\Requests\V1\Deposit\StoreDepositRequest;
use App\Http\Requests\V1\Deposit\UpdateDepositRequest; use App\Http\Requests\V1\Deposit\UpdateDepositRequest;
use App\Services\DepositService; use App\Services\DepositService;
@@ -94,4 +95,20 @@ public function summary(Request $request)
return ApiResponse::success($summary, __('message.fetched')); 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')
);
}
} }

View File

@@ -7,13 +7,16 @@
use App\Http\Requests\V1\Salary\BulkUpdateStatusRequest; use App\Http\Requests\V1\Salary\BulkUpdateStatusRequest;
use App\Http\Requests\V1\Salary\StoreSalaryRequest; use App\Http\Requests\V1\Salary\StoreSalaryRequest;
use App\Http\Requests\V1\Salary\UpdateSalaryRequest; use App\Http\Requests\V1\Salary\UpdateSalaryRequest;
use App\Services\ExportService;
use App\Services\SalaryService; use App\Services\SalaryService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class SalaryController extends Controller class SalaryController extends Controller
{ {
public function __construct( 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')); 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,
'급여현황'
);
}
} }

View File

@@ -4,6 +4,7 @@
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\BulkUpdateAccountCodeRequest;
use App\Http\Requests\V1\Withdrawal\StoreWithdrawalRequest; use App\Http\Requests\V1\Withdrawal\StoreWithdrawalRequest;
use App\Http\Requests\V1\Withdrawal\UpdateWithdrawalRequest; use App\Http\Requests\V1\Withdrawal\UpdateWithdrawalRequest;
use App\Services\WithdrawalService; use App\Services\WithdrawalService;
@@ -94,4 +95,20 @@ public function summary(Request $request)
return ApiResponse::success($summary, __('message.fetched')); 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')
);
}
} }

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 계정과목 일괄 변경 요청 검증
*
* 여러 모듈에서 공통으로 사용:
* - 출금관리 (WithdrawalController)
* - 입금관리 (DepositController)
* - 매출관리 (SaleController)
* - 카드거래 (CardTransactionController)
*/
class BulkUpdateAccountCodeRequest extends FormRequest
{
/**
* 권한 확인
*/
public function authorize(): bool
{
return true;
}
/**
* 유효성 검사 규칙
*
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['required', 'integer', 'min:1'],
'account_code' => ['required', 'string', 'max:50'],
];
}
/**
* 유효성 검사 메시지
*
* @return array<string, string>
*/
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<int, int>
*/
public function getIds(): array
{
return $this->validated('ids');
}
/**
* 검증된 계정과목 코드 반환
*/
public function getAccountCode(): string
{
return $this->validated('account_code');
}
}

View File

@@ -356,6 +356,108 @@ public function checkOut(array $data): Attendance
}); });
} }
/**
* 엑셀 내보내기용 데이터 조회
*
* @return array{data: array<int, array<string, mixed>>, headings: array<int, string>}
*/
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,
];
}
/** /**
* 월간 통계 조회 * 월간 통계 조회
*/ */

View File

@@ -179,6 +179,27 @@ public function destroy(int $id): bool
}); });
} }
/**
* 계정과목 일괄 변경
*
* @param array<int, int> $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,
]);
}
/** /**
* 입금 요약 (기간별 합계) * 입금 요약 (기간별 합계)
*/ */

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithTitle;
use Maatwebsite\Excel\Facades\Excel;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 범용 엑셀 내보내기 서비스
*
* 여러 모듈에서 공통으로 사용할 수 있는 엑셀 내보내기 기능 제공
* - 근태관리 (AttendanceController)
* - 급여관리 (SalaryController)
* - 기타 데이터 내보내기가 필요한 모듈
*/
class ExportService extends Service
{
/**
* 엑셀 파일 다운로드
*
* @param array<int, array<string, mixed>> $data 내보낼 데이터 배열
* @param array<int, string> $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<int, array<string, mixed>> $data 내보낼 데이터 배열
* @param array<int, string> $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<int, array<string, mixed>> $data 내보낼 데이터
* @param array<int, string> $headings 컬럼 헤더
* @param string $sheetTitle 시트 제목
*/
public function __construct(
private readonly array $data,
private readonly array $headings,
private readonly string $sheetTitle
) {}
/**
* 데이터 배열 반환
*
* @return array<int, array<string, mixed>>
*/
public function array(): array
{
return $this->data;
}
/**
* 헤더 배열 반환
*
* @return array<int, string>
*/
public function headings(): array
{
return $this->headings;
}
/**
* 시트 제목 반환
*/
public function title(): string
{
return $this->sheetTitle;
}
/**
* 스타일 적용
*
* @return array<int|string, array<string, mixed>>
*/
public function styles(Worksheet $sheet): array
{
return [
// 헤더 행 스타일 (굵게, 배경색)
1 => [
'font' => ['bold' => true],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E2E8F0'],
],
],
];
}
}

View File

@@ -255,6 +255,113 @@ public function bulkUpdateStatus(array $ids, string $status): int
}); });
} }
/**
* 엑셀 내보내기용 데이터 조회
*
* @return array{data: array<int, array<string, mixed>>, headings: array<int, string>}
*/
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,
];
}
/** /**
* 급여 통계 조회 * 급여 통계 조회
*/ */

View File

@@ -179,6 +179,27 @@ public function destroy(int $id): bool
}); });
} }
/**
* 계정과목 일괄 변경
*
* @param array<int, int> $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,
]);
}
/** /**
* 출금 요약 (기간별 합계) * 출금 요약 (기간별 합계)
*/ */

View File

@@ -340,6 +340,7 @@
Route::get('', [AttendanceController::class, 'index'])->name('v1.attendances.index'); Route::get('', [AttendanceController::class, 'index'])->name('v1.attendances.index');
Route::post('', [AttendanceController::class, 'store'])->name('v1.attendances.store'); Route::post('', [AttendanceController::class, 'store'])->name('v1.attendances.store');
Route::get('/monthly-stats', [AttendanceController::class, 'monthlyStats'])->name('v1.attendances.monthlyStats'); 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-in', [AttendanceController::class, 'checkIn'])->name('v1.attendances.checkIn');
Route::post('/check-out', [AttendanceController::class, 'checkOut'])->name('v1.attendances.checkOut'); Route::post('/check-out', [AttendanceController::class, 'checkOut'])->name('v1.attendances.checkOut');
Route::get('/{id}', [AttendanceController::class, 'show'])->name('v1.attendances.show'); Route::get('/{id}', [AttendanceController::class, 'show'])->name('v1.attendances.show');
@@ -503,6 +504,7 @@
Route::get('', [DepositController::class, 'index'])->name('v1.deposits.index'); Route::get('', [DepositController::class, 'index'])->name('v1.deposits.index');
Route::post('', [DepositController::class, 'store'])->name('v1.deposits.store'); Route::post('', [DepositController::class, 'store'])->name('v1.deposits.store');
Route::get('/summary', [DepositController::class, 'summary'])->name('v1.deposits.summary'); 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::get('/{id}', [DepositController::class, 'show'])->whereNumber('id')->name('v1.deposits.show');
Route::put('/{id}', [DepositController::class, 'update'])->whereNumber('id')->name('v1.deposits.update'); Route::put('/{id}', [DepositController::class, 'update'])->whereNumber('id')->name('v1.deposits.update');
Route::delete('/{id}', [DepositController::class, 'destroy'])->whereNumber('id')->name('v1.deposits.destroy'); 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::get('', [WithdrawalController::class, 'index'])->name('v1.withdrawals.index');
Route::post('', [WithdrawalController::class, 'store'])->name('v1.withdrawals.store'); Route::post('', [WithdrawalController::class, 'store'])->name('v1.withdrawals.store');
Route::get('/summary', [WithdrawalController::class, 'summary'])->name('v1.withdrawals.summary'); 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::get('/{id}', [WithdrawalController::class, 'show'])->whereNumber('id')->name('v1.withdrawals.show');
Route::put('/{id}', [WithdrawalController::class, 'update'])->whereNumber('id')->name('v1.withdrawals.update'); Route::put('/{id}', [WithdrawalController::class, 'update'])->whereNumber('id')->name('v1.withdrawals.update');
Route::delete('/{id}', [WithdrawalController::class, 'destroy'])->whereNumber('id')->name('v1.withdrawals.destroy'); 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::get('', [SalaryController::class, 'index'])->name('v1.salaries.index');
Route::post('', [SalaryController::class, 'store'])->name('v1.salaries.store'); Route::post('', [SalaryController::class, 'store'])->name('v1.salaries.store');
Route::get('/statistics', [SalaryController::class, 'statistics'])->name('v1.salaries.statistics'); 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::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::get('/{id}', [SalaryController::class, 'show'])->whereNumber('id')->name('v1.salaries.show');
Route::put('/{id}', [SalaryController::class, 'update'])->whereNumber('id')->name('v1.salaries.update'); Route::put('/{id}', [SalaryController::class, 'update'])->whereNumber('id')->name('v1.salaries.update');