Compare commits

..

1 Commits

Author SHA1 Message Date
e499e19342 feat: 더미 POS GUI 및 영수증 프린터 설정 추가
- 바코드 스캔 → 제품 조회 → 장바구니 → 결제 흐름의 더미 POS GUI 추가
- ESC/POS 영수증 프린터 설정 다이얼로그 추가
- barcode_reader_gui.py dbsetup import 경로 수정
- POS 프린터 config.json 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:02:48 +09:00
151 changed files with 1080 additions and 38995 deletions

7
.gitignore vendored
View File

@@ -87,12 +87,5 @@ tmp/
*.tmp
.claude/
# Test/Debug scripts (일회성 분석용)
backend/scripts/check_*.py
backend/scripts/find_*.py
backend/scripts/search_*.py
backend/scripts/compare_*.py
backend/scripts/analyze_*.py
# GUI settings (user-specific)
gui_settings.json

View File

@@ -1,277 +0,0 @@
# 수인약품 API 리버스 엔지니어링 문서
## 개요
수인약품 웹 주문 시스템의 API 구조를 분석한 문서입니다.
지오영 API와 같은 하이브리드 방식 (Playwright 로그인 → requests 직접 호출)으로 구현합니다.
## 기본 정보
- **Base URL**: `http://sooinpharm.co.kr`
- **인코딩**: EUC-KR (한글 파라미터 인코딩 시 주의)
- **거래처 코드**: `50911` (청춘약국)
- **세션 관리**: 쿠키 기반 (ASP 세션)
---
## 1. 로그인
### 로그인 페이지
- **URL**: `/Homepage/intro.asp`
- **Method**: POST (JavaScript 함수 `chkLogin()` 호출)
### 필드
| 필드명 | 설명 | 예시 |
|--------|------|------|
| tx_id | 아이디 | thug0bin |
| tx_pw | 비밀번호 | @Trajet6640 |
### 인증 쿠키
로그인 성공 시 ASP 세션 쿠키가 발급됨:
- `ASPSESSIONID*` (세션 ID)
### 로그인 성공 확인
- 로그인 후 페이지에 "로그아웃" 링크 존재 여부로 확인
- 로그인 후 자동으로 `/Service/Order/Order.asp`로 리다이렉트
---
## 2. 제품 검색 API
### URL
```
GET /Service/Order/Order.asp
```
### 파라미터
| 파라미터 | 필수 | 설명 | 값 예시 |
|----------|------|------|---------|
| so | N | 제품분류 | 0=전체, 1=전문, 2=일반 |
| so2 | N | 주문분류 | 0=전체, 1=다빈도, 2=관심, 3=재주문 |
| so3 | N | 검색타입 | **1=제품명, 2=KD코드, 3=표준코드** |
| tx_maker | N | 제조사 | 한독 |
| tx_physic | N | 검색어 | 073100220 (KD코드) |
| tx_ven | Y | 거래처코드 | 50911 |
| currVenNm | Y | 약국명 | 청춘약국 (URL인코딩) |
| sDate | N | 시작일 | 20260306 |
| eDate | N | 종료일 | 20260306 |
| sa | N | 정렬 | phy=제품명순, ven=제조사순 |
| Page | N | 페이지번호 | 1 |
| tx_StockLoc | N | 재고위치 | '00001' |
| df | N | 기간필터 | t=3개월 |
### KD코드 검색 예시 URL
```
/Service/Order/Order.asp?so=0&so2=0&so3=2&tx_physic=073100220&tx_ven=50911&currVenNm=%EC%B2%AD%EC%B6%98%EC%95%BD%EA%B5%AD&sDate=20260306&eDate=20260306&df=t
```
### 응답 (HTML)
HTML 테이블 형식으로 반환. BeautifulSoup로 파싱 필요.
#### 테이블 구조
```html
<tr class="ln_physic">
<td>073100220</td> <!-- KD코드 -->
<td>한국오가논</td> <!-- 제조사 -->
<td>
<a href="./PhysicInfo.asp?pc=32495&...">
(오가논)코자정 50mg(PTP)
</a>
</td> <!-- 제품명 (pc=내부코드) -->
<td>30T</td> <!-- 규격 -->
<td>보험전문</td> <!-- 구분 -->
<td>14,220</td> <!-- 단가 -->
<td>238</td> <!-- 재고 -->
<td>
<input name="qty_0"> <!-- 수량입력 -->
<input type="hidden" name="pc_0" value="32495"> <!-- 내부코드 -->
<input type="hidden" name="stock_0" value="238">
<input type="hidden" name="price_0" value="14220">
</td>
</tr>
```
#### 핵심 필드 추출
- **KD코드**: 첫 번째 td
- **제조사**: 두 번째 td
- **제품명**: 세 번째 td의 a 태그 텍스트
- **내부코드(pc)**: a 태그 href에서 `pc=xxxxx` 추출
- **규격**: 네 번째 td
- **단가**: 여섯 번째 td (콤마 제거 후 int)
- **재고**: 일곱 번째 td
---
## 3. 장바구니 추가 API
### URL
```
POST /Service/Order/BagOrder.asp
```
### Content-Type
```
application/x-www-form-urlencoded
```
### 파라미터 (각 제품당)
| 파라미터 | 설명 | 예시 |
|----------|------|------|
| qty_N | 수량 | 1 |
| pc_N | 내부 제품코드 | 32495 |
| stock_N | 현재 재고 | 238 |
| saleqty_N | 판매수량 | 0 |
| price_N | 단가 | 14220 |
| soldout_N | 품절여부 | N |
| ordunitqty_N | 주문단위수량 | 1 |
| bidqty_N | 입찰수량 | 0 |
| outqty_N | 출고수량 | 0 |
| overqty_N | 초과수량 | 0 |
| manage_N | 관리여부 | N |
| prodno_N | 제품번호 | (빈값) |
| termdt_N | 종료일자 | (빈값) |
> N은 0부터 시작하는 행 인덱스
### 요청 예시
```
qty_0=1&pc_0=32495&stock_0=238&saleqty_0=0&price_0=14220&soldout_0=N&ordunitqty_0=1&bidqty_0=0&outqty_0=0&overqty_0=0&manage_0=N&prodno_0=&termdt_0=
```
### 응답
HTML (장바구니 iframe 내용)
---
## 4. 장바구니 비우기 API
### URL
```
GET /Service/Order/BagOrder.asp?kind=del&currVenCd=50911&currMkind=&currRealVenCd=
```
### 파라미터
| 파라미터 | 설명 | 값 |
|----------|------|-----|
| kind | 동작 | del |
| currVenCd | 거래처코드 | 50911 |
| currMkind | 종류 | (빈값) |
| currRealVenCd | 실제거래처코드 | (빈값) |
---
## 5. 장바구니 조회 API
### URL
```
GET /Service/Order/BagOrder.asp?currVenCd=50911
```
### 응답 (HTML)
```html
<table class="tbl_list">
<tr>
<td>건별취소</td>
<td>제품명</td>
<td>수량</td>
<td>금액</td>
</tr>
<tr>
<td><a href="...">X</a></td>
<td>(오가논)코자정 50mg(PTP)</td>
<td>1</td>
<td>14,220</td>
</tr>
</table>
<div>
<dt>주문품목</dt><dd>1개</dd>
<dt>주문금액</dt><dd>14,220원</dd>
</div>
```
---
## 6. 주문 전송 API
### URL (추정)
```
POST /Service/Order/BagOrder.asp
```
### 파라미터
| 파라미터 | 설명 |
|----------|------|
| kind | order (추정) |
| memo | 주문메모 |
| currVenCd | 거래처코드 |
> 실제 주문 전송은 iframe 내 버튼 클릭으로 수행됨
> 정확한 API 파라미터는 추가 분석 필요
---
## 7. 제품 상세 정보 API
### URL
```
GET /Service/Order/PhysicInfo.asp
```
### 파라미터
| 파라미터 | 설명 | 예시 |
|----------|------|------|
| pc | 내부제품코드 | 32495 |
| ln | 행번호 | 0 |
| currVenCd | 거래처코드 | 50911 |
| currLoc | 재고위치 | '00001' |
---
## 구현 전략
### 지오영 API 패턴 적용
1. **Playwright 로그인**
- 초기 로그인만 Playwright 사용
- 쿠키 획득 후 requests 세션에 복사
- 세션 30분 유효 (재로그인 필요 시 자동 갱신)
2. **requests 직접 호출**
- 검색: GET /Service/Order/Order.asp
- 장바구니 추가: POST /Service/Order/BagOrder.asp
- 장바구니 비우기: GET /Service/Order/BagOrder.asp?kind=del
- 장바구니 조회: GET /Service/Order/BagOrder.asp
3. **HTML 파싱**
- BeautifulSoup 사용
- 테이블 행에서 제품 정보 추출
- 내부코드(pc) 추출 (장바구니 추가용)
### 예상 성능
- 기존 Playwright: ~30초/주문
- requests 직접 호출: **~1초/주문**
---
## 주의사항
1. **EUC-KR 인코딩**
- 한글 파라미터는 EUC-KR로 인코딩
- `urllib.parse.quote(text.encode('euc-kr'))`
2. **세션 관리**
- ASP 세션 쿠키 유지 필수
- 장시간 미사용 시 세션 만료
3. **동시 접속**
- 동일 계정 동시 접속 시 세션 충돌 가능
4. **재고 실시간성**
- 검색 시점의 재고 정보
- 주문 전 재고 재확인 권장
---
## 작성일
- 2026-03-06
- 리버스 엔지니어링 by Claude

View File

@@ -1,86 +0,0 @@
# -*- coding: utf-8 -*-
"""백제약품 주문 원장 페이지 분석"""
import asyncio
import json
import os
from dotenv import load_dotenv
load_dotenv()
async def analyze_order_ledger():
from playwright.async_api import async_playwright
username = os.getenv('BAEKJE_USER_ID')
password = os.getenv('BAEKJE_PASSWORD')
print(f'Username: {username}')
async with async_playwright() as p:
browser = await p.chromium.launch(headless=False)
context = await browser.new_context()
page = await context.new_page()
# 로그인 페이지
await page.goto('https://ibjp.co.kr/dist/login', timeout=15000)
await page.wait_for_load_state('networkidle', timeout=10000)
# 로그인 폼 입력
inputs = await page.locator('input[type="text"], input[type="password"]').all()
if len(inputs) >= 2:
await inputs[0].fill(username)
await inputs[1].fill(password)
# 로그인 버튼 클릭
buttons = await page.locator('button').all()
for btn in buttons:
text = await btn.text_content()
if '로그인' in (text or ''):
await btn.click()
break
# 로그인 완료 대기
try:
await page.wait_for_url('**/comOrd**', timeout=15000)
print('Login successful, redirected to comOrd')
except Exception as e:
print(f'URL wait failed: {e}')
await asyncio.sleep(3)
print(f'Current URL: {page.url}')
# 주문 원장 페이지로 이동
await page.goto('https://ibjp.co.kr/dist/ordLedger', timeout=15000)
await page.wait_for_load_state('networkidle', timeout=15000)
print(f'Order Ledger URL: {page.url}')
# 페이지 HTML 저장
html = await page.content()
with open('ordLedger_page.html', 'w', encoding='utf-8') as f:
f.write(html)
print('Page HTML saved to ordLedger_page.html')
# 스크린샷 저장
await page.screenshot(path='ordLedger_screenshot.png', full_page=True)
print('Screenshot saved')
# 테이블 데이터 분석
tables = await page.locator('table').all()
print(f'Found {len(tables)} tables')
for i, table in enumerate(tables):
headers = await table.locator('th').all()
header_texts = [await h.text_content() for h in headers]
print(f'Table {i} headers: {header_texts}')
# 페이지 텍스트 출력 (분석용)
body_text = await page.locator('body').text_content()
print('\n=== Page Text Preview ===')
print(body_text[:3000] if body_text else 'No text')
await asyncio.sleep(30) # 페이지 확인 시간
await browser.close()
if __name__ == '__main__':
asyncio.run(analyze_order_ledger())

View File

@@ -1,77 +0,0 @@
# -*- coding: utf-8 -*-
"""지오영 API 엔드포인트 분석 - 간단 버전"""
import asyncio
from playwright.async_api import async_playwright
async def analyze():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 모든 요청 로깅
all_requests = []
def log_request(request):
all_requests.append({
'url': request.url,
'method': request.method,
'data': request.post_data
})
page.on('request', log_request)
# 로그인
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
# 메인 페이지 HTML 분석
await page.goto('https://gwn.geoweb.kr/Home/Index')
await page.wait_for_load_state('networkidle')
# JavaScript에서 API 엔드포인트 찾기
js_content = await page.content()
await browser.close()
# POST 요청만 필터
print("="*60)
print("POST 요청들:")
print("="*60)
for r in all_requests:
if r['method'] == 'POST':
print(f"URL: {r['url']}")
if r['data']:
print(f"Data: {r['data'][:300]}")
print()
# HTML에서 API 힌트 찾기
print("="*60)
print("HTML에서 발견된 API 관련 패턴:")
print("="*60)
import re
# ajax, fetch, url 패턴 찾기
patterns = [
r'url:\s*[\'"]([^"\']+)[\'"]',
r'action=[\'"]([^"\']+)[\'"]',
r'\.post\([\'"]([^"\']+)[\'"]',
r'\.get\([\'"]([^"\']+)[\'"]',
r'fetch\([\'"]([^"\']+)[\'"]',
]
found_urls = set()
for pattern in patterns:
matches = re.findall(pattern, js_content)
for m in matches:
if 'Order' in m or 'Cart' in m or 'Add' in m or 'Product' in m:
found_urls.add(m)
for url in sorted(found_urls):
print(url)
if __name__ == "__main__":
asyncio.run(analyze())

File diff suppressed because it is too large Load Diff

View File

@@ -1,305 +0,0 @@
# -*- coding: utf-8 -*-
"""
백제약품 도매상 API - Flask Blueprint
핵심 로직은 wholesale 패키지에서 가져옴
이 파일은 Flask 웹 API 연동만 담당
"""
import time
import logging
from flask import Blueprint, jsonify, request as flask_request
# wholesale 패키지 경로 설정
import wholesale_path
# wholesale 패키지에서 핵심 클래스 가져오기
from wholesale import BaekjeSession
logger = logging.getLogger(__name__)
# Blueprint 생성
baekje_bp = Blueprint('baekje', __name__, url_prefix='/api/baekje')
# ========== 세션 관리 ==========
_baekje_session = None
_init_started = False
def get_baekje_session():
global _baekje_session
if _baekje_session is None:
_baekje_session = BaekjeSession()
return _baekje_session
def init_baekje_session():
"""앱 시작 시 백그라운드에서 로그인 시작"""
global _init_started
if _init_started:
return
_init_started = True
session = get_baekje_session()
# 저장된 토큰이 있으면 즉시 사용 가능
if session._logged_in:
logger.info(f"백제약품: 저장된 토큰 사용 중")
return
# 백그라운드 로그인 시작
session.start_background_login()
logger.info(f"백제약품: 백그라운드 로그인 시작됨")
# 모듈 로드 시 자동 시작
try:
init_baekje_session()
except Exception as e:
logger.warning(f"백제약품 초기화 오류: {e}")
def search_baekje_stock(keyword: str):
"""백제약품 재고 검색"""
try:
session = get_baekje_session()
result = session.search_products(keyword)
if result.get('success'):
return {
'success': True,
'keyword': keyword,
'count': result['total'],
'items': result['items']
}
else:
return result
except Exception as e:
logger.error(f"백제약품 검색 오류: {e}")
return {'success': False, 'error': 'SEARCH_ERROR', 'message': str(e)}
# ========== Flask API Routes ==========
@baekje_bp.route('/stock', methods=['GET'])
def api_baekje_stock():
"""
백제약품 재고 조회 API
GET /api/baekje/stock?kd_code=672300240
GET /api/baekje/stock?keyword=타이레놀
"""
kd_code = flask_request.args.get('kd_code', '').strip()
keyword = flask_request.args.get('keyword', '').strip()
search_term = kd_code or keyword
if not search_term:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code 또는 keyword 파라미터가 필요합니다'
}), 400
try:
result = search_baekje_stock(search_term)
return jsonify(result)
except Exception as e:
logger.error(f"백제약품 API 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e)
}), 500
@baekje_bp.route('/session-status', methods=['GET'])
def api_session_status():
"""세션 상태 확인"""
session = get_baekje_session()
return jsonify({
'success': True,
'wholesaler': 'baekje',
'name': '백제약품',
'logged_in': session._logged_in,
'last_login': session._last_login,
'session_timeout': session.SESSION_TIMEOUT
})
@baekje_bp.route('/login', methods=['POST'])
def api_login():
"""수동 로그인"""
session = get_baekje_session()
success = session.login()
return jsonify({
'success': success,
'message': '로그인 성공' if success else '로그인 실패'
})
@baekje_bp.route('/cart', methods=['GET'])
def api_get_cart():
"""장바구니 조회"""
session = get_baekje_session()
result = session.get_cart()
return jsonify(result)
@baekje_bp.route('/cart', methods=['POST'])
def api_add_to_cart():
"""
장바구니 추가
POST /api/baekje/cart
{
"product_code": "672300240",
"quantity": 2
}
"""
data = flask_request.get_json() or {}
product_code = data.get('product_code', '').strip()
quantity = int(data.get('quantity', 1))
if not product_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'product_code 필요'
}), 400
session = get_baekje_session()
result = session.add_to_cart(product_code, quantity)
return jsonify(result)
@baekje_bp.route('/order', methods=['POST'])
def api_submit_order():
"""
주문 등록
POST /api/baekje/order
{
"memo": "긴급 요청"
}
"""
data = flask_request.get_json() or {}
memo = data.get('memo', '')
session = get_baekje_session()
result = session.submit_order(memo)
return jsonify(result)
# ========== 프론트엔드 통합용 ==========
@baekje_bp.route('/search-for-order', methods=['POST'])
def api_search_for_order():
"""
발주용 재고 검색 (프론트엔드 통합용)
POST /api/baekje/search-for-order
{
"kd_code": "672300240",
"product_name": "타이레놀",
"specification": "500T"
}
"""
data = flask_request.get_json() or {}
kd_code = data.get('kd_code', '').strip()
product_name = data.get('product_name', '').strip()
specification = data.get('specification', '').strip()
search_term = kd_code or product_name
if not search_term:
return jsonify({
'success': False,
'error': 'MISSING_PARAM'
}), 400
result = search_baekje_stock(search_term)
if result.get('success') and specification:
# 규격 필터링
filtered = [
item for item in result.get('items', [])
if specification.lower() in item.get('spec', '').lower()
]
result['items'] = filtered
result['count'] = len(filtered)
return jsonify(result)
# ========== 잔고 조회 ==========
@baekje_bp.route('/balance', methods=['GET'])
def api_get_balance():
"""
잔고액 조회
GET /api/baekje/balance
GET /api/baekje/balance?year=2026
Returns:
{
"success": true,
"balance": 14193234,
"monthly": [
{"month": "2026-03", "sales": 6935133, "balance": 14193234, ...},
{"month": "2026-02", "sales": 18600692, "balance": 7258101, ...}
]
}
"""
year = flask_request.args.get('year', '').strip()
session = get_baekje_session()
result = session.get_balance(year if year else None)
return jsonify(result)
@baekje_bp.route('/monthly-sales', methods=['GET'])
def api_get_monthly_sales():
"""
월간 매출(주문) 합계 조회
GET /api/baekje/monthly-sales?year=2026&month=3
Returns:
{
"success": true,
"total_amount": 7305877, // 월간 매출 합계
"total_returns": 0, // 월간 반품 합계
"net_amount": 7305877, // 순매출 (매출 - 반품)
"total_paid": 0, // 월간 입금 합계
"ending_balance": 14563978, // 월말 잔액
"prev_balance": 14565453, // 전월이월금
"from_date": "2026-03-01",
"to_date": "2026-03-31",
"rotate_days": 58.4 // 회전일수
}
"""
from datetime import datetime
year = flask_request.args.get('year', '').strip()
month = flask_request.args.get('month', '').strip()
# 기본값: 현재 연월
now = datetime.now()
if not year:
year = now.year
else:
year = int(year)
if not month:
month = now.month
else:
month = int(month)
session = get_baekje_session()
result = session.get_monthly_sales(year, month)
return jsonify(result)

View File

@@ -1,206 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ko">
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge"/>
<link rel="stylesheet" href="http://sooinpharm.co.kr/Common/Css/Reset.css" type="text/css" media="screen" />
<link rel="stylesheet" href="http://sooinpharm.co.kr/Common/Css/Bag.css?v=260116" type="text/css" media="screen" />
<link rel="stylesheet" href="http://sooinpharm.co.kr/Common/Css/jquery-ui.css" type="text/css" />
<title>수인약품(주) :: 장바구니</title>
</head>
<body oncontextmenu="return false" >
<input type="hidden" id="domainNm" name="domainNm" value="sooinpharm.co.kr" />
<input type="hidden" id="aggqty" value="N">
<input type='hidden' id='hardcoding' value='sooinpharm'>
<input type='hidden' id='bidding' value=''>
<input type='hidden' id='gumaeKind' value='U'>
<input type="hidden" id="min_order_qty" value=""><!--최소주문수량-->
<input type="hidden" id="BigWideFlag" value="">
<input type="hidden" id="DayOrdAmt" value="0">
<input type='hidden' id='baekjestockcd' value=''>
<div id="msg_order" style="margin: 0px 0 2px 0px;padding: 0px 7px 2px 0;background-position-y: 50%;position:relative;">
<div style="padding-left: 19px;line-height:13px;min-height:37px;display:table;"><span style="display: table-cell;text-align: left;vertical-align: middle;">17시 이후 주문은 다음근무일로 주문됩니다</span></div>
</div>
<h1 id="bag_title">장바구니</h1>
<div id="bag"
style='height:518px;'
>
<form name="frmBag" id="frmBag" method="post" action="./OrderEnd.asp" autocomplete=off>
<fieldset class="info">
<legend>주문관련 버튼 및 메모</legend>
<ul class="btn">
<li><a href="./BagOrder.asp?kind=del&amp;currVenCd=50911&amp;currMkind=&amp;currRealVenCd=" title="장바구니 비우기" id="btn_cancel_order">장바구니 비우기</a></li>
<input type="hidden" name="hostuser" id="hostuser" />
<li><input type="image" src="http://sooinpharm.co.kr/Images/Btn/btn_order_v2.gif" alt="주문전송" title="주문전송" /></li>
<input type='hidden' id='btnState' value='true'>
</ul>
<p class="memo">
<label for="tx_memo" >주문전송 메모</label><input type="text" name="tx_memo" id="tx_memo" maxlength="150" class="setInput_h20" title="메모" value="" />
</p>
<input type="hidden" name="pDate" id="pDate" value=""/>
</fieldset>
<fieldset class="list">
<legend>장바구니</legend>
<table class="bag_list" summary="스크롤링을 위해 고정시킬 테이블 제목">
<caption>장바구니 리스트</caption>
<colgroup>
<col width="30" />
<col width="*" />
<col width="35" />
<col width="77" />
</colgroup>
<thead>
<tr>
<th scope="col" class="title1 first">건별취소</th>
<th scope="col" class="title2">제품명</th>
<th scope="col" class="title3">수량</th>
<th scope="col" class="title4">금액</th>
</tr>
</thead>
</table>
<div id="bag_view"
style='height:375px;'
> <!--닫는 태그-->
<div class="wrap_table" style="height:375px;overflow-y:scroll;overflow-x:hidden;"><!--scroll div-->
<table class="bag_list">
<caption>장바구니 리스트</caption>
<colgroup>
<col width="30" />
<col width="*" />
<col width="35" />
<col width="60" />
</colgroup>
<thead style="display:none;">
<tr>
<th scope="col" class="title1 first">건별취소</th>
<th scope="col" class="title2">제품명</th>
<th scope="col" class="title3">수량</th>
<th scope="col" class="title4">금액</th>
</tr>
</thead>
<tbody>
<tr id="bagLine0">

View File

@@ -1,79 +0,0 @@
# -*- coding: utf-8 -*-
"""지오영 API 엔드포인트 분석"""
import asyncio
from playwright.async_api import async_playwright
async def capture_network():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 네트워크 요청 캡처
requests_log = []
def log_request(request):
if 'geoweb' in request.url:
requests_log.append({
'url': request.url,
'method': request.method,
'post_data': request.post_data
})
page.on('request', log_request)
# 로그인
print("로그인 중...")
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
print("로그인 완료")
# 메인 페이지
await page.goto('https://gwn.geoweb.kr/Home/Index')
await page.wait_for_load_state('networkidle')
# 검색
print("검색 중...")
search_input = await page.query_selector('input#srchText, input[name="srchText"]')
if search_input:
await search_input.fill('643104281')
# 검색 버튼
search_btn = await page.query_selector('button:has-text("검색"), input[type="submit"]')
if search_btn:
await search_btn.click()
else:
await page.keyboard.press('Enter')
await page.wait_for_timeout(3000)
# 제품 행 클릭
print("제품 선택 중...")
rows = await page.query_selector_all('table tbody tr')
if rows:
await rows[0].click()
await page.wait_for_timeout(2000)
# 담기 버튼
print("담기 버튼 클릭...")
add_btn = await page.query_selector('button:has-text("담기")')
if add_btn:
await add_btn.click()
await page.wait_for_timeout(3000)
await browser.close()
print("\n" + "="*60)
print("캡처된 요청들:")
print("="*60)
for r in requests_log:
if r['method'] == 'POST' or 'cart' in r['url'].lower() or 'order' in r['url'].lower():
print(f"\n[{r['method']}] {r['url']}")
if r['post_data']:
print(f" Data: {r['post_data'][:200]}")
if __name__ == "__main__":
asyncio.run(capture_network())

View File

@@ -1,11 +0,0 @@
import sqlite3
conn = sqlite3.connect('db/orders.db')
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = [r[0] for r in cursor.fetchall()]
print('Tables:', tables)
for t in tables:
cursor.execute(f"PRAGMA table_info({t})")
cols = [r[1] for r in cursor.fetchall()]
print(f" {t}: {cols}")
conn.close()

View File

@@ -1,13 +0,0 @@
# -*- coding: utf-8 -*-
import sqlite3
conn = sqlite3.connect('db/orders.db')
# 테이블 목록
tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
print('=== orders.db 테이블 ===')
for t in tables:
count = conn.execute(f'SELECT COUNT(*) FROM {t[0]}').fetchone()[0]
print(f' {t[0]}: {count}개 레코드')
conn.close()

View File

@@ -1,28 +0,0 @@
# -*- coding: utf-8 -*-
import sqlite3
conn = sqlite3.connect('db/paai_logs.db')
# 테이블 목록
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = cursor.fetchall()
print('테이블 목록:', [t[0] for t in tables])
# 로그 개수
count = conn.execute('SELECT COUNT(*) FROM paai_logs').fetchone()[0]
print(f'PAAI 로그 수: {count}')
# 최근 로그
print('\n최근 로그 3개:')
recent = conn.execute('SELECT id, created_at, patient_name, status FROM paai_logs ORDER BY id DESC LIMIT 3').fetchall()
for r in recent:
print(f' #{r[0]} | {r[1]} | {r[2]} | {r[3]}')
# 피드백 통계
feedback = conn.execute('SELECT feedback_useful, COUNT(*) FROM paai_logs GROUP BY feedback_useful').fetchall()
print('\n피드백 통계:')
for f in feedback:
label = '유용' if f[0] == 1 else ('아님' if f[0] == 0 else '미응답')
print(f' {label}: {f[1]}')
conn.close()

View File

@@ -1,23 +0,0 @@
import sqlite3
conn = sqlite3.connect('db/mileage.db')
c = conn.cursor()
# 테이블 구조
c.execute("SELECT sql FROM sqlite_master WHERE name='pets'")
print("=== PETS TABLE SCHEMA ===")
print(c.fetchone())
# 샘플 데이터
c.execute("SELECT * FROM pets LIMIT 5")
print("\n=== SAMPLE DATA ===")
for row in c.fetchall():
print(row)
# 컬럼명
c.execute("PRAGMA table_info(pets)")
print("\n=== COLUMNS ===")
for col in c.fetchall():
print(col)
conn.close()

7
backend/config.json Normal file
View File

@@ -0,0 +1,7 @@
{
"pos_printer": {
"ip": "192.168.0.174",
"port": 9100,
"name": "메인 POS"
}
}

View File

@@ -1,54 +0,0 @@
# -*- coding: utf-8 -*-
"""
도매상 설정 중앙 관리
사용법:
from config import get_wholesalers, get_wholesaler
# 전체 도매상 목록
wholesalers = get_wholesalers()
# 특정 도매상 정보
geo = get_wholesaler('geoyoung')
print(geo['name']) # 지오영
print(geo['logo']) # /static/img/logo_geoyoung.ico
"""
import json
from pathlib import Path
_config = None
_config_path = Path(__file__).parent / 'wholesalers.json'
def _load_config():
global _config
if _config is None:
with open(_config_path, 'r', encoding='utf-8') as f:
_config = json.load(f)
return _config
def get_wholesalers():
"""전체 도매상 목록 반환 (순서대로)"""
config = _load_config()
order = config.get('order', [])
wholesalers = config.get('wholesalers', {})
return [wholesalers[key] for key in order if key in wholesalers]
def get_wholesaler(wholesaler_id: str):
"""특정 도매상 정보 반환"""
config = _load_config()
return config.get('wholesalers', {}).get(wholesaler_id)
def get_all_wholesalers_dict():
"""전체 도매상 딕셔너리 반환"""
config = _load_config()
return config.get('wholesalers', {})
def get_config():
"""전체 설정 반환"""
return _load_config()

View File

@@ -1,65 +0,0 @@
{
"wholesalers": {
"geoyoung": {
"id": "geoyoung",
"name": "지오영",
"shortName": "지오영",
"icon": "🏭",
"logo": "/static/img/logo_geoyoung.ico",
"color": "#06b6d4",
"gradient": "linear-gradient(135deg, #0891b2, #06b6d4)",
"bgColor": "rgba(6, 182, 212, 0.1)",
"api": {
"balance": "/api/geoyoung/balance",
"stock": "/api/geoyoung/stock",
"order": "/api/geoyoung/order"
},
"env": {
"userId": "GEOYOUNG_USER_ID",
"password": "GEOYOUNG_PASSWORD"
}
},
"sooin": {
"id": "sooin",
"name": "수인약품",
"shortName": "수인",
"icon": "💊",
"logo": "/static/img/logo_sooin.svg",
"color": "#a855f7",
"gradient": "linear-gradient(135deg, #7c3aed, #a855f7)",
"bgColor": "rgba(168, 85, 247, 0.1)",
"api": {
"balance": "/api/sooin/balance",
"stock": "/api/sooin/stock",
"order": "/api/sooin/order"
},
"env": {
"userId": "SOOIN_USER_ID",
"password": "SOOIN_PASSWORD",
"vendorCode": "SOOIN_VENDOR_CODE"
}
},
"baekje": {
"id": "baekje",
"name": "백제약품",
"shortName": "백제",
"icon": "💉",
"logo": "/static/img/logo_baekje.svg",
"color": "#f59e0b",
"gradient": "linear-gradient(135deg, #d97706, #f59e0b)",
"bgColor": "rgba(245, 158, 11, 0.1)",
"api": {
"balance": "/api/baekje/balance",
"stock": "/api/baekje/stock",
"order": "/api/baekje/order"
},
"env": {
"userId": "BAEKJE_USER_ID",
"password": "BAEKJE_PASSWORD"
}
}
},
"order": ["baekje", "geoyoung", "sooin"],
"version": "1.0.0",
"lastUpdated": "2026-03-06"
}

View File

@@ -154,46 +154,11 @@ class DatabaseManager:
return self.engines[database]
def get_session(self, database='PM_BASE'):
"""특정 데이터베이스 세션 반환 (자동 복구 포함)"""
"""특정 데이터베이스 세션 반환"""
if database not in self.sessions:
engine = self.get_engine(database)
Session = sessionmaker(bind=engine)
self.sessions[database] = Session()
else:
# 🔥 기존 세션 상태 체크 및 자동 복구
session = self.sessions[database]
try:
# 세션이 유효한지 간단한 쿼리로 테스트
session.execute(text("SELECT 1"))
except Exception as e:
error_msg = str(e).lower()
# 연결 끊김 또는 트랜잭션 에러 감지
if any(keyword in error_msg for keyword in [
'invalid transaction', 'rollback', 'connection',
'closed', 'lost', 'timeout', 'network', 'disconnect'
]):
print(f"[DB Manager] {database} 세션 복구 시도: {e}")
try:
session.rollback()
print(f"[DB Manager] {database} 롤백 성공, 세션 재사용")
except Exception as rollback_err:
print(f"[DB Manager] {database} 롤백 실패, 세션 재생성: {rollback_err}")
try:
session.close()
except:
pass
del self.sessions[database]
# 새 세션 생성
engine = self.get_engine(database)
Session = sessionmaker(bind=engine)
self.sessions[database] = Session()
print(f"[DB Manager] {database} 새 세션 생성 완료")
else:
# 다른 종류의 에러면 롤백만 시도
try:
session.rollback()
except:
pass
return self.sessions[database]
def rollback_session(self, database='PM_BASE'):
@@ -272,13 +237,7 @@ class DatabaseManager:
self.init_sqlite_schema()
self.sqlite_conn = old_conn
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
else:
# 기존 DB: 마이그레이션 실행
old_conn = self.sqlite_conn
self.sqlite_conn = conn
self._migrate_sqlite()
self.sqlite_conn = old_conn
return conn
def init_sqlite_schema(self):
@@ -360,67 +319,6 @@ class DatabaseManager:
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: ai_recommendations 테이블 생성")
# customer_identities 토큰 저장 컬럼 추가
cursor.execute("PRAGMA table_info(customer_identities)")
ci_columns = [row[1] for row in cursor.fetchall()]
if 'access_token' not in ci_columns:
cursor.execute("ALTER TABLE customer_identities ADD COLUMN access_token TEXT")
cursor.execute("ALTER TABLE customer_identities ADD COLUMN refresh_token TEXT")
cursor.execute("ALTER TABLE customer_identities ADD COLUMN token_expires_at DATETIME")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: customer_identities 토큰 컬럼 추가")
# pets 테이블 생성 (반려동물)
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='pets'")
if not cursor.fetchone():
cursor.executescript("""
CREATE TABLE IF NOT EXISTS pets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL,
species VARCHAR(20) NOT NULL,
breed VARCHAR(50),
gender VARCHAR(10),
birth_date DATE,
age_months INTEGER,
weight DECIMAL(5,2),
photo_url TEXT,
notes TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id);
CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species);
""")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: pets 테이블 생성")
# otc_label_presets 테이블 생성 (OTC 용법 라벨)
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='otc_label_presets'")
if not cursor.fetchone():
cursor.executescript("""
CREATE TABLE IF NOT EXISTS otc_label_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
barcode VARCHAR(20) NOT NULL UNIQUE,
drug_code VARCHAR(20),
display_name VARCHAR(100),
effect VARCHAR(100),
dosage_instruction TEXT,
usage_tip TEXT,
use_wide_format BOOLEAN DEFAULT TRUE,
print_count INTEGER DEFAULT 0,
last_printed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_otc_label_barcode ON otc_label_presets(barcode);
CREATE INDEX IF NOT EXISTS idx_otc_label_drug_code ON otc_label_presets(drug_code);
""")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: otc_label_presets 테이블 생성")
def test_connection(self, database='PM_BASE'):
"""연결 테스트"""
try:

View File

@@ -1,220 +0,0 @@
"""
KIMS API 로깅 모듈
- API 호출/응답 SQLite 저장
- AI 학습용 데이터 수집
"""
import sqlite3
import json
import os
from datetime import datetime
from pathlib import Path
# DB 파일 경로
DB_PATH = Path(__file__).parent / 'kims_logs.db'
def init_db():
"""DB 초기화 (테이블 생성)"""
schema_path = Path(__file__).parent / 'kims_logs_schema.sql'
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
with open(schema_path, 'r', encoding='utf-8') as f:
schema = f.read()
cursor.executescript(schema)
conn.commit()
conn.close()
print(f"KIMS 로그 DB 초기화 완료: {DB_PATH}")
def log_kims_call(
pre_serial: str = None,
user_id: int = None,
source: str = 'admin',
drug_codes: list = None,
drug_names: list = None,
api_status: str = 'SUCCESS',
http_status: int = 200,
response_time_ms: int = 0,
interactions: list = None,
response_raw: dict = None,
error_message: str = None
) -> int:
"""
KIMS API 호출 로그 저장
Returns:
log_id: 생성된 로그 ID
"""
# DB 없으면 초기화
if not DB_PATH.exists():
init_db()
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
interactions = interactions or []
drug_codes = drug_codes or []
drug_names = drug_names or []
# 심각한 상호작용 여부 (severity 1 또는 2)
has_severe = any(
str(i.get('severity', '5')) in ['1', '2']
for i in interactions
)
# 메인 로그 삽입
cursor.execute("""
INSERT INTO kims_api_logs (
pre_serial, user_id, source,
request_drug_codes, request_drug_names, request_drug_count,
api_status, http_status, response_time_ms,
interaction_count, has_severe_interaction,
interactions_json, response_raw, error_message
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
pre_serial,
user_id,
source,
json.dumps(drug_codes, ensure_ascii=False),
json.dumps(drug_names, ensure_ascii=False),
len(drug_codes),
api_status,
http_status,
response_time_ms,
len(interactions),
1 if has_severe else 0,
json.dumps(interactions, ensure_ascii=False),
json.dumps(response_raw, ensure_ascii=False) if response_raw else None,
error_message
))
log_id = cursor.lastrowid
# 상호작용 상세 삽입 (정규화)
for inter in interactions:
cursor.execute("""
INSERT INTO kims_interactions (
log_id,
drug1_code, drug1_name, drug1_generic,
drug2_code, drug2_name, drug2_generic,
severity_level, severity_desc,
likelihood_level, likelihood_desc,
observation, observation_generic,
clinical_management, action_to_take, reference
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
log_id,
inter.get('drug1_code'),
inter.get('drug1_name'),
inter.get('generic1'),
inter.get('drug2_code'),
inter.get('drug2_name'),
inter.get('generic2'),
int(inter.get('severity', 5)) if str(inter.get('severity', '')).isdigit() else None,
inter.get('severity_text'),
None, # likelihood_level
inter.get('likelihood'),
inter.get('description'),
None, # observation_generic
inter.get('management'),
inter.get('action'),
None # reference
))
conn.commit()
conn.close()
return log_id
def get_recent_logs(limit: int = 50):
"""최근 로그 조회"""
if not DB_PATH.exists():
return []
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM kims_api_logs
ORDER BY created_at DESC
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
def get_log_detail(log_id: int):
"""로그 상세 조회 (상호작용 포함)"""
if not DB_PATH.exists():
return None
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 메인 로그
cursor.execute("SELECT * FROM kims_api_logs WHERE id = ?", (log_id,))
log = cursor.fetchone()
if not log:
conn.close()
return None
# 상호작용 상세
cursor.execute("""
SELECT * FROM kims_interactions
WHERE log_id = ?
ORDER BY severity_level ASC
""", (log_id,))
interactions = cursor.fetchall()
conn.close()
result = dict(log)
result['interactions_detail'] = [dict(i) for i in interactions]
return result
def get_stats():
"""통계 조회"""
if not DB_PATH.exists():
return {}
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 전체 통계
cursor.execute("""
SELECT
COUNT(*) as total_calls,
SUM(CASE WHEN api_status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
SUM(CASE WHEN interaction_count > 0 THEN 1 ELSE 0 END) as with_interaction,
SUM(CASE WHEN has_severe_interaction = 1 THEN 1 ELSE 0 END) as with_severe,
AVG(response_time_ms) as avg_response_ms
FROM kims_api_logs
""")
stats = dict(cursor.fetchone())
# 최근 7일 일별 통계
cursor.execute("""
SELECT * FROM kims_stats
ORDER BY date DESC
LIMIT 7
""")
daily = [dict(row) for row in cursor.fetchall()]
conn.close()
stats['daily'] = daily
return stats
if __name__ == '__main__':
# DB 초기화 테스트
init_db()
print("KIMS 로그 DB 초기화 완료!")

View File

@@ -1,86 +0,0 @@
-- KIMS API 로그 테이블 스키마
-- AI 학습 데이터로 활용 예정
-- 1. API 호출 로그 (메인)
CREATE TABLE IF NOT EXISTS kims_api_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 호출 컨텍스트
pre_serial TEXT, -- 처방번호
user_id INTEGER, -- 마일리지 회원 ID (있으면)
source TEXT DEFAULT 'admin', -- 호출 소스 (admin, api, batch 등)
-- 요청 데이터
request_drug_codes TEXT NOT NULL, -- JSON: ["055101150", "622801610"]
request_drug_names TEXT, -- JSON: ["오메프투캡슐", "락소펜엠정"]
request_drug_count INTEGER, -- 요청 약품 수
-- 응답 데이터
api_status TEXT NOT NULL, -- SUCCESS, ERROR, TIMEOUT
http_status INTEGER, -- HTTP 상태 코드
response_time_ms INTEGER, -- 응답 시간 (밀리초)
-- 상호작용 결과
interaction_count INTEGER DEFAULT 0, -- 발견된 상호작용 수
has_severe_interaction INTEGER DEFAULT 0, -- 심각한 상호작용 여부 (1/2 등급)
-- 상세 데이터 (JSON)
interactions_json TEXT, -- 상호작용 상세 정보 JSON
response_raw TEXT, -- 전체 API 응답 (디버깅/학습용)
-- 에러 정보
error_message TEXT
);
-- 2. 상호작용 상세 (정규화, AI 학습용)
CREATE TABLE IF NOT EXISTS kims_interactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
log_id INTEGER NOT NULL, -- kims_api_logs.id FK
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 약품 1
drug1_code TEXT NOT NULL,
drug1_name TEXT,
drug1_generic TEXT, -- 성분명 (영문)
-- 약품 2
drug2_code TEXT NOT NULL,
drug2_name TEXT,
drug2_generic TEXT, -- 성분명 (영문)
-- 상호작용 정보
severity_level INTEGER, -- 1=심각, 2=중등도, 3=경미, 4=참고
severity_desc TEXT, -- 심각도 설명 (중증, 경미 등)
likelihood_level INTEGER, -- 발생 가능성
likelihood_desc TEXT,
-- 상세 설명 (AI 학습 핵심 데이터)
observation TEXT, -- 상호작용 설명 (한글)
observation_generic TEXT, -- 일반적 설명
clinical_management TEXT, -- 임상적 관리 방법
action_to_take TEXT, -- 권장 조치
reference TEXT, -- 참고문헌
FOREIGN KEY (log_id) REFERENCES kims_api_logs(id)
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_kims_logs_created ON kims_api_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_kims_logs_pre_serial ON kims_api_logs(pre_serial);
CREATE INDEX IF NOT EXISTS idx_kims_logs_status ON kims_api_logs(api_status);
CREATE INDEX IF NOT EXISTS idx_kims_interactions_log ON kims_interactions(log_id);
CREATE INDEX IF NOT EXISTS idx_kims_interactions_drugs ON kims_interactions(drug1_code, drug2_code);
CREATE INDEX IF NOT EXISTS idx_kims_interactions_severity ON kims_interactions(severity_level);
-- 통계 뷰
CREATE VIEW IF NOT EXISTS kims_stats AS
SELECT
DATE(created_at) as date,
COUNT(*) as total_calls,
SUM(CASE WHEN api_status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
SUM(CASE WHEN interaction_count > 0 THEN 1 ELSE 0 END) as with_interaction,
SUM(CASE WHEN has_severe_interaction = 1 THEN 1 ELSE 0 END) as with_severe,
AVG(response_time_ms) as avg_response_ms
FROM kims_api_logs
GROUP BY DATE(created_at);

View File

@@ -22,9 +22,6 @@ CREATE TABLE IF NOT EXISTS customer_identities (
provider VARCHAR(20) NOT NULL,
provider_user_id VARCHAR(100) NOT NULL,
provider_data TEXT,
access_token TEXT,
refresh_token TEXT,
token_expires_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(provider, provider_user_id)
@@ -123,44 +120,3 @@ CREATE TABLE IF NOT EXISTS ai_recommendations (
CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status);
CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at);
-- 8. 반려동물 테이블
CREATE TABLE IF NOT EXISTS pets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL, -- 이름 (예: 뽀삐, 나비)
species VARCHAR(20) NOT NULL, -- 종류: dog, cat, other
breed VARCHAR(50), -- 품종 (말티즈, 페르시안 등)
gender VARCHAR(10), -- male, female, unknown
birth_date DATE, -- 생년월일 (나중에 사용)
age_months INTEGER, -- 월령 (나중에 사용)
weight DECIMAL(5,2), -- 체중 kg (나중에 사용)
photo_url TEXT, -- 사진 URL
notes TEXT, -- 특이사항/메모
is_active BOOLEAN DEFAULT TRUE, -- 활성 상태
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id);
CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species);
-- 9. OTC 용법 라벨 테이블 (바코드 기준 오버라이드 데이터)
CREATE TABLE IF NOT EXISTS otc_label_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
barcode VARCHAR(20) NOT NULL UNIQUE, -- 바코드 (PK 역할)
drug_code VARCHAR(20), -- MSSQL DrugCode (참조용)
display_name VARCHAR(100), -- 표시 이름 (오버라이드, NULL이면 MSSQL 이름 사용)
effect VARCHAR(100), -- 효능 (예: "치통, 두통")
dosage_instruction TEXT, -- 용법 (예: "1일 3회, 1회 1정, 식후 30분")
usage_tip TEXT, -- 부가 설명 (예: "[통증 시에만 복용]")
use_wide_format BOOLEAN DEFAULT TRUE, -- 와이드 포맷 사용 여부
print_count INTEGER DEFAULT 0, -- 인쇄 횟수 (통계용)
last_printed_at DATETIME, -- 마지막 인쇄 시간
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_otc_label_barcode ON otc_label_presets(barcode);
CREATE INDEX IF NOT EXISTS idx_otc_label_drug_code ON otc_label_presets(drug_code);

View File

@@ -1,351 +0,0 @@
"""
PAAI (Pharmacist Assistant AI) 로깅 모듈
- API 호출/응답 SQLite 저장
- 분석 결과 및 피드백 관리
"""
import sqlite3
import json
import os
from datetime import datetime, timedelta
from pathlib import Path
# DB 파일 경로
DB_PATH = Path(__file__).parent / 'paai_logs.db'
def init_db():
"""DB 초기화 (테이블 생성)"""
schema_path = Path(__file__).parent / 'paai_logs_schema.sql'
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
with open(schema_path, 'r', encoding='utf-8') as f:
schema = f.read()
cursor.executescript(schema)
conn.commit()
conn.close()
print(f"PAAI 로그 DB 초기화 완료: {DB_PATH}")
def create_log(
pre_serial: str = None,
patient_code: str = None,
patient_name: str = None,
disease_code_1: str = None,
disease_name_1: str = None,
disease_code_2: str = None,
disease_name_2: str = None,
current_medications: list = None,
previous_serial: str = None,
previous_medications: list = None,
prescription_changes: dict = None,
otc_history: dict = None
) -> int:
"""
PAAI 분석 로그 생성 (초기 상태)
Returns:
log_id: 생성된 로그 ID
"""
if not DB_PATH.exists():
init_db()
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
current_medications = current_medications or []
previous_medications = previous_medications or []
otc_history = otc_history or {}
# 환자명 마스킹
masked_name = None
if patient_name:
masked_name = patient_name[0] + '*' * (len(patient_name) - 1) if len(patient_name) > 1 else patient_name
cursor.execute("""
INSERT INTO paai_logs (
pre_serial, patient_code, patient_name,
disease_code_1, disease_name_1, disease_code_2, disease_name_2,
current_medications, current_med_count,
previous_serial, previous_medications, prescription_changes,
otc_history, otc_visit_count,
status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
""", (
pre_serial,
patient_code,
masked_name,
disease_code_1,
disease_name_1,
disease_code_2,
disease_name_2,
json.dumps(current_medications, ensure_ascii=False),
len(current_medications),
previous_serial,
json.dumps(previous_medications, ensure_ascii=False),
json.dumps(prescription_changes, ensure_ascii=False) if prescription_changes else None,
json.dumps(otc_history, ensure_ascii=False),
otc_history.get('visit_count', 0)
))
log_id = cursor.lastrowid
conn.commit()
conn.close()
return log_id
def update_kims_result(
log_id: int,
kims_drug_codes: list = None,
kims_interactions: list = None,
kims_response_time_ms: int = 0
):
"""KIMS 상호작용 결과 업데이트"""
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
kims_drug_codes = kims_drug_codes or []
kims_interactions = kims_interactions or []
# 심각한 상호작용 여부 (severity 1 또는 2)
has_severe = any(
str(i.get('severity', '5')) in ['1', '2']
for i in kims_interactions
)
cursor.execute("""
UPDATE paai_logs SET
kims_drug_codes = ?,
kims_drug_count = ?,
kims_interactions = ?,
kims_interaction_count = ?,
kims_has_severe = ?,
kims_response_time_ms = ?,
status = 'kims_done'
WHERE id = ?
""", (
json.dumps(kims_drug_codes, ensure_ascii=False),
len(kims_drug_codes),
json.dumps(kims_interactions, ensure_ascii=False),
len(kims_interactions),
1 if has_severe else 0,
kims_response_time_ms,
log_id
))
conn.commit()
conn.close()
def update_ai_result(
log_id: int,
ai_prompt: str = None,
ai_model: str = None,
ai_response: dict = None,
ai_response_time_ms: int = 0,
ai_token_count: int = None
):
"""AI 분석 결과 업데이트"""
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
cursor.execute("""
UPDATE paai_logs SET
ai_prompt = ?,
ai_model = ?,
ai_response = ?,
ai_response_time_ms = ?,
ai_token_count = ?,
status = 'success'
WHERE id = ?
""", (
ai_prompt,
ai_model,
json.dumps(ai_response, ensure_ascii=False) if ai_response else None,
ai_response_time_ms,
ai_token_count,
log_id
))
conn.commit()
conn.close()
def update_error(log_id: int, error_message: str):
"""에러 상태 업데이트"""
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
cursor.execute("""
UPDATE paai_logs SET
status = 'error',
error_message = ?
WHERE id = ?
""", (error_message, log_id))
conn.commit()
conn.close()
def update_feedback(log_id: int, useful: bool, comment: str = None):
"""피드백 업데이트"""
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
cursor.execute("""
UPDATE paai_logs SET
feedback_useful = ?,
feedback_comment = ?
WHERE id = ?
""", (1 if useful else 0, comment, log_id))
conn.commit()
conn.close()
def get_recent_logs(
limit: int = 100,
status: str = None,
has_severe: bool = None,
date: str = None
) -> list:
"""최근 로그 조회"""
if not DB_PATH.exists():
return []
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
query = "SELECT * FROM paai_logs WHERE 1=1"
params = []
if status:
query += " AND status = ?"
params.append(status)
if has_severe is not None:
query += " AND kims_has_severe = ?"
params.append(1 if has_severe else 0)
if date:
query += " AND DATE(created_at) = ?"
params.append(date)
query += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
cursor.execute(query, params)
rows = cursor.fetchall()
result = []
for row in rows:
log = dict(row)
# JSON 필드 파싱
for field in ['current_medications', 'previous_medications', 'prescription_changes',
'otc_history', 'kims_drug_codes', 'kims_interactions', 'ai_response']:
if log.get(field):
try:
log[field] = json.loads(log[field])
except:
pass
result.append(log)
conn.close()
return result
def get_log_detail(log_id: int) -> dict:
"""로그 상세 조회"""
if not DB_PATH.exists():
return None
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM paai_logs WHERE id = ?", (log_id,))
row = cursor.fetchone()
if not row:
conn.close()
return None
log = dict(row)
# JSON 필드 파싱
for field in ['current_medications', 'previous_medications', 'prescription_changes',
'otc_history', 'kims_drug_codes', 'kims_interactions', 'ai_response']:
if log.get(field):
try:
log[field] = json.loads(log[field])
except:
pass
conn.close()
return log
def get_stats() -> dict:
"""통계 조회"""
if not DB_PATH.exists():
return {
'total': 0,
'today': 0,
'success_rate': 0,
'avg_response_time': 0,
'severe_count': 0
}
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
today = datetime.now().strftime('%Y-%m-%d')
# 전체 건수
cursor.execute("SELECT COUNT(*) FROM paai_logs")
total = cursor.fetchone()[0]
# 오늘 건수
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE DATE(created_at) = ?", (today,))
today_count = cursor.fetchone()[0]
# 성공률
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE status = 'success'")
success_count = cursor.fetchone()[0]
success_rate = (success_count / total * 100) if total > 0 else 0
# 평균 응답시간
cursor.execute("SELECT AVG(ai_response_time_ms) FROM paai_logs WHERE ai_response_time_ms > 0")
avg_time = cursor.fetchone()[0] or 0
# 심각한 상호작용 건수 (오늘)
cursor.execute("""
SELECT COUNT(*) FROM paai_logs
WHERE DATE(created_at) = ? AND kims_has_severe = 1
""", (today,))
severe_count = cursor.fetchone()[0]
# 피드백 통계
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE feedback_useful = 1")
useful_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE feedback_useful IS NOT NULL")
feedback_total = cursor.fetchone()[0]
conn.close()
return {
'total': total,
'today': today_count,
'success_rate': round(success_rate, 1),
'avg_response_time': int(avg_time),
'severe_count': severe_count,
'feedback': {
'useful': useful_count,
'total': feedback_total,
'rate': round(useful_count / feedback_total * 100, 1) if feedback_total > 0 else 0
}
}

View File

@@ -1,59 +0,0 @@
-- PAAI (Pharmacist Assistant AI) 로그 스키마
-- 생성일: 2026-03-04
CREATE TABLE IF NOT EXISTS paai_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 요청 정보
pre_serial TEXT, -- 처방번호
patient_code TEXT, -- 환자코드 (CusCode)
patient_name TEXT, -- 환자명 (마스킹: 김**)
-- 질병 정보
disease_code_1 TEXT, -- St1 (상병코드1)
disease_name_1 TEXT, -- 상병명1
disease_code_2 TEXT, -- St2 (상병코드2)
disease_name_2 TEXT, -- 상병명2
-- 처방 정보
current_medications TEXT, -- JSON: 현재 처방 [{code, name, dosage, ...}]
current_med_count INTEGER, -- 현재 처방 약품 수
previous_serial TEXT, -- 이전 처방번호
previous_medications TEXT, -- JSON: 이전 처방
prescription_changes TEXT, -- JSON: {added, removed, changed}
-- OTC 이력
otc_history TEXT, -- JSON: {purchases, frequent_items}
otc_visit_count INTEGER, -- OTC 구매 횟수
-- KIMS 상호작용
kims_drug_codes TEXT, -- JSON: 검사한 KD코드 배열
kims_drug_count INTEGER, -- 검사한 약품 수
kims_interactions TEXT, -- JSON: 상호작용 결과
kims_interaction_count INTEGER, -- 상호작용 건수
kims_has_severe BOOLEAN DEFAULT 0, -- 심각한 상호작용 (severity 1,2)
kims_response_time_ms INTEGER, -- KIMS API 응답시간
-- AI 분석
ai_prompt TEXT, -- AI에 전달한 프롬프트
ai_model TEXT, -- 사용된 모델
ai_response TEXT, -- JSON: AI 분석 결과
ai_response_time_ms INTEGER, -- AI 응답 시간
ai_token_count INTEGER, -- 토큰 사용량
-- 상태
status TEXT DEFAULT 'pending', -- pending, kims_done, success, error
error_message TEXT,
-- 피드백
feedback_useful INTEGER, -- 1=유용, 0=아님, NULL=미응답
feedback_comment TEXT -- 약사 코멘트
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_paai_created ON paai_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_paai_patient ON paai_logs(patient_code);
CREATE INDEX IF NOT EXISTS idx_paai_status ON paai_logs(status);
CREATE INDEX IF NOT EXISTS idx_paai_serial ON paai_logs(pre_serial);
CREATE INDEX IF NOT EXISTS idx_paai_severe ON paai_logs(kims_has_severe);

View File

@@ -1,38 +0,0 @@
-- product_images.db 스키마
-- yakkok.com에서 크롤링한 제품 이미지 저장
CREATE TABLE IF NOT EXISTS product_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
barcode TEXT UNIQUE NOT NULL, -- 바코드 (고유키)
drug_code TEXT, -- PIT3000 DrugCode
product_name TEXT NOT NULL, -- 제품명
search_name TEXT, -- 검색에 사용한 이름
image_base64 TEXT, -- 이미지 (base64)
image_url TEXT, -- 원본 URL
thumbnail_base64 TEXT, -- 썸네일 (base64, 작은 사이즈)
source TEXT DEFAULT 'yakkok', -- 출처
status TEXT DEFAULT 'pending', -- pending/success/failed/manual/no_result
error_message TEXT, -- 실패 시 에러 메시지
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_barcode ON product_images(barcode);
CREATE INDEX IF NOT EXISTS idx_status ON product_images(status);
CREATE INDEX IF NOT EXISTS idx_drug_code ON product_images(drug_code);
CREATE INDEX IF NOT EXISTS idx_created_at ON product_images(created_at);
-- 크롤링 로그 테이블
CREATE TABLE IF NOT EXISTS crawl_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id TEXT, -- 배치 ID
total_count INTEGER DEFAULT 0, -- 전체 개수
success_count INTEGER DEFAULT 0, -- 성공 개수
failed_count INTEGER DEFAULT 0, -- 실패 개수
skipped_count INTEGER DEFAULT 0, -- 스킵 개수 (이미 있음)
started_at DATETIME,
finished_at DATETIME,
status TEXT DEFAULT 'running', -- running/completed/failed
error_message TEXT
);

View File

@@ -1,85 +0,0 @@
# -*- coding: utf-8 -*-
"""지오영 JS 파일 다운로드 및 분석"""
import requests
import asyncio
from playwright.async_api import async_playwright
import re
async def download_and_analyze():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 로그인
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
# 세션 설정
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
# JS 파일 다운로드
js_urls = [
'https://gwn.geoweb.kr/bundles/order_product_cart?v=JPwFQ8DWaNMW1VmbtWYKTJqxT-5255z351W5iZE1qew1',
'https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1'
]
for url in js_urls:
print(f"\n{'='*60}")
print(f"분석: {url.split('/')[-1].split('?')[0]}")
print('='*60)
resp = session.get(url)
content = resp.text
# 장바구니/주문 관련 함수 찾기
patterns = [
(r'function\s+(fn\w*Cart\w*|add\w*Cart\w*|insert\w*Order\w*)\s*\([^)]*\)', 'function'),
(r'(fn\w*Cart\w*|add\w*Cart\w*)\s*=\s*function', 'var function'),
(r'url\s*:\s*["\']([^"\']*(?:Cart|Order|Add)[^"\']*)["\']', 'ajax url'),
(r'\$\.(?:ajax|post|get)\s*\(\s*["\']([^"\']+)["\']', 'ajax call'),
]
found = {}
for pattern, name in patterns:
matches = re.findall(pattern, content, re.IGNORECASE)
if matches:
for m in matches:
if m not in found:
found[m] = name
for item, ptype in found.items():
print(f"[{ptype}] {item}")
# InsertOrder 함수 찾기
if 'InsertOrder' in content or 'insertOrder' in content:
print("\n--- InsertOrder 함수 발견! ---")
# 해당 부분 추출
idx = content.lower().find('insertorder')
if idx > 0:
snippet = content[max(0, idx-100):idx+500]
print(snippet[:600])
# AddCart 패턴 찾기
add_patterns = re.findall(r'.{50}AddCart.{100}|.{50}addCart.{100}', content, re.IGNORECASE)
if add_patterns:
print("\n--- AddCart 관련 ---")
for p in add_patterns[:3]:
print(p)
# ajax 호출 상세
ajax_pattern = r'\$\.ajax\s*\(\s*\{[^}]{50,500}(Cart|Order)[^}]{0,200}\}'
ajax_matches = re.findall(ajax_pattern, content, re.IGNORECASE | re.DOTALL)
if ajax_matches:
print(f"\n--- AJAX 호출 {len(ajax_matches)}개 발견 ---")
if __name__ == "__main__":
asyncio.run(download_and_analyze())

View File

@@ -1,18 +0,0 @@
module.exports = {
apps: [
{
name: 'pharmacy-flask',
script: 'python',
args: 'app.py',
cwd: 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend',
interpreter: 'none',
watch: false,
autorestart: true,
max_restarts: 10,
env: {
FLASK_ENV: 'production',
PYTHONIOENCODING: 'utf-8'
}
}
]
};

View File

@@ -1,86 +0,0 @@
# -*- coding: utf-8 -*-
"""AddCart 함수 전체 추출"""
import requests
import asyncio
from playwright.async_api import async_playwright
import re
async def extract():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
resp = session.get('https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1')
content = resp.text
# AddCart 함수 전체 찾기
# function AddCart(n,t,i){ ... }
start = content.find('function AddCart')
if start > 0:
# 중괄호 매칭으로 함수 끝 찾기
depth = 0
end = start
in_func = False
for i in range(start, min(start + 5000, len(content))):
if content[i] == '{':
depth += 1
in_func = True
elif content[i] == '}':
depth -= 1
if in_func and depth == 0:
end = i + 1
break
func_content = content[start:end]
print("="*60)
print("AddCart 함수 전체:")
print("="*60)
print(func_content)
# ajax 호출 찾기
ajax_match = re.search(r'\$\.ajax\s*\(\s*\{[^}]+\}', func_content, re.DOTALL)
if ajax_match:
print("\n" + "="*60)
print("AJAX 호출:")
print("="*60)
print(ajax_match.group())
# InsertOrder 함수도 찾기
start2 = content.find('function InsertOrder')
if start2 > 0:
depth = 0
end2 = start2
in_func = False
for i in range(start2, min(start2 + 3000, len(content))):
if content[i] == '{':
depth += 1
in_func = True
elif content[i] == '}':
depth -= 1
if in_func and depth == 0:
end2 = i + 1
break
print("\n" + "="*60)
print("InsertOrder 함수:")
print("="*60)
print(content[start2:end2][:1500])
if __name__ == "__main__":
asyncio.run(extract())

View File

@@ -1,71 +0,0 @@
# -*- coding: utf-8 -*-
"""ProcessCart 함수 추출"""
import requests
import asyncio
from playwright.async_api import async_playwright
async def extract():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
resp = session.get('https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1')
content = resp.text
# ProcessCart 함수 찾기
start = content.find('function ProcessCart')
if start > 0:
depth = 0
end = start
in_func = False
for i in range(start, min(start + 5000, len(content))):
if content[i] == '{':
depth += 1
in_func = True
elif content[i] == '}':
depth -= 1
if in_func and depth == 0:
end = i + 1
break
func_content = content[start:end]
print("="*60)
print("ProcessCart 함수:")
print("="*60)
print(func_content)
else:
# 다른 패턴으로 찾기
print("ProcessCart를 변수로 찾기...")
start = content.find('ProcessCart=function')
if start > 0:
print(content[start:start+2000])
else:
# ajax 호출 찾기
import re
ajax_calls = re.findall(r'\$\.ajax\s*\(\s*\{[^}]{100,1000}(Cart|Order)[^}]{0,500}\}', content, re.IGNORECASE | re.DOTALL)
print(f"\nAJAX 호출 {len(ajax_calls)}개 발견")
# url 패턴 찾기
urls = re.findall(r'url\s*:\s*["\']([^"\']+)["\']', content)
print("\n모든 URL:")
for url in set(urls):
if 'Cart' in url or 'Order' in url or 'Add' in url or 'Insert' in url:
print(f" {url}")
if __name__ == "__main__":
asyncio.run(extract())

View File

@@ -1,90 +0,0 @@
# -*- coding: utf-8 -*-
"""지오영 JavaScript에서 장바구니 추가 함수 찾기"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
import re
async def analyze_js():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 로그인
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
# 메인 페이지
await page.goto('https://gwn.geoweb.kr/Home/Index')
await page.wait_for_timeout(3000)
# 모든 스크립트 태그 내용 가져오기
scripts = await page.evaluate('''() => {
var result = [];
var scripts = document.querySelectorAll('script');
scripts.forEach(s => {
if (s.src) {
result.push({type: 'src', url: s.src});
}
if (s.textContent && s.textContent.length > 100) {
result.push({type: 'inline', content: s.textContent});
}
});
return result;
}''')
print(f"스크립트 {len(scripts)}개 발견")
# 장바구니 관련 함수 찾기
for s in scripts:
if s['type'] == 'inline':
content = s['content']
# 담기, Cart, Add 관련 찾기
if '담기' in content or 'AddCart' in content or 'addCart' in content or 'InsertOrder' in content:
print("\n" + "="*60)
print("장바구니 관련 스크립트 발견!")
print("="*60)
# 함수 정의 찾기
func_patterns = [
r'function\s+(\w*[Cc]art\w*)\s*\([^)]*\)\s*{[^}]+}',
r'function\s+(\w*[Aa]dd\w*)\s*\([^)]*\)\s*{[^}]+}',
r'(\w+)\s*=\s*function\s*\([^)]*\)\s*{[^}]*[Cc]art[^}]*}',
]
for pattern in func_patterns:
matches = re.findall(pattern, content, re.DOTALL)
for m in matches:
print(f"함수 발견: {m}")
# ajax 호출 찾기
ajax_pattern = r'\$\.ajax\s*\(\s*{[^}]+url[^}]+}'
ajax_matches = re.findall(ajax_pattern, content, re.DOTALL)
for m in ajax_matches:
if 'cart' in m.lower() or 'order' in m.lower() or 'add' in m.lower():
print(f"\nAJAX 호출:\n{m[:500]}")
# 일부 내용 출력
lines = content.split('\n')
for i, line in enumerate(lines):
if '담기' in line or 'addCart' in line.lower() or 'insertorder' in line.lower():
print(f"\n관련 라인 {i}:")
print('\n'.join(lines[max(0,i-3):min(len(lines),i+10)]))
# 외부 JS 파일 확인
print("\n" + "="*60)
print("외부 스크립트 파일:")
print("="*60)
for s in scripts:
if s['type'] == 'src':
print(s['url'])
await browser.close()
if __name__ == "__main__":
asyncio.run(analyze_js())

View File

@@ -1,82 +0,0 @@
# -*- coding: utf-8 -*-
"""frmSave 폼과 주문 저장 로직 찾기"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
import re
async def analyze():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 네트워크 요청 캡처
requests_log = []
def log_req(req):
if req.method == 'POST':
requests_log.append({'url': req.url, 'data': req.post_data})
page.on('request', log_req)
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
# 메인 페이지
await page.goto('https://gwn.geoweb.kr/Home/Index')
await page.wait_for_timeout(2000)
# 페이지 HTML에서 frmSave 폼 찾기
html = await page.content()
print("="*60)
print("frmSave 폼 찾기:")
print("="*60)
soup = BeautifulSoup(html, 'html.parser')
# 모든 form 찾기
forms = soup.find_all('form')
for form in forms:
form_id = form.get('id', '')
form_action = form.get('action', '')
print(f"폼: id={form_id}, action={form_action}")
if 'save' in form_id.lower() or 'order' in form_id.lower():
print(f" >>> 주문 관련 폼 발견!")
inputs = form.find_all('input')
for inp in inputs[:10]:
print(f" - {inp.get('name')}: {inp.get('value', '')[:30]}")
# 주문저장 버튼 찾기
print("\n" + "="*60)
print("주문저장 버튼:")
print("="*60)
buttons = soup.find_all(['button', 'input'], type=['button', 'submit'])
for btn in buttons:
text = btn.get_text(strip=True) or btn.get('value', '')
onclick = btn.get('onclick', '')
if '저장' in text or '주문' in text:
print(f"버튼: {text}")
print(f" onclick: {onclick[:100]}")
# JavaScript에서 폼 action 찾기
scripts = soup.find_all('script')
for script in scripts:
text = script.get_text() or ''
if 'frmSave' in text:
print("\n" + "="*60)
print("frmSave 관련 스크립트:")
print("="*60)
# frmSave 근처 코드 출력
idx = text.find('frmSave')
print(text[max(0,idx-100):idx+300])
await browser.close()
if __name__ == "__main__":
asyncio.run(analyze())

View File

@@ -1,70 +0,0 @@
# -*- coding: utf-8 -*-
"""주문 확정 API 찾기"""
import requests
import asyncio
from playwright.async_api import async_playwright
import re
async def find_order_api():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
# order.js 다운로드
resp = session.get('https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1')
content = resp.text
# InsertOrder, ConfirmOrder, SubmitOrder 등 찾기
print("="*60)
print("주문 관련 함수 찾기")
print("="*60)
# 함수 찾기
funcs = ['InsertOrder', 'ConfirmOrder', 'SubmitOrder', 'SaveOrder', 'ProcessOrder', 'DataOrder']
for func in funcs:
start = content.find(f'function {func}')
if start < 0:
start = content.find(f'{func}=function')
if start < 0:
start = content.find(f'{func}(')
if start > 0:
print(f"\n{func} 발견!")
# 함수 내용 출력
snippet = content[max(0, start-20):start+800]
print(snippet[:600])
# DataOrder URL 찾기
print("\n" + "="*60)
print("DataOrder 관련")
print("="*60)
dataorder_pattern = re.findall(r'.{30}DataOrder.{100}', content)
for p in dataorder_pattern[:5]:
print(p)
# 모든 ajax URL 찾기
print("\n" + "="*60)
print("주문 관련 URL")
print("="*60)
urls = re.findall(r'url\s*:\s*["\']([^"\']*(?:Order|Submit|Confirm|Save)[^"\']*)["\']', content, re.IGNORECASE)
for url in set(urls):
print(url)
if __name__ == "__main__":
asyncio.run(find_order_api())

View File

@@ -1,76 +0,0 @@
# -*- coding: utf-8 -*-
"""주문 확정 API 찾기 - 전체 검색"""
import requests
import asyncio
from playwright.async_api import async_playwright
import re
async def analyze():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
# 모든 JS 번들 다운로드
js_urls = [
'https://gwn.geoweb.kr/bundles/order_product_cart?v=JPwFQ8DWaNMW1VmbtWYKTJqxT-5255z351W5iZE1qew1',
'https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1',
'https://gwn.geoweb.kr/bundles/javascript?v=Tn_AqbA-PX_uu3d0zjfQOYS6NPSDLtOVqjW95a949Ow1'
]
all_content = ""
for url in js_urls:
resp = session.get(url)
all_content += resp.text + "\n"
print(f"총 JS 길이: {len(all_content)}")
# 모든 ajax POST URL 찾기
print("\n" + "="*60)
print("모든 POST URL:")
print("="*60)
# $.ajax 패턴
ajax_patterns = re.findall(r'\$\.ajax\s*\(\s*\{[^}]*url\s*:\s*["\']([^"\']+)["\'][^}]*type\s*:\s*["\']POST["\']', all_content, re.IGNORECASE | re.DOTALL)
ajax_patterns += re.findall(r'\$\.ajax\s*\(\s*\{[^}]*type\s*:\s*["\']POST["\'][^}]*url\s*:\s*["\']([^"\']+)["\']', all_content, re.IGNORECASE | re.DOTALL)
for url in set(ajax_patterns):
print(url)
# 주문저장, 저장 관련
print("\n" + "="*60)
print("저장/주문 관련 키워드:")
print("="*60)
keywords = ['주문저장', '저장', 'save', 'submit', 'confirm', 'order', 'insert']
for kw in keywords:
matches = re.findall(rf'.{{50}}{kw}.{{50}}', all_content, re.IGNORECASE)
if matches:
print(f"\n--- {kw} ---")
for m in matches[:3]:
print(m.replace('\n', ' ')[:100])
# 버튼 onclick 찾기
print("\n" + "="*60)
print("주문저장 버튼:")
print("="*60)
save_btn = re.findall(r'주문저장.{0,200}', all_content)
for s in save_btn[:5]:
print(s[:150])
if __name__ == "__main__":
asyncio.run(analyze())

View File

@@ -1,484 +0,0 @@
# -*- coding: utf-8 -*-
"""
지오영 도매상 API - Flask Blueprint
핵심 로직은 wholesale 패키지에서 가져옴
이 파일은 Flask 웹 API 연동만 담당
"""
import re
import time
import logging
from flask import Blueprint, jsonify, request
# wholesale 패키지 경로 설정
import wholesale_path
# wholesale 패키지에서 핵심 클래스 가져오기
from wholesale import GeoYoungSession
logger = logging.getLogger(__name__)
# Blueprint 생성
geoyoung_bp = Blueprint('geoyoung', __name__, url_prefix='/api/geoyoung')
# ========== 세션 관리 ==========
_geo_session = None
def get_geo_session():
global _geo_session
if _geo_session is None:
_geo_session = GeoYoungSession()
return _geo_session
def search_geoyoung_stock(keyword: str, include_price: bool = True):
"""지오영 재고 검색 (동기, 단가 포함)"""
try:
session = get_geo_session()
# 새 API 사용 (단가 포함)
result = session.search_products(keyword, include_price=include_price)
if result.get('success'):
# 기존 형식으로 변환
items = [{
'insurance_code': item['code'],
'internal_code': item.get('internal_code'),
'manufacturer': item['manufacturer'],
'product_name': item['name'],
'specification': item['spec'],
'stock': item['stock'],
'price': item.get('price', 0), # 단가 추가!
'box_qty': item.get('box_qty'),
'case_qty': item.get('case_qty')
} for item in result['items']]
return {
'success': True,
'keyword': keyword,
'count': len(items),
'items': items
}
else:
return {'success': False, 'error': result.get('error'), 'message': '검색 실패'}
except Exception as e:
logger.error(f"지오영 검색 오류: {e}")
return {'success': False, 'error': 'SEARCH_ERROR', 'message': str(e)}
# ========== Flask API Routes ==========
@geoyoung_bp.route('/stock', methods=['GET'])
def api_geoyoung_stock():
"""
지오영 재고 조회 API (빠름)
GET /api/geoyoung/stock?kd_code=670400830
GET /api/geoyoung/stock?keyword=레바미피드
"""
kd_code = request.args.get('kd_code', '').strip()
keyword = request.args.get('keyword', '').strip()
search_term = kd_code or keyword
if not search_term:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code 또는 keyword 파라미터가 필요합니다'
}), 400
try:
result = search_geoyoung_stock(search_term)
return jsonify(result)
except Exception as e:
logger.error(f"지오영 API 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e)
}), 500
@geoyoung_bp.route('/stock-by-name', methods=['GET'])
def api_geoyoung_stock_by_name():
"""
제품명에서 성분명 추출 후 지오영 검색
GET /api/geoyoung/stock-by-name?product_name=휴니즈레바미피드정_(0.1g/1정)
"""
product_name = request.args.get('product_name', '').strip()
if not product_name:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'product_name 파라미터가 필요합니다'
}), 400
# 성분명 추출
prefixes = ['휴니즈', '휴온스', '대웅', '한미', '종근당', '유한', '녹십자', '동아', '일동', '광동',
'삼성', '안국', '보령', '광동', '경동', '현대', '일양', '태극', '환인', '에스케이']
ingredient = product_name
for prefix in prefixes:
if ingredient.startswith(prefix):
ingredient = ingredient[len(prefix):]
break
match = re.match(r'^([가-힣a-zA-Z]+)', ingredient)
if match:
ingredient = match.group(1)
if ingredient.endswith(''):
ingredient = ingredient[:-1]
elif ingredient.endswith('캡슐'):
ingredient = ingredient[:-2]
if not ingredient:
ingredient = product_name[:10]
try:
result = search_geoyoung_stock(ingredient)
result['extracted_ingredient'] = ingredient
result['original_product_name'] = product_name
return jsonify(result)
except Exception as e:
logger.error(f"지오영 API 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e)
}), 500
@geoyoung_bp.route('/session-status', methods=['GET'])
def api_session_status():
"""세션 상태 확인"""
session = get_geo_session()
return jsonify({
'logged_in': session._logged_in,
'last_login': session._last_login,
'session_age_sec': int(time.time() - session._last_login) if session._last_login else None
})
@geoyoung_bp.route('/cart', methods=['GET'])
def api_geoyoung_cart():
"""장바구니 조회 API"""
try:
session = get_geo_session()
result = session.get_cart()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e), 'items': []}), 500
@geoyoung_bp.route('/cart/clear', methods=['POST'])
def api_geoyoung_cart_clear():
"""장바구니 비우기 API"""
try:
session = get_geo_session()
result = session.clear_cart()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@geoyoung_bp.route('/cart/cancel', methods=['POST'])
def api_geoyoung_cart_cancel():
"""
장바구니 개별 항목 삭제 API (Hard delete)
POST /api/geoyoung/cart/cancel
{
"row_index": 0, // 또는
"product_code": "008709"
}
⚠️ 지오영은 완전 삭제됨 (복원 불가, 다시 추가해야 함)
"""
data = request.get_json() or {}
row_index = data.get('row_index')
product_code = data.get('product_code')
if row_index is None and not product_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'row_index 또는 product_code 필요'
}), 400
try:
session = get_geo_session()
result = session.cancel_item(row_index=row_index, product_code=product_code)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@geoyoung_bp.route('/cart/restore', methods=['POST'])
def api_geoyoung_cart_restore():
"""
삭제된 항목 복원 API - 지오영은 Hard delete이므로 지원 안 함
Returns:
항상 {'success': False, 'error': 'NOT_SUPPORTED'}
"""
return jsonify({
'success': False,
'error': 'NOT_SUPPORTED',
'message': '지오영은 삭제 후 복원 불가 (다시 추가 필요)'
}), 400
@geoyoung_bp.route('/confirm', methods=['POST'])
def api_geoyoung_confirm():
"""주문 확정 API"""
data = request.get_json() or {}
memo = data.get('memo', '')
try:
session = get_geo_session()
result = session.submit_order(memo)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@geoyoung_bp.route('/full-order', methods=['POST'])
def api_geoyoung_full_order():
"""전체 주문 API (검색 → 장바구니 → 확정)"""
data = request.get_json()
if not data or not data.get('kd_code'):
return jsonify({'success': False, 'error': 'kd_code required'}), 400
try:
session = get_geo_session()
result = session.full_order(
kd_code=data['kd_code'],
quantity=data.get('quantity', 1),
specification=data.get('specification'),
check_stock=data.get('check_stock', True),
auto_confirm=data.get('auto_confirm', True),
memo=data.get('memo', '')
)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@geoyoung_bp.route('/order', methods=['POST'])
def api_geoyoung_order():
"""지오영 주문 API (장바구니 추가)"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'NO_DATA'}), 400
kd_code = data.get('kd_code', '').strip()
quantity = data.get('quantity', 1)
specification = data.get('specification')
check_stock = data.get('check_stock', True)
if not kd_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code가 필요합니다'
}), 400
try:
session = get_geo_session()
result = session.quick_order(
kd_code=kd_code,
quantity=quantity,
spec=specification,
check_stock=check_stock
)
return jsonify(result)
except Exception as e:
logger.error(f"지오영 주문 오류: {e}")
return jsonify({
'success': False,
'error': 'ORDER_ERROR',
'message': str(e)
}), 500
@geoyoung_bp.route('/order-batch', methods=['POST'])
def api_geoyoung_order_batch():
"""지오영 일괄 주문 API"""
data = request.get_json()
if not data or not data.get('items'):
return jsonify({'success': False, 'error': 'NO_ITEMS'}), 400
items = data.get('items', [])
check_stock = data.get('check_stock', True)
session = get_geo_session()
results = []
success_count = 0
failed_count = 0
for item in items:
kd_code = item.get('kd_code', '').strip()
quantity = item.get('quantity', 1)
specification = item.get('specification')
if not kd_code:
results.append({
'kd_code': kd_code,
'success': False,
'error': 'MISSING_KD_CODE'
})
failed_count += 1
continue
try:
result = session.quick_order(
kd_code=kd_code,
quantity=quantity,
spec=specification,
check_stock=check_stock
)
result['kd_code'] = kd_code
result['requested_qty'] = quantity
results.append(result)
if result.get('success'):
success_count += 1
else:
failed_count += 1
except Exception as e:
results.append({
'kd_code': kd_code,
'success': False,
'error': 'EXCEPTION',
'message': str(e)
})
failed_count += 1
return jsonify({
'success': True,
'total': len(items),
'success_count': success_count,
'failed_count': failed_count,
'results': results
})
# ========== 잔고 탐색 (임시) ==========
@geoyoung_bp.route('/explore-balance', methods=['GET'])
def api_explore_balance():
"""잔고 페이지 탐색 (임시 디버그용)"""
from bs4 import BeautifulSoup
session = get_geo_session()
if not session._logged_in:
session.login()
results = {
'logged_in': session._logged_in,
'cookies': len(session.session.cookies),
'pages_found': [],
'balance_pages': []
}
# Order 페이지에서 메뉴 링크 수집
try:
# 먼저 Order 페이지 접근
resp = session.session.get(f"{session.BASE_URL}/Home/Order", timeout=10)
results['order_page'] = {
'status': resp.status_code,
'url': resp.url,
'is_error': 'Error' in resp.url
}
if resp.status_code == 200 and 'Error' not in resp.url:
soup = BeautifulSoup(resp.text, 'html.parser')
# 모든 링크 추출
for link in soup.find_all('a', href=True):
href = link.get('href', '')
text = link.get_text(strip=True)[:50]
if href.startswith('/') and href not in [l['href'] for l in results['pages_found']]:
entry = {'href': href, 'text': text}
results['pages_found'].append(entry)
# 잔고 관련 키워드
keywords = ['account', 'balance', 'trans', 'state', 'history', 'ledger', '잔고', '잔액', '거래', '명세', '내역']
if any(kw in href.lower() or kw in text for kw in keywords):
results['balance_pages'].append(entry)
except Exception as e:
results['error'] = str(e)
return jsonify(results)
@geoyoung_bp.route('/balance', methods=['GET'])
def api_get_balance():
"""
잔고액 조회
GET /api/geoyoung/balance
"""
session = get_geo_session()
# get_balance 메서드가 있으면 호출
if hasattr(session, 'get_balance'):
result = session.get_balance()
return jsonify(result)
else:
return jsonify({
'success': False,
'error': 'NOT_IMPLEMENTED',
'message': '지오영 잔고 조회 미구현'
}), 501
@geoyoung_bp.route('/monthly-sales', methods=['GET'])
def api_get_monthly_sales():
"""
월간 매출 조회
GET /api/geoyoung/monthly-sales?year=2026&month=3
"""
from datetime import datetime
year = request.args.get('year', type=int)
month = request.args.get('month', type=int)
# 기본값: 현재 월
if not year or not month:
now = datetime.now()
year = year or now.year
month = month or now.month
session = get_geo_session()
if hasattr(session, 'get_monthly_sales'):
result = session.get_monthly_sales(year, month)
return jsonify(result)
else:
return jsonify({
'success': False,
'error': 'NOT_IMPLEMENTED',
'message': '지오영 월간 매출 조회 미구현'
}), 501
# ========== 하위 호환성 ==========
# 기존 코드에서 직접 클래스 참조하는 경우를 위해
GeoyoungSession = GeoYoungSession

222
backend/gui/pos_thermal.py Normal file
View File

@@ -0,0 +1,222 @@
# pos_settings_dialog.py
# POS 영수증 프린터 설정 다이얼로그
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QLineEdit, QFormLayout, QMessageBox
)
from PyQt5.QtCore import Qt
import json
import os
import socket
import time
class POSSettingsDialog(QDialog):
"""POS 영수증 프린터 설정"""
def __init__(self, parent=None):
super().__init__(parent)
self.config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json')
self.setWindowTitle("POS 영수증 프린터 설정")
self.setMinimumSize(500, 300)
self.init_ui()
self.load_settings()
def init_ui(self):
layout = QVBoxLayout()
# 제목
title = QLabel("POS 영수증 프린터 설정")
title.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 10px;")
layout.addWidget(title)
# 설명
desc = QLabel("ESC/POS 프로토콜을 지원하는 영수증 프린터 설정\n올댓포스 AGENT가 설치된 PC IP를 입력하세요")
desc.setStyleSheet("color: gray; margin-bottom: 20px;")
layout.addWidget(desc)
# 폼 레이아웃
form_layout = QFormLayout()
# IP 주소
self.ip_input = QLineEdit()
self.ip_input.setPlaceholderText("예: 192.168.0.174")
form_layout.addRow("IP 주소 *", self.ip_input)
# 포트
self.port_input = QLineEdit()
self.port_input.setText("9100")
form_layout.addRow("포트", self.port_input)
# 프린터 이름
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("예: 메인 POS 프린터")
form_layout.addRow("프린터 이름", self.name_input)
layout.addLayout(form_layout)
layout.addStretch()
# 버튼들
button_layout = QHBoxLayout()
self.test_button = QPushButton("테스트 인쇄")
self.test_button.clicked.connect(self.test_print)
self.test_button.setStyleSheet("""
QPushButton {
background-color: #2196F3;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #1976D2;
}
""")
button_layout.addWidget(self.test_button)
button_layout.addStretch()
self.cancel_button = QPushButton("취소")
self.cancel_button.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_button)
self.save_button = QPushButton("저장")
self.save_button.clicked.connect(self.save_settings)
self.save_button.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #45a049;
}
""")
button_layout.addWidget(self.save_button)
layout.addLayout(button_layout)
self.setLayout(layout)
def load_settings(self):
"""설정 불러오기"""
try:
if os.path.exists(self.config_path):
with open(self.config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
pos_config = config.get('pos_printer', {})
self.ip_input.setText(pos_config.get('ip', ''))
self.port_input.setText(str(pos_config.get('port', 9100)))
self.name_input.setText(pos_config.get('name', ''))
except Exception as e:
print(f"[POS Settings] 설정 로드 오류: {e}")
def save_settings(self):
"""설정 저장"""
ip = self.ip_input.text().strip()
port = self.port_input.text().strip()
name = self.name_input.text().strip()
# 유효성 검사
if not ip:
QMessageBox.warning(self, "입력 오류", "IP 주소를 입력해주세요.")
return
try:
port_num = int(port)
except ValueError:
QMessageBox.warning(self, "입력 오류", "포트는 숫자여야 합니다.")
return
# 설정 저장
try:
config = {}
if os.path.exists(self.config_path):
with open(self.config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
config['pos_printer'] = {
'ip': ip,
'port': port_num,
'name': name if name else f"POS Printer ({ip})"
}
with open(self.config_path, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=4, ensure_ascii=False)
QMessageBox.information(self, "성공", "POS 프린터 설정이 저장되었습니다.")
self.accept()
except Exception as e:
QMessageBox.warning(self, "오류", f"설정 저장 실패: {str(e)}")
def test_print(self):
"""테스트 인쇄"""
ip = self.ip_input.text().strip()
port = self.port_input.text().strip()
if not ip:
QMessageBox.warning(self, "입력 오류", "IP 주소를 입력해주세요.")
return
try:
port_num = int(port)
except ValueError:
QMessageBox.warning(self, "입력 오류", "포트는 숫자여야 합니다.")
return
# ESC/POS 테스트 인쇄
try:
# ESC/POS 명령어
ESC = b'\x1b'
INIT = ESC + b'@' # 프린터 초기화
CUT = ESC + b'd\x03' # 용지 커트
# 테스트 메시지
message = f"""
================================
POS 프린터 테스트!
================================
IP: {ip}
Port: {port_num}
Time: {time.strftime('%Y-%m-%d %H:%M:%S')}
ESC/POS 명령으로 인쇄됨
정상 작동 확인!
================================
"""
# EUC-KR 인코딩 (한글 지원)
message_bytes = message.encode('euc-kr')
command = INIT + message_bytes + b'\n\n\n' + CUT
# TCP 소켓으로 전송
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect((ip, port_num))
sock.sendall(command)
sock.close()
QMessageBox.information(
self, "성공",
f"테스트 인쇄 명령을 전송했습니다!\n\n"
f"IP: {ip}:{port_num}\n\n"
f"POS 프린터에서 영수증 출력을 확인하세요."
)
except socket.timeout:
QMessageBox.warning(self, "실패", f"연결 시간 초과\n\n프린터가 켜져있는지 확인하세요.")
except ConnectionRefusedError:
QMessageBox.warning(self, "실패", f"연결 거부됨\n\nIP 주소와 포트를 확인하세요.")
except UnicodeEncodeError:
QMessageBox.warning(self, "인코딩 오류", "EUC-KR로 인코딩할 수 없는 문자가 있습니다.")
except Exception as e:
QMessageBox.warning(self, "실패", f"테스트 인쇄 실패\n\n{type(e).__name__}: {str(e)}")

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,859 +0,0 @@
# -*- coding: utf-8 -*-
"""
주문 관리 DB (SQLite)
- 다중 도매상 지원 (지오영, 수인, 백제 등)
- 주문 상태 추적
- 품목별 결과 관리
- 자동화 ERP 확장 대비
"""
import sqlite3
import os
from datetime import datetime
from typing import Optional, List, Dict
import json
# DB 경로
DB_PATH = os.path.join(os.path.dirname(__file__), 'db', 'orders.db')
def get_connection():
"""DB 연결"""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""DB 초기화 - 테이블 생성"""
conn = get_connection()
cursor = conn.cursor()
# ─────────────────────────────────────────────
# 도매상 마스터
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS wholesalers (
id TEXT PRIMARY KEY, -- 'geoyoung', 'sooin', 'baekje'
name TEXT NOT NULL, -- '지오영', '수인', '백제'
api_type TEXT, -- 'playwright', 'api', 'manual'
base_url TEXT,
is_active INTEGER DEFAULT 1,
config_json TEXT, -- 로그인 정보 등 (암호화 권장)
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
''')
# 기본 도매상 등록
cursor.execute('''
INSERT OR IGNORE INTO wholesalers (id, name, api_type, base_url)
VALUES
('geoyoung', '지오영', 'playwright', 'https://gwn.geoweb.kr'),
('sooin', '수인', 'manual', NULL),
('baekje', '백제', 'manual', NULL)
''')
# ─────────────────────────────────────────────
# 주문 헤더
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT UNIQUE, -- 주문번호 (ORD-20260306-001)
wholesaler_id TEXT NOT NULL, -- 도매상 ID
-- 주문 정보
order_date TEXT NOT NULL, -- 주문일 (YYYY-MM-DD)
order_time TEXT, -- 주문시간 (HH:MM:SS)
order_type TEXT DEFAULT 'manual', -- 'manual', 'auto', 'scheduled'
order_session TEXT, -- 'morning', 'afternoon', 'evening'
-- 상태
status TEXT DEFAULT 'draft', -- draft, pending, submitted, partial, completed, failed, cancelled
-- 집계
total_items INTEGER DEFAULT 0,
total_qty INTEGER DEFAULT 0,
success_items INTEGER DEFAULT 0,
failed_items INTEGER DEFAULT 0,
-- 참조
parent_order_id INTEGER, -- 재주문 시 원주문 참조
reference_period TEXT, -- 사용량 조회 기간 (2026-03-01~2026-03-06)
-- 메타
note TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
submitted_at TEXT, -- 실제 제출 시간
completed_at TEXT,
FOREIGN KEY (wholesaler_id) REFERENCES wholesalers(id),
FOREIGN KEY (parent_order_id) REFERENCES orders(id)
)
''')
# ─────────────────────────────────────────────
# 주문 품목 상세
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
-- 약품 정보
drug_code TEXT NOT NULL, -- PIT3000 약품코드
kd_code TEXT, -- 보험코드 (지오영 검색용)
product_name TEXT NOT NULL,
manufacturer TEXT,
-- 규격/수량
specification TEXT, -- '30T', '300T', '500T'
unit_qty INTEGER, -- 규격당 수량 (30, 300, 500)
order_qty INTEGER NOT NULL, -- 주문 수량 (단위 개수)
total_dose INTEGER, -- 총 정제수 (order_qty * unit_qty)
-- 주문 근거
usage_qty INTEGER, -- 사용량 (조회 기간)
current_stock INTEGER, -- 주문 시점 재고
-- 가격 (선택)
unit_price INTEGER,
total_price INTEGER,
-- 상태
status TEXT DEFAULT 'pending', -- pending, submitted, success, failed, cancelled
-- 결과
result_code TEXT, -- 'OK', 'OUT_OF_STOCK', 'NOT_FOUND', 'ERROR'
result_message TEXT,
wholesaler_order_no TEXT, -- 도매상 측 주문번호
-- 메타
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
)
''')
# ─────────────────────────────────────────────
# 주문 로그 (상태 변경 이력)
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS order_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
order_item_id INTEGER, -- NULL이면 주문 전체 로그
action TEXT NOT NULL, -- 'created', 'submitted', 'success', 'failed', 'cancelled'
old_status TEXT,
new_status TEXT,
message TEXT,
detail_json TEXT, -- API 응답 등 상세 정보
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY (order_item_id) REFERENCES order_items(id) ON DELETE CASCADE
)
''')
# ─────────────────────────────────────────────
# 주문 컨텍스트 (AI 학습용 스냅샷)
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS order_context (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_item_id INTEGER NOT NULL,
-- 약품 정보
drug_code TEXT NOT NULL,
product_name TEXT,
-- 주문 시점 재고
stock_at_order INTEGER, -- 주문 시점 현재고
-- 사용량 분석
usage_1d INTEGER, -- 최근 1일 사용량
usage_7d INTEGER, -- 최근 7일 사용량
usage_30d INTEGER, -- 최근 30일 사용량
avg_daily_usage REAL, -- 일평균 사용량 (30일 기준)
-- 주문 패턴
ordered_spec TEXT, -- 주문한 규격 (30T, 300T)
ordered_qty INTEGER, -- 주문 수량 (단위 개수)
ordered_dose INTEGER, -- 주문 총 정제수
-- 규격 선택 이유 (AI 분석용)
available_specs TEXT, -- 가능한 규격들 JSON ["30T", "300T"]
spec_stocks TEXT, -- 규격별 도매상 재고 JSON {"30T": 50, "300T": 0}
selection_reason TEXT, -- 'stock_available', 'best_fit', 'only_option', 'user_choice'
-- 예측 vs 실제 (나중에 업데이트)
days_until_stockout REAL, -- 주문 시점 예상 재고 소진일
actual_reorder_days INTEGER, -- 실제 재주문까지 일수 (나중에 업데이트)
-- 메타
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_item_id) REFERENCES order_items(id) ON DELETE CASCADE
)
''')
# ─────────────────────────────────────────────
# 일별 사용량 추적 (시계열 데이터)
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS daily_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drug_code TEXT NOT NULL,
usage_date TEXT NOT NULL, -- YYYY-MM-DD
-- 처방 데이터
rx_count INTEGER DEFAULT 0, -- 처방 건수
rx_qty INTEGER DEFAULT 0, -- 처방 수량 (정제수)
-- POS 데이터 (일반약)
pos_count INTEGER DEFAULT 0,
pos_qty INTEGER DEFAULT 0,
-- 집계
total_qty INTEGER DEFAULT 0,
-- 재고 스냅샷
stock_start INTEGER, -- 시작 재고
stock_end INTEGER, -- 종료 재고
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(drug_code, usage_date)
)
''')
# ─────────────────────────────────────────────
# AI 분석 결과/패턴
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS order_patterns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drug_code TEXT NOT NULL,
-- 분석 기간
analysis_date TEXT NOT NULL,
analysis_period_days INTEGER,
-- 사용 패턴
avg_daily_usage REAL,
usage_stddev REAL, -- 사용량 표준편차 (변동성)
peak_usage INTEGER, -- 최대 사용량
-- 주문 패턴
typical_order_spec TEXT, -- 주로 주문하는 규격
typical_order_qty INTEGER, -- 주로 주문하는 수량
order_frequency_days REAL, -- 평균 주문 주기 (일)
-- AI 추천
recommended_spec TEXT, -- 추천 규격
recommended_qty INTEGER, -- 추천 수량
recommended_reorder_point INTEGER,-- 추천 재주문점 (재고가 이 이하면 주문)
confidence_score REAL, -- 추천 신뢰도 (0-1)
-- 모델 정보
model_version TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(drug_code, analysis_date)
)
''')
# ─────────────────────────────────────────────
# 인덱스
# ─────────────────────────────────────────────
cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_wholesaler ON orders(wholesaler_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_items_drug ON order_items(drug_code)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_items_status ON order_items(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_context_drug ON order_context(drug_code)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_daily_usage_drug ON daily_usage(drug_code)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON daily_usage(usage_date)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_patterns_drug ON order_patterns(drug_code)')
conn.commit()
conn.close()
return True
def generate_order_no(wholesaler_id: str) -> str:
"""주문번호 생성 (ORD-GEO-20260306-001)"""
prefix_map = {
'geoyoung': 'GEO',
'sooin': 'SOO',
'baekje': 'BAK'
}
prefix = prefix_map.get(wholesaler_id, 'ORD')
date_str = datetime.now().strftime('%Y%m%d')
conn = get_connection()
cursor = conn.cursor()
# 오늘 해당 도매상 주문 수 카운트
cursor.execute('''
SELECT COUNT(*) FROM orders
WHERE wholesaler_id = ? AND order_date = ?
''', (wholesaler_id, datetime.now().strftime('%Y-%m-%d')))
count = cursor.fetchone()[0] + 1
conn.close()
return f"ORD-{prefix}-{date_str}-{count:03d}"
def create_order(wholesaler_id: str, items: List[Dict],
order_type: str = 'manual',
order_session: str = None,
reference_period: str = None,
note: str = None) -> Dict:
"""
주문 생성 (draft 상태)
items: [
{
'drug_code': '670400830',
'kd_code': '670400830',
'product_name': '레바미피드정 30T',
'manufacturer': '휴온스',
'specification': '30T',
'unit_qty': 30,
'order_qty': 10,
'usage_qty': 280,
'current_stock': 50
}
]
"""
conn = get_connection()
cursor = conn.cursor()
try:
order_no = generate_order_no(wholesaler_id)
now = datetime.now()
# 주문 헤더 생성
cursor.execute('''
INSERT INTO orders (
order_no, wholesaler_id, order_date, order_time,
order_type, order_session, reference_period, note,
total_items, total_qty, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft')
''', (
order_no,
wholesaler_id,
now.strftime('%Y-%m-%d'),
now.strftime('%H:%M:%S'),
order_type,
order_session,
reference_period,
note,
len(items),
sum(item.get('order_qty', 0) for item in items)
))
order_id = cursor.lastrowid
# 주문 품목 생성
for item in items:
unit_qty = item.get('unit_qty', 1)
order_qty = item.get('order_qty', 0)
cursor.execute('''
INSERT INTO order_items (
order_id, drug_code, kd_code, product_name, manufacturer,
specification, unit_qty, order_qty, total_dose,
usage_qty, current_stock, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
''', (
order_id,
item.get('drug_code'),
item.get('kd_code'),
item.get('product_name'),
item.get('manufacturer'),
item.get('specification'),
unit_qty,
order_qty,
order_qty * unit_qty,
item.get('usage_qty'),
item.get('current_stock')
))
# 로그
cursor.execute('''
INSERT INTO order_logs (order_id, action, new_status, message)
VALUES (?, 'created', 'draft', ?)
''', (order_id, f'{len(items)}개 품목 주문 생성'))
conn.commit()
return {
'success': True,
'order_id': order_id,
'order_no': order_no,
'total_items': len(items)
}
except Exception as e:
conn.rollback()
return {'success': False, 'error': str(e)}
finally:
conn.close()
def get_order(order_id: int) -> Optional[Dict]:
"""주문 조회 (품목 포함)"""
conn = get_connection()
cursor = conn.cursor()
cursor.execute('SELECT * FROM orders WHERE id = ?', (order_id,))
order = cursor.fetchone()
if not order:
conn.close()
return None
cursor.execute('SELECT * FROM order_items WHERE order_id = ?', (order_id,))
items = cursor.fetchall()
conn.close()
return {
**dict(order),
'items': [dict(item) for item in items]
}
def update_order_status(order_id: int, status: str, message: str = None) -> bool:
"""주문 상태 업데이트"""
conn = get_connection()
cursor = conn.cursor()
try:
# 현재 상태 조회
cursor.execute('SELECT status FROM orders WHERE id = ?', (order_id,))
row = cursor.fetchone()
if not row:
return False
old_status = row['status']
# 상태 업데이트
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
update_fields = ['status = ?', 'updated_at = ?']
params = [status, now]
if status == 'submitted':
update_fields.append('submitted_at = ?')
params.append(now)
elif status in ('completed', 'failed'):
update_fields.append('completed_at = ?')
params.append(now)
params.append(order_id)
cursor.execute(f'''
UPDATE orders SET {', '.join(update_fields)} WHERE id = ?
''', params)
# 로그
cursor.execute('''
INSERT INTO order_logs (order_id, action, old_status, new_status, message)
VALUES (?, ?, ?, ?, ?)
''', (order_id, status, old_status, status, message))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def update_item_result(item_id: int, status: str, result_code: str = None,
result_message: str = None, wholesaler_order_no: str = None) -> bool:
"""품목 결과 업데이트"""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute('''
UPDATE order_items SET
status = ?,
result_code = ?,
result_message = ?,
wholesaler_order_no = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (status, result_code, result_message, wholesaler_order_no, item_id))
# 주문 집계 업데이트
cursor.execute('SELECT order_id FROM order_items WHERE id = ?', (item_id,))
order_id = cursor.fetchone()['order_id']
cursor.execute('''
UPDATE orders SET
success_items = (SELECT COUNT(*) FROM order_items WHERE order_id = ? AND status = 'success'),
failed_items = (SELECT COUNT(*) FROM order_items WHERE order_id = ? AND status = 'failed'),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (order_id, order_id, order_id))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def get_order_history(wholesaler_id: str = None,
start_date: str = None,
end_date: str = None,
status: str = None,
limit: int = 50) -> List[Dict]:
"""주문 이력 조회"""
conn = get_connection()
cursor = conn.cursor()
query = 'SELECT * FROM orders WHERE 1=1'
params = []
if wholesaler_id:
query += ' AND wholesaler_id = ?'
params.append(wholesaler_id)
if start_date:
query += ' AND order_date >= ?'
params.append(start_date)
if end_date:
query += ' AND order_date <= ?'
params.append(end_date)
if status:
query += ' AND status = ?'
params.append(status)
query += ' ORDER BY created_at DESC LIMIT ?'
params.append(limit)
cursor.execute(query, params)
orders = [dict(row) for row in cursor.fetchall()]
conn.close()
return orders
# ─────────────────────────────────────────────
# AI 학습용 함수들
# ─────────────────────────────────────────────
def save_order_context(order_item_id: int, context: Dict) -> bool:
"""
주문 시점 컨텍스트 저장 (AI 학습용)
context: {
'drug_code': '670400830',
'product_name': '레바미피드정',
'stock_at_order': 50,
'usage_1d': 30,
'usage_7d': 180,
'usage_30d': 800,
'ordered_spec': '30T',
'ordered_qty': 10,
'available_specs': ['30T', '300T'],
'spec_stocks': {'30T': 50, '300T': 0},
'selection_reason': 'stock_available'
}
"""
conn = get_connection()
cursor = conn.cursor()
try:
# 일평균 사용량 계산
usage_30d = context.get('usage_30d', 0)
avg_daily = usage_30d / 30.0 if usage_30d else 0
# 재고 소진 예상일 계산
stock = context.get('stock_at_order', 0)
days_until_stockout = stock / avg_daily if avg_daily > 0 else None
# 주문 총 정제수
ordered_qty = context.get('ordered_qty', 0)
spec = context.get('ordered_spec', '')
unit_qty = int(''.join(filter(str.isdigit, spec))) if spec else 1
ordered_dose = ordered_qty * unit_qty
cursor.execute('''
INSERT INTO order_context (
order_item_id, drug_code, product_name,
stock_at_order, usage_1d, usage_7d, usage_30d, avg_daily_usage,
ordered_spec, ordered_qty, ordered_dose,
available_specs, spec_stocks, selection_reason,
days_until_stockout
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
order_item_id,
context.get('drug_code'),
context.get('product_name'),
context.get('stock_at_order'),
context.get('usage_1d'),
context.get('usage_7d'),
context.get('usage_30d'),
avg_daily,
context.get('ordered_spec'),
ordered_qty,
ordered_dose,
json.dumps(context.get('available_specs', []), ensure_ascii=False),
json.dumps(context.get('spec_stocks', {}), ensure_ascii=False),
context.get('selection_reason'),
days_until_stockout
))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def update_daily_usage(drug_code: str, usage_date: str,
rx_count: int = 0, rx_qty: int = 0,
pos_count: int = 0, pos_qty: int = 0,
stock_end: int = None) -> bool:
"""일별 사용량 업데이트 (UPSERT)"""
conn = get_connection()
cursor = conn.cursor()
try:
total_qty = rx_qty + pos_qty
cursor.execute('''
INSERT INTO daily_usage (
drug_code, usage_date, rx_count, rx_qty, pos_count, pos_qty,
total_qty, stock_end
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(drug_code, usage_date) DO UPDATE SET
rx_count = rx_count + excluded.rx_count,
rx_qty = rx_qty + excluded.rx_qty,
pos_count = pos_count + excluded.pos_count,
pos_qty = pos_qty + excluded.pos_qty,
total_qty = total_qty + excluded.total_qty,
stock_end = COALESCE(excluded.stock_end, stock_end)
''', (drug_code, usage_date, rx_count, rx_qty, pos_count, pos_qty,
total_qty, stock_end))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def get_usage_stats(drug_code: str, days: int = 30) -> Dict:
"""약품 사용량 통계 조회 (AI 분석용)"""
conn = get_connection()
cursor = conn.cursor()
from datetime import datetime, timedelta
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cursor.execute('''
SELECT
COUNT(*) as days_with_data,
SUM(total_qty) as total_usage,
AVG(total_qty) as avg_daily,
MAX(total_qty) as max_daily,
MIN(total_qty) as min_daily
FROM daily_usage
WHERE drug_code = ? AND usage_date BETWEEN ? AND ?
''', (drug_code, start_date, end_date))
row = cursor.fetchone()
conn.close()
if row and row['total_usage']:
return {
'drug_code': drug_code,
'period_days': days,
'days_with_data': row['days_with_data'],
'total_usage': row['total_usage'],
'avg_daily': round(row['avg_daily'], 2) if row['avg_daily'] else 0,
'max_daily': row['max_daily'],
'min_daily': row['min_daily']
}
return {
'drug_code': drug_code,
'period_days': days,
'days_with_data': 0,
'total_usage': 0,
'avg_daily': 0,
'max_daily': 0,
'min_daily': 0
}
def get_order_pattern(drug_code: str) -> Optional[Dict]:
"""약품 주문 패턴 조회"""
conn = get_connection()
cursor = conn.cursor()
# 최근 주문 이력 분석
cursor.execute('''
SELECT
oi.specification,
oi.order_qty,
oi.total_dose,
o.order_date
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE oi.drug_code = ? AND oi.status = 'success'
ORDER BY o.order_date DESC
LIMIT 10
''', (drug_code,))
orders = [dict(row) for row in cursor.fetchall()]
if not orders:
conn.close()
return None
# 가장 많이 사용된 규격
spec_counts = {}
for o in orders:
spec = o['specification']
spec_counts[spec] = spec_counts.get(spec, 0) + 1
typical_spec = max(spec_counts, key=spec_counts.get)
# 평균 주문 수량
typical_qty = sum(o['order_qty'] for o in orders) // len(orders)
# 주문 주기 계산
if len(orders) >= 2:
dates = [datetime.strptime(o['order_date'], '%Y-%m-%d') for o in orders]
intervals = [(dates[i] - dates[i+1]).days for i in range(len(dates)-1)]
avg_interval = sum(intervals) / len(intervals) if intervals else 0
else:
avg_interval = 0
conn.close()
return {
'drug_code': drug_code,
'order_count': len(orders),
'typical_spec': typical_spec,
'typical_qty': typical_qty,
'avg_order_interval_days': round(avg_interval, 1),
'recent_orders': orders[:5]
}
def get_ai_training_data(limit: int = 1000) -> List[Dict]:
"""AI 학습용 데이터 추출"""
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT
oc.*,
oi.status as order_status,
oi.result_code,
o.order_date,
o.wholesaler_id
FROM order_context oc
JOIN order_items oi ON oc.order_item_id = oi.id
JOIN orders o ON oi.order_id = o.id
ORDER BY oc.created_at DESC
LIMIT ?
''', (limit,))
data = []
for row in cursor.fetchall():
item = dict(row)
# JSON 필드 파싱
if item.get('available_specs'):
item['available_specs'] = json.loads(item['available_specs'])
if item.get('spec_stocks'):
item['spec_stocks'] = json.loads(item['spec_stocks'])
data.append(item)
conn.close()
return data
def save_ai_pattern(drug_code: str, pattern: Dict) -> bool:
"""AI 분석 결과 저장"""
conn = get_connection()
cursor = conn.cursor()
try:
today = datetime.now().strftime('%Y-%m-%d')
cursor.execute('''
INSERT INTO order_patterns (
drug_code, analysis_date, analysis_period_days,
avg_daily_usage, usage_stddev, peak_usage,
typical_order_spec, typical_order_qty, order_frequency_days,
recommended_spec, recommended_qty, recommended_reorder_point,
confidence_score, model_version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(drug_code, analysis_date) DO UPDATE SET
avg_daily_usage = excluded.avg_daily_usage,
recommended_spec = excluded.recommended_spec,
recommended_qty = excluded.recommended_qty,
confidence_score = excluded.confidence_score
''', (
drug_code,
today,
pattern.get('period_days', 30),
pattern.get('avg_daily_usage'),
pattern.get('usage_stddev'),
pattern.get('peak_usage'),
pattern.get('typical_order_spec'),
pattern.get('typical_order_qty'),
pattern.get('order_frequency_days'),
pattern.get('recommended_spec'),
pattern.get('recommended_qty'),
pattern.get('recommended_reorder_point'),
pattern.get('confidence_score'),
pattern.get('model_version', 'v1')
))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
# 초기화 실행
init_db()

View File

@@ -1,225 +0,0 @@
# -*- coding: utf-8 -*-
"""
PAAI 피드백 루프 시스템
- 피드백 수집, AI 정제, 프롬프트 인젝션
"""
import sqlite3
import os
import json
from datetime import datetime
from flask import Blueprint, request, jsonify
paai_feedback_bp = Blueprint('paai_feedback', __name__)
# DB 경로
DB_PATH = os.path.join(os.path.dirname(__file__), 'db', 'paai_feedback.db')
def get_db():
"""DB 연결"""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""테이블 초기화"""
conn = get_db()
conn.execute('''
CREATE TABLE IF NOT EXISTS paai_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 컨텍스트
prescription_id TEXT,
patient_name TEXT,
patient_context TEXT,
-- PAAI 응답
paai_request TEXT,
paai_response TEXT,
-- 피드백
rating TEXT,
category TEXT,
pharmacist_comment TEXT,
-- AI 정제 결과
refined_rule TEXT,
confidence REAL,
-- 적용 상태
applied_to_prompt INTEGER DEFAULT 0,
applied_to_training INTEGER DEFAULT 0
)
''')
conn.commit()
conn.close()
# 앱 시작 시 테이블 생성
init_db()
@paai_feedback_bp.route('/api/paai/feedback', methods=['POST'])
def submit_feedback():
"""피드백 제출"""
try:
data = request.json
conn = get_db()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO paai_feedback (
prescription_id, patient_name, patient_context,
paai_request, paai_response,
rating, category, pharmacist_comment
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (
data.get('prescription_id'),
data.get('patient_name'),
json.dumps(data.get('patient_context', {}), ensure_ascii=False),
data.get('paai_request'),
data.get('paai_response'),
data.get('rating'), # 'good' or 'bad'
data.get('category'), # 'interaction', 'indication', 'dosage', 'other'
data.get('pharmacist_comment')
))
feedback_id = cursor.lastrowid
conn.commit()
# bad 피드백이고 코멘트가 있으면 AI 정제 시도
if data.get('rating') == 'bad' and data.get('pharmacist_comment'):
refined = refine_feedback_async(feedback_id, data)
if refined:
cursor.execute('''
UPDATE paai_feedback
SET refined_rule = ?, confidence = ?
WHERE id = ?
''', (refined['rule'], refined['confidence'], feedback_id))
conn.commit()
conn.close()
return jsonify({
'success': True,
'feedback_id': feedback_id,
'message': '피드백이 저장되었습니다.'
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
def refine_feedback_async(feedback_id, data):
"""피드백을 규칙으로 정제 (동기 버전 - 나중에 비동기로 변경 가능)"""
try:
# TODO: AI 호출로 정제
# 지금은 간단히 코멘트를 규칙 형태로 저장
comment = data.get('pharmacist_comment', '')
if not comment:
return None
# 간단한 규칙 형태로 변환
category = data.get('category', 'other')
rule = f"[{category}] {comment}"
return {
'rule': rule,
'confidence': 0.8 # 기본 신뢰도
}
except:
return None
@paai_feedback_bp.route('/api/paai/feedback/rules', methods=['GET'])
def get_feedback_rules():
"""축적된 피드백 규칙 조회 (프롬프트 인젝션용)"""
try:
conn = get_db()
cursor = conn.cursor()
# bad 피드백 중 정제된 규칙만
cursor.execute('''
SELECT refined_rule, category, created_at
FROM paai_feedback
WHERE rating = 'bad'
AND refined_rule IS NOT NULL
ORDER BY created_at DESC
LIMIT 20
''')
rules = []
for row in cursor.fetchall():
rules.append({
'rule': row['refined_rule'],
'category': row['category'],
'created_at': row['created_at']
})
conn.close()
return jsonify({
'success': True,
'rules': rules,
'count': len(rules)
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@paai_feedback_bp.route('/api/paai/feedback/stats', methods=['GET'])
def get_feedback_stats():
"""피드백 통계"""
try:
conn = get_db()
cursor = conn.cursor()
cursor.execute('''
SELECT
COUNT(*) as total,
SUM(CASE WHEN rating = 'good' THEN 1 ELSE 0 END) as good,
SUM(CASE WHEN rating = 'bad' THEN 1 ELSE 0 END) as bad
FROM paai_feedback
''')
row = cursor.fetchone()
conn.close()
return jsonify({
'success': True,
'stats': {
'total': row['total'] or 0,
'good': row['good'] or 0,
'bad': row['bad'] or 0
}
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
def get_rules_for_prompt(patient_context=None):
"""프롬프트에 주입할 규칙 목록 반환"""
try:
conn = get_db()
cursor = conn.cursor()
# 최근 규칙 20개
cursor.execute('''
SELECT refined_rule
FROM paai_feedback
WHERE rating = 'bad'
AND refined_rule IS NOT NULL
ORDER BY created_at DESC
LIMIT 20
''')
rules = [row['refined_rule'] for row in cursor.fetchall()]
conn.close()
return rules
except:
return []

View File

@@ -1,159 +0,0 @@
"""PAAI ESC/POS 프린터 모듈"""
import json
from datetime import datetime
from escpos.printer import Network
from PIL import Image, ImageDraw, ImageFont
# 프린터 설정
PRINTER_IP = "192.168.0.174"
PRINTER_PORT = 9100
THERMAL_WIDTH = 576
def print_paai_result(pre_serial: str, patient_name: str, analysis: dict, kims_summary: dict) -> dict:
"""PAAI 분석 결과 인쇄"""
try:
# 이미지 생성
img = create_receipt_image(pre_serial, patient_name, analysis, kims_summary)
# 프린터 연결 및 출력
p = Network(PRINTER_IP, port=PRINTER_PORT, timeout=15)
p.image(img)
p.text('\n\n\n')
p.cut()
return {'success': True, 'message': '인쇄 완료'}
except Exception as e:
return {'success': False, 'error': str(e)}
def create_receipt_image(pre_serial: str, patient_name: str, analysis: dict, kims_summary: dict) -> Image:
"""영수증 이미지 생성"""
# 폰트
try:
font_title = ImageFont.truetype('malgun.ttf', 28)
font_section = ImageFont.truetype('malgunbd.ttf', 20)
font_normal = ImageFont.truetype('malgun.ttf', 18)
font_small = ImageFont.truetype('malgun.ttf', 15)
except:
font_title = ImageFont.load_default()
font_section = font_title
font_normal = font_title
font_small = font_title
width = THERMAL_WIDTH
padding = 20
y = padding
# 이미지 생성
img = Image.new('RGB', (width, 1000), 'white')
draw = ImageDraw.Draw(img)
# 헤더
draw.text((width//2, y), 'PAAI 복약안내', font=font_title, fill='black', anchor='mt')
y += 40
draw.line([(padding, y), (width-padding, y)], fill='black', width=1)
y += 15
# 환자 정보
draw.text((padding, y), f'환자: {patient_name}', font=font_normal, fill='black')
y += 25
draw.text((padding, y), f'처방번호: {pre_serial}', font=font_small, fill='black')
y += 20
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
draw.text((padding, y), f'출력: {now_str}', font=font_small, fill='black')
y += 25
draw.line([(padding, y), (width-padding, y)], fill='black', width=1)
y += 15
# 상호작용
interaction_count = kims_summary.get('interaction_count', 0)
has_severe = kims_summary.get('has_severe', False)
if has_severe:
draw.text((padding, y), '[주의] 중증 상호작용 있음!', font=font_section, fill='black')
elif interaction_count > 0:
draw.text((padding, y), f'약물 상호작용: {interaction_count}', font=font_normal, fill='black')
else:
draw.text((padding, y), '상호작용 없음', font=font_normal, fill='black')
y += 30
# 처방 해석
insight = analysis.get('prescription_insight', '')
if insight:
draw.text((padding, y), '[처방 해석]', font=font_section, fill='black')
y += 28
for line in wrap_text(insight, 40)[:3]:
draw.text((padding, y), line, font=font_small, fill='black')
y += 20
y += 10
# 주의사항
cautions = analysis.get('cautions', [])
if cautions:
draw.text((padding, y), '[복용 주의사항]', font=font_section, fill='black')
y += 28
for i, c in enumerate(cautions[:3], 1):
for line in wrap_text(f'{i}. {c}', 40)[:2]:
draw.text((padding, y), line, font=font_small, fill='black')
y += 20
y += 10
# 상담 포인트
counseling = analysis.get('counseling_points', [])
if counseling:
draw.text((padding, y), '[상담 포인트]', font=font_section, fill='black')
y += 28
for i, c in enumerate(counseling[:2], 1):
for line in wrap_text(f'{i}. {c}', 40)[:2]:
draw.text((padding, y), line, font=font_small, fill='black')
y += 20
y += 10
# 푸터
y += 10
draw.line([(padding, y), (width-padding, y)], fill='black', width=1)
y += 15
draw.text((width//2, y), '양구청춘약국 PAAI', font=font_small, fill='black', anchor='mt')
return img.crop((0, 0, width, y + 30))
def wrap_text(text: str, max_chars: int = 40) -> list:
"""텍스트 줄바꿈"""
lines = []
words = text.split()
current = ""
for word in words:
if len(current) + len(word) + 1 <= max_chars:
current = current + " " + word if current else word
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
return lines if lines else [text[:max_chars]]
if __name__ == '__main__':
# CLI 테스트
import sys
if len(sys.argv) > 1:
pre_serial = sys.argv[1]
else:
pre_serial = '20260305000075'
# 테스트 데이터
analysis = {
'prescription_insight': '테스트 처방입니다.',
'cautions': ['주의사항 1', '주의사항 2'],
'counseling_points': ['상담 포인트 1']
}
kims_summary = {'interaction_count': 0, 'has_severe': False}
result = print_paai_result(pre_serial, '테스트환자', analysis, kims_summary)
print(result)

View File

@@ -1,201 +0,0 @@
"""PAAI ESC/POS 프린터 CLI - EUC-KR 텍스트 방식"""
import sys
import json
import socket
from datetime import datetime
# 프린터 설정
PRINTER_IP = "192.168.0.174"
PRINTER_PORT = 9100
# ESC/POS 명령어
ESC = b'\x1b'
INIT = ESC + b'@' # 프린터 초기화
CUT = ESC + b'd\x03' # 피드 + 커트
def print_raw(data: bytes) -> bool:
"""바이트 데이터를 프린터로 전송"""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect((PRINTER_IP, PRINTER_PORT))
sock.sendall(data)
sock.close()
return True
except Exception as e:
print(f"프린터 오류: {e}", file=sys.stderr)
return False
def wrap_text(text: str, width: int = 44) -> list:
"""텍스트 줄바꿈 (44자 기준, 들여쓰기 고려)"""
if not text:
return []
lines = []
words = text.split()
current = ""
for word in words:
if len(current) + len(word) + 1 <= width:
current = current + " " + word if current else word
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
return lines if lines else [text[:width]]
def center_text(text: str, width: int = 48) -> str:
"""중앙 정렬"""
text_len = len(text)
if text_len >= width:
return text
spaces = (width - text_len) // 2
return " " * spaces + text
def format_paai_receipt(pre_serial: str, patient_name: str,
analysis: dict, kims_summary: dict) -> str:
"""PAAI 복약안내 영수증 텍스트 생성 (48자 기준)"""
LINE = "=" * 48
THIN = "-" * 48
now = datetime.now().strftime("%Y-%m-%d %H:%M")
# 헤더
msg = f"\n{LINE}\n"
msg += center_text("[ PAAI 복약안내 ]") + "\n"
msg += f"{LINE}\n"
# 환자 정보
msg += f"환자: {patient_name}\n"
msg += f"처방번호: {pre_serial}\n"
msg += f"출력: {now}\n"
msg += f"{THIN}\n"
# 상호작용 요약
interaction_count = kims_summary.get('interaction_count', 0)
has_severe = kims_summary.get('has_severe', False)
if has_severe:
msg += "[!!] 중증 상호작용 있음!\n"
elif interaction_count > 0:
msg += f"[!] 약물 상호작용: {interaction_count}\n"
else:
msg += "[V] 상호작용 없음\n"
msg += "\n"
# 처방 해석
insight = analysis.get('prescription_insight', '')
if insight:
msg += f"{THIN}\n"
msg += ">> 처방 해석\n"
for line in wrap_text(insight, 44):
msg += f" {line}\n"
msg += "\n"
# 복용 주의사항
cautions = analysis.get('cautions', [])
if cautions:
msg += f"{THIN}\n"
msg += ">> 복용 주의사항\n"
for i, caution in enumerate(cautions[:4], 1):
# 첫 줄
first_line = True
for line in wrap_text(f"{i}. {caution}", 44):
if first_line:
msg += f" {line}\n"
first_line = False
else:
msg += f" {line}\n"
msg += "\n"
# 상담 포인트
counseling = analysis.get('counseling_points', [])
if counseling:
msg += f"{THIN}\n"
msg += ">> 상담 포인트\n"
for i, point in enumerate(counseling[:3], 1):
first_line = True
for line in wrap_text(f"{i}. {point}", 44):
if first_line:
msg += f" {line}\n"
first_line = False
else:
msg += f" {line}\n"
msg += "\n"
# OTC 추천
otc_recs = analysis.get('otc_recommendations', [])
if otc_recs:
msg += f"{THIN}\n"
msg += ">> OTC 추천\n"
for rec in otc_recs[:2]:
product = rec.get('product', '')
reason = rec.get('reason', '')
msg += f" - {product}\n"
for line in wrap_text(reason, 42):
msg += f" {line}\n"
msg += "\n"
# 푸터
msg += f"{LINE}\n"
msg += center_text("양구청춘약국 PAAI") + "\n"
msg += center_text("Tel: 033-481-5222") + "\n"
msg += "\n"
return msg
def print_paai_receipt(data: dict) -> bool:
"""PAAI 영수증 인쇄"""
try:
pre_serial = data.get('pre_serial', '')
patient_name = data.get('patient_name', '')
analysis = data.get('analysis', {})
kims_summary = data.get('kims_summary', {})
# 텍스트 생성
message = format_paai_receipt(pre_serial, patient_name, analysis, kims_summary)
# EUC-KR 인코딩 (한글 지원)
text_bytes = message.encode('euc-kr', errors='replace')
# 명령어 조합
command = INIT + text_bytes + b'\n\n\n' + CUT
return print_raw(command)
except Exception as e:
print(f"인쇄 오류: {e}", file=sys.stderr)
return False
def main():
if len(sys.argv) < 2:
print("사용법: python paai_printer_cli.py <json_file>", file=sys.stderr)
sys.exit(1)
json_path = sys.argv[1]
try:
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if print_paai_receipt(data):
print("인쇄 완료")
sys.exit(0)
else:
sys.exit(1)
except Exception as e:
print(f"오류: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

File diff suppressed because it is too large Load Diff

View File

@@ -1,169 +0,0 @@
# pos_printer.py - ESC/POS 영수증 프린터 유틸리티
# 0bin-label-app/src/pos_settings_dialog.py 기반
import socket
import logging
from datetime import datetime
# 프린터 설정 (config에서 불러올 수도 있음)
POS_PRINTER_IP = "192.168.0.174"
POS_PRINTER_PORT = 9100
POS_PRINTER_NAME = "올댓포스 오른쪽"
# ESC/POS 명령어
ESC = b'\x1b'
GS = b'\x1d'
# 기본 명령
INIT = ESC + b'@' # 프린터 초기화
CUT = ESC + b'd\x03' # 피드 + 커트 (원본 방식)
FEED = b'\n\n\n' # 줄바꿈
# 정렬
ALIGN_LEFT = ESC + b'a\x00'
ALIGN_CENTER = ESC + b'a\x01'
ALIGN_RIGHT = ESC + b'a\x02'
# 폰트 스타일
BOLD_ON = ESC + b'E\x01'
BOLD_OFF = ESC + b'E\x00'
DOUBLE_HEIGHT = ESC + b'!\x10'
DOUBLE_WIDTH = ESC + b'!\x20'
DOUBLE_SIZE = ESC + b'!\x30' # 가로세로 2배
NORMAL_SIZE = ESC + b'!\x00'
# 로깅
logging.basicConfig(level=logging.INFO)
def print_raw(data: bytes, ip: str = None, port: int = None) -> bool:
"""
ESC/POS 바이트 데이터를 프린터로 전송
Args:
data: ESC/POS 명령어 + 텍스트 바이트
ip: 프린터 IP (기본값: POS_PRINTER_IP)
port: 프린터 포트 (기본값: POS_PRINTER_PORT)
Returns:
bool: 성공 여부
"""
ip = ip or POS_PRINTER_IP
port = port or POS_PRINTER_PORT
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect((ip, port))
sock.sendall(data)
sock.close()
logging.info(f"[POS Printer] 전송 성공: {ip}:{port}")
return True
except socket.timeout:
logging.error(f"[POS Printer] 연결 시간 초과: {ip}:{port}")
return False
except ConnectionRefusedError:
logging.error(f"[POS Printer] 연결 거부됨: {ip}:{port}")
return False
except Exception as e:
logging.error(f"[POS Printer] 전송 실패: {e}")
return False
def print_text(text: str, cut: bool = True) -> bool:
"""
텍스트를 영수증 프린터로 출력
Args:
text: 출력할 텍스트 (한글 지원)
cut: 출력 후 용지 커트 여부
Returns:
bool: 성공 여부
"""
try:
# EUC-KR 인코딩 (한글 지원)
text_bytes = text.encode('euc-kr', errors='replace')
# 명령어 조합
command = INIT + text_bytes + b'\n\n\n'
if cut:
command += CUT
return print_raw(command)
except Exception as e:
logging.error(f"[POS Printer] 텍스트 인쇄 실패: {e}")
return False
def print_cusetc(customer_name: str, cusetc: str, phone: str = None) -> bool:
"""
특이(참고)사항 영수증 출력 (단순 텍스트 방식)
Args:
customer_name: 고객 이름
cusetc: 특이사항 내용
phone: 전화번호 (선택)
Returns:
bool: 성공 여부
"""
now = datetime.now().strftime('%Y-%m-%d %H:%M')
# 전화번호 포맷팅
phone_display = ""
if phone:
phone_clean = phone.replace("-", "").replace(" ", "")
if len(phone_clean) == 11:
phone_display = f"{phone_clean[:3]}-{phone_clean[3:7]}-{phone_clean[7:]}"
else:
phone_display = phone
# 80mm 프린터 = 48자 기준
LINE = "=" * 48
THIN = "-" * 48
message = f"""
{LINE}
[ 특이사항 ]
{LINE}
고객: {customer_name}
"""
if phone_display:
message += f"연락처: {phone_display}\n"
message += f"""출력: {now}
{THIN}
{cusetc}
{LINE}
청춘약국
"""
return print_text(message, cut=True)
def test_print() -> bool:
"""테스트 인쇄"""
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
test_message = f"""
================================
POS 프린터 테스트
================================
IP: {POS_PRINTER_IP}
Port: {POS_PRINTER_PORT}
Time: {now}
청춘약국 마일리지 시스템
ESC/POS 정상 작동!
================================
"""
return print_text(test_message, cut=True)
if __name__ == "__main__":
# 테스트
print("POS 프린터 테스트 인쇄...")
result = test_print()
print(f"결과: {'성공' if result else '실패'}")

View File

@@ -5,6 +5,7 @@ MSSQL DB에서 약품 정보 조회 기능 포함
"""
import sys
import os
import serial
import serial.tools.list_ports
from datetime import datetime
@@ -19,6 +20,8 @@ from sqlalchemy import text
# MSSQL 데이터베이스 연결
sys.path.insert(0, '.')
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'db'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from dbsetup import DatabaseManager
# 바코드 라벨 출력

View File

@@ -0,0 +1,713 @@
"""
더미 POS 시스템 GUI (PyQt5)
바코드 스캐너로 제품을 추가하고 수량 조절, 할인 적용, 결제까지 지원
"""
import sys
import os
import serial
import serial.tools.list_ports
from datetime import datetime
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QGroupBox, QComboBox, QSpinBox,
QTableWidget, QTableWidgetItem, QHeaderView, QFrame,
QLineEdit, QDialog, QFormLayout, QDoubleSpinBox, QMessageBox,
QAbstractItemView, QCheckBox, QSplitter
)
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer
from PyQt5.QtGui import QFont, QColor, QBrush, QIcon
from sqlalchemy import text
# DB 연결
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'db'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from dbsetup import DatabaseManager
# ─── GS1 바코드 파싱 ───────────────────────────────────────────
def parse_gs1_barcode(barcode):
candidates = [barcode]
if barcode.startswith('01') and len(barcode) >= 16:
gtin14 = barcode[2:16]
candidates.append(gtin14)
if gtin14.startswith('0'):
candidates.append(gtin14[1:])
elif barcode.startswith('01') and len(barcode) == 15:
candidates.append(barcode[2:15])
return candidates
def search_drug_by_barcode(barcode):
try:
db_manager = DatabaseManager()
engine = db_manager.get_engine('PM_DRUG')
query = text('''
SELECT TOP 1
BARCODE, GoodsName, DrugCode, SplName,
Price, Saleprice, SUNG_CODE, IsUSE
FROM CD_GOODS
WHERE BARCODE = :barcode
AND (GoodsName NOT LIKE N'%(판매중지)%'
AND GoodsName NOT LIKE N'%(판매중단)%')
ORDER BY
CASE WHEN IsUSE = '1' THEN 0 ELSE 1 END,
CASE WHEN Price > 0 THEN 0 ELSE 1 END,
CASE WHEN SplName IS NOT NULL AND SplName != '' THEN 0 ELSE 1 END,
DrugCode DESC
''')
candidates = parse_gs1_barcode(barcode)
with engine.connect() as conn:
for candidate in candidates:
result = conn.execute(query, {"barcode": candidate})
row = result.fetchone()
if row:
return {
'barcode': row.BARCODE,
'goods_name': row.GoodsName,
'drug_code': row.DrugCode,
'manufacturer': row.SplName or '',
'price': float(row.Price) if row.Price else 0,
'sale_price': float(row.Saleprice) if row.Saleprice else 0,
'sung_code': row.SUNG_CODE or ''
}
return None
except Exception as e:
print(f'[오류] 약품 조회 실패: {e}')
return None
# ─── 바코드 리더 스레드 ────────────────────────────────────────
class BarcodeReaderThread(QThread):
barcode_received = pyqtSignal(str)
connection_status = pyqtSignal(bool, str)
def __init__(self, port='COM3', baudrate=115200):
super().__init__()
self.port = port
self.baudrate = baudrate
self.running = False
self.serial_connection = None
def run(self):
self.running = True
try:
self.serial_connection = serial.Serial(
port=self.port, baudrate=self.baudrate,
bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE, timeout=1
)
self.connection_status.emit(True, f'{self.port} 연결됨 ({self.baudrate} bps)')
while self.running:
if self.serial_connection.in_waiting > 0:
data = self.serial_connection.read(self.serial_connection.in_waiting)
try:
text_data = data.decode('utf-8')
except UnicodeDecodeError:
text_data = data.decode('ascii', errors='ignore')
for line in text_data.strip().split('\n'):
barcode = line.strip()
if barcode and len(barcode) in [13, 15, 16]:
self.barcode_received.emit(barcode)
except serial.SerialException as e:
self.connection_status.emit(False, f'연결 실패: {e}')
except Exception as e:
self.connection_status.emit(False, f'오류: {e}')
finally:
if self.serial_connection and self.serial_connection.is_open:
self.serial_connection.close()
def stop(self):
self.running = False
if self.serial_connection and self.serial_connection.is_open:
self.serial_connection.close()
class DrugSearchThread(QThread):
search_complete = pyqtSignal(str, object)
def __init__(self, barcode):
super().__init__()
self.barcode = barcode
def run(self):
info = search_drug_by_barcode(self.barcode)
self.search_complete.emit(self.barcode, info)
# ─── 할인 다이얼로그 ──────────────────────────────────────────
class DiscountDialog(QDialog):
def __init__(self, item_name, current_price, parent=None):
super().__init__(parent)
self.setWindowTitle(f'할인 적용 - {item_name}')
self.setMinimumWidth(350)
self.result_discount = 0
layout = QVBoxLayout()
info = QLabel(f'제품: {item_name}\n판매가: {current_price:,.0f}')
info.setStyleSheet('font-size: 14px; padding: 10px;')
layout.addWidget(info)
form = QFormLayout()
self.discount_type = QComboBox()
self.discount_type.addItems(['금액 할인 (원)', '비율 할인 (%)'])
form.addRow('할인 방식:', self.discount_type)
self.discount_value = QDoubleSpinBox()
self.discount_value.setMaximum(999999)
self.discount_value.setDecimals(0)
form.addRow('할인값:', self.discount_value)
layout.addLayout(form)
self.preview_label = QLabel('')
self.preview_label.setStyleSheet('font-size: 13px; color: #E53935; padding: 10px; font-weight: bold;')
layout.addWidget(self.preview_label)
self.discount_value.valueChanged.connect(
lambda: self._update_preview(current_price))
self.discount_type.currentIndexChanged.connect(
lambda: self._update_preview(current_price))
btn_layout = QHBoxLayout()
ok_btn = QPushButton('적용')
ok_btn.setStyleSheet('background: #4CAF50; color: white; font-weight: bold; padding: 8px 24px;')
ok_btn.clicked.connect(lambda: self._apply(current_price))
cancel_btn = QPushButton('취소')
cancel_btn.setStyleSheet('padding: 8px 24px;')
cancel_btn.clicked.connect(self.reject)
btn_layout.addWidget(cancel_btn)
btn_layout.addWidget(ok_btn)
layout.addLayout(btn_layout)
self.setLayout(layout)
def _update_preview(self, price):
val = self.discount_value.value()
if self.discount_type.currentIndex() == 0:
disc = val
else:
disc = price * val / 100
final = max(0, price - disc)
self.preview_label.setText(f'할인: -{disc:,.0f}원 → 최종가: {final:,.0f}')
def _apply(self, price):
val = self.discount_value.value()
if self.discount_type.currentIndex() == 0:
self.result_discount = val
else:
self.result_discount = price * val / 100
self.accept()
# ─── 메인 POS GUI ─────────────────────────────────────────────
class POSDummyGUI(QMainWindow):
def __init__(self):
super().__init__()
self.reader_thread = None
self.search_threads = []
self.cart_items = [] # [{barcode, goods_name, manufacturer, price, sale_price, qty, discount}]
self.init_ui()
def init_ui(self):
self.setWindowTitle('청춘약국 POS')
self.setGeometry(50, 50, 1200, 800)
self.setStyleSheet('''
QMainWindow { background: #F5F5F5; }
QGroupBox {
font-weight: bold; font-size: 13px;
border: 1px solid #E0E0E0; border-radius: 6px;
margin-top: 12px; padding-top: 18px;
background: white;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 12px; padding: 0 6px;
}
''')
central = QWidget()
self.setCentralWidget(central)
root_layout = QVBoxLayout()
root_layout.setContentsMargins(12, 8, 12, 8)
central.setLayout(root_layout)
# ── 상단: 연결 설정 ──
conn_group = QGroupBox('스캐너 연결')
conn_layout = QHBoxLayout()
conn_group.setLayout(conn_layout)
conn_layout.addWidget(QLabel('포트:'))
self.port_combo = QComboBox()
self.port_combo.setMinimumWidth(200)
self._refresh_ports()
conn_layout.addWidget(self.port_combo)
refresh_btn = QPushButton('')
refresh_btn.setFixedWidth(36)
refresh_btn.clicked.connect(self._refresh_ports)
conn_layout.addWidget(refresh_btn)
conn_layout.addWidget(QLabel('속도:'))
self.baudrate_spin = QSpinBox()
self.baudrate_spin.setRange(9600, 921600)
self.baudrate_spin.setValue(115200)
self.baudrate_spin.setSingleStep(9600)
conn_layout.addWidget(self.baudrate_spin)
self.connect_btn = QPushButton('연결')
self.connect_btn.setStyleSheet(
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
self.connect_btn.clicked.connect(self._toggle_connection)
conn_layout.addWidget(self.connect_btn)
self.status_label = QLabel('대기 중')
self.status_label.setStyleSheet('color: #9E9E9E; font-size: 13px; margin-left: 12px;')
conn_layout.addWidget(self.status_label)
conn_layout.addStretch()
# 수동 바코드 입력
conn_layout.addWidget(QLabel('수동입력:'))
self.manual_input = QLineEdit()
self.manual_input.setPlaceholderText('바코드 번호 입력 후 Enter')
self.manual_input.setMinimumWidth(180)
self.manual_input.returnPressed.connect(self._manual_barcode)
conn_layout.addWidget(self.manual_input)
root_layout.addWidget(conn_group)
# ── 중앙: 장바구니 테이블 + 우측 요약 ──
splitter = QSplitter(Qt.Horizontal)
# 장바구니 테이블
cart_group = QGroupBox('장바구니')
cart_layout = QVBoxLayout()
cart_group.setLayout(cart_layout)
self.cart_table = QTableWidget()
self.cart_table.setColumnCount(8)
self.cart_table.setHorizontalHeaderLabels([
'제품명', '제조사', '바코드', '입고가', '판매가', '수량', '할인', '소계'
])
header = self.cart_table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.Stretch)
for i in [1]:
header.setSectionResizeMode(i, QHeaderView.ResizeToContents)
for i in [2, 3, 4, 5, 6, 7]:
header.setSectionResizeMode(i, QHeaderView.ResizeToContents)
self.cart_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.cart_table.setAlternatingRowColors(True)
self.cart_table.setStyleSheet('''
QTableWidget {
font-size: 13px; gridline-color: #E0E0E0;
alternate-background-color: #FAFAFA;
}
QHeaderView::section {
background: #37474F; color: white;
font-weight: bold; font-size: 12px;
padding: 6px; border: none;
}
''')
self.cart_table.verticalHeader().setVisible(False)
cart_layout.addWidget(self.cart_table)
# 장바구니 아래 버튼들
cart_btn_layout = QHBoxLayout()
qty_up_btn = QPushButton('+1')
qty_up_btn.setStyleSheet(
'background: #2196F3; color: white; font-weight: bold; font-size: 16px; padding: 8px 16px; border-radius: 4px;')
qty_up_btn.clicked.connect(lambda: self._change_qty(1))
cart_btn_layout.addWidget(qty_up_btn)
qty_down_btn = QPushButton('-1')
qty_down_btn.setStyleSheet(
'background: #FF9800; color: white; font-weight: bold; font-size: 16px; padding: 8px 16px; border-radius: 4px;')
qty_down_btn.clicked.connect(lambda: self._change_qty(-1))
cart_btn_layout.addWidget(qty_down_btn)
discount_btn = QPushButton('할인')
discount_btn.setStyleSheet(
'background: #9C27B0; color: white; font-weight: bold; font-size: 14px; padding: 8px 16px; border-radius: 4px;')
discount_btn.clicked.connect(self._apply_discount)
cart_btn_layout.addWidget(discount_btn)
remove_btn = QPushButton('삭제')
remove_btn.setStyleSheet(
'background: #F44336; color: white; font-weight: bold; font-size: 14px; padding: 8px 16px; border-radius: 4px;')
remove_btn.clicked.connect(self._remove_selected)
cart_btn_layout.addWidget(remove_btn)
cart_btn_layout.addStretch()
clear_btn = QPushButton('전체 삭제')
clear_btn.setStyleSheet(
'background: #757575; color: white; font-size: 13px; padding: 8px 16px; border-radius: 4px;')
clear_btn.clicked.connect(self._clear_cart)
cart_btn_layout.addWidget(clear_btn)
cart_layout.addLayout(cart_btn_layout)
splitter.addWidget(cart_group)
# ── 우측 패널: 요약 + 결제 ──
right_panel = QWidget()
right_layout = QVBoxLayout()
right_layout.setContentsMargins(0, 0, 0, 0)
right_panel.setLayout(right_layout)
# 최근 스캔
scan_group = QGroupBox('최근 스캔')
scan_layout = QVBoxLayout()
scan_group.setLayout(scan_layout)
self.last_scan_label = QLabel('바코드를 스캔하세요')
self.last_scan_label.setStyleSheet('font-size: 14px; color: #757575; padding: 12px;')
self.last_scan_label.setWordWrap(True)
self.last_scan_label.setMinimumHeight(80)
scan_layout.addWidget(self.last_scan_label)
right_layout.addWidget(scan_group)
# 합계 요약
summary_group = QGroupBox('합계')
summary_layout = QVBoxLayout()
summary_group.setLayout(summary_layout)
self.item_count_label = QLabel('품목: 0개 / 수량: 0개')
self.item_count_label.setStyleSheet('font-size: 14px; color: #616161; padding: 4px 8px;')
summary_layout.addWidget(self.item_count_label)
sep1 = QFrame()
sep1.setFrameShape(QFrame.HLine)
sep1.setStyleSheet('color: #E0E0E0;')
summary_layout.addWidget(sep1)
self.cost_label = QLabel('입고 합계: 0원')
self.cost_label.setStyleSheet('font-size: 13px; color: #9E9E9E; padding: 4px 8px;')
summary_layout.addWidget(self.cost_label)
self.subtotal_label = QLabel('판매 합계: 0원')
self.subtotal_label.setStyleSheet('font-size: 14px; color: #424242; padding: 4px 8px;')
summary_layout.addWidget(self.subtotal_label)
self.discount_total_label = QLabel('할인 합계: -0원')
self.discount_total_label.setStyleSheet('font-size: 14px; color: #E53935; padding: 4px 8px;')
summary_layout.addWidget(self.discount_total_label)
sep2 = QFrame()
sep2.setFrameShape(QFrame.HLine)
sep2.setStyleSheet('color: #37474F; border: 1px solid #37474F;')
summary_layout.addWidget(sep2)
self.total_label = QLabel('총 결제금액: 0원')
self.total_label.setStyleSheet(
'font-size: 22px; font-weight: bold; color: #1B5E20; padding: 8px;')
summary_layout.addWidget(self.total_label)
self.margin_label = QLabel('마진: 0원 (0%)')
self.margin_label.setStyleSheet('font-size: 13px; color: #1565C0; padding: 4px 8px;')
summary_layout.addWidget(self.margin_label)
right_layout.addWidget(summary_group)
right_layout.addStretch()
# 결제 버튼
pay_btn = QPushButton('결 제')
pay_btn.setMinimumHeight(70)
pay_btn.setStyleSheet('''
QPushButton {
background: #1B5E20; color: white;
font-size: 26px; font-weight: bold;
border-radius: 8px;
}
QPushButton:hover { background: #2E7D32; }
QPushButton:pressed { background: #1B5E20; }
''')
pay_btn.clicked.connect(self._pay)
right_layout.addWidget(pay_btn)
splitter.addWidget(right_panel)
splitter.setSizes([800, 350])
root_layout.addWidget(splitter, 1)
# ── 하단 상태바 ──
self.statusBar().setStyleSheet('font-size: 12px; color: #757575;')
self.statusBar().showMessage('청춘약국 POS | 바코드 스캐너를 연결하고 "연결" 버튼을 누르세요')
# ── 포트 관리 ──
def _refresh_ports(self):
self.port_combo.clear()
for port in serial.tools.list_ports.comports():
self.port_combo.addItem(f'{port.device} - {port.description}', port.device)
for i in range(self.port_combo.count()):
if 'COM3' in (self.port_combo.itemData(i) or ''):
self.port_combo.setCurrentIndex(i)
break
def _toggle_connection(self):
if self.reader_thread and self.reader_thread.isRunning():
self.reader_thread.stop()
self.reader_thread.wait()
self.reader_thread = None
self.connect_btn.setText('연결')
self.connect_btn.setStyleSheet(
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
self.status_label.setText('연결 해제됨')
self.status_label.setStyleSheet('color: #9E9E9E; font-size: 13px; margin-left: 12px;')
self.statusBar().showMessage('스캐너 연결 해제')
else:
port = self.port_combo.currentData()
if not port:
self.status_label.setText('포트를 선택하세요')
return
self.reader_thread = BarcodeReaderThread(port, self.baudrate_spin.value())
self.reader_thread.barcode_received.connect(self._on_barcode)
self.reader_thread.connection_status.connect(self._on_connection)
self.reader_thread.start()
self.connect_btn.setText('연결 해제')
self.connect_btn.setStyleSheet(
'background: #F44336; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
def _on_connection(self, ok, msg):
if ok:
self.status_label.setText(msg)
self.status_label.setStyleSheet(
'color: #2E7D32; font-size: 13px; font-weight: bold; margin-left: 12px;')
self.statusBar().showMessage(f'스캐너 {msg} | 바코드를 스캔하세요')
else:
self.status_label.setText(msg)
self.status_label.setStyleSheet(
'color: #D32F2F; font-size: 13px; font-weight: bold; margin-left: 12px;')
self.connect_btn.setText('연결')
self.connect_btn.setStyleSheet(
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
# ── 바코드 수신 ──
def _manual_barcode(self):
barcode = self.manual_input.text().strip()
if barcode:
self.manual_input.clear()
self._on_barcode(barcode)
def _on_barcode(self, barcode):
self.last_scan_label.setText(f'스캔: {barcode}\n조회 중...')
self.last_scan_label.setStyleSheet('font-size: 14px; color: #FF6F00; padding: 12px;')
self.statusBar().showMessage(f'바코드 {barcode} 조회 중...')
thread = DrugSearchThread(barcode)
thread.search_complete.connect(self._on_search_done)
thread.start()
self.search_threads.append(thread)
def _on_search_done(self, barcode, info):
sender = self.sender()
if sender in self.search_threads:
self.search_threads.remove(sender)
if not info:
self.last_scan_label.setText(f'스캔: {barcode}\n제품을 찾을 수 없습니다')
self.last_scan_label.setStyleSheet('font-size: 14px; color: #D32F2F; padding: 12px;')
self.statusBar().showMessage(f'바코드 {barcode}: 데이터베이스에서 찾을 수 없음')
return
# 이미 장바구니에 있으면 수량 +1
for item in self.cart_items:
if item['barcode'] == info['barcode']:
item['qty'] += 1
self._refresh_table()
self.last_scan_label.setText(
f'{info["goods_name"]}\n수량 → {item["qty"]}')
self.last_scan_label.setStyleSheet('font-size: 14px; color: #1565C0; padding: 12px;')
self.statusBar().showMessage(f'{info["goods_name"]} 수량 +1 ({item["qty"]}개)')
return
# 새 항목 추가
self.cart_items.append({
'barcode': info['barcode'],
'goods_name': info['goods_name'],
'manufacturer': info['manufacturer'],
'price': info['price'],
'sale_price': info['sale_price'],
'qty': 1,
'discount': 0,
})
self._refresh_table()
self.last_scan_label.setText(
f'{info["goods_name"]}\n{info["manufacturer"]} | {info["sale_price"]:,.0f}')
self.last_scan_label.setStyleSheet('font-size: 14px; color: #2E7D32; padding: 12px; font-weight: bold;')
self.statusBar().showMessage(f'{info["goods_name"]} 추가됨 ({info["sale_price"]:,.0f}원)')
# ── 장바구니 조작 ──
def _selected_row(self):
rows = self.cart_table.selectionModel().selectedRows()
return rows[0].row() if rows else -1
def _change_qty(self, delta):
row = self._selected_row()
if row < 0:
self.statusBar().showMessage('제품을 선택하세요')
return
item = self.cart_items[row]
item['qty'] = max(1, item['qty'] + delta)
self._refresh_table()
self.cart_table.selectRow(row)
def _apply_discount(self):
row = self._selected_row()
if row < 0:
self.statusBar().showMessage('할인할 제품을 선택하세요')
return
item = self.cart_items[row]
dlg = DiscountDialog(item['goods_name'], item['sale_price'], self)
if dlg.exec_() == QDialog.Accepted:
item['discount'] = dlg.result_discount
self._refresh_table()
self.cart_table.selectRow(row)
def _remove_selected(self):
row = self._selected_row()
if row < 0:
self.statusBar().showMessage('삭제할 제품을 선택하세요')
return
name = self.cart_items[row]['goods_name']
del self.cart_items[row]
self._refresh_table()
self.statusBar().showMessage(f'{name} 삭제됨')
def _clear_cart(self):
if not self.cart_items:
return
reply = QMessageBox.question(
self, '전체 삭제', '장바구니를 비우시겠습니까?',
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.Yes:
self.cart_items.clear()
self._refresh_table()
self.statusBar().showMessage('장바구니 초기화')
# ── 테이블 갱신 ──
def _refresh_table(self):
self.cart_table.setRowCount(len(self.cart_items))
total_cost = 0
total_sale = 0
total_discount = 0
total_qty = 0
for i, item in enumerate(self.cart_items):
subtotal = (item['sale_price'] - item['discount']) * item['qty']
cost_total = item['price'] * item['qty']
cols = [
item['goods_name'],
item['manufacturer'],
item['barcode'],
f'{item["price"]:,.0f}',
f'{item["sale_price"]:,.0f}',
str(item['qty']),
f'-{item["discount"]:,.0f}' if item['discount'] > 0 else '',
f'{subtotal:,.0f}',
]
for j, val in enumerate(cols):
cell = QTableWidgetItem(val)
cell.setFlags(cell.flags() & ~Qt.ItemIsEditable)
# 숫자 컬럼 오른쪽 정렬
if j >= 3:
cell.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
# 할인 빨간색
if j == 6 and item['discount'] > 0:
cell.setForeground(QBrush(QColor('#E53935')))
# 소계 볼드
if j == 7:
font = cell.font()
font.setBold(True)
cell.setFont(font)
self.cart_table.setItem(i, j, cell)
total_cost += cost_total
total_sale += item['sale_price'] * item['qty']
total_discount += item['discount'] * item['qty']
total_qty += item['qty']
final_total = total_sale - total_discount
margin = final_total - total_cost
margin_pct = (margin / final_total * 100) if final_total > 0 else 0
self.item_count_label.setText(f'품목: {len(self.cart_items)}개 / 수량: {total_qty}')
self.cost_label.setText(f'입고 합계: {total_cost:,.0f}')
self.subtotal_label.setText(f'판매 합계: {total_sale:,.0f}')
self.discount_total_label.setText(f'할인 합계: -{total_discount:,.0f}')
self.total_label.setText(f'총 결제금액: {final_total:,.0f}')
self.margin_label.setText(f'마진: {margin:,.0f}원 ({margin_pct:.1f}%)')
# ── 결제 ──
def _pay(self):
if not self.cart_items:
self.statusBar().showMessage('장바구니가 비어있습니다')
return
total_sale = sum(it['sale_price'] * it['qty'] for it in self.cart_items)
total_discount = sum(it['discount'] * it['qty'] for it in self.cart_items)
final = total_sale - total_discount
items_text = '\n'.join(
f' {it["goods_name"]} x{it["qty"]} {(it["sale_price"] - it["discount"]) * it["qty"]:,.0f}'
for it in self.cart_items
)
reply = QMessageBox.question(
self, '결제 확인',
f'총 결제금액: {final:,.0f}\n\n{items_text}\n\n결제하시겠습니까?',
QMessageBox.Yes | QMessageBox.No, QMessageBox.No
)
if reply == QMessageBox.Yes:
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
QMessageBox.information(
self, '결제 완료',
f'결제가 완료되었습니다.\n\n'
f'시각: {now}\n'
f'금액: {final:,.0f}\n'
f'품목: {len(self.cart_items)}'
)
self.cart_items.clear()
self._refresh_table()
self.last_scan_label.setText('바코드를 스캔하세요')
self.last_scan_label.setStyleSheet('font-size: 14px; color: #757575; padding: 12px;')
self.statusBar().showMessage(f'결제 완료 ({final:,.0f}원) | {now}')
# ── 종료 ──
def closeEvent(self, event):
if self.reader_thread:
self.reader_thread.stop()
self.reader_thread.wait()
for t in self.search_threads:
if t.isRunning():
t.wait()
event.accept()
def main():
app = QApplication(sys.argv)
app.setStyle('Fusion')
window = POSDummyGUI()
window.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()

View File

@@ -1,82 +0,0 @@
# -*- coding: utf-8 -*-
"""
동물약 일괄 APC 매칭 - 후보 찾기
"""
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text, create_engine
# 1. MSSQL 동물약 (APC 없는 것만)
session = get_db_session('PM_DRUG')
result = session.execute(text("""
SELECT
G.DrugCode,
G.GoodsName,
G.Saleprice,
(
SELECT TOP 1 U.CD_CD_BARCODE
FROM CD_ITEM_UNIT_MEMBER U
WHERE U.DRUGCODE = G.DrugCode
AND U.CD_CD_BARCODE LIKE '023%'
) AS APC_CODE
FROM CD_GOODS G
WHERE G.POS_BOON = '010103'
AND G.GoodsSelCode = 'B'
ORDER BY G.GoodsName
"""))
no_apc = []
for row in result:
if not row.APC_CODE:
no_apc.append({
'code': row.DrugCode,
'name': row.GoodsName,
'price': row.Saleprice
})
session.close()
print(f'=== APC 없는 동물약: {len(no_apc)}개 ===\n')
# 2. PostgreSQL에서 매칭 후보 찾기
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
matches = []
for drug in no_apc:
name = drug['name']
# 제품명에서 검색 키워드 추출
# (판) 제거, 괄호 내용 제거
search_name = name.replace('(판)', '').split('(')[0].strip()
# PostgreSQL 검색
result = pg.execute(text("""
SELECT apc, product_name,
llm_pharm->>'사용가능 동물' as target,
llm_pharm->>'분류' as category
FROM apc
WHERE product_name ILIKE :pattern
ORDER BY LENGTH(product_name)
LIMIT 5
"""), {'pattern': f'%{search_name}%'})
candidates = list(result)
if candidates:
matches.append({
'mssql': drug,
'candidates': candidates
})
print(f'{name}')
for c in candidates[:2]:
print(f'{c.apc}: {c.product_name[:40]}... [{c.target or "?"}]')
else:
print(f'{name} - 매칭 없음')
pg.close()
print(f'\n=== 요약 ===')
print(f'APC 없는 제품: {len(no_apc)}')
print(f'매칭 후보 있음: {len(matches)}')
print(f'매칭 없음: {len(no_apc) - len(matches)}')

View File

@@ -1,75 +0,0 @@
# -*- coding: utf-8 -*-
"""
확실한 매칭만 일괄 등록
"""
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
from datetime import datetime
# 확실한 매칭 목록 (MSSQL 제품명, DrugCode, APC)
MAPPINGS = [
# 파라캅
('파라캅L(5kg이상)', 'LB000003159', '0230338510101'), # 파라캅 L 정 10정
('파라캅S(5kg이하)', 'LB000003160', '0230347110106'), # 파라캅 에스 정 10정
# 세레니아
('세레니아정16mg(개멀미약)', 'LB000003353', '0231884610109'), # 세레니아 정 16mg / 4정
('세레니아정24mg(개멀미약)', 'LB000003354', '0231884620107'), # 세레니아 정 24mg / 4정
]
session = get_db_session('PM_DRUG')
today = datetime.now().strftime('%Y%m%d')
print('=== 일괄 APC 매핑 ===\n')
for name, drugcode, apc in MAPPINGS:
# 기존 가격 조회
existing = session.execute(text("""
SELECT TOP 1 CD_MY_UNIT, CD_IN_UNIT
FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = :dc
ORDER BY SN DESC
"""), {'dc': drugcode}).fetchone()
if not existing:
print(f'{name}: 기존 레코드 없음')
continue
# 이미 APC 있는지 확인
check = session.execute(text("""
SELECT 1 FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = :dc AND CD_CD_BARCODE = :apc
"""), {'dc': drugcode, 'apc': apc}).fetchone()
if check:
print(f'⏭️ {name}: 이미 등록됨')
continue
# INSERT
try:
session.execute(text("""
INSERT INTO CD_ITEM_UNIT_MEMBER (
DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT,
CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE
) VALUES (
:drugcode, '015', 1.0, :my_unit, :in_unit,
:barcode, '', :change_date
)
"""), {
'drugcode': drugcode,
'my_unit': existing.CD_MY_UNIT,
'in_unit': existing.CD_IN_UNIT,
'barcode': apc,
'change_date': today
})
session.commit()
print(f'{name}{apc}')
except Exception as e:
session.rollback()
print(f'{name}: {e}')
session.close()
print('\n완료!')

View File

@@ -1,34 +0,0 @@
# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
# 테스트 AI 응답
ai_response = "개시딘은 피부염 치료에 사용하는 겔 형태의 외용약입니다."
drug_name = "(판)복합개시딘"
# 현재 매칭 로직
base_name = drug_name.split('(')[0].split('/')[0].strip()
print(f'제품명: {drug_name}')
print(f'괄호 앞: "{base_name}"')
# suffix 제거
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()
print(f'suffix 제거 후: "{base_name}"')
# 매칭 테스트
ai_lower = ai_response.lower()
ai_nospace = ai_lower.replace(' ', '')
base_lower = base_name.lower()
base_nospace = base_lower.replace(' ', '')
print(f'\n매칭 테스트:')
print(f' "{base_lower}" in ai_response? {base_lower in ai_lower}')
print(f' "{base_nospace}" in ai_nospace? {base_nospace in ai_nospace}')
# 문제: (판)이 먼저 잘려서 빈 문자열이 됨!
print(f'\n문제: split("(")[0] = "{drug_name.split("(")[0]}"')
print('"(판)"에서 "("로 시작하니까 빈 문자열!')

View File

@@ -1,51 +0,0 @@
# -*- coding: utf-8 -*-
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
# 테스트 AI 응답 (실제 응답 시뮬레이션)
ai_response = """
네, 안텔민은 개와 고양이 모두 사용 가능합니다!
**안텔민 킹** - 체중 5kg 이상 반려동물용
**안텔민 뽀삐** - 체중 5kg 이하 소형 반려동물용
두 제품 모두 개와 고양이의 내부 기생충 구제에 효과적입니다.
"""
animal_drugs = [
{'name': '안텔민킹(5kg이상)', 'code': 'LB000003157'},
{'name': '안텔민뽀삐(5kg이하)', 'code': 'LB000003158'},
{'name': '다이로하트정M(12~22kg)', 'code': 'LB000003151'},
]
print('=== 현재 매칭 로직 테스트 ===\n')
print(f'AI 응답:\n{ai_response}\n')
print('=' * 50)
ai_response_lower = ai_response.lower()
for drug in animal_drugs:
drug_name = drug['name']
base_name = drug_name.split('(')[0].split('/')[0].strip()
# suffix 제거
original_base = base_name
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()
matched = base_name.lower() in ai_response_lower
print(f'\n제품: {drug_name}')
print(f' 괄호 앞: {original_base}')
print(f' suffix 제거 후: {base_name}')
print(f' 매칭 결과: {"✅ 매칭됨" if matched else "❌ 매칭 안됨"}')
if not matched:
# 왜 안 됐는지 확인
print(f'"{base_name.lower()}" in 응답? {base_name.lower() in ai_response_lower}')
# 띄어쓰기 변형 체크
spaced = base_name.replace('', '').replace('뽀삐', ' 뽀삐')
print(f' → 띄어쓰기 변형 "{spaced.lower()}" in 응답? {spaced.lower() in ai_response_lower}')

View File

@@ -1,48 +0,0 @@
# -*- coding: utf-8 -*-
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
# 실제 AI 응답
ai_response = """안텔민은 개와 고양이 모두에게 사용할 수 있습니다만, 체중에 따라 복용할 용량이 다릅니다. 🐾
- **안텔민**: 5kg 이상 개와 고양이에게 복용 가능.
- **안텔민 뽀삐**: 5kg 미만 소형 반려동물에게 복용 가능.
따라서, 반려동물의 체중에 맞는 적절한 제품을 선택해야 해요! 🐶 체중을 알려주시면 더 구체적으로 안내해 드릴 수 있어요."""
animal_drugs = [
{'name': '안텔민', 'code': 'S0000001', 'apc': None},
{'name': '안텔민킹(5kg이상)', 'code': 'LB000003157', 'apc': '0230237810109'},
{'name': '안텔민뽀삐(5kg이하)', 'code': 'LB000003158', 'apc': '0230237010107'},
]
print('=== 매칭 테스트 ===\n')
print(f'AI 응답:\n{ai_response}\n')
print('=' * 50)
ai_response_lower = ai_response.lower()
ai_response_nospace = ai_response_lower.replace(' ', '')
for drug in animal_drugs:
drug_name = drug['name']
base_name = drug_name.split('(')[0].split('/')[0].strip()
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()
base_lower = base_name.lower()
base_nospace = base_lower.replace(' ', '')
in_normal = base_lower in ai_response_lower
in_nospace = base_nospace in ai_response_nospace
matched = len(base_name) >= 2 and (in_normal or in_nospace)
print(f'\n제품: {drug_name}')
print(f' base_name: "{base_name}"')
print(f' base_nospace: "{base_nospace}"')
print(f' 일반매칭: {in_normal}')
print(f' 공백제거매칭: {in_nospace}')
print(f' 최종: {"" if matched else ""}')

View File

@@ -1,43 +0,0 @@
# -*- coding: utf-8 -*-
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
# _get_animal_drugs 로직 복제
drug_session = get_db_session('PM_DRUG')
query = text("""
SELECT
G.DrugCode,
G.GoodsName,
G.Saleprice,
(
SELECT TOP 1 U.CD_CD_BARCODE
FROM CD_ITEM_UNIT_MEMBER U
WHERE U.DRUGCODE = G.DrugCode
AND U.CD_CD_BARCODE LIKE '023%'
ORDER BY U.CHANGE_DATE DESC
) AS APC_CODE
FROM CD_GOODS G
WHERE G.POS_BOON = '010103'
AND G.GoodsSelCode = 'B'
ORDER BY G.GoodsName
""")
rows = drug_session.execute(query).fetchall()
print('=== AI에 전달되는 보유 제품 목록 ===\n')
for r in rows:
apc = r.APC_CODE
rag_info = ""
if apc:
rag_info = f" [대상: 개, 고양이]" # RAG 정보 시뮬레이션
print(f"- {r.GoodsName} ({r.Saleprice:,.0f}원){rag_info}")
print('\n=== 안텔민 관련 제품만 ===')
for r in rows:
if '안텔민' in r.GoodsName:
print(f" {r.GoodsName} - APC: {r.APC_CODE}")

View File

@@ -1,42 +0,0 @@
# -*- coding: utf-8 -*-
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
from sqlalchemy import create_engine, text
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
# 안텔민킹 RAG 정보
apc = '0230237810109'
print(f'=== 안텔민킹 ({apc}) RAG 정보 ===\n')
result = pg.execute(text(f"""
SELECT
product_name,
llm_pharm->>'사용가능 동물' as target_animals,
llm_pharm->>'분류' as category,
llm_pharm->>'체중/부위' as dosage_weight,
llm_pharm->>'월령금기' as age_restriction
FROM apc
WHERE apc = '{apc}'
"""))
row = result.fetchone()
if row:
print(f'제품명: {row.product_name}')
print(f'사용가능 동물: {row.target_animals}')
print(f'분류: {row.category}')
print(f'체중/용량: {row.dosage_weight}')
print(f'월령금기: {row.age_restriction}')
# efficacy_effect도 확인
result2 = pg.execute(text(f"""
SELECT efficacy_effect FROM apc WHERE apc = '{apc}'
"""))
row2 = result2.fetchone()
if row2 and row2.efficacy_effect:
print(f'\n효능/효과 (원문 일부):')
print(row2.efficacy_effect[:500])
pg.close()

View File

@@ -1,86 +0,0 @@
# -*- coding: utf-8 -*-
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text, create_engine
# 1. _get_animal_drugs 시뮬레이션
drug_session = get_db_session('PM_DRUG')
query = text("""
SELECT
G.DrugCode,
G.GoodsName,
G.Saleprice,
(
SELECT TOP 1 U.CD_CD_BARCODE
FROM CD_ITEM_UNIT_MEMBER U
WHERE U.DRUGCODE = G.DrugCode
AND U.CD_CD_BARCODE LIKE '023%'
ORDER BY U.CHANGE_DATE DESC
) AS APC_CODE
FROM CD_GOODS G
WHERE G.POS_BOON = '010103'
AND G.GoodsSelCode = 'B'
ORDER BY G.GoodsName
""")
rows = drug_session.execute(query).fetchall()
animal_drugs = []
for r in rows:
animal_drugs.append({
'code': r.DrugCode,
'name': r.GoodsName,
'price': float(r.Saleprice) if r.Saleprice else 0,
'apc': r.APC_CODE
})
# 2. _get_animal_drug_rag 시뮬레이션
apc_codes = [d['apc'] for d in animal_drugs if d.get('apc')]
print(f'APC 코드 목록: {apc_codes}\n')
rag_data = {}
if apc_codes:
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
placeholders = ','.join([f"'{apc}'" for apc in apc_codes])
result = pg.execute(text(f"""
SELECT apc, product_name,
llm_pharm->>'사용가능 동물' as target_animals,
llm_pharm->>'분류' as category,
llm_pharm->>'체중/부위' as dosage_weight,
llm_pharm->>'기간/용법' as usage_period,
llm_pharm->>'월령금기' as age_restriction
FROM apc
WHERE apc IN ({placeholders})
"""))
for row in result:
rag_data[row.apc] = {
'target_animals': row.target_animals or '정보 없음',
'category': row.category or '',
'dosage_weight': row.dosage_weight or '',
'usage_period': row.usage_period or '',
'age_restriction': row.age_restriction or ''
}
pg.close()
print(f'RAG 데이터: {rag_data}\n')
# 3. available_products_text 생성
print('=== AI에 전달되는 제품 목록 (RAG 포함) ===\n')
for d in animal_drugs:
if '안텔민' in d['name']:
line = f"- {d['name']} ({d['price']:,.0f}원)"
if d.get('apc') and d['apc'] in rag_data:
info = rag_data[d['apc']]
details = []
if info.get('target_animals'):
details.append(f"대상: {info['target_animals']}")
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)}]"
print(line)

View File

@@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from sqlalchemy import create_engine, text
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
# 약국 제품 → PostgreSQL 매칭 (체중/용량 포함)
mappings = [
# (약국제품명, 검색키워드)
('제스타제(10정)', '제스타제', '10'),
('파라캅L(5kg이상)', '파라캅', 'L'),
('파라캅S(5kg이하)', '파라캅', 'S'),
('하트캅츄어블(11kg이하)', '하트캅', '11'),
('넥스가드L(15~30kg)', '넥스가드', '15'),
('넥스가드xs(2~3.5kg)', '넥스가드', '2'),
('다이로하트정M(12~22kg)', '다이로하트', '12'),
('다이로하트정S(5.6~11kg)', '다이로하트', '5.6'),
('다이로하트정SS(5.6kg이하)', '다이로하트', 'SS'),
('세레니아정16mg(개멀미약)', '세레니아', '16'),
('세레니아정24mg(개멀미약)', '세레니아', '24'),
('하트세이버츄어블M(12~22kg)', '하트세이버', '12'),
('하트세이버츄어블S(5.6~11kg)', '하트세이버', '5.6'),
('하트웜솔루션츄어블M(12~22kg)', '하트웜', '12'),
('하트웜솔루션츄어블S(11kg이하)', '하트웜', '11'),
]
print('=== 상세 매칭 검색 ===\n')
for pharm_name, keyword, size in mappings:
result = pg.execute(text("""
SELECT apc, product_name, packaging,
llm_pharm->>'사용가능 동물' as target
FROM apc
WHERE product_name ILIKE :kw
ORDER BY product_name
LIMIT 10
"""), {'kw': f'%{keyword}%'})
print(f'\n📦 {pharm_name} (검색: {keyword}, 사이즈: {size})')
for r in result:
mark = '' if size.lower() in r.product_name.lower() else ' '
print(f'{mark} {r.apc}: {r.product_name[:50]}')
pg.close()

View File

@@ -1,23 +0,0 @@
# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
session = get_db_session('PM_DRUG')
print('업데이트 전:')
r = session.execute(text("SELECT GoodsName, POS_BOON FROM CD_GOODS WHERE DrugCode = 'LB000003140'")).fetchone()
print(f' {r.GoodsName}: POS_BOON = {r.POS_BOON}')
session.execute(text("UPDATE CD_GOODS SET POS_BOON = '010103' WHERE DrugCode = 'LB000003140'"))
session.commit()
print('\n업데이트 후:')
r2 = session.execute(text("SELECT GoodsName, POS_BOON FROM CD_GOODS WHERE DrugCode = 'LB000003140'")).fetchone()
print(f' {r2.GoodsName}: POS_BOON = {r2.POS_BOON}')
print(' ✅ 완료!')
session.close()

View File

@@ -1,75 +0,0 @@
# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
from datetime import datetime
session = get_db_session('PM_DRUG')
# 1. 기존 데이터에서 가격 정보 가져오기
print('1. 기존 레코드에서 가격 정보 조회...')
existing = session.execute(text("""
SELECT TOP 1 CD_MY_UNIT, CD_IN_UNIT
FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = 'LB000003140'
ORDER BY SN DESC
""")).fetchone()
sale_price = existing.CD_MY_UNIT
purchase_price = existing.CD_IN_UNIT
print(f' 판매가: {sale_price:,.0f}')
print(f' 입고가: {purchase_price:,.0f}')
# 2. 오늘 날짜
today = datetime.now().strftime('%Y%m%d')
print(f'\n2. 날짜: {today}')
# 3. INSERT 실행
print('\n3. INSERT 실행...')
apc_code = '0231093520106' # 복합개시딘 10g
try:
session.execute(text("""
INSERT INTO CD_ITEM_UNIT_MEMBER (
DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT,
CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE
) VALUES (
:drugcode, :unit, :nm_unit, :my_unit, :in_unit,
:barcode, :pos, :change_date
)
"""), {
'drugcode': 'LB000003140',
'unit': '015',
'nm_unit': 1.0,
'my_unit': sale_price,
'in_unit': purchase_price,
'barcode': apc_code,
'pos': '',
'change_date': today
})
session.commit()
print(f' ✅ 성공! APC {apc_code} 추가됨')
# 4. 확인
print('\n4. 결과 확인...')
result = session.execute(text("""
SELECT DRUGCODE, CD_CD_BARCODE, CD_MY_UNIT, SN
FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = 'LB000003140' AND CD_CD_BARCODE = :apc
"""), {'apc': apc_code})
row = result.fetchone()
if row:
print(f' DRUGCODE: {row.DRUGCODE}')
print(f' BARCODE: {row.CD_CD_BARCODE}')
print(f' SN: {row.SN}')
except Exception as e:
session.rollback()
print(f' ❌ 실패: {e}')
session.close()

View File

@@ -1,90 +0,0 @@
# -*- coding: utf-8 -*-
"""
안텔민뽀삐 APC 추가 실행 (SN 자동 생성)
"""
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
from datetime import datetime
session = get_db_session('PM_DRUG')
# 1. 기존 데이터에서 가격 정보 가져오기
print('1. 기존 레코드에서 가격 정보 조회...')
existing = session.execute(text("""
SELECT TOP 1 CD_MY_UNIT, CD_IN_UNIT
FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = 'LB000003158'
ORDER BY SN DESC
""")).fetchone()
sale_price = existing.CD_MY_UNIT
purchase_price = existing.CD_IN_UNIT
print(f' 판매가: {sale_price:,.0f}')
print(f' 입고가: {purchase_price:,.0f}')
# 2. 오늘 날짜
today = datetime.now().strftime('%Y%m%d')
print(f'\n2. 날짜: {today}')
# 3. INSERT 실행 (SN은 IDENTITY 자동 생성)
print('\n3. INSERT 실행...')
apc_code = '0230237010107' # 안텔민뽀삐 10정
try:
session.execute(text("""
INSERT INTO CD_ITEM_UNIT_MEMBER (
DRUGCODE,
CD_CD_UNIT,
CD_NM_UNIT,
CD_MY_UNIT,
CD_IN_UNIT,
CD_CD_BARCODE,
CD_CD_POS,
CHANGE_DATE
) VALUES (
:drugcode,
:unit,
:nm_unit,
:my_unit,
:in_unit,
:barcode,
:pos,
:change_date
)
"""), {
'drugcode': 'LB000003158',
'unit': '015',
'nm_unit': 1.0,
'my_unit': sale_price,
'in_unit': purchase_price,
'barcode': apc_code,
'pos': '',
'change_date': today
})
session.commit()
print(f' ✅ 성공! APC {apc_code} 추가됨')
# 4. 확인
print('\n4. 결과 확인...')
result = session.execute(text("""
SELECT * FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = 'LB000003158' AND CD_CD_BARCODE = :apc
"""), {'apc': apc_code})
row = result.fetchone()
if row:
print(' --- 추가된 레코드 ---')
for col in result.keys():
print(f' {col}: {getattr(row, col)}')
except Exception as e:
session.rollback()
print(f' ❌ 실패: {e}')
session.close()

View File

@@ -1,36 +0,0 @@
# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
session = get_db_session('PM_DRUG')
print('=== 펫팜 공급 동물약 ===\n')
result = session.execute(text("""
SELECT
G.DrugCode,
G.GoodsName,
G.POS_BOON,
S.SplName,
(
SELECT TOP 1 U.CD_CD_BARCODE
FROM CD_ITEM_UNIT_MEMBER U
WHERE U.DRUGCODE = G.DrugCode
AND U.CD_CD_BARCODE LIKE '023%'
) AS APC_CODE
FROM CD_GOODS G
LEFT JOIN CD_SALEGOODS S ON G.DrugCode = S.DrugCode
WHERE S.SplName LIKE N'%펫팜%'
ORDER BY G.GoodsName
"""))
for row in result:
apc_status = f'{row.APC_CODE}' if row.APC_CODE else '❌ 없음'
boon_status = '🐾' if row.POS_BOON == '010103' else ' '
print(f'{boon_status} {row.GoodsName}')
print(f' APC: {apc_status}')
session.close()

View File

@@ -1,68 +0,0 @@
# -*- coding: utf-8 -*-
"""APC 매칭 성능 측정"""
import sys, io, time
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text, create_engine
print('=== APC 매칭 성능 측정 ===\n')
# 1. MSSQL: 동물약 + APC 조회
start = time.time()
session = get_db_session('PM_DRUG')
result = session.execute(text("""
SELECT G.DrugCode, G.GoodsName,
(SELECT TOP 1 U.CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER U
WHERE U.DRUGCODE = G.DrugCode AND U.CD_CD_BARCODE LIKE '023%') AS APC_CODE
FROM CD_GOODS G
WHERE G.POS_BOON = '010103' AND G.GoodsSelCode = 'B'
"""))
mssql_rows = list(result)
no_apc = [r for r in mssql_rows if not r.APC_CODE]
has_apc = [r for r in mssql_rows if r.APC_CODE]
mssql_time = time.time() - start
print(f'1. MSSQL 동물약 조회: {mssql_time:.3f}')
print(f' - 총 제품: {len(mssql_rows)}')
print(f' - APC 있음: {len(has_apc)}개 ✅')
print(f' - APC 없음: {len(no_apc)}개 ❌')
# 2. PostgreSQL 연결 + 매칭 검색
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
# 샘플 매칭 테스트
sample_count = min(5, len(no_apc))
start = time.time()
match_count = 0
for drug in no_apc[:sample_count]:
search_name = drug.GoodsName.replace('(판)', '').split('(')[0].strip()
res = pg.execute(text("""
SELECT apc, product_name FROM apc
WHERE product_name ILIKE :p LIMIT 5
"""), {'p': f'%{search_name}%'})
if list(res):
match_count += 1
pg_search_time = time.time() - start
per_search = pg_search_time / sample_count if sample_count > 0 else 0
print(f'\n2. PostgreSQL 매칭 검색: {pg_search_time:.3f}초 ({sample_count}개 샘플)')
print(f' - 건당 소요: {per_search*1000:.1f}ms')
print(f' - 매칭 성공: {match_count}/{sample_count}')
print(f' - 예상 전체: {per_search * len(no_apc):.1f}초 ({len(no_apc)}개)')
# 3. APC 테이블 통계
start = time.time()
total_apc = pg.execute(text("SELECT COUNT(*) FROM apc")).scalar()
with_image = pg.execute(text("SELECT COUNT(*) FROM apc WHERE image_url1 IS NOT NULL AND image_url1 != ''")).scalar()
pg.close()
print(f'\n3. APDB 통계:')
print(f' - 전체 APC: {total_apc:,}')
print(f' - 이미지 있음: {with_image:,}개 ({with_image/total_apc*100:.1f}%)')
# 4. CD_ITEM_UNIT_MEMBER 구조 확인
print(f'\n4. 현재 APC 매핑 상태:')
for r in has_apc[:5]:
print(f'{r.GoodsName[:25]:<25}{r.APC_CODE}')
session.close()
print('\n=== 측정 완료 ===')

View File

@@ -1,89 +0,0 @@
# -*- coding: utf-8 -*-
"""
안텔민뽀삐 APC 추가 준비 스크립트
- CD_ITEM_UNIT_MEMBER 구조 확인
- 안텔민킹 레코드 참고
- INSERT 쿼리 생성 (실행 안 함)
"""
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
session = get_db_session('PM_DRUG')
print('=' * 60)
print('1. CD_ITEM_UNIT_MEMBER 테이블 구조')
print('=' * 60)
result = session.execute(text("""
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'CD_ITEM_UNIT_MEMBER'
ORDER BY ORDINAL_POSITION
"""))
for r in result:
nullable = 'NULL' if r.IS_NULLABLE == 'YES' else 'NOT NULL'
length = f'({r.CHARACTER_MAXIMUM_LENGTH})' if r.CHARACTER_MAXIMUM_LENGTH else ''
print(f' {r.COLUMN_NAME}: {r.DATA_TYPE}{length} {nullable}')
print('\n' + '=' * 60)
print('2. 안텔민킹 APC 레코드 (참고용)')
print('=' * 60)
result = session.execute(text("""
SELECT * FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = 'LB000003157'
AND CD_CD_BARCODE LIKE '023%'
"""))
row = result.fetchone()
if row:
cols = result.keys()
for col in cols:
val = getattr(row, col)
print(f' {col}: {val}')
print('\n' + '=' * 60)
print('3. 안텔민뽀삐 현재 레코드')
print('=' * 60)
result2 = session.execute(text("""
SELECT * FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = 'LB000003158'
ORDER BY SN DESC
"""))
rows = list(result2)
print(f'{len(rows)}개 레코드')
for row in rows[:3]:
print(f'\n --- SN: {row.SN} ---')
cols = result2.keys()
for col in cols:
val = getattr(row, col)
print(f' {col}: {val}')
print('\n' + '=' * 60)
print('4. 다음 SN 값 확인')
print('=' * 60)
result3 = session.execute(text("SELECT MAX(SN) as max_sn FROM CD_ITEM_UNIT_MEMBER"))
max_sn = result3.fetchone().max_sn
print(f' 현재 MAX(SN): {max_sn}')
print(f' 다음 SN: {max_sn + 1}')
session.close()
print('\n' + '=' * 60)
print('5. PostgreSQL에서 안텔민뽀삐 APC 확인')
print('=' * 60)
from sqlalchemy import create_engine
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
result4 = pg.execute(text("""
SELECT apc, product_name
FROM apc
WHERE product_name ILIKE '%안텔민%뽀삐%' OR product_name ILIKE '%안텔민%5kg%이하%'
ORDER BY apc
"""))
for r in result4:
print(f' APC: {r.apc}')
print(f' 제품명: {r.product_name}')
print()
pg.close()

View File

@@ -1,168 +0,0 @@
# -*- coding: utf-8 -*-
"""
애니팜 PostgreSQL 조회 스크립트
Usage:
python scripts/query_aniparm.py schema # 테이블 구조 확인
python scripts/query_aniparm.py search <제품명> # 제품 검색
python scripts/query_aniparm.py barcode <바코드> # 바코드로 검색
python scripts/query_aniparm.py sample # 샘플 데이터
python scripts/query_aniparm.py stats # 통계
"""
import sys
import io
import json
# ═══════════════════════════════════════════════════════════
# 인코딩 설정 (Windows CP949 문제 방지)
# ═══════════════════════════════════════════════════════════
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
from sqlalchemy import create_engine, text
# PostgreSQL 연결
DATABASE_URI = 'postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master'
def get_connection():
engine = create_engine(DATABASE_URI)
return engine.connect()
def cmd_schema():
"""apc 테이블 구조 확인"""
conn = get_connection()
result = conn.execute(text("""
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_name = 'apc'
ORDER BY ordinal_position
"""))
print('=== apc 테이블 컬럼 ===')
for row in result:
length = f'({row.character_maximum_length})' if row.character_maximum_length else ''
print(f' {row.column_name}: {row.data_type}{length}')
conn.close()
def cmd_search(keyword):
"""제품명으로 검색"""
conn = get_connection()
result = conn.execute(text("""
SELECT idx, apc, product_name, company_name,
image_url1, godoimage_url_f, for_pets
FROM apc
WHERE product_name ILIKE :keyword
LIMIT 20
"""), {'keyword': f'%{keyword}%'})
print(f'=== "{keyword}" 검색 결과 ===')
count = 0
for row in result:
count += 1
print(f'\n[{count}] {row.product_name}')
print(f' APC: {row.apc}')
print(f' 제조사: {row.company_name}')
print(f' 동물용: {row.for_pets}')
if row.image_url1:
print(f' 이미지1: {row.image_url1[:50]}...')
if row.godoimage_url_f:
print(f' 고도몰F: {row.godoimage_url_f[:50]}...')
if count == 0:
print('(결과 없음)')
conn.close()
def cmd_barcode(barcode):
"""바코드로 검색 - 바코드 컬럼이 있는지 먼저 확인"""
conn = get_connection()
# 바코드 관련 컬럼 찾기
result = conn.execute(text("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'apc'
AND column_name ILIKE '%barcode%'
"""))
barcode_cols = [row.column_name for row in result]
if not barcode_cols:
print('apc 테이블에 barcode 관련 컬럼이 없습니다.')
print('다른 컬럼으로 검색해야 합니다.')
else:
print(f'바코드 컬럼 발견: {barcode_cols}')
# TODO: 바코드로 검색 구현
conn.close()
def cmd_sample():
"""샘플 데이터 (동물용 제품)"""
conn = get_connection()
result = conn.execute(text("""
SELECT idx, apc, product_name, company_name,
image_url1, godoimage_url_f, for_pets
FROM apc
WHERE for_pets = true
LIMIT 10
"""))
print('=== 동물용 제품 샘플 ===')
count = 0
for row in result:
count += 1
print(f'\n[{count}] {row.product_name}')
print(f' APC: {row.apc}')
print(f' 제조사: {row.company_name}')
img = row.image_url1 or row.godoimage_url_f or '(없음)'
if len(img) > 50:
img = img[:50] + '...'
print(f' 이미지: {img}')
if count == 0:
print('(동물용 제품 없음 - for_pets 필터 확인 필요)')
conn.close()
def cmd_stats():
"""통계"""
conn = get_connection()
result = conn.execute(text("""
SELECT
COUNT(*) as total,
SUM(CASE WHEN for_pets = true THEN 1 ELSE 0 END) as pet_count,
SUM(CASE WHEN image_url1 IS NOT NULL AND image_url1 != '' THEN 1 ELSE 0 END) as has_img1,
SUM(CASE WHEN godoimage_url_f IS NOT NULL AND godoimage_url_f != '' THEN 1 ELSE 0 END) as has_godo_f
FROM apc
"""))
row = result.fetchone()
print('=== apc 테이블 통계 ===')
print(f'전체 제품: {row.total:,}')
print(f'동물용(for_pets=true): {row.pet_count:,}')
print(f'image_url1 있음: {row.has_img1:,}')
print(f'godoimage_url_f 있음: {row.has_godo_f:,}')
conn.close()
def main():
if len(sys.argv) < 2:
print(__doc__)
return
cmd = sys.argv[1]
if cmd == 'schema':
cmd_schema()
elif cmd == 'search' and len(sys.argv) > 2:
cmd_search(sys.argv[2])
elif cmd == 'barcode' and len(sys.argv) > 2:
cmd_barcode(sys.argv[2])
elif cmd == 'sample':
cmd_sample()
elif cmd == 'stats':
cmd_stats()
else:
print(__doc__)
if __name__ == '__main__':
main()

View File

@@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
import sys, io, json
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from sqlalchemy import create_engine, text
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
# 안텔민킹 llm_pharm 전체 확인
result = pg.execute(text("""
SELECT product_name, llm_pharm FROM apc WHERE apc = '0230237810109'
"""))
row = result.fetchone()
print('=== 안텔민킹 llm_pharm 전체 키 ===\n')
data = row.llm_pharm
for k in sorted(data.keys()):
val = str(data[k])
if len(val) > 60:
val = val[:60] + '...'
print(f' {k}: {val}')
# 동물약 전체 개수
print('\n=== PostgreSQL 동물약 전체 개수 ===')
result2 = pg.execute(text("SELECT COUNT(*) FROM apc"))
print(f' 전체: {result2.fetchone()[0]}')
pg.close()

View File

@@ -1,16 +0,0 @@
# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from app import _get_animal_drugs
drugs = _get_animal_drugs()
gestage = [d for d in drugs if '제스타제' in d['name']]
print('=== 제스타제 API 결과 ===\n')
for d in gestage:
print(f"name: {d['name']}")
print(f"barcode: {d['barcode']}")
print(f"apc: {d['apc']}")
print(f"image_url: {d['image_url']}")

View File

@@ -1,23 +0,0 @@
# -*- coding: utf-8 -*-
"""pets 테이블 마이그레이션 테스트"""
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import db_manager
# SQLite 연결 (마이그레이션 자동 실행)
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
# pets 테이블 확인
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='pets'")
if cursor.fetchone():
print('✅ pets 테이블 생성 완료')
cursor.execute('PRAGMA table_info(pets)')
columns = cursor.fetchall()
print('\n컬럼 목록:')
for col in columns:
print(f' - {col[1]} ({col[2]})')
else:
print('❌ pets 테이블 없음')

View File

@@ -1,43 +0,0 @@
# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
session = get_db_session('PM_DRUG')
print('1. 현재 상태 확인...')
result = session.execute(text("""
SELECT DrugCode, GoodsName, POS_BOON
FROM CD_GOODS
WHERE DrugCode = 'LB000003140'
"""))
row = result.fetchone()
print(f' {row.GoodsName}: POS_BOON = {row.POS_BOON}')
print('\n2. POS_BOON을 동물약(010103)으로 업데이트...')
try:
session.execute(text("""
UPDATE CD_GOODS
SET POS_BOON = '010103'
WHERE DrugCode = 'LB000003140'
"""))
session.commit()
print(' ✅ 성공!')
# 확인
result2 = session.execute(text("""
SELECT DrugCode, GoodsName, POS_BOON
FROM CD_GOODS
WHERE DrugCode = 'LB000003140'
"""))
row2 = result2.fetchone()
print(f' {row2.GoodsName}: POS_BOON = {row2.POS_BOON}')
except Exception as e:
session.rollback()
print(f' ❌ 실패: {e}')
session.close()

View File

@@ -98,89 +98,6 @@ class KakaoAPIClient:
'error_description': f'Invalid JSON response: {e}'
}
def refresh_access_token(self, refresh_token: str) -> Tuple[bool, Dict[str, Any]]:
"""Refresh Token으로 Access Token 갱신"""
url = f"{self.auth_base_url}/oauth/token"
data = {
'grant_type': 'refresh_token',
'client_id': self.client_id,
'refresh_token': refresh_token,
}
if self.client_secret:
data['client_secret'] = self.client_secret
try:
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
response = self.session.post(url, data=data, headers=headers)
logger.info(f"카카오 토큰 갱신 응답 상태: {response.status_code}")
response.raise_for_status()
token_data = response.json()
if 'expires_in' in token_data:
expires_at = datetime.now() + timedelta(seconds=token_data['expires_in'])
token_data['expires_at'] = expires_at.isoformat()
return True, token_data
except requests.exceptions.RequestException as e:
logger.error(f"카카오 토큰 갱신 실패: {e}")
error_details = {
'error': 'token_refresh_failed',
'error_description': f'Failed to refresh access token: {e}'
}
try:
if hasattr(e, 'response') and e.response is not None:
kakao_error = e.response.json()
logger.error(f"카카오 API 오류: {kakao_error}")
error_details.update(kakao_error)
except Exception:
pass
return False, error_details
def get_user_info_with_refresh(
self,
access_token: str,
refresh_token: str,
token_expires_at: str = None
) -> Tuple[bool, Dict[str, Any], Dict[str, Any]]:
"""저장된 토큰으로 사용자 정보 조회 (만료 시 자동 갱신)
Returns:
(성공여부, 사용자정보/에러, 갱신된 토큰 데이터 또는 빈 dict)
"""
new_token_data = {}
# 만료 확인: 5분 이내면 미리 갱신
if token_expires_at:
try:
expires = datetime.fromisoformat(token_expires_at)
if datetime.now() >= expires - timedelta(minutes=5):
logger.info("Access token 만료 임박, 갱신 시도")
success, refreshed = self.refresh_access_token(refresh_token)
if success:
access_token = refreshed['access_token']
new_token_data = refreshed
else:
return False, refreshed, {}
except (ValueError, TypeError) as e:
logger.warning(f"token_expires_at 파싱 실패, 기존 토큰으로 시도: {e}")
# 사용자 정보 조회
success, user_info = self.get_user_info(access_token)
if not success and refresh_token:
# 실패 시 갱신 후 재시도
logger.info("사용자 정보 조회 실패, 토큰 갱신 후 재시도")
refresh_ok, refreshed = self.refresh_access_token(refresh_token)
if refresh_ok:
access_token = refreshed['access_token']
new_token_data = refreshed
success, user_info = self.get_user_info(access_token)
return success, user_info, new_token_data
def get_user_info(self, access_token: str) -> Tuple[bool, Dict[str, Any]]:
"""Access Token으로 사용자 정보 조회"""
url = f"{self.api_base_url}/v2/user/me"

View File

@@ -87,16 +87,7 @@ def _send_alimtalk(template_code, recipient_no, template_params):
logger.info(f"알림톡 발송 성공: {template_code}{recipient_no}")
return (True, "발송 성공")
else:
# 상세 에러 추출: sendResults[0].resultMessage 우선, 없으면 header.resultMessage
header_msg = result.get('header', {}).get('resultMessage', '')
send_results = result.get('message', {}).get('sendResults', [])
detail_msg = send_results[0].get('resultMessage', '') if send_results else ''
# 상세 에러가 있으면 그걸 사용, 없으면 header 에러
error_msg = detail_msg if detail_msg and detail_msg != 'SUCCESS' else header_msg
if not error_msg:
error_msg = str(result)
error_msg = result.get('header', {}).get('resultMessage', str(result))
logger.warning(f"알림톡 발송 실패: {template_code}{recipient_no}: {error_msg}")
return (False, error_msg)
@@ -109,25 +100,15 @@ def _send_alimtalk(template_code, recipient_no, template_params):
def build_item_summary(items):
"""구매 품목 요약 문자열 생성 (예: '타이레놀 외 3건')
Note: 카카오 알림톡 템플릿 변수는 14자 제한
(에러: "Blacklist can't use more than 14 characters in template value.")
특수문자(%, 괄호 등)는 문제없이 발송 가능!
"""
"""구매 품목 요약 문자열 생성 (예: '타이레놀 외 3건')"""
if not items:
return "약국 구매"
first = items[0]['name']
first = first.strip()
if len(first) > 20:
first = first[:18] + '..'
if len(items) == 1:
# 단일 품목: 14자 제한 (그냥 자름)
return first[:14]
# 복수 품목: "외 N건" 붙으므로 전체 14자 맞춤
suffix = f"{len(items) - 1}"
max_first = 14 - len(suffix)
return f"{first[:max_first]}{suffix}"
return first
return f"{first}{len(items) - 1}"
def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
@@ -165,7 +146,24 @@ def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
success, msg = _send_alimtalk(template_code, phone, params)
# 결과 로그 (V3만 사용, V2 폴백 제거 - V2 반려 상태)
if not success:
# V3 실패 로그
_log_to_db(template_code, phone, False, msg,
template_params=params, user_id=user_id,
trigger_source=trigger_source, transaction_id=transaction_id)
# V2 폴백
template_code = 'MILEAGE_CLAIM_V2'
params = {
'고객명': name,
'적립포인트': f'{points:,}',
'총잔액': f'{balance:,}',
'적립일시': now_kst,
'전화번호': phone
}
success, msg = _send_alimtalk(template_code, phone, params)
# 최종 결과 로그
_log_to_db(template_code, phone, success, msg,
template_params=params, user_id=user_id,
trigger_source=trigger_source, transaction_id=transaction_id)

View File

@@ -1,439 +0,0 @@
# -*- coding: utf-8 -*-
"""
수인약품 도매상 API - Flask Blueprint
핵심 로직은 wholesale 패키지에서 가져옴
이 파일은 Flask 웹 API 연동만 담당
"""
import time
import logging
from flask import Blueprint, jsonify, request as flask_request
# wholesale 패키지 경로 설정
import wholesale_path
# wholesale 패키지에서 핵심 클래스 가져오기
from wholesale import SooinSession
logger = logging.getLogger(__name__)
# Blueprint 생성
sooin_bp = Blueprint('sooin', __name__, url_prefix='/api/sooin')
# ========== 세션 관리 ==========
_sooin_session = None
def get_sooin_session():
global _sooin_session
if _sooin_session is None:
_sooin_session = SooinSession()
return _sooin_session
def search_sooin_stock(keyword: str, search_type: str = 'kd_code'):
"""수인약품 재고 검색 (동기, 빠름)"""
try:
session = get_sooin_session()
result = session.search_products(keyword)
if result.get('success'):
return {
'success': True,
'keyword': keyword,
'search_type': search_type,
'count': result['total'],
'items': result['items']
}
else:
return result
except Exception as e:
logger.error(f"수인약품 검색 오류: {e}")
return {'success': False, 'error': 'SEARCH_ERROR', 'message': str(e)}
# ========== Flask API Routes ==========
@sooin_bp.route('/stock', methods=['GET'])
def api_sooin_stock():
"""
수인약품 재고 조회 API
GET /api/sooin/stock?kd_code=073100220
GET /api/sooin/stock?keyword=코자정&type=name
"""
kd_code = flask_request.args.get('kd_code', '').strip()
keyword = flask_request.args.get('keyword', '').strip()
search_type = flask_request.args.get('type', 'kd_code').strip()
search_term = kd_code or keyword
if kd_code:
search_type = 'kd_code'
if not search_term:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code 또는 keyword 파라미터가 필요합니다'
}), 400
try:
result = search_sooin_stock(search_term, search_type)
return jsonify(result)
except Exception as e:
logger.error(f"수인약품 API 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e)
}), 500
@sooin_bp.route('/session-status', methods=['GET'])
def api_session_status():
"""세션 상태 확인"""
session = get_sooin_session()
return jsonify({
'logged_in': session._logged_in,
'last_login': session._last_login,
'session_age_sec': int(time.time() - session._last_login) if session._last_login else None
})
@sooin_bp.route('/balance', methods=['GET'])
def api_sooin_balance():
"""
수인약품 잔고(미수금) 조회 API
GET /api/sooin/balance
Returns:
{
"success": true,
"balance": 14293001, // 현재 잔고 (누계합)
"prev_balance": 10592762, // 전일잔액
"monthly_sales": 3700239, // 월 매출
"yearly_sales": 34380314 // 연 누계 매출
}
"""
try:
session = get_sooin_session()
result = session.get_balance()
return jsonify(result)
except Exception as e:
logger.error(f"수인약품 잔고 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'BALANCE_ERROR',
'message': str(e),
'balance': 0
}), 500
@sooin_bp.route('/monthly-sales', methods=['GET'])
def api_sooin_monthly_sales():
"""
수인약품 월간 매출 조회 API
GET /api/sooin/monthly-sales?year=2026&month=3
Returns:
{
"success": true,
"total_amount": 3700239, // 월간 매출 합계
"total_paid": 0, // 월간 입금 합계
"ending_balance": 14293001, // 월말 잔액
"opening_balance": 10592762, // 전일(기초) 잔액
"from_date": "2026-03-01",
"to_date": "2026-03-31"
}
"""
from datetime import datetime
year = flask_request.args.get('year', type=int)
month = flask_request.args.get('month', type=int)
# 기본값: 현재 월
if not year or not month:
now = datetime.now()
year = year or now.year
month = month or now.month
try:
session = get_sooin_session()
if hasattr(session, 'get_monthly_sales'):
result = session.get_monthly_sales(year, month)
return jsonify(result)
else:
return jsonify({
'success': False,
'error': 'NOT_IMPLEMENTED',
'message': '수인약품 월간 매출 조회 미구현'
}), 501
except Exception as e:
logger.error(f"수인약품 월간 매출 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'MONTHLY_SALES_ERROR',
'message': str(e)
}), 500
@sooin_bp.route('/cart', methods=['GET'])
def api_sooin_cart():
"""장바구니 조회 API"""
try:
session = get_sooin_session()
result = session.get_cart()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e), 'items': []}), 500
@sooin_bp.route('/cart/clear', methods=['POST'])
def api_sooin_cart_clear():
"""장바구니 비우기 API"""
try:
session = get_sooin_session()
result = session.clear_cart()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/cart/cancel', methods=['POST'])
def api_sooin_cart_cancel():
"""
장바구니 항목 취소 API
POST /api/sooin/cart/cancel
{ "row_index": 0 }
또는
{ "internal_code": "32495" }
"""
data = flask_request.get_json() or {}
row_index = data.get('row_index')
internal_code = data.get('internal_code')
if row_index is None and not internal_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'row_index 또는 internal_code가 필요합니다'
}), 400
try:
session = get_sooin_session()
result = session.cancel_item(row_index=row_index, product_code=internal_code)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/cart/restore', methods=['POST'])
def api_sooin_cart_restore():
"""
취소된 항목 복원 API
POST /api/sooin/cart/restore
{ "row_index": 0 }
"""
data = flask_request.get_json() or {}
row_index = data.get('row_index')
internal_code = data.get('internal_code')
try:
session = get_sooin_session()
result = session.restore_item(row_index=row_index, product_code=internal_code)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/order', methods=['POST'])
def api_sooin_order():
"""
수인약품 주문 API (장바구니 추가)
POST /api/sooin/order
{
"kd_code": "073100220",
"quantity": 1,
"specification": "30T",
"check_stock": true
}
"""
data = flask_request.get_json()
if not data:
return jsonify({'success': False, 'error': 'NO_DATA'}), 400
kd_code = data.get('kd_code', '').strip()
quantity = data.get('quantity', 1)
specification = data.get('specification')
check_stock = data.get('check_stock', True)
if not kd_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code가 필요합니다'
}), 400
try:
session = get_sooin_session()
result = session.quick_order(
kd_code=kd_code,
quantity=quantity,
spec=specification,
check_stock=check_stock
)
return jsonify(result)
except Exception as e:
logger.error(f"수인약품 주문 오류: {e}")
return jsonify({
'success': False,
'error': 'ORDER_ERROR',
'message': str(e)
}), 500
@sooin_bp.route('/confirm', methods=['POST'])
def api_sooin_confirm():
"""주문 확정 API"""
data = flask_request.get_json() or {}
memo = data.get('memo', '')
try:
session = get_sooin_session()
result = session.submit_order(memo)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/full-order', methods=['POST'])
def api_sooin_full_order():
"""
전체 주문 API (검색 → 장바구니 → 확정)
POST /api/sooin/full-order
{
"kd_code": "073100220",
"quantity": 1,
"specification": "30T",
"auto_confirm": true,
"memo": "자동주문"
}
"""
data = flask_request.get_json()
if not data or not data.get('kd_code'):
return jsonify({'success': False, 'error': 'kd_code required'}), 400
try:
session = get_sooin_session()
# 장바구니에 담기
cart_result = session.quick_order(
kd_code=data['kd_code'],
quantity=data.get('quantity', 1),
spec=data.get('specification'),
check_stock=data.get('check_stock', True)
)
if not cart_result.get('success'):
return jsonify(cart_result)
if not data.get('auto_confirm', True):
return jsonify(cart_result)
# 주문 확정
confirm_result = session.submit_order(data.get('memo', ''))
if confirm_result.get('success'):
return jsonify({
'success': True,
'message': f"{cart_result['product']['name']} {cart_result['quantity']}개 주문 완료",
'product': cart_result['product'],
'quantity': cart_result['quantity'],
'confirmed': True
})
else:
return jsonify({
'success': False,
'error': confirm_result.get('error', 'CONFIRM_FAILED'),
'message': f"장바구니 담기 성공, 주문 확정 실패",
'product': cart_result['product'],
'cart_added': True
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/order-batch', methods=['POST'])
def api_sooin_order_batch():
"""수인약품 일괄 주문 API"""
data = flask_request.get_json()
if not data or not data.get('items'):
return jsonify({'success': False, 'error': 'NO_ITEMS'}), 400
items = data.get('items', [])
check_stock = data.get('check_stock', True)
session = get_sooin_session()
results = []
success_count = 0
failed_count = 0
for item in items:
kd_code = item.get('kd_code', '').strip()
quantity = item.get('quantity', 1)
specification = item.get('specification')
if not kd_code:
results.append({
'kd_code': kd_code,
'success': False,
'error': 'MISSING_KD_CODE'
})
failed_count += 1
continue
try:
result = session.quick_order(
kd_code=kd_code,
quantity=quantity,
spec=specification,
check_stock=check_stock
)
result['kd_code'] = kd_code
result['requested_qty'] = quantity
results.append(result)
if result.get('success'):
success_count += 1
else:
failed_count += 1
except Exception as e:
results.append({
'kd_code': kd_code,
'success': False,
'error': 'EXCEPTION',
'message': str(e)
})
failed_count += 1
return jsonify({
'success': True,
'total': len(items),
'success_count': success_count,
'failed_count': failed_count,
'results': results
})

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,16 +0,0 @@
<svg id="logo_foot" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64.36" height="32" viewBox="0 0 64.36 32">
<defs>
<clipPath id="clip-path">
<rect id="Rectangle_4823" data-name="Rectangle 4823" width="64.36" height="32" fill="#999"/>
</clipPath>
</defs>
<g id="Group_14242" data-name="Group 14242" clip-path="url(#clip-path)">
<path id="Path_14020" data-name="Path 14020" d="M29.966,33.717c.348,0,.524.154.524.463v2.732h1.539V34.207a.437.437,0,0,1,.5-.49.449.449,0,0,1,.511.5v5.058a.566.566,0,0,1-.169.413.5.5,0,0,1-.676.017.634.634,0,0,1-.166-.43V37.842H30.49v1.437a.566.566,0,0,1-.169.413.474.474,0,0,1-.355.141.464.464,0,0,1-.335-.124.631.631,0,0,1-.162-.43v-5.1q0-.463.5-.463m13.23,0a.465.465,0,0,1,.345.114.4.4,0,0,1,.169.349V44.189a.476.476,0,0,1-.149.369.511.511,0,0,1-.375.161.484.484,0,0,1-.361-.141.532.532,0,0,1-.149-.389V39.1H41.088a.477.477,0,0,1-.375-.144.412.412,0,0,1-.149-.322.419.419,0,0,1,.146-.329.492.492,0,0,1,.365-.138h1.6V34.207a.434.434,0,0,1,.149-.362.533.533,0,0,1,.371-.128m2.384,0a.5.5,0,0,1,.342.128.4.4,0,0,1,.169.349v10a.467.467,0,0,1-.153.369.481.481,0,0,1-.358.161.469.469,0,0,1-.348-.141.535.535,0,0,1-.153-.389V34.207a.476.476,0,0,1,.159-.369.5.5,0,0,1,.342-.121m-21.463.235a.5.5,0,0,1,.348.124.527.527,0,0,1,.176.4V35.64h2.484v-1.2a.446.446,0,0,1,.169-.376.475.475,0,0,1,.332-.114.53.53,0,0,1,.355.128.416.416,0,0,1,.169.362v4.152a.975.975,0,0,1-.421.806,1.489,1.489,0,0,1-.942.289H24.83a1.219,1.219,0,0,1-.849-.279,1,1,0,0,1-.361-.816v-4.1a.545.545,0,0,1,.149-.4.48.48,0,0,1,.348-.141m13.041.158h4.214c.3,0,.444.131.444.4-.013.346-.159.52-.444.52H39.765v2.115a9.559,9.559,0,0,0,.623,3.581,7.858,7.858,0,0,0,1.641,2.2.452.452,0,0,1,.179.393.5.5,0,0,1-.172.332.616.616,0,0,1-.351.161.534.534,0,0,1-.4-.151,7.155,7.155,0,0,1-1.316-1.792,5.441,5.441,0,0,1-.713-2.256,5.5,5.5,0,0,1-.726,2.259A6.6,6.6,0,0,1,37.1,43.686a.658.658,0,0,1-.4.138.477.477,0,0,1-.315-.158.47.47,0,0,1-.119-.356.572.572,0,0,1,.225-.386,6.677,6.677,0,0,0,1.476-1.99,9.345,9.345,0,0,0,.773-3.779V35.026H37.145c-.325,0-.481-.158-.471-.47s.159-.446.484-.446M24.641,36.57v1.906a.324.324,0,0,0,.073.248.456.456,0,0,0,.305.074h1.628A.592.592,0,0,0,27,38.718a.286.286,0,0,0,.126-.242V36.57Zm-.2,4.323h7.255a1.425,1.425,0,0,1,.965.316,1.112,1.112,0,0,1,.381.869V44.2a.517.517,0,0,1-.176.393.477.477,0,0,1-.335.124.489.489,0,0,1-.342-.121.54.54,0,0,1-.159-.4V42.118a.305.305,0,0,0-.06-.218.494.494,0,0,0-.3-.077H24.439q-.507,0-.507-.463c0-.312.169-.467.507-.467" transform="translate(-15.788 -22.399)" fill="#999"/>
<path id="Path_14021" data-name="Path 14021" d="M45.059,44.463a.73.73,0,0,1-.531-.217.8.8,0,0,1-.228-.575V33.689a.735.735,0,0,1,.244-.563.786.786,0,0,1,1.026,0,.647.647,0,0,1,.258.546v10a.731.731,0,0,1-.241.567.723.723,0,0,1-.528.225m0-11a.246.246,0,0,0-.173.058.211.211,0,0,0-.069.171v9.982a.273.273,0,0,0,.077.2.215.215,0,0,0,.165.064.224.224,0,0,0,.172-.082.218.218,0,0,0,.08-.187v-10a.138.138,0,0,0-.06-.135.268.268,0,0,0-.192-.08m-2.394,11a.737.737,0,0,1-.544-.217.8.8,0,0,1-.225-.575v-.642a.8.8,0,0,1-.208.3.876.876,0,0,1-.5.227.8.8,0,0,1-.6-.216,7.454,7.454,0,0,1-1.371-1.862,6.958,6.958,0,0,1-.489-1.107,6.8,6.8,0,0,1-.5,1.113,6.894,6.894,0,0,1-1.484,1.887.925.925,0,0,1-.566.2.743.743,0,0,1-.5-.242.728.728,0,0,1-.188-.546.835.835,0,0,1,.318-.575,6.445,6.445,0,0,0,1.41-1.906,9.118,9.118,0,0,0,.745-3.662V34.77H36.624a.732.732,0,0,1-.552-.2.715.715,0,0,1-.178-.538.665.665,0,0,1,.743-.7h4.214a.626.626,0,0,1,.7.658c-.027.707-.5.782-.7.782H39.5v1.853a9.335,9.335,0,0,0,.6,3.483,7.625,7.625,0,0,0,1.574,2.106.731.731,0,0,1,.215.288V38.844h-1.33A.722.722,0,0,1,40,38.613a.649.649,0,0,1-.215-.5.68.68,0,0,1,.236-.527.735.735,0,0,1,.534-.2H41.9v-3.7a.681.681,0,0,1,.251-.57.765.765,0,0,1,.519-.182.724.724,0,0,1,.52.175.65.65,0,0,1,.262.55V43.671a.738.738,0,0,1-.235.564.758.758,0,0,1-.547.228m-2.112-6.552a.229.229,0,0,0-.182.061.172.172,0,0,0-.07.144.149.149,0,0,0,.06.125.263.263,0,0,0,.205.08h1.588a.26.26,0,0,1,.259.262v5.088a.267.267,0,0,0,.074.2.226.226,0,0,0,.178.064.257.257,0,0,0,.192-.085.218.218,0,0,0,.074-.184V33.662a.141.141,0,0,0-.059-.135.23.23,0,0,0-.176-.067.363.363,0,0,0-.218.061.2.2,0,0,0-.063.167V37.65a.26.26,0,0,1-.259.262Zm-3.916-4.058c-.225,0-.225.074-.225.185a.251.251,0,0,0,.032.165.279.279,0,0,0,.18.043h1.595a.26.26,0,0,1,.259.262v2.128a9.648,9.648,0,0,1-.795,3.885A6.98,6.98,0,0,1,36.144,42.6a.324.324,0,0,0-.142.217.194.194,0,0,0,.052.153.22.22,0,0,0,.152.076.412.412,0,0,0,.219-.089,6.378,6.378,0,0,0,1.353-1.733,5.267,5.267,0,0,0,.694-2.149.26.26,0,0,1,.258-.243h0a.26.26,0,0,1,.256.246,5.206,5.206,0,0,0,.681,2.145,6.934,6.934,0,0,0,1.268,1.728.269.269,0,0,0,.209.08.357.357,0,0,0,.2-.1.226.226,0,0,0,.085-.156.188.188,0,0,0-.085-.17,8.153,8.153,0,0,1-1.706-2.282,9.855,9.855,0,0,1-.654-3.7V34.508a.26.26,0,0,1,.259-.262h1.608c.086,0,.176,0,.186-.269,0-.077,0-.124-.186-.124Zm-4.629,10.61a.744.744,0,0,1-.513-.187.808.808,0,0,1-.246-.592V41.6c0-.009,0-.017,0-.025a.477.477,0,0,0-.1-.009H23.918a.728.728,0,1,1,0-1.453h7.255a1.679,1.679,0,0,1,1.133.378,1.374,1.374,0,0,1,.472,1.068v2.125a.783.783,0,0,1-.26.586.737.737,0,0,1-.51.193m-8.091-3.826c-.237,0-.249.078-.249.2s.012.2.249.2h7.228a.738.738,0,0,1,.457.13.185.185,0,0,1,.027.024.545.545,0,0,1,.136.4v2.084a.279.279,0,0,0,.084.211.235.235,0,0,0,.159.044.223.223,0,0,0,.163-.058.252.252,0,0,0,.089-.2V41.56a.855.855,0,0,0-.294-.673,1.169,1.169,0,0,0-.794-.25Zm8.091-1.061a.72.72,0,0,1-.514-.2.914.914,0,0,1-.245-.619V37.586H30.228v1.175a.835.835,0,0,1-.245.6.729.729,0,0,1-.538.217.72.72,0,0,1-.514-.2.9.9,0,0,1-.242-.619v-5.1a.681.681,0,0,1,.756-.725.694.694,0,0,1,.783.725v2.47h1.021V33.689a.694.694,0,0,1,.759-.752.7.7,0,0,1,.769.765v5.058a.835.835,0,0,1-.245.6.713.713,0,0,1-.525.217m-.142-.56a.226.226,0,0,0,.142.037.2.2,0,0,0,.159-.065.3.3,0,0,0,.093-.228V33.7c0-.171-.035-.242-.252-.242s-.242.057-.242.228v2.705a.26.26,0,0,1-.259.262H29.969a.26.26,0,0,1-.259-.262V33.662c0-.108,0-.2-.265-.2-.227,0-.239.077-.239.2v5.1a.367.367,0,0,0,.1.255.226.226,0,0,0,.142.037.217.217,0,0,0,.172-.065.3.3,0,0,0,.093-.228V37.324a.26.26,0,0,1,.259-.262h1.539a.26.26,0,0,1,.259.262v1.437A.374.374,0,0,0,31.866,39.016Zm-5.6.416H24.309a1.473,1.473,0,0,1-1.021-.345,1.255,1.255,0,0,1-.448-1.011v-4.1a.811.811,0,0,1,.225-.584.784.784,0,0,1,1.051-.027.794.794,0,0,1,.262.6v.9h1.966v-.936a.705.705,0,0,1,.271-.584.715.715,0,0,1,.489-.168.786.786,0,0,1,.52.188.662.662,0,0,1,.262.564v4.152a1.243,1.243,0,0,1-.525,1.015,1.74,1.74,0,0,1-1.1.341M23.6,33.7a.225.225,0,0,0-.168.068.277.277,0,0,0-.07.211v4.1a.74.74,0,0,0,.267.614.975.975,0,0,0,.685.219h1.956a1.244,1.244,0,0,0,.784-.234.72.72,0,0,0,.32-.6V33.924c0-.106-.039-.134-.06-.149a.3.3,0,0,0-.206-.08.216.216,0,0,0-.16.049.193.193,0,0,0-.082.18v1.2a.26.26,0,0,1-.259.262H24.12a.26.26,0,0,1-.259-.262V33.961a.265.265,0,0,0-.092-.209A.242.242,0,0,0,23.6,33.7m2.53,4.847H24.5a.668.668,0,0,1-.482-.144.563.563,0,0,1-.155-.44V36.052a.26.26,0,0,1,.259-.262H26.6a.26.26,0,0,1,.259.262v1.906a.549.549,0,0,1-.234.454.811.811,0,0,1-.5.13m-1.745-.531a.733.733,0,0,0,.117.008h1.628a.423.423,0,0,0,.2-.028c.023-.016.023-.02.023-.032V36.314H24.379v1.645a.5.5,0,0,0,0,.053" transform="translate(-15.267 -21.881)" fill="#999"/>
<path id="Path_14022" data-name="Path 14022" d="M110.236,33.72c.348,0,.524.158.524.473v1.413h1.283a.4.4,0,0,1,.451.45c0,.312-.149.467-.451.467H110.76v1.494h1.283a.4.4,0,0,1,.451.45c0,.312-.149.467-.451.467H110.76v.4a.459.459,0,0,1-.166.373.487.487,0,0,1-.358.141.5.5,0,0,1-.361-.151.476.476,0,0,1-.136-.366V34.2c0-.319.166-.477.5-.477m-5.279.222a2.7,2.7,0,0,1,2.135.809,2.62,2.62,0,0,1,.885,1.98,2.545,2.545,0,0,1-.892,1.943,2.665,2.665,0,0,1-2.129.809,2.99,2.99,0,0,1-2.324-.977,2.249,2.249,0,0,1-.723-1.776,2.675,2.675,0,0,1,.759-1.94,3.09,3.09,0,0,1,2.288-.849m0,.93a2.038,2.038,0,0,0-1.535.564,1.978,1.978,0,0,0,0,2.581,2.006,2.006,0,0,0,1.532.574,2.123,2.123,0,0,0,1.479-.463,2.1,2.1,0,0,0,.534-1.4,1.848,1.848,0,0,0-.4-1.255,2.258,2.258,0,0,0-1.611-.6m-2.122,5.79h6.529a1.553,1.553,0,0,1,1.081.379,1.145,1.145,0,0,1,.315.879v2.286a.517.517,0,0,1-.176.393.488.488,0,0,1-.348.124.5.5,0,0,1-.342-.121.551.551,0,0,1-.156-.4V41.923a.316.316,0,0,0-.073-.248.438.438,0,0,0-.305-.084h-6.526a.411.411,0,0,1-.471-.46c-.01-.312.146-.47.471-.47" transform="translate(-68.119 -22.402)" fill="#999"/>
<path id="Path_14023" data-name="Path 14023" d="M109.715,44.466a.753.753,0,0,1-.51-.184.818.818,0,0,1-.246-.595V41.4a.463.463,0,0,0,0-.057.387.387,0,0,0-.116-.013h-6.525a.667.667,0,0,1-.73-.722.708.708,0,0,1,.178-.529.731.731,0,0,1,.552-.2h6.529a1.8,1.8,0,0,1,1.253.446,1.4,1.4,0,0,1,.4,1.074v2.286a.781.781,0,0,1-.26.586.739.739,0,0,1-.523.193m-7.4-4.061a.277.277,0,0,0-.18.043.238.238,0,0,0-.032.157c0,.159.03.206.213.206h6.525a.668.668,0,0,1,.483.155.567.567,0,0,1,.154.439v2.282a.287.287,0,0,0,.083.214.252.252,0,0,0,.156.042.231.231,0,0,0,.173-.055.255.255,0,0,0,.093-.2V41.4a.892.892,0,0,0-.232-.687,1.29,1.29,0,0,0-.906-.31Zm6.666.954,0,0,0,0m.734-1.766a.751.751,0,0,1-.541-.225.729.729,0,0,1-.215-.554V33.678a.687.687,0,0,1,.756-.738.7.7,0,0,1,.783.735v1.151h1.025a.653.653,0,0,1,.71.712.661.661,0,0,1-.71.728H110.5v.97h1.025a.653.653,0,0,1,.71.711.661.661,0,0,1-.71.728H110.5v.141a.784.784,0,0,1-.783.775m0-6.129c-.215,0-.239.069-.239.215v5.135c0,.124.04.161.053.174a.247.247,0,0,0,.364.014.21.21,0,0,0,.087-.184v-.4a.26.26,0,0,1,.259-.261h1.283c.149,0,.192-.027.192-.205,0-.145-.025-.188-.192-.188h-1.283a.26.26,0,0,1-.259-.262V36a.26.26,0,0,1,.259-.262h1.283c.149,0,.192-.027.192-.2,0-.148-.024-.188-.192-.188h-1.283a.26.26,0,0,1-.259-.262V33.675c0-.113,0-.211-.265-.211m-5.279,5.763a3.249,3.249,0,0,1-2.515-1.061,2.494,2.494,0,0,1-.791-1.953,2.945,2.945,0,0,1,.834-2.123,3.352,3.352,0,0,1,2.472-.928,2.955,2.955,0,0,1,2.328.9,2.868,2.868,0,0,1,.952,2.155,2.814,2.814,0,0,1-.978,2.137,2.9,2.9,0,0,1-2.3.877m0-5.541a2.822,2.822,0,0,0-2.106.774,2.4,2.4,0,0,0-.682,1.754,1.983,1.983,0,0,0,.634,1.579,2.74,2.74,0,0,0,2.154.912,2.433,2.433,0,0,0,1.934-.72,2.307,2.307,0,0,0,.828-1.771,2.35,2.35,0,0,0-.8-1.788,2.45,2.45,0,0,0-1.96-.739m0,4.649a2.262,2.262,0,0,1-1.719-.654,2.241,2.241,0,0,1,0-2.945,2.3,2.3,0,0,1,1.721-.643,2.518,2.518,0,0,1,1.788.674,2.112,2.112,0,0,1,.484,1.447,2,2,0,0,1-2.272,2.121m0-3.719a1.781,1.781,0,0,0-1.351.486,1.716,1.716,0,0,0,0,2.216,1.749,1.749,0,0,0,1.346.494,1.889,1.889,0,0,0,1.306-.4,1.816,1.816,0,0,0,.448-1.2,1.6,1.6,0,0,0-.337-1.082,1.988,1.988,0,0,0-1.417-.515" transform="translate(-67.597 -21.884)" fill="#999"/>
<path id="Path_14024" data-name="Path 14024" d="M141.333,34.3h8.14a.538.538,0,0,1,.365.128.459.459,0,0,1,.146.326.5.5,0,0,1-.109.312.49.49,0,0,1-.365.151h-1.273l.076.047a.317.317,0,0,1,.07.064.465.465,0,0,1,.1.4l-.385,1.628h1.416a.414.414,0,0,1,.471.44c.017.319-.116.48-.408.48H141.28c-.288,0-.428-.158-.418-.47a.4.4,0,0,1,.458-.45h1.429l-.371-1.668a.492.492,0,0,1,.093-.409l.06-.057h-1.2a.41.41,0,0,1-.471-.46c-.01-.3.146-.456.471-.456m1.854.916.063.057a.527.527,0,0,1,.133.3l.365,1.776H147.1l.365-1.755a.428.428,0,0,1,.249-.366l.023-.013Zm-2.7,3.877h9.888a.443.443,0,0,1,.484.49q0,.4-.458.4h-4.473v.91l0,.06h2.656a1.362,1.362,0,0,1,.905.3.884.884,0,0,1,.351.738v1.678a1.056,1.056,0,0,1-.318.8,1.125,1.125,0,0,1-.839.322h-6.5a1.183,1.183,0,0,1-.859-.285A1.041,1.041,0,0,1,141,43.7V42.1a1.119,1.119,0,0,1,.325-.842,1.226,1.226,0,0,1,.872-.3h2.729l0-.034v-.936h-4.447q-.5,0-.5-.453c0-.292.169-.44.511-.44m1.936,2.779a.551.551,0,0,0-.312.057.418.418,0,0,0-.076.285v1.332c0,.134.017.218.056.252a.623.623,0,0,0,.371.077h5.922a.645.645,0,0,0,.332-.064.254.254,0,0,0,.086-.215V42.174c0-.111-.023-.181-.063-.208a.631.631,0,0,0-.378-.094Z" transform="translate(-93.565 -22.788)" fill="#999"/>
<path id="Path_14025" data-name="Path 14025" d="M148.163,44.535h-6.5a1.431,1.431,0,0,1-1.039-.358,1.294,1.294,0,0,1-.4-.994v-1.6a1.374,1.374,0,0,1,.405-1.032,1.48,1.48,0,0,1,1.05-.371h2.468v-.447h-4.188a.71.71,0,1,1,.013-1.416h9.888a.7.7,0,0,1,.742.741.638.638,0,0,1-.716.675h-4.214v.447h2.394a1.612,1.612,0,0,1,1.071.362,1.143,1.143,0,0,1,.444.94v1.678a1.315,1.315,0,0,1-.4.985,1.375,1.375,0,0,1-1.017.394M141.677,40.7a.973.973,0,0,0-.7.229.855.855,0,0,0-.242.65v1.6a.782.782,0,0,0,.24.612.944.944,0,0,0,.685.217h6.5a.872.872,0,0,0,.656-.246.8.8,0,0,0,.243-.61V41.478a.625.625,0,0,0-.249-.53,1.122,1.122,0,0,0-.749-.249h-2.656a.257.257,0,0,1-.188-.082.264.264,0,0,1-.071-.194l0-.061v-.9a.26.26,0,0,1,.259-.261h4.473c.2,0,.2-.053.2-.141-.008-.194-.076-.228-.226-.228h-9.888c-.252,0-.252.083-.252.178s0,.191.239.191H144.4a.26.26,0,0,1,.259.261l0,.945a.263.263,0,0,1-.066.2.257.257,0,0,1-.192.086Zm6.191,2.92h-5.922a.813.813,0,0,1-.536-.138.565.565,0,0,1-.15-.453V41.7a.654.654,0,0,1,.147-.465.7.7,0,0,1,.5-.139h5.939a.866.866,0,0,1,.531.144.477.477,0,0,1,.169.419v1.423a.505.505,0,0,1-.187.422.862.862,0,0,1-.489.118m-6.09-.535a1.09,1.09,0,0,0,.168.011h5.922a.766.766,0,0,0,.157-.013l0-1.427c0-.006,0-.012,0-.017a.571.571,0,0,0-.182-.023h-5.939a1.052,1.052,0,0,0-.125.006.574.574,0,0,0,0,.075v1.332c0,.022,0,.041,0,.056m6.3-1.423,0,0,0,0m.978-3.648h-8.293a.66.66,0,0,1-.5-.189.732.732,0,0,1-.177-.551.653.653,0,0,1,.716-.7H141.9l-.3-1.349a.971.971,0,0,1-.01-.262h-.783a.667.667,0,0,1-.729-.722.686.686,0,0,1,.174-.515.735.735,0,0,1,.555-.2h8.14a.8.8,0,0,1,.534.191.73.73,0,0,1,.236.524.77.77,0,0,1-.164.474.758.758,0,0,1-.569.251h-.777a.8.8,0,0,1,0,.291l-.311,1.32h1.088a.672.672,0,0,1,.729.689.739.739,0,0,1-.169.564.651.651,0,0,1-.5.19m-8.253-.919c-.172,0-.2.045-.2.188a.28.28,0,0,0,.032.178.2.2,0,0,0,.127.03h8.293a.186.186,0,0,0,.121-.027.283.283,0,0,0,.028-.178c-.007-.144-.038-.191-.213-.191h-1.416a.258.258,0,0,1-.2-.1.264.264,0,0,1-.048-.223l.384-1.628a.191.191,0,0,0-.042-.166l-.083-.056a.127.127,0,1,0-.24,0l-.031.018c-.1.053-.109.1-.112.151a.242.242,0,0,1,0,.037l-.365,1.755a.26.26,0,0,1-.253.208h-3.349a.259.259,0,0,1-.253-.209l-.365-1.776a.3.3,0,0,0-.067-.175l-.048-.043a.264.264,0,0,1-.069-.288.259.259,0,0,1,.242-.168h4.546a.259.259,0,0,1,.25.194l0,.009s0-.008,0-.012a.259.259,0,0,1,.249-.191h1.273a.235.235,0,0,0,.182-.075.253.253,0,0,0,.033-.127.191.191,0,0,0-.07-.141.289.289,0,0,0-.182-.051h-8.14a.284.284,0,0,0-.183.043.217.217,0,0,0-.03.143c0,.162.028.207.213.207h1.2a.258.258,0,0,1,.24.165.264.264,0,0,1-.063.287l-.059.057a.264.264,0,0,0-.016.177l.368,1.653a.265.265,0,0,1-.05.221.258.258,0,0,1-.2.1Zm2.638-.524h2.929l.318-1.531a.745.745,0,0,1,.012-.08h-3.588c0,.02.008.041.01.062Z" transform="translate(-93.044 -22.269)" fill="#999"/>
<path id="Path_14026" data-name="Path 14026" d="M32.18,32c-8.505,0-16.514-1.612-22.55-4.54C3.42,24.448,0,20.378,0,16S3.42,7.552,9.63,4.54C15.666,1.612,23.674,0,32.18,0S48.694,1.612,54.73,4.54c6.21,3.012,9.63,7.082,9.63,11.46s-3.42,8.448-9.63,11.46C48.694,30.388,40.685,32,32.18,32m0-30.49C23.893,1.51,16.114,3.07,10.275,5.9,4.611,8.649,1.492,12.235,1.492,16s3.119,7.351,8.783,10.1C16.114,28.93,23.893,30.49,32.18,30.49S48.246,28.93,54.085,26.1c5.664-2.747,8.783-6.333,8.783-10.1S59.749,8.649,54.085,5.9C48.246,3.07,40.467,1.51,32.18,1.51" transform="translate(0 0)" fill="#999"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +0,0 @@
<script type="text/javascript">
location.href = "./homepage/intro.asp";
</script>

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#7c3aed"/>
<text x="16" y="22" text-anchor="middle" fill="white" font-family="Arial" font-weight="bold" font-size="16"></text>
</svg>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

View File

@@ -20,41 +20,6 @@
-webkit-font-smoothing: antialiased;
}
/* 토스트 알림 */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
padding: 14px 20px;
border-radius: 10px;
color: white;
font-weight: 500;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
display: flex;
align-items: center;
gap: 10px;
}
.toast.success { background: linear-gradient(135deg, #10b981 0%, #059669 100%); }
.toast.error { background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); }
.toast.info { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); }
.toast.printing { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); }
@keyframes toastIn {
from { opacity: 0; transform: translateX(100px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(100px); }
}
.header {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
padding: 32px 24px;
@@ -492,44 +457,8 @@
{% endif %}
</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #fef3c7, #fde68a);">
<div class="stat-label" style="color: #92400e;">🐾 등록 반려동물</div>
<div class="stat-value" style="color: #92400e;">
{{ pet_stats.total_pets or 0 }}마리
<span style="font-size: 14px; font-weight: 500; margin-left: 8px;">
(🐕 {{ pet_stats.dog_count or 0 }} / 🐈 {{ pet_stats.cat_count or 0 }})
</span>
</div>
</div>
</div>
<!-- 최근 등록 반려동물 -->
{% if recent_pets %}
<div class="section">
<div class="section-title">🐾 최근 등록 반려동물 (10마리)</div>
<div style="display: flex; flex-wrap: wrap; gap: 12px;">
{% for pet in recent_pets %}
<div style="display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: linear-gradient(135deg, #fef3c7, #fde68a); border-radius: 14px; min-width: 220px;">
{% if pet.photo_url %}
<img src="{{ pet.photo_url }}" style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid #fff; box-shadow: 0 2px 6px rgba(0,0,0,0.15);">
{% else %}
<div style="width: 48px; height: 48px; border-radius: 50%; background: #fff; display: flex; align-items: center; justify-content: center; font-size: 24px; box-shadow: 0 2px 6px rgba(0,0,0,0.1);">
{% if pet.species == 'dog' %}🐕{% elif pet.species == 'cat' %}🐈{% else %}🐾{% endif %}
</div>
{% endif %}
<div>
<div style="font-weight: 700; font-size: 15px; color: #92400e;">
{% if pet.species == 'dog' %}🐕{% elif pet.species == 'cat' %}🐈{% else %}🐾{% endif %} {{ pet.name }}
</div>
<div style="font-size: 12px; color: #a16207;">{{ pet.breed or '품종 미등록' }}</div>
<div style="font-size: 11px; color: #b45309; margin-top: 2px;">{{ pet.owner_name }} ({{ pet.owner_phone[:3] }}-****-{{ pet.owner_phone[-4:] }})</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- 최근 가입 사용자 -->
<div class="section">
<div class="section-title">최근 가입 사용자 (20명)</div>
@@ -921,113 +850,6 @@
function closeUserModal() {
document.getElementById('userDetailModal').style.display = 'none';
}
// 특이사항 펼치기/접기 (클릭 시)
function toggleCusetc(el) {
if (el.style.maxHeight === 'none' || el.style.maxHeight === '') {
el.style.maxHeight = '40px';
el.style.overflow = 'hidden';
} else {
el.style.maxHeight = 'none';
el.style.overflow = 'visible';
}
}
// 특이사항 수정 모드
function editCusetc(cuscode, btn) {
document.getElementById('cusetc-view').style.display = 'none';
document.getElementById('cusetc-edit').style.display = 'block';
document.getElementById('cusetc-textarea').focus();
btn.style.display = 'none';
}
// 특이사항 저장
async function saveCusetc(cuscode) {
const textarea = document.getElementById('cusetc-textarea');
const newValue = textarea.value.trim();
try {
const res = await fetch(`/api/members/${cuscode}/cusetc`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cusetc: newValue })
});
const data = await res.json();
if (data.success) {
// 뷰 업데이트
const viewEl = document.getElementById('cusetc-view');
viewEl.innerHTML = newValue || '<span style="color: #9ca3af; font-weight: normal;">없음</span>';
viewEl.style.maxHeight = newValue.length > 30 ? '40px' : 'none';
cancelCusetc();
alert('✅ 저장되었습니다.');
} else {
alert('❌ ' + (data.error || '저장 실패'));
}
} catch (err) {
alert('❌ 오류: ' + err.message);
}
}
// 특이사항 수정 취소
function cancelCusetc() {
document.getElementById('cusetc-view').style.display = 'block';
document.getElementById('cusetc-edit').style.display = 'none';
// 수정 버튼 다시 표시
const editBtn = document.querySelector('#cusetc-view').parentElement.querySelector('button');
if (editBtn) editBtn.style.display = 'inline-block';
}
// 토스트 알림 함수
function showToast(message, type = 'info') {
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = { success: '✅', error: '❌', info: '', printing: '🖨️' };
toast.innerHTML = `<span>${icons[type] || ''}</span><span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// 인쇄용 전역 변수
let printData = { name: '', cusetc: '', phone: '' };
// 특이사항 인쇄 실행
async function doPrintCusetc() {
// 즉시 피드백
showToast(`${printData.name}님 특이사항 인쇄 중...`, 'printing');
try {
const res = await fetch('/api/print/cusetc', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_name: printData.name,
cusetc: printData.cusetc,
phone: printData.phone
})
});
const data = await res.json();
if (data.success) {
showToast(data.message, 'success');
} else {
showToast('인쇄 실패: ' + (data.error || '알 수 없는 오류'), 'error');
}
} catch (err) {
showToast('오류: ' + err.message, 'error');
}
}
function renderUserDetail(data) {
// 전역 변수에 데이터 저장
@@ -1066,26 +888,6 @@
<div style="color: #ec4899; font-size: 16px; font-weight: 600;">${user.birthday.includes('-') ? user.birthday.split('-')[0] + '월 ' + user.birthday.split('-')[1] + '일' : user.birthday.slice(0,2) + '월 ' + user.birthday.slice(2,4) + '일'}</div>
</div>
` : ''}
<!-- 특이(참고)사항 - 생일 옆 칸 -->
${data.pos_customer ? `
<div>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
<span style="color: #d97706; font-size: 13px;">⚠️ 특이사항</span>
<button onclick="editCusetc('${data.pos_customer.cuscode}', this)" style="background: none; border: 1px solid #d97706; color: #d97706; font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer;">✏️ 수정</button>
${data.pos_customer.cusetc ? `<button onclick="printData={name:'${data.pos_customer.name}',cusetc:decodeURIComponent('${encodeURIComponent(data.pos_customer.cusetc)}'),phone:'${user.phone||''}'};doPrintCusetc()" style="background: none; border: 1px solid #6b7280; color: #6b7280; font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer;">🖨️ 인쇄</button>` : ''}
</div>
<div id="cusetc-view" onclick="toggleCusetc(this)" style="color: #92400e; font-size: 14px; font-weight: 500; cursor: ${(data.pos_customer.cusetc || '').length > 30 ? 'pointer' : 'default'}; ${(data.pos_customer.cusetc || '').length > 30 ? 'max-height: 40px; overflow: hidden;' : ''}" title="${(data.pos_customer.cusetc || '').length > 30 ? '클릭하여 펼치기' : ''}">
${data.pos_customer.cusetc || '<span style="color: #9ca3af; font-weight: normal;">없음</span>'}
</div>
<div id="cusetc-edit" style="display: none;">
<textarea id="cusetc-textarea" style="width: 100%; min-height: 60px; padding: 8px; border: 1px solid #d97706; border-radius: 6px; font-size: 13px; resize: vertical;">${data.pos_customer.cusetc || ''}</textarea>
<div style="display: flex; gap: 6px; margin-top: 6px;">
<button onclick="saveCusetc('${data.pos_customer.cuscode}')" style="background: #d97706; color: white; border: none; padding: 4px 12px; border-radius: 4px; font-size: 12px; cursor: pointer;">저장</button>
<button onclick="cancelCusetc()" style="background: #e5e7eb; color: #374151; border: none; padding: 4px 12px; border-radius: 4px; font-size: 12px; cursor: pointer;">취소</button>
</div>
</div>
</div>
` : ''}
</div>
<div style="text-align: right; display: flex; gap: 8px; justify-content: flex-end;">
<button onclick="showAIAnalysisModal(${user.id})" style="padding: 10px 24px; background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;">
@@ -1111,9 +913,6 @@
<button onclick="switchTab('interests')" id="tab-interests" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;">
💝 관심 (${data.interests ? data.interests.length : 0})
</button>
<button onclick="switchTab('pets')" id="tab-pets" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;">
🐾 반려동물 (${data.pets ? data.pets.length : 0})
</button>
</div>
<!-- 정렬 버튼 (구매 이력용) -->
@@ -1200,10 +999,6 @@
`;
}).join('');
// 약품 코드 배열 (상호작용 체크용)
const drugCodes = (rx.items || []).map(item => item.drug_code).filter(c => c);
const drugCodesJson = JSON.stringify(drugCodes).replace(/"/g, '&quot;');
html += `
<div style="border: 1px solid #e9ecef; border-radius: 12px; margin-bottom: 12px; padding: 16px; border-left: 4px solid #6366f1;">
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
@@ -1214,14 +1009,6 @@
🏥 ${rx.hospital || ''} · ${rx.doctor || ''}
</div>
${rx.items && rx.items.length > 0 ? `<div style="background: #f8f9fa; border-radius: 8px; padding: 12px;">${itemsHtml}</div>` : ''}
${drugCodes.length >= 2 ? `
<div style="margin-top: 12px; text-align: right;">
<button onclick='checkDrugInteraction(${drugCodesJson}, "${rx.pre_serial || ""}")'
style="background: linear-gradient(135deg, #8b5cf6, #6366f1); color: #fff; border: none; padding: 8px 14px; border-radius: 8px; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px;">
🔬 AI 상호작용 체크
</button>
</div>
` : ''}
</div>
`;
});
@@ -1271,53 +1058,6 @@
html += '<p style="text-align: center; padding: 40px; color: #868e96;">💝 관심 상품이 없습니다<br><small>마일리지 적립 시 AI 추천에서 "관심있어요"를 누르면 여기에 표시됩니다</small></p>';
}
html += `
</div>
<!-- 반려동물 탭 -->
<div id="tab-content-pets" class="tab-content" style="display: none;">
`;
// 반려동물 렌더링
const pets = data.pets || [];
if (pets.length > 0) {
html += '<div style="display: grid; gap: 16px;">';
pets.forEach(pet => {
const photoHtml = pet.photo_url
? `<img src="${pet.photo_url}" alt="${pet.name}" style="width: 80px; height: 80px; border-radius: 50%; object-fit: cover;">`
: `<div style="width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, #fbbf24, #f59e0b); display: flex; align-items: center; justify-content: center; font-size: 36px;">${pet.species === 'dog' ? '🐕' : (pet.species === 'cat' ? '🐈' : '🐾')}</div>`;
html += `
<div style="border: 1px solid #e9ecef; border-radius: 16px; padding: 20px; display: flex; gap: 20px; align-items: center; border-left: 4px solid #f59e0b;">
<div style="flex-shrink: 0;">
${photoHtml}
</div>
<div style="flex: 1;">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
<span style="font-size: 20px; font-weight: 700; color: #212529;">${pet.name}</span>
<span style="background: ${pet.species === 'dog' ? '#dbeafe' : '#fce7f3'}; color: ${pet.species === 'dog' ? '#1e40af' : '#9d174d'}; font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 20px;">
${pet.species_label}
</span>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 12px; font-size: 14px; color: #6b7280;">
${pet.breed ? `<span>🏷️ ${pet.breed}</span>` : ''}
${pet.gender_label ? `<span>${pet.gender_label}</span>` : ''}
${pet.weight ? `<span>⚖️ ${pet.weight}kg</span>` : ''}
${pet.age_months ? `<span>🎂 ${pet.age_months}개월</span>` : ''}
</div>
${pet.notes ? `<div style="margin-top: 8px; font-size: 13px; color: #9ca3af; background: #f9fafb; padding: 8px 12px; border-radius: 8px;">📝 ${pet.notes}</div>` : ''}
<div style="margin-top: 10px; font-size: 12px; color: #d1d5db;">
등록일: ${pet.created_at}
</div>
</div>
</div>
`;
});
html += '</div>';
} else {
html += '<p style="text-align: center; padding: 40px; color: #868e96;">🐾 등록된 반려동물이 없습니다<br><small>고객이 마이페이지에서 반려동물을 등록하면 여기에 표시됩니다</small></p>';
}
html += `
</div>
`;
@@ -1970,169 +1710,6 @@
closeAIAnalysisModal();
}
});
// ═══════════════════════════════════════════════════
// KIMS 약물 상호작용 체크
// ═══════════════════════════════════════════════════
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function checkDrugInteraction(drugCodes, preSerial) {
// drugCodes가 문자열로 넘어올 수 있음
if (typeof drugCodes === 'string') {
try { drugCodes = JSON.parse(drugCodes); } catch(e) { return; }
}
// 로딩 모달 표시
showInteractionModal('loading');
try {
const response = await fetch('/api/kims/interaction-check', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
drug_codes: drugCodes,
pre_serial: preSerial
})
});
const data = await response.json();
if (data.success) {
showInteractionModal('result', data);
} else {
showInteractionModal('error', data.error || '알 수 없는 오류');
}
} catch (err) {
showInteractionModal('error', '서버 연결 실패: ' + err.message);
}
}
function showInteractionModal(type, data) {
let modal = document.getElementById('interactionModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'interactionModal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
document.body.appendChild(modal);
}
let content = '';
if (type === 'loading') {
content = `
<div style="background:#fff;border-radius:16px;padding:40px;text-align:center;max-width:400px;">
<div style="font-size:48px;margin-bottom:16px;">🔬</div>
<div style="font-size:18px;font-weight:600;color:#334155;">상호작용 분석 중...</div>
<div style="font-size:14px;color:#64748b;margin-top:8px;">KIMS 데이터베이스 조회 중</div>
</div>
`;
} else if (type === 'error') {
content = `
<div style="background:#fff;border-radius:16px;padding:30px;max-width:400px;">
<div style="font-size:40px;text-align:center;margin-bottom:16px;">⚠️</div>
<div style="font-size:16px;font-weight:600;color:#dc2626;text-align:center;">분석 실패</div>
<div style="font-size:14px;color:#64748b;margin-top:12px;text-align:center;">${escapeHtml(data)}</div>
<div style="text-align:center;margin-top:20px;">
<button onclick="document.getElementById('interactionModal').remove()"
style="background:#6366f1;color:#fff;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;">
닫기
</button>
</div>
</div>
`;
} else if (type === 'result') {
const interactions = data.interactions || [];
const drugsChecked = data.drugs_checked || [];
// 약품 목록 (상호작용 여부에 따른 색상)
const drugsHtml = drugsChecked.map(d => {
const hasInteraction = d.has_interaction;
const bgColor = hasInteraction ? '#fef2f2' : '#f1f5f9';
const borderColor = hasInteraction ? '#fca5a5' : '#e2e8f0';
const textColor = hasInteraction ? '#dc2626' : '#334155';
const icon = hasInteraction ? '⚠️ ' : '';
return `<span style="display:inline-block;background:${bgColor};border:1px solid ${borderColor};color:${textColor};padding:4px 8px;border-radius:4px;margin:2px;font-size:12px;">${icon}${escapeHtml(d.name.slice(0,20))}</span>`;
}).join('');
// 상호작용 목록
let interactionsHtml = '';
if (interactions.length === 0) {
interactionsHtml = `
<div style="text-align:center;padding:30px;">
<div style="font-size:48px;margin-bottom:12px;">✅</div>
<div style="font-size:16px;font-weight:600;color:#10b981;">상호작용 없음</div>
<div style="font-size:13px;color:#64748b;margin-top:8px;">
${data.total_pairs}개 약품 조합을 검사했습니다.<br>
주의가 필요한 상호작용이 발견되지 않았습니다.
</div>
</div>
`;
} else {
interactionsHtml = interactions.map(item => `
<div style="background:#fff;border:1px solid ${item.severity_color};border-radius:12px;padding:16px;margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="font-weight:600;color:#334155;">
${escapeHtml(item.drug1_name?.slice(0,20) || '')}${escapeHtml(item.drug2_name?.slice(0,20) || '')}
</span>
<span style="background:${item.severity_color};color:#fff;padding:4px 10px;border-radius:12px;font-size:12px;font-weight:500;">
${item.severity_text}
</span>
</div>
${item.description ? `
<div style="font-size:13px;color:#475569;margin-bottom:8px;line-height:1.5;">
📋 ${escapeHtml(item.description)}
</div>
` : ''}
${item.management ? `
<div style="font-size:12px;color:#059669;background:#ecfdf5;padding:8px 12px;border-radius:6px;">
💡 ${escapeHtml(item.management.slice(0, 150))}...
</div>
` : ''}
</div>
`).join('');
}
content = `
<div style="background:#f8fafc;border-radius:20px;max-width:500px;max-height:80vh;overflow:hidden;display:flex;flex-direction:column;">
<div style="background:linear-gradient(135deg,#8b5cf6,#6366f1);padding:20px 24px;color:#fff;">
<div style="font-size:18px;font-weight:700;display:flex;align-items:center;gap:10px;">
🔬 약물 상호작용 분석
</div>
<div style="font-size:13px;opacity:0.9;margin-top:6px;">
${drugsChecked.length}개 약품 · ${data.total_pairs}개 조합 검사
</div>
</div>
<div style="padding:16px 20px;border-bottom:1px solid #e2e8f0;">
<div style="font-size:12px;color:#64748b;margin-bottom:6px;">분석 약품</div>
${drugsHtml}
</div>
<div style="flex:1;overflow-y:auto;padding:16px 20px;">
${interactions.length > 0 ? `
<div style="font-size:13px;color:#dc2626;font-weight:600;margin-bottom:12px;">
⚠️ ${interactions.length}건의 상호작용 발견
</div>
` : ''}
${interactionsHtml}
</div>
<div style="padding:16px 20px;border-top:1px solid #e2e8f0;text-align:center;">
<button onclick="document.getElementById('interactionModal').remove()"
style="background:#6366f1;color:#fff;border:none;padding:12px 32px;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;">
닫기
</button>
</div>
</div>
`;
}
modal.innerHTML = content;
}
</script>
<!-- Lottie 애니메이션 라이브러리 (로컬) -->

View File

@@ -1,563 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KIMS 상호작용 로그 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f8fafc;
-webkit-font-smoothing: antialiased;
color: #1e293b;
}
/* ── 헤더 ── */
.header {
background: linear-gradient(135deg, #dc2626 0%, #f59e0b 50%, #16a34a 100%);
padding: 28px 32px 24px;
color: #fff;
}
.header-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-nav a {
color: rgba(255,255,255,0.8);
text-decoration: none;
font-size: 14px;
font-weight: 500;
}
.header-nav a:hover { color: #fff; }
.header h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 10px;
}
.header p {
font-size: 14px;
opacity: 0.9;
}
/* ── 컨텐츠 ── */
.content {
max-width: 1200px;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* ── 통계 카드 ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
margin-bottom: 28px;
}
.stat-card {
background: #fff;
border-radius: 14px;
padding: 20px;
border: 1px solid #e2e8f0;
}
.stat-label {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
letter-spacing: -1px;
}
.stat-value.default { color: #1e293b; }
.stat-value.green { color: #16a34a; }
.stat-value.orange { color: #f59e0b; }
.stat-value.red { color: #dc2626; }
.stat-value.blue { color: #3b82f6; }
.stat-sub {
font-size: 11px;
color: #94a3b8;
margin-top: 4px;
}
/* ── 필터 ── */
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-bar select, .filter-bar input {
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.filter-bar button {
padding: 10px 20px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
/* ── 테이블 ── */
.table-wrap {
background: #fff;
border-radius: 14px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
background: #f8fafc;
padding: 12px 14px;
font-size: 12px;
font-weight: 600;
color: #64748b;
text-align: left;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
tbody td {
padding: 14px;
font-size: 13px;
font-weight: 500;
color: #334155;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
tbody tr { cursor: pointer; transition: background .15s; }
tbody tr:hover { background: #f8fafc; }
tbody tr:last-child td { border-bottom: none; }
/* ── 배지 ── */
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 100px;
font-size: 11px;
font-weight: 600;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-error { background: #fee2e2; color: #dc2626; }
.badge-timeout { background: #fef3c7; color: #d97706; }
.badge-severe { background: #dc2626; color: #fff; }
.badge-moderate { background: #f59e0b; color: #fff; }
.badge-mild { background: #3b82f6; color: #fff; }
.badge-drug {
background: #f1f5f9;
color: #475569;
margin: 2px;
font-size: 10px;
padding: 3px 8px;
}
.badge-drug.warning {
background: #fef2f2;
border: 1px solid #fca5a5;
color: #dc2626;
}
/* ── 상호작용 카운트 ── */
.interaction-count {
font-weight: 700;
font-size: 16px;
}
.interaction-count.zero { color: #16a34a; }
.interaction-count.has { color: #dc2626; }
.interaction-count.severe {
color: #fff;
background: #dc2626;
padding: 4px 10px;
border-radius: 8px;
}
/* ── 아코디언 상세 ── */
.detail-row { display: none; }
.detail-row.open { display: table-row; }
.detail-row td {
padding: 0;
border-bottom: 1px solid #e2e8f0;
background: #fafbfd;
}
.detail-content {
padding: 20px 24px;
}
.detail-section {
margin-bottom: 20px;
}
.detail-section-title {
font-size: 13px;
font-weight: 700;
color: #64748b;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.drug-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
/* ── 상호작용 카드 ── */
.interaction-card {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
border-left: 4px solid #e2e8f0;
}
.interaction-card.severe { border-left-color: #dc2626; background: #fef2f2; }
.interaction-card.moderate { border-left-color: #f59e0b; background: #fffbeb; }
.interaction-card.mild { border-left-color: #3b82f6; background: #eff6ff; }
.interaction-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.interaction-drugs {
font-weight: 600;
color: #1e293b;
font-size: 14px;
}
.interaction-desc {
font-size: 13px;
color: #475569;
line-height: 1.6;
margin-bottom: 10px;
}
.interaction-mgmt {
font-size: 12px;
color: #059669;
background: #ecfdf5;
padding: 10px 12px;
border-radius: 8px;
line-height: 1.5;
}
/* ── 빈 상태 ── */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
}
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
/* ── 로딩 ── */
.loading {
text-align: center;
padding: 40px;
color: #64748b;
}
/* ── 반응형 ── */
@media (max-width: 900px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 600px) {
.stats-grid { grid-template-columns: 1fr; }
.filter-bar { flex-direction: column; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-nav">
<a href="/admin">← 관리자 홈</a>
<a href="/admin/members">회원 관리</a>
</div>
<h1>🔬 KIMS 상호작용 로그</h1>
<p>약물 상호작용 체크 API 호출 기록 · AI 학습용 데이터</p>
</div>
<div class="content">
<!-- 통계 -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="stat-label">총 호출</div>
<div class="stat-value default" id="statTotal">-</div>
</div>
<div class="stat-card">
<div class="stat-label">성공</div>
<div class="stat-value green" id="statSuccess">-</div>
</div>
<div class="stat-card">
<div class="stat-label">상호작용 발견</div>
<div class="stat-value orange" id="statInteraction">-</div>
</div>
<div class="stat-card">
<div class="stat-label">심각 경고</div>
<div class="stat-value red" id="statSevere">-</div>
</div>
<div class="stat-card">
<div class="stat-label">평균 응답</div>
<div class="stat-value blue" id="statAvgMs">-</div>
<div class="stat-sub">밀리초</div>
</div>
</div>
<!-- 필터 -->
<div class="filter-bar">
<select id="filterStatus">
<option value="">모든 상태</option>
<option value="SUCCESS">성공</option>
<option value="ERROR">에러</option>
<option value="TIMEOUT">타임아웃</option>
</select>
<select id="filterInteraction">
<option value="">모든 결과</option>
<option value="has">상호작용 있음</option>
<option value="severe">심각 상호작용</option>
<option value="none">상호작용 없음</option>
</select>
<input type="date" id="filterDate" />
<button onclick="loadLogs()">🔍 조회</button>
</div>
<!-- 테이블 -->
<div class="table-wrap">
<table>
<thead>
<tr>
<th>시간</th>
<th>처방번호</th>
<th>약품</th>
<th>상호작용</th>
<th>상태</th>
<th>응답</th>
</tr>
</thead>
<tbody id="logsBody">
<tr>
<td colspan="6" class="loading">로딩 중...</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
let logsData = [];
let openRowId = null;
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDateTime(dt) {
if (!dt) return '-';
const d = new Date(dt);
return `${d.getMonth()+1}/${d.getDate()} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
}
async function loadStats() {
try {
const res = await fetch('/api/kims/logs/stats');
const data = await res.json();
if (data.success) {
document.getElementById('statTotal').textContent = data.stats.total_calls || 0;
document.getElementById('statSuccess').textContent = data.stats.success_count || 0;
document.getElementById('statInteraction').textContent = data.stats.with_interaction || 0;
document.getElementById('statSevere').textContent = data.stats.with_severe || 0;
document.getElementById('statAvgMs').textContent = Math.round(data.stats.avg_response_ms || 0);
}
} catch (e) {
console.error('통계 로드 실패:', e);
}
}
async function loadLogs() {
const tbody = document.getElementById('logsBody');
tbody.innerHTML = '<tr><td colspan="6" class="loading">로딩 중...</td></tr>';
const status = document.getElementById('filterStatus').value;
const interaction = document.getElementById('filterInteraction').value;
const date = document.getElementById('filterDate').value;
try {
let url = '/api/kims/logs?limit=100';
if (status) url += `&status=${status}`;
if (interaction) url += `&interaction=${interaction}`;
if (date) url += `&date=${date}`;
const res = await fetch(url);
const data = await res.json();
if (!data.success || !data.logs || data.logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><div class="empty-icon">📭</div><div>로그가 없습니다</div></td></tr>';
return;
}
logsData = data.logs;
renderLogs();
} catch (e) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">로드 실패</td></tr>';
}
}
function renderLogs() {
const tbody = document.getElementById('logsBody');
let html = '';
logsData.forEach((log, idx) => {
// 약품 배지
let drugs = [];
try { drugs = JSON.parse(log.request_drug_names || '[]'); } catch(e) {}
const drugBadges = drugs.slice(0, 3).map(d =>
`<span class="badge badge-drug">${escapeHtml(d.slice(0, 12))}</span>`
).join('') + (drugs.length > 3 ? `<span class="badge badge-drug">+${drugs.length - 3}</span>` : '');
// 상호작용 표시
let interactionHtml = '';
if (log.interaction_count > 0) {
if (log.has_severe_interaction) {
interactionHtml = `<span class="interaction-count severe">⚠️ ${log.interaction_count}</span>`;
} else {
interactionHtml = `<span class="interaction-count has">${log.interaction_count}건</span>`;
}
} else {
interactionHtml = `<span class="interaction-count zero">✓ 없음</span>`;
}
// 상태 배지
let statusBadge = '';
if (log.api_status === 'SUCCESS') {
statusBadge = '<span class="badge badge-success">성공</span>';
} else if (log.api_status === 'TIMEOUT') {
statusBadge = '<span class="badge badge-timeout">타임아웃</span>';
} else {
statusBadge = '<span class="badge badge-error">에러</span>';
}
html += `
<tr onclick="toggleDetail(${log.id}, ${idx})">
<td>${formatDateTime(log.created_at)}</td>
<td>${escapeHtml(log.pre_serial) || '-'}</td>
<td>${drugBadges}</td>
<td>${interactionHtml}</td>
<td>${statusBadge}</td>
<td>${log.response_time_ms || 0}ms</td>
</tr>
<tr class="detail-row" id="detail-${log.id}">
<td colspan="6">
<div class="detail-content" id="detail-content-${log.id}">
로딩 중...
</div>
</td>
</tr>
`;
});
tbody.innerHTML = html;
}
async function toggleDetail(logId, idx) {
const detailRow = document.getElementById(`detail-${logId}`);
if (openRowId === logId) {
detailRow.classList.remove('open');
openRowId = null;
return;
}
// 기존 열린 행 닫기
if (openRowId) {
document.getElementById(`detail-${openRowId}`)?.classList.remove('open');
}
openRowId = logId;
detailRow.classList.add('open');
// 상세 데이터 로드
const contentDiv = document.getElementById(`detail-content-${logId}`);
try {
const res = await fetch(`/api/kims/logs/${logId}`);
const data = await res.json();
if (!data.success) {
contentDiv.innerHTML = '<p>상세 정보 로드 실패</p>';
return;
}
const log = data.log;
let drugs = [];
try { drugs = JSON.parse(log.request_drug_names || '[]'); } catch(e) {}
// 상호작용 카드
let interactionsHtml = '';
const interactions = log.interactions_detail || [];
if (interactions.length === 0) {
interactionsHtml = '<p style="color:#16a34a;font-weight:600;">✅ 상호작용 없음</p>';
} else {
interactions.forEach(inter => {
const sevLevel = inter.severity_level || 5;
const sevClass = sevLevel == 1 ? 'severe' : sevLevel == 2 ? 'moderate' : 'mild';
interactionsHtml += `
<div class="interaction-card ${sevClass}">
<div class="interaction-header">
<span class="interaction-drugs">${escapeHtml(inter.drug1_name)}${escapeHtml(inter.drug2_name)}</span>
<span class="badge badge-${sevClass}">${escapeHtml(inter.severity_desc) || '알 수 없음'}</span>
</div>
${inter.observation ? `<div class="interaction-desc">${escapeHtml(inter.observation)}</div>` : ''}
${inter.clinical_management ? `<div class="interaction-mgmt">💡 ${escapeHtml(inter.clinical_management).slice(0, 200)}...</div>` : ''}
</div>
`;
});
}
contentDiv.innerHTML = `
<div class="detail-section">
<div class="detail-section-title">💊 분석 약품 (${drugs.length}개)</div>
<div class="drug-pills">
${drugs.map(d => `<span class="badge badge-drug">${escapeHtml(d)}</span>`).join('')}
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">⚠️ 상호작용 (${interactions.length}건)</div>
${interactionsHtml}
</div>
<div class="detail-section" style="font-size:12px;color:#94a3b8;">
응답시간: ${log.response_time_ms}ms ·
호출시간: ${log.created_at} ·
처방번호: ${escapeHtml(log.pre_serial) || '-'}
</div>
`;
} catch (e) {
contentDiv.innerHTML = '<p>로드 실패</p>';
}
}
// 초기 로드
loadStats();
loadLogs();
</script>
</body>
</html>

View File

@@ -1038,10 +1038,6 @@
`;
}).join('');
// 약품 코드 배열 (상호작용 체크용)
const drugCodes = (rx.items || []).map(item => item.drug_code).filter(c => c);
const drugCodesJson = JSON.stringify(drugCodes);
return `
<div class="purchase-card" style="border-left: 3px solid #6366f1;">
<div class="purchase-header">
@@ -1054,14 +1050,6 @@
${rx.items && rx.items.length > 0 ? `
<div class="purchase-items">${itemsHtml}</div>
` : ''}
${drugCodes.length >= 2 ? `
<div style="margin-top:10px;text-align:right;">
<button onclick='checkDrugInteraction(${drugCodesJson}, "${rx.pre_serial || ""}")'
style="background:linear-gradient(135deg,#8b5cf6,#6366f1);color:#fff;border:none;padding:8px 14px;border-radius:8px;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">
🔬 AI 상호작용 체크
</button>
</div>
` : ''}
</div>
`;
}).join('');
@@ -1123,158 +1111,6 @@
// 페이지 로드 시 검색창 포커스
document.getElementById('searchInput').focus();
// ═══════════════════════════════════════════════════
// KIMS 약물 상호작용 체크
// ═══════════════════════════════════════════════════
async function checkDrugInteraction(drugCodes, preSerial) {
// 로딩 모달 표시
showInteractionModal('loading');
try {
const response = await fetch('/api/kims/interaction-check', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
drug_codes: drugCodes,
pre_serial: preSerial
})
});
const data = await response.json();
if (data.success) {
showInteractionModal('result', data);
} else {
showInteractionModal('error', data.error || '알 수 없는 오류');
}
} catch (err) {
showInteractionModal('error', '서버 연결 실패: ' + err.message);
}
}
function showInteractionModal(type, data) {
let modal = document.getElementById('interactionModal');
if (!modal) {
// 모달 생성
modal = document.createElement('div');
modal.id = 'interactionModal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
document.body.appendChild(modal);
}
let content = '';
if (type === 'loading') {
content = `
<div style="background:#fff;border-radius:16px;padding:40px;text-align:center;max-width:400px;">
<div style="font-size:48px;margin-bottom:16px;">🔬</div>
<div style="font-size:18px;font-weight:600;color:#334155;">상호작용 분석 중...</div>
<div style="font-size:14px;color:#64748b;margin-top:8px;">KIMS 데이터베이스 조회 중</div>
</div>
`;
} else if (type === 'error') {
content = `
<div style="background:#fff;border-radius:16px;padding:30px;max-width:400px;">
<div style="font-size:40px;text-align:center;margin-bottom:16px;">⚠️</div>
<div style="font-size:16px;font-weight:600;color:#dc2626;text-align:center;">분석 실패</div>
<div style="font-size:14px;color:#64748b;margin-top:12px;text-align:center;">${escapeHtml(data)}</div>
<div style="text-align:center;margin-top:20px;">
<button onclick="document.getElementById('interactionModal').remove()"
style="background:#6366f1;color:#fff;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;">
닫기
</button>
</div>
</div>
`;
} else if (type === 'result') {
const interactions = data.interactions || [];
const drugsChecked = data.drugs_checked || [];
// 약품 목록 (상호작용 있는 약품은 빨간색/주황색 배경)
const drugsHtml = drugsChecked.map(d => {
const hasInteraction = d.has_interaction;
const bgColor = hasInteraction ? '#fef2f2' : '#f1f5f9'; // 연한 빨강 vs 회색
const borderColor = hasInteraction ? '#fca5a5' : '#e2e8f0';
const textColor = hasInteraction ? '#dc2626' : '#334155';
const icon = hasInteraction ? '⚠️ ' : '';
return `<span style="display:inline-block;background:${bgColor};border:1px solid ${borderColor};color:${textColor};padding:4px 8px;border-radius:4px;margin:2px;font-size:12px;">${icon}${escapeHtml(d.name.slice(0,20))}</span>`;
}).join('');
// 상호작용 목록
let interactionsHtml = '';
if (interactions.length === 0) {
interactionsHtml = `
<div style="text-align:center;padding:30px;">
<div style="font-size:48px;margin-bottom:12px;">✅</div>
<div style="font-size:16px;font-weight:600;color:#10b981;">상호작용 없음</div>
<div style="font-size:13px;color:#64748b;margin-top:8px;">
${data.total_pairs}개 약품 조합을 검사했습니다.<br>
주의가 필요한 상호작용이 발견되지 않았습니다.
</div>
</div>
`;
} else {
interactionsHtml = interactions.map(item => `
<div style="background:#fff;border:1px solid ${item.severity_color};border-radius:12px;padding:16px;margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="font-weight:600;color:#334155;">
${escapeHtml(item.drug1_name?.slice(0,20) || '')}${escapeHtml(item.drug2_name?.slice(0,20) || '')}
</span>
<span style="background:${item.severity_color};color:#fff;padding:4px 10px;border-radius:12px;font-size:12px;font-weight:500;">
${item.severity_text}
</span>
</div>
${item.description ? `
<div style="font-size:13px;color:#475569;margin-bottom:8px;line-height:1.5;">
📋 ${escapeHtml(item.description)}
</div>
` : ''}
${item.management ? `
<div style="font-size:12px;color:#059669;background:#ecfdf5;padding:8px 12px;border-radius:6px;">
💡 ${escapeHtml(item.management)}
</div>
` : ''}
</div>
`).join('');
}
content = `
<div style="background:#f8fafc;border-radius:20px;max-width:500px;max-height:80vh;overflow:hidden;display:flex;flex-direction:column;">
<div style="background:linear-gradient(135deg,#8b5cf6,#6366f1);padding:20px 24px;color:#fff;">
<div style="font-size:18px;font-weight:700;display:flex;align-items:center;gap:10px;">
🔬 약물 상호작용 분석
</div>
<div style="font-size:13px;opacity:0.9;margin-top:6px;">
${drugsChecked.length}개 약품 · ${data.total_pairs}개 조합 검사
</div>
</div>
<div style="padding:16px 20px;border-bottom:1px solid #e2e8f0;">
<div style="font-size:12px;color:#64748b;margin-bottom:6px;">분석 약품</div>
${drugsHtml}
</div>
<div style="flex:1;overflow-y:auto;padding:16px 20px;">
${interactions.length > 0 ? `
<div style="font-size:13px;color:#dc2626;font-weight:600;margin-bottom:12px;">
⚠️ ${interactions.length}건의 상호작용 발견
</div>
` : ''}
${interactionsHtml}
</div>
<div style="padding:16px 20px;border-top:1px solid #e2e8f0;text-align:center;">
<button onclick="document.getElementById('interactionModal').remove()"
style="background:#6366f1;color:#fff;border:none;padding:12px 32px;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;">
닫기
</button>
</div>
</div>
`;
}
modal.innerHTML = content;
}
</script>
</body>
</html>

View File

@@ -1,704 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OTC 용법 라벨 관리 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', sans-serif;
background: #f5f7fa;
min-height: 100vh;
}
/* 헤더 */
.header {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
padding: 20px 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.header-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title {
font-size: 22px;
font-weight: 700;
}
.header-nav a {
color: white;
text-decoration: none;
margin-left: 16px;
opacity: 0.9;
}
.header-nav a:hover { opacity: 1; }
/* 컨테이너 */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
display: grid;
grid-template-columns: 400px 1fr;
gap: 24px;
}
/* 패널 */
.panel {
background: white;
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
overflow: hidden;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
font-weight: 700;
font-size: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.panel-body {
padding: 20px;
}
/* 검색 */
.search-box {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.search-input {
flex: 1;
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 10px;
font-size: 15px;
transition: border-color 0.2s;
}
.search-input:focus {
outline: none;
border-color: #f59e0b;
}
.search-btn {
padding: 12px 20px;
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
transition: transform 0.1s;
}
.search-btn:hover { transform: scale(1.02); }
/* 검색 결과 */
.search-results {
max-height: 200px;
overflow-y: auto;
border: 1px solid #e2e8f0;
border-radius: 10px;
margin-bottom: 20px;
}
.search-result-item {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
cursor: pointer;
transition: background 0.1s;
}
.search-result-item:hover { background: #fef3c7; }
.search-result-item:last-child { border-bottom: none; }
.search-result-name {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
}
.search-result-barcode {
font-size: 12px;
color: #64748b;
font-family: monospace;
}
/* 폼 */
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 600;
color: #64748b;
margin-bottom: 6px;
}
.form-input, .form-textarea {
width: 100%;
padding: 12px 14px;
border: 2px solid #e2e8f0;
border-radius: 10px;
font-size: 15px;
font-family: inherit;
transition: border-color 0.2s;
}
.form-input:focus, .form-textarea:focus {
outline: none;
border-color: #f59e0b;
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
.form-input[readonly] {
background: #f8fafc;
color: #64748b;
}
/* 버튼 */
.btn-group {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn {
flex: 1;
padding: 14px 20px;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(245,158,11,0.3); }
.btn-secondary {
background: #e2e8f0;
color: #475569;
}
.btn-secondary:hover { background: #cbd5e1; }
.btn-print {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
}
.btn-print:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(99,102,241,0.3); }
.btn-delete {
background: #fee2e2;
color: #dc2626;
}
.btn-delete:hover { background: #fecaca; }
/* 미리보기 */
.preview-container {
text-align: center;
padding: 20px;
background: #f8fafc;
border-radius: 12px;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.preview-image {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.preview-placeholder {
color: #94a3b8;
font-size: 14px;
}
/* 목록 테이블 */
.label-list {
max-height: 400px;
overflow-y: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th {
background: #f8fafc;
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: #64748b;
border-bottom: 1px solid #e2e8f0;
position: sticky;
top: 0;
}
td {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
tr:hover { background: #fef3c7; cursor: pointer; }
.td-name {
font-weight: 600;
color: #1e293b;
}
.td-effect {
color: #d97706;
font-weight: 500;
}
.td-count {
font-family: monospace;
color: #64748b;
}
/* 토스트 */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
padding: 14px 28px;
border-radius: 12px;
font-weight: 600;
z-index: 9999;
animation: toastIn 0.3s ease;
}
.toast.success { background: #10b981; color: white; }
.toast.error { background: #ef4444; color: white; }
@keyframes toastIn {
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* 반응형 */
@media (max-width: 900px) {
.container {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header class="header">
<div class="header-content">
<div class="header-title">💊 OTC 용법 라벨 관리</div>
<nav class="header-nav">
<a href="/admin">📊 대시보드</a>
<a href="/admin/pos-live">📋 실시간 POS</a>
<a href="/admin/members">👥 회원</a>
</nav>
</div>
</header>
<div class="container">
<!-- 왼쪽: 편집 패널 -->
<div class="panel">
<div class="panel-header">✏️ 라벨 편집</div>
<div class="panel-body">
<!-- 약품 검색 -->
<div class="search-box">
<input type="text" class="search-input" id="searchInput" placeholder="바코드 또는 약품명 검색...">
<button class="search-btn" onclick="searchDrug()">검색</button>
</div>
<!-- 검색 결과 -->
<div class="search-results" id="searchResults" style="display:none;"></div>
<!-- 편집 폼 -->
<form id="labelForm">
<div class="form-group">
<label class="form-label">바코드</label>
<input type="text" class="form-input" id="barcode" readonly placeholder="약품을 검색하세요">
</div>
<div class="form-group">
<label class="form-label">약품명 (표시용)</label>
<input type="text" class="form-input" id="displayName" placeholder="오버라이드 이름 (비우면 원본 사용)">
</div>
<div class="form-group">
<label class="form-label">효능 ⭐</label>
<input type="text" class="form-input" id="effect" placeholder="예: 치통, 두통">
</div>
<div class="form-group">
<label class="form-label">용법</label>
<textarea class="form-textarea" id="dosageInstruction" placeholder="예: 1일 3회, 1회 1정, 식후 30분"></textarea>
</div>
<div class="form-group">
<label class="form-label">부가 설명</label>
<input type="text" class="form-input" id="usageTip" placeholder="예: [통증 시에만 복용]">
</div>
<div class="btn-group">
<button type="button" class="btn btn-secondary" onclick="previewLabel()">👁️ 미리보기</button>
<button type="button" class="btn btn-primary" onclick="saveLabel()">💾 저장</button>
</div>
<div class="btn-group">
<button type="button" class="btn btn-print" onclick="printLabel()">🖨️ 인쇄</button>
<button type="button" class="btn btn-delete" onclick="deleteLabel()">🗑️ 삭제</button>
</div>
</form>
</div>
</div>
<!-- 오른쪽: 미리보기 + 목록 -->
<div style="display: flex; flex-direction: column; gap: 24px;">
<!-- 미리보기 -->
<div class="panel">
<div class="panel-header">👁️ 라벨 미리보기</div>
<div class="panel-body">
<div class="preview-container" id="previewContainer">
<div class="preview-placeholder">미리보기를 클릭하면 라벨이 표시됩니다</div>
</div>
</div>
</div>
<!-- 저장된 목록 -->
<div class="panel">
<div class="panel-header">📋 저장된 라벨 프리셋</div>
<div class="panel-body">
<div class="label-list" id="labelList">
<table>
<thead>
<tr>
<th>약품명</th>
<th>효능</th>
<th>인쇄</th>
</tr>
</thead>
<tbody id="labelListBody">
<tr><td colspan="3" style="text-align:center; color:#94a3b8;">로딩 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
let currentBarcode = '';
let currentDrugName = '';
// 초기화
document.addEventListener('DOMContentLoaded', () => {
loadLabelList();
// Enter 키로 검색
document.getElementById('searchInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') searchDrug();
});
// 입력 시 자동 미리보기 (디바운스)
let debounceTimer;
['effect', 'dosageInstruction', 'usageTip', 'displayName'].forEach(id => {
document.getElementById(id).addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(previewLabel, 500);
});
});
// URL 파라미터로 바코드/이름 전달 시 자동 로드
const params = new URLSearchParams(window.location.search);
const urlBarcode = params.get('barcode');
const urlName = params.get('name');
if (urlBarcode) {
currentBarcode = urlBarcode;
currentDrugName = urlName || urlBarcode;
document.getElementById('barcode').value = urlBarcode;
document.getElementById('searchInput').value = urlName || urlBarcode;
// 기존 프리셋 확인
fetch(`/api/admin/otc-labels/${urlBarcode}`)
.then(res => res.json())
.then(data => {
if (data.exists) {
document.getElementById('displayName').value = data.label.display_name || '';
document.getElementById('effect').value = data.label.effect || '';
document.getElementById('dosageInstruction').value = data.label.dosage_instruction || '';
document.getElementById('usageTip').value = data.label.usage_tip || '';
}
previewLabel();
});
}
});
// 약품 검색 (MSSQL)
async function searchDrug() {
const query = document.getElementById('searchInput').value.trim();
if (!query) return;
try {
const res = await fetch(`/api/admin/otc-labels/search-mssql?q=${encodeURIComponent(query)}`);
const data = await res.json();
const resultsDiv = document.getElementById('searchResults');
if (data.success && data.drugs.length > 0) {
resultsDiv.innerHTML = data.drugs.map(drug => `
<div class="search-result-item" onclick="selectDrug('${drug.barcode}', '${escapeHtml(drug.goods_name)}', '${drug.drug_code}')">
<div class="search-result-name">${drug.goods_name}</div>
<div class="search-result-barcode">${drug.barcode}</div>
</div>
`).join('');
resultsDiv.style.display = 'block';
} else {
resultsDiv.innerHTML = '<div class="search-result-item" style="color:#94a3b8;">검색 결과 없음</div>';
resultsDiv.style.display = 'block';
}
} catch (err) {
showToast('검색 오류: ' + err.message, 'error');
}
}
// 약품 선택
async function selectDrug(barcode, goodsName, drugCode) {
document.getElementById('searchResults').style.display = 'none';
document.getElementById('searchInput').value = goodsName;
currentBarcode = barcode;
currentDrugName = goodsName;
document.getElementById('barcode').value = barcode;
// 기존 프리셋 확인
try {
const res = await fetch(`/api/admin/otc-labels/${barcode}`);
const data = await res.json();
if (data.exists) {
// 기존 데이터 로드
document.getElementById('displayName').value = data.label.display_name || '';
document.getElementById('effect').value = data.label.effect || '';
document.getElementById('dosageInstruction').value = data.label.dosage_instruction || '';
document.getElementById('usageTip').value = data.label.usage_tip || '';
showToast('기존 프리셋 로드됨', 'success');
} else {
// 새 프리셋 (MSSQL 이름 사용)
document.getElementById('displayName').value = '';
document.getElementById('effect').value = '';
document.getElementById('dosageInstruction').value = '';
document.getElementById('usageTip').value = '';
}
previewLabel();
} catch (err) {
console.error(err);
}
}
// 미리보기
async function previewLabel() {
const drugName = document.getElementById('displayName').value || currentDrugName || '약품명';
const effect = document.getElementById('effect').value;
const dosageInstruction = document.getElementById('dosageInstruction').value;
const usageTip = document.getElementById('usageTip').value;
try {
const res = await fetch('/api/admin/otc-labels/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ drug_name: drugName, effect, dosage_instruction: dosageInstruction, usage_tip: usageTip })
});
const data = await res.json();
if (data.success) {
document.getElementById('previewContainer').innerHTML =
`<img src="${data.preview_url}" class="preview-image" alt="라벨 미리보기">`;
}
} catch (err) {
console.error('미리보기 오류:', err);
}
}
// 저장
async function saveLabel() {
if (!currentBarcode) {
showToast('먼저 약품을 검색하세요', 'error');
return;
}
// display_name이 비어있으면 원본 약품명 사용
const displayName = document.getElementById('displayName').value || currentDrugName;
const payload = {
barcode: currentBarcode,
display_name: displayName,
effect: document.getElementById('effect').value,
dosage_instruction: document.getElementById('dosageInstruction').value,
usage_tip: document.getElementById('usageTip').value
};
console.log('저장 payload:', payload);
try {
const res = await fetch('/api/admin/otc-labels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
console.log('저장 응답 status:', res.status);
const data = await res.json();
console.log('저장 응답 data:', data);
if (data.success) {
showToast('저장 완료!', 'success');
loadLabelList();
} else {
showToast(data.error || '알 수 없는 오류', 'error');
}
} catch (err) {
console.error('저장 오류:', err);
showToast('저장 오류: ' + err.message, 'error');
}
}
// 인쇄
async function printLabel() {
const drugName = document.getElementById('displayName').value || currentDrugName || '약품명';
const effect = document.getElementById('effect').value;
const dosageInstruction = document.getElementById('dosageInstruction').value;
const usageTip = document.getElementById('usageTip').value;
if (!effect && !dosageInstruction) {
showToast('효능 또는 용법을 입력하세요', 'error');
return;
}
try {
const res = await fetch('/api/admin/otc-labels/print', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
barcode: currentBarcode,
drug_name: drugName,
effect,
dosage_instruction: dosageInstruction,
usage_tip: usageTip
})
});
const data = await res.json();
if (data.success) {
showToast('🖨️ 인쇄 완료!', 'success');
loadLabelList();
} else {
showToast(data.error, 'error');
}
} catch (err) {
showToast('인쇄 오류: ' + err.message, 'error');
}
}
// 삭제
async function deleteLabel() {
if (!currentBarcode) {
showToast('삭제할 프리셋이 없습니다', 'error');
return;
}
if (!confirm(`"${currentDrugName}" 프리셋을 삭제하시겠습니까?`)) {
return;
}
try {
const res = await fetch(`/api/admin/otc-labels/${currentBarcode}`, {
method: 'DELETE'
});
const data = await res.json();
if (data.success) {
showToast('삭제 완료!', 'success');
// 폼 초기화
currentBarcode = '';
currentDrugName = '';
document.getElementById('barcode').value = '';
document.getElementById('displayName').value = '';
document.getElementById('effect').value = '';
document.getElementById('dosageInstruction').value = '';
document.getElementById('usageTip').value = '';
document.getElementById('previewContainer').innerHTML = '<div class="preview-placeholder">미리보기를 클릭하면 라벨이 표시됩니다</div>';
loadLabelList();
} else {
showToast(data.error || '삭제 실패', 'error');
}
} catch (err) {
showToast('삭제 오류: ' + err.message, 'error');
}
}
// 목록 로드
async function loadLabelList() {
try {
const res = await fetch('/api/admin/otc-labels');
const data = await res.json();
if (data.success) {
const tbody = document.getElementById('labelListBody');
if (data.labels.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center; color:#94a3b8;">저장된 프리셋이 없습니다</td></tr>';
return;
}
tbody.innerHTML = data.labels.map(label => `
<tr onclick="loadLabel('${label.barcode}')">
<td class="td-name">${label.display_name || label.barcode}</td>
<td class="td-effect">${label.effect || '-'}</td>
<td class="td-count">${label.print_count || 0}회</td>
</tr>
`).join('');
}
} catch (err) {
console.error('목록 로드 오류:', err);
}
}
// 목록에서 로드
async function loadLabel(barcode) {
try {
const res = await fetch(`/api/admin/otc-labels/${barcode}`);
const data = await res.json();
if (data.exists) {
currentBarcode = barcode;
currentDrugName = data.label.display_name || barcode;
document.getElementById('barcode').value = barcode;
document.getElementById('displayName').value = data.label.display_name || '';
document.getElementById('effect').value = data.label.effect || '';
document.getElementById('dosageInstruction').value = data.label.dosage_instruction || '';
document.getElementById('usageTip').value = data.label.usage_tip || '';
previewLabel();
showToast('프리셋 로드됨', 'success');
}
} catch (err) {
showToast('로드 오류: ' + err.message, 'error');
}
}
// 유틸
function escapeHtml(str) {
return str.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
}
function showToast(message, type = 'success') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
</script>
</body>
</html>

View File

@@ -1,494 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PAAI 분석 로그 - 관리자</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
background: #f3f4f6;
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
padding: 20px 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 1.5rem; }
.header a { color: #fff; text-decoration: none; opacity: 0.8; }
.header a:hover { opacity: 1; }
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* 통계 카드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 15px;
margin-bottom: 25px;
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 20px;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.stat-card .num { font-size: 2rem; font-weight: 700; color: #10b981; }
.stat-card .label { font-size: 0.85rem; color: #6b7280; margin-top: 5px; }
.stat-card.severe .num { color: #ef4444; }
/* 필터 */
.filters {
background: #fff;
padding: 15px 20px;
border-radius: 12px;
margin-bottom: 20px;
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.filters input, .filters select {
padding: 8px 12px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 0.9rem;
}
.filters input:focus, .filters select:focus {
outline: none;
border-color: #10b981;
}
.filters button {
padding: 8px 20px;
background: #10b981;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
.filters button:hover { background: #059669; }
/* 로그 테이블 */
.log-table {
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.log-table table {
width: 100%;
border-collapse: collapse;
}
.log-table th {
background: #f9fafb;
padding: 12px 15px;
text-align: left;
font-weight: 600;
color: #374151;
border-bottom: 2px solid #e5e7eb;
}
.log-table td {
padding: 12px 15px;
border-bottom: 1px solid #f3f4f6;
font-size: 0.9rem;
}
.log-table tr:hover { background: #f9fafb; cursor: pointer; }
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-success { background: #d1fae5; color: #065f46; }
.badge-error { background: #fee2e2; color: #991b1b; }
.badge-pending { background: #fef3c7; color: #92400e; }
.badge-severe { background: #fee2e2; color: #dc2626; }
.badge-caution { background: #fef3c7; color: #d97706; }
.feedback-icon { font-size: 1.1rem; }
/* 상세 모달 */
.modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
justify-content: center;
align-items: center;
padding: 20px;
}
.modal.show { display: flex; }
.modal-content {
background: #fff;
border-radius: 16px;
width: 100%;
max-width: 900px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
padding: 20px 25px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-close {
background: rgba(255,255,255,0.2);
border: none;
color: #fff;
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 1.5rem;
cursor: pointer;
}
.modal-body {
padding: 25px;
overflow-y: auto;
flex: 1;
}
.detail-section {
margin-bottom: 20px;
}
.detail-section h4 {
font-size: 0.95rem;
color: #374151;
margin-bottom: 10px;
border-bottom: 2px solid #10b981;
padding-bottom: 5px;
}
.detail-section pre {
background: #f9fafb;
padding: 15px;
border-radius: 8px;
font-size: 0.85rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.detail-item {
background: #f9fafb;
padding: 10px 15px;
border-radius: 8px;
}
.detail-item .label { font-size: 0.8rem; color: #6b7280; }
.detail-item .value { font-weight: 600; color: #111827; }
.loading {
text-align: center;
padding: 60px;
color: #9ca3af;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #10b981;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="header">
<h1>🤖 PAAI 분석 로그</h1>
<a href="/admin">← 관리자 홈</a>
</div>
<div class="container">
<!-- 통계 -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="num" id="statTotal">-</div>
<div class="label">전체</div>
</div>
<div class="stat-card">
<div class="num" id="statToday">-</div>
<div class="label">오늘</div>
</div>
<div class="stat-card">
<div class="num" id="statSuccessRate">-</div>
<div class="label">성공률</div>
</div>
<div class="stat-card">
<div class="num" id="statAvgTime">-</div>
<div class="label">평균 응답(ms)</div>
</div>
<div class="stat-card severe">
<div class="num" id="statSevere">-</div>
<div class="label">심각 상호작용</div>
</div>
<div class="stat-card">
<div class="num" id="statFeedback">-</div>
<div class="label">유용 피드백</div>
</div>
</div>
<!-- 필터 -->
<div class="filters">
<input type="date" id="filterDate" placeholder="날짜">
<select id="filterStatus">
<option value="">상태: 전체</option>
<option value="success">성공</option>
<option value="error">오류</option>
<option value="pending">대기중</option>
</select>
<select id="filterSevere">
<option value="">상호작용: 전체</option>
<option value="true">심각 있음</option>
<option value="false">심각 없음</option>
</select>
<button onclick="loadLogs()">🔍 조회</button>
<button onclick="loadLogs()" style="background:#6b7280;">🔄 새로고침</button>
</div>
<!-- 로그 테이블 -->
<div class="log-table">
<table>
<thead>
<tr>
<th>시간</th>
<th>환자</th>
<th>처방번호</th>
<th>약품수</th>
<th>KIMS</th>
<th>상태</th>
<th>응답시간</th>
<th>피드백</th>
</tr>
</thead>
<tbody id="logTableBody">
<tr><td colspan="8" class="loading"><div class="spinner"></div>로딩 중...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 상세 모달 -->
<div class="modal" id="detailModal">
<div class="modal-content">
<div class="modal-header">
<h3>📋 분석 상세</h3>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body" id="modalBody">
<div class="loading"><div class="spinner"></div></div>
</div>
</div>
</div>
<script>
// 페이지 로드
document.addEventListener('DOMContentLoaded', () => {
loadStats();
loadLogs();
});
// 통계 로드
async function loadStats() {
try {
const res = await fetch('/api/paai/logs/stats');
const data = await res.json();
if (data.success) {
const s = data.stats;
document.getElementById('statTotal').textContent = s.total;
document.getElementById('statToday').textContent = s.today;
document.getElementById('statSuccessRate').textContent = s.success_rate + '%';
document.getElementById('statAvgTime').textContent = s.avg_response_time;
document.getElementById('statSevere').textContent = s.severe_count;
document.getElementById('statFeedback').textContent =
s.feedback ? `${s.feedback.useful}/${s.feedback.total}` : '0/0';
}
} catch (err) {
console.error('Stats error:', err);
}
}
// 로그 로드
async function loadLogs() {
const tbody = document.getElementById('logTableBody');
tbody.innerHTML = '<tr><td colspan="8" class="loading"><div class="spinner"></div>로딩 중...</td></tr>';
try {
const date = document.getElementById('filterDate').value;
const status = document.getElementById('filterStatus').value;
const severe = document.getElementById('filterSevere').value;
const params = new URLSearchParams();
if (date) params.append('date', date);
if (status) params.append('status', status);
if (severe) params.append('has_severe', severe);
params.append('limit', '100');
const res = await fetch(`/api/paai/logs?${params}`);
const data = await res.json();
if (data.success) {
if (data.logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#9ca3af;padding:40px;">로그가 없습니다.</td></tr>';
return;
}
tbody.innerHTML = data.logs.map(log => {
const time = new Date(log.created_at).toLocaleString('ko-KR', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});
const statusBadge = {
'success': '<span class="badge badge-success">성공</span>',
'error': '<span class="badge badge-error">오류</span>',
'pending': '<span class="badge badge-pending">대기</span>',
'kims_done': '<span class="badge badge-pending">AI대기</span>'
}[log.status] || log.status;
const kimsBadge = log.kims_has_severe
? `<span class="badge badge-severe">🔴 ${log.kims_interaction_count}건</span>`
: log.kims_interaction_count > 0
? `<span class="badge badge-caution">⚠️ ${log.kims_interaction_count}건</span>`
: '<span style="color:#9ca3af;">-</span>';
const feedback = log.feedback_useful === 1 ? '👍'
: log.feedback_useful === 0 ? '👎' : '-';
return `
<tr onclick="showDetail(${log.id})">
<td>${time}</td>
<td>${log.patient_name || '-'}</td>
<td>${log.pre_serial || '-'}</td>
<td>${log.current_med_count || 0}종</td>
<td>${kimsBadge}</td>
<td>${statusBadge}</td>
<td>${log.ai_response_time_ms || '-'}ms</td>
<td class="feedback-icon">${feedback}</td>
</tr>
`;
}).join('');
}
} catch (err) {
console.error('Logs error:', err);
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#ef4444;">로드 실패</td></tr>';
}
}
// 상세 보기
async function showDetail(logId) {
const modal = document.getElementById('detailModal');
const body = document.getElementById('modalBody');
body.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
modal.classList.add('show');
try {
const res = await fetch(`/api/paai/logs/${logId}`);
const data = await res.json();
if (data.success) {
const log = data.log;
body.innerHTML = `
<div class="detail-section">
<h4>📌 기본 정보</h4>
<div class="detail-grid">
<div class="detail-item">
<div class="label">환자</div>
<div class="value">${log.patient_name || '-'} (${log.patient_code || '-'})</div>
</div>
<div class="detail-item">
<div class="label">처방번호</div>
<div class="value">${log.pre_serial || '-'}</div>
</div>
<div class="detail-item">
<div class="label">질병1</div>
<div class="value">[${log.disease_code_1 || '-'}] ${log.disease_name_1 || '-'}</div>
</div>
<div class="detail-item">
<div class="label">질병2</div>
<div class="value">[${log.disease_code_2 || '-'}] ${log.disease_name_2 || '-'}</div>
</div>
</div>
</div>
<div class="detail-section">
<h4>💊 현재 처방 (${log.current_med_count || 0}종)</h4>
<pre>${JSON.stringify(log.current_medications, null, 2)}</pre>
</div>
<div class="detail-section">
<h4>⚠️ KIMS 상호작용 (${log.kims_interaction_count || 0}건)</h4>
<pre>${JSON.stringify(log.kims_interactions, null, 2)}</pre>
</div>
<div class="detail-section">
<h4>🤖 AI 분석 결과</h4>
<pre>${JSON.stringify(log.ai_response, null, 2)}</pre>
</div>
<div class="detail-section">
<h4>📊 성능</h4>
<div class="detail-grid">
<div class="detail-item">
<div class="label">KIMS 응답</div>
<div class="value">${log.kims_response_time_ms || '-'}ms</div>
</div>
<div class="detail-item">
<div class="label">AI 응답</div>
<div class="value">${log.ai_response_time_ms || '-'}ms</div>
</div>
<div class="detail-item">
<div class="label">상태</div>
<div class="value">${log.status}</div>
</div>
<div class="detail-item">
<div class="label">피드백</div>
<div class="value">${log.feedback_useful === 1 ? '👍 유용' : log.feedback_useful === 0 ? '👎 아님' : '미응답'}</div>
</div>
</div>
</div>
`;
}
} catch (err) {
console.error('Detail error:', err);
body.innerHTML = '<div style="text-align:center;color:#ef4444;">로드 실패</div>';
}
}
function closeModal() {
document.getElementById('detailModal').classList.remove('show');
}
// ESC로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -369,170 +369,13 @@
/* 제품 셀 */
.product-cell {
display: flex;
align-items: center;
gap: 10px;
}
.product-thumb {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 6px;
background: var(--bg-secondary);
flex-shrink: 0;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.product-thumb:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(139,92,246,0.3);
}
.product-thumb-placeholder {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2a2a3e 0%, #1e1e2e 100%);
border-radius: 6px;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.05);
cursor: pointer;
transition: transform 0.15s, border-color 0.15s;
}
.product-thumb-placeholder:hover {
transform: scale(1.1);
border-color: var(--accent-purple);
}
.product-thumb-placeholder svg {
width: 18px;
height: 18px;
opacity: 0.3;
fill: #888;
}
/* 이미지 교체 모달 */
.image-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8);
z-index: 1000;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.image-modal.show { display: flex; }
.image-modal-content {
background: #1a1a3e;
border-radius: 16px;
padding: 24px;
max-width: 450px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
border: 1px solid rgba(139,92,246,0.3);
}
.image-modal-content h3 {
margin: 0 0 16px 0;
color: var(--accent-purple);
font-size: 18px;
}
.image-modal-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.tab-btn {
flex: 1;
padding: 10px;
border: 1px solid rgba(255,255,255,0.1);
background: transparent;
color: var(--text-secondary);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.tab-btn.active {
background: var(--accent-purple);
color: white;
border-color: var(--accent-purple);
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.image-input {
width: 100%;
padding: 12px;
background: var(--bg-secondary);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: var(--text-primary);
margin-bottom: 12px;
}
.image-input:focus {
outline: none;
border-color: var(--accent-purple);
}
.camera-container {
position: relative;
width: 100%;
aspect-ratio: 1;
background: #000;
border-radius: 8px;
overflow: hidden;
margin-bottom: 12px;
}
.camera-container video,
.camera-container canvas {
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-guide {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
}
.modal-btns {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.btn-modal {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-modal.secondary {
background: var(--bg-secondary);
color: var(--text-primary);
}
.btn-modal.primary {
background: var(--accent-purple);
color: white;
}
.btn-modal:hover {
transform: translateY(-1px);
}
.product-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
gap: 4px;
}
.product-name {
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.product-supplier {
font-size: 11px;
@@ -953,14 +796,8 @@
<tr>
<td>
<div class="product-cell">
${item.thumbnail
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')" alt="">`
: `<div class="product-thumb-placeholder" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')"><svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-7l-3 3.72L9 13l-3 4h12l-4-5z"/></svg></div>`
}
<div class="product-info">
<span class="product-name">${escapeHtml(item.product_name)}</span>
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
</div>
<span class="product-name">${escapeHtml(item.product_name)}</span>
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
</div>
</td>
<td>${renderCode(item)}</td>
@@ -989,14 +826,8 @@
<td style="color:var(--text-secondary);font-size:12px;">${item.sale_date}</td>
<td>
<div class="product-cell">
${item.thumbnail
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')" alt="">`
: `<div class="product-thumb-placeholder" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')"><svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-7l-3 3.72L9 13l-3 4h12l-4-5z"/></svg></div>`
}
<div class="product-info">
<span class="product-name">${escapeHtml(item.product_name)}</span>
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
</div>
<span class="product-name">${escapeHtml(item.product_name)}</span>
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
</div>
</td>
<td>${renderCode(item)}</td>
@@ -1066,288 +897,6 @@
// 초기 로드
loadSalesData();
// ──────────────── 이미지 교체 모달 ────────────────
let imgModalBarcode = null;
let imgModalDrugCode = null;
let imgModalName = null;
let cameraStream = null;
let capturedImageData = null;
function openImageModal(barcode, drugCode, productName) {
// 바코드나 drug_code 중 하나는 있어야 함
if (!barcode && !drugCode) {
showToast('제품 코드 정보가 없습니다', 'error');
return;
}
imgModalBarcode = barcode || null;
imgModalDrugCode = drugCode || null;
imgModalName = productName || (barcode || drugCode);
document.getElementById('imgModalProductName').textContent = imgModalName;
document.getElementById('imgModalCode').textContent = barcode || drugCode;
document.getElementById('imgUrlInput').value = '';
// URL 탭으로 초기화
switchImageTab('url');
document.getElementById('imageModal').classList.add('show');
document.getElementById('imgUrlInput').focus();
}
function closeImageModal() {
stopCamera();
document.getElementById('imageModal').classList.remove('show');
imgModalBarcode = null;
imgModalDrugCode = null;
imgModalName = null;
capturedImageData = null;
}
function switchImageTab(tab) {
document.querySelectorAll('.image-modal .tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
document.querySelectorAll('.image-modal .tab-content').forEach(content => {
content.classList.toggle('active', content.id === 'tab' + tab.charAt(0).toUpperCase() + tab.slice(1));
});
if (tab === 'camera') {
startCamera();
} else {
stopCamera();
}
}
async function startCamera() {
try {
stopCamera();
const constraints = {
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1920 },
height: { ideal: 1920 }
},
audio: false
};
cameraStream = await navigator.mediaDevices.getUserMedia(constraints);
const video = document.getElementById('cameraVideo');
video.srcObject = cameraStream;
video.style.display = 'block';
document.getElementById('captureCanvas').style.display = 'none';
document.getElementById('cameraGuide').style.display = 'block';
document.getElementById('captureBtn').style.display = 'block';
document.getElementById('previewBtns').style.display = 'none';
capturedImageData = null;
} catch (err) {
console.error('카메라 오류:', err);
showToast('카메라에 접근할 수 없습니다', 'error');
}
}
function stopCamera() {
if (cameraStream) {
cameraStream.getTracks().forEach(track => track.stop());
cameraStream = null;
}
const video = document.getElementById('cameraVideo');
if (video) video.srcObject = null;
}
function capturePhoto() {
const video = document.getElementById('cameraVideo');
const canvas = document.getElementById('captureCanvas');
const ctx = canvas.getContext('2d');
const vw = video.videoWidth;
const vh = video.videoHeight;
const minDim = Math.min(vw, vh);
const cropSize = minDim * 0.8;
const sx = (vw - cropSize) / 2;
const sy = (vh - cropSize) / 2;
canvas.width = 800;
canvas.height = 800;
ctx.drawImage(video, sx, sy, cropSize, cropSize, 0, 0, 800, 800);
capturedImageData = canvas.toDataURL('image/jpeg', 0.92);
video.style.display = 'none';
canvas.style.display = 'block';
document.getElementById('cameraGuide').style.display = 'none';
document.getElementById('captureBtn').style.display = 'none';
document.getElementById('previewBtns').style.display = 'flex';
}
function retakePhoto() {
const video = document.getElementById('cameraVideo');
const canvas = document.getElementById('captureCanvas');
video.style.display = 'block';
canvas.style.display = 'none';
document.getElementById('cameraGuide').style.display = 'block';
document.getElementById('captureBtn').style.display = 'block';
document.getElementById('previewBtns').style.display = 'none';
capturedImageData = null;
}
async function submitCapturedImage() {
if (!capturedImageData) {
showToast('촬영된 이미지가 없습니다', 'error');
return;
}
const code = imgModalBarcode || imgModalDrugCode;
const name = imgModalName;
const imageData = capturedImageData;
closeImageModal();
showToast(`"${name}" 이미지 저장 중...`, 'info');
try {
const res = await fetch(`/api/admin/product-images/${code}/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_data: imageData,
product_name: name,
drug_code: imgModalDrugCode
})
});
const data = await res.json();
if (data.success) {
showToast('✅ 이미지 저장 완료!', 'success');
loadSalesData(); // 새로고침
} else {
showToast(data.error || '저장 실패', 'error');
}
} catch (err) {
showToast('오류: ' + err.message, 'error');
}
}
async function submitImageUrl() {
const imageUrl = document.getElementById('imgUrlInput').value.trim();
if (!imageUrl) {
showToast('이미지 URL을 입력하세요', 'error');
return;
}
if (!imageUrl.startsWith('http')) {
showToast('올바른 URL을 입력하세요', 'error');
return;
}
const code = imgModalBarcode || imgModalDrugCode;
const name = imgModalName;
closeImageModal();
showToast(`"${name}" 이미지 다운로드 중...`, 'info');
try {
const res = await fetch(`/api/admin/product-images/${code}/replace`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_url: imageUrl,
product_name: name,
drug_code: imgModalDrugCode
})
});
const data = await res.json();
if (data.success) {
showToast('✅ 이미지 등록 완료!', 'success');
loadSalesData(); // 새로고침
} else {
showToast(data.error || '등록 실패', 'error');
}
} catch (err) {
showToast('오류: ' + err.message, 'error');
}
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
background: ${type === 'success' ? '#10b981' : type === 'error' ? '#ef4444' : '#6366f1'};
color: white;
border-radius: 8px;
font-size: 14px;
z-index: 2000;
animation: fadeIn 0.3s;
`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// 모달 외부 클릭시 닫기
document.getElementById('imageModal').addEventListener('click', e => {
if (e.target.id === 'imageModal') closeImageModal();
});
</script>
<!-- 이미지 교체 모달 -->
<div class="image-modal" id="imageModal">
<div class="image-modal-content">
<h3>📷 제품 이미지 등록</h3>
<div style="background: rgba(139,92,246,0.1); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
<div style="font-weight: 600;" id="imgModalProductName">제품명</div>
<div style="font-size: 12px; color: var(--text-muted); font-family: monospace;" id="imgModalCode">코드</div>
</div>
<div class="image-modal-tabs">
<button class="tab-btn active" data-tab="url" onclick="switchImageTab('url')">🔗 URL 입력</button>
<button class="tab-btn" data-tab="camera" onclick="switchImageTab('camera')">📸 촬영</button>
</div>
<!-- URL 탭 -->
<div class="tab-content active" id="tabUrl">
<input type="text" class="image-input" id="imgUrlInput" placeholder="이미지 URL을 입력하세요...">
<div class="modal-btns">
<button class="btn-modal secondary" onclick="closeImageModal()">취소</button>
<button class="btn-modal primary" onclick="submitImageUrl()">등록하기</button>
</div>
</div>
<!-- 카메라 탭 -->
<div class="tab-content" id="tabCamera">
<div class="camera-container">
<video id="cameraVideo" autoplay playsinline></video>
<canvas id="captureCanvas" style="display:none;"></canvas>
<div class="camera-guide" id="cameraGuide">
<svg width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none">
<rect x="10" y="10" width="80" height="80" fill="none" stroke="rgba(139,92,246,0.5)" stroke-width="0.5" stroke-dasharray="2,2"/>
<path d="M10,10 L20,10 M10,10 L10,20" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M90,10 L80,10 M90,10 L90,20" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M10,90 L20,90 M10,90 L10,80" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M90,90 L80,90 M90,90 L90,80" fill="none" stroke="#a855f7" stroke-width="1"/>
</svg>
</div>
</div>
<div class="modal-btns" id="captureBtn">
<button class="btn-modal secondary" onclick="closeImageModal()">취소</button>
<button class="btn-modal primary" onclick="capturePhoto()">📸 촬영</button>
</div>
<div class="modal-btns" id="previewBtns" style="display:none;">
<button class="btn-modal secondary" onclick="retakePhoto()">다시 촬영</button>
<button class="btn-modal primary" onclick="submitCapturedImage()">저장하기</button>
</div>
</div>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -119,49 +119,6 @@
letter-spacing: -0.2px;
}
/* 퀵 메뉴 */
.quick-menu {
display: flex;
justify-content: space-around;
padding: 20px 16px;
background: #fff;
margin: 0 16px 16px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.quick-menu-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-decoration: none;
padding: 8px 12px;
border-radius: 12px;
transition: background 0.2s;
}
.quick-menu-item:active {
background: #f5f5f5;
}
.quick-menu-icon {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.quick-menu-item span {
font-size: 12px;
font-weight: 600;
color: #495057;
letter-spacing: -0.3px;
}
.section {
padding: 24px;
}
@@ -344,26 +301,6 @@
<div class="balance-desc">약국에서 1P = 1원으로 사용 가능</div>
</div>
<!-- 퀵 메뉴 -->
<div class="quick-menu">
<a href="/mypage" class="quick-menu-item">
<div class="quick-menu-icon" style="background: #fef3c7;">🐾</div>
<span>반려동물</span>
</a>
<a href="#" class="quick-menu-item" onclick="alert('준비중입니다'); return false;">
<div class="quick-menu-icon" style="background: #dbeafe;">🎟️</div>
<span>쿠폰함</span>
</a>
<a href="#" class="quick-menu-item" onclick="alert('준비중입니다'); return false;">
<div class="quick-menu-icon" style="background: #fce7f3;">📦</div>
<span>구매내역</span>
</a>
<a href="/mypage" class="quick-menu-item">
<div class="quick-menu-icon" style="background: #ede9fe;">⚙️</div>
<span>내정보</span>
</a>
</div>
<div class="section">
<div class="section-title">적립 내역</div>
@@ -466,10 +403,7 @@
</div>
<div style="padding:0 24px 32px;">
<div style="text-align:center;padding:8px 0 20px;">
<div id="rec-image-container" style="margin-bottom:20px;width:100%;display:flex;justify-content:center;">
<img id="rec-image" style="width:160px;height:auto;border:none;outline:none;display:none;" alt="추천 제품">
<div id="rec-emoji" style="font-size:56px;">💊</div>
</div>
<div style="font-size:48px;margin-bottom:16px;">💊</div>
<div id="rec-message" style="color:#343a40;font-size:16px;font-weight:500;line-height:1.6;letter-spacing:-0.3px;margin-bottom:16px;"></div>
<div id="rec-product" style="display:inline-block;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:14px;font-weight:600;padding:8px 20px;border-radius:20px;letter-spacing:-0.2px;"></div>
</div>
@@ -578,17 +512,6 @@
_recId = data.recommendation.id;
document.getElementById('rec-message').textContent = data.recommendation.message;
document.getElementById('rec-product').textContent = data.recommendation.product;
// 제품 이미지 표시
if (data.recommendation.image) {
document.getElementById('rec-image').src = 'data:image/jpeg;base64,' + data.recommendation.image;
document.getElementById('rec-image').style.display = 'block';
document.getElementById('rec-emoji').style.display = 'none';
} else {
document.getElementById('rec-image').style.display = 'none';
document.getElementById('rec-emoji').style.display = 'block';
}
document.getElementById('rec-sheet').style.display = 'block';
document.getElementById('rec-backdrop').onclick = dismissRec;
}

View File

@@ -1,891 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#6366f1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="청춘약국">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<title>마이페이지 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f5f7fa;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
.app-container {
background: #ffffff;
min-height: 100vh;
max-width: 420px;
margin: 0 auto;
box-shadow: 0 0 20px rgba(0,0,0,0.05);
}
/* 헤더 */
.header {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
padding: 20px 24px 100px;
position: relative;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header-title {
color: white;
font-size: 20px;
font-weight: 700;
}
.btn-logout {
color: rgba(255,255,255,0.8);
font-size: 14px;
text-decoration: none;
padding: 8px 12px;
border-radius: 8px;
transition: background 0.2s;
}
.btn-logout:hover {
background: rgba(255,255,255,0.1);
}
/* 프로필 카드 */
.profile-card {
background: white;
border-radius: 20px;
margin: -80px 16px 16px;
padding: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
position: relative;
z-index: 10;
}
.profile-info {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.profile-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: white;
overflow: hidden;
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-details h2 {
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin-bottom: 4px;
}
.profile-details p {
color: #6b7280;
font-size: 14px;
}
/* 통계 그리드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding-top: 20px;
border-top: 1px solid #f3f4f6;
}
.stat-item {
text-align: center;
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 8px;
font-size: 20px;
}
.stat-icon.purple { background: #ede9fe; }
.stat-icon.blue { background: #dbeafe; }
.stat-icon.pink { background: #fce7f3; }
.stat-value {
font-size: 18px;
font-weight: 700;
color: #1f2937;
}
.stat-label {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
}
/* 섹션 */
.section {
background: white;
margin: 16px;
border-radius: 16px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #1f2937;
display: flex;
align-items: center;
gap: 8px;
}
.section-action {
color: #6366f1;
font-size: 13px;
font-weight: 500;
text-decoration: none;
}
/* 반려동물 카드 */
.pet-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: #f9fafb;
border-radius: 12px;
margin-bottom: 12px;
cursor: pointer;
transition: background 0.2s;
}
.pet-card:hover {
background: #f3f4f6;
}
.pet-photo {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
overflow: hidden;
flex-shrink: 0;
}
.pet-photo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.pet-info {
flex: 1;
}
.pet-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin-bottom: 4px;
}
.pet-details {
font-size: 13px;
color: #6b7280;
}
.pet-arrow {
color: #d1d5db;
font-size: 18px;
}
/* 반려동물 추가 버튼 */
.add-pet-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.add-pet-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
/* 메뉴 리스트 */
.menu-list {
list-style: none;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-item:hover {
background: #f9fafb;
margin: 0 -20px;
padding: 16px 20px;
}
.menu-left {
display: flex;
align-items: center;
gap: 12px;
}
.menu-icon {
font-size: 20px;
}
.menu-text {
font-size: 15px;
color: #374151;
}
.menu-badge {
background: #fef3c7;
color: #92400e;
font-size: 11px;
font-weight: 600;
padding: 4px 8px;
border-radius: 6px;
}
.menu-arrow {
color: #d1d5db;
}
/* 모달 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: none;
align-items: flex-end;
justify-content: center;
z-index: 1000;
}
.modal-overlay.active {
display: flex;
}
.modal-content {
background: white;
border-radius: 24px 24px 0 0;
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
padding: 24px;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.modal-title {
font-size: 20px;
font-weight: 700;
color: #1f2937;
}
.modal-close {
width: 32px;
height: 32px;
border-radius: 50%;
background: #f3f4f6;
border: none;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
/* 폼 스타일 */
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
.form-input {
width: 100%;
padding: 14px 16px;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 15px;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #6366f1;
}
/* 종류 선택 */
.species-options {
display: flex;
gap: 12px;
}
.species-option {
flex: 1;
padding: 20px;
border: 2px solid #e5e7eb;
border-radius: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.species-option:hover {
border-color: #c7d2fe;
}
.species-option.selected {
border-color: #6366f1;
background: #eef2ff;
}
.species-option .icon {
font-size: 40px;
margin-bottom: 8px;
}
.species-option .label {
font-size: 14px;
font-weight: 600;
color: #374151;
}
/* 사진 업로드 */
.photo-upload {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.photo-preview {
width: 120px;
height: 120px;
border-radius: 50%;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
overflow: hidden;
cursor: pointer;
transition: background 0.2s;
}
.photo-preview:hover {
background: #e5e7eb;
}
.photo-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-hint {
font-size: 13px;
color: #9ca3af;
}
/* 제출 버튼 */
.submit-btn {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 24px;
transition: transform 0.2s, box-shadow 0.2s;
}
.submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* 빈 상태 */
.empty-state {
text-align: center;
padding: 32px 16px;
color: #9ca3af;
}
.empty-state .icon {
font-size: 48px;
margin-bottom: 12px;
}
.empty-state p {
font-size: 14px;
}
/* 로딩 */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e5e7eb;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="app-container">
<!-- 헤더 -->
<div class="header">
<div class="header-top">
<h1 class="header-title">마이페이지</h1>
<a href="/logout" class="btn-logout">로그아웃</a>
</div>
</div>
<!-- 프로필 카드 -->
<div class="profile-card">
<div class="profile-info">
<div class="profile-avatar">
{% if user.profile_image_url %}
<img src="{{ user.profile_image_url }}" alt="프로필">
{% else %}
😊
{% endif %}
</div>
<div class="profile-details">
<h2>{{ user.nickname or '회원' }}님</h2>
<p>{{ user.phone or '전화번호 미등록' }}</p>
</div>
</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-icon purple">🎁</div>
<div class="stat-value">{{ '{:,}'.format(user.mileage_balance or 0) }}</div>
<div class="stat-label">포인트</div>
</div>
<div class="stat-item">
<div class="stat-icon blue">📦</div>
<div class="stat-value">{{ purchase_count or 0 }}</div>
<div class="stat-label">구매</div>
</div>
<div class="stat-item">
<div class="stat-icon pink">🐾</div>
<div class="stat-value" id="pet-count">{{ pets|length }}</div>
<div class="stat-label">반려동물</div>
</div>
</div>
</div>
<!-- 반려동물 섹션 -->
<div class="section">
<div class="section-header">
<h3 class="section-title">🐾 내 반려동물</h3>
</div>
<div id="pet-list">
{% if pets %}
{% for pet in pets %}
<div class="pet-card" onclick="editPet({{ pet.id }})">
<div class="pet-photo">
{% if pet.photo_url %}
<img src="{{ pet.photo_url }}" alt="{{ pet.name }}">
{% else %}
{{ '🐕' if pet.species == 'dog' else ('🐈' if pet.species == 'cat' else '🐾') }}
{% endif %}
</div>
<div class="pet-info">
<div class="pet-name">{{ pet.name }}</div>
<div class="pet-details">
{{ pet.species_label }}
{% if pet.breed %}· {{ pet.breed }}{% endif %}
{% if pet.gender %}· {{ '♂' if pet.gender == 'male' else ('♀' if pet.gender == 'female' else '') }}{% endif %}
</div>
</div>
<span class="pet-arrow"></span>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<div class="icon">🐾</div>
<p>등록된 반려동물이 없습니다</p>
</div>
{% endif %}
</div>
<button class="add-pet-btn" onclick="openAddPetModal()">
<span>+</span> 반려동물 추가하기
</button>
</div>
<!-- 메뉴 섹션 -->
<div class="section">
<ul class="menu-list">
<li class="menu-item" onclick="location.href='/my-page?phone={{ user.phone }}'">
<div class="menu-left">
<span class="menu-icon">📋</span>
<span class="menu-text">적립 내역</span>
</div>
<span class="menu-arrow"></span>
</li>
<li class="menu-item">
<div class="menu-left">
<span class="menu-icon">📦</span>
<span class="menu-text">구매 내역</span>
<span class="menu-badge">준비중</span>
</div>
<span class="menu-arrow"></span>
</li>
<li class="menu-item">
<div class="menu-left">
<span class="menu-icon">🎟️</span>
<span class="menu-text">쿠폰함</span>
<span class="menu-badge">준비중</span>
</div>
<span class="menu-arrow"></span>
</li>
<li class="menu-item">
<div class="menu-left">
<span class="menu-icon">⚙️</span>
<span class="menu-text">내 정보 수정</span>
</div>
<span class="menu-arrow"></span>
</li>
</ul>
</div>
</div>
<!-- 반려동물 추가/수정 모달 -->
<div class="modal-overlay" id="petModal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="modalTitle">반려동물 등록</h2>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<form id="petForm" onsubmit="submitPet(event)">
<input type="hidden" id="petId" value="">
<!-- 종류 선택 -->
<div class="form-group">
<label class="form-label">종류 *</label>
<div class="species-options">
<div class="species-option" data-species="dog" onclick="selectSpecies('dog')">
<div class="icon">🐕</div>
<div class="label">강아지</div>
</div>
<div class="species-option" data-species="cat" onclick="selectSpecies('cat')">
<div class="icon">🐈</div>
<div class="label">고양이</div>
</div>
</div>
</div>
<!-- 이름 -->
<div class="form-group">
<label class="form-label">이름 *</label>
<input type="text" class="form-input" id="petName" placeholder="예: 뽀삐" required>
</div>
<!-- 품종 -->
<div class="form-group">
<label class="form-label">품종</label>
<select class="form-input" id="petBreed">
<option value="">선택해주세요</option>
</select>
</div>
<!-- 성별 -->
<div class="form-group">
<label class="form-label">성별</label>
<select class="form-input" id="petGender">
<option value="">선택해주세요</option>
<option value="male">남아 ♂</option>
<option value="female">여아 ♀</option>
<option value="unknown">모름</option>
</select>
</div>
<!-- 사진 -->
<div class="form-group">
<label class="form-label">사진</label>
<div class="photo-upload">
<div class="photo-preview" id="photoPreview" onclick="document.getElementById('photoInput').click()">
📷
</div>
<input type="file" id="photoInput" accept="image/*" style="display:none" onchange="previewPhoto(event)">
<span class="photo-hint">탭하여 사진 추가</span>
</div>
</div>
<button type="submit" class="submit-btn" id="submitBtn">등록하기</button>
<button type="button" class="submit-btn" style="background:#ef4444; margin-top:12px; display:none;" id="deleteBtn" onclick="deletePet()">삭제하기</button>
</form>
</div>
</div>
<script>
let selectedSpecies = '';
let currentPetId = null;
const DOG_BREEDS = ['말티즈', '푸들', '포메라니안', '치와와', '시츄', '요크셔테리어', '비숑프리제', '골든리트리버', '래브라도리트리버', '진돗개', '시바견', '웰시코기', '닥스훈트', '비글', '보더콜리', '프렌치불독', '불독', '슈나우저', '사모예드', '허스키', '믹스견', '기타'];
const CAT_BREEDS = ['코리안숏헤어', '페르시안', '러시안블루', '샴', '먼치킨', '랙돌', '브리티쉬숏헤어', '아메리칸숏헤어', '스코티쉬폴드', '노르웨이숲', '메인쿤', '뱅갈', '아비시니안', '터키쉬앙고라', '믹스묘', '기타'];
function selectSpecies(species) {
selectedSpecies = species;
document.querySelectorAll('.species-option').forEach(el => {
el.classList.toggle('selected', el.dataset.species === species);
});
// 품종 옵션 업데이트
const breedSelect = document.getElementById('petBreed');
const breeds = species === 'dog' ? DOG_BREEDS : CAT_BREEDS;
breedSelect.innerHTML = '<option value="">선택해주세요</option>' +
breeds.map(b => `<option value="${b}">${b}</option>`).join('');
}
function openAddPetModal() {
currentPetId = null;
document.getElementById('modalTitle').textContent = '반려동물 등록';
document.getElementById('petId').value = '';
document.getElementById('petForm').reset();
document.getElementById('photoPreview').innerHTML = '📷';
document.getElementById('submitBtn').textContent = '등록하기';
document.getElementById('deleteBtn').style.display = 'none';
selectedSpecies = '';
document.querySelectorAll('.species-option').forEach(el => el.classList.remove('selected'));
document.getElementById('petModal').classList.add('active');
}
function editPet(petId) {
// TODO: API에서 pet 정보 가져와서 폼에 채우기
currentPetId = petId;
document.getElementById('modalTitle').textContent = '반려동물 수정';
document.getElementById('submitBtn').textContent = '수정하기';
document.getElementById('deleteBtn').style.display = 'block';
document.getElementById('petModal').classList.add('active');
}
function closeModal() {
document.getElementById('petModal').classList.remove('active');
}
function previewPhoto(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('photoPreview').innerHTML =
`<img src="${e.target.result}" alt="미리보기">`;
};
reader.readAsDataURL(file);
}
}
async function submitPet(event) {
event.preventDefault();
if (!selectedSpecies) {
alert('종류를 선택해주세요.');
return;
}
const name = document.getElementById('petName').value.trim();
if (!name) {
alert('이름을 입력해주세요.');
return;
}
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = '처리중...';
try {
const data = {
name: name,
species: selectedSpecies,
breed: document.getElementById('petBreed').value,
gender: document.getElementById('petGender').value
};
const url = currentPetId ? `/api/pets/${currentPetId}` : '/api/pets';
const method = currentPetId ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
// 사진 업로드
const photoInput = document.getElementById('photoInput');
if (photoInput.files.length > 0) {
const petId = result.pet_id || currentPetId;
const formData = new FormData();
formData.append('photo', photoInput.files[0]);
await fetch(`/api/pets/${petId}/photo`, {
method: 'POST',
body: formData
});
}
alert(result.message || '저장되었습니다!');
location.reload();
} else {
alert(result.error || '오류가 발생했습니다.');
}
} catch (error) {
console.error(error);
alert('서버 오류가 발생했습니다.');
} finally {
btn.disabled = false;
btn.textContent = currentPetId ? '수정하기' : '등록하기';
}
}
async function deletePet() {
if (!currentPetId) return;
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const response = await fetch(`/api/pets/${currentPetId}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
alert('삭제되었습니다.');
location.reload();
} else {
alert(result.error || '삭제 실패');
}
} catch (error) {
alert('서버 오류가 발생했습니다.');
}
}
// 모달 외부 클릭 시 닫기
document.getElementById('petModal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,869 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PAAI 어드민 - 청춘약국</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
background: #f3f4f6;
min-height: 100vh;
}
/* 헤더 */
.header {
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
padding: 20px 30px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
}
.header h1 {
font-size: 1.5rem;
display: flex;
align-items: center;
gap: 10px;
}
.header .nav-links {
display: flex;
gap: 15px;
}
.header .nav-links a {
color: rgba(255,255,255,0.9);
text-decoration: none;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.2s;
}
.header .nav-links a:hover,
.header .nav-links a.active {
background: rgba(255,255,255,0.2);
color: #fff;
}
/* 메인 컨테이너 */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* 통계 카드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
display: flex;
align-items: center;
gap: 15px;
}
.stat-card .icon {
width: 50px;
height: 50px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.stat-card .icon.blue { background: #dbeafe; }
.stat-card .icon.green { background: #d1fae5; }
.stat-card .icon.yellow { background: #fef3c7; }
.stat-card .icon.red { background: #fee2e2; }
.stat-card .icon.purple { background: #ede9fe; }
.stat-card .info { flex: 1; }
.stat-card .value { font-size: 1.8rem; font-weight: 700; color: #1f2937; }
.stat-card .label { font-size: 0.85rem; color: #6b7280; }
/* 섹션 */
.section {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
margin-bottom: 20px;
overflow: hidden;
}
.section-header {
background: #f9fafb;
padding: 15px 20px;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.section-header h2 {
font-size: 1.1rem;
color: #374151;
display: flex;
align-items: center;
gap: 8px;
}
.section-body {
padding: 20px;
}
/* 필터 */
.filters {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-group label {
font-size: 0.85rem;
color: #6b7280;
}
.filter-group input,
.filter-group select {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.9rem;
}
.filter-group input:focus,
.filter-group select:focus {
outline: none;
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #10b981;
color: #fff;
}
.btn-primary:hover { background: #059669; }
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover { background: #d1d5db; }
/* 로그 테이블 */
.log-table {
width: 100%;
border-collapse: collapse;
}
.log-table th {
background: #f9fafb;
padding: 12px 15px;
text-align: left;
font-weight: 600;
color: #374151;
border-bottom: 2px solid #e5e7eb;
font-size: 0.85rem;
}
.log-table td {
padding: 12px 15px;
border-bottom: 1px solid #e5e7eb;
font-size: 0.9rem;
color: #4b5563;
}
.log-table tr:hover { background: #f9fafb; }
.log-table .badge {
padding: 4px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-success { background: #d1fae5; color: #065f46; }
.badge-error { background: #fee2e2; color: #991b1b; }
.badge-pending { background: #fef3c7; color: #92400e; }
.badge-severe { background: #fee2e2; color: #dc2626; }
.badge-useful { background: #d1fae5; color: #065f46; }
.badge-not-useful { background: #fee2e2; color: #991b1b; }
.badge-no-feedback { background: #e5e7eb; color: #6b7280; }
.log-table .actions button {
padding: 6px 12px;
background: #ede9fe;
color: #7c3aed;
border: none;
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
}
.log-table .actions button:hover {
background: #ddd6fe;
}
/* 모달 */
.modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: flex-start;
padding: 40px 20px;
overflow-y: auto;
}
.modal.show { display: flex; }
.modal-content {
background: #fff;
border-radius: 16px;
width: 100%;
max-width: 900px;
box-shadow: 0 25px 50px rgba(0,0,0,0.2);
}
.modal-header {
background: linear-gradient(135deg, #7c3aed, #a855f7);
color: #fff;
padding: 20px 25px;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 16px 16px 0 0;
}
.modal-header h3 { font-size: 1.2rem; }
.modal-close {
background: rgba(255,255,255,0.2);
border: none;
color: #fff;
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 1.5rem;
cursor: pointer;
}
.modal-close:hover { background: rgba(255,255,255,0.3); }
.modal-body {
padding: 25px;
max-height: 70vh;
overflow-y: auto;
}
/* 상세 로그 섹션 */
.detail-section {
margin-bottom: 20px;
}
.detail-section-title {
font-size: 0.9rem;
font-weight: 700;
color: #374151;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.detail-section-title:hover { color: #10b981; }
.detail-section-content {
background: #f9fafb;
border-radius: 8px;
padding: 15px;
font-size: 0.85rem;
line-height: 1.6;
}
.detail-section-content.collapsed {
display: none;
}
.detail-section-content pre {
background: #1f2937;
color: #e5e7eb;
padding: 15px;
border-radius: 8px;
overflow-x: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.8rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.detail-grid {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px 15px;
}
.detail-grid dt {
color: #6b7280;
font-weight: 500;
}
.detail-grid dd {
color: #1f2937;
}
/* 차트 영역 */
.chart-container {
height: 200px;
display: flex;
align-items: flex-end;
gap: 8px;
padding: 20px 0;
}
.chart-bar {
flex: 1;
background: linear-gradient(to top, #10b981, #34d399);
border-radius: 4px 4px 0 0;
min-height: 10px;
position: relative;
cursor: pointer;
transition: all 0.2s;
}
.chart-bar:hover {
transform: scaleY(1.05);
transform-origin: bottom;
}
.chart-bar .tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.75rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.chart-bar:hover .tooltip { opacity: 1; }
.chart-labels {
display: flex;
gap: 8px;
}
.chart-labels span {
flex: 1;
text-align: center;
font-size: 0.7rem;
color: #9ca3af;
}
/* 로딩 */
.loading {
text-align: center;
padding: 40px;
color: #9ca3af;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #10b981;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 빈 상태 */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #9ca3af;
}
.empty-state .icon { font-size: 3rem; margin-bottom: 15px; }
/* 반응형 */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.filters {
flex-direction: column;
}
.log-table {
font-size: 0.8rem;
}
.log-table th, .log-table td {
padding: 8px 10px;
}
}
</style>
</head>
<body>
<!-- 헤더 -->
<header class="header">
<h1>🤖 PAAI 어드민</h1>
<nav class="nav-links">
<a href="/pmr" class="active">← 조제관리</a>
<a href="#" onclick="refreshData()">🔄 새로고침</a>
</nav>
</header>
<!-- 메인 -->
<div class="container">
<!-- 통계 카드 -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="icon blue">📊</div>
<div class="info">
<div class="value" id="statTotal">-</div>
<div class="label">총 분석</div>
</div>
</div>
<div class="stat-card">
<div class="icon green">📅</div>
<div class="info">
<div class="value" id="statToday">-</div>
<div class="label">오늘</div>
</div>
</div>
<div class="stat-card">
<div class="icon purple">👍</div>
<div class="info">
<div class="value" id="statUseful">-</div>
<div class="label">유용 평가율</div>
</div>
</div>
<div class="stat-card">
<div class="icon yellow">⚠️</div>
<div class="info">
<div class="value" id="statSevere">-</div>
<div class="label">KIMS 경고 (오늘)</div>
</div>
</div>
<div class="stat-card">
<div class="icon blue">⏱️</div>
<div class="info">
<div class="value" id="statAvgTime">-</div>
<div class="label">평균 응답시간</div>
</div>
</div>
</div>
<!-- 일별 통계 차트 -->
<div class="section">
<div class="section-header">
<h2>📈 일별 분석 추이 (최근 14일)</h2>
</div>
<div class="section-body">
<div class="chart-container" id="dailyChart"></div>
<div class="chart-labels" id="chartLabels"></div>
</div>
</div>
<!-- 분석 이력 -->
<div class="section">
<div class="section-header">
<h2>📋 분석 이력</h2>
</div>
<div class="section-body">
<!-- 필터 -->
<div class="filters">
<div class="filter-group">
<label>날짜:</label>
<input type="date" id="filterDate">
</div>
<div class="filter-group">
<label>환자명:</label>
<input type="text" id="filterPatient" placeholder="검색...">
</div>
<div class="filter-group">
<label>상태:</label>
<select id="filterStatus">
<option value="">전체</option>
<option value="success">성공</option>
<option value="error">에러</option>
<option value="pending">대기중</option>
</select>
</div>
<div class="filter-group">
<label>KIMS 경고:</label>
<select id="filterSevere">
<option value="">전체</option>
<option value="true">있음</option>
<option value="false">없음</option>
</select>
</div>
<button class="btn btn-primary" onclick="loadLogs()">검색</button>
<button class="btn btn-secondary" onclick="clearFilters()">초기화</button>
</div>
<!-- 테이블 -->
<div id="logsContainer">
<div class="loading">
<div class="spinner"></div>
<div>로딩 중...</div>
</div>
</div>
</div>
</div>
</div>
<!-- 상세 모달 -->
<div class="modal" id="detailModal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">📋 분석 상세</h3>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body" id="modalBody">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
<script>
// 초기화
document.addEventListener('DOMContentLoaded', () => {
loadStats();
loadDailyStats();
loadLogs();
});
// 통계 로드
async function loadStats() {
try {
const res = await fetch('/pmr/api/admin/stats');
const data = await res.json();
if (data.success) {
const s = data.stats;
document.getElementById('statTotal').textContent = s.total.toLocaleString();
document.getElementById('statToday').textContent = s.today;
document.getElementById('statSevere').textContent = s.severe_count;
document.getElementById('statAvgTime').textContent = (s.avg_response_time / 1000).toFixed(1) + '초';
if (s.feedback && s.feedback.total > 0) {
document.getElementById('statUseful').textContent = s.feedback.rate + '%';
} else {
document.getElementById('statUseful').textContent = '-';
}
}
} catch (err) {
console.error('Stats error:', err);
}
}
// 일별 통계 로드
async function loadDailyStats() {
try {
const res = await fetch('/pmr/api/admin/feedback-stats');
const data = await res.json();
if (data.success && data.stats.length > 0) {
renderChart(data.stats.slice(0, 14).reverse());
}
} catch (err) {
console.error('Daily stats error:', err);
}
}
// 차트 렌더링
function renderChart(stats) {
const container = document.getElementById('dailyChart');
const labels = document.getElementById('chartLabels');
const maxTotal = Math.max(...stats.map(s => s.total), 1);
container.innerHTML = stats.map(s => {
const height = Math.max((s.total / maxTotal) * 100, 5);
const usefulPct = s.total > 0 ? Math.round((s.useful / s.total) * 100) : 0;
return `
<div class="chart-bar" style="height: ${height}%">
<div class="tooltip">
${s.date.slice(5)}<br>
분석: ${s.total}건<br>
유용: ${usefulPct}%<br>
경고: ${s.severe}
</div>
</div>
`;
}).join('');
labels.innerHTML = stats.map(s => `<span>${s.date.slice(5)}</span>`).join('');
}
// 로그 로드
async function loadLogs() {
const container = document.getElementById('logsContainer');
container.innerHTML = '<div class="loading"><div class="spinner"></div><div>로딩 중...</div></div>';
try {
const params = new URLSearchParams();
const date = document.getElementById('filterDate').value;
const patient = document.getElementById('filterPatient').value;
const status = document.getElementById('filterStatus').value;
const severe = document.getElementById('filterSevere').value;
if (date) params.append('date', date);
if (patient) params.append('patient_name', patient);
if (status) params.append('status', status);
if (severe) params.append('has_severe', severe);
params.append('limit', '100');
const res = await fetch('/pmr/api/admin/logs?' + params.toString());
const data = await res.json();
if (data.success) {
renderLogs(data.logs);
} else {
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><div>로드 실패</div></div>';
}
} catch (err) {
console.error('Logs error:', err);
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><div>오류 발생</div></div>';
}
}
// 로그 테이블 렌더링
function renderLogs(logs) {
const container = document.getElementById('logsContainer');
if (logs.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">📭</div><div>분석 이력이 없습니다</div></div>';
return;
}
container.innerHTML = `
<table class="log-table">
<thead>
<tr>
<th>#</th>
<th>일시</th>
<th>환자</th>
<th>약품수</th>
<th>KIMS</th>
<th>상태</th>
<th>피드백</th>
<th>응답시간</th>
<th>상세</th>
</tr>
</thead>
<tbody>
${logs.map(log => {
const date = new Date(log.created_at);
const dateStr = date.toLocaleString('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const statusBadge = {
'success': '<span class="badge badge-success">성공</span>',
'error': '<span class="badge badge-error">에러</span>',
'pending': '<span class="badge badge-pending">대기</span>',
'kims_done': '<span class="badge badge-pending">AI 대기</span>'
}[log.status] || log.status;
let feedbackBadge = '<span class="badge badge-no-feedback">-</span>';
if (log.feedback_useful === 1) {
feedbackBadge = '<span class="badge badge-useful">👍</span>';
} else if (log.feedback_useful === 0) {
feedbackBadge = '<span class="badge badge-not-useful">👎</span>';
}
const kimsInfo = log.kims_has_severe
? `<span class="badge badge-severe"> ${log.kims_interaction_count}</span>`
: (log.kims_interaction_count > 0 ? `${log.kims_interaction_count}` : '-');
const responseTime = log.ai_response_time_ms
? (log.ai_response_time_ms / 1000).toFixed(1) + '초'
: '-';
return `
<tr>
<td>${log.id}</td>
<td>${dateStr}</td>
<td>${log.patient_name || '-'}</td>
<td>${log.current_med_count || 0}</td>
<td>${kimsInfo}</td>
<td>${statusBadge}</td>
<td>${feedbackBadge}</td>
<td>${responseTime}</td>
<td class="actions">
<button onclick="showDetail(${log.id})">상세</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
}
// 필터 초기화
function clearFilters() {
document.getElementById('filterDate').value = '';
document.getElementById('filterPatient').value = '';
document.getElementById('filterStatus').value = '';
document.getElementById('filterSevere').value = '';
loadLogs();
}
// 상세 보기
async function showDetail(logId) {
const modal = document.getElementById('detailModal');
const body = document.getElementById('modalBody');
const title = document.getElementById('modalTitle');
modal.classList.add('show');
body.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const res = await fetch(`/pmr/api/admin/log/${logId}`);
const data = await res.json();
if (data.success) {
renderDetail(data.log);
} else {
body.innerHTML = '<div class="empty-state">로드 실패</div>';
}
} catch (err) {
console.error('Detail error:', err);
body.innerHTML = '<div class="empty-state">오류 발생</div>';
}
}
// 상세 렌더링
function renderDetail(log) {
const body = document.getElementById('modalBody');
const title = document.getElementById('modalTitle');
title.textContent = `📋 분석 상세 - ${log.patient_name || '환자'} (#${log.id})`;
// 약품 목록 포맷
let medsHtml = '-';
if (log.current_medications && log.current_medications.length > 0) {
medsHtml = log.current_medications.map(m =>
`${m.name || m.code} (${m.dosage || '-'} × ${m.frequency || '-'} × ${m.days || '-'})`
).join('<br>');
}
// 피드백 상태
let feedbackHtml = '<span class="badge badge-no-feedback">없음</span>';
if (log.feedback_useful === 1) {
feedbackHtml = '<span class="badge badge-useful">👍 유용해요</span>';
} else if (log.feedback_useful === 0) {
feedbackHtml = '<span class="badge badge-not-useful">👎 아니요</span>';
}
body.innerHTML = `
<!-- 기본 정보 -->
<div class="detail-section">
<div class="detail-section-title" onclick="toggleSection(this)">
환자/처방 정보
</div>
<div class="detail-section-content">
<dl class="detail-grid">
<dt>처방번호</dt><dd>${log.pre_serial || '-'}</dd>
<dt>환자코드</dt><dd>${log.patient_code || '-'}</dd>
<dt>환자명</dt><dd>${log.patient_name || '-'}</dd>
<dt>질병 1</dt><dd>[${log.disease_code_1 || '-'}] ${log.disease_name_1 || '-'}</dd>
<dt>질병 2</dt><dd>[${log.disease_code_2 || '-'}] ${log.disease_name_2 || '-'}</dd>
<dt>약품</dt><dd>${medsHtml}</dd>
<dt>분석일시</dt><dd>${log.created_at}</dd>
<dt>상태</dt><dd>${log.status}</dd>
<dt>피드백</dt><dd>${feedbackHtml}</dd>
</dl>
</div>
</div>
<!-- KIMS 결과 -->
<div class="detail-section">
<div class="detail-section-title" onclick="toggleSection(this)">
KIMS 상호작용 (${log.kims_response_time_ms || 0}ms)
</div>
<div class="detail-section-content">
<p><strong>조회 약품:</strong> ${(log.kims_drug_codes || []).join(', ') || '-'}</p>
<p><strong>상호작용:</strong> ${log.kims_interaction_count || 0} ${log.kims_has_severe ? ' ' : ''}</p>
${log.kims_interactions && log.kims_interactions.length > 0 ? `
<pre>${JSON.stringify(log.kims_interactions, null, 2)}</pre>
` : ''}
</div>
</div>
<!-- AI 프롬프트 -->
<div class="detail-section">
<div class="detail-section-title" onclick="toggleSection(this)">
AI 프롬프트 (클릭하여 펼치기)
</div>
<div class="detail-section-content collapsed">
<pre>${escapeHtml(log.ai_prompt || '없음')}</pre>
</div>
</div>
<!-- AI 응답 -->
<div class="detail-section">
<div class="detail-section-title" onclick="toggleSection(this)">
AI 응답 (${log.ai_response_time_ms || 0}ms, ${log.ai_model || '-'})
</div>
<div class="detail-section-content">
<pre>${JSON.stringify(log.ai_response, null, 2) || '없음'}</pre>
</div>
</div>
${log.error_message ? `
<div class="detail-section">
<div class="detail-section-title" style="color: #dc2626;">
⚠️ 에러 메시지
</div>
<div class="detail-section-content" style="background: #fee2e2;">
${escapeHtml(log.error_message)}
</div>
</div>
` : ''}
`;
}
// 섹션 토글
function toggleSection(titleEl) {
const content = titleEl.nextElementSibling;
const isCollapsed = content.classList.contains('collapsed');
content.classList.toggle('collapsed');
titleEl.textContent = titleEl.textContent.replace(/^[▼▶]/, isCollapsed ? '▼' : '▶');
}
// 모달 닫기
function closeModal() {
document.getElementById('detailModal').classList.remove('show');
}
// 데이터 새로고침
function refreshData() {
loadStats();
loadDailyStats();
loadLogs();
}
// HTML 이스케이프
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 모달 외부 클릭 시 닫기
document.getElementById('detailModal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeModal();
});
// ESC 키로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
</script>
</body>
</html>

View File

@@ -1,101 +0,0 @@
# -*- coding: utf-8 -*-
"""백제약품 주문 원장 API 분석"""
import json
import requests
from datetime import datetime, timedelta
import calendar
# 저장된 토큰 로드
TOKEN_FILE = r'c:\Users\청춘약국\source\pharmacy-wholesale-api\.baekje_token.json'
with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
token_data = json.load(f)
token = token_data['token']
cust_cd = token_data['cust_cd']
print(f"Token expires: {datetime.fromtimestamp(token_data['expires'])}")
print(f"Customer code: {cust_cd}")
# API 세션 설정
session = requests.Session()
session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
'Origin': 'https://ibjp.co.kr',
'Referer': 'https://ibjp.co.kr/',
'Authorization': f'Bearer {token}'
})
API_URL = "https://www.ibjp.co.kr"
# 1. 주문 원장 API 시도 - 다양한 엔드포인트
endpoints = [
'/ordLedger/listSearch',
'/ordLedger/list',
'/ord/ledgerList',
'/ord/ledgerSearch',
'/cust/ordLedger',
'/custOrd/ledgerList',
'/ordHist/listSearch',
'/ordHist/list',
]
# 날짜 설정 (이번 달)
today = datetime.now()
year = today.year
month = today.month
_, last_day = calendar.monthrange(year, month)
from_date = f"{year}{month:02d}01"
to_date = f"{year}{month:02d}{last_day:02d}"
print(f"\n조회 기간: {from_date} ~ {to_date}")
print("\n=== API 엔드포인트 탐색 ===\n")
params = {
'custCd': cust_cd,
'startDt': from_date,
'endDt': to_date,
'stDate': from_date,
'edDate': to_date,
'year': str(year),
'month': f"{month:02d}",
}
for endpoint in endpoints:
try:
# GET 시도
resp = session.get(f"{API_URL}{endpoint}", params=params, timeout=10)
print(f"GET {endpoint}: {resp.status_code}")
if resp.status_code == 200:
try:
data = resp.json()
print(f" -> JSON Response (first 500 chars): {str(data)[:500]}")
except:
print(f" -> Text (first 200 chars): {resp.text[:200]}")
except Exception as e:
print(f"GET {endpoint}: Error - {e}")
try:
# POST 시도
resp = session.post(f"{API_URL}{endpoint}", json=params, timeout=10)
print(f"POST {endpoint}: {resp.status_code}")
if resp.status_code == 200:
try:
data = resp.json()
print(f" -> JSON Response (first 500 chars): {str(data)[:500]}")
except:
print(f" -> Text (first 200 chars): {resp.text[:200]}")
except Exception as e:
print(f"POST {endpoint}: Error - {e}")
# 2. 이미 알려진 API로 데이터 확인
print("\n=== 알려진 API 테스트 ===\n")
# 월간 잔고 조회 (이미 있는 함수에서 사용)
resp = session.get(f"{API_URL}/custMonth/listSearch", params={'custCd': cust_cd, 'year': str(year), 'endDt': to_date}, timeout=10)
print(f"custMonth/listSearch: {resp.status_code}")
if resp.status_code == 200:
data = resp.json()
print(f" -> {json.dumps(data, ensure_ascii=False, indent=2)[:1500]}")

View File

@@ -1,126 +0,0 @@
# -*- coding: utf-8 -*-
"""백제약품 주문 원장 API 분석 - 상세 탐색"""
import json
import requests
from datetime import datetime
import calendar
# 저장된 토큰 로드
TOKEN_FILE = r'c:\Users\청춘약국\source\pharmacy-wholesale-api\.baekje_token.json'
with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
token_data = json.load(f)
token = token_data['token']
cust_cd = token_data['cust_cd']
# API 세션 설정
session = requests.Session()
session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
'Origin': 'https://ibjp.co.kr',
'Referer': 'https://ibjp.co.kr/',
'Authorization': f'Bearer {token}'
})
API_URL = "https://www.ibjp.co.kr"
today = datetime.now()
year = today.year
month = today.month
_, last_day = calendar.monthrange(year, month)
print("=== 주문 원장 API 탐색 (다양한 파라미터) ===\n")
# 날짜 형식 변형
date_formats = [
{'startDt': f'{year}{month:02d}01', 'endDt': f'{year}{month:02d}{last_day:02d}'},
{'stDt': f'{year}{month:02d}01', 'edDt': f'{year}{month:02d}{last_day:02d}'},
{'fromDate': f'{year}-{month:02d}-01', 'toDate': f'{year}-{month:02d}-{last_day:02d}'},
{'strDt': f'{year}{month:02d}01', 'endDt': f'{year}{month:02d}{last_day:02d}'},
{'ordDt': f'{year}{month:02d}'},
]
endpoints = [
'/ordLedger/listSearch',
'/ordLedger/search',
'/ordLedger/ledgerList',
'/cust/ordLedgerList',
'/cust/ledger',
'/ord/histList',
'/ord/history',
'/ord/list',
]
for endpoint in endpoints:
for params in date_formats:
full_params = {**params, 'custCd': cust_cd}
try:
resp = session.get(f"{API_URL}{endpoint}", params=full_params, timeout=10)
if resp.status_code == 200:
print(f"✓ GET {endpoint} {params}: {resp.status_code}")
try:
data = resp.json()
print(f" -> {str(data)[:300]}")
except:
print(f" -> {resp.text[:200]}")
except Exception as e:
pass
try:
resp = session.post(f"{API_URL}{endpoint}", json=full_params, timeout=10)
if resp.status_code == 200:
print(f"✓ POST {endpoint} {params}: {resp.status_code}")
try:
data = resp.json()
print(f" -> {str(data)[:300]}")
except:
print(f" -> {resp.text[:200]}")
except Exception as e:
pass
print("\n=== 주문 이력 관련 API ===\n")
# 주문 이력 조회 시도
order_endpoints = [
'/ord/ordList',
'/ord/orderHistory',
'/ordReg/list',
'/ordReg/history',
'/order/list',
'/order/history',
]
for endpoint in order_endpoints:
try:
params = {'custCd': cust_cd, 'startDt': f'{year}{month:02d}01', 'endDt': f'{year}{month:02d}{last_day:02d}'}
resp = session.get(f"{API_URL}{endpoint}", params=params, timeout=10)
print(f"GET {endpoint}: {resp.status_code}")
if resp.status_code == 200:
try:
data = resp.json()
print(f" -> {str(data)[:500]}")
except:
print(f" -> {resp.text[:200]}")
except:
pass
print("\n=== custMonth/listSearch 상세 데이터 분석 ===\n")
# 이미 작동하는 API의 데이터 상세 분석
resp = session.get(f"{API_URL}/custMonth/listSearch", params={'custCd': cust_cd, 'year': str(year), 'endDt': f'{year}{month:02d}{last_day:02d}'}, timeout=10)
if resp.status_code == 200:
data = resp.json()
print("월간 데이터 구조:")
for item in data:
print(f"\n월: {item.get('BALANCE_YM')}")
print(f" 매출액(SALE_AMT): {item.get('SALE_AMT'):,}")
print(f" 반품액(BACK_AMT): {item.get('BACK_AMT'):,}")
print(f" 순반품(PURE_BACK_AMT): {item.get('PURE_BACK_AMT'):,}")
print(f" 순매출(TOTAL_AMT): {item.get('TOTAL_AMT'):,}")
print(f" 입금액(PAY_CASH_AMT): {item.get('PAY_CASH_AMT'):,}")
print(f" 전월이월(PRE_TOTAL_AMT): {item.get('PRE_TOTAL_AMT'):,}")
print(f" 월말잔고(BALANCE_A_AMT): {item.get('BALANCE_A_AMT'):,}")
print(f" 회전일수(ROTATE_DAY): {item.get('ROTATE_DAY')}")

View File

@@ -1,84 +0,0 @@
# -*- coding: utf-8 -*-
"""백제약품 get_monthly_sales() 테스트"""
import os
import sys
# wholesale 패키지 경로 추가
sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-wholesale-api')
os.chdir(r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend')
from dotenv import load_dotenv
load_dotenv()
from wholesale import BaekjeSession
def test_monthly_sales():
print("=" * 60)
print("백제약품 월간 매출 조회 테스트")
print("=" * 60)
session = BaekjeSession()
# 현재 월 조회
from datetime import datetime
now = datetime.now()
year = now.year
month = now.month
print(f"\n1. 현재 월 ({year}-{month:02d}) 조회:")
result = session.get_monthly_sales(year, month)
print(f" Success: {result.get('success')}")
if result.get('success'):
print(f" 월간 매출: {result.get('total_amount'):,}")
print(f" 월간 반품: {result.get('total_returns'):,}")
print(f" 순매출: {result.get('net_amount'):,}")
print(f" 월간 입금: {result.get('total_paid'):,}")
print(f" 월말 잔고: {result.get('ending_balance'):,}")
print(f" 전월이월: {result.get('prev_balance'):,}")
print(f" 회전일수: {result.get('rotate_days')}")
print(f" 조회기간: {result.get('from_date')} ~ {result.get('to_date')}")
else:
print(f" Error: {result.get('error')}")
# 전월 조회
prev_month = month - 1 if month > 1 else 12
prev_year = year if month > 1 else year - 1
print(f"\n2. 전월 ({prev_year}-{prev_month:02d}) 조회:")
result = session.get_monthly_sales(prev_year, prev_month)
print(f" Success: {result.get('success')}")
if result.get('success'):
print(f" 월간 매출: {result.get('total_amount'):,}")
print(f" 월간 반품: {result.get('total_returns'):,}")
print(f" 순매출: {result.get('net_amount'):,}")
print(f" 월간 입금: {result.get('total_paid'):,}")
print(f" 월말 잔고: {result.get('ending_balance'):,}")
print(f" 전월이월: {result.get('prev_balance'):,}")
print(f" 회전일수: {result.get('rotate_days')}")
print(f" 조회기간: {result.get('from_date')} ~ {result.get('to_date')}")
else:
print(f" Error: {result.get('error')}")
# 2달 전 조회
prev_month2 = prev_month - 1 if prev_month > 1 else 12
prev_year2 = prev_year if prev_month > 1 else prev_year - 1
print(f"\n3. 2달 전 ({prev_year2}-{prev_month2:02d}) 조회:")
result = session.get_monthly_sales(prev_year2, prev_month2)
print(f" Success: {result.get('success')}")
if result.get('success'):
print(f" 월간 매출: {result.get('total_amount'):,}")
print(f" 월간 반품: {result.get('total_returns'):,}")
print(f" 순매출: {result.get('net_amount'):,}")
print(f" 월간 입금: {result.get('total_paid'):,}")
print(f" 월말 잔고: {result.get('ending_balance'):,}")
else:
print(f" Error: {result.get('error')}")
print("\n" + "=" * 60)
print("테스트 완료!")
print("=" * 60)
if __name__ == '__main__':
test_monthly_sales()

View File

@@ -1,16 +0,0 @@
# -*- coding: utf-8 -*-
"""Bag.js 분석"""
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228')
js = resp.text
# del 포함된 부분 찾기
lines = js.split('\n')
for i, line in enumerate(lines):
if 'del' in line.lower() and ('kind' in line.lower() or 'bagorder' in line.lower()):
print(f'{i}: {line.strip()[:100]}')

View File

@@ -1,18 +0,0 @@
# -*- coding: utf-8 -*-
"""Bag.js 전체에서 del 찾기"""
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228')
js = resp.text
print(f'JS 길이: {len(js)}')
# del 포함된 줄 모두
for i, line in enumerate(js.split('\n')):
line = line.strip()
if 'del' in line.lower():
print(f'{line[:120]}')

View File

@@ -1,16 +0,0 @@
# -*- coding: utf-8 -*-
"""Bag.js 체크박스 관련 찾기"""
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228')
js = resp.text
# chk, checkbox 관련 코드 찾기
lines = js.split('\n')
for i, line in enumerate(lines):
if 'chk' in line.lower() or 'check' in line.lower():
print(f'{i}: {line.strip()[:120]}')

View File

@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
"""Bag.js AJAX URL 찾기"""
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228')
js = resp.text
# AJAX 호출 찾기 ($.ajax, url:, type: 패턴)
ajax_blocks = re.findall(r'\$\.ajax\s*\(\s*\{[^}]{0,500}\}', js, re.DOTALL)
print(f'AJAX 호출 {len(ajax_blocks)}개 발견:\n')
for i, block in enumerate(ajax_blocks[:5]):
print(f'=== AJAX {i+1} ===')
print(block[:300])
print()

View File

@@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
"""항목 취소 테스트"""
from sooin_api import SooinSession
import json
session = SooinSession()
session.login()
print('=== 항목 취소 테스트 ===\n')
# 1. 장바구니 비우기
session.clear_cart()
print('1. 장바구니 비움')
# 2. 두 개 담기
session.order_product('073100220', 1, '30T') # 코자정
print('2. 코자정 담음')
session.order_product('652100640', 1) # 스틸녹스
print('3. 스틸녹스 담음')
# 3. 장바구니 확인
cart = session.get_cart()
print(f'\n현재 장바구니:')
print(f' 총 항목: {cart.get("all_items", 0)}')
print(f' 활성(주문포함): {cart.get("total_items", 0)}')
print(f' 취소됨: {cart.get("cancelled_items", 0)}')
for item in cart.get('items', []):
status = '❌ 취소' if item.get('checked') else '✅ 활성'
print(f' [{item.get("row_index")}] {item.get("product_name")} - {status}')
# 4. 첫 번째 항목 취소
print(f'\n4. 첫 번째 항목(idx=0) 취소 시도...')
result = session.cancel_item(row_index=0)
print(f' 결과: {result.get("success")} - {result.get("message", result.get("error", ""))}')
# 5. 다시 확인
cart = session.get_cart()
print(f'\n취소 후 장바구니:')
print(f' 활성: {cart.get("total_items", 0)}')
print(f' 취소됨: {cart.get("cancelled_items", 0)}')
for item in cart.get('items', []):
status = '❌ 취소' if item.get('checked') else '✅ 활성'
print(f' [{item.get("row_index")}] {item.get("product_name")} - {status}')
print('\n=== 완료 ===')

View File

@@ -1,60 +0,0 @@
# -*- coding: utf-8 -*-
"""장바구니 추가 테스트 (실제 주문 X)"""
import json
import sys
sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend')
from sooin_api import SooinSession
print("=" * 60)
print("수인약품 API 장바구니 테스트")
print("=" * 60)
session = SooinSession()
# 1. 로그인
print("\n1. 로그인...")
if not session.login():
print("❌ 로그인 실패")
sys.exit(1)
print("✅ 로그인 성공!")
# 2. 장바구니 비우기
print("\n2. 장바구니 비우기...")
result = session.clear_cart()
print(f" 결과: {'성공' if result['success'] else '실패'}")
# 3. 제품 검색
print("\n3. 제품 검색 (KD코드: 073100220 - 코자정)...")
products = session.search_products('073100220', 'kd_code')
print(f" 검색 결과: {len(products)}")
for p in products:
print(f" - {p['product_name']} ({p['specification']}) 재고: {p['stock']} 단가: {p['unit_price']:,}")
print(f" 내부코드: {p['internal_code']}")
# 4. 장바구니 추가
if products:
print("\n4. 장바구니 추가 (첫 번째 제품, 1개)...")
product = products[1] # 30T 선택
result = session.add_to_cart(
internal_code=product['internal_code'],
quantity=1,
stock=product['stock'],
price=product['unit_price']
)
print(f" 결과: {json.dumps(result, ensure_ascii=False, indent=2)}")
# 5. 장바구니 조회
print("\n5. 장바구니 조회...")
cart = session.get_cart()
print(f" 장바구니: {cart['total_items']}개 품목, {cart['total_amount']:,}")
for item in cart['items']:
print(f" - {item['product_name']}: {item['quantity']}개 ({item['amount']:,}원)")
# 6. 장바구니 비우기 (정리)
print("\n6. 장바구니 비우기 (정리)...")
result = session.clear_cart()
print(f" 결과: {'성공' if result['success'] else '실패'}")
print("\n" + "=" * 60)
print("테스트 완료! (실제 주문은 하지 않았습니다)")
print("=" * 60)

Some files were not shown because too many files have changed in this diff Show More