Compare commits
111 Commits
main
...
f3b6496c91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3b6496c91 | ||
|
|
16c3881661 | ||
|
|
59a55d6b22 | ||
|
|
4275689c29 | ||
|
|
1b33f82fd4 | ||
|
|
141b211f07 | ||
|
|
088d88878a | ||
|
|
ebf2e8a016 | ||
|
|
41428646ab | ||
|
|
4fc667b844 | ||
|
|
7928bbd55c | ||
|
|
d8aa073564 | ||
|
|
6192f635ca | ||
|
|
fc2db78816 | ||
|
|
c21aa956da | ||
|
|
8d025457c0 | ||
|
|
75448ffdc5 | ||
|
|
1054a9ed17 | ||
|
|
71c35433fc | ||
|
|
836be958db | ||
|
|
f829276431 | ||
|
|
9ff25dcbce | ||
|
|
4352a8b9a8 | ||
|
|
5a2ab044ba | ||
|
|
a89dc9b354 | ||
|
|
27da568a13 | ||
|
|
abb8ad1325 | ||
|
|
f374ca4fd1 | ||
|
|
e2d3ea032f | ||
|
|
097bc4c84f | ||
|
|
321fd0de1e | ||
|
|
77c667e1f6 | ||
|
|
1c2bfd473b | ||
|
|
6bb86f8780 | ||
|
|
e95c08ef59 | ||
|
|
27bb0b7b86 | ||
|
|
96a3df8470 | ||
|
|
e7096f7bed | ||
|
|
01f0df9294 | ||
|
|
2859dc43cc | ||
|
|
a0cbb984e5 | ||
|
|
5dd3489385 | ||
|
|
b660f324ac | ||
|
|
fa4e87b461 | ||
|
|
9ce7e884d7 | ||
|
|
5074adce20 | ||
|
|
50825c597e | ||
|
|
acf8e44aa5 | ||
|
|
546a5e7ae6 | ||
|
|
30d95c8579 | ||
|
|
51216c582f | ||
|
|
9ba2846820 | ||
|
|
0aebdaea0c | ||
|
|
467c0e91aa | ||
|
|
0676c4f466 | ||
|
|
79259d004b | ||
|
|
8aa43221d2 | ||
|
|
95fdd23817 | ||
|
|
65754f594b | ||
|
|
4a3ec38ba7 | ||
|
|
4a06e60e29 | ||
|
|
ee28f97c11 | ||
|
|
29648e3a7d | ||
|
|
4713395557 | ||
|
|
007b37e6c6 | ||
|
|
0e954ac749 | ||
|
|
887aba3a03 | ||
|
|
c154537c87 | ||
|
|
b71d511c7a | ||
|
|
ac0e1ced0e | ||
|
|
76a4280ebd | ||
|
|
c525632246 | ||
|
|
a7b3d5b7e0 | ||
|
|
695c1f707f | ||
|
|
f1e609ba9f | ||
|
|
e10b50e0c3 | ||
|
|
c279e53c3e | ||
|
|
e37659dc04 | ||
|
|
52a4f69abc | ||
|
|
1cebb02ec6 | ||
|
|
f102f6b42e | ||
|
|
16adca3646 | ||
|
|
fbe7dde4ce | ||
|
|
8c20c8b8db | ||
|
|
67e576736d | ||
|
|
4c0cd68267 | ||
|
|
68dcb919e4 | ||
|
|
6a786ff042 | ||
|
|
4c93ee038a | ||
|
|
a42af23038 | ||
|
|
180393700b | ||
|
|
21e07bcca9 | ||
|
|
95d7ebab71 | ||
|
|
c1c38c68ac | ||
|
|
fd77dcbef9 | ||
|
|
912679b137 | ||
|
|
f438f42d15 | ||
|
|
b1d5bcfc98 | ||
|
|
8b58ab0d3a | ||
|
|
c022ee21d0 | ||
|
|
d612563580 | ||
|
|
dfbc6e4761 | ||
|
|
8ee148abe4 | ||
|
|
3c9739a92e | ||
|
|
73b8c8ec88 | ||
|
|
4254a0f7a2 | ||
|
|
e12328ec17 | ||
|
|
009d133aef | ||
|
|
9019347d48 | ||
|
|
b95e14419e | ||
|
|
dd28958a59 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -87,5 +87,12 @@ 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
|
||||
|
||||
3361
backend/app.py
3361
backend/app.py
File diff suppressed because it is too large
Load Diff
23
backend/check_pets.py
Normal file
23
backend/check_pets.py
Normal file
@@ -0,0 +1,23 @@
|
||||
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()
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"pos_printer": {
|
||||
"ip": "192.168.0.174",
|
||||
"port": 9100,
|
||||
"name": "메인 POS"
|
||||
}
|
||||
}
|
||||
@@ -154,11 +154,46 @@ class DatabaseManager:
|
||||
return self.engines[database]
|
||||
|
||||
def get_session(self, database='PM_BASE'):
|
||||
"""특정 데이터베이스 세션 반환"""
|
||||
"""특정 데이터베이스 세션 반환 (자동 복구 포함)"""
|
||||
if database not in self.sessions:
|
||||
engine = self.get_engine(database)
|
||||
Session = sessionmaker(bind=engine)
|
||||
self.sessions[database] = Session()
|
||||
else:
|
||||
# 🔥 기존 세션 상태 체크 및 자동 복구
|
||||
session = self.sessions[database]
|
||||
try:
|
||||
# 세션이 유효한지 간단한 쿼리로 테스트
|
||||
session.execute(text("SELECT 1"))
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
# 연결 끊김 또는 트랜잭션 에러 감지
|
||||
if any(keyword in error_msg for keyword in [
|
||||
'invalid transaction', 'rollback', 'connection',
|
||||
'closed', 'lost', 'timeout', 'network', 'disconnect'
|
||||
]):
|
||||
print(f"[DB Manager] {database} 세션 복구 시도: {e}")
|
||||
try:
|
||||
session.rollback()
|
||||
print(f"[DB Manager] {database} 롤백 성공, 세션 재사용")
|
||||
except Exception as rollback_err:
|
||||
print(f"[DB Manager] {database} 롤백 실패, 세션 재생성: {rollback_err}")
|
||||
try:
|
||||
session.close()
|
||||
except:
|
||||
pass
|
||||
del self.sessions[database]
|
||||
# 새 세션 생성
|
||||
engine = self.get_engine(database)
|
||||
Session = sessionmaker(bind=engine)
|
||||
self.sessions[database] = Session()
|
||||
print(f"[DB Manager] {database} 새 세션 생성 완료")
|
||||
else:
|
||||
# 다른 종류의 에러면 롤백만 시도
|
||||
try:
|
||||
session.rollback()
|
||||
except:
|
||||
pass
|
||||
return self.sessions[database]
|
||||
|
||||
def rollback_session(self, database='PM_BASE'):
|
||||
@@ -237,7 +272,13 @@ class DatabaseManager:
|
||||
self.init_sqlite_schema()
|
||||
self.sqlite_conn = old_conn
|
||||
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
|
||||
|
||||
else:
|
||||
# 기존 DB: 마이그레이션 실행
|
||||
old_conn = self.sqlite_conn
|
||||
self.sqlite_conn = conn
|
||||
self._migrate_sqlite()
|
||||
self.sqlite_conn = old_conn
|
||||
|
||||
return conn
|
||||
|
||||
def init_sqlite_schema(self):
|
||||
@@ -319,6 +360,67 @@ 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:
|
||||
|
||||
220
backend/db/kims_logger.py
Normal file
220
backend/db/kims_logger.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
KIMS API 로깅 모듈
|
||||
- API 호출/응답 SQLite 저장
|
||||
- AI 학습용 데이터 수집
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# DB 파일 경로
|
||||
DB_PATH = Path(__file__).parent / 'kims_logs.db'
|
||||
|
||||
def init_db():
|
||||
"""DB 초기화 (테이블 생성)"""
|
||||
schema_path = Path(__file__).parent / 'kims_logs_schema.sql'
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = f.read()
|
||||
cursor.executescript(schema)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"KIMS 로그 DB 초기화 완료: {DB_PATH}")
|
||||
|
||||
def log_kims_call(
|
||||
pre_serial: str = None,
|
||||
user_id: int = None,
|
||||
source: str = 'admin',
|
||||
drug_codes: list = None,
|
||||
drug_names: list = None,
|
||||
api_status: str = 'SUCCESS',
|
||||
http_status: int = 200,
|
||||
response_time_ms: int = 0,
|
||||
interactions: list = None,
|
||||
response_raw: dict = None,
|
||||
error_message: str = None
|
||||
) -> int:
|
||||
"""
|
||||
KIMS API 호출 로그 저장
|
||||
|
||||
Returns:
|
||||
log_id: 생성된 로그 ID
|
||||
"""
|
||||
# DB 없으면 초기화
|
||||
if not DB_PATH.exists():
|
||||
init_db()
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
interactions = interactions or []
|
||||
drug_codes = drug_codes or []
|
||||
drug_names = drug_names or []
|
||||
|
||||
# 심각한 상호작용 여부 (severity 1 또는 2)
|
||||
has_severe = any(
|
||||
str(i.get('severity', '5')) in ['1', '2']
|
||||
for i in interactions
|
||||
)
|
||||
|
||||
# 메인 로그 삽입
|
||||
cursor.execute("""
|
||||
INSERT INTO kims_api_logs (
|
||||
pre_serial, user_id, source,
|
||||
request_drug_codes, request_drug_names, request_drug_count,
|
||||
api_status, http_status, response_time_ms,
|
||||
interaction_count, has_severe_interaction,
|
||||
interactions_json, response_raw, error_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
pre_serial,
|
||||
user_id,
|
||||
source,
|
||||
json.dumps(drug_codes, ensure_ascii=False),
|
||||
json.dumps(drug_names, ensure_ascii=False),
|
||||
len(drug_codes),
|
||||
api_status,
|
||||
http_status,
|
||||
response_time_ms,
|
||||
len(interactions),
|
||||
1 if has_severe else 0,
|
||||
json.dumps(interactions, ensure_ascii=False),
|
||||
json.dumps(response_raw, ensure_ascii=False) if response_raw else None,
|
||||
error_message
|
||||
))
|
||||
|
||||
log_id = cursor.lastrowid
|
||||
|
||||
# 상호작용 상세 삽입 (정규화)
|
||||
for inter in interactions:
|
||||
cursor.execute("""
|
||||
INSERT INTO kims_interactions (
|
||||
log_id,
|
||||
drug1_code, drug1_name, drug1_generic,
|
||||
drug2_code, drug2_name, drug2_generic,
|
||||
severity_level, severity_desc,
|
||||
likelihood_level, likelihood_desc,
|
||||
observation, observation_generic,
|
||||
clinical_management, action_to_take, reference
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
log_id,
|
||||
inter.get('drug1_code'),
|
||||
inter.get('drug1_name'),
|
||||
inter.get('generic1'),
|
||||
inter.get('drug2_code'),
|
||||
inter.get('drug2_name'),
|
||||
inter.get('generic2'),
|
||||
int(inter.get('severity', 5)) if str(inter.get('severity', '')).isdigit() else None,
|
||||
inter.get('severity_text'),
|
||||
None, # likelihood_level
|
||||
inter.get('likelihood'),
|
||||
inter.get('description'),
|
||||
None, # observation_generic
|
||||
inter.get('management'),
|
||||
inter.get('action'),
|
||||
None # reference
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return log_id
|
||||
|
||||
def get_recent_logs(limit: int = 50):
|
||||
"""최근 로그 조회"""
|
||||
if not DB_PATH.exists():
|
||||
return []
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT * FROM kims_api_logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get_log_detail(log_id: int):
|
||||
"""로그 상세 조회 (상호작용 포함)"""
|
||||
if not DB_PATH.exists():
|
||||
return None
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 메인 로그
|
||||
cursor.execute("SELECT * FROM kims_api_logs WHERE id = ?", (log_id,))
|
||||
log = cursor.fetchone()
|
||||
|
||||
if not log:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
# 상호작용 상세
|
||||
cursor.execute("""
|
||||
SELECT * FROM kims_interactions
|
||||
WHERE log_id = ?
|
||||
ORDER BY severity_level ASC
|
||||
""", (log_id,))
|
||||
interactions = cursor.fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
result = dict(log)
|
||||
result['interactions_detail'] = [dict(i) for i in interactions]
|
||||
|
||||
return result
|
||||
|
||||
def get_stats():
|
||||
"""통계 조회"""
|
||||
if not DB_PATH.exists():
|
||||
return {}
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 전체 통계
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total_calls,
|
||||
SUM(CASE WHEN api_status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
|
||||
SUM(CASE WHEN interaction_count > 0 THEN 1 ELSE 0 END) as with_interaction,
|
||||
SUM(CASE WHEN has_severe_interaction = 1 THEN 1 ELSE 0 END) as with_severe,
|
||||
AVG(response_time_ms) as avg_response_ms
|
||||
FROM kims_api_logs
|
||||
""")
|
||||
stats = dict(cursor.fetchone())
|
||||
|
||||
# 최근 7일 일별 통계
|
||||
cursor.execute("""
|
||||
SELECT * FROM kims_stats
|
||||
ORDER BY date DESC
|
||||
LIMIT 7
|
||||
""")
|
||||
daily = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
conn.close()
|
||||
|
||||
stats['daily'] = daily
|
||||
return stats
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# DB 초기화 테스트
|
||||
init_db()
|
||||
print("KIMS 로그 DB 초기화 완료!")
|
||||
86
backend/db/kims_logs_schema.sql
Normal file
86
backend/db/kims_logs_schema.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
-- KIMS API 로그 테이블 스키마
|
||||
-- AI 학습 데이터로 활용 예정
|
||||
|
||||
-- 1. API 호출 로그 (메인)
|
||||
CREATE TABLE IF NOT EXISTS kims_api_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 호출 컨텍스트
|
||||
pre_serial TEXT, -- 처방번호
|
||||
user_id INTEGER, -- 마일리지 회원 ID (있으면)
|
||||
source TEXT DEFAULT 'admin', -- 호출 소스 (admin, api, batch 등)
|
||||
|
||||
-- 요청 데이터
|
||||
request_drug_codes TEXT NOT NULL, -- JSON: ["055101150", "622801610"]
|
||||
request_drug_names TEXT, -- JSON: ["오메프투캡슐", "락소펜엠정"]
|
||||
request_drug_count INTEGER, -- 요청 약품 수
|
||||
|
||||
-- 응답 데이터
|
||||
api_status TEXT NOT NULL, -- SUCCESS, ERROR, TIMEOUT
|
||||
http_status INTEGER, -- HTTP 상태 코드
|
||||
response_time_ms INTEGER, -- 응답 시간 (밀리초)
|
||||
|
||||
-- 상호작용 결과
|
||||
interaction_count INTEGER DEFAULT 0, -- 발견된 상호작용 수
|
||||
has_severe_interaction INTEGER DEFAULT 0, -- 심각한 상호작용 여부 (1/2 등급)
|
||||
|
||||
-- 상세 데이터 (JSON)
|
||||
interactions_json TEXT, -- 상호작용 상세 정보 JSON
|
||||
response_raw TEXT, -- 전체 API 응답 (디버깅/학습용)
|
||||
|
||||
-- 에러 정보
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
-- 2. 상호작용 상세 (정규화, AI 학습용)
|
||||
CREATE TABLE IF NOT EXISTS kims_interactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
log_id INTEGER NOT NULL, -- kims_api_logs.id FK
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 약품 1
|
||||
drug1_code TEXT NOT NULL,
|
||||
drug1_name TEXT,
|
||||
drug1_generic TEXT, -- 성분명 (영문)
|
||||
|
||||
-- 약품 2
|
||||
drug2_code TEXT NOT NULL,
|
||||
drug2_name TEXT,
|
||||
drug2_generic TEXT, -- 성분명 (영문)
|
||||
|
||||
-- 상호작용 정보
|
||||
severity_level INTEGER, -- 1=심각, 2=중등도, 3=경미, 4=참고
|
||||
severity_desc TEXT, -- 심각도 설명 (중증, 경미 등)
|
||||
likelihood_level INTEGER, -- 발생 가능성
|
||||
likelihood_desc TEXT,
|
||||
|
||||
-- 상세 설명 (AI 학습 핵심 데이터)
|
||||
observation TEXT, -- 상호작용 설명 (한글)
|
||||
observation_generic TEXT, -- 일반적 설명
|
||||
clinical_management TEXT, -- 임상적 관리 방법
|
||||
action_to_take TEXT, -- 권장 조치
|
||||
reference TEXT, -- 참고문헌
|
||||
|
||||
FOREIGN KEY (log_id) REFERENCES kims_api_logs(id)
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_logs_created ON kims_api_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_logs_pre_serial ON kims_api_logs(pre_serial);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_logs_status ON kims_api_logs(api_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_interactions_log ON kims_interactions(log_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_interactions_drugs ON kims_interactions(drug1_code, drug2_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_interactions_severity ON kims_interactions(severity_level);
|
||||
|
||||
-- 통계 뷰
|
||||
CREATE VIEW IF NOT EXISTS kims_stats AS
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as total_calls,
|
||||
SUM(CASE WHEN api_status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
|
||||
SUM(CASE WHEN interaction_count > 0 THEN 1 ELSE 0 END) as with_interaction,
|
||||
SUM(CASE WHEN has_severe_interaction = 1 THEN 1 ELSE 0 END) as with_severe,
|
||||
AVG(response_time_ms) as avg_response_ms
|
||||
FROM kims_api_logs
|
||||
GROUP BY DATE(created_at);
|
||||
@@ -22,6 +22,9 @@ CREATE TABLE IF NOT EXISTS customer_identities (
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
provider_user_id VARCHAR(100) NOT NULL,
|
||||
provider_data TEXT,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
token_expires_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
UNIQUE(provider, provider_user_id)
|
||||
@@ -120,3 +123,44 @@ 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);
|
||||
|
||||
351
backend/db/paai_logger.py
Normal file
351
backend/db/paai_logger.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
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
|
||||
}
|
||||
}
|
||||
59
backend/db/paai_logs_schema.sql
Normal file
59
backend/db/paai_logs_schema.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- 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);
|
||||
38
backend/db/product_images_schema.sql
Normal file
38
backend/db/product_images_schema.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- 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
|
||||
);
|
||||
18
backend/ecosystem.config.js
Normal file
18
backend/ecosystem.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -1,222 +0,0 @@
|
||||
# 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)}")
|
||||
1320
backend/pmr_api.py
Normal file
1320
backend/pmr_api.py
Normal file
File diff suppressed because it is too large
Load Diff
169
backend/pos_printer.py
Normal file
169
backend/pos_printer.py
Normal file
@@ -0,0 +1,169 @@
|
||||
# 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 '실패'}")
|
||||
@@ -5,7 +5,6 @@ MSSQL DB에서 약품 정보 조회 기능 포함
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
from datetime import datetime
|
||||
@@ -20,8 +19,6 @@ 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
|
||||
|
||||
# 바코드 라벨 출력
|
||||
|
||||
@@ -1,713 +0,0 @@
|
||||
"""
|
||||
더미 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()
|
||||
82
backend/scripts/batch_apc_matching.py
Normal file
82
backend/scripts/batch_apc_matching.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# -*- 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)}개')
|
||||
75
backend/scripts/batch_insert_apc.py
Normal file
75
backend/scripts/batch_insert_apc.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# -*- 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완료!')
|
||||
34
backend/scripts/debug_gesidin_match.py
Normal file
34
backend/scripts/debug_gesidin_match.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# -*- 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('→ "(판)"에서 "("로 시작하니까 빈 문자열!')
|
||||
51
backend/scripts/debug_matching.py
Normal file
51
backend/scripts/debug_matching.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# -*- 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}')
|
||||
48
backend/scripts/debug_matching2.py
Normal file
48
backend/scripts/debug_matching2.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# -*- 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 "❌"}')
|
||||
43
backend/scripts/debug_prompt.py
Normal file
43
backend/scripts/debug_prompt.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# -*- 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}")
|
||||
42
backend/scripts/debug_rag.py
Normal file
42
backend/scripts/debug_rag.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- 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()
|
||||
86
backend/scripts/debug_rag_prompt.py
Normal file
86
backend/scripts/debug_rag_prompt.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# -*- 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)
|
||||
45
backend/scripts/detailed_search.py
Normal file
45
backend/scripts/detailed_search.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# -*- 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()
|
||||
23
backend/scripts/fix_gesidin_boon.py
Normal file
23
backend/scripts/fix_gesidin_boon.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# -*- 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()
|
||||
75
backend/scripts/insert_apc_gesidin.py
Normal file
75
backend/scripts/insert_apc_gesidin.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# -*- 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()
|
||||
90
backend/scripts/insert_apc_poppy.py
Normal file
90
backend/scripts/insert_apc_poppy.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# -*- 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()
|
||||
36
backend/scripts/list_petfarm.py
Normal file
36
backend/scripts/list_petfarm.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# -*- 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()
|
||||
68
backend/scripts/perf_test.py
Normal file
68
backend/scripts/perf_test.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# -*- 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=== 측정 완료 ===')
|
||||
89
backend/scripts/prepare_apc_insert.py
Normal file
89
backend/scripts/prepare_apc_insert.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# -*- 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()
|
||||
168
backend/scripts/query_aniparm.py
Normal file
168
backend/scripts/query_aniparm.py
Normal file
@@ -0,0 +1,168 @@
|
||||
# -*- 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()
|
||||
27
backend/scripts/show_llm_pharm.py
Normal file
27
backend/scripts/show_llm_pharm.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- 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()
|
||||
16
backend/scripts/test_gestage_api.py
Normal file
16
backend/scripts/test_gestage_api.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# -*- 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']}")
|
||||
23
backend/scripts/test_pets_migration.py
Normal file
23
backend/scripts/test_pets_migration.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# -*- 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 테이블 없음')
|
||||
43
backend/scripts/update_gesidin_category.py
Normal file
43
backend/scripts/update_gesidin_category.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# -*- 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()
|
||||
@@ -98,6 +98,89 @@ 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"
|
||||
|
||||
@@ -87,7 +87,16 @@ def _send_alimtalk(template_code, recipient_no, template_params):
|
||||
logger.info(f"알림톡 발송 성공: {template_code} → {recipient_no}")
|
||||
return (True, "발송 성공")
|
||||
else:
|
||||
error_msg = result.get('header', {}).get('resultMessage', str(result))
|
||||
# 상세 에러 추출: 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)
|
||||
|
||||
logger.warning(f"알림톡 발송 실패: {template_code} → {recipient_no}: {error_msg}")
|
||||
return (False, error_msg)
|
||||
|
||||
@@ -100,15 +109,25 @@ def _send_alimtalk(template_code, recipient_no, template_params):
|
||||
|
||||
|
||||
def build_item_summary(items):
|
||||
"""구매 품목 요약 문자열 생성 (예: '타이레놀 외 3건')"""
|
||||
"""구매 품목 요약 문자열 생성 (예: '타이레놀 외 3건')
|
||||
|
||||
Note: 카카오 알림톡 템플릿 변수는 14자 제한
|
||||
(에러: "Blacklist can't use more than 14 characters in template value.")
|
||||
특수문자(%, 괄호 등)는 문제없이 발송 가능!
|
||||
"""
|
||||
if not items:
|
||||
return "약국 구매"
|
||||
first = items[0]['name']
|
||||
if len(first) > 20:
|
||||
first = first[:18] + '..'
|
||||
first = first.strip()
|
||||
|
||||
if len(items) == 1:
|
||||
return first
|
||||
return f"{first} 외 {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}"
|
||||
|
||||
|
||||
def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
|
||||
@@ -146,24 +165,7 @@ def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
|
||||
|
||||
success, msg = _send_alimtalk(template_code, phone, params)
|
||||
|
||||
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)
|
||||
|
||||
# 최종 결과 로그
|
||||
# 결과 로그 (V3만 사용, V2 폴백 제거 - V2 반려 상태)
|
||||
_log_to_db(template_code, phone, success, msg,
|
||||
template_params=params, user_id=user_id,
|
||||
trigger_source=trigger_source, transaction_id=transaction_id)
|
||||
|
||||
BIN
backend/static/uploads/pets/pet_1_d4ffe983.png
Normal file
BIN
backend/static/uploads/pets/pet_1_d4ffe983.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
BIN
backend/static/uploads/pets/pet_2_9919f990.jpg
Normal file
BIN
backend/static/uploads/pets/pet_2_9919f990.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
backend/static/uploads/pets/pet_3_53b73509.png
Normal file
BIN
backend/static/uploads/pets/pet_3_53b73509.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 MiB |
@@ -20,6 +20,41 @@
|
||||
-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;
|
||||
@@ -457,8 +492,44 @@
|
||||
{% 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>
|
||||
@@ -850,6 +921,113 @@
|
||||
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) {
|
||||
// 전역 변수에 데이터 저장
|
||||
@@ -888,6 +1066,26 @@
|
||||
<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;">
|
||||
@@ -913,6 +1111,9 @@
|
||||
<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>
|
||||
|
||||
<!-- 정렬 버튼 (구매 이력용) -->
|
||||
@@ -999,6 +1200,10 @@
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// 약품 코드 배열 (상호작용 체크용)
|
||||
const drugCodes = (rx.items || []).map(item => item.drug_code).filter(c => c);
|
||||
const drugCodesJson = JSON.stringify(drugCodes).replace(/"/g, '"');
|
||||
|
||||
html += `
|
||||
<div style="border: 1px solid #e9ecef; border-radius: 12px; margin-bottom: 12px; padding: 16px; border-left: 4px solid #6366f1;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
@@ -1009,6 +1214,14 @@
|
||||
🏥 ${rx.hospital || ''} · ${rx.doctor || ''}
|
||||
</div>
|
||||
${rx.items && rx.items.length > 0 ? `<div style="background: #f8f9fa; border-radius: 8px; padding: 12px;">${itemsHtml}</div>` : ''}
|
||||
${drugCodes.length >= 2 ? `
|
||||
<div style="margin-top: 12px; text-align: right;">
|
||||
<button onclick='checkDrugInteraction(${drugCodesJson}, "${rx.pre_serial || ""}")'
|
||||
style="background: linear-gradient(135deg, #8b5cf6, #6366f1); color: #fff; border: none; padding: 8px 14px; border-radius: 8px; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px;">
|
||||
🔬 AI 상호작용 체크
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
@@ -1058,6 +1271,53 @@
|
||||
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>
|
||||
`;
|
||||
@@ -1710,6 +1970,169 @@
|
||||
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 애니메이션 라이브러리 (로컬) -->
|
||||
|
||||
563
backend/templates/admin_kims_logs.html
Normal file
563
backend/templates/admin_kims_logs.html
Normal file
@@ -0,0 +1,563 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KIMS 상호작용 로그 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #f8fafc;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* ── 헤더 ── */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #f59e0b 50%, #16a34a 100%);
|
||||
padding: 28px 32px 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.8);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header-nav a:hover { color: #fff; }
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ── 컨텐츠 ── */
|
||||
.content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* ── 통계 카드 ── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.stat-value.default { color: #1e293b; }
|
||||
.stat-value.green { color: #16a34a; }
|
||||
.stat-value.orange { color: #f59e0b; }
|
||||
.stat-value.red { color: #dc2626; }
|
||||
.stat-value.blue { color: #3b82f6; }
|
||||
.stat-sub {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── 필터 ── */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-bar select, .filter-bar input {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
.filter-bar button {
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 테이블 ── */
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: #f8fafc;
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td {
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody tr { cursor: pointer; transition: background .15s; }
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* ── 배지 ── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 100px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-success { background: #dcfce7; color: #16a34a; }
|
||||
.badge-error { background: #fee2e2; color: #dc2626; }
|
||||
.badge-timeout { background: #fef3c7; color: #d97706; }
|
||||
.badge-severe { background: #dc2626; color: #fff; }
|
||||
.badge-moderate { background: #f59e0b; color: #fff; }
|
||||
.badge-mild { background: #3b82f6; color: #fff; }
|
||||
.badge-drug {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
margin: 2px;
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.badge-drug.warning {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* ── 상호작용 카운트 ── */
|
||||
.interaction-count {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.interaction-count.zero { color: #16a34a; }
|
||||
.interaction-count.has { color: #dc2626; }
|
||||
.interaction-count.severe {
|
||||
color: #fff;
|
||||
background: #dc2626;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── 아코디언 상세 ── */
|
||||
.detail-row { display: none; }
|
||||
.detail-row.open { display: table-row; }
|
||||
.detail-row td {
|
||||
padding: 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #fafbfd;
|
||||
}
|
||||
.detail-content {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.detail-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.drug-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── 상호작용 카드 ── */
|
||||
.interaction-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
border-left: 4px solid #e2e8f0;
|
||||
}
|
||||
.interaction-card.severe { border-left-color: #dc2626; background: #fef2f2; }
|
||||
.interaction-card.moderate { border-left-color: #f59e0b; background: #fffbeb; }
|
||||
.interaction-card.mild { border-left-color: #3b82f6; background: #eff6ff; }
|
||||
.interaction-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.interaction-drugs {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.interaction-desc {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.interaction-mgmt {
|
||||
font-size: 12px;
|
||||
color: #059669;
|
||||
background: #ecfdf5;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
||||
|
||||
/* ── 로딩 ── */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 900px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.stats-grid { grid-template-columns: 1fr; }
|
||||
.filter-bar { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<a href="/admin/members">회원 관리</a>
|
||||
</div>
|
||||
<h1>🔬 KIMS 상호작용 로그</h1>
|
||||
<p>약물 상호작용 체크 API 호출 기록 · AI 학습용 데이터</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 통계 -->
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 호출</div>
|
||||
<div class="stat-value default" id="statTotal">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">성공</div>
|
||||
<div class="stat-value green" id="statSuccess">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">상호작용 발견</div>
|
||||
<div class="stat-value orange" id="statInteraction">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">심각 경고</div>
|
||||
<div class="stat-value red" id="statSevere">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">평균 응답</div>
|
||||
<div class="stat-value blue" id="statAvgMs">-</div>
|
||||
<div class="stat-sub">밀리초</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="filter-bar">
|
||||
<select id="filterStatus">
|
||||
<option value="">모든 상태</option>
|
||||
<option value="SUCCESS">성공</option>
|
||||
<option value="ERROR">에러</option>
|
||||
<option value="TIMEOUT">타임아웃</option>
|
||||
</select>
|
||||
<select id="filterInteraction">
|
||||
<option value="">모든 결과</option>
|
||||
<option value="has">상호작용 있음</option>
|
||||
<option value="severe">심각 상호작용</option>
|
||||
<option value="none">상호작용 없음</option>
|
||||
</select>
|
||||
<input type="date" id="filterDate" />
|
||||
<button onclick="loadLogs()">🔍 조회</button>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>처방번호</th>
|
||||
<th>약품</th>
|
||||
<th>상호작용</th>
|
||||
<th>상태</th>
|
||||
<th>응답</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logsBody">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">로딩 중...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let logsData = [];
|
||||
let openRowId = null;
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return '-';
|
||||
const d = new Date(dt);
|
||||
return `${d.getMonth()+1}/${d.getDate()} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/api/kims/logs/stats');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.getElementById('statTotal').textContent = data.stats.total_calls || 0;
|
||||
document.getElementById('statSuccess').textContent = data.stats.success_count || 0;
|
||||
document.getElementById('statInteraction').textContent = data.stats.with_interaction || 0;
|
||||
document.getElementById('statSevere').textContent = data.stats.with_severe || 0;
|
||||
document.getElementById('statAvgMs').textContent = Math.round(data.stats.avg_response_ms || 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('통계 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
const tbody = document.getElementById('logsBody');
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="loading">로딩 중...</td></tr>';
|
||||
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const interaction = document.getElementById('filterInteraction').value;
|
||||
const date = document.getElementById('filterDate').value;
|
||||
|
||||
try {
|
||||
let url = '/api/kims/logs?limit=100';
|
||||
if (status) url += `&status=${status}`;
|
||||
if (interaction) url += `&interaction=${interaction}`;
|
||||
if (date) url += `&date=${date}`;
|
||||
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success || !data.logs || data.logs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><div class="empty-icon">📭</div><div>로그가 없습니다</div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
logsData = data.logs;
|
||||
renderLogs();
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">로드 실패</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
const tbody = document.getElementById('logsBody');
|
||||
let html = '';
|
||||
|
||||
logsData.forEach((log, idx) => {
|
||||
// 약품 배지
|
||||
let drugs = [];
|
||||
try { drugs = JSON.parse(log.request_drug_names || '[]'); } catch(e) {}
|
||||
const drugBadges = drugs.slice(0, 3).map(d =>
|
||||
`<span class="badge badge-drug">${escapeHtml(d.slice(0, 12))}</span>`
|
||||
).join('') + (drugs.length > 3 ? `<span class="badge badge-drug">+${drugs.length - 3}</span>` : '');
|
||||
|
||||
// 상호작용 표시
|
||||
let interactionHtml = '';
|
||||
if (log.interaction_count > 0) {
|
||||
if (log.has_severe_interaction) {
|
||||
interactionHtml = `<span class="interaction-count severe">⚠️ ${log.interaction_count}</span>`;
|
||||
} else {
|
||||
interactionHtml = `<span class="interaction-count has">${log.interaction_count}건</span>`;
|
||||
}
|
||||
} else {
|
||||
interactionHtml = `<span class="interaction-count zero">✓ 없음</span>`;
|
||||
}
|
||||
|
||||
// 상태 배지
|
||||
let statusBadge = '';
|
||||
if (log.api_status === 'SUCCESS') {
|
||||
statusBadge = '<span class="badge badge-success">성공</span>';
|
||||
} else if (log.api_status === 'TIMEOUT') {
|
||||
statusBadge = '<span class="badge badge-timeout">타임아웃</span>';
|
||||
} else {
|
||||
statusBadge = '<span class="badge badge-error">에러</span>';
|
||||
}
|
||||
|
||||
html += `
|
||||
<tr onclick="toggleDetail(${log.id}, ${idx})">
|
||||
<td>${formatDateTime(log.created_at)}</td>
|
||||
<td>${escapeHtml(log.pre_serial) || '-'}</td>
|
||||
<td>${drugBadges}</td>
|
||||
<td>${interactionHtml}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${log.response_time_ms || 0}ms</td>
|
||||
</tr>
|
||||
<tr class="detail-row" id="detail-${log.id}">
|
||||
<td colspan="6">
|
||||
<div class="detail-content" id="detail-content-${log.id}">
|
||||
로딩 중...
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
async function toggleDetail(logId, idx) {
|
||||
const detailRow = document.getElementById(`detail-${logId}`);
|
||||
|
||||
if (openRowId === logId) {
|
||||
detailRow.classList.remove('open');
|
||||
openRowId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 열린 행 닫기
|
||||
if (openRowId) {
|
||||
document.getElementById(`detail-${openRowId}`)?.classList.remove('open');
|
||||
}
|
||||
|
||||
openRowId = logId;
|
||||
detailRow.classList.add('open');
|
||||
|
||||
// 상세 데이터 로드
|
||||
const contentDiv = document.getElementById(`detail-content-${logId}`);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/kims/logs/${logId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
contentDiv.innerHTML = '<p>상세 정보 로드 실패</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const log = data.log;
|
||||
let drugs = [];
|
||||
try { drugs = JSON.parse(log.request_drug_names || '[]'); } catch(e) {}
|
||||
|
||||
// 상호작용 카드
|
||||
let interactionsHtml = '';
|
||||
const interactions = log.interactions_detail || [];
|
||||
|
||||
if (interactions.length === 0) {
|
||||
interactionsHtml = '<p style="color:#16a34a;font-weight:600;">✅ 상호작용 없음</p>';
|
||||
} else {
|
||||
interactions.forEach(inter => {
|
||||
const sevLevel = inter.severity_level || 5;
|
||||
const sevClass = sevLevel == 1 ? 'severe' : sevLevel == 2 ? 'moderate' : 'mild';
|
||||
|
||||
interactionsHtml += `
|
||||
<div class="interaction-card ${sevClass}">
|
||||
<div class="interaction-header">
|
||||
<span class="interaction-drugs">${escapeHtml(inter.drug1_name)} ↔ ${escapeHtml(inter.drug2_name)}</span>
|
||||
<span class="badge badge-${sevClass}">${escapeHtml(inter.severity_desc) || '알 수 없음'}</span>
|
||||
</div>
|
||||
${inter.observation ? `<div class="interaction-desc">${escapeHtml(inter.observation)}</div>` : ''}
|
||||
${inter.clinical_management ? `<div class="interaction-mgmt">💡 ${escapeHtml(inter.clinical_management).slice(0, 200)}...</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
contentDiv.innerHTML = `
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">💊 분석 약품 (${drugs.length}개)</div>
|
||||
<div class="drug-pills">
|
||||
${drugs.map(d => `<span class="badge badge-drug">${escapeHtml(d)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">⚠️ 상호작용 (${interactions.length}건)</div>
|
||||
${interactionsHtml}
|
||||
</div>
|
||||
<div class="detail-section" style="font-size:12px;color:#94a3b8;">
|
||||
응답시간: ${log.response_time_ms}ms ·
|
||||
호출시간: ${log.created_at} ·
|
||||
처방번호: ${escapeHtml(log.pre_serial) || '-'}
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
contentDiv.innerHTML = '<p>로드 실패</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// 초기 로드
|
||||
loadStats();
|
||||
loadLogs();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1038,6 +1038,10 @@
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// 약품 코드 배열 (상호작용 체크용)
|
||||
const drugCodes = (rx.items || []).map(item => item.drug_code).filter(c => c);
|
||||
const drugCodesJson = JSON.stringify(drugCodes);
|
||||
|
||||
return `
|
||||
<div class="purchase-card" style="border-left: 3px solid #6366f1;">
|
||||
<div class="purchase-header">
|
||||
@@ -1050,6 +1054,14 @@
|
||||
${rx.items && rx.items.length > 0 ? `
|
||||
<div class="purchase-items">${itemsHtml}</div>
|
||||
` : ''}
|
||||
${drugCodes.length >= 2 ? `
|
||||
<div style="margin-top:10px;text-align:right;">
|
||||
<button onclick='checkDrugInteraction(${drugCodesJson}, "${rx.pre_serial || ""}")'
|
||||
style="background:linear-gradient(135deg,#8b5cf6,#6366f1);color:#fff;border:none;padding:8px 14px;border-radius:8px;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">
|
||||
🔬 AI 상호작용 체크
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -1111,6 +1123,158 @@
|
||||
|
||||
// 페이지 로드 시 검색창 포커스
|
||||
document.getElementById('searchInput').focus();
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// KIMS 약물 상호작용 체크
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
async function checkDrugInteraction(drugCodes, preSerial) {
|
||||
// 로딩 모달 표시
|
||||
showInteractionModal('loading');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/kims/interaction-check', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
drug_codes: drugCodes,
|
||||
pre_serial: preSerial
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showInteractionModal('result', data);
|
||||
} else {
|
||||
showInteractionModal('error', data.error || '알 수 없는 오류');
|
||||
}
|
||||
} catch (err) {
|
||||
showInteractionModal('error', '서버 연결 실패: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showInteractionModal(type, data) {
|
||||
let modal = document.getElementById('interactionModal');
|
||||
if (!modal) {
|
||||
// 모달 생성
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'interactionModal';
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
|
||||
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
let content = '';
|
||||
|
||||
if (type === 'loading') {
|
||||
content = `
|
||||
<div style="background:#fff;border-radius:16px;padding:40px;text-align:center;max-width:400px;">
|
||||
<div style="font-size:48px;margin-bottom:16px;">🔬</div>
|
||||
<div style="font-size:18px;font-weight:600;color:#334155;">상호작용 분석 중...</div>
|
||||
<div style="font-size:14px;color:#64748b;margin-top:8px;">KIMS 데이터베이스 조회 중</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'error') {
|
||||
content = `
|
||||
<div style="background:#fff;border-radius:16px;padding:30px;max-width:400px;">
|
||||
<div style="font-size:40px;text-align:center;margin-bottom:16px;">⚠️</div>
|
||||
<div style="font-size:16px;font-weight:600;color:#dc2626;text-align:center;">분석 실패</div>
|
||||
<div style="font-size:14px;color:#64748b;margin-top:12px;text-align:center;">${escapeHtml(data)}</div>
|
||||
<div style="text-align:center;margin-top:20px;">
|
||||
<button onclick="document.getElementById('interactionModal').remove()"
|
||||
style="background:#6366f1;color:#fff;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'result') {
|
||||
const interactions = data.interactions || [];
|
||||
const drugsChecked = data.drugs_checked || [];
|
||||
|
||||
// 약품 목록 (상호작용 있는 약품은 빨간색/주황색 배경)
|
||||
const drugsHtml = drugsChecked.map(d => {
|
||||
const hasInteraction = d.has_interaction;
|
||||
const bgColor = hasInteraction ? '#fef2f2' : '#f1f5f9'; // 연한 빨강 vs 회색
|
||||
const borderColor = hasInteraction ? '#fca5a5' : '#e2e8f0';
|
||||
const textColor = hasInteraction ? '#dc2626' : '#334155';
|
||||
const icon = hasInteraction ? '⚠️ ' : '';
|
||||
return `<span style="display:inline-block;background:${bgColor};border:1px solid ${borderColor};color:${textColor};padding:4px 8px;border-radius:4px;margin:2px;font-size:12px;">${icon}${escapeHtml(d.name.slice(0,20))}</span>`;
|
||||
}).join('');
|
||||
|
||||
// 상호작용 목록
|
||||
let interactionsHtml = '';
|
||||
if (interactions.length === 0) {
|
||||
interactionsHtml = `
|
||||
<div style="text-align:center;padding:30px;">
|
||||
<div style="font-size:48px;margin-bottom:12px;">✅</div>
|
||||
<div style="font-size:16px;font-weight:600;color:#10b981;">상호작용 없음</div>
|
||||
<div style="font-size:13px;color:#64748b;margin-top:8px;">
|
||||
${data.total_pairs}개 약품 조합을 검사했습니다.<br>
|
||||
주의가 필요한 상호작용이 발견되지 않았습니다.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
interactionsHtml = interactions.map(item => `
|
||||
<div style="background:#fff;border:1px solid ${item.severity_color};border-radius:12px;padding:16px;margin-bottom:12px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
|
||||
<span style="font-weight:600;color:#334155;">
|
||||
${escapeHtml(item.drug1_name?.slice(0,20) || '')} ↔ ${escapeHtml(item.drug2_name?.slice(0,20) || '')}
|
||||
</span>
|
||||
<span style="background:${item.severity_color};color:#fff;padding:4px 10px;border-radius:12px;font-size:12px;font-weight:500;">
|
||||
${item.severity_text}
|
||||
</span>
|
||||
</div>
|
||||
${item.description ? `
|
||||
<div style="font-size:13px;color:#475569;margin-bottom:8px;line-height:1.5;">
|
||||
📋 ${escapeHtml(item.description)}
|
||||
</div>
|
||||
` : ''}
|
||||
${item.management ? `
|
||||
<div style="font-size:12px;color:#059669;background:#ecfdf5;padding:8px 12px;border-radius:6px;">
|
||||
💡 ${escapeHtml(item.management)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
content = `
|
||||
<div style="background:#f8fafc;border-radius:20px;max-width:500px;max-height:80vh;overflow:hidden;display:flex;flex-direction:column;">
|
||||
<div style="background:linear-gradient(135deg,#8b5cf6,#6366f1);padding:20px 24px;color:#fff;">
|
||||
<div style="font-size:18px;font-weight:700;display:flex;align-items:center;gap:10px;">
|
||||
🔬 약물 상호작용 분석
|
||||
</div>
|
||||
<div style="font-size:13px;opacity:0.9;margin-top:6px;">
|
||||
${drugsChecked.length}개 약품 · ${data.total_pairs}개 조합 검사
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 20px;border-bottom:1px solid #e2e8f0;">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:6px;">분석 약품</div>
|
||||
${drugsHtml}
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:16px 20px;">
|
||||
${interactions.length > 0 ? `
|
||||
<div style="font-size:13px;color:#dc2626;font-weight:600;margin-bottom:12px;">
|
||||
⚠️ ${interactions.length}건의 상호작용 발견
|
||||
</div>
|
||||
` : ''}
|
||||
${interactionsHtml}
|
||||
</div>
|
||||
<div style="padding:16px 20px;border-top:1px solid #e2e8f0;text-align:center;">
|
||||
<button onclick="document.getElementById('interactionModal').remove()"
|
||||
style="background:#6366f1;color:#fff;border:none;padding:12px 32px;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.innerHTML = content;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
704
backend/templates/admin_otc_labels.html
Normal file
704
backend/templates/admin_otc_labels.html
Normal file
@@ -0,0 +1,704 @@
|
||||
<!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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>
|
||||
494
backend/templates/admin_paai.html
Normal file
494
backend/templates/admin_paai.html
Normal file
@@ -0,0 +1,494 @@
|
||||
<!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>
|
||||
1486
backend/templates/admin_pos_live.html
Normal file
1486
backend/templates/admin_pos_live.html
Normal file
File diff suppressed because it is too large
Load Diff
1083
backend/templates/admin_product_images.html
Normal file
1083
backend/templates/admin_product_images.html
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -369,13 +369,170 @@
|
||||
|
||||
/* 제품 셀 */
|
||||
.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: 4px;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.product-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.product-supplier {
|
||||
font-size: 11px;
|
||||
@@ -796,8 +953,14 @@
|
||||
<tr>
|
||||
<td>
|
||||
<div class="product-cell">
|
||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
||||
${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>
|
||||
</div>
|
||||
</td>
|
||||
<td>${renderCode(item)}</td>
|
||||
@@ -826,8 +989,14 @@
|
||||
<td style="color:var(--text-secondary);font-size:12px;">${item.sale_date}</td>
|
||||
<td>
|
||||
<div class="product-cell">
|
||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
||||
${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>
|
||||
</div>
|
||||
</td>
|
||||
<td>${renderCode(item)}</td>
|
||||
@@ -897,6 +1066,288 @@
|
||||
|
||||
// 초기 로드
|
||||
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>
|
||||
|
||||
@@ -119,6 +119,49 @@
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
/* 퀵 메뉴 */
|
||||
.quick-menu {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 20px 16px;
|
||||
background: #fff;
|
||||
margin: 0 16px 16px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.quick-menu-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.quick-menu-item:active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.quick-menu-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.quick-menu-item span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -301,6 +344,26 @@
|
||||
<div class="balance-desc">약국에서 1P = 1원으로 사용 가능</div>
|
||||
</div>
|
||||
|
||||
<!-- 퀵 메뉴 -->
|
||||
<div class="quick-menu">
|
||||
<a href="/mypage" class="quick-menu-item">
|
||||
<div class="quick-menu-icon" style="background: #fef3c7;">🐾</div>
|
||||
<span>반려동물</span>
|
||||
</a>
|
||||
<a href="#" class="quick-menu-item" onclick="alert('준비중입니다'); return false;">
|
||||
<div class="quick-menu-icon" style="background: #dbeafe;">🎟️</div>
|
||||
<span>쿠폰함</span>
|
||||
</a>
|
||||
<a href="#" class="quick-menu-item" onclick="alert('준비중입니다'); return false;">
|
||||
<div class="quick-menu-icon" style="background: #fce7f3;">📦</div>
|
||||
<span>구매내역</span>
|
||||
</a>
|
||||
<a href="/mypage" class="quick-menu-item">
|
||||
<div class="quick-menu-icon" style="background: #ede9fe;">⚙️</div>
|
||||
<span>내정보</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">적립 내역</div>
|
||||
|
||||
@@ -403,7 +466,10 @@
|
||||
</div>
|
||||
<div style="padding:0 24px 32px;">
|
||||
<div style="text-align:center;padding:8px 0 20px;">
|
||||
<div style="font-size:48px;margin-bottom:16px;">💊</div>
|
||||
<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 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>
|
||||
@@ -512,6 +578,17 @@
|
||||
_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;
|
||||
}
|
||||
|
||||
891
backend/templates/mypage_v2.html
Normal file
891
backend/templates/mypage_v2.html
Normal file
@@ -0,0 +1,891 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="청춘약국">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<title>마이페이지 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: #ffffff;
|
||||
min-height: 100vh;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 20px 24px 100px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
/* 프로필 카드 */
|
||||
.profile-card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
margin: -80px 16px 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-details h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.profile-details p {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 통계 그리드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stat-icon.purple { background: #ede9fe; }
|
||||
.stat-icon.blue { background: #dbeafe; }
|
||||
.stat-icon.pink { background: #fce7f3; }
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 섹션 */
|
||||
.section {
|
||||
background: white;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-action {
|
||||
color: #6366f1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 반려동물 카드 */
|
||||
.pet-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.pet-card:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.pet-photo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pet-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.pet-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pet-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pet-details {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.pet-arrow {
|
||||
color: #d1d5db;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 반려동물 추가 버튼 */
|
||||
.add-pet-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.add-pet-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
/* 메뉴 리스트 */
|
||||
.menu-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.menu-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #f9fafb;
|
||||
margin: 0 -20px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.menu-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
font-size: 15px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.menu-badge {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: none;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 24px 24px 0 0;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 폼 스타일 */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
/* 종류 선택 */
|
||||
.species-options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.species-option {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.species-option:hover {
|
||||
border-color: #c7d2fe;
|
||||
}
|
||||
|
||||
.species-option.selected {
|
||||
border-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.species-option .icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.species-option .label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* 사진 업로드 */
|
||||
.photo-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.photo-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.photo-preview:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.photo-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-hint {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 제출 버튼 */
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 24px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty-state .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 로딩 */
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 헤더 -->
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<h1 class="header-title">마이페이지</h1>
|
||||
<a href="/logout" class="btn-logout">로그아웃</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로필 카드 -->
|
||||
<div class="profile-card">
|
||||
<div class="profile-info">
|
||||
<div class="profile-avatar">
|
||||
{% if user.profile_image_url %}
|
||||
<img src="{{ user.profile_image_url }}" alt="프로필">
|
||||
{% else %}
|
||||
😊
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="profile-details">
|
||||
<h2>{{ user.nickname or '회원' }}님</h2>
|
||||
<p>{{ user.phone or '전화번호 미등록' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon purple">🎁</div>
|
||||
<div class="stat-value">{{ '{:,}'.format(user.mileage_balance or 0) }}</div>
|
||||
<div class="stat-label">포인트</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon blue">📦</div>
|
||||
<div class="stat-value">{{ purchase_count or 0 }}</div>
|
||||
<div class="stat-label">구매</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon pink">🐾</div>
|
||||
<div class="stat-value" id="pet-count">{{ pets|length }}</div>
|
||||
<div class="stat-label">반려동물</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반려동물 섹션 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">🐾 내 반려동물</h3>
|
||||
</div>
|
||||
|
||||
<div id="pet-list">
|
||||
{% if pets %}
|
||||
{% for pet in pets %}
|
||||
<div class="pet-card" onclick="editPet({{ pet.id }})">
|
||||
<div class="pet-photo">
|
||||
{% if pet.photo_url %}
|
||||
<img src="{{ pet.photo_url }}" alt="{{ pet.name }}">
|
||||
{% else %}
|
||||
{{ '🐕' if pet.species == 'dog' else ('🐈' if pet.species == 'cat' else '🐾') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="pet-info">
|
||||
<div class="pet-name">{{ pet.name }}</div>
|
||||
<div class="pet-details">
|
||||
{{ pet.species_label }}
|
||||
{% if pet.breed %}· {{ pet.breed }}{% endif %}
|
||||
{% if pet.gender %}· {{ '♂' if pet.gender == 'male' else ('♀' if pet.gender == 'female' else '') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<span class="pet-arrow">›</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="icon">🐾</div>
|
||||
<p>등록된 반려동물이 없습니다</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button class="add-pet-btn" onclick="openAddPetModal()">
|
||||
<span>+</span> 반려동물 추가하기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴 섹션 -->
|
||||
<div class="section">
|
||||
<ul class="menu-list">
|
||||
<li class="menu-item" onclick="location.href='/my-page?phone={{ user.phone }}'">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">📋</span>
|
||||
<span class="menu-text">적립 내역</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">📦</span>
|
||||
<span class="menu-text">구매 내역</span>
|
||||
<span class="menu-badge">준비중</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">🎟️</span>
|
||||
<span class="menu-text">쿠폰함</span>
|
||||
<span class="menu-badge">준비중</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">내 정보 수정</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반려동물 추가/수정 모달 -->
|
||||
<div class="modal-overlay" id="petModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="modalTitle">반려동물 등록</h2>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
|
||||
<form id="petForm" onsubmit="submitPet(event)">
|
||||
<input type="hidden" id="petId" value="">
|
||||
|
||||
<!-- 종류 선택 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">종류 *</label>
|
||||
<div class="species-options">
|
||||
<div class="species-option" data-species="dog" onclick="selectSpecies('dog')">
|
||||
<div class="icon">🐕</div>
|
||||
<div class="label">강아지</div>
|
||||
</div>
|
||||
<div class="species-option" data-species="cat" onclick="selectSpecies('cat')">
|
||||
<div class="icon">🐈</div>
|
||||
<div class="label">고양이</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이름 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">이름 *</label>
|
||||
<input type="text" class="form-input" id="petName" placeholder="예: 뽀삐" required>
|
||||
</div>
|
||||
|
||||
<!-- 품종 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">품종</label>
|
||||
<select class="form-input" id="petBreed">
|
||||
<option value="">선택해주세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 성별 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">성별</label>
|
||||
<select class="form-input" id="petGender">
|
||||
<option value="">선택해주세요</option>
|
||||
<option value="male">남아 ♂</option>
|
||||
<option value="female">여아 ♀</option>
|
||||
<option value="unknown">모름</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 사진 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">사진</label>
|
||||
<div class="photo-upload">
|
||||
<div class="photo-preview" id="photoPreview" onclick="document.getElementById('photoInput').click()">
|
||||
📷
|
||||
</div>
|
||||
<input type="file" id="photoInput" accept="image/*" style="display:none" onchange="previewPhoto(event)">
|
||||
<span class="photo-hint">탭하여 사진 추가</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn" id="submitBtn">등록하기</button>
|
||||
<button type="button" class="submit-btn" style="background:#ef4444; margin-top:12px; display:none;" id="deleteBtn" onclick="deletePet()">삭제하기</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedSpecies = '';
|
||||
let currentPetId = null;
|
||||
|
||||
const DOG_BREEDS = ['말티즈', '푸들', '포메라니안', '치와와', '시츄', '요크셔테리어', '비숑프리제', '골든리트리버', '래브라도리트리버', '진돗개', '시바견', '웰시코기', '닥스훈트', '비글', '보더콜리', '프렌치불독', '불독', '슈나우저', '사모예드', '허스키', '믹스견', '기타'];
|
||||
const CAT_BREEDS = ['코리안숏헤어', '페르시안', '러시안블루', '샴', '먼치킨', '랙돌', '브리티쉬숏헤어', '아메리칸숏헤어', '스코티쉬폴드', '노르웨이숲', '메인쿤', '뱅갈', '아비시니안', '터키쉬앙고라', '믹스묘', '기타'];
|
||||
|
||||
function selectSpecies(species) {
|
||||
selectedSpecies = species;
|
||||
document.querySelectorAll('.species-option').forEach(el => {
|
||||
el.classList.toggle('selected', el.dataset.species === species);
|
||||
});
|
||||
|
||||
// 품종 옵션 업데이트
|
||||
const breedSelect = document.getElementById('petBreed');
|
||||
const breeds = species === 'dog' ? DOG_BREEDS : CAT_BREEDS;
|
||||
breedSelect.innerHTML = '<option value="">선택해주세요</option>' +
|
||||
breeds.map(b => `<option value="${b}">${b}</option>`).join('');
|
||||
}
|
||||
|
||||
function openAddPetModal() {
|
||||
currentPetId = null;
|
||||
document.getElementById('modalTitle').textContent = '반려동물 등록';
|
||||
document.getElementById('petId').value = '';
|
||||
document.getElementById('petForm').reset();
|
||||
document.getElementById('photoPreview').innerHTML = '📷';
|
||||
document.getElementById('submitBtn').textContent = '등록하기';
|
||||
document.getElementById('deleteBtn').style.display = 'none';
|
||||
selectedSpecies = '';
|
||||
document.querySelectorAll('.species-option').forEach(el => el.classList.remove('selected'));
|
||||
document.getElementById('petModal').classList.add('active');
|
||||
}
|
||||
|
||||
function editPet(petId) {
|
||||
// TODO: API에서 pet 정보 가져와서 폼에 채우기
|
||||
currentPetId = petId;
|
||||
document.getElementById('modalTitle').textContent = '반려동물 수정';
|
||||
document.getElementById('submitBtn').textContent = '수정하기';
|
||||
document.getElementById('deleteBtn').style.display = 'block';
|
||||
document.getElementById('petModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('petModal').classList.remove('active');
|
||||
}
|
||||
|
||||
function previewPhoto(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
document.getElementById('photoPreview').innerHTML =
|
||||
`<img src="${e.target.result}" alt="미리보기">`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPet(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedSpecies) {
|
||||
alert('종류를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = document.getElementById('petName').value.trim();
|
||||
if (!name) {
|
||||
alert('이름을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '처리중...';
|
||||
|
||||
try {
|
||||
const data = {
|
||||
name: name,
|
||||
species: selectedSpecies,
|
||||
breed: document.getElementById('petBreed').value,
|
||||
gender: document.getElementById('petGender').value
|
||||
};
|
||||
|
||||
const url = currentPetId ? `/api/pets/${currentPetId}` : '/api/pets';
|
||||
const method = currentPetId ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// 사진 업로드
|
||||
const photoInput = document.getElementById('photoInput');
|
||||
if (photoInput.files.length > 0) {
|
||||
const petId = result.pet_id || currentPetId;
|
||||
const formData = new FormData();
|
||||
formData.append('photo', photoInput.files[0]);
|
||||
|
||||
await fetch(`/api/pets/${petId}/photo`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
alert(result.message || '저장되었습니다!');
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.error || '오류가 발생했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = currentPetId ? '수정하기' : '등록하기';
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePet() {
|
||||
if (!currentPetId) return;
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/pets/${currentPetId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('삭제되었습니다.');
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.error || '삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('서버 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
document.getElementById('petModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeModal();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2275
backend/templates/pmr.html
Normal file
2275
backend/templates/pmr.html
Normal file
File diff suppressed because it is too large
Load Diff
869
backend/templates/pmr_admin.html
Normal file
869
backend/templates/pmr_admin.html
Normal file
@@ -0,0 +1,869 @@
|
||||
<!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>
|
||||
8
backend/test_pg.py
Normal file
8
backend/test_pg.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
pg_engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
|
||||
with pg_engine.connect() as conn:
|
||||
result = conn.execute(text("SELECT apc, product_name, company_name, main_ingredient FROM apc WHERE product_name LIKE '%아시엔로%' LIMIT 20"))
|
||||
print('아시엔로 검색 결과:')
|
||||
for row in result:
|
||||
print(f' APC: {row[0]} | {row[1]} | {row[2]} | {row[3]}')
|
||||
283
backend/utils/otc_label_printer.py
Normal file
283
backend/utils/otc_label_printer.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
OTC 용법 라벨 출력 모듈
|
||||
Brother QL-810W 프린터용 가로형 와이드 라벨 생성 및 출력
|
||||
|
||||
기반: person-lookup-web-local/print_label.py
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# 프린터 설정 (QL-810W)
|
||||
PRINTER_IP = "192.168.0.168" # QR 라벨과 동일한 Brother QL-810W
|
||||
PRINTER_MODEL = "QL-810W"
|
||||
LABEL_TYPE = "29" # 29mm 연속 출력 용지
|
||||
|
||||
# 폰트 경로 (Windows/Linux 크로스 플랫폼)
|
||||
FONT_PATHS = [
|
||||
"C:/Windows/Fonts/malgunbd.ttf", # Windows
|
||||
"/srv/person-lookup-web-local/pop_maker/fonts/malgunbd.ttf", # Linux
|
||||
"/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf", # Linux 대체
|
||||
]
|
||||
|
||||
def get_font_path():
|
||||
"""사용 가능한 폰트 경로 반환"""
|
||||
for path in FONT_PATHS:
|
||||
if Path(path).exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def create_otc_label_image(drug_name, effect="", dosage_instruction="", usage_tip=""):
|
||||
"""
|
||||
OTC 용법 라벨 이미지 생성 (800 x 306px)
|
||||
|
||||
레이아웃:
|
||||
- 효능: 중앙 상단에 크게 강조 (72pt)
|
||||
- 약품명: 오른쪽 중간 (36pt)
|
||||
- 용법: 왼쪽 하단 체크박스 (40pt)
|
||||
- 약국명: 오른쪽 하단 테두리 박스 (32pt)
|
||||
|
||||
Args:
|
||||
drug_name (str): 약품명
|
||||
effect (str): 효능
|
||||
dosage_instruction (str): 복용 방법
|
||||
usage_tip (str): 사용 팁
|
||||
|
||||
Returns:
|
||||
PIL.Image: 가로형 와이드 라벨 이미지 (800 x 306px, mode='1')
|
||||
"""
|
||||
try:
|
||||
# 1. 캔버스 생성 (가로로 긴 형태)
|
||||
width = 800
|
||||
height = 306 # Brother QL 29mm 용지 폭
|
||||
|
||||
img = Image.new('1', (width, height), 1) # 흰색 배경
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 2. 폰트 로드
|
||||
font_path = get_font_path()
|
||||
try:
|
||||
font_effect = ImageFont.truetype(font_path, 72) # 효능 (매우 크게!)
|
||||
font_drugname = ImageFont.truetype(font_path, 36) # 약품명 (중간)
|
||||
font_dosage = ImageFont.truetype(font_path, 40) # 용법 (크게)
|
||||
font_pharmacy = ImageFont.truetype(font_path, 32) # 약국명 (크게)
|
||||
font_small = ImageFont.truetype(font_path, 26) # 사용팁
|
||||
except (IOError, TypeError):
|
||||
font_effect = ImageFont.load_default()
|
||||
font_drugname = ImageFont.load_default()
|
||||
font_dosage = ImageFont.load_default()
|
||||
font_pharmacy = ImageFont.load_default()
|
||||
font_small = ImageFont.load_default()
|
||||
logging.warning("폰트 로드 실패. 기본 폰트 사용.")
|
||||
|
||||
# 3. 레이아웃
|
||||
x_margin = 25
|
||||
|
||||
# 효능 - 중앙 상단에 크게 (매우 강조!)
|
||||
if effect:
|
||||
effect_bbox = draw.textbbox((0, 0), effect, font=font_effect)
|
||||
effect_width = effect_bbox[2] - effect_bbox[0]
|
||||
effect_x = (width - effect_width) // 2
|
||||
# 굵게 표시 (offset)
|
||||
for offset in [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]:
|
||||
draw.text((effect_x + offset[0], 20 + offset[1]), effect, font=font_effect, fill=0)
|
||||
|
||||
# 약품명 - 오른쪽 중간 여백에 배치
|
||||
drugname_bbox = draw.textbbox((0, 0), drug_name, font=font_drugname)
|
||||
drugname_width = drugname_bbox[2] - drugname_bbox[0]
|
||||
drugname_x = width - drugname_width - 30 # 오른쪽에서 30px 여백
|
||||
drugname_y = 195
|
||||
draw.text((drugname_x, drugname_y), drug_name, font=font_drugname, fill=0)
|
||||
|
||||
# 용법 - 왼쪽 하단에 크게 표시
|
||||
y = 120 # 효능 아래부터 시작
|
||||
|
||||
# 사용팁이 없으면 복용방법을 더 크게
|
||||
if not usage_tip:
|
||||
try:
|
||||
font_dosage_adjusted = ImageFont.truetype(font_path, 50)
|
||||
except:
|
||||
font_dosage_adjusted = font_dosage
|
||||
else:
|
||||
font_dosage_adjusted = font_dosage
|
||||
|
||||
if dosage_instruction:
|
||||
# 대괄호로 묶인 부분을 별도 줄로 분리
|
||||
dosage_text = re.sub(r'\s*(\[.*?\])\s*', r'\n\1\n', dosage_instruction)
|
||||
|
||||
# 여러 줄 처리
|
||||
max_chars_per_line = 32
|
||||
dosage_lines = []
|
||||
|
||||
text_parts = dosage_text.split('\n')
|
||||
for part in text_parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
if part.startswith('[') and part.endswith(']'):
|
||||
dosage_lines.append(part)
|
||||
elif len(part) > max_chars_per_line:
|
||||
words = part.split()
|
||||
current_line = ""
|
||||
for word in words:
|
||||
if len(current_line + word) <= max_chars_per_line:
|
||||
current_line += word + " "
|
||||
else:
|
||||
if current_line:
|
||||
dosage_lines.append(current_line.strip())
|
||||
current_line = word + " "
|
||||
if current_line:
|
||||
dosage_lines.append(current_line.strip())
|
||||
else:
|
||||
dosage_lines.append(part)
|
||||
|
||||
# 첫 줄에 체크박스 추가
|
||||
if dosage_lines:
|
||||
first_line = f"□ {dosage_lines[0]}"
|
||||
draw.text((x_margin, y), first_line, font=font_dosage_adjusted, fill=0)
|
||||
|
||||
line_spacing = 60 if not usage_tip else 50
|
||||
y += line_spacing
|
||||
|
||||
for line in dosage_lines[1:]:
|
||||
indent = 0 if (line.startswith('[') and line.endswith(']')) else 30
|
||||
draw.text((x_margin + indent, y), line, font=font_dosage_adjusted, fill=0)
|
||||
y += line_spacing + 2
|
||||
|
||||
# 사용팁 (체크박스 + 텍스트)
|
||||
if usage_tip and y < height - 60:
|
||||
tip_text = f"□ {usage_tip}"
|
||||
if len(tip_text) > 55:
|
||||
tip_text = tip_text[:52] + "..."
|
||||
draw.text((x_margin, y), tip_text, font=font_small, fill=0)
|
||||
|
||||
# 약국명 - 오른쪽 하단에 크게 (테두리 박스)
|
||||
sign_text = "청춘약국"
|
||||
sign_bbox = draw.textbbox((0, 0), sign_text, font=font_pharmacy)
|
||||
sign_width = sign_bbox[2] - sign_bbox[0]
|
||||
sign_height = sign_bbox[3] - sign_bbox[1]
|
||||
|
||||
sign_padding_lr = 10
|
||||
sign_padding_top = 5
|
||||
sign_padding_bottom = 10
|
||||
|
||||
sign_x = width - sign_width - x_margin - 10 - sign_padding_lr
|
||||
sign_y = height - 55
|
||||
|
||||
# 테두리 박스 그리기
|
||||
box_x1 = sign_x - sign_padding_lr
|
||||
box_y1 = sign_y - sign_padding_top
|
||||
box_x2 = sign_x + sign_width + sign_padding_lr
|
||||
box_y2 = sign_y + sign_height + sign_padding_bottom
|
||||
draw.rectangle([box_x1, box_y1, box_x2, box_y2], outline=0, width=2)
|
||||
|
||||
# 약국명 텍스트 (굵게)
|
||||
for offset in [(0, 0), (1, 0), (0, 1), (1, 1)]:
|
||||
draw.text((sign_x + offset[0], sign_y + offset[1]), sign_text, font=font_pharmacy, fill=0)
|
||||
|
||||
# 5. 테두리 (가위선 스타일)
|
||||
for i in range(3):
|
||||
draw.rectangle([5 + i, 5 + i, width - 5 - i, height - 5 - i], outline=0)
|
||||
|
||||
logging.info(f"OTC 라벨 이미지 생성 성공: {drug_name}")
|
||||
return img
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"OTC 라벨 이미지 생성 실패: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def print_otc_label(drug_name, effect="", dosage_instruction="", usage_tip=""):
|
||||
"""
|
||||
OTC 용법 라벨을 Brother QL-810W 프린터로 출력
|
||||
|
||||
Args:
|
||||
drug_name (str): 약품명
|
||||
effect (str): 효능
|
||||
dosage_instruction (str): 복용 방법
|
||||
usage_tip (str): 사용 팁
|
||||
|
||||
Returns:
|
||||
bool: 성공 여부
|
||||
"""
|
||||
try:
|
||||
from brother_ql.raster import BrotherQLRaster
|
||||
from brother_ql.conversion import convert
|
||||
from brother_ql.backends.helpers import send
|
||||
|
||||
# 1. 라벨 이미지 생성
|
||||
label_img = create_otc_label_image(drug_name, effect, dosage_instruction, usage_tip)
|
||||
|
||||
# 2. 이미지 90도 회전 (Brother QL이 세로 방향 기준이므로)
|
||||
label_img_rotated = label_img.rotate(90, expand=True)
|
||||
|
||||
logging.info(f"이미지 회전 완료: {label_img_rotated.size}")
|
||||
|
||||
# 3. Brother QL 프린터로 전송
|
||||
qlr = BrotherQLRaster(PRINTER_MODEL)
|
||||
instructions = convert(
|
||||
qlr=qlr,
|
||||
images=[label_img_rotated],
|
||||
label='29',
|
||||
rotate='0',
|
||||
threshold=70.0,
|
||||
dither=False,
|
||||
compress=False,
|
||||
red=False,
|
||||
dpi_600=False,
|
||||
hq=True,
|
||||
cut=True
|
||||
)
|
||||
send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100")
|
||||
|
||||
logging.info(f"[SUCCESS] OTC 용법 라벨 인쇄 성공: {drug_name}")
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
logging.error("brother_ql 라이브러리가 설치되지 않았습니다.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.error(f"[ERROR] OTC 용법 라벨 인쇄 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def generate_preview_image(drug_name, effect="", dosage_instruction="", usage_tip=""):
|
||||
"""
|
||||
미리보기용 PNG 이미지 생성 (Base64 인코딩)
|
||||
|
||||
Args:
|
||||
drug_name (str): 약품명
|
||||
effect (str): 효능
|
||||
dosage_instruction (str): 복용 방법
|
||||
usage_tip (str): 사용 팁
|
||||
|
||||
Returns:
|
||||
str: Base64 인코딩된 PNG 이미지 (data:image/png;base64,... 형태)
|
||||
"""
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
try:
|
||||
# 라벨 이미지 생성
|
||||
label_img = create_otc_label_image(drug_name, effect, dosage_instruction, usage_tip)
|
||||
|
||||
# RGB로 변환 (1-bit → RGB)
|
||||
label_img_rgb = label_img.convert('RGB')
|
||||
|
||||
# PNG로 인코딩
|
||||
buffer = BytesIO()
|
||||
label_img_rgb.save(buffer, format='PNG')
|
||||
buffer.seek(0)
|
||||
|
||||
# Base64 인코딩
|
||||
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
return f"data:image/png;base64,{img_base64}"
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"미리보기 이미지 생성 실패: {e}")
|
||||
return None
|
||||
372
backend/utils/yakkok_crawler.py
Normal file
372
backend/utils/yakkok_crawler.py
Normal file
@@ -0,0 +1,372 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
yakkok.com 제품 이미지 크롤러
|
||||
- 제품명으로 검색하여 이미지 URL 추출
|
||||
- base64로 변환하여 SQLite에 저장
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import sqlite3
|
||||
import base64
|
||||
import logging
|
||||
import hashlib
|
||||
import re
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
import requests
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
# Playwright 동기 모드
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# DB 경로
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'db', 'product_images.db')
|
||||
|
||||
# yakkok.com 설정
|
||||
YAKKOK_BASE_URL = "https://yakkok.com"
|
||||
YAKKOK_SEARCH_URL = "https://yakkok.com/search?q={query}"
|
||||
|
||||
|
||||
def init_db():
|
||||
"""DB 초기화"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 스키마 파일 실행
|
||||
schema_path = os.path.join(os.path.dirname(__file__), '..', 'db', 'product_images_schema.sql')
|
||||
if os.path.exists(schema_path):
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
cursor.executescript(f.read())
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
logger.info(f"[DB] 초기화 완료: {DB_PATH}")
|
||||
|
||||
|
||||
def get_existing_barcodes():
|
||||
"""이미 저장된 바코드 목록 조회"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT barcode FROM product_images WHERE status IN ('success', 'manual')")
|
||||
barcodes = set(row[0] for row in cursor.fetchall())
|
||||
conn.close()
|
||||
return barcodes
|
||||
|
||||
|
||||
def save_product_image(barcode, drug_code, product_name, search_name,
|
||||
image_base64, image_url, thumbnail_base64=None,
|
||||
status='success', error_message=None):
|
||||
"""제품 이미지 저장"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO product_images
|
||||
(barcode, drug_code, product_name, search_name, image_base64, image_url,
|
||||
thumbnail_base64, status, error_message, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (barcode, drug_code, product_name, search_name, image_base64, image_url,
|
||||
thumbnail_base64, status, error_message, datetime.now().isoformat()))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"[DB] 저장 완료: {product_name} ({barcode}) - {status}")
|
||||
|
||||
|
||||
def download_image_as_base64(url, max_size=500):
|
||||
"""이미지 다운로드 후 base64 변환 (리사이즈 포함)"""
|
||||
try:
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
# PIL로 이미지 열기
|
||||
img = Image.open(BytesIO(response.content))
|
||||
|
||||
# RGBA -> RGB 변환 (JPEG 저장용)
|
||||
if img.mode == 'RGBA':
|
||||
bg = Image.new('RGB', img.size, (255, 255, 255))
|
||||
bg.paste(img, mask=img.split()[3])
|
||||
img = bg
|
||||
elif img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
# 리사이즈 (비율 유지)
|
||||
if max(img.size) > max_size:
|
||||
ratio = max_size / max(img.size)
|
||||
new_size = tuple(int(dim * ratio) for dim in img.size)
|
||||
img = img.resize(new_size, Image.LANCZOS)
|
||||
|
||||
# base64 변환
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format='JPEG', quality=85)
|
||||
base64_str = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
return base64_str
|
||||
except Exception as e:
|
||||
logger.error(f"[ERROR] 이미지 다운로드 실패: {url} - {e}")
|
||||
return None
|
||||
|
||||
|
||||
def clean_product_name(name):
|
||||
"""검색용 제품명 정리"""
|
||||
# 괄호 안 내용 제거 (용량 등)
|
||||
name = re.sub(r'\([^)]*\)', '', name)
|
||||
# 숫자+단위 제거 (100ml, 500mg 등)
|
||||
name = re.sub(r'\d+\s*(ml|mg|g|kg|정|캡슐|T|t|개|EA|ea)', '', name, flags=re.IGNORECASE)
|
||||
# 특수문자 제거
|
||||
name = re.sub(r'[_\-/\\]', ' ', name)
|
||||
# 연속 공백 정리
|
||||
name = re.sub(r'\s+', ' ', name).strip()
|
||||
return name
|
||||
|
||||
|
||||
def search_yakkok(page, product_name):
|
||||
"""yakkok.com에서 제품 검색하여 이미지 URL 반환"""
|
||||
try:
|
||||
# 검색어 정리
|
||||
search_name = clean_product_name(product_name)
|
||||
if not search_name:
|
||||
search_name = product_name
|
||||
|
||||
# 검색 페이지 접속
|
||||
search_url = YAKKOK_SEARCH_URL.format(query=quote(search_name))
|
||||
page.goto(search_url, wait_until='networkidle', timeout=15000)
|
||||
|
||||
# 잠시 대기
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# 첫 번째 검색 결과의 이미지 찾기
|
||||
img_selector = 'img[alt]'
|
||||
images = page.query_selector_all(img_selector)
|
||||
|
||||
for img in images:
|
||||
src = img.get_attribute('src')
|
||||
alt = img.get_attribute('alt') or ''
|
||||
|
||||
# 로고, 아이콘 등 제외
|
||||
if not src or 'logo' in src.lower() or 'icon' in src.lower():
|
||||
continue
|
||||
|
||||
# 검색 아이콘 등 제외
|
||||
if alt in ['검색', '홈', '마이', '재고콕', '약콕인증', '뒤로가기']:
|
||||
continue
|
||||
|
||||
# 제품 이미지로 보이는 것 반환
|
||||
if src.startswith('http') or src.startswith('//'):
|
||||
if src.startswith('//'):
|
||||
src = 'https:' + src
|
||||
return src, search_name
|
||||
|
||||
return None, search_name
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ERROR] 검색 실패: {product_name} - {e}")
|
||||
return None, search_name
|
||||
|
||||
|
||||
def crawl_products(products, headless=True):
|
||||
"""
|
||||
제품 목록 크롤링
|
||||
products: [(barcode, drug_code, product_name), ...]
|
||||
"""
|
||||
init_db()
|
||||
existing = get_existing_barcodes()
|
||||
|
||||
# 새로 크롤링할 제품만 필터
|
||||
to_crawl = [(b, d, n) for b, d, n in products if b not in existing]
|
||||
|
||||
if not to_crawl:
|
||||
logger.info("[INFO] 크롤링할 새 제품이 없습니다.")
|
||||
return {'total': 0, 'success': 0, 'failed': 0, 'skipped': len(products)}
|
||||
|
||||
logger.info(f"[INFO] 크롤링 시작: {len(to_crawl)}개 (스킵: {len(products) - len(to_crawl)}개)")
|
||||
|
||||
results = {'total': len(to_crawl), 'success': 0, 'failed': 0, 'skipped': len(products) - len(to_crawl)}
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=headless)
|
||||
context = browser.new_context(
|
||||
viewport={'width': 390, 'height': 844}, # 모바일 뷰포트
|
||||
user_agent='Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15'
|
||||
)
|
||||
page = context.new_page()
|
||||
|
||||
for barcode, drug_code, product_name in to_crawl:
|
||||
try:
|
||||
logger.info(f"[CRAWL] {product_name} ({barcode})")
|
||||
|
||||
# yakkok 검색
|
||||
image_url, search_name = search_yakkok(page, product_name)
|
||||
|
||||
if image_url:
|
||||
# 이미지 다운로드 & base64 변환
|
||||
image_base64 = download_image_as_base64(image_url)
|
||||
thumbnail_base64 = download_image_as_base64(image_url, max_size=100)
|
||||
|
||||
if image_base64:
|
||||
save_product_image(
|
||||
barcode=barcode,
|
||||
drug_code=drug_code,
|
||||
product_name=product_name,
|
||||
search_name=search_name,
|
||||
image_base64=image_base64,
|
||||
image_url=image_url,
|
||||
thumbnail_base64=thumbnail_base64,
|
||||
status='success'
|
||||
)
|
||||
results['success'] += 1
|
||||
else:
|
||||
save_product_image(
|
||||
barcode=barcode,
|
||||
drug_code=drug_code,
|
||||
product_name=product_name,
|
||||
search_name=search_name,
|
||||
image_base64=None,
|
||||
image_url=image_url,
|
||||
status='failed',
|
||||
error_message='이미지 다운로드 실패'
|
||||
)
|
||||
results['failed'] += 1
|
||||
else:
|
||||
save_product_image(
|
||||
barcode=barcode,
|
||||
drug_code=drug_code,
|
||||
product_name=product_name,
|
||||
search_name=search_name,
|
||||
image_base64=None,
|
||||
image_url=None,
|
||||
status='no_result',
|
||||
error_message='검색 결과 없음'
|
||||
)
|
||||
results['failed'] += 1
|
||||
|
||||
# 요청 간 딜레이
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ERROR] {product_name}: {e}")
|
||||
save_product_image(
|
||||
barcode=barcode,
|
||||
drug_code=drug_code,
|
||||
product_name=product_name,
|
||||
search_name=product_name,
|
||||
image_base64=None,
|
||||
image_url=None,
|
||||
status='failed',
|
||||
error_message=str(e)
|
||||
)
|
||||
results['failed'] += 1
|
||||
|
||||
browser.close()
|
||||
|
||||
logger.info(f"[DONE] 완료 - 성공: {results['success']}, 실패: {results['failed']}, 스킵: {results['skipped']}")
|
||||
return results
|
||||
|
||||
|
||||
def get_sales_products(date_str=None):
|
||||
"""특정 날짜 판매 제품 목록 조회 (MSSQL)
|
||||
|
||||
Args:
|
||||
date_str: 날짜 문자열 (YYYYMMDD 또는 YYYY-MM-DD), None이면 오늘
|
||||
"""
|
||||
try:
|
||||
# 상위 폴더의 db 모듈 import
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
from db.dbsetup import db_manager
|
||||
from sqlalchemy import text
|
||||
|
||||
session = db_manager.get_session('PM_PRES')
|
||||
|
||||
# 날짜 처리
|
||||
if date_str:
|
||||
# YYYY-MM-DD -> YYYYMMDD 변환
|
||||
target_date = date_str.replace('-', '')
|
||||
else:
|
||||
target_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
# 해당 날짜 판매된 품목 조회 (중복 제거)
|
||||
query = text("""
|
||||
SELECT DISTINCT
|
||||
COALESCE(NULLIF(G.Barcode, ''),
|
||||
(SELECT TOP 1 CD_CD_BARCODE FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER WHERE DrugCode = S.DrugCode)
|
||||
) AS barcode,
|
||||
S.DrugCode AS drug_code,
|
||||
ISNULL(G.GoodsName, '알수없음') AS product_name
|
||||
FROM SALE_SUB S
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE S.SL_NO_order LIKE :date_pattern
|
||||
AND S.DrugCode IS NOT NULL
|
||||
""")
|
||||
|
||||
result = session.execute(query, {'date_pattern': f'{target_date}%'}).fetchall()
|
||||
|
||||
products = []
|
||||
for row in result:
|
||||
barcode = row[0]
|
||||
if barcode: # 바코드 있는 것만
|
||||
products.append((barcode, row[1], row[2]))
|
||||
|
||||
logger.info(f"[MSSQL] {target_date} 판매 품목: {len(products)}개")
|
||||
return products
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ERROR] MSSQL 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_today_sales_products():
|
||||
"""오늘 판매된 제품 목록 조회 (하위호환)"""
|
||||
return get_sales_products(None)
|
||||
|
||||
|
||||
def crawl_sales_by_date(date_str=None, headless=True):
|
||||
"""특정 날짜 판매 제품 이미지 크롤링
|
||||
|
||||
Args:
|
||||
date_str: 날짜 문자열 (YYYYMMDD 또는 YYYY-MM-DD), None이면 오늘
|
||||
"""
|
||||
products = get_sales_products(date_str)
|
||||
if not products:
|
||||
return {'total': 0, 'success': 0, 'failed': 0, 'skipped': 0, 'message': '해당일 판매 내역 없음'}
|
||||
|
||||
return crawl_products(products, headless=headless)
|
||||
|
||||
|
||||
def crawl_today_sales(headless=True):
|
||||
"""오늘 판매된 제품 이미지 크롤링 (하위호환)"""
|
||||
return crawl_sales_by_date(None, headless=headless)
|
||||
|
||||
|
||||
# CLI 실행
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='yakkok.com 제품 이미지 크롤러')
|
||||
parser.add_argument('--today', action='store_true', help='오늘 판매 제품 크롤링')
|
||||
parser.add_argument('--product', type=str, help='특정 제품명으로 테스트')
|
||||
parser.add_argument('--visible', action='store_true', help='브라우저 표시')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.today:
|
||||
result = crawl_today_sales(headless=not args.visible)
|
||||
print(f"\n결과: {result}")
|
||||
elif args.product:
|
||||
# 테스트용 단일 제품 크롤링
|
||||
test_products = [('TEST001', 'TEST', args.product)]
|
||||
result = crawl_products(test_products, headless=not args.visible)
|
||||
print(f"\n결과: {result}")
|
||||
else:
|
||||
print("사용법:")
|
||||
print(" python yakkok_crawler.py --today # 오늘 판매 제품 크롤링")
|
||||
print(" python yakkok_crawler.py --product 타이레놀 # 특정 제품 테스트")
|
||||
print(" python yakkok_crawler.py --visible # 브라우저 표시")
|
||||
26
check_images_db.py
Normal file
26
check_images_db.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(r'C:\Users\청춘약국\source\pharmacy-pos-qr-system\backend\db\product_images.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 테이블 목록
|
||||
cursor.execute('SELECT name FROM sqlite_master WHERE type="table"')
|
||||
tables = [r[0] for r in cursor.fetchall()]
|
||||
print("테이블:", tables)
|
||||
|
||||
# 각 테이블 스키마
|
||||
for table in tables:
|
||||
cursor.execute(f'PRAGMA table_info({table})')
|
||||
cols = [r[1] for r in cursor.fetchall()]
|
||||
print(f"\n{table} 컬럼: {cols}")
|
||||
|
||||
# 샘플 데이터
|
||||
cursor.execute(f'SELECT * FROM {table} LIMIT 2')
|
||||
rows = cursor.fetchall()
|
||||
for r in rows:
|
||||
print(f" 샘플: {r[:3]}..." if len(r) > 3 else f" 샘플: {r}")
|
||||
|
||||
# 총 개수
|
||||
for table in tables:
|
||||
cursor.execute(f'SELECT COUNT(*) FROM {table}')
|
||||
print(f"\n{table} 총 {cursor.fetchone()[0]}개")
|
||||
515
docs/ANIMAL_DRUG_APC_MAPPING.html
Normal file
515
docs/ANIMAL_DRUG_APC_MAPPING.html
Normal file
@@ -0,0 +1,515 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>스마트헬스케어 사업제안서</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
line-height: 1.8;
|
||||
color: #1e293b;
|
||||
max-width: 210mm;
|
||||
margin: 0 auto;
|
||||
padding: 20mm;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #6366f1;
|
||||
margin: 40px 0 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 3px solid #6366f1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
margin: 35px 0 15px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin: 25px 0 12px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin: 20px 0 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 12px 0;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-left: 4px solid #6366f1;
|
||||
padding: 16px 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-style: italic;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
overflow-x: auto;
|
||||
margin: 20px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #fff;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: 15px 0;
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #6366f1, #8b5cf6, #ec4899);
|
||||
margin: 40px 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
em {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* 첫 페이지 타이틀 */
|
||||
h1:first-of-type {
|
||||
font-size: 32px;
|
||||
text-align: center;
|
||||
border-bottom: none;
|
||||
margin-top: 60px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h1:first-of-type + blockquote {
|
||||
text-align: center;
|
||||
border-left: none;
|
||||
background: none;
|
||||
font-size: 18px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
/* 프린트 스타일 */
|
||||
@media print {
|
||||
body {
|
||||
padding: 15mm;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
table, pre, blockquote {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
/* 페이지 구분 */
|
||||
.page-break {
|
||||
page-break-before: always;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="apc">동물약 APC 매핑 가이드</h1>
|
||||
<blockquote>
|
||||
<p>최종 업데이트: 2026-03-02</p>
|
||||
</blockquote>
|
||||
<h2 id="_1">개요</h2>
|
||||
<p>POS(PIT3000)의 동물약 제품을 APDB의 APC 코드와 매핑하여 제품 정보(용법, 용량, 주의사항) 및 이미지를 표시하기 위한 작업 가이드.</p>
|
||||
<hr />
|
||||
<h2 id="_2">현재 상태</h2>
|
||||
<h3 id="_3">매핑 현황</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>구분</th>
|
||||
<th>개수</th>
|
||||
<th>비율</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>동물약 총</td>
|
||||
<td>39개</td>
|
||||
<td>100%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>APC 매핑됨</td>
|
||||
<td>7개</td>
|
||||
<td>18%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>APC 미매핑</strong></td>
|
||||
<td><strong>32개</strong></td>
|
||||
<td><strong>82%</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3 id="_4">매핑 완료 제품</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>POS 제품명</th>
|
||||
<th>DrugCode</th>
|
||||
<th>APC</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>(판)복합개시딘</td>
|
||||
<td>LB000003140</td>
|
||||
<td>0231093520106</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>안텔민킹(5kg이상)</td>
|
||||
<td>LB000003158</td>
|
||||
<td>0230237810109</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>안텔민뽀삐(5kg이하)</td>
|
||||
<td>LB000003157</td>
|
||||
<td>0230237010107</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>파라캅L(5kg이상)</td>
|
||||
<td>LB000003159</td>
|
||||
<td>0230338510101</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>파라캅S(5kg이하)</td>
|
||||
<td>LB000003160</td>
|
||||
<td>0230347110106</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>세레니아정16mg(개멀미약)</td>
|
||||
<td>LB000003353</td>
|
||||
<td>0231884610109</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>세레니아정24mg(개멀미약)</td>
|
||||
<td>LB000003354</td>
|
||||
<td>0231884620107</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h2 id="_5">매핑 구조</h2>
|
||||
<h3 id="_6">데이터베이스 연결</h3>
|
||||
<pre><code>MSSQL (192.168.0.4\PM2014) PostgreSQL (192.168.0.87:5432)
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ PM_DRUG.CD_GOODS │ │ apdb_master.apc │
|
||||
│ - DrugCode │ │ - apc (PK) │
|
||||
│ - GoodsName │ │ - product_name │
|
||||
│ - BARCODE │ │ - image_url1 │
|
||||
│ │ │ - llm_pharm (JSONB) │
|
||||
├─────────────────────────┤ └─────────────────────────┘
|
||||
│ PM_DRUG.CD_ITEM_UNIT_ │
|
||||
│ MEMBER │
|
||||
│ - DRUGCODE (FK) │
|
||||
│ - CD_CD_BARCODE ◀───────┼── APC 코드 저장 (023%로 시작)
|
||||
│ - CHANGE_DATE │
|
||||
└─────────────────────────┘
|
||||
</code></pre>
|
||||
<h3 id="apc_1">APC 매핑 방식</h3>
|
||||
<ol>
|
||||
<li><code>CD_ITEM_UNIT_MEMBER</code> 테이블에 <strong>추가 바코드</strong>로 APC 등록</li>
|
||||
<li>기존 바코드는 유지, APC를 별도 레코드로 INSERT</li>
|
||||
<li>APC 코드는 <code>023%</code>로 시작 (식별자)</li>
|
||||
</ol>
|
||||
<hr />
|
||||
<h2 id="11">1:1 매핑 가능 후보</h2>
|
||||
<h3 id="1">✅ 확실한 매핑 (1개)</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>POS 제품명</th>
|
||||
<th>DrugCode</th>
|
||||
<th>APC</th>
|
||||
<th>APDB 제품명</th>
|
||||
<th>이미지</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>제스타제(10정)</strong></td>
|
||||
<td>LB000003146</td>
|
||||
<td>8809720800455</td>
|
||||
<td>제스타제</td>
|
||||
<td>✅ 있음</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3 id="1_1">⚠️ 검토 필요 (1개)</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>POS 제품명</th>
|
||||
<th>DrugCode</th>
|
||||
<th>APC 후보</th>
|
||||
<th>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>안텔민</td>
|
||||
<td>S0000001</td>
|
||||
<td>0230237800003</td>
|
||||
<td>"안텔민킹"과 "안텔민뽀삐"는 이미 별도 매핑됨. 이 제품이 무엇인지 확인 필요</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3 id="apdb-3">❌ APDB에 없음 (3개)</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>POS 제품명</th>
|
||||
<th>사유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>(판)클라펫정50(100정)</td>
|
||||
<td>APDB엔 "클라펫 정"만 있음 (함량 불일치)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>넥스가드xs(2~3.5kg)</td>
|
||||
<td>사이즈별 APC 없음</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>캐치원캣(2.5~7.5kg)/고양이</td>
|
||||
<td>APDB에 캐치원 자체가 없음</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h2 id="1n-27">1:N 매핑 필요 제품 (27개)</h2>
|
||||
<p>사이즈별로 세분화된 제품들. 하나의 APDB APC에 여러 POS 제품을 매핑해야 함.</p>
|
||||
<h3 id="_7">브랜드별 현황</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>브랜드</th>
|
||||
<th>POS 제품 수</th>
|
||||
<th>APDB 존재</th>
|
||||
<th>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>다이로하트</td>
|
||||
<td>3개 (SS/S/M)</td>
|
||||
<td>✅</td>
|
||||
<td>다이로하트 츄어블 정</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>하트세이버</td>
|
||||
<td>4개 (mini/S/M/L)</td>
|
||||
<td>✅</td>
|
||||
<td>하트세이버 플러스 츄어블</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>하트웜솔루션</td>
|
||||
<td>2개 (S/M)</td>
|
||||
<td>❌</td>
|
||||
<td>APDB에 없음</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>리펠로</td>
|
||||
<td>2개 (S/M)</td>
|
||||
<td>✅</td>
|
||||
<td>리펠로액 (이미지 있음!)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>캐치원</td>
|
||||
<td>5개 (SS/S/M/L/캣)</td>
|
||||
<td>❌</td>
|
||||
<td>APDB에 없음</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>셀라이트</td>
|
||||
<td>5개 (SS/S/M/L/XL)</td>
|
||||
<td>✅</td>
|
||||
<td>셀라이트 액</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>넥스가드</td>
|
||||
<td>2개 (xs/L)</td>
|
||||
<td>✅</td>
|
||||
<td>넥스가드 스펙트라</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>가드닐</td>
|
||||
<td>3개 (S/M/L)</td>
|
||||
<td>✅</td>
|
||||
<td>가드닐 액</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>심피드</td>
|
||||
<td>2개 (M/L)</td>
|
||||
<td>❌</td>
|
||||
<td>APDB에 없음</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>하트캅</td>
|
||||
<td>1개</td>
|
||||
<td>✅</td>
|
||||
<td>하트캅-츄어블 정</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h2 id="apdb">APDB 통계</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>항목</th>
|
||||
<th>수치</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>전체 APC</td>
|
||||
<td>16,326개</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>이미지 있음</td>
|
||||
<td>73개 (0.4%)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>LLM 정보 있음</td>
|
||||
<td>81개 (0.5%)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>동물 관련 키워드</td>
|
||||
<td>~200개</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>⚠️ <strong>주의:</strong> APDB에 이미지가 거의 없음. 이미지 표시가 목적이라면 다른 소스 필요.</p>
|
||||
<hr />
|
||||
<h2 id="_8">매핑 스크립트</h2>
|
||||
<h3 id="_9">매핑 후보 찾기</h3>
|
||||
<pre><code class="language-bash">python backend/scripts/batch_apc_matching.py
|
||||
</code></pre>
|
||||
<h3 id="11_1">1:1 매핑 가능 후보 추출</h3>
|
||||
<pre><code class="language-bash">python backend/scripts/find_1to1_candidates.py
|
||||
</code></pre>
|
||||
<h3 id="_10">매핑 실행 (수동)</h3>
|
||||
<pre><code class="language-python"># backend/scripts/batch_insert_apc.py 참고
|
||||
MAPPINGS = [
|
||||
('제스타제(10정)', 'LB000003146', '8809720800455'),
|
||||
]
|
||||
</code></pre>
|
||||
<h3 id="insert">INSERT 쿼리 예시</h3>
|
||||
<pre><code class="language-sql">INSERT INTO CD_ITEM_UNIT_MEMBER (
|
||||
DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT,
|
||||
CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE
|
||||
) VALUES (
|
||||
'LB000003146', -- DrugCode
|
||||
'015', -- 단위코드
|
||||
1.0, -- 단위명
|
||||
<기존값>, -- CD_MY_UNIT (기존 레코드에서 복사)
|
||||
<기존값>, -- CD_IN_UNIT (기존 레코드에서 복사)
|
||||
'8809720800455', -- APC 바코드
|
||||
'',
|
||||
'20260302' -- 변경일자
|
||||
)
|
||||
</code></pre>
|
||||
<hr />
|
||||
<h2 id="_11">다음 단계</h2>
|
||||
<ol>
|
||||
<li><strong>제스타제</strong> 1:1 매핑 실행</li>
|
||||
<li><strong>안텔민(S0000001)</strong> 제품 확인 후 결정</li>
|
||||
<li>1:N 매핑 정책 결정 (사이즈별 제품 → 동일 APC?)</li>
|
||||
<li>이미지 소스 대안 검토 (필요시)</li>
|
||||
</ol>
|
||||
<hr />
|
||||
<h2 id="_12">관련 파일</h2>
|
||||
<ul>
|
||||
<li><code>backend/db/dbsetup.py</code> - DB 연결 설정</li>
|
||||
<li><code>backend/scripts/batch_apc_matching.py</code> - 매칭 후보 찾기</li>
|
||||
<li><code>backend/scripts/batch_insert_apc.py</code> - 매핑 실행</li>
|
||||
<li><code>backend/scripts/find_1to1_candidates.py</code> - 1:1 후보 추출</li>
|
||||
<li><code>backend/app.py</code> - <code>_get_animal_drugs()</code>, <code>_get_animal_drug_rag()</code></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
176
docs/ANIMAL_DRUG_APC_MAPPING.md
Normal file
176
docs/ANIMAL_DRUG_APC_MAPPING.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# 동물약 APC 매핑 가이드
|
||||
|
||||
> 최종 업데이트: 2026-03-02
|
||||
|
||||
## 개요
|
||||
|
||||
POS(PIT3000)의 동물약 제품을 APDB의 APC 코드와 매핑하여 제품 정보(용법, 용량, 주의사항) 및 이미지를 표시하기 위한 작업 가이드.
|
||||
|
||||
---
|
||||
|
||||
## 현재 상태
|
||||
|
||||
### 매핑 현황
|
||||
|
||||
| 구분 | 개수 | 비율 |
|
||||
|------|------|------|
|
||||
| 동물약 총 | 39개 | 100% |
|
||||
| APC 매핑됨 | 7개 | 18% |
|
||||
| **APC 미매핑** | **32개** | **82%** |
|
||||
|
||||
### 매핑 완료 제품
|
||||
|
||||
| POS 제품명 | DrugCode | APC |
|
||||
|------------|----------|-----|
|
||||
| (판)복합개시딘 | LB000003140 | 0231093520106 |
|
||||
| 안텔민킹(5kg이상) | LB000003158 | 0230237810109 |
|
||||
| 안텔민뽀삐(5kg이하) | LB000003157 | 0230237010107 |
|
||||
| 파라캅L(5kg이상) | LB000003159 | 0230338510101 |
|
||||
| 파라캅S(5kg이하) | LB000003160 | 0230347110106 |
|
||||
| 세레니아정16mg(개멀미약) | LB000003353 | 0231884610109 |
|
||||
| 세레니아정24mg(개멀미약) | LB000003354 | 0231884620107 |
|
||||
|
||||
---
|
||||
|
||||
## 매핑 구조
|
||||
|
||||
### 데이터베이스 연결
|
||||
|
||||
```
|
||||
MSSQL (192.168.0.4\PM2014) PostgreSQL (192.168.0.87:5432)
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ PM_DRUG.CD_GOODS │ │ apdb_master.apc │
|
||||
│ - DrugCode │ │ - apc (PK) │
|
||||
│ - GoodsName │ │ - product_name │
|
||||
│ - BARCODE │ │ - image_url1 │
|
||||
│ │ │ - llm_pharm (JSONB) │
|
||||
├─────────────────────────┤ └─────────────────────────┘
|
||||
│ PM_DRUG.CD_ITEM_UNIT_ │
|
||||
│ MEMBER │
|
||||
│ - DRUGCODE (FK) │
|
||||
│ - CD_CD_BARCODE ◀───────┼── APC 코드 저장 (023%로 시작)
|
||||
│ - CHANGE_DATE │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### APC 매핑 방식
|
||||
|
||||
1. `CD_ITEM_UNIT_MEMBER` 테이블에 **추가 바코드**로 APC 등록
|
||||
2. 기존 바코드는 유지, APC를 별도 레코드로 INSERT
|
||||
3. APC 코드는 `023%`로 시작 (식별자)
|
||||
|
||||
---
|
||||
|
||||
## 1:1 매핑 가능 후보
|
||||
|
||||
### ✅ 확실한 매핑 (1개)
|
||||
|
||||
| POS 제품명 | DrugCode | APC | APDB 제품명 | 이미지 |
|
||||
|------------|----------|-----|-------------|--------|
|
||||
| **제스타제(10정)** | LB000003146 | 8809720800455 | 제스타제 | ✅ 있음 |
|
||||
|
||||
### ⚠️ 검토 필요 (1개)
|
||||
|
||||
| POS 제품명 | DrugCode | APC 후보 | 비고 |
|
||||
|------------|----------|----------|------|
|
||||
| 안텔민 | S0000001 | 0230237800003 | "안텔민킹"과 "안텔민뽀삐"는 이미 별도 매핑됨. 이 제품이 무엇인지 확인 필요 |
|
||||
|
||||
### ❌ APDB에 없음 (3개)
|
||||
|
||||
| POS 제품명 | 사유 |
|
||||
|------------|------|
|
||||
| (판)클라펫정50(100정) | APDB엔 "클라펫 정"만 있음 (함량 불일치) |
|
||||
| 넥스가드xs(2~3.5kg) | 사이즈별 APC 없음 |
|
||||
| 캐치원캣(2.5~7.5kg)/고양이 | APDB에 캐치원 자체가 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 1:N 매핑 필요 제품 (27개)
|
||||
|
||||
사이즈별로 세분화된 제품들. 하나의 APDB APC에 여러 POS 제품을 매핑해야 함.
|
||||
|
||||
### 브랜드별 현황
|
||||
|
||||
| 브랜드 | POS 제품 수 | APDB 존재 | 비고 |
|
||||
|--------|-------------|-----------|------|
|
||||
| 다이로하트 | 3개 (SS/S/M) | ✅ | 다이로하트 츄어블 정 |
|
||||
| 하트세이버 | 4개 (mini/S/M/L) | ✅ | 하트세이버 플러스 츄어블 |
|
||||
| 하트웜솔루션 | 2개 (S/M) | ❌ | APDB에 없음 |
|
||||
| 리펠로 | 2개 (S/M) | ✅ | 리펠로액 (이미지 있음!) |
|
||||
| 캐치원 | 5개 (SS/S/M/L/캣) | ❌ | APDB에 없음 |
|
||||
| 셀라이트 | 5개 (SS/S/M/L/XL) | ✅ | 셀라이트 액 |
|
||||
| 넥스가드 | 2개 (xs/L) | ✅ | 넥스가드 스펙트라 |
|
||||
| 가드닐 | 3개 (S/M/L) | ✅ | 가드닐 액 |
|
||||
| 심피드 | 2개 (M/L) | ❌ | APDB에 없음 |
|
||||
| 하트캅 | 1개 | ✅ | 하트캅-츄어블 정 |
|
||||
|
||||
---
|
||||
|
||||
## APDB 통계
|
||||
|
||||
| 항목 | 수치 |
|
||||
|------|------|
|
||||
| 전체 APC | 16,326개 |
|
||||
| 이미지 있음 | 73개 (0.4%) |
|
||||
| LLM 정보 있음 | 81개 (0.5%) |
|
||||
| 동물 관련 키워드 | ~200개 |
|
||||
|
||||
⚠️ **주의:** APDB에 이미지가 거의 없음. 이미지 표시가 목적이라면 다른 소스 필요.
|
||||
|
||||
---
|
||||
|
||||
## 매핑 스크립트
|
||||
|
||||
### 매핑 후보 찾기
|
||||
```bash
|
||||
python backend/scripts/batch_apc_matching.py
|
||||
```
|
||||
|
||||
### 1:1 매핑 가능 후보 추출
|
||||
```bash
|
||||
python backend/scripts/find_1to1_candidates.py
|
||||
```
|
||||
|
||||
### 매핑 실행 (수동)
|
||||
```python
|
||||
# backend/scripts/batch_insert_apc.py 참고
|
||||
MAPPINGS = [
|
||||
('제스타제(10정)', 'LB000003146', '8809720800455'),
|
||||
]
|
||||
```
|
||||
|
||||
### INSERT 쿼리 예시
|
||||
```sql
|
||||
INSERT INTO CD_ITEM_UNIT_MEMBER (
|
||||
DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT,
|
||||
CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE
|
||||
) VALUES (
|
||||
'LB000003146', -- DrugCode
|
||||
'015', -- 단위코드
|
||||
1.0, -- 단위명
|
||||
<기존값>, -- CD_MY_UNIT (기존 레코드에서 복사)
|
||||
<기존값>, -- CD_IN_UNIT (기존 레코드에서 복사)
|
||||
'8809720800455', -- APC 바코드
|
||||
'',
|
||||
'20260302' -- 변경일자
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. **제스타제** 1:1 매핑 실행
|
||||
2. **안텔민(S0000001)** 제품 확인 후 결정
|
||||
3. 1:N 매핑 정책 결정 (사이즈별 제품 → 동일 APC?)
|
||||
4. 이미지 소스 대안 검토 (필요시)
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `backend/db/dbsetup.py` - DB 연결 설정
|
||||
- `backend/scripts/batch_apc_matching.py` - 매칭 후보 찾기
|
||||
- `backend/scripts/batch_insert_apc.py` - 매핑 실행
|
||||
- `backend/scripts/find_1to1_candidates.py` - 1:1 후보 추출
|
||||
- `backend/app.py` - `_get_animal_drugs()`, `_get_animal_drug_rag()`
|
||||
299
docs/APC_MAPPING_PLAN.md
Normal file
299
docs/APC_MAPPING_PLAN.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# 🎯 APC 기반 동물약 매핑 기획서
|
||||
|
||||
> ⚠️ **주의**: 기획 단계에서는 MSSQL **READ ONLY**. 절대 데이터 입력/수정 금지.
|
||||
|
||||
---
|
||||
|
||||
## 📋 현상황 분석
|
||||
|
||||
### 1. 동물약 바코드 문제
|
||||
|
||||
#### 문제 1: 바코드 없음
|
||||
- 동물약은 **수의사 소분 판매 방지** 목적으로 공산품이지만 바코드가 없는 경우 많음
|
||||
- "판매 최소포장단위"별 바코드가 부여되지 않음
|
||||
- 예: 다이로하트, 넥스가드 등 → 바코드 없음
|
||||
|
||||
#### 문제 2: 바코드 중복
|
||||
- 바코드가 있어도 **여러 사이즈 제품이 동일 바코드** 사용
|
||||
- 예: 다이로하트정 SS/S/M/L → 모두 동일 바코드
|
||||
- 바코드만으로 사이즈/체중 구분 불가
|
||||
|
||||
#### 문제 3: 약국별 자체 바코드
|
||||
- 약국은 POS 재고관리를 위해 **자체 바코드 생성**하여 사용
|
||||
- 원래 바코드 무시하고 새로 지정
|
||||
- 이유: POS에서 스캔 시 제품별 즉시 구분 + 재고 차감 필요
|
||||
|
||||
#### 결과: 중앙 매핑 불가
|
||||
```
|
||||
약국 A: "안텔민사사" → 바코드 "A001"
|
||||
약국 B: "안텔민사사" → 바코드 "B999"
|
||||
약국 C: "안텔민사사" → 바코드 없음 (수기 입력)
|
||||
|
||||
↓ 중앙 시스템 입장
|
||||
|
||||
바코드 "A001" = ??? (알 수 없음)
|
||||
바코드 "B999" = ??? (알 수 없음)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 현재 데이터 구조 (2025-06-30 최종 확인)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ MSSQL (팜IT3000 - 약국 POS) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ CD_GOODS (제품 마스터) - 178,182개 │
|
||||
│ ├── DrugCode: LB000003157 (PK) │
|
||||
│ ├── GoodsName: "안텔민킹(5kg이상)" │
|
||||
│ └── 팜IT3000 전체 제품 DB │
|
||||
│ │ │
|
||||
│ ├────────────────┬─────────────────────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ CD_SALEGOODS CD_ITEM_UNIT_MEMBER CD_BARCODE│
|
||||
│ (대표 바코드 1개) (바코드 N개!) ★ (인체용) │
|
||||
│ 3,053개 ├ CD_CD_BARCODE 306,565개│
|
||||
│ BARCODE: │ 0230237810109 (APC!) 동물약X │
|
||||
│ 9990000001134 │ 9990000001134 (자체) │
|
||||
│ └ DRUGCODE → CD_GOODS.DrugCode │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
★ 핵심: 한 제품에 여러 바코드 가능! → CD_ITEM_UNIT_MEMBER
|
||||
★ APC 저장 위치: CD_ITEM_UNIT_MEMBER.CD_CD_BARCODE
|
||||
★ APC로 이미지 조회: https://ani.0bin.in/img/{APC}_F.jpg
|
||||
```
|
||||
│
|
||||
│ 매핑 필요
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PostgreSQL (애니팜 - 동물약 마스터) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ apc 테이블 │
|
||||
│ ├── apc: "0230237010107" (고유!) │
|
||||
│ ├── product_name: "대성 안텔민 사사 정 100mg/25mg/10정" │
|
||||
│ ├── company_name: "(주)대성미생물연구소" │
|
||||
│ ├── for_pets: true │
|
||||
│ ├── image_url1: "https://ani.0bin.in/img/..." │
|
||||
│ └── godoimage_url_f: "https://cdn.../..." │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 해결책: APC 기반 매핑
|
||||
|
||||
### 핵심 아이디어
|
||||
|
||||
**APC(Animal Product Code)를 고유 매핑 키로 사용**
|
||||
|
||||
```
|
||||
CD_GOODS.DrugCode ←→ CD_BARCODE.DRUGCODE ←→ APC(새로추가) ←→ PostgreSQL.apc
|
||||
```
|
||||
|
||||
### 왜 APC인가?
|
||||
|
||||
| 키 | 고유성 | 중앙관리 | 이미지 | 현황 |
|
||||
|----|--------|----------|--------|------|
|
||||
| 바코드 | ❌ 중복/없음 | ❌ 약국별 다름 | ❌ | 사용 불가 |
|
||||
| DrugCode | ⚠️ 약국내 고유 | ❌ 약국별 다름 | ❌ | 내부용 |
|
||||
| **APC** | ✅ 전국 고유 | ✅ 애니팜 관리 | ✅ | **사용 가능** |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 구현 계획
|
||||
|
||||
### Phase 1: 동물약 태깅 (완료 ✅)
|
||||
```
|
||||
CD_GOODS에서 POS_BOON='010103' 추출 → 38개 동물약 식별
|
||||
```
|
||||
|
||||
### Phase 2: AI 기반 APC 매핑
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ MSSQL 동물약 │ │ AI 분석 │ │ PostgreSQL │
|
||||
│ 38개 제품 │────►│ 제품명 매칭 │────►│ apc 후보 추천 │
|
||||
│ │ │ 성분/체중 분석 │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 관리자 확인 │
|
||||
│ 매핑 승인/수정 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
**AI 매칭 로직:**
|
||||
```python
|
||||
# MSSQL 제품
|
||||
mssql_product = "안텔민사사(5kg이하)"
|
||||
|
||||
# PostgreSQL 검색
|
||||
pgsql_candidates = search_apc("안텔민 사사")
|
||||
# → [
|
||||
# "대성 안텔민 사사 정 100mg/25mg/10정" (APC: 0230237010107),
|
||||
# "대성 안텔민 사사 정 100mg/25mg/50정" (APC: 0230237010205),
|
||||
# ...
|
||||
# ]
|
||||
|
||||
# AI 추천: 체중 범위, 포장단위 분석
|
||||
recommended_apc = "0230237010107" # 10정 (최소 판매단위)
|
||||
```
|
||||
|
||||
### Phase 3: APC 바코드 등록 방법
|
||||
|
||||
**옵션 A: CD_SALEGOODS.BARCODE 업데이트 (현재 구조 활용)**
|
||||
```sql
|
||||
-- CD_SALEGOODS에서 바코드를 APC로 변경
|
||||
UPDATE CD_SALEGOODS
|
||||
SET BARCODE = '0230237010107' -- APC 코드
|
||||
WHERE DrugCode = 'LB000003158'; -- 안텔민뽀삐
|
||||
```
|
||||
|
||||
또는 POS에서 직접:
|
||||
1. 제품 선택 → 바코드 수정 → APC 입력 → 저장
|
||||
|
||||
**옵션 B: 별도 매핑 테이블 (SQLite) - MSSQL 수정 최소화**
|
||||
```sql
|
||||
-- SQLite에 매핑 테이블 생성
|
||||
CREATE TABLE animal_drug_apc_mapping (
|
||||
id INTEGER PRIMARY KEY,
|
||||
mssql_drug_code TEXT NOT NULL, -- CD_GOODS.DrugCode
|
||||
mssql_barcode TEXT, -- CD_SALEGOODS.BARCODE (현재값)
|
||||
apc_code TEXT NOT NULL, -- PostgreSQL apc
|
||||
product_name TEXT, -- 확인용
|
||||
verified BOOLEAN DEFAULT 0, -- 관리자 검증 여부
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
**현재 동물약 바코드 현황:**
|
||||
| 제품 | DrugCode | CD_SALEGOODS.BARCODE |
|
||||
|------|----------|---------------------|
|
||||
| 안텔민뽀삐 | LB000003158 | 9990000001133 |
|
||||
| 안텔민킹 | LB000003157 | 9990000001134 |
|
||||
| 다이로하트S | LB000003150 | 9990000001131 |
|
||||
| 다이로하트M | LB000003151 | 9990000001132 |
|
||||
|
||||
### Phase 4: QR 라벨 출력 연동
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 프론트엔드: 제품 검색 → QR 라벨 출력 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. 제품 선택 (MSSQL) │
|
||||
│ └── DrugCode: LB000003158 │
|
||||
│ │
|
||||
│ 2. APC 매핑 확인 │
|
||||
│ └── APC: 0230237010107 (매핑됨 ✅) │
|
||||
│ │
|
||||
│ 3. QR 라벨 생성 │
|
||||
│ ├── QR 내용: APC 코드 │
|
||||
│ ├── 라벨 텍스트: 제품명 + 가격 │
|
||||
│ └── [인쇄] 버튼 활성화 │
|
||||
│ │
|
||||
│ ※ APC 미매핑 제품 → [인쇄] 버튼 비활성화 또는 경고 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Phase 5: 이미지 표시 연동
|
||||
|
||||
```
|
||||
챗봇 응답: "안텔민을 추천드려요"
|
||||
│
|
||||
├── MSSQL: "안텔민사사(5kg이하)" 재고 확인
|
||||
│
|
||||
├── APC 매핑: LB000003158 → 0230237010107
|
||||
│
|
||||
├── PostgreSQL: 이미지 URL 조회
|
||||
│ └── https://ani.0bin.in/img/0230237010107_F.jpg
|
||||
│
|
||||
└── 프론트: 제품 칩 + 이미지 썸네일 표시
|
||||
┌──────────────────────────┐
|
||||
│ 📦 안텔민사사 (5,000원) │
|
||||
│ [썸네일 이미지] │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 예상 데이터 흐름
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 전체 데이터 흐름 │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [약국 POS] │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ CD_GOODS ──────► CD_BARCODE (APC 추가) │
|
||||
│ │ │ │
|
||||
│ │ │ APC = "0230237010107" │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ 제품 판매 ◄──── QR 스캔 (APC 인식) │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ [챗봇/이미지] [애니팜 연동] │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ PostgreSQL ◄─────────────┘ │
|
||||
│ (이미지, 상세정보, 용법용량) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ TODO 체크리스트
|
||||
|
||||
### 1단계: 분석 (READ ONLY)
|
||||
- [x] MSSQL 동물약 38개 추출
|
||||
- [x] CD_BARCODE 구조 분석
|
||||
- [x] PostgreSQL apc 테이블 구조 분석
|
||||
- [x] 매핑 가능 제품 샘플 확인 (안텔민, 하트가드 등)
|
||||
- [ ] 전체 38개 제품 APC 후보 목록 생성
|
||||
|
||||
### 2단계: 기획
|
||||
- [x] 매핑 전략 수립 (APC 기반)
|
||||
- [ ] CD_BARCODE 활용 vs SQLite 매핑 테이블 결정
|
||||
- [ ] 관리자 매핑 UI 설계
|
||||
- [ ] QR 라벨 출력 연동 설계
|
||||
|
||||
### 3단계: 구현 (약사님 승인 후)
|
||||
- [ ] 매핑 테이블 생성
|
||||
- [ ] AI 매핑 추천 기능
|
||||
- [ ] 관리자 매핑 확인/수정 UI
|
||||
- [ ] QR 라벨 출력 (APC 기반)
|
||||
- [ ] 챗봇 이미지 연동
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. **MSSQL 수정 금지** (기획 단계)
|
||||
- READ ONLY 유지
|
||||
- 테스트도 SELECT만
|
||||
|
||||
2. **APC 신뢰성**
|
||||
- PostgreSQL apc 테이블이 마스터
|
||||
- 애니팜에서 관리하는 공식 코드
|
||||
|
||||
3. **약국별 차이**
|
||||
- 자체 바코드 사용 중인 약국 고려
|
||||
- 기존 워크플로우 방해하지 않도록
|
||||
|
||||
4. **단계적 적용**
|
||||
- 매핑 확인된 제품만 QR 출력 허용
|
||||
- 미매핑 제품은 기존 방식 유지
|
||||
|
||||
---
|
||||
|
||||
*작성일: 2025-06-30*
|
||||
*작성자: 용림 (Clawdbot)*
|
||||
*상태: 기획 중*
|
||||
433
docs/ARCHITECTURE.md
Normal file
433
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,433 @@
|
||||
# 🏗️ 약국 통합 솔루션 아키텍처
|
||||
|
||||
## 📋 개요
|
||||
|
||||
본 시스템은 **동물약 도매상(애니팜)**, **개별 약국 POS**, **마일리지 솔루션**을 통합하는 멀티 데이터베이스 아키텍처입니다.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 🏢 애니팜 (동물약 도매상) │
|
||||
│ PostgreSQL Database │
|
||||
│ 제품 마스터, 재고, 주문, 거래처 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ 제품 정보 / 발주
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 💊 개별 약국 (청춘약국 등) │
|
||||
│ ┌──────────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ MSSQL (팜IT3000) │ │ SQLite (솔루션) │ │
|
||||
│ │ - 제품 마스터 │ │ - 마일리지 │ │
|
||||
│ │ - 판매 내역 │◄──►│ - AI 추천 │ │
|
||||
│ │ - 조제 이력 │ │ - 알림톡 로그 │ │
|
||||
│ │ - 회원 정보 │ │ - 동물약 태그 │ │
|
||||
│ └──────────────────────┘ └──────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ API / 웹 인터페이스
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 🌐 Flask 웹 서버 (7001) │
|
||||
│ QR 적립 | AI 챗봇 | 관리자 | 회원 조회 | 알림톡 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ 외부 서비스
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 🔌 외부 API 연동 │
|
||||
│ - OpenAI GPT (동물약 챗봇, AI 업셀링) │
|
||||
│ - 카카오 OAuth (로그인) │
|
||||
│ - NHN Cloud 알림톡 │
|
||||
│ - Clawdbot Gateway (AI 에이전트) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 데이터베이스 구조
|
||||
|
||||
### 1️⃣ PostgreSQL (애니팜 - 동물약 도매상)
|
||||
|
||||
> **역할**: 동물약 도매 사업의 핵심 DB. 제품 마스터, 거래처(약국), 주문/발주 관리
|
||||
|
||||
| 테이블 | 설명 | 주요 컬럼 |
|
||||
|--------|------|-----------|
|
||||
| `products` | 제품 마스터 | id, name, barcode, price, category |
|
||||
| `customers` | 거래처 (약국) | id, pharmacy_name, owner, phone |
|
||||
| `orders` | 주문 내역 | id, customer_id, order_date, status |
|
||||
| `order_items` | 주문 상세 | order_id, product_id, qty, price |
|
||||
| `inventory` | 재고 현황 | product_id, stock_qty, location |
|
||||
|
||||
```sql
|
||||
-- 예시: 인기 동물약 TOP 10 조회
|
||||
SELECT p.name, SUM(oi.qty) as total_sold
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.created_at >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY p.name
|
||||
ORDER BY total_sold DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ MSSQL (팜IT3000 - 약국 POS)
|
||||
|
||||
> **역할**: 약국 청구/POS 프로그램의 DB. 제품, 판매, 조제, 회원 정보
|
||||
|
||||
#### 주요 데이터베이스
|
||||
| DB명 | 설명 |
|
||||
|------|------|
|
||||
| `PM_DRUG` | 제품 마스터 (의약품/건기식) |
|
||||
| `PM_PRES` | 판매/조제 내역 |
|
||||
| `PM_BASE` | 회원/거래처 기본 정보 |
|
||||
|
||||
#### 핵심 테이블
|
||||
|
||||
**PM_DRUG.dbo.CD_GOODS** - 제품 마스터
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `DrugCode` | 제품 코드 (PK) |
|
||||
| `GoodsName` | 제품명 |
|
||||
| `BARCODE` | 바코드 |
|
||||
| `Saleprice` | 판매가 |
|
||||
| `Price` | 원가 |
|
||||
| `POS_BOON` | 분류코드 (010103 = 동물약) |
|
||||
| `GoodsSelCode` | 판매상태 (B = 판매중) |
|
||||
|
||||
**PM_PRES.dbo.SALE_MAIN** - 판매 헤더
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `SL_NO_order` | 거래번호 (PK) |
|
||||
| `InsertTime` | 거래 일시 |
|
||||
| `SL_MY_total` | 총 금액 |
|
||||
| `SL_CD_custom` | 고객 코드 |
|
||||
|
||||
**PM_PRES.dbo.SALE_SUB** - 판매 상세
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `SL_NO_order` | 거래번호 (FK) |
|
||||
| `DrugCode` | 제품 코드 |
|
||||
| `SL_NM_item` | 수량 |
|
||||
| `SL_TOTAL_PRICE` | 금액 |
|
||||
|
||||
**PM_BASE.dbo.CD_PERSON** - 회원 정보
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `CUSCODE` | 고객 코드 (PK) |
|
||||
| `PANAME` | 이름 |
|
||||
| `PHONE` | 전화번호 |
|
||||
| `PANUM` | 주민번호 |
|
||||
|
||||
```sql
|
||||
-- 예시: 오늘 판매 내역 + 제품명 조회
|
||||
SELECT
|
||||
M.SL_NO_order AS 거래번호,
|
||||
M.InsertTime AS 거래일시,
|
||||
G.GoodsName AS 제품명,
|
||||
S.SL_NM_item AS 수량,
|
||||
S.SL_TOTAL_PRICE AS 금액
|
||||
FROM PM_PRES.dbo.SALE_MAIN M
|
||||
JOIN PM_PRES.dbo.SALE_SUB S ON M.SL_NO_order = S.SL_NO_order
|
||||
JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE CONVERT(DATE, M.InsertTime) = CONVERT(DATE, GETDATE())
|
||||
ORDER BY M.InsertTime DESC;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 예시: 동물약 목록 조회 (POS_BOON = '010103')
|
||||
SELECT DrugCode, GoodsName, Saleprice, BARCODE
|
||||
FROM PM_DRUG.dbo.CD_GOODS
|
||||
WHERE POS_BOON = '010103' AND GoodsSelCode = 'B'
|
||||
ORDER BY GoodsName;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ SQLite (마일리지 솔루션)
|
||||
|
||||
> **역할**: 약국별 마일리지 적립, AI 추천, 알림톡 로그 등 부가 기능
|
||||
|
||||
**경로**: `backend/db/mileage.db`
|
||||
|
||||
#### 핵심 테이블
|
||||
|
||||
**users** - 마일리지 회원
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | PK |
|
||||
| `nickname` | TEXT | 이름 |
|
||||
| `phone` | TEXT | 전화번호 (UNIQUE) |
|
||||
| `mileage_balance` | INTEGER | 포인트 잔액 |
|
||||
| `birthday` | TEXT | 생년월일 |
|
||||
| `created_at` | TIMESTAMP | 가입일 |
|
||||
|
||||
**claim_tokens** - QR 적립 토큰
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | PK |
|
||||
| `transaction_id` | TEXT | POS 거래번호 (UNIQUE) |
|
||||
| `token_hash` | TEXT | 토큰 해시 |
|
||||
| `total_amount` | REAL | 구매 금액 |
|
||||
| `claimable_points` | INTEGER | 적립 가능 포인트 |
|
||||
| `claimed_at` | TIMESTAMP | 적립 완료 시각 |
|
||||
| `claimed_by_user_id` | INTEGER | 적립한 회원 ID |
|
||||
|
||||
**mileage_ledger** - 포인트 원장
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | PK |
|
||||
| `user_id` | INTEGER | 회원 ID |
|
||||
| `transaction_id` | TEXT | 거래번호 |
|
||||
| `points` | INTEGER | 적립/차감 포인트 |
|
||||
| `balance_after` | INTEGER | 변동 후 잔액 |
|
||||
| `reason` | TEXT | CLAIM / USE / ADMIN |
|
||||
|
||||
**ai_recommendations** - AI 업셀링 추천
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | PK |
|
||||
| `user_id` | INTEGER | 회원 ID |
|
||||
| `recommended_product` | TEXT | 추천 제품 |
|
||||
| `recommendation_message` | TEXT | 추천 메시지 |
|
||||
| `status` | TEXT | active / interested / dismissed |
|
||||
|
||||
**drug_tags** - 동물약 태그 (별도 DB: `drug_tags.db`)
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `drug_code` | TEXT | 제품 코드 |
|
||||
| `drug_name` | TEXT | 제품명 |
|
||||
| `tag_type` | TEXT | animal_drug 등 |
|
||||
| `tag_value` | TEXT | all / dog / cat |
|
||||
|
||||
```sql
|
||||
-- 예시: 회원별 적립 내역 조회
|
||||
SELECT
|
||||
u.nickname, u.phone, u.mileage_balance,
|
||||
ml.points, ml.reason, ml.created_at
|
||||
FROM users u
|
||||
JOIN mileage_ledger ml ON u.id = ml.user_id
|
||||
WHERE u.phone = '01012345678'
|
||||
ORDER BY ml.created_at DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 데이터 흐름 예시
|
||||
|
||||
### 📱 시나리오 1: QR 마일리지 적립
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ POS 결제 │────►│ QR 발행 │────►│ 고객 스캔 │────►│ 적립 완료 │
|
||||
│ (MSSQL) │ │ (SQLite) │ │ (Flask) │ │ (SQLite) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
SALE_MAIN claim_tokens users 조회 mileage_ledger
|
||||
SALE_SUB 생성 & 저장 /생성 적립 기록
|
||||
```
|
||||
|
||||
**쿼리 흐름:**
|
||||
```sql
|
||||
-- 1. POS 판매 완료 시 (MSSQL)
|
||||
INSERT INTO SALE_MAIN (SL_NO_order, SL_MY_total, ...) VALUES (...)
|
||||
|
||||
-- 2. QR 토큰 생성 (SQLite)
|
||||
INSERT INTO claim_tokens (transaction_id, total_amount, claimable_points, ...)
|
||||
VALUES ('20260228001234', 50000, 1500, ...)
|
||||
|
||||
-- 3. 고객 QR 스캔 → 회원 조회/생성 (SQLite)
|
||||
SELECT * FROM users WHERE phone = '01012345678'
|
||||
-- 없으면:
|
||||
INSERT INTO users (nickname, phone, mileage_balance) VALUES ('홍길동', '01012345678', 0)
|
||||
|
||||
-- 4. 적립 처리 (SQLite)
|
||||
UPDATE users SET mileage_balance = mileage_balance + 1500 WHERE id = 1
|
||||
INSERT INTO mileage_ledger (user_id, transaction_id, points, balance_after, reason)
|
||||
VALUES (1, '20260228001234', 1500, 1500, 'CLAIM')
|
||||
|
||||
-- 5. 토큰 사용 완료 표시 (SQLite)
|
||||
UPDATE claim_tokens SET claimed_at = datetime('now'), claimed_by_user_id = 1
|
||||
WHERE transaction_id = '20260228001234'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🐾 시나리오 2: 동물약 AI 챗봇
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ 사용자 질문 │────►│ 동물약 조회 │────►│ OpenAI API │────►│ 응답 생성 │
|
||||
│ "구충제 추천" │ │ (MSSQL) │ │ (RAG) │ │ + 제품 매칭 │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │
|
||||
│ │
|
||||
▼ ▼
|
||||
CD_GOODS에서 지식 베이스 +
|
||||
동물약 38개 제품 목록 전달
|
||||
가격 포함 조회
|
||||
```
|
||||
|
||||
**쿼리 흐름:**
|
||||
```sql
|
||||
-- 1. 동물약 목록 조회 (MSSQL → RAG 컨텍스트)
|
||||
SELECT DrugCode, GoodsName, Saleprice, BARCODE
|
||||
FROM PM_DRUG.dbo.CD_GOODS
|
||||
WHERE POS_BOON = '010103' AND GoodsSelCode = 'B'
|
||||
ORDER BY GoodsName;
|
||||
-- 결과: 안텔민(5000원), 넥스가드L(84000원), ... 38개
|
||||
|
||||
-- 2. OpenAI API 호출 (Python)
|
||||
# System Prompt에 포함:
|
||||
# - 동물약 지식 (심장사상충, 구충제, 외부기생충 등)
|
||||
# - 현재 보유 제품 목록 + 가격
|
||||
|
||||
# User: "구충제 추천해줘"
|
||||
# AI 응답: "구충제로는 **안텔민**을 추천드려요! 프라지콴텔+피란텔 성분으로..."
|
||||
|
||||
-- 3. 응답에서 제품명 매칭 (Python)
|
||||
# AI 응답에 "안텔민" 포함 → 가격 5000원 표시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 👤 시나리오 3: 회원 상세 조회 (통합)
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ 전화번호 │────►│ DB 3곳 │────►│ 통합 응답 │
|
||||
│ 입력 │ │ 동시 조회 │ │ 반환 │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ SQLite │ │ MSSQL │ │ MSSQL │
|
||||
│ users │ │PM_BASE │ │PM_PRES │
|
||||
│마일리지 │ │회원정보 │ │조제이력 │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
**쿼리 흐름:**
|
||||
```sql
|
||||
-- 1. 마일리지 회원 조회 (SQLite)
|
||||
SELECT id, nickname, phone, mileage_balance, created_at
|
||||
FROM users WHERE phone = '01012345678'
|
||||
|
||||
-- 2. 적립 이력 조회 (SQLite)
|
||||
SELECT points, balance_after, reason, created_at, transaction_id
|
||||
FROM mileage_ledger WHERE user_id = 1
|
||||
ORDER BY created_at DESC LIMIT 50
|
||||
|
||||
-- 3. POS 고객 코드 조회 (MSSQL PM_BASE)
|
||||
SELECT CUSCODE, PANAME FROM CD_PERSON
|
||||
WHERE REPLACE(PHONE, '-', '') = '01012345678'
|
||||
|
||||
-- 4. 조제 이력 조회 (MSSQL PM_PRES)
|
||||
SELECT P.PreSerial, P.Indate, P.Drname, P.OrderName
|
||||
FROM PS_main P
|
||||
WHERE P.CusCode = 'C00001234'
|
||||
ORDER BY P.Indate DESC
|
||||
|
||||
-- 5. 구매 상세 조회 (MSSQL PM_PRES + PM_DRUG)
|
||||
SELECT G.GoodsName, S.SL_NM_item, S.SL_TOTAL_PRICE
|
||||
FROM SALE_SUB S
|
||||
JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE S.SL_NO_order = '20260228001234'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 기술 스택
|
||||
|
||||
| 계층 | 기술 | 용도 |
|
||||
|------|------|------|
|
||||
| **Frontend** | HTML/CSS/JS | 관리자 페이지, 키오스크, 마이페이지 |
|
||||
| **Backend** | Flask (Python) | REST API, 템플릿 렌더링 |
|
||||
| **Database** | PostgreSQL | 애니팜 (도매상) |
|
||||
| | MSSQL | 팜IT3000 (약국 POS) |
|
||||
| | SQLite | 마일리지 솔루션 |
|
||||
| **AI** | OpenAI GPT-4o-mini | 동물약 챗봇, 업셀링 추천 |
|
||||
| **인증** | 카카오 OAuth | 소셜 로그인 |
|
||||
| **알림** | NHN Cloud | 알림톡/SMS |
|
||||
| **프로세스** | PM2 | 서버 관리 |
|
||||
| **도메인** | Cloudflare | SSL, 프록시 |
|
||||
|
||||
---
|
||||
|
||||
## 📁 프로젝트 구조
|
||||
|
||||
```
|
||||
pharmacy-pos-qr-system/
|
||||
├── backend/
|
||||
│ ├── app.py # Flask 메인 앱
|
||||
│ ├── db/
|
||||
│ │ ├── dbsetup.py # DB 연결 관리
|
||||
│ │ ├── mileage.db # SQLite (마일리지)
|
||||
│ │ └── drug_tags.db # SQLite (동물약 태그)
|
||||
│ ├── templates/ # HTML 템플릿
|
||||
│ │ ├── admin.html
|
||||
│ │ ├── admin_products.html # 제품 검색 + AI 챗봇
|
||||
│ │ ├── admin_members.html
|
||||
│ │ ├── kiosk.html
|
||||
│ │ └── my_page.html
|
||||
│ ├── services/
|
||||
│ │ ├── kakao_client.py # 카카오 OAuth
|
||||
│ │ ├── nhn_alimtalk.py # 알림톡
|
||||
│ │ └── clawdbot_client.py # AI 에이전트
|
||||
│ ├── utils/
|
||||
│ │ └── qr_token_generator.py
|
||||
│ └── .env # 환경 변수
|
||||
├── docs/
|
||||
│ └── ARCHITECTURE.md # 이 문서
|
||||
├── logs/
|
||||
└── ecosystem.config.js # PM2 설정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 환경 변수 (.env)
|
||||
|
||||
```env
|
||||
# 카카오 OAuth
|
||||
KAKAO_CLIENT_ID=xxx
|
||||
KAKAO_CLIENT_SECRET=xxx
|
||||
KAKAO_REDIRECT_URI=https://mile.0bin.in/claim/kakao/callback
|
||||
|
||||
# OpenAI API
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
|
||||
# MSSQL 연결 (dbsetup.py에서 설정)
|
||||
# SQLite 경로 (backend/db/)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 주요 API 엔드포인트
|
||||
|
||||
| 경로 | 메서드 | 설명 | DB |
|
||||
|------|--------|------|-----|
|
||||
| `/api/products` | GET | 제품 검색 | MSSQL |
|
||||
| `/api/animal-chat` | POST | 동물약 AI 챗봇 | MSSQL + OpenAI |
|
||||
| `/api/animal-drugs` | GET | 동물약 목록 | MSSQL |
|
||||
| `/api/claim` | POST | 마일리지 적립 | SQLite |
|
||||
| `/api/members/search` | GET | 회원 검색 | MSSQL |
|
||||
| `/api/members/history/:phone` | GET | 회원 이력 통합 | 전체 |
|
||||
| `/admin/user/:id` | GET | 회원 상세 (적립+구매+조제) | 전체 |
|
||||
|
||||
---
|
||||
|
||||
## 📝 버전 이력
|
||||
|
||||
| 날짜 | 버전 | 변경 내용 |
|
||||
|------|------|----------|
|
||||
| 2026-02-28 | 1.0 | 초기 아키텍처 문서 작성 |
|
||||
| | | 동물약 AI 챗봇 추가 |
|
||||
| | | 플로팅 챗봇 UI 구현 |
|
||||
|
||||
---
|
||||
|
||||
*작성: Clawdbot AI | 청춘약국 통합 솔루션*
|
||||
309
docs/DATABASE_STRUCTURE.md
Normal file
309
docs/DATABASE_STRUCTURE.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# 데이터베이스 구조 (2025-06-30 정리)
|
||||
|
||||
## 개요
|
||||
|
||||
양구청춘약국 시스템은 3개의 데이터베이스를 사용합니다:
|
||||
|
||||
| DB | 용도 | 위치 |
|
||||
|----|------|------|
|
||||
| **MSSQL (PM_DRUG)** | POS 제품/재고/판매 | localhost (팜IT3000) |
|
||||
| **MSSQL (PM_PRES)** | 처방전/조제 | localhost (팜IT3000) |
|
||||
| **PostgreSQL** | 동물약 상세 정보 (RAG) | 192.168.0.87:5432 |
|
||||
| **SQLite** | 마일리지 시스템 | backend/db/mileage.db |
|
||||
|
||||
---
|
||||
|
||||
## MSSQL 테이블 구조 (PM_DRUG)
|
||||
|
||||
### 핵심 테이블 관계
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ CD_GOODS (제품 마스터) - 178,182개 │
|
||||
│ └── DrugCode (PK): LB000003157 │
|
||||
│ │ │
|
||||
│ ┌─────────┴─────────────┬──────────────────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ CD_SALEGOODS CD_ITEM_UNIT_MEMBER CD_BARCODE│
|
||||
│ (대표 바코드) (바코드 N개) ★ (인체용) │
|
||||
│ 3,053개 N:1 관계 306,565개│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### CD_GOODS (제품 마스터)
|
||||
|
||||
팜IT3000 전체 제품 DB. 약국이 개별 등록한 제품은 `LB`, `S`로 시작.
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| DrugCode | nvarchar | PK. `LB000003157` (약국등록), `050000010` (표준) |
|
||||
| GoodsName | nvarchar | 제품명 |
|
||||
| Saleprice | decimal | 판매가 |
|
||||
| BARCODE | nvarchar | (보통 비어있음 - CD_SALEGOODS 사용) |
|
||||
| POS_BOON | nvarchar | 분류코드. `010103` = 동물약 |
|
||||
| GoodsSelCode | nvarchar | `B` = 판매용 |
|
||||
|
||||
### CD_SALEGOODS (판매용 제품)
|
||||
|
||||
약국에서 실제 판매하는 제품. **대표 바코드 1개** 저장.
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| DrugCode | nvarchar | FK → CD_GOODS |
|
||||
| GoodsName | nvarchar | 제품명 |
|
||||
| BARCODE | nvarchar | **대표 바코드** (자체생성: `999000000xxxx`) |
|
||||
| SplCode | nvarchar | 공급처 코드 |
|
||||
| SplName | nvarchar | 공급처명 |
|
||||
|
||||
### CD_ITEM_UNIT_MEMBER (바코드 N개) ★
|
||||
|
||||
**한 제품에 여러 바코드** 저장. APC 코드는 여기에 저장됨!
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| DRUGCODE | nvarchar | FK → CD_GOODS.DrugCode |
|
||||
| CD_CD_BARCODE | nvarchar | **바코드** (APC: `0230237810109`) |
|
||||
| CD_CD_UNIT | nvarchar | 단위코드 (13, 015 등) |
|
||||
| CD_MY_UNIT | decimal | 판매가 |
|
||||
| CD_IN_UNIT | decimal | 입고가 |
|
||||
| CHANGE_DATE | nvarchar | 변경일 (YYYYMMDD) |
|
||||
| SN | bigint | 일련번호 |
|
||||
|
||||
### CD_BARCODE (인체용 표준)
|
||||
|
||||
식약처 인체용 의약품 표준 바코드. **동물약은 없음!**
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| DRUGCODE | nvarchar | 제품코드 |
|
||||
| BARCODE | nvarchar | 표준 바코드 |
|
||||
| BASECODE | nvarchar | 표준코드 |
|
||||
| ETCNAME | nvarchar | 제품명 |
|
||||
| CL_GUBUN | nvarchar | 구분 (전문의약품 등) |
|
||||
|
||||
---
|
||||
|
||||
## PostgreSQL 구조 (apdb_master)
|
||||
|
||||
동물약품 상세 정보. 농림축산검역본부 데이터 + LLM 가공.
|
||||
|
||||
### apc 테이블 (핵심)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| apc | varchar | **PK**. `0230237810109` |
|
||||
| product_name | varchar | 제품명 |
|
||||
| company_name | varchar | 제조사 |
|
||||
| main_ingredient | varchar | 주성분 |
|
||||
| efficacy_effect | text | 효능/효과 (HTML) |
|
||||
| dosage_instructions | text | 용법/용량 (HTML) |
|
||||
| precautions | text | 주의사항 (HTML) |
|
||||
| **llm_pharm** | jsonb | **LLM 가공 정보** ★ |
|
||||
| image_url1 | varchar | 앞면 이미지 |
|
||||
| image_url2 | varchar | 뒷면 이미지 |
|
||||
| weight_min_kg | float | 최소 체중 |
|
||||
| weight_max_kg | float | 최대 체중 |
|
||||
|
||||
### llm_pharm JSON 구조 (핵심!)
|
||||
|
||||
```json
|
||||
{
|
||||
"사용가능 동물": "개, 고양이",
|
||||
"분류": "내부구충제",
|
||||
"성분1": "메벤다졸",
|
||||
"성분2": "프라지콴텔",
|
||||
"체중/부위": "체중 5~9kg: 1정, 10~19kg: 2정...",
|
||||
"기간/용법": "1일 1회, 1~2일간 경구투여",
|
||||
"월령금기": "생후 1주 미만 사용 금지",
|
||||
"반려인주의": "사람이 복용 시 즉시 의사의 조치 필요",
|
||||
"앞이미지": "https://...",
|
||||
"뒤이미지": "https://..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 바코드 체계
|
||||
|
||||
| 패턴 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| `023xxxxxxxx` | **APC (동물약 표준)** | `0230237810109` |
|
||||
| `999000000xxxx` | 약국 자체 생성 | `9990000001134` |
|
||||
| `880xxxxxxxxx` | 일반 GS1 바코드 | `8809989000009` |
|
||||
|
||||
---
|
||||
|
||||
## 연결 예시
|
||||
|
||||
**안텔민킹(5kg이상) 조회:**
|
||||
|
||||
```sql
|
||||
-- MSSQL: 바코드 조회
|
||||
SELECT CD_CD_BARCODE
|
||||
FROM CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = 'LB000003157'
|
||||
AND CD_CD_BARCODE LIKE '023%';
|
||||
-- → 0230237810109
|
||||
|
||||
-- PostgreSQL: 상세 정보 조회
|
||||
SELECT llm_pharm->>'사용가능 동물', efficacy_effect
|
||||
FROM apc
|
||||
WHERE apc = '0230237810109';
|
||||
-- → 개, 고양이
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 이미지 URL 규칙
|
||||
|
||||
```
|
||||
https://ani.0bin.in/img/{APC}_F.jpg # 앞면
|
||||
https://ani.0bin.in/img/{APC}_B.jpg # 뒷면
|
||||
https://ani.0bin.in/img/{APC}_D.jpg # 상세
|
||||
```
|
||||
|
||||
예: `https://ani.0bin.in/img/0230237810109_F.jpg`
|
||||
|
||||
---
|
||||
|
||||
## APC 매핑 현황 (2025-06-30)
|
||||
|
||||
### 매핑 완료 (8개)
|
||||
|
||||
| 제품 | APC | 이미지 | 비고 |
|
||||
|------|-----|--------|------|
|
||||
| 안텔민킹(5kg이상) | 0230237810109 | ✅ | |
|
||||
| 안텔민뽀삐(5kg이하) | 0230237010107 | ✅ | |
|
||||
| (판)복합개시딘 | 0231093520106 | ✅ | |
|
||||
| 파라캅L(5kg이상) | 0230338510101 | ✅ | |
|
||||
| 파라캅S(5kg이하) | 0230347110106 | ✅ | |
|
||||
| 세레니아정16mg | 0231884610109 | ✅ | |
|
||||
| 세레니아정24mg | 0231884620107 | ✅ | |
|
||||
| 제스타제(10정) | 8809720800455 | ❌ | 바코드=APC |
|
||||
|
||||
### 바코드=APC 케이스
|
||||
|
||||
PostgreSQL에서 일부 제품은 APC 대신 **바코드**로 등록됨:
|
||||
|
||||
```
|
||||
제스타제:
|
||||
- 약국 바코드: 8809720800455
|
||||
- PostgreSQL apc: 8809720800455 (동일!)
|
||||
- RAG 데이터 있음 ✅
|
||||
- 이미지 URL: ❌ (023으로 시작 안 함)
|
||||
```
|
||||
|
||||
**시스템 처리 로직:**
|
||||
1. CD_ITEM_UNIT_MEMBER에서 `023%` APC 검색
|
||||
2. 없으면 기존 바코드를 APC로 사용
|
||||
3. PostgreSQL에서 해당 코드로 RAG 조회
|
||||
|
||||
### 매핑 대기 (주요 펫팜 공급)
|
||||
|
||||
| 제품 | 상태 |
|
||||
|------|------|
|
||||
| 가드닐 L/M/S | PostgreSQL 용량별 APC 없음 |
|
||||
| 다이로하트정 M/S/SS | 매칭 필요 |
|
||||
| 리펠로 M/S | 부모 APC만 있음 |
|
||||
| 셀라이트액 L/M/S/SS/XL | 매칭 필요 |
|
||||
| 캐치원 SS/S/M/L/캣 | PostgreSQL에 없음 |
|
||||
| 하트세이버 L/M/mini/S | 매칭 필요 |
|
||||
| 하트웜솔루션 M/S | 매칭 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 재고 시스템 (2025-06-30)
|
||||
|
||||
### 이중 재고 구조
|
||||
|
||||
| 위치 | 테이블 | 용도 | 조회 방식 |
|
||||
|------|--------|------|-----------|
|
||||
| **MSSQL (PM_DRUG)** | `IM_total` | 약국 재고 | `IM_QT_sale_debit` |
|
||||
| **PostgreSQL** | `inventory` | 도매상 재고 | `SUM(quantity)` |
|
||||
|
||||
### 약국 재고 (MSSQL)
|
||||
|
||||
```sql
|
||||
-- IM_total 테이블
|
||||
SELECT DrugCode, IM_QT_sale_debit as stock
|
||||
FROM IM_total
|
||||
WHERE DrugCode = 'LB000003157';
|
||||
-- → 8 (현재 약국 보유 수량)
|
||||
```
|
||||
|
||||
### 도매상 재고 (PostgreSQL)
|
||||
|
||||
도매상 재고는 **입출고 이력**으로 관리됩니다.
|
||||
|
||||
```sql
|
||||
-- inventory 테이블 (입출고 이력)
|
||||
-- quantity: +입고(INBOUND), -출고(OUTBOUND)
|
||||
|
||||
SELECT A.apc, A.product_name, SUM(I.quantity) as wholesaler_stock
|
||||
FROM inventory I
|
||||
JOIN apc A ON I.apdb_id = A.idx
|
||||
WHERE A.for_pets = true
|
||||
GROUP BY A.apc, A.product_name
|
||||
HAVING SUM(I.quantity) > 0;
|
||||
|
||||
-- 안텔민뽀삐: 38개
|
||||
-- 복합개시딘: 6개
|
||||
-- 세레니아16mg: 4개
|
||||
```
|
||||
|
||||
### inventory 테이블 주요 컬럼
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| apdb_id | integer | apc.idx FK |
|
||||
| quantity | integer | 수량 (+입고/-출고) |
|
||||
| transaction_type | varchar | INBOUND/OUTBOUND |
|
||||
| transaction_date | timestamp | 거래일시 |
|
||||
| wholesaler_price | numeric | 도매가 |
|
||||
| retail_price | numeric | 소매가 |
|
||||
| expiration_date | date | 유효기간 |
|
||||
|
||||
### API 응답 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "안텔민뽀삐(5kg이하)",
|
||||
"price": 5000,
|
||||
"stock": 8, // 약국 재고
|
||||
"wholesaler_stock": 38 // 도매상 재고
|
||||
}
|
||||
```
|
||||
|
||||
### 프론트엔드 표시
|
||||
|
||||
```
|
||||
┌────────────────────────────────┐
|
||||
│ 💊 안텔민뽀삐(5kg이하) │
|
||||
│ ₩5,000 약국 8 / 도매 38 │
|
||||
└────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **약국 재고 있음**: 초록색 `약국 8`
|
||||
- **약국 품절**: 빨간색 `품절`
|
||||
- **도매상 재고**: 파란색 `도매 38` (발주 가능)
|
||||
|
||||
---
|
||||
|
||||
## 향후 계획: 연관 제품 추천
|
||||
|
||||
약국에 없지만 도매상에 있는 제품 추천 로직:
|
||||
|
||||
1. **카테고리 기반**: 같은 efficacy_effect (심장사상충, 외부기생충 등)
|
||||
2. **신제품**: PostgreSQL `created_at` 최신순
|
||||
3. **인기 제품**: 도매상 출고량 기준 (`transaction_type = 'OUTBOUND'` 집계)
|
||||
|
||||
→ 클릭 시 발주 연결 (미구현)
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `backend/app.py`: `_get_animal_drugs()`, `_get_animal_drug_rag()`
|
||||
- `backend/scripts/insert_apc_*.py`: APC INSERT 스크립트
|
||||
- `backend/scripts/check_pgsql_stock_sum.py`: 도매상 재고 확인
|
||||
- `docs/APC_MAPPING_PLAN.md`: APC 매핑 기획
|
||||
167
docs/ENCODING_GUIDE.md
Normal file
167
docs/ENCODING_GUIDE.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# 🔤 인코딩 가이드 (필독!)
|
||||
|
||||
> ⚠️ **중요**: 한글 데이터 처리 시 반드시 이 가이드를 따를 것
|
||||
|
||||
---
|
||||
|
||||
## ✅ 현재 설정 (2025-06-30 적용됨)
|
||||
|
||||
```
|
||||
환경변수: PYTHONIOENCODING=utf-8 (User 레벨)
|
||||
```
|
||||
|
||||
이 설정으로 모든 Python 스크립트에서 UTF-8 출력이 기본 적용됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 📋 문제 상황
|
||||
|
||||
### 증상
|
||||
```
|
||||
DB 실제 값: "안텔민뽀삐"
|
||||
콘솔 출력: "안텔민사사" ← 깨져서 다른 글자로 보임!
|
||||
```
|
||||
|
||||
### 원인
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ DB (UTF-8) │ ──► │ Python │ ──► │ Windows 콘솔 │
|
||||
│ "뽀삐" │ │ stdout │ │ (CP949) │
|
||||
│ U+BF40 │ │ │ │ "사사" │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
↑
|
||||
인코딩 변환 실패!
|
||||
```
|
||||
|
||||
- Windows 콘솔 기본 인코딩: **CP949** (한국어 완성형)
|
||||
- CP949에서 지원하지 않거나 다르게 매핑되는 유니코드 문자 존재
|
||||
- "뽀삐" 같은 글자가 "사사"로 잘못 표시됨
|
||||
|
||||
---
|
||||
|
||||
## ✅ 해결책
|
||||
|
||||
### 1. 스크립트 상단에 인코딩 설정 추가 (필수!)
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
import io
|
||||
|
||||
# stdout을 UTF-8로 강제 설정
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
```
|
||||
|
||||
### 2. 환경변수 설정 (권장)
|
||||
|
||||
```powershell
|
||||
# PowerShell에서 실행 전 설정
|
||||
$env:PYTHONIOENCODING = "utf-8"
|
||||
|
||||
# 또는 시스템 환경변수로 영구 설정
|
||||
[Environment]::SetEnvironmentVariable("PYTHONIOENCODING", "utf-8", "User")
|
||||
```
|
||||
|
||||
### 3. Windows Terminal UTF-8 모드
|
||||
|
||||
```powershell
|
||||
# 콘솔 코드페이지를 UTF-8로 변경
|
||||
chcp 65001
|
||||
```
|
||||
|
||||
### 4. JSON 출력 사용 (가장 안전)
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
# 콘솔 출력 대신 JSON으로 반환
|
||||
result = {
|
||||
"product_name": "안텔민뽀삐",
|
||||
"apc": "0230237010107"
|
||||
}
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 스크립트 템플릿
|
||||
|
||||
모든 DB 조회 스크립트는 이 템플릿을 사용할 것:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
스크립트 설명
|
||||
"""
|
||||
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')
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 메인 로직
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def main():
|
||||
# ... 로직 ...
|
||||
|
||||
# 결과는 JSON으로 출력 (가장 안전)
|
||||
result = {"data": [...]}
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기존 스크립트 수정 목록
|
||||
|
||||
| 스크립트 | 상태 | 수정 필요 |
|
||||
|----------|------|-----------|
|
||||
| `scripts/query_mileage.py` | ⚠️ | 인코딩 설정 추가 |
|
||||
| `scripts/query_sales.py` | ⚠️ | 인코딩 설정 추가 |
|
||||
| `scripts/query_aniparm.py` | ⚠️ | 인코딩 설정 추가 |
|
||||
| `scripts/search_mssql.py` | ⚠️ | 인코딩 설정 추가 |
|
||||
| `scripts/check_*.py` | ⚠️ | 인코딩 설정 추가 |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 방법
|
||||
|
||||
```python
|
||||
# 인코딩 테스트 스크립트
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
import io
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
test_words = ["뽀삐", "킹", "안텔민뽀삐(5kg이하)", "다이로하트정M(12~22kg)"]
|
||||
|
||||
for word in test_words:
|
||||
print(f"원본: {word}")
|
||||
print(f"유니코드: {[f'U+{ord(c):04X}' for c in word]}")
|
||||
print()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. **절대 CP949 출력을 믿지 말 것** - 깨진 글자가 다른 글자로 보일 수 있음
|
||||
2. **DB 데이터 확인 시** - 직접 DB 툴로 확인하거나 JSON 출력 사용
|
||||
3. **AI 분석 시** - 유니코드 코드포인트로 확인 (U+XXXX)
|
||||
4. **매핑 작업 시** - 반드시 양쪽 DB 직접 확인 후 진행
|
||||
|
||||
---
|
||||
|
||||
*작성일: 2025-06-30*
|
||||
*사유: "뽀삐"가 "사사"로 잘못 표시되는 인코딩 문제 발생*
|
||||
238
docs/IMAGE_MAPPING_PLAN.md
Normal file
238
docs/IMAGE_MAPPING_PLAN.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# 🖼️ 동물약 이미지 매핑 계획
|
||||
|
||||
## 📋 목표
|
||||
챗봇에서 동물약 추천 시 **제품 이미지**를 함께 표시
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 데이터 현황
|
||||
|
||||
### MSSQL (약국 POS - PM_DRUG.CD_GOODS)
|
||||
|
||||
| 컬럼 | 설명 | 현황 |
|
||||
|------|------|------|
|
||||
| `DrugCode` | 제품코드 | ✅ 전체 있음 (예: LB000003151) |
|
||||
| `GoodsName` | 제품명 | ✅ 전체 있음 |
|
||||
| `BARCODE` | 바코드 | ⚠️ **14/38개만 있음 (37%)** |
|
||||
| `BaseCode` | 표준코드 | ❌ **0개** (사용 불가) |
|
||||
|
||||
### PostgreSQL (애니팜 - apc 테이블)
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `idx` | 고유 ID |
|
||||
| `apc` | APC 코드 (고유) |
|
||||
| `product_name` | 제품명 |
|
||||
| `image_url1` ~ `image_url3` | 이미지 URL |
|
||||
| `godoimage_url_f` | 고도몰 CDN - 앞 이미지 |
|
||||
| `godoimage_url_b` | 고도몰 CDN - 뒤 이미지 |
|
||||
| `godoimage_url_d` | 고도몰 CDN - 상세 이미지 |
|
||||
|
||||
**확인 필요**: `apc` 테이블에 바코드 컬럼이 있는지?
|
||||
|
||||
---
|
||||
|
||||
## 🔗 매핑 전략
|
||||
|
||||
### 옵션 1: 바코드 매핑 (37% 커버)
|
||||
```
|
||||
MSSQL.BARCODE ↔ PostgreSQL.barcode(?)
|
||||
```
|
||||
- 장점: 정확한 매칭
|
||||
- 단점: 14/38개만 매핑 가능
|
||||
|
||||
### 옵션 2: 제품명 유사도 매핑 (Fuzzy Matching)
|
||||
```python
|
||||
from fuzzywuzzy import fuzz
|
||||
|
||||
# MSSQL: "다이로하트정M(12~22kg)"
|
||||
# PostgreSQL: "다이로하트 정M 12~22kg" 등 유사 이름 매칭
|
||||
score = fuzz.partial_ratio(mssql_name, pgsql_name)
|
||||
if score > 80:
|
||||
matched = True
|
||||
```
|
||||
- 장점: 100% 커버 가능
|
||||
- 단점: 오매칭 위험
|
||||
|
||||
### 옵션 3: 매핑 테이블 생성 (권장) ✅
|
||||
```sql
|
||||
-- SQLite에 매핑 테이블 생성
|
||||
CREATE TABLE drug_image_mapping (
|
||||
id INTEGER PRIMARY KEY,
|
||||
mssql_drug_code TEXT UNIQUE, -- MSSQL DrugCode
|
||||
pgsql_apc TEXT, -- PostgreSQL apc 코드
|
||||
image_url TEXT, -- 확정된 이미지 URL
|
||||
verified BOOLEAN DEFAULT 0, -- 수동 검증 여부
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
**작업 흐름:**
|
||||
1. 바코드 매칭 (자동) → 14개 즉시 매핑
|
||||
2. 제품명 유사도로 후보 추천 → 관리자 확인
|
||||
3. 수동 매핑 → 나머지 제품
|
||||
|
||||
---
|
||||
|
||||
## 📊 MSSQL 동물약 바코드 현황 (38개)
|
||||
|
||||
### ✅ 바코드 있음 (14개)
|
||||
| 제품명 | DrugCode | 바코드 |
|
||||
|--------|----------|--------|
|
||||
| 가드L(20~40kg) | LB000003570 | 8801244508268 |
|
||||
| 가드M(10~20kg) | LB000003569 | 8801244508237 |
|
||||
| 가드S(2~10kg) | LB000003568 | 8801244508220 |
|
||||
| 하트가드정L(10~20kg) | LB000003564 | 8801244508343 |
|
||||
| 하트가드정M(5~10kg) | LB000003453 | 8801244508329 |
|
||||
| 하트가드정S(2.5~5kg) | LB000003452 | 8801244508312 |
|
||||
| 하트가드정SS(2.5kg이하) | LB000003451 | 8801244508305 |
|
||||
| 심파리카L(10~25kg) | LB000003634 | 8801244508534 |
|
||||
| 심파리카M(4~10kg) | LB000003635 | 8801244508435 |
|
||||
| 안텔민 | S0000001 | 8809989000009 |
|
||||
| 세레타정(10정) | LB000003146 | 8809720800455 |
|
||||
| 파라칸L(5kg이상) | LB000003159 | 8809625390914 |
|
||||
| 파라칸S(5kg이하) | LB000003160 | 8809625390655 |
|
||||
| 하트칸츄어블(11kg이하) | LB000003696 | 8809625390563 |
|
||||
|
||||
### ❌ 바코드 없음 (24개)
|
||||
| 제품명 | DrugCode |
|
||||
|--------|----------|
|
||||
| (동)클리어민50(100정) | LB000003504 |
|
||||
| 넥스가드L(15~30kg) | LB000003531 |
|
||||
| 넥스가드xs(2~3.5kg) | LB000003530 |
|
||||
| 다이로하트정M(12~22kg) | LB000003151 |
|
||||
| 다이로하트정S(5.6~11kg) | LB000003150 |
|
||||
| 다이로하트정SS(5.6kg이하) | LB000003149 |
|
||||
| 레보M(10~20kg) | LB000003161 |
|
||||
| 레보S(2~10kg) | LB000003162 |
|
||||
| 밀베마이신A정16mg(대동미어) | LB000003353 |
|
||||
| 밀베마이신A정24mg(대동미어) | LB000003354 |
|
||||
| 하트가드정XL(20~40kg) | LB000003545 |
|
||||
| 안텔민사사(5kg이하) | LB000003158 |
|
||||
| 안텔민킹(5kg이상) | LB000003157 |
|
||||
| 캐치펫캅(2.5~7.5kg)/고양이 | LB000003167 |
|
||||
| 캐치펫L(10~20kg)/개 | LB000003166 |
|
||||
| 캐치펫M(5~10kg)/개 | LB000003165 |
|
||||
| 캐치펫S(2.5~5kg)/개 | LB000003164 |
|
||||
| 캐치펫SS(2.5kg이하/개,고양이가능) | LB000003163 |
|
||||
| 하트플레이버블정L(23~45kg) | LB000003544 |
|
||||
| 하트플레이버블정M(12~22kg) | LB000003152 |
|
||||
| 하트플레이버블정mini(5.6kg이하) | LB000003154 |
|
||||
| 하트플레이버블정S(5.6~11kg) | LB000003153 |
|
||||
| 하트플라블러스정M(12~22kg) | LB000003155 |
|
||||
| 하트플라블러스정S(11kg이하) | LB000003156 |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 구현 단계
|
||||
|
||||
### Phase 1: PostgreSQL 조사
|
||||
- [ ] apc 테이블에 바코드 컬럼 확인
|
||||
- [ ] 이미지 URL 실제 데이터 샘플 확인
|
||||
- [ ] 동물약 제품 필터링 방법 확인 (`for_pets = true`?)
|
||||
|
||||
### Phase 2: 매핑 테이블 생성
|
||||
- [ ] SQLite에 `drug_image_mapping` 테이블 생성
|
||||
- [ ] 바코드 있는 14개 자동 매핑 시도
|
||||
- [ ] 관리자 페이지에 매핑 UI 추가
|
||||
|
||||
### Phase 3: 챗봇 연동
|
||||
- [ ] AI 응답에서 제품 매칭 시 이미지 URL 포함
|
||||
- [ ] 프론트엔드에 이미지 표시 (썸네일)
|
||||
- [ ] 클릭 시 큰 이미지 또는 상세 페이지
|
||||
|
||||
---
|
||||
|
||||
## 📝 다음 작업
|
||||
|
||||
1. **PostgreSQL apc 테이블 샘플 조회**
|
||||
```sql
|
||||
SELECT product_name, image_url1, godoimage_url_f
|
||||
FROM apc
|
||||
WHERE for_pets = true
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
2. **바코드 컬럼 존재 여부 확인**
|
||||
```sql
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'apc' AND column_name LIKE '%barcode%';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗃️ MSSQL 테이블 구조 상세
|
||||
|
||||
### CD_GOODS vs CD_BARCODE 관계
|
||||
|
||||
```
|
||||
CD_GOODS (제품 마스터) CD_BARCODE (바코드 마스터)
|
||||
├── DrugCode (PK) ──────────► DRUGCODE (FK)
|
||||
├── GoodsName ├── BARCODE (개별 바코드)
|
||||
├── BARCODE (대표 바코드) ├── TITLECODE (대표 바코드)
|
||||
├── BaseCode (❌ 비어있음) ├── BASECODE ✅ (100% 있음!)
|
||||
├── SUNG_CODE ├── SUNG_CODE (성분코드)
|
||||
└── Saleprice ├── DIK_CODE (의약품통합코드)
|
||||
├── ETCNAME (제품명)
|
||||
└── SPLNAME (제조사)
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
- `CD_GOODS.BaseCode`는 비어있음 (사용 안 함)
|
||||
- `CD_BARCODE.BASECODE`에 표준코드 100% 있음!
|
||||
- 1개 제품(DrugCode)에 여러 바코드 가능 (낱개, 박스 등)
|
||||
|
||||
### CD_BARCODE 매핑 키 통계
|
||||
|
||||
| 컬럼 | 보유율 | 설명 | 외부 매핑 |
|
||||
|------|--------|------|-----------|
|
||||
| `BARCODE` | 100% | 개별 바코드 | ⭐ PostgreSQL 매핑 가능 |
|
||||
| `BASECODE` | 100% | 표준코드 (식약처) | 인체용만 |
|
||||
| `TITLECODE` | 100% | 대표 바코드 | |
|
||||
| `DIK_CODE` | 68.2% | 의약품통합코드 | |
|
||||
| `SUNG_CODE` | 44.5% | 성분코드 | |
|
||||
|
||||
### 동물약 특이사항
|
||||
|
||||
```
|
||||
동물약 38개
|
||||
├── CD_GOODS에 있음 ✅ (POS_BOON = '010103')
|
||||
├── CD_BARCODE에 없음 ❌ (인체용 아님)
|
||||
├── DrugCode가 "LB"로 시작 (로컬/자체 등록)
|
||||
└── BASECODE 매핑 불가 → PostgreSQL(애니팜) 필요
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 PostgreSQL(애니팜) 매핑 전략
|
||||
|
||||
### 확인 필요 사항
|
||||
|
||||
PostgreSQL `apc` 테이블에서 확인할 컬럼:
|
||||
|
||||
```sql
|
||||
-- 바코드 관련 컬럼 확인
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'apc'
|
||||
AND column_name ILIKE '%barcode%';
|
||||
|
||||
-- 코드 관련 컬럼 확인
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'apc'
|
||||
AND (column_name ILIKE '%code%' OR column_name ILIKE '%apc%');
|
||||
```
|
||||
|
||||
### 예상 매핑 키
|
||||
|
||||
| MSSQL (CD_GOODS/BARCODE) | PostgreSQL (apc) | 매핑 방식 |
|
||||
|--------------------------|------------------|-----------|
|
||||
| `BARCODE` | `barcode`? | 직접 매핑 |
|
||||
| `GoodsName` | `product_name` | 유사도 매칭 |
|
||||
| `제조사` | `company_name` | 보조 키 |
|
||||
|
||||
---
|
||||
|
||||
*작성일: 2025-06-30*
|
||||
*업데이트: 2025-06-30 - CD_BARCODE 구조 분석 추가*
|
||||
*프로젝트: pharmacy-pos-qr-system*
|
||||
272
docs/MEMBER_MEMO_SYSTEM.md
Normal file
272
docs/MEMBER_MEMO_SYSTEM.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# 환자 메모/특이사항 시스템 설계 문서
|
||||
|
||||
## 📅 작성일: 2026-03-04
|
||||
|
||||
---
|
||||
|
||||
## 1. DB 접속 정보
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 서버 | `192.168.0.4\PM2014` |
|
||||
| 드라이버 | ODBC Driver 17 for SQL Server |
|
||||
| 인증 | Windows 인증 (Trusted_Connection) |
|
||||
| 데이터베이스 | PM_BASE (환자정보), PM_PRES (처방), PM_DRUG (약품) |
|
||||
|
||||
### 접속 코드 (pharmacy-pos-qr-system)
|
||||
```python
|
||||
from db.dbsetup import DatabaseManager
|
||||
db = DatabaseManager()
|
||||
session = db.get_session('PM_BASE')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 테이블 구조
|
||||
|
||||
### 2.1 CD_PERSON.CUSETC (특이참고사항)
|
||||
|
||||
**용도:** 단일 필드, 간단한 메모 (덮어쓰기 방식)
|
||||
|
||||
| 칼럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| CUSETC | VARCHAR(2000) | 특이/참고사항 텍스트 |
|
||||
|
||||
**특징:**
|
||||
- 한 환자당 하나의 값만 저장
|
||||
- 새로 입력하면 기존 값 덮어씀
|
||||
- 주로 미수금, 간단한 주의사항 등 기록
|
||||
|
||||
---
|
||||
|
||||
### 2.2 CD_PERSON_MEMO (메모 - 날짜별 누적)
|
||||
|
||||
**용도:** 별도 테이블, 상세 메모 이력 관리
|
||||
|
||||
| 칼럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| CUSCODE | VARCHAR(10) | 고객코드 (PK) |
|
||||
| MEMO_CODE | VARCHAR(5) | 메모코드 (PK) - 00001, 00002... |
|
||||
| PHARMA_ID | VARCHAR(10) | 작성자명 (약사 이름 직접 저장) |
|
||||
| MEMO_DATE | VARCHAR(8) | 작성일 (YYYYMMDD) |
|
||||
| MEMO_TITLE | VARCHAR(40) | 메모 제목 |
|
||||
| MEMO_Item | TEXT | 메모 내용 |
|
||||
|
||||
**특징:**
|
||||
- 한 환자당 여러 메모 가능 (날짜별 누적)
|
||||
- 복합 PK: CUSCODE + MEMO_CODE
|
||||
- 작성자/날짜 추적 가능
|
||||
|
||||
---
|
||||
|
||||
## 3. PHARMA_ID 분석
|
||||
|
||||
### 현재 저장된 값 (2026-03-04 기준)
|
||||
```
|
||||
[김영빈] - 2448건
|
||||
[박혜령] - 63건
|
||||
[이충섭] - 4건
|
||||
[시스템] - 2건
|
||||
[이수지] - 1건
|
||||
[지민구] - 1건
|
||||
[PHARM001] - 1건
|
||||
```
|
||||
|
||||
### 결론
|
||||
- **직접 이름 저장 방식** (마스터 테이블 조인 불필요)
|
||||
- 대부분 한글 이름, 일부 코드 형태 존재
|
||||
- 별도 약사 마스터 테이블 연결 없이 독립적으로 저장
|
||||
|
||||
---
|
||||
|
||||
## 4. 실제 데이터 예시
|
||||
|
||||
### 예시 1: 김미성 (0000014615)
|
||||
```
|
||||
[특이사항 - CD_PERSON.CUSETC]
|
||||
25/1 미수금:200
|
||||
|
||||
[메모 - CD_PERSON_MEMO]
|
||||
메모코드: 00001
|
||||
작성자: 김영빈
|
||||
날짜: 20260304
|
||||
제목: (없음)
|
||||
내용: 신장투석.이식 가족력
|
||||
```
|
||||
|
||||
### 예시 2: 박상호 (0000024142)
|
||||
```
|
||||
[특이사항 - CD_PERSON.CUSETC]
|
||||
25/1 미수금:1400
|
||||
|
||||
[메모 - CD_PERSON_MEMO]
|
||||
메모코드: 00003
|
||||
작성자: 김영빈
|
||||
날짜: 20260303
|
||||
제목: 가루약
|
||||
내용: 사미온만 아침,저녁
|
||||
나머지 저녁으로
|
||||
카나브는 알약으로 포장
|
||||
```
|
||||
|
||||
### 예시 3: 안동옥 (0000001030)
|
||||
```
|
||||
[특이사항 - CD_PERSON.CUSETC]
|
||||
25/1 미수금:200
|
||||
|
||||
[메모 - CD_PERSON_MEMO]
|
||||
메모코드: 00001
|
||||
작성자: 김영빈
|
||||
날짜: 20260224
|
||||
제목: (없음)
|
||||
내용: 26.2.23-에터미 해모임과 고지혀약 피타로우에프 먹은지 한달 만에
|
||||
간수피가 20대에서 120대로 수치가 오름.
|
||||
고덱스 처방과 약 끊고 변화 확인 요망(010-6209-0796)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 기존 API 현황
|
||||
|
||||
### GET /api/members/search?q={검색어}
|
||||
- 회원 검색 (이름 2자 이상, 전화번호)
|
||||
- 응답에 `memo` (CUSETC 100자 미리보기) 포함
|
||||
|
||||
### GET /api/members/{cuscode}
|
||||
- 회원 상세 조회
|
||||
- `member.memo`: CUSETC 전체
|
||||
- `memos[]`: CD_PERSON_MEMO 배열 (author, date, title, content)
|
||||
|
||||
---
|
||||
|
||||
## 6. 구현 계획
|
||||
|
||||
### 6.1 약사/직원 테이블 (신규 - SQLite)
|
||||
|
||||
**위치:** `backend/db/pharmacy_staff.db`
|
||||
|
||||
```sql
|
||||
CREATE TABLE staff (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(20) NOT NULL, -- 이름 (PHARMA_ID에 저장될 값)
|
||||
role VARCHAR(20), -- 역할 (약사, 직원 등)
|
||||
is_active BOOLEAN DEFAULT 1, -- 활성 여부
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 초기 데이터
|
||||
INSERT INTO staff (name, role) VALUES ('김영빈', '약사');
|
||||
INSERT INTO staff (name, role) VALUES ('박혜령', '약사');
|
||||
```
|
||||
|
||||
**용도:**
|
||||
- 메모 작성 시 드롭다운 목록 제공
|
||||
- 향후 로그인 시스템 확장 대비
|
||||
- 기본 작성자 설정 가능
|
||||
|
||||
### 6.2 UI 구현 방향
|
||||
|
||||
```
|
||||
[회원 상세 페이지]
|
||||
├── 기본 정보 (이름, 전화번호, 주민번호 등)
|
||||
├── 특이(참고)사항
|
||||
│ └── [단일 텍스트 영역] - 저장 시 덮어쓰기
|
||||
└── 메모 (날짜별 누적)
|
||||
├── [메모 목록] - 날짜순 정렬
|
||||
│ └── 각 메모: 날짜, 작성자, 제목, 내용 미리보기
|
||||
├── [새 메모 추가]
|
||||
│ ├── 작성자 드롭다운 (staff 테이블에서)
|
||||
│ ├── 제목 입력
|
||||
│ └── 내용 입력
|
||||
└── [메모 수정/삭제]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 현재 구현 현황 (2026-03-04)
|
||||
|
||||
### 7.1 특이(참고)사항 - 구현 완료 ✅
|
||||
|
||||
**위치:** https://mile.0bin.in/admin → 사용자 클릭 → 상세 모달
|
||||
|
||||
#### UI
|
||||
```
|
||||
┌──────────────────┬──────────────────┐
|
||||
│ 🎂 생일 │ ⚠️ 특이사항 [✏️ 수정]│
|
||||
│ 07월 12일 │ 개발약사2 │
|
||||
└──────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
- 생일 옆 칸에 표시 (공간 효율적 활용)
|
||||
- 30자 초과 시 truncate, 클릭하면 펼침
|
||||
- [✏️ 수정] 버튼 → 인라인 textarea → [저장] / [취소]
|
||||
|
||||
#### API
|
||||
|
||||
**조회:** `GET /admin/user/{userId}`
|
||||
```json
|
||||
{
|
||||
"pos_customer": {
|
||||
"cuscode": "0000000004",
|
||||
"name": "김영빈",
|
||||
"cusetc": "개발약사2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**수정:** `PUT /api/members/{cuscode}/cusetc`
|
||||
```json
|
||||
// Request
|
||||
{ "cusetc": "새로운 특이사항" }
|
||||
|
||||
// Response
|
||||
{
|
||||
"success": true,
|
||||
"message": "특이사항이 저장되었습니다.",
|
||||
"cusetc": "새로운 특이사항"
|
||||
}
|
||||
```
|
||||
|
||||
#### E2E 테스트 완료
|
||||
```bash
|
||||
# 검색
|
||||
GET /api/members/search?q=김영빈 → cuscode: 0000000004
|
||||
|
||||
# 수정
|
||||
PUT /api/members/0000000004/cusetc
|
||||
Body: { "cusetc": "개발약사2 - 테스트 수정" }
|
||||
→ 성공 ✅
|
||||
|
||||
# 확인
|
||||
GET /api/members/search?q=김영빈
|
||||
→ memo: "개발약사2 - 테스트 수정" ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.2 메모 (날짜별 누적) - 미구현 ⏳
|
||||
|
||||
**다음 단계:**
|
||||
1. staff 테이블 생성 (SQLite)
|
||||
2. 메모 CRUD API 구현
|
||||
3. UI 구현 (메모 목록, 추가, 수정, 삭제)
|
||||
|
||||
---
|
||||
|
||||
## 8. 관련 파일
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `backend/app.py` | Flask API (3740행~ 회원 관련, CUSETC 수정 API 포함) |
|
||||
| `backend/db/dbsetup.py` | DB 연결 설정 |
|
||||
| `backend/templates/admin.html` | 어드민 대시보드 (사용자 상세 모달, 특이사항 UI) |
|
||||
| `backend/templates/admin_members.html` | 회원 관리 페이지 |
|
||||
| `person-lookup-web-local/models.py` | SQLAlchemy 모델 정의 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 참고사항
|
||||
|
||||
- PIT3000 원본 테이블은 직접 수정 (INSERT/UPDATE)
|
||||
- 마일리지 시스템(SQLite)과 별개로 MSSQL에 저장
|
||||
- CD_PERSON_MEMO는 복합키(CUSCODE + MEMO_CODE) 주의
|
||||
359
docs/PAAI-SYSTEM.md
Normal file
359
docs/PAAI-SYSTEM.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# PAAI (Pharmacist Assistant AI) 시스템
|
||||
|
||||
> 약사를 위한 AI 기반 처방 분석 및 복약지도 보조 시스템
|
||||
|
||||
## 📋 목차
|
||||
1. [시스템 개요](#시스템-개요)
|
||||
2. [구현 현황](#구현-현황)
|
||||
3. [아키텍처](#아키텍처)
|
||||
4. [데이터베이스](#데이터베이스)
|
||||
5. [API 엔드포인트](#api-엔드포인트)
|
||||
6. [어드민 페이지](#어드민-페이지)
|
||||
7. [향후 계획](#향후-계획)
|
||||
|
||||
---
|
||||
|
||||
## 시스템 개요
|
||||
|
||||
### 목적
|
||||
- 처방전 분석 시 KIMS 약물 상호작용 자동 확인
|
||||
- AI 기반 복약지도 포인트 추천
|
||||
- OTC 구매 이력 기반 맞춤 상담 제안
|
||||
- 처방 변화 감지 및 분석
|
||||
|
||||
### 핵심 기능
|
||||
1. **KIMS 상호작용 조회** - 처방 약품 간 상호작용 자동 체크
|
||||
2. **AI 분석** - Clawdbot(Claude) 기반 처방 인사이트 생성
|
||||
3. **처방 비교** - 이전 처방과 현재 처방 변화 분석
|
||||
4. **OTC 연계** - 환자 OTC 구매 이력 기반 추천
|
||||
|
||||
---
|
||||
|
||||
## 구현 현황
|
||||
|
||||
### ✅ 완료된 기능
|
||||
|
||||
#### PMR (조제관리) 페이지
|
||||
- [x] 환자 목록 / 처방 상세 조회
|
||||
- [x] 이전 처방 비교 모드 (추가/변경/중단/동일 표시)
|
||||
- [x] OTC 구매 이력 모달
|
||||
- [x] PAAI 분석 버튼
|
||||
|
||||
#### PAAI 분석 기능
|
||||
- [x] KIMS API 연동 (약물 상호작용 조회)
|
||||
- [x] Clawdbot Gateway 연동 (AI 분석)
|
||||
- [x] 비동기 토스트 알림 (다른 환자 보면서도 알림 수신)
|
||||
- [x] 분석 결과 캐싱 (환자별)
|
||||
- [x] 피드백 수집 (유용/비유용)
|
||||
|
||||
#### 토스트 알림 시스템
|
||||
- [x] 우상단 오버레이 토스트
|
||||
- [x] A환자 분석 중 → B환자 조회 가능
|
||||
- [x] 토스트 클릭 시 해당 환자 결과 모달
|
||||
|
||||
### 🚧 진행 중
|
||||
|
||||
#### 어드민 페이지
|
||||
- [ ] 피드백 통계 대시보드
|
||||
- [ ] 분석 이력 검색
|
||||
- [ ] KIMS 호출 로그
|
||||
- [ ] AI 요청/응답 로그
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PMR 페이지 (pmr.html) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ 환자 목록 │ │ 처방 상세 │ │ PAAI 토스트/모달 │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Flask API (pmr_api.py) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ /pmr/api/ │ │ /pmr/api/ │ │ /pmr/api/paai/ │ │
|
||||
│ │ prescriptions│ │ patient/ │ │ analyze, feedback │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ PIT3000 DB │ │ KIMS API │ │ Clawdbot Gateway│
|
||||
│ (MSSQL) │ │ (상호작용) │ │ (Claude AI) │
|
||||
│ │ │ │ │ │
|
||||
│ - PM_PRES │ │ - 약품 검색 │ │ - WebSocket │
|
||||
│ - PM_DRUG │ │ - 상호작용 조회 │ │ - 세션 관리 │
|
||||
│ - PM_CUS │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ SQLite DB │
|
||||
│ (paai_logs) │
|
||||
│ │
|
||||
│ - 분석 로그 │
|
||||
│ - 피드백 │
|
||||
│ - KIMS 로그 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
### paai_logs 테이블 (SQLite)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS paai_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 환자/처방 정보
|
||||
pre_serial TEXT, -- 처방전 번호
|
||||
cus_code TEXT, -- 환자 코드
|
||||
patient_name TEXT, -- 환자명
|
||||
|
||||
-- 질병 정보
|
||||
disease_codes TEXT, -- JSON: ["M750", "K299"]
|
||||
disease_names TEXT, -- JSON: ["어깨 유착성 관절낭염", "위십이지장염"]
|
||||
|
||||
-- 처방 정보
|
||||
medication_count INTEGER, -- 약품 수
|
||||
medications_json TEXT, -- JSON: 전체 약품 리스트
|
||||
|
||||
-- KIMS 결과
|
||||
kims_called BOOLEAN, -- KIMS 호출 여부
|
||||
kims_request_json TEXT, -- KIMS 요청 데이터
|
||||
kims_response_json TEXT, -- KIMS 응답 원본
|
||||
kims_interaction_count INTEGER, -- 상호작용 건수
|
||||
kims_has_severe BOOLEAN, -- 중증 상호작용 여부
|
||||
kims_duration_ms INTEGER, -- KIMS 응답 시간
|
||||
|
||||
-- AI 분석 결과
|
||||
ai_called BOOLEAN, -- AI 호출 여부
|
||||
ai_prompt_json TEXT, -- AI에게 전달한 프롬프트
|
||||
ai_response_json TEXT, -- AI 응답 원본
|
||||
ai_parsed_json TEXT, -- 파싱된 분석 결과
|
||||
ai_duration_ms INTEGER, -- AI 응답 시간
|
||||
ai_model TEXT, -- 사용 모델 (claude-opus-4-5 등)
|
||||
|
||||
-- 피드백
|
||||
feedback_useful BOOLEAN, -- 유용했는지
|
||||
feedback_at TIMESTAMP, -- 피드백 시간
|
||||
feedback_comment TEXT, -- 추가 코멘트 (향후)
|
||||
|
||||
-- 메타
|
||||
total_duration_ms INTEGER, -- 전체 처리 시간
|
||||
error_message TEXT, -- 에러 발생 시
|
||||
client_ip TEXT -- 요청 IP
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_paai_created ON paai_logs(created_at);
|
||||
CREATE INDEX idx_paai_patient ON paai_logs(cus_code);
|
||||
CREATE INDEX idx_paai_feedback ON paai_logs(feedback_useful);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### PAAI 분석
|
||||
|
||||
#### POST `/pmr/api/paai/analyze`
|
||||
|
||||
**요청:**
|
||||
```json
|
||||
{
|
||||
"pre_serial": "20260305001",
|
||||
"cus_code": "C00123",
|
||||
"patient_name": "김미성",
|
||||
"disease_info": {
|
||||
"code_1": "M750",
|
||||
"name_1": "어깨의 유착성 관절낭염",
|
||||
"code_2": "K299",
|
||||
"name_2": "상세불명의 위십이지장염"
|
||||
},
|
||||
"current_medications": [
|
||||
{"code": "641500020", "name": "아세탑정", "dosage": "1", "frequency": "2", "days": "5"}
|
||||
],
|
||||
"previous_medications": [],
|
||||
"otc_history": {
|
||||
"visit_count": 5,
|
||||
"frequent_items": [{"name": "신신파스", "count": 3}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"log_id": 42,
|
||||
"kims_summary": {
|
||||
"interaction_count": 2,
|
||||
"has_severe": false,
|
||||
"interactions": [...]
|
||||
},
|
||||
"analysis": {
|
||||
"prescription_insight": "소염진통제와 위장약 병용 처방...",
|
||||
"kims_analysis": "아세클로페낙과 레바미피드 병용은...",
|
||||
"cautions": ["식후 30분 복용", "위장장애 주의"],
|
||||
"otc_recommendations": [
|
||||
{"product": "신신파스", "reason": "근골격계 통증 보조"}
|
||||
],
|
||||
"counseling_points": ["충분한 수분 섭취", "알코올 자제"]
|
||||
},
|
||||
"timing": {
|
||||
"kims_ms": 234,
|
||||
"ai_ms": 2891,
|
||||
"total_ms": 3125
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 피드백
|
||||
|
||||
#### POST `/pmr/api/paai/feedback`
|
||||
|
||||
**요청:**
|
||||
```json
|
||||
{
|
||||
"log_id": 42,
|
||||
"useful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 어드민 페이지
|
||||
|
||||
### 📊 대시보드 (`/pmr/admin`)
|
||||
|
||||
#### 1. 개요 통계
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 📊 PAAI 어드민 대시보드 [날짜 선택] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 127 │ │ 89% │ │ 15건 │ │ 2.3초 │ │
|
||||
│ │ 총 분석 │ │ 유용 평가│ │KIMS 경고 │ │ 평균응답 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 2. 피드백 통계
|
||||
- 일별/주별/월별 유용/비유용 비율 차트
|
||||
- 비유용 피드백 많은 케이스 분석
|
||||
|
||||
#### 3. 분석 이력 검색
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 🔍 분석 이력 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 환자명: [_________] 기간: [____] ~ [____] [검색] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ # │ 일시 │ 환자 │ 약품수│ KIMS │ 피드백│ 상세 │
|
||||
│ ───┼────────────┼─────────┼───────┼──────┼───────┼─────── │
|
||||
│ 1 │ 03-05 14:32│ 김미성 │ 4 │ 2건 │ 👍 │ [보기] │
|
||||
│ 2 │ 03-05 14:28│ 박철수 │ 6 │ 0건 │ 👎 │ [보기] │
|
||||
│ 3 │ 03-05 14:15│ 이영희 │ 3 │ 1건 │ - │ [보기] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 4. 상세 로그 보기 (모달)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 📋 분석 상세 - 김미성님 (2026-03-05 14:32) [닫기] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ▼ 환자/처방 정보 │
|
||||
│ 처방번호: 20260305001 │
|
||||
│ 질병: [M750] 어깨 유착성 관절낭염, [K299] 위십이지장염 │
|
||||
│ 약품: 아세탑정, 에페솔정, 레바미피드정, 브로나제정 │
|
||||
│ │
|
||||
│ ▼ KIMS 호출 (234ms) │
|
||||
│ 요청: {"medications": ["641500020", "645678901", ...]} │
|
||||
│ 응답: {"interactions": [...], "count": 2} │
|
||||
│ │
|
||||
│ ▼ AI 프롬프트 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 당신은 약사를 보조하는 AI입니다... │ │
|
||||
│ │ ## 환자 질병 │ │
|
||||
│ │ [M750] 어깨의 유착성 관절낭염... │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ▼ AI 응답 (2891ms) │
|
||||
│ 모델: claude-opus-4-5 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ { │ │
|
||||
│ │ "prescription_insight": "소염진통제와...", │ │
|
||||
│ │ "kims_analysis": "아세클로페낙과...", │ │
|
||||
│ │ "cautions": ["식후 30분 복용", ...], │ │
|
||||
│ │ ... │ │
|
||||
│ │ } │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ▼ 피드백 │
|
||||
│ 평가: 👍 유용해요 │
|
||||
│ 시간: 2026-03-05 14:35:21 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 5. KIMS 호출 로그
|
||||
- 일별 KIMS API 호출 횟수
|
||||
- 상호작용 감지율
|
||||
- 중증 경고 발생 케이스
|
||||
|
||||
#### 6. AI 성능 모니터링
|
||||
- 평균 응답 시간 추이
|
||||
- 에러율
|
||||
- 모델별 사용량
|
||||
|
||||
---
|
||||
|
||||
## 향후 계획
|
||||
|
||||
### Phase 1 (현재)
|
||||
- [x] 기본 PAAI 분석 기능
|
||||
- [x] 비동기 토스트 알림
|
||||
- [ ] 어드민 페이지 기본
|
||||
|
||||
### Phase 2
|
||||
- [ ] 피드백 기반 프롬프트 개선
|
||||
- [ ] 자주 나오는 상담 포인트 학습
|
||||
- [ ] 약국별 맞춤 설정
|
||||
|
||||
### Phase 3
|
||||
- [ ] 다중 약국 지원
|
||||
- [ ] 분석 결과 PDF 출력
|
||||
- [ ] 환자용 복약지도 문자 발송 연동
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
backend/
|
||||
├── pmr_api.py # Flask API 서버
|
||||
├── services/
|
||||
│ ├── kims_service.py # KIMS API 연동
|
||||
│ └── clawdbot_client.py # Clawdbot Gateway 연동
|
||||
├── templates/
|
||||
│ ├── pmr.html # 조제관리 페이지
|
||||
│ └── pmr_admin.html # 어드민 페이지 (예정)
|
||||
└── db/
|
||||
└── paai_logs.db # PAAI 로그 SQLite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*최종 업데이트: 2026-03-05*
|
||||
74
docs/TROUBLESHOOTING-CAMERA-UPLOAD.md
Normal file
74
docs/TROUBLESHOOTING-CAMERA-UPLOAD.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 트러블슈팅: 카메라 촬영 이미지 업로드 실패
|
||||
|
||||
## 📅 발생일: 2026-03-04
|
||||
|
||||
## 🔴 증상
|
||||
- 제품 이미지 관리 페이지에서 "촬영" 기능으로 이미지 교체 시 저장 실패
|
||||
- 에러 메시지: `NoneType object has no attribute 'strip'`
|
||||
- API 호출은 성공하나 이미지 데이터가 `null`로 전송됨
|
||||
|
||||
## 🔍 원인 분석
|
||||
|
||||
### 1차 원인: None 값 처리 누락 (백엔드)
|
||||
```python
|
||||
# 문제 코드
|
||||
image_data = data.get('image_data', '').strip() # None이면 에러
|
||||
|
||||
# 수정 코드
|
||||
image_data = (data.get('image_data') or '').strip()
|
||||
```
|
||||
|
||||
### 2차 원인: 변수 리셋 타이밍 문제 (프론트엔드) ⭐ 핵심
|
||||
```javascript
|
||||
// 문제 코드
|
||||
async function submitCapture() {
|
||||
const barcode = replaceTargetBarcode;
|
||||
const productName = replaceTargetName;
|
||||
closeReplaceModal(); // ← 여기서 capturedImageData = null 로 리셋됨!
|
||||
|
||||
// API 호출 시 capturedImageData가 이미 null
|
||||
body: JSON.stringify({
|
||||
image_data: capturedImageData, // null!
|
||||
...
|
||||
})
|
||||
}
|
||||
|
||||
// 수정 코드
|
||||
async function submitCapture() {
|
||||
const barcode = replaceTargetBarcode;
|
||||
const productName = replaceTargetName;
|
||||
const imageData = capturedImageData; // ← 미리 복사!
|
||||
|
||||
closeReplaceModal();
|
||||
|
||||
body: JSON.stringify({
|
||||
image_data: imageData, // 복사된 값 사용
|
||||
...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 3차 원인: 브라우저 캐시
|
||||
- 코드 수정 후에도 브라우저가 이전 JS를 캐시
|
||||
- **Ctrl+Shift+R** (강력 새로고침) 필요
|
||||
|
||||
## ✅ 해결 방법
|
||||
|
||||
1. **백엔드**: `None` 값에 대한 방어 코드 추가
|
||||
2. **프론트엔드**: `closeReplaceModal()` 호출 전에 필요한 변수들을 로컬 변수로 복사
|
||||
3. **테스트 시**: 강력 새로고침 (Ctrl+Shift+R) 또는 시크릿 모드 사용
|
||||
|
||||
## 📝 교훈
|
||||
|
||||
1. **모달 닫기 함수에서 상태 리셋 주의**
|
||||
- 모달을 닫으면서 관련 변수를 초기화하는 경우, async 함수에서 순서에 주의
|
||||
|
||||
2. **프론트엔드 디버깅 시 브라우저 캐시 확인**
|
||||
- 코드 수정 후에도 동작이 같다면 캐시 문제 의심
|
||||
|
||||
3. **API 직접 테스트로 문제 범위 좁히기**
|
||||
- `requests` 또는 `curl`로 API만 테스트하면 프론트/백엔드 문제 구분 가능
|
||||
|
||||
## 🔧 관련 파일
|
||||
- `backend/app.py` - `/api/admin/product-images/<barcode>/upload` 엔드포인트
|
||||
- `backend/templates/admin_product_images.html` - 카메라 촬영 UI 및 JS
|
||||
@@ -556,3 +556,7 @@ SELECT * FROM v_il1beta_increasing_foods;
|
||||
4. **효과 추적**: 3개월 후 재검사 결과 비교
|
||||
|
||||
---
|
||||
|
||||
**작성자**: Claude Sonnet 4.5
|
||||
**버전**: 1.0
|
||||
**최종 수정**: 2026-02-04
|
||||
|
||||
109
docs/troubleshooting/2026-03-02_이미지교체_바코드전달오류.md
Normal file
109
docs/troubleshooting/2026-03-02_이미지교체_바코드전달오류.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# 트러블슈팅: 이미지 교체 시 바코드 전달 오류
|
||||
|
||||
**날짜:** 2026-03-02
|
||||
**해결 상태:** ✅ 해결됨
|
||||
|
||||
---
|
||||
|
||||
## 증상
|
||||
|
||||
제품 이미지 관리 페이지에서 "교체" 버튼을 눌러 이미지 URL을 입력하면:
|
||||
- 기존 제품의 이미지가 교체되지 않음
|
||||
- 대신 새로운 레코드가 생성됨
|
||||
- 새 레코드의 barcode, product_name이 `null`로 저장됨
|
||||
|
||||
## 원인
|
||||
|
||||
### 1차 원인: JavaScript 템플릿 리터럴에서 escapeHtml 문제
|
||||
|
||||
```javascript
|
||||
// 문제 코드
|
||||
onclick="openReplaceModal('${item.barcode}', '${escapeHtml(item.product_name)}')"
|
||||
```
|
||||
|
||||
제품명에 특수문자(따옴표 등)가 포함되면 `escapeHtml` 함수가 문자열을 변환하면서 onclick 속성이 깨짐.
|
||||
|
||||
### 2차 원인: 전역 변수 참조 문제
|
||||
|
||||
`replaceTargetBarcode` 변수가 모달 제출 시 `undefined` 또는 `"null"` 문자열로 전달됨.
|
||||
|
||||
---
|
||||
|
||||
## 해결 방법
|
||||
|
||||
### 1. data 속성으로 바코드 전달 (HTML)
|
||||
|
||||
```javascript
|
||||
// 수정 후
|
||||
<button class="btn btn-primary btn-sm"
|
||||
data-barcode="${item.barcode}"
|
||||
data-name="${item.product_name || ''}"
|
||||
onclick="openReplaceModal(this.dataset.barcode, this.dataset.name)">교체</button>
|
||||
```
|
||||
|
||||
`data-*` 속성을 사용하여 값을 안전하게 저장하고, `this.dataset`으로 접근.
|
||||
|
||||
### 2. 바코드 유효성 검증 추가 (JavaScript)
|
||||
|
||||
```javascript
|
||||
function openReplaceModal(barcode, productName) {
|
||||
console.log('openReplaceModal called with:', barcode, productName);
|
||||
|
||||
// 바코드 검증
|
||||
if (!barcode || barcode === 'null' || barcode === 'undefined') {
|
||||
showToast('바코드 정보가 없습니다', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
replaceTargetBarcode = barcode;
|
||||
// ...
|
||||
}
|
||||
|
||||
async function submitReplace() {
|
||||
// 바코드 검증
|
||||
if (!replaceTargetBarcode || replaceTargetBarcode === 'null' || replaceTargetBarcode === 'undefined') {
|
||||
showToast('바코드 정보가 없습니다. 다시 시도해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const barcode = replaceTargetBarcode; // 로컬 변수에 복사
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 백엔드 UPDATE 쿼리 수정 (Python)
|
||||
|
||||
```python
|
||||
# 기존 레코드 확인 후 이미지만 업데이트 (product_name, drug_code 유지)
|
||||
cursor.execute("SELECT product_name, drug_code FROM product_images WHERE barcode = ?", (barcode,))
|
||||
existing = cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
# 기존 레코드 있으면 이미지만 업데이트
|
||||
cursor.execute("""
|
||||
UPDATE product_images
|
||||
SET image_base64 = ?, thumbnail_base64 = ?, image_url = ?,
|
||||
status = 'manual', error_message = NULL, updated_at = datetime('now')
|
||||
WHERE barcode = ?
|
||||
""", (image_base64, thumbnail_base64, image_url, barcode))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 커밋
|
||||
|
||||
- `4a06e60` - feat: 이미지 교체 기능 추가
|
||||
- `4a3ec38` - fix: 이미지 교체 시 바코드 전달 오류 수정
|
||||
- `65754f5` - fix: 이미지 교체 시 바코드 검증 강화
|
||||
|
||||
---
|
||||
|
||||
## 교훈
|
||||
|
||||
1. **onclick에 동적 값 전달 시 주의**: 템플릿 리터럴 + escapeHtml 조합은 예상치 못한 오류 발생 가능. `data-*` 속성 사용 권장.
|
||||
|
||||
2. **전역 변수 의존 최소화**: 모달 등 비동기 흐름에서 전역 변수 사용 시 값이 바뀔 수 있음. 로컬 변수에 복사 후 사용.
|
||||
|
||||
3. **입력 검증은 프론트/백 양쪽에서**: null, undefined, "null" 문자열 등 예외 케이스 모두 검증.
|
||||
|
||||
4. **디버깅 로그 활용**: `console.log`로 실제 전달되는 값 확인하면 원인 파악이 빠름.
|
||||
56
package-lock.json
generated
Normal file
56
package-lock.json
generated
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "pharmacy-pos-qr-system",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"playwright": "^1.58.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"playwright": "^1.58.2"
|
||||
}
|
||||
}
|
||||
39
test_print_cusetc.js
Normal file
39
test_print_cusetc.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
|
||||
// API 응답 캡처
|
||||
let apiResponse = null;
|
||||
page.on('response', async (response) => {
|
||||
if (response.url().includes('/api/print/cusetc')) {
|
||||
apiResponse = await response.json();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:7001/admin');
|
||||
console.log('✅ 관리자 페이지');
|
||||
|
||||
await page.click('td:has-text("김영빈")');
|
||||
await page.waitForSelector('text=특이사항', { timeout: 5000 });
|
||||
console.log('✅ 모달 열림');
|
||||
|
||||
// 인쇄 버튼 클릭
|
||||
const printBtn = await page.$('button[onclick*="doPrintCusetc"]');
|
||||
if (printBtn) {
|
||||
await printBtn.click();
|
||||
console.log('✅ 인쇄 버튼 클릭');
|
||||
|
||||
// 즉시 토스트 확인 (API 응답 전)
|
||||
await page.waitForTimeout(100);
|
||||
const toast = await page.$eval('.toast', el => el?.textContent).catch(() => null);
|
||||
console.log('📢 즉시 피드백:', toast || '(토스트 없음)');
|
||||
|
||||
// API 응답 대기
|
||||
await page.waitForTimeout(2000);
|
||||
console.log('📡 API 응답:', apiResponse?.success ? '✅ ' + apiResponse.message : '❌ ' + (apiResponse?.error || 'no response'));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
Reference in New Issue
Block a user