feat:홈택스 세금계산서 자동 증분 동기화

페이지 로드 시 바로빌 API를 백그라운드에서 호출하여 신규 데이터를 자동 동기화.
수동 데이터소스 토글/저장 버튼 제거, 항상 로컬 DB에서 즉시 표시 후 증분 동기화.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-14 22:24:19 +09:00
parent 0dbfb0b62e
commit 9714dedd04
3 changed files with 124 additions and 87 deletions

View File

@@ -1360,6 +1360,84 @@ public function sync(Request $request, HometaxSyncService $syncService): JsonRes
}
}
/**
* 자동 증분 동기화
* 마지막 동기화 시점 이후의 데이터만 바로빌 API에서 가져와 로컬 DB에 저장
*/
public function autoSync(Request $request, HometaxSyncService $syncService): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$type = $request->input('type', 'sales'); // 'sales' 또는 'purchase'
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
if (!$barobillMember) {
return response()->json(['success' => true, 'skipped' => true, 'reason' => 'no_member']);
}
// 마지막 동기화 시간 확인
$lastFetchField = $type === 'sales' ? 'last_sales_fetch_at' : 'last_purchases_fetch_at';
$lastFetch = $barobillMember->$lastFetchField;
// 10분 이내에 이미 동기화했으면 스킵
if ($lastFetch && $lastFetch->diffInMinutes(now()) < 10) {
return response()->json([
'success' => true,
'skipped' => true,
'reason' => 'recent_sync',
'lastSyncAt' => $syncService->getLastSyncTime($tenantId, $type),
'lastFetchAt' => $lastFetch->format('Y-m-d H:i:s'),
]);
}
// 증분 범위 계산: 마지막 동기화일 -1일 ~ 오늘
$startDate = $lastFetch
? $lastFetch->copy()->subDay()->format('Ymd')
: date('Ymd', strtotime('-1 month'));
$endDate = date('Ymd');
// 기존 sync 로직 재사용: API 호출 → DB 저장
$apiMethod = $type === 'sales' ? 'sales' : 'purchases';
$apiRequest = new Request([
'startDate' => $startDate,
'endDate' => $endDate,
'dateType' => 1, // 작성일자 기준
'limit' => 500,
]);
$apiResponse = $this->$apiMethod($apiRequest);
$apiData = json_decode($apiResponse->getContent(), true);
$syncResult = ['inserted' => 0, 'updated' => 0, 'total' => 0];
if ($apiData['success'] && !empty($apiData['data']['invoices'])) {
$syncResult = $syncService->syncInvoices(
$apiData['data']['invoices'],
$tenantId,
$type
);
}
return response()->json([
'success' => true,
'skipped' => false,
'data' => $syncResult,
'hasNewData' => ($syncResult['inserted'] ?? 0) > 0,
'lastSyncAt' => $syncService->getLastSyncTime($tenantId, $type),
'lastFetchAt' => now()->format('Y-m-d H:i:s'),
]);
} catch (\Throwable $e) {
Log::error('홈택스 자동동기화 오류: ' . $e->getMessage());
// 자동동기화 실패는 치명적이지 않음 - 로컬 데이터는 정상 표시됨
return response()->json([
'success' => true,
'skipped' => true,
'reason' => 'error',
'error' => $e->getMessage(),
]);
}
}
/**
* 세금계산서 메모 업데이트
*/

View File

