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'; } // SoapClient 초기화 $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->baseUrl.'/TI.asmx?WSDL', [ '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.hometax.index')); } // 현재 선택된 테넌트 정보 $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.hometax.index', [ 'certKey' => $this->certKey, 'corpNum' => $this->corpNum, 'isTestMode' => $isTestMode, 'hasSoapClient' => $this->soapClient !== null, 'tenantId' => $tenantId, '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; $this->baseUrl = $config->base_url ?: ($memberTestMode ? 'https://testws.baroservice.com' : 'https://ws.baroservice.com'); // SOAP 클라이언트 재초기화 $this->initSoapClient(); Log::info('[Hometax] 서버 모드 적용', [ 'targetEnv' => $targetEnv, 'certKey' => substr($this->certKey ?? '', 0, 10).'...', 'corpNum' => $this->corpNum, 'baseUrl' => $this->baseUrl, ]); } else { Log::warning('[Hometax] BarobillConfig 없음', ['targetEnv' => $targetEnv]); } } /** * 매출 세금계산서 목록 조회 (GetPeriodTaxInvoiceSalesList) * * 바로빌 API 참고: * - TaxType: 1(과세+영세), 3(면세) 만 가능 (0은 미지원) * - DateType: 3(전송일자) 권장 * - 전체 조회 시 1, 3을 각각 조회하여 합침 */ 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:과세+영세, 3:면세 $dateType = (int) $request->input('dateType', 1); // 1:작성일자, 2:발급일자, 3:전송일자 // 현재 테넌트의 바로빌 회원 정보 조회 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); if (! $barobillMember) { return response()->json([ 'success' => false, 'error' => '바로빌 회원사 정보가 없습니다. 테넌트 설정을 확인해주세요.', ]); } // 테넌트별 서버 모드 적용 if ($barobillMember->server_mode) { $this->applyMemberServerMode($barobillMember); } $userId = $barobillMember->barobill_id ?? ''; if (empty($userId)) { return response()->json([ 'success' => false, 'error' => '바로빌 사용자 ID가 설정되지 않았습니다.', ]); } // TaxType이 0(전체)인 경우 1(과세+영세)과 3(면세)를 각각 조회하여 합침 $taxTypesToQuery = ($taxType === 0) ? [1, 3] : [$taxType]; $allInvoices = []; $totalSummary = ['totalAmount' => 0, 'totalTax' => 0, 'totalSum' => 0, 'count' => 0]; $lastPagination = ['currentPage' => $page, 'countPerPage' => $limit, 'maxPageNum' => 1, 'maxIndex' => 0]; foreach ($taxTypesToQuery as $queryTaxType) { $result = $this->callSoap('GetPeriodTaxInvoiceSalesList', [ 'UserID' => $userId, 'TaxType' => $queryTaxType, // 1: 과세+영세, 3: 면세 'DateType' => $dateType, // 1:작성일자, 2:발급일자, 3:전송일자 'StartDate' => $startDate, 'EndDate' => $endDate, 'CountPerPage' => $limit, 'CurrentPage' => $page, ]); if (! $result['success']) { // 첫 번째 조회 실패 시 에러 반환 if (empty($allInvoices)) { return response()->json([ 'success' => false, 'error' => $result['error'], 'error_code' => $result['error_code'] ?? null, ]); } continue; // 이미 일부 데이터가 있으면 계속 진행 } $resultData = $result['data']; $errorCode = $this->checkErrorCode($resultData); // 에러 코드 체크 (데이터 없음 외의 에러) if ($errorCode && ! in_array($errorCode, [-60005, -60001])) { if (empty($allInvoices)) { return response()->json([ 'success' => false, 'error' => $this->getErrorMessage($errorCode), 'error_code' => $errorCode, ]); } continue; } // 데이터가 있는 경우 파싱 if (! $errorCode || ! in_array($errorCode, [-60005, -60001])) { $parsed = $this->parseInvoices($resultData, 'sales'); $allInvoices = array_merge($allInvoices, $parsed['invoices']); $totalSummary['totalAmount'] += $parsed['summary']['totalAmount']; $totalSummary['totalTax'] += $parsed['summary']['totalTax']; $totalSummary['totalSum'] += $parsed['summary']['totalSum'] ?? ($parsed['summary']['totalAmount'] + $parsed['summary']['totalTax']); $totalSummary['count'] += $parsed['summary']['count']; // 페이지네이션 정보 업데이트 (마지막 조회 결과 사용) $lastPagination = [ 'currentPage' => $resultData->CurrentPage ?? 1, 'countPerPage' => $resultData->CountPerPage ?? 50, 'maxPageNum' => $resultData->MaxPageNum ?? 1, 'maxIndex' => $resultData->MaxIndex ?? 0, ]; } } // 작성일 기준으로 정렬 (최신순) usort($allInvoices, fn ($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? '')); // 마지막 매출 수집 시간 업데이트 $barobillMember->update(['last_sales_fetch_at' => now()]); return response()->json([ 'success' => true, 'data' => [ 'invoices' => $allInvoices, 'pagination' => $lastPagination, 'summary' => $totalSummary, 'lastFetchAt' => now()->format('Y-m-d H:i:s'), ], ]); } catch (\Throwable $e) { Log::error('홈택스 매출 조회 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '서버 오류: '.$e->getMessage(), ]); } } /** * 매입 세금계산서 목록 조회 (GetPeriodTaxInvoicePurchaseList) * * 바로빌 API 참고: * - TaxType: 1(과세+영세), 3(면세) 만 가능 (0은 미지원) * - DateType: 3(전송일자) 권장 * - 전체 조회 시 1, 3을 각각 조회하여 합침 */ 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:과세+영세, 3:면세 $dateType = (int) $request->input('dateType', 1); // 1:작성일자, 2:발급일자, 3:전송일자 // 현재 테넌트의 바로빌 회원 정보 조회 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); if (! $barobillMember) { return response()->json([ 'success' => false, 'error' => '바로빌 회원사 정보가 없습니다. 테넌트 설정을 확인해주세요.', ]); } // 테넌트별 서버 모드 적용 if ($barobillMember->server_mode) { $this->applyMemberServerMode($barobillMember); } $userId = $barobillMember->barobill_id ?? ''; if (empty($userId)) { return response()->json([ 'success' => false, 'error' => '바로빌 사용자 ID가 설정되지 않았습니다.', ]); } // TaxType이 0(전체)인 경우 1(과세+영세)과 3(면세)를 각각 조회하여 합침 $taxTypesToQuery = ($taxType === 0) ? [1, 3] : [$taxType]; $allInvoices = []; $totalSummary = ['totalAmount' => 0, 'totalTax' => 0, 'totalSum' => 0, 'count' => 0]; $lastPagination = ['currentPage' => $page, 'countPerPage' => $limit, 'maxPageNum' => 1, 'maxIndex' => 0]; foreach ($taxTypesToQuery as $queryTaxType) { $result = $this->callSoap('GetPeriodTaxInvoicePurchaseList', [ 'UserID' => $userId, 'TaxType' => $queryTaxType, // 1: 과세+영세, 3: 면세 'DateType' => $dateType, // 1:작성일자, 2:발급일자, 3:전송일자 'StartDate' => $startDate, 'EndDate' => $endDate, 'CountPerPage' => $limit, 'CurrentPage' => $page, ]); if (! $result['success']) { // 첫 번째 조회 실패 시 에러 반환 if (empty($allInvoices)) { return response()->json([ 'success' => false, 'error' => $result['error'], 'error_code' => $result['error_code'] ?? null, ]); } continue; // 이미 일부 데이터가 있으면 계속 진행 } $resultData = $result['data']; $errorCode = $this->checkErrorCode($resultData); // 에러 코드 체크 (데이터 없음 외의 에러) if ($errorCode && ! in_array($errorCode, [-60005, -60001])) { if (empty($allInvoices)) { return response()->json([ 'success' => false, 'error' => $this->getErrorMessage($errorCode), 'error_code' => $errorCode, ]); } continue; } // 데이터가 있는 경우 파싱 if (! $errorCode || ! in_array($errorCode, [-60005, -60001])) { $parsed = $this->parseInvoices($resultData, 'purchase'); $allInvoices = array_merge($allInvoices, $parsed['invoices']); $totalSummary['totalAmount'] += $parsed['summary']['totalAmount']; $totalSummary['totalTax'] += $parsed['summary']['totalTax']; $totalSummary['totalSum'] += $parsed['summary']['totalSum'] ?? ($parsed['summary']['totalAmount'] + $parsed['summary']['totalTax']); $totalSummary['count'] += $parsed['summary']['count']; // 페이지네이션 정보 업데이트 (마지막 조회 결과 사용) $lastPagination = [ 'currentPage' => $resultData->CurrentPage ?? 1, 'countPerPage' => $resultData->CountPerPage ?? 50, 'maxPageNum' => $resultData->MaxPageNum ?? 1, 'maxIndex' => $resultData->MaxIndex ?? 0, ]; } } // 작성일 기준으로 정렬 (최신순) usort($allInvoices, fn ($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? '')); // 마지막 매입 수집 시간 업데이트 $barobillMember->update(['last_purchases_fetch_at' => now()]); return response()->json([ 'success' => true, 'data' => [ 'invoices' => $allInvoices, 'pagination' => $lastPagination, 'summary' => $totalSummary, 'lastFetchAt' => now()->format('Y-m-d H:i:s'), ], ]); } catch (\Throwable $e) { Log::error('홈택스 매입 조회 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '서버 오류: '.$e->getMessage(), ]); } } /** * 홈택스 스크래핑 서비스 등록 URL 조회 * * 바로빌에서 홈택스 스크래핑을 신청하기 위한 URL을 반환합니다. */ public function getScrapRequestUrl(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); if (! $barobillMember) { return response()->json([ 'success' => false, 'error' => '바로빌 회원사 정보가 없습니다.', ]); } $userId = $barobillMember->barobill_id ?? ''; $result = $this->callSoap('GetTaxInvoiceScrapRequestURL', [ 'UserID' => $userId, ]); if (! $result['success']) { return response()->json([ 'success' => false, 'error' => $result['error'], 'error_code' => $result['error_code'] ?? null, ]); } // 결과가 URL 문자열이면 성공 $url = $result['data']; if (is_string($url) && filter_var($url, FILTER_VALIDATE_URL)) { return response()->json([ 'success' => true, 'data' => ['url' => $url], ]); } // 숫자면 에러코드 if (is_numeric($url) && $url < 0) { return response()->json([ 'success' => false, 'error' => $this->getErrorMessage((int) $url), 'error_code' => (int) $url, ]); } return response()->json([ 'success' => true, 'data' => ['url' => (string) $url], ]); } catch (\Throwable $e) { Log::error('홈택스 스크래핑 URL 조회 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '서버 오류: '.$e->getMessage(), ]); } } /** * 홈택스 스크래핑 갱신 요청 * * 홈택스에서 최신 데이터를 다시 수집하도록 요청합니다. */ public function refreshScrap(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); if (! $barobillMember) { return response()->json([ 'success' => false, 'error' => '바로빌 회원사 정보가 없습니다.', ]); } $userId = $barobillMember->barobill_id ?? ''; $result = $this->callSoap('RefreshTaxInvoiceScrap', [ 'UserID' => $userId, ]); if (! $result['success']) { return response()->json([ 'success' => false, 'error' => $result['error'], 'error_code' => $result['error_code'] ?? null, ]); } $code = $result['data']; if (is_numeric($code)) { if ($code < 0) { return response()->json([ 'success' => false, 'error' => $this->getErrorMessage((int) $code), 'error_code' => (int) $code, ]); } return response()->json([ 'success' => true, 'message' => '홈택스 데이터 수집이 요청되었습니다. 잠시 후 다시 조회해주세요.', ]); } return response()->json([ 'success' => true, 'message' => '홈택스 데이터 수집이 요청되었습니다.', ]); } catch (\Throwable $e) { Log::error('홈택스 스크래핑 갱신 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '서버 오류: '.$e->getMessage(), ]); } } /** * 서비스 상태 진단 * * 바로빌 API 연결 및 홈택스 서비스 상태를 확인합니다. */ public function diagnose(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); } $userId = $barobillMember?->barobill_id ?? ''; $memberCorpNum = $barobillMember?->biz_no ?? ''; $diagnostics = [ 'config' => [ 'certKey' => ! empty($this->certKey) ? substr($this->certKey, 0, 8).'...' : '미설정', 'corpNum' => $this->corpNum ?? '미설정', // 파트너사 사업자번호 (API 인증용) 'isTestMode' => $this->isTestMode, 'baseUrl' => $this->baseUrl, ], 'member' => [ 'userId' => $userId ?: '미설정', // 테넌트의 바로빌 ID (API 호출에 사용) 'bizNo' => $memberCorpNum ?: '미설정', // 테넌트 사업자번호 (참고용) 'corpName' => $barobillMember?->corp_name ?? '미설정', ], 'tests' => [], ]; // 테스트 1: 홈택스 스크래핑 URL 조회 (서비스 활성화 확인용) $scrapUrlResult = $this->callSoap('GetTaxInvoiceScrapRequestURL', [ 'UserID' => $userId, ]); $diagnostics['tests']['scrapRequestUrl'] = [ 'method' => 'GetTaxInvoiceScrapRequestURL', 'success' => $scrapUrlResult['success'], 'result' => $scrapUrlResult['success'] ? (is_string($scrapUrlResult['data']) ? '성공 (URL 반환)' : $scrapUrlResult['data']) : ($scrapUrlResult['error'] ?? '오류'), ]; // 테스트 2: 매출 세금계산서 조회 (기간: 최근 1개월) // TaxType: 1(과세+영세), 3(면세) 만 가능 / DateType: 3(전송일자) 권장 $salesResult = $this->callSoap('GetPeriodTaxInvoiceSalesList', [ 'UserID' => $userId, 'TaxType' => 1, // 1: 과세+영세 (0은 미지원) 'DateType' => 3, // 3: 전송일자 기준 (권장) 'StartDate' => date('Ymd', strtotime('-1 month')), 'EndDate' => date('Ymd'), 'CountPerPage' => 1, 'CurrentPage' => 1, ]); $diagnostics['tests']['salesList'] = [ 'method' => 'GetPeriodTaxInvoiceSalesList', 'success' => $salesResult['success'], 'result' => $salesResult['success'] ? ($this->checkErrorCode($salesResult['data']) ? $this->getErrorMessage($this->checkErrorCode($salesResult['data'])) : '성공') : ($salesResult['error'] ?? '오류'), ]; // 테스트 3: 잔액 조회 (기본 연결 및 인증 확인용) $balanceResult = $this->callSoap('GetBalanceCostAmount', []); $diagnostics['tests']['balance'] = [ 'method' => 'GetBalanceCostAmount', 'success' => $balanceResult['success'], 'result' => $balanceResult['success'] ? (is_numeric($balanceResult['data']) && $balanceResult['data'] >= 0 ? '성공 (잔액: '.number_format($balanceResult['data']).'원)' : $balanceResult['data']) : ($balanceResult['error'] ?? '오류'), ]; return response()->json([ 'success' => true, 'data' => $diagnostics, ]); } catch (\Throwable $e) { Log::error('홈택스 서비스 진단 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '서버 오류: '.$e->getMessage(), ]); } } /** * 홈택스 수집 요청 (미지원 안내) */ public function requestCollect(Request $request): JsonResponse { // 홈택스 스크래핑 갱신으로 대체 return $this->refreshScrap($request); } /** * 수집 상태 확인 (마지막 수집 시간 조회) */ public function collectStatus(Request $request): JsonResponse { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); $salesLastFetch = $barobillMember?->last_sales_fetch_at; $purchasesLastFetch = $barobillMember?->last_purchases_fetch_at; return response()->json([ 'success' => true, 'data' => [ 'salesLastCollectDate' => $salesLastFetch ? $salesLastFetch->format('Y-m-d H:i') : '', 'purchaseLastCollectDate' => $purchasesLastFetch ? $purchasesLastFetch->format('Y-m-d H:i') : '', 'isCollecting' => false, 'collectStateText' => ($salesLastFetch || $purchasesLastFetch) ? '조회 완료' : '조회 전', '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가 올바르지 않거나 만료되었습니다.', -10008 => '날짜형식이 잘못되었습니다 (-10008). 날짜는 YYYYMMDD 형식(하이픈 제외)으로 입력해주세요.', -11010 => '과세형태(TaxType)가 잘못되었습니다 (-11010). TaxType은 1(과세+영세) 또는 3(면세)만 가능합니다.', -24005 => 'UserID가 필요합니다 (-24005). 바로빌 회원사 ID를 설정해주세요.', -24006 => '조회된 데이터가 없습니다 (-24006).', -25005 => '조회된 데이터가 없습니다 (-25005).', -26012 => '홈택스 스크래핑 서비스 미신청 (-26012). 바로빌에서 홈택스 매입매출 스크래핑 서비스 신청이 필요합니다.', -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(), ]); } } /** * SoapClient를 통한 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 { // 날짜 파라미터 로깅 (디버깅용) $dateInfo = ''; if (isset($params['StartDate']) || isset($params['EndDate'])) { $dateInfo = ', StartDate: '.($params['StartDate'] ?? 'N/A').', EndDate: '.($params['EndDate'] ?? 'N/A'); } Log::info("바로빌 홈택스 API 호출 (SoapClient) - Method: {$method}, CorpNum: ".($params['CorpNum'] ?? 'N/A').', UserID: '.($params['UserID'] ?? 'N/A').', CERTKEY: '.substr($params['CERTKEY'] ?? '', 0, 10).'...'.$dateInfo); // SoapClient로 호출 $result = $this->soapClient->$method($params); $resultProperty = $method.'Result'; if (! isset($result->$resultProperty)) { return [ 'success' => false, 'error' => '응답 결과를 찾을 수 없습니다.', ]; } $resultData = $result->$resultProperty; // 단순 숫자 응답인 경우 (에러 코드 또는 성공 코드) if (is_numeric($resultData)) { $code = (int) $resultData; if ($code < 0) { return [ 'success' => false, 'error' => $this->getErrorMessage($code), 'error_code' => $code, ]; } return [ 'success' => true, 'data' => $code, ]; } // 문자열 응답 (URL 등) if (is_string($resultData)) { return [ 'success' => true, 'data' => $resultData, ]; } // 객체 응답 (목록 조회 등) return [ 'success' => true, 'data' => $resultData, ]; } catch (\SoapFault $e) { Log::error('바로빌 SOAP 오류: '.$e->getMessage()); return [ 'success' => false, 'error' => 'SOAP 오류: '.$e->getMessage(), ]; } 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', 'http://ws.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; } } elseif ($child->count() > 0) { // 자식이 있는 요소 → 재귀 호출 $result->$name = $this->xmlToObject($child); } else { // 텍스트 노드 $result->$name = (string) $child; } } return $result; } /** * 로컬 DB에서 매출 세금계산서 조회 */ public function localSales(Request $request, HometaxSyncService $syncService): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $startDate = $request->input('startDate'); $endDate = $request->input('endDate'); // YYYYMMDD 형식을 Y-m-d로 변환 if (strlen($startDate) === 8) { $startDate = substr($startDate, 0, 4).'-'.substr($startDate, 4, 2).'-'.substr($startDate, 6, 2); } if (strlen($endDate) === 8) { $endDate = substr($endDate, 0, 4).'-'.substr($endDate, 4, 2).'-'.substr($endDate, 6, 2); } $dateType = $request->input('dateType', 1) == 1 ? 'write' : 'issue'; $searchCorp = $request->input('searchCorp'); $data = $syncService->getLocalInvoices( $tenantId, 'sales', $startDate, $endDate, $dateType, $searchCorp ); return response()->json([ 'success' => true, 'data' => $data, 'lastSyncAt' => $syncService->getLastSyncTime($tenantId, 'sales'), ]); } catch (\Throwable $e) { Log::error('로컬 매출 조회 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '조회 오류: '.$e->getMessage(), ]); } } /** * 로컬 DB에서 매입 세금계산서 조회 */ public function localPurchases(Request $request, HometaxSyncService $syncService): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $startDate = $request->input('startDate'); $endDate = $request->input('endDate'); // YYYYMMDD 형식을 Y-m-d로 변환 if (strlen($startDate) === 8) { $startDate = substr($startDate, 0, 4).'-'.substr($startDate, 4, 2).'-'.substr($startDate, 6, 2); } if (strlen($endDate) === 8) { $endDate = substr($endDate, 0, 4).'-'.substr($endDate, 4, 2).'-'.substr($endDate, 6, 2); } $dateType = $request->input('dateType', 1) == 1 ? 'write' : 'issue'; $searchCorp = $request->input('searchCorp'); $data = $syncService->getLocalInvoices( $tenantId, 'purchase', $startDate, $endDate, $dateType, $searchCorp ); return response()->json([ 'success' => true, 'data' => $data, 'lastSyncAt' => $syncService->getLastSyncTime($tenantId, 'purchase'), ]); } catch (\Throwable $e) { Log::error('로컬 매입 조회 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '조회 오류: '.$e->getMessage(), ]); } } /** * 바로빌 API에서 데이터를 가져와 로컬 DB에 동기화 */ public function sync(Request $request, HometaxSyncService $syncService): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $type = $request->input('type', 'all'); // 'sales', 'purchase', 'all' $startDate = $request->input('startDate', date('Ymd', strtotime('-1 month'))); $endDate = $request->input('endDate', date('Ymd')); $dateType = (int) $request->input('dateType', 1); $results = []; // 매출 동기화 if ($type === 'all' || $type === 'sales') { $salesRequest = new Request([ 'startDate' => $startDate, 'endDate' => $endDate, 'dateType' => $dateType, 'limit' => 500, ]); $salesResponse = $this->sales($salesRequest); $salesData = json_decode($salesResponse->getContent(), true); if ($salesData['success'] && ! empty($salesData['data']['invoices'])) { $results['sales'] = $syncService->syncInvoices( $salesData['data']['invoices'], $tenantId, 'sales' ); } else { $results['sales'] = [ 'inserted' => 0, 'updated' => 0, 'failed' => 0, 'total' => 0, 'error' => $salesData['error'] ?? null, ]; } } // 매입 동기화 if ($type === 'all' || $type === 'purchase') { $purchaseRequest = new Request([ 'startDate' => $startDate, 'endDate' => $endDate, 'dateType' => $dateType, 'limit' => 500, ]); $purchaseResponse = $this->purchases($purchaseRequest); $purchaseData = json_decode($purchaseResponse->getContent(), true); if ($purchaseData['success'] && ! empty($purchaseData['data']['invoices'])) { $results['purchase'] = $syncService->syncInvoices( $purchaseData['data']['invoices'], $tenantId, 'purchase' ); } else { $results['purchase'] = [ 'inserted' => 0, 'updated' => 0, 'failed' => 0, 'total' => 0, 'error' => $purchaseData['error'] ?? null, ]; } } // 총 결과 계산 $totalInserted = ($results['sales']['inserted'] ?? 0) + ($results['purchase']['inserted'] ?? 0); $totalUpdated = ($results['sales']['updated'] ?? 0) + ($results['purchase']['updated'] ?? 0); return response()->json([ 'success' => true, 'message' => "동기화 완료: {$totalInserted}건 추가, {$totalUpdated}건 갱신", 'data' => $results, ]); } catch (\Throwable $e) { Log::error('홈택스 동기화 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '동기화 오류: '.$e->getMessage(), ]); } } /** * 자동 증분 동기화 * 마지막 동기화 시점 이후의 데이터만 바로빌 API에서 가져와 로컬 DB에 저장 */ public function autoSync(Request $request, HometaxSyncService $syncService): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $type = $request->input('type', 'sales'); // 'sales' 또는 'purchase' $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); if (! $barobillMember) { return response()->json(['success' => true, 'skipped' => true, 'reason' => 'no_member']); } // 마지막 동기화 시간 확인 $lastFetchField = $type === 'sales' ? 'last_sales_fetch_at' : 'last_purchases_fetch_at'; $lastFetch = $barobillMember->$lastFetchField; // 10분 이내에 이미 동기화했으면 스킵 if ($lastFetch && $lastFetch->diffInMinutes(now()) < 10) { return response()->json([ 'success' => true, 'skipped' => true, 'reason' => 'recent_sync', 'lastSyncAt' => $syncService->getLastSyncTime($tenantId, $type), 'lastFetchAt' => $lastFetch->format('Y-m-d H:i:s'), ]); } // 증분 범위 계산: 마지막 동기화일 -3일 ~ 오늘 // 전송일자(dateType=3) 기준으로 조회하여 작성일자가 오래된 신규 전송건도 포착 $startDate = $lastFetch ? $lastFetch->copy()->subDays(3)->format('Ymd') : date('Ymd', strtotime('-1 month')); $endDate = date('Ymd'); // 기존 sync 로직 재사용: API 호출 → DB 저장 $apiMethod = $type === 'sales' ? 'sales' : 'purchases'; $apiRequest = new Request([ 'startDate' => $startDate, 'endDate' => $endDate, 'dateType' => 3, // 전송일자 기준 (바로빌 권장, 신규 전송건 누락 방지) 'limit' => 500, ]); $apiResponse = $this->$apiMethod($apiRequest); $apiData = json_decode($apiResponse->getContent(), true); $syncResult = ['inserted' => 0, 'updated' => 0, 'total' => 0]; if ($apiData['success'] && ! empty($apiData['data']['invoices'])) { $syncResult = $syncService->syncInvoices( $apiData['data']['invoices'], $tenantId, $type ); } return response()->json([ 'success' => true, 'skipped' => false, 'data' => $syncResult, 'hasNewData' => ($syncResult['inserted'] ?? 0) > 0, 'lastSyncAt' => $syncService->getLastSyncTime($tenantId, $type), 'lastFetchAt' => now()->format('Y-m-d H:i:s'), ]); } catch (\Throwable $e) { Log::error('홈택스 자동동기화 오류: '.$e->getMessage()); // 자동동기화 실패는 치명적이지 않음 - 로컬 데이터는 정상 표시됨 return response()->json([ 'success' => true, 'skipped' => true, 'reason' => 'error', 'error' => $e->getMessage(), ]); } } /** * 세금계산서 메모 업데이트 */ public function updateMemo(Request $request, HometaxSyncService $syncService): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $id = $request->input('id'); $memo = $request->input('memo'); $success = $syncService->updateMemo($id, $tenantId, $memo); return response()->json([ 'success' => $success, 'message' => $success ? '메모가 저장되었습니다.' : '저장에 실패했습니다.', ]); } catch (\Throwable $e) { return response()->json([ 'success' => false, 'error' => '오류: '.$e->getMessage(), ]); } } /** * 세금계산서 확인 여부 토글 */ public function toggleChecked(Request $request, HometaxSyncService $syncService): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $id = $request->input('id'); $success = $syncService->toggleChecked($id, $tenantId); return response()->json([ 'success' => $success, ]); } catch (\Throwable $e) { return response()->json([ 'success' => false, 'error' => '오류: '.$e->getMessage(), ]); } } /** * 수동 세금계산서 저장 */ public function manualStore(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $validated = $request->validate([ 'invoice_type' => 'required|in:sales,purchase', 'write_date' => 'required|date', 'invoicer_corp_name' => 'nullable|string|max:200', 'invoicer_corp_num' => 'nullable|string|max:20', 'invoicer_ceo_name' => 'nullable|string|max:50', 'invoicer_branch_num' => 'nullable|string|max:10', 'invoicer_address' => 'nullable|string|max:300', 'invoicer_biz_type' => 'nullable|string|max:100', 'invoicer_biz_class' => 'nullable|string|max:100', 'invoicer_email' => 'nullable|string|max:200', 'invoicer_email2' => 'nullable|string|max:200', 'invoicee_corp_name' => 'nullable|string|max:200', 'invoicee_corp_num' => 'nullable|string|max:20', 'invoicee_ceo_name' => 'nullable|string|max:50', 'invoicee_branch_num' => 'nullable|string|max:10', 'invoicee_address' => 'nullable|string|max:300', 'invoicee_biz_type' => 'nullable|string|max:100', 'invoicee_biz_class' => 'nullable|string|max:100', 'invoicee_email' => 'nullable|string|max:200', 'invoicee_email2' => 'nullable|string|max:200', 'supply_amount' => 'required|numeric|min:0', 'tax_amount' => 'nullable|numeric|min:0', 'item_name' => 'nullable|string|max:200', 'remark' => 'nullable|string|max:500', 'tax_type' => 'nullable|integer|in:1,2,3', 'purpose_type' => 'nullable|integer|in:1,2', ], [ 'invoice_type.required' => '매출/매입 구분은 필수입니다.', 'invoice_type.in' => '매출/매입 구분값이 올바르지 않습니다.', 'write_date.required' => '작성일자는 필수입니다.', 'write_date.date' => '작성일자 형식이 올바르지 않습니다.', 'supply_amount.required' => '공급가액은 필수입니다.', 'supply_amount.numeric' => '공급가액은 숫자여야 합니다.', 'supply_amount.min' => '공급가액은 0 이상이어야 합니다.', 'tax_amount.numeric' => '세액은 숫자여야 합니다.', 'tax_amount.min' => '세액은 0 이상이어야 합니다.', ]); // MAN-YYYYMMDD-NNN 형식 자동채번 $dateStr = date('Ymd', strtotime($validated['write_date'])); $lastNum = HometaxInvoice::where('tenant_id', $tenantId) ->where('nts_confirm_num', 'like', "MAN-{$dateStr}-%") ->orderByRaw('CAST(SUBSTRING_INDEX(nts_confirm_num, "-", -1) AS UNSIGNED) DESC') ->value('nts_confirm_num'); $seq = 1; if ($lastNum) { $parts = explode('-', $lastNum); $seq = (int) end($parts) + 1; } $ntsConfirmNum = sprintf('MAN-%s-%03d', $dateStr, $seq); $taxAmount = (float) ($validated['tax_amount'] ?? 0); $totalAmount = (float) $validated['supply_amount'] + $taxAmount; $invoice = HometaxInvoice::create([ 'tenant_id' => $tenantId, 'nts_confirm_num' => $ntsConfirmNum, 'invoice_type' => $validated['invoice_type'], 'write_date' => $validated['write_date'], 'issue_date' => $validated['write_date'], 'invoicer_corp_name' => $validated['invoicer_corp_name'] ?? '', 'invoicer_corp_num' => $validated['invoicer_corp_num'] ?? '', 'invoicer_ceo_name' => $validated['invoicer_ceo_name'] ?? '', 'invoicer_branch_num' => $validated['invoicer_branch_num'] ?? '', 'invoicer_address' => $validated['invoicer_address'] ?? '', 'invoicer_biz_type' => $validated['invoicer_biz_type'] ?? '', 'invoicer_biz_class' => $validated['invoicer_biz_class'] ?? '', 'invoicer_email' => $validated['invoicer_email'] ?? '', 'invoicer_email2' => $validated['invoicer_email2'] ?? '', 'invoicee_corp_name' => $validated['invoicee_corp_name'] ?? '', 'invoicee_corp_num' => $validated['invoicee_corp_num'] ?? '', 'invoicee_ceo_name' => $validated['invoicee_ceo_name'] ?? '', 'invoicee_branch_num' => $validated['invoicee_branch_num'] ?? '', 'invoicee_address' => $validated['invoicee_address'] ?? '', 'invoicee_biz_type' => $validated['invoicee_biz_type'] ?? '', 'invoicee_biz_class' => $validated['invoicee_biz_class'] ?? '', 'invoicee_email' => $validated['invoicee_email'] ?? '', 'invoicee_email2' => $validated['invoicee_email2'] ?? '', 'supply_amount' => $validated['supply_amount'], 'tax_amount' => $taxAmount, 'total_amount' => $totalAmount, 'item_name' => $validated['item_name'] ?? '', 'remark' => $validated['remark'] ?? '', 'tax_type' => $validated['tax_type'] ?? 1, 'purpose_type' => $validated['purpose_type'] ?? 1, 'synced_at' => now(), ]); return response()->json([ 'success' => true, 'message' => '수동 세금계산서가 등록되었습니다.', 'data' => $invoice, ]); } catch (\Illuminate\Validation\ValidationException $e) { return response()->json([ 'success' => false, 'error' => '입력값 오류: '.implode(', ', $e->validator->errors()->all()), ], 422); } catch (\Throwable $e) { Log::error('수동 세금계산서 저장 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '저장 오류: '.$e->getMessage(), ]); } } /** * 수동 세금계산서 수정 (MAN- 건만 가능) */ public function manualUpdate(Request $request, int $id): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $invoice = HometaxInvoice::where('id', $id) ->where('tenant_id', $tenantId) ->firstOrFail(); if (! str_starts_with($invoice->nts_confirm_num, 'MAN-')) { return response()->json([ 'success' => false, 'error' => '수동 입력 건만 수정할 수 있습니다.', ], 403); } $validated = $request->validate([ 'invoice_type' => 'sometimes|in:sales,purchase', 'write_date' => 'sometimes|date', 'invoicer_corp_name' => 'nullable|string|max:200', 'invoicer_corp_num' => 'nullable|string|max:20', 'invoicer_ceo_name' => 'nullable|string|max:50', 'invoicer_branch_num' => 'nullable|string|max:10', 'invoicer_address' => 'nullable|string|max:300', 'invoicer_biz_type' => 'nullable|string|max:100', 'invoicer_biz_class' => 'nullable|string|max:100', 'invoicer_email' => 'nullable|string|max:200', 'invoicer_email2' => 'nullable|string|max:200', 'invoicee_corp_name' => 'nullable|string|max:200', 'invoicee_corp_num' => 'nullable|string|max:20', 'invoicee_ceo_name' => 'nullable|string|max:50', 'invoicee_branch_num' => 'nullable|string|max:10', 'invoicee_address' => 'nullable|string|max:300', 'invoicee_biz_type' => 'nullable|string|max:100', 'invoicee_biz_class' => 'nullable|string|max:100', 'invoicee_email' => 'nullable|string|max:200', 'invoicee_email2' => 'nullable|string|max:200', 'supply_amount' => 'sometimes|numeric|min:0', 'tax_amount' => 'nullable|numeric|min:0', 'item_name' => 'nullable|string|max:200', 'remark' => 'nullable|string|max:500', 'tax_type' => 'nullable|integer|in:1,2,3', 'purpose_type' => 'nullable|integer|in:1,2', ], [ 'invoice_type.in' => '매출/매입 구분값이 올바르지 않습니다.', 'write_date.date' => '작성일자 형식이 올바르지 않습니다.', 'supply_amount.numeric' => '공급가액은 숫자여야 합니다.', 'supply_amount.min' => '공급가액은 0 이상이어야 합니다.', 'tax_amount.numeric' => '세액은 숫자여야 합니다.', 'tax_amount.min' => '세액은 0 이상이어야 합니다.', ]); // nullable 필드 빈 문자열 처리 foreach (['invoicer_corp_name', 'invoicer_corp_num', 'invoicer_ceo_name', 'invoicer_branch_num', 'invoicer_address', 'invoicer_biz_type', 'invoicer_biz_class', 'invoicer_email', 'invoicer_email2', 'invoicee_corp_name', 'invoicee_corp_num', 'invoicee_ceo_name', 'invoicee_branch_num', 'invoicee_address', 'invoicee_biz_type', 'invoicee_biz_class', 'invoicee_email', 'invoicee_email2'] as $field) { if (array_key_exists($field, $validated)) { $validated[$field] = $validated[$field] ?? ''; } } $validated['tax_amount'] = $validated['tax_amount'] ?? 0; $supply = $validated['supply_amount'] ?? $invoice->supply_amount; $tax = $validated['tax_amount'] ?? $invoice->tax_amount; $validated['total_amount'] = (float) $supply + (float) $tax; $invoice->update($validated); return response()->json([ 'success' => true, 'message' => '수정되었습니다.', 'data' => $invoice->fresh(), ]); } 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(), ]); } } /** * 수동 세금계산서 삭제 (MAN- 건만 가능) */ public function manualDestroy(int $id): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $invoice = HometaxInvoice::where('id', $id) ->where('tenant_id', $tenantId) ->firstOrFail(); if (! str_starts_with($invoice->nts_confirm_num, 'MAN-')) { return response()->json([ 'success' => false, 'error' => '수동 입력 건만 삭제할 수 있습니다.', ], 403); } $invoice->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 createJournalEntry(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $validated = $request->validate([ 'invoice_id' => 'required|integer', 'lines' => 'required|array|min:1', 'lines.*.dc_type' => 'required|in:debit,credit', 'lines.*.account_code' => 'required|string', 'lines.*.account_name' => 'required|string', 'lines.*.debit_amount' => 'required|numeric|min:0', 'lines.*.credit_amount' => 'required|numeric|min:0', 'lines.*.description' => 'nullable|string', ]); $invoice = HometaxInvoice::where('id', $validated['invoice_id']) ->where('tenant_id', $tenantId) ->firstOrFail(); $tradingPartner = $invoice->invoice_type === 'sales' ? $invoice->invoicee_corp_name : $invoice->invoicer_corp_name; DB::transaction(function () use ($tenantId, $invoice, $validated, $tradingPartner) { HometaxInvoiceJournal::saveJournals($tenantId, $invoice->id, [ 'nts_confirm_num' => $invoice->nts_confirm_num, 'invoice_type' => $invoice->invoice_type, 'write_date' => $invoice->write_date, 'supply_amount' => $invoice->supply_amount, 'tax_amount' => $invoice->tax_amount, 'total_amount' => $invoice->total_amount, 'trading_partner_name' => $tradingPartner, ], $validated['lines']); }); return response()->json([ 'success' => true, 'message' => '분개가 저장되었습니다.', ]); } catch (\Illuminate\Validation\ValidationException $e) { $errors = $e->errors(); $firstError = collect($errors)->flatten()->first() ?? '입력 데이터가 올바르지 않습니다.'; Log::error('분개 저장 검증 오류', ['errors' => $errors]); return response()->json([ 'success' => false, 'error' => $firstError, 'errors' => $errors, ], 422); } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { return response()->json([ 'success' => false, 'error' => '해당 세금계산서를 찾을 수 없습니다.', ], 404); } catch (\Throwable $e) { Log::error('분개 저장 오류: '.$e->getMessage(), ['trace' => $e->getTraceAsString()]); return response()->json([ 'success' => false, 'error' => '분개 저장 오류: '.$e->getMessage(), ]); } } /** * 특정 인보이스의 분개 조회 */ public function getJournals(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $invoiceId = $request->input('invoice_id'); if (! $invoiceId) { return response()->json(['success' => false, 'error' => 'invoice_id는 필수입니다.'], 422); } $journals = HometaxInvoiceJournal::getByInvoiceId($tenantId, (int) $invoiceId); return response()->json([ 'success' => true, 'data' => $journals->map(function ($j) { return [ 'dc_type' => $j->dc_type, 'account_code' => $j->account_code, 'account_name' => $j->account_name, 'debit_amount' => $j->debit_amount, 'credit_amount' => $j->credit_amount, 'description' => $j->description, ]; }), ]); } catch (\Throwable $e) { Log::error('분개 조회 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '분개 조회 오류: '.$e->getMessage(), ]); } } /** * 특정 인보이스의 분개 삭제 */ public function deleteJournals(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $invoiceId = $request->input('invoice_id'); if (! $invoiceId) { return response()->json(['success' => false, 'error' => 'invoice_id는 필수입니다.'], 422); } $deleted = HometaxInvoiceJournal::deleteJournals($tenantId, (int) $invoiceId); return response()->json([ 'success' => true, 'message' => "분개가 삭제되었습니다. ({$deleted}건)", ]); } catch (\Throwable $e) { Log::error('분개 삭제 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '분개 삭제 오류: '.$e->getMessage(), ]); } } /** * 카드내역 조회 (수동입력 참조용) */ public function cardTransactions(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $startDate = $request->input('startDate', date('Y-m-d', strtotime('-1 month'))); $endDate = $request->input('endDate', date('Y-m-d')); $search = $request->input('search', ''); $query = BarobillCardTransaction::where('tenant_id', $tenantId) ->whereBetween('use_date', [$startDate, $endDate]) ->orderByDesc('use_date') ->orderByDesc('use_time'); if (! empty($search)) { $query->where(function ($q) use ($search) { $q->where('merchant_name', 'like', "%{$search}%") ->orWhere('merchant_biz_num', 'like', "%{$search}%") ->orWhere('approval_num', 'like', "%{$search}%"); }); } $transactions = $query->limit(100)->get()->map(function ($t) { return [ 'id' => $t->id, 'useDate' => $t->use_date, 'useTime' => $t->use_time, 'merchantName' => $t->merchant_name, 'merchantBizNum' => $t->merchant_biz_num, 'approvalNum' => $t->approval_num, 'approvalAmount' => (float) $t->approval_amount, 'approvalAmountFormatted' => number_format($t->approval_amount), 'tax' => (float) ($t->tax ?? 0), 'supplyAmount' => (float) ($t->modified_supply_amount ?: ($t->approval_amount - ($t->tax ?? 0))), 'cardNum' => $t->card_num ? substr($t->card_num, -4) : '', 'cardCompanyName' => $t->card_company_name ?? '', ]; }); return response()->json([ 'success' => true, 'data' => $transactions, ]); } catch (\Throwable $e) { Log::error('카드내역 조회 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'error' => '조회 오류: '.$e->getMessage(), ]); } } }