feat: 영수증 OCR을 Google Vision(백엔드) 방식으로 전환
CI / build (push) Failing after 14m1s

- 이미지를 백엔드(/account/ocr/receipt)로 업로드 → Vision 텍스트 → 금액·날짜·상호 파싱
- 업로드 전 이미지 축소(최대 1600px JPEG), Tesseract.js 의존성 제거
- 진행률 표시 제거(인식 중…), 정확도·속도 대폭 개선

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-03 17:37:35 +09:00
parent 715dae70ab
commit 68dd926cce
5 changed files with 36 additions and 216 deletions
-115
View File
@@ -20,7 +20,6 @@
"pinia": "^3.0.4", "pinia": "^3.0.4",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"tesseract.js": "^7.0.0",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.4" "vue-router": "^5.0.4"
}, },
@@ -2261,12 +2260,6 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/boolbase": { "node_modules/boolbase": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -3291,12 +3284,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/idb-keyval": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.5.tgz",
"integrity": "sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==",
"license": "Apache-2.0"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3412,12 +3399,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-what": { "node_modules/is-what": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
@@ -4072,26 +4053,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.46", "version": "2.0.46",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
@@ -4217,15 +4178,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4727,12 +4679,6 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/rfdc": { "node_modules/rfdc": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
@@ -5053,30 +4999,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^7.0.0",
"wasm-feature-detect": "^1.8.0",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
"license": "Apache-2.0"
},
"node_modules/through2": { "node_modules/through2": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
@@ -5112,12 +5034,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tree-kill": { "node_modules/tree-kill": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -5676,34 +5592,12 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/webpack-virtual-modules": { "node_modules/webpack-virtual-modules": {
"version": "0.6.2", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -5863,15 +5757,6 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
} }
} }
} }
-1
View File
@@ -27,7 +27,6 @@
"pinia": "^3.0.4", "pinia": "^3.0.4",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"tesseract.js": "^7.0.0",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.4" "vue-router": "^5.0.4"
}, },
+10
View File
@@ -152,4 +152,14 @@ export const accountApi = {
removeTag(id) { removeTag(id) {
return http.delete(`/account/tags/${id}`) return http.delete(`/account/tags/${id}`)
}, },
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환)
ocrReceipt(blob) {
const fd = new FormData()
fd.append('image', blob, 'receipt.jpg')
return http.post('/account/ocr/receipt', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30000,
})
},
} }
+19 -93
View File
@@ -1,7 +1,7 @@
// 영수증 OCR (온디바이스, Tesseract.js). 서버·API키 불필요. // 영수증 OCR 보조 유틸.
// 이미지 → 텍스트 인식 → 금액·날짜·상호 추출. // 실제 인식은 백엔드(Google Vision)에서 수행하고, 여기서는
// 속도 최적화: ① 워커 1회 생성 후 재사용(모델 재로드 방지) ② 인식 전 이미지 축소·흑백 전처리. // ① 업로드 전 이미지 축소(imageToBlob) ② 인식된 텍스트에서 금액·날짜·상호 파싱(parseReceiptText)
import { createWorker } from 'tesseract.js' // 만 담당한다.
// 합계 금액을 가리키는 키워드 (우선순위 높은 순) // 합계 금액을 가리키는 키워드 (우선순위 높은 순)
const TOTAL_KEYWORDS = [ const TOTAL_KEYWORDS = [
@@ -12,27 +12,7 @@ const TOTAL_KEYWORDS = [
// 합계로 오인하기 쉬운(제외할) 키워드 // 합계로 오인하기 쉬운(제외할) 키워드
const EXCLUDE_KEYWORDS = ['부가세', '면세', '과세', '봉사료', '거스름', '받은금액', '현금', '잔액', '포인트'] const EXCLUDE_KEYWORDS = ['부가세', '면세', '과세', '봉사료', '거스름', '받은금액', '현금', '잔액', '포인트']
// ===== 워커 재사용 ===== // ===== 업로드 전 이미지 축소 (JPEG Blob) =====
let workerPromise = null
let onProgressCb = null
function getWorker() {
if (!workerPromise) {
workerPromise = createWorker('kor+eng', 1, {
logger: (m) => {
if (m.status === 'recognizing text' && onProgressCb && typeof m.progress === 'number') {
onProgressCb(Math.round(m.progress * 100))
}
},
}).then(async (w) => {
// 영수증은 단일 블록에 가까움 → 자동 레이아웃 분석 비용 절감
await w.setParameters({ tessedit_pageseg_mode: '6' })
return w
})
}
return workerPromise
}
// ===== 이미지 전처리: 축소 + 흑백 =====
function loadImage(src) { function loadImage(src) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image() const img = new Image()
@@ -41,7 +21,7 @@ function loadImage(src) {
img.src = src img.src = src
}) })
} }
async function preprocess(image, maxDim = 1600) { export async function imageToBlob(image, maxDim = 1600) {
const url = typeof image === 'string' ? image : URL.createObjectURL(image) const url = typeof image === 'string' ? image : URL.createObjectURL(image)
try { try {
const img = await loadImage(url) const img = await loadImage(url)
@@ -51,52 +31,16 @@ async function preprocess(image, maxDim = 1600) {
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = w canvas.width = w
canvas.height = h canvas.height = h
const ctx = canvas.getContext('2d') canvas.getContext('2d').drawImage(img, 0, 0, w, h)
ctx.drawImage(img, 0, 0, w, h) const blob = await new Promise((res) => canvas.toBlob(res, 'image/jpeg', 0.85))
// 흑백 → Otsu 이진화 (영수증 글자/숫자 인식률 향상) return blob || image
const imgData = ctx.getImageData(0, 0, w, h) } catch {
const d = imgData.data return image // 전처리 실패 시 원본 그대로
const hist = new Array(256).fill(0)
const gray = new Uint8Array(d.length / 4)
for (let i = 0, p = 0; i < d.length; i += 4, p++) {
const g = (d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114) | 0
gray[p] = g
hist[g]++
}
const th = otsu(hist, gray.length)
for (let i = 0, p = 0; i < d.length; i += 4, p++) {
const v = gray[p] > th ? 255 : 0
d[i] = d[i + 1] = d[i + 2] = v
}
ctx.putImageData(imgData, 0, 0)
return canvas
} finally { } finally {
if (typeof image !== 'string') URL.revokeObjectURL(url) if (typeof image !== 'string') URL.revokeObjectURL(url)
} }
} }
// Otsu 임계값 계산
function otsu(hist, total) {
let sum = 0
for (let i = 0; i < 256; i++) sum += i * hist[i]
let sumB = 0, wB = 0, max = 0, threshold = 127
for (let i = 0; i < 256; i++) {
wB += hist[i]
if (wB === 0) continue
const wF = total - wB
if (wF === 0) break
sumB += i * hist[i]
const mB = sumB / wB
const mF = (sum - sumB) / wF
const between = wB * wF * (mB - mF) * (mB - mF)
if (between > max) {
max = between
threshold = i
}
}
return threshold
}
// 한 줄에서 금액 후보 추출 (전화·사업자·카드번호·시간 제외) → [{n, hasComma}] // 한 줄에서 금액 후보 추출 (전화·사업자·카드번호·시간 제외) → [{n, hasComma}]
function moneyNumbers(line) { function moneyNumbers(line) {
const res = [] const res = []
@@ -108,9 +52,8 @@ function moneyNumbers(line) {
const end = start + raw.length const end = start + raw.length
const before = line[start - 1] || '' const before = line[start - 1] || ''
const after = line[end] || '' const after = line[end] || ''
// 시간(12:30) / 전화·사업자·카드(하이픈 연결) 제외 if (before === ':' || after === ':') continue // 시간
if (before === ':' || after === ':') continue if (before === '-' || after === '-') continue // 전화/사업자/카드
if (before === '-' || after === '-') continue
const n = parseInt(raw.replace(/,/g, ''), 10) const n = parseInt(raw.replace(/,/g, ''), 10)
if (Number.isNaN(n)) continue if (Number.isNaN(n)) continue
res.push({ n, hasComma: raw.includes(',') }) res.push({ n, hasComma: raw.includes(',') })
@@ -118,10 +61,8 @@ function moneyNumbers(line) {
return res return res
} }
// 합계 금액 추정
function extractAmount(lines) { function extractAmount(lines) {
// 1) 합계 키워드 줄(제외 키워드 줄은 건너뜀) + 바로 다음 줄까지 탐색. // 1) 합계 키워드 줄(+다음 줄)에서 — 콤마 금액 우선, 없으면 최댓값
// 콤마 금액이 있으면 우선, 없으면 최댓값.
for (const kw of TOTAL_KEYWORDS) { for (const kw of TOTAL_KEYWORDS) {
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(kw)) continue if (!lines[i].includes(kw)) continue
@@ -138,17 +79,14 @@ function extractAmount(lines) {
const all = lines.flatMap(moneyNumbers) const all = lines.flatMap(moneyNumbers)
const comma = all.filter((x) => x.hasComma).map((x) => x.n).filter((n) => n >= 100 && n < 100_000_000) const comma = all.filter((x) => x.hasComma).map((x) => x.n).filter((n) => n >= 100 && n < 100_000_000)
if (comma.length) return Math.max(...comma) if (comma.length) return Math.max(...comma)
// 3) 콤마가 전혀 없으면(인식 누락) 1000~천만, 10원 단위인 가장 큰 수 // 3) 콤마가 없으면 1000~천만, 10원 단위 최댓값
const plain = all.map((x) => x.n).filter((n) => n >= 1000 && n <= 10_000_000 && n % 10 === 0) const plain = all.map((x) => x.n).filter((n) => n >= 1000 && n <= 10_000_000 && n % 10 === 0)
return plain.length ? Math.max(...plain) : null return plain.length ? Math.max(...plain) : null
} }
// 거래일 추정 (YYYY-MM-DD 반환)
function extractDate(text) { function extractDate(text) {
// 4자리 연도: 2024-01-05 / 2024.01.05 / 2024년 1월 5일 / 2024/1/5
let m = text.match(/(20\d{2})\s*[.\-/년]\s*(\d{1,2})\s*[.\-/월]\s*(\d{1,2})/) let m = text.match(/(20\d{2})\s*[.\-/년]\s*(\d{1,2})\s*[.\-/월]\s*(\d{1,2})/)
if (m) return fmt(m[1], m[2], m[3]) if (m) return fmt(m[1], m[2], m[3])
// 2자리 연도: 24.01.05
m = text.match(/(\d{2})\s*[.\-/]\s*(\d{1,2})\s*[.\-/]\s*(\d{1,2})/) m = text.match(/(\d{2})\s*[.\-/]\s*(\d{1,2})\s*[.\-/]\s*(\d{1,2})/)
if (m) return fmt('20' + m[1], m[2], m[3]) if (m) return fmt('20' + m[1], m[2], m[3])
return null return null
@@ -160,7 +98,6 @@ function fmt(y, mo, d) {
return `${y}-${mm}-${dd}` return `${y}-${mm}-${dd}`
} }
// 상호(가게 이름) 추정 — 상단의 한글 포함, 숫자/키워드 없는 첫 줄
function extractStore(lines) { function extractStore(lines) {
for (const line of lines.slice(0, 6)) { for (const line of lines.slice(0, 6)) {
const t = line.trim() const t = line.trim()
@@ -174,23 +111,12 @@ function extractStore(lines) {
return null return null
} }
/** /** Vision 이 인식한 전체 텍스트 → { amount, date, store } */
* 영수증 이미지에서 금액·날짜·상호 추출. export function parseReceiptText(text) {
* @param {File|Blob|string} image 파일 또는 dataURL const lines = (text || '').split('\n').map((l) => l.trim()).filter(Boolean)
* @param {(p:number)=>void} onProgress 0~100 진행률 콜백
* @returns {Promise<{amount:number|null, date:string|null, store:string|null, text:string}>}
*/
export async function scanReceipt(image, onProgress) {
onProgressCb = onProgress
const [worker, canvas] = await Promise.all([getWorker(), preprocess(image)])
const { data } = await worker.recognize(canvas)
onProgressCb = null
const text = data.text || ''
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
return { return {
amount: extractAmount(lines), amount: extractAmount(lines),
date: extractDate(text), date: extractDate(text || ''),
store: extractStore(lines), store: extractStore(lines),
text,
} }
} }
+7 -7
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
import { scanReceipt } from '@/utils/receiptOcr' import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera' import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'
@@ -59,7 +59,6 @@ const formError = ref(null)
// 영수증 OCR (온디바이스) // 영수증 OCR (온디바이스)
const receiptInput = ref(null) const receiptInput = ref(null)
const ocrRunning = ref(false) const ocrRunning = ref(false)
const ocrProgress = ref(0)
const ocrResult = ref(null) // { amount, date, store } const ocrResult = ref(null) // { amount, date, store }
async function pickReceipt() { async function pickReceipt() {
ocrResult.value = null ocrResult.value = null
@@ -91,11 +90,12 @@ async function onReceiptFile(e) {
} }
async function runOcr(image) { async function runOcr(image) {
ocrRunning.value = true ocrRunning.value = true
ocrProgress.value = 0
ocrResult.value = null ocrResult.value = null
formError.value = null formError.value = null
try { try {
const r = await scanReceipt(image, (p) => (ocrProgress.value = p)) const blob = await imageToBlob(image)
const { text } = await accountApi.ocrReceipt(blob)
const r = parseReceiptText(text)
// 추출값이 있을 때만 폼에 채움 (메모는 비어있을 때만) // 추출값이 있을 때만 폼에 채움 (메모는 비어있을 때만)
if (r.amount) form.amount = r.amount if (r.amount) form.amount = r.amount
if (r.date) form.entryDate = r.date if (r.date) form.entryDate = r.date
@@ -104,8 +104,8 @@ async function runOcr(image) {
if (!r.amount && !r.date) { if (!r.amount && !r.date) {
formError.value = '영수증에서 정보를 충분히 인식하지 못했습니다. 직접 입력하거나 더 선명한 사진으로 다시 시도하세요.' formError.value = '영수증에서 정보를 충분히 인식하지 못했습니다. 직접 입력하거나 더 선명한 사진으로 다시 시도하세요.'
} }
} catch { } catch (e) {
formError.value = '영수증 인식에 실패했습니다. 다시 시도해 주세요.' formError.value = e.response?.data?.message || '영수증 인식에 실패했습니다. 다시 시도해 주세요.'
} finally { } finally {
ocrRunning.value = false ocrRunning.value = false
} }
@@ -541,7 +541,7 @@ onMounted(async () => {
type="button" class="receipt-btn" type="button" class="receipt-btn"
:disabled="submitting || ocrRunning" @click="pickReceipt" :disabled="submitting || ocrRunning" @click="pickReceipt"
>📷 영수증 스캔</button> >📷 영수증 스캔</button>
<span v-if="ocrRunning" class="receipt-status">인식 {{ ocrProgress }}%</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.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>