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,29 +6,35 @@
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 접대비 현황 서비스
|
||||
* 접대비 현황 서비스 (D1.7 리스크 감지형)
|
||||
*
|
||||
* CEO 대시보드용 접대비 데이터를 제공합니다.
|
||||
* CEO 대시보드용 접대비 리스크 데이터를 제공합니다.
|
||||
* 카드 4개: 주말/심야, 기피업종, 고액결제, 증빙미비
|
||||
*/
|
||||
class EntertainmentService extends Service
|
||||
{
|
||||
// 접대비 기본 한도율 (중소기업 기준: 매출의 0.3%)
|
||||
private const DEFAULT_LIMIT_RATE = 0.003;
|
||||
// 고액 결제 기준 (1회 50만원 초과)
|
||||
private const HIGH_AMOUNT_THRESHOLD = 500000;
|
||||
|
||||
// 기업 규모별 기본 한도 (연간)
|
||||
private const COMPANY_TYPE_LIMITS = [
|
||||
'large' => 36000000, // 대기업: 연 3,600만원
|
||||
'medium' => 36000000, // 중견기업: 연 3,600만원
|
||||
'small' => 24000000, // 중소기업: 연 2,400만원
|
||||
// 기피업종 MCC 코드 (유흥, 귀금속, 숙박 등)
|
||||
private const PROHIBITED_MCC_CODES = [
|
||||
'5813', // 음주업소
|
||||
'7011', // 숙박업
|
||||
'5944', // 귀금속
|
||||
'7941', // 레저/스포츠
|
||||
'7992', // 골프장
|
||||
'7273', // 데이트서비스
|
||||
'5932', // 골동품
|
||||
];
|
||||
|
||||
// 심야 시간대 (22시 ~ 06시)
|
||||
private const LATE_NIGHT_START = 22;
|
||||
|
||||
private const LATE_NIGHT_END = 6;
|
||||
|
||||
/**
|
||||
* 접대비 현황 요약 조회
|
||||
* 접대비 리스크 현황 요약 조회 (D1.7)
|
||||
*
|
||||
* @param string|null $limitType 기간 타입 (annual|quarterly, 기본: quarterly)
|
||||
* @param string|null $companyType 기업 유형 (large|medium|small, 기본: medium)
|
||||
* @param int|null $year 연도 (기본: 현재 연도)
|
||||
* @param int|null $quarter 분기 (1-4, 기본: 현재 분기)
|
||||
* @return array{cards: array, check_points: array}
|
||||
*/
|
||||
public function getSummary(
|
||||
@@ -40,73 +46,58 @@ public function getSummary(
|
||||
$tenantId = $this->tenantId();
|
||||
$now = Carbon::now();
|
||||
|
||||
// 기본값 설정
|
||||
$year = $year ?? $now->year;
|
||||
$limitType = $limitType ?? 'quarterly';
|
||||
$companyType = $companyType ?? 'medium';
|
||||
$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}년";
|
||||
} 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}사분기";
|
||||
}
|
||||
|
||||
// 연간 시작일 (매출 계산용)
|
||||
$yearStartDate = Carbon::create($year, 1, 1)->format('Y-m-d');
|
||||
$yearEndDate = Carbon::create($year, 12, 31)->format('Y-m-d');
|
||||
|
||||
// 매출액 조회 (연간)
|
||||
$annualSales = $this->getAnnualSales($tenantId, $yearStartDate, $yearEndDate);
|
||||
|
||||
// 접대비 한도 계산
|
||||
$annualLimit = $this->calculateLimit($annualSales, $companyType);
|
||||
$periodLimit = $limitType === 'annual' ? $annualLimit : ($annualLimit / 4);
|
||||
|
||||
// 접대비 사용액 조회
|
||||
$usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate);
|
||||
|
||||
// 잔여 한도
|
||||
$remainingLimit = max(0, $periodLimit - $usedAmount);
|
||||
// 리스크 감지 쿼리
|
||||
$weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $startDate, $endDate);
|
||||
$prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $startDate, $endDate);
|
||||
$highAmount = $this->getHighAmountRisk($tenantId, $startDate, $endDate);
|
||||
$missingReceipt = $this->getMissingReceiptRisk($tenantId, $startDate, $endDate);
|
||||
|
||||
// 카드 데이터 구성
|
||||
$cards = [
|
||||
[
|
||||
'id' => 'et_sales',
|
||||
'label' => '매출',
|
||||
'amount' => (int) $annualSales,
|
||||
'id' => 'et_weekend',
|
||||
'label' => '주말/심야',
|
||||
'amount' => (int) $weekendLateNight['total'],
|
||||
'subLabel' => "{$weekendLateNight['count']}건",
|
||||
],
|
||||
[
|
||||
'id' => 'et_limit',
|
||||
'label' => "{{$periodLabel}} 접대비 총 한도",
|
||||
'amount' => (int) $periodLimit,
|
||||
'id' => 'et_prohibited',
|
||||
'label' => '기피업종',
|
||||
'amount' => (int) $prohibitedBiz['total'],
|
||||
'subLabel' => $prohibitedBiz['count'] > 0 ? "불인정 {$prohibitedBiz['count']}건" : '0건',
|
||||
],
|
||||
[
|
||||
'id' => 'et_remaining',
|
||||
'label' => "{{$periodLabel}} 접대비 잔여한도",
|
||||
'amount' => (int) $remainingLimit,
|
||||
'id' => 'et_high_amount',
|
||||
'label' => '고액 결제',
|
||||
'amount' => (int) $highAmount['total'],
|
||||
'subLabel' => "{$highAmount['count']}건",
|
||||
],
|
||||
[
|
||||
'id' => 'et_used',
|
||||
'label' => "{{$periodLabel}} 접대비 사용금액",
|
||||
'amount' => (int) $usedAmount,
|
||||
'id' => 'et_no_receipt',
|
||||
'label' => '증빙 미비',
|
||||
'amount' => (int) $missingReceipt['total'],
|
||||
'subLabel' => "{$missingReceipt['count']}건",
|
||||
],
|
||||
];
|
||||
|
||||
// 체크포인트 생성
|
||||
$checkPoints = $this->generateCheckPoints(
|
||||
$periodLabel,
|
||||
$periodLimit,
|
||||
$usedAmount,
|
||||
$remainingLimit,
|
||||
$tenantId,
|
||||
$startDate,
|
||||
$endDate
|
||||
$checkPoints = $this->generateRiskCheckPoints(
|
||||
$weekendLateNight,
|
||||
$prohibitedBiz,
|
||||
$highAmount,
|
||||
$missingReceipt
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -116,65 +107,83 @@ public function getSummary(
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 매출액 조회
|
||||
* 주말/심야 사용 리스크 조회
|
||||
* expense_date가 주말(토/일) OR barobill join으로 use_time 22~06시
|
||||
*/
|
||||
private function getAnnualSales(int $tenantId, string $startDate, string $endDate): float
|
||||
private function getWeekendLateNightRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
// orders 테이블에서 확정된 수주 합계 조회
|
||||
$amount = DB::table('orders')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status_code', 'confirmed')
|
||||
->whereBetween('received_at', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->sum('total_amount');
|
||||
|
||||
return $amount ?: 30530000000; // 임시 기본값 (305억)
|
||||
}
|
||||
|
||||
/**
|
||||
* 접대비 한도 계산
|
||||
*/
|
||||
private function calculateLimit(float $annualSales, string $companyType): float
|
||||
{
|
||||
// 기본 한도 (기업 규모별)
|
||||
$baseLimit = self::COMPANY_TYPE_LIMITS[$companyType] ?? self::COMPANY_TYPE_LIMITS['medium'];
|
||||
|
||||
// 매출 기반 한도 (0.3%)
|
||||
$salesBasedLimit = $annualSales * self::DEFAULT_LIMIT_RATE;
|
||||
|
||||
// 기본 한도 + 매출 기반 한도 (실제 세법은 더 복잡하지만 간소화)
|
||||
return $baseLimit + $salesBasedLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 접대비 사용액 조회
|
||||
*/
|
||||
private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float
|
||||
{
|
||||
// TODO: 실제 접대비 계정과목에서 조회
|
||||
// expense_accounts 또는 card_transactions에서 접대비 항목 합계
|
||||
$amount = DB::table('expense_accounts')
|
||||
// 주말 사용 (토요일=7, 일요일=1 in MySQL DAYOFWEEK)
|
||||
$weekendResult = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'entertainment')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->sum('amount');
|
||||
->whereRaw('DAYOFWEEK(expense_date) IN (1, 7)')
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
return $amount ?: 10000000; // 임시 기본값
|
||||
// 심야 사용 (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', 'entertainment')
|
||||
->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) >= ?', [self::LATE_NIGHT_START])
|
||||
->orWhereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) < ?', [self::LATE_NIGHT_END]);
|
||||
})
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total')
|
||||
->first();
|
||||
|
||||
$totalCount = ($weekendResult->count ?? 0) + ($lateNightResult->count ?? 0);
|
||||
$totalAmount = ($weekendResult->total ?? 0) + ($lateNightResult->total ?? 0);
|
||||
|
||||
return ['count' => $totalCount, 'total' => $totalAmount];
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처 누락 건수 조회
|
||||
* 기피업종 사용 리스크 조회
|
||||
* barobill의 merchant_biz_type가 MCC 코드 매칭
|
||||
*/
|
||||
private function getMissingVendorCount(int $tenantId, string $startDate, string $endDate): array
|
||||
private function getProhibitedBizTypeRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
$result = DB::table('expense_accounts as ea')
|
||||
->join('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', 'entertainment')
|
||||
->whereBetween('ea.expense_date', [$startDate, $endDate])
|
||||
->whereNull('ea.deleted_at')
|
||||
->whereIn('bct.merchant_biz_type', self::PROHIBITED_MCC_CODES)
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'count' => $result->count ?? 0,
|
||||
'total' => $result->total ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 고액 결제 리스크 조회
|
||||
* 1회 50만원 초과 결제
|
||||
*/
|
||||
private function getHighAmountRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
// TODO: 거래처 정보 누락 건수 조회
|
||||
$result = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'entertainment')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('vendor_id')
|
||||
->whereNull('deleted_at')
|
||||
->where('amount', '>', self::HIGH_AMOUNT_THRESHOLD)
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
@@ -185,72 +194,436 @@ private function getMissingVendorCount(int $tenantId, string $startDate, string
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크포인트 생성
|
||||
* 증빙 미비 리스크 조회
|
||||
* receipt_no가 NULL 또는 빈 값
|
||||
*/
|
||||
private function generateCheckPoints(
|
||||
string $periodLabel,
|
||||
float $limit,
|
||||
float $used,
|
||||
float $remaining,
|
||||
int $tenantId,
|
||||
string $startDate,
|
||||
string $endDate
|
||||
private function getMissingReceiptRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
$result = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'entertainment')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('receipt_no')
|
||||
->orWhere('receipt_no', '');
|
||||
})
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'count' => $result->count ?? 0,
|
||||
'total' => $result->total ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 접대비 상세 정보 조회 (모달용)
|
||||
*
|
||||
* @param string|null $companyType 법인 유형 (large|medium|small, 기본: medium)
|
||||
* @param int|null $year 연도 (기본: 현재 연도)
|
||||
* @param int|null $quarter 분기 (1-4, 기본: 현재 분기)
|
||||
*/
|
||||
public function getDetail(
|
||||
?string $companyType = 'medium',
|
||||
?int $year = null,
|
||||
?int $quarter = null,
|
||||
?string $startDate = null,
|
||||
?string $endDate = null
|
||||
): array {
|
||||
$tenantId = $this->tenantId();
|
||||
$now = Carbon::now();
|
||||
|
||||
$year = $year ?? $now->year;
|
||||
$companyType = $companyType ?? 'medium';
|
||||
$quarter = $quarter ?? $now->quarter;
|
||||
|
||||
// 연간 기간 범위 (summary, calculation, quarterly, monthly_usage용 - 항상 연간)
|
||||
$annualStartDate = Carbon::create($year, 1, 1)->format('Y-m-d');
|
||||
$annualEndDate = Carbon::create($year, 12, 31)->format('Y-m-d');
|
||||
|
||||
// 거래/리스크 필터 기간 (start_date/end_date 전달 시 사용, 없으면 분기 기본)
|
||||
if ($startDate && $endDate) {
|
||||
$filterStartDate = $startDate;
|
||||
$filterEndDate = $endDate;
|
||||
} else {
|
||||
$filterStartDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d');
|
||||
$filterEndDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d');
|
||||
}
|
||||
|
||||
// 기본한도 계산 (중소기업: 3,600만, 일반법인: 1,200만)
|
||||
$baseLimit = $companyType === 'large' ? 12000000 : 36000000;
|
||||
|
||||
// 수입금액 조회 (sales 테이블)
|
||||
$revenue = $this->getAnnualRevenue($tenantId, $year);
|
||||
|
||||
// 수입금액별 추가한도 계산
|
||||
$revenueAdditional = $this->calculateRevenueAdditionalLimit($revenue);
|
||||
|
||||
// 연간 총 한도
|
||||
$annualLimit = $baseLimit + $revenueAdditional;
|
||||
$quarterlyLimit = $annualLimit / 4;
|
||||
|
||||
// 연간/분기 사용액 조회
|
||||
$annualUsed = $this->getUsedAmount($tenantId, $annualStartDate, $annualEndDate);
|
||||
$quarterlyUsed = $this->getUsedAmount($tenantId, $filterStartDate, $filterEndDate);
|
||||
|
||||
// 잔여/초과 계산
|
||||
$annualRemaining = max(0, $annualLimit - $annualUsed);
|
||||
$annualExceeded = max(0, $annualUsed - $annualLimit);
|
||||
|
||||
// 1. 요약 데이터
|
||||
$summary = [
|
||||
'annual_limit' => (int) $annualLimit,
|
||||
'annual_remaining' => (int) $annualRemaining,
|
||||
'annual_used' => (int) $annualUsed,
|
||||
'annual_exceeded' => (int) $annualExceeded,
|
||||
];
|
||||
|
||||
// 2. 리스크 검토 카드 (날짜 필터 적용)
|
||||
$weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $filterStartDate, $filterEndDate);
|
||||
$prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $filterStartDate, $filterEndDate);
|
||||
$highAmount = $this->getHighAmountRisk($tenantId, $filterStartDate, $filterEndDate);
|
||||
$missingReceipt = $this->getMissingReceiptRisk($tenantId, $filterStartDate, $filterEndDate);
|
||||
|
||||
$riskReview = [
|
||||
['label' => '주말/심야', 'amount' => (int) $weekendLateNight['total'], 'count' => $weekendLateNight['count']],
|
||||
['label' => '기피업종', 'amount' => (int) $prohibitedBiz['total'], 'count' => $prohibitedBiz['count']],
|
||||
['label' => '고액 결제', 'amount' => (int) $highAmount['total'], 'count' => $highAmount['count']],
|
||||
['label' => '증빙 미비', 'amount' => (int) $missingReceipt['total'], 'count' => $missingReceipt['count']],
|
||||
];
|
||||
|
||||
// 3. 월별 사용 추이
|
||||
$monthlyUsage = $this->getMonthlyUsageTrend($tenantId, $year);
|
||||
|
||||
// 4. 사용자별 분포 (날짜 필터 적용)
|
||||
$userDistribution = $this->getUserDistribution($tenantId, $filterStartDate, $filterEndDate);
|
||||
|
||||
// 5. 거래 내역 (날짜 필터 적용)
|
||||
$transactions = $this->getTransactions($tenantId, $filterStartDate, $filterEndDate);
|
||||
|
||||
// 6. 손금한도 계산 정보
|
||||
$calculation = [
|
||||
'company_type' => $companyType,
|
||||
'base_limit' => (int) $baseLimit,
|
||||
'revenue' => (int) $revenue,
|
||||
'revenue_additional' => (int) $revenueAdditional,
|
||||
'annual_limit' => (int) $annualLimit,
|
||||
];
|
||||
|
||||
// 7. 분기별 현황
|
||||
$quarterly = $this->getQuarterlyStatus($tenantId, $year, $quarterlyLimit);
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'risk_review' => $riskReview,
|
||||
'monthly_usage' => $monthlyUsage,
|
||||
'user_distribution' => $userDistribution,
|
||||
'transactions' => $transactions,
|
||||
'calculation' => $calculation,
|
||||
'quarterly' => $quarterly,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 접대비 사용액 조회
|
||||
*/
|
||||
private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float
|
||||
{
|
||||
return DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'entertainment')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->sum('amount') ?: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 수입금액(매출) 조회
|
||||
*/
|
||||
private function getAnnualRevenue(int $tenantId, int $year): float
|
||||
{
|
||||
return DB::table('sales')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereYear('sale_date', $year)
|
||||
->whereNull('deleted_at')
|
||||
->sum('total_amount') ?: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수입금액별 추가한도 계산 (세법 기준)
|
||||
* 100억 이하: 수입금액 × 0.2%
|
||||
* 100억 초과 ~ 500억 이하: 2,000만 + (수입금액 - 100억) × 0.1%
|
||||
* 500억 초과: 6,000만 + (수입금액 - 500억) × 0.03%
|
||||
*/
|
||||
private function calculateRevenueAdditionalLimit(float $revenue): float
|
||||
{
|
||||
$b10 = 10000000000; // 100억
|
||||
$b50 = 50000000000; // 500억
|
||||
|
||||
if ($revenue <= $b10) {
|
||||
return $revenue * 0.002;
|
||||
} elseif ($revenue <= $b50) {
|
||||
return 20000000 + ($revenue - $b10) * 0.001;
|
||||
} else {
|
||||
return 60000000 + ($revenue - $b50) * 0.0003;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 사용 추이 조회
|
||||
*/
|
||||
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', 'entertainment')
|
||||
->whereYear('expense_date', $year)
|
||||
->whereNull('deleted_at')
|
||||
->groupBy(DB::raw('MONTH(expense_date)'))
|
||||
->orderBy('month')
|
||||
->get();
|
||||
|
||||
$result = [];
|
||||
for ($i = 1; $i <= 12; $i++) {
|
||||
$found = $monthlyData->firstWhere('month', $i);
|
||||
$result[] = [
|
||||
'month' => $i,
|
||||
'label' => $i . '월',
|
||||
'amount' => $found ? (int) $found->amount : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자별 분포 조회
|
||||
*/
|
||||
private function getUserDistribution(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
$colors = ['#60A5FA', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#FB923C'];
|
||||
|
||||
$distribution = DB::table('expense_accounts as ea')
|
||||
->leftJoin('users as u', 'ea.created_by', '=', 'u.id')
|
||||
->select('u.name as user_name', DB::raw('SUM(ea.amount) as amount'))
|
||||
->where('ea.tenant_id', $tenantId)
|
||||
->where('ea.account_type', 'entertainment')
|
||||
->whereBetween('ea.expense_date', [$startDate, $endDate])
|
||||
->whereNull('ea.deleted_at')
|
||||
->groupBy('ea.created_by', 'u.name')
|
||||
->orderByDesc('amount')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
$total = $distribution->sum('amount');
|
||||
$result = [];
|
||||
$idx = 0;
|
||||
|
||||
foreach ($distribution as $item) {
|
||||
$result[] = [
|
||||
'user_name' => $item->user_name ?? '사용자',
|
||||
'amount' => (int) $item->amount,
|
||||
'percentage' => $total > 0 ? round(($item->amount / $total) * 100, 1) : 0,
|
||||
'color' => $colors[$idx % count($colors)],
|
||||
];
|
||||
$idx++;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 내역 조회
|
||||
*/
|
||||
private function getTransactions(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
$transactions = DB::table('expense_accounts as ea')
|
||||
->leftJoin('users as u', 'ea.created_by', '=', 'u.id')
|
||||
->leftJoin('barobill_card_transactions as bct', function ($join) {
|
||||
$join->on('ea.receipt_no', '=', 'bct.approval_num')
|
||||
->on('ea.tenant_id', '=', 'bct.tenant_id');
|
||||
})
|
||||
->select([
|
||||
'ea.id',
|
||||
'ea.card_no',
|
||||
'u.name as user_name',
|
||||
'ea.expense_date',
|
||||
'ea.vendor_name',
|
||||
'ea.amount',
|
||||
'ea.receipt_no',
|
||||
'bct.use_time',
|
||||
'bct.merchant_biz_type',
|
||||
])
|
||||
->where('ea.tenant_id', $tenantId)
|
||||
->where('ea.account_type', 'entertainment')
|
||||
->whereBetween('ea.expense_date', [$startDate, $endDate])
|
||||
->whereNull('ea.deleted_at')
|
||||
->orderByDesc('ea.expense_date')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
$result = [];
|
||||
foreach ($transactions as $t) {
|
||||
$riskType = $this->detectTransactionRiskType($t);
|
||||
|
||||
$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,
|
||||
'risk_type' => $riskType,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 건별 리스크 유형 감지
|
||||
*/
|
||||
private function detectTransactionRiskType(object $transaction): string
|
||||
{
|
||||
// 기피업종
|
||||
if ($transaction->merchant_biz_type && in_array($transaction->merchant_biz_type, self::PROHIBITED_MCC_CODES)) {
|
||||
return '기피업종';
|
||||
}
|
||||
|
||||
// 고액 결제
|
||||
if ($transaction->amount > self::HIGH_AMOUNT_THRESHOLD) {
|
||||
return '고액 결제';
|
||||
}
|
||||
|
||||
// 증빙 미비
|
||||
if (empty($transaction->receipt_no)) {
|
||||
return '증빙 미비';
|
||||
}
|
||||
|
||||
// 주말/심야 감지
|
||||
$expenseDate = Carbon::parse($transaction->expense_date);
|
||||
if ($expenseDate->isWeekend()) {
|
||||
return '주말/심야';
|
||||
}
|
||||
if ($transaction->use_time) {
|
||||
$hour = (int) substr($transaction->use_time, 0, 2);
|
||||
if ($hour >= self::LATE_NIGHT_START || $hour < self::LATE_NIGHT_END) {
|
||||
return '주말/심야';
|
||||
}
|
||||
}
|
||||
|
||||
return '정상';
|
||||
}
|
||||
|
||||
/**
|
||||
* 분기별 현황 조회
|
||||
*/
|
||||
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 generateRiskCheckPoints(
|
||||
array $weekendLateNight,
|
||||
array $prohibitedBiz,
|
||||
array $highAmount,
|
||||
array $missingReceipt
|
||||
): array {
|
||||
$checkPoints = [];
|
||||
$usageRate = $limit > 0 ? ($used / $limit) * 100 : 0;
|
||||
$usedFormatted = number_format($used / 10000);
|
||||
$limitFormatted = number_format($limit / 10000);
|
||||
$remainingFormatted = number_format($remaining / 10000);
|
||||
$totalRiskCount = $weekendLateNight['count'] + $prohibitedBiz['count']
|
||||
+ $highAmount['count'] + $missingReceipt['count'];
|
||||
|
||||
// 사용률에 따른 체크포인트
|
||||
if ($usageRate <= 75) {
|
||||
// 정상 운영
|
||||
$remainingRate = round(100 - $usageRate);
|
||||
// 주말/심야
|
||||
if ($weekendLateNight['count'] > 0) {
|
||||
$amountFormatted = number_format($weekendLateNight['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_normal',
|
||||
'type' => 'success',
|
||||
'message' => "{$periodLabel} 접대비 사용 {$usedFormatted}만원 / 한도 {$limitFormatted}만원 ({$remainingRate}%). 여유 있게 운영 중입니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$usedFormatted}만원", 'color' => 'green'],
|
||||
['text' => "{$limitFormatted}만원 ({$remainingRate}%)", 'color' => 'green'],
|
||||
],
|
||||
];
|
||||
} elseif ($usageRate <= 100) {
|
||||
// 주의 (85% 이상)
|
||||
$usageRateRounded = round($usageRate);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_warning',
|
||||
'id' => 'et_cp_weekend',
|
||||
'type' => 'warning',
|
||||
'message' => "접대비 한도 {$usageRateRounded}% 도달. 잔여 한도 {$remainingFormatted}만원입니다. 사용 계획을 점검해 주세요.",
|
||||
'message' => "주말/심야 사용 {$weekendLateNight['count']}건({$amountFormatted}만원) 감지. 업무관련성 소명자료로 증빙해주세요.",
|
||||
'highlights' => [
|
||||
['text' => "잔여 한도 {$remainingFormatted}만원", 'color' => 'orange'],
|
||||
],
|
||||
];
|
||||
} else {
|
||||
// 한도 초과
|
||||
$overAmount = $used - $limit;
|
||||
$overFormatted = number_format($overAmount / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_over',
|
||||
'type' => 'error',
|
||||
'message' => "접대비 한도 초과 {$overFormatted}만원 발생. 초과분은 손금불산입되어 법인세 부담이 증가합니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$overFormatted}만원 발생", 'color' => 'red'],
|
||||
['text' => "{$weekendLateNight['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 거래처 정보 누락 체크
|
||||
$missingVendor = $this->getMissingVendorCount($tenantId, $startDate, $endDate);
|
||||
if ($missingVendor['count'] > 0) {
|
||||
$missingTotal = number_format($missingVendor['total'] / 10000);
|
||||
// 기피업종
|
||||
if ($prohibitedBiz['count'] > 0) {
|
||||
$amountFormatted = number_format($prohibitedBiz['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_missing',
|
||||
'id' => 'et_cp_prohibited',
|
||||
'type' => 'error',
|
||||
'message' => "접대비 사용 중 {$missingVendor['count']}건({$missingTotal}만원)의 거래처 정보가 누락되었습니다. 기록을 보완해 주세요.",
|
||||
'message' => "기피업종 사용 {$prohibitedBiz['count']}건({$amountFormatted}만원) 감지. 유흥업종 결제는 접대비 불인정 사유입니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$missingVendor['count']}건({$missingTotal}만원)", 'color' => 'red'],
|
||||
['text' => '거래처 정보가 누락', 'color' => 'red'],
|
||||
['text' => "{$prohibitedBiz['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
||||
['text' => '접대비 불인정', 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 고액 결제
|
||||
if ($highAmount['count'] > 0) {
|
||||
$amountFormatted = number_format($highAmount['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_high',
|
||||
'type' => 'warning',
|
||||
'message' => "고액 결제 {$highAmount['count']}건({$amountFormatted}만원) 감지. 1회 50만원 초과 결제입니다. 증빙이 필요합니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$highAmount['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 증빙 미비
|
||||
if ($missingReceipt['count'] > 0) {
|
||||
$amountFormatted = number_format($missingReceipt['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_receipt',
|
||||
'type' => 'error',
|
||||
'message' => "미증빙 {$missingReceipt['count']}건({$amountFormatted}만원) 감지. 증빙이 필요합니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$missingReceipt['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 리스크 0건이면 정상 메시지
|
||||
if ($totalRiskCount === 0) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_normal',
|
||||
'type' => 'success',
|
||||
'message' => '접대비 사용 현황이 정상입니다.',
|
||||
'highlights' => [
|
||||
['text' => '정상', 'color' => 'green'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user