Compare commits
10 Commits
a42af23038
...
1cebb02ec6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cebb02ec6 | ||
|
|
f102f6b42e | ||
|
|
16adca3646 | ||
|
|
fbe7dde4ce | ||
|
|
8c20c8b8db | ||
|
|
67e576736d | ||
|
|
4c0cd68267 | ||
|
|
68dcb919e4 | ||
|
|
6a786ff042 | ||
|
|
4c93ee038a |
803
backend/app.py
803
backend/app.py
@ -492,8 +492,8 @@ def normalize_kakao_phone(kakao_phone):
|
||||
return None
|
||||
|
||||
|
||||
def link_kakao_identity(user_id, kakao_id, kakao_data):
|
||||
"""카카오 계정을 customer_identities에 연결"""
|
||||
def link_kakao_identity(user_id, kakao_id, kakao_data, token_data=None):
|
||||
"""카카오 계정을 customer_identities에 연결 (토큰 포함)"""
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
@ -504,13 +504,33 @@ def link_kakao_identity(user_id, kakao_id, kakao_data):
|
||||
|
||||
is_new_link = cursor.fetchone() is None
|
||||
|
||||
store_data = {k: v for k, v in kakao_data.items() if k != 'raw_data'}
|
||||
|
||||
if is_new_link:
|
||||
# raw_data 제외 (세션 크기 절약)
|
||||
store_data = {k: v for k, v in kakao_data.items() if k != 'raw_data'}
|
||||
cursor.execute("""
|
||||
INSERT INTO customer_identities (user_id, provider, provider_user_id, provider_data)
|
||||
VALUES (?, 'kakao', ?, ?)
|
||||
""", (user_id, kakao_id, json.dumps(store_data, ensure_ascii=False)))
|
||||
INSERT INTO customer_identities
|
||||
(user_id, provider, provider_user_id, provider_data, access_token, refresh_token, token_expires_at)
|
||||
VALUES (?, 'kakao', ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
user_id, kakao_id,
|
||||
json.dumps(store_data, ensure_ascii=False),
|
||||
token_data.get('access_token') if token_data else None,
|
||||
token_data.get('refresh_token') if token_data else None,
|
||||
token_data.get('expires_at') if token_data else None,
|
||||
))
|
||||
elif token_data:
|
||||
# 기존 레코드: 토큰 + 프로필 데이터 업데이트
|
||||
cursor.execute("""
|
||||
UPDATE customer_identities
|
||||
SET access_token = ?, refresh_token = ?, token_expires_at = ?, provider_data = ?
|
||||
WHERE provider = 'kakao' AND provider_user_id = ?
|
||||
""", (
|
||||
token_data.get('access_token'),
|
||||
token_data.get('refresh_token'),
|
||||
token_data.get('expires_at'),
|
||||
json.dumps(store_data, ensure_ascii=False),
|
||||
kakao_id,
|
||||
))
|
||||
|
||||
# 프로필 이미지, 이메일 업데이트
|
||||
updates = []
|
||||
@ -545,6 +565,52 @@ def find_user_by_kakao_id(kakao_id):
|
||||
return row['user_id'] if row else None
|
||||
|
||||
|
||||
def get_kakao_tokens(user_id):
|
||||
"""사용자의 저장된 카카오 토큰 조회"""
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT provider_user_id, access_token, refresh_token, token_expires_at
|
||||
FROM customer_identities
|
||||
WHERE provider = 'kakao' AND user_id = ?
|
||||
""", (user_id,))
|
||||
row = cursor.fetchone()
|
||||
if row and row['access_token']:
|
||||
return {
|
||||
'kakao_id': row['provider_user_id'],
|
||||
'access_token': row['access_token'],
|
||||
'refresh_token': row['refresh_token'],
|
||||
'token_expires_at': row['token_expires_at'],
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def update_kakao_tokens(kakao_id, token_data):
|
||||
"""카카오 토큰 업데이트 (갱신 후 저장용)"""
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
updates = ["access_token = ?"]
|
||||
params = [token_data['access_token']]
|
||||
|
||||
if 'expires_at' in token_data:
|
||||
updates.append("token_expires_at = ?")
|
||||
params.append(token_data['expires_at'])
|
||||
|
||||
# refresh_token은 갱신 응답에 포함된 경우에만 업데이트
|
||||
if 'refresh_token' in token_data:
|
||||
updates.append("refresh_token = ?")
|
||||
params.append(token_data['refresh_token'])
|
||||
|
||||
params.append(kakao_id)
|
||||
cursor.execute(f"""
|
||||
UPDATE customer_identities
|
||||
SET {', '.join(updates)}
|
||||
WHERE provider = 'kakao' AND provider_user_id = ?
|
||||
""", params)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 라우트
|
||||
# ============================================================================
|
||||
@ -807,7 +873,7 @@ def claim_kakao_start():
|
||||
return redirect(auth_url)
|
||||
|
||||
|
||||
def _handle_mypage_kakao_callback(code, kakao_client):
|
||||
def _handle_mypage_kakao_callback(code, kakao_client, redirect_to=None):
|
||||
"""
|
||||
마이페이지 카카오 콜백 처리 - 카카오 연동(머지) + 마이페이지 이동
|
||||
|
||||
@ -816,6 +882,9 @@ def _handle_mypage_kakao_callback(code, kakao_client):
|
||||
B) 미연결 + 카카오 전화번호로 기존 유저 발견 → 카카오 연동 후 이동
|
||||
C) 미연결 + 기존 유저 없음 → 신규 생성 + 카카오 연동
|
||||
D) 전화번호 없음 → 에러 안내
|
||||
|
||||
Args:
|
||||
redirect_to: 리다이렉트할 URL (None이면 기본 /my-page?phone=xxx)
|
||||
"""
|
||||
success, token_data = kakao_client.get_access_token(code)
|
||||
if not success:
|
||||
@ -843,9 +912,15 @@ def _handle_mypage_kakao_callback(code, kakao_client):
|
||||
"UPDATE users SET nickname = ? WHERE id = ? AND nickname = '고객'",
|
||||
(kakao_name, existing_user_id))
|
||||
conn.commit()
|
||||
cursor.execute("SELECT phone FROM users WHERE id = ?", (existing_user_id,))
|
||||
cursor.execute("SELECT phone, nickname FROM users WHERE id = ?", (existing_user_id,))
|
||||
row = cursor.fetchone()
|
||||
if row and row['phone']:
|
||||
# 세션에 로그인 정보 저장
|
||||
session['logged_in_user_id'] = existing_user_id
|
||||
session['logged_in_phone'] = row['phone']
|
||||
session['logged_in_name'] = row['nickname'] or kakao_name
|
||||
if redirect_to:
|
||||
return redirect(redirect_to)
|
||||
return redirect(f"/my-page?phone={row['phone']}")
|
||||
|
||||
# 전화번호 없으면 연동 불가
|
||||
@ -860,7 +935,7 @@ def _handle_mypage_kakao_callback(code, kakao_client):
|
||||
if phone_user:
|
||||
# Case B: 기존 전화번호 유저 → 카카오 연동 (머지)
|
||||
user_id = phone_user['id']
|
||||
link_kakao_identity(user_id, kakao_id, user_info)
|
||||
link_kakao_identity(user_id, kakao_id, user_info, token_data)
|
||||
# "고객" 이름이면 카카오 실명으로 업데이트
|
||||
if phone_user['nickname'] == '고객' and kakao_name and kakao_name != '고객':
|
||||
cursor.execute("UPDATE users SET nickname = ? WHERE id = ?",
|
||||
@ -870,9 +945,17 @@ def _handle_mypage_kakao_callback(code, kakao_client):
|
||||
else:
|
||||
# Case C: 신규 유저 생성 + 카카오 연동
|
||||
user_id, _ = get_or_create_user(kakao_phone, kakao_name)
|
||||
link_kakao_identity(user_id, kakao_id, user_info)
|
||||
link_kakao_identity(user_id, kakao_id, user_info, token_data)
|
||||
logging.info(f"마이페이지 카카오 신규: user_id={user_id}, kakao_id={kakao_id}")
|
||||
|
||||
# 세션에 로그인 정보 저장 (mypage_v2용)
|
||||
session['logged_in_user_id'] = user_id
|
||||
session['logged_in_phone'] = kakao_phone
|
||||
session['logged_in_name'] = kakao_name
|
||||
|
||||
# 지정된 리다이렉트 URL이 있으면 그쪽으로
|
||||
if redirect_to:
|
||||
return redirect(redirect_to)
|
||||
return redirect(f"/my-page?phone={kakao_phone}")
|
||||
|
||||
|
||||
@ -904,8 +987,9 @@ def claim_kakao_callback():
|
||||
return render_template('error.html', message="보안 검증에 실패했습니다. 다시 시도해주세요.")
|
||||
|
||||
# 2.5 마이페이지 조회 목적이면 별도 처리
|
||||
if state_data.get('purpose') == 'mypage':
|
||||
return _handle_mypage_kakao_callback(code, get_kakao_client())
|
||||
if state_data.get('purpose') in ('mypage', 'mypage_v2'):
|
||||
redirect_to = '/mypage' if state_data.get('purpose') == 'mypage_v2' else None
|
||||
return _handle_mypage_kakao_callback(code, get_kakao_client(), redirect_to=redirect_to)
|
||||
|
||||
# 3. claim 컨텍스트 복원
|
||||
token_param = state_data.get('t', '')
|
||||
@ -942,6 +1026,8 @@ def claim_kakao_callback():
|
||||
# 카카오에서 받은 생년월일 조합
|
||||
kakao_birthday = None
|
||||
kakao_bday = user_info.get('birthday') # MMDD 형식
|
||||
print(f"[KAKAO DEBUG] user_info keys: {list(user_info.keys())}")
|
||||
print(f"[KAKAO DEBUG] birthday={kakao_bday}, birthyear={user_info.get('birthyear')}")
|
||||
if kakao_bday and len(kakao_bday) == 4:
|
||||
if user_info.get('birthyear'):
|
||||
kakao_birthday = f"{user_info['birthyear']}-{kakao_bday[:2]}-{kakao_bday[2:]}" # YYYY-MM-DD
|
||||
@ -963,7 +1049,7 @@ def claim_kakao_callback():
|
||||
else:
|
||||
user_id, is_new = get_or_create_user(kakao_phone, kakao_name, birthday=kakao_birthday)
|
||||
|
||||
link_kakao_identity(user_id, kakao_id, user_info)
|
||||
link_kakao_identity(user_id, kakao_id, user_info, token_data)
|
||||
|
||||
success, msg, new_balance = claim_mileage(user_id, token_info)
|
||||
if not success:
|
||||
@ -1077,6 +1163,67 @@ def mypage_kakao_start():
|
||||
return redirect(auth_url)
|
||||
|
||||
|
||||
@app.route('/mypage')
|
||||
def mypage_v2():
|
||||
"""확장 마이페이지 (카카오 로그인 필수)"""
|
||||
user_id = session.get('logged_in_user_id')
|
||||
|
||||
if not user_id:
|
||||
# 로그인 필요 - 카카오 로그인으로 리다이렉트
|
||||
csrf_token = secrets.token_hex(16)
|
||||
state_data = {'purpose': 'mypage_v2', 'csrf': csrf_token}
|
||||
kakao_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
session['kakao_csrf'] = csrf_token
|
||||
return render_template('my_page_login.html', kakao_state=kakao_state, redirect_to='/mypage')
|
||||
|
||||
# 사용자 정보 조회
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT id, nickname, phone, profile_image_url, mileage_balance, created_at
|
||||
FROM users WHERE id = ?
|
||||
""", (user_id,))
|
||||
user_raw = cursor.fetchone()
|
||||
|
||||
if not user_raw:
|
||||
session.pop('logged_in_user_id', None)
|
||||
return redirect('/mypage')
|
||||
|
||||
user = dict(user_raw)
|
||||
|
||||
# 반려동물 목록 조회
|
||||
cursor.execute("""
|
||||
SELECT id, name, species, breed, gender, photo_url, created_at
|
||||
FROM pets WHERE user_id = ? AND is_active = TRUE
|
||||
ORDER BY created_at DESC
|
||||
""", (user_id,))
|
||||
|
||||
pets = []
|
||||
for row in cursor.fetchall():
|
||||
species_label = '강아지 🐕' if row['species'] == 'dog' else ('고양이 🐈' if row['species'] == 'cat' else '기타')
|
||||
pets.append({
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
'species': row['species'],
|
||||
'species_label': species_label,
|
||||
'breed': row['breed'],
|
||||
'gender': row['gender'],
|
||||
'photo_url': row['photo_url']
|
||||
})
|
||||
|
||||
# 구매 횟수 (적립 내역 수)
|
||||
cursor.execute("SELECT COUNT(*) FROM mileage_ledger WHERE user_id = ?", (user_id,))
|
||||
purchase_count = cursor.fetchone()[0]
|
||||
|
||||
return render_template('mypage_v2.html',
|
||||
user=user,
|
||||
pets=pets,
|
||||
purchase_count=purchase_count)
|
||||
|
||||
|
||||
@app.route('/my-page')
|
||||
def my_page():
|
||||
"""마이페이지 (전화번호로 조회)"""
|
||||
@ -2797,6 +2944,7 @@ def _get_animal_drugs():
|
||||
'apc': apc,
|
||||
'stock': int(r.Stock) if r.Stock else 0,
|
||||
'wholesaler_stock': 0, # PostgreSQL에서 가져옴
|
||||
'category': None, # PostgreSQL에서 가져옴
|
||||
'image_url': None # PostgreSQL에서 가져옴
|
||||
})
|
||||
|
||||
@ -2807,11 +2955,12 @@ def _get_animal_drugs():
|
||||
pg_engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
|
||||
with pg_engine.connect() as conn:
|
||||
placeholders = ','.join([f"'{a}'" for a in apc_list])
|
||||
# 이미지 URL 조회
|
||||
img_result = conn.execute(text(f"""
|
||||
SELECT apc, image_url1 FROM apc WHERE apc IN ({placeholders})
|
||||
# 이미지 URL + 분류 조회
|
||||
info_result = conn.execute(text(f"""
|
||||
SELECT apc, image_url1, llm_pharm->>'분류' as category
|
||||
FROM apc WHERE apc IN ({placeholders})
|
||||
"""))
|
||||
img_map = {row.apc: row.image_url1 for row in img_result}
|
||||
info_map = {row.apc: {'image_url': row.image_url1, 'category': row.category} for row in info_result}
|
||||
|
||||
# 도매상 재고 조회 (SUM)
|
||||
stock_result = conn.execute(text(f"""
|
||||
@ -2825,8 +2974,9 @@ def _get_animal_drugs():
|
||||
|
||||
for item in result:
|
||||
if item['apc']:
|
||||
if item['apc'] in img_map:
|
||||
item['image_url'] = img_map[item['apc']]
|
||||
if item['apc'] in info_map:
|
||||
item['image_url'] = info_map[item['apc']]['image_url']
|
||||
item['category'] = info_map[item['apc']]['category']
|
||||
if item['apc'] in stock_map:
|
||||
item['wholesaler_stock'] = stock_map[item['apc']]
|
||||
else:
|
||||
@ -2991,7 +3141,8 @@ def api_animal_chat():
|
||||
'code': drug['code'],
|
||||
'image_url': drug.get('image_url'), # APC 이미지 URL
|
||||
'stock': drug.get('stock', 0), # 약국 재고
|
||||
'wholesaler_stock': drug.get('wholesaler_stock', 0) # 도매상 재고
|
||||
'wholesaler_stock': drug.get('wholesaler_stock', 0), # 도매상 재고
|
||||
'category': drug.get('category') # 분류 (내부구충제, 심장사상충약 등)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
@ -3121,6 +3272,23 @@ def api_products():
|
||||
if animal_only and not is_animal:
|
||||
continue
|
||||
|
||||
# APC 조회 (동물약인 경우)
|
||||
apc = None
|
||||
if is_animal:
|
||||
try:
|
||||
apc_result = drug_session.execute(text("""
|
||||
SELECT TOP 1 CD_CD_BARCODE
|
||||
FROM CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = :drug_code AND CD_CD_BARCODE LIKE '023%'
|
||||
"""), {'drug_code': row.drug_code})
|
||||
apc_row = apc_result.fetchone()
|
||||
if apc_row:
|
||||
apc = apc_row[0]
|
||||
elif row.barcode:
|
||||
apc = row.barcode # 바코드=APC 케이스
|
||||
except:
|
||||
pass
|
||||
|
||||
items.append({
|
||||
'drug_code': row.drug_code or '',
|
||||
'product_name': row.product_name or '',
|
||||
@ -3130,9 +3298,44 @@ def api_products():
|
||||
'supplier': row.supplier or '',
|
||||
'is_set': bool(row.is_set),
|
||||
'is_animal_drug': is_animal,
|
||||
'stock': int(row.stock) if row.stock else 0
|
||||
'stock': int(row.stock) if row.stock else 0,
|
||||
'apc': apc,
|
||||
'category': None, # PostgreSQL에서 lazy fetch
|
||||
'wholesaler_stock': None
|
||||
})
|
||||
|
||||
# 동물약 분류 Lazy Fetch (PostgreSQL) - 실패해도 무시
|
||||
animal_items = [i for i in items if i['is_animal_drug'] and i['apc']]
|
||||
if animal_items:
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
pg_engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master',
|
||||
connect_args={'connect_timeout': 3}) # 3초 타임아웃
|
||||
with pg_engine.connect() as conn:
|
||||
apc_list = [i['apc'] for i in animal_items if i['apc']]
|
||||
if apc_list:
|
||||
placeholders = ','.join([f"'{a}'" for a in apc_list])
|
||||
# 분류 + 도매상 재고 조회
|
||||
result = conn.execute(text(f"""
|
||||
SELECT
|
||||
A.apc,
|
||||
A.llm_pharm->>'분류' as category,
|
||||
COALESCE(SUM(I.quantity), 0) as wholesaler_stock
|
||||
FROM apc A
|
||||
LEFT JOIN inventory I ON I.apdb_id = A.idx
|
||||
WHERE A.apc IN ({placeholders})
|
||||
GROUP BY A.apc, A.llm_pharm
|
||||
"""))
|
||||
pg_map = {row.apc: {'category': row.category, 'ws': int(row.wholesaler_stock)} for row in result}
|
||||
|
||||
for item in items:
|
||||
if item['apc'] and item['apc'] in pg_map:
|
||||
item['category'] = pg_map[item['apc']]['category']
|
||||
item['wholesaler_stock'] = pg_map[item['apc']]['ws']
|
||||
except Exception as pg_err:
|
||||
logging.warning(f"PostgreSQL 분류 조회 실패 (무시): {pg_err}")
|
||||
# PostgreSQL 실패해도 MSSQL 데이터는 정상 반환
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'items': items,
|
||||
@ -4060,6 +4263,562 @@ def kill_process_on_port(port: int) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# KIMS 약물 상호작용 API
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
@app.route('/admin/kims-logs')
|
||||
def admin_kims_logs():
|
||||
"""KIMS 상호작용 로그 뷰어 페이지"""
|
||||
return render_template('admin_kims_logs.html')
|
||||
|
||||
|
||||
@app.route('/api/kims/logs')
|
||||
def api_kims_logs():
|
||||
"""KIMS 로그 목록 조회"""
|
||||
from db.kims_logger import get_recent_logs
|
||||
|
||||
limit = int(request.args.get('limit', 100))
|
||||
status = request.args.get('status', '')
|
||||
interaction = request.args.get('interaction', '')
|
||||
date = request.args.get('date', '')
|
||||
|
||||
try:
|
||||
logs = get_recent_logs(limit=limit)
|
||||
|
||||
# 필터링
|
||||
if status:
|
||||
logs = [l for l in logs if l['api_status'] == status]
|
||||
if interaction == 'has':
|
||||
logs = [l for l in logs if l['interaction_count'] > 0]
|
||||
elif interaction == 'severe':
|
||||
logs = [l for l in logs if l['has_severe_interaction'] == 1]
|
||||
elif interaction == 'none':
|
||||
logs = [l for l in logs if l['interaction_count'] == 0]
|
||||
if date:
|
||||
logs = [l for l in logs if l['created_at'] and l['created_at'].startswith(date)]
|
||||
|
||||
return jsonify({'success': True, 'logs': logs})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@app.route('/api/kims/logs/stats')
|
||||
def api_kims_logs_stats():
|
||||
"""KIMS 로그 통계"""
|
||||
from db.kims_logger import get_stats
|
||||
|
||||
try:
|
||||
stats = get_stats()
|
||||
return jsonify({'success': True, 'stats': stats})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@app.route('/api/kims/logs/<int:log_id>')
|
||||
def api_kims_log_detail(log_id):
|
||||
"""KIMS 로그 상세 조회"""
|
||||
from db.kims_logger import get_log_detail
|
||||
|
||||
try:
|
||||
log = get_log_detail(log_id)
|
||||
if log:
|
||||
return jsonify({'success': True, 'log': log})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': '로그를 찾을 수 없습니다'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@app.route('/api/kims/interaction-check', methods=['POST'])
|
||||
def api_kims_interaction_check():
|
||||
"""
|
||||
KIMS 약물 상호작용 체크 API
|
||||
|
||||
Request:
|
||||
{
|
||||
"drug_codes": ["055101150", "622801610"], // DrugCode 배열
|
||||
"pre_serial": "P20250630001" // 처방번호 (로깅용, optional)
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"interactions": [...],
|
||||
"safe_count": 2,
|
||||
"drugs_checked": [{"code": "...", "name": "...", "kd_code": "..."}]
|
||||
}
|
||||
"""
|
||||
import requests as http_requests
|
||||
from db.kims_logger import log_kims_call
|
||||
import time as time_module
|
||||
|
||||
start_time = time_module.time()
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
drug_codes = data.get('drug_codes', [])
|
||||
pre_serial = data.get('pre_serial', '')
|
||||
user_id = data.get('user_id') # 회원 ID (있으면)
|
||||
|
||||
if len(drug_codes) < 2:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '상호작용 체크를 위해 최소 2개 이상의 약품이 필요합니다.'
|
||||
}), 400
|
||||
|
||||
# 1. DrugCode = KIMS KD코드 (9자리) - 직접 사용
|
||||
drug_session = db_manager.get_session('PM_DRUG')
|
||||
placeholders = ','.join([f"'{c}'" for c in drug_codes])
|
||||
|
||||
code_query = text(f"""
|
||||
SELECT DrugCode, GoodsName
|
||||
FROM CD_GOODS
|
||||
WHERE DrugCode IN ({placeholders})
|
||||
""")
|
||||
code_result = drug_session.execute(code_query).fetchall()
|
||||
|
||||
# DrugCode를 KIMS KD코드로 직접 사용
|
||||
kd_codes = []
|
||||
drugs_info = []
|
||||
for row in code_result:
|
||||
kd_code = row.DrugCode # DrugCode 자체가 KIMS 코드
|
||||
if kd_code and len(str(kd_code)) == 9:
|
||||
kd_codes.append(str(kd_code))
|
||||
drugs_info.append({
|
||||
'drug_code': row.DrugCode,
|
||||
'name': row.GoodsName[:50] if row.GoodsName else '알 수 없음',
|
||||
'kd_code': str(kd_code)
|
||||
})
|
||||
|
||||
if len(kd_codes) < 2:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'KIMS 코드로 변환 가능한 약품이 2개 미만입니다.',
|
||||
'drugs_checked': drugs_info
|
||||
}), 400
|
||||
|
||||
# 2. KIMS API 호출
|
||||
kims_url = "https://api2.kims.co.kr/api/interaction/info"
|
||||
kims_headers = {
|
||||
'Authorization': 'Basic VFNQTUtSOg==',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json; charset=utf-8'
|
||||
}
|
||||
kims_payload = {'KDCodes': kd_codes}
|
||||
|
||||
try:
|
||||
kims_response = http_requests.get(
|
||||
kims_url,
|
||||
headers=kims_headers,
|
||||
data=json.dumps(kims_payload),
|
||||
timeout=10,
|
||||
verify=False # SSL 검증 비활성화 (프로덕션에서는 주의)
|
||||
)
|
||||
|
||||
if kims_response.status_code != 200:
|
||||
log_kims_call(pre_serial=pre_serial, drug_codes=kd_codes, drug_names=[d['name'] for d in drugs_info],
|
||||
api_status='ERROR', http_status=kims_response.status_code,
|
||||
response_time_ms=int((time_module.time() - start_time) * 1000),
|
||||
error_message=f'HTTP {kims_response.status_code}')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'KIMS API 응답 오류: HTTP {kims_response.status_code}',
|
||||
'drugs_checked': drugs_info
|
||||
}), 502
|
||||
|
||||
kims_data = kims_response.json()
|
||||
|
||||
if kims_data.get('Message') != 'SUCCESS':
|
||||
log_kims_call(pre_serial=pre_serial, drug_codes=kd_codes, drug_names=[d['name'] for d in drugs_info],
|
||||
api_status='ERROR', http_status=200,
|
||||
response_time_ms=int((time_module.time() - start_time) * 1000),
|
||||
error_message=f'KIMS: {kims_data.get("Message")}', response_raw=kims_data)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'KIMS API 처리 실패: {kims_data.get("Message", "알 수 없는 오류")}',
|
||||
'drugs_checked': drugs_info
|
||||
}), 502
|
||||
|
||||
except http_requests.Timeout:
|
||||
log_kims_call(pre_serial=pre_serial, drug_codes=kd_codes, drug_names=[d['name'] for d in drugs_info],
|
||||
api_status='TIMEOUT', response_time_ms=10000, error_message='10초 초과')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'KIMS API 타임아웃 (10초 초과)',
|
||||
'drugs_checked': drugs_info
|
||||
}), 504
|
||||
except Exception as kims_err:
|
||||
log_kims_call(pre_serial=pre_serial, drug_codes=kd_codes, drug_names=[d['name'] for d in drugs_info],
|
||||
api_status='ERROR', response_time_ms=int((time_module.time() - start_time) * 1000),
|
||||
error_message=str(kims_err))
|
||||
logging.error(f"KIMS API 호출 실패: {kims_err}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'KIMS API 연결 실패: {str(kims_err)}',
|
||||
'drugs_checked': drugs_info
|
||||
}), 502
|
||||
|
||||
# 3. 상호작용 결과 파싱
|
||||
interactions = []
|
||||
severity_color = {'1': '#dc2626', '2': '#f59e0b', '3': '#3b82f6', '4': '#6b7280', '5': '#9ca3af'}
|
||||
|
||||
# 상호작용 있는 약품 코드 수집
|
||||
interaction_drug_codes = set()
|
||||
|
||||
for alert in kims_data.get('AlertList', []):
|
||||
for item in alert.get('AlertInfo', []):
|
||||
severity = str(item.get('SeverityLevel', '5'))
|
||||
severity_desc = item.get('SeverityDesc', '') # API에서 직접 제공 (중증, 경미 등)
|
||||
|
||||
# 상호작용 약품 코드 수집
|
||||
if item.get('DrugCode1'):
|
||||
interaction_drug_codes.add(item.get('DrugCode1'))
|
||||
if item.get('DrugCode2'):
|
||||
interaction_drug_codes.add(item.get('DrugCode2'))
|
||||
|
||||
interactions.append({
|
||||
'drug1_code': item.get('DrugCode1'),
|
||||
'drug1_name': item.get('ProductName1'),
|
||||
'drug2_code': item.get('DrugCode2'),
|
||||
'drug2_name': item.get('ProductName2'),
|
||||
'generic1': item.get('GenericName1'),
|
||||
'generic2': item.get('GenericName2'),
|
||||
'severity': severity,
|
||||
'severity_text': severity_desc or ('심각' if severity == '1' else '중등도' if severity == '2' else '경미' if severity == '3' else '참고'),
|
||||
'severity_color': severity_color.get(severity, '#9ca3af'),
|
||||
'description': item.get('Observation', ''),
|
||||
'management': item.get('ClinicalMng', ''),
|
||||
'action': item.get('ActionToTake', ''),
|
||||
'likelihood': item.get('LikelihoodDesc', '')
|
||||
})
|
||||
|
||||
# 심각도 순 정렬 (1=심각이 먼저)
|
||||
interactions.sort(key=lambda x: x['severity'])
|
||||
|
||||
# 총 약품 쌍 수 계산
|
||||
total_pairs = len(kd_codes) * (len(kd_codes) - 1) // 2
|
||||
safe_count = total_pairs - len(interactions)
|
||||
|
||||
# 약품 목록에 상호작용 여부 표시
|
||||
for drug in drugs_info:
|
||||
drug['has_interaction'] = drug['kd_code'] in interaction_drug_codes
|
||||
|
||||
# 응답 시간 계산
|
||||
response_time_ms = int((time_module.time() - start_time) * 1000)
|
||||
|
||||
# SQLite 로깅
|
||||
try:
|
||||
log_id = log_kims_call(
|
||||
pre_serial=pre_serial,
|
||||
user_id=user_id,
|
||||
source='admin',
|
||||
drug_codes=kd_codes,
|
||||
drug_names=[d['name'] for d in drugs_info],
|
||||
api_status='SUCCESS',
|
||||
http_status=200,
|
||||
response_time_ms=response_time_ms,
|
||||
interactions=interactions,
|
||||
response_raw=kims_data
|
||||
)
|
||||
logging.info(f"KIMS 로그 저장: ID={log_id}, {len(kd_codes)}개 약품, {len(interactions)}건 상호작용")
|
||||
except Exception as log_err:
|
||||
logging.warning(f"KIMS 로깅 실패 (무시): {log_err}")
|
||||
|
||||
logging.info(f"KIMS 상호작용 체크 완료: {len(kd_codes)}개 약품, {len(interactions)}건 발견 (처방: {pre_serial})")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'interactions': interactions,
|
||||
'interaction_count': len(interactions),
|
||||
'safe_count': max(0, safe_count),
|
||||
'total_pairs': total_pairs,
|
||||
'drugs_checked': drugs_info,
|
||||
'interaction_drug_codes': list(interaction_drug_codes) # 상호작용 있는 약품 코드
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# 에러 로깅
|
||||
response_time_ms = int((time_module.time() - start_time) * 1000)
|
||||
try:
|
||||
log_kims_call(
|
||||
pre_serial=pre_serial if 'pre_serial' in dir() else None,
|
||||
source='admin',
|
||||
drug_codes=drug_codes if 'drug_codes' in dir() else [],
|
||||
api_status='ERROR',
|
||||
response_time_ms=response_time_ms,
|
||||
error_message=str(e)
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
logging.error(f"KIMS 상호작용 체크 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'서버 오류: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 반려동물 API
|
||||
# ==============================================================================
|
||||
|
||||
# 견종/묘종 데이터
|
||||
DOG_BREEDS = [
|
||||
'말티즈', '푸들', '포메라니안', '치와와', '시츄', '요크셔테리어',
|
||||
'비숑프리제', '골든리트리버', '래브라도리트리버', '진돗개', '시바견',
|
||||
'웰시코기', '닥스훈트', '비글', '보더콜리', '프렌치불독', '불독',
|
||||
'슈나우저', '사모예드', '허스키', '믹스견', '기타'
|
||||
]
|
||||
|
||||
CAT_BREEDS = [
|
||||
'코리안숏헤어', '페르시안', '러시안블루', '샴', '먼치킨', '랙돌',
|
||||
'브리티쉬숏헤어', '아메리칸숏헤어', '스코티쉬폴드', '노르웨이숲',
|
||||
'메인쿤', '뱅갈', '아비시니안', '터키쉬앙고라', '믹스묘', '기타'
|
||||
]
|
||||
|
||||
@app.route('/api/pets', methods=['GET'])
|
||||
def get_pets():
|
||||
"""사용자의 반려동물 목록 조회"""
|
||||
# 세션에서 로그인 유저 확인
|
||||
user_id = session.get('logged_in_user_id')
|
||||
if not user_id:
|
||||
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
|
||||
|
||||
try:
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, name, species, breed, gender, birth_date,
|
||||
age_months, weight, photo_url, notes, created_at
|
||||
FROM pets
|
||||
WHERE user_id = ? AND is_active = TRUE
|
||||
ORDER BY created_at DESC
|
||||
""", (user_id,))
|
||||
|
||||
pets = []
|
||||
for row in cursor.fetchall():
|
||||
pets.append({
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
'species': row['species'],
|
||||
'species_label': '강아지 🐕' if row['species'] == 'dog' else ('고양이 🐈' if row['species'] == 'cat' else '기타'),
|
||||
'breed': row['breed'],
|
||||
'gender': row['gender'],
|
||||
'birth_date': row['birth_date'],
|
||||
'age_months': row['age_months'],
|
||||
'weight': float(row['weight']) if row['weight'] else None,
|
||||
'photo_url': row['photo_url'],
|
||||
'notes': row['notes'],
|
||||
'created_at': utc_to_kst_str(row['created_at'])
|
||||
})
|
||||
|
||||
return jsonify({'success': True, 'pets': pets, 'count': len(pets)})
|
||||
except Exception as e:
|
||||
logging.error(f"반려동물 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/pets', methods=['POST'])
|
||||
def create_pet():
|
||||
"""반려동물 등록"""
|
||||
user_id = session.get('logged_in_user_id')
|
||||
if not user_id:
|
||||
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
species = data.get('species', '').strip() # dog, cat, other
|
||||
breed = data.get('breed', '').strip()
|
||||
gender = data.get('gender') # male, female, unknown
|
||||
|
||||
if not name:
|
||||
return jsonify({'success': False, 'error': '이름을 입력해주세요.'}), 400
|
||||
if species not in ['dog', 'cat', 'other']:
|
||||
return jsonify({'success': False, 'error': '종류를 선택해주세요.'}), 400
|
||||
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO pets (user_id, name, species, breed, gender)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (user_id, name, species, breed, gender))
|
||||
|
||||
pet_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
|
||||
logging.info(f"반려동물 등록: user_id={user_id}, pet_id={pet_id}, name={name}, species={species}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'pet_id': pet_id,
|
||||
'message': f'{name}이(가) 등록되었습니다!'
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"반려동물 등록 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/pets/<int:pet_id>', methods=['PUT'])
|
||||
def update_pet(pet_id):
|
||||
"""반려동물 정보 수정"""
|
||||
user_id = session.get('logged_in_user_id')
|
||||
if not user_id:
|
||||
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 소유권 확인
|
||||
cursor.execute("SELECT id FROM pets WHERE id = ? AND user_id = ?", (pet_id, user_id))
|
||||
if not cursor.fetchone():
|
||||
return jsonify({'success': False, 'error': '반려동물을 찾을 수 없습니다.'}), 404
|
||||
|
||||
# 업데이트 필드 구성
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if 'name' in data:
|
||||
updates.append("name = ?")
|
||||
params.append(data['name'].strip())
|
||||
if 'species' in data:
|
||||
updates.append("species = ?")
|
||||
params.append(data['species'])
|
||||
if 'breed' in data:
|
||||
updates.append("breed = ?")
|
||||
params.append(data['breed'])
|
||||
if 'gender' in data:
|
||||
updates.append("gender = ?")
|
||||
params.append(data['gender'])
|
||||
if 'birth_date' in data:
|
||||
updates.append("birth_date = ?")
|
||||
params.append(data['birth_date'])
|
||||
if 'age_months' in data:
|
||||
updates.append("age_months = ?")
|
||||
params.append(data['age_months'])
|
||||
if 'weight' in data:
|
||||
updates.append("weight = ?")
|
||||
params.append(data['weight'])
|
||||
if 'notes' in data:
|
||||
updates.append("notes = ?")
|
||||
params.append(data['notes'])
|
||||
|
||||
if updates:
|
||||
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||
params.append(pet_id)
|
||||
|
||||
cursor.execute(f"""
|
||||
UPDATE pets SET {', '.join(updates)} WHERE id = ?
|
||||
""", params)
|
||||
conn.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': '수정되었습니다.'})
|
||||
except Exception as e:
|
||||
logging.error(f"반려동물 수정 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/pets/<int:pet_id>', methods=['DELETE'])
|
||||
def delete_pet(pet_id):
|
||||
"""반려동물 삭제 (soft delete)"""
|
||||
user_id = session.get('logged_in_user_id')
|
||||
if not user_id:
|
||||
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
|
||||
|
||||
try:
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 소유권 확인 및 삭제
|
||||
cursor.execute("""
|
||||
UPDATE pets SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?
|
||||
""", (pet_id, user_id))
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
return jsonify({'success': False, 'error': '반려동물을 찾을 수 없습니다.'}), 404
|
||||
|
||||
conn.commit()
|
||||
return jsonify({'success': True, 'message': '삭제되었습니다.'})
|
||||
except Exception as e:
|
||||
logging.error(f"반려동물 삭제 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/pets/<int:pet_id>/photo', methods=['POST'])
|
||||
def upload_pet_photo(pet_id):
|
||||
"""반려동물 사진 업로드"""
|
||||
user_id = session.get('logged_in_user_id')
|
||||
if not user_id:
|
||||
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
|
||||
|
||||
try:
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 소유권 확인
|
||||
cursor.execute("SELECT id, name FROM pets WHERE id = ? AND user_id = ?", (pet_id, user_id))
|
||||
pet = cursor.fetchone()
|
||||
if not pet:
|
||||
return jsonify({'success': False, 'error': '반려동물을 찾을 수 없습니다.'}), 404
|
||||
|
||||
if 'photo' not in request.files:
|
||||
return jsonify({'success': False, 'error': '사진 파일이 없습니다.'}), 400
|
||||
|
||||
file = request.files['photo']
|
||||
if file.filename == '':
|
||||
return jsonify({'success': False, 'error': '파일을 선택해주세요.'}), 400
|
||||
|
||||
# 파일 확장자 체크
|
||||
allowed = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
||||
ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else ''
|
||||
if ext not in allowed:
|
||||
return jsonify({'success': False, 'error': '지원하지 않는 이미지 형식입니다.'}), 400
|
||||
|
||||
# 파일 저장
|
||||
import uuid
|
||||
filename = f"pet_{pet_id}_{uuid.uuid4().hex[:8]}.{ext}"
|
||||
upload_dir = Path(app.root_path) / 'static' / 'uploads' / 'pets'
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filepath = upload_dir / filename
|
||||
file.save(str(filepath))
|
||||
|
||||
# DB 업데이트
|
||||
photo_url = f"/static/uploads/pets/{filename}"
|
||||
cursor.execute("""
|
||||
UPDATE pets SET photo_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||
""", (photo_url, pet_id))
|
||||
conn.commit()
|
||||
|
||||
logging.info(f"반려동물 사진 업로드: pet_id={pet_id}, filename={filename}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'photo_url': photo_url,
|
||||
'message': f'{pet["name"]} 사진이 등록되었습니다!'
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"반려동물 사진 업로드 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/pets/breeds/<species>')
|
||||
def get_breeds(species):
|
||||
"""종류별 품종 목록 조회"""
|
||||
if species == 'dog':
|
||||
return jsonify({'success': True, 'breeds': DOG_BREEDS})
|
||||
elif species == 'cat':
|
||||
return jsonify({'success': True, 'breeds': CAT_BREEDS})
|
||||
else:
|
||||
return jsonify({'success': True, 'breeds': ['기타']})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
|
||||
|
||||
@ -154,11 +154,46 @@ class DatabaseManager:
|
||||
return self.engines[database]
|
||||
|
||||
def get_session(self, database='PM_BASE'):
|
||||
"""특정 데이터베이스 세션 반환"""
|
||||
"""특정 데이터베이스 세션 반환 (자동 복구 포함)"""
|
||||
if database not in self.sessions:
|
||||
engine = self.get_engine(database)
|
||||
Session = sessionmaker(bind=engine)
|
||||
self.sessions[database] = Session()
|
||||
else:
|
||||
# 🔥 기존 세션 상태 체크 및 자동 복구
|
||||
session = self.sessions[database]
|
||||
try:
|
||||
# 세션이 유효한지 간단한 쿼리로 테스트
|
||||
session.execute(text("SELECT 1"))
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
# 연결 끊김 또는 트랜잭션 에러 감지
|
||||
if any(keyword in error_msg for keyword in [
|
||||
'invalid transaction', 'rollback', 'connection',
|
||||
'closed', 'lost', 'timeout', 'network', 'disconnect'
|
||||
]):
|
||||
print(f"[DB Manager] {database} 세션 복구 시도: {e}")
|
||||
try:
|
||||
session.rollback()
|
||||
print(f"[DB Manager] {database} 롤백 성공, 세션 재사용")
|
||||
except Exception as rollback_err:
|
||||
print(f"[DB Manager] {database} 롤백 실패, 세션 재생성: {rollback_err}")
|
||||
try:
|
||||
session.close()
|
||||
except:
|
||||
pass
|
||||
del self.sessions[database]
|
||||
# 새 세션 생성
|
||||
engine = self.get_engine(database)
|
||||
Session = sessionmaker(bind=engine)
|
||||
self.sessions[database] = Session()
|
||||
print(f"[DB Manager] {database} 새 세션 생성 완료")
|
||||
else:
|
||||
# 다른 종류의 에러면 롤백만 시도
|
||||
try:
|
||||
session.rollback()
|
||||
except:
|
||||
pass
|
||||
return self.sessions[database]
|
||||
|
||||
def rollback_session(self, database='PM_BASE'):
|
||||
@ -237,7 +272,13 @@ class DatabaseManager:
|
||||
self.init_sqlite_schema()
|
||||
self.sqlite_conn = old_conn
|
||||
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
|
||||
|
||||
else:
|
||||
# 기존 DB: 마이그레이션 실행
|
||||
old_conn = self.sqlite_conn
|
||||
self.sqlite_conn = conn
|
||||
self._migrate_sqlite()
|
||||
self.sqlite_conn = old_conn
|
||||
|
||||
return conn
|
||||
|
||||
def init_sqlite_schema(self):
|
||||
@ -319,6 +360,43 @@ class DatabaseManager:
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: ai_recommendations 테이블 생성")
|
||||
|
||||
# customer_identities 토큰 저장 컬럼 추가
|
||||
cursor.execute("PRAGMA table_info(customer_identities)")
|
||||
ci_columns = [row[1] for row in cursor.fetchall()]
|
||||
if 'access_token' not in ci_columns:
|
||||
cursor.execute("ALTER TABLE customer_identities ADD COLUMN access_token TEXT")
|
||||
cursor.execute("ALTER TABLE customer_identities ADD COLUMN refresh_token TEXT")
|
||||
cursor.execute("ALTER TABLE customer_identities ADD COLUMN token_expires_at DATETIME")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: customer_identities 토큰 컬럼 추가")
|
||||
|
||||
# pets 테이블 생성 (반려동물)
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='pets'")
|
||||
if not cursor.fetchone():
|
||||
cursor.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS pets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
species VARCHAR(20) NOT NULL,
|
||||
breed VARCHAR(50),
|
||||
gender VARCHAR(10),
|
||||
birth_date DATE,
|
||||
age_months INTEGER,
|
||||
weight DECIMAL(5,2),
|
||||
photo_url TEXT,
|
||||
notes TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species);
|
||||
""")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: pets 테이블 생성")
|
||||
|
||||
def test_connection(self, database='PM_BASE'):
|
||||
"""연결 테스트"""
|
||||
try:
|
||||
|
||||
220
backend/db/kims_logger.py
Normal file
220
backend/db/kims_logger.py
Normal file
@ -0,0 +1,220 @@
|
||||
"""
|
||||
KIMS API 로깅 모듈
|
||||
- API 호출/응답 SQLite 저장
|
||||
- AI 학습용 데이터 수집
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# DB 파일 경로
|
||||
DB_PATH = Path(__file__).parent / 'kims_logs.db'
|
||||
|
||||
def init_db():
|
||||
"""DB 초기화 (테이블 생성)"""
|
||||
schema_path = Path(__file__).parent / 'kims_logs_schema.sql'
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = f.read()
|
||||
cursor.executescript(schema)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"KIMS 로그 DB 초기화 완료: {DB_PATH}")
|
||||
|
||||
def log_kims_call(
|
||||
pre_serial: str = None,
|
||||
user_id: int = None,
|
||||
source: str = 'admin',
|
||||
drug_codes: list = None,
|
||||
drug_names: list = None,
|
||||
api_status: str = 'SUCCESS',
|
||||
http_status: int = 200,
|
||||
response_time_ms: int = 0,
|
||||
interactions: list = None,
|
||||
response_raw: dict = None,
|
||||
error_message: str = None
|
||||
) -> int:
|
||||
"""
|
||||
KIMS API 호출 로그 저장
|
||||
|
||||
Returns:
|
||||
log_id: 생성된 로그 ID
|
||||
"""
|
||||
# DB 없으면 초기화
|
||||
if not DB_PATH.exists():
|
||||
init_db()
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
interactions = interactions or []
|
||||
drug_codes = drug_codes or []
|
||||
drug_names = drug_names or []
|
||||
|
||||
# 심각한 상호작용 여부 (severity 1 또는 2)
|
||||
has_severe = any(
|
||||
str(i.get('severity', '5')) in ['1', '2']
|
||||
for i in interactions
|
||||
)
|
||||
|
||||
# 메인 로그 삽입
|
||||
cursor.execute("""
|
||||
INSERT INTO kims_api_logs (
|
||||
pre_serial, user_id, source,
|
||||
request_drug_codes, request_drug_names, request_drug_count,
|
||||
api_status, http_status, response_time_ms,
|
||||
interaction_count, has_severe_interaction,
|
||||
interactions_json, response_raw, error_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
pre_serial,
|
||||
user_id,
|
||||
source,
|
||||
json.dumps(drug_codes, ensure_ascii=False),
|
||||
json.dumps(drug_names, ensure_ascii=False),
|
||||
len(drug_codes),
|
||||
api_status,
|
||||
http_status,
|
||||
response_time_ms,
|
||||
len(interactions),
|
||||
1 if has_severe else 0,
|
||||
json.dumps(interactions, ensure_ascii=False),
|
||||
json.dumps(response_raw, ensure_ascii=False) if response_raw else None,
|
||||
error_message
|
||||
))
|
||||
|
||||
log_id = cursor.lastrowid
|
||||
|
||||
# 상호작용 상세 삽입 (정규화)
|
||||
for inter in interactions:
|
||||
cursor.execute("""
|
||||
INSERT INTO kims_interactions (
|
||||
log_id,
|
||||
drug1_code, drug1_name, drug1_generic,
|
||||
drug2_code, drug2_name, drug2_generic,
|
||||
severity_level, severity_desc,
|
||||
likelihood_level, likelihood_desc,
|
||||
observation, observation_generic,
|
||||
clinical_management, action_to_take, reference
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
log_id,
|
||||
inter.get('drug1_code'),
|
||||
inter.get('drug1_name'),
|
||||
inter.get('generic1'),
|
||||
inter.get('drug2_code'),
|
||||
inter.get('drug2_name'),
|
||||
inter.get('generic2'),
|
||||
int(inter.get('severity', 5)) if str(inter.get('severity', '')).isdigit() else None,
|
||||
inter.get('severity_text'),
|
||||
None, # likelihood_level
|
||||
inter.get('likelihood'),
|
||||
inter.get('description'),
|
||||
None, # observation_generic
|
||||
inter.get('management'),
|
||||
inter.get('action'),
|
||||
None # reference
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return log_id
|
||||
|
||||
def get_recent_logs(limit: int = 50):
|
||||
"""최근 로그 조회"""
|
||||
if not DB_PATH.exists():
|
||||
return []
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT * FROM kims_api_logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get_log_detail(log_id: int):
|
||||
"""로그 상세 조회 (상호작용 포함)"""
|
||||
if not DB_PATH.exists():
|
||||
return None
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 메인 로그
|
||||
cursor.execute("SELECT * FROM kims_api_logs WHERE id = ?", (log_id,))
|
||||
log = cursor.fetchone()
|
||||
|
||||
if not log:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
# 상호작용 상세
|
||||
cursor.execute("""
|
||||
SELECT * FROM kims_interactions
|
||||
WHERE log_id = ?
|
||||
ORDER BY severity_level ASC
|
||||
""", (log_id,))
|
||||
interactions = cursor.fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
result = dict(log)
|
||||
result['interactions_detail'] = [dict(i) for i in interactions]
|
||||
|
||||
return result
|
||||
|
||||
def get_stats():
|
||||
"""통계 조회"""
|
||||
if not DB_PATH.exists():
|
||||
return {}
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 전체 통계
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total_calls,
|
||||
SUM(CASE WHEN api_status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
|
||||
SUM(CASE WHEN interaction_count > 0 THEN 1 ELSE 0 END) as with_interaction,
|
||||
SUM(CASE WHEN has_severe_interaction = 1 THEN 1 ELSE 0 END) as with_severe,
|
||||
AVG(response_time_ms) as avg_response_ms
|
||||
FROM kims_api_logs
|
||||
""")
|
||||
stats = dict(cursor.fetchone())
|
||||
|
||||
# 최근 7일 일별 통계
|
||||
cursor.execute("""
|
||||
SELECT * FROM kims_stats
|
||||
ORDER BY date DESC
|
||||
LIMIT 7
|
||||
""")
|
||||
daily = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
conn.close()
|
||||
|
||||
stats['daily'] = daily
|
||||
return stats
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# DB 초기화 테스트
|
||||
init_db()
|
||||
print("KIMS 로그 DB 초기화 완료!")
|
||||
86
backend/db/kims_logs_schema.sql
Normal file
86
backend/db/kims_logs_schema.sql
Normal file
@ -0,0 +1,86 @@
|
||||
-- KIMS API 로그 테이블 스키마
|
||||
-- AI 학습 데이터로 활용 예정
|
||||
|
||||
-- 1. API 호출 로그 (메인)
|
||||
CREATE TABLE IF NOT EXISTS kims_api_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 호출 컨텍스트
|
||||
pre_serial TEXT, -- 처방번호
|
||||
user_id INTEGER, -- 마일리지 회원 ID (있으면)
|
||||
source TEXT DEFAULT 'admin', -- 호출 소스 (admin, api, batch 등)
|
||||
|
||||
-- 요청 데이터
|
||||
request_drug_codes TEXT NOT NULL, -- JSON: ["055101150", "622801610"]
|
||||
request_drug_names TEXT, -- JSON: ["오메프투캡슐", "락소펜엠정"]
|
||||
request_drug_count INTEGER, -- 요청 약품 수
|
||||
|
||||
-- 응답 데이터
|
||||
api_status TEXT NOT NULL, -- SUCCESS, ERROR, TIMEOUT
|
||||
http_status INTEGER, -- HTTP 상태 코드
|
||||
response_time_ms INTEGER, -- 응답 시간 (밀리초)
|
||||
|
||||
-- 상호작용 결과
|
||||
interaction_count INTEGER DEFAULT 0, -- 발견된 상호작용 수
|
||||
has_severe_interaction INTEGER DEFAULT 0, -- 심각한 상호작용 여부 (1/2 등급)
|
||||
|
||||
-- 상세 데이터 (JSON)
|
||||
interactions_json TEXT, -- 상호작용 상세 정보 JSON
|
||||
response_raw TEXT, -- 전체 API 응답 (디버깅/학습용)
|
||||
|
||||
-- 에러 정보
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
-- 2. 상호작용 상세 (정규화, AI 학습용)
|
||||
CREATE TABLE IF NOT EXISTS kims_interactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
log_id INTEGER NOT NULL, -- kims_api_logs.id FK
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 약품 1
|
||||
drug1_code TEXT NOT NULL,
|
||||
drug1_name TEXT,
|
||||
drug1_generic TEXT, -- 성분명 (영문)
|
||||
|
||||
-- 약품 2
|
||||
drug2_code TEXT NOT NULL,
|
||||
drug2_name TEXT,
|
||||
drug2_generic TEXT, -- 성분명 (영문)
|
||||
|
||||
-- 상호작용 정보
|
||||
severity_level INTEGER, -- 1=심각, 2=중등도, 3=경미, 4=참고
|
||||
severity_desc TEXT, -- 심각도 설명 (중증, 경미 등)
|
||||
likelihood_level INTEGER, -- 발생 가능성
|
||||
likelihood_desc TEXT,
|
||||
|
||||
-- 상세 설명 (AI 학습 핵심 데이터)
|
||||
observation TEXT, -- 상호작용 설명 (한글)
|
||||
observation_generic TEXT, -- 일반적 설명
|
||||
clinical_management TEXT, -- 임상적 관리 방법
|
||||
action_to_take TEXT, -- 권장 조치
|
||||
reference TEXT, -- 참고문헌
|
||||
|
||||
FOREIGN KEY (log_id) REFERENCES kims_api_logs(id)
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_logs_created ON kims_api_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_logs_pre_serial ON kims_api_logs(pre_serial);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_logs_status ON kims_api_logs(api_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_interactions_log ON kims_interactions(log_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_interactions_drugs ON kims_interactions(drug1_code, drug2_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_interactions_severity ON kims_interactions(severity_level);
|
||||
|
||||
-- 통계 뷰
|
||||
CREATE VIEW IF NOT EXISTS kims_stats AS
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as total_calls,
|
||||
SUM(CASE WHEN api_status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
|
||||
SUM(CASE WHEN interaction_count > 0 THEN 1 ELSE 0 END) as with_interaction,
|
||||
SUM(CASE WHEN has_severe_interaction = 1 THEN 1 ELSE 0 END) as with_severe,
|
||||
AVG(response_time_ms) as avg_response_ms
|
||||
FROM kims_api_logs
|
||||
GROUP BY DATE(created_at);
|
||||
@ -22,6 +22,9 @@ CREATE TABLE IF NOT EXISTS customer_identities (
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
provider_user_id VARCHAR(100) NOT NULL,
|
||||
provider_data TEXT,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
token_expires_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
UNIQUE(provider, provider_user_id)
|
||||
@ -120,3 +123,25 @@ CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at);
|
||||
|
||||
-- 8. 반려동물 테이블
|
||||
CREATE TABLE IF NOT EXISTS pets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name VARCHAR(50) NOT NULL, -- 이름 (예: 뽀삐, 나비)
|
||||
species VARCHAR(20) NOT NULL, -- 종류: dog, cat, other
|
||||
breed VARCHAR(50), -- 품종 (말티즈, 페르시안 등)
|
||||
gender VARCHAR(10), -- male, female, unknown
|
||||
birth_date DATE, -- 생년월일 (나중에 사용)
|
||||
age_months INTEGER, -- 월령 (나중에 사용)
|
||||
weight DECIMAL(5,2), -- 체중 kg (나중에 사용)
|
||||
photo_url TEXT, -- 사진 URL
|
||||
notes TEXT, -- 특이사항/메모
|
||||
is_active BOOLEAN DEFAULT TRUE, -- 활성 상태
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species);
|
||||
|
||||
BIN
backend/static/uploads/pets/pet_1_d4ffe983.png
Normal file
BIN
backend/static/uploads/pets/pet_1_d4ffe983.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@ -999,6 +999,10 @@
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// 약품 코드 배열 (상호작용 체크용)
|
||||
const drugCodes = (rx.items || []).map(item => item.drug_code).filter(c => c);
|
||||
const drugCodesJson = JSON.stringify(drugCodes).replace(/"/g, '"');
|
||||
|
||||
html += `
|
||||
<div style="border: 1px solid #e9ecef; border-radius: 12px; margin-bottom: 12px; padding: 16px; border-left: 4px solid #6366f1;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
@ -1009,6 +1013,14 @@
|
||||
🏥 ${rx.hospital || ''} · ${rx.doctor || ''}
|
||||
</div>
|
||||
${rx.items && rx.items.length > 0 ? `<div style="background: #f8f9fa; border-radius: 8px; padding: 12px;">${itemsHtml}</div>` : ''}
|
||||
${drugCodes.length >= 2 ? `
|
||||
<div style="margin-top: 12px; text-align: right;">
|
||||
<button onclick='checkDrugInteraction(${drugCodesJson}, "${rx.pre_serial || ""}")'
|
||||
style="background: linear-gradient(135deg, #8b5cf6, #6366f1); color: #fff; border: none; padding: 8px 14px; border-radius: 8px; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px;">
|
||||
🔬 AI 상호작용 체크
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
@ -1710,6 +1722,162 @@
|
||||
closeAIAnalysisModal();
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// KIMS 약물 상호작용 체크
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
async function checkDrugInteraction(drugCodes, preSerial) {
|
||||
// drugCodes가 문자열로 넘어올 수 있음
|
||||
if (typeof drugCodes === 'string') {
|
||||
try { drugCodes = JSON.parse(drugCodes); } catch(e) { return; }
|
||||
}
|
||||
|
||||
// 로딩 모달 표시
|
||||
showInteractionModal('loading');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/kims/interaction-check', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
drug_codes: drugCodes,
|
||||
pre_serial: preSerial
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showInteractionModal('result', data);
|
||||
} else {
|
||||
showInteractionModal('error', data.error || '알 수 없는 오류');
|
||||
}
|
||||
} catch (err) {
|
||||
showInteractionModal('error', '서버 연결 실패: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showInteractionModal(type, data) {
|
||||
let modal = document.getElementById('interactionModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'interactionModal';
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
|
||||
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
let content = '';
|
||||
|
||||
if (type === 'loading') {
|
||||
content = `
|
||||
<div style="background:#fff;border-radius:16px;padding:40px;text-align:center;max-width:400px;">
|
||||
<div style="font-size:48px;margin-bottom:16px;">🔬</div>
|
||||
<div style="font-size:18px;font-weight:600;color:#334155;">상호작용 분석 중...</div>
|
||||
<div style="font-size:14px;color:#64748b;margin-top:8px;">KIMS 데이터베이스 조회 중</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'error') {
|
||||
content = `
|
||||
<div style="background:#fff;border-radius:16px;padding:30px;max-width:400px;">
|
||||
<div style="font-size:40px;text-align:center;margin-bottom:16px;">⚠️</div>
|
||||
<div style="font-size:16px;font-weight:600;color:#dc2626;text-align:center;">분석 실패</div>
|
||||
<div style="font-size:14px;color:#64748b;margin-top:12px;text-align:center;">${escapeHtml(data)}</div>
|
||||
<div style="text-align:center;margin-top:20px;">
|
||||
<button onclick="document.getElementById('interactionModal').remove()"
|
||||
style="background:#6366f1;color:#fff;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'result') {
|
||||
const interactions = data.interactions || [];
|
||||
const drugsChecked = data.drugs_checked || [];
|
||||
|
||||
// 약품 목록 (상호작용 여부에 따른 색상)
|
||||
const drugsHtml = drugsChecked.map(d => {
|
||||
const hasInteraction = d.has_interaction;
|
||||
const bgColor = hasInteraction ? '#fef2f2' : '#f1f5f9';
|
||||
const borderColor = hasInteraction ? '#fca5a5' : '#e2e8f0';
|
||||
const textColor = hasInteraction ? '#dc2626' : '#334155';
|
||||
const icon = hasInteraction ? '⚠️ ' : '';
|
||||
return `<span style="display:inline-block;background:${bgColor};border:1px solid ${borderColor};color:${textColor};padding:4px 8px;border-radius:4px;margin:2px;font-size:12px;">${icon}${escapeHtml(d.name.slice(0,20))}</span>`;
|
||||
}).join('');
|
||||
|
||||
// 상호작용 목록
|
||||
let interactionsHtml = '';
|
||||
if (interactions.length === 0) {
|
||||
interactionsHtml = `
|
||||
<div style="text-align:center;padding:30px;">
|
||||
<div style="font-size:48px;margin-bottom:12px;">✅</div>
|
||||
<div style="font-size:16px;font-weight:600;color:#10b981;">상호작용 없음</div>
|
||||
<div style="font-size:13px;color:#64748b;margin-top:8px;">
|
||||
${data.total_pairs}개 약품 조합을 검사했습니다.<br>
|
||||
주의가 필요한 상호작용이 발견되지 않았습니다.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
interactionsHtml = interactions.map(item => `
|
||||
<div style="background:#fff;border:1px solid ${item.severity_color};border-radius:12px;padding:16px;margin-bottom:12px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
|
||||
<span style="font-weight:600;color:#334155;">
|
||||
${escapeHtml(item.drug1_name?.slice(0,20) || '')} ↔ ${escapeHtml(item.drug2_name?.slice(0,20) || '')}
|
||||
</span>
|
||||
<span style="background:${item.severity_color};color:#fff;padding:4px 10px;border-radius:12px;font-size:12px;font-weight:500;">
|
||||
${item.severity_text}
|
||||
</span>
|
||||
</div>
|
||||
${item.description ? `
|
||||
<div style="font-size:13px;color:#475569;margin-bottom:8px;line-height:1.5;">
|
||||
📋 ${escapeHtml(item.description)}
|
||||
</div>
|
||||
` : ''}
|
||||
${item.management ? `
|
||||
<div style="font-size:12px;color:#059669;background:#ecfdf5;padding:8px 12px;border-radius:6px;">
|
||||
💡 ${escapeHtml(item.management.slice(0, 150))}...
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
content = `
|
||||
<div style="background:#f8fafc;border-radius:20px;max-width:500px;max-height:80vh;overflow:hidden;display:flex;flex-direction:column;">
|
||||
<div style="background:linear-gradient(135deg,#8b5cf6,#6366f1);padding:20px 24px;color:#fff;">
|
||||
<div style="font-size:18px;font-weight:700;display:flex;align-items:center;gap:10px;">
|
||||
🔬 약물 상호작용 분석
|
||||
</div>
|
||||
<div style="font-size:13px;opacity:0.9;margin-top:6px;">
|
||||
${drugsChecked.length}개 약품 · ${data.total_pairs}개 조합 검사
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 20px;border-bottom:1px solid #e2e8f0;">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:6px;">분석 약품</div>
|
||||
${drugsHtml}
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:16px 20px;">
|
||||
${interactions.length > 0 ? `
|
||||
<div style="font-size:13px;color:#dc2626;font-weight:600;margin-bottom:12px;">
|
||||
⚠️ ${interactions.length}건의 상호작용 발견
|
||||
</div>
|
||||
` : ''}
|
||||
${interactionsHtml}
|
||||
</div>
|
||||
<div style="padding:16px 20px;border-top:1px solid #e2e8f0;text-align:center;">
|
||||
<button onclick="document.getElementById('interactionModal').remove()"
|
||||
style="background:#6366f1;color:#fff;border:none;padding:12px 32px;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.innerHTML = content;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Lottie 애니메이션 라이브러리 (로컬) -->
|
||||
|
||||
563
backend/templates/admin_kims_logs.html
Normal file
563
backend/templates/admin_kims_logs.html
Normal file
@ -0,0 +1,563 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KIMS 상호작용 로그 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #f8fafc;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* ── 헤더 ── */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #f59e0b 50%, #16a34a 100%);
|
||||
padding: 28px 32px 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.8);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header-nav a:hover { color: #fff; }
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ── 컨텐츠 ── */
|
||||
.content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* ── 통계 카드 ── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.stat-value.default { color: #1e293b; }
|
||||
.stat-value.green { color: #16a34a; }
|
||||
.stat-value.orange { color: #f59e0b; }
|
||||
.stat-value.red { color: #dc2626; }
|
||||
.stat-value.blue { color: #3b82f6; }
|
||||
.stat-sub {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── 필터 ── */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-bar select, .filter-bar input {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
.filter-bar button {
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 테이블 ── */
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: #f8fafc;
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td {
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody tr { cursor: pointer; transition: background .15s; }
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* ── 배지 ── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 100px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-success { background: #dcfce7; color: #16a34a; }
|
||||
.badge-error { background: #fee2e2; color: #dc2626; }
|
||||
.badge-timeout { background: #fef3c7; color: #d97706; }
|
||||
.badge-severe { background: #dc2626; color: #fff; }
|
||||
.badge-moderate { background: #f59e0b; color: #fff; }
|
||||
.badge-mild { background: #3b82f6; color: #fff; }
|
||||
.badge-drug {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
margin: 2px;
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.badge-drug.warning {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* ── 상호작용 카운트 ── */
|
||||
.interaction-count {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.interaction-count.zero { color: #16a34a; }
|
||||
.interaction-count.has { color: #dc2626; }
|
||||
.interaction-count.severe {
|
||||
color: #fff;
|
||||
background: #dc2626;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── 아코디언 상세 ── */
|
||||
.detail-row { display: none; }
|
||||
.detail-row.open { display: table-row; }
|
||||
.detail-row td {
|
||||
padding: 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #fafbfd;
|
||||
}
|
||||
.detail-content {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.detail-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.drug-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── 상호작용 카드 ── */
|
||||
.interaction-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
border-left: 4px solid #e2e8f0;
|
||||
}
|
||||
.interaction-card.severe { border-left-color: #dc2626; background: #fef2f2; }
|
||||
.interaction-card.moderate { border-left-color: #f59e0b; background: #fffbeb; }
|
||||
.interaction-card.mild { border-left-color: #3b82f6; background: #eff6ff; }
|
||||
.interaction-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.interaction-drugs {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.interaction-desc {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.interaction-mgmt {
|
||||
font-size: 12px;
|
||||
color: #059669;
|
||||
background: #ecfdf5;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
||||
|
||||
/* ── 로딩 ── */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 900px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.stats-grid { grid-template-columns: 1fr; }
|
||||
.filter-bar { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<a href="/admin/members">회원 관리</a>
|
||||
</div>
|
||||
<h1>🔬 KIMS 상호작용 로그</h1>
|
||||
<p>약물 상호작용 체크 API 호출 기록 · AI 학습용 데이터</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 통계 -->
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 호출</div>
|
||||
<div class="stat-value default" id="statTotal">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">성공</div>
|
||||
<div class="stat-value green" id="statSuccess">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">상호작용 발견</div>
|
||||
<div class="stat-value orange" id="statInteraction">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">심각 경고</div>
|
||||
<div class="stat-value red" id="statSevere">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">평균 응답</div>
|
||||
<div class="stat-value blue" id="statAvgMs">-</div>
|
||||
<div class="stat-sub">밀리초</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="filter-bar">
|
||||
<select id="filterStatus">
|
||||
<option value="">모든 상태</option>
|
||||
<option value="SUCCESS">성공</option>
|
||||
<option value="ERROR">에러</option>
|
||||
<option value="TIMEOUT">타임아웃</option>
|
||||
</select>
|
||||
<select id="filterInteraction">
|
||||
<option value="">모든 결과</option>
|
||||
<option value="has">상호작용 있음</option>
|
||||
<option value="severe">심각 상호작용</option>
|
||||
<option value="none">상호작용 없음</option>
|
||||
</select>
|
||||
<input type="date" id="filterDate" />
|
||||
<button onclick="loadLogs()">🔍 조회</button>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>처방번호</th>
|
||||
<th>약품</th>
|
||||
<th>상호작용</th>
|
||||
<th>상태</th>
|
||||
<th>응답</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logsBody">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">로딩 중...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let logsData = [];
|
||||
let openRowId = null;
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return '-';
|
||||
const d = new Date(dt);
|
||||
return `${d.getMonth()+1}/${d.getDate()} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/api/kims/logs/stats');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.getElementById('statTotal').textContent = data.stats.total_calls || 0;
|
||||
document.getElementById('statSuccess').textContent = data.stats.success_count || 0;
|
||||
document.getElementById('statInteraction').textContent = data.stats.with_interaction || 0;
|
||||
document.getElementById('statSevere').textContent = data.stats.with_severe || 0;
|
||||
document.getElementById('statAvgMs').textContent = Math.round(data.stats.avg_response_ms || 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('통계 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
const tbody = document.getElementById('logsBody');
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="loading">로딩 중...</td></tr>';
|
||||
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const interaction = document.getElementById('filterInteraction').value;
|
||||
const date = document.getElementById('filterDate').value;
|
||||
|
||||
try {
|
||||
let url = '/api/kims/logs?limit=100';
|
||||
if (status) url += `&status=${status}`;
|
||||
if (interaction) url += `&interaction=${interaction}`;
|
||||
if (date) url += `&date=${date}`;
|
||||
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success || !data.logs || data.logs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><div class="empty-icon">📭</div><div>로그가 없습니다</div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
logsData = data.logs;
|
||||
renderLogs();
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">로드 실패</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
const tbody = document.getElementById('logsBody');
|
||||
let html = '';
|
||||
|
||||
logsData.forEach((log, idx) => {
|
||||
// 약품 배지
|
||||
let drugs = [];
|
||||
try { drugs = JSON.parse(log.request_drug_names || '[]'); } catch(e) {}
|
||||
const drugBadges = drugs.slice(0, 3).map(d =>
|
||||
`<span class="badge badge-drug">${escapeHtml(d.slice(0, 12))}</span>`
|
||||
).join('') + (drugs.length > 3 ? `<span class="badge badge-drug">+${drugs.length - 3}</span>` : '');
|
||||
|
||||
// 상호작용 표시
|
||||
let interactionHtml = '';
|
||||
if (log.interaction_count > 0) {
|
||||
if (log.has_severe_interaction) {
|
||||
interactionHtml = `<span class="interaction-count severe">⚠️ ${log.interaction_count}</span>`;
|
||||
} else {
|
||||
interactionHtml = `<span class="interaction-count has">${log.interaction_count}건</span>`;
|
||||
}
|
||||
} else {
|
||||
interactionHtml = `<span class="interaction-count zero">✓ 없음</span>`;
|
||||
}
|
||||
|
||||
// 상태 배지
|
||||
let statusBadge = '';
|
||||
if (log.api_status === 'SUCCESS') {
|
||||
statusBadge = '<span class="badge badge-success">성공</span>';
|
||||
} else if (log.api_status === 'TIMEOUT') {
|
||||
statusBadge = '<span class="badge badge-timeout">타임아웃</span>';
|
||||
} else {
|
||||
statusBadge = '<span class="badge badge-error">에러</span>';
|
||||
}
|
||||
|
||||
html += `
|
||||
<tr onclick="toggleDetail(${log.id}, ${idx})">
|
||||
<td>${formatDateTime(log.created_at)}</td>
|
||||
<td>${escapeHtml(log.pre_serial) || '-'}</td>
|
||||
<td>${drugBadges}</td>
|
||||
<td>${interactionHtml}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${log.response_time_ms || 0}ms</td>
|
||||
</tr>
|
||||
<tr class="detail-row" id="detail-${log.id}">
|
||||
<td colspan="6">
|
||||
<div class="detail-content" id="detail-content-${log.id}">
|
||||
로딩 중...
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
async function toggleDetail(logId, idx) {
|
||||
const detailRow = document.getElementById(`detail-${logId}`);
|
||||
|
||||
if (openRowId === logId) {
|
||||
detailRow.classList.remove('open');
|
||||
openRowId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 열린 행 닫기
|
||||
if (openRowId) {
|
||||
document.getElementById(`detail-${openRowId}`)?.classList.remove('open');
|
||||
}
|
||||
|
||||
openRowId = logId;
|
||||
detailRow.classList.add('open');
|
||||
|
||||
// 상세 데이터 로드
|
||||
const contentDiv = document.getElementById(`detail-content-${logId}`);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/kims/logs/${logId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
contentDiv.innerHTML = '<p>상세 정보 로드 실패</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const log = data.log;
|
||||
let drugs = [];
|
||||
try { drugs = JSON.parse(log.request_drug_names || '[]'); } catch(e) {}
|
||||
|
||||
// 상호작용 카드
|
||||
let interactionsHtml = '';
|
||||
const interactions = log.interactions_detail || [];
|
||||
|
||||
if (interactions.length === 0) {
|
||||
interactionsHtml = '<p style="color:#16a34a;font-weight:600;">✅ 상호작용 없음</p>';
|
||||
} else {
|
||||
interactions.forEach(inter => {
|
||||
const sevLevel = inter.severity_level || 5;
|
||||
const sevClass = sevLevel == 1 ? 'severe' : sevLevel == 2 ? 'moderate' : 'mild';
|
||||
|
||||
interactionsHtml += `
|
||||
<div class="interaction-card ${sevClass}">
|
||||
<div class="interaction-header">
|
||||
<span class="interaction-drugs">${escapeHtml(inter.drug1_name)} ↔ ${escapeHtml(inter.drug2_name)}</span>
|
||||
<span class="badge badge-${sevClass}">${escapeHtml(inter.severity_desc) || '알 수 없음'}</span>
|
||||
</div>
|
||||
${inter.observation ? `<div class="interaction-desc">${escapeHtml(inter.observation)}</div>` : ''}
|
||||
${inter.clinical_management ? `<div class="interaction-mgmt">💡 ${escapeHtml(inter.clinical_management).slice(0, 200)}...</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
contentDiv.innerHTML = `
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">💊 분석 약품 (${drugs.length}개)</div>
|
||||
<div class="drug-pills">
|
||||
${drugs.map(d => `<span class="badge badge-drug">${escapeHtml(d)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">⚠️ 상호작용 (${interactions.length}건)</div>
|
||||
${interactionsHtml}
|
||||
</div>
|
||||
<div class="detail-section" style="font-size:12px;color:#94a3b8;">
|
||||
응답시간: ${log.response_time_ms}ms ·
|
||||
호출시간: ${log.created_at} ·
|
||||
처방번호: ${escapeHtml(log.pre_serial) || '-'}
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
contentDiv.innerHTML = '<p>로드 실패</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// 초기 로드
|
||||
loadStats();
|
||||
loadLogs();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1038,6 +1038,10 @@
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// 약품 코드 배열 (상호작용 체크용)
|
||||
const drugCodes = (rx.items || []).map(item => item.drug_code).filter(c => c);
|
||||
const drugCodesJson = JSON.stringify(drugCodes);
|
||||
|
||||
return `
|
||||
<div class="purchase-card" style="border-left: 3px solid #6366f1;">
|
||||
<div class="purchase-header">
|
||||
@ -1050,6 +1054,14 @@
|
||||
${rx.items && rx.items.length > 0 ? `
|
||||
<div class="purchase-items">${itemsHtml}</div>
|
||||
` : ''}
|
||||
${drugCodes.length >= 2 ? `
|
||||
<div style="margin-top:10px;text-align:right;">
|
||||
<button onclick='checkDrugInteraction(${drugCodesJson}, "${rx.pre_serial || ""}")'
|
||||
style="background:linear-gradient(135deg,#8b5cf6,#6366f1);color:#fff;border:none;padding:8px 14px;border-radius:8px;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">
|
||||
🔬 AI 상호작용 체크
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@ -1111,6 +1123,158 @@
|
||||
|
||||
// 페이지 로드 시 검색창 포커스
|
||||
document.getElementById('searchInput').focus();
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// KIMS 약물 상호작용 체크
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
async function checkDrugInteraction(drugCodes, preSerial) {
|
||||
// 로딩 모달 표시
|
||||
showInteractionModal('loading');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/kims/interaction-check', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
drug_codes: drugCodes,
|
||||
pre_serial: preSerial
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showInteractionModal('result', data);
|
||||
} else {
|
||||
showInteractionModal('error', data.error || '알 수 없는 오류');
|
||||
}
|
||||
} catch (err) {
|
||||
showInteractionModal('error', '서버 연결 실패: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showInteractionModal(type, data) {
|
||||
let modal = document.getElementById('interactionModal');
|
||||
if (!modal) {
|
||||
// 모달 생성
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'interactionModal';
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
|
||||
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
let content = '';
|
||||
|
||||
if (type === 'loading') {
|
||||
content = `
|
||||
<div style="background:#fff;border-radius:16px;padding:40px;text-align:center;max-width:400px;">
|
||||
<div style="font-size:48px;margin-bottom:16px;">🔬</div>
|
||||
<div style="font-size:18px;font-weight:600;color:#334155;">상호작용 분석 중...</div>
|
||||
<div style="font-size:14px;color:#64748b;margin-top:8px;">KIMS 데이터베이스 조회 중</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'error') {
|
||||
content = `
|
||||
<div style="background:#fff;border-radius:16px;padding:30px;max-width:400px;">
|
||||
<div style="font-size:40px;text-align:center;margin-bottom:16px;">⚠️</div>
|
||||
<div style="font-size:16px;font-weight:600;color:#dc2626;text-align:center;">분석 실패</div>
|
||||
<div style="font-size:14px;color:#64748b;margin-top:12px;text-align:center;">${escapeHtml(data)}</div>
|
||||
<div style="text-align:center;margin-top:20px;">
|
||||
<button onclick="document.getElementById('interactionModal').remove()"
|
||||
style="background:#6366f1;color:#fff;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'result') {
|
||||
const interactions = data.interactions || [];
|
||||
const drugsChecked = data.drugs_checked || [];
|
||||
|
||||
// 약품 목록 (상호작용 있는 약품은 빨간색/주황색 배경)
|
||||
const drugsHtml = drugsChecked.map(d => {
|
||||
const hasInteraction = d.has_interaction;
|
||||
const bgColor = hasInteraction ? '#fef2f2' : '#f1f5f9'; // 연한 빨강 vs 회색
|
||||
const borderColor = hasInteraction ? '#fca5a5' : '#e2e8f0';
|
||||
const textColor = hasInteraction ? '#dc2626' : '#334155';
|
||||
const icon = hasInteraction ? '⚠️ ' : '';
|
||||
return `<span style="display:inline-block;background:${bgColor};border:1px solid ${borderColor};color:${textColor};padding:4px 8px;border-radius:4px;margin:2px;font-size:12px;">${icon}${escapeHtml(d.name.slice(0,20))}</span>`;
|
||||
}).join('');
|
||||
|
||||
// 상호작용 목록
|
||||
let interactionsHtml = '';
|
||||
if (interactions.length === 0) {
|
||||
interactionsHtml = `
|
||||
<div style="text-align:center;padding:30px;">
|
||||
<div style="font-size:48px;margin-bottom:12px;">✅</div>
|
||||
<div style="font-size:16px;font-weight:600;color:#10b981;">상호작용 없음</div>
|
||||
<div style="font-size:13px;color:#64748b;margin-top:8px;">
|
||||
${data.total_pairs}개 약품 조합을 검사했습니다.<br>
|
||||
주의가 필요한 상호작용이 발견되지 않았습니다.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
interactionsHtml = interactions.map(item => `
|
||||
<div style="background:#fff;border:1px solid ${item.severity_color};border-radius:12px;padding:16px;margin-bottom:12px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
|
||||
<span style="font-weight:600;color:#334155;">
|
||||
${escapeHtml(item.drug1_name?.slice(0,20) || '')} ↔ ${escapeHtml(item.drug2_name?.slice(0,20) || '')}
|
||||
</span>
|
||||
<span style="background:${item.severity_color};color:#fff;padding:4px 10px;border-radius:12px;font-size:12px;font-weight:500;">
|
||||
${item.severity_text}
|
||||
</span>
|
||||
</div>
|
||||
${item.description ? `
|
||||
<div style="font-size:13px;color:#475569;margin-bottom:8px;line-height:1.5;">
|
||||
📋 ${escapeHtml(item.description)}
|
||||
</div>
|
||||
` : ''}
|
||||
${item.management ? `
|
||||
<div style="font-size:12px;color:#059669;background:#ecfdf5;padding:8px 12px;border-radius:6px;">
|
||||
💡 ${escapeHtml(item.management)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
content = `
|
||||
<div style="background:#f8fafc;border-radius:20px;max-width:500px;max-height:80vh;overflow:hidden;display:flex;flex-direction:column;">
|
||||
<div style="background:linear-gradient(135deg,#8b5cf6,#6366f1);padding:20px 24px;color:#fff;">
|
||||
<div style="font-size:18px;font-weight:700;display:flex;align-items:center;gap:10px;">
|
||||
🔬 약물 상호작용 분석
|
||||
</div>
|
||||
<div style="font-size:13px;opacity:0.9;margin-top:6px;">
|
||||
${drugsChecked.length}개 약품 · ${data.total_pairs}개 조합 검사
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 20px;border-bottom:1px solid #e2e8f0;">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:6px;">분석 약품</div>
|
||||
${drugsHtml}
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:16px 20px;">
|
||||
${interactions.length > 0 ? `
|
||||
<div style="font-size:13px;color:#dc2626;font-weight:600;margin-bottom:12px;">
|
||||
⚠️ ${interactions.length}건의 상호작용 발견
|
||||
</div>
|
||||
` : ''}
|
||||
${interactionsHtml}
|
||||
</div>
|
||||
<div style="padding:16px 20px;border-top:1px solid #e2e8f0;text-align:center;">
|
||||
<button onclick="document.getElementById('interactionModal').remove()"
|
||||
style="background:#6366f1;color:#fff;border:none;padding:12px 32px;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.innerHTML = content;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -725,12 +725,23 @@
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = productsData.map((item, idx) => `
|
||||
tbody.innerHTML = productsData.map((item, idx) => {
|
||||
// 분류 뱃지 (동물약만)
|
||||
const categoryBadge = item.category
|
||||
? `<span style="display:inline-block;background:#8b5cf6;color:#fff;font-size:10px;padding:2px 5px;border-radius:3px;margin-left:4px;">${escapeHtml(item.category)}</span>`
|
||||
: '';
|
||||
// 도매상 재고 표시 (동물약만)
|
||||
const wsStock = (item.wholesaler_stock && item.wholesaler_stock > 0)
|
||||
? `<span style="color:#3b82f6;font-size:11px;margin-left:4px;">(도매 ${item.wholesaler_stock})</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="product-name">
|
||||
${escapeHtml(item.product_name)}
|
||||
${item.is_animal_drug ? '<span style="display:inline-block;background:#10b981;color:#fff;font-size:11px;padding:2px 6px;border-radius:4px;margin-left:6px;">🐾 동물약</span>' : ''}
|
||||
${categoryBadge}
|
||||
</div>
|
||||
<div class="product-supplier ${item.is_set ? 'set' : ''}">${escapeHtml(item.supplier) || ''}</div>
|
||||
</td>
|
||||
@ -738,13 +749,13 @@
|
||||
<td>${item.barcode
|
||||
? `<span class="code code-barcode">${item.barcode}</span>`
|
||||
: `<span class="code code-na">없음</span>`}</td>
|
||||
<td class="stock ${(item.stock || 0) > 0 ? 'in-stock' : 'out-stock'}">${item.stock || 0}</td>
|
||||
<td class="stock ${(item.stock || 0) > 0 ? 'in-stock' : 'out-stock'}">${item.stock || 0}${wsStock}</td>
|
||||
<td class="price">${formatPrice(item.sale_price)}</td>
|
||||
<td>
|
||||
<button class="btn-qr" onclick="printQR(${idx})">🏷️ QR</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
`}).join('');
|
||||
}
|
||||
|
||||
// ── QR 인쇄 관련 ──
|
||||
@ -987,7 +998,7 @@
|
||||
imgContainer.innerHTML = '<div style="width:40px;height:40px;background:#f1f5f9;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:20px;">💊</div>';
|
||||
}
|
||||
|
||||
// 텍스트 (약국/도매 재고)
|
||||
// 텍스트 (카테고리 뱃지 + 약국/도매 재고)
|
||||
const textDiv = document.createElement('div');
|
||||
const pharmacyStock = p.stock || 0;
|
||||
const wholesalerStock = p.wholesaler_stock || 0;
|
||||
@ -995,7 +1006,13 @@
|
||||
const pharmacyText = (pharmacyStock > 0) ? `약국 ${pharmacyStock}` : '품절';
|
||||
const wholesalerText = (wholesalerStock > 0) ? `도매 ${wholesalerStock}` : '';
|
||||
const stockDisplay = wholesalerText ? `${pharmacyText} / ${wholesalerText}` : pharmacyText;
|
||||
textDiv.innerHTML = `<div style="font-size:13px;font-weight:500;color:#334155;">${p.name}</div><div style="font-size:12px;"><span style="color:#10b981;">${formatPrice(p.price)}</span> <span style="color:${stockColor};margin-left:6px;">${stockDisplay}</span></div>`;
|
||||
|
||||
// 카테고리 뱃지
|
||||
const categoryBadge = p.category
|
||||
? `<span style="display:inline-block;background:#8b5cf6;color:#fff;font-size:10px;padding:2px 5px;border-radius:3px;margin-left:4px;">${p.category}</span>`
|
||||
: '';
|
||||
|
||||
textDiv.innerHTML = `<div style="font-size:13px;font-weight:500;color:#334155;">${p.name}${categoryBadge}</div><div style="font-size:12px;"><span style="color:#10b981;">${formatPrice(p.price)}</span> <span style="color:${stockColor};margin-left:6px;">${stockDisplay}</span></div>`;
|
||||
|
||||
card.appendChild(imgContainer);
|
||||
card.appendChild(textDiv);
|
||||
|
||||
@ -119,6 +119,49 @@
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
/* 퀵 메뉴 */
|
||||
.quick-menu {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 20px 16px;
|
||||
background: #fff;
|
||||
margin: 0 16px 16px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.quick-menu-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.quick-menu-item:active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.quick-menu-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.quick-menu-item span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 24px;
|
||||
}
|
||||
@ -301,6 +344,26 @@
|
||||
<div class="balance-desc">약국에서 1P = 1원으로 사용 가능</div>
|
||||
</div>
|
||||
|
||||
<!-- 퀵 메뉴 -->
|
||||
<div class="quick-menu">
|
||||
<a href="/mypage" class="quick-menu-item">
|
||||
<div class="quick-menu-icon" style="background: #fef3c7;">🐾</div>
|
||||
<span>반려동물</span>
|
||||
</a>
|
||||
<a href="#" class="quick-menu-item" onclick="alert('준비중입니다'); return false;">
|
||||
<div class="quick-menu-icon" style="background: #dbeafe;">🎟️</div>
|
||||
<span>쿠폰함</span>
|
||||
</a>
|
||||
<a href="#" class="quick-menu-item" onclick="alert('준비중입니다'); return false;">
|
||||
<div class="quick-menu-icon" style="background: #fce7f3;">📦</div>
|
||||
<span>구매내역</span>
|
||||
</a>
|
||||
<a href="/mypage" class="quick-menu-item">
|
||||
<div class="quick-menu-icon" style="background: #ede9fe;">⚙️</div>
|
||||
<span>내정보</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">적립 내역</div>
|
||||
|
||||
|
||||
891
backend/templates/mypage_v2.html
Normal file
891
backend/templates/mypage_v2.html
Normal file
@ -0,0 +1,891 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="청춘약국">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<title>마이페이지 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: #ffffff;
|
||||
min-height: 100vh;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 20px 24px 100px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
/* 프로필 카드 */
|
||||
.profile-card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
margin: -80px 16px 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-details h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.profile-details p {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 통계 그리드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stat-icon.purple { background: #ede9fe; }
|
||||
.stat-icon.blue { background: #dbeafe; }
|
||||
.stat-icon.pink { background: #fce7f3; }
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 섹션 */
|
||||
.section {
|
||||
background: white;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-action {
|
||||
color: #6366f1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 반려동물 카드 */
|
||||
.pet-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.pet-card:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.pet-photo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pet-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.pet-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pet-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pet-details {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.pet-arrow {
|
||||
color: #d1d5db;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 반려동물 추가 버튼 */
|
||||
.add-pet-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.add-pet-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
/* 메뉴 리스트 */
|
||||
.menu-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.menu-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #f9fafb;
|
||||
margin: 0 -20px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.menu-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
font-size: 15px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.menu-badge {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: none;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 24px 24px 0 0;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 폼 스타일 */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
/* 종류 선택 */
|
||||
.species-options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.species-option {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.species-option:hover {
|
||||
border-color: #c7d2fe;
|
||||
}
|
||||
|
||||
.species-option.selected {
|
||||
border-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.species-option .icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.species-option .label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* 사진 업로드 */
|
||||
.photo-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.photo-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.photo-preview:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.photo-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-hint {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 제출 버튼 */
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 24px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty-state .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 로딩 */
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 헤더 -->
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<h1 class="header-title">마이페이지</h1>
|
||||
<a href="/logout" class="btn-logout">로그아웃</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로필 카드 -->
|
||||
<div class="profile-card">
|
||||
<div class="profile-info">
|
||||
<div class="profile-avatar">
|
||||
{% if user.profile_image_url %}
|
||||
<img src="{{ user.profile_image_url }}" alt="프로필">
|
||||
{% else %}
|
||||
😊
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="profile-details">
|
||||
<h2>{{ user.nickname or '회원' }}님</h2>
|
||||
<p>{{ user.phone or '전화번호 미등록' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon purple">🎁</div>
|
||||
<div class="stat-value">{{ '{:,}'.format(user.mileage_balance or 0) }}</div>
|
||||
<div class="stat-label">포인트</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon blue">📦</div>
|
||||
<div class="stat-value">{{ purchase_count or 0 }}</div>
|
||||
<div class="stat-label">구매</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon pink">🐾</div>
|
||||
<div class="stat-value" id="pet-count">{{ pets|length }}</div>
|
||||
<div class="stat-label">반려동물</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반려동물 섹션 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">🐾 내 반려동물</h3>
|
||||
</div>
|
||||
|
||||
<div id="pet-list">
|
||||
{% if pets %}
|
||||
{% for pet in pets %}
|
||||
<div class="pet-card" onclick="editPet({{ pet.id }})">
|
||||
<div class="pet-photo">
|
||||
{% if pet.photo_url %}
|
||||
<img src="{{ pet.photo_url }}" alt="{{ pet.name }}">
|
||||
{% else %}
|
||||
{{ '🐕' if pet.species == 'dog' else ('🐈' if pet.species == 'cat' else '🐾') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="pet-info">
|
||||
<div class="pet-name">{{ pet.name }}</div>
|
||||
<div class="pet-details">
|
||||
{{ pet.species_label }}
|
||||
{% if pet.breed %}· {{ pet.breed }}{% endif %}
|
||||
{% if pet.gender %}· {{ '♂' if pet.gender == 'male' else ('♀' if pet.gender == 'female' else '') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<span class="pet-arrow">›</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="icon">🐾</div>
|
||||
<p>등록된 반려동물이 없습니다</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button class="add-pet-btn" onclick="openAddPetModal()">
|
||||
<span>+</span> 반려동물 추가하기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴 섹션 -->
|
||||
<div class="section">
|
||||
<ul class="menu-list">
|
||||
<li class="menu-item" onclick="location.href='/my-page?phone={{ user.phone }}'">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">📋</span>
|
||||
<span class="menu-text">적립 내역</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">📦</span>
|
||||
<span class="menu-text">구매 내역</span>
|
||||
<span class="menu-badge">준비중</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">🎟️</span>
|
||||
<span class="menu-text">쿠폰함</span>
|
||||
<span class="menu-badge">준비중</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">내 정보 수정</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반려동물 추가/수정 모달 -->
|
||||
<div class="modal-overlay" id="petModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="modalTitle">반려동물 등록</h2>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
|
||||
<form id="petForm" onsubmit="submitPet(event)">
|
||||
<input type="hidden" id="petId" value="">
|
||||
|
||||
<!-- 종류 선택 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">종류 *</label>
|
||||
<div class="species-options">
|
||||
<div class="species-option" data-species="dog" onclick="selectSpecies('dog')">
|
||||
<div class="icon">🐕</div>
|
||||
<div class="label">강아지</div>
|
||||
</div>
|
||||
<div class="species-option" data-species="cat" onclick="selectSpecies('cat')">
|
||||
<div class="icon">🐈</div>
|
||||
<div class="label">고양이</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이름 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">이름 *</label>
|
||||
<input type="text" class="form-input" id="petName" placeholder="예: 뽀삐" required>
|
||||
</div>
|
||||
|
||||
<!-- 품종 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">품종</label>
|
||||
<select class="form-input" id="petBreed">
|
||||
<option value="">선택해주세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 성별 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">성별</label>
|
||||
<select class="form-input" id="petGender">
|
||||
<option value="">선택해주세요</option>
|
||||
<option value="male">남아 ♂</option>
|
||||
<option value="female">여아 ♀</option>
|
||||
<option value="unknown">모름</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 사진 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">사진</label>
|
||||
<div class="photo-upload">
|
||||
<div class="photo-preview" id="photoPreview" onclick="document.getElementById('photoInput').click()">
|
||||
📷
|
||||
</div>
|
||||
<input type="file" id="photoInput" accept="image/*" style="display:none" onchange="previewPhoto(event)">
|
||||
<span class="photo-hint">탭하여 사진 추가</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn" id="submitBtn">등록하기</button>
|
||||
<button type="button" class="submit-btn" style="background:#ef4444; margin-top:12px; display:none;" id="deleteBtn" onclick="deletePet()">삭제하기</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedSpecies = '';
|
||||
let currentPetId = null;
|
||||
|
||||
const DOG_BREEDS = ['말티즈', '푸들', '포메라니안', '치와와', '시츄', '요크셔테리어', '비숑프리제', '골든리트리버', '래브라도리트리버', '진돗개', '시바견', '웰시코기', '닥스훈트', '비글', '보더콜리', '프렌치불독', '불독', '슈나우저', '사모예드', '허스키', '믹스견', '기타'];
|
||||
const CAT_BREEDS = ['코리안숏헤어', '페르시안', '러시안블루', '샴', '먼치킨', '랙돌', '브리티쉬숏헤어', '아메리칸숏헤어', '스코티쉬폴드', '노르웨이숲', '메인쿤', '뱅갈', '아비시니안', '터키쉬앙고라', '믹스묘', '기타'];
|
||||
|
||||
function selectSpecies(species) {
|
||||
selectedSpecies = species;
|
||||
document.querySelectorAll('.species-option').forEach(el => {
|
||||
el.classList.toggle('selected', el.dataset.species === species);
|
||||
});
|
||||
|
||||
// 품종 옵션 업데이트
|
||||
const breedSelect = document.getElementById('petBreed');
|
||||
const breeds = species === 'dog' ? DOG_BREEDS : CAT_BREEDS;
|
||||
breedSelect.innerHTML = '<option value="">선택해주세요</option>' +
|
||||
breeds.map(b => `<option value="${b}">${b}</option>`).join('');
|
||||
}
|
||||
|
||||
function openAddPetModal() {
|
||||
currentPetId = null;
|
||||
document.getElementById('modalTitle').textContent = '반려동물 등록';
|
||||
document.getElementById('petId').value = '';
|
||||
document.getElementById('petForm').reset();
|
||||
document.getElementById('photoPreview').innerHTML = '📷';
|
||||
document.getElementById('submitBtn').textContent = '등록하기';
|
||||
document.getElementById('deleteBtn').style.display = 'none';
|
||||
selectedSpecies = '';
|
||||
document.querySelectorAll('.species-option').forEach(el => el.classList.remove('selected'));
|
||||
document.getElementById('petModal').classList.add('active');
|
||||
}
|
||||
|
||||
function editPet(petId) {
|
||||
// TODO: API에서 pet 정보 가져와서 폼에 채우기
|
||||
currentPetId = petId;
|
||||
document.getElementById('modalTitle').textContent = '반려동물 수정';
|
||||
document.getElementById('submitBtn').textContent = '수정하기';
|
||||
document.getElementById('deleteBtn').style.display = 'block';
|
||||
document.getElementById('petModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('petModal').classList.remove('active');
|
||||
}
|
||||
|
||||
function previewPhoto(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
document.getElementById('photoPreview').innerHTML =
|
||||
`<img src="${e.target.result}" alt="미리보기">`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPet(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedSpecies) {
|
||||
alert('종류를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = document.getElementById('petName').value.trim();
|
||||
if (!name) {
|
||||
alert('이름을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '처리중...';
|
||||
|
||||
try {
|
||||
const data = {
|
||||
name: name,
|
||||
species: selectedSpecies,
|
||||
breed: document.getElementById('petBreed').value,
|
||||
gender: document.getElementById('petGender').value
|
||||
};
|
||||
|
||||
const url = currentPetId ? `/api/pets/${currentPetId}` : '/api/pets';
|
||||
const method = currentPetId ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// 사진 업로드
|
||||
const photoInput = document.getElementById('photoInput');
|
||||
if (photoInput.files.length > 0) {
|
||||
const petId = result.pet_id || currentPetId;
|
||||
const formData = new FormData();
|
||||
formData.append('photo', photoInput.files[0]);
|
||||
|
||||
await fetch(`/api/pets/${petId}/photo`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
alert(result.message || '저장되었습니다!');
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.error || '오류가 발생했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = currentPetId ? '수정하기' : '등록하기';
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePet() {
|
||||
if (!currentPetId) return;
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/pets/${currentPetId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('삭제되었습니다.');
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.error || '삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('서버 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
document.getElementById('petModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeModal();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
176
docs/ANIMAL_DRUG_APC_MAPPING.md
Normal file
176
docs/ANIMAL_DRUG_APC_MAPPING.md
Normal file
@ -0,0 +1,176 @@
|
||||
# 동물약 APC 매핑 가이드
|
||||
|
||||
> 최종 업데이트: 2026-03-02
|
||||
|
||||
## 개요
|
||||
|
||||
POS(PIT3000)의 동물약 제품을 APDB의 APC 코드와 매핑하여 제품 정보(용법, 용량, 주의사항) 및 이미지를 표시하기 위한 작업 가이드.
|
||||
|
||||
---
|
||||
|
||||
## 현재 상태
|
||||
|
||||
### 매핑 현황
|
||||
|
||||
| 구분 | 개수 | 비율 |
|
||||
|------|------|------|
|
||||
| 동물약 총 | 39개 | 100% |
|
||||
| APC 매핑됨 | 7개 | 18% |
|
||||
| **APC 미매핑** | **32개** | **82%** |
|
||||
|
||||
### 매핑 완료 제품
|
||||
|
||||
| POS 제품명 | DrugCode | APC |
|
||||
|------------|----------|-----|
|
||||
| (판)복합개시딘 | LB000003140 | 0231093520106 |
|
||||
| 안텔민킹(5kg이상) | LB000003158 | 0230237810109 |
|
||||
| 안텔민뽀삐(5kg이하) | LB000003157 | 0230237010107 |
|
||||
| 파라캅L(5kg이상) | LB000003159 | 0230338510101 |
|
||||
| 파라캅S(5kg이하) | LB000003160 | 0230347110106 |
|
||||
| 세레니아정16mg(개멀미약) | LB000003353 | 0231884610109 |
|
||||
| 세레니아정24mg(개멀미약) | LB000003354 | 0231884620107 |
|
||||
|
||||
---
|
||||
|
||||
## 매핑 구조
|
||||
|
||||
### 데이터베이스 연결
|
||||
|
||||
```
|
||||
MSSQL (192.168.0.4\PM2014) PostgreSQL (192.168.0.87:5432)
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ PM_DRUG.CD_GOODS │ │ apdb_master.apc │
|
||||
│ - DrugCode │ │ - apc (PK) │
|
||||
│ - GoodsName │ │ - product_name │
|
||||
│ - BARCODE │ │ - image_url1 │
|
||||
│ │ │ - llm_pharm (JSONB) │
|
||||
├─────────────────────────┤ └─────────────────────────┘
|
||||
│ PM_DRUG.CD_ITEM_UNIT_ │
|
||||
│ MEMBER │
|
||||
│ - DRUGCODE (FK) │
|
||||
│ - CD_CD_BARCODE ◀───────┼── APC 코드 저장 (023%로 시작)
|
||||
│ - CHANGE_DATE │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### APC 매핑 방식
|
||||
|
||||
1. `CD_ITEM_UNIT_MEMBER` 테이블에 **추가 바코드**로 APC 등록
|
||||
2. 기존 바코드는 유지, APC를 별도 레코드로 INSERT
|
||||
3. APC 코드는 `023%`로 시작 (식별자)
|
||||
|
||||
---
|
||||
|
||||
## 1:1 매핑 가능 후보
|
||||
|
||||
### ✅ 확실한 매핑 (1개)
|
||||
|
||||
| POS 제품명 | DrugCode | APC | APDB 제품명 | 이미지 |
|
||||
|------------|----------|-----|-------------|--------|
|
||||
| **제스타제(10정)** | LB000003146 | 8809720800455 | 제스타제 | ✅ 있음 |
|
||||
|
||||
### ⚠️ 검토 필요 (1개)
|
||||
|
||||
| POS 제품명 | DrugCode | APC 후보 | 비고 |
|
||||
|------------|----------|----------|------|
|
||||
| 안텔민 | S0000001 | 0230237800003 | "안텔민킹"과 "안텔민뽀삐"는 이미 별도 매핑됨. 이 제품이 무엇인지 확인 필요 |
|
||||
|
||||
### ❌ APDB에 없음 (3개)
|
||||
|
||||
| POS 제품명 | 사유 |
|
||||
|------------|------|
|
||||
| (판)클라펫정50(100정) | APDB엔 "클라펫 정"만 있음 (함량 불일치) |
|
||||
| 넥스가드xs(2~3.5kg) | 사이즈별 APC 없음 |
|
||||
| 캐치원캣(2.5~7.5kg)/고양이 | APDB에 캐치원 자체가 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 1:N 매핑 필요 제품 (27개)
|
||||
|
||||
사이즈별로 세분화된 제품들. 하나의 APDB APC에 여러 POS 제품을 매핑해야 함.
|
||||
|
||||
### 브랜드별 현황
|
||||
|
||||
| 브랜드 | POS 제품 수 | APDB 존재 | 비고 |
|
||||
|--------|-------------|-----------|------|
|
||||
| 다이로하트 | 3개 (SS/S/M) | ✅ | 다이로하트 츄어블 정 |
|
||||
| 하트세이버 | 4개 (mini/S/M/L) | ✅ | 하트세이버 플러스 츄어블 |
|
||||
| 하트웜솔루션 | 2개 (S/M) | ❌ | APDB에 없음 |
|
||||
| 리펠로 | 2개 (S/M) | ✅ | 리펠로액 (이미지 있음!) |
|
||||
| 캐치원 | 5개 (SS/S/M/L/캣) | ❌ | APDB에 없음 |
|
||||
| 셀라이트 | 5개 (SS/S/M/L/XL) | ✅ | 셀라이트 액 |
|
||||
| 넥스가드 | 2개 (xs/L) | ✅ | 넥스가드 스펙트라 |
|
||||
| 가드닐 | 3개 (S/M/L) | ✅ | 가드닐 액 |
|
||||
| 심피드 | 2개 (M/L) | ❌ | APDB에 없음 |
|
||||
| 하트캅 | 1개 | ✅ | 하트캅-츄어블 정 |
|
||||
|
||||
---
|
||||
|
||||
## APDB 통계
|
||||
|
||||
| 항목 | 수치 |
|
||||
|------|------|
|
||||
| 전체 APC | 16,326개 |
|
||||
| 이미지 있음 | 73개 (0.4%) |
|
||||
| LLM 정보 있음 | 81개 (0.5%) |
|
||||
| 동물 관련 키워드 | ~200개 |
|
||||
|
||||
⚠️ **주의:** APDB에 이미지가 거의 없음. 이미지 표시가 목적이라면 다른 소스 필요.
|
||||
|
||||
---
|
||||
|
||||
## 매핑 스크립트
|
||||
|
||||
### 매핑 후보 찾기
|
||||
```bash
|
||||
python backend/scripts/batch_apc_matching.py
|
||||
```
|
||||
|
||||
### 1:1 매핑 가능 후보 추출
|
||||
```bash
|
||||
python backend/scripts/find_1to1_candidates.py
|
||||
```
|
||||
|
||||
### 매핑 실행 (수동)
|
||||
```python
|
||||
# backend/scripts/batch_insert_apc.py 참고
|
||||
MAPPINGS = [
|
||||
('제스타제(10정)', 'LB000003146', '8809720800455'),
|
||||
]
|
||||
```
|
||||
|
||||
### INSERT 쿼리 예시
|
||||
```sql
|
||||
INSERT INTO CD_ITEM_UNIT_MEMBER (
|
||||
DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT,
|
||||
CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE
|
||||
) VALUES (
|
||||
'LB000003146', -- DrugCode
|
||||
'015', -- 단위코드
|
||||
1.0, -- 단위명
|
||||
<기존값>, -- CD_MY_UNIT (기존 레코드에서 복사)
|
||||
<기존값>, -- CD_IN_UNIT (기존 레코드에서 복사)
|
||||
'8809720800455', -- APC 바코드
|
||||
'',
|
||||
'20260302' -- 변경일자
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. **제스타제** 1:1 매핑 실행
|
||||
2. **안텔민(S0000001)** 제품 확인 후 결정
|
||||
3. 1:N 매핑 정책 결정 (사이즈별 제품 → 동일 APC?)
|
||||
4. 이미지 소스 대안 검토 (필요시)
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `backend/db/dbsetup.py` - DB 연결 설정
|
||||
- `backend/scripts/batch_apc_matching.py` - 매칭 후보 찾기
|
||||
- `backend/scripts/batch_insert_apc.py` - 매핑 실행
|
||||
- `backend/scripts/find_1to1_candidates.py` - 1:1 후보 추출
|
||||
- `backend/app.py` - `_get_animal_drugs()`, `_get_animal_drug_rag()`
|
||||
Loading…
Reference in New Issue
Block a user