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 () => { + 자동 입력됨 사진에서 금액·날짜·상호를 자동 입력