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):
|
||||
|
||||
347
farmq-admin/create_subscription_tables.py
Normal file
347
farmq-admin/create_subscription_tables.py
Normal file
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PharmQ SaaS 구독 서비스 데이터베이스 테이블 생성 스크립트
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
def create_subscription_tables():
|
||||
"""구독 서비스 관련 테이블 생성"""
|
||||
|
||||
# FARMQ 데이터베이스 연결
|
||||
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("🚀 PharmQ SaaS 구독 서비스 테이블 생성 중...")
|
||||
|
||||
try:
|
||||
# 1. service_products - 서비스 상품 마스터
|
||||
print("📦 service_products 테이블 생성 중...")
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS service_products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_code VARCHAR(20) UNIQUE NOT NULL,
|
||||
product_name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
monthly_price DECIMAL(10,2) NOT NULL,
|
||||
setup_fee DECIMAL(10,2) DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# 2. pharmacy_subscriptions - 약국별 구독 현황
|
||||
print("🏥 pharmacy_subscriptions 테이블 생성 중...")
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS pharmacy_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pharmacy_id INTEGER NOT NULL,
|
||||
product_id INTEGER NOT NULL,
|
||||
subscription_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
next_billing_date DATE,
|
||||
monthly_fee DECIMAL(10,2) NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (pharmacy_id) REFERENCES pharmacies(id),
|
||||
FOREIGN KEY (product_id) REFERENCES service_products(id),
|
||||
UNIQUE(pharmacy_id, product_id)
|
||||
)
|
||||
''')
|
||||
|
||||
# 3. subscription_usage_logs - 서비스 이용 로그
|
||||
print("📊 subscription_usage_logs 테이블 생성 중...")
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS subscription_usage_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subscription_id INTEGER NOT NULL,
|
||||
usage_type VARCHAR(50) NOT NULL,
|
||||
usage_amount INTEGER DEFAULT 1,
|
||||
usage_date DATE NOT NULL,
|
||||
metadata TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (subscription_id) REFERENCES pharmacy_subscriptions(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# 4. billing_history - 결제 이력
|
||||
print("💳 billing_history 테이블 생성 중...")
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS billing_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subscription_id INTEGER NOT NULL,
|
||||
billing_period_start DATE NOT NULL,
|
||||
billing_period_end DATE NOT NULL,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
billing_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
billing_date DATE,
|
||||
payment_method VARCHAR(50),
|
||||
invoice_number VARCHAR(100),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (subscription_id) REFERENCES pharmacy_subscriptions(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# 인덱스 생성
|
||||
print("🔍 인덱스 생성 중...")
|
||||
indexes = [
|
||||
"CREATE INDEX IF NOT EXISTS idx_pharmacy_subscriptions_pharmacy_id ON pharmacy_subscriptions(pharmacy_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pharmacy_subscriptions_product_id ON pharmacy_subscriptions(product_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pharmacy_subscriptions_status ON pharmacy_subscriptions(subscription_status)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_usage_logs_subscription_id ON subscription_usage_logs(subscription_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_usage_logs_date ON subscription_usage_logs(usage_date)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_billing_subscription_id ON billing_history(subscription_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_billing_status ON billing_history(billing_status)"
|
||||
]
|
||||
|
||||
for index_sql in indexes:
|
||||
cursor.execute(index_sql)
|
||||
|
||||
conn.commit()
|
||||
print("✅ 모든 테이블이 성공적으로 생성되었습니다!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 오류 발생: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def insert_sample_service_products():
|
||||
"""기본 서비스 상품 데이터 삽입"""
|
||||
|
||||
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("📦 기본 서비스 상품 데이터 삽입 중...")
|
||||
|
||||
# 기본 서비스 상품 정의
|
||||
products = [
|
||||
{
|
||||
'product_code': 'CLOUD_PC',
|
||||
'product_name': '클라우드 PC',
|
||||
'description': 'Proxmox 기반 가상 데스크톱 서비스. 언제 어디서나 안전한 클라우드 환경에서 업무 처리 가능.',
|
||||
'monthly_price': 60000.00,
|
||||
'setup_fee': 0.00
|
||||
},
|
||||
{
|
||||
'product_code': 'AI_CCTV',
|
||||
'product_name': 'AI CCTV',
|
||||
'description': '인공지능 기반 보안 모니터링 시스템. 실시간 이상 상황 탐지 및 알림 서비스.',
|
||||
'monthly_price': 80000.00,
|
||||
'setup_fee': 50000.00
|
||||
},
|
||||
{
|
||||
'product_code': 'CRM',
|
||||
'product_name': 'CRM 시스템',
|
||||
'description': '고객 관계 관리 및 매출 분석 도구. 고객 데이터 통합 관리 및 마케팅 자동화.',
|
||||
'monthly_price': 40000.00,
|
||||
'setup_fee': 0.00
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
for product in products:
|
||||
# 중복 확인
|
||||
cursor.execute("SELECT id FROM service_products WHERE product_code = ?", (product['product_code'],))
|
||||
if cursor.fetchone():
|
||||
print(f"⚠️ {product['product_name']} 상품이 이미 존재합니다.")
|
||||
continue
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO service_products
|
||||
(product_code, product_name, description, monthly_price, setup_fee)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (
|
||||
product['product_code'],
|
||||
product['product_name'],
|
||||
product['description'],
|
||||
product['monthly_price'],
|
||||
product['setup_fee']
|
||||
))
|
||||
|
||||
print(f"✅ {product['product_name']} 상품 추가됨 (월 {product['monthly_price']:,.0f}원)")
|
||||
|
||||
conn.commit()
|
||||
print("✅ 기본 서비스 상품 데이터 삽입 완료!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 서비스 상품 데이터 삽입 오류: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def create_sample_subscriptions():
|
||||
"""샘플 구독 데이터 생성 (기존 약국 데이터 기반)"""
|
||||
|
||||
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("🏥 샘플 구독 데이터 생성 중...")
|
||||
|
||||
try:
|
||||
# 기존 약국 ID 조회
|
||||
cursor.execute("SELECT id, pharmacy_name FROM pharmacies LIMIT 10")
|
||||
pharmacies = cursor.fetchall()
|
||||
|
||||
# 서비스 상품 ID 조회
|
||||
cursor.execute("SELECT id, product_code, monthly_price FROM service_products")
|
||||
products = cursor.fetchall()
|
||||
|
||||
if not pharmacies:
|
||||
print("⚠️ 약국 데이터가 없습니다. 구독 데이터를 생성할 수 없습니다.")
|
||||
return
|
||||
|
||||
if not products:
|
||||
print("⚠️ 서비스 상품 데이터가 없습니다.")
|
||||
return
|
||||
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
|
||||
subscription_count = 0
|
||||
|
||||
for pharmacy_id, pharmacy_name in pharmacies:
|
||||
# 각 약국마다 랜덤하게 1-3개의 서비스 구독
|
||||
num_subscriptions = random.randint(1, 3)
|
||||
selected_products = random.sample(products, num_subscriptions)
|
||||
|
||||
for product_id, product_code, monthly_price in selected_products:
|
||||
# 중복 구독 확인
|
||||
cursor.execute('''
|
||||
SELECT id FROM pharmacy_subscriptions
|
||||
WHERE pharmacy_id = ? AND product_id = ?
|
||||
''', (pharmacy_id, product_id))
|
||||
|
||||
if cursor.fetchone():
|
||||
continue # 이미 구독 중
|
||||
|
||||
# 구독 시작일 (최근 1년 내 랜덤)
|
||||
start_date = date.today() - timedelta(days=random.randint(0, 365))
|
||||
|
||||
# 다음 결제일 (시작일로부터 월 단위)
|
||||
next_billing = start_date + timedelta(days=30)
|
||||
if next_billing < date.today():
|
||||
# 과거 날짜면 현재 날짜 기준으로 조정
|
||||
days_since_start = (date.today() - start_date).days
|
||||
months_passed = days_since_start // 30
|
||||
next_billing = start_date + timedelta(days=(months_passed + 1) * 30)
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO pharmacy_subscriptions
|
||||
(pharmacy_id, product_id, subscription_status, start_date,
|
||||
next_billing_date, monthly_fee, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
pharmacy_id,
|
||||
product_id,
|
||||
'ACTIVE',
|
||||
start_date.isoformat(),
|
||||
next_billing.isoformat(),
|
||||
monthly_price,
|
||||
f'{pharmacy_name}의 {product_code} 서비스 구독'
|
||||
))
|
||||
|
||||
subscription_count += 1
|
||||
print(f"✅ {pharmacy_name} → {product_code} 구독 추가 (월 {monthly_price:,.0f}원)")
|
||||
|
||||
conn.commit()
|
||||
print(f"✅ 총 {subscription_count}개의 샘플 구독 데이터가 생성되었습니다!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 샘플 구독 데이터 생성 오류: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def show_subscription_summary():
|
||||
"""구독 현황 요약 출력"""
|
||||
|
||||
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("📊 PharmQ SaaS 구독 현황 요약")
|
||||
print("="*60)
|
||||
|
||||
try:
|
||||
# 전체 구독 통계
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
sp.product_name,
|
||||
COUNT(*) as subscription_count,
|
||||
SUM(ps.monthly_fee) as total_monthly_revenue
|
||||
FROM pharmacy_subscriptions ps
|
||||
JOIN service_products sp ON ps.product_id = sp.id
|
||||
WHERE ps.subscription_status = 'ACTIVE'
|
||||
GROUP BY sp.product_name
|
||||
ORDER BY total_monthly_revenue DESC
|
||||
''')
|
||||
|
||||
results = cursor.fetchall()
|
||||
total_revenue = 0
|
||||
|
||||
for product_name, count, revenue in results:
|
||||
print(f"🔹 {product_name}: {count}개 약국 구독 (월 {revenue:,.0f}원)")
|
||||
total_revenue += revenue
|
||||
|
||||
print(f"\n💰 총 월 매출: {total_revenue:,.0f}원")
|
||||
|
||||
# 약국별 구독 수
|
||||
cursor.execute('''
|
||||
SELECT COUNT(DISTINCT pharmacy_id) as subscribed_pharmacies
|
||||
FROM pharmacy_subscriptions
|
||||
WHERE subscription_status = 'ACTIVE'
|
||||
''')
|
||||
|
||||
subscribed_pharmacies = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM pharmacies")
|
||||
total_pharmacies = cursor.fetchone()[0]
|
||||
|
||||
print(f"🏥 구독 약국: {subscribed_pharmacies}/{total_pharmacies}개 ({subscribed_pharmacies/total_pharmacies*100:.1f}%)")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 요약 정보 조회 오류: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
print("="*60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🚀 PharmQ SaaS 구독 서비스 데이터베이스 초기화")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
# 1. 테이블 생성
|
||||
create_subscription_tables()
|
||||
print()
|
||||
|
||||
# 2. 기본 서비스 상품 삽입
|
||||
insert_sample_service_products()
|
||||
print()
|
||||
|
||||
# 3. 샘플 구독 데이터 생성
|
||||
create_sample_subscriptions()
|
||||
print()
|
||||
|
||||
# 4. 구독 현황 요약
|
||||
show_subscription_summary()
|
||||
|
||||
print("\n🎉 PharmQ SaaS 구독 서비스 데이터베이스 초기화 완료!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n💥 초기화 실패: {e}")
|
||||
exit(1)
|
||||
@@ -70,6 +70,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 구독 서비스 현황 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-box text-success"></i> 구독 서비스 현황
|
||||
</h5>
|
||||
<a href="/subscriptions" class="btn btn-outline-success btn-sm">
|
||||
<i class="fas fa-cog"></i> 관리하기
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row" id="subscription-stats">
|
||||
<!-- 구독 통계가 여기에 로드됩니다 -->
|
||||
<div class="col-12 text-center">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 서비스별 구독 현황 -->
|
||||
<div class="row mt-4" id="service-breakdown">
|
||||
<!-- 서비스별 상세 정보가 여기에 로드됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- 실시간 알림 -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
@@ -291,9 +322,132 @@ function updateAlerts() {
|
||||
.catch(error => console.error('Alert update failed:', error));
|
||||
}
|
||||
|
||||
// 구독 현황 로드
|
||||
function loadSubscriptionStats() {
|
||||
fetch('/api/subscriptions/stats')
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
updateSubscriptionDisplay(result.data);
|
||||
} else {
|
||||
console.error('구독 통계 로드 실패:', result.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('구독 통계 API 오류:', error);
|
||||
showSubscriptionError();
|
||||
});
|
||||
}
|
||||
|
||||
function updateSubscriptionDisplay(data) {
|
||||
// 전체 구독 통계 카드 생성
|
||||
const statsHtml = `
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="card" style="background: linear-gradient(135deg, #38b2ac 0%, #2c7a7b 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, #805ad5 0%, #6b46c1 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, #ed8936 0%, #dd6b20 100%); color: white;">
|
||||
<div class="card-body text-center">
|
||||
<div class="stat-number">${data.subscribed_pharmacies}/${data.total_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, #48bb78 0%, #38a169 100%); color: white;">
|
||||
<div class="card-body text-center">
|
||||
<div class="stat-number">${data.subscription_rate}%</div>
|
||||
<div class="stat-label">
|
||||
<i class="fas fa-percentage"></i> 구독률
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('subscription-stats').innerHTML = statsHtml;
|
||||
|
||||
// 서비스별 구독 현황
|
||||
const serviceIconMap = {
|
||||
'CLOUD_PC': '💻',
|
||||
'AI_CCTV': '📷',
|
||||
'CRM': '📊'
|
||||
};
|
||||
|
||||
const serviceColorMap = {
|
||||
'CLOUD_PC': '#4f46e5',
|
||||
'AI_CCTV': '#0891b2',
|
||||
'CRM': '#ca8a04'
|
||||
};
|
||||
|
||||
let servicesHtml = '';
|
||||
data.services.forEach(service => {
|
||||
const icon = serviceIconMap[service.code] || '📦';
|
||||
const color = serviceColorMap[service.code] || '#6b7280';
|
||||
const percentage = data.total_pharmacies > 0 ? Math.round(service.count / data.total_pharmacies * 100) : 0;
|
||||
|
||||
servicesHtml += `
|
||||
<div class="col-lg-4 col-md-6 mb-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="me-3" style="font-size: 2rem;">${icon}</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">${service.name}</h6>
|
||||
<div class="progress mb-1" style="height: 8px;">
|
||||
<div class="progress-bar" style="background-color: ${color}; width: ${percentage}%"></div>
|
||||
</div>
|
||||
<small class="text-muted">${service.count}개 약국 구독 (${percentage}%)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<strong style="color: ${color};">₩${service.revenue.toLocaleString()}/월</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
document.getElementById('service-breakdown').innerHTML = servicesHtml;
|
||||
}
|
||||
|
||||
function showSubscriptionError() {
|
||||
document.getElementById('subscription-stats').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>
|
||||
`;
|
||||
}
|
||||
|
||||
// 페이지 로드 시 구독 현황 로드
|
||||
loadSubscriptionStats();
|
||||
|
||||
// 통계 업데이트 (10초마다 - 더 자주)
|
||||
setInterval(updateStats, 10000);
|
||||
// 알림 업데이트 (30초마다)
|
||||
setInterval(updateAlerts, 30000);
|
||||
// 구독 현황 업데이트 (60초마다)
|
||||
setInterval(loadSubscriptionStats, 60000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -162,6 +162,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 구독 서비스 정보 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-box text-success"></i> 구독 서비스 현황
|
||||
</h5>
|
||||
<button class="btn btn-outline-success btn-sm" onclick="showSubscriptionModal()">
|
||||
<i class="fas fa-plus"></i> 새 서비스 구독
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="subscription-content">
|
||||
<div class="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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 연결된 머신 목록 -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
@@ -315,5 +341,233 @@ function deleteMachine(machineId, machineName) {
|
||||
showToast('머신 삭제 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// 구독 정보 로드
|
||||
function loadSubscriptionInfo() {
|
||||
const pharmacyId = {{ pharmacy.id }};
|
||||
|
||||
fetch(`/api/pharmacy/${pharmacyId}/subscriptions`)
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
displaySubscriptionInfo(result.data);
|
||||
} else {
|
||||
showSubscriptionError();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('구독 정보 로드 오류:', error);
|
||||
showSubscriptionError();
|
||||
});
|
||||
}
|
||||
|
||||
function displaySubscriptionInfo(data) {
|
||||
const activeSubscriptions = data.active_subscriptions;
|
||||
const availableServices = data.available_services;
|
||||
|
||||
let html = '';
|
||||
|
||||
// 활성 구독 서비스
|
||||
if (activeSubscriptions.length > 0) {
|
||||
html += `
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h6 class="mb-3"><i class="fas fa-check-circle text-success"></i> 구독 중인 서비스</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
`;
|
||||
|
||||
let totalFee = 0;
|
||||
activeSubscriptions.forEach(sub => {
|
||||
totalFee += sub.monthly_fee;
|
||||
|
||||
const serviceIcons = {
|
||||
'CLOUD_PC': { icon: '💻', color: 'primary' },
|
||||
'AI_CCTV': { icon: '📷', color: 'info' },
|
||||
'CRM': { icon: '📊', color: 'warning' }
|
||||
};
|
||||
|
||||
const service = serviceIcons[sub.code] || { icon: '📦', color: 'secondary' };
|
||||
|
||||
html += `
|
||||
<div class="col-lg-4 col-md-6 mb-3">
|
||||
<div class="card border-${service.color}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="me-3" style="font-size: 2rem;">${service.icon}</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">${sub.name}</h6>
|
||||
<div class="small text-muted">
|
||||
시작일: ${sub.start_date}<br>
|
||||
다음결제: ${sub.next_billing_date}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong class="text-${service.color}">₩${sub.monthly_fee.toLocaleString()}/월</strong>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="cancelSubscription(${sub.id}, '${sub.name}')">
|
||||
<i class="fas fa-times"></i> 해지
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">
|
||||
<strong><i class="fas fa-calculator"></i> 총 월 구독료: ₩${totalFee.toLocaleString()}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 구독 가능한 서비스
|
||||
if (availableServices.length > 0) {
|
||||
html += `
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h6 class="mb-3"><i class="fas fa-plus-circle text-primary"></i> 구독 가능한 서비스</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
`;
|
||||
|
||||
availableServices.forEach(service => {
|
||||
const serviceIcons = {
|
||||
'CLOUD_PC': { icon: '💻', color: 'primary' },
|
||||
'AI_CCTV': { icon: '📷', color: 'info' },
|
||||
'CRM': { icon: '📊', color: 'warning' }
|
||||
};
|
||||
|
||||
const serviceInfo = serviceIcons[service.code] || { icon: '📦', color: 'secondary' };
|
||||
|
||||
html += `
|
||||
<div class="col-lg-4 col-md-6 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="me-3" style="font-size: 2rem;">${serviceInfo.icon}</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">${service.name}</h6>
|
||||
<p class="small text-muted mb-2">${service.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong class="text-${serviceInfo.color}">₩${service.monthly_price.toLocaleString()}/월</strong>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-${serviceInfo.color} btn-sm" onclick="subscribeService(${service.id}, '${service.name}', ${service.monthly_price})">
|
||||
<i class="fas fa-plus"></i> 구독
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (activeSubscriptions.length === 0 && availableServices.length === 0) {
|
||||
html = `
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-box-open fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">구독 가능한 서비스가 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById('subscription-content').innerHTML = html;
|
||||
}
|
||||
|
||||
function showSubscriptionError() {
|
||||
document.getElementById('subscription-content').innerHTML = `
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-exclamation-triangle fa-3x text-warning mb-3"></i>
|
||||
<p class="text-muted">구독 정보를 불러오는 중 오류가 발생했습니다.</p>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="loadSubscriptionInfo()">
|
||||
<i class="fas fa-redo"></i> 다시 시도
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function subscribeService(serviceId, serviceName, monthlyPrice) {
|
||||
if (!confirm(`"${serviceName}" 서비스를 월 ₩${monthlyPrice.toLocaleString()}에 구독하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pharmacyId = {{ pharmacy.id }};
|
||||
|
||||
fetch('/api/subscriptions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pharmacy_id: pharmacyId,
|
||||
product_id: serviceId,
|
||||
monthly_fee: monthlyPrice
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showToast(result.message, 'success');
|
||||
setTimeout(() => loadSubscriptionInfo(), 1000);
|
||||
} else {
|
||||
showToast(result.error, 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('구독 생성 오류:', error);
|
||||
showToast('구독 생성 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function cancelSubscription(subscriptionId, serviceName) {
|
||||
if (!confirm(`"${serviceName}" 서비스 구독을 해지하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/subscriptions/${subscriptionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showToast(result.message, 'success');
|
||||
setTimeout(() => loadSubscriptionInfo(), 1000);
|
||||
} else {
|
||||
showToast(result.error, 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('구독 해지 오류:', error);
|
||||
showToast('구독 해지 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// 페이지 로드 시 구독 정보 로드
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(() => loadSubscriptionInfo(), 500);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -40,6 +40,7 @@
|
||||
<tr>
|
||||
<th>약국 정보</th>
|
||||
<th>담당자</th>
|
||||
<th>구독 서비스</th>
|
||||
<th>연결된 머신</th>
|
||||
<th>네트워크 상태</th>
|
||||
<th>액션</th>
|
||||
@@ -68,6 +69,15 @@
|
||||
<i class="fas fa-phone"></i> {{ pharmacy_data.phone or '연락처 미등록' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="subscription-services" data-pharmacy-id="{{ pharmacy_data.id }}">
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">로딩...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-info me-2">{{ pharmacy_data.machine_count }}대</span>
|
||||
@@ -200,6 +210,9 @@ let pharmacyModal;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
pharmacyModal = new bootstrap.Modal(document.getElementById('pharmacyModal'));
|
||||
|
||||
// 구독 상태 로드
|
||||
setTimeout(loadSubscriptionStatuses, 500); // 페이지 로드 후 약간의 지연
|
||||
});
|
||||
|
||||
function showAddModal() {
|
||||
@@ -316,6 +329,94 @@ document.getElementById('pharmacyForm').addEventListener('submit', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
// 구독 서비스 상태 로드
|
||||
function loadSubscriptionStatuses() {
|
||||
const subscriptionContainers = document.querySelectorAll('.subscription-services');
|
||||
|
||||
subscriptionContainers.forEach(container => {
|
||||
const pharmacyId = container.dataset.pharmacyId;
|
||||
|
||||
fetch(`/api/pharmacy/${pharmacyId}/subscriptions`)
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
displaySubscriptionStatus(container, result.data);
|
||||
} else {
|
||||
showSubscriptionError(container);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`약국 ${pharmacyId} 구독 정보 로드 실패:`, error);
|
||||
showSubscriptionError(container);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function displaySubscriptionStatus(container, data) {
|
||||
const subscriptions = data.active_subscriptions;
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center">
|
||||
<span class="badge bg-light text-muted">
|
||||
<i class="fas fa-minus"></i> 구독 없음
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 서비스 아이콘 맵핑
|
||||
const serviceIcons = {
|
||||
'CLOUD_PC': { icon: '💻', color: 'primary', name: '클라우드PC' },
|
||||
'AI_CCTV': { icon: '📷', color: 'info', name: 'AI CCTV' },
|
||||
'CRM': { icon: '📊', color: 'warning', name: 'CRM' }
|
||||
};
|
||||
|
||||
let html = '<div class="d-flex flex-wrap gap-1">';
|
||||
let totalFee = 0;
|
||||
|
||||
subscriptions.forEach(sub => {
|
||||
const service = serviceIcons[sub.code] || { icon: '📦', color: 'secondary', name: sub.name };
|
||||
totalFee += sub.monthly_fee;
|
||||
|
||||
html += `
|
||||
<span class="badge bg-${service.color}" title="${service.name} - ₩${sub.monthly_fee.toLocaleString()}/월" data-bs-toggle="tooltip">
|
||||
${service.icon}
|
||||
</span>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// 총 월 구독료 표시
|
||||
html += `
|
||||
<div class="small text-muted text-center mt-1">
|
||||
<strong>₩${totalFee.toLocaleString()}/월</strong>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// 툴팁 초기화
|
||||
const tooltipTriggerList = container.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
tooltipTriggerList.forEach(tooltipTriggerEl => {
|
||||
new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
}
|
||||
|
||||
function showSubscriptionError(container) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center">
|
||||
<span class="badge bg-danger">
|
||||
<i class="fas fa-exclamation-triangle"></i> 오류
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 구독 상태 로드 함수들은 위의 DOMContentLoaded에서 호출됨
|
||||
|
||||
// 테이블 정렬 및 검색 기능 추가 (향후)
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user