@@ -70,6 +70,7 @@
localSales: '{{ route("barobill.hometax.local-sales") }}',
localPurchases: '{{ route("barobill.hometax.local-purchases") }}',
sync: '{{ route("barobill.hometax.sync") }}',
autoSync: '{{ route("barobill.hometax.auto-sync") }}',
updateMemo: '{{ route("barobill.hometax.update-memo") }}',
toggleChecked: '{{ route("barobill.hometax.toggle-checked") }}',
requestCollect: '{{ route("barobill.hometax.request-collect") }}',
@@ -403,8 +404,7 @@ className="px-2 py-1 bg-red-50 text-red-700 rounded text-xs hover:bg-red-100 tra
const [dateTo, setDateTo] = useState(currentMonth.to);
const [dateType, setDateType] = useState('write'); // 'write': 작성일자, 'issue': 발급일자
const [searchCorpName, setSearchCorpName] = useState(''); // 거래처 검색
const [dataSource, setDataSource] = useState('local'); // 'local': 로컬 DB, 'api': 바로빌 API
const [syncing, setSyncing] = useState(false); // 동기화 중 여부
const [autoSyncing, setAutoSyncing] = useState(false); // 자동 동기화 중 여부
const [lastSyncAt, setLastSyncAt] = useState({ sales: null, purchase: null }); // 마지막 동기화 시간
const [selectedPeriod, setSelectedPeriod] = useState(null); // 선택된 기간 버튼 ('q1', 'q2', 'q3', 'q4', 'h1', 'h2', 'year')
@@ -434,6 +434,8 @@ className="px-2 py-1 bg-red-50 text-red-700 rounded text-xs hover:bg-red-100 tra
loadCollectStatus();
fetchTradingPartners();
loadAccountCodes();
// 초기 로드 후 자동 동기화 (매출)
triggerAutoSync('sales');
}, []);
// 탭 변경 시 해당 탭 데이터 로드 (아직 로드되지 않은 경우)
@@ -443,6 +445,8 @@ className="px-2 py-1 bg-red-50 text-red-700 rounded text-xs hover:bg-red-100 tra
} else if (activeTab === 'purchase' && !purchaseData.loaded) {
loadPurchaseData();
}
// 탭 전환 시 해당 탭 자동 동기화
triggerAutoSync(activeTab === 'sales' ? 'sales' : 'purchase');
}, [activeTab]);
// 매출 데이터 로드
@@ -460,11 +464,8 @@ className="px-2 py-1 bg-red-50 text-red-700 rounded text-xs hover:bg-red-100 tra
limit: 500
});
// 데이터소스에 따라 API 선택
const apiUrl = dataSource === 'local' ? API.localSales : API.sales;
try {
const res = await fetch(`${apiUrl}?${params}`);
const res = await fetch(`${API.localSales}?${params}`);
const json = await res.json();
if (json.success) {
@@ -505,11 +506,8 @@ className="px-2 py-1 bg-red-50 text-red-700 rounded text-xs hover:bg-red-100 tra
limit: 500
});
// 데이터소스에 따라 API 선택
const apiUrl = dataSource === 'local' ? API.localPurchases : API.purchases;
try {
const res = await fetch(`${apiUrl}?${params}`);
const res = await fetch(`${API.localPurchases}?${params}`);
const json = await res.json();
if (json.success) {
@@ -548,44 +546,39 @@ className="px-2 py-1 bg-red-50 text-red-700 rounded text-xs hover:bg-red-100 tra
}
};
// 바로빌 API에서 로컬 DB로 동기화
const handleSync = async () => {
if (!confirm('바로빌에서 데이터를 가져와 로컬 DB에 저장합니다.\n계속하시겠습니까?')) return;
setSyncing(true);
setError(null);
const dateTypeCode = dateType === 'write' ? 1 : 2;
// 자동 증분 동기화
const triggerAutoSync = async (type) => {
setAutoSyncing(true);
try {
const res = await fetch(API.sync, {
const res = await fetch(API.autoSync, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN
},
body: JSON.stringify({
type: 'all',
startDate: dateFrom.replace(/-/g, ''),
endDate: dateTo.replace(/-/g, ''),
dateType: dateTypeCode
})
body: JSON.stringify({ type })
});
const json = await res.json();
const data = await res.json();
if (data.success) {
notify(data.message, 'success');
// 동기화 후 데이터 다시 로드
setSalesData(prev => ({ ...prev, loaded: false }));
setPurchaseData(prev => ({ ...prev, loaded: false }));
loadCurrentTabData();
} else {
notify(data.error || '동기화 실패', 'error');
if (json.success && !json.skipped && json.hasNewData) {
// 신규 데이터가 있으면 화면 갱신
if (type === 'sales') {
loadSalesData();
} else {
loadPurchaseData();
}
notify(`${json.data.inserted}건 추가, ${json.data.updated}건 갱신`, 'success');
}
// 마지막 동기화 시간 업데이트
if (json.lastSyncAt) {
setLastSyncAt(prev => ({ ...prev, [type]: json.lastSyncAt }));
}
} catch (err) {
notify('동기화 오류: ' + err.message, 'error');
console.error('자동 동기화 오류:', err);
// 자동동기화 실패는 사용자에게 알리지 않음
} finally {
setSyncing(false);
setAutoSyncing(false);
}
};
@@ -1013,57 +1006,22 @@ className="flex-1 max-w-md px-3 py-2 text-sm border border-[#ced4da] rounded bg-
/>
<span className="text-xs text-[#6c757d]">(사업자번호 또는 사업자명)</span>
</div>
{/* 데이터소스 행 */}
<div className="flex flex-wrap items-center gap-3 px-5 py-4">
<label className="text-sm font-medium text-[#495057] w-20 shrink-0">데이터</label>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="dataSource"
value="local"
checked={dataSource === 'local'}
onChange={(e) => setDataSource(e.target.value)}
className="w-4 h-4 text-[#0d6efd]"
/>
<span className="text-sm text-[#495057]">로컬 DB</span>
<span className="text-xs text-[#198754]">(빠름)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="dataSource"
value="api"
checked={dataSource === 'api'}
onChange={(e) => setDataSource(e.target.value)}
className="w-4 h-4 text-[#0d6efd]"
/>
<span className="text-sm text-[#495057]">바로빌 API</span>
<span className="text-xs text-[#6c757d]">(실시간)</span>
</label>
{/* 저장 버튼 - 주황색 */}
<button
onClick={handleSync}
disabled={syncing}
className="flex items-center gap-2 text-sm font-semibold rounded-md disabled:opacity-50"
style=@{{ backgroundColor: '#fd7e14', color: '#fff', padding: '6px 16px', border: 'none', boxShadow: '0 2px 4px rgba(253,126,20,0.3)' }}
title="바로빌에서 데이터를 가져와 로컬 DB에 저장합니다"
>
{syncing ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
)}
저장
</button>
{/* 동기화 상태 */}
<div className="flex flex-wrap items-center gap-3 px-5 py-3 border-t border-[#dee2e6]">
<label className="text-sm font-medium text-[#495057] w-20 shrink-0">동기화</label>
<div className="flex items-center gap-3">
{autoSyncing && (
<span className="flex items-center gap-2 text-xs text-[#0d6efd]">
<div className="animate-spin rounded-full h-3 w-3 border-2 border-[#0d6efd] border-t-transparent"></div>
바로빌 동기화 ...
</span>
)}
{lastSyncAt[activeTab] && (
<span className="text-xs text-[#6c757d]">
마지막 동기화: {lastSyncAt[activeTab]}
</span>
)}
</div>
{lastSyncAt[activeTab] && (
<span className="ml-auto text-xs text-[#6c757d]">
마지막 저장: {lastSyncAt[activeTab]}
</span>
)}
</div>
{/* 현재 조회 결과 */}
<div className="px-5 py-3 bg-[#f8f9fa] border-t border-[#dee2e6] flex items-center justify-between text-sm">

View File

@@ -583,6 +583,7 @@
Route::get('/local-sales', [\App\Http\Controllers\Barobill\HometaxController::class, 'localSales'])->name('local-sales');
Route::get('/local-purchases', [\App\Http\Controllers\Barobill\HometaxController::class, 'localPurchases'])->name('local-purchases');
Route::post('/sync', [\App\Http\Controllers\Barobill\HometaxController::class, 'sync'])->name('sync');
Route::post('/auto-sync', [\App\Http\Controllers\Barobill\HometaxController::class, 'autoSync'])->name('auto-sync');
Route::post('/update-memo', [\App\Http\Controllers\Barobill\HometaxController::class, 'updateMemo'])->name('update-memo');
Route::post('/toggle-checked', [\App\Http\Controllers\Barobill\HometaxController::class, 'toggleChecked'])->name('toggle-checked');
// 수동입력 CRUD