feat:홈택스 매입/매출 조회 기능 구현
- HometaxController 생성 (HOMETAX.asmx SOAP API 연동) - 매출 세금계산서 목록 조회 (GetHomeTaxTIBySalesEx) - 매입 세금계산서 목록 조회 (GetHomeTaxTIByPurchaseEx) - 홈택스 수집 요청 및 상태 조회 기능 - React 기반 UI (매출/매입 탭 전환, 통계 대시보드) - CSV 엑셀 다운로드 기능 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
680
app/Http/Controllers/Barobill/HometaxController.php
Normal file
680
app/Http/Controllers/Barobill/HometaxController.php
Normal file
@@ -0,0 +1,680 @@
|
||||
<?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;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* 바로빌 홈택스 매입/매출 조회 컨트롤러
|
||||
*
|
||||
* 홈택스에 신고된 세금계산서 및 현금영수증 내역을 조회합니다.
|
||||
*
|
||||
* @see https://dev.barobill.co.kr/services/hometax
|
||||
*/
|
||||
class HometaxController extends Controller
|
||||
{
|
||||
/**
|
||||
* 바로빌 SOAP 설정
|
||||
*/
|
||||
private ?string $certKey = null;
|
||||
private ?string $corpNum = null;
|
||||
private bool $isTestMode = false;
|
||||
private ?string $soapUrl = null;
|
||||
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';
|
||||
// 홈택스 조회는 HOMETAX.asmx 사용
|
||||
$baseUrl = $this->isTestMode
|
||||
? 'https://testws.baroservice.com'
|
||||
: 'https://ws.baroservice.com';
|
||||
$this->soapUrl = $baseUrl . '/HOMETAX.asmx?WSDL';
|
||||
} 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->soapUrl = $this->isTestMode
|
||||
? 'https://testws.baroservice.com/HOMETAX.asmx?WSDL'
|
||||
: 'https://ws.baroservice.com/HOMETAX.asmx?WSDL';
|
||||
}
|
||||
|
||||
$this->initSoapClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* SOAP 클라이언트 초기화
|
||||
*/
|
||||
private function initSoapClient(): void
|
||||
{
|
||||
if (!empty($this->certKey) || $this->isTestMode) {
|
||||
try {
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true
|
||||
]
|
||||
]);
|
||||
|
||||
$this->soapClient = new \SoapClient($this->soapUrl, [
|
||||
'trace' => true,
|
||||
'encoding' => 'UTF-8',
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30,
|
||||
'stream_context' => $context,
|
||||
'cache_wsdl' => WSDL_CACHE_NONE
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('바로빌 홈택스 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 홈택스 매입/매출 메인 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('barobill.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' => $this->soapClient !== null,
|
||||
'currentTenant' => $currentTenant,
|
||||
'barobillMember' => $barobillMember,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매출 세금계산서 목록 조회 (GetHomeTaxTIBySalesEx)
|
||||
*/
|
||||
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);
|
||||
|
||||
// 현재 테넌트의 바로빌 회원 정보 조회
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
|
||||
$userId = $barobillMember?->barobill_id ?? '';
|
||||
|
||||
$result = $this->callSoap('GetHomeTaxTIBySalesEx', [
|
||||
'ID' => $userId,
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $limit,
|
||||
'CurrentPage' => $page,
|
||||
'OrderDirection' => 2 // 2: 내림차순
|
||||
]);
|
||||
|
||||
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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매입 세금계산서 목록 조회 (GetHomeTaxTIByPurchaseEx)
|
||||
*/
|
||||
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);
|
||||
|
||||
// 현재 테넌트의 바로빌 회원 정보 조회
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
|
||||
$userId = $barobillMember?->barobill_id ?? '';
|
||||
|
||||
$result = $this->callSoap('GetHomeTaxTIByPurchaseEx', [
|
||||
'ID' => $userId,
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $limit,
|
||||
'CurrentPage' => $page,
|
||||
'OrderDirection' => 2 // 2: 내림차순
|
||||
]);
|
||||
|
||||
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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 홈택스 수집 요청 (RequestHomeTaxTICollect)
|
||||
*/
|
||||
public function requestCollect(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$collectType = $request->input('type', 'all'); // all, sales, purchase
|
||||
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
|
||||
$userId = $barobillMember?->barobill_id ?? '';
|
||||
|
||||
$result = $this->callSoap('RequestHomeTaxTICollect', [
|
||||
'ID' => $userId,
|
||||
'CollectType' => $this->getCollectTypeCode($collectType)
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $result['error']
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '홈택스 데이터 수집이 요청되었습니다. 잠시 후 다시 조회해주세요.',
|
||||
'data' => $result['data']
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('홈택스 수집 요청 오류: ' . $e->getMessage());
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 상태 확인 (GetCollectState)
|
||||
*/
|
||||
public function collectStatus(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
|
||||
$userId = $barobillMember?->barobill_id ?? '';
|
||||
|
||||
$result = $this->callSoap('GetHomeTaxTICollectState', [
|
||||
'ID' => $userId
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $result['error']
|
||||
]);
|
||||
}
|
||||
|
||||
$data = $result['data'];
|
||||
$state = $this->parseCollectState($data);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $state
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('홈택스 수집 상태 조회 오류: ' . $e->getMessage());
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 파싱
|
||||
*/
|
||||
private function parseInvoices($resultData, string $type = 'sales'): array
|
||||
{
|
||||
$invoices = [];
|
||||
$totalAmount = 0;
|
||||
$totalTax = 0;
|
||||
|
||||
$rawList = [];
|
||||
$listProperty = $type === 'sales' ? 'HomeTaxTISalesList' : 'HomeTaxTIPurchaseList';
|
||||
$itemProperty = $type === 'sales' ? 'HomeTaxTISales' : 'HomeTaxTIPurchase';
|
||||
|
||||
if (isset($resultData->$listProperty) && isset($resultData->$listProperty->$itemProperty)) {
|
||||
$rawList = is_array($resultData->$listProperty->$itemProperty)
|
||||
? $resultData->$listProperty->$itemProperty
|
||||
: [$resultData->$listProperty->$itemProperty];
|
||||
}
|
||||
|
||||
foreach ($rawList as $item) {
|
||||
$supplyAmount = floatval($item->SupplyAmount ?? 0);
|
||||
$taxAmount = floatval($item->TaxAmount ?? 0);
|
||||
$totalAmount += $supplyAmount;
|
||||
$totalTax += $taxAmount;
|
||||
|
||||
// 날짜 포맷팅
|
||||
$writeDate = $item->WriteDate ?? '';
|
||||
$formattedDate = '';
|
||||
if (!empty($writeDate) && strlen($writeDate) >= 8) {
|
||||
$formattedDate = substr($writeDate, 0, 4) . '-' . substr($writeDate, 4, 2) . '-' . substr($writeDate, 6, 2);
|
||||
}
|
||||
|
||||
$invoices[] = [
|
||||
'ntsConfirmNum' => $item->NTSConfirmNum ?? '', // 국세청 승인번호
|
||||
'writeDate' => $writeDate,
|
||||
'writeDateFormatted' => $formattedDate,
|
||||
'invoicerCorpNum' => $item->InvoicerCorpNum ?? '', // 공급자 사업자번호
|
||||
'invoicerCorpName' => $item->InvoicerCorpName ?? '', // 공급자 상호
|
||||
'invoiceeCorpNum' => $item->InvoiceeCorpNum ?? '', // 공급받는자 사업자번호
|
||||
'invoiceeCorpName' => $item->InvoiceeCorpName ?? '', // 공급받는자 상호
|
||||
'supplyAmount' => $supplyAmount,
|
||||
'supplyAmountFormatted' => number_format($supplyAmount),
|
||||
'taxAmount' => $taxAmount,
|
||||
'taxAmountFormatted' => number_format($taxAmount),
|
||||
'totalAmount' => $supplyAmount + $taxAmount,
|
||||
'totalAmountFormatted' => number_format($supplyAmount + $taxAmount),
|
||||
'taxType' => $item->TaxType ?? '', // 과세유형
|
||||
'taxTypeName' => $this->getTaxTypeName($item->TaxType ?? ''),
|
||||
'issueType' => $item->IssueType ?? '', // 발급유형
|
||||
'issueTypeName' => $this->getIssueTypeName($item->IssueType ?? ''),
|
||||
'purposeType' => $item->PurposeType ?? '', // 영수/청구
|
||||
'purposeTypeName' => $this->getPurposeTypeName($item->PurposeType ?? ''),
|
||||
'modifyCode' => $item->ModifyCode ?? '',
|
||||
'remark' => $item->Remark1 ?? '',
|
||||
'collectDate' => $item->CollectDate ?? '', // 수집일
|
||||
];
|
||||
}
|
||||
|
||||
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가 올바르지 않거나 만료되었습니다.',
|
||||
-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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 과세유형 코드 -> 명칭
|
||||
*/
|
||||
private function getTaxTypeName(string $code): string
|
||||
{
|
||||
return match($code) {
|
||||
'01' => '과세',
|
||||
'02' => '영세',
|
||||
'03' => '면세',
|
||||
default => $code
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급유형 코드 -> 명칭
|
||||
*/
|
||||
private function getIssueTypeName(string $code): string
|
||||
{
|
||||
return match($code) {
|
||||
'01' => '정발행',
|
||||
'02' => '역발행',
|
||||
'03' => '위수탁',
|
||||
default => $code
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 영수/청구 코드 -> 명칭
|
||||
*/
|
||||
private function getPurposeTypeName(string $code): string
|
||||
{
|
||||
return match($code) {
|
||||
'01' => '영수',
|
||||
'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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
Log::info("바로빌 홈택스 API 호출 - Method: {$method}, CorpNum: {$this->corpNum}");
|
||||
|
||||
$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' => $this->getErrorMessage((int)$resultData),
|
||||
'error_code' => (int)$resultData
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $resultData
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $result
|
||||
];
|
||||
} catch (\SoapFault $e) {
|
||||
Log::error('바로빌 홈택스 SOAP 오류: ' . $e->getMessage());
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'SOAP 오류: ' . $e->getMessage(),
|
||||
'error_code' => $e->getCode()
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('바로빌 홈택스 API 호출 오류: ' . $e->getMessage());
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'API 호출 오류: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,668 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '홈텍스매입/매출')
|
||||
@section('title', '홈택스 매입/매출')
|
||||
|
||||
@section('content')
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">홈텍스매입/매출</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">홈텍스 매입/매출 데이터 조회</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-24 h-24 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-12 h-12 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
<!-- 현재 테넌트 정보 카드 (React 외부) -->
|
||||
@if($currentTenant)
|
||||
<div class="rounded-xl shadow-lg p-5 mb-6" style="background: linear-gradient(to right, #7c3aed, #8b5cf6); color: white;">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 rounded-xl" style="background: rgba(255,255,255,0.2);">
|
||||
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-2">준비중입니다</h2>
|
||||
<p class="text-gray-500">홈텍스매입/매출 조회 기능이 곧 제공됩니다.</p>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-bold" style="background: rgba(255,255,255,0.2);">T-ID: {{ $currentTenant->id }}</span>
|
||||
@if($currentTenant->id == 1)
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-bold" style="background: #facc15; color: #713f12;">파트너사</span>
|
||||
@endif
|
||||
</div>
|
||||
<h2 class="text-xl font-bold">{{ $currentTenant->company_name }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
@if($barobillMember)
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 text-sm">
|
||||
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
||||
<p class="text-xs" style="color: rgba(255,255,255,0.6);">사업자번호</p>
|
||||
<p class="font-medium">{{ $barobillMember->biz_no }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
||||
<p class="text-xs" style="color: rgba(255,255,255,0.6);">대표자</p>
|
||||
<p class="font-medium">{{ $barobillMember->ceo_name ?? '-' }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
||||
<p class="text-xs" style="color: rgba(255,255,255,0.6);">담당자</p>
|
||||
<p class="font-medium">{{ $barobillMember->manager_name ?? '-' }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
||||
<p class="text-xs" style="color: rgba(255,255,255,0.6);">바로빌 ID</p>
|
||||
<p class="font-medium">{{ $barobillMember->barobill_id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-center gap-2" style="color: #fef08a;">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span class="text-sm">바로빌 회원사 미연동</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div id="hometax-root"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useCallback } = React;
|
||||
|
||||
// API Routes
|
||||
const API = {
|
||||
sales: '{{ route("barobill.hometax.sales") }}',
|
||||
purchases: '{{ route("barobill.hometax.purchases") }}',
|
||||
requestCollect: '{{ route("barobill.hometax.request-collect") }}',
|
||||
collectStatus: '{{ route("barobill.hometax.collect-status") }}',
|
||||
export: '{{ route("barobill.hometax.export") }}',
|
||||
};
|
||||
|
||||
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||
|
||||
// 날짜 유틸리티 함수
|
||||
const getMonthDates = (offset = 0) => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + offset;
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
return {
|
||||
from: firstDay.toISOString().split('T')[0],
|
||||
to: lastDay.toISOString().split('T')[0]
|
||||
};
|
||||
};
|
||||
|
||||
// Toast 알림
|
||||
const notify = (message, type = 'info') => {
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast(message, type);
|
||||
} else {
|
||||
alert(message);
|
||||
}
|
||||
};
|
||||
|
||||
// StatCard Component
|
||||
const StatCard = ({ title, value, subtext, icon, color = 'purple' }) => {
|
||||
const colorClasses = {
|
||||
purple: 'bg-purple-50 text-purple-600',
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
green: 'bg-green-50 text-green-600',
|
||||
red: 'bg-red-50 text-red-600',
|
||||
amber: 'bg-amber-50 text-amber-600',
|
||||
stone: 'bg-stone-50 text-stone-600'
|
||||
};
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm border border-stone-100 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-stone-500">{title}</h3>
|
||||
<div className={`p-2 rounded-lg ${colorClasses[color] || colorClasses.purple}`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-stone-900 mb-1">{value}</div>
|
||||
{subtext && <div className="text-xs text-stone-400">{subtext}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Tab Component
|
||||
const TabButton = ({ active, onClick, children, badge }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-6 py-3 text-sm font-medium rounded-lg transition-all ${
|
||||
active
|
||||
? 'bg-purple-600 text-white shadow-lg shadow-purple-200'
|
||||
: 'bg-white text-stone-600 hover:bg-stone-50 border border-stone-200'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{children}
|
||||
{badge !== undefined && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
active ? 'bg-white/20 text-white' : 'bg-stone-100 text-stone-600'
|
||||
}`}>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
// InvoiceTable Component
|
||||
const InvoiceTable = ({
|
||||
invoices,
|
||||
loading,
|
||||
type,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
onDateFromChange,
|
||||
onDateToChange,
|
||||
onThisMonth,
|
||||
onLastMonth,
|
||||
onExport,
|
||||
onRequestCollect
|
||||
}) => {
|
||||
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
<span className="ml-3 text-stone-500">홈택스 데이터 조회 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isExpense = type === 'purchase';
|
||||
const partnerLabel = isExpense ? '공급자' : '공급받는자';
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-100 overflow-hidden">
|
||||
<div className="p-6 border-b border-stone-100">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<h2 className="text-lg font-bold text-stone-900">
|
||||
{isExpense ? '매입 세금계산서' : '매출 세금계산서'}
|
||||
</h2>
|
||||
{/* 기간 조회 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-stone-500">기간</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => onDateFromChange(e.target.value)}
|
||||
className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
<span className="text-stone-400">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => onDateToChange(e.target.value)}
|
||||
className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onThisMonth}
|
||||
className="px-3 py-1.5 text-sm bg-purple-50 text-purple-600 rounded-lg hover:bg-purple-100 transition-colors font-medium"
|
||||
>
|
||||
이번 달
|
||||
</button>
|
||||
<button
|
||||
onClick={onLastMonth}
|
||||
className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium"
|
||||
>
|
||||
지난달
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<button
|
||||
onClick={onRequestCollect}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
홈택스 수집 요청
|
||||
</button>
|
||||
<button
|
||||
onClick={onExport}
|
||||
disabled={invoices.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
엑셀 다운로드
|
||||
</button>
|
||||
<span className="text-sm text-stone-500 ml-2">
|
||||
조회: <span className="font-semibold text-stone-700">{invoices.length}</span>건
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto" style={{ maxHeight: '500px', overflowY: 'auto' }}>
|
||||
<table className="w-full text-left text-sm text-stone-600">
|
||||
<thead className="bg-stone-50 text-xs uppercase font-medium text-stone-500 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-4 bg-stone-50">작성일</th>
|
||||
<th className="px-4 py-4 bg-stone-50">국세청승인번호</th>
|
||||
<th className="px-4 py-4 bg-stone-50">{partnerLabel}</th>
|
||||
<th className="px-4 py-4 text-right bg-stone-50">공급가액</th>
|
||||
<th className="px-4 py-4 text-right bg-stone-50">세액</th>
|
||||
<th className="px-4 py-4 text-right bg-stone-50 text-purple-600">합계</th>
|
||||
<th className="px-4 py-4 bg-stone-50 text-center">과세유형</th>
|
||||
<th className="px-4 py-4 bg-stone-50 text-center">발급유형</th>
|
||||
<th className="px-4 py-4 bg-stone-50 text-center">영수/청구</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{invoices.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="9" className="px-6 py-8 text-center text-stone-400">
|
||||
해당 기간에 조회된 세금계산서가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
invoices.map((inv, index) => (
|
||||
<tr key={index} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="font-medium text-stone-900">{inv.writeDateFormatted || '-'}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-mono text-xs text-stone-600">{inv.ntsConfirmNum || '-'}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-stone-900">
|
||||
{isExpense ? inv.invoicerCorpName : inv.invoiceeCorpName}
|
||||
</div>
|
||||
<div className="text-xs text-stone-400">
|
||||
{isExpense ? inv.invoicerCorpNum : inv.invoiceeCorpNum}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-stone-700">
|
||||
{inv.supplyAmountFormatted}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-stone-600">
|
||||
{inv.taxAmountFormatted}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-bold text-purple-600">
|
||||
{inv.totalAmountFormatted}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
inv.taxTypeName === '과세' ? 'bg-blue-100 text-blue-700' :
|
||||
inv.taxTypeName === '영세' ? 'bg-green-100 text-green-700' :
|
||||
inv.taxTypeName === '면세' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-stone-100 text-stone-600'
|
||||
}`}>
|
||||
{inv.taxTypeName || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
inv.issueTypeName === '정발행' ? 'bg-purple-100 text-purple-700' :
|
||||
inv.issueTypeName === '역발행' ? 'bg-orange-100 text-orange-700' :
|
||||
'bg-stone-100 text-stone-600'
|
||||
}`}>
|
||||
{inv.issueTypeName || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-stone-600 text-sm">
|
||||
{inv.purposeTypeName || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main App Component
|
||||
const App = () => {
|
||||
const [activeTab, setActiveTab] = useState('sales'); // sales or purchase
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [salesData, setSalesData] = useState({ invoices: [], summary: {}, pagination: {} });
|
||||
const [purchaseData, setPurchaseData] = useState({ invoices: [], summary: {}, pagination: {} });
|
||||
const [error, setError] = useState(null);
|
||||
const [collectStatus, setCollectStatus] = useState(null);
|
||||
|
||||
// 날짜 필터 상태 (기본: 현재 월)
|
||||
const currentMonth = getMonthDates(0);
|
||||
const [dateFrom, setDateFrom] = useState(currentMonth.from);
|
||||
const [dateTo, setDateTo] = useState(currentMonth.to);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadCollectStatus();
|
||||
}, []);
|
||||
|
||||
// 날짜 변경 시 다시 로드
|
||||
useEffect(() => {
|
||||
if (dateFrom && dateTo) {
|
||||
loadData();
|
||||
}
|
||||
}, [dateFrom, dateTo]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
startDate: dateFrom.replace(/-/g, ''),
|
||||
endDate: dateTo.replace(/-/g, ''),
|
||||
limit: 100
|
||||
});
|
||||
|
||||
try {
|
||||
// 매출/매입 동시 조회
|
||||
const [salesRes, purchaseRes] = await Promise.all([
|
||||
fetch(`${API.sales}?${params}`),
|
||||
fetch(`${API.purchases}?${params}`)
|
||||
]);
|
||||
|
||||
const salesJson = await salesRes.json();
|
||||
const purchaseJson = await purchaseRes.json();
|
||||
|
||||
if (salesJson.success) {
|
||||
setSalesData({
|
||||
invoices: salesJson.data?.invoices || [],
|
||||
summary: salesJson.data?.summary || {},
|
||||
pagination: salesJson.data?.pagination || {}
|
||||
});
|
||||
}
|
||||
|
||||
if (purchaseJson.success) {
|
||||
setPurchaseData({
|
||||
invoices: purchaseJson.data?.invoices || [],
|
||||
summary: purchaseJson.data?.summary || {},
|
||||
pagination: purchaseJson.data?.pagination || {}
|
||||
});
|
||||
}
|
||||
|
||||
if (!salesJson.success && !purchaseJson.success) {
|
||||
setError(salesJson.error || purchaseJson.error || '조회 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('서버 통신 오류: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCollectStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(API.collectStatus);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setCollectStatus(data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('수집 상태 조회 오류:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestCollect = async () => {
|
||||
if (!confirm('홈택스 데이터 수집을 요청하시겠습니까?\n수집에는 시간이 걸릴 수 있습니다.')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(API.requestCollect, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({ type: 'all' })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
notify(data.message, 'success');
|
||||
loadCollectStatus();
|
||||
} else {
|
||||
notify(data.error || '수집 요청 실패', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
notify('수집 요청 오류: ' + err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const invoices = activeTab === 'sales' ? salesData.invoices : purchaseData.invoices;
|
||||
if (invoices.length === 0) {
|
||||
notify('다운로드할 데이터가 없습니다.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(API.export, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: activeTab,
|
||||
invoices: invoices
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `홈택스_${activeTab === 'sales' ? '매출' : '매입'}_${dateFrom.replace(/-/g, '')}_${dateTo.replace(/-/g, '')}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
notify(data.error || '다운로드 실패', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
notify('다운로드 오류: ' + err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 이번 달 버튼
|
||||
const handleThisMonth = () => {
|
||||
const dates = getMonthDates(0);
|
||||
setDateFrom(dates.from);
|
||||
setDateTo(dates.to);
|
||||
};
|
||||
|
||||
// 지난달 버튼
|
||||
const handleLastMonth = () => {
|
||||
const dates = getMonthDates(-1);
|
||||
setDateFrom(dates.from);
|
||||
setDateTo(dates.to);
|
||||
};
|
||||
|
||||
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원';
|
||||
|
||||
const currentData = activeTab === 'sales' ? salesData : purchaseData;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-stone-900">홈택스 매입/매출</h1>
|
||||
<p className="text-stone-500 mt-1">홈택스에 신고된 세금계산서 매입/매출 내역 조회</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@if($isTestMode)
|
||||
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-xs font-medium">테스트 모드</span>
|
||||
@endif
|
||||
@if($hasSoapClient)
|
||||
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">SOAP 연결됨</span>
|
||||
@else
|
||||
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">SOAP 미연결</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수집 상태 안내 */}
|
||||
{collectStatus && (
|
||||
<div className={`p-4 rounded-xl ${collectStatus.isCollecting ? 'bg-amber-50 border border-amber-200' : 'bg-stone-50 border border-stone-200'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{collectStatus.isCollecting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-amber-600"></div>
|
||||
<span className="text-amber-700 font-medium">홈택스 데이터 수집 중...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5 text-stone-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-stone-600">
|
||||
마지막 수집: 매출 {collectStatus.salesLastCollectDate || '-'} / 매입 {collectStatus.purchaseLastCollectDate || '-'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dashboard */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="매출 공급가액"
|
||||
value={formatCurrency(salesData.summary.totalAmount)}
|
||||
subtext={`${salesData.summary.count || 0}건`}
|
||||
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="매출 세액"
|
||||
value={formatCurrency(salesData.summary.totalTax)}
|
||||
subtext="부가세 합계"
|
||||
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/></svg>}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="매입 공급가액"
|
||||
value={formatCurrency(purchaseData.summary.totalAmount)}
|
||||
subtext={`${purchaseData.summary.count || 0}건`}
|
||||
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"/></svg>}
|
||||
color="red"
|
||||
/>
|
||||
<StatCard
|
||||
title="매입 세액"
|
||||
value={formatCurrency(purchaseData.summary.totalTax)}
|
||||
subtext="매입세액 합계"
|
||||
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/></svg>}
|
||||
color="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tab Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<TabButton
|
||||
active={activeTab === 'sales'}
|
||||
onClick={() => setActiveTab('sales')}
|
||||
badge={salesData.summary.count || 0}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
|
||||
</svg>
|
||||
매출
|
||||
</TabButton>
|
||||
<TabButton
|
||||
active={activeTab === 'purchase'}
|
||||
onClick={() => setActiveTab('purchase')}
|
||||
badge={purchaseData.summary.count || 0}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"/>
|
||||
</svg>
|
||||
매입
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 p-4 rounded-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-xl">⚠️</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold mb-2">{error}</p>
|
||||
{error.includes('-60002') && (
|
||||
<div className="mt-3 p-3 bg-white rounded border border-red-200 text-sm">
|
||||
<p className="font-medium mb-2">해결 방법:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-stone-700">
|
||||
<li>바로빌 사이트에 로그인</li>
|
||||
<li>홈택스 연동 설정 메뉴 확인</li>
|
||||
<li>홈택스 인증서 등록 및 연동 설정</li>
|
||||
<li>부서사용자 ID/비밀번호 설정</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
{error.includes('-60004') && (
|
||||
<div className="mt-3 p-3 bg-white rounded border border-red-200 text-sm">
|
||||
<p className="font-medium mb-2">해결 방법:</p>
|
||||
<p className="text-stone-700">홈택스에서 부서사용자 아이디를 생성 후 바로빌에 등록해주세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoice Table */}
|
||||
{!error && (
|
||||
<InvoiceTable
|
||||
invoices={currentData.invoices}
|
||||
loading={loading}
|
||||
type={activeTab}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
onDateFromChange={setDateFrom}
|
||||
onDateToChange={setDateTo}
|
||||
onThisMonth={handleThisMonth}
|
||||
onLastMonth={handleLastMonth}
|
||||
onExport={handleExport}
|
||||
onRequestCollect={handleRequestCollect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Summary Card */}
|
||||
{!error && !loading && (
|
||||
<div className="bg-gradient-to-r from-purple-50 to-violet-50 rounded-xl p-6 border border-purple-100">
|
||||
<h3 className="text-lg font-bold text-stone-900 mb-4">기간 요약</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-stone-500 mb-1">매출 합계 (공급가액 + 세액)</p>
|
||||
<p className="text-2xl font-bold text-green-600">{formatCurrency(salesData.summary.totalSum)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-stone-500 mb-1">매입 합계 (공급가액 + 세액)</p>
|
||||
<p className="text-2xl font-bold text-red-600">{formatCurrency(purchaseData.summary.totalSum)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-stone-500 mb-1">예상 부가세 (매출세액 - 매입세액)</p>
|
||||
<p className={`text-2xl font-bold ${
|
||||
(salesData.summary.totalTax || 0) - (purchaseData.summary.totalTax || 0) >= 0
|
||||
? 'text-purple-600'
|
||||
: 'text-blue-600'
|
||||
}`}>
|
||||
{formatCurrency((salesData.summary.totalTax || 0) - (purchaseData.summary.totalTax || 0))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('hometax-root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
Route::get('/tax-invoice', [\App\Http\Controllers\Barobill\BarobillController::class, 'taxInvoice'])->name('tax-invoice.index');
|
||||
Route::get('/bank-account', [\App\Http\Controllers\Barobill\BarobillController::class, 'bankAccount'])->name('bank-account.index');
|
||||
Route::get('/card-usage', [\App\Http\Controllers\Barobill\BarobillController::class, 'cardUsage'])->name('card-usage.index');
|
||||
Route::get('/hometax', [\App\Http\Controllers\Barobill\BarobillController::class, 'hometax'])->name('hometax.index');
|
||||
// Route::get('/hometax', ...) - 아래 hometax 그룹으로 이동됨
|
||||
Route::get('/usage', [\App\Http\Controllers\Barobill\BarobillController::class, 'usage'])->name('usage.index');
|
||||
// 기존 config 라우트 (호환성)
|
||||
Route::get('/config', [\App\Http\Controllers\Barobill\BarobillController::class, 'config'])->name('config.index');
|
||||
@@ -314,6 +314,16 @@
|
||||
Route::post('/splits', [\App\Http\Controllers\Barobill\EcardController::class, 'saveSplits'])->name('splits.save');
|
||||
Route::delete('/splits', [\App\Http\Controllers\Barobill\EcardController::class, 'deleteSplits'])->name('splits.delete');
|
||||
});
|
||||
|
||||
// 홈택스 매입/매출 (React 페이지)
|
||||
Route::prefix('hometax')->name('hometax.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Barobill\HometaxController::class, 'index'])->name('index');
|
||||
Route::get('/sales', [\App\Http\Controllers\Barobill\HometaxController::class, 'sales'])->name('sales');
|
||||
Route::get('/purchases', [\App\Http\Controllers\Barobill\HometaxController::class, 'purchases'])->name('purchases');
|
||||
Route::post('/request-collect', [\App\Http\Controllers\Barobill\HometaxController::class, 'requestCollect'])->name('request-collect');
|
||||
Route::get('/collect-status', [\App\Http\Controllers\Barobill\HometaxController::class, 'collectStatus'])->name('collect-status');
|
||||
Route::post('/export', [\App\Http\Controllers\Barobill\HometaxController::class, 'exportExcel'])->name('export');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user