feat(account): 영수증 사진 AI 구조화 — 비전 우선, OCR 폴백
Deploy / deploy (push) Failing after 14m23s

영수증 선택 시 Claude Vision(/receipt-ai)으로 금액·상호·날짜·분류·계좌 자동 채움.
AI 실패/미인식 시 기존 Google Vision OCR+규칙파서로 폴백. 결과 배너에 ·분류 표시.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-05 14:22:07 +09:00
parent 6a4d01b6e1
commit 7e13a42848
2 changed files with 45 additions and 3 deletions
+13 -2
View File
@@ -216,7 +216,7 @@ const realApi = {
return http.post('/account/restore', payload)
},
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환)
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환) — AI 폴백용
ocrReceipt(blob) {
const fd = new FormData()
fd.append('image', blob, 'receipt.jpg')
@@ -225,6 +225,17 @@ const realApi = {
timeout: 30000,
})
},
// 영수증 AI 구조화 (Claude Vision) — 금액·상호·날짜·분류·계좌를 한 번에. 사용자 후보 목록을 함께 전달
parseReceiptAi(blob, { categoryHints = [], walletHints = [] } = {}) {
const fd = new FormData()
fd.append('image', blob, 'receipt.jpg')
categoryHints.forEach((c) => fd.append('categoryHints', c))
walletHints.forEach((w) => fd.append('walletHints', w))
return http.post('/account/ocr/receipt-ai', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30000,
})
},
}
// ===== 둘러보기(데모) 모드 =====
@@ -246,7 +257,7 @@ const DEMO_WRITES = new Set([
'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings',
'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets',
'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories',
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'restore',
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'parseReceiptAi', 'restore',
'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome',
'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade',
'refreshPrices', 'refreshAllPrices',
+32 -1
View File
@@ -250,6 +250,35 @@ async function runOcr(image) {
formError.value = null
try {
const blob = await imageToBlob(image)
// 1) AI 비전 우선 — 금액·상호·날짜·분류·계좌를 한 번에 구조화
let ai = null
try {
ai = await accountApi.parseReceiptAi(blob, {
categoryHints: allCategoryNames.value,
walletHints: wallets.value.map((w) => w.name),
})
} catch {
ai = null // AI 실패 → 아래 OCR 폴백
}
if (ai && ai.recognized) {
form.type = ai.type === 'INCOME' ? 'INCOME' : 'EXPENSE'
if (ai.amount) form.amount = Number(ai.amount)
if (ai.merchant && !form.memo) form.memo = ai.merchant
if (ai.date) form.entryDate = ai.date
if (ai.category) form.category = ai.category
let aiCard = null
if (ai.wallet) {
const w = wallets.value.find((x) => x.name === ai.wallet)
if (w) {
form.walletKind = w.type
form.walletId = w.id
aiCard = w.name
}
}
ocrResult.value = { amount: ai.amount, date: ai.date, store: ai.merchant, card: aiCard, category: ai.category, ai: true }
return
}
// 2) 폴백 — 기존 Google Vision OCR + 규칙 파서
const { text } = await accountApi.ocrReceipt(blob)
const r = parseReceiptText(text)
// 추출값이 있을 때만 폼에 채움 (메모는 비어있을 때만)
@@ -1048,13 +1077,15 @@ onMounted(async () => {
</div>
<span v-if="ocrRunning" class="receipt-status">영수증 인식 </span>
<span v-else-if="ocrResult" class="receipt-status ok">
<template v-if="ocrResult.ai"> </template>
<template v-if="ocrResult.amount">{{ won(ocrResult.amount) }}</template>
<template v-if="ocrResult.date"> · {{ ocrResult.date }}</template>
<template v-if="ocrResult.store"> · {{ ocrResult.store }}</template>
<template v-if="ocrResult.category"> · {{ ocrResult.category }}</template>
<template v-if="ocrResult.card"> · 💳{{ ocrResult.card }}</template>
자동 입력됨
</span>
<span v-else class="receipt-hint">사진에서 금액·날짜·상호를 자동 입력</span>
<span v-else class="receipt-hint">사진에서 금액·날짜·상호·분류 자동 입력</span>
<div v-if="ocrRunning" class="receipt-progress"><div class="bar"></div></div>
</div>