feat: [재무] 어음 V8 + 상품권 접대비 연동 + 일반전표/계정과목 API
- Bill 확장 필드 (V8), Loan 상품권 카테고리/접대비 자동 연동 - GeneralJournalEntry CRUD, AccountSubject API - 접대비/복리후생비 날짜 필터, 매출채권 soft delete 제외 - 바로빌 연동 API 엔드포인트 추가 - 부가세 상세 조회 API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,10 @@
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 복리후생비 현황 서비스
|
||||
* 복리후생비 현황 서비스 (D1.7 리스크 감지형)
|
||||
*
|
||||
* CEO 대시보드용 복리후생비 데이터를 제공합니다.
|
||||
* CEO 대시보드용 복리후생비 리스크 데이터를 제공합니다.
|
||||
* 카드 4개: 비과세 한도 초과, 사적 사용 의심, 특정인 편중, 항목별 한도 초과
|
||||
*/
|
||||
class WelfareService extends Service
|
||||
{
|
||||
@@ -20,15 +21,22 @@ class WelfareService extends Service
|
||||
|
||||
private const INDUSTRY_AVG_MAX = 250000;
|
||||
|
||||
// 특정인 편중 기준 (전체 대비 5% 초과)
|
||||
private const CONCENTRATION_THRESHOLD = 0.05;
|
||||
|
||||
// 항목별 1인당 월 기준 금액
|
||||
private const SUB_TYPE_LIMITS = [
|
||||
'meal' => 200000, // 식대 20만원
|
||||
'transportation' => 100000, // 교통비 10만원
|
||||
'congratulation' => 50000, // 경조사 5만원
|
||||
'health_check' => 30000, // 건강검진 3만원
|
||||
'education' => 80000, // 교육비 8만원
|
||||
'welfare_point' => 100000, // 복지포인트 10만원
|
||||
];
|
||||
|
||||
/**
|
||||
* 복리후생비 현황 요약 조회
|
||||
* 복리후생비 리스크 현황 요약 조회 (D1.7)
|
||||
*
|
||||
* @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(
|
||||
@@ -42,79 +50,68 @@ public function getSummary(
|
||||
$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);
|
||||
// 리스크 감지 쿼리
|
||||
$taxFreeExcess = $this->getTaxFreeExcessRisk($tenantId, $startDate, $endDate, $employeeCount, $monthCount);
|
||||
$privateUse = $this->getPrivateUseRisk($tenantId, $startDate, $endDate);
|
||||
$concentration = $this->getConcentrationRisk($tenantId, $startDate, $endDate);
|
||||
$categoryExcess = $this->getCategoryExcessRisk($tenantId, $startDate, $endDate, $employeeCount, $monthCount);
|
||||
|
||||
// 카드 데이터 구성
|
||||
$cards = [
|
||||
[
|
||||
'id' => 'wf_annual_limit',
|
||||
'label' => '당해년도 복리후생비 한도',
|
||||
'amount' => (int) $annualLimit,
|
||||
'id' => 'wf_tax_excess',
|
||||
'label' => '비과세 한도 초과',
|
||||
'amount' => (int) $taxFreeExcess['total'],
|
||||
'subLabel' => "{$taxFreeExcess['count']}건",
|
||||
],
|
||||
[
|
||||
'id' => 'wf_period_limit',
|
||||
'label' => "{{$periodLabel}} 복리후생비 총 한도",
|
||||
'amount' => (int) $periodLimit,
|
||||
'id' => 'wf_private_use',
|
||||
'label' => '사적 사용 의심',
|
||||
'amount' => (int) $privateUse['total'],
|
||||
'subLabel' => "{$privateUse['count']}건",
|
||||
],
|
||||
[
|
||||
'id' => 'wf_remaining',
|
||||
'label' => "{{$periodLabel}} 복리후생비 잔여한도",
|
||||
'amount' => (int) $remainingLimit,
|
||||
'id' => 'wf_concentration',
|
||||
'label' => '특정인 편중',
|
||||
'amount' => (int) $concentration['total'],
|
||||
'subLabel' => "{$concentration['count']}건",
|
||||
],
|
||||
[
|
||||
'id' => 'wf_used',
|
||||
'label' => "{{$periodLabel}} 복리후생비 사용금액",
|
||||
'amount' => (int) $usedAmount,
|
||||
'id' => 'wf_category_excess',
|
||||
'label' => '항목별 한도 초과',
|
||||
'amount' => (int) $categoryExcess['total'],
|
||||
'subLabel' => "{$categoryExcess['count']}건",
|
||||
],
|
||||
];
|
||||
|
||||
// 체크포인트 생성
|
||||
$checkPoints = $this->generateCheckPoints(
|
||||
$checkPoints = $this->generateRiskCheckPoints(
|
||||
$tenantId,
|
||||
$employeeCount,
|
||||
$usedAmount,
|
||||
$monthCount,
|
||||
$startDate,
|
||||
$endDate
|
||||
$endDate,
|
||||
$taxFreeExcess,
|
||||
$privateUse,
|
||||
$concentration,
|
||||
$categoryExcess
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -123,6 +120,260 @@ public function getSummary(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 비과세 한도 초과 리스크 조회
|
||||
* sub_type='meal' 1인당 월 > 200,000원
|
||||
*/
|
||||
private function getTaxFreeExcessRisk(int $tenantId, string $startDate, string $endDate, int $employeeCount, int $monthCount): array
|
||||
{
|
||||
if ($employeeCount <= 0) {
|
||||
return ['count' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
// 식대 총액 조회
|
||||
$mealTotal = 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');
|
||||
|
||||
$perPersonMonthly = $mealTotal / $employeeCount / max(1, $monthCount);
|
||||
$excessAmount = max(0, $perPersonMonthly - self::TAX_FREE_MEAL_LIMIT) * $employeeCount * $monthCount;
|
||||
|
||||
if ($excessAmount > 0) {
|
||||
// 초과 건수 (식대 건수 기준)
|
||||
$count = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'welfare')
|
||||
->where('sub_type', 'meal')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
return ['count' => $count, 'total' => (int) $excessAmount];
|
||||
}
|
||||
|
||||
return ['count' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 사적 사용 의심 리스크 조회
|
||||
* 주말/심야 사용 (접대비와 동일 로직, account_type='welfare')
|
||||
*/
|
||||
private function getPrivateUseRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
// 주말 사용
|
||||
$weekendResult = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'welfare')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->whereRaw('DAYOFWEEK(expense_date) IN (1, 7)')
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
// 심야 사용 (barobill 조인)
|
||||
$lateNightResult = DB::table('expense_accounts as ea')
|
||||
->leftJoin('barobill_card_transactions as bct', function ($join) {
|
||||
$join->on('ea.receipt_no', '=', 'bct.approval_num')
|
||||
->on('ea.tenant_id', '=', 'bct.tenant_id');
|
||||
})
|
||||
->where('ea.tenant_id', $tenantId)
|
||||
->where('ea.account_type', 'welfare')
|
||||
->whereBetween('ea.expense_date', [$startDate, $endDate])
|
||||
->whereNull('ea.deleted_at')
|
||||
->whereRaw('DAYOFWEEK(ea.expense_date) NOT IN (1, 7)')
|
||||
->whereNotNull('bct.use_time')
|
||||
->where(function ($q) {
|
||||
$q->whereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) >= 22')
|
||||
->orWhereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) < 6');
|
||||
})
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'count' => ($weekendResult->count ?? 0) + ($lateNightResult->count ?? 0),
|
||||
'total' => ($weekendResult->total ?? 0) + ($lateNightResult->total ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정인 편중 리스크 조회
|
||||
* 1인 사용비율 > 전체의 5%
|
||||
*/
|
||||
private function getConcentrationRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
// 전체 복리후생비 사용액
|
||||
$totalAmount = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'welfare')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->sum('amount');
|
||||
|
||||
if ($totalAmount <= 0) {
|
||||
return ['count' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
$threshold = $totalAmount * self::CONCENTRATION_THRESHOLD;
|
||||
|
||||
// 사용자별 사용액 조회 (편중된 사용자)
|
||||
$concentrated = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'welfare')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('created_by')
|
||||
->havingRaw('SUM(amount) > ?', [$threshold])
|
||||
->selectRaw('COUNT(*) as count, SUM(amount) as total')
|
||||
->get();
|
||||
|
||||
$totalConcentrated = $concentrated->sum('total');
|
||||
$userCount = $concentrated->count();
|
||||
|
||||
return ['count' => $userCount, 'total' => (int) $totalConcentrated];
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목별 한도 초과 리스크 조회
|
||||
* 각 sub_type별 1인당 월 기준금액 초과
|
||||
*/
|
||||
private function getCategoryExcessRisk(int $tenantId, string $startDate, string $endDate, int $employeeCount, int $monthCount): array
|
||||
{
|
||||
if ($employeeCount <= 0) {
|
||||
return ['count' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
$totalExcess = 0;
|
||||
$excessCount = 0;
|
||||
|
||||
foreach (self::SUB_TYPE_LIMITS as $subType => $monthlyLimit) {
|
||||
$amount = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'welfare')
|
||||
->where('sub_type', $subType)
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->sum('amount');
|
||||
|
||||
$perPersonMonthly = $amount / $employeeCount / max(1, $monthCount);
|
||||
if ($perPersonMonthly > $monthlyLimit) {
|
||||
$excess = ($perPersonMonthly - $monthlyLimit) * $employeeCount * $monthCount;
|
||||
$totalExcess += $excess;
|
||||
$excessCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return ['count' => $excessCount, 'total' => (int) $totalExcess];
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스크 감지 체크포인트 생성
|
||||
*/
|
||||
private function generateRiskCheckPoints(
|
||||
int $tenantId,
|
||||
int $employeeCount,
|
||||
int $monthCount,
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
array $taxFreeExcess,
|
||||
array $privateUse,
|
||||
array $concentration,
|
||||
array $categoryExcess
|
||||
): array {
|
||||
$checkPoints = [];
|
||||
|
||||
// 1인당 월 복리후생비 계산 (업계 평균 비교)
|
||||
$usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate);
|
||||
$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_avg',
|
||||
'type' => 'success',
|
||||
'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$perPersonFormatted}만원", 'color' => 'green'],
|
||||
],
|
||||
];
|
||||
} elseif ($perPersonMonthly > self::INDUSTRY_AVG_MAX) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'wf_cp_avg_high',
|
||||
'type' => 'warning',
|
||||
'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 높습니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$perPersonFormatted}만원", 'color' => 'orange'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 식대 비과세 한도 체크
|
||||
$mealAmount = $this->getMonthlyMealAmount($tenantId, $startDate, $endDate);
|
||||
$perPersonMeal = $employeeCount > 0 && $monthCount > 0
|
||||
? $mealAmount / $employeeCount / $monthCount
|
||||
: 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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 사적 사용 의심
|
||||
if ($privateUse['count'] > 0) {
|
||||
$amountFormatted = number_format($privateUse['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'wf_cp_private',
|
||||
'type' => 'warning',
|
||||
'message' => "주말/심야 사용 {$privateUse['count']}건({$amountFormatted}만원) 감지. 사적 사용 여부를 확인해주세요.",
|
||||
'highlights' => [
|
||||
['text' => "{$privateUse['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 특정인 편중
|
||||
if ($concentration['count'] > 0) {
|
||||
$amountFormatted = number_format($concentration['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'wf_cp_concentration',
|
||||
'type' => 'warning',
|
||||
'message' => "특정인 편중 {$concentration['count']}명({$amountFormatted}만원). 전체의 5% 초과 사용자가 있습니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$concentration['count']}명({$amountFormatted}만원)", 'color' => 'orange'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 리스크 0건이면 정상
|
||||
$totalRisk = $taxFreeExcess['count'] + $privateUse['count'] + $concentration['count'] + $categoryExcess['count'];
|
||||
if ($totalRisk === 0 && empty($checkPoints)) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'wf_cp_normal',
|
||||
'type' => 'success',
|
||||
'message' => '복리후생비 사용 현황이 정상입니다.',
|
||||
'highlights' => [
|
||||
['text' => '정상', 'color' => 'green'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $checkPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 수 조회 (급여 대상 직원 기준)
|
||||
*
|
||||
@@ -506,73 +757,4 @@ private function getQuarterlyStatus(int $tenantId, int $year, float $quarterlyLi
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user