- WelfareService: getDetail() 메서드 및 헬퍼 메서드 추가 - getAccountBalance(), getMonthlyUsageTrend() - getCategoryDistribution(), getTransactions() - getQuarterlyStatus() - WelfareController: detail() 액션 추가 - routes/api.php: /welfare/detail 라우트 등록 - Swagger: WelfareDetailResponse 및 관련 스키마 7개 추가 Co-Authored-By: Claude <noreply@anthropic.com>
538 lines
20 KiB
PHP
538 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* 복리후생비 현황 서비스
|
|
*
|
|
* CEO 대시보드용 복리후생비 데이터를 제공합니다.
|
|
*/
|
|
class WelfareService extends Service
|
|
{
|
|
// 비과세 식대 한도 (월)
|
|
private const TAX_FREE_MEAL_LIMIT = 200000;
|
|
|
|
// 1인당 월 복리후생비 업계 평균 범위
|
|
private const INDUSTRY_AVG_MIN = 150000;
|
|
|
|
private const INDUSTRY_AVG_MAX = 250000;
|
|
|
|
/**
|
|
* 복리후생비 현황 요약 조회
|
|
*
|
|
* @param string|null $limitType 기간 타입 (annual|quarterly, 기본: quarterly)
|
|
* @param string|null $calculationType 계산 방식 (fixed|ratio, 기본: fixed)
|
|
* @param int|null $fixedAmountPerMonth 1인당 월 정액 (기본: 200000)
|
|
* @param float|null $ratio 급여 대비 비율 (기본: 0.05)
|
|
* @param int|null $year 연도 (기본: 현재 연도)
|
|
* @param int|null $quarter 분기 (1-4, 기본: 현재 분기)
|
|
* @return array{cards: array, check_points: array}
|
|
*/
|
|
public function getSummary(
|
|
?string $limitType = 'quarterly',
|
|
?string $calculationType = 'fixed',
|
|
?int $fixedAmountPerMonth = 200000,
|
|
?float $ratio = 0.05,
|
|
?int $year = null,
|
|
?int $quarter = null
|
|
): array {
|
|
$tenantId = $this->tenantId();
|
|
$now = Carbon::now();
|
|
|
|
// 기본값 설정
|
|
$year = $year ?? $now->year;
|
|
$limitType = $limitType ?? 'quarterly';
|
|
$calculationType = $calculationType ?? 'fixed';
|
|
$fixedAmountPerMonth = $fixedAmountPerMonth ?? 200000;
|
|
$ratio = $ratio ?? 0.05;
|
|
$quarter = $quarter ?? $now->quarter;
|
|
|
|
// 기간 범위 계산
|
|
if ($limitType === 'annual') {
|
|
$startDate = Carbon::create($year, 1, 1)->format('Y-m-d');
|
|
$endDate = Carbon::create($year, 12, 31)->format('Y-m-d');
|
|
$periodLabel = "{$year}년";
|
|
$monthCount = 12;
|
|
} else {
|
|
$startDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d');
|
|
$endDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d');
|
|
$periodLabel = "{$quarter}사분기";
|
|
$monthCount = 3;
|
|
}
|
|
|
|
// 직원 수 조회
|
|
$employeeCount = $this->getEmployeeCount($tenantId);
|
|
|
|
// 한도 계산
|
|
if ($calculationType === 'fixed') {
|
|
$annualLimit = $fixedAmountPerMonth * 12 * $employeeCount;
|
|
} else {
|
|
// 급여 총액 기반 비율 계산
|
|
$totalSalary = $this->getTotalSalary($tenantId, $year);
|
|
$annualLimit = $totalSalary * $ratio;
|
|
}
|
|
|
|
$periodLimit = $limitType === 'annual' ? $annualLimit : ($annualLimit / 4);
|
|
|
|
// 복리후생비 사용액 조회
|
|
$usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate);
|
|
|
|
// 잔여 한도
|
|
$remainingLimit = max(0, $periodLimit - $usedAmount);
|
|
|
|
// 카드 데이터 구성
|
|
$cards = [
|
|
[
|
|
'id' => 'wf_annual_limit',
|
|
'label' => '당해년도 복리후생비 한도',
|
|
'amount' => (int) $annualLimit,
|
|
],
|
|
[
|
|
'id' => 'wf_period_limit',
|
|
'label' => "{{$periodLabel}} 복리후생비 총 한도",
|
|
'amount' => (int) $periodLimit,
|
|
],
|
|
[
|
|
'id' => 'wf_remaining',
|
|
'label' => "{{$periodLabel}} 복리후생비 잔여한도",
|
|
'amount' => (int) $remainingLimit,
|
|
],
|
|
[
|
|
'id' => 'wf_used',
|
|
'label' => "{{$periodLabel}} 복리후생비 사용금액",
|
|
'amount' => (int) $usedAmount,
|
|
],
|
|
];
|
|
|
|
// 체크포인트 생성
|
|
$checkPoints = $this->generateCheckPoints(
|
|
$tenantId,
|
|
$employeeCount,
|
|
$usedAmount,
|
|
$monthCount,
|
|
$startDate,
|
|
$endDate
|
|
);
|
|
|
|
return [
|
|
'cards' => $cards,
|
|
'check_points' => $checkPoints,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 직원 수 조회
|
|
*/
|
|
private function getEmployeeCount(int $tenantId): int
|
|
{
|
|
$count = DB::table('users')
|
|
->join('user_tenants', 'users.id', '=', 'user_tenants.user_id')
|
|
->where('user_tenants.tenant_id', $tenantId)
|
|
->where('user_tenants.is_active', true)
|
|
->whereNull('users.deleted_at')
|
|
->count();
|
|
|
|
return $count ?: 50; // 임시 기본값
|
|
}
|
|
|
|
/**
|
|
* 연간 급여 총액 조회
|
|
*/
|
|
private function getTotalSalary(int $tenantId, int $year): float
|
|
{
|
|
// TODO: 실제 급여 테이블에서 조회
|
|
// payroll 또는 salary_histories에서 연간 급여 합계
|
|
return 2000000000; // 임시 기본값 (20억)
|
|
}
|
|
|
|
/**
|
|
* 복리후생비 사용액 조회
|
|
*/
|
|
private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float
|
|
{
|
|
// TODO: 실제 복리후생비 계정과목에서 조회
|
|
$amount = DB::table('expense_accounts')
|
|
->where('tenant_id', $tenantId)
|
|
->where('account_type', 'welfare')
|
|
->whereBetween('expense_date', [$startDate, $endDate])
|
|
->whereNull('deleted_at')
|
|
->sum('amount');
|
|
|
|
return $amount ?: 5123000; // 임시 기본값
|
|
}
|
|
|
|
/**
|
|
* 월 식대 조회
|
|
*/
|
|
private function getMonthlyMealAmount(int $tenantId, string $startDate, string $endDate): float
|
|
{
|
|
// TODO: 식대 항목 조회
|
|
$amount = DB::table('expense_accounts')
|
|
->where('tenant_id', $tenantId)
|
|
->where('account_type', 'welfare')
|
|
->where('sub_type', 'meal')
|
|
->whereBetween('expense_date', [$startDate, $endDate])
|
|
->whereNull('deleted_at')
|
|
->sum('amount');
|
|
|
|
return $amount ?: 0;
|
|
}
|
|
|
|
/**
|
|
* 복리후생비 상세 정보 조회 (모달용)
|
|
*
|
|
* @param string|null $calculationType 계산 방식 (fixed|ratio, 기본: fixed)
|
|
* @param int|null $fixedAmountPerMonth 1인당 월 정액 (기본: 200000)
|
|
* @param float|null $ratio 급여 대비 비율 (기본: 0.05)
|
|
* @param int|null $year 연도 (기본: 현재 연도)
|
|
* @param int|null $quarter 분기 (1-4, 기본: 현재 분기)
|
|
*/
|
|
public function getDetail(
|
|
?string $calculationType = 'fixed',
|
|
?int $fixedAmountPerMonth = 200000,
|
|
?float $ratio = 0.05,
|
|
?int $year = null,
|
|
?int $quarter = null
|
|
): array {
|
|
$tenantId = $this->tenantId();
|
|
$now = Carbon::now();
|
|
|
|
// 기본값 설정
|
|
$year = $year ?? $now->year;
|
|
$calculationType = $calculationType ?? 'fixed';
|
|
$fixedAmountPerMonth = $fixedAmountPerMonth ?? 200000;
|
|
$ratio = $ratio ?? 0.05;
|
|
$quarter = $quarter ?? $now->quarter;
|
|
|
|
// 연간 기간 범위
|
|
$annualStartDate = Carbon::create($year, 1, 1)->format('Y-m-d');
|
|
$annualEndDate = Carbon::create($year, 12, 31)->format('Y-m-d');
|
|
|
|
// 분기 기간 범위
|
|
$quarterStartDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d');
|
|
$quarterEndDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d');
|
|
|
|
// 직원 수 조회
|
|
$employeeCount = $this->getEmployeeCount($tenantId);
|
|
|
|
// 한도 계산
|
|
if ($calculationType === 'fixed') {
|
|
$annualLimit = $fixedAmountPerMonth * 12 * $employeeCount;
|
|
$totalSalary = 0;
|
|
} else {
|
|
$totalSalary = $this->getTotalSalary($tenantId, $year);
|
|
$annualLimit = $totalSalary * $ratio;
|
|
}
|
|
|
|
$quarterlyLimit = $annualLimit / 4;
|
|
|
|
// 연간/분기 사용액 조회
|
|
$annualUsed = $this->getUsedAmount($tenantId, $annualStartDate, $annualEndDate);
|
|
$quarterlyUsed = $this->getUsedAmount($tenantId, $quarterStartDate, $quarterEndDate);
|
|
|
|
// 복리후생비 계정 (연간)
|
|
$annualAccount = $this->getAccountBalance($tenantId, $year);
|
|
|
|
// 잔여/초과 계산
|
|
$annualRemaining = max(0, $annualLimit - $annualUsed);
|
|
$quarterlyRemaining = max(0, $quarterlyLimit - $quarterlyUsed);
|
|
$quarterlyExceeded = max(0, $quarterlyUsed - $quarterlyLimit);
|
|
|
|
// 1. 요약 데이터
|
|
$summary = [
|
|
'annual_account' => (int) $annualAccount,
|
|
'annual_limit' => (int) $annualLimit,
|
|
'annual_used' => (int) $annualUsed,
|
|
'annual_remaining' => (int) $annualRemaining,
|
|
'quarterly_limit' => (int) $quarterlyLimit,
|
|
'quarterly_remaining' => (int) $quarterlyRemaining,
|
|
'quarterly_used' => (int) $quarterlyUsed,
|
|
'quarterly_exceeded' => (int) $quarterlyExceeded,
|
|
];
|
|
|
|
// 2. 월별 사용 추이
|
|
$monthlyUsage = $this->getMonthlyUsageTrend($tenantId, $year);
|
|
|
|
// 3. 항목별 분포
|
|
$categoryDistribution = $this->getCategoryDistribution($tenantId, $annualStartDate, $annualEndDate);
|
|
|
|
// 4. 일별 사용 내역
|
|
$transactions = $this->getTransactions($tenantId, $quarterStartDate, $quarterEndDate);
|
|
|
|
// 5. 계산 정보
|
|
$calculation = [
|
|
'type' => $calculationType,
|
|
'employee_count' => $employeeCount,
|
|
'annual_limit' => (int) $annualLimit,
|
|
];
|
|
|
|
if ($calculationType === 'fixed') {
|
|
$calculation['monthly_amount'] = $fixedAmountPerMonth;
|
|
} else {
|
|
$calculation['total_salary'] = (int) $totalSalary;
|
|
$calculation['ratio'] = $ratio * 100; // 백분율로 변환
|
|
}
|
|
|
|
// 6. 분기별 현황
|
|
$quarterly = $this->getQuarterlyStatus($tenantId, $year, $quarterlyLimit);
|
|
|
|
return [
|
|
'summary' => $summary,
|
|
'monthly_usage' => $monthlyUsage,
|
|
'category_distribution' => $categoryDistribution,
|
|
'transactions' => $transactions,
|
|
'calculation' => $calculation,
|
|
'quarterly' => $quarterly,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 복리후생비 계정 잔액 조회
|
|
*/
|
|
private function getAccountBalance(int $tenantId, int $year): float
|
|
{
|
|
// TODO: 실제 계정 잔액 조회 로직 구현
|
|
// 예: accounting_accounts에서 복리후생비 계정 잔액 조회
|
|
return 3123000; // 임시 기본값
|
|
}
|
|
|
|
/**
|
|
* 월별 사용 추이 조회
|
|
*/
|
|
private function getMonthlyUsageTrend(int $tenantId, int $year): array
|
|
{
|
|
$monthlyData = DB::table('expense_accounts')
|
|
->select(DB::raw('MONTH(expense_date) as month'), DB::raw('SUM(amount) as amount'))
|
|
->where('tenant_id', $tenantId)
|
|
->where('account_type', 'welfare')
|
|
->whereYear('expense_date', $year)
|
|
->whereNull('deleted_at')
|
|
->groupBy(DB::raw('MONTH(expense_date)'))
|
|
->orderBy('month')
|
|
->get();
|
|
|
|
// 12개월 모두 포함 (데이터 없는 달은 0)
|
|
$result = [];
|
|
for ($i = 1; $i <= 12; $i++) {
|
|
$found = $monthlyData->firstWhere('month', $i);
|
|
$result[] = [
|
|
'month' => $i,
|
|
'amount' => $found ? (int) $found->amount : 0,
|
|
];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 항목별 분포 조회
|
|
*/
|
|
private function getCategoryDistribution(int $tenantId, string $startDate, string $endDate): array
|
|
{
|
|
$categoryLabels = [
|
|
'meal' => '식비',
|
|
'health_check' => '건강검진',
|
|
'congratulation' => '경조사비',
|
|
'other' => '기타',
|
|
];
|
|
|
|
$distribution = DB::table('expense_accounts')
|
|
->select('sub_type', DB::raw('SUM(amount) as amount'))
|
|
->where('tenant_id', $tenantId)
|
|
->where('account_type', 'welfare')
|
|
->whereBetween('expense_date', [$startDate, $endDate])
|
|
->whereNull('deleted_at')
|
|
->groupBy('sub_type')
|
|
->get();
|
|
|
|
$total = $distribution->sum('amount');
|
|
|
|
$result = [];
|
|
foreach ($distribution as $item) {
|
|
$subType = $item->sub_type ?? 'other';
|
|
$result[] = [
|
|
'category' => $subType,
|
|
'label' => $categoryLabels[$subType] ?? '기타',
|
|
'amount' => (int) $item->amount,
|
|
'ratio' => $total > 0 ? round(($item->amount / $total) * 100, 1) : 0,
|
|
];
|
|
}
|
|
|
|
// 데이터가 없는 경우 기본값 반환
|
|
if (empty($result)) {
|
|
$result = [
|
|
['category' => 'meal', 'label' => '식비', 'amount' => 55000000, 'ratio' => 55],
|
|
['category' => 'health_check', 'label' => '건강검진', 'amount' => 25000000, 'ratio' => 25],
|
|
['category' => 'congratulation', 'label' => '경조사비', 'amount' => 10000000, 'ratio' => 10],
|
|
['category' => 'other', 'label' => '기타', 'amount' => 10000000, 'ratio' => 10],
|
|
];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 일별 사용 내역 조회
|
|
*/
|
|
private function getTransactions(int $tenantId, string $startDate, string $endDate): array
|
|
{
|
|
$categoryLabels = [
|
|
'meal' => '식비',
|
|
'health_check' => '건강검진',
|
|
'congratulation' => '경조사비',
|
|
'other' => '기타',
|
|
];
|
|
|
|
$transactions = DB::table('expense_accounts as ea')
|
|
->leftJoin('users as u', 'ea.created_by', '=', 'u.id')
|
|
->select([
|
|
'ea.id',
|
|
'ea.card_no',
|
|
'u.name as user_name',
|
|
'ea.expense_date',
|
|
'ea.vendor_name',
|
|
'ea.amount',
|
|
'ea.sub_type',
|
|
])
|
|
->where('ea.tenant_id', $tenantId)
|
|
->where('ea.account_type', 'welfare')
|
|
->whereBetween('ea.expense_date', [$startDate, $endDate])
|
|
->whereNull('ea.deleted_at')
|
|
->orderByDesc('ea.expense_date')
|
|
->limit(100)
|
|
->get();
|
|
|
|
$result = [];
|
|
foreach ($transactions as $t) {
|
|
$subType = $t->sub_type ?? 'other';
|
|
$result[] = [
|
|
'id' => $t->id,
|
|
'card_name' => $t->card_no ? '카드 *'.substr($t->card_no, -4) : '카드명',
|
|
'user_name' => $t->user_name ?? '사용자',
|
|
'expense_date' => Carbon::parse($t->expense_date)->format('Y-m-d H:i'),
|
|
'vendor_name' => $t->vendor_name ?? '가맹점명',
|
|
'amount' => (int) $t->amount,
|
|
'sub_type' => $subType,
|
|
'sub_type_label' => $categoryLabels[$subType] ?? '기타',
|
|
];
|
|
}
|
|
|
|
// 데이터가 없는 경우 기본값 반환
|
|
if (empty($result)) {
|
|
$result = [
|
|
['id' => 1, 'card_name' => '카드명', 'user_name' => '홍길동', 'expense_date' => '2025-12-12 12:12', 'vendor_name' => '가맹점명', 'amount' => 1000000, 'sub_type' => 'meal', 'sub_type_label' => '식비'],
|
|
['id' => 2, 'card_name' => '카드명', 'user_name' => '홍길동', 'expense_date' => '2025-12-12 12:12', 'vendor_name' => '가맹점명', 'amount' => 1200000, 'sub_type' => 'health_check', 'sub_type_label' => '건강검진'],
|
|
['id' => 3, 'card_name' => '카드명', 'user_name' => '홍길동', 'expense_date' => '2025-12-12 12:12', 'vendor_name' => '가맹점명', 'amount' => 1500000, 'sub_type' => 'congratulation', 'sub_type_label' => '경조사비'],
|
|
];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 분기별 현황 조회
|
|
*/
|
|
private function getQuarterlyStatus(int $tenantId, int $year, float $quarterlyLimit): array
|
|
{
|
|
$result = [];
|
|
$previousRemaining = 0;
|
|
|
|
for ($q = 1; $q <= 4; $q++) {
|
|
$startDate = Carbon::create($year, ($q - 1) * 3 + 1, 1)->format('Y-m-d');
|
|
$endDate = Carbon::create($year, $q * 3, 1)->endOfMonth()->format('Y-m-d');
|
|
|
|
$used = $this->getUsedAmount($tenantId, $startDate, $endDate);
|
|
$carryover = $previousRemaining > 0 ? $previousRemaining : 0;
|
|
$totalLimit = $quarterlyLimit + $carryover;
|
|
$remaining = max(0, $totalLimit - $used);
|
|
$exceeded = max(0, $used - $totalLimit);
|
|
|
|
$result[] = [
|
|
'quarter' => $q,
|
|
'limit' => (int) $quarterlyLimit,
|
|
'carryover' => (int) $carryover,
|
|
'used' => (int) $used,
|
|
'remaining' => (int) $remaining,
|
|
'exceeded' => (int) $exceeded,
|
|
];
|
|
|
|
$previousRemaining = $remaining;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 체크포인트 생성
|
|
*/
|
|
private function generateCheckPoints(
|
|
int $tenantId,
|
|
int $employeeCount,
|
|
float $usedAmount,
|
|
int $monthCount,
|
|
string $startDate,
|
|
string $endDate
|
|
): array {
|
|
$checkPoints = [];
|
|
|
|
// 1인당 월 복리후생비 계산
|
|
$perPersonMonthly = $employeeCount > 0 && $monthCount > 0
|
|
? $usedAmount / $employeeCount / $monthCount
|
|
: 0;
|
|
$perPersonFormatted = number_format($perPersonMonthly / 10000);
|
|
|
|
// 업계 평균 비교
|
|
if ($perPersonMonthly >= self::INDUSTRY_AVG_MIN && $perPersonMonthly <= self::INDUSTRY_AVG_MAX) {
|
|
$checkPoints[] = [
|
|
'id' => 'wf_cp_normal',
|
|
'type' => 'success',
|
|
'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.",
|
|
'highlights' => [
|
|
['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'green'],
|
|
],
|
|
];
|
|
} elseif ($perPersonMonthly < self::INDUSTRY_AVG_MIN) {
|
|
$checkPoints[] = [
|
|
'id' => 'wf_cp_low',
|
|
'type' => 'warning',
|
|
'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 낮습니다.",
|
|
'highlights' => [
|
|
['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'orange'],
|
|
],
|
|
];
|
|
} else {
|
|
$checkPoints[] = [
|
|
'id' => 'wf_cp_high',
|
|
'type' => 'warning',
|
|
'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 높습니다.",
|
|
'highlights' => [
|
|
['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'orange'],
|
|
],
|
|
];
|
|
}
|
|
|
|
// 식대 비과세 한도 체크
|
|
$mealAmount = $this->getMonthlyMealAmount($tenantId, $startDate, $endDate);
|
|
$perPersonMeal = $employeeCount > 0 ? $mealAmount / $employeeCount : 0;
|
|
|
|
if ($perPersonMeal > self::TAX_FREE_MEAL_LIMIT) {
|
|
$mealFormatted = number_format($perPersonMeal / 10000);
|
|
$limitFormatted = number_format(self::TAX_FREE_MEAL_LIMIT / 10000);
|
|
$checkPoints[] = [
|
|
'id' => 'wf_cp_meal',
|
|
'type' => 'error',
|
|
'message' => "식대가 월 {$mealFormatted}만원으로 비과세 한도({$limitFormatted}만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.",
|
|
'highlights' => [
|
|
['text' => "식대가 월 {$mealFormatted}만원으로", 'color' => 'red'],
|
|
['text' => '초과', 'color' => 'red'],
|
|
],
|
|
];
|
|
}
|
|
|
|
return $checkPoints;
|
|
}
|
|
}
|