1405 lines
55 KiB
PHP
1405 lines
55 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 App\Services\Barobill\HometaxSyncService;
|
|
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 = '';
|
|
private ?\SoapClient $soapClient = null;
|
|
|
|
// 바로빌 파트너사 (본사) 테넌트 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';
|
|
}
|
|
|
|
// 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') . "</{$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;
|
|
}
|
|
|
|
/**
|
|
* 로컬 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()
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 세금계산서 메모 업데이트
|
|
*/
|
|
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()
|
|
]);
|
|
}
|
|
}
|
|
}
|