From 537cea31b0ad8a3be12977856ed8543bb31a65fa Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Wed, 3 Jun 2026 17:56:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=98=81=EC=88=98=EC=A6=9D=20OCR=20?= =?UTF-8?q?=EC=83=81=ED=98=B8=EB=AA=85=E2=86=92=EB=A9=94=EB=AA=A8,=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EC=82=AC=20=EA=B0=90=EC=A7=80=E2=86=92?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=9E=90=EB=8F=99=EC=84=A0=ED=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상호 추출 개선(GS25 등 숫자 포함 상호 허용, 날짜·전화·사업자·주소 제외) - 카드 결제 영수증의 카드사 감지 → 등록된 카드(issuer/name 매칭) 자동 선택 - 매칭 실패해도 카드결제면 계좌종류를 카드로 좁혀줌 - 결과 표시에 💳카드명 추가 Co-Authored-By: Claude Opus 4.8 --- src/utils/receiptOcr.js | 45 ++++++++++++++++++++++++------- src/views/account/AccountView.vue | 23 +++++++++++++++- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/utils/receiptOcr.js b/src/utils/receiptOcr.js index 7f920d1..bc21c88 100644 --- a/src/utils/receiptOcr.js +++ b/src/utils/receiptOcr.js @@ -98,25 +98,52 @@ function fmt(y, mo, d) { return `${y}-${mm}-${dd}` } +// 상호(가게 이름) 추정 — 상단의 글자 줄 (날짜·전화·사업자·주소·키워드 제외, GS25 등 숫자 포함 상호는 허용) +const ADDR_HINTS = ['읍', '면', '번지', '아파트'] +const STORE_STOP = ['영수증', '주소', '사업자', '대표', 'TEL', '전화', '구매', '승인', '매출', '신용', '카드', '거래'] function extractStore(lines) { - for (const line of lines.slice(0, 6)) { - const t = line.trim() - if (t.length < 2 || t.length > 30) continue - if (/\d/.test(t)) continue - if (!/[가-힣]/.test(t)) continue + for (const raw of lines.slice(0, 6)) { + const t = raw.trim() + if (t.length < 2 || t.length > 25) continue + if (!/[가-힣A-Za-z]/.test(t)) continue // 글자가 있어야 + if (/^[\d,\s.\-:]+$/.test(t)) continue // 숫자/기호만 + if (/\d{2,4}[.\-/]\d{1,2}[.\-/]\d{1,2}/.test(t)) continue // 날짜 + if (/\d{2,4}-\d{3,4}-\d{4}/.test(t)) continue // 전화 + if (/\d{3}-\d{2}-\d{5}/.test(t)) continue // 사업자번호 if (TOTAL_KEYWORDS.some((k) => t.includes(k))) continue - if (['영수증', '주소', '사업자', '대표', 'TEL', '전화'].some((k) => t.includes(k))) continue + if (STORE_STOP.some((k) => t.includes(k))) continue + if (ADDR_HINTS.some((k) => t.includes(k)) && /\d/.test(t)) continue // 주소 + if (/(시|도|구|군)\s*\S*(로|길|동)\s*\d/.test(t)) continue // 도로명/지번 주소 return t } return null } -/** Vision 이 인식한 전체 텍스트 → { amount, date, store } */ +// 카드사 감지 (영수증 텍스트에서). 등록된 카드의 issuer/name 과 매칭하는 데 사용. +const CARD_ISSUERS = [ + '카카오뱅크', '삼성', '신한', '현대', '롯데', '하나', '우리', 'KB국민', '국민', 'KB', + '비씨', 'BC', '농협', 'NH', '씨티', '카카오', '토스', '수협', '광주', '전북', '제주', +] +function isCardPayment(text) { + return /카드|신용\s*승인|승인번호|할부|체크\s*승인|일시불/.test(text) +} +function extractCardIssuer(text) { + if (!isCardPayment(text)) return null + for (const c of CARD_ISSUERS) { + if (text.includes(c)) return c + } + return null +} + +/** Vision 이 인식한 전체 텍스트 → { amount, date, store, cardIssuer, isCard } */ export function parseReceiptText(text) { - const lines = (text || '').split('\n').map((l) => l.trim()).filter(Boolean) + const t = text || '' + const lines = t.split('\n').map((l) => l.trim()).filter(Boolean) return { amount: extractAmount(lines), - date: extractDate(text || ''), + date: extractDate(t), store: extractStore(lines), + cardIssuer: extractCardIssuer(t), + isCard: isCardPayment(t), } } diff --git a/src/views/account/AccountView.vue b/src/views/account/AccountView.vue index 34daf07..726ed60 100644 --- a/src/views/account/AccountView.vue +++ b/src/views/account/AccountView.vue @@ -100,7 +100,20 @@ async function runOcr(image) { if (r.amount) form.amount = r.amount if (r.date) form.entryDate = r.date if (r.store && !form.memo) form.memo = r.store - ocrResult.value = { amount: r.amount, date: r.date, store: r.store } + // 카드 결제 영수증이면 등록된 카드 자동 선택 (카드사 매칭) + let cardName = null + if (form.type === 'EXPENSE') { + const card = r.cardIssuer ? matchCardWallet(r.cardIssuer) : null + if (card) { + form.walletKind = 'CARD' + form.walletId = card.id + cardName = card.name + } else if (r.isCard) { + form.walletKind = 'CARD' // 카드결제지만 매칭 실패 → 카드 목록만 좁혀줌 + form.walletId = '' + } + } + ocrResult.value = { amount: r.amount, date: r.date, store: r.store, card: cardName } if (!r.amount && !r.date) { formError.value = '영수증에서 정보를 충분히 인식하지 못했습니다. 직접 입력하거나 더 선명한 사진으로 다시 시도하세요.' } @@ -134,6 +147,13 @@ function walletKindOf(id) { const w = wallets.value.find((x) => x.id === id) return w ? w.type : '' } +// 영수증에서 감지한 카드사명으로 등록된 카드(CARD) 찾기 +function matchCardWallet(issuer) { + if (!issuer) return null + return wallets.value.find( + (w) => w.type === 'CARD' && ((w.issuer || '').includes(issuer) || (w.name || '').includes(issuer)), + ) +} function onWalletKindChange() { form.walletId = '' } @@ -546,6 +566,7 @@ onMounted(async () => { + 자동 입력됨 사진에서 금액·날짜·상호를 자동 입력