first(); if ($activeConfig) { $this->certKey = $activeConfig->cert_key; $this->corpNum = $activeConfig->corp_num; $this->isTestMode = $activeConfig->environment === 'test'; // 계좌 조회는 BANKACCOUNT.asmx 사용 $baseUrl = $this->isTestMode ? 'https://testws.baroservice.com' : 'https://ws.baroservice.com'; $this->soapUrl = $baseUrl.'/BANKACCOUNT.asmx?WSDL'; } else { $this->isTestMode = config('services.barobill.test_mode', true); // 테스트 모드에 따라 적절한 CERT_KEY 선택 $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/BANKACCOUNT.asmx?WSDL' : 'https://ws.baroservice.com/BANKACCOUNT.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_NONE, ]); } 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.account-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.eaccount.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.'/BANKACCOUNT.asmx?WSDL'; // SOAP 클라이언트 재초기화 $this->initSoapClient(); Log::info('[Eaccount] 서버 모드 적용', [ 'targetEnv' => $targetEnv, 'certKey' => substr($this->certKey ?? '', 0, 10).'...', 'corpNum' => $this->corpNum, 'soapUrl' => $this->soapUrl, ]); } else { Log::warning('[Eaccount] BarobillConfig 없음', ['targetEnv' => $targetEnv]); } } /** * 등록된 계좌 목록 조회 (GetBankAccountEx) */ public function accounts(Request $request): JsonResponse { try { $availOnly = $request->input('availOnly', 0); // 현재 테넌트의 바로빌 회원 정보 조회 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); // 테넌트별 서버 모드 적용 if ($barobillMember && $barobillMember->server_mode) { $this->applyMemberServerMode($barobillMember); } // 바로빌 사용자 ID 결정 $userId = $barobillMember?->barobill_id ?? ''; $result = $this->callSoap('GetBankAccountEx', [ 'AvailOnly' => (int) $availOnly, ]); if (! $result['success']) { return response()->json([ 'success' => false, 'error' => $result['error'], 'error_code' => $result['error_code'] ?? null, ]); } $accounts = []; $data = $result['data']; // BankAccount 또는 BankAccountEx에서 계좌 목록 추출 $accountList = []; if (isset($data->BankAccount)) { $accountList = is_array($data->BankAccount) ? $data->BankAccount : [$data->BankAccount]; } elseif (isset($data->BankAccountEx)) { $accountList = is_array($data->BankAccountEx) ? $data->BankAccountEx : [$data->BankAccountEx]; } foreach ($accountList as $acc) { if (! is_object($acc)) { continue; } $bankAccountNum = $acc->BankAccountNum ?? ''; if (empty($bankAccountNum) || (is_numeric($bankAccountNum) && $bankAccountNum < 0)) { continue; } $bankCode = $acc->BankCode ?? ''; $bankName = $acc->BankName ?? $this->getBankName($bankCode); $accounts[] = [ 'bankAccountNum' => $bankAccountNum, 'bankCode' => $bankCode, 'bankName' => $bankName, 'accountName' => $acc->AccountName ?? '', 'accountType' => $acc->AccountType ?? '', 'currency' => $acc->Currency ?? 'KRW', 'issueDate' => $acc->IssueDate ?? '', 'balance' => $acc->Balance ?? 0, 'status' => isset($acc->UseState) ? (int) $acc->UseState : 1, ]; } return response()->json([ 'success' => true, 'accounts' => $accounts, 'count' => count($accounts), ]); } catch (\Throwable $e) { Log::error('계좌 목록 조회 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '서버 오류: '.$e->getMessage(), ]); } } /** * 계좌별 최신 잔액 조회 (DB에서 조회 - 대시보드용) */ public function latestBalances(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); // 전체 거래를 한번에 조회 (계좌별 → 시간순) $allTransactions = BankTransaction::where('tenant_id', $tenantId) ->select('bank_account_num', 'bank_name', 'balance', 'deposit', 'withdraw', 'trans_date', 'trans_time', 'is_manual') ->orderBy('bank_account_num') ->orderBy('trans_date', 'asc') ->orderBy('trans_time', 'asc') ->orderBy('id', 'asc') ->get(); // 계좌별로 그룹화하여 최종 잔액 계산 $accountBalances = []; foreach ($allTransactions as $tx) { $accNum = $tx->bank_account_num; if (! isset($accountBalances[$accNum])) { $accountBalances[$accNum] = [ 'bankAccountNum' => $accNum, 'bankName' => $tx->bank_name, 'balance' => 0, 'lastTransDate' => '', 'lastTransTime' => '', ]; } $prev = $accountBalances[$accNum]['balance']; if (! $tx->is_manual && (float) $tx->balance != 0) { $accountBalances[$accNum]['balance'] = (float) $tx->balance; } else { $accountBalances[$accNum]['balance'] = $prev + (float) $tx->deposit - (float) $tx->withdraw; } $accountBalances[$accNum]['lastTransDate'] = $tx->trans_date; $accountBalances[$accNum]['lastTransTime'] = $tx->trans_time; } $result = array_values($accountBalances); return response()->json([ 'success' => true, 'balances' => $result, 'count' => count($result), ]); } catch (\Throwable $e) { Log::error('최신 잔액 조회 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => $e->getMessage(), ]); } } /** * 계좌 입출금내역 조회 (GetPeriodBankAccountTransLog) */ public function transactions(Request $request): JsonResponse { // SOAP API 호출이 여러 건 발생할 수 있으므로 타임아웃 연장 if (function_exists('set_time_limit') && ! in_array('set_time_limit', explode(',', ini_get('disable_functions')))) { @set_time_limit(120); } try { $startDate = $request->input('startDate', date('Ymd')); $endDate = $request->input('endDate', date('Ymd')); $bankAccountNum = str_replace('-', '', $request->input('accountNum', '')); $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); } // DB에서 저장된 계정과목 데이터 조회 $savedData = BankTransaction::getByDateRange($tenantId, $startDate, $endDate, $bankAccountNum ?: null); // 오버라이드 데이터 (수정된 적요/내용) 조회 $overrideData = null; // DB에서 수동 입력 건 조회 $manualQuery = BankTransaction::where('tenant_id', $tenantId) ->where('is_manual', true) ->whereBetween('trans_date', [$startDate, $endDate]); if (! empty($bankAccountNum)) { $manualQuery->where('bank_account_num', $bankAccountNum); } $manualTransactions = $manualQuery->orderBy('trans_date', 'desc') ->orderBy('trans_time', 'desc') ->get(); // 전체 계좌 조회: 빈 값이면 모든 계좌의 거래 내역 조회 if (empty($bankAccountNum)) { return $this->getAllAccountsTransactions($userId, $startDate, $endDate, $page, $limit, $savedData, $overrideData, $tenantId, $manualTransactions); } // 단일 계좌 조회 - 기간을 월별로 분할하여 SOAP API 호출 (긴 기간 에러 방지) // 캐싱: tenantId 전달, bankName/bankCode는 단일 계좌에서는 빈 문자열 $fetched = $this->fetchAccountTransactions($userId, $bankAccountNum, $startDate, $endDate, $tenantId, '', ''); // API 데이터가 없는 경우 (수동 건만 표시) if (empty($fetched['logs'])) { $manualLogs = $this->convertManualToLogs($manualTransactions); $baseBalance = $this->findBaseBalance($tenantId, $startDate, $bankAccountNum); $recalcLogs = $this->recalcManualBalances($manualLogs['logs'], $baseBalance); return response()->json([ 'success' => true, 'data' => [ 'logs' => $recalcLogs, 'summary' => $manualLogs['summary'], 'pagination' => ['currentPage' => 1, 'maxPageNum' => 1], ], ]); } // 월별 청크 결과를 합쳐서 파싱 $fakeData = new \stdClass; $fakeData->BankAccountLogList = new \stdClass; $fakeData->BankAccountLogList->BankAccountTransLog = $fetched['logs']; $logs = $this->parseTransactionLogs($fakeData, '', $savedData, $tenantId); // 수동 입력 건 병합 (중복 제거: 수동 거래와 동일한 API 거래는 제외) $manualLogs = $this->convertManualToLogs($manualTransactions); $mergeResult = $this->mergeWithDedup($logs['logs'], $manualLogs['logs']); $mergedLogs = $mergeResult['logs']; // 날짜/시간 기준 정렬 (최신순) usort($mergedLogs, function ($a, $b) { $dtA = ($a['transDate'] ?? '').($a['transTime'] ?? ''); $dtB = ($b['transDate'] ?? '').($b['transTime'] ?? ''); return strcmp($dtB, $dtA); }); // 수동입력 건 잔액 재계산 (조회기간 이전 잔액 기준) $baseBalance = $this->findBaseBalance($tenantId, $startDate, $bankAccountNum); $mergedLogs = $this->recalcManualBalances($mergedLogs, $baseBalance); // summary 합산 (중복 제거된 API 거래 금액 차감) $mergedSummary = [ 'totalDeposit' => $logs['summary']['totalDeposit'] + $manualLogs['summary']['totalDeposit'] - $mergeResult['removedDeposit'], 'totalWithdraw' => $logs['summary']['totalWithdraw'] + $manualLogs['summary']['totalWithdraw'] - $mergeResult['removedWithdraw'], 'count' => count($mergedLogs), ]; // 클라이언트 사이드 페이지네이션 $totalCount = count($mergedLogs); $maxPageNum = (int) ceil($totalCount / $limit); $startIndex = ($page - 1) * $limit; $paginatedLogs = array_slice($mergedLogs, $startIndex, $limit); 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(), [ 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString(), ]); return response()->json([ 'success' => false, 'error' => '서버 오류: '.$e->getMessage().' ('.$e->getFile().':'.$e->getLine().')', ]); } } /** * 전체 계좌의 거래 내역 조회 */ private function getAllAccountsTransactions(string $userId, string $startDate, string $endDate, int $page, int $limit, $savedData = null, $overrideData = null, int $tenantId = 1, $manualTransactions = null): JsonResponse { // 먼저 계좌 목록 조회 $accountResult = $this->callSoap('GetBankAccountEx', ['AvailOnly' => 0]); if (! $accountResult['success']) { return response()->json([ 'success' => false, 'error' => $accountResult['error'], ]); } $accountList = []; $data = $accountResult['data']; if (isset($data->BankAccount)) { $accountList = is_array($data->BankAccount) ? $data->BankAccount : [$data->BankAccount]; } elseif (isset($data->BankAccountEx)) { $accountList = is_array($data->BankAccountEx) ? $data->BankAccountEx : [$data->BankAccountEx]; } $allLogs = []; $totalDeposit = 0; $totalWithdraw = 0; foreach ($accountList as $acc) { if (! is_object($acc)) { continue; } $accNum = $acc->BankAccountNum ?? ''; if (empty($accNum) || (is_numeric($accNum) && $accNum < 0)) { continue; } // 기간을 월별로 분할하여 SOAP API 호출 (긴 기간 에러 방지) // 캐싱: 계좌별 bankName/bankCode 전달 $fetched = $this->fetchAccountTransactions($userId, $accNum, $startDate, $endDate, $tenantId, $acc->BankName ?? '', $acc->BankCode ?? ''); if (! empty($fetched['logs'])) { $fakeData = new \stdClass; $fakeData->BankAccountLogList = new \stdClass; $fakeData->BankAccountLogList->BankAccountTransLog = $fetched['logs']; $parsed = $this->parseTransactionLogs($fakeData, $acc->BankName ?? '', $savedData, $tenantId); foreach ($parsed['logs'] as $log) { $log['bankName'] = $acc->BankName ?? $this->getBankName($acc->BankCode ?? ''); $allLogs[] = $log; } $totalDeposit += $parsed['summary']['totalDeposit']; $totalWithdraw += $parsed['summary']['totalWithdraw']; } } // 수동 입력 건 병합 (중복 제거: 수동 거래와 동일한 API 거래는 제외) if ($manualTransactions && $manualTransactions->isNotEmpty()) { $manualLogs = $this->convertManualToLogs($manualTransactions); $mergeResult = $this->mergeWithDedup($allLogs, $manualLogs['logs']); $allLogs = $mergeResult['logs']; $totalDeposit += $manualLogs['summary']['totalDeposit'] - $mergeResult['removedDeposit']; $totalWithdraw += $manualLogs['summary']['totalWithdraw'] - $mergeResult['removedWithdraw']; } // 날짜/시간 기준 정렬 (최신순) usort($allLogs, function ($a, $b) { $dateA = ($a['transDate'] ?? '').($a['transTime'] ?? ''); $dateB = ($b['transDate'] ?? '').($b['transTime'] ?? ''); return strcmp($dateB, $dateA); }); // 수동입력 건 잔액 재계산 (조회기간 이전 잔액 기준) $baseBalance = $this->findBaseBalance($tenantId, $startDate); $allLogs = $this->recalcManualBalances($allLogs, $baseBalance); // 페이지네이션 $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' => [ 'totalDeposit' => $totalDeposit, 'totalWithdraw' => $totalWithdraw, 'count' => $totalCount, ], ], ]); } /** * 거래 내역 파싱 (저장된 계정과목 + 오버라이드 병합) */ private function parseTransactionLogs($resultData, string $defaultBankName = '', $savedData = null, int $tenantId = 1): array { $logs = []; $uniqueKeys = []; $totalDeposit = 0; $totalWithdraw = 0; $rawLogs = []; if (isset($resultData->BankAccountLogList) && isset($resultData->BankAccountLogList->BankAccountTransLog)) { $rawLogs = is_array($resultData->BankAccountLogList->BankAccountTransLog) ? $resultData->BankAccountLogList->BankAccountTransLog : [$resultData->BankAccountLogList->BankAccountTransLog]; } // 1단계: 모든 고유 키 수집 foreach ($rawLogs as $log) { $bankAccountNum = $log->BankAccountNum ?? ''; $transDT = $log->TransDT ?? ''; $deposit = (int) floatval($log->Deposit ?? 0); $withdraw = (int) floatval($log->Withdraw ?? 0); $balance = (int) floatval($log->Balance ?? 0); $summary = $log->TransRemark1 ?? $log->Summary ?? ''; $remark2 = $log->TransRemark2 ?? ''; $cleanSummary = BankTransaction::cleanSummary($summary, $remark2); $uniqueKey = implode('|', [$bankAccountNum, $transDT, $deposit, $withdraw, $balance, $cleanSummary]); $uniqueKeys[] = $uniqueKey; } // 2단계: 오버라이드 데이터 일괄 조회 $overrides = BankTransactionOverride::getByUniqueKeys($tenantId, $uniqueKeys); // 3단계: 각 로그 처리 foreach ($rawLogs as $log) { $deposit = floatval($log->Deposit ?? 0); $withdraw = floatval($log->Withdraw ?? 0); $balance = floatval($log->Balance ?? 0); $totalDeposit += $deposit; $totalWithdraw += $withdraw; // 거래일시 파싱 $transDT = $log->TransDT ?? ''; $transDate = ''; $transTime = ''; $dateTime = ''; if (! empty($transDT) && strlen($transDT) >= 14) { $transDate = substr($transDT, 0, 8); $transTime = substr($transDT, 8, 6); $dateTime = substr($transDT, 0, 4).'-'.substr($transDT, 4, 2).'-'.substr($transDT, 6, 2).' '. substr($transDT, 8, 2).':'.substr($transDT, 10, 2).':'.substr($transDT, 12, 2); } // 적요 파싱 (TransRemark1만 적요로, TransRemark2는 상대계좌예금주명으로 분리) $summary = $log->TransRemark1 ?? $log->Summary ?? ''; $remark2 = $log->TransRemark2 ?? ''; $transType = $log->TransType ?? ''; // ★ 적요에서 상대계좌예금주명(remark2) 중복 제거 $cleanSummary = BankTransaction::cleanSummary($summary, $remark2); $bankAccountNum = $log->BankAccountNum ?? ''; // 고유 키 생성하여 저장된 데이터와 매칭 (숫자는 정수로 변환하여 형식 통일) $uniqueKey = implode('|', [$bankAccountNum, $transDT, (int) $deposit, (int) $withdraw, (int) $balance, $cleanSummary]); $savedItem = $savedData?->get($uniqueKey); $override = $overrides->get($uniqueKey); // 원본 적요/내용 (remark2를 합산하지 않음 - 상대계좌예금주명 컬럼에서 별도 표시) $originalSummary = $cleanSummary; $originalCast = $savedItem?->cast ?? $remark2; // 오버라이드 적용 (수정된 값이 있으면 사용) $displaySummary = $override?->modified_summary ?? $originalSummary; $displayCast = $override?->modified_cast ?? $originalCast; $logItem = [ 'transDate' => $transDate, 'transTime' => $transTime, 'transDateTime' => $dateTime, 'bankAccountNum' => $bankAccountNum, 'bankName' => $log->BankName ?? $defaultBankName, 'deposit' => $deposit, 'withdraw' => $withdraw, 'depositFormatted' => number_format($deposit), 'withdrawFormatted' => number_format($withdraw), 'balance' => $balance, 'balanceFormatted' => number_format($balance), 'summary' => $displaySummary, 'originalSummary' => $originalSummary, 'cast' => $displayCast, 'originalCast' => $originalCast, 'memo' => $log->Memo ?? '', 'transOffice' => $log->TransOffice ?? '', // 저장된 계정과목 정보 병합 'accountCode' => $savedItem?->account_code ?? '', 'accountName' => $savedItem?->account_name ?? '', // 저장된 거래처 정보 병합 'clientCode' => $savedItem?->client_code ?? '', 'clientName' => $savedItem?->client_name ?? '', 'isSaved' => $savedItem !== null, 'isOverridden' => $override !== null, 'uniqueKey' => $uniqueKey, ]; $logs[] = $logItem; } return [ 'logs' => $logs, 'summary' => [ 'totalDeposit' => $totalDeposit, 'totalWithdraw' => $totalWithdraw, 'count' => count($logs), ], ]; } /** * 에러 코드 체크 */ private function checkErrorCode($data): ?int { if (isset($data->CurrentPage) && is_numeric($data->CurrentPage) && $data->CurrentPage < 0) { return (int) $data->CurrentPage; } if (isset($data->BankAccountNum) && is_numeric($data->BankAccountNum) && $data->BankAccountNum < 0) { return (int) $data->BankAccountNum; } return null; } /** * 에러 메시지 반환 */ private function getErrorMessage(int $errorCode): string { $messages = [ -10002 => '인증 실패 (-10002). CERTKEY가 올바르지 않거나 만료되었습니다.', -50214 => '은행 로그인 실패 (-50214). 바로빌 사이트에서 계좌 비밀번호/인증서를 점검해주세요.', -24005 => '사용자 정보 불일치 (-24005). 사업자번호를 확인해주세요.', -25001 => '등록된 계좌가 없습니다 (-25001).', -25005 => '조회된 데이터가 없습니다 (-25005).', -25006 => '계좌번호가 잘못되었습니다 (-25006).', -25007 => '조회 기간이 잘못되었습니다 (-25007).', ]; return $messages[$errorCode] ?? '바로빌 API 오류: '.$errorCode; } /** * 긴 기간을 월별 청크로 분할 (바로빌 API 기간 제한 대응) * YYYYMMDD 형식의 시작/종료일을 받아 월별 [start, end] 배열 반환 */ private function splitDateRangeMonthly(string $startDate, string $endDate): array { $start = Carbon::createFromFormat('Ymd', $startDate)->startOfDay(); $end = Carbon::createFromFormat('Ymd', $endDate)->endOfDay(); $chunks = []; $cursor = $start->copy(); while ($cursor->lte($end)) { $chunkStart = $cursor->copy(); $chunkEnd = $cursor->copy()->endOfMonth()->startOfDay(); // 마지막 청크: 종료일이 월말보다 이전이면 종료일 사용 if ($chunkEnd->gt($end)) { $chunkEnd = $end->copy()->startOfDay(); } $chunks[] = [ 'start' => $chunkStart->format('Ymd'), 'end' => $chunkEnd->format('Ymd'), ]; // 다음 월 1일로 이동 $cursor = $chunkEnd->copy()->addDay()->startOfMonth(); } return $chunks; } /** * 단일 계좌의 거래 내역을 기간 분할하여 조회 (캐싱 지원) * 긴 기간도 월별로 나누어 캐시/SOAP API 호출 후 병합 * * 캐시 판단 로직: * - 과거 월 (현재 월 이전): sync 레코드 존재 → DB에서 제공 (API 호출 안 함) * - 현재 월: sync 레코드 존재 + synced_at이 10분 이내 → DB에서 제공 * - 미동기화: API 호출 → DB 저장 → sync 레코드 생성/갱신 */ private function fetchAccountTransactions(string $userId, string $accNum, string $startDate, string $endDate, int $tenantId = 0, string $bankName = '', string $bankCode = ''): array { $chunks = $this->splitDateRangeMonthly($startDate, $endDate); $allRawLogs = []; $lastSuccessData = null; $currentYearMonth = Carbon::now()->format('Ym'); foreach ($chunks as $chunk) { $yearMonth = substr($chunk['start'], 0, 6); $isCurrentMonth = ($yearMonth === $currentYearMonth); // 캐시 판단 (tenantId가 전달된 경우에만) $useCache = false; if ($tenantId > 0) { $syncStatus = BankSyncStatus::where('tenant_id', $tenantId) ->where('bank_account_num', $accNum) ->where('synced_year_month', $yearMonth) ->first(); if ($syncStatus) { if (! $isCurrentMonth) { $useCache = true; // 과거 월: 항상 캐시 } elseif ($syncStatus->synced_at->diffInMinutes(now()) < 10) { $useCache = true; // 현재 월: 10분 이내면 캐시 } } } if ($useCache) { // DB에서 캐시된 거래 조회 → stdClass 변환 $cachedRecords = BankTransaction::getCachedByMonth($tenantId, $accNum, $chunk['start'], $chunk['end']); foreach ($cachedRecords as $record) { $allRawLogs[] = $this->convertDbToRawLog($record); } Log::debug("바로빌 캐시 사용 ({$chunk['start']}~{$chunk['end']}): {$cachedRecords->count()}건"); continue; } // API 호출 $result = $this->callSoap('GetPeriodBankAccountTransLog', [ 'ID' => $userId, 'BankAccountNum' => $accNum, 'StartDate' => $chunk['start'], 'EndDate' => $chunk['end'], 'TransDirection' => 1, 'CountPerPage' => 1000, 'CurrentPage' => 1, 'OrderDirection' => 2, ]); if (! $result['success']) { continue; } $chunkData = $result['data']; $errorCode = $this->checkErrorCode($chunkData); // 데이터 없음(-25005, -25001)은 건너뜀, 기타 에러도 건너뜀 (다른 월은 성공할 수 있음) if ($errorCode && ! in_array($errorCode, [-25005, -25001])) { Log::debug("바로빌 API 기간 분할 - 에러 발생 ({$chunk['start']}~{$chunk['end']}): {$errorCode}"); continue; } if ($errorCode && in_array($errorCode, [-25005, -25001])) { // 데이터 없음이어도 sync 상태 기록 (빈 월 반복 호출 방지) if ($tenantId > 0) { $this->updateSyncStatus($tenantId, $accNum, $yearMonth); } continue; } // 로그 추출 $rawLogs = []; if (isset($chunkData->BankAccountLogList) && isset($chunkData->BankAccountLogList->BankAccountTransLog)) { $logs = $chunkData->BankAccountLogList->BankAccountTransLog; $rawLogs = is_array($logs) ? $logs : [$logs]; } foreach ($rawLogs as $log) { $allRawLogs[] = $log; } // 캐시 저장 (tenantId가 전달된 경우에만) if ($tenantId > 0 && ! empty($rawLogs)) { $this->cacheApiTransactions($tenantId, $accNum, $bankName, $bankCode, $rawLogs); $this->updateSyncStatus($tenantId, $accNum, $yearMonth); Log::debug("바로빌 API 캐시 저장 ({$chunk['start']}~{$chunk['end']}): ".count($rawLogs).'건'); } elseif ($tenantId > 0) { $this->updateSyncStatus($tenantId, $accNum, $yearMonth); } $lastSuccessData = $chunkData; } return [ 'logs' => $allRawLogs, 'lastData' => $lastSuccessData, ]; } /** * SOAP API 결과를 DB에 배치 저장 (캐시) * insertOrIgnore 사용으로 기존 레코드(사용자가 account_code 할당한 것) 보호 */ private function cacheApiTransactions(int $tenantId, string $accNum, string $bankName, string $bankCode, array $rawLogs): void { $rows = []; $now = now(); foreach ($rawLogs as $log) { $transDT = $log->TransDT ?? ''; $transDate = strlen($transDT) >= 8 ? substr($transDT, 0, 8) : ''; $transTime = strlen($transDT) >= 14 ? substr($transDT, 8, 6) : ''; $deposit = floatval($log->Deposit ?? 0); $withdraw = floatval($log->Withdraw ?? 0); $balance = floatval($log->Balance ?? 0); $summary = $log->TransRemark1 ?? $log->Summary ?? ''; $remark2 = $log->TransRemark2 ?? ''; // ★ 적요에서 상대계좌예금주명(remark2) 중복 제거 $cleanSummary = BankTransaction::cleanSummary($summary, $remark2); $rows[] = [ 'tenant_id' => $tenantId, 'bank_account_num' => $log->BankAccountNum ?? $accNum, 'bank_code' => $log->BankCode ?? $bankCode, 'bank_name' => $log->BankName ?? $bankName, 'trans_date' => $transDate, 'trans_time' => $transTime, 'trans_dt' => $transDT, 'deposit' => $deposit, 'withdraw' => $withdraw, 'balance' => $balance, 'summary' => $cleanSummary, 'cast' => $remark2, 'memo' => $log->Memo ?? '', 'trans_office' => $log->TransOffice ?? '', 'is_manual' => false, 'created_at' => $now, 'updated_at' => $now, ]; } // 100건씩 배치 insertOrIgnore (unique constraint로 중복 방지) foreach (array_chunk($rows, 100) as $batch) { DB::table('barobill_bank_transactions')->insertOrIgnore($batch); } } /** * DB 레코드를 SOAP BankAccountTransLog 객체 형태로 변환 * parseTransactionLogs()에 그대로 전달 가능 */ private function convertDbToRawLog(BankTransaction $record): \stdClass { $log = new \stdClass; $log->BankAccountNum = $record->bank_account_num; $log->TransDT = $record->trans_dt; $log->Deposit = $record->deposit; $log->Withdraw = $record->withdraw; $log->Balance = $record->balance; $log->TransRemark1 = $record->summary ?? ''; $log->TransRemark2 = $record->cast ?? ''; $log->Memo = $record->memo ?? ''; $log->TransOffice = $record->trans_office ?? ''; $log->TransType = ''; $log->BankName = $record->bank_name ?? ''; $log->BankCode = $record->bank_code ?? ''; $log->ClientCode = $record->client_code ?? ''; $log->ClientName = $record->client_name ?? ''; return $log; } /** * 동기화 상태 갱신 */ private function updateSyncStatus(int $tenantId, string $accNum, string $yearMonth): void { BankSyncStatus::updateOrCreate( [ 'tenant_id' => $tenantId, 'bank_account_num' => $accNum, 'synced_year_month' => $yearMonth, ], [ 'synced_at' => now(), ] ); } /** * 은행 코드 -> 은행명 변환 */ private function getBankName(string $code): string { $banks = [ '002' => 'KDB산업은행', '003' => 'IBK기업은행', '004' => 'KB국민은행', '007' => '수협은행', '011' => 'NH농협은행', '020' => '우리은행', '023' => 'SC제일은행', '027' => '한국씨티은행', '031' => '대구은행', '032' => '부산은행', '034' => '광주은행', '035' => '제주은행', '037' => '전북은행', '039' => '경남은행', '045' => '새마을금고', '048' => '신협', '050' => '저축은행', '064' => '산림조합', '071' => '우체국', '081' => '하나은행', '088' => '신한은행', '089' => 'K뱅크', '090' => '카카오뱅크', '092' => '토스뱅크', ]; return $banks[$code] ?? $code; } /** * 거래처 검색 API */ public function searchClients(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $keyword = trim($request->input('q', '')); if (empty($keyword)) { return response()->json([ 'success' => true, 'data' => [], ]); } $query = TradingPartner::forTenant($tenantId) ->where('status', 'active'); if (is_numeric($keyword)) { $query->where(function ($q) use ($keyword) { $q->where('id', $keyword) ->orWhere('name', 'like', "%{$keyword}%"); }); } else { $query->where('name', 'like', "%{$keyword}%"); } $clients = $query->select('id', 'name') ->orderBy('id') ->limit(20) ->get(); return response()->json([ 'success' => true, 'data' => $clients->map(fn ($c) => [ 'code' => (string) $c->id, 'name' => $c->name, ]), ]); } catch (\Throwable $e) { Log::error('거래처 검색 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '검색 오류: '.$e->getMessage(), ]); } } /** * 계정과목 목록 조회 (글로벌 데이터) */ public function accountCodes(): JsonResponse { $codes = AccountCode::getActive(); return response()->json([ 'success' => true, 'data' => $codes->map(fn ($c) => [ 'id' => $c->id, 'code' => $c->code, 'name' => $c->name, 'category' => $c->category, ]), ]); } /** * 전체 계정과목 목록 조회 (설정용, 비활성 포함, 글로벌 데이터) */ public function accountCodesAll(): JsonResponse { $codes = AccountCode::getAll(); return response()->json([ 'success' => true, 'data' => $codes, ]); } /** * 계정과목 추가 (글로벌 데이터) */ public function accountCodeStore(Request $request): JsonResponse { try { $validated = $request->validate([ 'code' => 'required|string|max:10', 'name' => 'required|string|max:100', 'category' => 'nullable|string|max:50', ]); // 중복 체크 (글로벌) $exists = AccountCode::where('code', $validated['code'])->exists(); if ($exists) { return response()->json([ 'success' => false, 'error' => '이미 존재하는 계정과목 코드입니다.', ], 422); } $maxSort = AccountCode::max('sort_order') ?? 0; $accountCode = AccountCode::create([ 'tenant_id' => self::HEADQUARTERS_TENANT_ID, // 글로벌 데이터는 기본 테넌트에 저장 'code' => $validated['code'], 'name' => $validated['name'], 'category' => $validated['category'] ?? null, 'sort_order' => $maxSort + 1, 'is_active' => true, ]); return response()->json([ 'success' => true, 'message' => '계정과목이 추가되었습니다.', 'data' => $accountCode, ]); } catch (\Throwable $e) { return response()->json([ 'success' => false, 'error' => '추가 실패: '.$e->getMessage(), ], 500); } } /** * 계정과목 수정 (글로벌 데이터) */ public function accountCodeUpdate(Request $request, int $id): JsonResponse { try { $accountCode = AccountCode::find($id); if (! $accountCode) { return response()->json([ 'success' => false, 'error' => '계정과목을 찾을 수 없습니다.', ], 404); } $validated = $request->validate([ 'code' => 'sometimes|string|max:10', 'name' => 'sometimes|string|max:100', 'category' => 'nullable|string|max:50', 'is_active' => 'sometimes|boolean', ]); // 코드 변경 시 중복 체크 (글로벌) if (isset($validated['code']) && $validated['code'] !== $accountCode->code) { $exists = AccountCode::where('code', $validated['code']) ->where('id', '!=', $id) ->exists(); if ($exists) { return response()->json([ 'success' => false, 'error' => '이미 존재하는 계정과목 코드입니다.', ], 422); } } $accountCode->update($validated); return response()->json([ 'success' => true, 'message' => '계정과목이 수정되었습니다.', 'data' => $accountCode, ]); } catch (\Throwable $e) { return response()->json([ 'success' => false, 'error' => '수정 실패: '.$e->getMessage(), ], 500); } } /** * 계정과목 삭제 (글로벌 데이터) */ public function accountCodeDestroy(int $id): JsonResponse { try { $accountCode = AccountCode::find($id); if (! $accountCode) { return response()->json([ 'success' => false, 'error' => '계정과목을 찾을 수 없습니다.', ], 404); } $accountCode->delete(); return response()->json([ 'success' => true, 'message' => '계정과목이 삭제되었습니다.', ]); } catch (\Throwable $e) { return response()->json([ 'success' => false, 'error' => '삭제 실패: '.$e->getMessage(), ], 500); } } /** * 입출금 내역 저장 (계정과목 포함) */ public function save(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $transactions = $request->input('transactions', []); Log::info('[Eaccount Save] 요청 수신', [ 'tenant_id' => $tenantId, 'transaction_count' => count($transactions), ]); if (empty($transactions)) { return response()->json([ 'success' => false, 'error' => '저장할 데이터가 없습니다.', ]); } // 수동 거래 디버그 $manualCount = 0; $apiCount = 0; foreach ($transactions as $t) { if (! empty($t['isManual'])) { $manualCount++; Log::info('[Eaccount Save] 수동 거래 감지', [ 'dbId' => $t['dbId'] ?? 'MISSING', 'cast' => $t['cast'] ?? 'EMPTY', 'isManual' => $t['isManual'], ]); } else { $apiCount++; } } Log::info('[Eaccount Save] 거래 분류', ['manual' => $manualCount, 'api' => $apiCount]); $saved = 0; $updated = 0; $savedUniqueKeys = []; DB::beginTransaction(); foreach ($transactions as $trans) { // 수동 입력 거래: dbId로 직접 찾아서 비-키 필드만 업데이트 // balance는 화면에서 재계산된 값이므로 composite key 매칭 불가 if (! empty($trans['isManual']) && ! empty($trans['dbId'])) { $affectedRows = DB::table('barobill_bank_transactions') ->where('id', $trans['dbId']) ->where('tenant_id', $tenantId) ->update([ 'summary' => $trans['summary'] ?? '', 'cast' => $trans['cast'] ?? '', 'memo' => $trans['memo'] ?? '', 'trans_office' => $trans['transOffice'] ?? '', 'account_code' => $trans['accountCode'] ?? null, 'account_name' => $trans['accountName'] ?? null, 'client_code' => $trans['clientCode'] ?? null, 'client_name' => $trans['clientName'] ?? null, 'updated_at' => now(), ]); Log::info('[Eaccount Save] 수동 거래 업데이트', [ 'dbId' => $trans['dbId'], 'cast' => $trans['cast'] ?? '', 'affected_rows' => $affectedRows, ]); $updated++; continue; } // 거래일시 생성 $transDt = ($trans['transDate'] ?? '').($trans['transTime'] ?? ''); $data = [ 'tenant_id' => $tenantId, 'bank_account_num' => $trans['bankAccountNum'] ?? '', 'bank_code' => $trans['bankCode'] ?? '', 'bank_name' => $trans['bankName'] ?? '', 'trans_date' => $trans['transDate'] ?? '', 'trans_time' => $trans['transTime'] ?? '', 'trans_dt' => $transDt, 'deposit' => floatval($trans['deposit'] ?? 0), 'withdraw' => floatval($trans['withdraw'] ?? 0), 'balance' => floatval($trans['balance'] ?? 0), 'summary' => $trans['summary'] ?? '', 'cast' => $trans['cast'] ?? '', 'memo' => $trans['memo'] ?? '', 'trans_office' => $trans['transOffice'] ?? '', 'account_code' => $trans['accountCode'] ?? null, 'account_name' => $trans['accountName'] ?? null, 'client_code' => $trans['clientCode'] ?? null, 'client_name' => $trans['clientName'] ?? null, ]; // 고유 키 생성 (오버라이드 동기화용) $uniqueKey = implode('|', [ $data['bank_account_num'], $transDt, (int) $data['deposit'], (int) $data['withdraw'], (int) $data['balance'], $data['summary'], ]); $savedUniqueKeys[] = $uniqueKey; // 순수 Query Builder로 Upsert (Eloquent 모델 우회) // balance + summary 포함하여 매칭 → 같은 금액·시간이라도 적요가 다르면 별도 거래로 식별 $existingIds = DB::table('barobill_bank_transactions') ->where('tenant_id', $tenantId) ->where('bank_account_num', $data['bank_account_num']) ->where('trans_dt', $transDt) ->where('deposit', $data['deposit']) ->where('withdraw', $data['withdraw']) ->where('balance', $data['balance']) ->where('summary', $data['summary']) ->orderByDesc('id') ->pluck('id'); if ($existingIds->isNotEmpty()) { $keepId = $existingIds->first(); // 최신 건 유지 // 완전 동일한 중복 건 삭제 if ($existingIds->count() > 1) { DB::table('barobill_bank_transactions') ->whereIn('id', $existingIds->slice(1)->values()) ->delete(); } DB::table('barobill_bank_transactions') ->where('id', $keepId) ->update([ 'summary' => $data['summary'], 'cast' => $data['cast'], 'trans_office' => $data['trans_office'], 'account_code' => $data['account_code'], 'account_name' => $data['account_name'], 'client_code' => $data['client_code'], 'client_name' => $data['client_name'], 'updated_at' => now(), ]); $updated++; } else { DB::table('barobill_bank_transactions')->insert(array_merge($data, [ 'created_at' => now(), 'updated_at' => now(), ])); $saved++; } } // 오버라이드 동기화: 메인 테이블에 저장된 값이 최신이므로 // override의 modified_cast를 제거하여 충돌 방지 if (! empty($savedUniqueKeys)) { $overrides = BankTransactionOverride::forTenant($tenantId) ->whereIn('unique_key', $savedUniqueKeys) ->get(); foreach ($overrides as $override) { if ($override->modified_cast !== null) { if (! empty($override->modified_summary)) { // summary 오버라이드는 유지, cast 오버라이드만 제거 $override->update(['modified_cast' => null]); } else { // summary도 없으면 오버라이드 레코드 삭제 $override->delete(); } } } } 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 { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $startDate = $request->input('startDate', date('Ymd')); $endDate = $request->input('endDate', date('Ymd')); $accountNum = $request->input('accountNum', ''); // DB에서 저장된 데이터 조회 $query = BankTransaction::where('tenant_id', $tenantId) ->whereBetween('trans_date', [$startDate, $endDate]) ->orderBy('trans_date', 'desc') ->orderBy('trans_time', 'desc'); if (! empty($accountNum)) { $query->where('bank_account_num', $accountNum); } $transactions = $query->get(); // 데이터가 없으면 바로빌에서 조회 (저장 안된 경우) if ($transactions->isEmpty()) { return response()->json([ 'success' => false, 'error' => '저장된 데이터가 없습니다. 먼저 데이터를 조회하고 저장해주세요.', ]); } $filename = "입출금내역_{$startDate}_{$endDate}.csv"; return response()->streamDownload(function () use ($transactions) { $handle = fopen('php://output', 'w'); // UTF-8 BOM for Excel fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF)); // 헤더 fputcsv($handle, [ '거래일시', '은행명', '계좌번호', '적요', '입금', '출금', '잔액', '취급점', '상대계좌예금주명', '계정과목코드', '계정과목명', ]); // 데이터 foreach ($transactions as $trans) { $dateTime = ''; if ($trans->trans_date) { $dateTime = substr($trans->trans_date, 0, 4).'-'. substr($trans->trans_date, 4, 2).'-'. substr($trans->trans_date, 6, 2); if ($trans->trans_time) { $dateTime .= ' '.substr($trans->trans_time, 0, 2).':'. substr($trans->trans_time, 2, 2).':'. substr($trans->trans_time, 4, 2); } } fputcsv($handle, [ $dateTime, $trans->bank_name, $trans->bank_account_num, $trans->summary, $trans->deposit, $trans->withdraw, $trans->balance, $trans->trans_office, $trans->cast, $trans->account_code, $trans->account_name, ]); } 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 saveOverride(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $validated = $request->validate([ 'uniqueKey' => 'required|string|max:100', 'modifiedSummary' => 'nullable|string|max:200', 'modifiedCast' => 'nullable|string|max:200', ]); $result = BankTransactionOverride::saveOverride( $tenantId, $validated['uniqueKey'], $validated['modifiedSummary'] ?? null, $validated['modifiedCast'] ?? null ); if ($result === null) { return response()->json([ 'success' => true, 'message' => '오버라이드가 삭제되었습니다.', 'deleted' => true, ]); } return response()->json([ 'success' => true, 'message' => '오버라이드가 저장되었습니다.', 'data' => [ 'id' => $result->id, 'modifiedSummary' => $result->modified_summary, 'modifiedCast' => $result->modified_cast, ], ]); } catch (\Throwable $e) { Log::error('오버라이드 저장 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '저장 오류: '.$e->getMessage(), ], 500); } } /** * 수동 거래 등록 */ public function storeManual(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $validated = $request->validate([ 'bank_account_num' => 'required|string', 'bank_code' => 'nullable|string', 'bank_name' => 'nullable|string', 'trans_date' => 'required|date_format:Ymd', 'trans_time' => 'nullable|string|max:6', 'deposit' => 'required|numeric', 'withdraw' => 'required|numeric', 'balance' => 'nullable|numeric', 'summary' => 'nullable|string', 'cast' => 'nullable|string', 'memo' => 'nullable|string', 'trans_office' => 'nullable|string', 'account_code' => 'nullable|string', 'account_name' => 'nullable|string', ]); $transTime = $validated['trans_time'] ?? '000000'; $transDt = $validated['trans_date'].$transTime; $transaction = BankTransaction::create([ 'tenant_id' => $tenantId, 'bank_account_num' => $validated['bank_account_num'], 'bank_code' => $validated['bank_code'] ?? '', 'bank_name' => $validated['bank_name'] ?? '', 'trans_date' => $validated['trans_date'], 'trans_time' => $transTime, 'trans_dt' => $transDt, 'deposit' => $validated['deposit'], 'withdraw' => $validated['withdraw'], 'balance' => $validated['balance'] ?? 0, 'summary' => $validated['summary'] ?? '', 'cast' => $validated['cast'] ?? '', 'memo' => $validated['memo'] ?? '', 'trans_office' => $validated['trans_office'] ?? '', 'account_code' => $validated['account_code'] ?? null, 'account_name' => $validated['account_name'] ?? null, '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 = BankTransaction::where('id', $id) ->where('tenant_id', $tenantId) ->where('is_manual', true) ->firstOrFail(); $validated = $request->validate([ 'bank_account_num' => 'required|string', 'bank_code' => 'nullable|string', 'bank_name' => 'nullable|string', 'trans_date' => 'required|date_format:Ymd', 'trans_time' => 'nullable|string|max:6', 'deposit' => 'required|numeric', 'withdraw' => 'required|numeric', 'balance' => 'nullable|numeric', 'summary' => 'nullable|string', 'cast' => 'nullable|string', 'memo' => 'nullable|string', 'trans_office' => 'nullable|string', 'account_code' => 'nullable|string', 'account_name' => 'nullable|string', ]); $transTime = $validated['trans_time'] ?? '000000'; $transDt = $validated['trans_date'].$transTime; // 수동 거래 수정: unique key 컬럼(deposit/withdraw/balance)은 제외 // balance는 화면에서 재계산(recalcManualBalances)되므로 DB값 유지 필수 // (프론트에서 재계산된 balance를 보내면 다른 레코드와 unique key 충돌) DB::table('barobill_bank_transactions') ->where('id', $transaction->id) ->update([ 'summary' => $validated['summary'] ?? '', 'cast' => $validated['cast'] ?? '', 'memo' => $validated['memo'] ?? '', 'trans_office' => $validated['trans_office'] ?? '', 'account_code' => $validated['account_code'] ?? null, 'account_name' => $validated['account_name'] ?? null, 'updated_at' => now(), ]); 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 = BankTransaction::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(), ]); } } /** * 분개 내역 조회 */ 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 = BankTransactionSplit::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) { return floatval($s['amount'] ?? 0); }, $splits)); if (abs($originalAmount - $splitTotal) > 0.01) { return response()->json([ 'success' => false, 'error' => "분개 금액 합계({$splitTotal})가 원본 금액({$originalAmount})과 일치하지 않습니다.", ]); } DB::beginTransaction(); BankTransactionSplit::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 = BankTransactionSplit::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(), ]); } } /** * 병합된 로그에서 수동입력 건의 잔액을 직전 거래 기준으로 재계산 * 로그는 날짜 내림차순(DESC) 정렬 상태로 전달됨 */ /** * 조회기간 직전의 마지막 잔액 조회 * API 데이터는 잔액이 정확하므로 그대로 사용, * 수동입력은 잔액이 0일 수 있으므로 입출금 누적으로 계산 */ private function findBaseBalance(int $tenantId, string $startDate, ?string $bankAccountNum = null): ?float { // 조회기간 이전의 모든 거래를 시간순(ASC)으로 조회 $prevTransactions = BankTransaction::where('tenant_id', $tenantId) ->where('trans_date', '<', $startDate) ->when($bankAccountNum, fn ($q) => $q->where('bank_account_num', $bankAccountNum)) ->orderBy('trans_date', 'asc') ->orderBy('trans_time', 'asc') ->get(); if ($prevTransactions->isEmpty()) { return null; } // 시간순으로 순회하며 잔액 추적 $balance = null; foreach ($prevTransactions as $tx) { if (! $tx->is_manual && (float) $tx->balance != 0) { // API 데이터: 바로빌이 제공한 정확한 잔액 사용 $balance = (float) $tx->balance; } else { // 수동입력 또는 잔액 0인 건: 이전 잔액에서 입출금 계산 $prev = $balance ?? 0; $balance = $prev + (float) $tx->deposit - (float) $tx->withdraw; } } return $balance; } /** * API 로그와 수동 로그 병합 (중복 제거) * 수동 거래와 동일한 API 거래가 있으면 API 거래를 제외하고 수동 거래를 유지 * 매칭 기준: 계좌번호 + 거래일시 + 입금액 + 출금액 (잔액 제외 - 수동 거래는 재계산됨) * * @return array ['logs' => array, 'removedDeposit' => float, 'removedWithdraw' => float] */ private function mergeWithDedup(array $apiLogs, array $manualLogs): array { if (empty($manualLogs)) { return ['logs' => $apiLogs, 'removedDeposit' => 0, 'removedWithdraw' => 0]; } // 수동 거래의 매칭 키 생성 (잔액 제외) $manualKeys = []; foreach ($manualLogs as $mLog) { $key = implode('|', [ $mLog['bankAccountNum'] ?? '', ($mLog['transDate'] ?? '').($mLog['transTime'] ?? ''), (int) ($mLog['deposit'] ?? 0), (int) ($mLog['withdraw'] ?? 0), ]); $manualKeys[$key] = true; } // API 로그에서 수동 거래와 중복되는 것 제외 $dedupedApiLogs = []; $removedDeposit = 0; $removedWithdraw = 0; foreach ($apiLogs as $aLog) { $key = implode('|', [ $aLog['bankAccountNum'] ?? '', ($aLog['transDate'] ?? '').($aLog['transTime'] ?? ''), (int) ($aLog['deposit'] ?? 0), (int) ($aLog['withdraw'] ?? 0), ]); if (isset($manualKeys[$key])) { $removedDeposit += (float) ($aLog['deposit'] ?? 0); $removedWithdraw += (float) ($aLog['withdraw'] ?? 0); continue; // 수동 거래가 우선, API 거래 스킵 } $dedupedApiLogs[] = $aLog; } if ($removedDeposit > 0 || $removedWithdraw > 0) { Log::info('[Eaccount] 중복 거래 제거', [ 'count' => count($manualLogs) - count($dedupedApiLogs) + count($apiLogs) - count($manualLogs), 'removedDeposit' => $removedDeposit, 'removedWithdraw' => $removedWithdraw, ]); } return [ 'logs' => array_merge($dedupedApiLogs, $manualLogs), 'removedDeposit' => $removedDeposit, 'removedWithdraw' => $removedWithdraw, ]; } private function recalcManualBalances(array $logs, ?float $baseBalance = null): array { if (empty($logs)) { return $logs; } // 시간순(ASC)으로 뒤집어서 순차 처리 $logs = array_reverse($logs); $prevBalance = $baseBalance; foreach ($logs as &$log) { if (! empty($log['isManual'])) { $deposit = (float) ($log['deposit'] ?? 0); $withdraw = (float) ($log['withdraw'] ?? 0); $newBalance = ($prevBalance !== null ? $prevBalance : 0) + $deposit - $withdraw; $log['balance'] = $newBalance; $log['balanceFormatted'] = number_format($newBalance); } $prevBalance = (float) ($log['balance'] ?? 0); } unset($log); // 다시 내림차순(DESC)으로 복원 return array_reverse($logs); } /** * 수동 입력 건을 API 로그 형식으로 변환 */ private function convertManualToLogs($manualTransactions): array { $logs = []; $totalDeposit = 0; $totalWithdraw = 0; if (! $manualTransactions || $manualTransactions->isEmpty()) { return [ 'logs' => [], 'summary' => [ 'totalDeposit' => 0, 'totalWithdraw' => 0, 'count' => 0, ], ]; } foreach ($manualTransactions as $t) { $deposit = (float) $t->deposit; $withdraw = (float) $t->withdraw; $balance = (float) $t->balance; $totalDeposit += $deposit; $totalWithdraw += $withdraw; $transDt = $t->trans_dt ?? ''; $dateTime = ''; if (! empty($transDt) && strlen($transDt) >= 8) { $dateTime = substr($transDt, 0, 4).'-'.substr($transDt, 4, 2).'-'.substr($transDt, 6, 2); if (strlen($transDt) >= 14) { $dateTime .= ' '.substr($transDt, 8, 2).':'.substr($transDt, 10, 2).':'.substr($transDt, 12, 2); } } $summary = $t->summary ?? ''; $uniqueKey = implode('|', [ $t->bank_account_num, $transDt, (int) $deposit, (int) $withdraw, (int) $balance, $summary, ]); $logs[] = [ 'transDate' => $t->trans_date, 'transTime' => $t->trans_time, 'transDateTime' => $dateTime, 'bankAccountNum' => $t->bank_account_num, 'bankName' => $t->bank_name ?? '', 'deposit' => $deposit, 'withdraw' => $withdraw, 'depositFormatted' => number_format($deposit), 'withdrawFormatted' => number_format($withdraw), 'balance' => $balance, 'balanceFormatted' => number_format($balance), 'summary' => $summary, 'originalSummary' => $summary, 'cast' => $t->cast ?? '', 'originalCast' => $t->cast ?? '', 'memo' => $t->memo ?? '', 'transOffice' => $t->trans_office ?? '', 'accountCode' => $t->account_code ?? '', 'accountName' => $t->account_name ?? '', 'clientCode' => $t->client_code ?? '', 'clientName' => $t->client_name ?? '', 'isSaved' => true, 'isOverridden' => false, 'isManual' => true, 'dbId' => $t->id, 'uniqueKey' => $uniqueKey, ]; } return [ 'logs' => $logs, 'summary' => [ 'totalDeposit' => $totalDeposit, 'totalWithdraw' => $totalWithdraw, 'count' => count($logs), ], ]; } /** * 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(), ]; } } }