영수증 선택 시 Claude Vision(/receipt-ai)으로 금액·상호·날짜·분류·계좌 자동 채움. AI 실패/미인식 시 기존 Google Vision OCR+규칙파서로 폴백. 결과 배너에 ✨·분류 표시. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+13
-2
@@ -216,7 +216,7 @@ const realApi = {
|
|||||||
return http.post('/account/restore', payload)
|
return http.post('/account/restore', payload)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환)
|
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환) — AI 폴백용
|
||||||
ocrReceipt(blob) {
|
ocrReceipt(blob) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('image', blob, 'receipt.jpg')
|
fd.append('image', blob, 'receipt.jpg')
|
||||||
@@ -225,6 +225,17 @@ const realApi = {
|
|||||||
timeout: 30000,
|
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',
|
'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings',
|
||||||
'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets',
|
'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets',
|
||||||
'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories',
|
'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories',
|
||||||
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'restore',
|
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'parseReceiptAi', 'restore',
|
||||||
'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome',
|
'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome',
|
||||||
'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade',
|
'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade',
|
||||||
'refreshPrices', 'refreshAllPrices',
|
'refreshPrices', 'refreshAllPrices',
|
||||||
|
|||||||
@@ -250,6 +250,35 @@ async function runOcr(image) {
|
|||||||
formError.value = null
|
formError.value = null
|
||||||
try {
|
try {
|
||||||
const blob = await imageToBlob(image)
|
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 { text } = await accountApi.ocrReceipt(blob)
|
||||||
const r = parseReceiptText(text)
|
const r = parseReceiptText(text)
|
||||||
// 추출값이 있을 때만 폼에 채움 (메모는 비어있을 때만)
|
// 추출값이 있을 때만 폼에 채움 (메모는 비어있을 때만)
|
||||||
@@ -1048,13 +1077,15 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<span v-if="ocrRunning" class="receipt-status">영수증 인식 중…</span>
|
<span v-if="ocrRunning" class="receipt-status">영수증 인식 중…</span>
|
||||||
<span v-else-if="ocrResult" class="receipt-status ok">
|
<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.amount">{{ won(ocrResult.amount) }}원</template>
|
||||||
<template v-if="ocrResult.date"> · {{ ocrResult.date }}</template>
|
<template v-if="ocrResult.date"> · {{ ocrResult.date }}</template>
|
||||||
<template v-if="ocrResult.store"> · {{ ocrResult.store }}</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>
|
<template v-if="ocrResult.card"> · 💳{{ ocrResult.card }}</template>
|
||||||
자동 입력됨
|
자동 입력됨
|
||||||
</span>
|
</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 v-if="ocrRunning" class="receipt-progress"><div class="bar"></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user