first(); if ($activeConfig) { $this->certKey = $activeConfig->cert_key; $this->corpNum = $activeConfig->corp_num; $this->isTestMode = $activeConfig->environment === 'test'; $this->baseUrl = $this->isTestMode ? 'https://testws.baroservice.com' : 'https://ws.baroservice.com'; } 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->baseUrl = $this->isTestMode ? 'https://testws.baroservice.com' : 'https://ws.baroservice.com'; } } /** * 홈택스 매입/매출 메인 페이지 */ public function index(Request $request): View|Response { if ($request->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('barobill.hometax.index')); } // 현재 선택된 테넌트 정보 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $currentTenant = Tenant::find($tenantId); // 해당 테넌트의 바로빌 회원사 정보 $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); return view('barobill.hometax.index', [ 'certKey' => $this->certKey, 'corpNum' => $this->corpNum, 'isTestMode' => $this->isTestMode, 'hasSoapClient' => !empty($this->certKey) || $this->isTestMode, 'currentTenant' => $currentTenant, 'barobillMember' => $barobillMember, ]); } /** * 매출 세금계산서 목록 조회 (GetPeriodTaxInvoiceSalesList) */ public function sales(Request $request): JsonResponse { try { $startDate = $request->input('startDate', date('Ymd', strtotime('-1 month'))); $endDate = $request->input('endDate', date('Ymd')); $page = (int)$request->input('page', 1); $limit = (int)$request->input('limit', 50); $taxType = (int)$request->input('taxType', 0); // 0:전체, 1:과세, 2:영세, 3:면세 // 현재 테넌트의 바로빌 회원 정보 조회 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); $userId = $barobillMember?->barobill_id ?? ''; $result = $this->callSoap('GetPeriodTaxInvoiceSalesList', [ 'UserID' => $userId, 'TaxType' => $taxType, 'DateType' => 1, // 1:작성일 기준 'StartDate' => $startDate, 'EndDate' => $endDate, 'CountPerPage' => $limit, 'CurrentPage' => $page ]); if (!$result['success']) { return response()->json([ 'success' => false, 'error' => $result['error'], 'error_code' => $result['error_code'] ?? null ]); } $resultData = $result['data']; // 에러 코드 체크 $errorCode = $this->checkErrorCode($resultData); if ($errorCode && !in_array($errorCode, [-60005, -60001])) { return response()->json([ 'success' => false, 'error' => $this->getErrorMessage($errorCode), 'error_code' => $errorCode ]); } // 데이터가 없는 경우 if ($errorCode && in_array($errorCode, [-60005, -60001])) { return response()->json([ 'success' => true, 'data' => [ 'invoices' => [], 'summary' => ['totalAmount' => 0, 'totalTax' => 0, 'count' => 0], 'pagination' => ['currentPage' => 1, 'maxPageNum' => 1] ] ]); } // 데이터 파싱 $parsed = $this->parseInvoices($resultData, 'sales'); return response()->json([ 'success' => true, 'data' => [ 'invoices' => $parsed['invoices'], 'pagination' => [ 'currentPage' => $resultData->CurrentPage ?? 1, 'countPerPage' => $resultData->CountPerPage ?? 50, 'maxPageNum' => $resultData->MaxPageNum ?? 1, 'maxIndex' => $resultData->MaxIndex ?? 0 ], 'summary' => $parsed['summary'] ] ]); } catch (\Throwable $e) { Log::error('홈택스 매출 조회 오류: ' . $e->getMessage()); return response()->json([ 'success' => false, 'error' => '서버 오류: ' . $e->getMessage() ]); } } /** * 매입 세금계산서 목록 조회 (GetPeriodTaxInvoicePurchaseList) */ public function purchases(Request $request): JsonResponse { try { $startDate = $request->input('startDate', date('Ymd', strtotime('-1 month'))); $endDate = $request->input('endDate', date('Ymd')); $page = (int)$request->input('page', 1); $limit = (int)$request->input('limit', 50); $taxType = (int)$request->input('taxType', 0); // 0:전체, 1:과세, 2:영세, 3:면세 // 현재 테넌트의 바로빌 회원 정보 조회 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); $userId = $barobillMember?->barobill_id ?? ''; $result = $this->callSoap('GetPeriodTaxInvoicePurchaseList', [ 'UserID' => $userId, 'TaxType' => $taxType, 'DateType' => 1, // 1:작성일 기준 'StartDate' => $startDate, 'EndDate' => $endDate, 'CountPerPage' => $limit, 'CurrentPage' => $page ]); if (!$result['success']) { return response()->json([ 'success' => false, 'error' => $result['error'], 'error_code' => $result['error_code'] ?? null ]); } $resultData = $result['data']; // 에러 코드 체크 $errorCode = $this->checkErrorCode($resultData); if ($errorCode && !in_array($errorCode, [-60005, -60001])) { return response()->json([ 'success' => false, 'error' => $this->getErrorMessage($errorCode), 'error_code' => $errorCode ]); } // 데이터가 없는 경우 if ($errorCode && in_array($errorCode, [-60005, -60001])) { return response()->json([ 'success' => true, 'data' => [ 'invoices' => [], 'summary' => ['totalAmount' => 0, 'totalTax' => 0, 'count' => 0], 'pagination' => ['currentPage' => 1, 'maxPageNum' => 1] ] ]); } // 데이터 파싱 $parsed = $this->parseInvoices($resultData, 'purchase'); return response()->json([ 'success' => true, 'data' => [ 'invoices' => $parsed['invoices'], 'pagination' => [ 'currentPage' => $resultData->CurrentPage ?? 1, 'countPerPage' => $resultData->CountPerPage ?? 50, 'maxPageNum' => $resultData->MaxPageNum ?? 1, 'maxIndex' => $resultData->MaxIndex ?? 0 ], 'summary' => $parsed['summary'] ] ]); } catch (\Throwable $e) { Log::error('홈택스 매입 조회 오류: ' . $e->getMessage()); return response()->json([ 'success' => false, 'error' => '서버 오류: ' . $e->getMessage() ]); } } /** * 홈택스 수집 요청 * * 참고: 홈택스 수집 API는 별도 서비스 구독이 필요할 수 있습니다. * 현재는 바로빌에 등록된 세금계산서만 조회합니다. */ public function requestCollect(Request $request): JsonResponse { // 홈택스 수집 API는 별도 구독 필요 - 현재 미지원 안내 return response()->json([ 'success' => false, 'error' => '홈택스 수집 기능은 별도 서비스 구독이 필요합니다. 바로빌에 등록된 세금계산서는 매출/매입 탭에서 조회 가능합니다.' ]); } /** * 수집 상태 확인 * * 참고: 홈택스 수집 상태 API는 별도 서비스 구독이 필요할 수 있습니다. */ public function collectStatus(Request $request): JsonResponse { // 홈택스 수집 상태 API는 별도 구독 필요 - 현재 미지원 안내 return response()->json([ 'success' => true, 'data' => [ 'salesLastCollectDate' => '', 'purchaseLastCollectDate' => '', 'isCollecting' => false, 'collectStateText' => '미지원', 'message' => '홈택스 수집 기능은 별도 서비스 구독이 필요합니다.' ] ]); } /** * SOAP 객체에서 안전하게 속성 가져오기 */ private function getProperty(object $obj, string $prop, mixed $default = ''): mixed { return property_exists($obj, $prop) ? $obj->$prop : $default; } /** * 세금계산서 파싱 (PagedTaxInvoiceEx -> SimpleTaxInvoiceEx) */ private function parseInvoices($resultData, string $type = 'sales'): array { $invoices = []; $totalAmount = 0; $totalTax = 0; $rawList = []; // PagedTaxInvoiceEx 응답 구조: SimpleTaxInvoiceExList -> SimpleTaxInvoiceEx if (isset($resultData->SimpleTaxInvoiceExList) && isset($resultData->SimpleTaxInvoiceExList->SimpleTaxInvoiceEx)) { $rawList = is_array($resultData->SimpleTaxInvoiceExList->SimpleTaxInvoiceEx) ? $resultData->SimpleTaxInvoiceExList->SimpleTaxInvoiceEx : [$resultData->SimpleTaxInvoiceExList->SimpleTaxInvoiceEx]; } foreach ($rawList as $item) { // SimpleTaxInvoiceEx는 AmountTotal, TaxTotal, TotalAmount 사용 $supplyAmount = floatval($this->getProperty($item, 'AmountTotal', 0)); $taxAmount = floatval($this->getProperty($item, 'TaxTotal', 0)); $total = floatval($this->getProperty($item, 'TotalAmount', 0)); if ($total == 0) { $total = $supplyAmount + $taxAmount; } $totalAmount += $supplyAmount; $totalTax += $taxAmount; // 날짜 포맷팅 - WriteDate 또는 IssueDT 사용 $writeDate = $this->getProperty($item, 'WriteDate', ''); if (empty($writeDate)) { $writeDate = $this->getProperty($item, 'IssueDT', ''); } $formattedDate = ''; if (!empty($writeDate) && strlen($writeDate) >= 8) { $formattedDate = substr($writeDate, 0, 4) . '-' . substr($writeDate, 4, 2) . '-' . substr($writeDate, 6, 2); } // 과세유형 (int: 1=과세, 2=영세, 3=면세) $taxType = $this->getProperty($item, 'TaxType', ''); // 영수/청구 (int: 1=영수, 2=청구) $purposeType = $this->getProperty($item, 'PurposeType', ''); $invoices[] = [ 'ntsConfirmNum' => $this->getProperty($item, 'NTSSendKey', ''), 'writeDate' => $writeDate, 'writeDateFormatted' => $formattedDate, 'issueDT' => $this->getProperty($item, 'IssueDT', ''), 'invoicerCorpNum' => $this->getProperty($item, 'InvoicerCorpNum', ''), 'invoicerCorpName' => $this->getProperty($item, 'InvoicerCorpName', ''), 'invoicerCEOName' => $this->getProperty($item, 'InvoicerCEOName', ''), 'invoiceeCorpNum' => $this->getProperty($item, 'InvoiceeCorpNum', ''), 'invoiceeCorpName' => $this->getProperty($item, 'InvoiceeCorpName', ''), 'invoiceeCEOName' => $this->getProperty($item, 'InvoiceeCEOName', ''), 'supplyAmount' => $supplyAmount, 'supplyAmountFormatted' => number_format($supplyAmount), 'taxAmount' => $taxAmount, 'taxAmountFormatted' => number_format($taxAmount), 'totalAmount' => $total, 'totalAmountFormatted' => number_format($total), 'taxType' => $taxType, 'taxTypeName' => $this->getTaxTypeName($taxType), 'purposeType' => $purposeType, 'purposeTypeName' => $this->getPurposeTypeName($purposeType), 'modifyCode' => $this->getProperty($item, 'ModifyCode', ''), 'remark' => $this->getProperty($item, 'Remark1', ''), 'itemName' => $this->getProperty($item, 'ItemName', ''), ]; } return [ 'invoices' => $invoices, 'summary' => [ 'totalAmount' => $totalAmount, 'totalTax' => $totalTax, 'totalSum' => $totalAmount + $totalTax, 'count' => count($invoices) ] ]; } /** * 에러 코드 체크 */ private function checkErrorCode($data): ?int { if (isset($data->CurrentPage) && is_numeric($data->CurrentPage) && $data->CurrentPage < 0) { return (int)$data->CurrentPage; } return null; } /** * 에러 메시지 반환 */ private function getErrorMessage(int $errorCode): string { $messages = [ -10002 => '인증 실패 (-10002). CERTKEY가 올바르지 않거나 만료되었습니다.', -60001 => '등록된 홈택스 정보가 없습니다 (-60001).', -60002 => '홈택스 인증서가 등록되지 않았습니다 (-60002).', -60003 => '홈택스 수집 서비스가 활성화되지 않았습니다 (-60003).', -60004 => '홈택스 부서사용자 ID가 등록되지 않았습니다 (-60004).', -60005 => '조회된 데이터가 없습니다 (-60005).', -60010 => '홈택스 로그인 실패 (-60010). 부서사용자 ID/비밀번호를 확인해주세요.', -60011 => '홈택스 데이터 수집 중입니다 (-60011). 잠시 후 다시 조회해주세요.', ]; return $messages[$errorCode] ?? '바로빌 API 오류: ' . $errorCode; } /** * 수집 유형 코드 반환 */ private function getCollectTypeCode(string $type): int { return match($type) { 'sales' => 1, 'purchase' => 2, default => 0 // all }; } /** * 과세유형 코드 -> 명칭 (1:과세, 2:영세, 3:면세) */ private function getTaxTypeName(mixed $code): string { $code = (string)$code; return match($code) { '1', '01' => '과세', '2', '02' => '영세', '3', '03' => '면세', default => $code ?: '-' }; } /** * 발급유형 코드 -> 명칭 */ private function getIssueTypeName(mixed $code): string { $code = (string)$code; return match($code) { '1', '01' => '정발행', '2', '02' => '역발행', '3', '03' => '위수탁', default => $code ?: '-' }; } /** * 영수/청구 코드 -> 명칭 (1:영수, 2:청구) */ private function getPurposeTypeName(mixed $code): string { $code = (string)$code; return match($code) { '1', '01' => '영수', '2', '02' => '청구', default => $code ?: '-' }; } /** * 수집 상태 파싱 */ private function parseCollectState($data): array { return [ 'salesLastCollectDate' => $data->SalesLastCollectDate ?? '', 'purchaseLastCollectDate' => $data->PurchaseLastCollectDate ?? '', 'isCollecting' => ($data->CollectState ?? 0) == 1, 'collectStateText' => ($data->CollectState ?? 0) == 1 ? '수집 중' : '대기', ]; } /** * 엑셀 다운로드 */ public function exportExcel(Request $request): StreamedResponse|JsonResponse { try { $type = $request->input('type', 'sales'); // sales or purchase $invoices = $request->input('invoices', []); if (empty($invoices)) { return response()->json([ 'success' => false, 'error' => '저장할 데이터가 없습니다.' ]); } $typeName = $type === 'sales' ? '매출' : '매입'; $filename = "홈택스_{$typeName}_" . date('Ymd_His') . ".csv"; return response()->streamDownload(function () use ($invoices, $type) { $handle = fopen('php://output', 'w'); // UTF-8 BOM fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF)); // 헤더 if ($type === 'sales') { fputcsv($handle, [ '작성일', '국세청승인번호', '공급받는자 사업자번호', '공급받는자 상호', '공급가액', '세액', '합계', '과세유형', '발급유형', '영수/청구' ]); } else { fputcsv($handle, [ '작성일', '국세청승인번호', '공급자 사업자번호', '공급자 상호', '공급가액', '세액', '합계', '과세유형', '발급유형', '영수/청구' ]); } // 데이터 foreach ($invoices as $inv) { if ($type === 'sales') { fputcsv($handle, [ $inv['writeDateFormatted'] ?? '', $inv['ntsConfirmNum'] ?? '', $inv['invoiceeCorpNum'] ?? '', $inv['invoiceeCorpName'] ?? '', $inv['supplyAmount'] ?? 0, $inv['taxAmount'] ?? 0, $inv['totalAmount'] ?? 0, $inv['taxTypeName'] ?? '', $inv['issueTypeName'] ?? '', $inv['purposeTypeName'] ?? '' ]); } else { fputcsv($handle, [ $inv['writeDateFormatted'] ?? '', $inv['ntsConfirmNum'] ?? '', $inv['invoicerCorpNum'] ?? '', $inv['invoicerCorpName'] ?? '', $inv['supplyAmount'] ?? 0, $inv['taxAmount'] ?? 0, $inv['totalAmount'] ?? 0, $inv['taxTypeName'] ?? '', $inv['issueTypeName'] ?? '', $inv['purposeTypeName'] ?? '' ]); } } 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() ]); } } /** * HTTP를 통한 SOAP 호출 (SoapClient 대신 직접 HTTP 요청) */ private function callSoap(string $method, array $params = []): array { 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 호출 (HTTP) - Method: {$method}, CorpNum: {$this->corpNum}"); // SOAP 요청 XML 생성 $soapXml = $this->buildSoapRequest($method, $params); // HTTP 요청 $response = Http::withOptions([ 'verify' => false, ]) ->withHeaders([ 'Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => 'https://www.baroservice.com/' . $method, ]) ->withBody($soapXml, 'text/xml') ->timeout(30) ->post($this->baseUrl . '/TI.asmx'); if (!$response->successful()) { return [ 'success' => false, 'error' => 'HTTP 오류: ' . $response->status() ]; } $xmlResponse = $response->body(); Log::debug("SOAP 응답 길이: " . strlen($xmlResponse)); // XML 응답 파싱 return $this->parseSoapResponse($xmlResponse, $method); } catch (\Throwable $e) { Log::error('바로빌 API 호출 오류: ' . $e->getMessage()); return [ 'success' => false, 'error' => 'API 호출 오류: ' . $e->getMessage() ]; } } /** * SOAP 요청 XML 생성 */ private function buildSoapRequest(string $method, array $params): string { $paramsXml = ''; foreach ($params as $key => $value) { $paramsXml .= "<{$key}>" . htmlspecialchars((string)$value, ENT_XML1, 'UTF-8') . ""; } return ' ' . $paramsXml . ' '; } /** * SOAP 응답 XML 파싱 */ private function parseSoapResponse(string $xmlResponse, string $method): array { try { // XML 파싱 $xml = simplexml_load_string($xmlResponse, 'SimpleXMLElement', LIBXML_NOCDATA); if ($xml === false) { return [ 'success' => false, 'error' => 'XML 파싱 실패' ]; } // 네임스페이스 등록 $xml->registerXPathNamespace('soap', 'http://schemas.xmlsoap.org/soap/envelope/'); $xml->registerXPathNamespace('bar', 'https://www.baroservice.com/'); // 결과 노드 찾기 $resultNodes = $xml->xpath("//bar:{$method}Response/bar:{$method}Result"); if (empty($resultNodes)) { // 네임스페이스 없이 다시 시도 $resultNodes = $xml->xpath("//*[local-name()='{$method}Response']/*[local-name()='{$method}Result']"); } if (empty($resultNodes)) { Log::warning("응답에서 {$method}Result를 찾을 수 없음"); return [ 'success' => false, 'error' => '응답 결과를 찾을 수 없습니다.' ]; } $resultNode = $resultNodes[0]; // 단순 숫자 응답인 경우 (에러 코드) $textContent = trim((string)$resultNode); if (is_numeric($textContent) && $resultNode->count() === 0) { $code = (int)$textContent; if ($code < 0) { return [ 'success' => false, 'error' => $this->getErrorMessage($code), 'error_code' => $code ]; } return [ 'success' => true, 'data' => $code ]; } // 복잡한 객체 응답 파싱 $resultData = $this->xmlToObject($resultNode); return [ 'success' => true, 'data' => $resultData ]; } catch (\Throwable $e) { Log::error('SOAP 응답 파싱 오류: ' . $e->getMessage()); return [ 'success' => false, 'error' => 'XML 파싱 오류: ' . $e->getMessage() ]; } } /** * SimpleXMLElement를 stdClass로 변환 */ private function xmlToObject(\SimpleXMLElement $xml): object { $result = new \stdClass(); // 속성 처리 foreach ($xml->attributes() as $attrName => $attrValue) { $result->$attrName = (string)$attrValue; } // 자식 요소 처리 $children = $xml->children(); $childNames = []; foreach ($children as $name => $child) { $childNames[$name] = ($childNames[$name] ?? 0) + 1; } foreach ($children as $name => $child) { if ($childNames[$name] > 1) { // 여러 개의 동일 이름 요소 → 배열 if (!isset($result->$name)) { $result->$name = []; } if ($child->count() > 0) { $result->{$name}[] = $this->xmlToObject($child); } else { $result->{$name}[] = (string)$child; } } else if ($child->count() > 0) { // 자식이 있는 요소 → 재귀 호출 $result->$name = $this->xmlToObject($child); } else { // 텍스트 노드 $result->$name = (string)$child; } } return $result; } }