feat: 급여 관리 API 구현 (Phase 2: 3.2)

- 마이그레이션: payrolls, payroll_settings 테이블 생성
- 모델: Payroll (상태관리 draft→confirmed→paid), PayrollSetting
- 서비스: PayrollService (4대보험 계산, 급여명세서)
- 컨트롤러: PayrollController + FormRequest 5개
- API 엔드포인트 13개:
  - 급여 CRUD + confirm/pay/payslip
  - 일괄 계산/확정 (calculate, bulk-confirm)
  - 설정 관리 (settings/payroll)
- Swagger 문서: PayrollApi.php
- i18n: error.php, message.php, validation.php 키 추가
This commit is contained in:
2025-12-18 10:56:16 +09:00
parent b43796a558
commit 7089dd1e46
16 changed files with 2350 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Payroll\CalculatePayrollRequest;
use App\Http\Requests\V1\Payroll\PayPayrollRequest;
use App\Http\Requests\V1\Payroll\StorePayrollRequest;
use App\Http\Requests\V1\Payroll\UpdatePayrollRequest;
use App\Http\Requests\V1\Payroll\UpdatePayrollSettingRequest;
use App\Http\Responses\ApiResponse;
use App\Services\PayrollService;
use Illuminate\Http\Request;
class PayrollController extends Controller
{
public function __construct(
private readonly PayrollService $service
) {}
/**
* 급여 목록
*/
public function index(Request $request)
{
$params = $request->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);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\V1\Payroll;
use Illuminate\Foundation\Http\FormRequest;
class CalculatePayrollRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'year' => ['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'),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\V1\Payroll;
use Illuminate\Foundation\Http\FormRequest;
class PayPayrollRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
];
}
public function attributes(): array
{
return [
'withdrawal_id' => __('validation.attributes.withdrawal_id'),
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Requests\V1\Payroll;
use Illuminate\Foundation\Http\FormRequest;
class StorePayrollRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'user_id' => ['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'),
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Requests\V1\Payroll;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePayrollRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'user_id' => ['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'),
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Requests\V1\Payroll;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePayrollSettingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'income_tax_rate' => ['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'),
];
}
}

View File

@@ -0,0 +1,336 @@
<?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 int $pay_year
* @property int $pay_month
* @property float $base_salary
* @property float $overtime_pay
* @property float $bonus
* @property array|null $allowances
* @property float $gross_salary
* @property float $income_tax
* @property float $resident_tax
* @property float $health_insurance
* @property float $pension
* @property float $employment_insurance
* @property array|null $deductions
* @property float $total_deductions
* @property float $net_salary
* @property string $status
* @property \Carbon\Carbon|null $confirmed_at
* @property int|null $confirmed_by
* @property \Carbon\Carbon|null $paid_at
* @property int|null $withdrawal_id
* @property string|null $note
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $deleted_by
*/
class Payroll extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'payrolls';
protected $casts = [
'allowances' => '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;
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 급여 설정 모델
*
* @property int $id
* @property int $tenant_id
* @property float $income_tax_rate
* @property float $resident_tax_rate
* @property float $health_insurance_rate
* @property float $long_term_care_rate
* @property float $pension_rate
* @property float $employment_insurance_rate
* @property float $pension_max_salary
* @property float $pension_min_salary
* @property int $pay_day
* @property bool $auto_calculate
* @property array|null $allowance_types
* @property array|null $deduction_types
*/
class PayrollSetting extends Model
{
use BelongsToTenant;
protected $table = 'payroll_settings';
public $timestamps = true;
protected $casts = [
'income_tax_rate' => '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,
]
);
}
}

View File

