|
| 1 | +/** |
| 2 | + * 구독 데이터 가져오기 — CSV / XLSX / XLS 파싱 |
| 3 | + * |
| 4 | + * 지원 포맷: |
| 5 | + * - .csv (UTF-8 BOM, EUC-KR 자동 감지) |
| 6 | + * - .xlsx / .xls (SheetJS) |
| 7 | + * |
| 8 | + * 컬럼 헤더는 한국어·영어 모두 인식. |
| 9 | + */ |
| 10 | + |
| 11 | +import * as XLSX from 'xlsx'; |
| 12 | + |
| 13 | +export type BillingCycle = 'monthly' | 'yearly'; |
| 14 | +export type Currency = 'KRW' | 'USD' | 'EUR' | 'JPY' | 'GBP'; |
| 15 | + |
| 16 | +export interface ImportRow { |
| 17 | + serviceName: string; |
| 18 | + currentAmount: number; |
| 19 | + currency: Currency; |
| 20 | + billingCycle: BillingCycle; |
| 21 | + billingDay: number; |
| 22 | + startedAt?: string; |
| 23 | + category?: string; |
| 24 | + url?: string; |
| 25 | +} |
| 26 | + |
| 27 | +export interface ImportError { |
| 28 | + row: number; |
| 29 | + message: string; |
| 30 | +} |
| 31 | + |
| 32 | +export interface ImportResult { |
| 33 | + rows: ImportRow[]; |
| 34 | + errors: ImportError[]; |
| 35 | +} |
| 36 | + |
| 37 | +// ── 헤더 매핑 ────────────────────────────────────────────────── |
| 38 | + |
| 39 | +const FIELD_ALIASES: Record<keyof ImportRow, string[]> = { |
| 40 | + serviceName: ['서비스명', '서비스', 'servicename', 'service', 'name'], |
| 41 | + currentAmount: ['금액', '가격', 'amount', 'price', 'currentamount'], |
| 42 | + currency: ['통화', 'currency'], |
| 43 | + billingCycle: ['결제주기', '주기', 'billingcycle', 'cycle'], |
| 44 | + billingDay: ['결제일', '결제 일', 'billingday', 'day'], |
| 45 | + startedAt: ['구독시작일', '시작일', 'startedat', 'started', 'start'], |
| 46 | + category: ['카테고리', 'category'], |
| 47 | + url: ['url', 'URL', '주소'], |
| 48 | +}; |
| 49 | + |
| 50 | +function findCol(headers: string[], field: keyof ImportRow): string | undefined { |
| 51 | + const aliases = FIELD_ALIASES[field]; |
| 52 | + const lower = headers.map((h) => h.trim().toLowerCase().replace(/\s/g, '')); |
| 53 | + for (const alias of aliases) { |
| 54 | + const idx = lower.indexOf(alias.toLowerCase().replace(/\s/g, '')); |
| 55 | + if (idx !== -1) return headers[idx]; |
| 56 | + } |
| 57 | + return undefined; |
| 58 | +} |
| 59 | + |
| 60 | +// ── 값 파싱 ──────────────────────────────────────────────────── |
| 61 | + |
| 62 | +function normalizeCurrency(raw: string): Currency { |
| 63 | + const v = raw.trim().toUpperCase(); |
| 64 | + const valid: Currency[] = ['KRW', 'USD', 'EUR', 'JPY', 'GBP']; |
| 65 | + return valid.includes(v as Currency) ? (v as Currency) : 'KRW'; |
| 66 | +} |
| 67 | + |
| 68 | +function normalizeCycle(raw: string): BillingCycle { |
| 69 | + const v = raw.trim().toLowerCase(); |
| 70 | + if (v === 'yearly' || v === '연간' || v === 'annual') return 'yearly'; |
| 71 | + return 'monthly'; |
| 72 | +} |
| 73 | + |
| 74 | +function parseAmount(raw: string | number): number | null { |
| 75 | + if (typeof raw === 'number') return isFinite(raw) ? raw : null; |
| 76 | + const s = String(raw).trim().replace(/,/g, ''); |
| 77 | + const n = parseFloat(s); |
| 78 | + return isNaN(n) ? null : n; |
| 79 | +} |
| 80 | + |
| 81 | +function parseDay(raw: string | number): number | null { |
| 82 | + const n = typeof raw === 'number' ? raw : parseInt(String(raw).trim()); |
| 83 | + if (isNaN(n) || n < 1 || n > 31) return null; |
| 84 | + return n; |
| 85 | +} |
| 86 | + |
| 87 | +function parseDate(raw: string | number | undefined): string | undefined { |
| 88 | + if (raw === undefined || raw === null || raw === '') return undefined; |
| 89 | + const s = String(raw).trim().slice(0, 10).replace(/[./]/g, '-'); |
| 90 | + const compact = String(raw).trim().replace(/\D/g, '').slice(0, 8); |
| 91 | + if (compact.length === 8) { |
| 92 | + return `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`; |
| 93 | + } |
| 94 | + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; |
| 95 | + return undefined; |
| 96 | +} |
| 97 | + |
| 98 | +// ── CSV 디코딩 ───────────────────────────────────────────────── |
| 99 | + |
| 100 | +function decodeBuffer(buf: Buffer): string { |
| 101 | + if (buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) { |
| 102 | + return buf.slice(3).toString('utf-8'); |
| 103 | + } |
| 104 | + const asUtf8 = buf.toString('utf-8'); |
| 105 | + if (!asUtf8.includes('�')) return asUtf8; |
| 106 | + try { |
| 107 | + const decoder = new TextDecoder('euc-kr', { fatal: true }); |
| 108 | + return decoder.decode(buf); |
| 109 | + } catch { |
| 110 | + return asUtf8; |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +// ── 메인 파서 ───────────────────────────────────────────────── |
| 115 | + |
| 116 | +export function parseSubscriptionFile(buf: Buffer, ext: string): ImportResult { |
| 117 | + let rows: Record<string, unknown>[]; |
| 118 | + |
| 119 | + if (ext === 'csv') { |
| 120 | + const text = decodeBuffer(buf); |
| 121 | + const wb = XLSX.read(text, { type: 'string' }); |
| 122 | + const ws = wb.Sheets[wb.SheetNames[0]!]!; |
| 123 | + rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(ws, { defval: '' }); |
| 124 | + } else { |
| 125 | + const wb = XLSX.read(buf, { type: 'buffer' }); |
| 126 | + const ws = wb.Sheets[wb.SheetNames[0]!]!; |
| 127 | + rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(ws, { defval: '' }); |
| 128 | + } |
| 129 | + |
| 130 | + if (rows.length === 0) return { rows: [], errors: [] }; |
| 131 | + |
| 132 | + const headers = Object.keys(rows[0]!); |
| 133 | + const colMap: Partial<Record<keyof ImportRow, string>> = {}; |
| 134 | + for (const field of Object.keys(FIELD_ALIASES) as (keyof ImportRow)[]) { |
| 135 | + const found = findCol(headers, field); |
| 136 | + if (found) colMap[field] = found; |
| 137 | + } |
| 138 | + |
| 139 | + const result: ImportResult = { rows: [], errors: [] }; |
| 140 | + |
| 141 | + rows.forEach((raw, idx) => { |
| 142 | + const rowNum = idx + 2; // 헤더 행은 1행 |
| 143 | + |
| 144 | + const nameRaw = String(raw[colMap.serviceName ?? ''] ?? '').trim(); |
| 145 | + if (!nameRaw) { |
| 146 | + result.errors.push({ row: rowNum, message: '서비스명이 비어 있습니다.' }); |
| 147 | + return; |
| 148 | + } |
| 149 | + |
| 150 | + const amountRaw = raw[colMap.currentAmount ?? '']; |
| 151 | + const amount = parseAmount(amountRaw as string | number); |
| 152 | + if (amount === null || amount < 0) { |
| 153 | + result.errors.push({ row: rowNum, message: `금액 파싱 실패: "${amountRaw}"` }); |
| 154 | + return; |
| 155 | + } |
| 156 | + |
| 157 | + const dayRaw = raw[colMap.billingDay ?? '']; |
| 158 | + const day = colMap.billingDay ? parseDay(dayRaw as string | number) : 1; |
| 159 | + if (day === null) { |
| 160 | + result.errors.push({ row: rowNum, message: `결제일 파싱 실패: "${dayRaw}"` }); |
| 161 | + return; |
| 162 | + } |
| 163 | + |
| 164 | + const currency = colMap.currency |
| 165 | + ? normalizeCurrency(String(raw[colMap.currency] ?? 'KRW')) |
| 166 | + : 'KRW'; |
| 167 | + |
| 168 | + const cycle = colMap.billingCycle |
| 169 | + ? normalizeCycle(String(raw[colMap.billingCycle] ?? 'monthly')) |
| 170 | + : 'monthly'; |
| 171 | + |
| 172 | + const startedAt = parseDate( |
| 173 | + colMap.startedAt ? (raw[colMap.startedAt] as string | undefined) : undefined, |
| 174 | + ); |
| 175 | + |
| 176 | + const category = colMap.category |
| 177 | + ? String(raw[colMap.category] ?? '').trim() || undefined |
| 178 | + : undefined; |
| 179 | + |
| 180 | + const rawUrl = colMap.url ? String(raw[colMap.url] ?? '').trim() : ''; |
| 181 | + const url = rawUrl.match(/^https?:\/\//) ? rawUrl : undefined; |
| 182 | + |
| 183 | + result.rows.push({ |
| 184 | + serviceName: nameRaw, |
| 185 | + currentAmount: amount, |
| 186 | + currency, |
| 187 | + billingCycle: cycle, |
| 188 | + billingDay: day, |
| 189 | + startedAt, |
| 190 | + category, |
| 191 | + url, |
| 192 | + }); |
| 193 | + }); |
| 194 | + |
| 195 | + return result; |
| 196 | +} |
0 commit comments