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:
172
app/Http/Controllers/Api/V1/PayrollController.php
Normal file
172
app/Http/Controllers/Api/V1/PayrollController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/V1/Payroll/CalculatePayrollRequest.php
Normal file
32
app/Http/Requests/V1/Payroll/CalculatePayrollRequest.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Http/Requests/V1/Payroll/PayPayrollRequest.php
Normal file
27
app/Http/Requests/V1/Payroll/PayPayrollRequest.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
57
app/Http/Requests/V1/Payroll/StorePayrollRequest.php
Normal file
57
app/Http/Requests/V1/Payroll/StorePayrollRequest.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
57
app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php
Normal file
57
app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
54
app/Http/Requests/V1/Payroll/UpdatePayrollSettingRequest.php
Normal file
54
app/Http/Requests/V1/Payroll/UpdatePayrollSettingRequest.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
336
app/Models/Tenants/Payroll.php
Normal file
336
app/Models/Tenants/Payroll.php
Normal 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;
|
||||
}
|
||||
}
|
||||
191
app/Models/Tenants/PayrollSetting.php
Normal file
191
app/Models/Tenants/PayrollSetting.php
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
562
app/Services/PayrollService.php
Normal file
562
app/Services/PayrollService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
655
app/Swagger/v1/PayrollApi.php
Normal file
655
app/Swagger/v1/PayrollApi.php
Normal 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() {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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' => '기본급이 설정되지 않았습니다.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -269,4 +269,23 @@
|
||||
'withdrawal' => '출금',
|
||||
'purchase' => '매입',
|
||||
],
|
||||
|
||||
// 급여 관리
|
||||
'payroll' => [
|
||||
'fetched' => '급여를 조회했습니다.',
|
||||
'created' => '급여가 등록되었습니다.',
|
||||
'updated' => '급여가 수정되었습니다.',
|
||||
'deleted' => '급여가 삭제되었습니다.',
|
||||
'confirmed' => '급여가 확정되었습니다.',
|
||||
'paid' => '급여가 지급 처리되었습니다.',
|
||||
'bulk_confirmed' => '급여가 일괄 확정되었습니다.',
|
||||
'calculated' => '급여가 일괄 계산되었습니다.',
|
||||
'payslip_fetched' => '급여명세서를 조회했습니다.',
|
||||
],
|
||||
|
||||
// 급여 설정 관리
|
||||
'payroll_setting' => [
|
||||
'fetched' => '급여 설정을 조회했습니다.',
|
||||
'updated' => '급여 설정이 수정되었습니다.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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' => '공제 유형',
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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'); // 필드 설정 대량 저장(트랜잭션 처리)
|
||||
|
||||
Reference in New Issue
Block a user