first(); if ($activeConfig) { $this->certKey = $activeConfig->cert_key; $this->corpNum = $activeConfig->corp_num; $this->isTestMode = $activeConfig->environment === 'test'; // 카드 조회는 CARD.asmx 사용 $baseUrl = $this->isTestMode ? 'https://testws.baroservice.com' : 'https://ws.baroservice.com'; $this->soapUrl = $baseUrl.'/CARD.asmx?WSDL'; } else { $this->isTestMode = config('services.barobill.test_mode', true); $this->certKey = $this->isTestMode ? config('services.barobill.cert_key_test', '') : config('services.barobill.cert_key_prod', ''); $this->corpNum = config('services.barobill.corp_num', ''); $this->soapUrl = $this->isTestMode ? 'https://testws.baroservice.com/CARD.asmx?WSDL' : 'https://ws.baroservice.com/CARD.asmx?WSDL'; } $this->initSoapClient(); } /** * SOAP 클라이언트 초기화 */ private function initSoapClient(): void { if (! empty($this->certKey) || $this->isTestMode) { try { $context = stream_context_create([ 'ssl' => [ 'verify_peer' => false, 'verify_peer_name' => false, 'allow_self_signed' => true, ], ]); $this->soapClient = new \SoapClient($this->soapUrl, [ 'trace' => true, 'encoding' => 'UTF-8', 'exceptions' => true, 'connection_timeout' => 30, 'stream_context' => $context, 'cache_wsdl' => WSDL_CACHE_BOTH, ]); } catch (\Throwable $e) { Log::error('바로빌 카드 SOAP 클라이언트 생성 실패: '.$e->getMessage()); } } } /** * 카드 사용내역 메인 페이지 */ public function index(Request $request): View|Response { if ($request->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('finance.card-transactions')); } // 현재 선택된 테넌트 정보 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $currentTenant = Tenant::find($tenantId); // 해당 테넌트의 바로빌 회원사 정보 $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); // 테넌트별 서버 모드 적용 (회원사 설정 우선) $isTestMode = $barobillMember ? $barobillMember->isTestMode() : $this->isTestMode; // 서버 모드에 따라 SOAP 설정 재초기화 if ($barobillMember && $barobillMember->server_mode) { $this->applyMemberServerMode($barobillMember); } return view('barobill.ecard.index', [ 'certKey' => $this->certKey, 'corpNum' => $this->corpNum, 'isTestMode' => $isTestMode, 'hasSoapClient' => $this->soapClient !== null, 'currentTenant' => $currentTenant, 'barobillMember' => $barobillMember, ]); } /** * 회원사 서버 모드에 따라 SOAP 설정 적용 */ private function applyMemberServerMode(BarobillMember $member): void { $memberTestMode = $member->isTestMode(); $targetEnv = $memberTestMode ? 'test' : 'production'; // 해당 환경의 BarobillConfig 조회 (is_active 무관하게 환경에 맞는 설정 사용) $config = BarobillConfig::where('environment', $targetEnv)->first(); if ($config) { $this->isTestMode = $memberTestMode; $this->certKey = $config->cert_key; $this->corpNum = $config->corp_num; $baseUrl = $config->base_url ?: ($memberTestMode ? 'https://testws.baroservice.com' : 'https://ws.baroservice.com'); $this->soapUrl = $baseUrl.'/CARD.asmx?WSDL'; // SOAP 클라이언트 재초기화 $this->initSoapClient(); Log::info('[Ecard] 서버 모드 적용', [ 'targetEnv' => $targetEnv, 'certKey' => substr($this->certKey ?? '', 0, 10).'...', 'corpNum' => $this->corpNum, 'soapUrl' => $this->soapUrl, ]); } else { Log::warning('[Ecard] BarobillConfig 없음', ['targetEnv' => $targetEnv]); } } /** * 등록된 카드 목록 조회 (GetCardEx2) * 레퍼런스: https://dev.barobill.co.kr/docs/references/카드조회-API#GetCardEx2 */ public function cards(Request $request): JsonResponse { try { // 테넌트별 서버 모드 적용 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); if ($barobillMember && $barobillMember->server_mode) { $this->applyMemberServerMode($barobillMember); } $availOnly = $request->input('availOnly', 0); $result = $this->callSoap('GetCardEx2', [ 'AvailOnly' => (int) $availOnly, ]); if (! $result['success']) { return response()->json([ 'success' => false, 'error' => $result['error'], 'error_code' => $result['error_code'] ?? null, ]); } $cards = []; $data = $result['data']; // GetCardEx2는 CardEx 배열을 반환 $cardList = []; if (isset($data->CardEx)) { $cardList = is_array($data->CardEx) ? $data->CardEx : [$data->CardEx]; } foreach ($cardList as $card) { if (! is_object($card)) { continue; } $cardNum = $card->CardNum ?? ''; // 에러 체크: CardNum이 음수면 에러 코드 if (empty($cardNum) || (is_numeric($cardNum) && $cardNum < 0)) { continue; } $cardCompanyCode = $card->CardCompanyCode ?? $card->CardCompany ?? ''; $cardCompanyName = ! empty($card->CardCompanyName) ? $card->CardCompanyName : $this->getCardCompanyName($cardCompanyCode); $cards[] = [ 'cardNum' => $cardNum, 'cardCompany' => $cardCompanyCode, 'cardBrand' => $cardCompanyName, 'alias' => $card->Alias ?? '', 'cardType' => $card->CardType ?? '', 'cardTypeName' => ($card->CardType ?? '') === '2' ? '법인카드' : '개인카드', 'status' => $card->Status ?? '', 'statusName' => $this->getCardStatusName($card->Status ?? ''), 'webId' => $card->WebId ?? '', ]; } return response()->json([ 'success' => true, 'cards' => $cards, 'count' => count($cards), ]); } catch (\Throwable $e) { Log::error('카드 목록 조회 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '서버 오류: '.$e->getMessage(), ]); } } /** * 카드 상태 코드 -> 이름 변환 */ private function getCardStatusName(string $status): string { $statuses = [ '0' => '대기중', '1' => '정상', '2' => '해지', '3' => '수집오류', '4' => '일시중지', ]; return $statuses[$status] ?? $status; } /** * 카드 사용내역 조회 (GetPeriodCardApprovalLog) */ public function transactions(Request $request): JsonResponse { try { $startDate = $request->input('startDate', date('Ymd')); $endDate = $request->input('endDate', date('Ymd')); $cardNum = str_replace('-', '', $request->input('cardNum', '')); $page = (int) $request->input('page', 1); $limit = (int) $request->input('limit', 50); // 현재 테넌트의 바로빌 회원 정보 조회 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); $userId = $barobillMember?->barobill_id ?? ''; // 테넌트별 서버 모드 적용 if ($barobillMember && $barobillMember->server_mode) { $this->applyMemberServerMode($barobillMember); } // 디버그 로그 Log::info('[ECard] 조회 요청', [ 'tenantId' => $tenantId, 'userId' => $userId, 'startDate' => $startDate, 'endDate' => $endDate, 'cardNum' => $cardNum, 'page' => $page, 'limit' => $limit, ]); // DB에서 저장된 계정과목 데이터 조회 $savedData = CardTransaction::getByDateRange($tenantId, $startDate, $endDate, $cardNum ?: null); // DB에서 수동 입력 건 조회 $manualTransactions = CardTransaction::where('tenant_id', $tenantId) ->where('is_manual', true) ->whereBetween('use_date', [$startDate, $endDate]) ->when($cardNum, fn ($q) => $q->where('card_num', $cardNum)) ->orderBy('use_date', 'desc') ->orderBy('use_time', 'desc') ->get(); // 전체 카드 조회: 빈 값이면 모든 카드의 사용 내역 조회 if (empty($cardNum)) { return $this->getAllCardsTransactions($userId, $startDate, $endDate, $page, $limit, $savedData, $manualTransactions); } // 단일 카드 조회 (전체 데이터를 가져와서 통계 계산 후 로컬 페이지네이션) $params = [ 'ID' => $userId, 'CardNum' => $cardNum, 'StartDate' => $startDate, 'EndDate' => $endDate, 'CountPerPage' => 10000, 'CurrentPage' => 1, 'OrderDirection' => 2, // 2:내림차순 ]; Log::info('[ECard] GetPeriodCardApprovalLog 호출', $params); $result = $this->callSoap('GetPeriodCardApprovalLog', $params); Log::info('[ECard] GetPeriodCardApprovalLog 응답', [ 'success' => $result['success'], 'error' => $result['error'] ?? null, 'error_code' => $result['error_code'] ?? null, 'data_keys' => $result['success'] && isset($result['data']) ? (is_object($result['data']) ? get_object_vars($result['data']) : 'not_object') : null, ]); if (! $result['success']) { return response()->json([ 'success' => false, 'error' => $result['error'], 'error_code' => $result['error_code'] ?? null, ]); } $resultData = $result['data']; // 에러 코드 체크 $errorCode = $this->checkErrorCode($resultData); Log::info('[ECard] 에러 코드 체크', ['errorCode' => $errorCode]); if ($errorCode && ! in_array($errorCode, [-24005, -24001])) { return response()->json([ 'success' => false, 'error' => $this->getErrorMessage($errorCode), 'error_code' => $errorCode, ]); } // 데이터가 없는 경우 if ($errorCode && in_array($errorCode, [-24005, -24001])) { Log::info('[ECard] 데이터 없음 (에러코드로 판단)'); // API 데이터 없어도 수동 건은 표시 $manualLogs = $this->convertManualToLogs($manualTransactions); return response()->json([ 'success' => true, 'data' => [ 'logs' => $manualLogs['logs'], 'summary' => $manualLogs['summary'], 'pagination' => ['currentPage' => 1, 'maxPageNum' => 1], ], ]); } // 데이터 파싱 (저장된 계정과목 병합) $logs = $this->parseTransactionLogs($resultData, $savedData); // 숨김 키 필터링 $hiddenKeys = CardTransactionHide::getHiddenKeys($tenantId, $startDate, $endDate); if ($hiddenKeys->isNotEmpty()) { $hiddenSet = $hiddenKeys->flip(); $logs = $this->filterHiddenLogs($logs, $hiddenSet); } // 수동 입력 건 병합 $manualLogs = $this->convertManualToLogs($manualTransactions); $allLogs = array_merge($logs['logs'], $manualLogs['logs']); // 날짜/시간 기준 정렬 (최신순) usort($allLogs, fn ($a, $b) => strcmp($b['useDt'] ?? '', $a['useDt'] ?? '')); // 전체 데이터에서 통계 계산 (공제/불공제 포함) $mergedSummary = $this->mergeSummaries($logs['summary'], $manualLogs['summary']); // 로컬 페이지네이션 $totalCount = count($allLogs); $maxPageNum = (int) ceil($totalCount / $limit); $startIndex = ($page - 1) * $limit; $paginatedLogs = array_slice($allLogs, $startIndex, $limit); Log::info('[ECard] 파싱 결과', [ 'total_count' => $totalCount, 'page_count' => count($paginatedLogs), 'manual_count' => count($manualLogs['logs']), 'summary' => $mergedSummary, ]); return response()->json([ 'success' => true, 'data' => [ 'logs' => $paginatedLogs, 'pagination' => [ 'currentPage' => $page, 'countPerPage' => $limit, 'maxPageNum' => $maxPageNum, 'maxIndex' => $totalCount, ], 'summary' => $mergedSummary, ], ]); } catch (\Throwable $e) { Log::error('카드 사용내역 조회 오류: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); return response()->json([ 'success' => false, 'error' => '서버 오류: '.$e->getMessage(), ]); } } /** * 전체 카드의 사용 내역 조회 */ private function getAllCardsTransactions(string $userId, string $startDate, string $endDate, int $page, int $limit, $savedData = null, $manualTransactions = null): JsonResponse { Log::info('[ECard] 전체 카드 조회 시작', [ 'userId' => $userId, 'startDate' => $startDate, 'endDate' => $endDate, ]); // 먼저 카드 목록 조회 (GetCardEx2 사용) $cardResult = $this->callSoap('GetCardEx2', ['AvailOnly' => 0]); Log::info('[ECard] GetCardEx2 응답', [ 'success' => $cardResult['success'], 'error' => $cardResult['error'] ?? null, ]); if (! $cardResult['success']) { return response()->json([ 'success' => false, 'error' => $cardResult['error'], ]); } $cardList = []; $data = $cardResult['data']; if (isset($data->CardEx)) { $cardList = is_array($data->CardEx) ? $data->CardEx : [$data->CardEx]; } Log::info('[ECard] 카드 목록', [ 'count' => count($cardList), 'cards' => array_map(fn ($c) => [ 'CardNum' => $c->CardNum ?? 'N/A', 'Alias' => $c->Alias ?? 'N/A', 'Status' => $c->Status ?? 'N/A', ], $cardList), ]); // 숨김 키 조회 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $hiddenKeys = CardTransactionHide::getHiddenKeys($tenantId, $startDate, $endDate); $hiddenSet = $hiddenKeys->isNotEmpty() ? $hiddenKeys->flip() : collect(); $allLogs = []; $totalAmount = 0; $approvalCount = 0; $cancelCount = 0; $totalTax = 0; foreach ($cardList as $card) { if (! is_object($card)) { continue; } $cardNum = $card->CardNum ?? ''; if (empty($cardNum) || (is_numeric($cardNum) && $cardNum < 0)) { continue; } $params = [ 'ID' => $userId, 'CardNum' => $cardNum, 'StartDate' => $startDate, 'EndDate' => $endDate, 'CountPerPage' => 1000, 'CurrentPage' => 1, 'OrderDirection' => 2, ]; Log::info('[ECard] 카드별 사용내역 조회', ['cardNum' => $cardNum, 'params' => $params]); $cardResult = $this->callSoap('GetPeriodCardApprovalLog', $params); Log::info('[ECard] 카드별 응답', [ 'cardNum' => $cardNum, 'success' => $cardResult['success'], 'error' => $cardResult['error'] ?? null, 'data_type' => isset($cardResult['data']) ? gettype($cardResult['data']) : 'null', 'data_props' => isset($cardResult['data']) && is_object($cardResult['data']) ? array_keys(get_object_vars($cardResult['data'])) : null, ]); if ($cardResult['success']) { $cardData = $cardResult['data']; $errorCode = $this->checkErrorCode($cardData); Log::info('[ECard] 카드별 에러코드', [ 'cardNum' => $cardNum, 'errorCode' => $errorCode, 'CurrentPage' => $cardData->CurrentPage ?? 'N/A', 'MaxIndex' => $cardData->MaxIndex ?? 'N/A', 'hasCardLogList' => isset($cardData->CardLogList), 'hasCardApprovalLog' => isset($cardData->CardLogList) && isset($cardData->CardLogList->CardApprovalLog), ]); if (! $errorCode || in_array($errorCode, [-24005, -24001])) { $parsed = $this->parseTransactionLogs($cardData, $savedData); // 숨김 키 필터링 if ($hiddenSet->isNotEmpty()) { $parsed = $this->filterHiddenLogs($parsed, $hiddenSet); } Log::info('[ECard] 카드별 파싱 결과', [ 'cardNum' => $cardNum, 'logs_count' => count($parsed['logs']), ]); foreach ($parsed['logs'] as $log) { $log['cardBrand'] = $this->getCardCompanyName($card->CardCompany ?? ''); $allLogs[] = $log; } $totalAmount += $parsed['summary']['totalAmount']; $approvalCount += $parsed['summary']['approvalCount']; $cancelCount += $parsed['summary']['cancelCount']; $totalTax += $parsed['summary']['totalTax'] ?? 0; } } } // 수동 입력 건 병합 if ($manualTransactions && $manualTransactions->isNotEmpty()) { $manualLogs = $this->convertManualToLogs($manualTransactions); foreach ($manualLogs['logs'] as $mLog) { $allLogs[] = $mLog; } $totalAmount += $manualLogs['summary']['totalAmount']; $approvalCount += $manualLogs['summary']['approvalCount']; $cancelCount += $manualLogs['summary']['cancelCount']; } // 날짜/시간 기준 정렬 (최신순) usort($allLogs, function ($a, $b) { return strcmp($b['useDt'] ?? '', $a['useDt'] ?? ''); }); // 전체 데이터에서 공제/불공제 통계 계산 (불공제 부가세는 합계 제외) $deductibleAmount = 0; $deductibleCount = 0; $nonDeductibleAmount = 0; $nonDeductibleCount = 0; $totalTax = 0; foreach ($allLogs as $log) { $type = $log['deductionType'] ?? 'non_deductible'; $amount = abs($log['approvalAmount'] ?? 0); if ($type === 'deductible') { $deductibleAmount += $amount; $deductibleCount++; $totalTax += abs($log['effectiveTax'] ?? $log['tax'] ?? 0); } else { $nonDeductibleAmount += $amount; $nonDeductibleCount++; } } // 페이지네이션 $totalCount = count($allLogs); $maxPageNum = (int) ceil($totalCount / $limit); $startIndex = ($page - 1) * $limit; $paginatedLogs = array_slice($allLogs, $startIndex, $limit); return response()->json([ 'success' => true, 'data' => [ 'logs' => $paginatedLogs, 'pagination' => [ 'currentPage' => $page, 'countPerPage' => $limit, 'maxPageNum' => $maxPageNum, 'maxIndex' => $totalCount, ], 'summary' => [ 'totalAmount' => $totalAmount, 'count' => $totalCount, 'approvalCount' => $approvalCount, 'cancelCount' => $cancelCount, 'totalTax' => $totalTax, 'deductibleAmount' => $deductibleAmount, 'deductibleCount' => $deductibleCount, 'nonDeductibleAmount' => $nonDeductibleAmount, 'nonDeductibleCount' => $nonDeductibleCount, ], ], ]); } /** * 거래 내역 파싱 (저장된 계정과목 병합) */ private function parseTransactionLogs($resultData, $savedData = null): array { $logs = []; $totalAmount = 0; $approvalCount = 0; $cancelCount = 0; $totalTax = 0; $deductibleAmount = 0; $deductibleCount = 0; $nonDeductibleAmount = 0; $nonDeductibleCount = 0; $rawLogs = []; if (isset($resultData->CardLogList) && isset($resultData->CardLogList->CardApprovalLog)) { $rawLogs = is_array($resultData->CardLogList->CardApprovalLog) ? $resultData->CardLogList->CardApprovalLog : [$resultData->CardLogList->CardApprovalLog]; } foreach ($rawLogs as $log) { $amount = floatval($log->ApprovalAmount ?? 0); $rawApprovalType = $log->ApprovalType ?? null; $approvalType = (string) ($rawApprovalType ?? '1'); // ApprovalType: 1=승인 or "승인", 2=취소 or "취소" // API에서 한글 텍스트로 반환될 수 있으므로 취소가 아닌 경우 모두 승인으로 처리 $isApproval = ! in_array($approvalType, ['2', '취소'], true); // 디버깅: ApprovalType 값 확인 (첫 번째 로그만) if (count($logs) === 0) { Log::info('[ECard] 첫 번째 로그 전체 데이터', [ 'log_object' => json_encode($log), 'ApprovalType_raw' => $rawApprovalType, 'ApprovalType_type' => gettype($rawApprovalType), 'ApprovalType_casted' => $approvalType, 'isApproval' => $isApproval, 'amount' => $amount, ]); } if ($isApproval) { $totalAmount += $amount; $approvalCount++; } else { $cancelCount++; } // 사용일시 파싱 $useDT = $log->UseDT ?? ''; $useDate = ''; $useTime = ''; $dateTime = ''; if (! empty($useDT) && strlen($useDT) >= 8) { $useDate = substr($useDT, 0, 8); if (strlen($useDT) >= 14) { $useTime = substr($useDT, 8, 6); $dateTime = substr($useDT, 0, 4).'-'.substr($useDT, 4, 2).'-'.substr($useDT, 6, 2).' '. substr($useDT, 8, 2).':'.substr($useDT, 10, 2).':'.substr($useDT, 12, 2); } else { $dateTime = substr($useDT, 0, 4).'-'.substr($useDT, 4, 2).'-'.substr($useDT, 6, 2); } } $cardNum = $log->CardNum ?? ''; $approvalNum = $log->ApprovalNum ?? ''; // 고유 키 생성하여 저장된 데이터와 매칭 $uniqueKey = implode('|', [$cardNum, $useDT, $approvalNum, (int) $amount]); $savedItem = $savedData?->get($uniqueKey); $logItem = [ 'cardNum' => $cardNum, 'cardNumMasked' => $this->maskCardNumber($cardNum), 'cardCompany' => $log->CardCompany ?? '', 'cardBrand' => $this->getCardCompanyName($log->CardCompany ?? ''), 'useDt' => $useDT, 'useDate' => $useDate, 'useTime' => $useTime, 'useDateTime' => $dateTime, 'approvalNum' => $approvalNum, 'approvalType' => $isApproval ? '1' : '2', 'approvalTypeName' => $isApproval ? '승인' : '취소', 'approvalAmount' => $amount, 'approvalAmountFormatted' => number_format($amount), 'tax' => floatval($log->Tax ?? 0), 'taxFormatted' => number_format(floatval($log->Tax ?? 0)), 'serviceCharge' => floatval($log->ServiceCharge ?? 0), 'paymentPlan' => $log->PaymentPlan ?? '0', 'paymentPlanName' => $this->getPaymentPlanName($log->PaymentPlan ?? '0'), 'currencyCode' => $log->CurrencyCode ?? 'KRW', 'merchantName' => $log->UseStoreName ?? '', 'merchantBizNum' => $log->UseStoreCorpNum ?? '', 'merchantAddr' => $log->UseStoreAddr ?? '', 'merchantCeo' => $log->UseStoreCeo ?? '', 'merchantBizType' => $log->UseStoreBizType ?? '', 'merchantTel' => $log->UseStoreTel ?? '', 'memo' => $log->Memo ?? '', 'useKey' => $log->UseKey ?? '', // 저장된 계정과목 정보 병합 'accountCode' => $savedItem?->account_code ?? '', 'accountName' => $savedItem?->account_name ?? '', // 수정 가능한 필드들 (저장된 값 또는 기본값) 'deductionType' => $savedItem?->deduction_type ?? ($log->UseStoreCorpNum ? 'deductible' : 'non_deductible'), 'evidenceName' => $savedItem?->evidence_name ?? ($log->UseStoreName ?? ''), 'description' => $savedItem?->description ?? ($log->UseStoreBizType ?? $log->Memo ?? ''), 'isSaved' => $savedItem !== null, // 금액 수정 관련 필드 'modifiedSupplyAmount' => $savedItem?->modified_supply_amount !== null ? (float) $savedItem->modified_supply_amount : null, 'modifiedTax' => $savedItem?->modified_tax !== null ? (float) $savedItem->modified_tax : null, 'effectiveSupplyAmount' => $savedItem?->modified_supply_amount !== null ? (float) $savedItem->modified_supply_amount : ($amount - floatval($log->Tax ?? 0)), 'effectiveTax' => $savedItem?->modified_tax !== null ? (float) $savedItem->modified_tax : floatval($log->Tax ?? 0), 'isAmountModified' => $savedItem?->modified_supply_amount !== null || $savedItem?->modified_tax !== null, 'isManual' => (bool) ($savedItem?->is_manual ?? false), 'dbId' => $savedItem?->id ?? null, ]; // 공제/불공제 통계 집계 $deductionType = $logItem['deductionType']; $absAmount = abs($amount); if ($deductionType === 'deductible') { $deductibleAmount += $absAmount; $deductibleCount++; $totalTax += abs($logItem['effectiveTax']); } else { $nonDeductibleAmount += $absAmount; $nonDeductibleCount++; } $logs[] = $logItem; } return [ 'logs' => $logs, 'summary' => [ 'totalAmount' => $totalAmount, 'count' => count($logs), 'approvalCount' => $approvalCount, 'cancelCount' => $cancelCount, 'totalTax' => $totalTax, 'deductibleAmount' => $deductibleAmount, 'deductibleCount' => $deductibleCount, 'nonDeductibleAmount' => $nonDeductibleAmount, 'nonDeductibleCount' => $nonDeductibleCount, ], ]; } /** * 에러 코드 체크 */ private function checkErrorCode($data): ?int { if (isset($data->CurrentPage) && is_numeric($data->CurrentPage) && $data->CurrentPage < 0) { return (int) $data->CurrentPage; } if (isset($data->CardNum) && is_numeric($data->CardNum) && $data->CardNum < 0) { return (int) $data->CardNum; } return null; } /** * 에러 메시지 반환 */ private function getErrorMessage(int $errorCode): string { $messages = [ -10002 => '인증 실패 (-10002). CERTKEY가 올바르지 않거나 만료되었습니다.', -24005 => '사용자 정보 불일치 (-24005). 사업자번호를 확인해주세요.', -24001 => '등록된 카드가 없습니다 (-24001).', -24005 => '조회된 데이터가 없습니다 (-24005).', -25006 => '카드번호가 잘못되었습니다 (-25006).', -25007 => '조회 기간이 잘못되었습니다 (-25007).', ]; return $messages[$errorCode] ?? '바로빌 API 오류: '.$errorCode; } /** * 카드번호 마스킹 */ private function maskCardNumber(string $cardNum): string { if (strlen($cardNum) < 8) { return $cardNum; } return substr($cardNum, 0, 4).'-****-****-'.substr($cardNum, -4); } /** * 카드사 코드 -> 카드사명 변환 */ private function getCardCompanyName(string $code): string { $companies = [ '01' => '비씨', '02' => 'KB국민', '03' => '하나(외환)', '04' => '삼성', '06' => '신한', '07' => '현대', '08' => '롯데', '11' => 'NH농협', '12' => '수협', '13' => '씨티', '14' => '우리', '15' => '광주', '16' => '전북', '21' => '하나', '22' => '제주', '23' => 'SC제일', '25' => 'KDB산업', '26' => 'IBK기업', '27' => '새마을금고', '28' => '신협', '29' => '저축은행', '30' => '우체국', '31' => '카카오뱅크', '32' => 'K뱅크', '33' => '토스뱅크', ]; return $companies[$code] ?? $code; } /** * 할부 이름 반환 */ private function getPaymentPlanName(string $plan): string { if (empty($plan) || $plan === '0' || $plan === '00') { return '일시불'; } return $plan.'개월'; } /** * 계정과목 목록 조회 */ public function accountCodes(): JsonResponse { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $codes = AccountCode::getActiveByTenant($tenantId); return response()->json([ 'success' => true, 'data' => $codes->map(fn ($c) => [ 'id' => $c->id, 'code' => $c->code, 'name' => $c->name, 'category' => $c->category, ]), ]); } /** * 카드 사용내역 저장 (계정과목 포함) */ public function save(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $transactions = $request->input('transactions', []); if (empty($transactions)) { return response()->json([ 'success' => false, 'error' => '저장할 데이터가 없습니다.', ]); } $saved = 0; $updated = 0; DB::beginTransaction(); foreach ($transactions as $trans) { $data = [ 'tenant_id' => $tenantId, 'card_num' => $trans['cardNum'] ?? '', 'card_company' => $trans['cardCompany'] ?? '', 'card_company_name' => $trans['cardBrand'] ?? '', 'use_dt' => $trans['useDt'] ?? '', 'use_date' => $trans['useDate'] ?? '', 'use_time' => $trans['useTime'] ?? '', 'approval_num' => $trans['approvalNum'] ?? '', 'approval_type' => $trans['approvalType'] ?? '', 'approval_amount' => floatval($trans['approvalAmount'] ?? 0), 'tax' => floatval($trans['tax'] ?? 0), 'service_charge' => floatval($trans['serviceCharge'] ?? 0), 'payment_plan' => $trans['paymentPlan'] ?? '', 'currency_code' => $trans['currencyCode'] ?? 'KRW', 'merchant_name' => $trans['merchantName'] ?? '', 'merchant_biz_num' => $trans['merchantBizNum'] ?? '', 'merchant_addr' => $trans['merchantAddr'] ?? '', 'merchant_ceo' => $trans['merchantCeo'] ?? '', 'merchant_biz_type' => $trans['merchantBizType'] ?? '', 'merchant_tel' => $trans['merchantTel'] ?? '', 'memo' => $trans['memo'] ?? '', 'use_key' => $trans['useKey'] ?? '', 'account_code' => $trans['accountCode'] ?? null, 'account_name' => $trans['accountName'] ?? null, 'deduction_type' => $trans['deductionType'] ?? null, 'evidence_name' => $trans['evidenceName'] ?? null, 'description' => $trans['description'] ?? null, ]; // Upsert: 있으면 업데이트, 없으면 생성 $existing = CardTransaction::where('tenant_id', $tenantId) ->where('card_num', $data['card_num']) ->where('use_dt', $data['use_dt']) ->where('approval_num', $data['approval_num']) ->where('approval_amount', $data['approval_amount']) ->first(); // 수정 금액 처리 $modifiedSupplyAmount = isset($trans['modifiedSupplyAmount']) && $trans['modifiedSupplyAmount'] !== null ? floatval($trans['modifiedSupplyAmount']) : null; $modifiedTax = isset($trans['modifiedTax']) && $trans['modifiedTax'] !== null ? floatval($trans['modifiedTax']) : null; $data['modified_supply_amount'] = $modifiedSupplyAmount; $data['modified_tax'] = $modifiedTax; if ($existing) { // 금액 변경 감지 및 이력 기록 $oldSupply = $existing->modified_supply_amount !== null ? (float) $existing->modified_supply_amount : ((float) $existing->approval_amount - (float) $existing->tax); $oldTax = $existing->modified_tax !== null ? (float) $existing->modified_tax : (float) $existing->tax; $newSupply = $modifiedSupplyAmount !== null ? $modifiedSupplyAmount : ((float) $existing->approval_amount - (float) $existing->tax); $newTax = $modifiedTax !== null ? $modifiedTax : (float) $existing->tax; $amountChanged = abs($oldSupply - $newSupply) > 0.01 || abs($oldTax - $newTax) > 0.01; if ($amountChanged) { $uniqueKey = implode('|', [ $existing->card_num, $existing->use_dt, $existing->approval_num, (int) $existing->approval_amount, ]); CardTransactionAmountLog::create([ 'card_transaction_id' => $existing->id, 'original_unique_key' => $uniqueKey, 'before_supply_amount' => $oldSupply, 'before_tax' => $oldTax, 'after_supply_amount' => $newSupply, 'after_tax' => $newTax, 'modified_by' => auth()->id(), 'modified_by_name' => auth()->user()?->name ?? '', 'ip_address' => $request->ip(), ]); } // 계정과목 및 수정 가능한 필드들 업데이트 $existing->update([ 'account_code' => $data['account_code'], 'account_name' => $data['account_name'], 'deduction_type' => $data['deduction_type'], 'evidence_name' => $data['evidence_name'], 'description' => $data['description'], 'modified_supply_amount' => $data['modified_supply_amount'], 'modified_tax' => $data['modified_tax'], ]); // 금액 변경 시 기존 분개 자료의 차변/대변 금액도 자동 갱신 if ($amountChanged) { $this->syncJournalAmounts($tenantId, $uniqueKey, $newSupply, $newTax, $data['deduction_type']); } $updated++; } else { CardTransaction::create($data); $saved++; } } DB::commit(); return response()->json([ 'success' => true, 'message' => "저장 완료: 신규 {$saved}건, 수정 {$updated}건", 'saved' => $saved, 'updated' => $updated, ]); } catch (\Throwable $e) { DB::rollBack(); Log::error('카드 사용내역 저장 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '저장 오류: '.$e->getMessage(), ]); } } /** * 엑셀 다운로드 */ public function exportExcel(Request $request): StreamedResponse|JsonResponse { try { $startDate = $request->input('startDate', date('Ymd')); $endDate = $request->input('endDate', date('Ymd')); $logs = $request->input('logs', []); $splitsData = $request->input('splits', []); // 데이터가 없으면 안내 if (empty($logs)) { return response()->json([ 'success' => false, 'error' => '내보낼 데이터가 없습니다.', ]); } $filename = "카드사용내역_{$startDate}_{$endDate}.csv"; return response()->streamDownload(function () use ($logs, $splitsData) { $handle = fopen('php://output', 'w'); // UTF-8 BOM for Excel fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF)); // 헤더 fputcsv($handle, [ '구분', '사용일시', '카드번호', '카드사', '공제여부', '사업자번호', '가맹점명', '증빙/판매자상호', '내역', '합계금액', '공급가액', '세액', '승인번호', '계정과목코드', '계정과목명', '메모', ]); // 데이터 foreach ($logs as $log) { $dateTime = $log['useDateTime'] ?? ''; $cardNum = $log['cardNum'] ?? ''; $cardBrand = $log['cardBrand'] ?? ''; $approvalNum = $log['approvalNum'] ?? ''; $approvalAmount = floatval($log['approvalAmount'] ?? 0); $tax = floatval($log['effectiveTax'] ?? $log['tax'] ?? 0); $supplyAmount = floatval($log['effectiveSupplyAmount'] ?? ($approvalAmount - $tax)); $totalAmount = $supplyAmount + $tax; // 카드번호를 문자형으로 강제 (엑셀 과학적 표기 방지) $cardNumText = $cardNum ? "=\"{$cardNum}\"" : ''; // 고유키로 분리 데이터 확인 $uniqueKey = $log['uniqueKey'] ?? implode('|', [ $cardNum, $log['useDt'] ?? '', $approvalNum, (int) $approvalAmount, ]); $splits = $splitsData[$uniqueKey] ?? []; $hasSplits = count($splits) > 0; // 공제여부 $deductionType = $log['deductionType'] ?? ($log['merchantBizNum'] ? 'deductible' : 'non_deductible'); $deductionText = ($deductionType === 'non_deductible') ? '불공' : '공제'; // 사업자번호, 가맹점명 $merchantBizNum = $log['merchantBizNum'] ?? ''; $merchantName = $log['merchantName'] ?? ''; // 증빙/판매자상호, 내역 $evidenceName = $log['evidenceName'] ?? $merchantName; $description = $log['description'] ?? $log['merchantBizType'] ?? ''; if ($hasSplits) { // 분리가 있는 경우: 원본 행 (합계 표시) fputcsv($handle, [ '원본', $dateTime, $cardNumText, $cardBrand, '-', $merchantBizNum, $merchantName, $evidenceName, $description, number_format($totalAmount), number_format($supplyAmount), number_format($tax), $approvalNum, '-', '분리됨 ('.count($splits).'건)', '', ]); // 각 분리 행 출력 foreach ($splits as $index => $split) { $splitDeductionType = $split['deduction_type'] ?? $split['deductionType'] ?? 'deductible'; $splitDeductionText = ($splitDeductionType === 'non_deductible') ? '불공' : '공제'; $splitSupplyAmount = floatval($split['split_supply_amount'] ?? $split['supplyAmount'] ?? 0); $splitTax = floatval($split['split_tax'] ?? $split['tax'] ?? 0); $splitTotal = $splitSupplyAmount + $splitTax; $splitEvidenceName = $split['evidence_name'] ?? $split['evidenceName'] ?? ''; $splitDescription = $split['description'] ?? ''; $splitAccountCode = $split['account_code'] ?? $split['accountCode'] ?? ''; $splitAccountName = $split['account_name'] ?? $split['accountName'] ?? ''; $splitMemo = $split['memo'] ?? ''; fputcsv($handle, [ '└ 분리 #'.($index + 1), '', '', '', $splitDeductionText, '', '', $splitEvidenceName, $splitDescription, number_format($splitTotal), number_format($splitSupplyAmount), number_format($splitTax), '', $splitAccountCode, $splitAccountName, $splitMemo, ]); } } else { // 분리가 없는 경우: 일반 행 fputcsv($handle, [ '일반', $dateTime, $cardNumText, $cardBrand, $deductionText, $merchantBizNum, $merchantName, $evidenceName, $description, number_format($totalAmount), number_format($supplyAmount), number_format($tax), $approvalNum, $log['accountCode'] ?? '', $log['accountName'] ?? '', '', ]); } } fclose($handle); }, $filename, [ 'Content-Type' => 'text/csv; charset=utf-8', 'Content-Disposition' => 'attachment; filename="'.$filename.'"', ]); } catch (\Throwable $e) { Log::error('엑셀 다운로드 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '다운로드 오류: '.$e->getMessage(), ]); } } /** * 분리 내역 조회 */ public function splits(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $startDate = $request->input('startDate', date('Ymd')); $endDate = $request->input('endDate', date('Ymd')); $splits = CardTransactionSplit::getByDateRange($tenantId, $startDate, $endDate); return response()->json([ 'success' => true, 'data' => $splits, ]); } catch (\Throwable $e) { Log::error('분리 내역 조회 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '조회 오류: '.$e->getMessage(), ]); } } /** * 분리 저장 */ public function saveSplits(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $uniqueKey = $request->input('uniqueKey'); $originalData = $request->input('originalData', []); $splits = $request->input('splits', []); if (empty($uniqueKey)) { return response()->json([ 'success' => false, 'error' => '고유키가 없습니다.', ]); } // 분리 금액 합계 검증 (공급가액 + 부가세 합계) $originalAmount = floatval($originalData['originalAmount'] ?? 0); $splitTotal = array_sum(array_map(function ($s) { if (isset($s['supplyAmount']) && isset($s['tax'])) { return floatval($s['supplyAmount']) + floatval($s['tax']); } return floatval($s['amount'] ?? 0); }, $splits)); if (abs($originalAmount - $splitTotal) > 0.01) { return response()->json([ 'success' => false, 'error' => "분리 금액 합계({$splitTotal})가 원본 금액({$originalAmount})과 일치하지 않습니다.", ]); } DB::beginTransaction(); CardTransactionSplit::saveSplits($tenantId, $uniqueKey, $originalData, $splits); DB::commit(); return response()->json([ 'success' => true, 'message' => '분리가 저장되었습니다.', 'splitCount' => count($splits), ]); } catch (\Throwable $e) { DB::rollBack(); Log::error('분리 저장 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '저장 오류: '.$e->getMessage(), ]); } } /** * 분리 삭제 (원본으로 복원) */ public function deleteSplits(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $uniqueKey = $request->input('uniqueKey'); if (empty($uniqueKey)) { return response()->json([ 'success' => false, 'error' => '고유키가 없습니다.', ]); } $deleted = CardTransactionSplit::deleteSplits($tenantId, $uniqueKey); return response()->json([ 'success' => true, 'message' => '분리가 삭제되었습니다.', 'deleted' => $deleted, ]); } catch (\Throwable $e) { Log::error('분리 삭제 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '삭제 오류: '.$e->getMessage(), ]); } } /** * 수동 거래 등록 */ public function storeManual(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $validated = $request->validate([ 'card_num' => 'required|string', 'card_company' => 'required|string', 'use_date' => 'required|date_format:Ymd', 'use_time' => 'nullable|string|max:6', 'approval_num' => 'nullable|string', 'approval_type' => 'required|in:1,2', 'approval_amount' => 'required|numeric', 'tax' => 'required|numeric', 'merchant_name' => 'nullable|string', 'merchant_biz_num' => 'nullable|string', 'deduction_type' => 'required|in:deductible,non_deductible', 'account_code' => 'nullable|string', 'account_name' => 'nullable|string', 'evidence_name' => 'nullable|string', 'description' => 'nullable|string', 'memo' => 'nullable|string', ]); $useTime = $validated['use_time'] ?? '000000'; $useDt = $validated['use_date'].$useTime; $transaction = CardTransaction::create([ 'tenant_id' => $tenantId, 'card_num' => $validated['card_num'], 'card_company' => $validated['card_company'], 'card_company_name' => $this->getCardCompanyName($validated['card_company']), 'use_dt' => $useDt, 'use_date' => $validated['use_date'], 'use_time' => $useTime, 'approval_num' => $validated['approval_num'] ?? '', 'approval_type' => $validated['approval_type'], 'approval_amount' => $validated['approval_amount'], 'tax' => $validated['tax'], 'service_charge' => 0, 'payment_plan' => '0', 'currency_code' => 'KRW', 'merchant_name' => $validated['merchant_name'] ?? '', 'merchant_biz_num' => $validated['merchant_biz_num'] ?? '', 'deduction_type' => $validated['deduction_type'], 'account_code' => $validated['account_code'] ?? null, 'account_name' => $validated['account_name'] ?? null, 'evidence_name' => $validated['evidence_name'] ?? '', 'description' => $validated['description'] ?? '', 'memo' => $validated['memo'] ?? '', 'use_key' => '', 'is_manual' => true, ]); return response()->json([ 'success' => true, 'message' => '수동 거래가 등록되었습니다.', 'data' => ['id' => $transaction->id], ]); } catch (\Illuminate\Validation\ValidationException $e) { return response()->json([ 'success' => false, 'error' => '입력 데이터가 올바르지 않습니다.', 'errors' => $e->errors(), ], 422); } catch (\Throwable $e) { Log::error('수동 거래 등록 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '등록 오류: '.$e->getMessage(), ]); } } /** * 수동 거래 수정 */ public function updateManual(Request $request, int $id): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $transaction = CardTransaction::where('id', $id) ->where('tenant_id', $tenantId) ->where('is_manual', true) ->firstOrFail(); $validated = $request->validate([ 'card_num' => 'required|string', 'card_company' => 'required|string', 'use_date' => 'required|date_format:Ymd', 'use_time' => 'nullable|string|max:6', 'approval_num' => 'nullable|string', 'approval_type' => 'required|in:1,2', 'approval_amount' => 'required|numeric', 'tax' => 'required|numeric', 'merchant_name' => 'nullable|string', 'merchant_biz_num' => 'nullable|string', 'deduction_type' => 'required|in:deductible,non_deductible', 'account_code' => 'nullable|string', 'account_name' => 'nullable|string', 'evidence_name' => 'nullable|string', 'description' => 'nullable|string', 'memo' => 'nullable|string', ]); $useTime = $validated['use_time'] ?? '000000'; $useDt = $validated['use_date'].$useTime; $transaction->update([ 'card_num' => $validated['card_num'], 'card_company' => $validated['card_company'], 'card_company_name' => $this->getCardCompanyName($validated['card_company']), 'use_dt' => $useDt, 'use_date' => $validated['use_date'], 'use_time' => $useTime, 'approval_num' => $validated['approval_num'] ?? '', 'approval_type' => $validated['approval_type'], 'approval_amount' => $validated['approval_amount'], 'tax' => $validated['tax'], 'merchant_name' => $validated['merchant_name'] ?? '', 'merchant_biz_num' => $validated['merchant_biz_num'] ?? '', 'deduction_type' => $validated['deduction_type'], 'account_code' => $validated['account_code'] ?? null, 'account_name' => $validated['account_name'] ?? null, 'evidence_name' => $validated['evidence_name'] ?? '', 'description' => $validated['description'] ?? '', 'memo' => $validated['memo'] ?? '', ]); return response()->json([ 'success' => true, 'message' => '수동 거래가 수정되었습니다.', ]); } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { return response()->json([ 'success' => false, 'error' => '해당 거래를 찾을 수 없거나 수동 입력 건이 아닙니다.', ], 404); } catch (\Illuminate\Validation\ValidationException $e) { return response()->json([ 'success' => false, 'error' => '입력 데이터가 올바르지 않습니다.', 'errors' => $e->errors(), ], 422); } catch (\Throwable $e) { Log::error('수동 거래 수정 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '수정 오류: '.$e->getMessage(), ]); } } /** * 수동 거래 삭제 */ public function destroyManual(int $id): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $transaction = CardTransaction::where('id', $id) ->where('tenant_id', $tenantId) ->where('is_manual', true) ->firstOrFail(); $transaction->delete(); return response()->json([ 'success' => true, 'message' => '수동 거래가 삭제되었습니다.', ]); } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { return response()->json([ 'success' => false, 'error' => '해당 거래를 찾을 수 없거나 수동 입력 건이 아닙니다.', ], 404); } catch (\Throwable $e) { Log::error('수동 거래 삭제 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '삭제 오류: '.$e->getMessage(), ]); } } /** * 수동 입력 건을 로그 배열로 변환 */ private function convertManualToLogs($manualTransactions): array { $logs = []; $totalAmount = 0; $approvalCount = 0; $cancelCount = 0; $totalTax = 0; $deductibleAmount = 0; $deductibleCount = 0; $nonDeductibleAmount = 0; $nonDeductibleCount = 0; if (! $manualTransactions || $manualTransactions->isEmpty()) { return [ 'logs' => [], 'summary' => [ 'totalAmount' => 0, 'count' => 0, 'approvalCount' => 0, 'cancelCount' => 0, 'totalTax' => 0, 'deductibleAmount' => 0, 'deductibleCount' => 0, 'nonDeductibleAmount' => 0, 'nonDeductibleCount' => 0, ], ]; } foreach ($manualTransactions as $t) { // 수동입력: approval_amount = 공급가액, tax = 부가세 $supplyAmount = (float) $t->approval_amount; $tax = (float) $t->tax; $totalApproval = $supplyAmount + $tax; // 합계금액 = 공급가액 + 부가세 $isApproval = $t->approval_type !== '2'; $useDt = $t->use_dt; if ($isApproval) { $totalAmount += $totalApproval; $approvalCount++; } else { $cancelCount++; } $dateTime = ''; if (! empty($useDt) && strlen($useDt) >= 8) { $dateTime = substr($useDt, 0, 4).'-'.substr($useDt, 4, 2).'-'.substr($useDt, 6, 2); if (strlen($useDt) >= 14) { $dateTime .= ' '.substr($useDt, 8, 2).':'.substr($useDt, 10, 2).':'.substr($useDt, 12, 2); } } $deductionType = $t->deduction_type ?? 'non_deductible'; $absAmount = abs($totalApproval); if ($deductionType === 'deductible') { $deductibleAmount += $absAmount; $deductibleCount++; $totalTax += abs($tax); } else { $nonDeductibleAmount += $absAmount; $nonDeductibleCount++; } $logs[] = [ 'cardNum' => $t->card_num, 'cardNumMasked' => $this->maskCardNumber($t->card_num), 'cardCompany' => $t->card_company, 'cardBrand' => $t->card_company_name ?: $this->getCardCompanyName($t->card_company), 'useDt' => $useDt, 'useDate' => $t->use_date, 'useTime' => $t->use_time, 'useDateTime' => $dateTime, 'approvalNum' => $t->approval_num ?? '', 'approvalType' => $isApproval ? '1' : '2', 'approvalTypeName' => $isApproval ? '승인' : '취소', 'approvalAmount' => $totalApproval, 'approvalAmountFormatted' => number_format($totalApproval), 'tax' => $tax, 'taxFormatted' => number_format($tax), 'serviceCharge' => 0, 'paymentPlan' => '0', 'paymentPlanName' => '일시불', 'currencyCode' => 'KRW', 'merchantName' => $t->merchant_name ?? '', 'merchantBizNum' => $t->merchant_biz_num ?? '', 'merchantAddr' => $t->merchant_addr ?? '', 'merchantCeo' => $t->merchant_ceo ?? '', 'merchantBizType' => $t->merchant_biz_type ?? '', 'merchantTel' => $t->merchant_tel ?? '', 'memo' => $t->memo ?? '', 'useKey' => $t->use_key ?? '', 'accountCode' => $t->account_code ?? '', 'accountName' => $t->account_name ?? '', 'deductionType' => $deductionType, 'evidenceName' => $t->evidence_name ?? '', 'description' => $t->description ?? '', 'isSaved' => true, 'modifiedSupplyAmount' => null, 'modifiedTax' => null, 'effectiveSupplyAmount' => $supplyAmount, 'effectiveTax' => $tax, 'isAmountModified' => false, 'isManual' => true, 'dbId' => $t->id, ]; } return [ 'logs' => $logs, 'summary' => [ 'totalAmount' => $totalAmount, 'count' => count($logs), 'approvalCount' => $approvalCount, 'cancelCount' => $cancelCount, 'totalTax' => $totalTax, 'deductibleAmount' => $deductibleAmount, 'deductibleCount' => $deductibleCount, 'nonDeductibleAmount' => $nonDeductibleAmount, 'nonDeductibleCount' => $nonDeductibleCount, ], ]; } /** * 두 summary 배열 합산 */ private function mergeSummaries(array $a, array $b): array { return [ 'totalAmount' => ($a['totalAmount'] ?? 0) + ($b['totalAmount'] ?? 0), 'count' => ($a['count'] ?? 0) + ($b['count'] ?? 0), 'approvalCount' => ($a['approvalCount'] ?? 0) + ($b['approvalCount'] ?? 0), 'cancelCount' => ($a['cancelCount'] ?? 0) + ($b['cancelCount'] ?? 0), 'totalTax' => ($a['totalTax'] ?? 0) + ($b['totalTax'] ?? 0), 'deductibleAmount' => ($a['deductibleAmount'] ?? 0) + ($b['deductibleAmount'] ?? 0), 'deductibleCount' => ($a['deductibleCount'] ?? 0) + ($b['deductibleCount'] ?? 0), 'nonDeductibleAmount' => ($a['nonDeductibleAmount'] ?? 0) + ($b['nonDeductibleAmount'] ?? 0), 'nonDeductibleCount' => ($a['nonDeductibleCount'] ?? 0) + ($b['nonDeductibleCount'] ?? 0), ]; } /** * 숨김 키로 로그 필터링 */ private function filterHiddenLogs(array $parsed, $hiddenSet): array { $filteredLogs = []; $totalAmount = 0; $approvalCount = 0; $cancelCount = 0; $totalTax = 0; $deductibleAmount = 0; $deductibleCount = 0; $nonDeductibleAmount = 0; $nonDeductibleCount = 0; foreach ($parsed['logs'] as $log) { $uniqueKey = implode('|', [ $log['cardNum'], $log['useDt'], $log['approvalNum'], (int) $log['approvalAmount'], ]); if ($hiddenSet->has($uniqueKey)) { continue; } $filteredLogs[] = $log; $amount = floatval($log['approvalAmount']); $isApproval = $log['approvalType'] !== '2'; if ($isApproval) { $totalAmount += $amount; $approvalCount++; } else { $cancelCount++; } $deductionType = $log['deductionType'] ?? 'non_deductible'; $absAmount = abs($amount); if ($deductionType === 'deductible') { $deductibleAmount += $absAmount; $deductibleCount++; $totalTax += abs($log['effectiveTax'] ?? $log['tax'] ?? 0); } else { $nonDeductibleAmount += $absAmount; $nonDeductibleCount++; } } return [ 'logs' => $filteredLogs, 'summary' => [ 'totalAmount' => $totalAmount, 'count' => count($filteredLogs), 'approvalCount' => $approvalCount, 'cancelCount' => $cancelCount, 'totalTax' => $totalTax, 'deductibleAmount' => $deductibleAmount, 'deductibleCount' => $deductibleCount, 'nonDeductibleAmount' => $nonDeductibleAmount, 'nonDeductibleCount' => $nonDeductibleCount, ], ]; } /** * 거래 숨김 처리 */ public function hideTransaction(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $uniqueKey = $request->input('uniqueKey'); $originalData = $request->input('originalData', []); if (empty($uniqueKey)) { return response()->json([ 'success' => false, 'error' => '고유키가 없습니다.', ]); } // 이미 숨김 처리된 건인지 확인 $exists = CardTransactionHide::where('tenant_id', $tenantId) ->where('original_unique_key', $uniqueKey) ->exists(); if ($exists) { return response()->json([ 'success' => false, 'error' => '이미 숨김 처리된 거래입니다.', ]); } CardTransactionHide::hideTransaction($tenantId, $uniqueKey, $originalData, auth()->id()); return response()->json([ 'success' => true, 'message' => '거래가 숨김 처리되었습니다.', ]); } catch (\Throwable $e) { Log::error('거래 숨김 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '숨김 처리 오류: '.$e->getMessage(), ]); } } /** * 거래 복원 (숨김 해제) */ public function restoreTransaction(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $uniqueKey = $request->input('uniqueKey'); if (empty($uniqueKey)) { return response()->json([ 'success' => false, 'error' => '고유키가 없습니다.', ]); } $deleted = CardTransactionHide::restoreTransaction($tenantId, $uniqueKey); if ($deleted === 0) { return response()->json([ 'success' => false, 'error' => '숨김 데이터를 찾을 수 없습니다.', ]); } return response()->json([ 'success' => true, 'message' => '거래가 복원되었습니다.', ]); } catch (\Throwable $e) { Log::error('거래 복원 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '복원 오류: '.$e->getMessage(), ]); } } /** * 숨김 처리된 거래 목록 조회 */ public function hiddenTransactions(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $startDate = $request->input('startDate', date('Ymd')); $endDate = $request->input('endDate', date('Ymd')); $hidden = CardTransactionHide::where('tenant_id', $tenantId) ->where('use_date', '>=', $startDate) ->where('use_date', '<=', $endDate) ->orderBy('use_date', 'desc') ->get() ->map(fn ($h) => [ 'id' => $h->id, 'uniqueKey' => $h->original_unique_key, 'cardNum' => $h->card_num, 'useDate' => $h->use_date, 'approvalNum' => $h->approval_num, 'originalAmount' => (float) $h->original_amount, 'originalAmountFormatted' => number_format((float) $h->original_amount), 'merchantName' => $h->merchant_name, 'hiddenAt' => $h->created_at?->format('Y-m-d H:i'), ]); return response()->json([ 'success' => true, 'data' => $hidden, 'count' => $hidden->count(), ]); } catch (\Throwable $e) { Log::error('숨김 목록 조회 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '조회 오류: '.$e->getMessage(), ]); } } /** * 카드 금액 변경 시 기존 분개 자료의 차변/대변 금액 자동 갱신 */ private function syncJournalAmounts(int $tenantId, string $uniqueKey, float $newSupply, float $newTax, ?string $deductionType): void { $journal = JournalEntry::getJournalBySourceKey($tenantId, 'ecard_transaction', $uniqueKey); if (! $journal) { return; } $lines = $journal->lines()->get(); if ($lines->isEmpty()) { return; } $isDeductible = ($deductionType ?? 'non_deductible') === 'deductible'; $totalAmount = (int) round($newSupply + $newTax); $supplyInt = (int) round($newSupply); $taxInt = (int) round($newTax); // 기존 라인의 계정과목/적요를 보존하면서 금액만 갱신 $debitLines = $lines->where('dc_type', 'debit')->values(); $creditLines = $lines->where('dc_type', 'credit')->values(); // 기존 라인 삭제 후 재생성 (금액 갱신) $journal->lines()->delete(); $lineNo = 1; if ($isDeductible && $debitLines->count() >= 2) { // 공제: 비용 계정(공급가액) + 부가세대급금(세액) $expenseLine = $debitLines->first(fn ($l) => $l->account_code !== '135') ?? $debitLines[0]; $taxLine = $debitLines->first(fn ($l) => $l->account_code === '135') ?? $debitLines[1]; JournalEntryLine::create([ 'tenant_id' => $tenantId, 'journal_entry_id' => $journal->id, 'line_no' => $lineNo++, 'dc_type' => 'debit', 'account_code' => $expenseLine->account_code, 'account_name' => $expenseLine->account_name, 'debit_amount' => $supplyInt, 'credit_amount' => 0, 'trading_partner_id' => $expenseLine->trading_partner_id, 'trading_partner_name' => $expenseLine->trading_partner_name, 'description' => $expenseLine->description, ]); JournalEntryLine::create([ 'tenant_id' => $tenantId, 'journal_entry_id' => $journal->id, 'line_no' => $lineNo++, 'dc_type' => 'debit', 'account_code' => $taxLine->account_code, 'account_name' => $taxLine->account_name, 'debit_amount' => $taxInt, 'credit_amount' => 0, 'trading_partner_id' => $taxLine->trading_partner_id, 'trading_partner_name' => $taxLine->trading_partner_name, 'description' => $taxLine->description, ]); } elseif ($isDeductible) { // 공제인데 기존 라인이 1개뿐이면 기본 구조로 생성 $expenseAccount = $debitLines->first(); JournalEntryLine::create([ 'tenant_id' => $tenantId, 'journal_entry_id' => $journal->id, 'line_no' => $lineNo++, 'dc_type' => 'debit', 'account_code' => $expenseAccount?->account_code ?? '826', 'account_name' => $expenseAccount?->account_name ?? '잡비', 'debit_amount' => $supplyInt, 'credit_amount' => 0, 'trading_partner_id' => $expenseAccount?->trading_partner_id, 'trading_partner_name' => $expenseAccount?->trading_partner_name, 'description' => $expenseAccount?->description, ]); JournalEntryLine::create([ 'tenant_id' => $tenantId, 'journal_entry_id' => $journal->id, 'line_no' => $lineNo++, 'dc_type' => 'debit', 'account_code' => '135', 'account_name' => '부가세대급금', 'debit_amount' => $taxInt, 'credit_amount' => 0, ]); } else { // 불공제: 비용 계정 = 공급가액 + 세액 $expenseAccount = $debitLines->first(); JournalEntryLine::create([ 'tenant_id' => $tenantId, 'journal_entry_id' => $journal->id, 'line_no' => $lineNo++, 'dc_type' => 'debit', 'account_code' => $expenseAccount?->account_code ?? '826', 'account_name' => $expenseAccount?->account_name ?? '잡비', 'debit_amount' => $totalAmount, 'credit_amount' => 0, 'trading_partner_id' => $expenseAccount?->trading_partner_id, 'trading_partner_name' => $expenseAccount?->trading_partner_name, 'description' => $expenseAccount?->description, ]); } // 대변: 미지급비용 (기존 대변 라인의 계정 보존) $creditAccount = $creditLines->first(); JournalEntryLine::create([ 'tenant_id' => $tenantId, 'journal_entry_id' => $journal->id, 'line_no' => $lineNo, 'dc_type' => 'credit', 'account_code' => $creditAccount?->account_code ?? '205', 'account_name' => $creditAccount?->account_name ?? '미지급비용', 'debit_amount' => 0, 'credit_amount' => $totalAmount, 'trading_partner_id' => $creditAccount?->trading_partner_id, 'trading_partner_name' => $creditAccount?->trading_partner_name, 'description' => $creditAccount?->description, ]); // 분개 헤더 합계 갱신 $journal->update([ 'total_debit' => $totalAmount, 'total_credit' => $totalAmount, ]); } // ================================================================ // 카드거래 복식부기 분개 API (journal_entries 통합) // ================================================================ /** * 카드 거래 분개 생성/수정 */ public function storeJournal(Request $request): JsonResponse { $request->validate([ 'source_key' => 'required|string|max:255', 'entry_date' => 'required|date', 'description' => 'nullable|string|max:500', 'lines' => 'required|array|min:2', 'lines.*.dc_type' => 'required|in:debit,credit', 'lines.*.account_code' => 'required|string|max:10', 'lines.*.account_name' => 'required|string|max:100', 'lines.*.debit_amount' => 'required|integer|min:0', 'lines.*.credit_amount' => 'required|integer|min:0', 'lines.*.trading_partner_id' => 'nullable|integer', 'lines.*.trading_partner_name' => 'nullable|string|max:100', 'lines.*.description' => 'nullable|string|max:300', ]); $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $lines = $request->lines; $totalDebit = collect($lines)->sum('debit_amount'); $totalCredit = collect($lines)->sum('credit_amount'); if ($totalDebit !== $totalCredit || $totalDebit === 0) { return response()->json([ 'success' => false, 'message' => '차변합계와 대변합계가 일치해야 하며 0보다 커야 합니다.', ], 422); } $existing = JournalEntry::getJournalBySourceKey($tenantId, 'ecard_transaction', $request->source_key); $maxRetries = 3; $lastError = null; for ($attempt = 0; $attempt < $maxRetries; $attempt++) { try { $entry = DB::transaction(function () use ($tenantId, $request, $lines, $totalDebit, $totalCredit, $existing) { if ($existing) { // 수정 모드: 기존 lines 삭제 후 재생성 (전표번호 유지) $existing->update([ 'entry_date' => $request->entry_date, 'description' => $request->description, 'total_debit' => $totalDebit, 'total_credit' => $totalCredit, ]); $existing->lines()->delete(); $entry = $existing; } else { // 신규 생성 $entryNo = JournalEntry::generateEntryNo($tenantId, $request->entry_date); $entry = JournalEntry::create([ 'tenant_id' => $tenantId, 'entry_no' => $entryNo, 'entry_date' => $request->entry_date, 'description' => $request->description, 'total_debit' => $totalDebit, 'total_credit' => $totalCredit, 'status' => 'draft', 'source_type' => 'ecard_transaction', 'source_key' => $request->source_key, 'created_by_name' => auth()->user()?->name ?? '시스템', ]); } foreach ($lines as $i => $line) { JournalEntryLine::create([ 'tenant_id' => $tenantId, 'journal_entry_id' => $entry->id, 'line_no' => $i + 1, 'dc_type' => $line['dc_type'], 'account_code' => $line['account_code'], 'account_name' => $line['account_name'], 'debit_amount' => $line['debit_amount'], 'credit_amount' => $line['credit_amount'], 'trading_partner_id' => $line['trading_partner_id'] ?? null, 'trading_partner_name' => $line['trading_partner_name'] ?? null, 'description' => $line['description'] ?? null, ]); } return $entry; }); return response()->json([ 'success' => true, 'message' => $existing ? '분개가 수정되었습니다.' : '분개가 저장되었습니다.', 'data' => ['id' => $entry->id, 'entry_no' => $entry->entry_no], ]); } catch (\Illuminate\Database\QueryException $e) { $lastError = $e; if ($e->errorInfo[1] === 1062) { continue; } break; } catch (\Throwable $e) { $lastError = $e; break; } } Log::error('카드거래 분개 저장 오류: '.$lastError->getMessage()); return response()->json([ 'success' => false, 'message' => '분개 저장 실패: '.$lastError->getMessage(), ], 500); } /** * 특정 카드 거래의 분개 조회 */ public function getJournal(Request $request): JsonResponse { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $sourceKey = $request->get('source_key'); if (! $sourceKey) { return response()->json(['success' => false, 'message' => 'source_key가 필요합니다.'], 422); } $entry = JournalEntry::forTenant($tenantId) ->where('source_type', 'ecard_transaction') ->where('source_key', $sourceKey) ->with('lines') ->first(); if (! $entry) { return response()->json(['success' => true, 'data' => null]); } return response()->json([ 'success' => true, 'data' => [ 'id' => $entry->id, 'entry_no' => $entry->entry_no, 'entry_date' => $entry->entry_date->format('Y-m-d'), 'description' => $entry->description, 'total_debit' => $entry->total_debit, 'total_credit' => $entry->total_credit, 'status' => $entry->status, 'lines' => $entry->lines->map(fn ($line) => [ 'id' => $line->id, 'line_no' => $line->line_no, 'dc_type' => $line->dc_type, 'account_code' => $line->account_code, 'account_name' => $line->account_name, 'debit_amount' => $line->debit_amount, 'credit_amount' => $line->credit_amount, 'trading_partner_id' => $line->trading_partner_id, 'trading_partner_name' => $line->trading_partner_name, 'description' => $line->description, ]), ], ]); } /** * 카드 거래 분개 삭제 (soft delete) */ public function deleteJournal(int $id): JsonResponse { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $entry = JournalEntry::forTenant($tenantId) ->where('source_type', 'ecard_transaction') ->findOrFail($id); $entry->delete(); return response()->json([ 'success' => true, 'message' => '분개가 삭제되었습니다.', ]); } /** * 기간 내 카드 거래 분개 상태 일괄 조회 */ public function getJournalStatuses(Request $request): JsonResponse { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $startDate = $request->input('startDate'); $endDate = $request->input('endDate'); if (! $startDate || ! $endDate) { return response()->json(['success' => false, 'message' => 'startDate, endDate가 필요합니다.'], 422); } // YYYYMMDD → YYYY-MM-DD 변환 $startFormatted = substr($startDate, 0, 4).'-'.substr($startDate, 4, 2).'-'.substr($startDate, 6, 2); $endFormatted = substr($endDate, 0, 4).'-'.substr($endDate, 4, 2).'-'.substr($endDate, 6, 2); $journals = JournalEntry::where('tenant_id', $tenantId) ->where('source_type', 'ecard_transaction') ->whereBetween('entry_date', [$startFormatted, $endFormatted]) ->select('id', 'source_key', 'entry_no') ->get(); $map = []; foreach ($journals as $j) { $map[$j->source_key] = [ 'id' => $j->id, 'entry_no' => $j->entry_no, 'hasJournal' => true, ]; } return response()->json([ 'success' => true, 'data' => $map, ]); } /** * SOAP 호출 */ private function callSoap(string $method, array $params = []): array { if (! $this->soapClient) { return [ 'success' => false, 'error' => '바로빌 SOAP 클라이언트가 초기화되지 않았습니다.', ]; } if (empty($this->certKey) && ! $this->isTestMode) { return [ 'success' => false, 'error' => 'CERTKEY가 설정되지 않았습니다.', ]; } if (empty($this->corpNum)) { return [ 'success' => false, 'error' => '사업자번호가 설정되지 않았습니다.', ]; } // CERTKEY와 CorpNum 자동 추가 if (! isset($params['CERTKEY'])) { $params['CERTKEY'] = $this->certKey ?? ''; } if (! isset($params['CorpNum'])) { $params['CorpNum'] = $this->corpNum; } try { Log::info("바로빌 카드 API 호출 - Method: {$method}, CorpNum: {$this->corpNum}"); $result = $this->soapClient->$method($params); $resultProperty = $method.'Result'; if (isset($result->$resultProperty)) { $resultData = $result->$resultProperty; // 에러 코드 체크 if (is_numeric($resultData) && $resultData < 0) { return [ 'success' => false, 'error' => $this->getErrorMessage((int) $resultData), 'error_code' => (int) $resultData, ]; } return [ 'success' => true, 'data' => $resultData, ]; } return [ 'success' => true, 'data' => $result, ]; } catch (\SoapFault $e) { Log::error('바로빌 SOAP 오류: '.$e->getMessage()); return [ 'success' => false, 'error' => 'SOAP 오류: '.$e->getMessage(), 'error_code' => $e->getCode(), ]; } catch (\Throwable $e) { Log::error('바로빌 API 호출 오류: '.$e->getMessage()); return [ 'success' => false, 'error' => 'API 호출 오류: '.$e->getMessage(), ]; } } }