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:
2025-09-11 19:48:12 +09:00
parent c37cf023c1
commit 35ecd4748e
15 changed files with 3967 additions and 0 deletions

View File

@@ -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):