- 상호 추출 개선(GS25 등 숫자 포함 상호 허용, 날짜·전화·사업자·주소 제외) - 카드 결제 영수증의 카드사 감지 → 등록된 카드(issuer/name 매칭) 자동 선택 - 매칭 실패해도 카드결제면 계좌종류를 카드로 좁혀줌 - 결과 표시에 💳카드명 추가 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+36
-9
@@ -98,25 +98,52 @@ function fmt(y, mo, d) {
|
|||||||
return `${y}-${mm}-${dd}`
|
return `${y}-${mm}-${dd}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 상호(가게 이름) 추정 — 상단의 글자 줄 (날짜·전화·사업자·주소·키워드 제외, GS25 등 숫자 포함 상호는 허용)
|
||||||
|
const ADDR_HINTS = ['읍', '면', '번지', '아파트']
|
||||||
|
const STORE_STOP = ['영수증', '주소', '사업자', '대표', 'TEL', '전화', '구매', '승인', '매출', '신용', '카드', '거래']
|
||||||
function extractStore(lines) {
|
function extractStore(lines) {
|
||||||
for (const line of lines.slice(0, 6)) {
|
for (const raw of lines.slice(0, 6)) {
|
||||||
const t = line.trim()
|
const t = raw.trim()
|
||||||
if (t.length < 2 || t.length > 30) continue
|
if (t.length < 2 || t.length > 25) continue
|
||||||
if (/\d/.test(t)) continue
|
if (!/[가-힣A-Za-z]/.test(t)) continue // 글자가 있어야
|
||||||
if (!/[가-힣]/.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 (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 t
|
||||||
}
|
}
|
||||||
return null
|
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) {
|
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 {
|
return {
|
||||||
amount: extractAmount(lines),
|
amount: extractAmount(lines),
|
||||||
date: extractDate(text || ''),
|
date: extractDate(t),
|
||||||
store: extractStore(lines),
|
store: extractStore(lines),
|
||||||
|
cardIssuer: extractCardIssuer(t),
|
||||||
|
isCard: isCardPayment(t),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,20 @@ async function runOcr(image) {
|
|||||||
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
|
||||||
if (r.store && !form.memo) form.memo = r.store
|
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) {
|
if (!r.amount && !r.date) {
|
||||||
formError.value = '영수증에서 정보를 충분히 인식하지 못했습니다. 직접 입력하거나 더 선명한 사진으로 다시 시도하세요.'
|
formError.value = '영수증에서 정보를 충분히 인식하지 못했습니다. 직접 입력하거나 더 선명한 사진으로 다시 시도하세요.'
|
||||||
}
|
}
|
||||||
@@ -134,6 +147,13 @@ function walletKindOf(id) {
|
|||||||
const w = wallets.value.find((x) => x.id === id)
|
const w = wallets.value.find((x) => x.id === id)
|
||||||
return w ? w.type : ''
|
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() {
|
function onWalletKindChange() {
|
||||||
form.walletId = ''
|
form.walletId = ''
|
||||||
}
|
}
|
||||||
@@ -546,6 +566,7 @@ onMounted(async () => {
|
|||||||
<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.card"> · 💳{{ ocrResult.card }}</template>
|
||||||
자동 입력됨
|
자동 입력됨
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="receipt-hint">사진에서 금액·날짜·상호를 자동 입력</span>
|
<span v-else class="receipt-hint">사진에서 금액·날짜·상호를 자동 입력</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user