2026-06-03 17:37:35 +09:00
|
|
|
// 영수증 OCR 보조 유틸.
|
|
|
|
|
// 실제 인식은 백엔드(Google Vision)에서 수행하고, 여기서는
|
|
|
|
|
// ① 업로드 전 이미지 축소(imageToBlob) ② 인식된 텍스트에서 금액·날짜·상호 파싱(parseReceiptText)
|
|
|
|
|
// 만 담당한다.
|
2026-06-03 16:42:07 +09:00
|
|
|
|
|
|
|
|
// 합계 금액을 가리키는 키워드 (우선순위 높은 순)
|
|
|
|
|
const TOTAL_KEYWORDS = [
|
|
|
|
|
'받을금액', '받을 금액', '결제금액', '결제 금액', '합계금액', '합 계', '합계',
|
|
|
|
|
'총액', '총 액', '총구매액', '판매금액', '결제대상금액', '청구금액', '승인금액',
|
|
|
|
|
'total', 'TOTAL',
|
|
|
|
|
]
|
|
|
|
|
// 합계로 오인하기 쉬운(제외할) 키워드
|
|
|
|
|
const EXCLUDE_KEYWORDS = ['부가세', '면세', '과세', '봉사료', '거스름', '받은금액', '현금', '잔액', '포인트']
|
|
|
|
|
|
2026-06-03 17:37:35 +09:00
|
|
|
// ===== 업로드 전 이미지 축소 (JPEG Blob) =====
|
2026-06-03 17:02:34 +09:00
|
|
|
function loadImage(src) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const img = new Image()
|
|
|
|
|
img.onload = () => resolve(img)
|
|
|
|
|
img.onerror = reject
|
|
|
|
|
img.src = src
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-06-03 17:37:35 +09:00
|
|
|
export async function imageToBlob(image, maxDim = 1600) {
|
2026-06-03 17:02:34 +09:00
|
|
|
const url = typeof image === 'string' ? image : URL.createObjectURL(image)
|
|
|
|
|
try {
|
|
|
|
|
const img = await loadImage(url)
|
|
|
|
|
const scale = Math.min(1, maxDim / Math.max(img.width, img.height))
|
|
|
|
|
const w = Math.max(1, Math.round(img.width * scale))
|
|
|
|
|
const h = Math.max(1, Math.round(img.height * scale))
|
|
|
|
|
const canvas = document.createElement('canvas')
|
|
|
|
|
canvas.width = w
|
|
|
|
|
canvas.height = h
|
2026-06-03 17:37:35 +09:00
|
|
|
canvas.getContext('2d').drawImage(img, 0, 0, w, h)
|
|
|
|
|
const blob = await new Promise((res) => canvas.toBlob(res, 'image/jpeg', 0.85))
|
|
|
|
|
return blob || image
|
|
|
|
|
} catch {
|
|
|
|
|
return image // 전처리 실패 시 원본 그대로
|
2026-06-03 17:02:34 +09:00
|
|
|
} finally {
|
|
|
|
|
if (typeof image !== 'string') URL.revokeObjectURL(url)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 한 줄에서 금액 후보 추출 (전화·사업자·카드번호·시간 제외) → [{n, hasComma}]
|
|
|
|
|
function moneyNumbers(line) {
|
|
|
|
|
const res = []
|
|
|
|
|
const re = /\d{1,3}(?:,\d{3})+|\d+/g
|
2026-06-03 16:42:07 +09:00
|
|
|
let m
|
|
|
|
|
while ((m = re.exec(line)) !== null) {
|
2026-06-03 17:02:34 +09:00
|
|
|
const raw = m[0]
|
|
|
|
|
const start = m.index
|
|
|
|
|
const end = start + raw.length
|
|
|
|
|
const before = line[start - 1] || ''
|
|
|
|
|
const after = line[end] || ''
|
2026-06-03 17:37:35 +09:00
|
|
|
if (before === ':' || after === ':') continue // 시간
|
|
|
|
|
if (before === '-' || after === '-') continue // 전화/사업자/카드
|
2026-06-03 17:02:34 +09:00
|
|
|
const n = parseInt(raw.replace(/,/g, ''), 10)
|
|
|
|
|
if (Number.isNaN(n)) continue
|
|
|
|
|
res.push({ n, hasComma: raw.includes(',') })
|
2026-06-03 16:42:07 +09:00
|
|
|
}
|
2026-06-03 17:02:34 +09:00
|
|
|
return res
|
2026-06-03 16:42:07 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractAmount(lines) {
|
2026-06-03 17:37:35 +09:00
|
|
|
// 1) 합계 키워드 줄(+다음 줄)에서 — 콤마 금액 우선, 없으면 최댓값
|
2026-06-03 16:42:07 +09:00
|
|
|
for (const kw of TOTAL_KEYWORDS) {
|
2026-06-03 17:02:34 +09:00
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
|
|
|
if (!lines[i].includes(kw)) continue
|
|
|
|
|
if (EXCLUDE_KEYWORDS.some((ex) => lines[i].includes(ex))) continue
|
|
|
|
|
for (let j = i; j <= Math.min(i + 1, lines.length - 1); j++) {
|
|
|
|
|
const nums = moneyNumbers(lines[j])
|
|
|
|
|
if (!nums.length) continue
|
|
|
|
|
const comma = nums.filter((x) => x.hasComma).map((x) => x.n)
|
|
|
|
|
return comma.length ? Math.max(...comma) : Math.max(...nums.map((x) => x.n))
|
|
|
|
|
}
|
2026-06-03 16:42:07 +09:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-03 17:02:34 +09:00
|
|
|
// 2) 폴백: 콤마 금액 우선
|
|
|
|
|
const all = lines.flatMap(moneyNumbers)
|
|
|
|
|
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)
|
2026-06-03 17:37:35 +09:00
|
|
|
// 3) 콤마가 없으면 1000~천만, 10원 단위 최댓값
|
2026-06-03 17:02:34 +09:00
|
|
|
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
|
2026-06-03 16:42:07 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractDate(text) {
|
|
|
|
|
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])
|
|
|
|
|
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])
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
function fmt(y, mo, d) {
|
|
|
|
|
const mm = String(Number(mo)).padStart(2, '0')
|
|
|
|
|
const dd = String(Number(d)).padStart(2, '0')
|
|
|
|
|
if (Number(mo) < 1 || Number(mo) > 12 || Number(d) < 1 || Number(d) > 31) return null
|
|
|
|
|
return `${y}-${mm}-${dd}`
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 17:56:18 +09:00
|
|
|
// 상호(가게 이름) 추정 — 상단의 글자 줄 (날짜·전화·사업자·주소·키워드 제외, GS25 등 숫자 포함 상호는 허용)
|
|
|
|
|
const ADDR_HINTS = ['읍', '면', '번지', '아파트']
|
|
|
|
|
const STORE_STOP = ['영수증', '주소', '사업자', '대표', 'TEL', '전화', '구매', '승인', '매출', '신용', '카드', '거래']
|
2026-06-03 16:42:07 +09:00
|
|
|
function extractStore(lines) {
|
2026-06-03 17:56:18 +09:00
|
|
|
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 // 사업자번호
|
2026-06-03 16:42:07 +09:00
|
|
|
if (TOTAL_KEYWORDS.some((k) => t.includes(k))) continue
|
2026-06-03 17:56:18 +09:00
|
|
|
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 // 도로명/지번 주소
|
2026-06-03 16:42:07 +09:00
|
|
|
return t
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 17:56:18 +09:00
|
|
|
// 카드사 감지 (영수증 텍스트에서). 등록된 카드의 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 } */
|
2026-06-03 17:37:35 +09:00
|
|
|
export function parseReceiptText(text) {
|
2026-06-03 17:56:18 +09:00
|
|
|
const t = text || ''
|
|
|
|
|
const lines = t.split('\n').map((l) => l.trim()).filter(Boolean)
|
2026-06-03 16:42:07 +09:00
|
|
|
return {
|
|
|
|
|
amount: extractAmount(lines),
|
2026-06-03 17:56:18 +09:00
|
|
|
date: extractDate(t),
|
2026-06-03 16:42:07 +09:00
|
|
|
store: extractStore(lines),
|
2026-06-03 17:56:18 +09:00
|
|
|
cardIssuer: extractCardIssuer(t),
|
|
|
|
|
isCard: isCardPayment(t),
|
2026-06-03 16:42:07 +09:00
|
|
|
}
|
|
|
|
|
}
|