Files
sam-manage/app/Http/Controllers/Barobill/EtaxController.php
pro ff64612a05 feat:전자세금계산서 테넌트별 필터링 추가
- 세금계산서 발행 시 tenant_id 저장
- 조회 시 현재 테넌트의 세금계산서만 표시
- 테넌트 1(본사)이면 모든 세금계산서 표시

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:59:14 +09:00

518 lines
19 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\Log;
use Illuminate\View\View;
/**
* 바로빌 전자세금계산서 컨트롤러
*/
class EtaxController extends Controller
{
/**
* 바로빌 SOAP 설정
*/
private ?string $certKey = null;
private ?string $corpNum = null;
private bool $isTestMode = false;
private ?string $soapUrl = null;
private ?\SoapClient $soapClient = null;
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->soapUrl = $activeConfig->base_url . '/TI.asmx?WSDL';
} else {
// 설정이 없으면 기본값 사용
$this->certKey = config('services.barobill.cert_key', '');
$this->corpNum = config('services.barobill.corp_num', '');
$this->isTestMode = config('services.barobill.test_mode', true);
$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 {
$this->soapClient = new \SoapClient($this->soapUrl, [
'trace' => true,
'encoding' => 'UTF-8',
'exceptions' => true,
'connection_timeout' => 30
]);
} 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;
return view('barobill.etax.index', [
'certKey' => $this->certKey,
'corpNum' => $this->corpNum,
'isTestMode' => $this->isTestMode,
'hasSoapClient' => $this->soapClient !== null,
'currentTenant' => $currentTenant,
'barobillMember' => $barobillMember,
]);
}
// 바로빌 파트너사 (본사) 테넌트 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
{
$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
{
$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');
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');
file_put_contents($dataFile, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
return response()->json([
'success' => true,
'message' => '국세청 전송이 완료되었습니다. (시뮬레이션)',
'simulation' => true,
]);
}
}
/**
* 세금계산서 삭제
*/
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' => []],
];
foreach ($invoiceData['items'] ?? [] as $item) {
$taxInvoice['TaxInvoiceTradeLineItems']['TaxInvoiceTradeLineItem'][] = [
'PurchaseExpiry' => '', // 공제기한
'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'),
'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;
}
}