PharmQ SaaS 구독 서비스 관리 시스템 완전 구현
📋 기획 및 설계: - PharmQ SaaS 서비스 기획서 작성 - 구독 서비스 라인업 정의 (클라우드PC, AI CCTV, CRM) - DB 스키마 설계 및 API 아키텍처 설계 🗄️ 데이터베이스 구조: - service_products: 서비스 상품 마스터 테이블 - pharmacy_subscriptions: 약국별 구독 현황 테이블 - subscription_usage_logs: 서비스 이용 로그 테이블 - billing_history: 결제 이력 테이블 - 샘플 데이터 자동 생성 (21개 구독, 월 118만원 매출) 🔧 백엔드 API 구현: - 구독 현황 통계 API (/api/subscriptions/stats) - 약국별 구독 조회 API (/api/pharmacies/subscriptions) - 구독 상세 정보 API (/api/pharmacy/{id}/subscriptions) - 구독 생성/해지 API (/api/subscriptions) 🖥️ 프론트엔드 UI 구현: - 대시보드 구독 현황 카드 (월 매출, 구독 수, 구독률 등) - 약국 목록에 구독 상태 아이콘 및 월 구독료 표시 - 약국 상세 페이지 구독 서비스 섹션 추가 - 실시간 구독 생성/해지 기능 구현 ✨ 주요 특징: - 서비스별 색상 코딩 및 이모지 아이콘 시스템 - 실시간 업데이트 (구독 생성/해지 즉시 반영) - 반응형 디자인 (모바일/태블릿 최적화) - 툴팁 기반 상세 정보 표시 📊 현재 구독 현황: - 총 월 매출: ₩1,180,000 - 구독 약국: 10/14개 (71.4%) - AI CCTV: 6개 약국, CRM: 10개 약국, 클라우드PC: 5개 약국 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -825,6 +825,328 @@ def create_app(config_name=None):
|
||||
'error': f'서버 오류: {str(e)}'
|
||||
}), 500
|
||||
|
||||
# =================== 구독 서비스 관리 API ===================
|
||||
|
||||
@app.route('/api/subscriptions/stats', methods=['GET'])
|
||||
def api_subscription_stats():
|
||||
"""대시보드용 구독 현황 통계"""
|
||||
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_monthly_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 sp.product_code
|
||||
''')
|
||||
|
||||
services = []
|
||||
total_revenue = 0
|
||||
total_subscriptions = 0
|
||||
|
||||
for row in cursor.fetchall():
|
||||
product_code, product_name, count, revenue = row
|
||||
revenue = revenue or 0
|
||||
services.append({
|
||||
'code': product_code,
|
||||
'name': product_name,
|
||||
'count': count,
|
||||
'revenue': revenue
|
||||
})
|
||||
total_revenue += revenue
|
||||
total_subscriptions += count
|
||||
|
||||
# 전체 약국 수
|
||||
cursor.execute("SELECT COUNT(*) FROM pharmacies")
|
||||
total_pharmacies = cursor.fetchone()[0]
|
||||
|
||||
# 구독 중인 약국 수
|
||||
cursor.execute('''
|
||||
SELECT COUNT(DISTINCT pharmacy_id)
|
||||
FROM pharmacy_subscriptions
|
||||
WHERE subscription_status = 'ACTIVE'
|
||||
''')
|
||||
subscribed_pharmacies = cursor.fetchone()[0]
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'services': services,
|
||||
'total_revenue': total_revenue,
|
||||
'total_subscriptions': total_subscriptions,
|
||||
'total_pharmacies': total_pharmacies,
|
||||
'subscribed_pharmacies': subscribed_pharmacies,
|
||||
'subscription_rate': round(subscribed_pharmacies / total_pharmacies * 100, 1) if total_pharmacies > 0 else 0
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 구독 통계 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/pharmacies/subscriptions', methods=['GET'])
|
||||
def api_pharmacy_subscriptions():
|
||||
"""약국별 구독 현황 조회"""
|
||||
try:
|
||||
import sqlite3
|
||||
|
||||
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 약국별 구독 현황 조회
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
p.id,
|
||||
p.pharmacy_name,
|
||||
p.manager_name,
|
||||
p.address,
|
||||
GROUP_CONCAT(sp.product_code) as subscribed_services,
|
||||
SUM(ps.monthly_fee) as total_monthly_fee
|
||||
FROM pharmacies p
|
||||
LEFT JOIN pharmacy_subscriptions ps ON p.id = ps.pharmacy_id
|
||||
AND ps.subscription_status = 'ACTIVE'
|
||||
LEFT JOIN service_products sp ON ps.product_id = sp.id
|
||||
GROUP BY p.id, p.pharmacy_name, p.manager_name, p.address
|
||||
ORDER BY total_monthly_fee DESC NULLS LAST, p.pharmacy_name
|
||||
''')
|
||||
|
||||
pharmacies = []
|
||||
for row in cursor.fetchall():
|
||||
pharmacy_id, name, manager, address, services, fee = row
|
||||
|
||||
# 구독 서비스 리스트 변환
|
||||
service_list = []
|
||||
if services:
|
||||
for service_code in services.split(','):
|
||||
service_list.append(service_code)
|
||||
|
||||
pharmacies.append({
|
||||
'id': pharmacy_id,
|
||||
'name': name,
|
||||
'manager': manager,
|
||||
'address': address,
|
||||
'subscribed_services': service_list,
|
||||
'monthly_fee': fee or 0
|
||||
})
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': pharmacies
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 약국 구독 현황 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/pharmacy/<int:pharmacy_id>/subscriptions', methods=['GET'])
|
||||
def api_pharmacy_subscription_detail(pharmacy_id):
|
||||
"""특정 약국의 구독 상세 현황"""
|
||||
try:
|
||||
import sqlite3
|
||||
|
||||
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 약국 기본 정보
|
||||
cursor.execute("SELECT pharmacy_name FROM pharmacies WHERE id = ?", (pharmacy_id,))
|
||||
pharmacy_result = cursor.fetchone()
|
||||
if not pharmacy_result:
|
||||
return jsonify({'success': False, 'error': '약국을 찾을 수 없습니다.'}), 404
|
||||
|
||||
# 구독 중인 서비스
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
ps.id,
|
||||
sp.product_code,
|
||||
sp.product_name,
|
||||
ps.monthly_fee,
|
||||
ps.start_date,
|
||||
ps.next_billing_date,
|
||||
ps.subscription_status,
|
||||
ps.notes
|
||||
FROM pharmacy_subscriptions ps
|
||||
JOIN service_products sp ON ps.product_id = sp.id
|
||||
WHERE ps.pharmacy_id = ? AND ps.subscription_status = 'ACTIVE'
|
||||
ORDER BY sp.product_name
|
||||
''', (pharmacy_id,))
|
||||
|
||||
active_subscriptions = []
|
||||
for row in cursor.fetchall():
|
||||
sub_id, code, name, fee, start, next_bill, status, notes = row
|
||||
active_subscriptions.append({
|
||||
'id': sub_id,
|
||||
'code': code,
|
||||
'name': name,
|
||||
'monthly_fee': fee,
|
||||
'start_date': start,
|
||||
'next_billing_date': next_bill,
|
||||
'status': status,
|
||||
'notes': notes
|
||||
})
|
||||
|
||||
# 구독 가능한 서비스 (미구독 서비스)
|
||||
cursor.execute('''
|
||||
SELECT sp.id, sp.product_code, sp.product_name, sp.monthly_price, sp.description
|
||||
FROM service_products sp
|
||||
WHERE sp.is_active = 1
|
||||
AND sp.id NOT IN (
|
||||
SELECT ps.product_id
|
||||
FROM pharmacy_subscriptions ps
|
||||
WHERE ps.pharmacy_id = ? AND ps.subscription_status = 'ACTIVE'
|
||||
)
|
||||
ORDER BY sp.product_name
|
||||
''', (pharmacy_id,))
|
||||
|
||||
available_services = []
|
||||
for row in cursor.fetchall():
|
||||
prod_id, code, name, price, desc = row
|
||||
available_services.append({
|
||||
'id': prod_id,
|
||||
'code': code,
|
||||
'name': name,
|
||||
'monthly_price': price,
|
||||
'description': desc
|
||||
})
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'pharmacy_name': pharmacy_result[0],
|
||||
'active_subscriptions': active_subscriptions,
|
||||
'available_services': available_services
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 약국 구독 상세 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/subscriptions', methods=['POST'])
|
||||
def api_create_subscription():
|
||||
"""새 구독 생성"""
|
||||
try:
|
||||
import sqlite3
|
||||
from datetime import date, timedelta
|
||||
|
||||
data = request.get_json()
|
||||
pharmacy_id = data.get('pharmacy_id')
|
||||
product_id = data.get('product_id')
|
||||
monthly_fee = data.get('monthly_fee')
|
||||
notes = data.get('notes', '')
|
||||
|
||||
if not pharmacy_id or not product_id:
|
||||
return jsonify({'success': False, 'error': '필수 파라미터가 누락되었습니다.'}), 400
|
||||
|
||||
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 중복 구독 확인
|
||||
cursor.execute('''
|
||||
SELECT id FROM pharmacy_subscriptions
|
||||
WHERE pharmacy_id = ? AND product_id = ? AND subscription_status = 'ACTIVE'
|
||||
''', (pharmacy_id, product_id))
|
||||
|
||||
if cursor.fetchone():
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '이미 구독 중인 서비스입니다.'}), 400
|
||||
|
||||
# 상품 정보 조회
|
||||
cursor.execute("SELECT monthly_price FROM service_products WHERE id = ?", (product_id,))
|
||||
product_result = cursor.fetchone()
|
||||
if not product_result:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '존재하지 않는 서비스입니다.'}), 404
|
||||
|
||||
# 월 구독료 설정 (사용자 지정 또는 기본 가격)
|
||||
if monthly_fee is None:
|
||||
monthly_fee = product_result[0]
|
||||
|
||||
# 구독 생성
|
||||
start_date = date.today()
|
||||
next_billing_date = start_date + timedelta(days=30)
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO pharmacy_subscriptions
|
||||
(pharmacy_id, product_id, subscription_status, start_date, next_billing_date, monthly_fee, notes)
|
||||
VALUES (?, ?, 'ACTIVE', ?, ?, ?, ?)
|
||||
''', (pharmacy_id, product_id, start_date.isoformat(), next_billing_date.isoformat(), monthly_fee, notes))
|
||||
|
||||
conn.commit()
|
||||
subscription_id = cursor.lastrowid
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '구독이 성공적으로 생성되었습니다.',
|
||||
'subscription_id': subscription_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 구독 생성 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/subscriptions/<int:subscription_id>', methods=['DELETE'])
|
||||
def api_cancel_subscription(subscription_id):
|
||||
"""구독 해지"""
|
||||
try:
|
||||
import sqlite3
|
||||
|
||||
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 구독 존재 확인
|
||||
cursor.execute("SELECT id FROM pharmacy_subscriptions WHERE id = ?", (subscription_id,))
|
||||
if not cursor.fetchone():
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '존재하지 않는 구독입니다.'}), 404
|
||||
|
||||
# 구독 상태를 CANCELLED로 변경
|
||||
cursor.execute('''
|
||||
UPDATE pharmacy_subscriptions
|
||||
SET subscription_status = 'CANCELLED', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''', (subscription_id,))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '구독이 성공적으로 해지되었습니다.'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 구독 해지 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
# =================== 구독 서비스 관리 페이지 ===================
|
||||
|
||||
@app.route('/subscriptions')
|
||||
def subscriptions_page():
|
||||
"""구독 서비스 관리 페이지"""
|
||||
return render_template('subscriptions/list.html')
|
||||
|
||||
# 에러 핸들러
|
||||
@app.errorhandler(404)
|
||||
def not_found_error(error):
|
||||
|
||||
Reference in New Issue
Block a user