diff --git a/app/Http/Controllers/Api/V1/PayrollController.php b/app/Http/Controllers/Api/V1/PayrollController.php index 4c8af6f8..e1197dfa 100644 --- a/app/Http/Controllers/Api/V1/PayrollController.php +++ b/app/Http/Controllers/Api/V1/PayrollController.php @@ -4,7 +4,9 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\V1\Payroll\BulkGeneratePayrollRequest; use App\Http\Requests\V1\Payroll\CalculatePayrollRequest; +use App\Http\Requests\V1\Payroll\CopyFromPreviousPayrollRequest; use App\Http\Requests\V1\Payroll\PayPayrollRequest; use App\Http\Requests\V1\Payroll\StorePayrollRequest; use App\Http\Requests\V1\Payroll\UpdatePayrollRequest; @@ -28,6 +30,7 @@ public function index(Request $request) 'month', 'user_id', 'status', + 'department_id', 'search', 'sort_by', 'sort_dir', @@ -103,6 +106,16 @@ public function confirm(int $id) return ApiResponse::success($payroll, __('message.payroll.confirmed')); } + /** + * 급여 확정 취소 + */ + public function unconfirm(int $id) + { + $payroll = $this->service->unconfirm($id); + + return ApiResponse::success($payroll, __('message.payroll.unconfirmed')); + } + /** * 급여 지급 처리 */ @@ -113,6 +126,16 @@ public function pay(int $id, PayPayrollRequest $request) return ApiResponse::success($payroll, __('message.payroll.paid')); } + /** + * 급여 지급 취소 (슈퍼관리자) + */ + public function unpay(int $id) + { + $payroll = $this->service->unpay($id); + + return ApiResponse::success($payroll, __('message.payroll.unpaid')); + } + /** * 일괄 확정 */ @@ -127,13 +150,29 @@ public function bulkConfirm(Request $request) } /** - * 급여명세서 조회 + * 재직사원 일괄 생성 */ - public function payslip(int $id) + public function bulkGenerate(BulkGeneratePayrollRequest $request) { - $payslip = $this->service->payslip($id); + $year = (int) $request->input('year'); + $month = (int) $request->input('month'); - return ApiResponse::success($payslip, __('message.fetched')); + $result = $this->service->bulkGenerate($year, $month); + + return ApiResponse::success($result, __('message.payroll.bulk_generated')); + } + + /** + * 전월 급여 복사 + */ + public function copyFromPrevious(CopyFromPreviousPayrollRequest $request) + { + $year = (int) $request->input('year'); + $month = (int) $request->input('month'); + + $result = $this->service->copyFromPreviousMonth($year, $month); + + return ApiResponse::success($result, __('message.payroll.copied')); } /** @@ -150,6 +189,35 @@ public function calculate(CalculatePayrollRequest $request) return ApiResponse::success($payrolls, __('message.payroll.calculated')); } + /** + * 급여 계산 미리보기 + */ + public function calculatePreview(Request $request) + { + $data = $request->only([ + 'user_id', + 'base_salary', + 'overtime_pay', + 'bonus', + 'allowances', + 'deductions', + ]); + + $result = $this->service->calculatePreview($data); + + return ApiResponse::success($result, __('message.calculated')); + } + + /** + * 급여명세서 조회 + */ + public function payslip(int $id) + { + $payslip = $this->service->payslip($id); + + return ApiResponse::success($payslip, __('message.fetched')); + } + /** * 급여 설정 조회 */ diff --git a/app/Http/Requests/V1/Payroll/BulkGeneratePayrollRequest.php b/app/Http/Requests/V1/Payroll/BulkGeneratePayrollRequest.php new file mode 100644 index 00000000..103ca07d --- /dev/null +++ b/app/Http/Requests/V1/Payroll/BulkGeneratePayrollRequest.php @@ -0,0 +1,29 @@ + ['required', 'integer', 'min:2000', 'max:2100'], + 'month' => ['required', 'integer', 'min:1', 'max:12'], + ]; + } + + public function attributes(): array + { + return [ + 'year' => __('validation.attributes.pay_year'), + 'month' => __('validation.attributes.pay_month'), + ]; + } +} diff --git a/app/Http/Requests/V1/Payroll/CopyFromPreviousPayrollRequest.php b/app/Http/Requests/V1/Payroll/CopyFromPreviousPayrollRequest.php new file mode 100644 index 00000000..c88a89e4 --- /dev/null +++ b/app/Http/Requests/V1/Payroll/CopyFromPreviousPayrollRequest.php @@ -0,0 +1,29 @@ + ['required', 'integer', 'min:2000', 'max:2100'], + 'month' => ['required', 'integer', 'min:1', 'max:12'], + ]; + } + + public function attributes(): array + { + return [ + 'year' => __('validation.attributes.pay_year'), + 'month' => __('validation.attributes.pay_month'), + ]; + } +} diff --git a/app/Http/Requests/V1/Payroll/StorePayrollRequest.php b/app/Http/Requests/V1/Payroll/StorePayrollRequest.php index b416dcae..35f6e931 100644 --- a/app/Http/Requests/V1/Payroll/StorePayrollRequest.php +++ b/app/Http/Requests/V1/Payroll/StorePayrollRequest.php @@ -31,6 +31,14 @@ public function rules(): array 'deductions' => ['nullable', 'array'], 'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'], 'deductions.*.amount' => ['required_with:deductions', 'numeric', 'min:0'], + 'deduction_overrides' => ['nullable', 'array'], + 'deduction_overrides.income_tax' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.resident_tax' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.health_insurance' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.long_term_care' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.pension' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.employment_insurance' => ['nullable', 'numeric', 'min:0'], + 'family_count' => ['nullable', 'integer', 'min:1', 'max:11'], 'note' => ['nullable', 'string', 'max:1000'], ]; } diff --git a/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php b/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php index 6eefc3d3..413dfaf7 100644 --- a/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php +++ b/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php @@ -31,6 +31,14 @@ public function rules(): array 'deductions' => ['nullable', 'array'], 'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'], 'deductions.*.amount' => ['required_with:deductions', 'numeric', 'min:0'], + 'deduction_overrides' => ['nullable', 'array'], + 'deduction_overrides.income_tax' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.resident_tax' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.health_insurance' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.long_term_care' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.pension' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.employment_insurance' => ['nullable', 'numeric', 'min:0'], + '_is_super_admin' => ['nullable', 'boolean'], 'note' => ['nullable', 'string', 'max:1000'], ]; } diff --git a/app/Models/Tenants/IncomeTaxBracket.php b/app/Models/Tenants/IncomeTaxBracket.php new file mode 100644 index 00000000..973cd17c --- /dev/null +++ b/app/Models/Tenants/IncomeTaxBracket.php @@ -0,0 +1,64 @@ + 'integer', + 'salary_from' => 'integer', + 'salary_to' => 'integer', + 'family_count' => 'integer', + 'tax_amount' => 'integer', + ]; + + public function scopeForYear(Builder $query, int $year): Builder + { + return $query->where('tax_year', $year); + } + + public function scopeForSalaryRange(Builder $query, int $salaryThousand): Builder + { + return $query->where('salary_from', '<=', $salaryThousand) + ->where(function ($q) use ($salaryThousand) { + $q->where('salary_to', '>', $salaryThousand) + ->orWhere(function ($q2) use ($salaryThousand) { + $q2->whereColumn('salary_from', 'salary_to') + ->where('salary_from', $salaryThousand); + }); + }); + } + + public function scopeForFamilyCount(Builder $query, int $count): Builder + { + return $query->where('family_count', $count); + } + + /** + * 간이세액표에서 세액 조회 + */ + public static function lookupTax(int $year, int $salaryThousand, int $familyCount): int + { + $familyCount = max(1, min(11, $familyCount)); + + $bracket = static::forYear($year) + ->forSalaryRange($salaryThousand) + ->forFamilyCount($familyCount) + ->first(); + + return $bracket ? $bracket->tax_amount : 0; + } +} diff --git a/app/Models/Tenants/Payroll.php b/app/Models/Tenants/Payroll.php index da12f95f..a3e36824 100644 --- a/app/Models/Tenants/Payroll.php +++ b/app/Models/Tenants/Payroll.php @@ -49,17 +49,19 @@ class Payroll extends Model 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', + 'options' => 'array', + 'base_salary' => 'decimal:0', + 'overtime_pay' => 'decimal:0', + 'bonus' => 'decimal:0', + 'gross_salary' => 'decimal:0', + 'income_tax' => 'decimal:0', + 'resident_tax' => 'decimal:0', + 'health_insurance' => 'decimal:0', + 'long_term_care' => 'decimal:0', + 'pension' => 'decimal:0', + 'employment_insurance' => 'decimal:0', + 'total_deductions' => 'decimal:0', + 'net_salary' => 'decimal:0', 'confirmed_at' => 'datetime', 'paid_at' => 'datetime', 'pay_year' => 'integer', @@ -79,9 +81,11 @@ class Payroll extends Model 'income_tax', 'resident_tax', 'health_insurance', + 'long_term_care', 'pension', 'employment_insurance', 'deductions', + 'options', 'total_deductions', 'net_salary', 'status', @@ -104,6 +108,7 @@ class Payroll extends Model 'income_tax' => 0, 'resident_tax' => 0, 'health_insurance' => 0, + 'long_term_care' => 0, 'pension' => 0, 'employment_insurance' => 0, 'total_deductions' => 0, @@ -227,13 +232,33 @@ public function scopeForUser($query, int $userId) // ========================================================================= /** - * 수정 가능 여부 (작성중 상태만) + * 수정 가능 여부 (작성중, 슈퍼관리자는 모든 상태) */ - public function isEditable(): bool + public function isEditable(bool $isSuperAdmin = false): bool { + if ($isSuperAdmin) { + return true; + } + return $this->status === self::STATUS_DRAFT; } + /** + * 확정 취소 가능 여부 + */ + public function isUnconfirmable(): bool + { + return $this->status === self::STATUS_CONFIRMED; + } + + /** + * 지급 취소 가능 여부 (슈퍼관리자 전용) + */ + public function isUnpayable(): bool + { + return $this->status === self::STATUS_PAID; + } + /** * 확정 가능 여부 */ @@ -322,6 +347,7 @@ public function calculateTotalDeductions(): float return $this->income_tax + $this->resident_tax + $this->health_insurance + + $this->long_term_care + $this->pension + $this->employment_insurance + $this->deductions_total; diff --git a/app/Services/PayrollService.php b/app/Services/PayrollService.php index ca49c9d1..a4fdb0b8 100644 --- a/app/Services/PayrollService.php +++ b/app/Services/PayrollService.php @@ -2,8 +2,10 @@ namespace App\Services; +use App\Models\Tenants\IncomeTaxBracket; use App\Models\Tenants\Payroll; use App\Models\Tenants\PayrollSetting; +use App\Models\Tenants\TenantUserProfile; use App\Models\Tenants\Withdrawal; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; @@ -12,13 +14,12 @@ class PayrollService extends Service { + private const TAX_TABLE_YEAR = 2024; + // ========================================================================= // 급여 목록/상세 // ========================================================================= - /** - * 급여 목록 - */ public function index(array $params): LengthAwarePaginator { $tenantId = $this->tenantId(); @@ -27,34 +28,30 @@ public function index(array $params): LengthAwarePaginator ->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']}%"); }); } + if (! empty($params['department_id'])) { + $deptId = $params['department_id']; + $query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) { + $q->where('tenant_id', $tenantId)->where('department_id', $deptId); + }); + } - // 정렬 $sortBy = $params['sort_by'] ?? 'pay_year'; $sortDir = $params['sort_dir'] ?? 'desc'; @@ -64,14 +61,9 @@ public function index(array $params): LengthAwarePaginator $query->orderBy($sortBy, $sortDir); } - $perPage = $params['per_page'] ?? 20; - - return $query->paginate($perPage); + return $query->paginate($params['per_page'] ?? 20); } - /** - * 특정 연월 급여 요약 - */ public function summary(int $year, int $month): array { $tenantId = $this->tenantId(); @@ -98,27 +90,17 @@ public function summary(int $year, int $month): array '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, + 'total_gross' => (int) $stats->total_gross, + 'total_deductions' => (int) $stats->total_deductions, + 'total_net' => (int) $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', - ]) + ->where('tenant_id', $this->tenantId()) + ->with(['user:id,name,email', 'confirmer:id,name', 'withdrawal', 'creator:id,name']) ->findOrFail($id); } @@ -126,59 +108,60 @@ public function show(int $id): Payroll // 급여 생성/수정/삭제 // ========================================================================= - /** - * 급여 생성 - */ 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(); + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 중복 확인 (soft-deleted 포함) + $existing = Payroll::withTrashed() + ->where('tenant_id', $tenantId) + ->where('user_id', $data['user_id']) + ->where('pay_year', $data['pay_year']) + ->where('pay_month', $data['pay_month']) + ->first(); - if ($exists) { - throw new BadRequestHttpException(__('error.payroll.already_exists')); - } + if ($existing && ! $existing->trashed()) { + throw new BadRequestHttpException(__('error.payroll.already_exists')); + } + if ($existing && $existing->trashed()) { + $existing->forceDelete(); + } - // 금액 계산 - $grossSalary = $this->calculateGross($data); - $totalDeductions = $this->calculateDeductions($data); - $netSalary = $grossSalary - $totalDeductions; + // 자동 계산 + $settings = PayrollSetting::getOrCreate($tenantId); + $familyCount = $data['family_count'] ?? $this->resolveFamilyCount($data['user_id']); + $calculated = $this->calculateAmounts($data, $settings, $familyCount); + $this->applyDeductionOverrides($calculated, $data['deduction_overrides'] ?? null); - 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, - ]); + 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' => $calculated['gross_salary'], + 'income_tax' => $calculated['income_tax'], + 'resident_tax' => $calculated['resident_tax'], + 'health_insurance' => $calculated['health_insurance'], + 'long_term_care' => $calculated['long_term_care'], + 'pension' => $calculated['pension'], + 'employment_insurance' => $calculated['employment_insurance'], + 'deductions' => $data['deductions'] ?? null, + 'total_deductions' => $calculated['total_deductions'], + 'net_salary' => $calculated['net_salary'], + '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(); @@ -188,11 +171,12 @@ public function update(int $id, array $data): Payroll ->where('tenant_id', $tenantId) ->findOrFail($id); - if (! $payroll->isEditable()) { + $isSuperAdmin = $data['_is_super_admin'] ?? false; + if (! $payroll->isEditable($isSuperAdmin)) { 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; @@ -211,41 +195,63 @@ public function update(int $id, array $data): Payroll } } - // 금액 업데이트 - $updateData = array_merge($payroll->toArray(), $data); - $grossSalary = $this->calculateGross($updateData); - $totalDeductions = $this->calculateDeductions($updateData); - $netSalary = $grossSalary - $totalDeductions; + // 지급 항목 (신규 입력값 또는 기존값) + $baseSalary = (float) ($data['base_salary'] ?? $payroll->base_salary); + $overtimePay = (float) ($data['overtime_pay'] ?? $payroll->overtime_pay); + $bonus = (float) ($data['bonus'] ?? $payroll->bonus); + $allowances = array_key_exists('allowances', $data) ? $data['allowances'] : $payroll->allowances; + $deductions = array_key_exists('deductions', $data) ? $data['deductions'] : $payroll->deductions; - $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, + $allowancesTotal = 0; + $allowancesArr = is_string($allowances) ? json_decode($allowances, true) : $allowances; + foreach ($allowancesArr ?? [] as $allowance) { + $allowancesTotal += (float) ($allowance['amount'] ?? 0); + } + $grossSalary = (int) ($baseSalary + $overtimePay + $bonus + $allowancesTotal); + + // 공제 항목 (수동 수정값 우선, 없으면 기존값 유지) + $overrides = $data['deduction_overrides'] ?? []; + $incomeTax = isset($overrides['income_tax']) ? (int) $overrides['income_tax'] : (int) $payroll->income_tax; + $residentTax = isset($overrides['resident_tax']) ? (int) $overrides['resident_tax'] : (int) $payroll->resident_tax; + $healthInsurance = isset($overrides['health_insurance']) ? (int) $overrides['health_insurance'] : (int) $payroll->health_insurance; + $longTermCare = isset($overrides['long_term_care']) ? (int) $overrides['long_term_care'] : (int) $payroll->long_term_care; + $pension = isset($overrides['pension']) ? (int) $overrides['pension'] : (int) $payroll->pension; + $employmentInsurance = isset($overrides['employment_insurance']) ? (int) $overrides['employment_insurance'] : (int) $payroll->employment_insurance; + + $extraDeductions = 0; + $deductionsArr = is_string($deductions) ? json_decode($deductions, true) : $deductions; + foreach ($deductionsArr ?? [] as $deduction) { + $extraDeductions += (float) ($deduction['amount'] ?? 0); + } + + $totalDeductions = (int) ($incomeTax + $residentTax + $healthInsurance + $longTermCare + $pension + $employmentInsurance + $extraDeductions); + $netSalary = (int) max(0, $grossSalary - $totalDeductions); + + $payroll->update([ + 'user_id' => $newUserId, + 'pay_year' => $newYear, + 'pay_month' => $newMonth, + 'base_salary' => $baseSalary, + 'overtime_pay' => $overtimePay, + 'bonus' => $bonus, + 'allowances' => $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, + 'income_tax' => $incomeTax, + 'resident_tax' => $residentTax, + 'health_insurance' => $healthInsurance, + 'long_term_care' => $longTermCare, + 'pension' => $pension, + 'employment_insurance' => $employmentInsurance, + 'deductions' => $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(); @@ -267,12 +273,9 @@ public function destroy(int $id): bool } // ========================================================================= - // 급여 확정/지급 + // 상태 관리 (확정/지급/취소) // ========================================================================= - /** - * 급여 확정 - */ public function confirm(int $id): Payroll { $tenantId = $this->tenantId(); @@ -286,18 +289,39 @@ public function confirm(int $id): Payroll 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(); + $payroll->update([ + 'status' => Payroll::STATUS_CONFIRMED, + 'confirmed_at' => now(), + 'confirmed_by' => $userId, + 'updated_by' => $userId, + ]); return $payroll->fresh(['user:id,name,email', 'confirmer:id,name']); } - /** - * 급여 지급 처리 - */ + public function unconfirm(int $id): Payroll + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $payroll = Payroll::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $payroll->isUnconfirmable()) { + throw new BadRequestHttpException(__('error.payroll.not_unconfirmable')); + } + + $payroll->update([ + 'status' => Payroll::STATUS_DRAFT, + 'confirmed_at' => null, + 'confirmed_by' => null, + 'updated_by' => $userId, + ]); + + return $payroll->fresh(['user:id,name,email']); + } + public function pay(int $id, ?int $withdrawalId = null): Payroll { $tenantId = $this->tenantId(); @@ -312,7 +336,6 @@ public function pay(int $id, ?int $withdrawalId = null): Payroll throw new BadRequestHttpException(__('error.payroll.not_payable')); } - // 출금 내역 연결 검증 if ($withdrawalId) { $withdrawal = Withdrawal::query() ->where('tenant_id', $tenantId) @@ -324,19 +347,42 @@ public function pay(int $id, ?int $withdrawalId = null): Payroll } } - $payroll->status = Payroll::STATUS_PAID; - $payroll->paid_at = now(); - $payroll->withdrawal_id = $withdrawalId; - $payroll->updated_by = $userId; - $payroll->save(); + $payroll->update([ + 'status' => Payroll::STATUS_PAID, + 'paid_at' => now(), + 'withdrawal_id' => $withdrawalId, + 'updated_by' => $userId, + ]); return $payroll->fresh(['user:id,name,email', 'withdrawal']); }); } - /** - * 일괄 확정 - */ + public function unpay(int $id): Payroll + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $payroll = Payroll::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $payroll->isUnpayable()) { + throw new BadRequestHttpException(__('error.payroll.not_unpayable')); + } + + $payroll->update([ + 'status' => Payroll::STATUS_DRAFT, + 'confirmed_at' => null, + 'confirmed_by' => null, + 'paid_at' => null, + 'withdrawal_id' => null, + 'updated_by' => $userId, + ]); + + return $payroll->fresh(['user:id,name,email']); + } + public function bulkConfirm(int $year, int $month): int { $tenantId = $this->tenantId(); @@ -356,84 +402,174 @@ public function bulkConfirm(int $year, int $month): int } // ========================================================================= - // 급여명세서 + // 일괄 처리 (생성/복사/계산) // ========================================================================= /** - * 급여명세서 데이터 + * 재직사원 일괄 생성 */ - public function payslip(int $id): array + public function bulkGenerate(int $year, int $month): array { - $payroll = $this->show($id); + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + $settings = PayrollSetting::getOrCreate($tenantId); + $created = 0; + $skipped = 0; - // 수당 목록 - $allowances = collect($payroll->allowances ?? [])->map(function ($item) { - return [ - 'name' => $item['name'] ?? '', - 'amount' => (float) ($item['amount'] ?? 0), - ]; - })->toArray(); + $employees = TenantUserProfile::query() + ->with('user:id,name') + ->where('tenant_id', $tenantId) + ->where('employee_status', 'active') + ->get(); - // 공제 목록 - $deductions = collect($payroll->deductions ?? [])->map(function ($item) { - return [ - 'name' => $item['name'] ?? '', - 'amount' => (float) ($item['amount'] ?? 0), - ]; - })->toArray(); + DB::transaction(function () use ($employees, $tenantId, $year, $month, $settings, $userId, &$created, &$skipped) { + foreach ($employees as $employee) { + $existing = Payroll::withTrashed() + ->where('tenant_id', $tenantId) + ->where('user_id', $employee->user_id) + ->forPeriod($year, $month) + ->first(); - 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(), - ]; + if ($existing && ! $existing->trashed()) { + $skipped++; + + continue; + } + if ($existing && $existing->trashed()) { + $existing->forceDelete(); + } + + // 연봉에서 월급 산출 + $salaryInfo = $employee->json_extra['salary_info'] ?? $employee->json_extra ?? []; + $annualSalary = $salaryInfo['annual_salary'] ?? ($employee->json_extra['salary'] ?? 0); + $baseSalary = $annualSalary > 0 ? (int) round($annualSalary / 12) : 0; + + $data = [ + 'base_salary' => $baseSalary, + 'overtime_pay' => 0, + 'bonus' => 0, + 'allowances' => null, + 'deductions' => null, + ]; + + // 피부양자 기반 가족수 산출 + $dependents = $employee->json_extra['dependents'] ?? []; + $familyCount = 1 + collect($dependents) + ->where('is_dependent', true)->count(); + + $calculated = $this->calculateAmounts($data, $settings, $familyCount); + + Payroll::create([ + 'tenant_id' => $tenantId, + 'user_id' => $employee->user_id, + 'pay_year' => $year, + 'pay_month' => $month, + 'base_salary' => $baseSalary, + 'overtime_pay' => 0, + 'bonus' => 0, + 'allowances' => null, + 'gross_salary' => $calculated['gross_salary'], + 'income_tax' => $calculated['income_tax'], + 'resident_tax' => $calculated['resident_tax'], + 'health_insurance' => $calculated['health_insurance'], + 'long_term_care' => $calculated['long_term_care'], + 'pension' => $calculated['pension'], + 'employment_insurance' => $calculated['employment_insurance'], + 'deductions' => null, + 'total_deductions' => $calculated['total_deductions'], + 'net_salary' => $calculated['net_salary'], + 'status' => Payroll::STATUS_DRAFT, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + $created++; + } + }); + + return ['created' => $created, 'skipped' => $skipped]; } - // ========================================================================= - // 급여 일괄 계산 - // ========================================================================= + /** + * 전월 급여 복사 + */ + public function copyFromPreviousMonth(int $year, int $month): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $prevYear = $month === 1 ? $year - 1 : $year; + $prevMonth = $month === 1 ? 12 : $month - 1; + + $previousPayrolls = Payroll::query() + ->where('tenant_id', $tenantId) + ->forPeriod($prevYear, $prevMonth) + ->get(); + + if ($previousPayrolls->isEmpty()) { + throw new BadRequestHttpException(__('error.payroll.no_previous_month')); + } + + $created = 0; + $skipped = 0; + + DB::transaction(function () use ($previousPayrolls, $tenantId, $year, $month, $userId, &$created, &$skipped) { + foreach ($previousPayrolls as $prev) { + $existing = Payroll::withTrashed() + ->where('tenant_id', $tenantId) + ->where('user_id', $prev->user_id) + ->forPeriod($year, $month) + ->first(); + + if ($existing && ! $existing->trashed()) { + $skipped++; + + continue; + } + if ($existing && $existing->trashed()) { + $existing->forceDelete(); + } + + Payroll::create([ + 'tenant_id' => $tenantId, + 'user_id' => $prev->user_id, + 'pay_year' => $year, + 'pay_month' => $month, + 'base_salary' => $prev->base_salary, + 'overtime_pay' => $prev->overtime_pay, + 'bonus' => $prev->bonus, + 'allowances' => $prev->allowances, + 'gross_salary' => $prev->gross_salary, + 'income_tax' => $prev->income_tax, + 'resident_tax' => $prev->resident_tax, + 'health_insurance' => $prev->health_insurance, + 'long_term_care' => $prev->long_term_care, + 'pension' => $prev->pension, + 'employment_insurance' => $prev->employment_insurance, + 'deductions' => $prev->deductions, + 'total_deductions' => $prev->total_deductions, + 'net_salary' => $prev->net_salary, + 'status' => Payroll::STATUS_DRAFT, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + $created++; + } + }); + + return ['created' => $created, 'skipped' => $skipped]; + } /** - * 급여 일괄 계산 (생성 또는 업데이트) + * 급여 일괄 계산 (기존 draft 급여 재계산) */ 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) @@ -448,57 +584,118 @@ public function calculate(int $year, int $month, ?array $userIds = null): Collec $payrolls = $query->get(); foreach ($payrolls as $payroll) { - // 4대보험 재계산 - $baseSalary = (float) $payroll->base_salary; + $familyCount = $this->resolveFamilyCount($payroll->user_id); - $healthInsurance = $settings->calculateHealthInsurance($baseSalary); - $longTermCare = $settings->calculateLongTermCare($healthInsurance); - $pension = $settings->calculatePension($baseSalary); - $employmentInsurance = $settings->calculateEmploymentInsurance($baseSalary); + $data = [ + 'base_salary' => (float) $payroll->base_salary, + 'overtime_pay' => (float) $payroll->overtime_pay, + 'bonus' => (float) $payroll->bonus, + 'allowances' => $payroll->allowances, + 'deductions' => $payroll->deductions, + ]; - // 건강보험에 장기요양보험 포함 - $totalHealthInsurance = $healthInsurance + $longTermCare; + $calculated = $this->calculateAmounts($data, $settings, $familyCount); - $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(); + $payroll->update([ + 'gross_salary' => $calculated['gross_salary'], + 'income_tax' => $calculated['income_tax'], + 'resident_tax' => $calculated['resident_tax'], + 'health_insurance' => $calculated['health_insurance'], + 'long_term_care' => $calculated['long_term_care'], + 'pension' => $calculated['pension'], + 'employment_insurance' => $calculated['employment_insurance'], + 'total_deductions' => $calculated['total_deductions'], + 'net_salary' => $calculated['net_salary'], + 'updated_by' => $userId, + ]); } return $payrolls->fresh(['user:id,name,email']); }); } + /** + * 계산 미리보기 (저장하지 않음) + */ + public function calculatePreview(array $data): array + { + $tenantId = $this->tenantId(); + $settings = PayrollSetting::getOrCreate($tenantId); + $familyCount = 1; + + if (! empty($data['user_id'])) { + $familyCount = $this->resolveFamilyCount((int) $data['user_id']); + } + + $calculated = $this->calculateAmounts($data, $settings, $familyCount); + + return array_merge($calculated, ['family_count' => $familyCount]); + } + + // ========================================================================= + // 급여명세서 + // ========================================================================= + + public function payslip(int $id): array + { + $payroll = $this->show($id); + + $allowances = collect($payroll->allowances ?? [])->map(fn ($item) => [ + 'name' => $item['name'] ?? '', + 'amount' => (int) ($item['amount'] ?? 0), + ])->toArray(); + + $deductions = collect($payroll->deductions ?? [])->map(fn ($item) => [ + 'name' => $item['name'] ?? '', + 'amount' => (int) ($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' => (int) $payroll->base_salary, + 'overtime_pay' => (int) $payroll->overtime_pay, + 'bonus' => (int) $payroll->bonus, + 'allowances' => $allowances, + 'allowances_total' => (int) $payroll->allowances_total, + 'gross_total' => (int) $payroll->gross_salary, + ], + 'deductions' => [ + 'income_tax' => (int) $payroll->income_tax, + 'resident_tax' => (int) $payroll->resident_tax, + 'health_insurance' => (int) $payroll->health_insurance, + 'long_term_care' => (int) $payroll->long_term_care, + 'pension' => (int) $payroll->pension, + 'employment_insurance' => (int) $payroll->employment_insurance, + 'other_deductions' => $deductions, + 'other_total' => (int) $payroll->deductions_total, + 'total' => (int) $payroll->total_deductions, + ], + 'net_salary' => (int) $payroll->net_salary, + 'status' => $payroll->status, + 'status_label' => $payroll->status_label, + 'paid_at' => $payroll->paid_at?->toIso8601String(), + ]; + } + // ========================================================================= // 급여 설정 // ========================================================================= - /** - * 급여 설정 조회 - */ public function getSettings(): PayrollSetting { - $tenantId = $this->tenantId(); - - return PayrollSetting::getOrCreate($tenantId); + return PayrollSetting::getOrCreate($this->tenantId()); } - /** - * 급여 설정 수정 - */ public function updateSettings(array $data): PayrollSetting { - $tenantId = $this->tenantId(); - - $settings = PayrollSetting::getOrCreate($tenantId); + $settings = PayrollSetting::getOrCreate($this->tenantId()); $settings->fill([ 'income_tax_rate' => $data['income_tax_rate'] ?? $settings->income_tax_rate, @@ -521,42 +718,202 @@ public function updateSettings(array $data): PayrollSetting } // ========================================================================= - // 헬퍼 메서드 + // 계산 엔진 // ========================================================================= /** - * 총지급액 계산 + * 급여 금액 자동 계산 + * + * 식대(bonus)는 비과세 항목으로, 총 지급액에는 포함되지만 + * 4대보험 및 세금 산출 기준(과세표준)에서는 제외된다. */ - private function calculateGross(array $data): float + public function calculateAmounts(array $data, ?PayrollSetting $settings = null, int $familyCount = 1): array { + $settings = $settings ?? PayrollSetting::getOrCreate($this->tenantId()); + $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'); + $allowances = is_string($data['allowances']) ? json_decode($data['allowances'], true) : $data['allowances']; + foreach ($allowances ?? [] as $allowance) { + $allowancesTotal += (float) ($allowance['amount'] ?? 0); + } } - return $baseSalary + $overtimePay + $bonus + $allowancesTotal; + // 총 지급액 (비과세 포함) + $grossSalary = $baseSalary + $overtimePay + $bonus + $allowancesTotal; + + // 과세표준 = 총 지급액 - 식대(비과세) + $taxableBase = $grossSalary - $bonus; + + // 4대보험 (과세표준 기준) + $healthInsurance = $this->calcHealthInsurance($taxableBase, $settings); + $longTermCare = $this->calcLongTermCare($taxableBase, $settings); + $pension = $this->calcPension($taxableBase, $settings); + $employmentInsurance = $this->calcEmploymentInsurance($taxableBase, $settings); + + // 근로소득세 (간이세액표, 가족수 반영) + $incomeTax = $this->calculateIncomeTax($taxableBase, $familyCount); + // 지방소득세 (근로소득세의 10%, 10원 단위 절삭) + $residentTax = (int) (floor($incomeTax * ($settings->resident_tax_rate / 100) / 10) * 10); + + // 추가 공제 합계 + $extraDeductions = 0; + if (! empty($data['deductions'])) { + $deductions = is_string($data['deductions']) ? json_decode($data['deductions'], true) : $data['deductions']; + foreach ($deductions ?? [] as $deduction) { + $extraDeductions += (float) ($deduction['amount'] ?? 0); + } + } + + $totalDeductions = $incomeTax + $residentTax + $healthInsurance + $longTermCare + $pension + $employmentInsurance + $extraDeductions; + $netSalary = $grossSalary - $totalDeductions; + + return [ + 'gross_salary' => (int) $grossSalary, + 'taxable_base' => (int) $taxableBase, + 'income_tax' => $incomeTax, + 'resident_tax' => $residentTax, + 'health_insurance' => $healthInsurance, + 'long_term_care' => $longTermCare, + 'pension' => $pension, + 'employment_insurance' => $employmentInsurance, + 'total_deductions' => (int) $totalDeductions, + 'net_salary' => (int) max(0, $netSalary), + ]; } /** - * 총공제액 계산 + * 수동 수정된 공제 항목 반영 */ - private function calculateDeductions(array $data): float + private function applyDeductionOverrides(array &$calculated, ?array $overrides): void { - $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'); + if (empty($overrides)) { + return; } - return $incomeTax + $residentTax + $healthInsurance + $pension + $employmentInsurance + $deductionsTotal; + $oldStatutory = $calculated['pension'] + $calculated['health_insurance'] + $calculated['long_term_care'] + + $calculated['employment_insurance'] + $calculated['income_tax'] + $calculated['resident_tax']; + $extraDeductions = max(0, $calculated['total_deductions'] - $oldStatutory); + + $fields = ['pension', 'health_insurance', 'long_term_care', 'employment_insurance', 'income_tax', 'resident_tax']; + foreach ($fields as $field) { + if (isset($overrides[$field])) { + $calculated[$field] = (int) $overrides[$field]; + } + } + + $newStatutory = $calculated['pension'] + $calculated['health_insurance'] + $calculated['long_term_care'] + + $calculated['employment_insurance'] + $calculated['income_tax'] + $calculated['resident_tax']; + $calculated['total_deductions'] = (int) ($newStatutory + $extraDeductions); + $calculated['net_salary'] = (int) max(0, $calculated['gross_salary'] - $calculated['total_deductions']); + } + + /** + * 근로소득세 계산 (2024 국세청 간이세액표 기반) + */ + public function calculateIncomeTax(float $taxableBase, int $familyCount = 1): int + { + if ($taxableBase <= 0) { + return 0; + } + + $salaryThousand = (int) floor($taxableBase / 1000); + $familyCount = max(1, min(11, $familyCount)); + + if ($salaryThousand < 770) { + return 0; + } + + if ($salaryThousand > 10000) { + return $this->calculateHighIncomeTax($salaryThousand, $familyCount); + } + + return IncomeTaxBracket::lookupTax(self::TAX_TABLE_YEAR, $salaryThousand, $familyCount); + } + + /** + * 10,000천원 초과 구간 근로소득세 공식 계산 (소득세법 시행령 별표2) + */ + private function calculateHighIncomeTax(int $salaryThousand, int $familyCount): int + { + $baseTax = IncomeTaxBracket::where('tax_year', self::TAX_TABLE_YEAR) + ->where('salary_from', 10000) + ->whereColumn('salary_from', 'salary_to') + ->where('family_count', $familyCount) + ->value('tax_amount') ?? 0; + + if ($salaryThousand <= 14000) { + $excessWon = ($salaryThousand - 10000) * 1000; + $tax = $baseTax + ($excessWon * 0.98 * 0.35) + 25000; + } elseif ($salaryThousand <= 28000) { + $excessWon = ($salaryThousand - 14000) * 1000; + $tax = $baseTax + 1397000 + ($excessWon * 0.98 * 0.38); + } elseif ($salaryThousand <= 30000) { + $excessWon = ($salaryThousand - 28000) * 1000; + $tax = $baseTax + 6610600 + ($excessWon * 0.98 * 0.40); + } elseif ($salaryThousand <= 45000) { + $excessWon = ($salaryThousand - 30000) * 1000; + $tax = $baseTax + 7394600 + ($excessWon * 0.40); + } elseif ($salaryThousand <= 87000) { + $excessWon = ($salaryThousand - 45000) * 1000; + $tax = $baseTax + 13394600 + ($excessWon * 0.42); + } else { + $excessWon = ($salaryThousand - 87000) * 1000; + $tax = $baseTax + 31034600 + ($excessWon * 0.45); + } + + return (int) (floor($tax / 10) * 10); + } + + private function calcHealthInsurance(float $taxableBase, PayrollSetting $settings): int + { + return (int) (floor($taxableBase * ($settings->health_insurance_rate / 100) / 10) * 10); + } + + private function calcLongTermCare(float $taxableBase, PayrollSetting $settings): int + { + $healthInsurance = $taxableBase * ($settings->health_insurance_rate / 100); + + return (int) (floor($healthInsurance * ($settings->long_term_care_rate / 100) / 10) * 10); + } + + private function calcPension(float $taxableBase, PayrollSetting $settings): int + { + $base = min(max($taxableBase, (float) $settings->pension_min_salary), (float) $settings->pension_max_salary); + + return (int) (floor($base * ($settings->pension_rate / 100) / 10) * 10); + } + + private function calcEmploymentInsurance(float $taxableBase, PayrollSetting $settings): int + { + return (int) (floor($taxableBase * ($settings->employment_insurance_rate / 100) / 10) * 10); + } + + /** + * user_id로 공제대상가족수 산출 (본인 1 + 피부양자) + */ + public function resolveFamilyCount(int $userId): int + { + $tenantId = $this->tenantId(); + + $profile = TenantUserProfile::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->first(['json_extra']); + + if (! $profile) { + return 1; + } + + $dependents = $profile->json_extra['dependents'] ?? []; + $dependentCount = collect($dependents) + ->where('is_dependent', true) + ->count(); + + return max(1, min(11, 1 + $dependentCount)); } } diff --git a/lang/ko/error.php b/lang/ko/error.php index bc537ea7..6418ac09 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -282,7 +282,10 @@ 'not_editable' => '작성중 상태의 급여만 수정할 수 있습니다.', 'not_deletable' => '작성중 상태의 급여만 삭제할 수 있습니다.', 'not_confirmable' => '작성중 상태의 급여만 확정할 수 있습니다.', + 'not_unconfirmable' => '확정된 급여만 확정 취소할 수 있습니다.', 'not_payable' => '확정된 급여만 지급 처리할 수 있습니다.', + 'not_unpayable' => '지급완료된 급여만 지급 취소할 수 있습니다.', + 'no_previous_month' => '전월 급여 데이터가 없습니다.', 'invalid_withdrawal' => '유효하지 않은 출금 내역입니다.', 'user_not_found' => '직원 정보를 찾을 수 없습니다.', 'no_base_salary' => '기본급이 설정되지 않았습니다.', diff --git a/lang/ko/message.php b/lang/ko/message.php index 60a6b531..a727c996 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -320,8 +320,12 @@ 'updated' => '급여가 수정되었습니다.', 'deleted' => '급여가 삭제되었습니다.', 'confirmed' => '급여가 확정되었습니다.', + 'unconfirmed' => '급여 확정이 취소되었습니다.', 'paid' => '급여가 지급 처리되었습니다.', + 'unpaid' => '급여 지급이 취소되었습니다.', 'bulk_confirmed' => '급여가 일괄 확정되었습니다.', + 'bulk_generated' => '급여가 일괄 생성되었습니다.', + 'copied' => '전월 급여가 복사되었습니다.', 'calculated' => '급여가 일괄 계산되었습니다.', 'payslip_fetched' => '급여명세서를 조회했습니다.', ], diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index 2b996bd6..77f10807 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -96,12 +96,17 @@ 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('/calculate-preview', [PayrollController::class, 'calculatePreview'])->name('v1.payrolls.calculate-preview'); Route::post('/bulk-confirm', [PayrollController::class, 'bulkConfirm'])->name('v1.payrolls.bulk-confirm'); + Route::post('/bulk-generate', [PayrollController::class, 'bulkGenerate'])->name('v1.payrolls.bulk-generate'); + Route::post('/copy-from-previous', [PayrollController::class, 'copyFromPrevious'])->name('v1.payrolls.copy-from-previous'); 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}/unconfirm', [PayrollController::class, 'unconfirm'])->whereNumber('id')->name('v1.payrolls.unconfirm'); Route::post('/{id}/pay', [PayrollController::class, 'pay'])->whereNumber('id')->name('v1.payrolls.pay'); + Route::post('/{id}/unpay', [PayrollController::class, 'unpay'])->whereNumber('id')->name('v1.payrolls.unpay'); Route::get('/{id}/payslip', [PayrollController::class, 'payslip'])->whereNumber('id')->name('v1.payrolls.payslip'); });