- approval_no → approval_num 컬럼명 수정
- use_time 심야 판별: HOUR() → SUBSTRING 문자열 파싱으로 변경
- whereNotNull('bct.use_time') 조건 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
301 lines
11 KiB
PHP
301 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* 접대비 현황 서비스 (D1.7 리스크 감지형)
|
|
*
|
|
* CEO 대시보드용 접대비 리스크 데이터를 제공합니다.
|
|
* 카드 4개: 주말/심야, 기피업종, 고액결제, 증빙미비
|
|
*/
|
|
class EntertainmentService extends Service
|
|
{
|
|
// 고액 결제 기준 (1회 50만원 초과)
|
|
private const HIGH_AMOUNT_THRESHOLD = 500000;
|
|
|
|
// 기피업종 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)
|
|
*
|
|
* @return array{cards: array, check_points: array}
|
|
*/
|
|
public function getSummary(
|
|
?string $limitType = 'quarterly',
|
|
?string $companyType = 'medium',
|
|
?int $year = null,
|
|
?int $quarter = null
|
|
): array {
|
|
$tenantId = $this->tenantId();
|
|
$now = Carbon::now();
|
|
|
|
$year = $year ?? $now->year;
|
|
$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');
|
|
} 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');
|
|
}
|
|
|
|
// 리스크 감지 쿼리
|
|
$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_weekend',
|
|
'label' => '주말/심야',
|
|
'amount' => (int) $weekendLateNight['total'],
|
|
'subLabel' => "{$weekendLateNight['count']}건",
|
|
],
|
|
[
|
|
'id' => 'et_prohibited',
|
|
'label' => '기피업종',
|
|
'amount' => (int) $prohibitedBiz['total'],
|
|
'subLabel' => $prohibitedBiz['count'] > 0 ? "불인정 {$prohibitedBiz['count']}건" : '0건',
|
|
],
|
|
[
|
|
'id' => 'et_high_amount',
|
|
'label' => '고액 결제',
|
|
'amount' => (int) $highAmount['total'],
|
|
'subLabel' => "{$highAmount['count']}건",
|
|
],
|
|
[
|
|
'id' => 'et_no_receipt',
|
|
'label' => '증빙 미비',
|
|
'amount' => (int) $missingReceipt['total'],
|
|
'subLabel' => "{$missingReceipt['count']}건",
|
|
],
|
|
];
|
|
|
|
// 체크포인트 생성
|
|
$checkPoints = $this->generateRiskCheckPoints(
|
|
$weekendLateNight,
|
|
$prohibitedBiz,
|
|
$highAmount,
|
|
$missingReceipt
|
|
);
|
|
|
|
return [
|
|
'cards' => $cards,
|
|
'check_points' => $checkPoints,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 주말/심야 사용 리스크 조회
|
|
* expense_date가 주말(토/일) OR barobill join으로 use_time 22~06시
|
|
*/
|
|
private function getWeekendLateNightRisk(int $tenantId, string $startDate, string $endDate): array
|
|
{
|
|
// 주말 사용 (토요일=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')
|
|
->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', '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 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
|
|
{
|
|
$result = DB::table('expense_accounts')
|
|
->where('tenant_id', $tenantId)
|
|
->where('account_type', 'entertainment')
|
|
->whereBetween('expense_date', [$startDate, $endDate])
|
|
->whereNull('deleted_at')
|
|
->where('amount', '>', self::HIGH_AMOUNT_THRESHOLD)
|
|
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
|
->first();
|
|
|
|
return [
|
|
'count' => $result->count ?? 0,
|
|
'total' => $result->total ?? 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 증빙 미비 리스크 조회
|
|
* receipt_no가 NULL 또는 빈 값
|
|
*/
|
|
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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 리스크 감지 체크포인트 생성
|
|
*/
|
|
private function generateRiskCheckPoints(
|
|
array $weekendLateNight,
|
|
array $prohibitedBiz,
|
|
array $highAmount,
|
|
array $missingReceipt
|
|
): array {
|
|
$checkPoints = [];
|
|
$totalRiskCount = $weekendLateNight['count'] + $prohibitedBiz['count']
|
|
+ $highAmount['count'] + $missingReceipt['count'];
|
|
|
|
// 주말/심야
|
|
if ($weekendLateNight['count'] > 0) {
|
|
$amountFormatted = number_format($weekendLateNight['total'] / 10000);
|
|
$checkPoints[] = [
|
|
'id' => 'et_cp_weekend',
|
|
'type' => 'warning',
|
|
'message' => "주말/심야 사용 {$weekendLateNight['count']}건({$amountFormatted}만원) 감지. 업무관련성 소명자료로 증빙해주세요.",
|
|
'highlights' => [
|
|
['text' => "{$weekendLateNight['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
|
],
|
|
];
|
|
}
|
|
|
|
// 기피업종
|
|
if ($prohibitedBiz['count'] > 0) {
|
|
$amountFormatted = number_format($prohibitedBiz['total'] / 10000);
|
|
$checkPoints[] = [
|
|
'id' => 'et_cp_prohibited',
|
|
'type' => 'error',
|
|
'message' => "기피업종 사용 {$prohibitedBiz['count']}건({$amountFormatted}만원) 감지. 유흥업종 결제는 접대비 불인정 사유입니다.",
|
|
'highlights' => [
|
|
['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'],
|
|
],
|
|
];
|
|
}
|
|
|
|
return $checkPoints;
|
|
}
|
|
}
|