Files
sam-manage/app/Http/Controllers/Barobill/HometaxController.php

762 lines
28 KiB
PHP

<?php
namespace App\Http\Controllers\Barobill;
use App\Http\Controllers\Controller;
use App\Models\Barobill\BarobillConfig;
use App\Models\Barobill\BarobillMember;
use App\Models\Tenants\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 바로빌 홈택스 매입/매출 조회 컨트롤러
*
* 바로빌에서 발행/수신한 세금계산서 내역을 조회합니다.
*
* @see https://dev.barobill.co.kr/docs/taxinvoice
*/
class HometaxController extends Controller
{
/**
* 바로빌 설정
*/
private ?string $certKey = null;
private ?string $corpNum = null;
private bool $isTestMode = false;
private string $baseUrl = '';
// 바로빌 파트너사 (본사) 테넌트 ID
private const HEADQUARTERS_TENANT_ID = 1;
public function __construct()
{
// DB에서 활성화된 바로빌 설정 조회
$activeConfig = BarobillConfig::where('is_active', true)->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가 올바르지 않거나 만료되었습니다.',
-24005 => '조회 권한이 없습니다 (-24005). 바로빌 회원사 등록을 확인해주세요.',
-24006 => '조회된 데이터가 없습니다 (-24006).',
-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' => 'http://ws.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') . "</{$key}>";
}
return '<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:bar="http://ws.baroservice.com/">
<soap:Body>
<bar:' . $method . '>
' . $paramsXml . '
</bar:' . $method . '>
</soap:Body>
</soap:Envelope>';
}
/**
* 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;
}
} else if ($child->count() > 0) {
// 자식이 있는 요소 → 재귀 호출
$result->$name = $this->xmlToObject($child);
} else {
// 텍스트 노드
$result->$name = (string)$child;
}
}
return $result;
}
}