일별 지출에서 일회성 큰 지출일(중앙값 3배 초과)을 하루평균 산정에서 제외 → 실제 지출 + 남은 날 × 평소 하루 지출. AI 비결정 값(40만~500만 변동) 문제 해결. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user