Compare commits

...

10 Commits

Author SHA1 Message Date
thug0bin
1cebb02ec6 feat: 반려동물 등록 기능 및 확장 마이페이지 추가
- pets 테이블 추가 (이름, 종류, 품종, 사진 등)
- 반려동물 CRUD API (/api/pets)
- 확장 마이페이지 (/mypage) - 카카오 로그인 기반
- 기존 마이페이지에 퀵 메뉴 추가 (반려동물/쿠폰/구매내역/내정보)
- 카카오 로그인 시 세션에 user_id 저장
- 동물약 APC 매핑 가이드 문서 추가
2026-03-02 13:56:22 +09:00
thug0bin
f102f6b42e feat: 대시보드 조제 모달에도 AI 상호작용 체크 버튼 추가 2026-02-28 13:50:02 +09:00
thug0bin
16adca3646 feat: KIMS 상호작용 로그 뷰어 페이지 추가 (/admin/kims-logs) 2026-02-28 13:38:47 +09:00
thug0bin
fbe7dde4ce feat: KIMS API 호출 SQLite 로깅 (AI 학습용 데이터 수집) 2026-02-28 13:32:53 +09:00
thug0bin
8c20c8b8db fix: KIMS 심각도 매핑 수정 (SeverityDesc 사용) + 상호작용 약품 pill 색상 강조 2026-02-28 13:29:53 +09:00
thug0bin
67e576736d fix: KIMS API에 DrugCode 직접 사용 (BASECODE 조인 제거) 2026-02-28 13:22:26 +09:00
thug0bin
4c0cd68267 fix: KIMS 코드 조회 쿼리 최적화 (중복 제거) 2026-02-28 13:20:31 +09:00
thug0bin
68dcb919e4 feat: KIMS 약물 상호작용 체크 기능 추가 (조제 탭 버튼 + 모달) 2026-02-28 13:15:31 +09:00
thug0bin
6a786ff042 feat: 제품 검색에 분류 뱃지 + 도매상 재고 추가 (PostgreSQL 방어적 lazy fetch) 2026-02-28 12:48:58 +09:00
thug0bin
4c93ee038a feat: 챗봇 관련 제품에 분류 뱃지 추가 (내부구충제, 심장사상충약 등) 2026-02-28 12:32:03 +09:00
13 changed files with 3239 additions and 29 deletions

View File

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

View File

@ -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
View 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 초기화 완료!")

View 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);

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@ -999,6 +999,10 @@
`;
}).join('');
// 약품 코드 배열 (상호작용 체크용)
const drugCodes = (rx.items || []).map(item => item.drug_code).filter(c => c);
const drugCodesJson = JSON.stringify(drugCodes).replace(/"/g, '&quot;');
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 애니메이션 라이브러리 (로컬) -->

View 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>

View File

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

View File

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

View File

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

View 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>

View 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()`