@@ -0,0 +1,562 @@
<?php
namespace App\Services;
use App\Models\Tenants\Payroll;
use App\Models\Tenants\PayrollSetting;
use App\Models\Tenants\Withdrawal;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class PayrollService extends Service
{
// =========================================================================
// 급여 목록/상세
// =========================================================================
/**
* 급여 목록
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->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;
}
}

View File

@@ -0,0 +1,655 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Payrolls", description="급여 관리")
*
* @OA\Schema(
* schema="Payroll",
* 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="pay_year", type="integer", example=2024, description="지급 연도"),
* @OA\Property(property="pay_month", type="integer", example=12, description="지급 월"),
* @OA\Property(property="base_salary", type="number", format="float", example=3000000, description="기본급"),
* @OA\Property(property="overtime_pay", type="number", format="float", example=200000, nullable=true, description="연장근무수당"),
* @OA\Property(property="bonus", type="number", format="float", example=500000, nullable=true, description="상여금"),
* @OA\Property(property="allowances", type="array", nullable=true, description="수당 목록", @OA\Items(type="object",
* @OA\Property(property="name", type="string", example="식대"),
* @OA\Property(property="amount", type="number", example=100000)
* )),
* @OA\Property(property="gross_salary", type="number", format="float", example=3800000, description="총 지급액"),
* @OA\Property(property="income_tax", type="number", format="float", example=80000, description="소득세"),
* @OA\Property(property="resident_tax", type="number", format="float", example=8000, description="주민세"),
* @OA\Property(property="health_insurance", type="number", format="float", example=120000, description="건강보험료"),
* @OA\Property(property="pension", type="number", format="float", example=135000, description="국민연금"),
* @OA\Property(property="employment_insurance", type="number", format="float", example=30400, description="고용보험료"),
* @OA\Property(property="deductions", type="array", nullable=true, description="기타 공제 목록", @OA\Items(type="object",
* @OA\Property(property="name", type="string", example="조합비"),
* @OA\Property(property="amount", type="number", example=10000)
* )),
* @OA\Property(property="total_deductions", type="number", format="float", example=383400, description="총 공제액"),
* @OA\Property(property="net_salary", type="number", format="float", example=3416600, description="실수령액"),
* @OA\Property(property="status", type="string", enum={"draft","confirmed","paid"}, example="draft", description="상태"),
* @OA\Property(property="confirmed_at", type="string", format="date-time", nullable=true, description="확정일시"),
* @OA\Property(property="confirmed_by", type="integer", nullable=true, description="확정자 ID"),
* @OA\Property(property="paid_at", type="string", format="date-time", nullable=true, description="지급일시"),
* @OA\Property(property="withdrawal_id", type="integer", nullable=true, description="출금 내역 ID"),
* @OA\Property(property="note", type="string", 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="created_at", type="string", format="date-time", example="2024-01-01T09:00:00Z"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-01T09:00:00Z")
* )
*
* @OA\Schema(
* schema="PayrollStoreRequest",
* type="object",
* required={"user_id", "pay_year", "pay_month", "base_salary"},
* description="급여 생성 요청",
*
* @OA\Property(property="user_id", type="integer", example=10, description="직원 ID"),
* @OA\Property(property="pay_year", type="integer", example=2024, description="지급 연도 (2000-2100)"),
* @OA\Property(property="pay_month", type="integer", example=12, description="지급 월 (1-12)"),
* @OA\Property(property="base_salary", type="number", example=3000000, description="기본급"),
* @OA\Property(property="overtime_pay", type="number", example=200000, description="연장근무수당"),
* @OA\Property(property="bonus", type="number", example=500000, description="상여금"),
* @OA\Property(property="allowances", type="array", description="수당 목록", @OA\Items(type="object",
* @OA\Property(property="name", type="string", example="식대"),
* @OA\Property(property="amount", type="number", example=100000)
* )),
* @OA\Property(property="income_tax", type="number", example=80000, description="소득세"),
* @OA\Property(property="resident_tax", type="number", example=8000, description="주민세"),
* @OA\Property(property="health_insurance", type="number", example=120000, description="건강보험료"),
* @OA\Property(property="pension", type="number", example=135000, description="국민연금"),
* @OA\Property(property="employment_insurance", type="number", example=30400, description="고용보험료"),
* @OA\Property(property="deductions", type="array", description="기타 공제 목록", @OA\Items(type="object",
* @OA\Property(property="name", type="string", example="조합비"),
* @OA\Property(property="amount", type="number", example=10000)
* )),
* @OA\Property(property="note", type="string", example="비고", description="비고")
* )
*
* @OA\Schema(
* schema="PayrollUpdateRequest",
* type="object",
* description="급여 수정 요청 (작성중 상태만 가능)",
*
* @OA\Property(property="user_id", type="integer", example=10, description="직원 ID"),
* @OA\Property(property="pay_year", type="integer", example=2024, description="지급 연도"),
* @OA\Property(property="pay_month", type="integer", example=12, description="지급 월"),
* @OA\Property(property="base_salary", type="number", example=3000000, description="기본급"),
* @OA\Property(property="overtime_pay", type="number", example=200000, description="연장근무수당"),
* @OA\Property(property="bonus", type="number", example=500000, description="상여금"),
* @OA\Property(property="allowances", type="array", description="수당 목록", @OA\Items(type="object")),
* @OA\Property(property="income_tax", type="number", example=80000, description="소득세"),
* @OA\Property(property="resident_tax", type="number", example=8000, description="주민세"),
* @OA\Property(property="health_insurance", type="number", example=120000, description="건강보험료"),
* @OA\Property(property="pension", type="number", example=135000, description="국민연금"),
* @OA\Property(property="employment_insurance", type="number", example=30400, description="고용보험료"),
* @OA\Property(property="deductions", type="array", description="기타 공제 목록", @OA\Items(type="object")),
* @OA\Property(property="note", type="string", example="비고", description="비고")
* )
*
* @OA\Schema(
* schema="PayrollCalculateRequest",
* type="object",
* required={"year", "month"},
* description="급여 일괄 계산 요청",
*
* @OA\Property(property="year", type="integer", example=2024, description="지급 연도"),
* @OA\Property(property="month", type="integer", example=12, description="지급 월"),
* @OA\Property(property="user_ids", type="array", description="직원 ID 목록 (미지정시 전체)", @OA\Items(type="integer"))
* )
*
* @OA\Schema(
* schema="PayrollSummary",
* type="object",
* description="급여 현황 요약",
*
* @OA\Property(property="draft", type="integer", example=10, description="작성중"),
* @OA\Property(property="confirmed", type="integer", example=45, description="확정"),
* @OA\Property(property="paid", type="integer", example=40, description="지급완료"),
* @OA\Property(property="total_gross", type="number", example=150000000, description="총 지급액 합계"),
* @OA\Property(property="total_net", type="number", example=130000000, description="총 실수령액 합계")
* )
*
* @OA\Schema(
* schema="PayrollSetting",
* 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="income_tax_rate", type="number", example=3.3, description="소득세율 (%)"),
* @OA\Property(property="resident_tax_rate", type="number", example=10.0, description="주민세율 (소득세 대비 %)"),
* @OA\Property(property="health_insurance_rate", type="number", example=3.545, description="건강보험요율 (%)"),
* @OA\Property(property="long_term_care_rate", type="number", example=12.95, description="장기요양보험요율 (건강보험 대비 %)"),
* @OA\Property(property="pension_rate", type="number", example=4.5, description="국민연금요율 (%)"),
* @OA\Property(property="employment_insurance_rate", type="number", example=0.9, description="고용보험요율 (%)"),
* @OA\Property(property="pension_max_salary", type="number", example=5900000, description="국민연금 상한기준"),
* @OA\Property(property="pension_min_salary", type="number", example=370000, description="국민연금 하한기준"),
* @OA\Property(property="pay_day", type="integer", example=25, description="급여 지급일"),
* @OA\Property(property="auto_calculate", type="boolean", example=true, description="자동계산 여부"),
* @OA\Property(property="allowance_types", type="array", description="수당 유형 목록", @OA\Items(type="object",
* @OA\Property(property="code", type="string", example="MEAL"),
* @OA\Property(property="name", type="string", example="식대"),
* @OA\Property(property="is_taxable", type="boolean", example=false)
* )),
* @OA\Property(property="deduction_types", type="array", description="공제 유형 목록", @OA\Items(type="object",
* @OA\Property(property="code", type="string", example="UNION"),
* @OA\Property(property="name", type="string", example="조합비")
* ))
* )
*
* @OA\Schema(
* schema="PayrollSettingUpdateRequest",
* type="object",
* description="급여 설정 수정 요청",
*
* @OA\Property(property="income_tax_rate", type="number", example=3.3, description="소득세율"),
* @OA\Property(property="resident_tax_rate", type="number", example=10.0, description="주민세율"),
* @OA\Property(property="health_insurance_rate", type="number", example=3.545, description="건강보험요율"),
* @OA\Property(property="long_term_care_rate", type="number", example=12.95, description="장기요양보험요율"),
* @OA\Property(property="pension_rate", type="number", example=4.5, description="국민연금요율"),
* @OA\Property(property="employment_insurance_rate", type="number", example=0.9, description="고용보험요율"),
* @OA\Property(property="pension_max_salary", type="number", example=5900000, description="국민연금 상한기준"),
* @OA\Property(property="pension_min_salary", type="number", example=370000, description="국민연금 하한기준"),
* @OA\Property(property="pay_day", type="integer", example=25, description="급여 지급일 (1-31)"),
* @OA\Property(property="auto_calculate", type="boolean", example=true, description="자동계산 여부"),
* @OA\Property(property="allowance_types", type="array", description="수당 유형 목록", @OA\Items(type="object")),
* @OA\Property(property="deduction_types", type="array", description="공제 유형 목록", @OA\Items(type="object"))
* )
*
* @OA\Schema(
* schema="Payslip",
* type="object",
* description="급여명세서",
*
* @OA\Property(property="employee", type="object", description="직원 정보",
* @OA\Property(property="id", type="integer", example=10),
* @OA\Property(property="name", type="string", example="홍길동"),
* @OA\Property(property="employee_number", type="string", example="EMP001"),
* @OA\Property(property="department", type="string", example="개발팀"),
* @OA\Property(property="position", type="string", example="과장")
* ),
* @OA\Property(property="period", type="string", example="2024년 12월", description="급여 기간"),
* @OA\Property(property="pay_date", type="string", format="date", example="2024-12-25", description="지급일"),
* @OA\Property(property="earnings", type="object", description="지급 내역",
* @OA\Property(property="base_salary", type="number", example=3000000),
* @OA\Property(property="overtime_pay", type="number", example=200000),
* @OA\Property(property="bonus", type="number", example=500000),
* @OA\Property(property="allowances", type="array", @OA\Items(type="object")),
* @OA\Property(property="total", type="number", example=3800000)
* ),
* @OA\Property(property="deductions", type="object", description="공제 내역",
* @OA\Property(property="income_tax", type="number", example=80000),
* @OA\Property(property="resident_tax", type="number", example=8000),
* @OA\Property(property="health_insurance", type="number", example=120000),
* @OA\Property(property="pension", type="number", example=135000),
* @OA\Property(property="employment_insurance", type="number", example=30400),
* @OA\Property(property="others", type="array", @OA\Items(type="object")),
* @OA\Property(property="total", type="number", example=383400)
* ),
* @OA\Property(property="net_salary", type="number", example=3416600, description="실수령액")
* )
*/
class PayrollApi
{
/**
* @OA\Get(
* path="/api/v1/payrolls",
* tags={"Payrolls"},
* 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=12)),
* @OA\Parameter(name="status", in="query", description="상태 필터", @OA\Schema(type="string", enum={"draft","confirmed","paid"})),
* @OA\Parameter(name="user_id", in="query", description="직원 ID", @OA\Schema(type="integer")),
* @OA\Parameter(name="search", in="query", description="검색어 (직원명)", @OA\Schema(type="string")),
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준", @OA\Schema(type="string", enum={"pay_year","pay_month","base_salary","net_salary","created_at"}, default="pay_year")),
* @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/Payroll")),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=50)
* )
* )
* }
* )
* ),
*
* @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/payrolls/summary",
* tags={"Payrolls"},
* 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=12)),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PayrollSummary"))
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function summary() {}
/**
* @OA\Post(
* path="/api/v1/payrolls",
* tags={"Payrolls"},
* summary="급여 생성",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/PayrollStoreRequest")
* ),
*
* @OA\Response(
* response=201,
* description="생성 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Payroll"))
* }
* )
* ),
*
* @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\Get(
* path="/api/v1/payrolls/{id}",
* tags={"Payrolls"},
* summary="급여 상세 조회",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, 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/Payroll"))
* }
* )
* ),
*
* @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\Put(
* path="/api/v1/payrolls/{id}",
* tags={"Payrolls"},
* 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/PayrollUpdateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="수정 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Payroll"))
* }
* )
* ),
*
* @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/payrolls/{id}",
* tags={"Payrolls"},
* 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=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 destroy() {}
/**
* @OA\Post(
* path="/api/v1/payrolls/{id}/confirm",
* tags={"Payrolls"},
* 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(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Payroll"))
* }
* )
* ),
*
* @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 confirm() {}
/**
* @OA\Post(
* path="/api/v1/payrolls/{id}/pay",
* tags={"Payrolls"},
* summary="급여 지급 처리",
* description="확정된 급여를 지급 처리합니다. 출금 내역과 연결할 수 있습니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="급여 ID", @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=false,
*
* @OA\JsonContent(
*
* @OA\Property(property="withdrawal_id", type="integer", example=123, description="출금 내역 ID (선택)")
* )
* ),
*
* @OA\Response(
* response=200,
* description="지급 처리 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Payroll"))
* }
* )
* ),
*
* @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 pay() {}
/**
* @OA\Post(
* path="/api/v1/payrolls/bulk-confirm",
* tags={"Payrolls"},
* summary="급여 일괄 확정",
* description="지정 기간의 작성중 급여를 일괄 확정합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(
* required={"year", "month"},
*
* @OA\Property(property="year", type="integer", example=2024, description="지급 연도"),
* @OA\Property(property="month", type="integer", example=12, description="지급 월")
* )
* ),
*
* @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="confirmed_count", type="integer", example=45, description="확정된 건수")
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function bulkConfirm() {}
/**
* @OA\Post(
* path="/api/v1/payrolls/calculate",
* tags={"Payrolls"},
* summary="급여 일괄 계산",
* description="지정 기간의 급여를 일괄 계산하여 생성합니다. 이미 존재하는 급여는 건너뜁니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/PayrollCalculateRequest")
* ),
*
* @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="created_count", type="integer", example=45, description="생성된 건수"),
* @OA\Property(property="skipped_count", type="integer", example=5, description="건너뛴 건수")
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function calculate() {}
/**
* @OA\Get(
* path="/api/v1/payrolls/{id}/payslip",
* tags={"Payrolls"},
* 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(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Payslip"))
* }
* )
* ),
*
* @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 payslip() {}
/**
* @OA\Get(
* path="/api/v1/settings/payroll",
* tags={"Payrolls"},
* 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/PayrollSetting"))
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function getSettings() {}
/**
* @OA\Put(
* path="/api/v1/settings/payroll",
* tags={"Payrolls"},
* summary="급여 설정 수정",
* description="테넌트의 급여 설정을 수정합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/PayrollSettingUpdateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="수정 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PayrollSetting"))
* }
* )
* ),
*
* @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 updateSettings() {}
}

View File

@@ -0,0 +1,71 @@
<?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('payrolls', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,53 @@
<?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('payroll_settings', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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' => '기본급이 설정되지 않았습니다.',
],
];

View File

@@ -269,4 +269,23 @@
'withdrawal' => '출금',
'purchase' => '매입',
],
// 급여 관리
'payroll' => [
'fetched' => '급여를 조회했습니다.',
'created' => '급여가 등록되었습니다.',
'updated' => '급여가 수정되었습니다.',
'deleted' => '급여가 삭제되었습니다.',
'confirmed' => '급여가 확정되었습니다.',
'paid' => '급여가 지급 처리되었습니다.',
'bulk_confirmed' => '급여가 일괄 확정되었습니다.',
'calculated' => '급여가 일괄 계산되었습니다.',
'payslip_fetched' => '급여명세서를 조회했습니다.',
],
// 급여 설정 관리
'payroll_setting' => [
'fetched' => '급여 설정을 조회했습니다.',
'updated' => '급여 설정이 수정되었습니다.',
],
];

View File

@@ -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' => '공제 유형',
],
];

View File

@@ -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'); // 필드 설정 대량 저장(트랜잭션 처리)