diff --git a/app/Http/Controllers/Api/V1/PayrollController.php b/app/Http/Controllers/Api/V1/PayrollController.php new file mode 100644 index 0000000..881fb0b --- /dev/null +++ b/app/Http/Controllers/Api/V1/PayrollController.php @@ -0,0 +1,172 @@ +only([ + 'year', + 'month', + 'user_id', + 'status', + 'search', + 'sort_by', + 'sort_dir', + 'per_page', + 'page', + ]); + + $payrolls = $this->service->index($params); + + return ApiResponse::handle(__('message.fetched'), $payrolls); + } + + /** + * 특정 연월 급여 요약 + */ + public function summary(Request $request) + { + $year = (int) $request->input('year', date('Y')); + $month = (int) $request->input('month', date('n')); + + $summary = $this->service->summary($year, $month); + + return ApiResponse::handle(__('message.fetched'), $summary); + } + + /** + * 급여 등록 + */ + public function store(StorePayrollRequest $request) + { + $payroll = $this->service->store($request->validated()); + + return ApiResponse::handle(__('message.created'), $payroll, 201); + } + + /** + * 급여 상세 + */ + public function show(int $id) + { + $payroll = $this->service->show($id); + + return ApiResponse::handle(__('message.fetched'), $payroll); + } + + /** + * 급여 수정 + */ + public function update(int $id, UpdatePayrollRequest $request) + { + $payroll = $this->service->update($id, $request->validated()); + + return ApiResponse::handle(__('message.updated'), $payroll); + } + + /** + * 급여 삭제 + */ + public function destroy(int $id) + { + $this->service->destroy($id); + + return ApiResponse::handle(__('message.deleted')); + } + + /** + * 급여 확정 + */ + public function confirm(int $id) + { + $payroll = $this->service->confirm($id); + + return ApiResponse::handle(__('message.payroll.confirmed'), $payroll); + } + + /** + * 급여 지급 처리 + */ + public function pay(int $id, PayPayrollRequest $request) + { + $payroll = $this->service->pay($id, $request->input('withdrawal_id')); + + return ApiResponse::handle(__('message.payroll.paid'), $payroll); + } + + /** + * 일괄 확정 + */ + public function bulkConfirm(Request $request) + { + $year = (int) $request->input('year', date('Y')); + $month = (int) $request->input('month', date('n')); + + $count = $this->service->bulkConfirm($year, $month); + + return ApiResponse::handle(__('message.payroll.bulk_confirmed'), ['count' => $count]); + } + + /** + * 급여명세서 조회 + */ + public function payslip(int $id) + { + $payslip = $this->service->payslip($id); + + return ApiResponse::handle(__('message.fetched'), $payslip); + } + + /** + * 급여 일괄 계산 + */ + public function calculate(CalculatePayrollRequest $request) + { + $year = (int) $request->input('year'); + $month = (int) $request->input('month'); + $userIds = $request->input('user_ids'); + + $payrolls = $this->service->calculate($year, $month, $userIds); + + return ApiResponse::handle(__('message.payroll.calculated'), $payrolls); + } + + /** + * 급여 설정 조회 + */ + public function getSettings() + { + $settings = $this->service->getSettings(); + + return ApiResponse::handle(__('message.fetched'), $settings); + } + + /** + * 급여 설정 수정 + */ + public function updateSettings(UpdatePayrollSettingRequest $request) + { + $settings = $this->service->updateSettings($request->validated()); + + return ApiResponse::handle(__('message.updated'), $settings); + } +} diff --git a/app/Http/Requests/V1/Payroll/CalculatePayrollRequest.php b/app/Http/Requests/V1/Payroll/CalculatePayrollRequest.php new file mode 100644 index 0000000..2c85879 --- /dev/null +++ b/app/Http/Requests/V1/Payroll/CalculatePayrollRequest.php @@ -0,0 +1,32 @@ + ['required', 'integer', 'min:2000', 'max:2100'], + 'month' => ['required', 'integer', 'min:1', 'max:12'], + 'user_ids' => ['nullable', 'array'], + 'user_ids.*' => ['integer', 'exists:users,id'], + ]; + } + + public function attributes(): array + { + return [ + 'year' => __('validation.attributes.pay_year'), + 'month' => __('validation.attributes.pay_month'), + 'user_ids' => __('validation.attributes.user_ids'), + ]; + } +} diff --git a/app/Http/Requests/V1/Payroll/PayPayrollRequest.php b/app/Http/Requests/V1/Payroll/PayPayrollRequest.php new file mode 100644 index 0000000..d8937a3 --- /dev/null +++ b/app/Http/Requests/V1/Payroll/PayPayrollRequest.php @@ -0,0 +1,27 @@ + ['nullable', 'integer', 'exists:withdrawals,id'], + ]; + } + + public function attributes(): array + { + return [ + 'withdrawal_id' => __('validation.attributes.withdrawal_id'), + ]; + } +} diff --git a/app/Http/Requests/V1/Payroll/StorePayrollRequest.php b/app/Http/Requests/V1/Payroll/StorePayrollRequest.php new file mode 100644 index 0000000..b416dca --- /dev/null +++ b/app/Http/Requests/V1/Payroll/StorePayrollRequest.php @@ -0,0 +1,57 @@ + ['required', 'integer', 'exists:users,id'], + 'pay_year' => ['required', 'integer', 'min:2000', 'max:2100'], + 'pay_month' => ['required', 'integer', 'min:1', 'max:12'], + 'base_salary' => ['required', 'numeric', 'min:0'], + 'overtime_pay' => ['nullable', 'numeric', 'min:0'], + 'bonus' => ['nullable', 'numeric', 'min:0'], + 'allowances' => ['nullable', 'array'], + 'allowances.*.name' => ['required_with:allowances', 'string', 'max:50'], + 'allowances.*.amount' => ['required_with:allowances', 'numeric', 'min:0'], + 'income_tax' => ['nullable', 'numeric', 'min:0'], + 'resident_tax' => ['nullable', 'numeric', 'min:0'], + 'health_insurance' => ['nullable', 'numeric', 'min:0'], + 'pension' => ['nullable', 'numeric', 'min:0'], + 'employment_insurance' => ['nullable', 'numeric', 'min:0'], + 'deductions' => ['nullable', 'array'], + 'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'], + 'deductions.*.amount' => ['required_with:deductions', 'numeric', 'min:0'], + 'note' => ['nullable', 'string', 'max:1000'], + ]; + } + + public function attributes(): array + { + return [ + 'user_id' => __('validation.attributes.user_id'), + 'pay_year' => __('validation.attributes.pay_year'), + 'pay_month' => __('validation.attributes.pay_month'), + 'base_salary' => __('validation.attributes.base_salary'), + 'overtime_pay' => __('validation.attributes.overtime_pay'), + 'bonus' => __('validation.attributes.bonus'), + 'allowances' => __('validation.attributes.allowances'), + 'income_tax' => __('validation.attributes.income_tax'), + 'resident_tax' => __('validation.attributes.resident_tax'), + 'health_insurance' => __('validation.attributes.health_insurance'), + 'pension' => __('validation.attributes.pension'), + 'employment_insurance' => __('validation.attributes.employment_insurance'), + 'deductions' => __('validation.attributes.deductions'), + 'note' => __('validation.attributes.note'), + ]; + } +} diff --git a/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php b/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php new file mode 100644 index 0000000..6eefc3d --- /dev/null +++ b/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php @@ -0,0 +1,57 @@ + ['sometimes', 'integer', 'exists:users,id'], + 'pay_year' => ['sometimes', 'integer', 'min:2000', 'max:2100'], + 'pay_month' => ['sometimes', 'integer', 'min:1', 'max:12'], + 'base_salary' => ['sometimes', 'numeric', 'min:0'], + 'overtime_pay' => ['nullable', 'numeric', 'min:0'], + 'bonus' => ['nullable', 'numeric', 'min:0'], + 'allowances' => ['nullable', 'array'], + 'allowances.*.name' => ['required_with:allowances', 'string', 'max:50'], + 'allowances.*.amount' => ['required_with:allowances', 'numeric', 'min:0'], + 'income_tax' => ['nullable', 'numeric', 'min:0'], + 'resident_tax' => ['nullable', 'numeric', 'min:0'], + 'health_insurance' => ['nullable', 'numeric', 'min:0'], + 'pension' => ['nullable', 'numeric', 'min:0'], + 'employment_insurance' => ['nullable', 'numeric', 'min:0'], + 'deductions' => ['nullable', 'array'], + 'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'], + 'deductions.*.amount' => ['required_with:deductions', 'numeric', 'min:0'], + 'note' => ['nullable', 'string', 'max:1000'], + ]; + } + + public function attributes(): array + { + return [ + 'user_id' => __('validation.attributes.user_id'), + 'pay_year' => __('validation.attributes.pay_year'), + 'pay_month' => __('validation.attributes.pay_month'), + 'base_salary' => __('validation.attributes.base_salary'), + 'overtime_pay' => __('validation.attributes.overtime_pay'), + 'bonus' => __('validation.attributes.bonus'), + 'allowances' => __('validation.attributes.allowances'), + 'income_tax' => __('validation.attributes.income_tax'), + 'resident_tax' => __('validation.attributes.resident_tax'), + 'health_insurance' => __('validation.attributes.health_insurance'), + 'pension' => __('validation.attributes.pension'), + 'employment_insurance' => __('validation.attributes.employment_insurance'), + 'deductions' => __('validation.attributes.deductions'), + 'note' => __('validation.attributes.note'), + ]; + } +} diff --git a/app/Http/Requests/V1/Payroll/UpdatePayrollSettingRequest.php b/app/Http/Requests/V1/Payroll/UpdatePayrollSettingRequest.php new file mode 100644 index 0000000..b23daf7 --- /dev/null +++ b/app/Http/Requests/V1/Payroll/UpdatePayrollSettingRequest.php @@ -0,0 +1,54 @@ + ['nullable', 'numeric', 'min:0', 'max:100'], + 'resident_tax_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'health_insurance_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'long_term_care_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'pension_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'employment_insurance_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'pension_max_salary' => ['nullable', 'numeric', 'min:0'], + 'pension_min_salary' => ['nullable', 'numeric', 'min:0'], + 'pay_day' => ['nullable', 'integer', 'min:1', 'max:31'], + 'auto_calculate' => ['nullable', 'boolean'], + 'allowance_types' => ['nullable', 'array'], + 'allowance_types.*.code' => ['required_with:allowance_types', 'string', 'max:20'], + 'allowance_types.*.name' => ['required_with:allowance_types', 'string', 'max:50'], + 'allowance_types.*.is_taxable' => ['nullable', 'boolean'], + 'deduction_types' => ['nullable', 'array'], + 'deduction_types.*.code' => ['required_with:deduction_types', 'string', 'max:20'], + 'deduction_types.*.name' => ['required_with:deduction_types', 'string', 'max:50'], + ]; + } + + public function attributes(): array + { + return [ + 'income_tax_rate' => __('validation.attributes.income_tax_rate'), + 'resident_tax_rate' => __('validation.attributes.resident_tax_rate'), + 'health_insurance_rate' => __('validation.attributes.health_insurance_rate'), + 'long_term_care_rate' => __('validation.attributes.long_term_care_rate'), + 'pension_rate' => __('validation.attributes.pension_rate'), + 'employment_insurance_rate' => __('validation.attributes.employment_insurance_rate'), + 'pension_max_salary' => __('validation.attributes.pension_max_salary'), + 'pension_min_salary' => __('validation.attributes.pension_min_salary'), + 'pay_day' => __('validation.attributes.pay_day'), + 'auto_calculate' => __('validation.attributes.auto_calculate'), + 'allowance_types' => __('validation.attributes.allowance_types'), + 'deduction_types' => __('validation.attributes.deduction_types'), + ]; + } +} diff --git a/app/Models/Tenants/Payroll.php b/app/Models/Tenants/Payroll.php new file mode 100644 index 0000000..2805f60 --- /dev/null +++ b/app/Models/Tenants/Payroll.php @@ -0,0 +1,336 @@ + 'array', + 'deductions' => 'array', + 'base_salary' => 'decimal:2', + 'overtime_pay' => 'decimal:2', + 'bonus' => 'decimal:2', + 'gross_salary' => 'decimal:2', + 'income_tax' => 'decimal:2', + 'resident_tax' => 'decimal:2', + 'health_insurance' => 'decimal:2', + 'pension' => 'decimal:2', + 'employment_insurance' => 'decimal:2', + 'total_deductions' => 'decimal:2', + 'net_salary' => 'decimal:2', + 'confirmed_at' => 'datetime', + 'paid_at' => 'datetime', + 'pay_year' => 'integer', + 'pay_month' => 'integer', + ]; + + protected $fillable = [ + 'tenant_id', + 'user_id', + 'pay_year', + 'pay_month', + 'base_salary', + 'overtime_pay', + 'bonus', + 'allowances', + 'gross_salary', + 'income_tax', + 'resident_tax', + 'health_insurance', + 'pension', + 'employment_insurance', + 'deductions', + 'total_deductions', + 'net_salary', + 'status', + 'confirmed_at', + 'confirmed_by', + 'paid_at', + 'withdrawal_id', + 'note', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + protected $attributes = [ + 'status' => 'draft', + 'base_salary' => 0, + 'overtime_pay' => 0, + 'bonus' => 0, + 'gross_salary' => 0, + 'income_tax' => 0, + 'resident_tax' => 0, + 'health_insurance' => 0, + 'pension' => 0, + 'employment_insurance' => 0, + 'total_deductions' => 0, + 'net_salary' => 0, + ]; + + // ========================================================================= + // 상태 상수 + // ========================================================================= + + public const STATUS_DRAFT = 'draft'; // 작성중 + + public const STATUS_CONFIRMED = 'confirmed'; // 확정 + + public const STATUS_PAID = 'paid'; // 지급완료 + + public const STATUSES = [ + self::STATUS_DRAFT, + self::STATUS_CONFIRMED, + self::STATUS_PAID, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 급여 대상 사용자 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * 확정자 + */ + public function confirmer(): BelongsTo + { + return $this->belongsTo(User::class, 'confirmed_by'); + } + + /** + * 출금 내역 + */ + public function withdrawal(): BelongsTo + { + return $this->belongsTo(Withdrawal::class, 'withdrawal_id'); + } + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + /** + * 특정 상태 + */ + public function scopeWithStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * 작성중 + */ + public function scopeDraft($query) + { + return $query->where('status', self::STATUS_DRAFT); + } + + /** + * 확정 + */ + public function scopeConfirmed($query) + { + return $query->where('status', self::STATUS_CONFIRMED); + } + + /** + * 지급완료 + */ + public function scopePaid($query) + { + return $query->where('status', self::STATUS_PAID); + } + + /** + * 특정 연월 + */ + public function scopeForPeriod($query, int $year, int $month) + { + return $query->where('pay_year', $year)->where('pay_month', $month); + } + + /** + * 특정 사용자 + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 수정 가능 여부 (작성중 상태만) + */ + public function isEditable(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + /** + * 확정 가능 여부 + */ + public function isConfirmable(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + /** + * 지급 가능 여부 + */ + public function isPayable(): bool + { + return $this->status === self::STATUS_CONFIRMED; + } + + /** + * 삭제 가능 여부 (작성중만) + */ + public function isDeletable(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + /** + * 상태 라벨 + */ + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_DRAFT => '작성중', + self::STATUS_CONFIRMED => '확정', + self::STATUS_PAID => '지급완료', + default => $this->status, + }; + } + + /** + * 급여 기간 문자열 + */ + public function getPeriodLabelAttribute(): string + { + return sprintf('%d년 %02d월', $this->pay_year, $this->pay_month); + } + + /** + * 수당 합계 + */ + public function getAllowancesTotalAttribute(): float + { + if (empty($this->allowances)) { + return 0; + } + + return collect($this->allowances)->sum('amount'); + } + + /** + * 공제 합계 (JSON) + */ + public function getDeductionsTotalAttribute(): float + { + if (empty($this->deductions)) { + return 0; + } + + return collect($this->deductions)->sum('amount'); + } + + /** + * 총지급액 계산 + */ + public function calculateGrossSalary(): float + { + return $this->base_salary + + $this->overtime_pay + + $this->bonus + + $this->allowances_total; + } + + /** + * 총공제액 계산 + */ + public function calculateTotalDeductions(): float + { + return $this->income_tax + + $this->resident_tax + + $this->health_insurance + + $this->pension + + $this->employment_insurance + + $this->deductions_total; + } + + /** + * 실수령액 계산 + */ + public function calculateNetSalary(): float + { + return $this->gross_salary - $this->total_deductions; + } +} diff --git a/app/Models/Tenants/PayrollSetting.php b/app/Models/Tenants/PayrollSetting.php new file mode 100644 index 0000000..cb10035 --- /dev/null +++ b/app/Models/Tenants/PayrollSetting.php @@ -0,0 +1,191 @@ + 'decimal:2', + 'resident_tax_rate' => 'decimal:2', + 'health_insurance_rate' => 'decimal:3', + 'long_term_care_rate' => 'decimal:4', + 'pension_rate' => 'decimal:3', + 'employment_insurance_rate' => 'decimal:3', + 'pension_max_salary' => 'decimal:2', + 'pension_min_salary' => 'decimal:2', + 'pay_day' => 'integer', + 'auto_calculate' => 'boolean', + 'allowance_types' => 'array', + 'deduction_types' => 'array', + ]; + + protected $fillable = [ + 'tenant_id', + 'income_tax_rate', + 'resident_tax_rate', + 'health_insurance_rate', + 'long_term_care_rate', + 'pension_rate', + 'employment_insurance_rate', + 'pension_max_salary', + 'pension_min_salary', + 'pay_day', + 'auto_calculate', + 'allowance_types', + 'deduction_types', + ]; + + protected $attributes = [ + 'income_tax_rate' => 0, + 'resident_tax_rate' => 10, + 'health_insurance_rate' => 3.545, + 'long_term_care_rate' => 0.9082, + 'pension_rate' => 4.5, + 'employment_insurance_rate' => 0.9, + 'pension_max_salary' => 5900000, + 'pension_min_salary' => 370000, + 'pay_day' => 25, + 'auto_calculate' => false, + ]; + + // ========================================================================= + // 기본 수당 유형 + // ========================================================================= + + public const DEFAULT_ALLOWANCE_TYPES = [ + ['code' => 'meal', 'name' => '식대', 'is_taxable' => false], + ['code' => 'transport', 'name' => '교통비', 'is_taxable' => false], + ['code' => 'position', 'name' => '직책수당', 'is_taxable' => true], + ['code' => 'skill', 'name' => '기술수당', 'is_taxable' => true], + ['code' => 'family', 'name' => '가족수당', 'is_taxable' => true], + ['code' => 'housing', 'name' => '주거수당', 'is_taxable' => true], + ]; + + // ========================================================================= + // 기본 공제 유형 + // ========================================================================= + + public const DEFAULT_DEDUCTION_TYPES = [ + ['code' => 'loan', 'name' => '대출상환'], + ['code' => 'union', 'name' => '조합비'], + ['code' => 'savings', 'name' => '저축'], + ['code' => 'etc', 'name' => '기타공제'], + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 테넌트 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 수당 유형 목록 (기본값 포함) + */ + public function getAllowanceTypesWithDefaultAttribute(): array + { + return $this->allowance_types ?? self::DEFAULT_ALLOWANCE_TYPES; + } + + /** + * 공제 유형 목록 (기본값 포함) + */ + public function getDeductionTypesWithDefaultAttribute(): array + { + return $this->deduction_types ?? self::DEFAULT_DEDUCTION_TYPES; + } + + /** + * 건강보험료 계산 + */ + public function calculateHealthInsurance(float $salary): float + { + return round($salary * ($this->health_insurance_rate / 100), 0); + } + + /** + * 장기요양보험료 계산 (건강보험료의 %) + */ + public function calculateLongTermCare(float $healthInsurance): float + { + return round($healthInsurance * ($this->long_term_care_rate / 100), 0); + } + + /** + * 국민연금 계산 + */ + public function calculatePension(float $salary): float + { + // 기준소득월액 상/하한 적용 + $standardSalary = min(max($salary, $this->pension_min_salary), $this->pension_max_salary); + + return round($standardSalary * ($this->pension_rate / 100), 0); + } + + /** + * 고용보험료 계산 + */ + public function calculateEmploymentInsurance(float $salary): float + { + return round($salary * ($this->employment_insurance_rate / 100), 0); + } + + /** + * 주민세 계산 (소득세의 10%) + */ + public function calculateResidentTax(float $incomeTax): float + { + return round($incomeTax * ($this->resident_tax_rate / 100), 0); + } + + /** + * 테넌트별 설정 가져오기 또는 생성 + */ + public static function getOrCreate(int $tenantId): self + { + return self::firstOrCreate( + ['tenant_id' => $tenantId], + [ + 'allowance_types' => self::DEFAULT_ALLOWANCE_TYPES, + 'deduction_types' => self::DEFAULT_DEDUCTION_TYPES, + ] + ); + } +} diff --git a/app/Services/PayrollService.php b/app/Services/PayrollService.php new file mode 100644 index 0000000..ca49c9d --- /dev/null +++ b/app/Services/PayrollService.php @@ -0,0 +1,562 @@ +tenantId(); + + $query = Payroll::query() + ->where('tenant_id', $tenantId) + ->with(['user:id,name,email', 'creator:id,name']); + + // 연도 필터 + if (! empty($params['year'])) { + $query->where('pay_year', $params['year']); + } + + // 월 필터 + if (! empty($params['month'])) { + $query->where('pay_month', $params['month']); + } + + // 사용자 필터 + if (! empty($params['user_id'])) { + $query->where('user_id', $params['user_id']); + } + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 검색 (사용자명) + if (! empty($params['search'])) { + $query->whereHas('user', function ($q) use ($params) { + $q->where('name', 'like', "%{$params['search']}%"); + }); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'pay_year'; + $sortDir = $params['sort_dir'] ?? 'desc'; + + if ($sortBy === 'period') { + $query->orderBy('pay_year', $sortDir)->orderBy('pay_month', $sortDir); + } else { + $query->orderBy($sortBy, $sortDir); + } + + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 특정 연월 급여 요약 + */ + public function summary(int $year, int $month): array + { + $tenantId = $this->tenantId(); + + $stats = Payroll::query() + ->where('tenant_id', $tenantId) + ->where('pay_year', $year) + ->where('pay_month', $month) + ->selectRaw(' + COUNT(*) as total_count, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft_count, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as confirmed_count, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as paid_count, + SUM(gross_salary) as total_gross, + SUM(total_deductions) as total_deductions, + SUM(net_salary) as total_net + ', [Payroll::STATUS_DRAFT, Payroll::STATUS_CONFIRMED, Payroll::STATUS_PAID]) + ->first(); + + return [ + 'year' => $year, + 'month' => $month, + 'total_count' => (int) $stats->total_count, + 'draft_count' => (int) $stats->draft_count, + 'confirmed_count' => (int) $stats->confirmed_count, + 'paid_count' => (int) $stats->paid_count, + 'total_gross' => (float) $stats->total_gross, + 'total_deductions' => (float) $stats->total_deductions, + 'total_net' => (float) $stats->total_net, + ]; + } + + /** + * 급여 상세 + */ + public function show(int $id): Payroll + { + $tenantId = $this->tenantId(); + + return Payroll::query() + ->where('tenant_id', $tenantId) + ->with([ + 'user:id,name,email', + 'confirmer:id,name', + 'withdrawal', + 'creator:id,name', + ]) + ->findOrFail($id); + } + + // ========================================================================= + // 급여 생성/수정/삭제 + // ========================================================================= + + /** + * 급여 생성 + */ + public function store(array $data): Payroll + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 중복 확인 + $exists = Payroll::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $data['user_id']) + ->where('pay_year', $data['pay_year']) + ->where('pay_month', $data['pay_month']) + ->exists(); + + if ($exists) { + throw new BadRequestHttpException(__('error.payroll.already_exists')); + } + + // 금액 계산 + $grossSalary = $this->calculateGross($data); + $totalDeductions = $this->calculateDeductions($data); + $netSalary = $grossSalary - $totalDeductions; + + return Payroll::create([ + 'tenant_id' => $tenantId, + 'user_id' => $data['user_id'], + 'pay_year' => $data['pay_year'], + 'pay_month' => $data['pay_month'], + 'base_salary' => $data['base_salary'] ?? 0, + 'overtime_pay' => $data['overtime_pay'] ?? 0, + 'bonus' => $data['bonus'] ?? 0, + 'allowances' => $data['allowances'] ?? null, + 'gross_salary' => $grossSalary, + 'income_tax' => $data['income_tax'] ?? 0, + 'resident_tax' => $data['resident_tax'] ?? 0, + 'health_insurance' => $data['health_insurance'] ?? 0, + 'pension' => $data['pension'] ?? 0, + 'employment_insurance' => $data['employment_insurance'] ?? 0, + 'deductions' => $data['deductions'] ?? null, + 'total_deductions' => $totalDeductions, + 'net_salary' => $netSalary, + 'status' => Payroll::STATUS_DRAFT, + 'note' => $data['note'] ?? null, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + } + + /** + * 급여 수정 + */ + public function update(int $id, array $data): Payroll + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $payroll = Payroll::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $payroll->isEditable()) { + throw new BadRequestHttpException(__('error.payroll.not_editable')); + } + + // 연월 변경 시 중복 확인 + $newYear = $data['pay_year'] ?? $payroll->pay_year; + $newMonth = $data['pay_month'] ?? $payroll->pay_month; + $newUserId = $data['user_id'] ?? $payroll->user_id; + + if ($newYear != $payroll->pay_year || $newMonth != $payroll->pay_month || $newUserId != $payroll->user_id) { + $exists = Payroll::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $newUserId) + ->where('pay_year', $newYear) + ->where('pay_month', $newMonth) + ->where('id', '!=', $id) + ->exists(); + + if ($exists) { + throw new BadRequestHttpException(__('error.payroll.already_exists')); + } + } + + // 금액 업데이트 + $updateData = array_merge($payroll->toArray(), $data); + $grossSalary = $this->calculateGross($updateData); + $totalDeductions = $this->calculateDeductions($updateData); + $netSalary = $grossSalary - $totalDeductions; + + $payroll->fill([ + 'user_id' => $data['user_id'] ?? $payroll->user_id, + 'pay_year' => $data['pay_year'] ?? $payroll->pay_year, + 'pay_month' => $data['pay_month'] ?? $payroll->pay_month, + 'base_salary' => $data['base_salary'] ?? $payroll->base_salary, + 'overtime_pay' => $data['overtime_pay'] ?? $payroll->overtime_pay, + 'bonus' => $data['bonus'] ?? $payroll->bonus, + 'allowances' => $data['allowances'] ?? $payroll->allowances, + 'gross_salary' => $grossSalary, + 'income_tax' => $data['income_tax'] ?? $payroll->income_tax, + 'resident_tax' => $data['resident_tax'] ?? $payroll->resident_tax, + 'health_insurance' => $data['health_insurance'] ?? $payroll->health_insurance, + 'pension' => $data['pension'] ?? $payroll->pension, + 'employment_insurance' => $data['employment_insurance'] ?? $payroll->employment_insurance, + 'deductions' => $data['deductions'] ?? $payroll->deductions, + 'total_deductions' => $totalDeductions, + 'net_salary' => $netSalary, + 'note' => $data['note'] ?? $payroll->note, + 'updated_by' => $userId, + ]); + + $payroll->save(); + + return $payroll->fresh(['user:id,name,email', 'creator:id,name']); + } + + /** + * 급여 삭제 + */ + public function destroy(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $payroll = Payroll::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $payroll->isDeletable()) { + throw new BadRequestHttpException(__('error.payroll.not_deletable')); + } + + $payroll->deleted_by = $userId; + $payroll->save(); + $payroll->delete(); + + return true; + } + + // ========================================================================= + // 급여 확정/지급 + // ========================================================================= + + /** + * 급여 확정 + */ + public function confirm(int $id): Payroll + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $payroll = Payroll::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $payroll->isConfirmable()) { + throw new BadRequestHttpException(__('error.payroll.not_confirmable')); + } + + $payroll->status = Payroll::STATUS_CONFIRMED; + $payroll->confirmed_at = now(); + $payroll->confirmed_by = $userId; + $payroll->updated_by = $userId; + $payroll->save(); + + return $payroll->fresh(['user:id,name,email', 'confirmer:id,name']); + } + + /** + * 급여 지급 처리 + */ + public function pay(int $id, ?int $withdrawalId = null): Payroll + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $withdrawalId, $tenantId, $userId) { + $payroll = Payroll::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $payroll->isPayable()) { + throw new BadRequestHttpException(__('error.payroll.not_payable')); + } + + // 출금 내역 연결 검증 + if ($withdrawalId) { + $withdrawal = Withdrawal::query() + ->where('tenant_id', $tenantId) + ->where('id', $withdrawalId) + ->first(); + + if (! $withdrawal) { + throw new BadRequestHttpException(__('error.payroll.invalid_withdrawal')); + } + } + + $payroll->status = Payroll::STATUS_PAID; + $payroll->paid_at = now(); + $payroll->withdrawal_id = $withdrawalId; + $payroll->updated_by = $userId; + $payroll->save(); + + return $payroll->fresh(['user:id,name,email', 'withdrawal']); + }); + } + + /** + * 일괄 확정 + */ + public function bulkConfirm(int $year, int $month): int + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return Payroll::query() + ->where('tenant_id', $tenantId) + ->where('pay_year', $year) + ->where('pay_month', $month) + ->where('status', Payroll::STATUS_DRAFT) + ->update([ + 'status' => Payroll::STATUS_CONFIRMED, + 'confirmed_at' => now(), + 'confirmed_by' => $userId, + 'updated_by' => $userId, + ]); + } + + // ========================================================================= + // 급여명세서 + // ========================================================================= + + /** + * 급여명세서 데이터 + */ + public function payslip(int $id): array + { + $payroll = $this->show($id); + + // 수당 목록 + $allowances = collect($payroll->allowances ?? [])->map(function ($item) { + return [ + 'name' => $item['name'] ?? '', + 'amount' => (float) ($item['amount'] ?? 0), + ]; + })->toArray(); + + // 공제 목록 + $deductions = collect($payroll->deductions ?? [])->map(function ($item) { + return [ + 'name' => $item['name'] ?? '', + 'amount' => (float) ($item['amount'] ?? 0), + ]; + })->toArray(); + + return [ + 'payroll' => $payroll, + 'period' => $payroll->period_label, + 'employee' => [ + 'id' => $payroll->user->id, + 'name' => $payroll->user->name, + 'email' => $payroll->user->email, + ], + 'earnings' => [ + 'base_salary' => (float) $payroll->base_salary, + 'overtime_pay' => (float) $payroll->overtime_pay, + 'bonus' => (float) $payroll->bonus, + 'allowances' => $allowances, + 'allowances_total' => (float) $payroll->allowances_total, + 'gross_total' => (float) $payroll->gross_salary, + ], + 'deductions' => [ + 'income_tax' => (float) $payroll->income_tax, + 'resident_tax' => (float) $payroll->resident_tax, + 'health_insurance' => (float) $payroll->health_insurance, + 'pension' => (float) $payroll->pension, + 'employment_insurance' => (float) $payroll->employment_insurance, + 'other_deductions' => $deductions, + 'other_total' => (float) $payroll->deductions_total, + 'total' => (float) $payroll->total_deductions, + ], + 'net_salary' => (float) $payroll->net_salary, + 'status' => $payroll->status, + 'status_label' => $payroll->status_label, + 'paid_at' => $payroll->paid_at?->toIso8601String(), + ]; + } + + // ========================================================================= + // 급여 일괄 계산 + // ========================================================================= + + /** + * 급여 일괄 계산 (생성 또는 업데이트) + */ + public function calculate(int $year, int $month, ?array $userIds = null): Collection + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 급여 설정 가져오기 + $settings = PayrollSetting::getOrCreate($tenantId); + + // 대상 사용자 조회 + // TODO: 실제로는 직원 목록에서 급여 대상자를 조회해야 함 + // 여기서는 기존 급여 데이터만 업데이트 + + return DB::transaction(function () use ($year, $month, $userIds, $tenantId, $userId, $settings) { + $query = Payroll::query() + ->where('tenant_id', $tenantId) + ->where('pay_year', $year) + ->where('pay_month', $month) + ->where('status', Payroll::STATUS_DRAFT); + + if ($userIds) { + $query->whereIn('user_id', $userIds); + } + + $payrolls = $query->get(); + + foreach ($payrolls as $payroll) { + // 4대보험 재계산 + $baseSalary = (float) $payroll->base_salary; + + $healthInsurance = $settings->calculateHealthInsurance($baseSalary); + $longTermCare = $settings->calculateLongTermCare($healthInsurance); + $pension = $settings->calculatePension($baseSalary); + $employmentInsurance = $settings->calculateEmploymentInsurance($baseSalary); + + // 건강보험에 장기요양보험 포함 + $totalHealthInsurance = $healthInsurance + $longTermCare; + + $payroll->health_insurance = $totalHealthInsurance; + $payroll->pension = $pension; + $payroll->employment_insurance = $employmentInsurance; + + // 주민세 재계산 + $payroll->resident_tax = $settings->calculateResidentTax($payroll->income_tax); + + // 총액 재계산 + $payroll->total_deductions = $payroll->calculateTotalDeductions(); + $payroll->net_salary = $payroll->calculateNetSalary(); + $payroll->updated_by = $userId; + $payroll->save(); + } + + return $payrolls->fresh(['user:id,name,email']); + }); + } + + // ========================================================================= + // 급여 설정 + // ========================================================================= + + /** + * 급여 설정 조회 + */ + public function getSettings(): PayrollSetting + { + $tenantId = $this->tenantId(); + + return PayrollSetting::getOrCreate($tenantId); + } + + /** + * 급여 설정 수정 + */ + public function updateSettings(array $data): PayrollSetting + { + $tenantId = $this->tenantId(); + + $settings = PayrollSetting::getOrCreate($tenantId); + + $settings->fill([ + 'income_tax_rate' => $data['income_tax_rate'] ?? $settings->income_tax_rate, + 'resident_tax_rate' => $data['resident_tax_rate'] ?? $settings->resident_tax_rate, + 'health_insurance_rate' => $data['health_insurance_rate'] ?? $settings->health_insurance_rate, + 'long_term_care_rate' => $data['long_term_care_rate'] ?? $settings->long_term_care_rate, + 'pension_rate' => $data['pension_rate'] ?? $settings->pension_rate, + 'employment_insurance_rate' => $data['employment_insurance_rate'] ?? $settings->employment_insurance_rate, + 'pension_max_salary' => $data['pension_max_salary'] ?? $settings->pension_max_salary, + 'pension_min_salary' => $data['pension_min_salary'] ?? $settings->pension_min_salary, + 'pay_day' => $data['pay_day'] ?? $settings->pay_day, + 'auto_calculate' => $data['auto_calculate'] ?? $settings->auto_calculate, + 'allowance_types' => $data['allowance_types'] ?? $settings->allowance_types, + 'deduction_types' => $data['deduction_types'] ?? $settings->deduction_types, + ]); + + $settings->save(); + + return $settings; + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 총지급액 계산 + */ + private function calculateGross(array $data): float + { + $baseSalary = (float) ($data['base_salary'] ?? 0); + $overtimePay = (float) ($data['overtime_pay'] ?? 0); + $bonus = (float) ($data['bonus'] ?? 0); + + $allowancesTotal = 0; + if (! empty($data['allowances'])) { + $allowancesTotal = collect($data['allowances'])->sum('amount'); + } + + return $baseSalary + $overtimePay + $bonus + $allowancesTotal; + } + + /** + * 총공제액 계산 + */ + private function calculateDeductions(array $data): float + { + $incomeTax = (float) ($data['income_tax'] ?? 0); + $residentTax = (float) ($data['resident_tax'] ?? 0); + $healthInsurance = (float) ($data['health_insurance'] ?? 0); + $pension = (float) ($data['pension'] ?? 0); + $employmentInsurance = (float) ($data['employment_insurance'] ?? 0); + + $deductionsTotal = 0; + if (! empty($data['deductions'])) { + $deductionsTotal = collect($data['deductions'])->sum('amount'); + } + + return $incomeTax + $residentTax + $healthInsurance + $pension + $employmentInsurance + $deductionsTotal; + } +} diff --git a/app/Swagger/v1/PayrollApi.php b/app/Swagger/v1/PayrollApi.php new file mode 100644 index 0000000..cb83d49 --- /dev/null +++ b/app/Swagger/v1/PayrollApi.php @@ -0,0 +1,655 @@ +id(); + $table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID'); + $table->foreignId('user_id')->constrained()->onDelete('cascade')->comment('사용자 ID'); + $table->unsignedSmallInteger('pay_year')->comment('급여 연도'); + $table->unsignedTinyInteger('pay_month')->comment('급여 월 (1-12)'); + + // 지급 항목 + $table->decimal('base_salary', 15, 2)->default(0)->comment('기본급'); + $table->decimal('overtime_pay', 15, 2)->default(0)->comment('연장근로수당'); + $table->decimal('bonus', 15, 2)->default(0)->comment('상여금'); + $table->json('allowances')->nullable()->comment('수당 상세 [{name, amount}]'); + $table->decimal('gross_salary', 15, 2)->default(0)->comment('총지급액'); + + // 공제 항목 + $table->decimal('income_tax', 15, 2)->default(0)->comment('소득세'); + $table->decimal('resident_tax', 15, 2)->default(0)->comment('주민세'); + $table->decimal('health_insurance', 15, 2)->default(0)->comment('건강보험'); + $table->decimal('pension', 15, 2)->default(0)->comment('국민연금'); + $table->decimal('employment_insurance', 15, 2)->default(0)->comment('고용보험'); + $table->json('deductions')->nullable()->comment('공제 상세 [{name, amount}]'); + $table->decimal('total_deductions', 15, 2)->default(0)->comment('총공제액'); + + // 실수령액 + $table->decimal('net_salary', 15, 2)->default(0)->comment('실수령액'); + + // 상태 관리 + $table->string('status', 20)->default('draft')->comment('상태: draft/confirmed/paid'); + $table->timestamp('confirmed_at')->nullable()->comment('확정일시'); + $table->foreignId('confirmed_by')->nullable()->constrained('users')->nullOnDelete()->comment('확정자'); + $table->timestamp('paid_at')->nullable()->comment('지급일시'); + $table->foreignId('withdrawal_id')->nullable()->comment('출금 연결 ID'); + + // 비고 + $table->text('note')->nullable()->comment('비고'); + + // 감사 컬럼 + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('생성자'); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자'); + $table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete()->comment('삭제자'); + $table->softDeletes(); + $table->timestamps(); + + // 인덱스 + $table->unique(['tenant_id', 'user_id', 'pay_year', 'pay_month'], 'uk_tenant_user_month'); + $table->index(['tenant_id', 'pay_year', 'pay_month'], 'idx_tenant_month'); + $table->index(['tenant_id', 'status'], 'idx_tenant_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payrolls'); + } +}; diff --git a/database/migrations/2025_12_18_100002_create_payroll_settings_table.php b/database/migrations/2025_12_18_100002_create_payroll_settings_table.php new file mode 100644 index 0000000..4ea94b7 --- /dev/null +++ b/database/migrations/2025_12_18_100002_create_payroll_settings_table.php @@ -0,0 +1,53 @@ +id(); + $table->foreignId('tenant_id')->unique()->constrained()->onDelete('cascade')->comment('테넌트 ID'); + + // 세율 설정 (%) + $table->decimal('income_tax_rate', 5, 2)->default(0)->comment('소득세율 (%)'); + $table->decimal('resident_tax_rate', 5, 2)->default(10)->comment('주민세율 (소득세의 %)'); + + // 4대보험 요율 (%) + $table->decimal('health_insurance_rate', 5, 3)->default(3.545)->comment('건강보험료율 (%)'); + $table->decimal('long_term_care_rate', 5, 3)->default(0.9082)->comment('장기요양보험료율 (건강보험의 %)'); + $table->decimal('pension_rate', 5, 3)->default(4.5)->comment('국민연금 요율 (%)'); + $table->decimal('employment_insurance_rate', 5, 3)->default(0.9)->comment('고용보험 요율 (%)'); + + // 기준금액 + $table->decimal('pension_max_salary', 15, 2)->default(5900000)->comment('국민연금 기준소득월액 상한'); + $table->decimal('pension_min_salary', 15, 2)->default(370000)->comment('국민연금 기준소득월액 하한'); + + // 기타 설정 + $table->unsignedTinyInteger('pay_day')->default(25)->comment('급여 지급일'); + $table->boolean('auto_calculate')->default(false)->comment('자동 계산 여부'); + + // 수당 설정 + $table->json('allowance_types')->nullable()->comment('수당 유형 [{code, name, is_taxable}]'); + + // 공제 설정 + $table->json('deduction_types')->nullable()->comment('공제 유형 [{code, name}]'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payroll_settings'); + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index 24d555a..269235c 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -215,4 +215,17 @@ 'invalid_longitude' => '경도는 -180 ~ 180 사이여야 합니다.', 'has_dependencies' => '연관된 데이터가 있어 삭제할 수 없습니다.', ], + + // 급여 관리 관련 + 'payroll' => [ + 'not_found' => '급여 정보를 찾을 수 없습니다.', + 'already_exists' => '해당 연월에 이미 급여가 등록되어 있습니다.', + 'not_editable' => '작성중 상태의 급여만 수정할 수 있습니다.', + 'not_deletable' => '작성중 상태의 급여만 삭제할 수 있습니다.', + 'not_confirmable' => '작성중 상태의 급여만 확정할 수 있습니다.', + 'not_payable' => '확정된 급여만 지급 처리할 수 있습니다.', + 'invalid_withdrawal' => '유효하지 않은 출금 내역입니다.', + 'user_not_found' => '직원 정보를 찾을 수 없습니다.', + 'no_base_salary' => '기본급이 설정되지 않았습니다.', + ], ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index 58090ab..c9e0dff 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -269,4 +269,23 @@ 'withdrawal' => '출금', 'purchase' => '매입', ], + + // 급여 관리 + 'payroll' => [ + 'fetched' => '급여를 조회했습니다.', + 'created' => '급여가 등록되었습니다.', + 'updated' => '급여가 수정되었습니다.', + 'deleted' => '급여가 삭제되었습니다.', + 'confirmed' => '급여가 확정되었습니다.', + 'paid' => '급여가 지급 처리되었습니다.', + 'bulk_confirmed' => '급여가 일괄 확정되었습니다.', + 'calculated' => '급여가 일괄 계산되었습니다.', + 'payslip_fetched' => '급여명세서를 조회했습니다.', + ], + + // 급여 설정 관리 + 'payroll_setting' => [ + 'fetched' => '급여 설정을 조회했습니다.', + 'updated' => '급여 설정이 수정되었습니다.', + ], ]; diff --git a/lang/ko/validation.php b/lang/ko/validation.php index 546f696..58e9580 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -182,6 +182,37 @@ 'sort_order' => '정렬 순서', 'parent_id' => '상위 항목', 'tenant_id' => '테넌트', + + // 급여 관련 + 'pay_year' => '지급 연도', + 'pay_month' => '지급 월', + 'base_salary' => '기본급', + 'overtime_pay' => '연장근무수당', + 'bonus' => '상여금', + 'allowances' => '수당 목록', + 'income_tax' => '소득세', + 'resident_tax' => '주민세', + 'health_insurance' => '건강보험료', + 'pension' => '국민연금', + 'employment_insurance' => '고용보험료', + 'deductions' => '공제 목록', + 'note' => '비고', + 'withdrawal_id' => '출금 내역', + 'user_ids' => '직원 목록', + + // 급여 설정 관련 + 'income_tax_rate' => '소득세율', + 'resident_tax_rate' => '주민세율', + 'health_insurance_rate' => '건강보험요율', + 'long_term_care_rate' => '장기요양보험요율', + 'pension_rate' => '국민연금요율', + 'employment_insurance_rate' => '고용보험요율', + 'pension_max_salary' => '국민연금 상한기준', + 'pension_min_salary' => '국민연금 하한기준', + 'pay_day' => '급여 지급일', + 'auto_calculate' => '자동계산 여부', + 'allowance_types' => '수당 유형', + 'deduction_types' => '공제 유형', ], ]; diff --git a/routes/api.php b/routes/api.php index 79b65a7..d5eb447 100644 --- a/routes/api.php +++ b/routes/api.php @@ -71,6 +71,7 @@ use App\Http\Controllers\Api\V1\UserRoleController; use App\Http\Controllers\Api\V1\WithdrawalController; use App\Http\Controllers\Api\V1\WorkSettingController; +use App\Http\Controllers\Api\V1\PayrollController; use Illuminate\Support\Facades\Route; // V1 초기 개발 @@ -366,6 +367,21 @@ Route::delete('/{id}', [WithdrawalController::class, 'destroy'])->whereNumber('id')->name('v1.withdrawals.destroy'); }); + // Payroll API (급여 관리) + Route::prefix('payrolls')->group(function () { + Route::get('', [PayrollController::class, 'index'])->name('v1.payrolls.index'); + Route::post('', [PayrollController::class, 'store'])->name('v1.payrolls.store'); + Route::get('/summary', [PayrollController::class, 'summary'])->name('v1.payrolls.summary'); + Route::post('/calculate', [PayrollController::class, 'calculate'])->name('v1.payrolls.calculate'); + Route::post('/bulk-confirm', [PayrollController::class, 'bulkConfirm'])->name('v1.payrolls.bulk-confirm'); + Route::get('/{id}', [PayrollController::class, 'show'])->whereNumber('id')->name('v1.payrolls.show'); + Route::put('/{id}', [PayrollController::class, 'update'])->whereNumber('id')->name('v1.payrolls.update'); + Route::delete('/{id}', [PayrollController::class, 'destroy'])->whereNumber('id')->name('v1.payrolls.destroy'); + Route::post('/{id}/confirm', [PayrollController::class, 'confirm'])->whereNumber('id')->name('v1.payrolls.confirm'); + Route::post('/{id}/pay', [PayrollController::class, 'pay'])->whereNumber('id')->name('v1.payrolls.pay'); + Route::get('/{id}/payslip', [PayrollController::class, 'payslip'])->whereNumber('id')->name('v1.payrolls.payslip'); + }); + // Sale API (매출 관리) Route::prefix('sales')->group(function () { Route::get('', [SaleController::class, 'index'])->name('v1.sales.index'); @@ -414,6 +430,10 @@ Route::get('/attendance', [WorkSettingController::class, 'showAttendanceSetting'])->name('v1.settings.attendance.show'); Route::put('/attendance', [WorkSettingController::class, 'updateAttendanceSetting'])->name('v1.settings.attendance.update'); + // 급여 설정 + Route::get('/payroll', [PayrollController::class, 'getSettings'])->name('v1.settings.payroll.show'); + Route::put('/payroll', [PayrollController::class, 'updateSettings'])->name('v1.settings.payroll.update'); + // 테넌트 필드 설정 (기존 fields에서 이동) Route::get('/fields', [TenantFieldSettingController::class, 'index'])->name('v1.settings.fields.index'); // 필드 설정 목록(전역+테넌트 병합 효과값) Route::put('/fields/bulk', [TenantFieldSettingController::class, 'bulkUpsert'])->name('v1.settings.fields.bulk'); // 필드 설정 대량 저장(트랜잭션 처리)