feat:홈택스 API 진단 및 스크래핑 기능 추가

- HometaxController에 서비스 진단 메소드 추가 (diagnose)
- 홈택스 스크래핑 URL 조회 메소드 추가 (getScrapRequestUrl)
- 홈택스 스크래핑 갱신 요청 메소드 추가 (refreshScrap)
- 뷰에 서비스 진단 모달 UI 추가
- 라우트: scrap-url, refresh-scrap, diagnose 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
pro
2026-01-23 18:03:41 +09:00
parent f632d5b9b4
commit fecd01e917
3 changed files with 382 additions and 14 deletions

View File

@@ -253,36 +253,215 @@ public function purchases(Request $request): JsonResponse
}
/**
* 홈택스 수집 요청
* 홈택스 스크래핑 서비스 등록 URL 조회
*
* 참고: 홈택스 수집 API는 별도 서비스 구독이 필요할 수 있습니다.
* 현재는 바로빌에 등록된 세금계산서만 조회합니다.
* 바로빌에서 홈택스 스크래핑을 신청하기 위한 URL을 반환합니다.
*/
public function requestCollect(Request $request): JsonResponse
public function getScrapRequestUrl(Request $request): JsonResponse
{
// 홈택스 수집 API는 별도 구독 필요 - 현재 미지원 안내
return response()->json([
'success' => false,
'error' => '홈택스 수집 기능은 별도 서비스 구독이 필요합니다. 바로빌에 등록된 세금계산서는 매출/매입 탭에서 조회 가능합니다.'
]);
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
$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()
]);
}
}
/**
* 수집 상태 확인
* 홈택스 스크래핑 갱신 요청
*
* 참고: 홈택스 수집 상태 API는 별도 서비스 구독이 필요할 수 있습니다.
* 홈택스에서 최신 데이터를 다시 수집하도록 요청합니다.
*/
public function refreshScrap(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('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();
$userId = $barobillMember?->barobill_id ?? '';
$memberCorpNum = $barobillMember?->biz_no ?? '';
$diagnostics = [
'config' => [
'certKey' => !empty($this->certKey) ? substr($this->certKey, 0, 8) . '...' : '미설정',
'corpNum' => $this->corpNum ?? '미설정',
'isTestMode' => $this->isTestMode,
'baseUrl' => $this->baseUrl
],
'member' => [
'userId' => $userId ?: '미설정',
'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개월)
$salesResult = $this->callSoap('GetPeriodTaxInvoiceSalesList', [
'UserID' => $userId,
'TaxType' => 0,
'DateType' => 1,
'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: 회원사 로그인 URL 조회 (기본 연결 확인용)
$loginUrlResult = $this->callSoap('GetLoginURL', ['UserID' => $userId]);
$diagnostics['tests']['loginUrl'] = [
'method' => 'GetLoginURL',
'success' => $loginUrlResult['success'],
'result' => $loginUrlResult['success']
? (is_string($loginUrlResult['data']) ? '성공 (URL 반환)' : $loginUrlResult['data'])
: ($loginUrlResult['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
{
// 홈택스 수집 상태 API는 별도 구독 필요 - 현재 미지원 안내
return response()->json([
'success' => true,
'data' => [
'salesLastCollectDate' => '',
'purchaseLastCollectDate' => '',
'isCollecting' => false,
'collectStateText' => '미지원',
'message' => '홈택스 수집 기능은 별도 서비스 구독이 필요합니다.'
'collectStateText' => '확인 필요',
'message' => '서비스 상태 진단 기능을 사용하여 홈택스 연동 상태를 확인해주세요.'
]
]);
}

View File

@@ -72,6 +72,9 @@
requestCollect: '{{ route("barobill.hometax.request-collect") }}',
collectStatus: '{{ route("barobill.hometax.collect-status") }}',
export: '{{ route("barobill.hometax.export") }}',
scrapUrl: '{{ route("barobill.hometax.scrap-url") }}',
refreshScrap: '{{ route("barobill.hometax.refresh-scrap") }}',
diagnose: '{{ route("barobill.hometax.diagnose") }}',
};
const CSRF_TOKEN = '{{ csrf_token() }}';
@@ -334,6 +337,11 @@ className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-l
const [dateFrom, setDateFrom] = useState(currentMonth.from);
const [dateTo, setDateTo] = useState(currentMonth.to);
// 진단 관련 상태
const [showDiagnoseModal, setShowDiagnoseModal] = useState(false);
const [diagnoseResult, setDiagnoseResult] = useState(null);
const [diagnosing, setDiagnosing] = useState(false);
// 초기 로드
useEffect(() => {
loadData();
@@ -469,6 +477,49 @@ className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-l
}
};
// 서비스 진단
const handleDiagnose = async () => {
setShowDiagnoseModal(true);
setDiagnosing(true);
setDiagnoseResult(null);
try {
const res = await fetch(API.diagnose);
const data = await res.json();
if (data.success) {
setDiagnoseResult(data.data);
} else {
setDiagnoseResult({ error: data.error || '진단 실패' });
}
} catch (err) {
setDiagnoseResult({ error: '서버 통신 오류: ' + err.message });
} finally {
setDiagnosing(false);
}
};
// 홈택스 스크래핑 새로고침
const handleRefreshScrap = async () => {
try {
const res = await fetch(API.refreshScrap, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN
}
});
const data = await res.json();
if (data.success) {
notify(data.message || '홈택스 데이터 수집이 요청되었습니다.', 'success');
loadData();
} else {
notify(data.error || '수집 요청 실패', 'error');
}
} catch (err) {
notify('수집 요청 오류: ' + err.message, 'error');
}
};
// 이번 달 버튼
const handleThisMonth = () => {
const dates = getMonthDates(0);
@@ -496,6 +547,15 @@ className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-l
<p className="text-stone-500 mt-1">홈택스에 신고된 세금계산서 매입/매출 내역 조회</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDiagnose}
className="px-3 py-1.5 bg-stone-100 text-stone-700 rounded-lg text-xs font-medium hover:bg-stone-200 transition-colors flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
서비스 진단
</button>
@if($isTestMode)
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-xs font-medium">테스트 모드</span>
@endif
@@ -658,6 +718,131 @@ className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-l
</div>
</div>
)}
{/* 진단 모달 */}
{showDiagnoseModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden">
<div className="p-6 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">홈택스 서비스 진단</h3>
<button
onClick={() => setShowDiagnoseModal(false)}
className="p-2 hover:bg-stone-100 rounded-lg transition-colors"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 overflow-y-auto max-h-[60vh]">
{diagnosing ? (
<div className="flex items-center justify-center py-10">
<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>
) : diagnoseResult?.error ? (
<div className="bg-red-50 text-red-600 p-4 rounded-xl">
{diagnoseResult.error}
</div>
) : diagnoseResult && (
<div className="space-y-6">
{/* 설정 정보 */}
<div>
<h4 className="text-sm font-semibold text-stone-700 mb-3">API 설정</h4>
<div className="bg-stone-50 rounded-xl p-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-stone-500">CERTKEY</span>
<span className="font-mono text-stone-700">{diagnoseResult.config?.certKey}</span>
</div>
<div className="flex justify-between">
<span className="text-stone-500">파트너사업자번호</span>
<span className="font-mono text-stone-700">{diagnoseResult.config?.corpNum}</span>
</div>
<div className="flex justify-between">
<span className="text-stone-500">테스트모드</span>
<span className={diagnoseResult.config?.isTestMode ? 'text-amber-600' : 'text-green-600'}>
{diagnoseResult.config?.isTestMode ? '예' : '아니오'}
</span>
</div>
<div className="flex justify-between">
<span className="text-stone-500">API URL</span>
<span className="font-mono text-xs text-stone-600">{diagnoseResult.config?.baseUrl}</span>
</div>
</div>
</div>
{/* 회원사 정보 */}
<div>
<h4 className="text-sm font-semibold text-stone-700 mb-3">회원사 정보</h4>
<div className="bg-stone-50 rounded-xl p-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-stone-500">바로빌 ID</span>
<span className="font-mono text-stone-700">{diagnoseResult.member?.userId || '미설정'}</span>
</div>
<div className="flex justify-between">
<span className="text-stone-500">사업자번호</span>
<span className="font-mono text-stone-700">{diagnoseResult.member?.bizNo || '미설정'}</span>
</div>
<div className="flex justify-between">
<span className="text-stone-500">상호</span>
<span className="text-stone-700">{diagnoseResult.member?.corpName || '미설정'}</span>
</div>
</div>
</div>
{/* API 테스트 결과 */}
<div>
<h4 className="text-sm font-semibold text-stone-700 mb-3">API 테스트 결과</h4>
<div className="space-y-3">
{Object.entries(diagnoseResult.tests || {}).map(([key, test]) => (
<div key={key} className={`rounded-xl p-4 ${test.success ? 'bg-green-50' : 'bg-red-50'}`}>
<div className="flex items-start justify-between">
<div>
<p className="font-medium text-stone-700">{test.method}</p>
<p className={`text-sm mt-1 ${test.success ? 'text-green-600' : 'text-red-600'}`}>
{test.result}
</p>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
test.success ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{test.success ? '성공' : '실패'}
</span>
</div>
</div>
))}
</div>
</div>
{/* 해결 방법 안내 */}
<div className="bg-amber-50 rounded-xl p-4 text-sm">
<p className="font-medium text-amber-800 mb-2">💡 문제 해결 안내</p>
<ul className="text-amber-700 space-y-1">
<li> API 권한 오류 : 바로빌 사이트에서 홈택스 연동 서비스 신청 필요</li>
<li> 홈택스 연동 미등록 : 부서사용자 또는 인증서 방식으로 홈택스 연동 등록 필요</li>
<li> 데이터 미조회 : 바로빌에서 발행한 세금계산서만 API로 조회 가능</li>
</ul>
</div>
</div>
)}
</div>
<div className="p-4 border-t border-stone-100 flex justify-end gap-3">
<button
onClick={handleRefreshScrap}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors"
>
홈택스 수집 요청
</button>
<button
onClick={() => setShowDiagnoseModal(false)}
className="px-4 py-2 bg-stone-100 text-stone-700 rounded-lg text-sm font-medium hover:bg-stone-200 transition-colors"
>
닫기
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -323,6 +323,10 @@
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');
// 홈택스 스크래핑 관련
Route::get('/scrap-url', [\App\Http\Controllers\Barobill\HometaxController::class, 'getScrapRequestUrl'])->name('scrap-url');
Route::post('/refresh-scrap', [\App\Http\Controllers\Barobill\HometaxController::class, 'refreshScrap'])->name('refresh-scrap');
Route::get('/diagnose', [\App\Http\Controllers\Barobill\HometaxController::class, 'diagnose'])->name('diagnose');
});
});