매출 대시보드 완전 구현 완료

- /revenue 경로로 매출 대시보드 페이지 추가
- 4개 매출 분석 API 엔드포인트 구현:
  * /api/analytics/revenue/monthly - 12개월 매출 트렌드
  * /api/analytics/revenue/by-service - 서비스별 매출 비중
  * /api/analytics/pharmacy-ranking - 약국별 구독료 순위
  * /api/analytics/subscription-trends - 구독 성장/해지 트렌드
- Chart.js 기반 종합 시각화 대시보드:
  * 월별 매출 라인 차트
  * 서비스별 매출 도넛 차트
  * 약국 순위 테이블
  * 구독 트렌드 바 차트
- 실시간 데이터 로딩 및 오류 처리 구현

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
시골약사 2025-09-11 19:59:29 +09:00
parent e93f96abe4
commit 6116f3fd15
2 changed files with 764 additions and 0 deletions

View File

@ -1147,6 +1147,227 @@ def create_app(config_name=None):
"""구독 서비스 관리 페이지""" """구독 서비스 관리 페이지"""
return render_template('subscriptions/list.html') return render_template('subscriptions/list.html')
# =================== 매출 대시보드 ===================
@app.route('/revenue')
def revenue_dashboard():
"""매출 대시보드 페이지"""
return render_template('revenue/dashboard.html')
@app.route('/api/analytics/revenue/monthly', methods=['GET'])
def api_monthly_revenue():
"""월별 매출 통계"""
try:
import sqlite3
from datetime import datetime, timedelta
import calendar
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 최근 12개월 매출 데이터 생성
current_date = datetime.now()
monthly_data = []
for i in range(12):
# i개월 전 날짜 계산
target_date = current_date - timedelta(days=30*i)
year = target_date.year
month = target_date.month
month_name = calendar.month_name[month]
# 해당 월의 구독 수 및 매출 계산
cursor.execute('''
SELECT
COUNT(ps.id) as subscription_count,
SUM(ps.monthly_fee) as total_revenue
FROM pharmacy_subscriptions ps
WHERE ps.subscription_status = 'ACTIVE'
AND ps.start_date <= ?
''', (f'{year}-{month:02d}-28',))
result = cursor.fetchone()
subscription_count = result[0] or 0
total_revenue = result[1] or 0
monthly_data.insert(0, {
'month': f'{year}-{month:02d}',
'month_name': f'{month_name[:3]} {year}',
'subscription_count': subscription_count,
'revenue': total_revenue
})
conn.close()
return jsonify({
'success': True,
'data': monthly_data
})
except Exception as e:
print(f"❌ 월별 매출 통계 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/analytics/revenue/by-service', methods=['GET'])
def api_revenue_by_service():
"""서비스별 매출 통계"""
try:
import sqlite3
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 서비스별 매출 통계
cursor.execute('''
SELECT
sp.product_code,
sp.product_name,
COUNT(ps.id) as subscription_count,
SUM(ps.monthly_fee) as total_revenue
FROM service_products sp
LEFT JOIN pharmacy_subscriptions ps ON sp.id = ps.product_id
AND ps.subscription_status = 'ACTIVE'
GROUP BY sp.id, sp.product_code, sp.product_name
ORDER BY total_revenue DESC NULLS LAST
''')
services = []
total_revenue = 0
for row in cursor.fetchall():
product_code, product_name, count, revenue = row
revenue = revenue or 0
total_revenue += revenue
services.append({
'code': product_code,
'name': product_name,
'subscription_count': count,
'revenue': revenue
})
# 비율 계산
for service in services:
service['percentage'] = round((service['revenue'] / total_revenue * 100), 1) if total_revenue > 0 else 0
conn.close()
return jsonify({
'success': True,
'data': {
'services': services,
'total_revenue': total_revenue
}
})
except Exception as e:
print(f"❌ 서비스별 매출 통계 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/analytics/pharmacy-ranking', methods=['GET'])
def api_pharmacy_ranking():
"""약국별 구독료 순위"""
try:
import sqlite3
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 약국별 구독료 순위 (상위 10개)
cursor.execute('''
SELECT
p.pharmacy_name,
p.manager_name,
COUNT(ps.id) as service_count,
SUM(ps.monthly_fee) as total_monthly_fee
FROM pharmacies p
JOIN pharmacy_subscriptions ps ON p.id = ps.pharmacy_id
WHERE ps.subscription_status = 'ACTIVE'
GROUP BY p.id, p.pharmacy_name, p.manager_name
ORDER BY total_monthly_fee DESC
LIMIT 10
''')
rankings = []
for i, row in enumerate(cursor.fetchall(), 1):
pharmacy_name, manager_name, service_count, total_fee = row
rankings.append({
'rank': i,
'pharmacy_name': pharmacy_name,
'manager_name': manager_name,
'service_count': service_count,
'monthly_fee': total_fee
})
conn.close()
return jsonify({
'success': True,
'data': rankings
})
except Exception as e:
print(f"❌ 약국별 순위 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/analytics/subscription-trends', methods=['GET'])
def api_subscription_trends():
"""구독 트렌드 통계 (신규/해지)"""
try:
import sqlite3
from datetime import datetime, timedelta
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 최근 6개월 구독 트렌드
trends = []
current_date = datetime.now()
for i in range(6):
target_date = current_date - timedelta(days=30*i)
year = target_date.year
month = target_date.month
# 해당 월 신규 구독 수
cursor.execute('''
SELECT COUNT(*)
FROM pharmacy_subscriptions
WHERE strftime('%Y-%m', start_date) = ?
''', (f'{year}-{month:02d}',))
new_subscriptions = cursor.fetchone()[0]
# 해당 월 해지 구독 수
cursor.execute('''
SELECT COUNT(*)
FROM pharmacy_subscriptions
WHERE subscription_status = 'CANCELLED'
AND strftime('%Y-%m', updated_at) = ?
''', (f'{year}-{month:02d}',))
cancelled_subscriptions = cursor.fetchone()[0]
trends.insert(0, {
'month': f'{year}-{month:02d}',
'new_subscriptions': new_subscriptions,
'cancelled_subscriptions': cancelled_subscriptions,
'net_growth': new_subscriptions - cancelled_subscriptions
})
conn.close()
return jsonify({
'success': True,
'data': trends
})
except Exception as e:
print(f"❌ 구독 트렌드 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# 에러 핸들러 # 에러 핸들러
@app.errorhandler(404) @app.errorhandler(404)
def not_found_error(error): def not_found_error(error):

View File

@ -0,0 +1,543 @@
{% extends "base.html" %}
{% block title %}매출 대시보드 - PharmQ Super Admin{% endblock %}
{% block breadcrumb %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('dashboard') }}">대시보드</a></li>
<li class="breadcrumb-item active">매출 대시보드</li>
</ol>
</nav>
{% endblock %}
{% block content %}
<!-- 페이지 헤더 -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="h2 mb-0">
<i class="fas fa-chart-pie text-warning"></i>
매출 대시보드
</h1>
<p class="text-muted">PharmQ SaaS 매출 현황 및 트렌드 분석</p>
</div>
<div>
<button class="btn btn-warning" onclick="refreshRevenueData()">
<i class="fas fa-sync-alt"></i> 새로고침
</button>
</div>
</div>
</div>
</div>
<!-- 매출 요약 카드 -->
<div class="row mb-4" id="revenue-summary-cards">
<div class="col-12 text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">매출 요약 로딩 중...</span>
</div>
<p class="mt-2 text-muted">매출 요약을 불러오는 중입니다...</p>
</div>
</div>
<!-- 차트 섹션 -->
<div class="row mb-4">
<!-- 월별 매출 트렌드 -->
<div class="col-lg-8 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-line text-primary"></i> 월별 매출 트렌드 (최근 12개월)
</h5>
</div>
<div class="card-body">
<div style="height: 350px;">
<canvas id="monthlyRevenueChart"></canvas>
</div>
<div class="mt-3" id="monthly-chart-loading">
<div class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">차트 로딩 중...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 서비스별 매출 비중 -->
<div class="col-lg-4 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-pie text-success"></i> 서비스별 매출 비중
</h5>
</div>
<div class="card-body">
<div style="height: 300px;">
<canvas id="serviceRevenueChart"></canvas>
</div>
<div class="mt-3" id="service-chart-loading">
<div class="text-center">
<div class="spinner-border text-success" role="status">
<span class="visually-hidden">차트 로딩 중...</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- 약국별 구독료 순위 -->
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-trophy text-warning"></i> 약국별 구독료 순위 (TOP 10)
</h5>
</div>
<div class="card-body">
<div id="pharmacy-ranking">
<div class="text-center">
<div class="spinner-border text-warning" role="status">
<span class="visually-hidden">순위 로딩 중...</span>
</div>
<p class="mt-2 text-muted">약국별 순위를 불러오는 중입니다...</p>
</div>
</div>
</div>
</div>
</div>
<!-- 구독 트렌드 -->
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-bar text-info"></i> 구독 트렌드 (최근 6개월)
</h5>
</div>
<div class="card-body">
<div style="height: 250px;">
<canvas id="subscriptionTrendChart"></canvas>
</div>
<div class="mt-3" id="trend-chart-loading">
<div class="text-center">
<div class="spinner-border text-info" role="status">
<span class="visually-hidden">트렌드 차트 로딩 중...</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
let monthlyRevenueChart = null;
let serviceRevenueChart = null;
let subscriptionTrendChart = null;
// 페이지 로드 시 모든 데이터 로드
document.addEventListener('DOMContentLoaded', function() {
loadRevenueData();
});
// 모든 매출 데이터 로드
function loadRevenueData() {
loadRevenueSummary();
loadMonthlyRevenue();
loadServiceRevenue();
loadPharmacyRanking();
loadSubscriptionTrends();
}
// 매출 요약 로드
function loadRevenueSummary() {
fetch('/api/subscriptions/stats')
.then(response => response.json())
.then(result => {
if (result.success) {
displayRevenueSummary(result.data);
} else {
showRevenueSummaryError();
}
})
.catch(error => {
console.error('매출 요약 로드 오류:', error);
showRevenueSummaryError();
});
}
// 매출 요약 표시
function displayRevenueSummary(data) {
const summaryHtml = `
<div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<div class="card-body text-center">
<div class="stat-number">₩${data.total_revenue.toLocaleString()}</div>
<div class="stat-label">
<i class="fas fa-won-sign"></i> 현재 월 매출
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white;">
<div class="card-body text-center">
<div class="stat-number">${data.total_subscriptions}</div>
<div class="stat-label">
<i class="fas fa-clipboard-list"></i> 활성 구독
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); color: white;">
<div class="card-body text-center">
<div class="stat-number">${data.subscribed_pharmacies}</div>
<div class="stat-label">
<i class="fas fa-store"></i> 구독 약국
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); color: white;">
<div class="card-body text-center">
<div class="stat-number">₩${Math.round(data.total_revenue / data.subscribed_pharmacies || 0).toLocaleString()}</div>
<div class="stat-label">
<i class="fas fa-calculator"></i> 평균 ARPU
</div>
</div>
</div>
</div>
`;
document.getElementById('revenue-summary-cards').innerHTML = summaryHtml;
}
// 월별 매출 로드
function loadMonthlyRevenue() {
fetch('/api/analytics/revenue/monthly')
.then(response => response.json())
.then(result => {
if (result.success) {
displayMonthlyRevenueChart(result.data);
} else {
showMonthlyChartError();
}
})
.catch(error => {
console.error('월별 매출 로드 오류:', error);
showMonthlyChartError();
});
}
// 월별 매출 차트 표시
function displayMonthlyRevenueChart(data) {
document.getElementById('monthly-chart-loading').style.display = 'none';
const ctx = document.getElementById('monthlyRevenueChart').getContext('2d');
if (monthlyRevenueChart) {
monthlyRevenueChart.destroy();
}
monthlyRevenueChart = new Chart(ctx, {
type: 'line',
data: {
labels: data.map(item => item.month_name),
datasets: [{
label: '월 매출 (원)',
data: data.map(item => item.revenue),
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return '₩' + value.toLocaleString();
}
}
}
},
plugins: {
tooltip: {
callbacks: {
label: function(context) {
return '매출: ₩' + context.parsed.y.toLocaleString();
}
}
}
}
}
});
}
// 서비스별 매출 로드
function loadServiceRevenue() {
fetch('/api/analytics/revenue/by-service')
.then(response => response.json())
.then(result => {
if (result.success) {
displayServiceRevenueChart(result.data);
} else {
showServiceChartError();
}
})
.catch(error => {
console.error('서비스별 매출 로드 오류:', error);
showServiceChartError();
});
}
// 서비스별 매출 차트 표시
function displayServiceRevenueChart(data) {
document.getElementById('service-chart-loading').style.display = 'none';
const ctx = document.getElementById('serviceRevenueChart').getContext('2d');
if (serviceRevenueChart) {
serviceRevenueChart.destroy();
}
const colors = ['#FF6384', '#36A2EB', '#FFCE56'];
serviceRevenueChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: data.services.map(service => service.name),
datasets: [{
data: data.services.map(service => service.revenue),
backgroundColor: colors.slice(0, data.services.length),
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
},
tooltip: {
callbacks: {
label: function(context) {
const service = data.services[context.dataIndex];
return service.name + ': ₩' + service.revenue.toLocaleString() + ' (' + service.percentage + '%)';
}
}
}
}
}
});
}
// 약국 순위 로드
function loadPharmacyRanking() {
fetch('/api/analytics/pharmacy-ranking')
.then(response => response.json())
.then(result => {
if (result.success) {
displayPharmacyRanking(result.data);
} else {
showPharmacyRankingError();
}
})
.catch(error => {
console.error('약국 순위 로드 오류:', error);
showPharmacyRankingError();
});
}
// 약국 순위 표시
function displayPharmacyRanking(rankings) {
let html = '<div class="list-group list-group-flush">';
rankings.forEach(pharmacy => {
const rankIcon = pharmacy.rank <= 3 ?
`<i class="fas fa-medal text-warning"></i>` :
`<span class="badge bg-light text-dark">${pharmacy.rank}</span>`;
html += `
<div class="list-group-item d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<div class="me-3">${rankIcon}</div>
<div>
<h6 class="mb-1">${pharmacy.pharmacy_name}</h6>
<small class="text-muted">${pharmacy.manager_name} · ${pharmacy.service_count}개 서비스</small>
</div>
</div>
<div class="text-end">
<strong class="text-success">₩${pharmacy.monthly_fee.toLocaleString()}/월</strong>
</div>
</div>
`;
});
html += '</div>';
document.getElementById('pharmacy-ranking').innerHTML = html;
}
// 구독 트렌드 로드
function loadSubscriptionTrends() {
fetch('/api/analytics/subscription-trends')
.then(response => response.json())
.then(result => {
if (result.success) {
displaySubscriptionTrendChart(result.data);
} else {
showTrendChartError();
}
})
.catch(error => {
console.error('구독 트렌드 로드 오류:', error);
showTrendChartError();
});
}
// 구독 트렌드 차트 표시
function displaySubscriptionTrendChart(data) {
document.getElementById('trend-chart-loading').style.display = 'none';
const ctx = document.getElementById('subscriptionTrendChart').getContext('2d');
if (subscriptionTrendChart) {
subscriptionTrendChart.destroy();
}
subscriptionTrendChart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(item => item.month),
datasets: [{
label: '신규 구독',
data: data.map(item => item.new_subscriptions),
backgroundColor: 'rgba(54, 162, 235, 0.8)'
}, {
label: '해지 구독',
data: data.map(item => item.cancelled_subscriptions),
backgroundColor: 'rgba(255, 99, 132, 0.8)'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
}
// 데이터 새로고침
function refreshRevenueData() {
showToast('매출 데이터를 새로고침 중...', 'info');
// 로딩 상태로 되돌리기
document.getElementById('revenue-summary-cards').innerHTML = `
<div class="col-12 text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">새로고침 중...</span>
</div>
</div>
`;
// 모든 데이터 다시 로드
loadRevenueData();
}
// 오류 표시 함수들
function showRevenueSummaryError() {
document.getElementById('revenue-summary-cards').innerHTML = `
<div class="col-12 text-center text-muted py-4">
<i class="fas fa-exclamation-triangle fa-3x mb-3"></i>
<p>매출 요약을 불러오는 중 오류가 발생했습니다.</p>
</div>
`;
}
function showMonthlyChartError() {
document.getElementById('monthly-chart-loading').innerHTML = `
<div class="text-center text-muted py-4">
<i class="fas fa-exclamation-triangle fa-2x mb-3"></i>
<p>차트 데이터를 불러올 수 없습니다.</p>
</div>
`;
}
function showServiceChartError() {
document.getElementById('service-chart-loading').innerHTML = `
<div class="text-center text-muted py-4">
<i class="fas fa-exclamation-triangle fa-2x mb-3"></i>
<p>서비스 데이터를 불러올 수 없습니다.</p>
</div>
`;
}
function showPharmacyRankingError() {
document.getElementById('pharmacy-ranking').innerHTML = `
<div class="text-center text-muted py-4">
<i class="fas fa-exclamation-triangle fa-2x mb-3"></i>
<p>순위 데이터를 불러올 수 없습니다.</p>
</div>
`;
}
function showTrendChartError() {
document.getElementById('trend-chart-loading').innerHTML = `
<div class="text-center text-muted py-4">
<i class="fas fa-exclamation-triangle fa-2x mb-3"></i>
<p>트렌드 데이터를 불러올 수 없습니다.</p>
</div>
`;
}
</script>
<style>
.stat-number {
font-size: 2rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.9;
}
.list-group-item {
border-left: none;
border-right: none;
}
.list-group-item:first-child {
border-top: none;
}
.list-group-item:last-child {
border-bottom: none;
}
</style>
{% endblock %}