fix(stats): 예상 지출을 결정적 계산으로 — 매 접속 동일값
Deploy / deploy (push) Failing after 11m2s

일별 지출에서 일회성 큰 지출일(중앙값 3배 초과)을 하루평균 산정에서 제외 →
실제 지출 + 남은 날 × 평소 하루 지출. AI 비결정 값(40만~500만 변동) 문제 해결.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-05 15:43:47 +09:00
parent 59a17f9087
commit 0090c31294
+53 -19
View File
@@ -26,8 +26,9 @@ const categoryList = ref([]) // 분류 목록(대/소분류 매핑용)
const trendData = ref([]) // [{month, netWorth}] const trendData = ref([]) // [{month, netWorth}]
const aiBullets = ref([]) // AI 재무 코멘트(현황 관찰) const aiBullets = ref([]) // AI 재무 코멘트(현황 관찰)
const aiTips = ref([]) // AI 절약 가이드(실행 제안) const aiTips = ref([]) // AI 절약 가이드(실행 제안)
const aiForecast = ref(null) // AI 월말 예상 지출(일회성 큰 지출 제외 추정) const forecast = ref(null) // 월말 예상 지출(결정적 계산, 일회성 큰 지출 제외)
const aiForecastNote = ref('') // 예상 지출 근거 문장 const forecastNote = ref('') // 예상 지출 근거 문장
const forecastLoading = ref(false)
const aiLoading = ref(false) const aiLoading = ref(false)
const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`) const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`)
@@ -67,8 +68,49 @@ const expenseDelta = computed(() => {
return { prev, diff, pct } 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(() => { const bars = computed(() => {
@@ -261,6 +303,7 @@ async function load() {
monthIncome.value = summary.totalIncome monthIncome.value = summary.totalIncome
monthExpense.value = summary.totalExpense monthExpense.value = summary.totalExpense
prevMonthExpense.value = prevSummary.totalExpense prevMonthExpense.value = prevSummary.totalExpense
loadForecast() // 결정적 예상 지출(비동기)
loadAiComment() // 집계가 준비된 뒤 AI 코멘트 생성(비동기, 화면 로딩 막지 않음) loadAiComment() // 집계가 준비된 뒤 AI 코멘트 생성(비동기, 화면 로딩 막지 않음)
} catch (e) { } catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.' error.value = e.response?.data?.message || '불러오지 못했습니다.'
@@ -274,10 +317,7 @@ async function loadAiComment() {
aiLoading.value = true aiLoading.value = true
aiBullets.value = [] aiBullets.value = []
aiTips.value = [] aiTips.value = []
aiForecast.value = null
aiForecastNote.value = ''
try { try {
const isCurrent = year.value === now.getFullYear() && month.value === now.getMonth() + 1
const res = await accountApi.aiComment({ const res = await accountApi.aiComment({
year: year.value, year: year.value,
month: month.value, month: month.value,
@@ -286,19 +326,13 @@ async function loadAiComment() {
prevExpense: prevMonthExpense.value, prevExpense: prevMonthExpense.value,
budget: budgetTotal.value, budget: budgetTotal.value,
spent: spentTotal.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 })), categories: (catData.value || []).slice(0, 8).map((c) => ({ category: c.category, total: c.total })),
}) })
aiBullets.value = res?.bullets || [] aiBullets.value = res?.bullets || []
aiTips.value = res?.tips || [] aiTips.value = res?.tips || []
aiForecast.value = res?.forecast || null
aiForecastNote.value = res?.forecastNote || ''
} catch { } catch {
aiBullets.value = [] aiBullets.value = []
aiTips.value = [] aiTips.value = []
aiForecast.value = null
aiForecastNote.value = ''
} finally { } finally {
aiLoading.value = false aiLoading.value = false
} }
@@ -423,19 +457,19 @@ onMounted(async () => {
{{ expenseDelta.diff >= 0 ? '+' : '' }}{{ won(expenseDelta.diff) }}<template v-if="expenseDelta.pct !== null"> ({{ expenseDelta.pct >= 0 ? '+' : '' }}{{ expenseDelta.pct }}%)</template> {{ expenseDelta.diff >= 0 ? '+' : '' }}{{ won(expenseDelta.diff) }}<template v-if="expenseDelta.pct !== null"> ({{ expenseDelta.pct >= 0 ? '+' : '' }}{{ expenseDelta.pct }}%)</template>
</b> </b>
</div> </div>
<div v-if="aiLoading && !aiForecast" class="ie-row forecast"> <div v-if="forecastLoading && !forecast" class="ie-row forecast">
<span>예상 지출 <small>AI 계산 </small></span> <span>예상 지출 <small>계산 </small></span>
<b class="pending">···</b> <b class="pending">···</b>
</div> </div>
<div v-else-if="aiForecast" class="ie-row forecast"> <div v-else-if="forecast" class="ie-row forecast">
<span>예상 지출 <small>월말 추정 · AI</small></span> <span>예상 지출 <small>월말 추정</small></span>
<b class="expense">{{ won(aiForecast) }}</b> <b class="expense">{{ won(forecast) }}</b>
</div> </div>
<div class="ie-bar"> <div class="ie-bar">
<div class="ie-fill income" :style="{ width: incomePct + '%' }"></div> <div class="ie-fill income" :style="{ width: incomePct + '%' }"></div>
</div> </div>
<div class="ie-row net"><span>수지</span><b :class="monthIncome - monthExpense < 0 ? 'expense' : 'income'">{{ won(monthIncome - monthExpense) }}</b></div> <div class="ie-row net"><span>수지</span><b :class="monthIncome - monthExpense < 0 ? 'expense' : 'income'">{{ won(monthIncome - monthExpense) }}</b></div>
<p v-if="aiForecastNote" class="forecast-note">{{ aiForecastNote }}</p> <p v-if="forecastNote" class="forecast-note">{{ forecastNote }}</p>
</div> </div>
</div> </div>
</div> </div>