diff --git a/src/views/account/AccountDashboardView.vue b/src/views/account/AccountDashboardView.vue index 4e9f0ca..377b021 100644 --- a/src/views/account/AccountDashboardView.vue +++ b/src/views/account/AccountDashboardView.vue @@ -26,8 +26,9 @@ const categoryList = ref([]) // 분류 목록(대/소분류 매핑용) const trendData = ref([]) // [{month, netWorth}] const aiBullets = ref([]) // AI 재무 코멘트(현황 관찰) const aiTips = ref([]) // AI 절약 가이드(실행 제안) -const aiForecast = ref(null) // AI 월말 예상 지출(일회성 큰 지출 제외 추정) -const aiForecastNote = ref('') // 예상 지출 근거 문장 +const forecast = ref(null) // 월말 예상 지출(결정적 계산, 일회성 큰 지출 제외) +const forecastNote = ref('') // 예상 지출 근거 문장 +const forecastLoading = ref(false) const aiLoading = ref(false) const periodLabel = computed(() => `${year.value}년 ${String(month.value).padStart(2, '0')}월`) @@ -67,8 +68,49 @@ const expenseDelta = computed(() => { return { prev, diff, pct } }) -/* 월말 예상 지출은 단순 선형(run-rate) 대신 AI가 추정한다(aiForecast). - 일회성 큰 지출(노트북 등)을 일평균에서 제외해 현실적인 값을 준다. loadAiComment 참고. */ +/* ===== 월말 예상 지출 (결정적 계산 — 매 접속 동일값) ===== + 일별 지출을 받아 '평소 하루 지출'을 구하되, 일회성 큰 지출(노트북 등)이 있는 날은 + 일평균을 왜곡하므로 제외한다. 추정 = 실제 지출(일회성 포함) + 남은 날 × 평소 하루 지출. */ +function robustDailyRate(days) { + if (!days.length) return { rate: 0, dropped: 0 } + const sorted = [...days].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 + const max = sorted[sorted.length - 1] + // 최대 지출일이 중앙값의 3배(그리고 10만원)를 넘으면 일회성으로 보고 하루평균 산정에서 제외 + const dropOutlier = sorted.length >= 4 && max > Math.max(median * 3, 100000) + const base = dropOutlier ? sorted.slice(0, -1) : sorted + const rate = base.reduce((a, b) => a + b, 0) / base.length + return { rate, dropped: dropOutlier ? max : 0 } +} +async function loadForecast() { + forecast.value = null + forecastNote.value = '' + const isCurrent = year.value === now.getFullYear() && month.value === now.getMonth() + 1 + if (!isCurrent) return // 과거/미래 달은 추정 불필요 + const totalDays = daysInMonth(year.value, month.value) + const elapsed = now.getDate() + if (elapsed < 1) return + forecastLoading.value = true + try { + const daily = await accountApi.budgetPeriod({ unit: 'DAY', year: year.value, month: month.value }) + const map = {} + ;(daily || []).forEach((d) => (map[String(d.bucket)] = d.expense || 0)) + const days = [] + for (let d = 1; d <= elapsed; d++) days.push(map[String(d)] || 0) + const { rate, dropped } = robustDailyRate(days) + const remaining = totalDays - elapsed + forecast.value = Math.round(monthExpense.value + rate * remaining) + forecastNote.value = + dropped > 0 + ? `가장 큰 하루 지출(${won(dropped)})은 일회성으로 보고 남은 ${remaining}일 추정에서 제외` + : `최근 하루 평균 지출로 남은 ${remaining}일을 더한 추정` + } catch { + forecast.value = null + } finally { + forecastLoading.value = false + } +} /* ===== 막대 차트 (기간별 예산 대비 지출) ===== */ const bars = computed(() => { @@ -261,6 +303,7 @@ async function load() { monthIncome.value = summary.totalIncome monthExpense.value = summary.totalExpense prevMonthExpense.value = prevSummary.totalExpense + loadForecast() // 결정적 예상 지출(비동기) loadAiComment() // 집계가 준비된 뒤 AI 코멘트 생성(비동기, 화면 로딩 막지 않음) } catch (e) { error.value = e.response?.data?.message || '불러오지 못했습니다.' @@ -274,10 +317,7 @@ async function loadAiComment() { aiLoading.value = true aiBullets.value = [] aiTips.value = [] - aiForecast.value = null - aiForecastNote.value = '' try { - const isCurrent = year.value === now.getFullYear() && month.value === now.getMonth() + 1 const res = await accountApi.aiComment({ year: year.value, month: month.value, @@ -286,19 +326,13 @@ async function loadAiComment() { prevExpense: prevMonthExpense.value, budget: budgetTotal.value, spent: spentTotal.value, - elapsedDays: isCurrent ? now.getDate() : 0, - totalDays: daysInMonth(year.value, month.value), categories: (catData.value || []).slice(0, 8).map((c) => ({ category: c.category, total: c.total })), }) aiBullets.value = res?.bullets || [] aiTips.value = res?.tips || [] - aiForecast.value = res?.forecast || null - aiForecastNote.value = res?.forecastNote || '' } catch { aiBullets.value = [] aiTips.value = [] - aiForecast.value = null - aiForecastNote.value = '' } finally { aiLoading.value = false } @@ -423,19 +457,19 @@ onMounted(async () => { {{ expenseDelta.diff >= 0 ? '+' : '' }}{{ won(expenseDelta.diff) }} -
- 예상 지출 AI 계산 중… +
+ 예상 지출 계산 중… ···
-
- 예상 지출 월말 추정 · AI - {{ won(aiForecast) }} +
+ 예상 지출 월말 추정 + {{ won(forecast) }}
수지{{ won(monthIncome - monthExpense) }}
-

{{ aiForecastNote }}

+

{{ forecastNote }}