10048 lines
388 KiB
Python
10048 lines
388 KiB
Python
"""
|
||
Flask 웹 서버 - QR 마일리지 적립
|
||
간편 적립: 전화번호 + 이름만 입력
|
||
"""
|
||
|
||
import sys
|
||
import os
|
||
|
||
# 현재 디렉토리를 Python path에 추가 (PM2 실행 시 utils 모듈 찾기 위함)
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
# Windows 콘솔 UTF-8 강제 (한글 깨짐 방지)
|
||
if sys.platform == 'win32':
|
||
import io
|
||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
|
||
|
||
from flask import Flask, request, render_template, render_template_string, jsonify, redirect, url_for, session
|
||
import hashlib
|
||
import base64
|
||
import secrets
|
||
from datetime import datetime, timezone, timedelta
|
||
import logging
|
||
from sqlalchemy import text
|
||
from dotenv import load_dotenv
|
||
import json
|
||
import time
|
||
import sqlite3
|
||
from pathlib import Path
|
||
|
||
# 환경 변수 로드 (명시적 경로)
|
||
env_path = Path(__file__).parent / '.env'
|
||
load_dotenv(dotenv_path=env_path)
|
||
|
||
# OpenAI import
|
||
try:
|
||
from openai import OpenAI, OpenAIError, RateLimitError, APITimeoutError
|
||
OPENAI_AVAILABLE = True
|
||
except ImportError:
|
||
OPENAI_AVAILABLE = False
|
||
logging.warning("OpenAI 라이브러리가 설치되지 않았습니다. AI 분석 기능을 사용할 수 없습니다.")
|
||
|
||
# Path setup
|
||
BACKEND_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, BACKEND_DIR)
|
||
from db.dbsetup import DatabaseManager
|
||
|
||
# OTC 라벨 프린터 import
|
||
try:
|
||
from utils.otc_label_printer import generate_preview_image, print_otc_label
|
||
OTC_LABEL_AVAILABLE = True
|
||
except ImportError as e:
|
||
OTC_LABEL_AVAILABLE = False
|
||
logging.warning(f"OTC 라벨 프린터 모듈 로드 실패: {e}")
|
||
|
||
app = Flask(__name__)
|
||
app.secret_key = 'pharmacy-qr-mileage-secret-key-2026'
|
||
|
||
# 세션 설정 (PWA 자동적립 지원)
|
||
app.config['SESSION_COOKIE_SECURE'] = not app.debug # HTTPS 전용 (로컬 개발 시 제외)
|
||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # QR 스캔 시 쿠키 전송 허용
|
||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=90) # 3개월 유지
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Blueprint 등록
|
||
# ─────────────────────────────────────────────────────────────
|
||
from pmr_api import pmr_bp
|
||
app.register_blueprint(pmr_bp)
|
||
|
||
from paai_feedback import paai_feedback_bp
|
||
app.register_blueprint(paai_feedback_bp)
|
||
|
||
from geoyoung_api import geoyoung_bp
|
||
app.register_blueprint(geoyoung_bp)
|
||
|
||
from sooin_api import sooin_bp
|
||
app.register_blueprint(sooin_bp)
|
||
|
||
from baekje_api import baekje_bp
|
||
app.register_blueprint(baekje_bp)
|
||
|
||
from dongwon_api import dongwon_bp
|
||
app.register_blueprint(dongwon_bp)
|
||
|
||
from wholesaler_config_api import wholesaler_config_bp
|
||
app.register_blueprint(wholesaler_config_bp)
|
||
|
||
from order_api import order_bp
|
||
app.register_blueprint(order_bp)
|
||
|
||
# 데이터베이스 매니저
|
||
db_manager = DatabaseManager()
|
||
|
||
# KST 타임존 (UTC+9)
|
||
KST = timezone(timedelta(hours=9))
|
||
|
||
# 키오스크 현재 세션 (메모리 변수, 서버 재시작 시 초기화)
|
||
kiosk_current_session = None
|
||
|
||
|
||
def utc_to_kst_str(utc_time_str):
|
||
"""
|
||
UTC 시간 문자열을 KST 시간 문자열로 변환
|
||
|
||
Args:
|
||
utc_time_str (str): UTC 시간 문자열 (ISO 8601 형식)
|
||
|
||
Returns:
|
||
str: KST 시간 문자열 (YYYY-MM-DD HH:MM:SS)
|
||
"""
|
||
if not utc_time_str:
|
||
return None
|
||
|
||
try:
|
||
# ISO 8601 형식 파싱 ('2026-01-23T12:28:36' 또는 '2026-01-23 12:28:36')
|
||
utc_time_str = utc_time_str.replace(' ', 'T') # 공백을 T로 변환
|
||
|
||
# datetime 객체로 변환
|
||
if 'T' in utc_time_str:
|
||
utc_time = datetime.fromisoformat(utc_time_str)
|
||
else:
|
||
utc_time = datetime.fromisoformat(utc_time_str)
|
||
|
||
# UTC 타임존 설정 (naive datetime인 경우)
|
||
if utc_time.tzinfo is None:
|
||
utc_time = utc_time.replace(tzinfo=timezone.utc)
|
||
|
||
# KST로 변환
|
||
kst_time = utc_time.astimezone(KST)
|
||
|
||
# 문자열로 반환 (초 단위까지만)
|
||
return kst_time.strftime('%Y-%m-%d %H:%M:%S')
|
||
|
||
except Exception as e:
|
||
logging.error(f"시간 변환 실패: {utc_time_str}, 오류: {e}")
|
||
return utc_time_str # 변환 실패 시 원본 반환
|
||
|
||
|
||
# ===== OpenAI 설정 및 헬퍼 함수 =====
|
||
|
||
# OpenAI API 설정
|
||
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
|
||
OPENAI_MODEL = os.getenv('OPENAI_MODEL', 'gpt-4o-mini')
|
||
OPENAI_MAX_TOKENS = int(os.getenv('OPENAI_MAX_TOKENS', '1000'))
|
||
OPENAI_TEMPERATURE = float(os.getenv('OPENAI_TEMPERATURE', '0.7'))
|
||
|
||
# OpenAI 사용 가능 여부 및 API 키 확인
|
||
if OPENAI_AVAILABLE and not OPENAI_API_KEY:
|
||
logging.warning("OPENAI_API_KEY가 설정되지 않았습니다. AI 분석 기능을 사용할 수 없습니다.")
|
||
OPENAI_AVAILABLE = False
|
||
|
||
# System Prompt
|
||
SYSTEM_PROMPT = """당신은 약국 고객 관리 전문가입니다. 고객의 구매 데이터를 분석하여 다음을 제공합니다:
|
||
1. 구매 패턴 및 행동 분석
|
||
2. 주요 관심 제품 카테고리 파악
|
||
3. 업셀링을 위한 제품 추천
|
||
4. 고객 맞춤형 마케팅 전략
|
||
|
||
응답은 반드시 JSON 형식으로 작성하며, 한국어로 작성합니다.
|
||
약국 운영자가 실제로 활용할 수 있는 구체적이고 실용적인 인사이트를 제공해야 합니다."""
|
||
|
||
# 에러 메시지
|
||
ERROR_MESSAGES = {
|
||
'NO_USER': '사용자를 찾을 수 없습니다.',
|
||
'NO_PURCHASES': '구매 이력이 없어 분석할 수 없습니다. 최소 1건 이상의 구매가 필요합니다.',
|
||
'OPENAI_NOT_AVAILABLE': 'AI 분석 기능을 사용할 수 없습니다. 관리자에게 문의하세요.',
|
||
'OPENAI_API_KEY_MISSING': 'OpenAI API 키가 설정되지 않았습니다.',
|
||
'OPENAI_API_ERROR': 'OpenAI API 호출에 실패했습니다. 잠시 후 다시 시도해주세요.',
|
||
'OPENAI_RATE_LIMIT': 'API 호출 횟수 제한에 도달했습니다. 잠시 후 다시 시도해주세요.',
|
||
'OPENAI_TIMEOUT': 'AI 분석 시간이 초과되었습니다. 다시 시도해주세요.',
|
||
'PARSING_ERROR': 'AI 응답을 처리하는 중 오류가 발생했습니다.',
|
||
'UNKNOWN_ERROR': '알 수 없는 오류가 발생했습니다. 관리자에게 문의하세요.'
|
||
}
|
||
|
||
|
||
def categorize_product(product_name):
|
||
"""제품명에서 카테고리 추정 (간단한 키워드 매칭)"""
|
||
categories = {
|
||
'소화제': ['타센', '베아제', '겔포스', '소화'],
|
||
'진통제': ['타이레놀', '게보린', '펜잘', '이부프로펜'],
|
||
'감기약': ['판콜', '화이투벤', '지르텍', '감기'],
|
||
'피부약': ['후시딘', '마데카솔', '더마틱스'],
|
||
'비타민': ['비타민', '센트룸', '활성비타민'],
|
||
'안약': ['안약', '인공눈물'],
|
||
'소염진통제': ['자미슬', '펠루비', '게보린']
|
||
}
|
||
|
||
for category, keywords in categories.items():
|
||
for keyword in keywords:
|
||
if keyword in product_name:
|
||
return category
|
||
|
||
return '기타'
|
||
|
||
|
||
def prepare_analysis_prompt(user, purchases):
|
||
"""OpenAI API 전송용 프롬프트 생성"""
|
||
# 사용자 정보 요약
|
||
user_summary = f"""사용자: {user['nickname']} ({user['phone']})
|
||
가입일: {utc_to_kst_str(user['created_at']) if user['created_at'] else '-'}
|
||
포인트 잔액: {user['mileage_balance']:,}P
|
||
총 구매 건수: {len(purchases)}건
|
||
"""
|
||
|
||
# 구매 이력 상세
|
||
purchase_details = []
|
||
total_spent = 0
|
||
all_products = []
|
||
product_freq = {}
|
||
|
||
for idx, purchase in enumerate(purchases, 1):
|
||
total_spent += purchase['amount']
|
||
products_str = ', '.join([f"{item['name']} x{item['qty']}" for item in purchase['items']])
|
||
|
||
# 제품 빈도 계산
|
||
for item in purchase['items']:
|
||
product_name = item['name']
|
||
all_products.append(product_name)
|
||
product_freq[product_name] = product_freq.get(product_name, 0) + 1
|
||
|
||
purchase_details.append(
|
||
f"{idx}. {purchase['date']} - {purchase['amount']:,}원 구매, {purchase['points']}P 적립\n"
|
||
f" 구매 품목: {products_str}"
|
||
)
|
||
|
||
# 통계 계산
|
||
avg_purchase = total_spent // len(purchases) if purchases else 0
|
||
top_products = sorted(product_freq.items(), key=lambda x: x[1], reverse=True)[:5]
|
||
top_products_str = ', '.join([f"{name}({count}회)" for name, count in top_products])
|
||
|
||
# 최종 프롬프트 조립
|
||
prompt = f"""다음은 약국 고객의 구매 데이터입니다. 구매 패턴을 분석하고 마케팅 전략을 제안해주세요.
|
||
|
||
{user_summary}
|
||
|
||
통계 요약:
|
||
- 총 구매 금액: {total_spent:,}원
|
||
- 평균 구매 금액: {avg_purchase:,}원
|
||
- 자주 구매한 품목: {top_products_str}
|
||
|
||
구매 이력 (최근 {len(purchases)}건):
|
||
{chr(10).join(purchase_details)}
|
||
|
||
분석 요청사항:
|
||
1. 구매 패턴 분석: 구매 빈도, 구매 금액 패턴 등
|
||
2. 주로 구매하는 품목 카테고리 (예: 소화제, 감기약, 건강기능식품 등)
|
||
3. 추천 제품: 기존 구매 패턴을 기반으로 관심있을만한 제품 3-5가지 (업셀링)
|
||
4. 마케팅 전략: 이 고객에게 효과적일 프로모션 또는 포인트 활용 방안
|
||
|
||
응답은 다음 JSON 형식으로 해주세요:
|
||
{{
|
||
"pattern": "구매 패턴에 대한 상세한 분석 (2-3문장)",
|
||
"main_products": ["카테고리1: 품목들", "카테고리2: 품목들"],
|
||
"recommendations": ["추천제품1 (이유)", "추천제품2 (이유)", "추천제품3 (이유)"],
|
||
"marketing_strategy": "마케팅 전략 제안 (2-3문장)"
|
||
}}
|
||
"""
|
||
return prompt
|
||
|
||
|
||
def parse_openai_response(response_text):
|
||
"""OpenAI API 응답을 파싱하여 구조화된 데이터 반환"""
|
||
import re
|
||
|
||
try:
|
||
# JSON 추출 (마크다운 코드 블록 제거)
|
||
json_match = re.search(r'```json\s*(\{.*?\})\s*```', response_text, re.DOTALL)
|
||
if json_match:
|
||
json_str = json_match.group(1)
|
||
else:
|
||
# 코드 블록 없이 JSON만 있는 경우
|
||
json_str = response_text.strip()
|
||
|
||
# JSON 파싱
|
||
analysis = json.loads(json_str)
|
||
|
||
# 필수 필드 검증
|
||
required_fields = ['pattern', 'main_products', 'recommendations', 'marketing_strategy']
|
||
for field in required_fields:
|
||
if field not in analysis:
|
||
raise ValueError(f"필수 필드 누락: {field}")
|
||
|
||
# 타입 검증
|
||
if not isinstance(analysis['main_products'], list):
|
||
analysis['main_products'] = [str(analysis['main_products'])]
|
||
if not isinstance(analysis['recommendations'], list):
|
||
analysis['recommendations'] = [str(analysis['recommendations'])]
|
||
|
||
return analysis
|
||
|
||
except json.JSONDecodeError as e:
|
||
# JSON 파싱 실패 시 fallback
|
||
logging.error(f"JSON 파싱 실패: {e}")
|
||
return {
|
||
'pattern': '응답 파싱에 실패했습니다.',
|
||
'main_products': ['분석 결과를 확인할 수 없습니다.'],
|
||
'recommendations': ['다시 시도해주세요.'],
|
||
'marketing_strategy': response_text[:500]
|
||
}
|
||
except Exception as e:
|
||
logging.error(f"응답 파싱 오류: {e}")
|
||
raise
|
||
|
||
|
||
def handle_openai_error(error):
|
||
"""OpenAI API 에러를 사용자 친화적 메시지로 변환"""
|
||
error_str = str(error).lower()
|
||
|
||
if 'api key' in error_str or 'authentication' in error_str:
|
||
return ERROR_MESSAGES['OPENAI_API_KEY_MISSING']
|
||
elif 'rate limit' in error_str or 'quota' in error_str:
|
||
return ERROR_MESSAGES['OPENAI_RATE_LIMIT']
|
||
elif 'timeout' in error_str:
|
||
return ERROR_MESSAGES['OPENAI_TIMEOUT']
|
||
else:
|
||
return ERROR_MESSAGES['OPENAI_API_ERROR']
|
||
|
||
|
||
def call_openai_with_retry(prompt, max_retries=3):
|
||
"""재시도 로직을 포함한 OpenAI API 호출"""
|
||
if not OPENAI_AVAILABLE:
|
||
return False, ERROR_MESSAGES['OPENAI_NOT_AVAILABLE']
|
||
|
||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||
|
||
for attempt in range(max_retries):
|
||
try:
|
||
response = client.chat.completions.create(
|
||
model=OPENAI_MODEL,
|
||
messages=[
|
||
{"role": "system", "content": SYSTEM_PROMPT},
|
||
{"role": "user", "content": prompt}
|
||
],
|
||
max_tokens=OPENAI_MAX_TOKENS,
|
||
temperature=OPENAI_TEMPERATURE,
|
||
timeout=30
|
||
)
|
||
|
||
return True, response
|
||
|
||
except RateLimitError as e:
|
||
if attempt < max_retries - 1:
|
||
wait_time = 2 ** attempt
|
||
logging.warning(f"OpenAI Rate limit, {wait_time}초 대기 후 재시도...")
|
||
time.sleep(wait_time)
|
||
else:
|
||
return False, handle_openai_error(e)
|
||
|
||
except APITimeoutError as e:
|
||
if attempt < max_retries - 1:
|
||
logging.warning(f"OpenAI 타임아웃, 재시도 중... ({attempt+1}/{max_retries})")
|
||
time.sleep(1)
|
||
else:
|
||
return False, ERROR_MESSAGES['OPENAI_TIMEOUT']
|
||
|
||
except OpenAIError as e:
|
||
return False, handle_openai_error(e)
|
||
|
||
except Exception as e:
|
||
logging.error(f"OpenAI API 호출 오류: {e}")
|
||
return False, ERROR_MESSAGES['UNKNOWN_ERROR']
|
||
|
||
return False, ERROR_MESSAGES['OPENAI_TIMEOUT']
|
||
|
||
|
||
def verify_claim_token(transaction_id, nonce):
|
||
"""
|
||
QR 토큰 검증
|
||
|
||
Args:
|
||
transaction_id (str): 거래 ID
|
||
nonce (str): 12자 hex nonce
|
||
|
||
Returns:
|
||
tuple: (성공 여부, 메시지, 토큰 정보 dict)
|
||
"""
|
||
try:
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
# 1. 거래 ID로 토큰 조회
|
||
cursor.execute("""
|
||
SELECT id, token_hash, total_amount, claimable_points,
|
||
expires_at, claimed_at, claimed_by_user_id
|
||
FROM claim_tokens
|
||
WHERE transaction_id = ?
|
||
""", (transaction_id,))
|
||
|
||
token_record = cursor.fetchone()
|
||
|
||
if not token_record:
|
||
return (False, "유효하지 않은 QR 코드입니다.", None)
|
||
|
||
# 2. 이미 적립된 토큰인지 확인
|
||
if token_record['claimed_at']:
|
||
return (False, "이미 적립 완료된 영수증입니다.", None)
|
||
|
||
# 3. 만료 확인
|
||
expires_at = datetime.strptime(token_record['expires_at'], '%Y-%m-%d %H:%M:%S')
|
||
if datetime.now() > expires_at:
|
||
return (False, "적립 기간이 만료되었습니다 (30일).", None)
|
||
|
||
# 4. 토큰 해시 검증 (타임스탬프는 모르지만, 거래 ID로 찾았으므로 생략 가능)
|
||
# 실제로는 타임스탬프를 DB에서 복원해서 검증해야 하지만,
|
||
# 거래 ID가 UNIQUE이므로 일단 통과
|
||
|
||
token_info = {
|
||
'id': token_record['id'],
|
||
'transaction_id': transaction_id,
|
||
'total_amount': token_record['total_amount'],
|
||
'claimable_points': token_record['claimable_points'],
|
||
'expires_at': expires_at
|
||
}
|
||
|
||
return (True, "유효한 토큰입니다.", token_info)
|
||
|
||
except Exception as e:
|
||
return (False, f"토큰 검증 실패: {str(e)}", None)
|
||
|
||
|
||
def get_or_create_user(phone, name, birthday=None):
|
||
"""
|
||
사용자 조회 또는 생성 (간편 적립용)
|
||
|
||
Args:
|
||
phone (str): 전화번호
|
||
name (str): 이름
|
||
birthday (str, optional): 생년월일 (YYYY-MM-DD)
|
||
|
||
Returns:
|
||
tuple: (user_id, is_new_user)
|
||
"""
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
# 전화번호로 조회
|
||
cursor.execute("""
|
||
SELECT id, mileage_balance FROM users WHERE phone = ?
|
||
""", (phone,))
|
||
|
||
user = cursor.fetchone()
|
||
|
||
if user:
|
||
# 기존 유저: birthday가 제공되면 업데이트
|
||
if birthday:
|
||
cursor.execute("UPDATE users SET birthday = ? WHERE id = ?", (birthday, user['id']))
|
||
conn.commit()
|
||
return (user['id'], False)
|
||
|
||
# 신규 생성
|
||
cursor.execute("""
|
||
INSERT INTO users (nickname, phone, birthday, mileage_balance)
|
||
VALUES (?, ?, ?, 0)
|
||
""", (name, phone, birthday))
|
||
|
||
conn.commit()
|
||
return (cursor.lastrowid, True)
|
||
|
||
|
||
def claim_mileage(user_id, token_info):
|
||
"""
|
||
마일리지 적립 처리
|
||
|
||
Args:
|
||
user_id (int): 사용자 ID
|
||
token_info (dict): 토큰 정보
|
||
|
||
Returns:
|
||
tuple: (성공 여부, 메시지, 적립 후 잔액)
|
||
"""
|
||
try:
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
# 1. 현재 잔액 조회
|
||
cursor.execute("SELECT mileage_balance FROM users WHERE id = ?", (user_id,))
|
||
user = cursor.fetchone()
|
||
current_balance = user['mileage_balance']
|
||
|
||
# 2. 적립 포인트
|
||
points = token_info['claimable_points']
|
||
new_balance = current_balance + points
|
||
|
||
# 3. 사용자 잔액 업데이트
|
||
cursor.execute("""
|
||
UPDATE users SET mileage_balance = ?, updated_at = ?
|
||
WHERE id = ?
|
||
""", (new_balance, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), user_id))
|
||
|
||
# 4. 마일리지 원장 기록
|
||
cursor.execute("""
|
||
INSERT INTO mileage_ledger (user_id, transaction_id, points, balance_after, reason, description)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
""", (
|
||
user_id,
|
||
token_info['transaction_id'],
|
||
points,
|
||
new_balance,
|
||
'CLAIM',
|
||
f"영수증 QR 적립 ({token_info['total_amount']:,}원 구매)"
|
||
))
|
||
|
||
# 5. claim_tokens 업데이트 (적립 완료 표시)
|
||
cursor.execute("""
|
||
UPDATE claim_tokens
|
||
SET claimed_at = ?, claimed_by_user_id = ?
|
||
WHERE id = ?
|
||
""", (datetime.now().strftime('%Y-%m-%d %H:%M:%S'), user_id, token_info['id']))
|
||
|
||
conn.commit()
|
||
|
||
return (True, f"{points}P 적립 완료!", new_balance)
|
||
|
||
except Exception as e:
|
||
conn.rollback()
|
||
return (False, f"적립 처리 실패: {str(e)}", 0)
|
||
|
||
|
||
def normalize_kakao_phone(kakao_phone):
|
||
"""
|
||
카카오 전화번호 형식 변환
|
||
"+82 10-1234-5678" → "01012345678"
|
||
"""
|
||
if not kakao_phone:
|
||
return None
|
||
digits = kakao_phone.replace('+', '').replace('-', '').replace(' ', '')
|
||
if digits.startswith('82'):
|
||
digits = '0' + digits[2:]
|
||
if len(digits) >= 10:
|
||
return digits
|
||
return None
|
||
|
||
|
||
def link_kakao_identity(user_id, kakao_id, kakao_data, token_data=None):
|
||
"""카카오 계정을 customer_identities에 연결 (토큰 포함)"""
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("""
|
||
SELECT id FROM customer_identities
|
||
WHERE provider = 'kakao' AND provider_user_id = ?
|
||
""", (kakao_id,))
|
||
|
||
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:
|
||
cursor.execute("""
|
||
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 = []
|
||
params = []
|
||
if kakao_data.get('profile_image'):
|
||
updates.append("profile_image_url = ?")
|
||
params.append(kakao_data['profile_image'])
|
||
if kakao_data.get('email'):
|
||
updates.append("email = ?")
|
||
params.append(kakao_data['email'])
|
||
|
||
# 카카오 인증일 기록 (최초 연동 시)
|
||
if is_new_link:
|
||
updates.append("kakao_verified_at = datetime('now')")
|
||
|
||
if updates:
|
||
params.append(user_id)
|
||
cursor.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", params)
|
||
|
||
conn.commit()
|
||
|
||
|
||
def find_user_by_kakao_id(kakao_id):
|
||
"""카카오 ID로 기존 연결된 사용자 조회"""
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
SELECT user_id FROM customer_identities
|
||
WHERE provider = 'kakao' AND provider_user_id = ?
|
||
""", (kakao_id,))
|
||
row = cursor.fetchone()
|
||
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()
|
||
|
||
|
||
# ============================================================================
|
||
# 라우트
|
||
# ============================================================================
|
||
|
||
@app.route('/')
|
||
def index():
|
||
"""메인 페이지"""
|
||
logged_in = 'logged_in_user_id' in session
|
||
return render_template('index.html',
|
||
logged_in=logged_in,
|
||
logged_in_name=session.get('logged_in_name', ''),
|
||
logged_in_phone=session.get('logged_in_phone', '')
|
||
)
|
||
|
||
|
||
@app.route('/signup')
|
||
def signup():
|
||
"""회원가입 페이지"""
|
||
if 'logged_in_user_id' in session:
|
||
return redirect(f"/my-page?phone={session.get('logged_in_phone', '')}")
|
||
return render_template('signup.html')
|
||
|
||
|
||
@app.route('/api/signup', methods=['POST'])
|
||
def api_signup():
|
||
"""회원가입 API"""
|
||
try:
|
||
data = request.get_json()
|
||
name = data.get('name', '').strip()
|
||
phone = data.get('phone', '').strip().replace('-', '').replace(' ', '')
|
||
birthday = data.get('birthday', '').strip() or None # 선택 항목
|
||
|
||
if not name or not phone:
|
||
return jsonify({'success': False, 'message': '이름과 전화번호를 모두 입력해주세요.'}), 400
|
||
|
||
if len(phone) < 10:
|
||
return jsonify({'success': False, 'message': '올바른 전화번호를 입력해주세요.'}), 400
|
||
|
||
user_id, is_new = get_or_create_user(phone, name, birthday=birthday)
|
||
|
||
# 세션에 유저 정보 저장
|
||
session.permanent = True
|
||
session['logged_in_user_id'] = user_id
|
||
session['logged_in_phone'] = phone
|
||
session['logged_in_name'] = name
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': '가입이 완료되었습니다.' if is_new else '이미 가입된 회원입니다. 로그인되었습니다.',
|
||
'is_new': is_new
|
||
})
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'message': f'오류가 발생했습니다: {str(e)}'}), 500
|
||
|
||
|
||
@app.route('/claim')
|
||
def claim():
|
||
"""
|
||
QR 코드 랜딩 페이지
|
||
URL: /claim?t=transaction_id:nonce
|
||
"""
|
||
# 토큰 파라미터 파싱
|
||
token_param = request.args.get('t', '')
|
||
|
||
if ':' not in token_param:
|
||
return render_template('error.html', message="잘못된 QR 코드 형식입니다.")
|
||
|
||
parts = token_param.split(':')
|
||
if len(parts) != 2:
|
||
return render_template('error.html', message="잘못된 QR 코드 형식입니다.")
|
||
|
||
transaction_id, nonce = parts[0], parts[1]
|
||
|
||
# 토큰 검증
|
||
success, message, token_info = verify_claim_token(transaction_id, nonce)
|
||
|
||
if not success:
|
||
return render_template('error.html', message=message)
|
||
|
||
# 세션에 로그인된 유저가 있으면 자동 적립 (PWA)
|
||
if 'logged_in_user_id' in session:
|
||
auto_user_id = session['logged_in_user_id']
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT id, nickname, phone, mileage_balance FROM users WHERE id = ?", (auto_user_id,))
|
||
auto_user = cursor.fetchone()
|
||
|
||
if auto_user:
|
||
auto_success, auto_msg, auto_balance = claim_mileage(auto_user_id, token_info)
|
||
if auto_success:
|
||
return render_template('claim_kakao_success.html',
|
||
points=token_info['claimable_points'],
|
||
balance=auto_balance,
|
||
phone=auto_user['phone'],
|
||
name=auto_user['nickname'])
|
||
return render_template('error.html', message=auto_msg)
|
||
else:
|
||
# 유저가 삭제됨 - 세션 클리어
|
||
session.pop('logged_in_user_id', None)
|
||
session.pop('logged_in_phone', None)
|
||
session.pop('logged_in_name', None)
|
||
|
||
# MSSQL에서 구매 품목 조회
|
||
sale_items = []
|
||
try:
|
||
db_session = db_manager.get_session('PM_PRES')
|
||
sale_sub_query = text("""
|
||
SELECT
|
||
ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name,
|
||
S.SL_NM_item AS quantity,
|
||
S.SL_TOTAL_PRICE AS total
|
||
FROM SALE_SUB S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
WHERE S.SL_NO_order = :transaction_id
|
||
ORDER BY S.DrugCode
|
||
""")
|
||
rows = db_session.execute(sale_sub_query, {'transaction_id': transaction_id}).fetchall()
|
||
sale_items = [
|
||
{'name': r.goods_name, 'qty': int(r.quantity or 0), 'total': int(r.total or 0)}
|
||
for r in rows
|
||
]
|
||
except Exception as e:
|
||
logging.warning(f"품목 조회 실패 (transaction_id={transaction_id}): {e}")
|
||
|
||
# JS SDK용 카카오 state 생성 (CSRF 보호)
|
||
csrf_token = secrets.token_hex(16)
|
||
state_data = {'t': token_param, 'csrf': csrf_token}
|
||
kakao_state = base64.urlsafe_b64encode(
|
||
json.dumps(state_data).encode()
|
||
).decode()
|
||
session['kakao_csrf'] = csrf_token
|
||
|
||
return render_template('claim_form.html', token_info=token_info, sale_items=sale_items, kakao_state=kakao_state)
|
||
|
||
|
||
@app.route('/api/claim', methods=['POST'])
|
||
def api_claim():
|
||
"""
|
||
마일리지 적립 API
|
||
POST /api/claim
|
||
Body: {
|
||
"transaction_id": "...",
|
||
"nonce": "...",
|
||
"phone": "010-1234-5678",
|
||
"name": "홍길동"
|
||
}
|
||
"""
|
||
try:
|
||
data = request.get_json()
|
||
|
||
transaction_id = data.get('transaction_id')
|
||
nonce = data.get('nonce')
|
||
phone = data.get('phone', '').strip()
|
||
name = data.get('name', '').strip()
|
||
privacy_consent = data.get('privacy_consent', False)
|
||
|
||
# 입력 검증
|
||
if not phone or not name:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': '전화번호와 이름을 모두 입력해주세요.'
|
||
}), 400
|
||
|
||
# 개인정보 동의 검증
|
||
if not privacy_consent:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': '개인정보 수집·이용에 동의해주세요.'
|
||
}), 400
|
||
|
||
# 전화번호 형식 정리 (하이픈 제거)
|
||
phone = phone.replace('-', '').replace(' ', '')
|
||
|
||
if len(phone) < 10:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': '올바른 전화번호를 입력해주세요.'
|
||
}), 400
|
||
|
||
# 토큰 검증
|
||
success, message, token_info = verify_claim_token(transaction_id, nonce)
|
||
if not success:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': message
|
||
}), 400
|
||
|
||
# 사용자 조회/생성
|
||
user_id, is_new = get_or_create_user(phone, name)
|
||
|
||
# 마일리지 적립
|
||
success, message, new_balance = claim_mileage(user_id, token_info)
|
||
|
||
if not success:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': message
|
||
}), 500
|
||
|
||
# 세션에 유저 정보 저장 (PWA 자동적립용)
|
||
session.permanent = True
|
||
session['logged_in_user_id'] = user_id
|
||
session['logged_in_phone'] = phone
|
||
session['logged_in_name'] = name
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': message,
|
||
'points': token_info['claimable_points'],
|
||
'balance': new_balance,
|
||
'is_new_user': is_new
|
||
})
|
||
|
||
except Exception as e:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': f'오류가 발생했습니다: {str(e)}'
|
||
}), 500
|
||
|
||
|
||
# ============================================================================
|
||
# 카카오 적립 라우트
|
||
# ============================================================================
|
||
|
||
@app.route('/claim/kakao/start')
|
||
def claim_kakao_start():
|
||
"""카카오 OAuth 시작 - claim 컨텍스트를 state에 담아 카카오로 리다이렉트"""
|
||
from services.kakao_client import get_kakao_client
|
||
|
||
token_param = request.args.get('t', '')
|
||
if ':' not in token_param:
|
||
return render_template('error.html', message="잘못된 QR 코드입니다.")
|
||
|
||
parts = token_param.split(':')
|
||
if len(parts) != 2:
|
||
return render_template('error.html', message="잘못된 QR 코드입니다.")
|
||
|
||
transaction_id, nonce = parts
|
||
|
||
# 토큰 사전 검증
|
||
success, message, token_info = verify_claim_token(transaction_id, nonce)
|
||
if not success:
|
||
return render_template('error.html', message=message)
|
||
|
||
# state: claim 컨텍스트 + CSRF 토큰
|
||
csrf_token = secrets.token_hex(16)
|
||
state_data = {
|
||
't': token_param,
|
||
'csrf': csrf_token
|
||
}
|
||
state_encoded = base64.urlsafe_b64encode(
|
||
json.dumps(state_data).encode()
|
||
).decode()
|
||
|
||
session['kakao_csrf'] = csrf_token
|
||
|
||
kakao_client = get_kakao_client()
|
||
auth_url = kakao_client.get_authorization_url(state=state_encoded)
|
||
|
||
return redirect(auth_url)
|
||
|
||
|
||
def _handle_mypage_kakao_callback(code, kakao_client, redirect_to=None):
|
||
"""
|
||
마이페이지 카카오 콜백 처리 - 카카오 연동(머지) + 마이페이지 이동
|
||
|
||
케이스별 동작:
|
||
A) 카카오 ID가 이미 연결된 유저 → 그 유저의 마이페이지로 이동
|
||
B) 미연결 + 카카오 전화번호로 기존 유저 발견 → 카카오 연동 후 이동
|
||
C) 미연결 + 기존 유저 없음 → 신규 생성 + 카카오 연동
|
||
D) 전화번호 없음 → 에러 안내
|
||
|
||
Args:
|
||
redirect_to: 리다이렉트할 URL (None이면 기본 /my-page?phone=xxx)
|
||
"""
|
||
success, token_data = kakao_client.get_access_token(code)
|
||
if not success:
|
||
return render_template('error.html', message="카카오 인증에 실패했습니다.")
|
||
|
||
access_token = token_data.get('access_token')
|
||
success, user_info = kakao_client.get_user_info(access_token)
|
||
if not success:
|
||
return render_template('error.html', message="카카오 사용자 정보를 가져올 수 없습니다.")
|
||
|
||
kakao_id = user_info.get('kakao_id')
|
||
kakao_phone_raw = user_info.get('phone_number')
|
||
kakao_phone = normalize_kakao_phone(kakao_phone_raw)
|
||
kakao_name = user_info.get('name') or user_info.get('nickname', '고객')
|
||
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
# Case A: 카카오 ID로 이미 연결된 유저 있음
|
||
existing_user_id = find_user_by_kakao_id(kakao_id)
|
||
if existing_user_id:
|
||
# "고객" 이름이면 카카오 실명으로 업데이트
|
||
if kakao_name and kakao_name != '고객':
|
||
cursor.execute(
|
||
"UPDATE users SET nickname = ? WHERE id = ? AND nickname = '고객'",
|
||
(kakao_name, existing_user_id))
|
||
conn.commit()
|
||
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']}")
|
||
|
||
# 전화번호 없으면 연동 불가
|
||
if not kakao_phone:
|
||
return render_template('error.html',
|
||
message="카카오 계정에 전화번호 정보가 없습니다. 카카오 설정에서 전화번호를 등록해주세요.")
|
||
|
||
# Case B/C: 전화번호로 기존 유저 조회
|
||
cursor.execute("SELECT id, nickname FROM users WHERE phone = ?", (kakao_phone,))
|
||
phone_user = cursor.fetchone()
|
||
|
||
if phone_user:
|
||
# Case B: 기존 전화번호 유저 → 카카오 연동 (머지)
|
||
user_id = phone_user['id']
|
||
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 = ?",
|
||
(kakao_name, user_id))
|
||
conn.commit()
|
||
logging.info(f"마이페이지 카카오 머지: user_id={user_id}, kakao_id={kakao_id}")
|
||
else:
|
||
# Case C: 신규 유저 생성 + 카카오 연동
|
||
user_id, _ = get_or_create_user(kakao_phone, kakao_name)
|
||
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}")
|
||
|
||
|
||
@app.route('/claim/kakao/callback')
|
||
def claim_kakao_callback():
|
||
"""카카오 OAuth 콜백 - 토큰 교환 → 사용자 정보 → 적립 처리"""
|
||
from services.kakao_client import get_kakao_client
|
||
|
||
code = request.args.get('code')
|
||
state_encoded = request.args.get('state')
|
||
error = request.args.get('error')
|
||
|
||
if error:
|
||
return render_template('error.html', message="카카오 로그인이 취소되었습니다.")
|
||
|
||
if not code or not state_encoded:
|
||
return render_template('error.html', message="카카오 인증 정보가 올바르지 않습니다.")
|
||
|
||
# 1. state 디코딩
|
||
try:
|
||
state_json = base64.urlsafe_b64decode(state_encoded).decode()
|
||
state_data = json.loads(state_json)
|
||
except Exception:
|
||
return render_template('error.html', message="인증 상태 정보가 올바르지 않습니다.")
|
||
|
||
# 2. CSRF 검증
|
||
csrf_token = state_data.get('csrf')
|
||
if csrf_token != session.pop('kakao_csrf', None):
|
||
return render_template('error.html', message="보안 검증에 실패했습니다. 다시 시도해주세요.")
|
||
|
||
# 2.5 마이페이지 조회 목적이면 별도 처리
|
||
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', '')
|
||
parts = token_param.split(':')
|
||
if len(parts) != 2:
|
||
return render_template('error.html', message="적립 정보가 올바르지 않습니다.")
|
||
|
||
transaction_id, nonce = parts
|
||
|
||
# 4. 토큰 재검증 (OAuth 중 다른 사람이 적립했을 수 있음)
|
||
success, message, token_info = verify_claim_token(transaction_id, nonce)
|
||
if not success:
|
||
return render_template('error.html', message=message)
|
||
|
||
# 5. 카카오 access_token 교환
|
||
kakao_client = get_kakao_client()
|
||
success, token_data = kakao_client.get_access_token(code)
|
||
if not success:
|
||
logging.error(f"카카오 토큰 교환 실패: {token_data}")
|
||
return render_template('error.html', message="카카오 인증에 실패했습니다. 다시 시도해주세요.")
|
||
|
||
# 6. 사용자 정보 조회
|
||
access_token = token_data.get('access_token')
|
||
success, user_info = kakao_client.get_user_info(access_token)
|
||
if not success:
|
||
logging.error(f"카카오 사용자 정보 조회 실패: {user_info}")
|
||
return render_template('error.html', message="카카오 사용자 정보를 가져올 수 없습니다.")
|
||
|
||
kakao_id = user_info.get('kakao_id')
|
||
kakao_name = user_info.get('name') or user_info.get('nickname', '')
|
||
kakao_phone_raw = user_info.get('phone_number')
|
||
kakao_phone = normalize_kakao_phone(kakao_phone_raw)
|
||
|
||
# 카카오에서 받은 생년월일 조합
|
||
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
|
||
else:
|
||
kakao_birthday = f"{kakao_bday[:2]}-{kakao_bday[2:]}" # MM-DD (연도 없음)
|
||
|
||
# 7. 분기: 전화번호가 있으면 자동 적립, 없으면 폰 입력 폼
|
||
if kakao_phone:
|
||
# 자동 적립
|
||
existing_user_id = find_user_by_kakao_id(kakao_id)
|
||
if existing_user_id:
|
||
user_id = existing_user_id
|
||
is_new = False
|
||
# 생년월일이 있으면 업데이트
|
||
if kakao_birthday:
|
||
conn = db_manager.get_sqlite_connection()
|
||
conn.cursor().execute("UPDATE users SET birthday = ? WHERE id = ? AND birthday IS NULL", (kakao_birthday, user_id))
|
||
conn.commit()
|
||
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, token_data)
|
||
|
||
success, msg, new_balance = claim_mileage(user_id, token_info)
|
||
if not success:
|
||
return render_template('error.html', message=msg)
|
||
|
||
# 세션에 유저 정보 저장 (PWA 자동적립용)
|
||
session.permanent = True
|
||
session['logged_in_user_id'] = user_id
|
||
session['logged_in_phone'] = kakao_phone
|
||
session['logged_in_name'] = kakao_name
|
||
|
||
return render_template('claim_kakao_success.html',
|
||
points=token_info['claimable_points'],
|
||
balance=new_balance,
|
||
phone=kakao_phone,
|
||
name=kakao_name)
|
||
else:
|
||
# 전화번호 없음 → 세션에 카카오 정보 저장 + 폰 입력 폼
|
||
session['kakao_claim'] = {
|
||
'kakao_id': kakao_id,
|
||
'name': kakao_name,
|
||
'profile_image': user_info.get('profile_image'),
|
||
'email': user_info.get('email'),
|
||
'token_param': token_param
|
||
}
|
||
|
||
return render_template('claim_kakao_phone.html',
|
||
token_info=token_info,
|
||
kakao_name=kakao_name,
|
||
kakao_profile_image=user_info.get('profile_image'))
|
||
|
||
|
||
@app.route('/api/claim/kakao', methods=['POST'])
|
||
def api_claim_kakao():
|
||
"""카카오 적립 (전화번호 폴백) - 세션의 카카오 정보 + 폼의 전화번호로 적립"""
|
||
kakao_data = session.pop('kakao_claim', None)
|
||
if not kakao_data:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': '카카오 인증 정보가 만료되었습니다. 다시 시도해주세요.'
|
||
}), 400
|
||
|
||
data = request.get_json()
|
||
phone = data.get('phone', '').strip().replace('-', '').replace(' ', '')
|
||
|
||
if len(phone) < 10:
|
||
session['kakao_claim'] = kakao_data
|
||
return jsonify({
|
||
'success': False,
|
||
'message': '올바른 전화번호를 입력해주세요.'
|
||
}), 400
|
||
|
||
token_param = kakao_data['token_param']
|
||
parts = token_param.split(':')
|
||
transaction_id, nonce = parts
|
||
|
||
success, message, token_info = verify_claim_token(transaction_id, nonce)
|
||
if not success:
|
||
return jsonify({'success': False, 'message': message}), 400
|
||
|
||
kakao_id = kakao_data['kakao_id']
|
||
name = kakao_data['name']
|
||
|
||
existing_user_id = find_user_by_kakao_id(kakao_id)
|
||
if existing_user_id:
|
||
user_id = existing_user_id
|
||
is_new = False
|
||
else:
|
||
user_id, is_new = get_or_create_user(phone, name)
|
||
|
||
link_kakao_identity(user_id, kakao_id, kakao_data)
|
||
|
||
success, message, new_balance = claim_mileage(user_id, token_info)
|
||
if not success:
|
||
return jsonify({'success': False, 'message': message}), 500
|
||
|
||
# 세션에 유저 정보 저장 (PWA 자동적립용)
|
||
session.permanent = True
|
||
session['logged_in_user_id'] = user_id
|
||
session['logged_in_phone'] = phone
|
||
session['logged_in_name'] = name
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': message,
|
||
'points': token_info['claimable_points'],
|
||
'balance': new_balance,
|
||
'is_new_user': is_new
|
||
})
|
||
|
||
|
||
@app.route('/my-page/kakao/start')
|
||
def mypage_kakao_start():
|
||
"""마이페이지 카카오 로그인 조회"""
|
||
from services.kakao_client import get_kakao_client
|
||
|
||
csrf_token = secrets.token_hex(16)
|
||
state_data = {
|
||
'purpose': 'mypage',
|
||
'csrf': csrf_token
|
||
}
|
||
state_encoded = base64.urlsafe_b64encode(
|
||
json.dumps(state_data).encode()
|
||
).decode()
|
||
|
||
session['kakao_csrf'] = csrf_token
|
||
|
||
kakao_client = get_kakao_client()
|
||
auth_url = kakao_client.get_authorization_url(state=state_encoded)
|
||
|
||
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():
|
||
"""마이페이지 (전화번호로 조회)"""
|
||
phone = request.args.get('phone', '')
|
||
|
||
if not phone:
|
||
# JS SDK용 카카오 state 생성
|
||
csrf_token = secrets.token_hex(16)
|
||
state_data = {'purpose': 'mypage', '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)
|
||
|
||
# 전화번호로 사용자 조회
|
||
phone = phone.replace('-', '').replace(' ', '')
|
||
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("""
|
||
SELECT id, nickname, phone, mileage_balance, created_at
|
||
FROM users WHERE phone = ?
|
||
""", (phone,))
|
||
|
||
user_raw = cursor.fetchone()
|
||
|
||
if not user_raw:
|
||
return render_template('error.html', message='등록되지 않은 전화번호입니다.')
|
||
|
||
# 사용자 정보에 KST 시간 변환 적용
|
||
user = dict(user_raw)
|
||
user['created_at'] = utc_to_kst_str(user_raw['created_at'])
|
||
|
||
# 적립 내역 조회 (transaction_id 포함)
|
||
cursor.execute("""
|
||
SELECT points, balance_after, reason, description, created_at, transaction_id
|
||
FROM mileage_ledger
|
||
WHERE user_id = ?
|
||
ORDER BY created_at DESC
|
||
LIMIT 20
|
||
""", (user['id'],))
|
||
|
||
transactions_raw = cursor.fetchall()
|
||
|
||
# 거래 내역에 KST 시간 변환 적용
|
||
transactions = []
|
||
for tx in transactions_raw:
|
||
tx_dict = dict(tx)
|
||
tx_dict['created_at'] = utc_to_kst_str(tx['created_at'])
|
||
transactions.append(tx_dict)
|
||
|
||
return render_template('my_page.html', user=user, transactions=transactions, user_id=user['id'])
|
||
|
||
|
||
# ============================================================================
|
||
# PWA / 공통 라우트
|
||
# ============================================================================
|
||
|
||
@app.route('/sw.js')
|
||
def service_worker():
|
||
"""서비스 워커를 루트 경로에서 제공 (scope='/' 허용)"""
|
||
return app.send_static_file('sw.js'), 200, {
|
||
'Content-Type': 'application/javascript',
|
||
'Service-Worker-Allowed': '/'
|
||
}
|
||
|
||
|
||
@app.route('/privacy')
|
||
def privacy():
|
||
"""개인정보 처리방침"""
|
||
return render_template('privacy.html')
|
||
|
||
|
||
@app.route('/logout')
|
||
def logout():
|
||
"""세션 로그아웃"""
|
||
session.pop('logged_in_user_id', None)
|
||
session.pop('logged_in_phone', None)
|
||
session.pop('logged_in_name', None)
|
||
return redirect('/')
|
||
|
||
|
||
@app.route('/admin/transaction/<transaction_id>')
|
||
def admin_transaction_detail(transaction_id):
|
||
"""거래 세부 내역 조회 (MSSQL)"""
|
||
try:
|
||
# MSSQL PM_PRES 연결
|
||
session = db_manager.get_session('PM_PRES')
|
||
|
||
# SALE_MAIN 조회 (거래 헤더)
|
||
sale_main_query = text("""
|
||
SELECT
|
||
SL_NO_order,
|
||
InsertTime,
|
||
SL_MY_total,
|
||
SL_MY_discount,
|
||
SL_MY_sale,
|
||
SL_MY_credit,
|
||
SL_MY_recive,
|
||
SL_MY_rec_vat,
|
||
ISNULL(SL_NM_custom, '[비고객]') AS customer_name
|
||
FROM SALE_MAIN
|
||
WHERE SL_NO_order = :transaction_id
|
||
""")
|
||
|
||
sale_main = session.execute(sale_main_query, {'transaction_id': transaction_id}).fetchone()
|
||
|
||
if not sale_main:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': '거래 내역을 찾을 수 없습니다.'
|
||
}), 404
|
||
|
||
# SALE_SUB 조회 (판매 상품 상세)
|
||
sale_sub_query = text("""
|
||
SELECT
|
||
S.DrugCode,
|
||
ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name,
|
||
S.SL_NM_item AS quantity,
|
||
S.SL_NM_cost_a AS price,
|
||
S.SL_TOTAL_PRICE AS total
|
||
FROM SALE_SUB S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
WHERE S.SL_NO_order = :transaction_id
|
||
ORDER BY S.DrugCode
|
||
""")
|
||
|
||
sale_items = session.execute(sale_sub_query, {'transaction_id': transaction_id}).fetchall()
|
||
|
||
# 결과를 JSON으로 반환
|
||
result = {
|
||
'success': True,
|
||
'transaction': {
|
||
'id': sale_main.SL_NO_order,
|
||
'date': str(sale_main.InsertTime),
|
||
'total_amount': int(sale_main.SL_MY_total or 0),
|
||
'discount': int(sale_main.SL_MY_discount or 0),
|
||
'sale_amount': int(sale_main.SL_MY_sale or 0),
|
||
'credit': int(sale_main.SL_MY_credit or 0),
|
||
'supply_value': int(sale_main.SL_MY_recive or 0),
|
||
'vat': int(sale_main.SL_MY_rec_vat or 0),
|
||
'customer_name': sale_main.customer_name
|
||
},
|
||
'items': [
|
||
{
|
||
'code': item.DrugCode,
|
||
'name': item.goods_name,
|
||
'qty': int(item.quantity or 0),
|
||
'price': int(item.price or 0),
|
||
'total': int(item.total or 0)
|
||
}
|
||
for item in sale_items
|
||
]
|
||
}
|
||
|
||
return jsonify(result)
|
||
|
||
except Exception as e:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': f'조회 실패: {str(e)}'
|
||
}), 500
|
||
|
||
|
||
@app.route('/admin/user/<int:user_id>')
|
||
def admin_user_detail(user_id):
|
||
"""사용자 상세 이력 조회 - 구매 이력, 적립 이력, 구매 품목"""
|
||
conn = None
|
||
try:
|
||
# 1. SQLite 연결 (새 연결 사용)
|
||
conn = db_manager.get_sqlite_connection(new_connection=True)
|
||
cursor = conn.cursor()
|
||
|
||
# 2. 사용자 기본 정보 조회
|
||
cursor.execute("""
|
||
SELECT id, nickname, phone, mileage_balance, created_at, birthday
|
||
FROM users WHERE id = ?
|
||
""", (user_id,))
|
||
user = cursor.fetchone()
|
||
|
||
if not user:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': '사용자를 찾을 수 없습니다.'
|
||
}), 404
|
||
|
||
# 3. 마일리지 이력 조회 (최근 50건)
|
||
cursor.execute("""
|
||
SELECT transaction_id, points, balance_after, reason, description, created_at
|
||
FROM mileage_ledger
|
||
WHERE user_id = ?
|
||
ORDER BY created_at DESC
|
||
LIMIT 50
|
||
""", (user_id,))
|
||
mileage_history = cursor.fetchall()
|
||
|
||
# 4. 구매 이력 조회 (적립된 거래만, 최근 20건)
|
||
cursor.execute("""
|
||
SELECT transaction_id, total_amount, claimable_points, claimed_at
|
||
FROM claim_tokens
|
||
WHERE claimed_by_user_id = ?
|
||
ORDER BY claimed_at DESC
|
||
LIMIT 20
|
||
""", (user_id,))
|
||
claimed_tokens = cursor.fetchall()
|
||
|
||
# 5. 각 거래의 상품 상세 조회 (MSSQL)
|
||
purchases = []
|
||
|
||
try:
|
||
session = db_manager.get_session('PM_PRES')
|
||
|
||
for token in claimed_tokens:
|
||
transaction_id = token['transaction_id']
|
||
|
||
# SALE_MAIN에서 거래 시간 조회
|
||
sale_main_query = text("""
|
||
SELECT InsertTime
|
||
FROM SALE_MAIN
|
||
WHERE SL_NO_order = :transaction_id
|
||
""")
|
||
|
||
sale_main = session.execute(
|
||
sale_main_query,
|
||
{'transaction_id': transaction_id}
|
||
).fetchone()
|
||
|
||
# 거래 시간 추출 (MSSQL의 실제 거래 시간)
|
||
if sale_main and sale_main.InsertTime:
|
||
transaction_date = str(sale_main.InsertTime)[:16].replace('T', ' ')
|
||
else:
|
||
transaction_date = '-'
|
||
|
||
# SALE_SUB + CD_GOODS JOIN (BARCODE 추가)
|
||
sale_items_query = text("""
|
||
SELECT
|
||
S.BARCODE,
|
||
S.DrugCode,
|
||
ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name,
|
||
S.SL_NM_item AS quantity,
|
||
S.SL_NM_cost_a AS price,
|
||
S.SL_TOTAL_PRICE AS total
|
||
FROM SALE_SUB S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
WHERE S.SL_NO_order = :transaction_id
|
||
ORDER BY S.DrugCode
|
||
""")
|
||
|
||
items_raw = session.execute(
|
||
sale_items_query,
|
||
{'transaction_id': transaction_id}
|
||
).fetchall()
|
||
|
||
# 상품 리스트 변환 (카테고리 포함)
|
||
items = []
|
||
for item in items_raw:
|
||
barcode = item.BARCODE
|
||
|
||
# SQLite에서 제품 카테고리 조회 (테이블 없으면 건너뜀)
|
||
categories = []
|
||
if barcode:
|
||
try:
|
||
cursor.execute("""
|
||
SELECT category_name, relevance_score
|
||
FROM product_category_mapping
|
||
WHERE barcode = ?
|
||
ORDER BY relevance_score DESC
|
||
""", (barcode,))
|
||
|
||
for cat_row in cursor.fetchall():
|
||
categories.append({
|
||
'name': cat_row[0],
|
||
'score': cat_row[1]
|
||
})
|
||
except Exception:
|
||
pass # 테이블 없으면 무시
|
||
|
||
items.append({
|
||
'code': item.DrugCode,
|
||
'barcode': barcode,
|
||
'name': item.goods_name,
|
||
'qty': int(item.quantity or 0),
|
||
'price': int(item.price or 0),
|
||
'total': int(item.total or 0),
|
||
'categories': categories
|
||
})
|
||
|
||
# 상품 요약 생성 ("첫번째상품명 외 N개")
|
||
if items:
|
||
first_item_name = items[0]['name']
|
||
items_count = len(items)
|
||
if items_count == 1:
|
||
items_summary = first_item_name
|
||
else:
|
||
items_summary = f"{first_item_name} 외 {items_count - 1}개"
|
||
else:
|
||
items_summary = "상품 정보 없음"
|
||
items_count = 0
|
||
|
||
purchases.append({
|
||
'transaction_id': transaction_id,
|
||
'date': transaction_date, # MSSQL의 실제 거래 시간 사용
|
||
'amount': int(token['total_amount']),
|
||
'points': int(token['claimable_points']),
|
||
'items_summary': items_summary,
|
||
'items_count': items_count,
|
||
'items': items
|
||
})
|
||
|
||
except Exception as mssql_error:
|
||
# MSSQL 연결 실패 시 빈 배열 반환
|
||
print(f"[WARNING] MSSQL 조회 실패 (user {user_id}): {mssql_error}")
|
||
purchases = []
|
||
|
||
# 5-1. MSSQL 직접 구매 이력 추가 (QR 적립 안 된 구매)
|
||
# 전화번호 → CUSCODE → SALE_MAIN 조회
|
||
try:
|
||
if user['phone']:
|
||
phone_clean = user['phone'].replace('-', '').replace(' ', '')
|
||
base_session = db_manager.get_session('PM_BASE')
|
||
pres_session = db_manager.get_session('PM_PRES')
|
||
|
||
# 전화번호로 CUSCODE 조회
|
||
cuscode_query = text("""
|
||
SELECT TOP 1 CUSCODE, PANAME
|
||
FROM CD_PERSON
|
||
WHERE REPLACE(REPLACE(PHONE, '-', ''), ' ', '') = :phone
|
||
OR REPLACE(REPLACE(TEL_NO, '-', ''), ' ', '') = :phone
|
||
OR REPLACE(REPLACE(PHONE2, '-', ''), ' ', '') = :phone
|
||
""")
|
||
cus_row = base_session.execute(cuscode_query, {'phone': phone_clean}).fetchone()
|
||
|
||
if cus_row:
|
||
cuscode = cus_row.CUSCODE
|
||
|
||
# 이미 QR 적립된 transaction_id 목록
|
||
existing_tx_ids = {p['transaction_id'] for p in purchases}
|
||
|
||
# SALE_MAIN에서 이 고객의 구매 조회 (최근 30일, 최대 20건)
|
||
sale_query = text("""
|
||
SELECT TOP 20
|
||
M.SL_NO_order,
|
||
M.InsertTime,
|
||
M.SL_MY_sale
|
||
FROM SALE_MAIN M
|
||
WHERE M.SL_CD_custom = :cuscode
|
||
AND M.InsertTime >= DATEADD(day, -30, GETDATE())
|
||
ORDER BY M.InsertTime DESC
|
||
""")
|
||
sales = pres_session.execute(sale_query, {'cuscode': cuscode}).fetchall()
|
||
|
||
for sale in sales:
|
||
tx_id = sale.SL_NO_order
|
||
if tx_id in existing_tx_ids:
|
||
continue # 이미 QR 적립된 건 스킵
|
||
|
||
# 품목 조회
|
||
items_query = text("""
|
||
SELECT
|
||
S.DrugCode,
|
||
ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name,
|
||
S.SL_NM_item AS quantity,
|
||
S.SL_NM_cost_a AS price,
|
||
S.SL_TOTAL_PRICE AS total
|
||
FROM SALE_SUB S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
WHERE S.SL_NO_order = :tx_id
|
||
""")
|
||
items_raw = pres_session.execute(items_query, {'tx_id': tx_id}).fetchall()
|
||
|
||
items = [{
|
||
'code': item.DrugCode,
|
||
'barcode': '',
|
||
'name': item.goods_name,
|
||
'qty': int(item.quantity or 0),
|
||
'price': int(item.price or 0),
|
||
'total': int(item.total or 0),
|
||
'categories': []
|
||
} for item in items_raw]
|
||
|
||
# 상품 요약
|
||
if items:
|
||
first_item_name = items[0]['name']
|
||
items_count = len(items)
|
||
items_summary = first_item_name if items_count == 1 else f"{first_item_name} 외 {items_count - 1}개"
|
||
else:
|
||
items_summary = "상품 정보 없음"
|
||
items_count = 0
|
||
|
||
purchases.append({
|
||
'transaction_id': tx_id,
|
||
'date': str(sale.InsertTime)[:16].replace('T', ' ') if sale.InsertTime else '-',
|
||
'amount': int(sale.SL_MY_sale or 0),
|
||
'points': 0, # QR 적립 안 됨
|
||
'items_summary': items_summary,
|
||
'items_count': items_count,
|
||
'items': items,
|
||
'source': 'pos' # POS 직접 매핑 구매
|
||
})
|
||
|
||
# 날짜순 정렬 (최신 먼저)
|
||
purchases.sort(key=lambda x: x['date'], reverse=True)
|
||
except Exception as pos_purchase_error:
|
||
logging.warning(f"POS 구매 이력 조회 실패 (user {user_id}): {pos_purchase_error}")
|
||
|
||
# 6. 조제 이력 조회 (전화번호 → CUSCODE → PS_main)
|
||
prescriptions = []
|
||
pos_customer = None
|
||
if user['phone']:
|
||
try:
|
||
phone_clean = user['phone'].replace('-', '').replace(' ', '')
|
||
base_session = db_manager.get_session('PM_BASE')
|
||
pres_session = db_manager.get_session('PM_PRES')
|
||
|
||
# 전화번호로 CUSCODE 조회 (특이사항 CUSETC 포함)
|
||
cuscode_query = text("""
|
||
SELECT TOP 1 CUSCODE, PANAME, CUSETC
|
||
FROM CD_PERSON
|
||
WHERE REPLACE(REPLACE(PHONE, '-', ''), ' ', '') = :phone
|
||
OR REPLACE(REPLACE(TEL_NO, '-', ''), ' ', '') = :phone
|
||
OR REPLACE(REPLACE(PHONE2, '-', ''), ' ', '') = :phone
|
||
""")
|
||
cus_row = base_session.execute(cuscode_query, {'phone': phone_clean}).fetchone()
|
||
|
||
if cus_row:
|
||
cuscode = cus_row.CUSCODE
|
||
pos_customer = {
|
||
'cuscode': cuscode,
|
||
'name': cus_row.PANAME,
|
||
'cusetc': cus_row.CUSETC or '' # 특이(참고)사항
|
||
}
|
||
|
||
# 조제 이력 조회
|
||
rx_query = text("""
|
||
SELECT TOP 30
|
||
P.PreSerial, P.Indate, P.Paname, P.Drname, P.OrderName, P.TDAYS
|
||
FROM PS_main P
|
||
WHERE P.CusCode = :cuscode
|
||
ORDER BY P.Indate DESC, P.PreSerial DESC
|
||
""")
|
||
rxs = pres_session.execute(rx_query, {'cuscode': cuscode}).fetchall()
|
||
|
||
for rx in rxs:
|
||
# 처방 품목 조회
|
||
items_query = text("""
|
||
SELECT S.DrugCode, G.GoodsName, S.Days, S.QUAN, S.QUAN_TIME
|
||
FROM PS_sub_pharm S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
WHERE S.PreSerial = :pre_serial
|
||
""")
|
||
items = pres_session.execute(items_query, {'pre_serial': rx.PreSerial}).fetchall()
|
||
|
||
prescriptions.append({
|
||
'pre_serial': rx.PreSerial,
|
||
'date': rx.Indate,
|
||
'patient_name': rx.Paname,
|
||
'doctor': rx.Drname,
|
||
'hospital': rx.OrderName,
|
||
'total_days': rx.TDAYS,
|
||
'items': [{
|
||
'drug_code': item.DrugCode,
|
||
'name': item.GoodsName or '알 수 없음',
|
||
'days': float(item.Days) if item.Days else 0,
|
||
'quantity': float(item.QUAN) if item.QUAN else 0,
|
||
'times_per_day': float(item.QUAN_TIME) if item.QUAN_TIME else 0
|
||
} for item in items]
|
||
})
|
||
except Exception as rx_error:
|
||
logging.warning(f"조제 이력 조회 실패 (user {user_id}): {rx_error}")
|
||
|
||
# 7. 관심상품 조회 (AI 추천에서 '관심있어요' 누른 상품)
|
||
interests = []
|
||
try:
|
||
cursor.execute("""
|
||
SELECT recommended_product, recommendation_reason, trigger_products, created_at
|
||
FROM ai_recommendations
|
||
WHERE user_id = ? AND status = 'interested'
|
||
ORDER BY created_at DESC
|
||
LIMIT 20
|
||
""", (user_id,))
|
||
for row in cursor.fetchall():
|
||
interests.append({
|
||
'product': row['recommended_product'],
|
||
'reason': row['recommendation_reason'],
|
||
'trigger_products': row['trigger_products'],
|
||
'created_at': utc_to_kst_str(row['created_at'])
|
||
})
|
||
except Exception as interest_error:
|
||
logging.warning(f"관심상품 조회 실패 (user {user_id}): {interest_error}")
|
||
|
||
# 8. 반려동물 조회
|
||
pets = []
|
||
try:
|
||
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,))
|
||
for row in cursor.fetchall():
|
||
species_label = '강아지 🐕' if row['species'] == 'dog' else ('고양이 🐈' if row['species'] == 'cat' else '기타')
|
||
gender_label = '♂ 남아' if row['gender'] == 'male' else ('♀ 여아' if row['gender'] == 'female' else '')
|
||
pets.append({
|
||
'id': row['id'],
|
||
'name': row['name'],
|
||
'species': row['species'],
|
||
'species_label': species_label,
|
||
'breed': row['breed'],
|
||
'gender': row['gender'],
|
||
'gender_label': gender_label,
|
||
'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'])
|
||
})
|
||
except Exception as pet_error:
|
||
logging.warning(f"반려동물 조회 실패 (user {user_id}): {pet_error}")
|
||
|
||
# 9. 응답 생성
|
||
return jsonify({
|
||
'success': True,
|
||
'user': {
|
||
'id': user['id'],
|
||
'name': user['nickname'],
|
||
'phone': user['phone'],
|
||
'balance': user['mileage_balance'],
|
||
'created_at': utc_to_kst_str(user['created_at']),
|
||
'is_kakao_verified': user['nickname'] != '고객', # 카카오 인증 여부
|
||
'birthday': user['birthday'] if user['birthday'] else None
|
||
},
|
||
'mileage_history': [
|
||
{
|
||
'points': ml['points'],
|
||
'balance_after': ml['balance_after'],
|
||
'reason': ml['reason'],
|
||
'description': ml['description'],
|
||
'created_at': utc_to_kst_str(ml['created_at']),
|
||
'transaction_id': ml['transaction_id']
|
||
}
|
||
for ml in mileage_history
|
||
],
|
||
'purchases': purchases,
|
||
'prescriptions': prescriptions,
|
||
'pos_customer': pos_customer,
|
||
'interests': interests,
|
||
'pets': pets
|
||
})
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
logging.error(f"사용자 상세 조회 실패: {e}\n{traceback.format_exc()}")
|
||
return jsonify({
|
||
'success': False,
|
||
'message': f'조회 실패: {str(e)}'
|
||
}), 500
|
||
finally:
|
||
if conn:
|
||
try:
|
||
conn.close()
|
||
except:
|
||
pass
|
||
|
||
|
||
@app.route('/admin/search/user')
|
||
def admin_search_user():
|
||
"""사용자 검색 (이름/전화번호/전화번호 뒷자리)"""
|
||
query = request.args.get('q', '').strip()
|
||
search_type = request.args.get('type', 'name') # 'name', 'phone', 'phone_last'
|
||
|
||
if not query:
|
||
return jsonify({'success': False, 'message': '검색어를 입력하세요'}), 400
|
||
|
||
conn = None
|
||
try:
|
||
conn = db_manager.get_sqlite_connection(new_connection=True)
|
||
cursor = conn.cursor()
|
||
if search_type == 'phone_last':
|
||
# 전화번호 뒷자리 검색
|
||
cursor.execute("""
|
||
SELECT id, nickname, phone, mileage_balance
|
||
FROM users
|
||
WHERE phone LIKE ?
|
||
ORDER BY created_at DESC
|
||
""", (f'%{query}',))
|
||
elif search_type == 'phone':
|
||
# 전체 전화번호 검색
|
||
cursor.execute("""
|
||
SELECT id, nickname, phone, mileage_balance
|
||
FROM users
|
||
WHERE phone = ?
|
||
""", (query,))
|
||
else:
|
||
# 이름 검색
|
||
cursor.execute("""
|
||
SELECT id, nickname, phone, mileage_balance
|
||
FROM users
|
||
WHERE nickname LIKE ?
|
||
ORDER BY created_at DESC
|
||
""", (f'%{query}%',))
|
||
|
||
results = cursor.fetchall()
|
||
|
||
if not results:
|
||
return jsonify({'success': False, 'message': '검색 결과가 없습니다'}), 404
|
||
|
||
if len(results) == 1:
|
||
# 단일 매칭 - user_id만 반환
|
||
return jsonify({
|
||
'success': True,
|
||
'multiple': False,
|
||
'user_id': results[0]['id']
|
||
})
|
||
else:
|
||
# 여러 명 매칭 - 선택 모달용 데이터 반환
|
||
users = [{
|
||
'id': row['id'],
|
||
'name': row['nickname'],
|
||
'phone': row['phone'],
|
||
'balance': row['mileage_balance'],
|
||
'is_kakao_verified': row['nickname'] != '고객' # 카카오 인증 여부
|
||
} for row in results]
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'multiple': True,
|
||
'users': users
|
||
})
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
logging.error(f"사용자 검색 실패: {e}\n{traceback.format_exc()}")
|
||
return jsonify({
|
||
'success': False,
|
||
'message': f'검색 실패: {str(e)}'
|
||
}), 500
|
||
finally:
|
||
if conn:
|
||
try:
|
||
conn.close()
|
||
except:
|
||
pass
|
||
|
||
|
||
@app.route('/admin/search/product')
|
||
def admin_search_product():
|
||
"""제품 검색 - 적립자 목록 반환 (SQLite 적립자 기준)"""
|
||
query = request.args.get('q', '').strip()
|
||
|
||
if not query:
|
||
return jsonify({'success': False, 'message': '검색어를 입력하세요'}), 400
|
||
|
||
conn = None
|
||
try:
|
||
conn = db_manager.get_sqlite_connection(new_connection=True)
|
||
cursor = conn.cursor()
|
||
# 1. MSSQL에서 제품명으로 거래번호 찾기
|
||
session = db_manager.get_session('PM_PRES')
|
||
|
||
sale_items_query = text("""
|
||
SELECT DISTINCT
|
||
S.SL_NO_order,
|
||
S.SL_NM_item,
|
||
M.InsertTime
|
||
FROM SALE_SUB S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
LEFT JOIN SALE_MAIN M ON S.SL_NO_order = M.SL_NO_order
|
||
WHERE G.GoodsName LIKE :product_name
|
||
ORDER BY M.InsertTime DESC
|
||
""")
|
||
|
||
sale_results = session.execute(sale_items_query, {
|
||
'product_name': f'%{query}%'
|
||
}).fetchall()
|
||
|
||
if not sale_results:
|
||
return jsonify({
|
||
'success': True,
|
||
'results': []
|
||
})
|
||
|
||
# 2. SQLite에서 적립된 거래만 필터링 (claimed_by_user_id IS NOT NULL)
|
||
transaction_ids = [row.SL_NO_order for row in sale_results]
|
||
placeholders = ','.join('?' * len(transaction_ids))
|
||
|
||
cursor.execute(f"""
|
||
SELECT
|
||
ct.transaction_id,
|
||
ct.total_amount,
|
||
ct.claimed_at,
|
||
ct.claimed_by_user_id,
|
||
u.nickname,
|
||
u.phone
|
||
FROM claim_tokens ct
|
||
JOIN users u ON ct.claimed_by_user_id = u.id
|
||
WHERE ct.transaction_id IN ({placeholders})
|
||
AND ct.claimed_by_user_id IS NOT NULL
|
||
ORDER BY ct.claimed_at DESC
|
||
LIMIT 50
|
||
""", transaction_ids)
|
||
|
||
claimed_results = cursor.fetchall()
|
||
|
||
# 3. 결과 조합
|
||
results = []
|
||
for claim_row in claimed_results:
|
||
# 해당 거래의 MSSQL 정보 찾기
|
||
mssql_row = next((r for r in sale_results if r.SL_NO_order == claim_row['transaction_id']), None)
|
||
|
||
if mssql_row:
|
||
results.append({
|
||
'user_id': claim_row['claimed_by_user_id'],
|
||
'user_name': claim_row['nickname'],
|
||
'user_phone': claim_row['phone'],
|
||
'purchase_date': str(mssql_row.InsertTime)[:16].replace('T', ' ') if mssql_row.InsertTime else '-', # MSSQL 실제 거래 시간
|
||
'claimed_date': str(claim_row['claimed_at'])[:16].replace('T', ' ') if claim_row['claimed_at'] else '-', # 적립 시간
|
||
'quantity': float(mssql_row.SL_NM_item or 0),
|
||
'total_amount': int(claim_row['total_amount'])
|
||
})
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'results': results
|
||
})
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
logging.error(f"제품 검색 실패: {e}\n{traceback.format_exc()}")
|
||
return jsonify({
|
||
'success': False,
|
||
'message': f'검색 실패: {str(e)}'
|
||
}), 500
|
||
finally:
|
||
if conn:
|
||
try:
|
||
conn.close()
|
||
except:
|
||
pass
|
||
|
||
|
||
@app.route('/admin/ai-analyze-user/<int:user_id>', methods=['POST'])
|
||
def admin_ai_analyze_user(user_id):
|
||
"""OpenAI GPT를 사용한 사용자 구매 패턴 AI 분석"""
|
||
try:
|
||
# OpenAI 사용 가능 여부 확인
|
||
if not OPENAI_AVAILABLE:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': ERROR_MESSAGES['OPENAI_NOT_AVAILABLE']
|
||
}), 503
|
||
|
||
# 1. SQLite 연결
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
# 2. 사용자 기본 정보 조회
|
||
cursor.execute("""
|
||
SELECT id, nickname, phone, mileage_balance, created_at
|
||
FROM users WHERE id = ?
|
||
""", (user_id,))
|
||
user_row = cursor.fetchone()
|
||
|
||
if not user_row:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': ERROR_MESSAGES['NO_USER']
|
||
}), 404
|
||
|
||
user = {
|
||
'id': user_row['id'],
|
||
'nickname': user_row['nickname'],
|
||
'phone': user_row['phone'],
|
||
'mileage_balance': user_row['mileage_balance'],
|
||
'created_at': user_row['created_at']
|
||
}
|
||
|
||
# 3. 구매 이력 조회 (최근 20건)
|
||
cursor.execute("""
|
||
SELECT transaction_id, total_amount, claimable_points, claimed_at
|
||
FROM claim_tokens
|
||
WHERE claimed_by_user_id = ?
|
||
ORDER BY claimed_at DESC
|
||
LIMIT 20
|
||
""", (user_id,))
|
||
claimed_tokens = cursor.fetchall()
|
||
|
||
if not claimed_tokens:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': ERROR_MESSAGES['NO_PURCHASES']
|
||
}), 400
|
||
|
||
# 4. MSSQL에서 상품 상세 조회
|
||
purchases = []
|
||
session = db_manager.get_session('PM_PRES')
|
||
|
||
for token in claimed_tokens:
|
||
transaction_id = token['transaction_id']
|
||
|
||
# SALE_MAIN에서 거래 시간 조회
|
||
sale_main_query = text("""
|
||
SELECT InsertTime
|
||
FROM SALE_MAIN
|
||
WHERE SL_NO_order = :transaction_id
|
||
""")
|
||
sale_main = session.execute(sale_main_query, {'transaction_id': transaction_id}).fetchone()
|
||
|
||
# SALE_SUB + CD_GOODS JOIN
|
||
sale_items_query = text("""
|
||
SELECT
|
||
S.DrugCode,
|
||
ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name,
|
||
S.SL_NM_item AS quantity,
|
||
S.SL_NM_cost_a AS price,
|
||
S.SL_TOTAL_PRICE AS total
|
||
FROM SALE_SUB S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
WHERE S.SL_NO_order = :transaction_id
|
||
ORDER BY S.DrugCode
|
||
""")
|
||
items_raw = session.execute(sale_items_query, {'transaction_id': transaction_id}).fetchall()
|
||
|
||
items = [{
|
||
'name': item.goods_name,
|
||
'qty': int(item.quantity or 0),
|
||
'price': int(item.price or 0)
|
||
} for item in items_raw]
|
||
|
||
purchases.append({
|
||
'date': str(sale_main.InsertTime)[:16].replace('T', ' ') if sale_main and sale_main.InsertTime else '-',
|
||
'amount': int(token['total_amount']),
|
||
'points': int(token['claimable_points']),
|
||
'items': items
|
||
})
|
||
|
||
# 5. OpenAI API 호출용 프롬프트 생성
|
||
prompt = prepare_analysis_prompt(user, purchases)
|
||
|
||
# 6. OpenAI API 호출
|
||
logging.info(f"AI 분석 시작: 사용자 ID {user_id}")
|
||
success, response = call_openai_with_retry(prompt)
|
||
|
||
if not success:
|
||
# response에는 에러 메시지가 담겨 있음
|
||
return jsonify({
|
||
'success': False,
|
||
'message': response
|
||
}), 500
|
||
|
||
# 7. 응답 파싱
|
||
response_text = response.choices[0].message.content
|
||
analysis = parse_openai_response(response_text)
|
||
|
||
logging.info(f"AI 분석 완료: 사용자 ID {user_id}, 토큰: {response.usage.total_tokens}")
|
||
|
||
# 8. 결과 반환
|
||
return jsonify({
|
||
'success': True,
|
||
'user': {
|
||
'id': user['id'],
|
||
'name': user['nickname'],
|
||
'phone': user['phone'],
|
||
'balance': user['mileage_balance']
|
||
},
|
||
'analysis': analysis,
|
||
'metadata': {
|
||
'model_used': OPENAI_MODEL,
|
||
'tokens_used': response.usage.total_tokens,
|
||
'analysis_time': datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
|
||
}
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"AI 분석 오류: {e}")
|
||
return jsonify({
|
||
'success': False,
|
||
'message': ERROR_MESSAGES['UNKNOWN_ERROR']
|
||
}), 500
|
||
|
||
|
||
@app.route('/admin/use-points', methods=['POST'])
|
||
def admin_use_points():
|
||
"""관리자 페이지에서 포인트 사용 (차감)"""
|
||
try:
|
||
data = request.get_json()
|
||
user_id = data.get('user_id')
|
||
points_to_use = data.get('points')
|
||
|
||
if not user_id or not points_to_use:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': '사용자 ID와 포인트를 입력하세요.'
|
||
}), 400
|
||
|
||
if points_to_use <= 0:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': '1 이상의 포인트를 입력하세요.'
|
||
}), 400
|
||
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
# 1. 현재 사용자 잔액 확인
|
||
cursor.execute("""
|
||
SELECT id, nickname, mileage_balance
|
||
FROM users
|
||
WHERE id = ?
|
||
""", (user_id,))
|
||
|
||
user = cursor.fetchone()
|
||
|
||
if not user:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': '사용자를 찾을 수 없습니다.'
|
||
}), 404
|
||
|
||
current_balance = user['mileage_balance']
|
||
|
||
# 2. 잔액 확인
|
||
if points_to_use > current_balance:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': f'잔액({current_balance:,}P)이 부족합니다.'
|
||
}), 400
|
||
|
||
# 3. 포인트 차감
|
||
new_balance = current_balance - points_to_use
|
||
|
||
cursor.execute("""
|
||
UPDATE users
|
||
SET mileage_balance = ?
|
||
WHERE id = ?
|
||
""", (new_balance, user_id))
|
||
|
||
# 4. 마일리지 원장 기록
|
||
cursor.execute("""
|
||
INSERT INTO mileage_ledger
|
||
(user_id, transaction_id, points, balance_after, reason, description)
|
||
VALUES (?, NULL, ?, ?, 'USE', ?)
|
||
""", (
|
||
user_id,
|
||
-points_to_use, # 음수로 저장
|
||
new_balance,
|
||
f'포인트 사용 (관리자) - {points_to_use:,}P 차감'
|
||
))
|
||
|
||
conn.commit()
|
||
|
||
logging.info(f"포인트 사용 완료: 사용자 ID {user_id}, 차감 {points_to_use}P, 잔액 {new_balance}P")
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': f'{points_to_use:,}P 사용 완료',
|
||
'new_balance': new_balance,
|
||
'used_points': points_to_use
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"포인트 사용 실패: {e}")
|
||
return jsonify({
|
||
'success': False,
|
||
'message': f'포인트 사용 실패: {str(e)}'
|
||
}), 500
|
||
|
||
|
||
@app.route('/admin')
|
||
def admin():
|
||
"""관리자 페이지 - 전체 사용자 및 적립 현황"""
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
# 전체 통계
|
||
cursor.execute("""
|
||
SELECT
|
||
COUNT(*) as total_users,
|
||
SUM(mileage_balance) as total_balance
|
||
FROM users
|
||
""")
|
||
stats = cursor.fetchone()
|
||
|
||
# 최근 가입 사용자 (20명)
|
||
cursor.execute("""
|
||
SELECT id, nickname, phone, mileage_balance, created_at, kakao_verified_at
|
||
FROM users
|
||
ORDER BY created_at DESC
|
||
LIMIT 20
|
||
""")
|
||
recent_users_raw = cursor.fetchall()
|
||
|
||
# 시간을 KST로 변환 + 조제 이력 확인
|
||
recent_users = []
|
||
|
||
# MSSQL 세션 (조제 이력 확인용)
|
||
base_session = None
|
||
pres_session = None
|
||
try:
|
||
base_session = db_manager.get_session('PM_BASE')
|
||
pres_session = db_manager.get_session('PM_PRES')
|
||
except:
|
||
pass
|
||
|
||
for user in recent_users_raw:
|
||
user_dict = dict(user)
|
||
user_dict['created_at'] = utc_to_kst_str(user['created_at'])
|
||
user_dict['kakao_verified_at'] = utc_to_kst_str(user['kakao_verified_at']) if user['kakao_verified_at'] else None
|
||
|
||
# 조제 이력 확인 (MSSQL)
|
||
user_dict['has_prescription'] = False
|
||
if base_session and pres_session and user['phone']:
|
||
try:
|
||
phone_clean = user['phone'].replace('-', '').replace(' ', '')
|
||
|
||
# 1단계: PM_BASE에서 전화번호로 CUSCODE 조회
|
||
cuscode_query = text("""
|
||
SELECT TOP 1 CUSCODE
|
||
FROM CD_PERSON
|
||
WHERE REPLACE(REPLACE(PHONE, '-', ''), ' ', '') = :phone
|
||
OR REPLACE(REPLACE(TEL_NO, '-', ''), ' ', '') = :phone
|
||
OR REPLACE(REPLACE(PHONE2, '-', ''), ' ', '') = :phone
|
||
""")
|
||
cus_row = base_session.execute(cuscode_query, {'phone': phone_clean}).fetchone()
|
||
|
||
if cus_row:
|
||
# 2단계: PM_PRES에서 CUSCODE로 조제 기록 확인
|
||
pres_check = pres_session.execute(text("""
|
||
SELECT TOP 1 PreSerial FROM PS_main WHERE CusCode = :cuscode
|
||
"""), {'cuscode': cus_row.CUSCODE}).fetchone()
|
||
|
||
if pres_check:
|
||
user_dict['has_prescription'] = True
|
||
|
||
except Exception as e:
|
||
logging.warning(f"조제 이력 확인 실패 (user {user['id']}): {e}")
|
||
|
||
recent_users.append(user_dict)
|
||
|
||
# 최근 적립 내역 (50건)
|
||
cursor.execute("""
|
||
SELECT
|
||
ml.id,
|
||
u.nickname,
|
||
u.phone,
|
||
ml.points,
|
||
ml.balance_after,
|
||
ml.reason,
|
||
ml.description,
|
||
ml.created_at,
|
||
ml.transaction_id
|
||
FROM mileage_ledger ml
|
||
JOIN users u ON ml.user_id = u.id
|
||
ORDER BY ml.created_at DESC
|
||
LIMIT 50
|
||
""")
|
||
recent_transactions_raw = cursor.fetchall()
|
||
|
||
# 시간을 KST로 변환
|
||
recent_transactions = []
|
||
for trans in recent_transactions_raw:
|
||
trans_dict = dict(trans)
|
||
trans_dict['created_at'] = utc_to_kst_str(trans['created_at'])
|
||
recent_transactions.append(trans_dict)
|
||
|
||
# QR 토큰 통계
|
||
cursor.execute("""
|
||
SELECT
|
||
COUNT(*) as total_tokens,
|
||
SUM(CASE WHEN claimed_at IS NOT NULL THEN 1 ELSE 0 END) as claimed_count,
|
||
SUM(CASE WHEN claimed_at IS NULL THEN 1 ELSE 0 END) as unclaimed_count,
|
||
SUM(claimable_points) as total_points_issued,
|
||
SUM(CASE WHEN claimed_at IS NOT NULL THEN claimable_points ELSE 0 END) as total_points_claimed
|
||
FROM claim_tokens
|
||
""")
|
||
token_stats = cursor.fetchone()
|
||
|
||
# 반려동물 통계
|
||
cursor.execute("""
|
||
SELECT
|
||
COUNT(*) as total_pets,
|
||
SUM(CASE WHEN species = 'dog' THEN 1 ELSE 0 END) as dog_count,
|
||
SUM(CASE WHEN species = 'cat' THEN 1 ELSE 0 END) as cat_count,
|
||
COUNT(DISTINCT user_id) as owners_count
|
||
FROM pets
|
||
WHERE is_active = 1
|
||
""")
|
||
pet_stats = cursor.fetchone()
|
||
|
||
# 최근 등록 반려동물 (10마리)
|
||
cursor.execute("""
|
||
SELECT p.id, p.name, p.species, p.breed, p.photo_url, p.created_at,
|
||
u.nickname as owner_name, u.phone as owner_phone
|
||
FROM pets p
|
||
JOIN users u ON p.user_id = u.id
|
||
WHERE p.is_active = 1
|
||
ORDER BY p.created_at DESC
|
||
LIMIT 10
|
||
""")
|
||
recent_pets_raw = cursor.fetchall()
|
||
recent_pets = []
|
||
for pet in recent_pets_raw:
|
||
pet_dict = dict(pet)
|
||
pet_dict['created_at'] = utc_to_kst_str(pet['created_at'])
|
||
recent_pets.append(pet_dict)
|
||
|
||
# 최근 QR 발행 내역 (20건)
|
||
cursor.execute("""
|
||
SELECT
|
||
transaction_id,
|
||
total_amount,
|
||
claimable_points,
|
||
claimed_at,
|
||
claimed_by_user_id,
|
||
created_at
|
||
FROM claim_tokens
|
||
ORDER BY created_at DESC
|
||
LIMIT 20
|
||
""")
|
||
recent_tokens_raw = cursor.fetchall()
|
||
|
||
# Convert only created_at (발행일), leave claimed_at (적립일) unconverted
|
||
recent_tokens = []
|
||
for token in recent_tokens_raw:
|
||
token_dict = dict(token)
|
||
token_dict['created_at'] = utc_to_kst_str(token['created_at']) # Convert 발행일
|
||
# claimed_at stays as-is (or remains None if not claimed)
|
||
recent_tokens.append(token_dict)
|
||
|
||
return render_template('admin.html',
|
||
stats=stats,
|
||
recent_users=recent_users,
|
||
recent_transactions=recent_transactions,
|
||
token_stats=token_stats,
|
||
recent_tokens=recent_tokens,
|
||
pet_stats=pet_stats,
|
||
recent_pets=recent_pets)
|
||
|
||
|
||
# ============================================================================
|
||
# AI 업셀링 추천
|
||
# ============================================================================
|
||
|
||
def _get_available_products():
|
||
"""약국 보유 제품 목록 (최근 30일 판매 실적 기준 TOP 40)"""
|
||
try:
|
||
mssql_session = db_manager.get_session('PM_PRES')
|
||
rows = mssql_session.execute(text("""
|
||
SELECT TOP 40
|
||
ISNULL(G.GoodsName, '') AS name,
|
||
COUNT(*) as sales,
|
||
MAX(G.Saleprice) as price
|
||
FROM SALE_SUB S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
WHERE S.SL_DT_appl >= CONVERT(VARCHAR(8), DATEADD(DAY, -30, GETDATE()), 112)
|
||
AND G.GoodsName IS NOT NULL
|
||
AND G.GoodsName NOT LIKE N'%(판매중지)%'
|
||
GROUP BY G.GoodsName
|
||
ORDER BY COUNT(*) DESC
|
||
""")).fetchall()
|
||
return [{'name': r.name, 'price': float(r.price or 0), 'sales': r.sales} for r in rows]
|
||
except Exception as e:
|
||
logging.warning(f"[AI추천] 보유 제품 목록 조회 실패: {e}")
|
||
return []
|
||
|
||
|
||
def _generate_upsell_recommendation(user_id, transaction_id, sale_items, user_name):
|
||
"""키오스크 적립 후 AI 업셀링 추천 생성 (fire-and-forget)"""
|
||
from services.clawdbot_client import generate_upsell, generate_upsell_real
|
||
|
||
if not sale_items:
|
||
return
|
||
|
||
# 현재 구매 품목
|
||
current_items = ', '.join(item['name'] for item in sale_items if item.get('name'))
|
||
if not current_items:
|
||
return
|
||
|
||
# 최근 구매 이력 수집
|
||
recent_products = current_items # 기본값
|
||
try:
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
SELECT ct.transaction_id
|
||
FROM claim_tokens ct
|
||
WHERE ct.claimed_by_user_id = ? AND ct.transaction_id != ?
|
||
ORDER BY ct.claimed_at DESC LIMIT 5
|
||
""", (user_id, transaction_id))
|
||
recent_tokens = cursor.fetchall()
|
||
|
||
if recent_tokens:
|
||
all_products = []
|
||
mssql_session = db_manager.get_session('PM_PRES')
|
||
for token in recent_tokens:
|
||
rows = mssql_session.execute(text("""
|
||
SELECT ISNULL(G.GoodsName, '') AS goods_name
|
||
FROM SALE_SUB S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
WHERE S.SL_NO_order = :tid
|
||
"""), {'tid': token['transaction_id']}).fetchall()
|
||
for r in rows:
|
||
if r.goods_name:
|
||
all_products.append(r.goods_name)
|
||
if all_products:
|
||
recent_products = ', '.join(set(all_products))
|
||
except Exception as e:
|
||
logging.warning(f"[AI추천] 구매 이력 수집 실패 (현재 품목만 사용): {e}")
|
||
|
||
# 실데이터 기반 추천 (보유 제품 목록 제공)
|
||
available = _get_available_products()
|
||
rec = None
|
||
|
||
if available:
|
||
logging.info(f"[AI추천] 실데이터 생성 시작: user={user_name}, items={current_items}, 보유제품={len(available)}개")
|
||
rec = generate_upsell_real(user_name, current_items, recent_products, available)
|
||
|
||
# 실데이터 실패 시 기존 방식 fallback
|
||
if not rec:
|
||
logging.info(f"[AI추천] 자유 생성 fallback: user={user_name}")
|
||
rec = generate_upsell(user_name, current_items, recent_products)
|
||
|
||
if not rec:
|
||
logging.warning("[AI추천] 생성 실패 (AI 응답 없음)")
|
||
return
|
||
|
||
# SQLite에 저장
|
||
try:
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
expires_at = (datetime.now() + timedelta(days=7)).strftime('%Y-%m-%d %H:%M:%S')
|
||
cursor.execute("""
|
||
INSERT INTO ai_recommendations
|
||
(user_id, transaction_id, recommended_product, recommendation_message,
|
||
recommendation_reason, trigger_products, ai_raw_response, expires_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
""", (
|
||
user_id, transaction_id,
|
||
rec['product'], rec['message'], rec['reason'],
|
||
json.dumps([item['name'] for item in sale_items], ensure_ascii=False),
|
||
json.dumps(rec, ensure_ascii=False),
|
||
expires_at
|
||
))
|
||
conn.commit()
|
||
logging.info(f"[AI추천] 저장 완료: user_id={user_id}, product={rec['product']}")
|
||
except Exception as e:
|
||
logging.warning(f"[AI추천] DB 저장 실패: {e}")
|
||
|
||
|
||
@app.route('/api/recommendation/<int:user_id>')
|
||
def api_get_recommendation(user_id):
|
||
"""마이페이지용 AI 추천 조회"""
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||
cursor.execute("""
|
||
SELECT id, recommended_product, recommendation_message, created_at
|
||
FROM ai_recommendations
|
||
WHERE user_id = ? AND status = 'active'
|
||
AND (expires_at IS NULL OR expires_at > ?)
|
||
ORDER BY created_at DESC LIMIT 1
|
||
""", (user_id, now))
|
||
|
||
rec = cursor.fetchone()
|
||
if not rec:
|
||
return jsonify({'success': True, 'has_recommendation': False})
|
||
|
||
# 표시 횟수 업데이트
|
||
cursor.execute("""
|
||
UPDATE ai_recommendations
|
||
SET displayed_count = displayed_count + 1,
|
||
displayed_at = COALESCE(displayed_at, ?)
|
||
WHERE id = ?
|
||
""", (now, rec['id']))
|
||
conn.commit()
|
||
|
||
# 제품 이미지 조회 (product_images DB에서 제품명으로 검색)
|
||
product_image = None
|
||
try:
|
||
img_db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db')
|
||
logging.info(f"[AI추천] 이미지 DB 경로: {img_db_path}")
|
||
img_conn = sqlite3.connect(img_db_path)
|
||
img_conn.row_factory = sqlite3.Row
|
||
img_cursor = img_conn.cursor()
|
||
|
||
product_name = rec['recommended_product']
|
||
logging.info(f"[AI추천] 검색할 제품명: {product_name}")
|
||
# 제품명으로 이미지 검색 (LIKE 검색으로 부분 매칭) - 원본 이미지 사용
|
||
img_cursor.execute("""
|
||
SELECT image_base64 FROM product_images
|
||
WHERE product_name LIKE ? AND image_base64 IS NOT NULL
|
||
LIMIT 1
|
||
""", (f'%{product_name}%',))
|
||
img_row = img_cursor.fetchone()
|
||
if img_row:
|
||
product_image = img_row['image_base64']
|
||
logging.info(f"[AI추천] 이미지 찾음: {len(product_image)} bytes")
|
||
else:
|
||
logging.info(f"[AI추천] 이미지 없음 (제품: {product_name})")
|
||
img_conn.close()
|
||
except Exception as e:
|
||
logging.warning(f"[AI추천] 제품 이미지 조회 실패: {e}")
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'has_recommendation': True,
|
||
'recommendation': {
|
||
'id': rec['id'],
|
||
'product': rec['recommended_product'],
|
||
'message': rec['recommendation_message'],
|
||
'image': product_image # base64 썸네일 이미지 (없으면 null)
|
||
}
|
||
})
|
||
|
||
|
||
@app.route('/api/recommendation/<int:rec_id>/dismiss', methods=['POST'])
|
||
def api_dismiss_recommendation(rec_id):
|
||
"""추천 닫기 / 관심 표시"""
|
||
data = request.get_json(silent=True) or {}
|
||
action = data.get('action', 'dismissed')
|
||
if action not in ('dismissed', 'interested'):
|
||
action = 'dismissed'
|
||
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||
cursor.execute("""
|
||
UPDATE ai_recommendations SET status = ?, dismissed_at = ?
|
||
WHERE id = ?
|
||
""", (action, now, rec_id))
|
||
conn.commit()
|
||
logging.info(f"[AI추천] id={rec_id} → {action}")
|
||
return jsonify({'success': True})
|
||
|
||
|
||
# ============================================================================
|
||
# 알림톡 로그
|
||
# ============================================================================
|
||
|
||
@app.route('/admin/alimtalk')
|
||
def admin_alimtalk():
|
||
"""알림톡 발송 로그 + NHN 발송 내역"""
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
# 로컬 발송 로그 (최근 50건)
|
||
cursor.execute("""
|
||
SELECT a.*, u.nickname, u.phone as user_phone
|
||
FROM alimtalk_logs a
|
||
LEFT JOIN users u ON a.user_id = u.id
|
||
ORDER BY a.created_at DESC
|
||
LIMIT 50
|
||
""")
|
||
local_logs = [dict(row) for row in cursor.fetchall()]
|
||
|
||
# 통계
|
||
cursor.execute("""
|
||
SELECT
|
||
COUNT(*) as total,
|
||
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count,
|
||
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as fail_count
|
||
FROM alimtalk_logs
|
||
""")
|
||
stats = dict(cursor.fetchone())
|
||
|
||
# 오늘 통계
|
||
cursor.execute("""
|
||
SELECT
|
||
COUNT(*) as today_total,
|
||
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as today_success
|
||
FROM alimtalk_logs
|
||
WHERE date(created_at) = date('now')
|
||
""")
|
||
today = dict(cursor.fetchone())
|
||
stats.update(today)
|
||
|
||
return render_template('admin_alimtalk.html', local_logs=local_logs, stats=stats)
|
||
|
||
|
||
@app.route('/admin/ai-crm')
|
||
def admin_ai_crm():
|
||
"""AI 업셀링 CRM 대시보드"""
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
# 추천 목록 (최근 50건) + 사용자 정보 JOIN
|
||
cursor.execute("""
|
||
SELECT r.*, u.nickname, u.phone as user_phone
|
||
FROM ai_recommendations r
|
||
LEFT JOIN users u ON r.user_id = u.id
|
||
ORDER BY r.created_at DESC
|
||
LIMIT 50
|
||
""")
|
||
recs = [dict(row) for row in cursor.fetchall()]
|
||
|
||
# trigger_products JSON 파싱
|
||
for rec in recs:
|
||
tp = rec.get('trigger_products')
|
||
if tp:
|
||
try:
|
||
rec['trigger_list'] = json.loads(tp)
|
||
except Exception:
|
||
rec['trigger_list'] = [tp]
|
||
else:
|
||
rec['trigger_list'] = []
|
||
|
||
# 통계
|
||
cursor.execute("""
|
||
SELECT
|
||
COUNT(*) as total,
|
||
SUM(CASE WHEN status = 'active' AND (expires_at IS NULL OR expires_at > datetime('now')) THEN 1 ELSE 0 END) as active_count,
|
||
SUM(CASE WHEN status = 'interested' THEN 1 ELSE 0 END) as interested_count,
|
||
SUM(CASE WHEN status = 'dismissed' THEN 1 ELSE 0 END) as dismissed_count,
|
||
SUM(CASE WHEN displayed_count > 0 THEN 1 ELSE 0 END) as displayed_count
|
||
FROM ai_recommendations
|
||
""")
|
||
stats = dict(cursor.fetchone())
|
||
|
||
# 오늘 생성 건수
|
||
cursor.execute("""
|
||
SELECT COUNT(*) as today_count
|
||
FROM ai_recommendations
|
||
WHERE date(created_at) = date('now')
|
||
""")
|
||
stats['today_count'] = cursor.fetchone()['today_count']
|
||
|
||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||
return render_template('admin_ai_crm.html', recs=recs, stats=stats, now=now)
|
||
|
||
|
||
@app.route('/api/admin/alimtalk/nhn-history')
|
||
def api_admin_alimtalk_nhn_history():
|
||
"""NHN Cloud 실제 발송 내역 API"""
|
||
from services.nhn_alimtalk import get_nhn_send_history
|
||
|
||
date_str = request.args.get('date', datetime.now().strftime('%Y-%m-%d'))
|
||
start = f"{date_str} 00:00"
|
||
end = f"{date_str} 23:59"
|
||
|
||
messages = get_nhn_send_history(start, end)
|
||
|
||
result = []
|
||
for m in messages:
|
||
result.append({
|
||
'requestDate': m.get('requestDate', ''),
|
||
'recipientNo': m.get('recipientNo', ''),
|
||
'templateCode': m.get('templateCode', ''),
|
||
'messageStatus': m.get('messageStatus', ''),
|
||
'resultCode': m.get('resultCode', ''),
|
||
'resultMessage': m.get('resultMessage', ''),
|
||
'content': m.get('content', ''),
|
||
})
|
||
|
||
return jsonify({'success': True, 'messages': result})
|
||
|
||
|
||
@app.route('/api/admin/alimtalk/test-send', methods=['POST'])
|
||
def api_admin_alimtalk_test_send():
|
||
"""관리자 수동 알림톡 발송 테스트"""
|
||
from services.nhn_alimtalk import send_mileage_claim_alimtalk
|
||
|
||
data = request.get_json()
|
||
phone = data.get('phone', '').strip().replace('-', '')
|
||
name = data.get('name', '테스트')
|
||
|
||
if len(phone) < 10:
|
||
return jsonify({'success': False, 'message': '올바른 전화번호를 입력해주세요.'}), 400
|
||
|
||
success, msg = send_mileage_claim_alimtalk(
|
||
phone, name, 100, 500,
|
||
items=[{'name': '테스트 발송', 'qty': 1, 'total': 1000}],
|
||
trigger_source='admin_test'
|
||
)
|
||
|
||
return jsonify({'success': success, 'message': msg})
|
||
|
||
|
||
# ============================================================================
|
||
# 키오스크 적립
|
||
# ============================================================================
|
||
|
||
@app.route('/kiosk')
|
||
def kiosk():
|
||
"""키오스크 메인 페이지 (전체 화면 웹 UI)"""
|
||
return render_template('kiosk.html')
|
||
|
||
|
||
@app.route('/api/kiosk/trigger', methods=['POST'])
|
||
def api_kiosk_trigger():
|
||
"""
|
||
POS → 키오스크 세션 생성
|
||
POST /api/kiosk/trigger
|
||
Body: {"transaction_id": "...", "amount": 50000}
|
||
"""
|
||
global kiosk_current_session
|
||
|
||
data = request.get_json()
|
||
transaction_id = data.get('transaction_id')
|
||
amount = data.get('amount', 0)
|
||
|
||
if not transaction_id:
|
||
return jsonify({'success': False, 'message': 'transaction_id가 필요합니다.'}), 400
|
||
|
||
try:
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
# QR 토큰 존재 확인
|
||
cursor.execute("SELECT id, token_hash, claimable_points, claimed_at FROM claim_tokens WHERE transaction_id = ?",
|
||
(transaction_id,))
|
||
token_row = cursor.fetchone()
|
||
|
||
if token_row and token_row['claimed_at']:
|
||
return jsonify({'success': False, 'message': '이미 적립된 거래입니다.'}), 400
|
||
|
||
if token_row:
|
||
# 기존 토큰 사용 — QR URL은 새 nonce로 생성
|
||
# (verify_claim_token은 transaction_id로만 조회하므로 nonce 불일치 무관)
|
||
claimable_points = token_row['claimable_points']
|
||
nonce = secrets.token_hex(6)
|
||
from utils.qr_token_generator import QR_BASE_URL
|
||
qr_url = f"{QR_BASE_URL}?t={transaction_id}:{nonce}"
|
||
else:
|
||
# 새 토큰 생성
|
||
from utils.qr_token_generator import generate_claim_token, save_token_to_db
|
||
token_info = generate_claim_token(transaction_id, float(amount))
|
||
success, error = save_token_to_db(
|
||
transaction_id,
|
||
token_info['token_hash'],
|
||
float(amount),
|
||
token_info['claimable_points'],
|
||
token_info['expires_at'],
|
||
token_info['pharmacy_id']
|
||
)
|
||
if not success:
|
||
return jsonify({'success': False, 'message': error}), 500
|
||
|
||
claimable_points = token_info['claimable_points']
|
||
qr_url = token_info['qr_url']
|
||
|
||
# MSSQL에서 구매 품목 조회
|
||
sale_items = []
|
||
try:
|
||
mssql_session = db_manager.get_session('PM_PRES')
|
||
sale_sub_query = text("""
|
||
SELECT
|
||
ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name,
|
||
S.SL_NM_item AS quantity,
|
||
S.SL_TOTAL_PRICE AS total
|
||
FROM SALE_SUB S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
WHERE S.SL_NO_order = :transaction_id
|
||
ORDER BY S.DrugCode
|
||
""")
|
||
rows = mssql_session.execute(sale_sub_query, {'transaction_id': transaction_id}).fetchall()
|
||
sale_items = [
|
||
{'name': r.goods_name, 'qty': int(r.quantity or 0), 'total': int(r.total or 0)}
|
||
for r in rows
|
||
]
|
||
except Exception as e:
|
||
logging.warning(f"키오스크 품목 조회 실패 (transaction_id={transaction_id}): {e}")
|
||
|
||
# 키오스크 세션 저장
|
||
kiosk_current_session = {
|
||
'transaction_id': transaction_id,
|
||
'amount': int(amount),
|
||
'points': claimable_points,
|
||
'qr_url': qr_url,
|
||
'items': sale_items,
|
||
'created_at': datetime.now(KST).isoformat()
|
||
}
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': f'키오스크 적립 대기 ({claimable_points}P)',
|
||
'points': claimable_points
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"키오스크 트리거 오류: {e}")
|
||
return jsonify({'success': False, 'message': f'오류: {str(e)}'}), 500
|
||
|
||
|
||
@app.route('/api/kiosk/current')
|
||
def api_kiosk_current():
|
||
"""
|
||
키오스크 폴링 - 현재 세션 조회
|
||
GET /api/kiosk/current
|
||
"""
|
||
global kiosk_current_session
|
||
|
||
if kiosk_current_session is None:
|
||
return jsonify({'active': False})
|
||
|
||
# 5분 경과 시 자동 만료
|
||
created = datetime.fromisoformat(kiosk_current_session['created_at'])
|
||
if datetime.now(KST) - created > timedelta(minutes=5):
|
||
kiosk_current_session = None
|
||
return jsonify({'active': False})
|
||
|
||
return jsonify({
|
||
'active': True,
|
||
'transaction_id': kiosk_current_session['transaction_id'],
|
||
'amount': kiosk_current_session['amount'],
|
||
'points': kiosk_current_session['points'],
|
||
'qr_url': kiosk_current_session.get('qr_url'),
|
||
'items': kiosk_current_session.get('items', [])
|
||
})
|
||
|
||
|
||
@app.route('/api/kiosk/claim', methods=['POST'])
|
||
def api_kiosk_claim():
|
||
"""
|
||
키오스크 전화번호 적립
|
||
POST /api/kiosk/claim
|
||
Body: {"phone": "01012345678"}
|
||
"""
|
||
global kiosk_current_session
|
||
|
||
if kiosk_current_session is None:
|
||
return jsonify({'success': False, 'message': '적립 대기 중인 거래가 없습니다.'}), 400
|
||
|
||
data = request.get_json()
|
||
phone = data.get('phone', '').strip().replace('-', '').replace(' ', '')
|
||
|
||
if len(phone) < 10:
|
||
return jsonify({'success': False, 'message': '올바른 전화번호를 입력해주세요.'}), 400
|
||
|
||
transaction_id = kiosk_current_session['transaction_id']
|
||
|
||
try:
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
# claim_tokens에서 nonce 조회를 위해 token_hash로 검증
|
||
cursor.execute("""
|
||
SELECT id, transaction_id, token_hash, total_amount, claimable_points,
|
||
pharmacy_id, expires_at, claimed_at, claimed_by_user_id
|
||
FROM claim_tokens WHERE transaction_id = ?
|
||
""", (transaction_id,))
|
||
token_row = cursor.fetchone()
|
||
|
||
if not token_row:
|
||
return jsonify({'success': False, 'message': '토큰을 찾을 수 없습니다.'}), 400
|
||
|
||
if token_row['claimed_at']:
|
||
kiosk_current_session = None
|
||
return jsonify({'success': False, 'message': '이미 적립된 거래입니다.'}), 400
|
||
|
||
# 만료 확인
|
||
expires_at = datetime.strptime(token_row['expires_at'], '%Y-%m-%d %H:%M:%S')
|
||
if datetime.now() > expires_at:
|
||
kiosk_current_session = None
|
||
return jsonify({'success': False, 'message': '만료된 거래입니다.'}), 400
|
||
|
||
# token_info 딕셔너리 구성 (claim_mileage 호환)
|
||
token_info = {
|
||
'id': token_row['id'],
|
||
'transaction_id': token_row['transaction_id'],
|
||
'total_amount': token_row['total_amount'],
|
||
'claimable_points': token_row['claimable_points']
|
||
}
|
||
|
||
# 사용자 조회/생성
|
||
user_id, is_new = get_or_create_user(phone, '고객')
|
||
|
||
# 마일리지 적립
|
||
claim_success, claim_msg, new_balance = claim_mileage(user_id, token_info)
|
||
|
||
if not claim_success:
|
||
return jsonify({'success': False, 'message': claim_msg}), 500
|
||
|
||
# 키오스크 세션에서 품목 정보 캡처 후 클리어
|
||
claimed_points = token_info['claimable_points']
|
||
sale_items = kiosk_current_session.get('items', [])
|
||
kiosk_current_session = None
|
||
|
||
# 알림톡 발송 (fire-and-forget)
|
||
try:
|
||
from services.nhn_alimtalk import send_mileage_claim_alimtalk
|
||
|
||
# 유저 이름 조회 (기존 유저는 실명이 있을 수 있음)
|
||
cursor.execute("SELECT nickname FROM users WHERE id = ?", (user_id,))
|
||
user_row = cursor.fetchone()
|
||
user_name = user_row['nickname'] if user_row else '고객'
|
||
|
||
logging.warning(f"[알림톡] 발송 시도: phone={phone}, name={user_name}, points={claimed_points}, balance={new_balance}, items={sale_items}")
|
||
success, msg = send_mileage_claim_alimtalk(
|
||
phone, user_name, claimed_points, new_balance,
|
||
items=sale_items, user_id=user_id,
|
||
trigger_source='kiosk', transaction_id=transaction_id
|
||
)
|
||
logging.warning(f"[알림톡] 발송 결과: success={success}, msg={msg}")
|
||
except Exception as alimtalk_err:
|
||
logging.warning(f"[알림톡] 발송 예외 (적립은 완료): {alimtalk_err}")
|
||
|
||
# AI 업셀링 추천 생성 (별도 스레드 — 적립 응답 블로킹 방지)
|
||
import threading
|
||
def _bg_upsell():
|
||
try:
|
||
_generate_upsell_recommendation(user_id, transaction_id, sale_items, user_name)
|
||
except Exception as rec_err:
|
||
logging.warning(f"[AI추천] 생성 예외 (적립은 완료): {rec_err}")
|
||
threading.Thread(target=_bg_upsell, daemon=True).start()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': f'{claimed_points}P 적립 완료!',
|
||
'points': claimed_points,
|
||
'balance': new_balance,
|
||
'is_new_user': is_new
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"키오스크 적립 오류: {e}")
|
||
return jsonify({'success': False, 'message': f'오류: {str(e)}'}), 500
|
||
|
||
|
||
# ===== AI Gateway 모니터 페이지 =====
|
||
|
||
@app.route('/admin/ai-gw')
|
||
def admin_ai_gw():
|
||
"""AI Gateway 모니터 페이지"""
|
||
return render_template('admin_ai_gw.html')
|
||
|
||
|
||
# ===== 판매 상세 조회 페이지 =====
|
||
|
||
@app.route('/admin/sales-detail')
|
||
def admin_sales_detail():
|
||
"""판매 상세 조회 페이지 (상품코드/바코드/표준코드 매핑)"""
|
||
return render_template('admin_sales_detail.html')
|
||
|
||
|
||
# =============================================================================
|
||
# 동물약 추천 챗봇 API
|
||
# =============================================================================
|
||
|
||
# 동물약 지식 베이스 (RAG 컨텍스트)
|
||
ANIMAL_DRUG_KNOWLEDGE = """
|
||
## 🐕 심장사상충 예방약 (매월 투여)
|
||
- **하트가드 (Heartgard)**: 이버멕틴 성분, 츄어블, 소/중/대형견용, 고기맛
|
||
- **다이로하트 (Dirohart)**: 이버멕틴 성분, 하트가드 제네릭, 경제적
|
||
- **하트캅 (Heartcap)**: 밀베마이신 옥심, 태블릿, 정밀한 용량
|
||
|
||
## 🐕 외부기생충 (벼룩/진드기) 예방약
|
||
- **넥스가드 (NexGard)**: 아폭솔라너 성분, 츄어블, 1개월 지속, 맛있는 소고기맛
|
||
- **넥스가드 스펙트라**: 넥스가드 + 심장사상충 예방 (아폭솔라너 + 밀베마이신)
|
||
- **브라벡토 (Bravecto)**: 플루랄라너 성분, 츄어블, **3개월** 지속 (12주)
|
||
- **심파리카 (Simparica)**: 사롤라너 성분, 츄어블, 1개월 지속
|
||
- **심파리카 트리오**: 심파리카 + 심장사상충 + 내부기생충 예방
|
||
- **크레델리오 (Credelio)**: 로틸라너 성분, 1개월 지속, 소형 정제
|
||
|
||
## 🐱 고양이 전용
|
||
- **브라벡토 스팟온 (고양이)**: 외부기생충, 3개월 지속, 바르는 타입
|
||
- **레볼루션 (Revolution)**: 셀라멕틴 성분, 스팟온, 심장사상충 + 벼룩 + 귀진드기
|
||
- **브로드라인**: 피프로닐 + 에피프리노미드, 내/외부기생충 + 심장사상충
|
||
|
||
## 🪱 내부기생충 (구충제)
|
||
- **파라캅 (Paracap)**: 펜벤다졸 성분, 광범위 구충
|
||
- **드론탈 (Drontal)**: 프라지콴텔 + 피란텔, 국제적 신뢰
|
||
- 기타 구충제는 위 "보유 동물약" 목록의 [대상:] 정보 참조
|
||
|
||
## 💊 용량 안내
|
||
- 체중별 제품 선택이 중요합니다
|
||
- 소형견(5kg 미만), 중형견(5-10kg), 대형견(10-25kg), 초대형견(25kg 이상)
|
||
- **각 제품의 [대상:] 정보를 확인하세요** - 개/고양이 사용 가능 여부가 표시됨
|
||
|
||
## ⚠️ 주의사항
|
||
- 콜리/셸티/보더콜리 등 MDR1 유전자 변이견은 이버멕틴 주의
|
||
- 임신/수유 중인 동물은 수의사 상담 필요
|
||
- 체중 정확히 측정 후 제품 선택
|
||
|
||
## 🚨 항생제 필수 경고 (퀴놀론계)
|
||
- **엔로플록사신(아시엔로, Baytril)**: 🐱 고양이 망막 독성! 5mg/kg/day 초과 시 실명 위험. 대안: 마르보플록사신
|
||
- **이버멕틴 고용량**: MDR1 유전자 변이견(콜리, 셸티, 오스트레일리안 셰퍼드) 신경독성 주의
|
||
- **어린 동물 퀴놀론계**: 연골 발달에 영향, 성장기 동물 주의
|
||
"""
|
||
|
||
# 동물약 챗봇 System Prompt
|
||
ANIMAL_CHAT_SYSTEM_PROMPT = """당신은 약국의 동물약 전문 상담사입니다.
|
||
|
||
**⚠️ 중요: 아래 "보유 동물약" 목록의 [대상:, 용량:, 금기:] 정보를 최우선으로 참조하세요!**
|
||
|
||
**역할:** 동물약 추천, 용법/용량 안내, 주의사항 안내
|
||
|
||
**💊 업셀링 규칙 (중요!):**
|
||
- **항생제 질문 시**: "항생제 복용 중에는 장 건강을 위해 정장제(유산균)를 함께 급여하면 좋아요!"라고 안내하고, 제스타제 같은 정장제도 함께 추천
|
||
- **구충제 질문 시**: 정기적인 예방 일정 안내
|
||
- **피부약 질문 시**: 2단계 추천 구조 적용
|
||
- 🔹 **1차 추천(치료)**: 증상에 맞는 의약품 (개시딘겔-세균성피부염, 테르비덤-진균감염 등)
|
||
- 🔹 **2차 언급(보조케어)**: "염증이 가라앉은 후 회복기에는 스킨카솔 같은 피부케어 제품(의약외품)도 도움이 됩니다"
|
||
- ⚠️ 스킨카솔은 **의약외품**이므로 염증 "치료"용으로 추천하지 말 것! 피부 재생/보호/보습 목적으로만 언급
|
||
- 긁힘, 작은 상처, 피부 건조, 털빠짐 예방 → 스킨카솔 단독 추천 가능
|
||
|
||
**질문 유형별 응답:**
|
||
|
||
1. **일반/정보 질문** ("~가 뭐야?", "~종류가 뭐가 있어?", "~에 대해 알려줘")
|
||
- 📚 RAG 검색 결과를 활용해 **일반적인 정보** 제공
|
||
- 시중에 있는 여러 제품/성분 설명 가능
|
||
- 예: "바르는 동물약 종류" → 셀라멕틴(레볼루션, 셀라이트), 피프로닐(프론트라인, 프로닐스팟) 등
|
||
|
||
2. **추천/구매 질문** ("~추천해줘", "~사려고", "우리 약국에 있어?", "재고")
|
||
- 📦 **보유 제품 목록**에서만 추천
|
||
- 재고 있는 제품 위주로 안내
|
||
- 예: "심장사상충 약 추천해줘" → 보유 중인 하트세이버, 다이로하트 추천
|
||
|
||
3. **비교/상세 질문** ("~랑 차이", "자세히", "왜", "성분", "작용기전")
|
||
- 📚 RAG + 보유 목록 둘 다 활용
|
||
- 일반적 비교 설명 + 우리 약국 보유 여부 안내
|
||
- 길게 상세히 (10-15문장)
|
||
|
||
**⚠️ 투여방법 구분 (필수!):**
|
||
- "먹는 약", "경구", "복용" 질문 → 내복약만 추천 (정제, 츄어블, 캡슐, 시럽)
|
||
- "바르는 약", "도포", "외용" 질문 → 외용약만 추천 (겔, 스팟온, 크림, 연고)
|
||
- RAG 정보의 "제형", "분류", "체중/부위" 필드 확인 필수
|
||
- 외용약(겔, 도포, 환부에 직접)은 절대 "먹는 약"으로 추천하지 않음!
|
||
- 보유 제품 목록의 [내복/외용] 표시 확인!
|
||
|
||
**기본 규칙:**
|
||
1. 체중별 제품은 정확한 전체 이름 사용 (안텔민킹, 안텔민뽀삐 등)
|
||
2. 용량/투약 질문: 체중별 표 형식으로 정리
|
||
3. 친근하게 🐕🐱
|
||
4. **업셀링은 자연스럽게** - 강요하지 말고 "~하면 좋아요" 식으로 부드럽게 권유
|
||
|
||
{available_products}
|
||
|
||
{knowledge_base}"""
|
||
|
||
|
||
def _get_animal_drug_rag(apc_codes):
|
||
"""PostgreSQL에서 동물약 상세 정보 조회 (RAG용)"""
|
||
if not apc_codes:
|
||
return {}
|
||
|
||
try:
|
||
from sqlalchemy import create_engine
|
||
pg_engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
|
||
with pg_engine.connect() as conn:
|
||
# APC 코드들로 상세 정보 조회
|
||
placeholders = ','.join([f"'{apc}'" for apc in apc_codes if apc])
|
||
if not placeholders:
|
||
return {}
|
||
|
||
result = conn.execute(text(f"""
|
||
SELECT apc, product_name, main_ingredient,
|
||
component_name_ko,
|
||
image_url1,
|
||
llm_pharm->>'사용가능 동물' as target_animals,
|
||
llm_pharm->>'분류' as category,
|
||
llm_pharm->>'쉬운분류' as easy_category,
|
||
llm_pharm->>'약품 제형' as dosage_form,
|
||
llm_pharm->>'체중/부위' as dosage_weight,
|
||
llm_pharm->>'기간/용법' as usage_period,
|
||
llm_pharm->>'월령금기' as age_restriction,
|
||
llm_pharm->>'반려인주의' as owner_caution,
|
||
llm_pharm->>'주성분' as main_ingredient_detail,
|
||
llm_pharm->>'LLM설명' as description,
|
||
llm_pharm->>'어떤질병에사용하나요?' as usage_for
|
||
FROM apc
|
||
WHERE apc IN ({placeholders})
|
||
"""))
|
||
|
||
rag_data = {}
|
||
for row in result:
|
||
rag_data[row.apc] = {
|
||
'target_animals': row.target_animals or '정보 없음',
|
||
'category': row.category or '',
|
||
'easy_category': row.easy_category or '',
|
||
'dosage_form': row.dosage_form or '',
|
||
'dosage_weight': row.dosage_weight or '',
|
||
'usage_period': row.usage_period or '',
|
||
'age_restriction': row.age_restriction or '',
|
||
'owner_caution': row.owner_caution or '',
|
||
'main_ingredient': row.component_name_ko or row.main_ingredient_detail or row.main_ingredient or '',
|
||
'description': row.description or '',
|
||
'usage_for': row.usage_for or '',
|
||
'image_url': row.image_url1 or ''
|
||
}
|
||
return rag_data
|
||
except Exception as e:
|
||
logging.warning(f"동물약 RAG 조회 실패: {e}")
|
||
return {}
|
||
|
||
|
||
def _get_animal_drugs():
|
||
"""보유 중인 동물약 목록 조회 (APC 이미지 포함)
|
||
|
||
APC 우선순위:
|
||
1. CD_ITEM_UNIT_MEMBER에서 APC 코드 (0xx: ~2023년, 9xx: 2024년~)
|
||
2. 없으면 기존 BARCODE를 PostgreSQL에서 조회 (바코드=APC인 경우)
|
||
"""
|
||
try:
|
||
drug_session = db_manager.get_session('PM_DRUG')
|
||
# CD_ITEM_UNIT_MEMBER에서 APC 바코드 조회 + IM_total에서 재고 조회
|
||
query = text("""
|
||
SELECT
|
||
G.DrugCode,
|
||
G.GoodsName,
|
||
G.Saleprice,
|
||
G.BARCODE,
|
||
ISNULL(IT.IM_QT_sale_debit, 0) AS Stock,
|
||
(
|
||
SELECT TOP 1 U.CD_CD_BARCODE
|
||
FROM CD_ITEM_UNIT_MEMBER U
|
||
WHERE U.DRUGCODE = G.DrugCode
|
||
AND (U.CD_CD_BARCODE LIKE '02%' OR U.CD_CD_BARCODE LIKE '92%')
|
||
AND LEN(U.CD_CD_BARCODE) = 13
|
||
ORDER BY U.CHANGE_DATE DESC
|
||
) AS APC_CODE
|
||
FROM CD_GOODS G
|
||
LEFT JOIN IM_total IT ON G.DrugCode = IT.DrugCode
|
||
WHERE G.POS_BOON = '010103'
|
||
AND G.GoodsSelCode = 'B'
|
||
ORDER BY G.GoodsName
|
||
""")
|
||
rows = drug_session.execute(query).fetchall()
|
||
|
||
result = []
|
||
apc_list = []
|
||
for r in rows:
|
||
apc = r.APC_CODE if hasattr(r, 'APC_CODE') else None
|
||
barcode = r.BARCODE or ''
|
||
|
||
# APC가 없으면 바코드를 APC로 사용 (PostgreSQL에서 바코드=APC인 경우)
|
||
if not apc and barcode:
|
||
apc = barcode
|
||
|
||
if apc:
|
||
apc_list.append(apc)
|
||
|
||
result.append({
|
||
'code': r.DrugCode,
|
||
'name': r.GoodsName,
|
||
'price': float(r.Saleprice) if r.Saleprice else 0,
|
||
'barcode': barcode,
|
||
'apc': apc,
|
||
'stock': int(r.Stock) if r.Stock else 0,
|
||
'wholesaler_stock': 0, # PostgreSQL에서 가져옴
|
||
'category': None, # PostgreSQL에서 가져옴
|
||
'image_url': None # PostgreSQL에서 가져옴
|
||
})
|
||
|
||
# PostgreSQL에서 이미지 URL + 도매상 재고 가져오기
|
||
if apc_list:
|
||
try:
|
||
from sqlalchemy import create_engine
|
||
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 + 분류 조회
|
||
info_result = conn.execute(text(f"""
|
||
SELECT apc, image_url1, llm_pharm->>'분류' as category
|
||
FROM apc WHERE apc IN ({placeholders})
|
||
"""))
|
||
info_map = {row.apc: {'image_url': row.image_url1, 'category': row.category} for row in info_result}
|
||
|
||
# 도매상 재고 조회 (SUM)
|
||
stock_result = conn.execute(text(f"""
|
||
SELECT A.apc, 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
|
||
"""))
|
||
stock_map = {row.apc: int(row.wholesaler_stock) for row in stock_result}
|
||
|
||
for item in result:
|
||
if 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:
|
||
item['wholesaler_stock'] = 0
|
||
except Exception as e:
|
||
logging.warning(f"PostgreSQL 이미지/재고 조회 실패: {e}")
|
||
|
||
return result
|
||
except Exception as e:
|
||
logging.warning(f"동물약 목록 조회 실패: {e}")
|
||
return []
|
||
|
||
|
||
@app.route('/api/animal-chat', methods=['POST'])
|
||
def api_animal_chat():
|
||
"""
|
||
동물약 추천 챗봇 API
|
||
|
||
Request:
|
||
{
|
||
"messages": [
|
||
{"role": "user", "content": "강아지 심장사상충 약 추천해주세요"}
|
||
]
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"success": true,
|
||
"message": "AI 응답 텍스트",
|
||
"products": [{"name": "...", "price": ...}] // 언급된 보유 제품
|
||
}
|
||
"""
|
||
try:
|
||
import time
|
||
from utils.animal_chat_logger import ChatLogEntry, log_chat
|
||
|
||
# 로그 엔트리 초기화
|
||
log_entry = ChatLogEntry()
|
||
total_start = time.time()
|
||
|
||
if not OPENAI_AVAILABLE:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': 'AI 기능을 사용할 수 없습니다. 관리자에게 문의하세요.'
|
||
}), 503
|
||
|
||
data = request.get_json()
|
||
messages = data.get('messages', [])
|
||
|
||
if not messages:
|
||
return jsonify({
|
||
'success': False,
|
||
'message': '메시지를 입력해주세요.'
|
||
}), 400
|
||
|
||
# 입력 로깅
|
||
last_user_msg = next((m['content'] for m in reversed(messages) if m.get('role') == 'user'), '')
|
||
log_entry.user_message = last_user_msg
|
||
log_entry.history_length = len(messages)
|
||
log_entry.session_id = data.get('session_id', '')
|
||
|
||
# 보유 동물약 목록 조회 (MSSQL)
|
||
mssql_start = time.time()
|
||
animal_drugs = _get_animal_drugs()
|
||
log_entry.mssql_drug_count = len(animal_drugs)
|
||
log_entry.mssql_duration_ms = int((time.time() - mssql_start) * 1000)
|
||
|
||
# APC가 있는 제품의 상세 정보 조회 (PostgreSQL RAG)
|
||
pgsql_start = time.time()
|
||
apc_codes = [d['apc'] for d in animal_drugs if d.get('apc')]
|
||
rag_data = _get_animal_drug_rag(apc_codes) if apc_codes else {}
|
||
log_entry.pgsql_rag_count = len(rag_data)
|
||
log_entry.pgsql_duration_ms = int((time.time() - pgsql_start) * 1000)
|
||
|
||
available_products_text = ""
|
||
if animal_drugs:
|
||
product_lines = []
|
||
for d in animal_drugs:
|
||
line = f"- {d['name']} ({d['price']:,.0f}원)"
|
||
# RAG 정보 추가
|
||
if d.get('apc') and d['apc'] in rag_data:
|
||
info = rag_data[d['apc']]
|
||
details = []
|
||
|
||
# 투여방법 표시 (내복/외용 구분)
|
||
admin_type = ""
|
||
dosage_form = info.get('dosage_form', '').lower()
|
||
easy_cat = info.get('easy_category', '').lower()
|
||
dosage_weight = info.get('dosage_weight', '').lower()
|
||
|
||
# 외용약 판별 (겔, 크림, 연고, 스팟온, 도포, 환부)
|
||
if any(kw in dosage_form for kw in ['겔', '크림', '연고', '스팟온', '점이', '외용']) or \
|
||
any(kw in easy_cat for kw in ['외용', '피부약', '점이', '점안']) or \
|
||
any(kw in dosage_weight for kw in ['도포', '환부', '바르']):
|
||
admin_type = "외용"
|
||
# 내복약 판별 (정제, 츄어블, 캡슐, 시럽, 경구)
|
||
elif any(kw in dosage_form for kw in ['정제', '츄어블', '캡슐', '시럽', '산제', '과립', '액제', '경구']) or \
|
||
any(kw in easy_cat for kw in ['내복', '경구', '구충', '심장사상충', '정장', '소화']):
|
||
admin_type = "내복"
|
||
|
||
if admin_type:
|
||
form_display = info.get('dosage_form', '')[:10] if info.get('dosage_form') else ''
|
||
cat_display = info.get('easy_category', '')[:15] if info.get('easy_category') else ''
|
||
details.append(f"💊{admin_type}/{form_display}, {cat_display}")
|
||
|
||
if info.get('target_animals'):
|
||
details.append(f"대상: {info['target_animals']}")
|
||
if info.get('main_ingredient'):
|
||
details.append(f"성분: {info['main_ingredient']}")
|
||
if info.get('usage_for'):
|
||
details.append(f"용도: {info['usage_for']}")
|
||
if info.get('dosage_weight'):
|
||
details.append(f"용량: {info['dosage_weight']}")
|
||
if info.get('age_restriction'):
|
||
details.append(f"금기: {info['age_restriction']}")
|
||
if details:
|
||
line += f" [{', '.join(details)}]"
|
||
product_lines.append(line)
|
||
|
||
available_products_text = f"""
|
||
**현재 보유 동물약:**
|
||
{chr(10).join(product_lines)}
|
||
"""
|
||
|
||
# 상세 질문 감지 (전역으로 사용)
|
||
detail_keywords = ['자세히', '상세히', '더 알려', '설명해', '왜', '어떻게', '원리', '기전', '성분']
|
||
is_detail_request = any(kw in last_user_msg for kw in detail_keywords)
|
||
|
||
# 투여방법 키워드 감지
|
||
oral_keywords = ['먹는', '먹이는', '경구', '복용', '츄어블', '정제', '캡슐']
|
||
topical_keywords = ['바르는', '도포', '외용', '스팟온', '겔', '크림', '연고']
|
||
|
||
is_oral_request = any(kw in last_user_msg for kw in oral_keywords)
|
||
is_topical_request = any(kw in last_user_msg for kw in topical_keywords)
|
||
|
||
# 투여방법 필터 안내 추가
|
||
admin_filter_note = ""
|
||
if is_oral_request and not is_topical_request:
|
||
admin_filter_note = "\n\n⚠️ **사용자가 '먹는 약/경구/복용' 질문** → 내복약(정제, 츄어블, 캡슐)만 추천! 외용약(겔, 크림, 도포)은 추천하지 마세요!"
|
||
elif is_topical_request and not is_oral_request:
|
||
admin_filter_note = "\n\n⚠️ **사용자가 '바르는 약/외용/도포' 질문** → 외용약(겔, 크림, 스팟온)만 추천! 내복약(정제, 츄어블)은 추천하지 마세요!"
|
||
|
||
# 🚨 엔로플록사신/퀴놀론 관련 질문 감지 → 망막 독성 경고 주입
|
||
enro_keywords = ['엔로플록사신', '아시엔로', 'asienro', 'enrofloxacin', '퀴놀론', 'fluoroquinolone', 'baytril', '바이트릴']
|
||
is_enro_question = any(kw.lower() in last_user_msg.lower() for kw in enro_keywords)
|
||
|
||
enro_warning = ""
|
||
if is_enro_question:
|
||
enro_warning = """
|
||
|
||
🚨 **[필수 안내] 엔로플록사신 고양이 망막 독성 - 반드시 답변에 포함!**
|
||
- FDA 부작용 보고: 산동 454건, 실명 399건, 망막장애 244건
|
||
- 고양이는 **5mg/kg/day 절대 초과 금지** (권장: 2.5~5mg/kg)
|
||
- 15mg/kg/day × 21일 → 망막 이상, 30mg/kg/day → 급성 실명
|
||
- 탈수/신장질환 시 위험 증가 (약물 배설 지연)
|
||
- 안전한 대안: Marbofloxacin(마르보플록사신), Pradofloxacin(프라도플록사신)
|
||
- ⚠️ 이 경고를 답변 첫 부분에 반드시 언급하세요!
|
||
"""
|
||
|
||
# 벡터 DB 검색 (LanceDB RAG)
|
||
vector_context = ""
|
||
vector_start = time.time()
|
||
try:
|
||
from utils.animal_rag import get_animal_rag
|
||
if last_user_msg:
|
||
n_results = 5 if is_detail_request else 3
|
||
|
||
rag = get_animal_rag()
|
||
vector_results = rag.search(last_user_msg, n_results=n_results)
|
||
log_entry.vector_results_count = len(vector_results)
|
||
log_entry.vector_top_scores = [r.get('score', 0) for r in vector_results]
|
||
log_entry.vector_sources = [f"{r.get('source', '')}#{r.get('section', '')}" for r in vector_results]
|
||
vector_context = rag.get_context_for_chat(last_user_msg, n_results=n_results)
|
||
|
||
# 상세 요청 시 컨텍스트 활용 지시 추가
|
||
if is_detail_request and vector_context:
|
||
vector_context = "⚠️ 아래 RAG 검색 결과를 적극 활용하여 상세하게 답변하세요!\n\n" + vector_context
|
||
except Exception as e:
|
||
logging.warning(f"벡터 검색 실패 (무시): {e}")
|
||
log_entry.vector_duration_ms = int((time.time() - vector_start) * 1000)
|
||
|
||
# System Prompt 구성
|
||
knowledge_section = ANIMAL_DRUG_KNOWLEDGE + "\n\n" + vector_context if vector_context else ANIMAL_DRUG_KNOWLEDGE
|
||
knowledge_section += admin_filter_note # 투여방법 필터 안내 추가
|
||
knowledge_section += enro_warning # 🚨 엔로플록사신 망막 독성 경고 추가
|
||
|
||
system_prompt = ANIMAL_CHAT_SYSTEM_PROMPT.format(
|
||
available_products=available_products_text,
|
||
knowledge_base=knowledge_section
|
||
)
|
||
|
||
# OpenAI API 호출
|
||
openai_start = time.time()
|
||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||
|
||
api_messages = [{"role": "system", "content": system_prompt}]
|
||
for msg in messages[-10:]: # 최근 10개 메시지만
|
||
api_messages.append({
|
||
"role": msg.get("role", "user"),
|
||
"content": msg.get("content", "")
|
||
})
|
||
|
||
# max_tokens 동적 설정 (상세 질문 시 증가)
|
||
max_tokens = 1500 if is_detail_request else 500
|
||
|
||
response = client.chat.completions.create(
|
||
model=OPENAI_MODEL,
|
||
messages=api_messages,
|
||
max_tokens=max_tokens,
|
||
temperature=0.7
|
||
)
|
||
|
||
ai_response = response.choices[0].message.content
|
||
|
||
# OpenAI 로깅
|
||
log_entry.openai_model = OPENAI_MODEL
|
||
log_entry.openai_prompt_tokens = response.usage.prompt_tokens
|
||
log_entry.openai_completion_tokens = response.usage.completion_tokens
|
||
log_entry.openai_total_tokens = response.usage.total_tokens
|
||
log_entry.openai_duration_ms = int((time.time() - openai_start) * 1000)
|
||
|
||
# 응답에서 언급된 보유 제품 찾기 (부분 매칭)
|
||
mentioned_products = []
|
||
# 공백 제거한 버전도 준비 (AI가 띄어쓰기 넣을 수 있음)
|
||
ai_response_lower = ai_response.lower()
|
||
ai_response_nospace = ai_response_lower.replace(' ', '')
|
||
|
||
# 제품명 길이순 정렬 (긴 이름 우선 매칭 - "안텔민킹"이 "안텔민"보다 먼저)
|
||
sorted_drugs = sorted(animal_drugs, key=lambda x: len(x['name']), reverse=True)
|
||
|
||
for drug in sorted_drugs:
|
||
drug_name = drug['name']
|
||
# (판) 같은 접두어 제거
|
||
clean_name = drug_name
|
||
if clean_name.startswith('(판)'):
|
||
clean_name = clean_name[3:]
|
||
# 제품명에서 핵심 키워드 추출 (괄호 앞부분)
|
||
# 예: "다이로하트정M(12~22kg)" → "다이로하트"
|
||
base_name = clean_name.split('(')[0].split('/')[0].strip()
|
||
# 사이즈 제거: "다이로하트정M" → "다이로하트"
|
||
for suffix in ['정', '액', 'L', 'M', 'S', 'XL', 'XS', 'SS', 'mini']:
|
||
if base_name.endswith(suffix):
|
||
base_name = base_name[:-len(suffix)]
|
||
base_name = base_name.strip()
|
||
|
||
# 핵심 키워드가 AI 응답에 포함되어 있는지 확인
|
||
# 공백 있는 버전과 없는 버전 모두 체크
|
||
base_lower = base_name.lower()
|
||
base_nospace = base_lower.replace(' ', '')
|
||
|
||
# 기본 매칭
|
||
matched = (len(base_name) >= 2 and
|
||
(base_lower in ai_response_lower or base_nospace in ai_response_nospace))
|
||
|
||
# 추가: 상위 키워드 매칭 (예: "안텔민" 언급 시 "안텔민킹", "안텔민뽀삐"도 매칭)
|
||
# APC 있는 제품 우선
|
||
if not matched and drug.get('apc'):
|
||
# 제품명의 핵심 부분 추출 (예: "안텔민킹" → "안텔민")
|
||
core_name = base_nospace.rstrip('킹뽀삐')
|
||
if len(core_name) >= 2 and core_name in ai_response_nospace:
|
||
matched = True
|
||
|
||
if matched:
|
||
# 중복 방지: 이미 추가된 더 specific한 제품이 있으면 건너뜀
|
||
# 예: "안텔민킹" 추가됨 → "안텔민" 건너뜀
|
||
already_covered = any(
|
||
base_nospace in p['name'].lower().replace(' ', '') or
|
||
p['name'].lower().replace(' ', '').startswith(base_nospace)
|
||
for p in mentioned_products
|
||
)
|
||
if not already_covered:
|
||
mentioned_products.append({
|
||
'name': drug_name,
|
||
'price': drug['price'],
|
||
'code': drug['code'],
|
||
'image_url': drug.get('image_url'), # APC 이미지 URL
|
||
'stock': drug.get('stock', 0), # 약국 재고
|
||
'wholesaler_stock': drug.get('wholesaler_stock', 0), # 도매상 재고
|
||
'category': drug.get('category') # 분류 (내부구충제, 심장사상충약 등)
|
||
})
|
||
|
||
# 최종 로깅
|
||
log_entry.assistant_response = ai_response
|
||
log_entry.products_mentioned = [p['name'] for p in mentioned_products[:5]]
|
||
log_entry.total_duration_ms = int((time.time() - total_start) * 1000)
|
||
log_chat(log_entry)
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': ai_response,
|
||
'products': mentioned_products[:5], # 최대 5개
|
||
'usage': {
|
||
'input_tokens': response.usage.prompt_tokens,
|
||
'output_tokens': response.usage.completion_tokens
|
||
}
|
||
})
|
||
|
||
except RateLimitError as e:
|
||
log_entry.error = f"RateLimitError: {e}"
|
||
log_entry.total_duration_ms = int((time.time() - total_start) * 1000)
|
||
log_chat(log_entry)
|
||
return jsonify({
|
||
'success': False,
|
||
'message': 'AI 사용량 한도에 도달했습니다. 잠시 후 다시 시도해주세요.'
|
||
}), 429
|
||
except APITimeoutError as e:
|
||
log_entry.error = f"APITimeoutError: {e}"
|
||
log_entry.total_duration_ms = int((time.time() - total_start) * 1000)
|
||
log_chat(log_entry)
|
||
return jsonify({
|
||
'success': False,
|
||
'message': 'AI 응답 시간이 초과되었습니다. 다시 시도해주세요.'
|
||
}), 504
|
||
except Exception as e:
|
||
logging.error(f"동물약 챗봇 오류: {e}")
|
||
log_entry.error = str(e)
|
||
log_entry.total_duration_ms = int((time.time() - total_start) * 1000)
|
||
log_chat(log_entry)
|
||
return jsonify({
|
||
'success': False,
|
||
'message': f'오류가 발생했습니다: {str(e)}'
|
||
}), 500
|
||
|
||
|
||
@app.route('/api/animal-drugs')
|
||
def api_animal_drugs():
|
||
"""보유 동물약 목록 API"""
|
||
try:
|
||
drugs = _get_animal_drugs()
|
||
return jsonify({
|
||
'success': True,
|
||
'items': drugs,
|
||
'count': len(drugs)
|
||
})
|
||
except Exception as e:
|
||
return jsonify({
|
||
'success': False,
|
||
'error': str(e)
|
||
}), 500
|
||
|
||
|
||
# ===== 제품 검색 페이지 =====
|
||
|
||
@app.route('/admin/products')
|
||
def admin_products():
|
||
"""제품 검색 페이지 (전체 재고에서 검색, QR 인쇄)"""
|
||
return render_template('admin_products.html')
|
||
|
||
|
||
@app.route('/admin/drug-usage')
|
||
def admin_drug_usage():
|
||
"""기간별 사용약품 조회 페이지"""
|
||
return render_template('admin_drug_usage.html')
|
||
|
||
|
||
@app.route('/admin/members')
|
||
def admin_members():
|
||
"""회원 검색 페이지 (팜IT3000 CD_PERSON, 알림톡/SMS 발송)"""
|
||
return render_template('admin_members.html')
|
||
|
||
|
||
@app.route('/api/products')
|
||
def api_products():
|
||
"""
|
||
제품 검색 API
|
||
- 상품명, 상품코드, 바코드로 검색
|
||
- 세트상품 바코드도 CD_ITEM_UNIT_MEMBER에서 조회
|
||
- in_stock_only: 사용약품만 (IM_total에 재고 있는 제품)
|
||
"""
|
||
search = request.args.get('search', '').strip()
|
||
limit = int(request.args.get('limit', 100))
|
||
animal_only = request.args.get('animal_only', '0') == '1'
|
||
in_stock_only = request.args.get('in_stock_only', '0') == '1'
|
||
|
||
# 동물약만 체크시 검색어 없어도 전체 조회 가능
|
||
if not animal_only and (not search or len(search) < 2):
|
||
return jsonify({'success': False, 'error': '검색어는 2글자 이상 입력하세요'})
|
||
|
||
try:
|
||
drug_session = db_manager.get_session('PM_DRUG')
|
||
|
||
# WHERE 조건 생성 (동물약 전체 조회 시 검색 조건 없음)
|
||
search_condition = ""
|
||
if search:
|
||
search_condition = """
|
||
AND (G.GoodsName LIKE :search_like
|
||
OR G.DrugCode LIKE :search_like
|
||
OR G.BARCODE LIKE :search_like)
|
||
"""
|
||
|
||
# 동물약만 필터 (쿼리에서 직접 처리)
|
||
animal_condition = "AND G.POS_BOON = '010103'" if animal_only else ""
|
||
|
||
# 제품 검색 쿼리 - 사용약품만 옵션에 따라 JOIN 방식 변경
|
||
if in_stock_only:
|
||
# 최적화된 쿼리: 재고 있는 제품만 (IM_total INNER JOIN)
|
||
# 대표바코드만 표시, 단위바코드 첫번째/갯수, APC(02%)
|
||
products_query = text(f"""
|
||
SELECT TOP {limit}
|
||
G.DrugCode as drug_code,
|
||
G.GoodsName as product_name,
|
||
ISNULL(NULLIF(G.BARCODE, ''), '') as barcode,
|
||
G.Saleprice as sale_price,
|
||
G.Price as cost_price,
|
||
ISNULL(G.SplName, '') as supplier,
|
||
0 as is_set,
|
||
G.POS_BOON as pos_boon,
|
||
IT.IM_QT_sale_debit as stock,
|
||
ISNULL(POS.CD_NM_sale, '') as location,
|
||
APC.CD_CD_BARCODE as apc_code,
|
||
UNIT_FIRST.CD_CD_BARCODE as unit_barcode,
|
||
ISNULL(UNIT_CNT.cnt, 0) as unit_barcode_count
|
||
FROM CD_GOODS G
|
||
INNER JOIN IM_total IT ON G.DrugCode = IT.DrugCode AND IT.IM_QT_sale_debit > 0
|
||
LEFT JOIN CD_item_position POS ON G.DrugCode = POS.DrugCode
|
||
OUTER APPLY (
|
||
SELECT TOP 1 CD_CD_BARCODE
|
||
FROM CD_ITEM_UNIT_MEMBER
|
||
WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE LIKE '02%'
|
||
) APC
|
||
OUTER APPLY (
|
||
SELECT TOP 1 CD_CD_BARCODE
|
||
FROM CD_ITEM_UNIT_MEMBER
|
||
WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != ''
|
||
) UNIT_FIRST
|
||
OUTER APPLY (
|
||
SELECT COUNT(*) as cnt
|
||
FROM CD_ITEM_UNIT_MEMBER
|
||
WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != ''
|
||
) UNIT_CNT
|
||
WHERE 1=1
|
||
{animal_condition}
|
||
{search_condition}
|
||
ORDER BY G.GoodsName
|
||
""")
|
||
else:
|
||
# 전체 쿼리 (OUTER APPLY 포함, 느림)
|
||
# 동물약만 조회 시 OUTER APPLY 생략 가능
|
||
if animal_only:
|
||
products_query = text(f"""
|
||
SELECT TOP {limit}
|
||
G.DrugCode as drug_code,
|
||
G.GoodsName as product_name,
|
||
ISNULL(NULLIF(G.BARCODE, ''), '') as barcode,
|
||
G.Saleprice as sale_price,
|
||
G.Price as cost_price,
|
||
ISNULL(G.SplName, '') as supplier,
|
||
0 as is_set,
|
||
G.POS_BOON as pos_boon,
|
||
ISNULL(IT.IM_QT_sale_debit, 0) as stock,
|
||
ISNULL(POS.CD_NM_sale, '') as location,
|
||
APC.CD_CD_BARCODE as apc_code,
|
||
UNIT_FIRST.CD_CD_BARCODE as unit_barcode,
|
||
ISNULL(UNIT_CNT.cnt, 0) as unit_barcode_count
|
||
FROM CD_GOODS G
|
||
LEFT JOIN IM_total IT ON G.DrugCode = IT.DrugCode
|
||
LEFT JOIN CD_item_position POS ON G.DrugCode = POS.DrugCode
|
||
OUTER APPLY (
|
||
SELECT TOP 1 CD_CD_BARCODE
|
||
FROM CD_ITEM_UNIT_MEMBER
|
||
WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE LIKE '02%'
|
||
) APC
|
||
OUTER APPLY (
|
||
SELECT TOP 1 CD_CD_BARCODE
|
||
FROM CD_ITEM_UNIT_MEMBER
|
||
WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != ''
|
||
) UNIT_FIRST
|
||
OUTER APPLY (
|
||
SELECT COUNT(*) as cnt
|
||
FROM CD_ITEM_UNIT_MEMBER
|
||
WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != ''
|
||
) UNIT_CNT
|
||
WHERE G.POS_BOON = '010103'
|
||
{search_condition}
|
||
ORDER BY G.GoodsName
|
||
""")
|
||
else:
|
||
products_query = text(f"""
|
||
SELECT TOP {limit}
|
||
G.DrugCode as drug_code,
|
||
G.GoodsName as product_name,
|
||
ISNULL(NULLIF(G.BARCODE, ''), '') as barcode,
|
||
G.Saleprice as sale_price,
|
||
G.Price as cost_price,
|
||
CASE
|
||
WHEN G.SplName IS NOT NULL AND G.SplName != '' THEN G.SplName
|
||
WHEN SET_CHK.is_set = 1 THEN N'세트상품'
|
||
ELSE ''
|
||
END as supplier,
|
||
CASE WHEN SET_CHK.is_set = 1 THEN 1 ELSE 0 END as is_set,
|
||
G.POS_BOON as pos_boon,
|
||
ISNULL(IT.IM_QT_sale_debit, 0) as stock,
|
||
ISNULL(POS.CD_NM_sale, '') as location,
|
||
APC.CD_CD_BARCODE as apc_code,
|
||
UNIT_FIRST.CD_CD_BARCODE as unit_barcode,
|
||
ISNULL(UNIT_CNT.cnt, 0) as unit_barcode_count
|
||
FROM CD_GOODS G
|
||
LEFT JOIN IM_total IT ON G.DrugCode = IT.DrugCode
|
||
LEFT JOIN CD_item_position POS ON G.DrugCode = POS.DrugCode
|
||
OUTER APPLY (
|
||
SELECT TOP 1 1 as is_set
|
||
FROM CD_item_set
|
||
WHERE SetCode = G.DrugCode AND DrugCode = 'SET0000'
|
||
) SET_CHK
|
||
OUTER APPLY (
|
||
SELECT TOP 1 CD_CD_BARCODE
|
||
FROM CD_ITEM_UNIT_MEMBER
|
||
WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE LIKE '02%'
|
||
) APC
|
||
OUTER APPLY (
|
||
SELECT TOP 1 CD_CD_BARCODE
|
||
FROM CD_ITEM_UNIT_MEMBER
|
||
WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != ''
|
||
) UNIT_FIRST
|
||
OUTER APPLY (
|
||
SELECT COUNT(*) as cnt
|
||
FROM CD_ITEM_UNIT_MEMBER
|
||
WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != ''
|
||
) UNIT_CNT
|
||
WHERE 1=1
|
||
{search_condition}
|
||
ORDER BY G.GoodsName
|
||
""")
|
||
|
||
# 파라미터 설정 (검색어가 있을 때만)
|
||
params = {}
|
||
if search:
|
||
params['search_like'] = f'%{search}%'
|
||
|
||
rows = drug_session.execute(products_query, params).fetchall()
|
||
|
||
items = []
|
||
for row in rows:
|
||
is_animal = row.pos_boon == '010103'
|
||
|
||
# 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 '02%' OR CD_CD_BARCODE LIKE '92%')
|
||
AND LEN(CD_CD_BARCODE) = 13
|
||
"""), {'drug_code': row.drug_code})
|
||
apc_row = apc_result.fetchone()
|
||
if apc_row:
|
||
apc = apc_row[0]
|
||
except:
|
||
pass
|
||
|
||
# APC 코드: 쿼리에서 02%로 조회한 것만 사용 (바코드 대체 X)
|
||
apc_code = getattr(row, 'apc_code', None) or ''
|
||
|
||
# 단위바코드 (첫 번째, 갯수)
|
||
unit_barcode = getattr(row, 'unit_barcode', None) or ''
|
||
unit_barcode_count = getattr(row, 'unit_barcode_count', 0) or 0
|
||
|
||
# PostgreSQL 조회용 APC (분류/도매재고): apc 또는 apc_code 또는 unit_barcode
|
||
pg_apc = apc or apc_code or unit_barcode
|
||
|
||
items.append({
|
||
'drug_code': row.drug_code or '',
|
||
'product_name': row.product_name or '',
|
||
'barcode': row.barcode or '',
|
||
'sale_price': float(row.sale_price) if row.sale_price else 0,
|
||
'cost_price': float(row.cost_price) if row.cost_price else 0,
|
||
'supplier': row.supplier or '',
|
||
'is_set': bool(row.is_set),
|
||
'is_animal_drug': is_animal,
|
||
'stock': int(row.stock) if row.stock else 0,
|
||
'location': row.location or '', # 위치
|
||
'apc': apc_code, # UI용 APC 코드 (02로 시작하는 것만)
|
||
'unit_barcode': unit_barcode, # 단위바코드 첫 번째 (QR용)
|
||
'unit_barcode_count': int(unit_barcode_count), # 단위바코드 갯수 (뱃지용)
|
||
'_pg_apc': pg_apc, # PostgreSQL 조회용 (내부용)
|
||
'category': None, # PostgreSQL에서 lazy fetch
|
||
'wholesaler_stock': None,
|
||
'thumbnail': None # 아래에서 채움
|
||
})
|
||
|
||
# 동물약 분류 Lazy Fetch (PostgreSQL) - 실패해도 무시
|
||
animal_items = [i for i in items if i['is_animal_drug'] and i.get('_pg_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 데이터는 정상 반환
|
||
|
||
# 제품 이미지 조회 (product_images.db)
|
||
try:
|
||
images_db_path = Path(__file__).parent / 'db' / 'product_images.db'
|
||
if images_db_path.exists():
|
||
img_conn = sqlite3.connect(str(images_db_path))
|
||
img_cursor = img_conn.cursor()
|
||
|
||
barcodes = [item['barcode'] for item in items if item['barcode']]
|
||
drug_codes = [item['drug_code'] for item in items if item['drug_code']]
|
||
|
||
image_map = {}
|
||
if barcodes:
|
||
placeholders = ','.join(['?' for _ in barcodes])
|
||
img_cursor.execute(f'''
|
||
SELECT barcode, thumbnail_base64
|
||
FROM product_images
|
||
WHERE barcode IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||
''', barcodes)
|
||
for row in img_cursor.fetchall():
|
||
image_map[f'bc:{row[0]}'] = row[1]
|
||
|
||
if drug_codes:
|
||
placeholders = ','.join(['?' for _ in drug_codes])
|
||
img_cursor.execute(f'''
|
||
SELECT drug_code, thumbnail_base64
|
||
FROM product_images
|
||
WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||
''', drug_codes)
|
||
for row in img_cursor.fetchall():
|
||
if f'dc:{row[0]}' not in image_map:
|
||
image_map[f'dc:{row[0]}'] = row[1]
|
||
|
||
img_conn.close()
|
||
|
||
for item in items:
|
||
thumb = image_map.get(f'bc:{item["barcode"]}') or image_map.get(f'dc:{item["drug_code"]}')
|
||
if thumb:
|
||
item['thumbnail'] = thumb
|
||
except Exception as img_err:
|
||
logging.warning(f"제품 이미지 조회 오류: {img_err}")
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'items': items,
|
||
'count': len(items)
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"제품 검색 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
# ==================== 건조시럽 환산계수 API ====================
|
||
|
||
@app.route('/api/drug-info/conversion-factor/<sung_code>')
|
||
def api_conversion_factor(sung_code):
|
||
"""
|
||
건조시럽 환산계수 조회 API
|
||
|
||
PostgreSQL drysyrup 테이블에서 SUNG_CODE로 환산계수 조회
|
||
mL → g 변환에 사용 (예: 120ml * 0.11 = 13.2g)
|
||
|
||
Args:
|
||
sung_code: 성분코드 (예: "535000ASY")
|
||
|
||
Returns:
|
||
{
|
||
"sung_code": "535000ASY",
|
||
"conversion_factor": 0.11,
|
||
"ingredient_name": "아목시실린수화물·클라불란산칼륨",
|
||
"product_name": "일성오구멘틴듀오시럽 228mg/5ml"
|
||
}
|
||
|
||
연결 실패/데이터 없음 시:
|
||
{"sung_code": "...", "conversion_factor": null, ...}
|
||
"""
|
||
try:
|
||
result = db_manager.get_conversion_factor(sung_code)
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'sung_code': sung_code,
|
||
'conversion_factor': result['conversion_factor'],
|
||
'ingredient_name': result['ingredient_name'],
|
||
'product_name': result['product_name']
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"환산계수 조회 오류 (SUNG_CODE={sung_code}): {e}")
|
||
# 에러 발생해도 null 반환 (서비스 중단 방지)
|
||
return jsonify({
|
||
'success': True,
|
||
'sung_code': sung_code,
|
||
'conversion_factor': None,
|
||
'ingredient_name': None,
|
||
'product_name': None
|
||
})
|
||
|
||
|
||
@app.route('/api/drug-info/drysyrup/<sung_code>', methods=['GET'])
|
||
def api_drysyrup_get(sung_code):
|
||
"""
|
||
건조시럽 전체 정보 조회 API
|
||
|
||
PostgreSQL drysyrup 테이블에서 SUNG_CODE로 전체 정보 조회
|
||
"""
|
||
try:
|
||
session = db_manager.get_postgres_session()
|
||
if session is None:
|
||
return jsonify({
|
||
'success': True,
|
||
'exists': False,
|
||
'error': 'PostgreSQL 연결 실패'
|
||
})
|
||
|
||
query = text("""
|
||
SELECT
|
||
ingredient_code,
|
||
ingredient_name,
|
||
product_name,
|
||
conversion_factor,
|
||
post_prep_amount,
|
||
main_ingredient_amt,
|
||
storage_conditions,
|
||
expiration_date
|
||
FROM drysyrup
|
||
WHERE ingredient_code = :sung_code
|
||
LIMIT 1
|
||
""")
|
||
row = session.execute(query, {'sung_code': sung_code}).fetchone()
|
||
|
||
if not row:
|
||
return jsonify({
|
||
'success': True,
|
||
'exists': False
|
||
})
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'exists': True,
|
||
'sung_code': row[0],
|
||
'ingredient_name': row[1],
|
||
'product_name': row[2],
|
||
'conversion_factor': float(row[3]) if row[3] is not None else None,
|
||
'post_prep_amount': row[4],
|
||
'main_ingredient_amt': row[5],
|
||
'storage_conditions': row[6],
|
||
'expiration_date': row[7]
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"건조시럽 조회 오류 (SUNG_CODE={sung_code}): {e}")
|
||
return jsonify({
|
||
'success': True,
|
||
'exists': False,
|
||
'error': str(e)
|
||
})
|
||
|
||
|
||
@app.route('/api/drug-info/drysyrup', methods=['POST'])
|
||
def api_drysyrup_create():
|
||
"""
|
||
건조시럽 신규 등록 API
|
||
"""
|
||
try:
|
||
data = request.get_json()
|
||
if not data or not data.get('sung_code'):
|
||
return jsonify({'success': False, 'error': '성분코드 필수'}), 400
|
||
|
||
session = db_manager.get_postgres_session()
|
||
if session is None:
|
||
return jsonify({'success': False, 'error': 'PostgreSQL 연결 실패'}), 500
|
||
|
||
# 중복 체크
|
||
check_query = text("SELECT 1 FROM drysyrup WHERE ingredient_code = :sung_code")
|
||
existing = session.execute(check_query, {'sung_code': data['sung_code']}).fetchone()
|
||
if existing:
|
||
return jsonify({'success': False, 'error': '이미 등록된 성분코드'}), 400
|
||
|
||
insert_query = text("""
|
||
INSERT INTO drysyrup (
|
||
ingredient_code, ingredient_name, product_name,
|
||
conversion_factor, post_prep_amount, main_ingredient_amt,
|
||
storage_conditions, expiration_date
|
||
) VALUES (
|
||
:sung_code, :ingredient_name, :product_name,
|
||
:conversion_factor, :post_prep_amount, :main_ingredient_amt,
|
||
:storage_conditions, :expiration_date
|
||
)
|
||
""")
|
||
|
||
session.execute(insert_query, {
|
||
'sung_code': data.get('sung_code'),
|
||
'ingredient_name': data.get('ingredient_name', ''),
|
||
'product_name': data.get('product_name', ''),
|
||
'conversion_factor': data.get('conversion_factor'),
|
||
'post_prep_amount': data.get('post_prep_amount', ''),
|
||
'main_ingredient_amt': data.get('main_ingredient_amt', ''),
|
||
'storage_conditions': data.get('storage_conditions', '실온'),
|
||
'expiration_date': data.get('expiration_date', '')
|
||
})
|
||
session.commit()
|
||
|
||
return jsonify({'success': True, 'message': '등록 완료'})
|
||
|
||
except Exception as e:
|
||
logging.error(f"건조시럽 등록 오류: {e}")
|
||
try:
|
||
session.rollback()
|
||
except:
|
||
pass
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/drug-info/drysyrup/<sung_code>', methods=['PUT'])
|
||
def api_drysyrup_update(sung_code):
|
||
"""
|
||
건조시럽 정보 수정 API
|
||
"""
|
||
try:
|
||
data = request.get_json()
|
||
if not data:
|
||
return jsonify({'success': False, 'error': '데이터 필수'}), 400
|
||
|
||
session = db_manager.get_postgres_session()
|
||
if session is None:
|
||
return jsonify({'success': False, 'error': 'PostgreSQL 연결 실패'}), 500
|
||
|
||
# 존재 여부 확인
|
||
check_query = text("SELECT 1 FROM drysyrup WHERE ingredient_code = :sung_code")
|
||
existing = session.execute(check_query, {'sung_code': sung_code}).fetchone()
|
||
|
||
if not existing:
|
||
# 없으면 신규 등록으로 처리
|
||
insert_query = text("""
|
||
INSERT INTO drysyrup (
|
||
ingredient_code, ingredient_name, product_name,
|
||
conversion_factor, post_prep_amount, main_ingredient_amt,
|
||
storage_conditions, expiration_date
|
||
) VALUES (
|
||
:sung_code, :ingredient_name, :product_name,
|
||
:conversion_factor, :post_prep_amount, :main_ingredient_amt,
|
||
:storage_conditions, :expiration_date
|
||
)
|
||
""")
|
||
session.execute(insert_query, {
|
||
'sung_code': sung_code,
|
||
'ingredient_name': data.get('ingredient_name', ''),
|
||
'product_name': data.get('product_name', ''),
|
||
'conversion_factor': data.get('conversion_factor'),
|
||
'post_prep_amount': data.get('post_prep_amount', ''),
|
||
'main_ingredient_amt': data.get('main_ingredient_amt', ''),
|
||
'storage_conditions': data.get('storage_conditions', '실온'),
|
||
'expiration_date': data.get('expiration_date', '')
|
||
})
|
||
else:
|
||
# 있으면 업데이트
|
||
update_query = text("""
|
||
UPDATE drysyrup SET
|
||
ingredient_name = :ingredient_name,
|
||
product_name = :product_name,
|
||
conversion_factor = :conversion_factor,
|
||
post_prep_amount = :post_prep_amount,
|
||
main_ingredient_amt = :main_ingredient_amt,
|
||
storage_conditions = :storage_conditions,
|
||
expiration_date = :expiration_date
|
||
WHERE ingredient_code = :sung_code
|
||
""")
|
||
session.execute(update_query, {
|
||
'sung_code': sung_code,
|
||
'ingredient_name': data.get('ingredient_name', ''),
|
||
'product_name': data.get('product_name', ''),
|
||
'conversion_factor': data.get('conversion_factor'),
|
||
'post_prep_amount': data.get('post_prep_amount', ''),
|
||
'main_ingredient_amt': data.get('main_ingredient_amt', ''),
|
||
'storage_conditions': data.get('storage_conditions', '실온'),
|
||
'expiration_date': data.get('expiration_date', '')
|
||
})
|
||
|
||
session.commit()
|
||
return jsonify({'success': True, 'message': '저장 완료'})
|
||
|
||
except Exception as e:
|
||
logging.error(f"건조시럽 수정 오류 (SUNG_CODE={sung_code}): {e}")
|
||
try:
|
||
session.rollback()
|
||
except:
|
||
pass
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/drug-info/drysyrup', methods=['GET'])
|
||
def api_drysyrup_list():
|
||
"""
|
||
건조시럽 전체 목록 조회 API
|
||
|
||
Query params:
|
||
q: 검색어 (성분명/제품명 검색)
|
||
|
||
Returns:
|
||
{ "success": true, "data": [...] }
|
||
"""
|
||
try:
|
||
session = db_manager.get_postgres_session()
|
||
if session is None:
|
||
return jsonify({
|
||
'success': False,
|
||
'error': 'PostgreSQL 연결 실패',
|
||
'data': []
|
||
})
|
||
|
||
search_query = request.args.get('q', '').strip()
|
||
|
||
if search_query:
|
||
query = text("""
|
||
SELECT
|
||
idx,
|
||
ingredient_code,
|
||
ingredient_name,
|
||
product_name,
|
||
conversion_factor,
|
||
post_prep_amount,
|
||
main_ingredient_amt,
|
||
storage_conditions,
|
||
expiration_date
|
||
FROM drysyrup
|
||
WHERE
|
||
ingredient_name ILIKE :search
|
||
OR product_name ILIKE :search
|
||
OR ingredient_code ILIKE :search
|
||
ORDER BY ingredient_name, product_name
|
||
""")
|
||
rows = session.execute(query, {'search': f'%{search_query}%'}).fetchall()
|
||
else:
|
||
query = text("""
|
||
SELECT
|
||
idx,
|
||
ingredient_code,
|
||
ingredient_name,
|
||
product_name,
|
||
conversion_factor,
|
||
post_prep_amount,
|
||
main_ingredient_amt,
|
||
storage_conditions,
|
||
expiration_date
|
||
FROM drysyrup
|
||
ORDER BY ingredient_name, product_name
|
||
""")
|
||
rows = session.execute(query).fetchall()
|
||
|
||
data = []
|
||
for row in rows:
|
||
data.append({
|
||
'idx': row[0],
|
||
'sung_code': row[1],
|
||
'ingredient_name': row[2],
|
||
'product_name': row[3],
|
||
'conversion_factor': float(row[4]) if row[4] is not None else None,
|
||
'post_prep_amount': row[5],
|
||
'main_ingredient_amt': row[6],
|
||
'storage_conditions': row[7],
|
||
'expiration_date': row[8]
|
||
})
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'data': data,
|
||
'count': len(data)
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"건조시럽 목록 조회 오류: {e}")
|
||
return jsonify({
|
||
'success': False,
|
||
'error': str(e),
|
||
'data': []
|
||
}), 500
|
||
|
||
|
||
@app.route('/api/drug-info/drysyrup/<sung_code>', methods=['DELETE'])
|
||
def api_drysyrup_delete(sung_code):
|
||
"""
|
||
건조시럽 삭제 API
|
||
"""
|
||
try:
|
||
session = db_manager.get_postgres_session()
|
||
if session is None:
|
||
return jsonify({'success': False, 'error': 'PostgreSQL 연결 실패'}), 500
|
||
|
||
# 존재 여부 확인
|
||
check_query = text("SELECT 1 FROM drysyrup WHERE ingredient_code = :sung_code")
|
||
existing = session.execute(check_query, {'sung_code': sung_code}).fetchone()
|
||
|
||
if not existing:
|
||
return jsonify({'success': False, 'error': '존재하지 않는 성분코드'}), 404
|
||
|
||
# 삭제
|
||
delete_query = text("DELETE FROM drysyrup WHERE ingredient_code = :sung_code")
|
||
session.execute(delete_query, {'sung_code': sung_code})
|
||
session.commit()
|
||
|
||
return jsonify({'success': True, 'message': '삭제 완료'})
|
||
|
||
except Exception as e:
|
||
logging.error(f"건조시럽 삭제 오류 (SUNG_CODE={sung_code}): {e}")
|
||
try:
|
||
session.rollback()
|
||
except:
|
||
pass
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/admin/drysyrup')
|
||
def admin_drysyrup():
|
||
"""건조시럽 환산계수 관리 페이지"""
|
||
return render_template('admin_drysyrup.html')
|
||
|
||
|
||
# ==================== 입고이력 API ====================
|
||
|
||
@app.route('/api/drugs/<drug_code>/purchase-history')
|
||
def api_drug_purchase_history(drug_code):
|
||
"""
|
||
약품 입고이력 조회 API
|
||
- WH_sub: 입고 상세 (약품코드, 수량, 단가)
|
||
- WH_main: 입고 마스터 (입고일, 도매상코드)
|
||
- PM_BASE.CD_custom: 도매상명
|
||
"""
|
||
try:
|
||
drug_session = db_manager.get_session('PM_DRUG')
|
||
|
||
# 입고이력 조회 (최근 100건)
|
||
result = drug_session.execute(text("""
|
||
SELECT TOP 100
|
||
m.WH_DT_appl as purchase_date,
|
||
COALESCE(c.CD_NM_custom, m.WH_BUSINAME, '미확인') as supplier_name,
|
||
CAST(COALESCE(s.WH_NM_item_a, 0) AS INT) as quantity,
|
||
CAST(COALESCE(s.WH_MY_unit_a, 0) AS INT) as unit_price,
|
||
c.CD_TEL_charge1 as supplier_tel
|
||
FROM WH_sub s
|
||
JOIN WH_main m ON m.WH_NO_stock = s.WH_SR_stock AND m.WH_DT_appl = s.WH_DT_appl
|
||
LEFT JOIN PM_BASE.dbo.CD_custom c ON m.WH_CD_cust_sale = c.CD_CD_custom
|
||
WHERE s.DrugCode = :drug_code
|
||
ORDER BY m.WH_DT_appl DESC
|
||
"""), {'drug_code': drug_code})
|
||
|
||
history = []
|
||
for row in result.fetchall():
|
||
# 날짜 포맷팅 (YYYYMMDD -> YYYY-MM-DD)
|
||
date_str = row.purchase_date or ''
|
||
if len(date_str) == 8:
|
||
date_str = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"
|
||
|
||
history.append({
|
||
'date': date_str,
|
||
'supplier': row.supplier_name or '미확인',
|
||
'quantity': row.quantity or 0,
|
||
'unit_price': row.unit_price or 0,
|
||
'supplier_tel': row.supplier_tel or ''
|
||
})
|
||
|
||
# 약품명 조회
|
||
name_result = drug_session.execute(text("""
|
||
SELECT GoodsName FROM CD_GOODS WHERE DrugCode = :drug_code
|
||
"""), {'drug_code': drug_code})
|
||
name_row = name_result.fetchone()
|
||
drug_name = name_row[0] if name_row else drug_code
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'drug_code': drug_code,
|
||
'drug_name': drug_name,
|
||
'history': history,
|
||
'count': len(history)
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"입고이력 조회 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
# ==================== 처방 사용이력 API ====================
|
||
|
||
@app.route('/api/products/<drug_code>/usage-history')
|
||
def api_product_usage_history(drug_code):
|
||
"""
|
||
제품 처방 사용이력 조회 API
|
||
- PS_main + PS_sub_pharm JOIN
|
||
- 페이지네이션, 기간 필터 지원
|
||
- 환자명 마스킹 처리
|
||
"""
|
||
page = int(request.args.get('page', 1))
|
||
per_page = int(request.args.get('per_page', 20))
|
||
months = int(request.args.get('months', 12))
|
||
|
||
offset = (page - 1) * per_page
|
||
|
||
try:
|
||
pres_session = db_manager.get_session('PM_PRES')
|
||
|
||
# 기간 계산 (N개월 전부터)
|
||
from datetime import datetime, timedelta
|
||
start_date = (datetime.now() - timedelta(days=months * 30)).strftime('%Y%m%d')
|
||
|
||
# 총 건수 조회 (COUNT)
|
||
count_result = pres_session.execute(text("""
|
||
SELECT COUNT(*) as total_count
|
||
FROM PS_sub_pharm sp
|
||
JOIN PS_main pm ON pm.PreSerial = sp.PreSerial
|
||
WHERE sp.DrugCode = :drug_code
|
||
AND pm.Indate >= :start_date
|
||
AND (sp.PS_Type IS NULL OR sp.PS_Type != '9')
|
||
"""), {'drug_code': drug_code, 'start_date': start_date})
|
||
total_count = count_result.fetchone()[0]
|
||
|
||
# 데이터 조회 (페이지네이션)
|
||
data_result = pres_session.execute(text("""
|
||
SELECT
|
||
pm.Paname as patient_name,
|
||
pm.CusCode as cus_code,
|
||
pm.Indate as rx_date,
|
||
sp.QUAN as quantity,
|
||
sp.QUAN_TIME as times,
|
||
sp.Days as days
|
||
FROM PS_sub_pharm sp
|
||
JOIN PS_main pm ON pm.PreSerial = sp.PreSerial
|
||
WHERE sp.DrugCode = :drug_code
|
||
AND pm.Indate >= :start_date
|
||
AND (sp.PS_Type IS NULL OR sp.PS_Type != '9')
|
||
ORDER BY pm.Indate DESC
|
||
OFFSET :offset ROWS
|
||
FETCH NEXT :per_page ROWS ONLY
|
||
"""), {
|
||
'drug_code': drug_code,
|
||
'start_date': start_date,
|
||
'offset': offset,
|
||
'per_page': per_page
|
||
})
|
||
|
||
items = []
|
||
for row in data_result.fetchall():
|
||
patient_name = row.patient_name or ''
|
||
cus_code = row.cus_code or ''
|
||
|
||
# 날짜 포맷팅 (YYYYMMDD -> YYYY-MM-DD)
|
||
rx_date = row.rx_date or ''
|
||
if len(rx_date) == 8:
|
||
rx_date = f"{rx_date[:4]}-{rx_date[4:6]}-{rx_date[6:8]}"
|
||
|
||
quantity = int(row.quantity) if row.quantity else 1
|
||
times = int(row.times) if row.times else 1 # 횟수 (QUAN_TIME)
|
||
days = int(row.days) if row.days else 1
|
||
total_dose = quantity * times * days # 수량 × 횟수 × 일수
|
||
|
||
items.append({
|
||
'patient_name': patient_name,
|
||
'cus_code': cus_code,
|
||
'rx_date': rx_date,
|
||
'quantity': quantity,
|
||
'times': times,
|
||
'days': days,
|
||
'total_dose': total_dose
|
||
})
|
||
|
||
# 약품명 조회
|
||
drug_session = db_manager.get_session('PM_DRUG')
|
||
name_result = drug_session.execute(text("""
|
||
SELECT GoodsName FROM CD_GOODS WHERE DrugCode = :drug_code
|
||
"""), {'drug_code': drug_code})
|
||
name_row = name_result.fetchone()
|
||
product_name = name_row[0] if name_row else drug_code
|
||
|
||
# 총 페이지 수 계산
|
||
total_pages = (total_count + per_page - 1) // per_page
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'product_name': product_name,
|
||
'pagination': {
|
||
'page': page,
|
||
'per_page': per_page,
|
||
'total_count': total_count,
|
||
'total_pages': total_pages
|
||
},
|
||
'items': items
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"사용이력 조회 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/patients/<cus_code>/recent-prescriptions')
|
||
def api_patient_recent_prescriptions(cus_code):
|
||
"""
|
||
환자 최근 처방 내역 조회 API
|
||
- 해당 환자가 최근에 어떤 약을 처방받았는지 확인
|
||
- DB 부담 최소화: 최근 6개월, 최대 30건
|
||
"""
|
||
try:
|
||
pres_session = db_manager.get_session('PM_PRES')
|
||
drug_session = db_manager.get_session('PM_DRUG')
|
||
|
||
# 최근 6개월
|
||
from datetime import datetime, timedelta
|
||
start_date = (datetime.now() - timedelta(days=180)).strftime('%Y%m%d')
|
||
|
||
# 환자의 최근 처방전 조회 (최대 10건)
|
||
rx_result = pres_session.execute(text("""
|
||
SELECT TOP 10
|
||
pm.PreSerial,
|
||
pm.Paname as patient_name,
|
||
pm.Indate as rx_date,
|
||
pm.Drname as doctor_name,
|
||
pm.OrderName as hospital_name
|
||
FROM PS_main pm
|
||
WHERE pm.CusCode = :cus_code
|
||
AND pm.Indate >= :start_date
|
||
ORDER BY pm.Indate DESC
|
||
"""), {'cus_code': cus_code, 'start_date': start_date})
|
||
|
||
prescriptions = []
|
||
for rx in rx_result.fetchall():
|
||
# 날짜 포맷팅
|
||
rx_date = rx.rx_date or ''
|
||
if len(rx_date) == 8:
|
||
rx_date = f"{rx_date[:4]}-{rx_date[4:6]}-{rx_date[6:8]}"
|
||
|
||
# 해당 처방의 약품 목록 조회
|
||
items_result = pres_session.execute(text("""
|
||
SELECT
|
||
sp.DrugCode,
|
||
sp.QUAN as quantity,
|
||
sp.QUAN_TIME as times,
|
||
sp.Days as days
|
||
FROM PS_sub_pharm sp
|
||
WHERE sp.PreSerial = :pre_serial
|
||
AND (sp.PS_Type IS NULL OR sp.PS_Type != '9')
|
||
"""), {'pre_serial': rx.PreSerial})
|
||
|
||
# 먼저 모든 약품 데이터를 리스트로 가져오기
|
||
raw_items = items_result.fetchall()
|
||
drug_codes = [item.DrugCode for item in raw_items]
|
||
|
||
# 약품명 + 성분명 + 분류(PRINT_TYPE) 일괄 조회
|
||
drug_info_map = {}
|
||
if drug_codes:
|
||
placeholders = ','.join([f"'{dc}'" for dc in drug_codes])
|
||
name_result = drug_session.execute(text(f"""
|
||
SELECT g.DrugCode, g.GoodsName, s.SUNG_HNM, m.PRINT_TYPE
|
||
FROM CD_GOODS g
|
||
LEFT JOIN CD_SUNG s ON g.SUNG_CODE = s.SUNG_CODE
|
||
LEFT JOIN CD_MC m ON g.DrugCode = m.DRUGCODE
|
||
WHERE g.DrugCode IN ({placeholders})
|
||
"""))
|
||
for row in name_result.fetchall():
|
||
drug_info_map[row[0]] = {
|
||
'name': row[1],
|
||
'ingredient': row[2] or '',
|
||
'category': row[3] or '' # 분류 (알러지질환약 등)
|
||
}
|
||
|
||
items = []
|
||
for item in raw_items:
|
||
info = drug_info_map.get(item.DrugCode, {})
|
||
drug_name = info.get('name', item.DrugCode)
|
||
ingredient = info.get('ingredient', '')
|
||
category = info.get('category', '') # 분류 (알러지질환약 등)
|
||
|
||
quantity = int(item.quantity) if item.quantity else 1
|
||
times = int(item.times) if item.times else 1
|
||
days = int(item.days) if item.days else 1
|
||
|
||
items.append({
|
||
'drug_code': item.DrugCode,
|
||
'drug_name': drug_name,
|
||
'category': category, # 분류 추가
|
||
'quantity': quantity,
|
||
'times': times,
|
||
'days': days,
|
||
'total_dose': quantity * times * days
|
||
})
|
||
|
||
prescriptions.append({
|
||
'pre_serial': rx.PreSerial,
|
||
'rx_date': rx_date,
|
||
'doctor_name': rx.doctor_name or '',
|
||
'hospital_name': rx.hospital_name or '',
|
||
'items': items
|
||
})
|
||
|
||
# 환자명
|
||
patient_name = prescriptions[0]['items'][0]['drug_name'] if prescriptions else ''
|
||
if prescriptions and pres_session.execute(text("""
|
||
SELECT TOP 1 Paname FROM PS_main WHERE CusCode = :cus_code
|
||
"""), {'cus_code': cus_code}).fetchone():
|
||
patient_name = pres_session.execute(text("""
|
||
SELECT TOP 1 Paname FROM PS_main WHERE CusCode = :cus_code
|
||
"""), {'cus_code': cus_code}).fetchone()[0]
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'cus_code': cus_code,
|
||
'patient_name': patient_name,
|
||
'prescription_count': len(prescriptions),
|
||
'prescriptions': prescriptions
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"환자 처방 조회 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
# ==================== 위치 정보 API ====================
|
||
|
||
@app.route('/api/locations')
|
||
def api_get_all_locations():
|
||
"""모든 위치명 목록 조회"""
|
||
try:
|
||
drug_session = db_manager.get_session('PM_DRUG')
|
||
result = drug_session.execute(text("""
|
||
SELECT DISTINCT CD_NM_sale
|
||
FROM CD_item_position
|
||
WHERE CD_NM_sale IS NOT NULL AND CD_NM_sale != ''
|
||
ORDER BY CD_NM_sale
|
||
"""))
|
||
locations = [row[0] for row in result.fetchall()]
|
||
return jsonify({
|
||
'success': True,
|
||
'locations': locations,
|
||
'total': len(locations)
|
||
})
|
||
except Exception as e:
|
||
logging.error(f"위치 목록 조회 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/drugs/<drug_code>/location', methods=['PUT'])
|
||
def api_update_drug_location(drug_code):
|
||
"""약품 위치 업데이트"""
|
||
try:
|
||
data = request.get_json()
|
||
location_name = data.get('location_name', '').strip() if data else ''
|
||
|
||
# 위치명 길이 검증 (최대 20자)
|
||
if location_name and len(location_name) > 20:
|
||
return jsonify({'success': False, 'error': '위치명은 20자를 초과할 수 없습니다'}), 400
|
||
|
||
drug_session = db_manager.get_session('PM_DRUG')
|
||
|
||
# 기존 레코드 확인
|
||
existing = drug_session.execute(text("""
|
||
SELECT DrugCode FROM CD_item_position WHERE DrugCode = :drug_code
|
||
"""), {'drug_code': drug_code}).fetchone()
|
||
|
||
if existing:
|
||
# UPDATE
|
||
if location_name:
|
||
drug_session.execute(text("""
|
||
UPDATE CD_item_position SET CD_NM_sale = :location WHERE DrugCode = :drug_code
|
||
"""), {'location': location_name, 'drug_code': drug_code})
|
||
else:
|
||
# 빈 값이면 삭제
|
||
drug_session.execute(text("""
|
||
DELETE FROM CD_item_position WHERE DrugCode = :drug_code
|
||
"""), {'drug_code': drug_code})
|
||
else:
|
||
# INSERT (위치가 있을 때만)
|
||
if location_name:
|
||
drug_session.execute(text("""
|
||
INSERT INTO CD_item_position (DrugCode, CD_NM_sale) VALUES (:drug_code, :location)
|
||
"""), {'drug_code': drug_code, 'location': location_name})
|
||
|
||
drug_session.commit()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': '위치 정보가 업데이트되었습니다',
|
||
'location': location_name
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"위치 업데이트 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/admin/sales')
|
||
def admin_sales_pos():
|
||
"""판매 내역 페이지 (POS 스타일, 거래별 그룹핑)"""
|
||
return render_template('admin_sales_pos.html')
|
||
|
||
|
||
@app.route('/api/sales-detail')
|
||
def api_sales_detail():
|
||
"""
|
||
판매 상세 조회 API (바코드 포함)
|
||
GET /api/sales-detail?days=7&search=타이레놀&barcode=has&customer=홍길동
|
||
"""
|
||
try:
|
||
days = int(request.args.get('days', 7))
|
||
search = request.args.get('search', '').strip()
|
||
barcode_filter = request.args.get('barcode', 'all') # all, has, none
|
||
|
||
mssql_session = db_manager.get_session('PM_PRES')
|
||
drug_session = db_manager.get_session('PM_DRUG')
|
||
|
||
# 판매 내역 조회 (최근 N일)
|
||
# CD_GOODS.BARCODE가 없으면 CD_ITEM_UNIT_MEMBER.CD_CD_BARCODE 사용 (세트상품/자체등록 바코드)
|
||
# 세트상품 여부 확인: CD_item_set에 SetCode로 존재하면 세트상품
|
||
sales_query = text("""
|
||
SELECT
|
||
S.SL_DT_appl as sale_date,
|
||
S.SL_NO_order as item_order,
|
||
S.DrugCode as drug_code,
|
||
ISNULL(G.GoodsName, '알 수 없음') as product_name,
|
||
COALESCE(NULLIF(G.BARCODE, ''), U.CD_CD_BARCODE, '') as barcode,
|
||
CASE
|
||
WHEN G.SplName IS NOT NULL AND G.SplName != '' THEN G.SplName
|
||
WHEN SET_CHK.is_set = 1 THEN '세트상품'
|
||
ELSE ''
|
||
END as supplier,
|
||
ISNULL(S.QUAN, 1) as quantity,
|
||
ISNULL(S.SL_TOTAL_PRICE, 0) as total_price_db,
|
||
ISNULL(G.Saleprice, 0) as unit_price
|
||
FROM SALE_SUB S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
OUTER APPLY (
|
||
SELECT TOP 1 CD_CD_BARCODE
|
||
FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER
|
||
WHERE DRUGCODE = S.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != ''
|
||
) U
|
||
OUTER APPLY (
|
||
SELECT TOP 1 1 as is_set
|
||
FROM PM_DRUG.dbo.CD_item_set
|
||
WHERE SetCode = S.DrugCode AND DrugCode = 'SET0000'
|
||
) SET_CHK
|
||
WHERE S.SL_DT_appl >= CONVERT(VARCHAR(8), DATEADD(DAY, -:days, GETDATE()), 112)
|
||
ORDER BY S.SL_DT_appl DESC, S.SL_NO_order DESC
|
||
""")
|
||
|
||
rows = mssql_session.execute(sales_query, {'days': days}).fetchall()
|
||
|
||
items = []
|
||
total_amount = 0
|
||
barcode_count = 0
|
||
unique_products = set()
|
||
|
||
for row in rows:
|
||
drug_code = row.drug_code or ''
|
||
product_name = row.product_name or ''
|
||
barcode = row.barcode or ''
|
||
|
||
# 검색 필터 (상품명, 코드, 바코드)
|
||
if search:
|
||
search_lower = search.lower()
|
||
if (search_lower not in product_name.lower() and
|
||
search_lower not in drug_code.lower() and
|
||
search_lower not in barcode.lower()):
|
||
continue
|
||
|
||
# 바코드 필터
|
||
if barcode_filter == 'has' and not barcode:
|
||
continue
|
||
if barcode_filter == 'none' and barcode:
|
||
continue
|
||
|
||
# 표준코드 조회 (CD_BARCODE 테이블)
|
||
standard_code = ''
|
||
if barcode:
|
||
try:
|
||
std_result = drug_session.execute(text("""
|
||
SELECT BASECODE FROM CD_BARCODE WHERE BARCODE = :barcode
|
||
"""), {'barcode': barcode}).fetchone()
|
||
if std_result and std_result[0]:
|
||
standard_code = std_result[0]
|
||
except:
|
||
pass
|
||
|
||
quantity = int(row.quantity or 1)
|
||
unit_price = float(row.unit_price or 0)
|
||
total_price_from_db = float(row.total_price_db or 0)
|
||
# DB에 합계가 있으면 사용, 없으면 계산
|
||
total_price = total_price_from_db if total_price_from_db > 0 else (quantity * unit_price)
|
||
|
||
# 날짜 포맷팅
|
||
sale_date_str = str(row.sale_date or '')
|
||
if len(sale_date_str) == 8:
|
||
sale_date_str = f"{sale_date_str[:4]}-{sale_date_str[4:6]}-{sale_date_str[6:]}"
|
||
|
||
items.append({
|
||
'sale_date': sale_date_str,
|
||
'drug_code': drug_code,
|
||
'product_name': product_name,
|
||
'barcode': barcode,
|
||
'standard_code': standard_code,
|
||
'supplier': row.supplier or '',
|
||
'quantity': quantity,
|
||
'unit_price': int(unit_price),
|
||
'total_price': int(total_price),
|
||
'thumbnail': None # 나중에 채워짐
|
||
})
|
||
|
||
total_amount += total_price
|
||
if barcode:
|
||
barcode_count += 1
|
||
unique_products.add(drug_code)
|
||
|
||
# 제품 이미지 조회 (product_images.db에서)
|
||
try:
|
||
images_db_path = Path(__file__).parent / 'db' / 'product_images.db'
|
||
if images_db_path.exists():
|
||
img_conn = sqlite3.connect(str(images_db_path))
|
||
img_cursor = img_conn.cursor()
|
||
|
||
# barcode와 drug_code 수집
|
||
barcodes = [item['barcode'] for item in items if item['barcode']]
|
||
drug_codes = [item['drug_code'] for item in items if item['drug_code']]
|
||
|
||
# 이미지 조회 (barcode 또는 drug_code로 매칭)
|
||
image_map = {}
|
||
if barcodes:
|
||
placeholders = ','.join(['?' for _ in barcodes])
|
||
img_cursor.execute(f'''
|
||
SELECT barcode, thumbnail_base64
|
||
FROM product_images
|
||
WHERE barcode IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||
''', barcodes)
|
||
for row in img_cursor.fetchall():
|
||
image_map[f'bc:{row[0]}'] = row[1]
|
||
|
||
if drug_codes:
|
||
placeholders = ','.join(['?' for _ in drug_codes])
|
||
img_cursor.execute(f'''
|
||
SELECT drug_code, thumbnail_base64
|
||
FROM product_images
|
||
WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||
''', drug_codes)
|
||
for row in img_cursor.fetchall():
|
||
if f'dc:{row[0]}' not in image_map: # barcode 우선
|
||
image_map[f'dc:{row[0]}'] = row[1]
|
||
|
||
img_conn.close()
|
||
|
||
# 아이템에 썸네일 매핑
|
||
for item in items:
|
||
thumb = image_map.get(f'bc:{item["barcode"]}') or image_map.get(f'dc:{item["drug_code"]}')
|
||
if thumb:
|
||
item['thumbnail'] = thumb
|
||
except Exception as img_err:
|
||
logging.warning(f"제품 이미지 조회 오류: {img_err}")
|
||
|
||
# 바코드 매핑률 계산
|
||
barcode_rate = round(barcode_count / len(items) * 100, 1) if items else 0
|
||
|
||
# 이미지 매핑률 계산
|
||
image_count = sum(1 for item in items if item.get('thumbnail'))
|
||
image_rate = round(image_count / len(items) * 100, 1) if items else 0
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'items': items[:500], # 최대 500건
|
||
'stats': {
|
||
'total_count': len(items),
|
||
'total_amount': int(total_amount),
|
||
'barcode_rate': barcode_rate,
|
||
'image_rate': image_rate,
|
||
'unique_products': len(unique_products)
|
||
}
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"판매 상세 조회 오류: {e}")
|
||
return jsonify({
|
||
'success': False,
|
||
'error': str(e)
|
||
}), 500
|
||
|
||
|
||
# ===== 사용량 조회 페이지 및 API =====
|
||
|
||
@app.route('/admin/usage')
|
||
def admin_usage():
|
||
"""OTC 사용량 조회 · 주문 페이지"""
|
||
return render_template('admin_usage.html')
|
||
|
||
|
||
@app.route('/admin/rx-usage')
|
||
def admin_rx_usage():
|
||
"""전문의약품 사용량 조회 · 주문 페이지"""
|
||
return render_template('admin_rx_usage.html')
|
||
|
||
|
||
@app.route('/api/usage')
|
||
def api_usage():
|
||
"""
|
||
기간별 품목 사용량 조회 API
|
||
GET /api/usage?start_date=2026-01-01&end_date=2026-01-31&search=타이레놀&sort=qty_desc
|
||
"""
|
||
try:
|
||
start_date = request.args.get('start_date', '')
|
||
end_date = request.args.get('end_date', '')
|
||
search = request.args.get('search', '').strip()
|
||
sort = request.args.get('sort', 'qty_desc') # qty_desc, qty_asc, name_asc, amount_desc
|
||
|
||
# 날짜 형식 변환 (YYYY-MM-DD -> YYYYMMDD)
|
||
start_date_fmt = start_date.replace('-', '') if start_date else datetime.now().strftime('%Y%m%d')
|
||
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
|
||
|
||
mssql_session = db_manager.get_session('PM_PRES')
|
||
|
||
# 품목별 사용량 집계 쿼리
|
||
usage_query = text("""
|
||
SELECT
|
||
S.DrugCode as drug_code,
|
||
ISNULL(G.GoodsName, '알 수 없음') as product_name,
|
||
CASE
|
||
WHEN G.SplName IS NOT NULL AND G.SplName != '' THEN G.SplName
|
||
WHEN SET_CHK.is_set = 1 THEN '세트상품'
|
||
ELSE ''
|
||
END as supplier,
|
||
SUM(ISNULL(S.QUAN, 1)) as total_qty,
|
||
SUM(ISNULL(S.SL_TOTAL_PRICE, 0)) as total_amount,
|
||
COALESCE(NULLIF(G.BARCODE, ''), U.CD_CD_BARCODE, '') as barcode
|
||
FROM SALE_SUB S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
OUTER APPLY (
|
||
SELECT TOP 1 CD_CD_BARCODE
|
||
FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER
|
||
WHERE DRUGCODE = S.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != ''
|
||
) U
|
||
OUTER APPLY (
|
||
SELECT TOP 1 1 as is_set
|
||
FROM PM_DRUG.dbo.CD_item_set
|
||
WHERE SetCode = S.DrugCode AND DrugCode = 'SET0000'
|
||
) SET_CHK
|
||
WHERE S.SL_DT_appl >= :start_date
|
||
AND S.SL_DT_appl <= :end_date
|
||
GROUP BY S.DrugCode, G.GoodsName, G.SplName, SET_CHK.is_set, G.BARCODE, U.CD_CD_BARCODE
|
||
ORDER BY SUM(ISNULL(S.QUAN, 1)) DESC
|
||
""")
|
||
|
||
rows = mssql_session.execute(usage_query, {
|
||
'start_date': start_date_fmt,
|
||
'end_date': end_date_fmt
|
||
}).fetchall()
|
||
|
||
items = []
|
||
total_qty = 0
|
||
total_amount = 0
|
||
|
||
for row in rows:
|
||
drug_code = row.drug_code or ''
|
||
product_name = row.product_name or ''
|
||
|
||
# 검색 필터
|
||
if search:
|
||
search_lower = search.lower()
|
||
if (search_lower not in product_name.lower() and
|
||
search_lower not in drug_code.lower()):
|
||
continue
|
||
|
||
qty = int(row.total_qty or 0)
|
||
amount = float(row.total_amount or 0)
|
||
|
||
items.append({
|
||
'drug_code': drug_code,
|
||
'product_name': product_name,
|
||
'supplier': row.supplier or '',
|
||
'barcode': row.barcode or '',
|
||
'total_qty': qty,
|
||
'total_amount': int(amount),
|
||
'thumbnail': None
|
||
})
|
||
|
||
total_qty += qty
|
||
total_amount += amount
|
||
|
||
# 정렬
|
||
if sort == 'qty_asc':
|
||
items.sort(key=lambda x: x['total_qty'])
|
||
elif sort == 'qty_desc':
|
||
items.sort(key=lambda x: x['total_qty'], reverse=True)
|
||
elif sort == 'name_asc':
|
||
items.sort(key=lambda x: x['product_name'])
|
||
elif sort == 'amount_desc':
|
||
items.sort(key=lambda x: x['total_amount'], reverse=True)
|
||
|
||
# 제품 이미지 조회
|
||
try:
|
||
images_db_path = Path(__file__).parent / 'db' / 'product_images.db'
|
||
if images_db_path.exists():
|
||
img_conn = sqlite3.connect(str(images_db_path))
|
||
img_cursor = img_conn.cursor()
|
||
|
||
barcodes = [item['barcode'] for item in items if item['barcode']]
|
||
drug_codes = [item['drug_code'] for item in items]
|
||
|
||
image_map = {}
|
||
if barcodes:
|
||
placeholders = ','.join(['?' for _ in barcodes])
|
||
img_cursor.execute(f'''
|
||
SELECT barcode, thumbnail_base64
|
||
FROM product_images
|
||
WHERE barcode IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||
''', barcodes)
|
||
for r in img_cursor.fetchall():
|
||
image_map[f'bc:{r[0]}'] = r[1]
|
||
|
||
if drug_codes:
|
||
placeholders = ','.join(['?' for _ in drug_codes])
|
||
img_cursor.execute(f'''
|
||
SELECT drug_code, thumbnail_base64
|
||
FROM product_images
|
||
WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||
''', drug_codes)
|
||
for r in img_cursor.fetchall():
|
||
if f'dc:{r[0]}' not in image_map:
|
||
image_map[f'dc:{r[0]}'] = r[1]
|
||
|
||
img_conn.close()
|
||
|
||
for item in items:
|
||
thumb = image_map.get(f'bc:{item["barcode"]}') or image_map.get(f'dc:{item["drug_code"]}')
|
||
if thumb:
|
||
item['thumbnail'] = thumb
|
||
except Exception as img_err:
|
||
logging.warning(f"제품 이미지 조회 오류: {img_err}")
|
||
|
||
# 기간 일수 계산
|
||
try:
|
||
from datetime import datetime as dt
|
||
start_dt = dt.strptime(start_date_fmt, '%Y%m%d')
|
||
end_dt = dt.strptime(end_date_fmt, '%Y%m%d')
|
||
period_days = (end_dt - start_dt).days + 1
|
||
except:
|
||
period_days = 1
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'items': items[:500], # 최대 500건
|
||
'stats': {
|
||
'period_days': period_days,
|
||
'product_count': len(items),
|
||
'total_qty': total_qty,
|
||
'total_amount': int(total_amount)
|
||
}
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"사용량 조회 오류: {e}")
|
||
return jsonify({
|
||
'success': False,
|
||
'error': str(e)
|
||
}), 500
|
||
|
||
|
||
@app.route('/api/rx-usage')
|
||
def api_rx_usage():
|
||
"""
|
||
전문의약품(처방전) 기간별 사용량 조회 API
|
||
GET /api/rx-usage?start_date=2026-01-01&end_date=2026-01-31&search=타이레놀&sort=qty_desc
|
||
"""
|
||
try:
|
||
start_date = request.args.get('start_date', '')
|
||
end_date = request.args.get('end_date', '')
|
||
search = request.args.get('search', '').strip()
|
||
sort = request.args.get('sort', 'qty_desc')
|
||
|
||
# 날짜 형식 변환 (YYYY-MM-DD -> YYYYMMDD)
|
||
start_date_fmt = start_date.replace('-', '') if start_date else datetime.now().strftime('%Y%m%d')
|
||
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
|
||
|
||
mssql_session = db_manager.get_session('PM_PRES')
|
||
|
||
# 1년간 사용 환자 3명 이하 약품의 환자 목록 조회 + 조회 기간 내 사용 여부
|
||
# PS_Type: 0,1=일반, 4=대체조제(실제), 9=대체조제(원본) - 9는 제외해야 실제 조제된 약만 집계
|
||
patient_query = text("""
|
||
WITH PatientUsage AS (
|
||
SELECT DISTINCT
|
||
P.DrugCode,
|
||
M.Paname,
|
||
MAX(CASE WHEN M.Indate >= :start_date AND M.Indate <= :end_date THEN 1 ELSE 0 END) as used_in_period
|
||
FROM PS_sub_pharm P
|
||
JOIN PS_main M ON P.PreSerial = M.PreSerial
|
||
WHERE M.Indate >= CONVERT(VARCHAR, DATEADD(YEAR, -1, GETDATE()), 112)
|
||
AND (P.PS_Type IS NULL OR P.PS_Type != '9')
|
||
GROUP BY P.DrugCode, M.Paname
|
||
)
|
||
SELECT
|
||
PU.DrugCode as drug_code,
|
||
COUNT(*) as patient_count,
|
||
STUFF((
|
||
SELECT ', ' + PU2.Paname
|
||
FROM PatientUsage PU2
|
||
WHERE PU2.DrugCode = PU.DrugCode
|
||
ORDER BY PU2.Paname
|
||
FOR XML PATH(''), TYPE
|
||
).value('.', 'NVARCHAR(MAX)'), 1, 2, '') as patient_names,
|
||
STUFF((
|
||
SELECT ', ' + PU3.Paname
|
||
FROM PatientUsage PU3
|
||
WHERE PU3.DrugCode = PU.DrugCode AND PU3.used_in_period = 1
|
||
ORDER BY PU3.Paname
|
||
FOR XML PATH(''), TYPE
|
||
).value('.', 'NVARCHAR(MAX)'), 1, 2, '') as today_patients
|
||
FROM PatientUsage PU
|
||
GROUP BY PU.DrugCode
|
||
HAVING COUNT(*) <= 3
|
||
""")
|
||
|
||
patient_rows = mssql_session.execute(patient_query, {
|
||
'start_date': start_date_fmt,
|
||
'end_date': end_date_fmt
|
||
}).fetchall()
|
||
patient_map = {row.drug_code: {
|
||
'count': row.patient_count,
|
||
'names': row.patient_names,
|
||
'today': row.today_patients # 오늘 사용한 환자
|
||
} for row in patient_rows}
|
||
|
||
# 조회 기간 내 주문량 조회 (orders.db에서)
|
||
import sqlite3
|
||
orders_db_path = os.path.join(os.path.dirname(__file__), 'db', 'orders.db')
|
||
orders_conn = sqlite3.connect(orders_db_path)
|
||
orders_cur = orders_conn.cursor()
|
||
|
||
# 조회 기간 내 성공한 주문량 집계
|
||
orders_cur.execute('''
|
||
SELECT
|
||
oi.drug_code,
|
||
SUM(oi.order_qty) as ordered_qty,
|
||
SUM(oi.total_dose) as ordered_dose
|
||
FROM order_items oi
|
||
JOIN orders o ON oi.order_id = o.id
|
||
WHERE o.order_date >= ? AND o.order_date <= ?
|
||
AND o.status IN ('submitted', 'success', 'confirmed')
|
||
AND oi.status IN ('success', 'pending')
|
||
GROUP BY oi.drug_code
|
||
''', (start_date, end_date))
|
||
|
||
ordered_map = {row[0]: {'qty': row[1] or 0, 'dose': row[2] or 0} for row in orders_cur.fetchall()}
|
||
orders_conn.close()
|
||
|
||
# 전문의약품 품목별 사용량 집계 쿼리 (현재고: IM_total.IM_QT_sale_debit, 위치: CD_item_position.CD_NM_sale)
|
||
# PS_Type != '9' 조건: 대체조제 원본 처방 제외 → 실제 조제된 약만 집계
|
||
rx_query = text("""
|
||
SELECT
|
||
P.DrugCode as drug_code,
|
||
MAX(ISNULL(G.GoodsName, '알 수 없음')) as product_name,
|
||
MAX(ISNULL(G.SplName, '')) as supplier,
|
||
SUM(ISNULL(P.QUAN, 1)) as total_qty,
|
||
SUM(ISNULL(P.INV_QUAN, 0)) as total_dose, -- 총 투약량 (1회복용량 x 복용횟수 x 일수)
|
||
SUM(ISNULL(P.DRUPRICE, 0)) as total_amount, -- DRUPRICE 합계
|
||
COUNT(DISTINCT P.PreSerial) as prescription_count,
|
||
MAX(COALESCE(NULLIF(G.BARCODE, ''), '')) as barcode,
|
||
MAX(ISNULL(IT.IM_QT_sale_debit, 0)) as current_stock,
|
||
MAX(ISNULL(POS.CD_NM_sale, '')) as location
|
||
FROM PS_sub_pharm P
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON P.DrugCode = G.DrugCode
|
||
LEFT JOIN PM_DRUG.dbo.IM_total IT ON P.DrugCode = IT.DrugCode
|
||
LEFT JOIN PM_DRUG.dbo.CD_item_position POS ON P.DrugCode = POS.DrugCode
|
||
WHERE P.Indate >= :start_date
|
||
AND P.Indate <= :end_date
|
||
AND (P.PS_Type IS NULL OR P.PS_Type != '9')
|
||
GROUP BY P.DrugCode
|
||
ORDER BY SUM(ISNULL(P.INV_QUAN, 0)) DESC -- 투약량 기준 정렬
|
||
""")
|
||
|
||
rows = mssql_session.execute(rx_query, {
|
||
'start_date': start_date_fmt,
|
||
'end_date': end_date_fmt
|
||
}).fetchall()
|
||
|
||
items = []
|
||
total_qty = 0
|
||
total_dose = 0
|
||
total_amount = 0
|
||
total_prescriptions = set()
|
||
|
||
for row in rows:
|
||
drug_code = row.drug_code or ''
|
||
product_name = row.product_name or ''
|
||
|
||
# 검색 필터
|
||
if search:
|
||
search_lower = search.lower()
|
||
if (search_lower not in product_name.lower() and
|
||
search_lower not in drug_code.lower()):
|
||
continue
|
||
|
||
qty = int(row.total_qty or 0)
|
||
dose = int(row.total_dose or 0)
|
||
amount = float(row.total_amount or 0)
|
||
rx_count = int(row.prescription_count or 0)
|
||
|
||
# 소수 환자 약품인지 확인 (1년간 3명 이하)
|
||
patient_info = patient_map.get(drug_code)
|
||
|
||
# 조회 기간 내 주문량
|
||
order_info = ordered_map.get(drug_code, {'qty': 0, 'dose': 0})
|
||
ordered_dose = order_info['dose']
|
||
|
||
# 잔여 필요량 = 사용량 - 주문량 (음수면 0)
|
||
remaining_dose = max(0, dose - ordered_dose)
|
||
|
||
items.append({
|
||
'drug_code': drug_code,
|
||
'product_name': product_name,
|
||
'supplier': row.supplier or '',
|
||
'barcode': row.barcode or '',
|
||
'total_qty': qty,
|
||
'total_dose': dose, # 총 투약량 (수량 x 일수)
|
||
'total_amount': int(amount),
|
||
'prescription_count': rx_count,
|
||
'current_stock': int(row.current_stock or 0), # 현재고
|
||
'location': row.location or '', # 약국 내 위치
|
||
'thumbnail': None,
|
||
'patient_count': patient_info['count'] if patient_info else None,
|
||
'patient_names': patient_info['names'] if patient_info else None,
|
||
'today_patients': patient_info['today'] if patient_info else None,
|
||
'ordered_dose': ordered_dose, # 조회 기간 내 주문량
|
||
'remaining_dose': remaining_dose # 잔여 필요량
|
||
})
|
||
|
||
total_qty += qty
|
||
total_dose += dose
|
||
total_amount += amount
|
||
|
||
# 정렬
|
||
if sort == 'qty_asc':
|
||
items.sort(key=lambda x: x['total_dose'])
|
||
elif sort == 'qty_desc':
|
||
items.sort(key=lambda x: x['total_dose'], reverse=True)
|
||
elif sort == 'name_asc':
|
||
items.sort(key=lambda x: x['product_name'])
|
||
elif sort == 'amount_desc':
|
||
items.sort(key=lambda x: x['total_amount'], reverse=True)
|
||
elif sort == 'rx_desc':
|
||
items.sort(key=lambda x: x['prescription_count'], reverse=True)
|
||
|
||
# 제품 이미지 조회
|
||
try:
|
||
images_db_path = Path(__file__).parent / 'db' / 'product_images.db'
|
||
if images_db_path.exists():
|
||
img_conn = sqlite3.connect(str(images_db_path))
|
||
img_cursor = img_conn.cursor()
|
||
|
||
drug_codes = [item['drug_code'] for item in items]
|
||
|
||
image_map = {}
|
||
if drug_codes:
|
||
placeholders = ','.join(['?' for _ in drug_codes])
|
||
img_cursor.execute(f'''
|
||
SELECT drug_code, thumbnail_base64
|
||
FROM product_images
|
||
WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||
''', drug_codes)
|
||
for r in img_cursor.fetchall():
|
||
image_map[r[0]] = r[1]
|
||
|
||
img_conn.close()
|
||
|
||
for item in items:
|
||
if item['drug_code'] in image_map:
|
||
item['thumbnail'] = image_map[item['drug_code']]
|
||
except Exception as img_err:
|
||
logging.warning(f"제품 이미지 조회 오류: {img_err}")
|
||
|
||
# 기간 일수 계산
|
||
try:
|
||
from datetime import datetime as dt
|
||
start_dt = dt.strptime(start_date_fmt, '%Y%m%d')
|
||
end_dt = dt.strptime(end_date_fmt, '%Y%m%d')
|
||
period_days = (end_dt - start_dt).days + 1
|
||
except:
|
||
period_days = 1
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'items': items[:500],
|
||
'stats': {
|
||
'period_days': period_days,
|
||
'product_count': len(items),
|
||
'total_qty': total_qty,
|
||
'total_dose': total_dose,
|
||
'total_amount': int(total_amount)
|
||
}
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"전문의약품 사용량 조회 오류: {e}")
|
||
return jsonify({
|
||
'success': False,
|
||
'error': str(e)
|
||
}), 500
|
||
|
||
|
||
# ===== Claude 상태 API =====
|
||
|
||
@app.route('/api/claude-status')
|
||
def api_claude_status():
|
||
"""
|
||
Claude 사용량 상태 조회 (토큰 차감 없음)
|
||
GET /api/claude-status
|
||
GET /api/claude-status?detail=true — 전체 세션 상세 포함
|
||
|
||
Returns:
|
||
{
|
||
"ok": true,
|
||
"connected": true,
|
||
"model": "claude-opus-4-5",
|
||
"mainSession": { ... },
|
||
"summary": { ... },
|
||
"sessions": [ ... ], // detail=true 일 때만
|
||
"timestamp": "2026-02-27T09:45:00+09:00"
|
||
}
|
||
"""
|
||
try:
|
||
from services.clawdbot_client import get_claude_status
|
||
|
||
# 상세 모드 여부
|
||
detail_mode = request.args.get('detail', 'false').lower() == 'true'
|
||
|
||
status = get_claude_status()
|
||
|
||
if not status or not status.get('connected'):
|
||
return jsonify({
|
||
'ok': False,
|
||
'connected': False,
|
||
'error': status.get('error', 'Gateway 연결 실패'),
|
||
'timestamp': datetime.now(KST).isoformat()
|
||
}), 503
|
||
|
||
sessions_data = status.get('sessions', {})
|
||
sessions_list = sessions_data.get('sessions', [])
|
||
defaults = sessions_data.get('defaults', {})
|
||
|
||
# 메인 세션 찾기
|
||
main_session = None
|
||
for s in sessions_list:
|
||
if s.get('key') == 'agent:main:main':
|
||
main_session = s
|
||
break
|
||
|
||
# 전체 토큰 합계
|
||
total_tokens = sum(s.get('totalTokens', 0) for s in sessions_list)
|
||
|
||
# 메인 세션 컨텍스트 사용률
|
||
context_used = 0
|
||
context_max = defaults.get('contextTokens', 200000)
|
||
context_percent = 0
|
||
if main_session:
|
||
context_used = main_session.get('totalTokens', 0)
|
||
context_max = main_session.get('contextTokens', context_max)
|
||
if context_max > 0:
|
||
context_percent = round(context_used / context_max * 100, 1)
|
||
|
||
# 기본 응답
|
||
response = {
|
||
'ok': True,
|
||
'connected': True,
|
||
'model': f"{defaults.get('modelProvider', 'unknown')}/{defaults.get('model', 'unknown')}",
|
||
'context': {
|
||
'used': context_used,
|
||
'max': context_max,
|
||
'percent': context_percent,
|
||
'display': f"{context_used//1000}k/{context_max//1000}k ({context_percent}%)"
|
||
},
|
||
'mainSession': {
|
||
'key': main_session.get('key') if main_session else None,
|
||
'inputTokens': main_session.get('inputTokens', 0) if main_session else 0,
|
||
'outputTokens': main_session.get('outputTokens', 0) if main_session else 0,
|
||
'totalTokens': main_session.get('totalTokens', 0) if main_session else 0,
|
||
'lastChannel': main_session.get('lastChannel') if main_session else None
|
||
} if main_session else None,
|
||
'summary': {
|
||
'totalSessions': len(sessions_list),
|
||
'totalTokens': total_tokens,
|
||
'activeModel': defaults.get('model')
|
||
},
|
||
'timestamp': datetime.now(KST).isoformat()
|
||
}
|
||
|
||
# 상세 모드: 전체 세션 목록 추가
|
||
if detail_mode:
|
||
detailed_sessions = []
|
||
for s in sessions_list:
|
||
session_tokens = s.get('totalTokens', 0)
|
||
session_context_max = s.get('contextTokens', 200000)
|
||
session_percent = round(session_tokens / session_context_max * 100, 1) if session_context_max > 0 else 0
|
||
|
||
# 세션 키에서 이름 추출 (agent:main:xxx → xxx)
|
||
session_key = s.get('key', '')
|
||
session_name = session_key.split(':')[-1] if ':' in session_key else session_key
|
||
|
||
# 마지막 활동 시간
|
||
updated_at = s.get('updatedAt')
|
||
updated_str = None
|
||
if updated_at:
|
||
try:
|
||
dt = datetime.fromtimestamp(updated_at / 1000, tz=KST)
|
||
updated_str = dt.strftime('%Y-%m-%d %H:%M')
|
||
except:
|
||
pass
|
||
|
||
detailed_sessions.append({
|
||
'key': session_key,
|
||
'name': session_name,
|
||
'displayName': s.get('displayName', session_name),
|
||
'model': f"{s.get('modelProvider', 'unknown')}/{s.get('model', 'unknown')}",
|
||
'tokens': {
|
||
'input': s.get('inputTokens', 0),
|
||
'output': s.get('outputTokens', 0),
|
||
'total': session_tokens,
|
||
'contextMax': session_context_max,
|
||
'contextPercent': session_percent,
|
||
'display': f"{session_tokens//1000}k/{session_context_max//1000}k ({session_percent}%)"
|
||
},
|
||
'channel': s.get('lastChannel') or s.get('origin', {}).get('provider'),
|
||
'kind': s.get('kind'),
|
||
'updatedAt': updated_str
|
||
})
|
||
|
||
# 토큰 사용량 순으로 정렬
|
||
detailed_sessions.sort(key=lambda x: x['tokens']['total'], reverse=True)
|
||
response['sessions'] = detailed_sessions
|
||
|
||
# 모델별 통계
|
||
model_stats = {}
|
||
for s in sessions_list:
|
||
model_key = f"{s.get('modelProvider', 'unknown')}/{s.get('model', 'unknown')}"
|
||
if model_key not in model_stats:
|
||
model_stats[model_key] = {'sessions': 0, 'tokens': 0}
|
||
model_stats[model_key]['sessions'] += 1
|
||
model_stats[model_key]['tokens'] += s.get('totalTokens', 0)
|
||
response['modelStats'] = model_stats
|
||
|
||
return jsonify(response)
|
||
|
||
except Exception as e:
|
||
logging.error(f"Claude 상태 조회 오류: {e}")
|
||
return jsonify({
|
||
'ok': False,
|
||
'connected': False,
|
||
'error': str(e),
|
||
'timestamp': datetime.now(KST).isoformat()
|
||
}), 500
|
||
|
||
|
||
# =============================================================================
|
||
# 회원 검색 API (팜IT3000 CD_PERSON)
|
||
# =============================================================================
|
||
|
||
@app.route('/api/members/search')
|
||
def api_members_search():
|
||
"""
|
||
회원 검색 API
|
||
- 이름 또는 전화번호로 검색
|
||
- PM_BASE.dbo.CD_PERSON 테이블 조회
|
||
"""
|
||
search = request.args.get('q', '').strip()
|
||
search_type = request.args.get('type', 'auto') # auto, name, phone
|
||
limit = min(int(request.args.get('limit', 50)), 200)
|
||
|
||
if not search or len(search) < 2:
|
||
return jsonify({'success': False, 'error': '검색어는 2글자 이상 입력하세요'})
|
||
|
||
try:
|
||
# PM_BASE 연결
|
||
base_session = db_manager.get_session('PM_BASE')
|
||
|
||
# 검색 타입 자동 감지
|
||
if search_type == 'auto':
|
||
# 숫자만 있으면 전화번호, 아니면 이름
|
||
if search.replace('-', '').replace(' ', '').isdigit():
|
||
search_type = 'phone'
|
||
else:
|
||
search_type = 'name'
|
||
|
||
# 전화번호 정규화
|
||
phone_search = search.replace('-', '').replace(' ', '')
|
||
|
||
if search_type == 'phone':
|
||
# 전화번호 검색 (3개 컬럼 모두)
|
||
query = text(f"""
|
||
SELECT TOP {limit}
|
||
CUSCODE, PANAME, PANUM,
|
||
TEL_NO, PHONE, PHONE2,
|
||
CUSETC, EMAIL, SMS_STOP
|
||
FROM CD_PERSON
|
||
WHERE
|
||
REPLACE(REPLACE(TEL_NO, '-', ''), ' ', '') LIKE :phone
|
||
OR REPLACE(REPLACE(PHONE, '-', ''), ' ', '') LIKE :phone
|
||
OR REPLACE(REPLACE(PHONE2, '-', ''), ' ', '') LIKE :phone
|
||
ORDER BY PANAME
|
||
""")
|
||
rows = base_session.execute(query, {'phone': f'%{phone_search}%'}).fetchall()
|
||
else:
|
||
# 이름 검색
|
||
query = text(f"""
|
||
SELECT TOP {limit}
|
||
CUSCODE, PANAME, PANUM,
|
||
TEL_NO, PHONE, PHONE2,
|
||
CUSETC, EMAIL, SMS_STOP
|
||
FROM CD_PERSON
|
||
WHERE PANAME LIKE :name
|
||
ORDER BY PANAME
|
||
""")
|
||
rows = base_session.execute(query, {'name': f'%{search}%'}).fetchall()
|
||
|
||
members = []
|
||
for row in rows:
|
||
# 유효한 전화번호 찾기 (PHONE 우선, 없으면 TEL_NO, PHONE2)
|
||
phone = row.PHONE or row.TEL_NO or row.PHONE2 or ''
|
||
phone = phone.strip() if phone else ''
|
||
|
||
members.append({
|
||
'cuscode': row.CUSCODE,
|
||
'name': row.PANAME or '',
|
||
'panum': row.PANUM or '',
|
||
'phone': phone,
|
||
'tel_no': row.TEL_NO or '',
|
||
'phone1': row.PHONE or '',
|
||
'phone2': row.PHONE2 or '',
|
||
'memo': (row.CUSETC or '')[:100], # 메모 100자 제한
|
||
'email': row.EMAIL or '',
|
||
'sms_stop': row.SMS_STOP == 'Y' # SMS 수신거부 여부
|
||
})
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'items': members,
|
||
'count': len(members),
|
||
'search_type': search_type
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"회원 검색 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/members/<cuscode>')
|
||
def api_member_detail(cuscode):
|
||
"""회원 상세 정보 + 메모 조회"""
|
||
try:
|
||
base_session = db_manager.get_session('PM_BASE')
|
||
|
||
# 회원 정보
|
||
query = text("""
|
||
SELECT
|
||
CUSCODE, PANAME, PANUM,
|
||
TEL_NO, PHONE, PHONE2,
|
||
CUSETC, EMAIL, ADDRESS, SMS_STOP
|
||
FROM CD_PERSON
|
||
WHERE CUSCODE = :cuscode
|
||
""")
|
||
row = base_session.execute(query, {'cuscode': cuscode}).fetchone()
|
||
|
||
if not row:
|
||
return jsonify({'success': False, 'error': '회원을 찾을 수 없습니다'}), 404
|
||
|
||
member = {
|
||
'cuscode': row.CUSCODE,
|
||
'name': row.PANAME or '',
|
||
'panum': row.PANUM or '',
|
||
'phone': row.PHONE or row.TEL_NO or row.PHONE2 or '',
|
||
'tel_no': row.TEL_NO or '',
|
||
'phone1': row.PHONE or '',
|
||
'phone2': row.PHONE2 or '',
|
||
'memo': row.CUSETC or '',
|
||
'email': row.EMAIL or '',
|
||
'address': row.ADDRESS or '',
|
||
'sms_stop': row.SMS_STOP == 'Y'
|
||
}
|
||
|
||
# 상세 메모 조회
|
||
memo_query = text("""
|
||
SELECT MEMO_CODE, PHARMA_ID, MEMO_DATE, MEMO_TITLE, MEMO_Item
|
||
FROM CD_PERSON_MEMO
|
||
WHERE CUSCODE = :cuscode
|
||
ORDER BY MEMO_DATE DESC
|
||
""")
|
||
memo_rows = base_session.execute(memo_query, {'cuscode': cuscode}).fetchall()
|
||
|
||
memos = []
|
||
for m in memo_rows:
|
||
memos.append({
|
||
'memo_code': m.MEMO_CODE,
|
||
'author': m.PHARMA_ID or '',
|
||
'date': m.MEMO_DATE or '',
|
||
'title': m.MEMO_TITLE or '',
|
||
'content': m.MEMO_Item or ''
|
||
})
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'member': member,
|
||
'memos': memos
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"회원 상세 조회 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/members/<cuscode>/phone', methods=['PUT'])
|
||
def api_update_phone(cuscode):
|
||
"""환자 전화번호 수정 API"""
|
||
try:
|
||
data = request.get_json() or {}
|
||
new_phone = data.get('phone', '').strip()
|
||
|
||
# 길이 제한 (20자)
|
||
if len(new_phone) > 20:
|
||
return jsonify({'success': False, 'error': '전화번호는 20자를 초과할 수 없습니다.'}), 400
|
||
|
||
base_session = db_manager.get_session('PM_BASE')
|
||
|
||
# PHONE 업데이트
|
||
update_query = text("""
|
||
UPDATE CD_PERSON
|
||
SET PHONE = :phone
|
||
WHERE CUSCODE = :cuscode
|
||
""")
|
||
result = base_session.execute(update_query, {'phone': new_phone, 'cuscode': cuscode})
|
||
base_session.commit()
|
||
|
||
if result.rowcount == 0:
|
||
return jsonify({'success': False, 'error': '해당 고객을 찾을 수 없습니다.'}), 404
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': '전화번호가 저장되었습니다.',
|
||
'phone': new_phone
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"전화번호 수정 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/members/<cuscode>/cusetc', methods=['PUT'])
|
||
def api_update_cusetc(cuscode):
|
||
"""특이(참고)사항 수정 API"""
|
||
try:
|
||
data = request.get_json() or {}
|
||
new_cusetc = data.get('cusetc', '').strip()
|
||
|
||
# 길이 제한 (2000자)
|
||
if len(new_cusetc) > 2000:
|
||
return jsonify({'success': False, 'error': '특이사항은 2000자를 초과할 수 없습니다.'}), 400
|
||
|
||
base_session = db_manager.get_session('PM_BASE')
|
||
|
||
# CUSETC 업데이트
|
||
update_query = text("""
|
||
UPDATE CD_PERSON
|
||
SET CUSETC = :cusetc
|
||
WHERE CUSCODE = :cuscode
|
||
""")
|
||
result = base_session.execute(update_query, {'cusetc': new_cusetc, 'cuscode': cuscode})
|
||
base_session.commit()
|
||
|
||
if result.rowcount == 0:
|
||
return jsonify({'success': False, 'error': '해당 고객을 찾을 수 없습니다.'}), 404
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': '특이사항이 저장되었습니다.',
|
||
'cusetc': new_cusetc
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"특이사항 수정 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/print/cusetc', methods=['POST'])
|
||
def api_print_cusetc():
|
||
"""특이(참고)사항 영수증 인쇄 API"""
|
||
try:
|
||
from pos_printer import print_cusetc
|
||
|
||
data = request.get_json() or {}
|
||
customer_name = data.get('customer_name', '').strip()
|
||
cusetc = data.get('cusetc', '').strip()
|
||
phone = data.get('phone', '').strip()
|
||
|
||
if not customer_name:
|
||
return jsonify({'success': False, 'error': '고객 이름이 필요합니다.'}), 400
|
||
|
||
if not cusetc:
|
||
return jsonify({'success': False, 'error': '특이사항이 비어있습니다.'}), 400
|
||
|
||
# ESC/POS 프린터로 출력
|
||
result = print_cusetc(customer_name, cusetc, phone)
|
||
|
||
if result:
|
||
return jsonify({
|
||
'success': True,
|
||
'message': f'{customer_name}님의 특이사항이 인쇄되었습니다.'
|
||
})
|
||
else:
|
||
return jsonify({
|
||
'success': False,
|
||
'error': '프린터 연결 실패. 프린터가 켜져있는지 확인하세요.'
|
||
}), 500
|
||
|
||
except ImportError:
|
||
return jsonify({'success': False, 'error': 'pos_printer 모듈을 찾을 수 없습니다.'}), 500
|
||
except Exception as e:
|
||
logging.error(f"특이사항 인쇄 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/members/history/<phone>')
|
||
def api_member_history(phone):
|
||
"""
|
||
회원 구매 이력 통합 조회 API
|
||
- 마일리지 적립/사용 내역 (SQLite)
|
||
- transaction_id로 POS 품목 조회 (MSSQL)
|
||
- 전체 구매 이력 (SALE_MAIN)
|
||
- 조제 이력 (PS_main)
|
||
"""
|
||
try:
|
||
# 전화번호 정규화
|
||
phone = phone.replace('-', '').replace(' ', '')
|
||
|
||
result = {
|
||
'success': True,
|
||
'phone': phone,
|
||
'mileage': None,
|
||
'purchases': [], # 전체 구매 이력
|
||
'prescriptions': [], # 조제 이력
|
||
'interests': [], # 관심 상품 (AI 업셀링)
|
||
'pos_customer': None
|
||
}
|
||
|
||
transaction_ids = [] # 적립된 거래번호 수집
|
||
|
||
# 1. 마일리지 내역 조회 (SQLite) - 새 연결 사용 (멀티스레드 안전)
|
||
sqlite_conn = None
|
||
try:
|
||
sqlite_conn = db_manager.get_sqlite_connection(new_connection=True)
|
||
cursor = sqlite_conn.cursor()
|
||
|
||
# 사용자 정보 조회
|
||
cursor.execute("""
|
||
SELECT id, nickname, phone, mileage_balance, created_at
|
||
FROM users WHERE phone = ?
|
||
""", (phone,))
|
||
user = cursor.fetchone()
|
||
|
||
if user:
|
||
user_id = user['id']
|
||
|
||
# 적립/사용 내역 조회 (claim_tokens 조인으로 금액 포함)
|
||
cursor.execute("""
|
||
SELECT
|
||
ml.points, ml.balance_after, ml.reason,
|
||
ml.description, ml.transaction_id, ml.created_at,
|
||
ct.total_amount
|
||
FROM mileage_ledger ml
|
||
LEFT JOIN claim_tokens ct ON ml.transaction_id = ct.transaction_id
|
||
WHERE ml.user_id = ?
|
||
ORDER BY ml.created_at DESC
|
||
LIMIT 50
|
||
""", (user_id,))
|
||
transactions = cursor.fetchall()
|
||
|
||
tx_list = []
|
||
for t in transactions:
|
||
tx_data = {
|
||
'points': t['points'],
|
||
'balance_after': t['balance_after'],
|
||
'reason': t['reason'],
|
||
'description': t['description'],
|
||
'transaction_id': t['transaction_id'],
|
||
'created_at': t['created_at'],
|
||
'total_amount': t['total_amount'],
|
||
'items': [] # 나중에 POS에서 채움
|
||
}
|
||
tx_list.append(tx_data)
|
||
|
||
# 적립 거래번호 수집
|
||
if t['transaction_id'] and t['reason'] == 'CLAIM':
|
||
transaction_ids.append(t['transaction_id'])
|
||
|
||
result['mileage'] = {
|
||
'user_id': user_id,
|
||
'name': user['nickname'] or '',
|
||
'phone': user['phone'],
|
||
'balance': user['mileage_balance'] or 0,
|
||
'member_since': user['created_at'],
|
||
'transactions': tx_list
|
||
}
|
||
|
||
# 관심 상품 조회 (AI 업셀링에서 '관심있어요' 표시한 것)
|
||
cursor.execute("""
|
||
SELECT
|
||
id, recommended_product, recommendation_message,
|
||
recommendation_reason, trigger_products, created_at
|
||
FROM ai_recommendations
|
||
WHERE user_id = ? AND status = 'interested'
|
||
ORDER BY created_at DESC
|
||
LIMIT 20
|
||
""", (user_id,))
|
||
interests = cursor.fetchall()
|
||
|
||
result['interests'] = [{
|
||
'id': i['id'],
|
||
'product': i['recommended_product'],
|
||
'message': i['recommendation_message'],
|
||
'reason': i['recommendation_reason'],
|
||
'trigger_products': i['trigger_products'],
|
||
'created_at': i['created_at']
|
||
} for i in interests]
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
logging.error(f"마일리지 조회 실패: {e}\n{traceback.format_exc()}")
|
||
finally:
|
||
# SQLite 연결 닫기
|
||
if sqlite_conn:
|
||
try:
|
||
sqlite_conn.close()
|
||
except:
|
||
pass
|
||
|
||
# 2. 전화번호로 POS 고객코드 조회 (MSSQL)
|
||
cuscode = None
|
||
try:
|
||
base_session = db_manager.get_session('PM_BASE')
|
||
cuscode_query = text("""
|
||
SELECT TOP 1 CUSCODE, PANAME
|
||
FROM CD_PERSON
|
||
WHERE REPLACE(REPLACE(PHONE, '-', ''), ' ', '') = :phone
|
||
OR REPLACE(REPLACE(TEL_NO, '-', ''), ' ', '') = :phone
|
||
OR REPLACE(REPLACE(PHONE2, '-', ''), ' ', '') = :phone
|
||
""")
|
||
cus_row = base_session.execute(cuscode_query, {'phone': phone}).fetchone()
|
||
|
||
if cus_row:
|
||
cuscode = cus_row.CUSCODE
|
||
result['pos_customer'] = {
|
||
'cuscode': cuscode,
|
||
'name': cus_row.PANAME
|
||
}
|
||
except Exception as e:
|
||
logging.warning(f"POS 고객 조회 실패: {e}")
|
||
|
||
# 3. transaction_id로 POS 품목 조회 (마일리지 적립 내역)
|
||
if transaction_ids and result['mileage']:
|
||
try:
|
||
pres_session = db_manager.get_session('PM_PRES')
|
||
|
||
# 각 거래번호별 품목 조회
|
||
items_by_tx = {}
|
||
for tx_id in transaction_ids[:30]: # 최대 30건
|
||
items_query = text("""
|
||
SELECT
|
||
S.DrugCode,
|
||
G.GoodsName,
|
||
S.QUAN as quantity,
|
||
S.SL_TOTAL_PRICE as price
|
||
FROM SALE_SUB S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
WHERE S.SL_NO_order = :order_no
|
||
""")
|
||
items = pres_session.execute(items_query, {'order_no': tx_id}).fetchall()
|
||
|
||
items_by_tx[tx_id] = [{
|
||
'drug_code': item.DrugCode,
|
||
'name': item.GoodsName or '알 수 없음',
|
||
'quantity': float(item.quantity) if item.quantity else 1,
|
||
'price': float(item.price) if item.price else 0
|
||
} for item in items]
|
||
|
||
# 마일리지 내역에 품목 추가
|
||
for tx in result['mileage']['transactions']:
|
||
tx_id = tx.get('transaction_id')
|
||
if tx_id and tx_id in items_by_tx:
|
||
tx['items'] = items_by_tx[tx_id]
|
||
|
||
except Exception as e:
|
||
logging.warning(f"POS 품목 조회 실패: {e}")
|
||
|
||
# 4. 전체 구매 이력 조회 (고객코드 기준)
|
||
if cuscode:
|
||
try:
|
||
pres_session = db_manager.get_session('PM_PRES')
|
||
|
||
# 최근 60일 구매 이력
|
||
purchase_query = text("""
|
||
SELECT TOP 30
|
||
M.SL_NO_order as order_no,
|
||
M.SL_DT_appl as order_date,
|
||
M.SL_MY_total as total_amount
|
||
FROM SALE_MAIN M
|
||
WHERE M.SL_CD_custom = :cuscode
|
||
ORDER BY M.SL_DT_appl DESC, M.SL_NO_order DESC
|
||
""")
|
||
orders = pres_session.execute(purchase_query, {'cuscode': cuscode}).fetchall()
|
||
|
||
purchases = []
|
||
for order in orders:
|
||
# 품목 조회
|
||
items_query = text("""
|
||
SELECT
|
||
S.DrugCode,
|
||
G.GoodsName,
|
||
S.QUAN as quantity,
|
||
S.SL_TOTAL_PRICE as price
|
||
FROM SALE_SUB S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
WHERE S.SL_NO_order = :order_no
|
||
""")
|
||
items = pres_session.execute(items_query, {'order_no': order.order_no}).fetchall()
|
||
|
||
purchases.append({
|
||
'order_no': order.order_no,
|
||
'date': order.order_date,
|
||
'total': float(order.total_amount) if order.total_amount else 0,
|
||
'items': [{
|
||
'drug_code': item.DrugCode,
|
||
'name': item.GoodsName or '알 수 없음',
|
||
'quantity': float(item.quantity) if item.quantity else 1,
|
||
'price': float(item.price) if item.price else 0
|
||
} for item in items]
|
||
})
|
||
|
||
result['purchases'] = purchases
|
||
|
||
except Exception as e:
|
||
logging.warning(f"전체 구매 이력 조회 실패: {e}")
|
||
|
||
# 5. 조제 이력 조회 (고객코드 기준)
|
||
if cuscode:
|
||
try:
|
||
pres_session = db_manager.get_session('PM_PRES')
|
||
|
||
# 최근 조제 이력 (최대 30건)
|
||
rx_query = text("""
|
||
SELECT TOP 30
|
||
P.PreSerial,
|
||
P.Indate,
|
||
P.Paname,
|
||
P.Drname,
|
||
P.OrderName,
|
||
P.TDAYS
|
||
FROM PS_main P
|
||
WHERE P.CusCode = :cuscode
|
||
ORDER BY P.Indate DESC, P.PreSerial DESC
|
||
""")
|
||
rxs = pres_session.execute(rx_query, {'cuscode': cuscode}).fetchall()
|
||
|
||
prescriptions = []
|
||
for rx in rxs:
|
||
# 처방 품목 조회
|
||
items_query = text("""
|
||
SELECT
|
||
S.DrugCode,
|
||
G.GoodsName,
|
||
S.Days,
|
||
S.QUAN,
|
||
S.QUAN_TIME
|
||
FROM PS_sub_pharm S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
WHERE S.PreSerial = :pre_serial
|
||
""")
|
||
items = pres_session.execute(items_query, {'pre_serial': rx.PreSerial}).fetchall()
|
||
|
||
prescriptions.append({
|
||
'pre_serial': rx.PreSerial,
|
||
'date': rx.Indate,
|
||
'patient_name': rx.Paname,
|
||
'doctor': rx.Drname,
|
||
'hospital': rx.OrderName,
|
||
'total_days': rx.TDAYS,
|
||
'items': [{
|
||
'drug_code': item.DrugCode,
|
||
'name': item.GoodsName or '알 수 없음',
|
||
'days': float(item.Days) if item.Days else 0,
|
||
'quantity': float(item.QUAN) if item.QUAN else 0,
|
||
'times_per_day': float(item.QUAN_TIME) if item.QUAN_TIME else 0
|
||
} for item in items]
|
||
})
|
||
|
||
result['prescriptions'] = prescriptions
|
||
|
||
except Exception as e:
|
||
logging.warning(f"조제 이력 조회 실패: {e}")
|
||
|
||
return jsonify(result)
|
||
|
||
except Exception as e:
|
||
logging.error(f"회원 이력 조회 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
# =============================================================================
|
||
# 알림톡/SMS 발송 API
|
||
# =============================================================================
|
||
|
||
@app.route('/api/message/send', methods=['POST'])
|
||
def api_message_send():
|
||
"""
|
||
알림톡/SMS 발송 API
|
||
|
||
Body:
|
||
{
|
||
"recipients": [{"cuscode": "", "name": "", "phone": ""}],
|
||
"message": "메시지 내용",
|
||
"type": "alimtalk" | "sms"
|
||
}
|
||
"""
|
||
try:
|
||
data = request.get_json()
|
||
if not data:
|
||
return jsonify({'success': False, 'error': '데이터가 없습니다'}), 400
|
||
|
||
recipients = data.get('recipients', [])
|
||
message = data.get('message', '')
|
||
msg_type = data.get('type', 'sms') # alimtalk 또는 sms
|
||
|
||
if not recipients:
|
||
return jsonify({'success': False, 'error': '수신자가 없습니다'}), 400
|
||
if not message:
|
||
return jsonify({'success': False, 'error': '메시지 내용이 없습니다'}), 400
|
||
|
||
# 전화번호 정규화
|
||
valid_recipients = []
|
||
for r in recipients:
|
||
phone = (r.get('phone') or '').replace('-', '').replace(' ', '')
|
||
if phone and len(phone) >= 10:
|
||
valid_recipients.append({
|
||
'cuscode': r.get('cuscode', ''),
|
||
'name': r.get('name', ''),
|
||
'phone': phone
|
||
})
|
||
|
||
if not valid_recipients:
|
||
return jsonify({'success': False, 'error': '유효한 전화번호가 없습니다'}), 400
|
||
|
||
# TODO: 실제 발송 로직 (NHN Cloud 알림톡/SMS)
|
||
# 현재는 테스트 모드로 성공 응답
|
||
|
||
results = []
|
||
for r in valid_recipients:
|
||
results.append({
|
||
'phone': r['phone'],
|
||
'name': r['name'],
|
||
'status': 'success', # 실제 발송 시 결과로 변경
|
||
'message': f'{msg_type} 발송 예약됨'
|
||
})
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'type': msg_type,
|
||
'sent_count': len(results),
|
||
'results': results,
|
||
'message': f'{len(results)}명에게 {msg_type} 발송 완료 (테스트 모드)'
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"메시지 발송 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
# =============================================================================
|
||
# QR 라벨 인쇄 API
|
||
# =============================================================================
|
||
|
||
@app.route('/api/qr-print', methods=['POST'])
|
||
def api_qr_print():
|
||
"""QR 라벨 인쇄 API"""
|
||
try:
|
||
from qr_printer import print_drug_qr_label
|
||
|
||
data = request.get_json()
|
||
if not data:
|
||
return jsonify({'success': False, 'error': '데이터가 없습니다'}), 400
|
||
|
||
drug_name = data.get('drug_name', '')
|
||
barcode = data.get('barcode', '')
|
||
drug_code = data.get('drug_code', '')
|
||
sale_price = data.get('sale_price', 0)
|
||
|
||
if not drug_name:
|
||
return jsonify({'success': False, 'error': '상품명이 필요합니다'}), 400
|
||
|
||
# 바코드가 없으면 drug_code 사용
|
||
qr_data = barcode if barcode else drug_code
|
||
|
||
result = print_drug_qr_label(
|
||
drug_name=drug_name,
|
||
barcode=qr_data,
|
||
sale_price=sale_price,
|
||
drug_code=drug_code,
|
||
pharmacy_name='청춘약국'
|
||
)
|
||
|
||
return jsonify(result)
|
||
|
||
except ImportError as e:
|
||
logging.error(f"QR 프린터 모듈 로드 실패: {e}")
|
||
return jsonify({'success': False, 'error': 'QR 프린터 모듈이 없습니다'}), 500
|
||
except Exception as e:
|
||
logging.error(f"QR 인쇄 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/qr-preview', methods=['POST'])
|
||
def api_qr_preview():
|
||
"""QR 라벨 미리보기 API (base64 이미지 반환)"""
|
||
try:
|
||
from qr_printer import preview_qr_label
|
||
|
||
data = request.get_json()
|
||
if not data:
|
||
return jsonify({'success': False, 'error': '데이터가 없습니다'}), 400
|
||
|
||
drug_name = data.get('drug_name', '')
|
||
barcode = data.get('barcode', '')
|
||
drug_code = data.get('drug_code', '')
|
||
sale_price = data.get('sale_price', 0)
|
||
|
||
if not drug_name:
|
||
return jsonify({'success': False, 'error': '상품명이 필요합니다'}), 400
|
||
|
||
qr_data = barcode if barcode else drug_code
|
||
|
||
image_data = preview_qr_label(
|
||
drug_name=drug_name,
|
||
barcode=qr_data,
|
||
sale_price=sale_price,
|
||
drug_code=drug_code,
|
||
pharmacy_name='청춘약국'
|
||
)
|
||
|
||
return jsonify({'success': True, 'image': image_data})
|
||
|
||
except Exception as e:
|
||
logging.error(f"QR 미리보기 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
def check_port_available(port: int) -> bool:
|
||
"""포트가 사용 가능한지 확인"""
|
||
import socket
|
||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
sock.settimeout(1)
|
||
result = sock.connect_ex(('127.0.0.1', port))
|
||
sock.close()
|
||
return result != 0 # 0이면 이미 사용 중, 0이 아니면 사용 가능
|
||
|
||
|
||
def kill_process_on_port(port: int) -> bool:
|
||
"""특정 포트를 사용하는 프로세스 종료 (Windows)"""
|
||
import subprocess
|
||
try:
|
||
# netstat으로 PID 찾기
|
||
result = subprocess.run(
|
||
f'netstat -ano | findstr ":{port}"',
|
||
shell=True, capture_output=True, text=True
|
||
)
|
||
|
||
for line in result.stdout.strip().split('\n'):
|
||
if 'LISTENING' in line:
|
||
parts = line.split()
|
||
pid = parts[-1]
|
||
if pid.isdigit():
|
||
subprocess.run(f'taskkill /F /PID {pid}', shell=True)
|
||
logging.info(f"포트 {port} 사용 중인 프로세스 종료: PID {pid}")
|
||
return True
|
||
return False
|
||
except Exception as e:
|
||
logging.error(f"프로세스 종료 실패: {e}")
|
||
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('/admin/return-management')
|
||
def admin_return_management():
|
||
"""반품 후보 관리 페이지"""
|
||
return render_template('admin_return_management.html')
|
||
|
||
|
||
@app.route('/api/return-candidates')
|
||
def api_return_candidates():
|
||
"""반품 후보 목록 조회 API (가격 정보 포함)"""
|
||
import pyodbc
|
||
|
||
status = request.args.get('status', '')
|
||
urgency = request.args.get('urgency', '')
|
||
search = request.args.get('search', '')
|
||
sort = request.args.get('sort', 'months_desc')
|
||
|
||
try:
|
||
db_path = os.path.join(BACKEND_DIR, 'db', 'orders.db')
|
||
conn = sqlite3.connect(db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
cursor = conn.cursor()
|
||
|
||
# 기본 쿼리
|
||
query = "SELECT * FROM return_candidates WHERE 1=1"
|
||
params = []
|
||
|
||
# 상태 필터
|
||
if status:
|
||
query += " AND status = ?"
|
||
params.append(status)
|
||
|
||
# 검색어 필터
|
||
if search:
|
||
query += " AND (drug_name LIKE ? OR drug_code LIKE ?)"
|
||
params.extend([f'%{search}%', f'%{search}%'])
|
||
|
||
# 긴급도 필터
|
||
if urgency == 'critical':
|
||
query += " AND (months_since_use >= 36 OR months_since_purchase >= 36)"
|
||
elif urgency == 'warning':
|
||
query += " AND ((months_since_use >= 24 OR months_since_purchase >= 24) AND (COALESCE(months_since_use, 0) < 36 AND COALESCE(months_since_purchase, 0) < 36))"
|
||
elif urgency == 'normal':
|
||
query += " AND (COALESCE(months_since_use, 0) < 24 AND COALESCE(months_since_purchase, 0) < 24)"
|
||
|
||
# 정렬
|
||
sort_map = {
|
||
'months_desc': 'COALESCE(months_since_use, months_since_purchase, 0) DESC',
|
||
'months_asc': 'COALESCE(months_since_use, months_since_purchase, 0) ASC',
|
||
'stock_desc': 'current_stock DESC',
|
||
'name_asc': 'drug_name ASC',
|
||
'detected_desc': 'detected_at DESC'
|
||
}
|
||
query += f" ORDER BY {sort_map.get(sort, 'detected_at DESC')}"
|
||
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
|
||
# 약품코드 목록 추출
|
||
drug_codes = [row['drug_code'] for row in rows]
|
||
|
||
# MSSQL에서 가격 + 위치 정보 조회 (한 번에)
|
||
price_map = {}
|
||
location_map = {}
|
||
if drug_codes:
|
||
try:
|
||
mssql_conn_str = (
|
||
'DRIVER={ODBC Driver 17 for SQL Server};'
|
||
'SERVER=192.168.0.4\\PM2014;'
|
||
'DATABASE=PM_DRUG;'
|
||
'UID=sa;'
|
||
'PWD=tmddls214!%(;'
|
||
'TrustServerCertificate=yes;'
|
||
'Connection Timeout=5'
|
||
)
|
||
mssql_conn = pyodbc.connect(mssql_conn_str, timeout=5)
|
||
mssql_cursor = mssql_conn.cursor()
|
||
|
||
# IN 절 생성 (SQL Injection 방지를 위해 파라미터화)
|
||
# Price가 없으면 Saleprice, Topprice 순으로 fallback
|
||
# CD_item_position JOIN으로 위치 정보도 함께 조회
|
||
placeholders = ','.join(['?' for _ in drug_codes])
|
||
mssql_cursor.execute(f"""
|
||
SELECT G.DrugCode,
|
||
COALESCE(NULLIF(G.Price, 0), NULLIF(G.Saleprice, 0), NULLIF(G.Topprice, 0), 0) as BestPrice,
|
||
ISNULL(POS.CD_NM_sale, '') as Location
|
||
FROM CD_GOODS G
|
||
LEFT JOIN CD_item_position POS ON G.DrugCode = POS.DrugCode
|
||
WHERE G.DrugCode IN ({placeholders})
|
||
""", drug_codes)
|
||
|
||
for row in mssql_cursor.fetchall():
|
||
price_map[row[0]] = float(row[1]) if row[1] else 0
|
||
location_map[row[0]] = row[2] or ''
|
||
|
||
mssql_conn.close()
|
||
except Exception as e:
|
||
logging.warning(f"MSSQL 가격/위치 조회 실패: {e}")
|
||
|
||
# 전체 데이터 조회 (통계용)
|
||
cursor.execute("SELECT drug_code, current_stock, months_since_use, months_since_purchase FROM return_candidates")
|
||
all_rows = cursor.fetchall()
|
||
|
||
# 긴급도별 금액 합계 계산
|
||
total_amount = 0
|
||
critical_amount = 0
|
||
warning_amount = 0
|
||
|
||
for row in all_rows:
|
||
code = row['drug_code']
|
||
stock = row['current_stock'] or 0
|
||
price = price_map.get(code, 0)
|
||
amount = stock * price
|
||
|
||
months_use = row['months_since_use'] or 0
|
||
months_purchase = row['months_since_purchase'] or 0
|
||
max_months = max(months_use, months_purchase)
|
||
|
||
total_amount += amount
|
||
if max_months >= 36:
|
||
critical_amount += amount
|
||
elif max_months >= 24:
|
||
warning_amount += amount
|
||
|
||
items = []
|
||
for row in rows:
|
||
code = row['drug_code']
|
||
stock = row['current_stock'] or 0
|
||
price = price_map.get(code, 0)
|
||
recoverable = stock * price
|
||
|
||
items.append({
|
||
'id': row['id'],
|
||
'drug_code': code,
|
||
'drug_name': row['drug_name'],
|
||
'current_stock': stock,
|
||
'unit_price': price,
|
||
'recoverable_amount': recoverable,
|
||
'location': location_map.get(code, ''),
|
||
'last_prescription_date': row['last_prescription_date'],
|
||
'months_since_use': row['months_since_use'],
|
||
'last_purchase_date': row['last_purchase_date'],
|
||
'months_since_purchase': row['months_since_purchase'],
|
||
'status': row['status'],
|
||
'decision_reason': row['decision_reason'],
|
||
'detected_at': row['detected_at'],
|
||
'updated_at': row['updated_at']
|
||
})
|
||
|
||
# 통계 계산
|
||
cursor.execute("SELECT COUNT(*) FROM return_candidates WHERE months_since_use >= 36 OR months_since_purchase >= 36")
|
||
critical = cursor.fetchone()[0]
|
||
|
||
cursor.execute("""SELECT COUNT(*) FROM return_candidates
|
||
WHERE (months_since_use >= 24 OR months_since_purchase >= 24)
|
||
AND (COALESCE(months_since_use, 0) < 36 AND COALESCE(months_since_purchase, 0) < 36)""")
|
||
warning = cursor.fetchone()[0]
|
||
|
||
cursor.execute("SELECT COUNT(*) FROM return_candidates WHERE status = 'pending'")
|
||
pending = cursor.fetchone()[0]
|
||
|
||
cursor.execute("SELECT COUNT(*) FROM return_candidates WHERE status IN ('returned', 'keep', 'disposed', 'resolved')")
|
||
processed = cursor.fetchone()[0]
|
||
|
||
cursor.execute("SELECT COUNT(*) FROM return_candidates")
|
||
total = cursor.fetchone()[0]
|
||
|
||
conn.close()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'items': items,
|
||
'stats': {
|
||
'critical': critical,
|
||
'warning': warning,
|
||
'pending': pending,
|
||
'processed': processed,
|
||
'total': total,
|
||
'total_amount': total_amount,
|
||
'critical_amount': critical_amount,
|
||
'warning_amount': warning_amount
|
||
}
|
||
})
|
||
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/return-candidates/<int:item_id>', methods=['PUT'])
|
||
def api_update_return_candidate(item_id):
|
||
"""반품 후보 상태 변경 API"""
|
||
try:
|
||
data = request.get_json()
|
||
new_status = data.get('status')
|
||
reason = data.get('reason', '')
|
||
|
||
if not new_status:
|
||
return jsonify({'success': False, 'error': '상태가 지정되지 않았습니다'}), 400
|
||
|
||
valid_statuses = ['pending', 'reviewed', 'returned', 'keep', 'disposed', 'resolved']
|
||
if new_status not in valid_statuses:
|
||
return jsonify({'success': False, 'error': f'유효하지 않은 상태: {new_status}'}), 400
|
||
|
||
db_path = os.path.join(BACKEND_DIR, 'db', 'orders.db')
|
||
conn = sqlite3.connect(db_path)
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("""
|
||
UPDATE return_candidates
|
||
SET status = ?,
|
||
decision_reason = ?,
|
||
reviewed_at = datetime('now', 'localtime'),
|
||
updated_at = datetime('now', 'localtime')
|
||
WHERE id = ?
|
||
""", (new_status, reason, item_id))
|
||
|
||
if cursor.rowcount == 0:
|
||
conn.close()
|
||
return jsonify({'success': False, 'error': '항목을 찾을 수 없습니다'}), 404
|
||
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return jsonify({'success': True, 'message': '상태가 변경되었습니다'})
|
||
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@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': ['기타']})
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# POS 실시간 판매 조회 (Qt GUI 웹 버전)
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
@app.route('/admin/pos-live')
|
||
def admin_pos_live():
|
||
"""POS 실시간 판매 조회 페이지 (Qt GUI 웹 버전)"""
|
||
return render_template('admin_pos_live.html')
|
||
|
||
|
||
@app.route('/api/admin/pos-live')
|
||
def api_admin_pos_live():
|
||
"""
|
||
실시간 판매 내역 API (Qt GUI와 동일한 쿼리)
|
||
- MSSQL: SALE_MAIN, CD_SUNAB, SALE_SUB
|
||
- SQLite: claim_tokens, users, mileage_ledger
|
||
"""
|
||
date_str = request.args.get('date')
|
||
if not date_str:
|
||
date_str = datetime.now().strftime('%Y%m%d')
|
||
|
||
mssql_conn = None
|
||
try:
|
||
# MSSQL 연결
|
||
mssql_engine = db_manager.get_engine('PM_PRES')
|
||
mssql_conn = mssql_engine.raw_connection()
|
||
mssql_cursor = mssql_conn.cursor()
|
||
|
||
# SQLite 연결
|
||
sqlite_conn = db_manager.get_sqlite_connection()
|
||
sqlite_cursor = sqlite_conn.cursor()
|
||
|
||
# 메인 쿼리: SALE_MAIN + CD_SUNAB 조인
|
||
query = """
|
||
SELECT
|
||
M.SL_NO_order,
|
||
M.InsertTime,
|
||
M.SL_MY_sale,
|
||
ISNULL(M.SL_NM_custom, '') AS customer_name,
|
||
M.SL_CD_custom AS customer_code,
|
||
ISNULL(S.card_total, 0) AS card_total,
|
||
ISNULL(S.cash_total, 0) AS cash_total,
|
||
ISNULL(M.SL_MY_total, 0) AS total_amount,
|
||
ISNULL(M.SL_MY_discount, 0) AS discount,
|
||
S.cash_receipt_mode,
|
||
S.cash_receipt_num
|
||
FROM SALE_MAIN M
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total,
|
||
ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total,
|
||
nCASHINMODE AS cash_receipt_mode,
|
||
nAPPROVAL_NUM AS cash_receipt_num
|
||
FROM CD_SUNAB
|
||
WHERE PRESERIAL = M.SL_NO_order
|
||
) S
|
||
WHERE M.SL_DT_appl = ?
|
||
ORDER BY M.InsertTime DESC
|
||
"""
|
||
|
||
mssql_cursor.execute(query, date_str)
|
||
rows = mssql_cursor.fetchall()
|
||
|
||
sales_list = []
|
||
total_sales = 0
|
||
|
||
for row in rows:
|
||
order_no, insert_time, sale_amount, customer, customer_code, card_total, cash_total, total_amount, discount, cash_receipt_mode, cash_receipt_num = row
|
||
|
||
# 품목 수 조회 (SALE_SUB)
|
||
mssql_cursor.execute("""
|
||
SELECT COUNT(*) FROM SALE_SUB WHERE SL_NO_order = ?
|
||
""", order_no)
|
||
item_count_row = mssql_cursor.fetchone()
|
||
item_count = item_count_row[0] if item_count_row else 0
|
||
|
||
# SQLite에서 QR 발행 여부 확인
|
||
sqlite_cursor.execute("""
|
||
SELECT id FROM claim_tokens WHERE transaction_id = ?
|
||
""", (order_no,))
|
||
qr_record = sqlite_cursor.fetchone()
|
||
qr_issued = bool(qr_record)
|
||
|
||
# SQLite에서 적립 사용자 조회 (user_id 포함)
|
||
sqlite_cursor.execute("""
|
||
SELECT u.id as user_id, u.nickname, u.phone, ct.claimable_points
|
||
FROM claim_tokens ct
|
||
LEFT JOIN users u ON ct.claimed_by_user_id = u.id
|
||
WHERE ct.transaction_id = ? AND ct.claimed_at IS NOT NULL
|
||
""", (order_no,))
|
||
claimed_user = sqlite_cursor.fetchone()
|
||
|
||
# 적립 사용자 정보 분리
|
||
claimed_user_id = None
|
||
if claimed_user and claimed_user['nickname'] and claimed_user['phone']:
|
||
claimed_user_id = claimed_user['user_id']
|
||
claimed_name = claimed_user['nickname']
|
||
claimed_phone = claimed_user['phone']
|
||
claimed_points = claimed_user['claimable_points']
|
||
else:
|
||
claimed_name = ""
|
||
claimed_phone = ""
|
||
claimed_points = 0
|
||
|
||
# 반려동물 정보 조회
|
||
pets_list = []
|
||
if claimed_user_id:
|
||
sqlite_cursor.execute("""
|
||
SELECT name, species, breed, photo_url
|
||
FROM pets
|
||
WHERE user_id = ? AND is_active = 1
|
||
""", (claimed_user_id,))
|
||
pets_rows = sqlite_cursor.fetchall()
|
||
for pet in pets_rows:
|
||
pets_list.append({
|
||
'name': pet['name'],
|
||
'species': pet['species'], # dog, cat
|
||
'breed': pet['breed'] or '',
|
||
'photo_url': pet['photo_url'] or ''
|
||
})
|
||
|
||
# 결제수단 판별
|
||
card_amt = float(card_total) if card_total else 0.0
|
||
cash_amt = float(cash_total) if cash_total else 0.0
|
||
has_cash_receipt = (
|
||
str(cash_receipt_mode or '').strip() == '1'
|
||
and str(cash_receipt_num or '').strip() != ''
|
||
)
|
||
|
||
if card_amt > 0 and cash_amt > 0:
|
||
pay_method = '카드+현금'
|
||
elif card_amt > 0:
|
||
pay_method = '카드'
|
||
elif cash_amt > 0:
|
||
pay_method = '현영' if has_cash_receipt else '현금'
|
||
else:
|
||
pay_method = ''
|
||
|
||
paid = (card_amt + cash_amt) > 0
|
||
disc_amt = float(discount) if discount else 0.0
|
||
total_amt = float(total_amount) if total_amount else 0.0
|
||
sale_amt = float(sale_amount) if sale_amount else 0.0
|
||
|
||
total_sales += sale_amt
|
||
|
||
sales_list.append({
|
||
'order_no': order_no,
|
||
'time': insert_time.strftime('%H:%M') if insert_time else '--:--',
|
||
'amount': sale_amt,
|
||
'discount': disc_amt,
|
||
'total_before_dc': total_amt,
|
||
'customer': customer if customer else '',
|
||
'customer_code': customer_code if customer_code else '0000000000',
|
||
'pay_method': pay_method,
|
||
'paid': paid,
|
||
'item_count': item_count,
|
||
'claimed_name': claimed_name,
|
||
'claimed_phone': claimed_phone,
|
||
'claimed_points': claimed_points,
|
||
'qr_issued': qr_issued,
|
||
'pets': pets_list # 반려동물 정보
|
||
})
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'date': date_str,
|
||
'count': len(sales_list),
|
||
'total_sales': total_sales,
|
||
'sales': sales_list
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"POS 실시간 조회 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
finally:
|
||
if mssql_conn:
|
||
mssql_conn.close()
|
||
|
||
|
||
@app.route('/api/admin/pos-live/detail/<order_no>')
|
||
def api_admin_pos_live_detail(order_no):
|
||
"""
|
||
판매 상세 조회 API (SALE_SUB 품목 목록)
|
||
"""
|
||
mssql_conn = None
|
||
try:
|
||
mssql_engine = db_manager.get_engine('PM_PRES')
|
||
mssql_conn = mssql_engine.raw_connection()
|
||
cursor = mssql_conn.cursor()
|
||
|
||
# 품목 상세 조회 (바코드 포함 - CD_GOODS 또는 CD_ITEM_UNIT_MEMBER에서)
|
||
cursor.execute("""
|
||
SELECT
|
||
S.DrugCode AS drug_code,
|
||
ISNULL(G.GoodsName, '(약품명 없음)') AS product_name,
|
||
S.SL_NM_item AS quantity,
|
||
S.SL_NM_cost_a AS unit_price,
|
||
S.SL_TOTAL_PRICE AS total_price,
|
||
COALESCE(NULLIF(G.Barcode, ''),
|
||
(SELECT TOP 1 CD_CD_BARCODE FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER WHERE DrugCode = S.DrugCode)
|
||
) AS barcode
|
||
FROM SALE_SUB S
|
||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||
WHERE S.SL_NO_order = ?
|
||
ORDER BY S.DrugCode
|
||
""", order_no)
|
||
|
||
rows = cursor.fetchall()
|
||
items = []
|
||
seen_drugs = set() # 중복 제거용
|
||
for row in rows:
|
||
drug_code = row[0]
|
||
if drug_code in seen_drugs:
|
||
continue
|
||
seen_drugs.add(drug_code)
|
||
items.append({
|
||
'drug_code': drug_code,
|
||
'product_name': row[1],
|
||
'quantity': int(row[2]) if row[2] else 0,
|
||
'unit_price': float(row[3]) if row[3] else 0,
|
||
'total_price': float(row[4]) if row[4] else 0,
|
||
'barcode': row[5] or ''
|
||
})
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'order_no': order_no,
|
||
'items': items
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"판매 상세 조회 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
finally:
|
||
if mssql_conn:
|
||
mssql_conn.close()
|
||
|
||
|
||
@app.route('/api/customers/<cus_code>/mileage')
|
||
def api_customer_mileage(cus_code):
|
||
"""
|
||
고객 마일리지 조회 API (비동기)
|
||
- CD_PERSON에서 이름+전화번호 조회
|
||
- SQLite users와 이름+전화뒤4자리로 매칭
|
||
"""
|
||
if not cus_code or cus_code == '0000000000':
|
||
return jsonify({'success': False, 'mileage': None})
|
||
|
||
mssql_conn = None
|
||
try:
|
||
# 1. CD_PERSON에서 이름, 전화번호 조회
|
||
mssql_engine = db_manager.get_engine('PM_BASE')
|
||
mssql_conn = mssql_engine.raw_connection()
|
||
cursor = mssql_conn.cursor()
|
||
|
||
cursor.execute("""
|
||
SELECT PANAME, PHONE, TEL_NO, PHONE2
|
||
FROM CD_PERSON
|
||
WHERE CUSCODE = ?
|
||
""", cus_code)
|
||
row = cursor.fetchone()
|
||
|
||
if not row:
|
||
return jsonify({'success': False, 'mileage': None})
|
||
|
||
name, phone1, phone2, phone3 = row
|
||
phone = phone1 or phone2 or phone3 or ''
|
||
phone_digits = ''.join(c for c in phone if c.isdigit())
|
||
last4 = phone_digits[-4:] if len(phone_digits) >= 4 else ''
|
||
|
||
if not name or not last4:
|
||
return jsonify({'success': False, 'mileage': None})
|
||
|
||
# 2. SQLite에서 이름+전화뒤4자리로 매칭
|
||
sqlite_conn = db_manager.get_sqlite_connection()
|
||
sqlite_cursor = sqlite_conn.cursor()
|
||
|
||
sqlite_cursor.execute("""
|
||
SELECT nickname, phone, mileage_balance
|
||
FROM users
|
||
""")
|
||
|
||
for user in sqlite_cursor.fetchall():
|
||
user_phone = ''.join(c for c in (user['phone'] or '') if c.isdigit())
|
||
user_last4 = user_phone[-4:] if len(user_phone) >= 4 else ''
|
||
|
||
if user['nickname'] == name and user_last4 == last4:
|
||
return jsonify({
|
||
'success': True,
|
||
'mileage': user['mileage_balance'] or 0,
|
||
'name': name
|
||
})
|
||
|
||
return jsonify({'success': False, 'mileage': None})
|
||
|
||
except Exception as e:
|
||
logging.error(f"마일리지 조회 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
finally:
|
||
if mssql_conn:
|
||
mssql_conn.close()
|
||
|
||
|
||
@app.route('/api/customers/search')
|
||
def api_customers_search():
|
||
"""
|
||
고객 검색 API (CD_PERSON + 최근 조제/구매 활동)
|
||
- name: 검색할 이름
|
||
- 결과: 최근 활동순 정렬, 생년월일 포함
|
||
"""
|
||
name = request.args.get('name', '').strip()
|
||
if not name or len(name) < 2:
|
||
return jsonify({'success': False, 'error': '이름을 2자 이상 입력하세요.'}), 400
|
||
|
||
mssql_conn = None
|
||
try:
|
||
mssql_engine = db_manager.get_engine('PM_BASE')
|
||
mssql_conn = mssql_engine.raw_connection()
|
||
cursor = mssql_conn.cursor()
|
||
|
||
# CD_PERSON에서 이름으로 검색 + 최근 조제/구매일 조인
|
||
cursor.execute("""
|
||
SELECT DISTINCT
|
||
p.CUSCODE,
|
||
p.PANAME,
|
||
LEFT(p.PANUM, 6) AS birth_date,
|
||
p.PHONE,
|
||
(SELECT MAX(Indate) FROM PM_PRES.dbo.PS_main WHERE CusCode = p.CUSCODE) AS last_rx,
|
||
(SELECT MAX(InsertTime) FROM PM_PRES.dbo.SALE_MAIN WHERE SL_CD_custom = p.CUSCODE) AS last_sale
|
||
FROM CD_PERSON p
|
||
WHERE p.PANAME LIKE ?
|
||
ORDER BY p.PANAME
|
||
""", f'%{name}%')
|
||
|
||
rows = cursor.fetchall()
|
||
results = []
|
||
|
||
for row in rows:
|
||
cus_code, pa_name, birth, phone, last_rx, last_sale = row
|
||
|
||
# 최근 활동일 계산
|
||
last_activity = None
|
||
activity_type = None
|
||
|
||
if last_rx and last_sale:
|
||
# 둘 다 있으면 더 최근 것
|
||
rx_date = datetime.strptime(last_rx, '%Y%m%d') if isinstance(last_rx, str) else last_rx
|
||
if isinstance(last_sale, datetime):
|
||
if rx_date > last_sale:
|
||
last_activity = rx_date
|
||
activity_type = '조제'
|
||
else:
|
||
last_activity = last_sale
|
||
activity_type = '구매'
|
||
else:
|
||
last_activity = rx_date
|
||
activity_type = '조제'
|
||
elif last_rx:
|
||
last_activity = datetime.strptime(last_rx, '%Y%m%d') if isinstance(last_rx, str) else last_rx
|
||
activity_type = '조제'
|
||
elif last_sale:
|
||
last_activity = last_sale
|
||
activity_type = '구매'
|
||
|
||
# 며칠 전 계산
|
||
days_ago = None
|
||
if last_activity:
|
||
if isinstance(last_activity, datetime):
|
||
days_ago = (datetime.now() - last_activity).days
|
||
else:
|
||
days_ago = (datetime.now() - datetime.strptime(str(last_activity)[:8], '%Y%m%d')).days
|
||
|
||
results.append({
|
||
'cus_code': cus_code,
|
||
'name': pa_name,
|
||
'birth': birth if birth else '',
|
||
'phone': phone if phone else '',
|
||
'activity_type': activity_type,
|
||
'days_ago': days_ago,
|
||
'last_activity': last_activity.strftime('%Y-%m-%d') if last_activity else None
|
||
})
|
||
|
||
# 최근 활동순 정렬 (활동 있는 것 먼저, 그 중 최근 것 먼저)
|
||
results.sort(key=lambda x: (x['days_ago'] is None, x['days_ago'] if x['days_ago'] is not None else 9999))
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'results': results[:50] # 최대 50개
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"고객 검색 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
finally:
|
||
if mssql_conn:
|
||
mssql_conn.close()
|
||
|
||
|
||
@app.route('/api/pos-live/<order_no>/customer', methods=['PUT'])
|
||
def api_pos_live_update_customer(order_no):
|
||
"""
|
||
판매 건의 고객 정보 업데이트 API
|
||
- cus_code: 고객 코드 (CD_PERSON.CUSCODE)
|
||
- cus_name: 고객 이름
|
||
"""
|
||
data = request.get_json()
|
||
if not data:
|
||
return jsonify({'success': False, 'error': 'JSON 데이터 필요'}), 400
|
||
|
||
cus_code = data.get('cus_code', '').strip()
|
||
cus_name = data.get('cus_name', '').strip()
|
||
|
||
if not cus_code or not cus_name:
|
||
return jsonify({'success': False, 'error': '고객 코드와 이름 필요'}), 400
|
||
|
||
mssql_conn = None
|
||
try:
|
||
mssql_engine = db_manager.get_engine('PM_PRES')
|
||
mssql_conn = mssql_engine.raw_connection()
|
||
cursor = mssql_conn.cursor()
|
||
|
||
# SALE_MAIN 업데이트
|
||
cursor.execute("""
|
||
UPDATE SALE_MAIN
|
||
SET SL_CD_custom = ?, SL_NM_custom = ?
|
||
WHERE SL_NO_order = ?
|
||
""", cus_code, cus_name, order_no)
|
||
|
||
mssql_conn.commit()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': f'고객 정보가 {cus_name}({cus_code})으로 업데이트되었습니다.'
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"고객 정보 업데이트 오류: {e}")
|
||
if mssql_conn:
|
||
mssql_conn.rollback()
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
finally:
|
||
if mssql_conn:
|
||
mssql_conn.close()
|
||
|
||
|
||
@app.route('/api/admin/user-mileage/<phone>')
|
||
def api_admin_user_mileage(phone):
|
||
"""
|
||
회원 마일리지 내역 API
|
||
"""
|
||
try:
|
||
sqlite_conn = db_manager.get_sqlite_connection()
|
||
cursor = sqlite_conn.cursor()
|
||
|
||
# 전화번호로 사용자 조회
|
||
cursor.execute("""
|
||
SELECT id, nickname, phone, mileage_balance, created_at
|
||
FROM users WHERE phone = ?
|
||
""", (phone,))
|
||
user = cursor.fetchone()
|
||
|
||
if not user:
|
||
return jsonify({'success': False, 'error': '등록되지 않은 회원입니다.'}), 404
|
||
|
||
# 적립 내역 조회
|
||
cursor.execute("""
|
||
SELECT points, balance_after, reason, description, created_at
|
||
FROM mileage_ledger
|
||
WHERE user_id = ?
|
||
ORDER BY created_at DESC
|
||
LIMIT 50
|
||
""", (user['id'],))
|
||
transactions = cursor.fetchall()
|
||
|
||
history = []
|
||
for tx in transactions:
|
||
history.append({
|
||
'points': tx['points'],
|
||
'balance_after': tx['balance_after'],
|
||
'reason': tx['reason'],
|
||
'description': tx['description'],
|
||
'created_at': tx['created_at']
|
||
})
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'user': {
|
||
'id': user['id'],
|
||
'nickname': user['nickname'],
|
||
'phone': user['phone'],
|
||
'mileage_balance': user['mileage_balance'],
|
||
'created_at': user['created_at']
|
||
},
|
||
'history': history
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"회원 마일리지 조회 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# QR 라벨 생성 및 프린터 출력 API (Brother QL-810W)
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
@app.route('/api/admin/qr/generate', methods=['POST'])
|
||
def api_admin_qr_generate():
|
||
"""
|
||
QR 토큰 생성 API
|
||
- claim_tokens 테이블에 저장
|
||
- 미리보기 이미지 반환 (선택)
|
||
"""
|
||
try:
|
||
data = request.get_json()
|
||
order_no = data.get('order_no')
|
||
amount = data.get('amount', 0)
|
||
preview = data.get('preview', True) # 기본: 미리보기
|
||
|
||
if not order_no:
|
||
return jsonify({'success': False, 'error': '주문번호가 필요합니다'}), 400
|
||
|
||
# 기존 모듈 import
|
||
from utils.qr_token_generator import generate_claim_token, save_token_to_db
|
||
from utils.qr_label_printer import print_qr_label
|
||
|
||
# 거래 시간 조회 (MSSQL)
|
||
mssql_engine = db_manager.get_engine('PM_PRES')
|
||
mssql_conn = mssql_engine.raw_connection()
|
||
cursor = mssql_conn.cursor()
|
||
|
||
cursor.execute("""
|
||
SELECT InsertTime, SL_MY_sale FROM SALE_MAIN WHERE SL_NO_order = ?
|
||
""", order_no)
|
||
row = cursor.fetchone()
|
||
mssql_conn.close()
|
||
|
||
if not row:
|
||
return jsonify({'success': False, 'error': f'거래를 찾을 수 없습니다: {order_no}'}), 404
|
||
|
||
transaction_time = row[0] or datetime.now()
|
||
if amount <= 0:
|
||
amount = float(row[1]) if row[1] else 0
|
||
|
||
# 1. 토큰 생성
|
||
token_info = generate_claim_token(order_no, amount)
|
||
|
||
# 2. DB 저장
|
||
success, error = save_token_to_db(
|
||
order_no,
|
||
token_info['token_hash'],
|
||
amount,
|
||
token_info['claimable_points'],
|
||
token_info['expires_at'],
|
||
token_info['pharmacy_id']
|
||
)
|
||
|
||
if not success:
|
||
return jsonify({'success': False, 'error': error}), 400
|
||
|
||
# 3. 미리보기 이미지 생성
|
||
image_url = None
|
||
if preview:
|
||
success, image_path = print_qr_label(
|
||
token_info['qr_url'],
|
||
order_no,
|
||
amount,
|
||
token_info['claimable_points'],
|
||
transaction_time,
|
||
preview_mode=True
|
||
)
|
||
if success and image_path:
|
||
# 상대 경로로 변환
|
||
filename = os.path.basename(image_path)
|
||
image_url = f'/static/temp/{filename}'
|
||
# temp 폴더를 static에서 접근 가능하게 복사
|
||
static_temp = os.path.join(os.path.dirname(__file__), 'static', 'temp')
|
||
os.makedirs(static_temp, exist_ok=True)
|
||
import shutil
|
||
shutil.copy(image_path, os.path.join(static_temp, filename))
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'order_no': order_no,
|
||
'amount': amount,
|
||
'claimable_points': token_info['claimable_points'],
|
||
'qr_url': token_info['qr_url'],
|
||
'expires_at': token_info['expires_at'].strftime('%Y-%m-%d %H:%M'),
|
||
'image_url': image_url
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"QR 생성 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/admin/qr/print', methods=['POST'])
|
||
def api_admin_qr_print():
|
||
"""
|
||
QR 라벨 프린터 출력 API (Brother QL-810W)
|
||
"""
|
||
try:
|
||
data = request.get_json()
|
||
order_no = data.get('order_no')
|
||
printer_type = data.get('printer', 'brother') # 'brother' or 'pos'
|
||
|
||
if not order_no:
|
||
return jsonify({'success': False, 'error': '주문번호가 필요합니다'}), 400
|
||
|
||
# claim_tokens에서 정보 조회
|
||
sqlite_conn = db_manager.get_sqlite_connection()
|
||
cursor = sqlite_conn.cursor()
|
||
|
||
cursor.execute("""
|
||
SELECT token_hash, total_amount, claimable_points, created_at
|
||
FROM claim_tokens WHERE transaction_id = ?
|
||
""", (order_no,))
|
||
token_row = cursor.fetchone()
|
||
|
||
if not token_row:
|
||
return jsonify({'success': False, 'error': 'QR이 생성되지 않은 거래입니다. 먼저 생성해주세요.'}), 404
|
||
|
||
# 거래 시간 조회 (MSSQL)
|
||
mssql_engine = db_manager.get_engine('PM_PRES')
|
||
mssql_conn = mssql_engine.raw_connection()
|
||
mssql_cursor = mssql_conn.cursor()
|
||
|
||
mssql_cursor.execute("""
|
||
SELECT InsertTime FROM SALE_MAIN WHERE SL_NO_order = ?
|
||
""", order_no)
|
||
row = mssql_cursor.fetchone()
|
||
mssql_conn.close()
|
||
|
||
transaction_time = row[0] if row else datetime.now()
|
||
|
||
# QR URL 재생성 (토큰 해시에서)
|
||
from utils.qr_token_generator import QR_BASE_URL
|
||
# claim_tokens에서 nonce를 저장하지 않으므로, 새로 생성
|
||
# 하지만 이미 저장된 경우 재출력만 하면 됨
|
||
# 실제로는 token_hash로 검증하므로 QR URL은 동일하게 유지해야 함
|
||
# 여기서는 간단히 재생성 (실제로는 nonce도 저장하는 게 좋음)
|
||
from utils.qr_token_generator import generate_claim_token
|
||
|
||
amount = token_row['total_amount']
|
||
claimable_points = token_row['claimable_points']
|
||
|
||
# 새 토큰 생성 (URL용) - 기존 토큰과 다르지만 적립 시 해시로 검증
|
||
# 주의: 실제로는 기존 토큰을 저장하고 재사용해야 함
|
||
# 여기서는 임시로 새 URL 생성 (인쇄만 다시 하는 케이스)
|
||
token_info = generate_claim_token(order_no, amount)
|
||
|
||
if printer_type == 'brother':
|
||
from utils.qr_label_printer import print_qr_label
|
||
|
||
success = print_qr_label(
|
||
token_info['qr_url'],
|
||
order_no,
|
||
amount,
|
||
claimable_points,
|
||
transaction_time,
|
||
preview_mode=False
|
||
)
|
||
|
||
if success:
|
||
return jsonify({
|
||
'success': True,
|
||
'message': f'Brother QL-810W 라벨 출력 완료 ({claimable_points}P)'
|
||
})
|
||
else:
|
||
return jsonify({'success': False, 'error': 'Brother 프린터 전송 실패'}), 500
|
||
|
||
elif printer_type == 'pos':
|
||
from utils.pos_qr_printer import print_qr_receipt_escpos
|
||
|
||
# POS 프린터 설정 (config.json에서)
|
||
config_path = os.path.join(os.path.dirname(__file__), 'config.json')
|
||
pos_config = {}
|
||
if os.path.exists(config_path):
|
||
import json
|
||
with open(config_path, 'r', encoding='utf-8') as f:
|
||
config = json.load(f)
|
||
pos_config = config.get('pos_printer', {})
|
||
|
||
if not pos_config.get('ip'):
|
||
return jsonify({'success': False, 'error': 'POS 프린터 설정이 필요합니다'}), 400
|
||
|
||
success = print_qr_receipt_escpos(
|
||
token_info['qr_url'],
|
||
order_no,
|
||
amount,
|
||
claimable_points,
|
||
transaction_time,
|
||
pos_config['ip'],
|
||
pos_config.get('port', 9100)
|
||
)
|
||
|
||
if success:
|
||
return jsonify({
|
||
'success': True,
|
||
'message': f'POS 영수증 출력 완료 ({claimable_points}P)'
|
||
})
|
||
else:
|
||
return jsonify({'success': False, 'error': 'POS 프린터 전송 실패'}), 500
|
||
|
||
else:
|
||
return jsonify({'success': False, 'error': f'지원하지 않는 프린터: {printer_type}'}), 400
|
||
|
||
except Exception as e:
|
||
logging.error(f"QR 출력 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
# ============================================================================
|
||
# OTC 용법 라벨 시스템 API
|
||
# ============================================================================
|
||
|
||
@app.route('/admin/otc-labels')
|
||
def admin_otc_labels():
|
||
"""OTC 용법 라벨 관리 페이지"""
|
||
return render_template('admin_otc_labels.html')
|
||
|
||
|
||
@app.route('/api/admin/otc-labels', methods=['GET'])
|
||
def api_get_otc_labels():
|
||
"""OTC 라벨 프리셋 목록 조회"""
|
||
try:
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("""
|
||
SELECT id, barcode, drug_code, display_name, effect,
|
||
dosage_instruction, usage_tip, use_wide_format,
|
||
print_count, last_printed_at, created_at, updated_at
|
||
FROM otc_label_presets
|
||
ORDER BY updated_at DESC
|
||
""")
|
||
|
||
rows = cursor.fetchall()
|
||
labels = [dict(row) for row in rows]
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'count': len(labels),
|
||
'labels': labels
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"OTC 라벨 목록 조회 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/admin/otc-labels/<barcode>', methods=['GET'])
|
||
def api_get_otc_label(barcode):
|
||
"""OTC 라벨 프리셋 단건 조회 (바코드 기준)"""
|
||
try:
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("""
|
||
SELECT id, barcode, drug_code, display_name, effect,
|
||
dosage_instruction, usage_tip, use_wide_format,
|
||
print_count, last_printed_at, created_at, updated_at
|
||
FROM otc_label_presets
|
||
WHERE barcode = ?
|
||
""", (barcode,))
|
||
|
||
row = cursor.fetchone()
|
||
|
||
if not row:
|
||
return jsonify({'success': False, 'error': '등록된 프리셋이 없습니다.', 'exists': False}), 404
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'exists': True,
|
||
'label': dict(row)
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"OTC 라벨 조회 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/admin/otc-labels', methods=['POST'])
|
||
def api_upsert_otc_label():
|
||
"""OTC 라벨 프리셋 등록/수정 (Upsert)"""
|
||
try:
|
||
data = request.get_json()
|
||
|
||
if not data or not data.get('barcode'):
|
||
return jsonify({'success': False, 'error': 'barcode는 필수입니다.'}), 400
|
||
|
||
barcode = data['barcode']
|
||
drug_code = data.get('drug_code', '')
|
||
display_name = data.get('display_name', '')
|
||
effect = data.get('effect', '')
|
||
dosage_instruction = data.get('dosage_instruction', '')
|
||
usage_tip = data.get('usage_tip', '')
|
||
use_wide_format = data.get('use_wide_format', True)
|
||
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
# Upsert (INSERT OR REPLACE)
|
||
cursor.execute("""
|
||
INSERT INTO otc_label_presets
|
||
(barcode, drug_code, display_name, effect, dosage_instruction, usage_tip, use_wide_format, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||
ON CONFLICT(barcode) DO UPDATE SET
|
||
drug_code = excluded.drug_code,
|
||
display_name = excluded.display_name,
|
||
effect = excluded.effect,
|
||
dosage_instruction = excluded.dosage_instruction,
|
||
usage_tip = excluded.usage_tip,
|
||
use_wide_format = excluded.use_wide_format,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
""", (barcode, drug_code, display_name, effect, dosage_instruction, usage_tip, use_wide_format))
|
||
|
||
conn.commit()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': f'라벨 프리셋 저장 완료 ({barcode})'
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"OTC 라벨 저장 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/admin/otc-labels/<barcode>', methods=['DELETE'])
|
||
def api_delete_otc_label(barcode):
|
||
"""OTC 라벨 프리셋 삭제"""
|
||
try:
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("DELETE FROM otc_label_presets WHERE barcode = ?", (barcode,))
|
||
conn.commit()
|
||
|
||
if cursor.rowcount > 0:
|
||
return jsonify({'success': True, 'message': f'삭제 완료 ({barcode})'})
|
||
else:
|
||
return jsonify({'success': False, 'error': '존재하지 않는 프리셋'}), 404
|
||
|
||
except Exception as e:
|
||
logging.error(f"OTC 라벨 삭제 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/admin/otc-labels/preview', methods=['POST'])
|
||
def api_preview_otc_label():
|
||
"""OTC 라벨 미리보기 이미지 생성"""
|
||
try:
|
||
data = request.get_json()
|
||
|
||
if not data:
|
||
return jsonify({'success': False, 'error': '요청 데이터가 없습니다.'}), 400
|
||
|
||
drug_name = data.get('drug_name', '약품명')
|
||
effect = data.get('effect', '')
|
||
dosage_instruction = data.get('dosage_instruction', '')
|
||
usage_tip = data.get('usage_tip', '')
|
||
|
||
if not OTC_LABEL_AVAILABLE:
|
||
return jsonify({'success': False, 'error': 'OTC 라벨 모듈이 로드되지 않았습니다.'}), 500
|
||
|
||
preview_url = generate_preview_image(drug_name, effect, dosage_instruction, usage_tip)
|
||
|
||
if preview_url:
|
||
return jsonify({
|
||
'success': True,
|
||
'preview_url': preview_url
|
||
})
|
||
else:
|
||
return jsonify({'success': False, 'error': '미리보기 생성 실패'}), 500
|
||
|
||
except Exception as e:
|
||
logging.error(f"OTC 라벨 미리보기 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/admin/otc-labels/print', methods=['POST'])
|
||
def api_print_otc_label():
|
||
"""OTC 라벨 인쇄 (Brother QL-810W)"""
|
||
try:
|
||
data = request.get_json()
|
||
|
||
if not data:
|
||
return jsonify({'success': False, 'error': '요청 데이터가 없습니다.'}), 400
|
||
|
||
barcode = data.get('barcode')
|
||
drug_name = data.get('drug_name', '약품명')
|
||
effect = data.get('effect', '')
|
||
dosage_instruction = data.get('dosage_instruction', '')
|
||
usage_tip = data.get('usage_tip', '')
|
||
|
||
if not OTC_LABEL_AVAILABLE:
|
||
return jsonify({'success': False, 'error': 'OTC 라벨 모듈이 로드되지 않았습니다.'}), 500
|
||
|
||
success = print_otc_label(drug_name, effect, dosage_instruction, usage_tip)
|
||
|
||
if success:
|
||
# 인쇄 횟수 업데이트
|
||
if barcode:
|
||
conn = db_manager.get_sqlite_connection()
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
UPDATE otc_label_presets
|
||
SET print_count = print_count + 1, last_printed_at = CURRENT_TIMESTAMP
|
||
WHERE barcode = ?
|
||
""", (barcode,))
|
||
conn.commit()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': f'라벨 인쇄 완료: {drug_name}'
|
||
})
|
||
else:
|
||
return jsonify({'success': False, 'error': '프린터 전송 실패'}), 500
|
||
|
||
except Exception as e:
|
||
logging.error(f"OTC 라벨 인쇄 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/admin/otc-labels/search-mssql', methods=['GET'])
|
||
def api_search_mssql_drug():
|
||
"""MSSQL에서 약품 검색 (바코드 또는 이름)"""
|
||
try:
|
||
query = request.args.get('q', '').strip()
|
||
|
||
if not query:
|
||
return jsonify({'success': False, 'error': '검색어를 입력해주세요.'}), 400
|
||
|
||
mssql_session = db_manager.get_session('PM_DRUG')
|
||
|
||
# 바코드 또는 이름으로 검색 (CD_ITEM_UNIT_MEMBER 바코드 포함)
|
||
sql = text("""
|
||
SELECT TOP 20
|
||
G.DrugCode,
|
||
COALESCE(NULLIF(G.Barcode, ''),
|
||
(SELECT TOP 1 CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER WHERE DrugCode = G.DrugCode)
|
||
) AS Barcode,
|
||
G.GoodsName,
|
||
G.Saleprice
|
||
FROM CD_GOODS G
|
||
WHERE G.GoodsName LIKE :query
|
||
OR G.Barcode LIKE :query
|
||
OR G.DrugCode IN (SELECT DrugCode FROM CD_ITEM_UNIT_MEMBER WHERE CD_CD_BARCODE LIKE :query)
|
||
ORDER BY
|
||
CASE WHEN G.Barcode = :exact THEN 0 ELSE 1 END,
|
||
G.GoodsName
|
||
""")
|
||
|
||
rows = mssql_session.execute(sql, {
|
||
'query': f'%{query}%',
|
||
'exact': query
|
||
}).fetchall()
|
||
|
||
drugs = []
|
||
for row in rows:
|
||
drugs.append({
|
||
'drug_code': row.DrugCode,
|
||
'barcode': row.Barcode,
|
||
'goods_name': row.GoodsName,
|
||
'sale_price': float(row.Saleprice or 0)
|
||
})
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'count': len(drugs),
|
||
'drugs': drugs
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"MSSQL 약품 검색 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
# ============================================================
|
||
# 제품 이미지 관리 (yakkok 크롤러)
|
||
# ============================================================
|
||
|
||
@app.route('/admin/product-images')
|
||
def admin_product_images():
|
||
"""제품 이미지 관리 어드민 페이지"""
|
||
return render_template('admin_product_images.html')
|
||
|
||
|
||
@app.route('/api/admin/product-images')
|
||
def api_product_images_list():
|
||
"""제품 이미지 목록 조회"""
|
||
import sqlite3
|
||
try:
|
||
db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db')
|
||
conn = sqlite3.connect(db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
cursor = conn.cursor()
|
||
|
||
status_filter = request.args.get('status', '')
|
||
search = request.args.get('search', '')
|
||
limit = int(request.args.get('limit', 50))
|
||
offset = int(request.args.get('offset', 0))
|
||
|
||
where_clauses = []
|
||
params = []
|
||
|
||
if status_filter:
|
||
# "failed" 필터는 failed + no_result 둘 다 포함 (통계와 일치시키기 위해)
|
||
if status_filter == 'failed':
|
||
where_clauses.append("status IN ('failed', 'no_result')")
|
||
else:
|
||
where_clauses.append("status = ?")
|
||
params.append(status_filter)
|
||
|
||
if search:
|
||
where_clauses.append("(product_name LIKE ? OR barcode LIKE ?)")
|
||
params.extend([f'%{search}%', f'%{search}%'])
|
||
|
||
where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else ""
|
||
|
||
# 총 개수
|
||
cursor.execute(f"SELECT COUNT(*) FROM product_images {where_sql}", params)
|
||
total = cursor.fetchone()[0]
|
||
|
||
# 목록 조회
|
||
cursor.execute(f"""
|
||
SELECT id, barcode, drug_code, product_name, thumbnail_base64,
|
||
image_url, status, created_at, error_message
|
||
FROM product_images
|
||
{where_sql}
|
||
ORDER BY created_at DESC
|
||
LIMIT ? OFFSET ?
|
||
""", params + [limit, offset])
|
||
|
||
items = [dict(row) for row in cursor.fetchall()]
|
||
conn.close()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'total': total,
|
||
'items': items
|
||
})
|
||
except Exception as e:
|
||
logging.error(f"제품 이미지 목록 조회 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/admin/product-images/<barcode>')
|
||
def api_product_image_detail(barcode):
|
||
"""제품 이미지 상세 조회 (원본 base64 포함)"""
|
||
import sqlite3
|
||
try:
|
||
db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db')
|
||
conn = sqlite3.connect(db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("SELECT * FROM product_images WHERE barcode = ?", (barcode,))
|
||
row = cursor.fetchone()
|
||
conn.close()
|
||
|
||
if row:
|
||
return jsonify({'success': True, 'image': dict(row)})
|
||
else:
|
||
return jsonify({'success': False, 'error': '이미지 없음'}), 404
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/admin/product-images/crawl-today', methods=['POST'])
|
||
def api_crawl_today():
|
||
"""특정 날짜 판매 제품 크롤링 (date 파라미터 없으면 오늘)"""
|
||
try:
|
||
from utils.yakkok_crawler import crawl_sales_by_date
|
||
data = request.get_json() or {}
|
||
date_str = data.get('date') # YYYY-MM-DD 형식
|
||
result = crawl_sales_by_date(date_str, headless=True)
|
||
return jsonify({'success': True, 'result': result, 'date': date_str or 'today'})
|
||
except Exception as e:
|
||
logging.error(f"크롤링 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/admin/product-images/crawl', methods=['POST'])
|
||
def api_crawl_products():
|
||
"""특정 제품 크롤링"""
|
||
try:
|
||
from utils.yakkok_crawler import crawl_products
|
||
data = request.get_json()
|
||
products = data.get('products', []) # [(barcode, drug_code, product_name), ...]
|
||
|
||
if not products:
|
||
return jsonify({'success': False, 'error': '제품 목록 필요'}), 400
|
||
|
||
result = crawl_products(products, headless=True)
|
||
return jsonify({'success': True, 'result': result})
|
||
except Exception as e:
|
||
logging.error(f"크롤링 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/admin/product-images/<barcode>', methods=['DELETE'])
|
||
def api_delete_product_image(barcode):
|
||
"""제품 이미지 삭제"""
|
||
import sqlite3
|
||
try:
|
||
db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db')
|
||
conn = sqlite3.connect(db_path)
|
||
cursor = conn.cursor()
|
||
cursor.execute("DELETE FROM product_images WHERE barcode = ?", (barcode,))
|
||
conn.commit()
|
||
conn.close()
|
||
return jsonify({'success': True})
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/admin/product-images/<barcode>/replace', methods=['POST'])
|
||
def api_replace_product_image(barcode):
|
||
"""이미지 URL로 교체"""
|
||
import sqlite3
|
||
import requests
|
||
import base64
|
||
from PIL import Image
|
||
from io import BytesIO
|
||
|
||
try:
|
||
data = request.get_json()
|
||
image_url = data.get('image_url', '').strip()
|
||
|
||
if not image_url:
|
||
return jsonify({'success': False, 'error': 'URL 필요'}), 400
|
||
|
||
# 다양한 User-Agent와 헤더로 시도
|
||
headers_list = [
|
||
{
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
|
||
'Referer': 'https://www.google.com/',
|
||
},
|
||
{
|
||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||
'Accept': 'image/*,*/*;q=0.8',
|
||
},
|
||
{
|
||
'User-Agent': 'Googlebot-Image/1.0',
|
||
}
|
||
]
|
||
|
||
response = None
|
||
for headers in headers_list:
|
||
try:
|
||
response = requests.get(image_url, headers=headers, timeout=15, allow_redirects=True)
|
||
if response.status_code == 200 and len(response.content) > 1000:
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not response or response.status_code != 200:
|
||
return jsonify({'success': False, 'error': f'이미지 다운로드 실패 (상태: {response.status_code if response else "연결실패"})'}), 400
|
||
|
||
# PIL로 이미지 처리
|
||
try:
|
||
img = Image.open(BytesIO(response.content))
|
||
|
||
# RGBA -> RGB 변환
|
||
if img.mode == 'RGBA':
|
||
bg = Image.new('RGB', img.size, (255, 255, 255))
|
||
bg.paste(img, mask=img.split()[3])
|
||
img = bg
|
||
elif img.mode != 'RGB':
|
||
img = img.convert('RGB')
|
||
|
||
# 리사이즈 (최대 500px)
|
||
max_size = 500
|
||
if max(img.size) > max_size:
|
||
ratio = max_size / max(img.size)
|
||
new_size = tuple(int(dim * ratio) for dim in img.size)
|
||
img = img.resize(new_size, Image.LANCZOS)
|
||
|
||
# base64 변환
|
||
buffer = BytesIO()
|
||
img.save(buffer, format='JPEG', quality=85)
|
||
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||
|
||
# 썸네일 생성
|
||
thumb_size = 100
|
||
ratio = thumb_size / max(img.size)
|
||
thumb_img = img.resize(tuple(int(dim * ratio) for dim in img.size), Image.LANCZOS)
|
||
thumb_buffer = BytesIO()
|
||
thumb_img.save(thumb_buffer, format='JPEG', quality=80)
|
||
thumbnail_base64 = base64.b64encode(thumb_buffer.getvalue()).decode('utf-8')
|
||
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'error': f'이미지 처리 실패: {str(e)}'}), 400
|
||
|
||
# SQLite 업데이트 (기존 값 유지)
|
||
db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db')
|
||
conn = sqlite3.connect(db_path)
|
||
cursor = conn.cursor()
|
||
|
||
# 기존 레코드 확인
|
||
cursor.execute("SELECT product_name, drug_code FROM product_images WHERE barcode = ?", (barcode,))
|
||
existing = cursor.fetchone()
|
||
|
||
if existing:
|
||
# 기존 레코드 있으면 이미지만 업데이트 (product_name, drug_code 유지)
|
||
cursor.execute("""
|
||
UPDATE product_images
|
||
SET image_base64 = ?, thumbnail_base64 = ?, image_url = ?,
|
||
status = 'manual', error_message = NULL, updated_at = datetime('now')
|
||
WHERE barcode = ?
|
||
""", (image_base64, thumbnail_base64, image_url, barcode))
|
||
else:
|
||
# 레코드가 없으면 새로 생성 (product_name은 barcode로 임시 저장)
|
||
cursor.execute("""
|
||
INSERT INTO product_images (barcode, image_base64, thumbnail_base64, image_url, status, product_name)
|
||
VALUES (?, ?, ?, ?, 'manual', ?)
|
||
""", (barcode, image_base64, thumbnail_base64, image_url, barcode))
|
||
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return jsonify({'success': True, 'message': '이미지 교체 완료'})
|
||
|
||
except Exception as e:
|
||
logging.error(f"이미지 교체 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/admin/product-images/<barcode>/upload', methods=['POST'])
|
||
def api_upload_product_image(barcode):
|
||
"""카메라 촬영 이미지 업로드 (base64 -> 1:1 크롭 -> 800x800 리사이즈)"""
|
||
import sqlite3
|
||
import base64
|
||
from PIL import Image
|
||
from io import BytesIO
|
||
|
||
try:
|
||
data = request.get_json() or {}
|
||
image_data = (data.get('image_data') or '').strip()
|
||
product_name = data.get('product_name') or barcode
|
||
|
||
if not image_data:
|
||
return jsonify({'success': False, 'error': '이미지 데이터 필요'}), 400
|
||
|
||
# data:image/...;base64, 접두사 제거
|
||
if ',' in image_data:
|
||
image_data = image_data.split(',', 1)[1]
|
||
|
||
try:
|
||
# base64 디코딩
|
||
image_bytes = base64.b64decode(image_data)
|
||
img = Image.open(BytesIO(image_bytes))
|
||
|
||
# RGBA -> RGB 변환 (PNG 등 투명 배경 처리)
|
||
if img.mode == 'RGBA':
|
||
bg = Image.new('RGB', img.size, (255, 255, 255))
|
||
bg.paste(img, mask=img.split()[3])
|
||
img = bg
|
||
elif img.mode != 'RGB':
|
||
img = img.convert('RGB')
|
||
|
||
# 1:1 중앙 크롭 (정사각형)
|
||
width, height = img.size
|
||
min_dim = min(width, height)
|
||
left = (width - min_dim) // 2
|
||
top = (height - min_dim) // 2
|
||
right = left + min_dim
|
||
bottom = top + min_dim
|
||
img = img.crop((left, top, right, bottom))
|
||
|
||
# 800x800 리사이즈
|
||
target_size = 800
|
||
img = img.resize((target_size, target_size), Image.LANCZOS)
|
||
|
||
# base64 변환 (원본)
|
||
buffer = BytesIO()
|
||
img.save(buffer, format='JPEG', quality=90)
|
||
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||
|
||
# 썸네일 생성 (200x200)
|
||
thumb_size = 200
|
||
thumb_img = img.resize((thumb_size, thumb_size), Image.LANCZOS)
|
||
thumb_buffer = BytesIO()
|
||
thumb_img.save(thumb_buffer, format='JPEG', quality=85)
|
||
thumbnail_base64 = base64.b64encode(thumb_buffer.getvalue()).decode('utf-8')
|
||
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'error': f'이미지 처리 실패: {str(e)}'}), 400
|
||
|
||
# SQLite 저장
|
||
db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db')
|
||
conn = sqlite3.connect(db_path)
|
||
cursor = conn.cursor()
|
||
|
||
# 기존 레코드 확인
|
||
cursor.execute("SELECT product_name, drug_code FROM product_images WHERE barcode = ?", (barcode,))
|
||
existing = cursor.fetchone()
|
||
|
||
if existing:
|
||
# 기존 레코드 있으면 이미지만 업데이트
|
||
cursor.execute("""
|
||
UPDATE product_images
|
||
SET image_base64 = ?, thumbnail_base64 = ?, image_url = NULL,
|
||
status = 'manual', error_message = NULL, updated_at = datetime('now')
|
||
WHERE barcode = ?
|
||
""", (image_base64, thumbnail_base64, barcode))
|
||
else:
|
||
# 새 레코드 생성
|
||
cursor.execute("""
|
||
INSERT INTO product_images (barcode, product_name, image_base64, thumbnail_base64, status)
|
||
VALUES (?, ?, ?, ?, 'manual')
|
||
""", (barcode, product_name, image_base64, thumbnail_base64))
|
||
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return jsonify({'success': True, 'message': '촬영 이미지 저장 완료'})
|
||
|
||
except Exception as e:
|
||
logging.error(f"이미지 업로드 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/admin/product-images/stats')
|
||
def api_product_images_stats():
|
||
"""이미지 통계"""
|
||
import sqlite3
|
||
try:
|
||
db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db')
|
||
conn = sqlite3.connect(db_path)
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("""
|
||
SELECT status, COUNT(*) as count
|
||
FROM product_images
|
||
GROUP BY status
|
||
""")
|
||
stats = {row[0]: row[1] for row in cursor.fetchall()}
|
||
|
||
cursor.execute("SELECT COUNT(*) FROM product_images")
|
||
total = cursor.fetchone()[0]
|
||
|
||
conn.close()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'total': total,
|
||
'stats': stats
|
||
})
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# 동물약 정보 인쇄 API (ESC/POS 80mm)
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
@app.route('/api/animal-drug-info/print', methods=['POST'])
|
||
def api_animal_drug_info_print():
|
||
"""동물약 정보 인쇄 (APC로 PostgreSQL 조회 후 ESC/POS 출력)"""
|
||
try:
|
||
import re
|
||
from html import unescape
|
||
|
||
data = request.get_json()
|
||
apc = data.get('apc', '')
|
||
product_name = data.get('product_name', '')
|
||
|
||
if not apc:
|
||
return jsonify({'success': False, 'error': 'APC 코드가 필요합니다'}), 400
|
||
|
||
# PostgreSQL에서 약품 정보 조회
|
||
try:
|
||
from sqlalchemy import create_engine
|
||
pg_engine = create_engine(
|
||
'postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master',
|
||
connect_args={'connect_timeout': 5}
|
||
)
|
||
|
||
with pg_engine.connect() as conn:
|
||
result = conn.execute(text("""
|
||
SELECT
|
||
a.product_name,
|
||
a.company_name,
|
||
a.main_ingredient,
|
||
a.efficacy_effect,
|
||
a.dosage_instructions,
|
||
a.precautions,
|
||
a.weight_min_kg,
|
||
a.weight_max_kg,
|
||
a.pet_size_label,
|
||
a.component_code,
|
||
g.component_name_ko,
|
||
g.dosing_interval_adult,
|
||
g.dosing_interval_high_risk,
|
||
g.dosing_interval_puppy,
|
||
g.companion_drugs
|
||
FROM apc a
|
||
LEFT JOIN component_guide g ON a.component_code = g.component_code
|
||
WHERE a.apc = :apc
|
||
LIMIT 1
|
||
"""), {'apc': apc})
|
||
row = result.fetchone()
|
||
|
||
# 포장단위 APC → 대표 APC 폴백
|
||
if not row and len(apc) == 13 and apc.startswith('023'):
|
||
item_prefix = apc[:8]
|
||
result = conn.execute(text("""
|
||
SELECT
|
||
a.product_name, a.company_name, a.main_ingredient,
|
||
a.efficacy_effect, a.dosage_instructions, a.precautions,
|
||
a.weight_min_kg, a.weight_max_kg, a.pet_size_label,
|
||
a.component_code,
|
||
g.component_name_ko, g.dosing_interval_adult,
|
||
g.dosing_interval_high_risk, g.dosing_interval_puppy,
|
||
g.companion_drugs
|
||
FROM apc a
|
||
LEFT JOIN component_guide g ON a.component_code = g.component_code
|
||
WHERE a.apc LIKE :prefix
|
||
ORDER BY LENGTH(a.apc)
|
||
LIMIT 1
|
||
"""), {'prefix': f'{item_prefix}%'})
|
||
row = result.fetchone()
|
||
|
||
if not row:
|
||
return jsonify({'success': False, 'error': f'APC {apc} 정보를 찾을 수 없습니다'}), 404
|
||
|
||
except Exception as e:
|
||
logging.error(f"PostgreSQL 조회 오류: {e}")
|
||
return jsonify({'success': False, 'error': f'DB 조회 오류: {str(e)}'}), 500
|
||
|
||
# HTML 태그 제거 함수
|
||
def strip_html(html_text):
|
||
if not html_text:
|
||
return ''
|
||
# HTML 태그 제거 (줄바꿈 보존)
|
||
# <p>, <br>, </div> 등을 줄바꿈으로 변환
|
||
text = re.sub(r'</p>|<br\s*/?>|</div>', '\n', html_text)
|
||
text = re.sub(r'<[^>]+>', '', text)
|
||
# HTML 엔티티 변환
|
||
text = unescape(text)
|
||
|
||
# 표 형식 감지 (─ 또는 ====/---- 포함)
|
||
if '─' in text or '━' in text or ('======' in text and '------' in text):
|
||
# 표 형식: 각 줄의 앞뒤 공백만 정리, 줄 내 공백은 유지
|
||
lines = text.split('\n')
|
||
cleaned = []
|
||
for line in lines:
|
||
line = line.strip()
|
||
if line:
|
||
cleaned.append(line)
|
||
return '\n'.join(cleaned)
|
||
else:
|
||
# 일반 텍스트: 연속 공백/줄바꿈 정리
|
||
text = re.sub(r'\s+', ' ', text).strip()
|
||
return text
|
||
|
||
# 항목별 줄바꿈 처리 (가. 나. 다. 라. / 1) 2) 3) 등)
|
||
def format_for_print(text):
|
||
if not text:
|
||
return ''
|
||
# 가. 나. 다. 라. 마. 바. 사. 아. 자. 앞에 줄바꿈
|
||
text = re.sub(r'\s*(가|나|다|라|마|바|사|아|자)\.\s*', r'\n\1. ', text)
|
||
# 1) 2) 3) 등 앞에 줄바꿈 (단, 문장 시작이 아닌 경우)
|
||
text = re.sub(r'\s+(\d+)\)\s*', r'\n \1) ', text)
|
||
# 첫 줄바꿈 제거
|
||
text = text.strip()
|
||
return text
|
||
|
||
# 텍스트를 줄 단위로 분리 (80mm ≈ 42자)
|
||
def wrap_text(text, width=40):
|
||
lines = []
|
||
words = text.split()
|
||
current_line = ""
|
||
for word in words:
|
||
if len(current_line) + len(word) + 1 <= width:
|
||
current_line += (" " if current_line else "") + word
|
||
else:
|
||
if current_line:
|
||
lines.append(current_line)
|
||
current_line = word
|
||
if current_line:
|
||
lines.append(current_line)
|
||
return lines
|
||
|
||
# 데이터 파싱
|
||
pg_product_name = row.product_name or product_name
|
||
company = row.company_name or ''
|
||
ingredient = row.main_ingredient or ''
|
||
efficacy = strip_html(row.efficacy_effect)
|
||
dosage = strip_html(row.dosage_instructions)
|
||
precautions = strip_html(row.precautions)
|
||
|
||
# 80mm 프린터 = 48자 기준
|
||
LINE = "=" * 48
|
||
THIN = "-" * 48
|
||
|
||
# 텍스트 메시지 생성 (48자 기준 중앙정렬)
|
||
message = f"""
|
||
{LINE}
|
||
[ 애니팜 투약지도서 ]
|
||
{LINE}
|
||
|
||
{pg_product_name}
|
||
"""
|
||
if company:
|
||
message += f"제조: {company}\n"
|
||
|
||
if ingredient and ingredient != 'NaN':
|
||
message += f"""
|
||
{THIN}
|
||
▶ 주성분
|
||
"""
|
||
for line in wrap_text(ingredient, 46):
|
||
message += f" {line}\n"
|
||
|
||
if efficacy:
|
||
message += f"""
|
||
{THIN}
|
||
▶ 효능효과
|
||
"""
|
||
formatted_efficacy = format_for_print(efficacy)
|
||
for para in formatted_efficacy.split('\n'):
|
||
for line in wrap_text(para.strip(), 44):
|
||
message += f" {line}\n"
|
||
|
||
if dosage:
|
||
message += f"""
|
||
{THIN}
|
||
▶ 용법용량
|
||
"""
|
||
# 표 형식 감지
|
||
has_box_table = '─' in dosage or '━' in dosage
|
||
has_ascii_table = '======' in dosage and '------' in dosage
|
||
if has_box_table:
|
||
# ─ 표: 줄바꿈 유지
|
||
for line in dosage.split('\n'):
|
||
line = line.strip()
|
||
if line:
|
||
message += f"{line}\n"
|
||
elif has_ascii_table:
|
||
# ===/--- 표: 구분선 제거, 데이터만 정리
|
||
for line in dosage.split('\n'):
|
||
stripped = line.strip()
|
||
if not stripped:
|
||
continue
|
||
if stripped.startswith('===') or stripped.startswith('---'):
|
||
message += f" {'─' * 44}\n"
|
||
else:
|
||
# 공백 정렬된 열을 적절히 정리
|
||
message += f" {stripped}\n"
|
||
else:
|
||
formatted_dosage = format_for_print(dosage)
|
||
for para in formatted_dosage.split('\n'):
|
||
for line in wrap_text(para.strip(), 44):
|
||
message += f" {line}\n"
|
||
|
||
# 투약 주기 (component_guide JOIN)
|
||
if row.dosing_interval_adult:
|
||
message += f"""
|
||
{THIN}
|
||
★ 투약 주기 ★
|
||
"""
|
||
message += f" 일반: {row.dosing_interval_adult}\n"
|
||
if row.dosing_interval_high_risk:
|
||
message += f" 고위험: {row.dosing_interval_high_risk}\n"
|
||
if row.dosing_interval_puppy:
|
||
message += f" 새끼: {row.dosing_interval_puppy}\n"
|
||
|
||
# 병용약 권장 (component_guide JOIN)
|
||
if row.companion_drugs:
|
||
message += f"""
|
||
{THIN}
|
||
★ 함께 투약 권장 ★
|
||
"""
|
||
for line in wrap_text(row.companion_drugs, 44):
|
||
message += f" {line}\n"
|
||
|
||
# 주의사항 (마지막)
|
||
if precautions:
|
||
message += f"""
|
||
{THIN}
|
||
▶ 주의사항
|
||
"""
|
||
formatted_precautions = format_for_print(precautions)
|
||
for para in formatted_precautions.split('\n'):
|
||
for line in wrap_text(para.strip(), 44):
|
||
message += f" {line}\n"
|
||
|
||
message += f"""
|
||
{LINE}
|
||
청 춘 약 국
|
||
Tel: 033-481-5222
|
||
"""
|
||
|
||
# 네트워크 프린터로 인쇄 (pos_printer 사용)
|
||
from pos_printer import print_text
|
||
|
||
result = print_text(message, cut=True)
|
||
|
||
if result:
|
||
return jsonify({'success': True, 'message': '동물약 안내서 인쇄 완료'})
|
||
else:
|
||
return jsonify({'success': False, 'error': '프린터 출력 실패'}), 500
|
||
|
||
except Exception as e:
|
||
logging.error(f"동물약 정보 인쇄 API 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/animal-drug-info/preview', methods=['POST'])
|
||
def api_animal_drug_info_preview():
|
||
"""동물약 정보 미리보기 (텍스트 반환)"""
|
||
try:
|
||
import re
|
||
from html import unescape
|
||
|
||
data = request.get_json()
|
||
apc = data.get('apc', '')
|
||
|
||
if not apc:
|
||
return jsonify({'success': False, 'error': 'APC 코드가 필요합니다'}), 400
|
||
|
||
# PostgreSQL에서 약품 정보 조회
|
||
try:
|
||
from sqlalchemy import create_engine
|
||
pg_engine = create_engine(
|
||
'postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master',
|
||
connect_args={'connect_timeout': 5}
|
||
)
|
||
|
||
with pg_engine.connect() as conn:
|
||
result = conn.execute(text("""
|
||
SELECT
|
||
a.product_name,
|
||
a.company_name,
|
||
a.main_ingredient,
|
||
a.efficacy_effect,
|
||
a.dosage_instructions,
|
||
a.precautions,
|
||
a.component_code,
|
||
g.component_name_ko,
|
||
g.dosing_interval_adult,
|
||
g.dosing_interval_high_risk,
|
||
g.dosing_interval_puppy,
|
||
g.companion_drugs,
|
||
g.contraindication as guide_contraindication
|
||
FROM apc a
|
||
LEFT JOIN component_guide g ON a.component_code = g.component_code
|
||
WHERE a.apc = :apc
|
||
LIMIT 1
|
||
"""), {'apc': apc})
|
||
row = result.fetchone()
|
||
|
||
# 포장단위 APC → 대표 APC 폴백 (앞 8자리 품목코드로 검색)
|
||
if not row and len(apc) == 13 and apc.startswith('023'):
|
||
item_prefix = apc[:8]
|
||
result = conn.execute(text("""
|
||
SELECT
|
||
a.product_name, a.company_name, a.main_ingredient,
|
||
a.efficacy_effect, a.dosage_instructions, a.precautions,
|
||
a.component_code,
|
||
g.component_name_ko, g.dosing_interval_adult,
|
||
g.dosing_interval_high_risk, g.dosing_interval_puppy,
|
||
g.companion_drugs,
|
||
g.contraindication as guide_contraindication
|
||
FROM apc a
|
||
LEFT JOIN component_guide g ON a.component_code = g.component_code
|
||
WHERE a.apc LIKE :prefix
|
||
ORDER BY LENGTH(a.apc)
|
||
LIMIT 1
|
||
"""), {'prefix': f'{item_prefix}%'})
|
||
row = result.fetchone()
|
||
|
||
if not row:
|
||
return jsonify({'success': False, 'error': f'APC {apc} 정보 없음'}), 404
|
||
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'error': f'DB 오류: {str(e)}'}), 500
|
||
|
||
# HTML 태그 제거 (표 형식은 줄바꿈 유지)
|
||
def strip_html(html_text):
|
||
if not html_text:
|
||
return ''
|
||
# <p>, </div> → 줄바꿈
|
||
text = re.sub(r'</p>|</div>', '\n', html_text)
|
||
text = re.sub(r'<[^>]+>', '', text)
|
||
text = unescape(text)
|
||
|
||
# 표 형식 감지 (─ 또는 ====/---- 포함)
|
||
if '─' in text or '━' in text or ('======' in text and '------' in text):
|
||
lines = [l.strip() for l in text.split('\n') if l.strip()]
|
||
return '\n'.join(lines)
|
||
else:
|
||
text = re.sub(r'\s+', ' ', text).strip()
|
||
return text
|
||
|
||
# 항목별 줄바꿈 처리 (가. 나. 다. 라. / 1) 2) 3) 등)
|
||
def format_items(text):
|
||
if not text:
|
||
return ''
|
||
# 가. 나. 다. 라. 마. 바. 사. 아. 자. 앞에 줄바꿈
|
||
text = re.sub(r'\s*(가|나|다|라|마|바|사|아|자)\.\s*', r'\n\1. ', text)
|
||
# 1) 2) 3) 등 앞에 줄바꿈
|
||
text = re.sub(r'\s+(\d+)\)\s*', r'\n \1) ', text)
|
||
return text.strip()
|
||
|
||
# 표 형식을 HTML 테이블로 변환
|
||
def format_table_html(text):
|
||
if not text:
|
||
return ''
|
||
|
||
has_box_line = '─' in text or '━' in text
|
||
has_ascii_table = '======' in text and '------' in text
|
||
|
||
# 표가 없으면 일반 처리
|
||
if not has_box_line and not has_ascii_table:
|
||
return format_items(text)
|
||
|
||
# ── (A) 안텔민 형식: ─ 구분 + "체중(kg)" 헤더 + "투여정수" 데이터 (2행 표) ──
|
||
if has_box_line:
|
||
lines = [l.strip() for l in text.split('\n') if l.strip() and '─' not in l and '━' not in l]
|
||
header_line = None
|
||
data_line = None
|
||
other_lines = []
|
||
|
||
for line in lines:
|
||
if '체중' in line and 'kg' in line.lower():
|
||
header_line = line
|
||
elif '투여' in line and ('정수' in line or '정' in line):
|
||
data_line = line
|
||
else:
|
||
other_lines.append(line)
|
||
|
||
result = '\n'.join(other_lines)
|
||
|
||
if header_line and data_line:
|
||
headers = re.split(r'\s{2,}', header_line)
|
||
values = re.split(r'\s{2,}', data_line)
|
||
|
||
html = '<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">'
|
||
html += '<tr style="background:#dbeafe;">'
|
||
for h in headers:
|
||
html += f'<th style="border:1px solid #93c5fd;padding:8px;text-align:center;">{h}</th>'
|
||
html += '</tr>'
|
||
html += '<tr style="background:#fff;">'
|
||
for v in values:
|
||
html += f'<td style="border:1px solid #93c5fd;padding:8px;text-align:center;font-weight:bold;color:#1e40af;">{v}</td>'
|
||
html += '</tr>'
|
||
html += '</table>'
|
||
|
||
result = result + '\n' + html if result else html
|
||
return result
|
||
|
||
# ── (B) 넥스가드 형식: ====/---- 구분 + 다행 테이블 ──
|
||
if has_ascii_table:
|
||
lines = text.split('\n')
|
||
before_table = []
|
||
table_rows = []
|
||
after_table = []
|
||
header_cols = []
|
||
in_table = False
|
||
table_ended = False
|
||
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
if not stripped:
|
||
continue
|
||
is_eq_sep = stripped.startswith('===')
|
||
is_dash_sep = stripped.startswith('---')
|
||
|
||
if is_eq_sep:
|
||
if in_table:
|
||
# 두 번째 === → 테이블 끝
|
||
table_ended = True
|
||
else:
|
||
# 첫 번째 === → 테이블 시작
|
||
in_table = True
|
||
continue
|
||
if is_dash_sep:
|
||
# --- 는 행 구분선 → 건너뛰기
|
||
continue
|
||
|
||
if not in_table:
|
||
before_table.append(stripped)
|
||
continue
|
||
if table_ended:
|
||
after_table.append(stripped)
|
||
continue
|
||
|
||
# ㅣ 또는 | 구분자 감지
|
||
if 'ㅣ' in stripped or '|' in stripped:
|
||
sep = 'ㅣ' if 'ㅣ' in stripped else '|'
|
||
cells = [c.strip() for c in stripped.split(sep) if c.strip()]
|
||
else:
|
||
cells = re.split(r'\s{2,}', stripped)
|
||
|
||
# 테이블 헤더 행 감지
|
||
if '체중' in stripped and not header_cols:
|
||
header_cols = cells
|
||
continue
|
||
|
||
# 데이터 행
|
||
if len(cells) >= 2:
|
||
table_rows.append(cells)
|
||
|
||
result_parts = []
|
||
if before_table:
|
||
result_parts.append(format_items('\n'.join(before_table)))
|
||
|
||
if header_cols and table_rows:
|
||
html = '<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">'
|
||
html += '<tr style="background:#dbeafe;">'
|
||
for h in header_cols:
|
||
html += f'<th style="border:1px solid #93c5fd;padding:8px;text-align:center;">{h}</th>'
|
||
html += '</tr>'
|
||
for i, row in enumerate(table_rows):
|
||
bg = '#fff' if i % 2 == 0 else '#f8fafc'
|
||
html += f'<tr style="background:{bg};">'
|
||
for cell in row:
|
||
html += f'<td style="border:1px solid #93c5fd;padding:6px 8px;text-align:center;font-size:12px;">{cell}</td>'
|
||
# 셀 수가 헤더보다 적으면 빈 셀 채우기
|
||
for _ in range(len(header_cols) - len(row)):
|
||
html += '<td style="border:1px solid #93c5fd;padding:6px 8px;"></td>'
|
||
html += '</tr>'
|
||
html += '</table>'
|
||
result_parts.append(html)
|
||
|
||
if after_table:
|
||
result_parts.append(format_items('\n'.join(after_table)))
|
||
|
||
return '\n'.join(result_parts)
|
||
|
||
return format_items(text)
|
||
|
||
def format_dosage(raw_html):
|
||
"""dosage_instructions 처리: 원본 HTML table 보존 or 텍스트 표 변환"""
|
||
if not raw_html:
|
||
return ''
|
||
# 원본에 <table> 태그가 있으면 → HTML 테이블 보존 + 스타일 적용
|
||
if '<table' in raw_html:
|
||
# table 앞뒤 텍스트 분리
|
||
before_html = re.split(r'<div[^>]*class="_table_wrap', raw_html, maxsplit=1)[0] if '_table_wrap' in raw_html else raw_html.split('<table')[0]
|
||
after_match = re.search(r'</table>(.*)', raw_html, re.DOTALL)
|
||
after_html = after_match.group(1) if after_match else ''
|
||
|
||
# 앞부분 텍스트 처리
|
||
before_text = strip_html(before_html)
|
||
before_text = format_items(before_text) if before_text else ''
|
||
|
||
# 테이블 추출 및 스타일 적용
|
||
table_match = re.search(r'<table[^>]*>(.*?)</table>', raw_html, re.DOTALL)
|
||
if table_match:
|
||
table_inner = table_match.group(1)
|
||
# caption, hidden 요소 제거
|
||
table_inner = re.sub(r'<caption>.*?</caption>', '', table_inner, flags=re.DOTALL)
|
||
# 기존 style 제거하고 새 스타일 적용
|
||
table_inner = re.sub(r'<td[^>]*>', '<td style="border:1px solid #93c5fd;padding:6px 8px;text-align:center;font-size:12px;">', table_inner)
|
||
table_inner = re.sub(r'<th[^>]*>', '<th style="border:1px solid #93c5fd;padding:8px;text-align:center;background:#dbeafe;">', table_inner)
|
||
# 첫 번째 tr에 헤더 배경색 적용
|
||
table_inner = re.sub(r'<tr[^>]*>', '<tr>', table_inner)
|
||
table_inner = table_inner.replace('<tr>', '<tr style="background:#dbeafe;">', 1)
|
||
# p 태그 제거 (셀 내부)
|
||
table_inner = re.sub(r'<p[^>]*>', '', table_inner)
|
||
table_inner = re.sub(r'</p>', '<br>', table_inner)
|
||
# tbody 유지
|
||
styled_table = f'<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">{table_inner}</table>'
|
||
else:
|
||
styled_table = ''
|
||
|
||
# 뒷부분 텍스트 처리
|
||
after_text = strip_html(after_html)
|
||
after_text = format_items(after_text) if after_text else ''
|
||
|
||
parts = [p for p in [before_text, styled_table, after_text] if p]
|
||
return '\n'.join(parts)
|
||
|
||
# 원본에 <table> 없으면 기존 로직
|
||
return format_table_html(strip_html(raw_html))
|
||
|
||
# 투약주기 조합
|
||
dosing_interval = None
|
||
if row.dosing_interval_adult:
|
||
parts = []
|
||
parts.append(f"일반: {row.dosing_interval_adult}")
|
||
if row.dosing_interval_high_risk:
|
||
parts.append(f"고위험: {row.dosing_interval_high_risk}")
|
||
if row.dosing_interval_puppy:
|
||
parts.append(f"새끼: {row.dosing_interval_puppy}")
|
||
dosing_interval = '\n'.join(parts)
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'data': {
|
||
'product_name': row.product_name,
|
||
'company_name': row.company_name,
|
||
'main_ingredient': row.main_ingredient if row.main_ingredient != 'NaN' else None,
|
||
'efficacy_effect': format_items(strip_html(row.efficacy_effect)),
|
||
'dosage_instructions': format_dosage(row.dosage_instructions),
|
||
'dosage_has_table': any(c in (row.dosage_instructions or '') for c in ('─', '━', '======', '<table')),
|
||
'precautions': format_items(strip_html(row.precautions)),
|
||
# 성분 가이드 (component_guide JOIN)
|
||
'component_code': row.component_code,
|
||
'component_name': row.component_name_ko,
|
||
'dosing_interval': dosing_interval,
|
||
'companion_drugs': row.companion_drugs,
|
||
'guide_contraindication': row.guide_contraindication
|
||
}
|
||
})
|
||
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# PAAI (Pharmacist Assistant AI) Admin 라우트
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
@app.route('/admin/paai')
|
||
def admin_paai():
|
||
"""PAAI 분석 로그 관리 페이지"""
|
||
return render_template('admin_paai.html')
|
||
|
||
|
||
@app.route('/api/paai/logs')
|
||
def api_paai_logs():
|
||
"""PAAI 로그 목록 조회"""
|
||
from db.paai_logger import get_recent_logs
|
||
|
||
limit = int(request.args.get('limit', 100))
|
||
status = request.args.get('status', '')
|
||
has_severe = request.args.get('has_severe', '')
|
||
date = request.args.get('date', '')
|
||
|
||
try:
|
||
logs = get_recent_logs(
|
||
limit=limit,
|
||
status=status if status else None,
|
||
has_severe=True if has_severe == 'true' else (False if has_severe == 'false' else None),
|
||
date=date if date else None
|
||
)
|
||
return jsonify({'success': True, 'logs': logs, 'count': len(logs)})
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/paai/logs/stats')
|
||
def api_paai_logs_stats():
|
||
"""PAAI 로그 통계"""
|
||
from db.paai_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)}), 500
|
||
|
||
|
||
@app.route('/api/paai/logs/<int:log_id>')
|
||
def api_paai_log_detail(log_id):
|
||
"""PAAI 로그 상세 조회"""
|
||
from db.paai_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)}), 500
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# 모바일 이미지 업로드 세션 (QR 기반)
|
||
# ══════════════════════════════════════════════════════════════════
|
||
import uuid
|
||
from datetime import datetime, timedelta
|
||
|
||
# 메모리 기반 세션 저장소 (서버 재시작 시 초기화됨)
|
||
upload_sessions = {}
|
||
|
||
def cleanup_expired_sessions():
|
||
"""만료된 세션 정리"""
|
||
now = datetime.now()
|
||
expired = [sid for sid, s in upload_sessions.items() if s['expires_at'] < now]
|
||
for sid in expired:
|
||
del upload_sessions[sid]
|
||
|
||
@app.route('/api/upload-session', methods=['POST'])
|
||
def api_create_upload_session():
|
||
"""업로드 세션 생성 (QR용)"""
|
||
cleanup_expired_sessions()
|
||
|
||
data = request.get_json() or {}
|
||
barcode = data.get('barcode', '')
|
||
product_name = data.get('product_name', barcode) # 없으면 바코드 사용
|
||
|
||
if not barcode:
|
||
return jsonify({'success': False, 'error': '바코드가 필요합니다'}), 400
|
||
|
||
session_id = str(uuid.uuid4())[:12] # 짧은 ID
|
||
expires_at = datetime.now() + timedelta(minutes=10)
|
||
|
||
upload_sessions[session_id] = {
|
||
'barcode': barcode,
|
||
'product_name': product_name,
|
||
'created_at': datetime.now(),
|
||
'expires_at': expires_at,
|
||
'status': 'pending', # pending → uploaded
|
||
'image_base64': None
|
||
}
|
||
|
||
# QR URL 생성
|
||
qr_url = f"https://mile.0bin.in/upload/{session_id}?barcode={barcode}"
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'session_id': session_id,
|
||
'qr_url': qr_url,
|
||
'expires_in': 600
|
||
})
|
||
|
||
@app.route('/api/upload-session/<session_id>')
|
||
def api_get_upload_session(session_id):
|
||
"""업로드 세션 상태 확인 (폴링용)"""
|
||
cleanup_expired_sessions()
|
||
|
||
session = upload_sessions.get(session_id)
|
||
if not session:
|
||
return jsonify({'status': 'expired'})
|
||
|
||
result = {'status': session['status']}
|
||
if session['status'] == 'uploaded' and session['image_base64']:
|
||
result['image_base64'] = session['image_base64']
|
||
|
||
return jsonify(result)
|
||
|
||
@app.route('/api/upload-session/<session_id>/image', methods=['POST'])
|
||
def api_upload_session_image(session_id):
|
||
"""모바일에서 이미지 업로드"""
|
||
session = upload_sessions.get(session_id)
|
||
if not session:
|
||
return jsonify({'success': False, 'error': '세션이 만료되었습니다'}), 404
|
||
|
||
if session['expires_at'] < datetime.now():
|
||
del upload_sessions[session_id]
|
||
return jsonify({'success': False, 'error': '세션이 만료되었습니다'}), 404
|
||
|
||
# 이미지 데이터 받기
|
||
import base64
|
||
from PIL import Image
|
||
from io import BytesIO
|
||
|
||
if 'image' not in request.files:
|
||
# base64로 받은 경우
|
||
data = request.get_json() or {}
|
||
image_data_raw = data.get('image_base64')
|
||
if not image_data_raw:
|
||
return jsonify({'success': False, 'error': '이미지가 필요합니다'}), 400
|
||
else:
|
||
# 파일로 받은 경우
|
||
file = request.files['image']
|
||
image_data_raw = base64.b64encode(file.read()).decode('utf-8')
|
||
|
||
barcode = session['barcode']
|
||
product_name = session.get('product_name', barcode)
|
||
|
||
try:
|
||
# base64 디코딩 & PIL 이미지 처리
|
||
image_bytes = base64.b64decode(image_data_raw)
|
||
img = Image.open(BytesIO(image_bytes))
|
||
|
||
# RGBA -> RGB 변환
|
||
if img.mode == 'RGBA':
|
||
bg = Image.new('RGB', img.size, (255, 255, 255))
|
||
bg.paste(img, mask=img.split()[3])
|
||
img = bg
|
||
elif img.mode != 'RGB':
|
||
img = img.convert('RGB')
|
||
|
||
# 1:1 중앙 크롭
|
||
width, height = img.size
|
||
min_dim = min(width, height)
|
||
left = (width - min_dim) // 2
|
||
top = (height - min_dim) // 2
|
||
img = img.crop((left, top, left + min_dim, top + min_dim))
|
||
|
||
# 800x800 리사이즈
|
||
img = img.resize((800, 800), Image.LANCZOS)
|
||
|
||
# base64 변환 (원본)
|
||
buffer = BytesIO()
|
||
img.save(buffer, format='JPEG', quality=90)
|
||
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||
|
||
# 썸네일 생성 (200x200)
|
||
thumb_img = img.resize((200, 200), Image.LANCZOS)
|
||
thumb_buffer = BytesIO()
|
||
thumb_img.save(thumb_buffer, format='JPEG', quality=85)
|
||
thumbnail_base64 = base64.b64encode(thumb_buffer.getvalue()).decode('utf-8')
|
||
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'error': f'이미지 처리 실패: {str(e)}'}), 400
|
||
|
||
# SQLite 저장
|
||
try:
|
||
img_db_path = Path(__file__).parent / 'db' / 'product_images.db'
|
||
conn = sqlite3.connect(str(img_db_path))
|
||
cursor = conn.cursor()
|
||
|
||
# 기존 이미지 확인
|
||
cursor.execute('SELECT id FROM product_images WHERE barcode = ?', (barcode,))
|
||
existing = cursor.fetchone()
|
||
|
||
if existing:
|
||
cursor.execute('''
|
||
UPDATE product_images
|
||
SET image_base64 = ?, thumbnail_base64 = ?, status = 'manual', updated_at = datetime('now')
|
||
WHERE barcode = ?
|
||
''', (image_base64, thumbnail_base64, barcode))
|
||
else:
|
||
cursor.execute('''
|
||
INSERT INTO product_images (barcode, product_name, image_base64, thumbnail_base64, status)
|
||
VALUES (?, ?, ?, ?, 'manual')
|
||
''', (barcode, product_name, image_base64, thumbnail_base64))
|
||
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
# 세션 상태 업데이트
|
||
session['status'] = 'uploaded'
|
||
session['image_base64'] = thumbnail_base64 # 폴링용으로 썸네일 사용
|
||
|
||
return jsonify({'success': True, 'message': '이미지가 저장되었습니다'})
|
||
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
@app.route('/upload/<session_id>')
|
||
def mobile_upload_page(session_id):
|
||
"""모바일 업로드 페이지"""
|
||
session = upload_sessions.get(session_id)
|
||
barcode = request.args.get('barcode', '')
|
||
|
||
if not session:
|
||
return render_template_string('''
|
||
<!DOCTYPE html>
|
||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>세션 만료</title>
|
||
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f8d7da;color:#721c24;text-align:center;padding:20px;}
|
||
.msg{font-size:18px;}</style></head>
|
||
<body><div class="msg">⏰ 세션이 만료되었습니다.<br><br>PC에서 다시 QR코드를 생성해주세요.</div></body></html>
|
||
''')
|
||
|
||
return render_template_string('''
|
||
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
||
<title>제품 이미지 촬영</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
min-height: 100vh;
|
||
padding: 20px;
|
||
}
|
||
.container {
|
||
max-width: 400px;
|
||
margin: 0 auto;
|
||
background: #fff;
|
||
border-radius: 20px;
|
||
padding: 24px;
|
||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||
}
|
||
h1 {
|
||
font-size: 20px;
|
||
color: #333;
|
||
margin-bottom: 8px;
|
||
text-align: center;
|
||
}
|
||
.barcode {
|
||
background: #f0f0f0;
|
||
padding: 8px 16px;
|
||
border-radius: 8px;
|
||
font-family: monospace;
|
||
font-size: 14px;
|
||
text-align: center;
|
||
margin-bottom: 20px;
|
||
color: #666;
|
||
}
|
||
.preview-area {
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
background: #f5f5f5;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-bottom: 20px;
|
||
overflow: hidden;
|
||
border: 2px dashed #ddd;
|
||
}
|
||
.preview-area.has-image { border: none; }
|
||
.preview-area img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.placeholder {
|
||
color: #999;
|
||
font-size: 14px;
|
||
text-align: center;
|
||
}
|
||
.placeholder .icon { font-size: 48px; margin-bottom: 8px; }
|
||
.btn {
|
||
width: 100%;
|
||
padding: 16px;
|
||
border: none;
|
||
border-radius: 12px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
margin-bottom: 12px;
|
||
transition: transform 0.1s;
|
||
}
|
||
.btn:active { transform: scale(0.98); }
|
||
.btn-camera {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: #fff;
|
||
}
|
||
.btn-upload {
|
||
background: #10b981;
|
||
color: #fff;
|
||
}
|
||
.btn-upload:disabled {
|
||
background: #ccc;
|
||
cursor: not-allowed;
|
||
}
|
||
input[type="file"] { display: none; }
|
||
.status {
|
||
text-align: center;
|
||
padding: 16px;
|
||
border-radius: 12px;
|
||
font-weight: 600;
|
||
}
|
||
.status.success { background: #d1fae5; color: #065f46; }
|
||
.status.error { background: #fee2e2; color: #991b1b; }
|
||
.loading { display: none; text-align: center; padding: 20px; }
|
||
.loading.show { display: block; }
|
||
.spinner {
|
||
width: 40px; height: 40px;
|
||
border: 4px solid #eee;
|
||
border-top-color: #667eea;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
margin: 0 auto 12px;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>📸 제품 이미지 촬영</h1>
|
||
<div class="barcode">바코드: {{ barcode }}</div>
|
||
|
||
<div class="preview-area" id="previewArea">
|
||
<div class="placeholder">
|
||
<div class="icon">📷</div>
|
||
<div>카메라 버튼을 눌러<br>제품 사진을 촬영하세요</div>
|
||
</div>
|
||
</div>
|
||
|
||
<input type="file" id="fileInput" accept="image/*" capture="environment">
|
||
<button class="btn btn-camera" onclick="document.getElementById('fileInput').click()">
|
||
📷 카메라로 촬영
|
||
</button>
|
||
|
||
<button class="btn btn-upload" id="uploadBtn" disabled onclick="uploadImage()">
|
||
⬆️ 업로드
|
||
</button>
|
||
|
||
<div class="loading" id="loading">
|
||
<div class="spinner"></div>
|
||
<div>업로드 중...</div>
|
||
</div>
|
||
|
||
<div class="status" id="status" style="display:none;"></div>
|
||
</div>
|
||
|
||
<script>
|
||
const sessionId = '{{ session_id }}';
|
||
let imageData = null;
|
||
|
||
document.getElementById('fileInput').addEventListener('change', function(e) {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = function(ev) {
|
||
imageData = ev.target.result;
|
||
|
||
const preview = document.getElementById('previewArea');
|
||
preview.innerHTML = '<img src="' + imageData + '">';
|
||
preview.classList.add('has-image');
|
||
|
||
document.getElementById('uploadBtn').disabled = false;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
|
||
async function uploadImage() {
|
||
if (!imageData) return;
|
||
|
||
document.getElementById('loading').classList.add('show');
|
||
document.getElementById('uploadBtn').disabled = true;
|
||
|
||
try {
|
||
// base64에서 데이터 부분만 추출
|
||
const base64Data = imageData.split(',')[1];
|
||
|
||
const res = await fetch('/api/upload-session/' + sessionId + '/image', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ image_base64: base64Data })
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
document.getElementById('loading').classList.remove('show');
|
||
|
||
const status = document.getElementById('status');
|
||
if (data.success) {
|
||
status.className = 'status success';
|
||
status.innerHTML = '✅ 업로드 완료!<br><br>PC에서 확인하세요.';
|
||
status.style.display = 'block';
|
||
|
||
// 버튼 숨기기
|
||
document.querySelector('.btn-camera').style.display = 'none';
|
||
document.getElementById('uploadBtn').style.display = 'none';
|
||
} else {
|
||
status.className = 'status error';
|
||
status.textContent = '❌ ' + (data.error || '업로드 실패');
|
||
status.style.display = 'block';
|
||
document.getElementById('uploadBtn').disabled = false;
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('loading').classList.remove('show');
|
||
const status = document.getElementById('status');
|
||
status.className = 'status error';
|
||
status.textContent = '❌ 네트워크 오류';
|
||
status.style.display = 'block';
|
||
document.getElementById('uploadBtn').disabled = false;
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
''', session_id=session_id, barcode=barcode)
|
||
|
||
|
||
# ============================================
|
||
# 동물약 챗봇 로그 API
|
||
# ============================================
|
||
|
||
@app.route('/admin/animal-chat-logs')
|
||
def admin_animal_chat_logs():
|
||
"""동물약 챗봇 로그 페이지"""
|
||
return render_template('admin_animal_chat_logs.html')
|
||
|
||
|
||
@app.route('/api/animal-chat-logs')
|
||
def api_animal_chat_logs():
|
||
"""동물약 챗봇 로그 조회 API"""
|
||
from utils.animal_chat_logger import get_logs, get_stats
|
||
|
||
date_from = request.args.get('date_from')
|
||
date_to = request.args.get('date_to')
|
||
error_only = request.args.get('error_only') == 'true'
|
||
limit = int(request.args.get('limit', 100))
|
||
offset = int(request.args.get('offset', 0))
|
||
|
||
logs = get_logs(
|
||
limit=limit,
|
||
offset=offset,
|
||
date_from=date_from,
|
||
date_to=date_to,
|
||
error_only=error_only
|
||
)
|
||
|
||
stats = get_stats(date_from=date_from, date_to=date_to)
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'logs': logs,
|
||
'stats': stats
|
||
})
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# 기간별 사용약품 조회 API
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
@app.route('/api/drug-usage')
|
||
def api_drug_usage():
|
||
"""
|
||
기간별 사용약품 조회 API
|
||
|
||
파라미터:
|
||
- start_date: 시작일 (YYYYMMDD, 필수)
|
||
- end_date: 종료일 (YYYYMMDD, 필수)
|
||
- date_type: dispense (조제일, 기본) / expiry (소진일)
|
||
- drug_code: 특정 약품코드 필터
|
||
- search: 약품명 검색
|
||
- limit: 결과 제한 (기본 100)
|
||
"""
|
||
try:
|
||
# 파라미터 추출
|
||
start_date = request.args.get('start_date')
|
||
end_date = request.args.get('end_date')
|
||
date_type = request.args.get('date_type', 'dispense') # dispense or expiry
|
||
drug_code = request.args.get('drug_code')
|
||
search = request.args.get('search')
|
||
limit = int(request.args.get('limit', 100))
|
||
|
||
# 필수 파라미터 확인
|
||
if not start_date or not end_date:
|
||
return jsonify({
|
||
'success': False,
|
||
'error': 'start_date와 end_date는 필수입니다 (YYYYMMDD 형식)'
|
||
}), 400
|
||
|
||
# 날짜 형식 검증 (8자리 숫자)
|
||
if not (start_date.isdigit() and len(start_date) == 8):
|
||
return jsonify({'success': False, 'error': 'start_date는 YYYYMMDD 형식이어야 합니다'}), 400
|
||
if not (end_date.isdigit() and len(end_date) == 8):
|
||
return jsonify({'success': False, 'error': 'end_date는 YYYYMMDD 형식이어야 합니다'}), 400
|
||
|
||
pres_session = db_manager.get_session('PM_PRES')
|
||
|
||
# ─────────────────────────────────────────
|
||
# 조제 데이터 쿼리
|
||
# ─────────────────────────────────────────
|
||
if date_type == 'expiry':
|
||
# 소진일(조제만료일) 기준 필터
|
||
rx_query = """
|
||
SELECT
|
||
sp.DrugCode,
|
||
g.GoodsName,
|
||
m.PRINT_TYPE as category,
|
||
COUNT(*) as rx_count,
|
||
SUM(sp.QUAN * sp.QUAN_TIME * sp.Days) as total_qty
|
||
FROM PS_sub_pharm sp
|
||
INNER JOIN PS_main pm ON pm.PreSerial = sp.PreSerial
|
||
INNER JOIN PM_DRUG.dbo.CD_GOODS g ON sp.DrugCode = g.DrugCode
|
||
LEFT JOIN PM_DRUG.dbo.CD_MC m ON sp.DrugCode = m.DRUGCODE
|
||
WHERE sp.PS_Type != '9'
|
||
AND DATEADD(day, sp.Days, CONVERT(date, pm.Indate, 112))
|
||
BETWEEN CONVERT(date, :start_date, 112) AND CONVERT(date, :end_date, 112)
|
||
"""
|
||
else:
|
||
# 조제일 기준 필터 (기본)
|
||
rx_query = """
|
||
SELECT
|
||
sp.DrugCode,
|
||
g.GoodsName,
|
||
m.PRINT_TYPE as category,
|
||
COUNT(*) as rx_count,
|
||
SUM(sp.QUAN * sp.QUAN_TIME * sp.Days) as total_qty
|
||
FROM PS_sub_pharm sp
|
||
INNER JOIN PS_main pm ON pm.PreSerial = sp.PreSerial
|
||
INNER JOIN PM_DRUG.dbo.CD_GOODS g ON sp.DrugCode = g.DrugCode
|
||
LEFT JOIN PM_DRUG.dbo.CD_MC m ON sp.DrugCode = m.DRUGCODE
|
||
WHERE pm.Indate BETWEEN :start_date AND :end_date
|
||
AND sp.PS_Type != '9'
|
||
"""
|
||
|
||
# 약품코드 필터 추가
|
||
if drug_code:
|
||
rx_query += " AND sp.DrugCode = :drug_code"
|
||
|
||
# 약품명 검색 필터 추가
|
||
if search:
|
||
rx_query += " AND g.GoodsName LIKE :search"
|
||
|
||
rx_query += " GROUP BY sp.DrugCode, g.GoodsName, m.PRINT_TYPE"
|
||
rx_query += " ORDER BY rx_count DESC"
|
||
|
||
# 파라미터 바인딩
|
||
params = {'start_date': start_date, 'end_date': end_date}
|
||
if drug_code:
|
||
params['drug_code'] = drug_code
|
||
if search:
|
||
params['search'] = f'%{search}%'
|
||
|
||
rx_result = pres_session.execute(text(rx_query), params)
|
||
rx_data = {}
|
||
for row in rx_result:
|
||
rx_data[row.DrugCode] = {
|
||
'drug_code': row.DrugCode,
|
||
'goods_name': row.GoodsName,
|
||
'category': row.category or '',
|
||
'rx_count': row.rx_count,
|
||
'rx_total_qty': float(row.total_qty) if row.total_qty else 0
|
||
}
|
||
|
||
# ─────────────────────────────────────────
|
||
# 입고 데이터 쿼리 (PM_DRUG에서 조회)
|
||
# ─────────────────────────────────────────
|
||
drug_session = db_manager.get_session('PM_DRUG')
|
||
|
||
import_query = """
|
||
SELECT
|
||
ws.DrugCode,
|
||
COUNT(*) as import_count,
|
||
SUM(ws.WH_NM_item_a) as total_qty
|
||
FROM WH_sub ws
|
||
INNER JOIN WH_main wm ON ws.WH_SR_stock = wm.WH_NO_stock
|
||
"""
|
||
|
||
# 약품명 검색 시 CD_GOODS 조인 추가
|
||
if search:
|
||
import_query += " INNER JOIN CD_GOODS g ON ws.DrugCode = g.DrugCode"
|
||
|
||
import_query += " WHERE wm.WH_DT_appl BETWEEN :start_date AND :end_date"
|
||
|
||
# 약품코드 필터 추가
|
||
if drug_code:
|
||
import_query += " AND ws.DrugCode = :drug_code"
|
||
|
||
# 약품명 검색 필터 추가
|
||
if search:
|
||
import_query += " AND g.GoodsName LIKE :search"
|
||
|
||
import_query += " GROUP BY ws.DrugCode"
|
||
|
||
import_params = {'start_date': start_date, 'end_date': end_date}
|
||
if drug_code:
|
||
import_params['drug_code'] = drug_code
|
||
if search:
|
||
import_params['search'] = f'%{search}%'
|
||
|
||
import_result = drug_session.execute(text(import_query), import_params)
|
||
import_data = {}
|
||
for row in import_result:
|
||
import_data[row.DrugCode] = {
|
||
'import_count': row.import_count,
|
||
'import_total_qty': float(row.total_qty) if row.total_qty else 0
|
||
}
|
||
|
||
# ─────────────────────────────────────────
|
||
# 결과 병합 (drug_code 기준)
|
||
# ─────────────────────────────────────────
|
||
all_drug_codes = set(rx_data.keys()) | set(import_data.keys())
|
||
items = []
|
||
|
||
for code in all_drug_codes:
|
||
rx_info = rx_data.get(code, {})
|
||
import_info = import_data.get(code, {})
|
||
|
||
import_qty = import_info.get('import_total_qty', 0)
|
||
rx_qty = rx_info.get('rx_total_qty', 0)
|
||
item = {
|
||
'drug_code': code,
|
||
'goods_name': rx_info.get('goods_name', ''),
|
||
'category': rx_info.get('category', ''),
|
||
'rx_count': rx_info.get('rx_count', 0),
|
||
'rx_total_qty': rx_qty,
|
||
'import_count': import_info.get('import_count', 0),
|
||
'import_total_qty': import_qty,
|
||
'current_stock': 0 # IM_total에서 나중에 채움
|
||
}
|
||
|
||
# 약품명이 없으면 (입고만 있는 경우) PM_DRUG에서 조회
|
||
if not item['goods_name'] and code in import_data:
|
||
name_result = drug_session.execute(
|
||
text("SELECT GoodsName FROM CD_GOODS WHERE DrugCode = :code"),
|
||
{'code': code}
|
||
).fetchone()
|
||
if name_result:
|
||
item['goods_name'] = name_result.GoodsName
|
||
|
||
items.append(item)
|
||
|
||
# IM_total에서 현재 재고 조회
|
||
if items:
|
||
drug_codes = [item['drug_code'] for item in items]
|
||
placeholders = ','.join([f"'{c}'" for c in drug_codes])
|
||
stock_result = drug_session.execute(text(f"""
|
||
SELECT DrugCode, IM_QT_sale_debit
|
||
FROM IM_total
|
||
WHERE DrugCode IN ({placeholders})
|
||
"""))
|
||
stock_map = {row.DrugCode: float(row.IM_QT_sale_debit) if row.IM_QT_sale_debit else 0
|
||
for row in stock_result}
|
||
for item in items:
|
||
item['current_stock'] = stock_map.get(item['drug_code'], 0)
|
||
|
||
# 조제 건수 기준 정렬
|
||
items.sort(key=lambda x: x['rx_count'], reverse=True)
|
||
|
||
# limit 적용
|
||
items = items[:limit]
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'period': {
|
||
'start': start_date,
|
||
'end': end_date,
|
||
'date_type': date_type
|
||
},
|
||
'total_count': len(items),
|
||
'items': items
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"drug-usage API 오류: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return jsonify({
|
||
'success': False,
|
||
'error': str(e)
|
||
}), 500
|
||
|
||
|
||
@app.route('/api/drug-usage/<drug_code>/imports')
|
||
def api_drug_usage_imports(drug_code):
|
||
"""약품별 입고 상세 API"""
|
||
start_date = request.args.get('start_date', '')
|
||
end_date = request.args.get('end_date', '')
|
||
|
||
if not start_date or not end_date:
|
||
return jsonify({'success': False, 'error': 'start_date, end_date 필수'}), 400
|
||
|
||
try:
|
||
drug_session = db_manager.get_session('PM_DRUG')
|
||
|
||
result = drug_session.execute(text("""
|
||
SELECT
|
||
wm.WH_DT_appl as import_date,
|
||
ws.WH_NM_item_a as quantity,
|
||
ws.WH_MY_unit_a as unit_price,
|
||
c.CD_NM_custom as supplier_name,
|
||
c.CD_NM_charge1 as contact_person
|
||
FROM WH_sub ws
|
||
INNER JOIN WH_main wm ON ws.WH_SR_stock = wm.WH_NO_stock
|
||
LEFT JOIN PM_BASE.dbo.CD_custom c ON wm.WH_CD_cust_sale = c.CD_CD_custom
|
||
WHERE ws.DrugCode = :drug_code
|
||
AND wm.WH_DT_appl BETWEEN :start_date AND :end_date
|
||
ORDER BY wm.WH_DT_appl DESC
|
||
"""), {'drug_code': drug_code, 'start_date': start_date, 'end_date': end_date})
|
||
|
||
items = []
|
||
for row in result:
|
||
qty = float(row.quantity) if row.quantity else 0
|
||
price = float(row.unit_price) if row.unit_price else 0
|
||
items.append({
|
||
'import_date': row.import_date or '',
|
||
'quantity': qty,
|
||
'unit_price': price,
|
||
'amount': qty * price,
|
||
'supplier_name': row.supplier_name or '',
|
||
'person_name': row.contact_person or ''
|
||
})
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'drug_code': drug_code,
|
||
'total_count': len(items),
|
||
'items': items
|
||
})
|
||
except Exception as e:
|
||
logging.error(f"drug-usage imports API 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/drug-usage/<drug_code>/prescriptions')
|
||
def api_drug_usage_prescriptions(drug_code):
|
||
"""약품별 조제(매출) 상세 API"""
|
||
from utils.drug_unit import get_drug_unit
|
||
|
||
start_date = request.args.get('start_date', '')
|
||
end_date = request.args.get('end_date', '')
|
||
|
||
if not start_date or not end_date:
|
||
return jsonify({'success': False, 'error': 'start_date, end_date 필수'}), 400
|
||
|
||
try:
|
||
pres_session = db_manager.get_session('PM_PRES')
|
||
drug_session = db_manager.get_session('PM_DRUG')
|
||
|
||
# 약품 정보 조회 (단위 판별용)
|
||
drug_info = drug_session.execute(text("""
|
||
SELECT GoodsName, SUNG_CODE FROM CD_GOODS WHERE DrugCode = :drug_code
|
||
"""), {'drug_code': drug_code}).fetchone()
|
||
|
||
goods_name = drug_info.GoodsName if drug_info else ''
|
||
sung_code = drug_info.SUNG_CODE if drug_info else ''
|
||
unit = get_drug_unit(goods_name, sung_code)
|
||
|
||
result = pres_session.execute(text("""
|
||
SELECT
|
||
pm.Indate as rx_date,
|
||
CONVERT(varchar, DATEADD(day, sp.Days, CONVERT(date, pm.Indate, 112)), 112) as expiry_date,
|
||
pm.Paname as patient_name,
|
||
pm.OrderName as institution_name,
|
||
sp.QUAN as dosage,
|
||
sp.QUAN_TIME as frequency,
|
||
sp.Days as days
|
||
FROM PS_sub_pharm sp
|
||
INNER JOIN PS_main pm ON pm.PreSerial = sp.PreSerial
|
||
WHERE sp.DrugCode = :drug_code
|
||
AND pm.Indate BETWEEN :start_date AND :end_date
|
||
AND (sp.PS_Type IS NULL OR sp.PS_Type != '9')
|
||
ORDER BY pm.Indate DESC
|
||
"""), {'drug_code': drug_code, 'start_date': start_date, 'end_date': end_date})
|
||
|
||
items = []
|
||
seen_patients = set()
|
||
recent_patients = [] # 최근 조제받은 환자 (중복 제외, 최대 3명)
|
||
total_usage = 0 # 총 사용량
|
||
|
||
for row in result:
|
||
dosage = float(row.dosage) if row.dosage else 0
|
||
freq = float(row.frequency) if row.frequency else 0
|
||
days = int(row.days) if row.days else 0
|
||
patient = row.patient_name or ''
|
||
qty = dosage * freq * days
|
||
total_usage += qty
|
||
|
||
# 중복 제외 환자 목록 (최근순, 최대 3명)
|
||
if patient and patient not in seen_patients:
|
||
seen_patients.add(patient)
|
||
if len(recent_patients) < 3:
|
||
recent_patients.append(patient)
|
||
|
||
items.append({
|
||
'rx_date': row.rx_date or '',
|
||
'expiry_date': row.expiry_date or '',
|
||
'patient_name': patient,
|
||
'institution_name': row.institution_name or '',
|
||
'dosage': dosage,
|
||
'frequency': freq,
|
||
'days': days,
|
||
'total_qty': qty
|
||
})
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'drug_code': drug_code,
|
||
'total_count': len(items),
|
||
'unique_patients': len(seen_patients),
|
||
'recent_patients': recent_patients,
|
||
'total_usage': total_usage,
|
||
'unit': unit,
|
||
'items': items
|
||
})
|
||
except Exception as e:
|
||
logging.error(f"drug-usage prescriptions API 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 재고 시계열 분석 API
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
@app.route('/admin/stock-analytics')
|
||
def admin_stock_analytics():
|
||
"""재고 시계열 분석 페이지"""
|
||
return render_template('admin_stock_analytics.html')
|
||
|
||
|
||
@app.route('/api/stock-analytics/daily-trend')
|
||
def api_stock_daily_trend():
|
||
"""
|
||
일별 입출고 추이 API
|
||
GET /api/stock-analytics/daily-trend?start_date=2026-01-01&end_date=2026-03-13&drug_code=A123456789
|
||
"""
|
||
conn = None
|
||
try:
|
||
start_date = request.args.get('start_date', '')
|
||
end_date = request.args.get('end_date', '')
|
||
drug_code = request.args.get('drug_code', '').strip()
|
||
|
||
# 날짜 형식 변환 (YYYY-MM-DD -> YYYYMMDD)
|
||
start_date_fmt = start_date.replace('-', '') if start_date else (datetime.now() - timedelta(days=30)).strftime('%Y%m%d')
|
||
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
|
||
|
||
# 새로운 pyodbc 연결 (동시 요청 충돌 방지)
|
||
import pyodbc
|
||
conn_str = (
|
||
'DRIVER={ODBC Driver 17 for SQL Server};'
|
||
'SERVER=192.168.0.4\\PM2014;'
|
||
'DATABASE=PM_DRUG;'
|
||
'UID=sa;'
|
||
'PWD=tmddls214!%(;'
|
||
'TrustServerCertificate=yes'
|
||
)
|
||
conn = pyodbc.connect(conn_str, timeout=10)
|
||
cursor = conn.cursor()
|
||
|
||
# 특정 약품 또는 전체 일별 입출고 집계
|
||
# credit=출고, debit=입고 (약국 재고 기준)
|
||
if drug_code:
|
||
# 특정 약품
|
||
cursor.execute("""
|
||
SELECT
|
||
IM_DT_appl as date,
|
||
SUM(ISNULL(IM_QT_sale_debit, 0)) as inbound,
|
||
SUM(ISNULL(IM_QT_sale_credit, 0)) as outbound
|
||
FROM IM_date_total
|
||
WHERE IM_DT_appl >= ?
|
||
AND IM_DT_appl <= ?
|
||
AND DrugCode = ?
|
||
GROUP BY IM_DT_appl
|
||
ORDER BY IM_DT_appl
|
||
""", (start_date_fmt, end_date_fmt, drug_code))
|
||
else:
|
||
# 전체 합계
|
||
cursor.execute("""
|
||
SELECT
|
||
IM_DT_appl as date,
|
||
SUM(ISNULL(IM_QT_sale_debit, 0)) as inbound,
|
||
SUM(ISNULL(IM_QT_sale_credit, 0)) as outbound
|
||
FROM IM_date_total
|
||
WHERE IM_DT_appl >= ?
|
||
AND IM_DT_appl <= ?
|
||
GROUP BY IM_DT_appl
|
||
ORDER BY IM_DT_appl
|
||
""", (start_date_fmt, end_date_fmt))
|
||
|
||
rows = cursor.fetchall()
|
||
|
||
items = []
|
||
total_inbound = 0
|
||
total_outbound = 0
|
||
|
||
for row in rows:
|
||
date_str = str(row.date) if row.date else ''
|
||
# YYYYMMDD -> YYYY-MM-DD
|
||
formatted_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" if len(date_str) == 8 else date_str
|
||
|
||
inbound = int(row.inbound or 0)
|
||
outbound = int(row.outbound or 0)
|
||
|
||
items.append({
|
||
'date': formatted_date,
|
||
'inbound': inbound,
|
||
'outbound': outbound
|
||
})
|
||
|
||
total_inbound += inbound
|
||
total_outbound += outbound
|
||
|
||
conn.close()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'drug_code': drug_code if drug_code else 'ALL',
|
||
'items': items,
|
||
'stats': {
|
||
'total_inbound': total_inbound,
|
||
'total_outbound': total_outbound,
|
||
'net_change': total_inbound - total_outbound,
|
||
'data_count': len(items)
|
||
}
|
||
})
|
||
|
||
except Exception as e:
|
||
if conn:
|
||
conn.close()
|
||
logging.error(f"daily-trend API 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/stock-analytics/top-usage')
|
||
def api_stock_top_usage():
|
||
"""
|
||
기간별 사용량 TOP N API
|
||
GET /api/stock-analytics/top-usage?start_date=2026-01-01&end_date=2026-03-13&limit=10
|
||
"""
|
||
try:
|
||
start_date = request.args.get('start_date', '')
|
||
end_date = request.args.get('end_date', '')
|
||
limit = int(request.args.get('limit', '10'))
|
||
|
||
# 날짜 형식 변환
|
||
start_date_fmt = start_date.replace('-', '') if start_date else (datetime.now() - timedelta(days=30)).strftime('%Y%m%d')
|
||
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
|
||
|
||
drug_session = db_manager.get_session('PM_DRUG')
|
||
|
||
# 출고량 기준 TOP N (credit=출고, debit=입고)
|
||
query = text("""
|
||
SELECT TOP(:limit)
|
||
D.DrugCode as drug_code,
|
||
G.GoodsName as product_name,
|
||
G.SplName as supplier,
|
||
SUM(ISNULL(D.IM_QT_sale_credit, 0)) as total_outbound,
|
||
SUM(ISNULL(D.IM_QT_sale_debit, 0)) as total_inbound,
|
||
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock
|
||
FROM IM_date_total D
|
||
LEFT JOIN CD_GOODS G ON D.DrugCode = G.DrugCode
|
||
LEFT JOIN IM_total IT ON D.DrugCode = IT.DrugCode
|
||
WHERE D.IM_DT_appl >= :start_date
|
||
AND D.IM_DT_appl <= :end_date
|
||
GROUP BY D.DrugCode, G.GoodsName, G.SplName, IT.IM_QT_sale_debit
|
||
ORDER BY SUM(ISNULL(D.IM_QT_sale_credit, 0)) DESC
|
||
""")
|
||
|
||
rows = drug_session.execute(query, {
|
||
'start_date': start_date_fmt,
|
||
'end_date': end_date_fmt,
|
||
'limit': limit
|
||
}).fetchall()
|
||
|
||
items = []
|
||
for row in rows:
|
||
items.append({
|
||
'drug_code': row.drug_code or '',
|
||
'product_name': row.product_name or '알 수 없음',
|
||
'supplier': row.supplier or '',
|
||
'total_outbound': int(row.total_outbound or 0),
|
||
'total_inbound': int(row.total_inbound or 0),
|
||
'current_stock': int(row.current_stock or 0)
|
||
})
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'items': items,
|
||
'limit': limit
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"top-usage API 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/stock-analytics/stock-level')
|
||
def api_stock_level():
|
||
"""
|
||
특정 약품 재고 변화 (누적) API
|
||
GET /api/stock-analytics/stock-level?drug_code=A123456789&start_date=2026-01-01&end_date=2026-03-13
|
||
"""
|
||
conn = None
|
||
try:
|
||
drug_code = request.args.get('drug_code', '').strip()
|
||
start_date = request.args.get('start_date', '')
|
||
end_date = request.args.get('end_date', '')
|
||
|
||
if not drug_code:
|
||
return jsonify({'success': False, 'error': 'drug_code 파라미터 필요'}), 400
|
||
|
||
# 날짜 형식 변환
|
||
start_date_fmt = start_date.replace('-', '') if start_date else (datetime.now() - timedelta(days=30)).strftime('%Y%m%d')
|
||
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
|
||
|
||
# 새로운 pyodbc 연결 (동시 요청 충돌 방지)
|
||
import pyodbc
|
||
conn_str = (
|
||
'DRIVER={ODBC Driver 17 for SQL Server};'
|
||
'SERVER=192.168.0.4\\PM2014;'
|
||
'DATABASE=PM_DRUG;'
|
||
'UID=sa;'
|
||
'PWD=tmddls214!%(;'
|
||
'TrustServerCertificate=yes'
|
||
)
|
||
conn = pyodbc.connect(conn_str, timeout=10)
|
||
cursor = conn.cursor()
|
||
|
||
# 현재 재고
|
||
cursor.execute("""
|
||
SELECT
|
||
IT.IM_QT_sale_debit as current_stock,
|
||
G.GoodsName as product_name,
|
||
G.SplName as supplier
|
||
FROM IM_total IT
|
||
LEFT JOIN CD_GOODS G ON IT.DrugCode = G.DrugCode
|
||
WHERE IT.DrugCode = ?
|
||
""", (drug_code,))
|
||
|
||
stock_row = cursor.fetchone()
|
||
current_stock = int(stock_row.current_stock or 0) if stock_row else 0
|
||
product_name = stock_row.product_name if stock_row else drug_code
|
||
supplier = stock_row.supplier if stock_row else ''
|
||
|
||
# 일별 입출고 데이터 (역순으로 누적 계산)
|
||
# credit=출고, debit=입고 (약국 재고 기준)
|
||
cursor.execute("""
|
||
SELECT
|
||
IM_DT_appl as date,
|
||
SUM(ISNULL(IM_QT_sale_debit, 0)) as inbound,
|
||
SUM(ISNULL(IM_QT_sale_credit, 0)) as outbound
|
||
FROM IM_date_total
|
||
WHERE DrugCode = ?
|
||
AND IM_DT_appl >= ?
|
||
AND IM_DT_appl <= ?
|
||
GROUP BY IM_DT_appl
|
||
ORDER BY IM_DT_appl DESC
|
||
""", (drug_code, start_date_fmt, end_date_fmt))
|
||
|
||
rows = cursor.fetchall()
|
||
|
||
# 역순으로 누적 재고 계산 (현재 → 과거)
|
||
items = []
|
||
running_stock = current_stock
|
||
|
||
for row in rows:
|
||
date_str = str(row.date) if row.date else ''
|
||
formatted_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" if len(date_str) == 8 else date_str
|
||
|
||
inbound = int(row.inbound or 0)
|
||
outbound = int(row.outbound or 0)
|
||
|
||
items.append({
|
||
'date': formatted_date,
|
||
'stock': running_stock,
|
||
'inbound': inbound,
|
||
'outbound': outbound
|
||
})
|
||
|
||
# 과거로 갈수록: 재고 = 재고 - 입고 + 출고
|
||
running_stock = running_stock - inbound + outbound
|
||
|
||
# 시간순 정렬 (과거 → 현재)
|
||
items.reverse()
|
||
|
||
conn.close()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'drug_code': drug_code,
|
||
'product_name': product_name,
|
||
'supplier': supplier,
|
||
'current_stock': current_stock,
|
||
'items': items
|
||
})
|
||
|
||
except Exception as e:
|
||
if conn:
|
||
conn.close()
|
||
logging.error(f"stock-level API 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/stock-analytics/search-drugs')
|
||
def api_search_drugs():
|
||
"""
|
||
약품 검색 API (재고 분석용)
|
||
GET /api/stock-analytics/search-drugs?q=타이레놀&limit=20
|
||
"""
|
||
try:
|
||
query = request.args.get('q', '').strip()
|
||
limit = int(request.args.get('limit', '20'))
|
||
|
||
if not query or len(query) < 2:
|
||
return jsonify({'success': True, 'items': []})
|
||
|
||
drug_session = db_manager.get_session('PM_DRUG')
|
||
|
||
search_query = text("""
|
||
SELECT TOP(:limit)
|
||
G.DrugCode as drug_code,
|
||
G.GoodsName as product_name,
|
||
G.SplName as supplier,
|
||
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock
|
||
FROM CD_GOODS G
|
||
LEFT JOIN IM_total IT ON G.DrugCode = IT.DrugCode
|
||
WHERE G.GoodsName LIKE :keyword
|
||
OR G.DrugCode LIKE :keyword
|
||
ORDER BY
|
||
CASE WHEN G.GoodsName LIKE :exact_keyword THEN 0 ELSE 1 END,
|
||
IT.IM_QT_sale_debit DESC
|
||
""")
|
||
|
||
rows = drug_session.execute(search_query, {
|
||
'keyword': f'%{query}%',
|
||
'exact_keyword': f'{query}%',
|
||
'limit': limit
|
||
}).fetchall()
|
||
|
||
items = []
|
||
for row in rows:
|
||
items.append({
|
||
'drug_code': row.drug_code or '',
|
||
'product_name': row.product_name or '',
|
||
'supplier': row.supplier or '',
|
||
'current_stock': int(row.current_stock or 0)
|
||
})
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'items': items
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"search-drugs API 오류: {e}")
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
|
||
if __name__ == '__main__':
|
||
import os
|
||
|
||
PORT = 7001
|
||
|
||
# Flask reloader 자식 프로세스가 아닌 경우에만 체크
|
||
if os.environ.get('WERKZEUG_RUN_MAIN') != 'true':
|
||
if not check_port_available(PORT):
|
||
logging.warning(f"포트 {PORT}이 이미 사용 중입니다. 기존 프로세스를 종료합니다...")
|
||
if kill_process_on_port(PORT):
|
||
import time
|
||
time.sleep(2) # 프로세스 종료 대기
|
||
else:
|
||
logging.error(f"포트 {PORT} 해제 실패. 수동으로 확인하세요.")
|
||
|
||
# 프로덕션 모드로 실행
|
||
app.run(host='0.0.0.0', port=PORT, debug=False, threaded=True)
|