first(); if ($activeConfig) { $this->certKey = $activeConfig->cert_key; $this->corpNum = $activeConfig->corp_num; $this->isTestMode = $activeConfig->environment === 'test'; $this->soapUrl = $activeConfig->base_url . '/TI.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/TI.asmx?WSDL' : 'https://ws.baroservice.com/TI.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('barobill.etax.index')); } // 현재 선택된 테넌트 정보 $tenantId = session('selected_tenant_id'); $currentTenant = $tenantId ? Tenant::find($tenantId) : null; // 해당 테넌트의 바로빌 회원사 정보 (공급자 정보로 사용) $barobillMember = $tenantId ? BarobillMember::where('tenant_id', $tenantId)->first() : null; // 테넌트별 서버 모드 적용 (회원사 설정 우선) $isTestMode = $barobillMember ? $barobillMember->isTestMode() : $this->isTestMode; // 서버 모드에 따라 SOAP 설정 재초기화 if ($barobillMember && $barobillMember->server_mode) { $this->applyMemberServerMode($barobillMember); } return view('barobill.etax.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 . '/TI.asmx?WSDL'; // SOAP 클라이언트 재초기화 $this->initSoapClient(); Log::info('[Etax] 서버 모드 적용', [ 'targetEnv' => $targetEnv, 'certKey' => substr($this->certKey ?? '', 0, 10) . '...', 'corpNum' => $this->corpNum, 'soapUrl' => $this->soapUrl, ]); } else { Log::warning('[Etax] BarobillConfig 없음', ['targetEnv' => $targetEnv]); } } // 바로빌 파트너사 (본사) 테넌트 ID private const HEADQUARTERS_TENANT_ID = 1; /** * 세금계산서 목록 조회 * - 테넌트 1(본사)이면 모든 세금계산서 표시 * - 다른 테넌트면 해당 테넌트의 세금계산서만 표시 */ public function getInvoices(): JsonResponse { $tenantId = session('selected_tenant_id'); $isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID; // 데이터 파일에서 조회 (실제 구현 시 DB 사용) $dataFile = storage_path('app/barobill/invoices_data.json'); $invoices = []; if (file_exists($dataFile)) { $data = json_decode(file_get_contents($dataFile), true); $allInvoices = $data['invoices'] ?? []; // 본사(테넌트 1)가 아니면 해당 테넌트의 세금계산서만 필터링 if (!$isHeadquarters && $tenantId) { $invoices = array_values(array_filter($allInvoices, function ($invoice) use ($tenantId) { return ($invoice['tenant_id'] ?? null) == $tenantId; })); } else { $invoices = $allInvoices; } } return response()->json([ 'success' => true, 'invoices' => $invoices, 'tenant_id' => $tenantId, 'is_headquarters' => $isHeadquarters, ]); } /** * 세금계산서 발행 */ public function issue(Request $request): JsonResponse { // 테넌트별 서버 모드 적용 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); if ($barobillMember && $barobillMember->server_mode) { $this->applyMemberServerMode($barobillMember); } $input = $request->all(); $useRealAPI = $this->soapClient !== null && ($this->isTestMode || !empty($this->certKey)); $debugInfo = [ 'hasSoapClient' => $this->soapClient !== null, 'hasCertKey' => !empty($this->certKey), 'hasCorpNum' => !empty($this->corpNum), 'isTestMode' => $this->isTestMode, 'willUseRealAPI' => $useRealAPI, ]; if ($useRealAPI) { $apiResult = $this->issueTaxInvoice($input); if ($apiResult['success']) { $mgtKey = $input['issueKey'] ?? 'MGT' . date('YmdHis') . rand(1000, 9999); $newInvoice = $this->createInvoiceRecord($input, $mgtKey, $apiResult['data'] ?? null); $this->saveInvoice($newInvoice); return response()->json([ 'success' => true, 'message' => '세금계산서가 성공적으로 발행되었습니다.', 'data' => [ 'issueKey' => $newInvoice['issueKey'], 'mgtKey' => $mgtKey, 'status' => 'issued', ], 'invoice' => $newInvoice, 'simulation' => false, 'debug' => $debugInfo, ]); } else { return response()->json([ 'success' => false, 'error' => $apiResult['error'] ?? 'API 호출 실패', 'error_code' => $apiResult['error_code'] ?? null, 'debug' => $debugInfo, ], 400); } } else { // 시뮬레이션 모드 $issueKey = 'BARO-' . date('Y') . '-' . str_pad(rand(1, 9999), 4, '0', STR_PAD_LEFT); $newInvoice = $this->createInvoiceRecord($input, $issueKey, null); $this->saveInvoice($newInvoice); return response()->json([ 'success' => true, 'message' => '세금계산서가 성공적으로 발행되었습니다. (시뮬레이션 모드)', 'data' => [ 'issueKey' => $issueKey, 'status' => 'issued', ], 'invoice' => $newInvoice, 'simulation' => true, 'debug' => $debugInfo, 'warning' => '시뮬레이션 모드입니다. 실제 바로빌 API를 호출하려면 CERTKEY를 설정하세요.', ]); } } /** * 세금계산서 국세청 전송 */ public function sendToNts(Request $request): JsonResponse { // 테넌트별 서버 모드 적용 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); if ($barobillMember && $barobillMember->server_mode) { $this->applyMemberServerMode($barobillMember); } $invoiceId = $request->input('invoiceId'); // 인보이스 조회 $dataFile = storage_path('app/barobill/invoices_data.json'); $data = json_decode(file_get_contents($dataFile), true) ?? ['invoices' => []]; $invoice = null; $invoiceIndex = null; foreach ($data['invoices'] as $index => $inv) { if ($inv['id'] === $invoiceId) { $invoice = $inv; $invoiceIndex = $index; break; } } if (!$invoice) { return response()->json([ 'success' => false, 'error' => '세금계산서를 찾을 수 없습니다.', ], 404); } $useRealAPI = $this->soapClient !== null && !empty($this->certKey); if ($useRealAPI && !empty($invoice['mgtKey'])) { $result = $this->callBarobillSOAP('SendToNTS', [ 'CorpNum' => $this->corpNum, 'MgtKey' => $invoice['mgtKey'], ]); if ($result['success']) { $data['invoices'][$invoiceIndex]['status'] = 'sent'; $data['invoices'][$invoiceIndex]['ntsReceiptNo'] = 'NTS-' . date('YmdHis'); $data['invoices'][$invoiceIndex]['sentAt'] = date('Y-m-d'); file_put_contents($dataFile, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); return response()->json([ 'success' => true, 'message' => '국세청 전송이 완료되었습니다.', ]); } else { return response()->json([ 'success' => false, 'error' => $result['error'] ?? '전송 실패', ], 400); } } else { // 시뮬레이션 $data['invoices'][$invoiceIndex]['status'] = 'sent'; $data['invoices'][$invoiceIndex]['ntsReceiptNo'] = 'NTS-SIM-' . date('YmdHis'); $data['invoices'][$invoiceIndex]['sentAt'] = date('Y-m-d'); file_put_contents($dataFile, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); return response()->json([ 'success' => true, 'message' => '국세청 전송이 완료되었습니다. (시뮬레이션)', 'simulation' => true, ]); } } /** * 공급자 기초정보 조회 */ public function getSupplier(): JsonResponse { $tenantId = session('selected_tenant_id'); if (!$tenantId) { return response()->json(['success' => false, 'error' => '테넌트가 선택되지 않았습니다.'], 400); } $member = BarobillMember::where('tenant_id', $tenantId)->first(); if (!$member) { return response()->json(['success' => false, 'error' => '바로빌 회원사 정보가 없습니다.'], 404); } return response()->json([ 'success' => true, 'supplier' => [ 'bizno' => $member->biz_no, 'name' => $member->corp_name ?? '', 'ceo' => $member->ceo_name ?? '', 'addr' => $member->addr ?? '', 'bizType' => $member->biz_type ?? '', 'bizClass' => $member->biz_class ?? '', 'contact' => $member->manager_name ?? '', 'contactPhone' => $member->manager_hp ?? '', 'email' => $member->manager_email ?? '', ], ]); } /** * 공급자 기초정보 수정 */ public function updateSupplier(Request $request): JsonResponse { $tenantId = session('selected_tenant_id'); if (!$tenantId) { return response()->json(['success' => false, 'error' => '테넌트가 선택되지 않았습니다.'], 400); } $member = BarobillMember::where('tenant_id', $tenantId)->first(); if (!$member) { return response()->json(['success' => false, 'error' => '바로빌 회원사 정보가 없습니다.'], 404); } $validated = $request->validate([ 'corp_name' => 'required|string|max:100', 'ceo_name' => 'required|string|max:50', 'addr' => 'nullable|string|max:255', 'biz_type' => 'nullable|string|max:100', 'biz_class' => 'nullable|string|max:100', 'manager_name' => 'nullable|string|max:50', 'manager_email' => 'nullable|email|max:100', 'manager_hp' => 'nullable|string|max:20', ]); $member->update($validated); return response()->json([ 'success' => true, 'message' => '공급자 정보가 수정되었습니다.', 'supplier' => [ 'bizno' => $member->biz_no, 'name' => $member->corp_name ?? '', 'ceo' => $member->ceo_name ?? '', 'addr' => $member->addr ?? '', 'bizType' => $member->biz_type ?? '', 'bizClass' => $member->biz_class ?? '', 'contact' => $member->manager_name ?? '', 'contactPhone' => $member->manager_hp ?? '', 'email' => $member->manager_email ?? '', ], ]); } /** * 세금계산서 삭제 */ public function delete(Request $request): JsonResponse { $invoiceId = $request->input('invoiceId'); $dataFile = storage_path('app/barobill/invoices_data.json'); if (!file_exists($dataFile)) { return response()->json([ 'success' => false, 'error' => '데이터 파일이 없습니다.', ], 404); } $data = json_decode(file_get_contents($dataFile), true) ?? ['invoices' => []]; $originalCount = count($data['invoices']); $data['invoices'] = array_values(array_filter($data['invoices'], fn($inv) => $inv['id'] !== $invoiceId)); if (count($data['invoices']) === $originalCount) { return response()->json([ 'success' => false, 'error' => '세금계산서를 찾을 수 없습니다.', ], 404); } file_put_contents($dataFile, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); return response()->json([ 'success' => true, 'message' => '세금계산서가 삭제되었습니다.', ]); } /** * 바로빌 SOAP API 호출 */ private function callBarobillSOAP(string $method, array $params = []): array { if (!$this->soapClient) { return [ 'success' => false, 'error' => '바로빌 SOAP 클라이언트가 초기화되지 않았습니다.', ]; } if (!isset($params['CERTKEY'])) { $params['CERTKEY'] = $this->certKey; } try { $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' => '바로빌 API 오류 코드: ' . $resultData, 'error_code' => $resultData, ]; } return [ 'success' => true, 'data' => $resultData, ]; } return [ 'success' => true, 'data' => $result, ]; } catch (\SoapFault $e) { return [ 'success' => false, 'error' => 'SOAP 오류: ' . $e->getMessage(), ]; } catch (\Throwable $e) { return [ 'success' => false, 'error' => 'API 호출 오류: ' . $e->getMessage(), ]; } } /** * 세금계산서 발행 API 호출 */ private function issueTaxInvoice(array $invoiceData): array { $mgtKey = $invoiceData['issueKey'] ?? 'MGT' . date('YmdHis') . rand(1000, 9999); $supplyAmt = 0; $vat = 0; foreach ($invoiceData['items'] ?? [] as $item) { $supplyAmt += floatval($item['supplyAmt'] ?? 0); $vat += floatval($item['vat'] ?? 0); } $total = $supplyAmt + $vat; $taxType = $vat == 0 ? 2 : 1; $taxInvoice = [ 'IssueDirection' => 1, // 1: 정발행 'TaxInvoiceType' => 1, // 1: 세금계산서 'ModifyCode' => '', // 수정사유코드 (신규발행시 빈값) 'TaxType' => $taxType, // 1: 과세, 2: 영세, 3: 면세 'TaxCalcType' => 1, // 1: 소계합계 'PurposeType' => 2, // 2: 청구 'WriteDate' => date('Ymd', strtotime($invoiceData['supplyDate'] ?? date('Y-m-d'))), 'AmountTotal' => number_format($supplyAmt, 0, '', ''), 'TaxTotal' => number_format($vat, 0, '', ''), 'TotalAmount' => number_format($total, 0, '', ''), 'Cash' => '0', 'ChkBill' => '0', 'Note' => '0', 'Credit' => number_format($total, 0, '', ''), 'Remark1' => $invoiceData['memo'] ?? '', 'Remark2' => '', 'Remark3' => '', 'Kwon' => '', 'Ho' => '', 'SerialNum' => '', 'InvoicerParty' => [ 'MgtNum' => $mgtKey, 'CorpNum' => $this->corpNum, 'TaxRegID' => '', // 종사업장번호 'CorpName' => $invoiceData['supplierName'] ?? '', 'CEOName' => $invoiceData['supplierCeo'] ?? '', 'Addr' => $invoiceData['supplierAddr'] ?? '', 'BizType' => '', // 업태 'BizClass' => '', // 종목 'ContactID' => $invoiceData['supplierContactId'] ?? 'cbx0913', // 바로빌 담당자 ID (필수) 'ContactName' => $invoiceData['supplierContact'] ?? '', 'TEL' => $invoiceData['supplierTel'] ?? '', 'HP' => '', 'Email' => $invoiceData['supplierEmail'] ?? '', ], 'InvoiceeParty' => [ 'MgtNum' => '', 'CorpNum' => str_replace('-', '', $invoiceData['recipientBizno'] ?? ''), 'TaxRegID' => '', 'CorpName' => $invoiceData['recipientName'] ?? '', 'CEOName' => $invoiceData['recipientCeo'] ?? '', 'Addr' => $invoiceData['recipientAddr'] ?? '', 'BizType' => '', 'BizClass' => '', 'ContactID' => '', 'ContactName' => $invoiceData['recipientContact'] ?? '', 'TEL' => $invoiceData['recipientTel'] ?? '', 'HP' => '', 'Email' => $invoiceData['recipientEmail'] ?? '', ], 'BrokerParty' => [], // 위수탁 거래시에만 사용 'TaxInvoiceTradeLineItems' => ['TaxInvoiceTradeLineItem' => []], ]; $year = substr($invoiceData['supplyDate'] ?? date('Y-m-d'), 0, 4); foreach ($invoiceData['items'] ?? [] as $item) { $month = str_pad($item['month'] ?? '', 2, '0', STR_PAD_LEFT); $day = str_pad($item['day'] ?? '', 2, '0', STR_PAD_LEFT); $purchaseExpiry = ($month && $day && $month !== '00' && $day !== '00') ? $year . $month . $day : ''; $taxInvoice['TaxInvoiceTradeLineItems']['TaxInvoiceTradeLineItem'][] = [ 'PurchaseExpiry' => $purchaseExpiry, // 거래일자 (YYYYMMDD) 'Name' => $item['name'] ?? '', // 품명 'Information' => $item['spec'] ?? '', // 규격 'ChargeableUnit' => $item['qty'] ?? '1', // 수량 'UnitPrice' => number_format(floatval($item['unitPrice'] ?? 0), 0, '', ''), // 단가 'Amount' => number_format(floatval($item['supplyAmt'] ?? 0), 0, '', ''), // 공급가액 'Tax' => number_format(floatval($item['vat'] ?? 0), 0, '', ''), // 부가세 'Description' => $item['description'] ?? '', // 비고 ]; } return $this->callBarobillSOAP('RegistAndIssueTaxInvoice', [ 'CorpNum' => $this->corpNum, 'Invoice' => $taxInvoice, 'SendSMS' => false, 'ForceIssue' => false, 'MailTitle' => '', ]); } /** * 인보이스 레코드 생성 */ private function createInvoiceRecord(array $input, string $issueKey, $apiData): array { return [ 'id' => 'inv_' . time() . '_' . rand(1000, 9999), 'tenant_id' => session('selected_tenant_id'), // 테넌트별 필터링용 'issueKey' => $issueKey, 'mgtKey' => $issueKey, 'supplierBizno' => $input['supplierBizno'] ?? '', 'supplierName' => $input['supplierName'] ?? '', 'supplierCeo' => $input['supplierCeo'] ?? '', 'supplierAddr' => $input['supplierAddr'] ?? '', 'supplierContact' => $input['supplierContact'] ?? '', 'supplierEmail' => $input['supplierEmail'] ?? '', 'recipientBizno' => $input['recipientBizno'] ?? '', 'recipientName' => $input['recipientName'] ?? '', 'recipientCeo' => $input['recipientCeo'] ?? '', 'recipientAddr' => $input['recipientAddr'] ?? '', 'recipientContact' => $input['recipientContact'] ?? '', 'recipientEmail' => $input['recipientEmail'] ?? '', 'supplyDate' => $input['supplyDate'] ?? date('Y-m-d'), 'items' => $input['items'] ?? [], 'totalSupplyAmt' => $input['totalSupplyAmt'] ?? 0, 'totalVat' => $input['totalVat'] ?? 0, 'total' => $input['total'] ?? 0, 'status' => 'issued', 'memo' => $input['memo'] ?? '', 'createdAt' => date('Y-m-d\TH:i:s'), 'sentAt' => date('Y-m-d'), 'barobillInvoiceId' => is_numeric($apiData) ? (string)$apiData : '', ]; } /** * 인보이스 저장 */ private function saveInvoice(array $invoice): bool { $dataDir = storage_path('app/barobill'); if (!is_dir($dataDir)) { mkdir($dataDir, 0755, true); } $dataFile = $dataDir . '/invoices_data.json'; $existingData = ['invoices' => []]; if (file_exists($dataFile)) { $content = file_get_contents($dataFile); if ($content) { $existingData = json_decode($content, true) ?? ['invoices' => []]; } } $existingData['invoices'][] = $invoice; if (count($existingData['invoices']) > 100) { $existingData['invoices'] = array_slice($existingData['invoices'], -100); } return file_put_contents($dataFile, json_encode($existingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) !== false; } }