Compare commits
70 Commits
1e904000c7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e499e19342 | |||
|
|
68ad59285a | ||
|
|
d106db64f3 | ||
|
|
197ded3806 | ||
|
|
431909e50b | ||
|
|
8c127cfb95 | ||
|
|
8c366cc4db | ||
|
|
3fc9bbaf8e | ||
|
|
c33d857fa6 | ||
|
|
d0e7d6bbd2 | ||
|
|
04b0f3a8ca | ||
|
|
159386942e | ||
|
|
3467cacd2f | ||
|
|
a3a0bc8868 | ||
|
|
bd30ece284 | ||
|
|
94a8df6653 | ||
|
|
4691d65c14 | ||
|
|
866d10fd92 | ||
|
|
1414bb1432 | ||
|
|
87a56d0f6c | ||
|
|
76da7d9cd1 | ||
|
|
870e40a6db | ||
|
|
d44aed16be | ||
|
|
a1640f55f8 | ||
|
|
753df2c13c | ||
|
|
79369d9a56 | ||
|
|
02e56b9413 | ||
|
|
8c3bcb525d | ||
|
|
7843ca8fcf | ||
|
|
a7e96e5efa | ||
|
|
625012f5ee | ||
|
|
c4ab865c93 | ||
|
|
6e23dc8b20 | ||
|
|
705696a7fb | ||
|
|
9bd2174501 | ||
|
|
f3fa4707ac | ||
|
|
1b78704ca6 | ||
|
|
2a090c9704 | ||
|
|
ccb0067a1c | ||
|
|
da51f4bfd1 | ||
|
|
db5f6063ec | ||
|
|
4c3e1d08b2 | ||
|
|
a2829436d1 | ||
|
|
3e3934e2e5 | ||
|
|
5042cffb9f | ||
|
|
b5a99f7b3b | ||
|
|
a3ff69b67f | ||
|
|
0c52542713 | ||
|
|
ac59464612 | ||
|
|
e4ccfd60c9 | ||
|
|
2625430ca5 | ||
|
|
e7c529c22c | ||
|
|
cb927d2207 | ||
|
|
22cbf3d42e | ||
|
|
a4410f5fe0 | ||
|
|
f80c19567a | ||
|
|
a30374cd4a | ||
|
|
d868a494c2 | ||
|
|
f969756caa | ||
|
|
2b3d8649ba | ||
|
|
c4fa655005 | ||
|
|
ed2a3f28bf | ||
|
|
62502c81b3 | ||
|
|
d1a5964bb7 | ||
|
|
62632cb7b8 | ||
|
|
eb44701410 | ||
|
|
31cf6e3816 | ||
|
|
82220a4a44 | ||
| 774c199c1a | |||
| 37821fefdb |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -86,3 +86,6 @@ docker-compose.override.yml
|
||||
tmp/
|
||||
*.tmp
|
||||
.claude/
|
||||
|
||||
# GUI settings (user-specific)
|
||||
gui_settings.json
|
||||
|
||||
2433
backend/app.py
2433
backend/app.py
File diff suppressed because it is too large
Load Diff
7
backend/config.json
Normal file
7
backend/config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"pos_printer": {
|
||||
"ip": "192.168.0.174",
|
||||
"port": 9100,
|
||||
"name": "메인 POS"
|
||||
}
|
||||
}
|
||||
335
backend/db/age_food_graph.py
Normal file
335
backend/db/age_food_graph.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
Apache AGE 그래프 생성: Food + Biomarker 노드 및 관계
|
||||
|
||||
목적: PostgreSQL 테이블 데이터를 Apache AGE 그래프로 변환
|
||||
작성일: 2026-02-04
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# UTF-8 인코딩 강제
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
|
||||
class AGEFoodGraphBuilder:
|
||||
"""Apache AGE 그래프 빌더"""
|
||||
|
||||
def __init__(self, db_config):
|
||||
"""
|
||||
Args:
|
||||
db_config: PostgreSQL 연결 설정
|
||||
"""
|
||||
self.db_config = db_config
|
||||
self.conn = None
|
||||
self.cursor = None
|
||||
self.graph_name = 'pharmacy_graph'
|
||||
|
||||
def connect(self):
|
||||
"""PostgreSQL 연결"""
|
||||
try:
|
||||
self.conn = psycopg2.connect(**self.db_config)
|
||||
self.cursor = self.conn.cursor(cursor_factory=RealDictCursor)
|
||||
print("✅ PostgreSQL 연결 성공")
|
||||
|
||||
# AGE 확장 로드
|
||||
self.cursor.execute("LOAD 'age';")
|
||||
self.cursor.execute("SET search_path = ag_catalog, '$user', public;")
|
||||
|
||||
# 그래프 생성 (이미 있으면 무시)
|
||||
try:
|
||||
self.cursor.execute(f"SELECT create_graph('{self.graph_name}');")
|
||||
self.conn.commit()
|
||||
print(f"✅ 그래프 '{self.graph_name}' 생성 완료")
|
||||
except psycopg2.Error as e:
|
||||
if 'already exists' in str(e):
|
||||
print(f"ℹ️ 그래프 '{self.graph_name}' 이미 존재")
|
||||
self.conn.rollback()
|
||||
else:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ PostgreSQL 연결 실패: {e}")
|
||||
raise
|
||||
|
||||
def create_food_nodes(self):
|
||||
"""Food 노드 생성"""
|
||||
print("\n📦 Food 노드 생성 중...")
|
||||
|
||||
try:
|
||||
# SQL 테이블에서 식품 데이터 조회
|
||||
self.cursor.execute("""
|
||||
SELECT food_id, food_name, food_name_en, category, subcategory, description
|
||||
FROM foods
|
||||
""")
|
||||
foods = self.cursor.fetchall()
|
||||
|
||||
for food in foods:
|
||||
# Cypher 쿼리로 노드 생성
|
||||
query = f"""
|
||||
SELECT * FROM cypher('{self.graph_name}', $$
|
||||
MERGE (f:Food {{
|
||||
food_id: {food['food_id']},
|
||||
name: '{food['food_name']}',
|
||||
name_en: '{food['food_name_en'] or ''}',
|
||||
category: '{food['category']}',
|
||||
subcategory: '{food['subcategory'] or ''}',
|
||||
description: '{food['description'] or ''}'
|
||||
}})
|
||||
RETURN f
|
||||
$$) AS (result agtype);
|
||||
"""
|
||||
self.cursor.execute(query)
|
||||
|
||||
self.conn.commit()
|
||||
print(f"✅ Food 노드 {len(foods)}개 생성 완료")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Food 노드 생성 실패: {e}")
|
||||
self.conn.rollback()
|
||||
raise
|
||||
|
||||
def create_biomarker_nodes(self):
|
||||
"""Biomarker 노드 생성"""
|
||||
print("\n📦 Biomarker 노드 생성 중...")
|
||||
|
||||
try:
|
||||
# SQL 테이블에서 바이오마커 데이터 조회
|
||||
self.cursor.execute("""
|
||||
SELECT biomarker_id, biomarker_name, biomarker_type,
|
||||
normal_range_min, normal_range_max, unit, description
|
||||
FROM biomarkers
|
||||
""")
|
||||
biomarkers = self.cursor.fetchall()
|
||||
|
||||
for bm in biomarkers:
|
||||
query = f"""
|
||||
SELECT * FROM cypher('{self.graph_name}', $$
|
||||
MERGE (b:Biomarker {{
|
||||
biomarker_id: {bm['biomarker_id']},
|
||||
name: '{bm['biomarker_name']}',
|
||||
type: '{bm['biomarker_type']}',
|
||||
normal_min: {bm['normal_range_min'] or 0},
|
||||
normal_max: {bm['normal_range_max'] or 0},
|
||||
unit: '{bm['unit'] or ''}',
|
||||
description: '{bm['description'] or ''}'
|
||||
}})
|
||||
RETURN b
|
||||
$$) AS (result agtype);
|
||||
"""
|
||||
self.cursor.execute(query)
|
||||
|
||||
self.conn.commit()
|
||||
print(f"✅ Biomarker 노드 {len(biomarkers)}개 생성 완료")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Biomarker 노드 생성 실패: {e}")
|
||||
self.conn.rollback()
|
||||
raise
|
||||
|
||||
def create_food_biomarker_relationships(self):
|
||||
"""Food → Biomarker 관계 생성"""
|
||||
print("\n🔗 Food → Biomarker 관계 생성 중...")
|
||||
|
||||
try:
|
||||
# SQL 테이블에서 관계 데이터 조회
|
||||
self.cursor.execute("""
|
||||
SELECT
|
||||
f.food_id, f.food_name,
|
||||
b.biomarker_id, b.biomarker_name,
|
||||
fbe.effect_type, fbe.magnitude, fbe.percent_change,
|
||||
fbe.mechanism, fbe.evidence_pmid, fbe.study_type, fbe.reliability
|
||||
FROM food_biomarker_effects fbe
|
||||
JOIN foods f ON fbe.food_id = f.food_id
|
||||
JOIN biomarkers b ON fbe.biomarker_id = b.biomarker_id
|
||||
""")
|
||||
effects = self.cursor.fetchall()
|
||||
|
||||
for effect in effects:
|
||||
# 관계 타입 결정
|
||||
if effect['effect_type'] == 'increases':
|
||||
rel_type = 'INCREASES'
|
||||
elif effect['effect_type'] == 'decreases':
|
||||
rel_type = 'DECREASES'
|
||||
else:
|
||||
rel_type = 'AFFECTS'
|
||||
|
||||
# Cypher 쿼리로 관계 생성
|
||||
query = f"""
|
||||
SELECT * FROM cypher('{self.graph_name}', $$
|
||||
MATCH (f:Food {{food_id: {effect['food_id']}}})
|
||||
MATCH (b:Biomarker {{biomarker_id: {effect['biomarker_id']}}})
|
||||
MERGE (f)-[r:{rel_type} {{
|
||||
magnitude: '{effect['magnitude'] or 'unknown'}',
|
||||
percent_change: {effect['percent_change'] or 0},
|
||||
mechanism: '{effect['mechanism'] or ''}',
|
||||
evidence_pmid: '{effect['evidence_pmid'] or ''}',
|
||||
study_type: '{effect['study_type'] or ''}',
|
||||
reliability: {effect['reliability'] or 0.5}
|
||||
}}]->(b)
|
||||
RETURN r
|
||||
$$) AS (result agtype);
|
||||
"""
|
||||
self.cursor.execute(query)
|
||||
|
||||
self.conn.commit()
|
||||
print(f"✅ Food-Biomarker 관계 {len(effects)}개 생성 완료")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 관계 생성 실패: {e}")
|
||||
self.conn.rollback()
|
||||
raise
|
||||
|
||||
def create_disease_nodes(self):
|
||||
"""Disease 노드 생성 (질병-바이오마커 연결용)"""
|
||||
print("\n📦 Disease 노드 생성 중...")
|
||||
|
||||
try:
|
||||
# SQL 테이블에서 질병 데이터 조회
|
||||
self.cursor.execute("""
|
||||
SELECT DISTINCT disease_icd_code, disease_name
|
||||
FROM disease_biomarker_association
|
||||
""")
|
||||
diseases = self.cursor.fetchall()
|
||||
|
||||
for disease in diseases:
|
||||
query = f"""
|
||||
SELECT * FROM cypher('{self.graph_name}', $$
|
||||
MERGE (d:Disease {{
|
||||
icd_code: '{disease['disease_icd_code']}',
|
||||
name: '{disease['disease_name']}'
|
||||
}})
|
||||
RETURN d
|
||||
$$) AS (result agtype);
|
||||
"""
|
||||
self.cursor.execute(query)
|
||||
|
||||
self.conn.commit()
|
||||
print(f"✅ Disease 노드 {len(diseases)}개 생성 완료")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Disease 노드 생성 실패: {e}")
|
||||
self.conn.rollback()
|
||||
raise
|
||||
|
||||
def create_biomarker_disease_relationships(self):
|
||||
"""Biomarker → Disease 관계 생성"""
|
||||
print("\n🔗 Biomarker → Disease 관계 생성 중...")
|
||||
|
||||
try:
|
||||
self.cursor.execute("""
|
||||
SELECT
|
||||
b.biomarker_id, b.biomarker_name,
|
||||
dba.disease_icd_code, dba.disease_name,
|
||||
dba.association_strength, dba.threshold_value,
|
||||
dba.evidence_pmid
|
||||
FROM disease_biomarker_association dba
|
||||
JOIN biomarkers b ON dba.biomarker_id = b.biomarker_id
|
||||
""")
|
||||
associations = self.cursor.fetchall()
|
||||
|
||||
for assoc in associations:
|
||||
query = f"""
|
||||
SELECT * FROM cypher('{self.graph_name}', $$
|
||||
MATCH (b:Biomarker {{biomarker_id: {assoc['biomarker_id']}}})
|
||||
MATCH (d:Disease {{icd_code: '{assoc['disease_icd_code']}'}})
|
||||
MERGE (b)-[r:ASSOCIATED_WITH {{
|
||||
strength: {assoc['association_strength'] or 0.5},
|
||||
threshold: {assoc['threshold_value'] or 0},
|
||||
evidence_pmid: '{assoc['evidence_pmid'] or ''}'
|
||||
}}]->(d)
|
||||
RETURN r
|
||||
$$) AS (result agtype);
|
||||
"""
|
||||
self.cursor.execute(query)
|
||||
|
||||
self.conn.commit()
|
||||
print(f"✅ Biomarker-Disease 관계 {len(associations)}개 생성 완료")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 관계 생성 실패: {e}")
|
||||
self.conn.rollback()
|
||||
raise
|
||||
|
||||
def verify_graph(self):
|
||||
"""그래프 검증"""
|
||||
print("\n🔍 그래프 검증 중...")
|
||||
|
||||
try:
|
||||
# 노드 개수 확인
|
||||
queries = {
|
||||
'Food': f"SELECT * FROM cypher('{self.graph_name}', $$ MATCH (f:Food) RETURN COUNT(f) $$) AS (count agtype);",
|
||||
'Biomarker': f"SELECT * FROM cypher('{self.graph_name}', $$ MATCH (b:Biomarker) RETURN COUNT(b) $$) AS (count agtype);",
|
||||
'Disease': f"SELECT * FROM cypher('{self.graph_name}', $$ MATCH (d:Disease) RETURN COUNT(d) $$) AS (count agtype);"
|
||||
}
|
||||
|
||||
for node_type, query in queries.items():
|
||||
self.cursor.execute(query)
|
||||
result = self.cursor.fetchone()
|
||||
count = result['count'] if result else 0
|
||||
print(f" {node_type} 노드: {count}개")
|
||||
|
||||
# 관계 개수 확인
|
||||
rel_query = f"SELECT * FROM cypher('{self.graph_name}', $$ MATCH ()-[r]->() RETURN COUNT(r) $$) AS (count agtype);"
|
||||
self.cursor.execute(rel_query)
|
||||
rel_result = self.cursor.fetchone()
|
||||
rel_count = rel_result['count'] if rel_result else 0
|
||||
print(f" 관계: {rel_count}개")
|
||||
|
||||
print("✅ 그래프 검증 완료")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 그래프 검증 실패: {e}")
|
||||
|
||||
def build(self):
|
||||
"""전체 그래프 빌드"""
|
||||
print("\n" + "=" * 60)
|
||||
print("Apache AGE 그래프 빌드 시작")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
self.connect()
|
||||
self.create_food_nodes()
|
||||
self.create_biomarker_nodes()
|
||||
self.create_disease_nodes()
|
||||
self.create_food_biomarker_relationships()
|
||||
self.create_biomarker_disease_relationships()
|
||||
self.verify_graph()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 그래프 빌드 완료!")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 그래프 빌드 실패: {e}")
|
||||
raise
|
||||
finally:
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
print("\n🔌 PostgreSQL 연결 종료")
|
||||
|
||||
|
||||
def main():
|
||||
"""메인 실행"""
|
||||
|
||||
# PostgreSQL 연결 설정 (환경에 맞게 수정)
|
||||
db_config = {
|
||||
'host': 'localhost',
|
||||
'database': 'pharmacy_db',
|
||||
'user': 'postgres',
|
||||
'password': 'your_password_here', # 실제 비밀번호로 변경
|
||||
'port': 5432
|
||||
}
|
||||
|
||||
builder = AGEFoodGraphBuilder(db_config)
|
||||
builder.build()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -185,37 +185,61 @@ class DatabaseManager:
|
||||
# 새 세션 생성
|
||||
return self.get_session(database)
|
||||
|
||||
def get_sqlite_connection(self):
|
||||
def get_sqlite_connection(self, new_connection=False):
|
||||
"""
|
||||
SQLite mileage.db 연결 반환 (싱글톤 패턴)
|
||||
최초 호출 시 스키마 자동 초기화
|
||||
SQLite mileage.db 연결 반환
|
||||
|
||||
Args:
|
||||
new_connection: True면 항상 새 연결 생성 (멀티스레드 안전)
|
||||
|
||||
Returns:
|
||||
sqlite3.Connection: SQLite 연결 객체
|
||||
"""
|
||||
# 새 연결 요청 시 항상 새로 생성
|
||||
if new_connection:
|
||||
return self._create_sqlite_connection()
|
||||
|
||||
# 기존 싱글톤 방식 (하위 호환)
|
||||
if self.sqlite_conn is not None:
|
||||
try:
|
||||
cursor = self.sqlite_conn.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
cursor.close()
|
||||
except Exception as e:
|
||||
print(f"[DB Manager] SQLite 연결 체크 실패, 재연결: {e}")
|
||||
try:
|
||||
self.sqlite_conn.close()
|
||||
except:
|
||||
pass
|
||||
self.sqlite_conn = None
|
||||
|
||||
if self.sqlite_conn is None:
|
||||
# 파일 존재 여부 확인
|
||||
is_new_db = not self.sqlite_db_path.exists()
|
||||
|
||||
# 연결 생성
|
||||
self.sqlite_conn = sqlite3.connect(
|
||||
str(self.sqlite_db_path),
|
||||
check_same_thread=False, # 멀티스레드 허용
|
||||
timeout=10.0 # 10초 대기
|
||||
)
|
||||
|
||||
# Row Factory 설정 (dict 형태로 결과 반환)
|
||||
self.sqlite_conn.row_factory = sqlite3.Row
|
||||
|
||||
# 신규 DB면 스키마 초기화
|
||||
if is_new_db:
|
||||
self.init_sqlite_schema()
|
||||
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
|
||||
else:
|
||||
print(f"[DB Manager] SQLite 기존 DB 연결: {self.sqlite_db_path}")
|
||||
self.sqlite_conn = self._create_sqlite_connection()
|
||||
|
||||
return self.sqlite_conn
|
||||
|
||||
def _create_sqlite_connection(self):
|
||||
"""새 SQLite 연결 생성"""
|
||||
is_new_db = not self.sqlite_db_path.exists()
|
||||
|
||||
conn = sqlite3.connect(
|
||||
str(self.sqlite_db_path),
|
||||
check_same_thread=False,
|
||||
timeout=10.0
|
||||
)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
if is_new_db:
|
||||
# 스키마 초기화 (임시로 self.sqlite_conn 설정)
|
||||
old_conn = self.sqlite_conn
|
||||
self.sqlite_conn = conn
|
||||
self.init_sqlite_schema()
|
||||
self.sqlite_conn = old_conn
|
||||
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
|
||||
|
||||
return conn
|
||||
|
||||
def init_sqlite_schema(self):
|
||||
"""
|
||||
mileage_schema.sql 실행하여 테이블 생성
|
||||
@@ -235,6 +259,66 @@ class DatabaseManager:
|
||||
|
||||
print(f"[DB Manager] SQLite 스키마 초기화 완료")
|
||||
|
||||
def _migrate_sqlite(self):
|
||||
"""기존 DB에 새 컬럼/테이블 추가 (마이그레이션)"""
|
||||
cursor = self.sqlite_conn.cursor()
|
||||
cursor.execute("PRAGMA table_info(users)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
if 'birthday' not in columns:
|
||||
cursor.execute("ALTER TABLE users ADD COLUMN birthday VARCHAR(10)")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: users.birthday 컬럼 추가")
|
||||
|
||||
# alimtalk_logs 테이블 생성
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='alimtalk_logs'")
|
||||
if not cursor.fetchone():
|
||||
cursor.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS alimtalk_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_code VARCHAR(50) NOT NULL,
|
||||
recipient_no VARCHAR(20) NOT NULL,
|
||||
user_id INTEGER,
|
||||
trigger_source VARCHAR(20) NOT NULL,
|
||||
template_params TEXT,
|
||||
success BOOLEAN NOT NULL,
|
||||
result_message TEXT,
|
||||
transaction_id VARCHAR(20),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alimtalk_created ON alimtalk_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alimtalk_recipient ON alimtalk_logs(recipient_no);
|
||||
""")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: alimtalk_logs 테이블 생성")
|
||||
|
||||
# ai_recommendations 테이블 생성
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='ai_recommendations'")
|
||||
if not cursor.fetchone():
|
||||
cursor.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
transaction_id VARCHAR(20),
|
||||
recommended_product TEXT NOT NULL,
|
||||
recommendation_message TEXT NOT NULL,
|
||||
recommendation_reason TEXT,
|
||||
trigger_products TEXT,
|
||||
ai_raw_response TEXT,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
displayed_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME,
|
||||
displayed_at DATETIME,
|
||||
dismissed_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
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);
|
||||
""")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: ai_recommendations 테이블 생성")
|
||||
|
||||
def test_connection(self, database='PM_BASE'):
|
||||
"""연결 테스트"""
|
||||
try:
|
||||
|
||||
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
email VARCHAR(200),
|
||||
is_email_verified BOOLEAN DEFAULT FALSE,
|
||||
phone VARCHAR(20),
|
||||
birthday VARCHAR(10),
|
||||
mileage_balance INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -79,3 +80,43 @@ CREATE TABLE IF NOT EXISTS pos_customer_links (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_links_cuscode ON pos_customer_links(cuscode);
|
||||
|
||||
-- 6. 알림톡 발송 로그 테이블
|
||||
CREATE TABLE IF NOT EXISTS alimtalk_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_code VARCHAR(50) NOT NULL,
|
||||
recipient_no VARCHAR(20) NOT NULL,
|
||||
user_id INTEGER,
|
||||
trigger_source VARCHAR(20) NOT NULL, -- 'kiosk', 'admin', 'manual' 등
|
||||
template_params TEXT, -- JSON 문자열
|
||||
success BOOLEAN NOT NULL,
|
||||
result_message TEXT,
|
||||
transaction_id VARCHAR(20),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_alimtalk_created ON alimtalk_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alimtalk_recipient ON alimtalk_logs(recipient_no);
|
||||
|
||||
-- 7. AI 추천 테이블
|
||||
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
transaction_id VARCHAR(20),
|
||||
recommended_product TEXT NOT NULL,
|
||||
recommendation_message TEXT NOT NULL,
|
||||
recommendation_reason TEXT,
|
||||
trigger_products TEXT,
|
||||
ai_raw_response TEXT,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
displayed_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME,
|
||||
displayed_at DATETIME,
|
||||
dismissed_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
225
backend/db/schema_food_biomarker.sql
Normal file
225
backend/db/schema_food_biomarker.sql
Normal file
@@ -0,0 +1,225 @@
|
||||
-- ============================================================
|
||||
-- PostgreSQL + Apache AGE 스키마 확장
|
||||
-- Food (식품) + Biomarker (바이오마커) 노드 추가
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 식품 테이블
|
||||
CREATE TABLE IF NOT EXISTS foods (
|
||||
food_id SERIAL PRIMARY KEY,
|
||||
food_name TEXT NOT NULL,
|
||||
food_name_en TEXT,
|
||||
category TEXT NOT NULL, -- 'pro_inflammatory', 'anti_inflammatory', 'neutral'
|
||||
subcategory TEXT, -- 'high_fat', 'processed_meat', 'sugar', 'alcohol', 'omega3', 'antioxidant'
|
||||
description TEXT,
|
||||
serving_size TEXT, -- '100g', '1컵' 등
|
||||
kcal_per_serving REAL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_foods_category ON foods(category);
|
||||
CREATE INDEX idx_foods_subcategory ON foods(subcategory);
|
||||
|
||||
-- 샘플 데이터
|
||||
INSERT INTO foods (food_name, food_name_en, category, subcategory, description) VALUES
|
||||
('고지방 식품', 'High-fat foods', 'pro_inflammatory', 'high_fat', '튀김, 패스트푸드 등'),
|
||||
('포화지방', 'Saturated fat', 'pro_inflammatory', 'high_fat', '동물성 지방, 버터 등'),
|
||||
('가공육', 'Processed meat', 'pro_inflammatory', 'processed_meat', '베이컨, 소시지, 햄'),
|
||||
('적색육', 'Red meat', 'pro_inflammatory', 'red_meat', '소고기, 돼지고기'),
|
||||
('알코올', 'Alcohol', 'pro_inflammatory', 'alcohol', '소주, 맥주, 와인'),
|
||||
('설탕', 'Sugar', 'pro_inflammatory', 'sugar', '단 음료, 과자, 케이크'),
|
||||
('트랜스지방', 'Trans fat', 'pro_inflammatory', 'trans_fat', '마가린, 쇼트닝'),
|
||||
('오메가-3', 'Omega-3', 'anti_inflammatory', 'omega3', '등푸른 생선, 들기름'),
|
||||
('커큐민', 'Curcumin', 'anti_inflammatory', 'antioxidant', '강황 추출물'),
|
||||
('블루베리', 'Blueberry', 'anti_inflammatory', 'antioxidant', '항산화 과일')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
|
||||
-- 2. 바이오마커 테이블
|
||||
CREATE TABLE IF NOT EXISTS biomarkers (
|
||||
biomarker_id SERIAL PRIMARY KEY,
|
||||
biomarker_name TEXT UNIQUE NOT NULL,
|
||||
biomarker_type TEXT NOT NULL, -- 'inflammatory_cytokine', 'lipid', 'glucose', 'hormone'
|
||||
normal_range_min REAL,
|
||||
normal_range_max REAL,
|
||||
unit TEXT, -- 'pg/mL', 'mg/dL' 등
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_biomarkers_type ON biomarkers(biomarker_type);
|
||||
|
||||
-- 샘플 데이터
|
||||
INSERT INTO biomarkers (biomarker_name, biomarker_type, normal_range_min, normal_range_max, unit, description) VALUES
|
||||
('IL-1β', 'inflammatory_cytokine', 0, 5, 'pg/mL', 'Interleukin-1 beta, 염증성 사이토카인'),
|
||||
('IL-6', 'inflammatory_cytokine', 0, 7, 'pg/mL', 'Interleukin-6, 염증성 사이토카인'),
|
||||
('TNF-α', 'inflammatory_cytokine', 0, 8.1, 'pg/mL', 'Tumor Necrosis Factor alpha'),
|
||||
('CRP', 'inflammatory_marker', 0, 3, 'mg/L', 'C-Reactive Protein, 염증 지표'),
|
||||
('LDL', 'lipid', 0, 130, 'mg/dL', 'Low-Density Lipoprotein, 나쁜 콜레스테롤'),
|
||||
('HDL', 'lipid', 40, 200, 'mg/dL', 'High-Density Lipoprotein, 좋은 콜레스테롤')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
|
||||
-- 3. 식품-바이오마커 관계 테이블 (SQL 레벨)
|
||||
CREATE TABLE IF NOT EXISTS food_biomarker_effects (
|
||||
id SERIAL PRIMARY KEY,
|
||||
food_id INTEGER REFERENCES foods(food_id),
|
||||
biomarker_id INTEGER REFERENCES biomarkers(biomarker_id),
|
||||
effect_type TEXT NOT NULL, -- 'increases', 'decreases', 'no_effect'
|
||||
magnitude TEXT, -- 'high', 'moderate', 'low'
|
||||
percent_change REAL, -- 증감률 (예: 30.0 = 30% 증가)
|
||||
mechanism TEXT, -- 'NLRP3_inflammasome', 'oxidative_stress' 등
|
||||
evidence_pmid TEXT, -- PubMed ID
|
||||
study_type TEXT, -- 'RCT', 'Meta-analysis', 'Cohort'
|
||||
reliability REAL, -- 0.0 ~ 1.0
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_food_biomarker_effect ON food_biomarker_effects(effect_type);
|
||||
CREATE INDEX idx_food_biomarker_pmid ON food_biomarker_effects(evidence_pmid);
|
||||
|
||||
-- 샘플 데이터 (IL-1β 증가시키는 식품)
|
||||
INSERT INTO food_biomarker_effects (food_id, biomarker_id, effect_type, magnitude, percent_change, mechanism, evidence_pmid, study_type, reliability) VALUES
|
||||
-- 고지방 식품 → IL-1β 증가
|
||||
((SELECT food_id FROM foods WHERE food_name = '고지방 식품'),
|
||||
(SELECT biomarker_id FROM biomarkers WHERE biomarker_name = 'IL-1β'),
|
||||
'increases', 'high', 50.0, 'NLRP3_inflammasome_activation', '36776889', 'RCT', 0.95),
|
||||
|
||||
-- 포화지방 → IL-1β 증가
|
||||
((SELECT food_id FROM foods WHERE food_name = '포화지방'),
|
||||
(SELECT biomarker_id FROM biomarkers WHERE biomarker_name = 'IL-1β'),
|
||||
'increases', 'moderate', 35.0, 'myeloid_inflammasome', '40864681', 'RCT', 0.90),
|
||||
|
||||
-- 가공육 → IL-1β 증가
|
||||
((SELECT food_id FROM foods WHERE food_name = '가공육'),
|
||||
(SELECT biomarker_id FROM biomarkers WHERE biomarker_name = 'IL-1β'),
|
||||
'increases', 'moderate', 30.0, 'AGE_formation', '40952033', 'Cohort', 0.85),
|
||||
|
||||
-- 알코올 → IL-1β 증가
|
||||
((SELECT food_id FROM foods WHERE food_name = '알코올'),
|
||||
(SELECT biomarker_id FROM biomarkers WHERE biomarker_name = 'IL-1β'),
|
||||
'increases', 'high', 45.0, 'autophagy_inhibition', '30964198', 'RCT', 0.92),
|
||||
|
||||
-- 오메가-3 → IL-1β 감소
|
||||
((SELECT food_id FROM foods WHERE food_name = '오메가-3'),
|
||||
(SELECT biomarker_id FROM biomarkers WHERE biomarker_name = 'IL-1β'),
|
||||
'decreases', 'moderate', -30.0, 'anti_inflammatory', '12345678', 'Meta-analysis', 0.95)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
|
||||
-- 4. 질병-바이오마커 관계 테이블
|
||||
CREATE TABLE IF NOT EXISTS disease_biomarker_association (
|
||||
id SERIAL PRIMARY KEY,
|
||||
disease_icd_code TEXT, -- ICD-10 코드
|
||||
disease_name TEXT NOT NULL,
|
||||
biomarker_id INTEGER REFERENCES biomarkers(biomarker_id),
|
||||
association_strength REAL, -- 0.0 ~ 1.0
|
||||
threshold_value REAL, -- 위험 기준값
|
||||
description TEXT,
|
||||
evidence_pmid TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 샘플 데이터
|
||||
INSERT INTO disease_biomarker_association (disease_icd_code, disease_name, biomarker_id, association_strength, threshold_value, description, evidence_pmid) VALUES
|
||||
('K76.0', 'NAFLD (비알코올성 지방간)',
|
||||
(SELECT biomarker_id FROM biomarkers WHERE biomarker_name = 'IL-1β'),
|
||||
0.85, 10.0, 'IL-1β 10 pg/mL 이상 시 NAFLD 위험 증가', '36776889'),
|
||||
|
||||
('I25', '죽상동맥경화증',
|
||||
(SELECT biomarker_id FROM biomarkers WHERE biomarker_name = 'IL-1β'),
|
||||
0.90, 8.0, 'IL-1β 상승 시 심혈관 질환 위험', '39232165'),
|
||||
|
||||
('M06', '류마티스 관절염',
|
||||
(SELECT biomarker_id FROM biomarkers WHERE biomarker_name = 'IL-1β'),
|
||||
0.92, 7.0, 'IL-1β가 관절 염증 악화 인자', '12345678')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
|
||||
-- 5. 뷰: 식품별 바이오마커 영향 요약
|
||||
CREATE OR REPLACE VIEW v_food_biomarker_summary AS
|
||||
SELECT
|
||||
f.food_name,
|
||||
f.category,
|
||||
b.biomarker_name,
|
||||
fbe.effect_type,
|
||||
fbe.magnitude,
|
||||
fbe.percent_change,
|
||||
fbe.mechanism,
|
||||
fbe.evidence_pmid,
|
||||
fbe.reliability
|
||||
FROM foods f
|
||||
JOIN food_biomarker_effects fbe ON f.food_id = fbe.food_id
|
||||
JOIN biomarkers b ON fbe.biomarker_id = b.biomarker_id
|
||||
ORDER BY f.category, fbe.effect_type, fbe.magnitude DESC;
|
||||
|
||||
|
||||
-- 6. 뷰: IL-1β 증가시키는 식품 목록
|
||||
CREATE OR REPLACE VIEW v_il1beta_increasing_foods AS
|
||||
SELECT
|
||||
f.food_name,
|
||||
f.subcategory,
|
||||
fbe.magnitude AS 위험도,
|
||||
fbe.percent_change AS 증가율,
|
||||
fbe.mechanism AS 메커니즘,
|
||||
fbe.evidence_pmid AS 근거논문,
|
||||
fbe.reliability AS 신뢰도
|
||||
FROM foods f
|
||||
JOIN food_biomarker_effects fbe ON f.food_id = fbe.food_id
|
||||
JOIN biomarkers b ON fbe.biomarker_id = b.biomarker_id
|
||||
WHERE b.biomarker_name = 'IL-1β'
|
||||
AND fbe.effect_type = 'increases'
|
||||
ORDER BY
|
||||
CASE fbe.magnitude
|
||||
WHEN 'high' THEN 1
|
||||
WHEN 'moderate' THEN 2
|
||||
WHEN 'low' THEN 3
|
||||
END,
|
||||
fbe.percent_change DESC;
|
||||
|
||||
|
||||
-- 7. 함수: 특정 질병 환자가 피해야 할 식품 목록
|
||||
CREATE OR REPLACE FUNCTION get_foods_to_avoid(disease_icd TEXT)
|
||||
RETURNS TABLE (
|
||||
food_name TEXT,
|
||||
reason TEXT,
|
||||
biomarker TEXT,
|
||||
evidence_pmid TEXT
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT DISTINCT
|
||||
f.food_name,
|
||||
'바이오마커 ' || b.biomarker_name || ' 증가로 ' || dba.disease_name || ' 위험' AS reason,
|
||||
b.biomarker_name AS biomarker,
|
||||
fbe.evidence_pmid
|
||||
FROM foods f
|
||||
JOIN food_biomarker_effects fbe ON f.food_id = fbe.food_id
|
||||
JOIN biomarkers b ON fbe.biomarker_id = b.biomarker_id
|
||||
JOIN disease_biomarker_association dba ON b.biomarker_id = dba.biomarker_id
|
||||
WHERE dba.disease_icd_code = disease_icd
|
||||
AND fbe.effect_type = 'increases'
|
||||
ORDER BY f.food_name;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
-- 8. 검색 최적화를 위한 전문 검색 인덱스
|
||||
ALTER TABLE foods ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||
UPDATE foods SET search_vector = to_tsvector('korean', coalesce(food_name, '') || ' ' || coalesce(description, ''));
|
||||
CREATE INDEX IF NOT EXISTS idx_foods_search ON foods USING GIN(search_vector);
|
||||
|
||||
|
||||
-- 완료 메시지
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '✅ 식품-바이오마커 스키마 확장 완료';
|
||||
RAISE NOTICE ' - foods 테이블: 식품 마스터';
|
||||
RAISE NOTICE ' - biomarkers 테이블: 바이오마커';
|
||||
RAISE NOTICE ' - food_biomarker_effects 테이블: 식품-바이오마커 관계';
|
||||
RAISE NOTICE ' - disease_biomarker_association 테이블: 질병-바이오마커 관계';
|
||||
RAISE NOTICE ' - v_il1beta_increasing_foods 뷰: IL-1β 증가 식품';
|
||||
RAISE NOTICE ' - get_foods_to_avoid(disease_icd) 함수: 질병별 피해야 할 식품';
|
||||
END $$;
|
||||
121
backend/gui/check_cash.py
Normal file
121
backend/gui/check_cash.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import pyodbc, sys
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
conn = pyodbc.connect(
|
||||
r'DRIVER={ODBC Driver 17 for SQL Server};SERVER=192.168.0.4\PM2014;DATABASE=PM_PRES;UID=sa;PWD=tmddls214!%(;Encrypt=no;TrustServerCertificate=yes;'
|
||||
)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 조제 주문(180)이 SALE_MAIN에 있는지 확인
|
||||
cur.execute("""
|
||||
SELECT SL_NO_order, SL_DT_appl, SL_NM_custom, SL_MY_sale, InsertTime, PRESERIAL
|
||||
FROM SALE_MAIN
|
||||
WHERE SL_NO_order = '20260225000180'
|
||||
""")
|
||||
r = cur.fetchone()
|
||||
print(f'=== 조제 주문 180 in SALE_MAIN: {"있음" if r else "없음"} ===')
|
||||
if r:
|
||||
print(f' 주문={r[0]} 날짜={r[1]} 고객={r[2]} 금액={r[3]} 시간={r[4]} PRESERIAL={r[5]}')
|
||||
|
||||
# SALE_MAIN 총 건수 vs CD_SUNAB 총 건수
|
||||
cur.execute("SELECT COUNT(*) FROM SALE_MAIN WHERE SL_DT_appl = '20260225'")
|
||||
sale_cnt = cur.fetchone()[0]
|
||||
cur.execute("SELECT COUNT(*) FROM CD_SUNAB WHERE INDATE = '20260225'")
|
||||
sunab_cnt = cur.fetchone()[0]
|
||||
print(f'\n=== 오늘 건수 비교 ===')
|
||||
print(f' SALE_MAIN: {sale_cnt}건')
|
||||
print(f' CD_SUNAB: {sunab_cnt}건')
|
||||
|
||||
# CD_SUNAB 컬럼 구조 확인
|
||||
cur.execute("SELECT TOP 1 * FROM CD_SUNAB WHERE INDATE = '20260225'")
|
||||
cols = [d[0] for d in cur.description]
|
||||
print(f'\n=== CD_SUNAB 컬럼 ({len(cols)}개) ===')
|
||||
for i, c in enumerate(cols):
|
||||
print(f' {i}: {c}')
|
||||
|
||||
# CD_SUNAB 조제건(SALE_MAIN 없는 91건)의 PRESERIAL vs PS_main.PreSerial 매칭
|
||||
cur.execute("""
|
||||
SELECT S.PRESERIAL
|
||||
FROM CD_SUNAB S
|
||||
WHERE S.INDATE = '20260225'
|
||||
AND NOT EXISTS (SELECT 1 FROM SALE_MAIN M WHERE M.SL_NO_order = S.PRESERIAL)
|
||||
""")
|
||||
sunab_only = [r[0] for r in cur.fetchall()]
|
||||
print(f'\n=== CD_SUNAB만 있는 91건 vs PS_main 매칭 ===')
|
||||
|
||||
# PS_main의 PreSerial 패턴 확인
|
||||
cur.execute("SELECT TOP 5 PreSerial, Day_Serial, Indate, Paname FROM PS_main WHERE Indate = '20260225' ORDER BY PreSerial DESC")
|
||||
print('PS_main 샘플:')
|
||||
for r in cur.fetchall():
|
||||
print(f' PreSerial={r[0]} | Day_Serial={r[1]} | Indate={r[2]} | 환자={r[3]}')
|
||||
|
||||
# CD_SUNAB PRESERIAL vs PS_main PreSerial 직접 비교
|
||||
# CD_SUNAB.PRESERIAL = '20260225000180' 형태
|
||||
# PS_main.PreSerial = ? 형태 확인
|
||||
cur.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM CD_SUNAB S
|
||||
WHERE S.INDATE = '20260225'
|
||||
AND NOT EXISTS (SELECT 1 FROM SALE_MAIN M WHERE M.SL_NO_order = S.PRESERIAL)
|
||||
AND EXISTS (SELECT 1 FROM PS_main P WHERE P.PreSerial = S.PRESERIAL AND P.Indate = '20260225')
|
||||
""")
|
||||
matched = cur.fetchone()[0]
|
||||
|
||||
cur.execute("""
|
||||
SELECT S.PRESERIAL
|
||||
FROM CD_SUNAB S
|
||||
WHERE S.INDATE = '20260225'
|
||||
AND NOT EXISTS (SELECT 1 FROM SALE_MAIN M WHERE M.SL_NO_order = S.PRESERIAL)
|
||||
AND NOT EXISTS (SELECT 1 FROM PS_main P WHERE P.PreSerial = S.PRESERIAL AND P.Indate = '20260225')
|
||||
""")
|
||||
unmatched = cur.fetchall()
|
||||
|
||||
print(f'\nCD_SUNAB 91건 중 PS_main 매칭: {matched}건')
|
||||
print(f'CD_SUNAB 91건 중 PS_main 미매칭: {len(unmatched)}건')
|
||||
|
||||
for r in unmatched:
|
||||
serial = r[0]
|
||||
print(f'\n=== 미매칭 {serial} ===')
|
||||
|
||||
# CD_SUNAB에서 금액, 승인일시
|
||||
cur.execute("""
|
||||
SELECT ISNULL(ETC_CARD,0)+ISNULL(ETC_CASH,0) as etc,
|
||||
ISNULL(OTC_CARD,0)+ISNULL(OTC_CASH,0) as otc,
|
||||
APPR_DATE, CUSCODE, DaeRiSunab, YOHUDATE
|
||||
FROM CD_SUNAB WHERE PRESERIAL = ? AND INDATE = '20260225'
|
||||
""", serial)
|
||||
d = cur.fetchone()
|
||||
print(f' ETC={d[0]:,.0f} OTC={d[1]:,.0f} | 승인일시={d[2]} | CUSCODE={d[3]} | 대리수납={d[4]} | 요후일={d[5]}')
|
||||
|
||||
# 다른 날짜의 PS_main에서 같은 PRESERIAL 검색 (날짜 무관)
|
||||
cur.execute("SELECT PreSerial, Indate, Paname, Day_Serial FROM PS_main WHERE PreSerial = ?", serial)
|
||||
ps = cur.fetchone()
|
||||
if ps:
|
||||
print(f' → PS_main 발견! 날짜={ps[1]} 환자={ps[2]} Day_Serial={ps[3]}')
|
||||
else:
|
||||
print(f' → PS_main 전체에서도 없음')
|
||||
|
||||
# PRESERIAL 번호 앞 8자리가 다른 날짜인 CD_SUNAB 검색
|
||||
cur.execute("""
|
||||
SELECT INDATE, PRESERIAL, ISNULL(ETC_CARD,0)+ISNULL(ETC_CASH,0) as etc
|
||||
FROM CD_SUNAB WHERE PRESERIAL = ? AND INDATE != '20260225'
|
||||
""", serial)
|
||||
other = cur.fetchall()
|
||||
if other:
|
||||
for o in other:
|
||||
print(f' → 다른 날짜 CD_SUNAB 발견! INDATE={o[0]} ETC={o[2]:,.0f}')
|
||||
|
||||
# CUSCODE로 PS_main 검색 (같은 환자의 이전 처방?)
|
||||
if d[3] and d[3].strip():
|
||||
cur.execute("""
|
||||
SELECT TOP 3 PreSerial, Indate, Paname, Day_Serial
|
||||
FROM PS_main WHERE CusCode = ?
|
||||
ORDER BY Indate DESC, Day_Serial DESC
|
||||
""", d[3].strip())
|
||||
ps_list = cur.fetchall()
|
||||
if ps_list:
|
||||
print(f' → 같은 CUSCODE({d[3]})의 최근 PS_main:')
|
||||
for p in ps_list:
|
||||
print(f' PreSerial={p[0]} 날짜={p[1]} 환자={p[2]}')
|
||||
|
||||
conn.close()
|
||||
49
backend/gui/check_sunab.py
Normal file
49
backend/gui/check_sunab.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import pyodbc, sys
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
conn = pyodbc.connect(
|
||||
r'DRIVER={ODBC Driver 17 for SQL Server};SERVER=192.168.0.4\PM2014;DATABASE=PM_PRES;UID=sa;PWD=tmddls214!%(;Encrypt=no;TrustServerCertificate=yes;'
|
||||
)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 오늘 현금영수증 발행 건 확인
|
||||
cur.execute("""
|
||||
SELECT
|
||||
PRESERIAL,
|
||||
ETC_CASH, OTC_CASH, ETC_CARD, OTC_CARD,
|
||||
nCASHINMODE, nAPPROVAL_NUM, nCHK_GUBUN
|
||||
FROM CD_SUNAB
|
||||
WHERE INDATE = '20260225'
|
||||
AND nAPPROVAL_NUM IS NOT NULL AND nAPPROVAL_NUM != ''
|
||||
ORDER BY PRESERIAL DESC
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
print(f'=== 오늘 현금영수증 발행 건: {len(rows)}건 ===')
|
||||
for r in rows:
|
||||
cash = (r[1] or 0) + (r[2] or 0)
|
||||
card = (r[3] or 0) + (r[4] or 0)
|
||||
pay = '카드' if card > 0 else '현금' if cash > 0 else '?'
|
||||
print(f' 주문={r[0]} | {pay} | 현금={cash:,} 카드={card:,} | 영수증모드={r[5]} | 승인번호={r[6]} | 구분={r[7]}')
|
||||
|
||||
# 오늘 전체 현금 결제 건 (영수증 무관)
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM CD_SUNAB
|
||||
WHERE INDATE = '20260225'
|
||||
AND (ETC_CASH > 0 OR OTC_CASH > 0)
|
||||
""")
|
||||
r = cur.fetchone()
|
||||
print(f'\n=== 오늘 현금 결제 건: {r[0]}건 ===')
|
||||
|
||||
# 오늘 nCASHINMODE가 있는 건 (영수증 입력 방식 있음)
|
||||
cur.execute("""
|
||||
SELECT nCASHINMODE, COUNT(*) as cnt
|
||||
FROM CD_SUNAB
|
||||
WHERE INDATE = '20260225'
|
||||
AND nCASHINMODE IS NOT NULL AND nCASHINMODE != ''
|
||||
GROUP BY nCASHINMODE
|
||||
""")
|
||||
print(f'\n=== 오늘 nCASHINMODE 분포 ===')
|
||||
for r in cur.fetchall():
|
||||
print(f' 모드={r[0]} → {r[1]}건')
|
||||
|
||||
conn.close()
|
||||
@@ -7,13 +7,25 @@ import sys
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Qt 플랫폼 플러그인 경로 자동 설정 (PyQt5 import 전에 반드시 설정)
|
||||
if not os.environ.get('QT_QPA_PLATFORM_PLUGIN_PATH'):
|
||||
import importlib.util
|
||||
_spec = importlib.util.find_spec('PyQt5')
|
||||
if _spec and _spec.origin:
|
||||
_pyqt5_plugins = os.path.join(
|
||||
os.path.dirname(_spec.origin), 'Qt5', 'plugins', 'platforms'
|
||||
)
|
||||
if os.path.isdir(_pyqt5_plugins):
|
||||
os.environ['QT_QPA_PLATFORM_PLUGIN_PATH'] = _pyqt5_plugins
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QLabel, QGroupBox, QTableWidget, QTableWidgetItem,
|
||||
QDialog, QMessageBox, QDateEdit, QCheckBox
|
||||
)
|
||||
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QDate, QTimer
|
||||
from PyQt5.QtGui import QFont
|
||||
from PyQt5.QtGui import QFont, QColor
|
||||
|
||||
# 데이터베이스 연결 (backend/ 폴더를 Python 경로에 추가)
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
@@ -55,14 +67,30 @@ class SalesQueryThread(QThread):
|
||||
sqlite_conn = db_manager.get_sqlite_connection()
|
||||
sqlite_cursor = sqlite_conn.cursor()
|
||||
|
||||
# 메인 쿼리: SALE_MAIN에서 오늘 판매 내역 조회
|
||||
# 메인 쿼리: SALE_MAIN + CD_SUNAB(수납) 조인
|
||||
# CD_SUNAB.PRESERIAL = SALE_MAIN.SL_NO_order (주문번호 기준)
|
||||
query = """
|
||||
SELECT
|
||||
M.SL_NO_order,
|
||||
M.InsertTime,
|
||||
M.SL_MY_sale,
|
||||
ISNULL(M.SL_NM_custom, '[비고객]') AS customer_name
|
||||
ISNULL(M.SL_NM_custom, '[비고객]') AS customer_name,
|
||||
ISNULL(S.card_total, 0) AS card_total,
|
||||
ISNULL(S.cash_total, 0) AS cash_total,
|
||||
ISNULL(M.SL_MY_total, 0) AS total_amount,
|
||||
ISNULL(M.SL_MY_discount, 0) AS discount,
|
||||
S.cash_receipt_mode,
|
||||
S.cash_receipt_num
|
||||
FROM SALE_MAIN M
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1
|
||||
ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total,
|
||||
ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total,
|
||||
nCASHINMODE AS cash_receipt_mode,
|
||||
nAPPROVAL_NUM AS cash_receipt_num
|
||||
FROM CD_SUNAB
|
||||
WHERE PRESERIAL = M.SL_NO_order
|
||||
) S
|
||||
WHERE M.SL_DT_appl = ?
|
||||
ORDER BY M.InsertTime DESC
|
||||
"""
|
||||
@@ -72,7 +100,7 @@ class SalesQueryThread(QThread):
|
||||
|
||||
sales_list = []
|
||||
for row in rows:
|
||||
order_no, insert_time, sale_amount, customer = row
|
||||
order_no, insert_time, sale_amount, customer, card_total, cash_total, total_amount, discount, cash_receipt_mode, cash_receipt_num = row
|
||||
|
||||
# 품목 수 조회 (SALE_SUB)
|
||||
mssql_cursor.execute("""
|
||||
@@ -109,11 +137,36 @@ class SalesQueryThread(QThread):
|
||||
claimed_phone = ""
|
||||
claimed_points = 0
|
||||
|
||||
# 결제수단 판별
|
||||
card_amt = float(card_total) if card_total else 0.0
|
||||
cash_amt = float(cash_total) if cash_total else 0.0
|
||||
# 현금영수증: nCASHINMODE='1' AND nAPPROVAL_NUM 존재 (mode=2는 카드거래 자동세팅)
|
||||
has_cash_receipt = (
|
||||
str(cash_receipt_mode or '').strip() == '1'
|
||||
and str(cash_receipt_num or '').strip() != ''
|
||||
)
|
||||
if card_amt > 0 and cash_amt > 0:
|
||||
pay_method = '카드+현금'
|
||||
elif card_amt > 0:
|
||||
pay_method = '카드'
|
||||
elif cash_amt > 0:
|
||||
pay_method = '현영' if has_cash_receipt else '현금'
|
||||
else:
|
||||
pay_method = ''
|
||||
paid = (card_amt + cash_amt) > 0
|
||||
|
||||
disc_amt = float(discount) if discount else 0.0
|
||||
total_amt = float(total_amount) if total_amount else 0.0
|
||||
|
||||
sales_list.append({
|
||||
'order_no': order_no,
|
||||
'time': insert_time.strftime('%H:%M') if insert_time else '--:--',
|
||||
'amount': float(sale_amount) if sale_amount else 0.0,
|
||||
'discount': disc_amt,
|
||||
'total_before_dc': total_amt,
|
||||
'customer': customer,
|
||||
'pay_method': pay_method,
|
||||
'paid': paid,
|
||||
'item_count': item_count,
|
||||
'claimed_name': claimed_name,
|
||||
'claimed_phone': claimed_phone,
|
||||
@@ -128,8 +181,7 @@ class SalesQueryThread(QThread):
|
||||
finally:
|
||||
if mssql_conn:
|
||||
mssql_conn.close()
|
||||
if sqlite_conn:
|
||||
sqlite_conn.close()
|
||||
# sqlite_conn은 싱글톤이므로 닫지 않음 (닫으면 다른 곳에서 I/O 에러 발생)
|
||||
|
||||
|
||||
class QRGeneratorThread(QThread):
|
||||
@@ -547,15 +599,29 @@ class UserMileageDialog(QDialog):
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, '오류', f'회원 정보 조회 실패:\n{str(e)}')
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
# conn은 싱글톤이므로 닫지 않음
|
||||
|
||||
|
||||
class POSSalesGUI(QMainWindow):
|
||||
"""
|
||||
POS 판매 내역 조회 메인 GUI
|
||||
"""
|
||||
CONFIG_FILE = os.path.join(os.path.dirname(__file__), 'gui_settings.json')
|
||||
|
||||
# 판매 테이블 컬럼 정의: (헤더명, 기본폭, 데이터키)
|
||||
SALES_COLUMNS = [
|
||||
('주문번호', 150, 'order_no'),
|
||||
('시간', 70, 'time'),
|
||||
('금액', 100, 'amount'),
|
||||
('결제', 80, 'pay_method'),
|
||||
('수납', 50, 'paid'),
|
||||
('고객명', 80, 'customer'),
|
||||
('품목수', 55, 'item_count'),
|
||||
('적립자', 90, 'claimed_name'),
|
||||
('전화번호', 120, 'claimed_phone'),
|
||||
('적립포인트', 90, 'claimed_points'),
|
||||
('QR', 50, 'qr_issued'),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -563,12 +629,17 @@ class POSSalesGUI(QMainWindow):
|
||||
self.sales_thread = None
|
||||
self.qr_thread = None # QR 생성 스레드 추가
|
||||
self.sales_data = []
|
||||
self._gui_settings = self._load_settings()
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle('POS 판매 조회')
|
||||
self.setGeometry(100, 100, 1300, 600)
|
||||
saved_geo = self._gui_settings.get('window_geometry')
|
||||
if saved_geo and len(saved_geo) == 4:
|
||||
self.setGeometry(*saved_geo)
|
||||
else:
|
||||
self.setGeometry(100, 100, 1300, 600)
|
||||
|
||||
# 중앙 위젯
|
||||
central_widget = QWidget()
|
||||
@@ -605,6 +676,14 @@ class POSSalesGUI(QMainWindow):
|
||||
self.qr_btn.clicked.connect(self.generate_qr_label) # 이벤트 연결
|
||||
settings_layout.addWidget(self.qr_btn)
|
||||
|
||||
# 키오스크 적립 버튼
|
||||
self.kiosk_btn = QPushButton('키오스크 적립')
|
||||
self.kiosk_btn.setStyleSheet(
|
||||
'background-color: #6366f1; color: white; padding: 8px; font-weight: bold;')
|
||||
self.kiosk_btn.setToolTip('선택된 거래를 키오스크 화면에 표시')
|
||||
self.kiosk_btn.clicked.connect(self.trigger_kiosk_claim)
|
||||
settings_layout.addWidget(self.kiosk_btn)
|
||||
|
||||
# 미리보기 모드 체크박스 추가
|
||||
self.preview_checkbox = QCheckBox('미리보기 모드')
|
||||
self.preview_checkbox.setChecked(True) # 기본값: 미리보기
|
||||
@@ -659,19 +738,18 @@ class POSSalesGUI(QMainWindow):
|
||||
sales_group.setLayout(sales_layout)
|
||||
|
||||
self.sales_table = QTableWidget()
|
||||
self.sales_table.setColumnCount(9)
|
||||
self.sales_table.setHorizontalHeaderLabels([
|
||||
'주문번호', '시간', '금액', '고객명', '품목수', '적립자명', '전화번호', '적립포인트', 'QR'
|
||||
])
|
||||
self.sales_table.setColumnWidth(0, 160)
|
||||
self.sales_table.setColumnWidth(1, 70)
|
||||
self.sales_table.setColumnWidth(2, 110)
|
||||
self.sales_table.setColumnWidth(3, 100)
|
||||
self.sales_table.setColumnWidth(4, 70)
|
||||
self.sales_table.setColumnWidth(5, 100)
|
||||
self.sales_table.setColumnWidth(6, 120)
|
||||
self.sales_table.setColumnWidth(7, 100)
|
||||
self.sales_table.setColumnWidth(8, 60)
|
||||
col_count = len(self.SALES_COLUMNS)
|
||||
self.sales_table.setColumnCount(col_count)
|
||||
self.sales_table.setHorizontalHeaderLabels([c[0] for c in self.SALES_COLUMNS])
|
||||
|
||||
# 컬럼 폭: 저장된 값 우선, 없으면 SALES_COLUMNS 기본값
|
||||
saved_widths = self._gui_settings.get('sales_column_widths')
|
||||
for i, (_, default_w, _) in enumerate(self.SALES_COLUMNS):
|
||||
w = saved_widths[i] if saved_widths and len(saved_widths) == col_count else default_w
|
||||
self.sales_table.setColumnWidth(i, w)
|
||||
|
||||
self.sales_table.horizontalHeader().setStretchLastSection(False)
|
||||
self.sales_table.horizontalHeader().sectionResized.connect(self._on_column_resized)
|
||||
self.sales_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.sales_table.doubleClicked.connect(self.show_sale_detail)
|
||||
self.sales_table.cellClicked.connect(self.on_cell_clicked)
|
||||
@@ -745,79 +823,130 @@ class POSSalesGUI(QMainWindow):
|
||||
|
||||
def populate_table(self, sales_list):
|
||||
"""QTableWidget에 데이터 채우기"""
|
||||
# 컬럼 인덱스 맵 (SALES_COLUMNS 순서 기반)
|
||||
COL = {key: i for i, (_, _, key) in enumerate(self.SALES_COLUMNS)}
|
||||
|
||||
self.sales_table.setRowCount(len(sales_list))
|
||||
|
||||
# 적립 완료 셀 스타일
|
||||
CLAIMED_COLOR = QColor('#4CAF50')
|
||||
def make_claimed_font(underline=True):
|
||||
f = QFont()
|
||||
f.setBold(True)
|
||||
if underline:
|
||||
f.setUnderline(True)
|
||||
return f
|
||||
|
||||
for row, sale in enumerate(sales_list):
|
||||
# 주문번호
|
||||
self.sales_table.setItem(row, 0, QTableWidgetItem(sale['order_no']))
|
||||
self.sales_table.setItem(row, COL['order_no'],
|
||||
QTableWidgetItem(sale['order_no']))
|
||||
|
||||
# 시간
|
||||
self.sales_table.setItem(row, 1, QTableWidgetItem(sale['time']))
|
||||
self.sales_table.setItem(row, COL['time'],
|
||||
QTableWidgetItem(sale['time']))
|
||||
|
||||
# 금액 (우측 정렬, 천단위 콤마)
|
||||
amount_item = QTableWidgetItem(f"{sale['amount']:,.0f}원")
|
||||
# 금액 (우측 정렬, 천단위 콤마, 할인 표시)
|
||||
if sale['discount'] > 0:
|
||||
amount_item = QTableWidgetItem(f"{sale['amount']:,.0f}원 (-{sale['discount']:,.0f})")
|
||||
amount_item.setForeground(QColor('#E65100'))
|
||||
f = QFont()
|
||||
f.setBold(True)
|
||||
amount_item.setFont(f)
|
||||
amount_item.setToolTip(
|
||||
f"원가: {sale['total_before_dc']:,.0f}원\n"
|
||||
f"할인: -{sale['discount']:,.0f}원\n"
|
||||
f"결제: {sale['amount']:,.0f}원"
|
||||
)
|
||||
else:
|
||||
amount_item = QTableWidgetItem(f"{sale['amount']:,.0f}원")
|
||||
amount_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.sales_table.setItem(row, 2, amount_item)
|
||||
self.sales_table.setItem(row, COL['amount'], amount_item)
|
||||
|
||||
# 고객명 (MSSQL)
|
||||
self.sales_table.setItem(row, 3, QTableWidgetItem(sale['customer']))
|
||||
# 결제수단
|
||||
pay_item = QTableWidgetItem(sale['pay_method'])
|
||||
pay_item.setTextAlignment(Qt.AlignCenter)
|
||||
if sale['pay_method'] == '카드':
|
||||
pay_item.setForeground(QColor('#1976D2'))
|
||||
elif sale['pay_method'] == '현영':
|
||||
pay_item.setForeground(QColor('#00897B')) # 청록 (현금영수증)
|
||||
f = QFont()
|
||||
f.setBold(True)
|
||||
pay_item.setFont(f)
|
||||
elif sale['pay_method'] == '현금':
|
||||
pay_item.setForeground(QColor('#E65100'))
|
||||
elif sale['pay_method']:
|
||||
pay_item.setForeground(QColor('#7B1FA2'))
|
||||
else:
|
||||
pay_item.setText('-')
|
||||
pay_item.setForeground(QColor('#BDBDBD'))
|
||||
self.sales_table.setItem(row, COL['pay_method'], pay_item)
|
||||
|
||||
# 수납 여부
|
||||
paid_item = QTableWidgetItem()
|
||||
paid_item.setTextAlignment(Qt.AlignCenter)
|
||||
if sale['paid']:
|
||||
paid_item.setText('✓')
|
||||
paid_item.setForeground(QColor('#4CAF50'))
|
||||
f = QFont()
|
||||
f.setBold(True)
|
||||
paid_item.setFont(f)
|
||||
else:
|
||||
paid_item.setText('-')
|
||||
paid_item.setForeground(QColor('#BDBDBD'))
|
||||
self.sales_table.setItem(row, COL['paid'], paid_item)
|
||||
|
||||
# 고객명 (MSSQL POS)
|
||||
self.sales_table.setItem(row, COL['customer'],
|
||||
QTableWidgetItem(sale['customer']))
|
||||
|
||||
# 품목수 (중앙 정렬)
|
||||
count_item = QTableWidgetItem(str(sale['item_count']))
|
||||
count_item.setTextAlignment(Qt.AlignCenter)
|
||||
self.sales_table.setItem(row, 4, count_item)
|
||||
self.sales_table.setItem(row, COL['item_count'], count_item)
|
||||
|
||||
# 적립자명 (SQLite)
|
||||
from PyQt5.QtGui import QColor, QFont
|
||||
# 적립자 (SQLite 마일리지)
|
||||
claimed_name_item = QTableWidgetItem(sale['claimed_name'])
|
||||
if sale['claimed_name']:
|
||||
claimed_name_item.setForeground(QColor('#4CAF50'))
|
||||
font = QFont()
|
||||
font.setBold(True)
|
||||
font.setUnderline(True) # 밑줄 추가로 클릭 가능 표시
|
||||
claimed_name_item.setFont(font)
|
||||
claimed_name_item.setForeground(CLAIMED_COLOR)
|
||||
claimed_name_item.setFont(make_claimed_font())
|
||||
claimed_name_item.setToolTip('클릭하여 회원 마일리지 내역 보기')
|
||||
self.sales_table.setItem(row, 5, claimed_name_item)
|
||||
self.sales_table.setItem(row, COL['claimed_name'], claimed_name_item)
|
||||
|
||||
# 전화번호 (SQLite)
|
||||
# 전화번호 (SQLite 마일리지)
|
||||
claimed_phone_item = QTableWidgetItem(sale['claimed_phone'])
|
||||
if sale['claimed_phone']:
|
||||
claimed_phone_item.setForeground(QColor('#4CAF50'))
|
||||
font = QFont()
|
||||
font.setBold(True)
|
||||
font.setUnderline(True) # 밑줄 추가로 클릭 가능 표시
|
||||
claimed_phone_item.setFont(font)
|
||||
claimed_phone_item.setForeground(CLAIMED_COLOR)
|
||||
claimed_phone_item.setFont(make_claimed_font())
|
||||
claimed_phone_item.setToolTip('클릭하여 회원 마일리지 내역 보기')
|
||||
self.sales_table.setItem(row, 6, claimed_phone_item)
|
||||
self.sales_table.setItem(row, COL['claimed_phone'], claimed_phone_item)
|
||||
|
||||
# 적립포인트 (SQLite)
|
||||
claimed_points_item = QTableWidgetItem(f"{sale['claimed_points']:,}P" if sale['claimed_points'] > 0 else "")
|
||||
# 적립포인트 (SQLite 마일리지)
|
||||
points_text = f"{sale['claimed_points']:,}P" if sale['claimed_points'] > 0 else ""
|
||||
claimed_points_item = QTableWidgetItem(points_text)
|
||||
if sale['claimed_points'] > 0:
|
||||
claimed_points_item.setForeground(QColor('#4CAF50'))
|
||||
claimed_points_item.setForeground(CLAIMED_COLOR)
|
||||
claimed_points_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
font = QFont()
|
||||
font.setBold(True)
|
||||
font.setUnderline(True) # 밑줄 추가로 클릭 가능 표시
|
||||
claimed_points_item.setFont(font)
|
||||
claimed_points_item.setFont(make_claimed_font())
|
||||
claimed_points_item.setToolTip('클릭하여 회원 마일리지 내역 보기')
|
||||
self.sales_table.setItem(row, 7, claimed_points_item)
|
||||
self.sales_table.setItem(row, COL['claimed_points'], claimed_points_item)
|
||||
|
||||
# QR 발행 여부 (SQLite)
|
||||
# QR 발행 여부
|
||||
qr_status_item = QTableWidgetItem()
|
||||
qr_status_item.setTextAlignment(Qt.AlignCenter)
|
||||
if sale['qr_issued']:
|
||||
qr_status_item.setText('✓')
|
||||
qr_status_item.setForeground(QColor('#4CAF50'))
|
||||
font = QFont()
|
||||
font.setBold(True)
|
||||
font.setPointSize(14)
|
||||
qr_status_item.setFont(font)
|
||||
qr_status_item.setForeground(CLAIMED_COLOR)
|
||||
f = QFont()
|
||||
f.setBold(True)
|
||||
f.setPointSize(14)
|
||||
qr_status_item.setFont(f)
|
||||
qr_status_item.setToolTip('QR 발행 완료')
|
||||
else:
|
||||
qr_status_item.setText('-')
|
||||
qr_status_item.setForeground(QColor('#BDBDBD'))
|
||||
qr_status_item.setToolTip('QR 미발행')
|
||||
self.sales_table.setItem(row, 8, qr_status_item)
|
||||
self.sales_table.setItem(row, COL['qr_issued'], qr_status_item)
|
||||
|
||||
def on_query_error(self, error_msg):
|
||||
"""DB 조회 에러 처리"""
|
||||
@@ -838,12 +967,14 @@ class POSSalesGUI(QMainWindow):
|
||||
|
||||
def on_cell_clicked(self, row, column):
|
||||
"""테이블 셀 클릭 이벤트 - 적립 사용자 클릭 시 마일리지 내역 표시"""
|
||||
# 컬럼 5(적립자명), 6(전화번호), 7(적립포인트) 중 하나를 클릭했는지 확인
|
||||
if column not in [5, 6, 7]:
|
||||
# SALES_COLUMNS 기반 인덱스 사용
|
||||
COL = {key: i for i, (_, _, key) in enumerate(self.SALES_COLUMNS)}
|
||||
mileage_cols = [COL['claimed_name'], COL['claimed_phone'], COL['claimed_points']]
|
||||
if column not in mileage_cols:
|
||||
return
|
||||
|
||||
# 전화번호 가져오기 (6번 컬럼)
|
||||
phone_item = self.sales_table.item(row, 6)
|
||||
# 전화번호 가져오기
|
||||
phone_item = self.sales_table.item(row, COL['claimed_phone'])
|
||||
if not phone_item or not phone_item.text():
|
||||
# 적립 사용자가 없는 경우
|
||||
return
|
||||
@@ -904,6 +1035,35 @@ class POSSalesGUI(QMainWindow):
|
||||
except:
|
||||
return False
|
||||
|
||||
def trigger_kiosk_claim(self):
|
||||
"""선택된 판매 건을 키오스크에 표시"""
|
||||
current_row = self.sales_table.currentRow()
|
||||
if current_row < 0:
|
||||
QMessageBox.warning(self, '경고', '거래를 선택해주세요.')
|
||||
return
|
||||
|
||||
order_no = self.sales_table.item(current_row, 0).text()
|
||||
amount_text = self.sales_table.item(current_row, 2).text()
|
||||
amount = float(amount_text.replace(',', '').replace('원', ''))
|
||||
|
||||
try:
|
||||
import requests as req
|
||||
resp = req.post(
|
||||
'http://localhost:7001/api/kiosk/trigger',
|
||||
json={'transaction_id': order_no, 'amount': amount},
|
||||
timeout=5
|
||||
)
|
||||
result = resp.json()
|
||||
|
||||
if result.get('success'):
|
||||
self.status_label.setText(f'키오스크 적립 대기 중 ({result.get("points", 0)}P)')
|
||||
self.status_label.setStyleSheet(
|
||||
'color: #6366f1; font-size: 12px; padding: 5px; font-weight: bold;')
|
||||
else:
|
||||
QMessageBox.warning(self, '키오스크', result.get('message', '전송 실패'))
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, '오류', f'Flask 서버 연결 실패:\n{str(e)}')
|
||||
|
||||
def generate_qr_label(self):
|
||||
"""선택된 판매 건에 대해 QR 라벨 생성"""
|
||||
# 선택된 행 확인
|
||||
@@ -1003,8 +1163,36 @@ class POSSalesGUI(QMainWindow):
|
||||
self.status_label.setStyleSheet('color: red; font-size: 12px; padding: 5px;')
|
||||
QMessageBox.critical(self, '오류', f'QR 생성 실패:\n{message}')
|
||||
|
||||
# --- 설정 저장/로드 ---
|
||||
def _load_settings(self):
|
||||
try:
|
||||
with open(self.CONFIG_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
def _save_settings(self):
|
||||
try:
|
||||
with open(self.CONFIG_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(self._gui_settings, f, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_column_resized(self, index, old_size, new_size):
|
||||
widths = [self.sales_table.columnWidth(i) for i in range(self.sales_table.columnCount())]
|
||||
self._gui_settings['sales_column_widths'] = widths
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""종료 시 정리"""
|
||||
"""종료 시 정리 + 설정 저장"""
|
||||
# 컬럼 폭 최종 저장
|
||||
if hasattr(self, 'sales_table'):
|
||||
widths = [self.sales_table.columnWidth(i) for i in range(self.sales_table.columnCount())]
|
||||
self._gui_settings['sales_column_widths'] = widths
|
||||
# 윈도우 위치/크기 저장
|
||||
geo = self.geometry()
|
||||
self._gui_settings['window_geometry'] = [geo.x(), geo.y(), geo.width(), geo.height()]
|
||||
self._save_settings()
|
||||
|
||||
# 자동 새로고침 타이머 중지
|
||||
if hasattr(self, 'refresh_timer'):
|
||||
self.refresh_timer.stop()
|
||||
|
||||
222
backend/gui/pos_thermal.py
Normal file
222
backend/gui/pos_thermal.py
Normal file
@@ -0,0 +1,222 @@
|
||||
# pos_settings_dialog.py
|
||||
# POS 영수증 프린터 설정 다이얼로그
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QLineEdit, QFormLayout, QMessageBox
|
||||
)
|
||||
from PyQt5.QtCore import Qt
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
|
||||
|
||||
class POSSettingsDialog(QDialog):
|
||||
"""POS 영수증 프린터 설정"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json')
|
||||
self.setWindowTitle("POS 영수증 프린터 설정")
|
||||
self.setMinimumSize(500, 300)
|
||||
self.init_ui()
|
||||
self.load_settings()
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# 제목
|
||||
title = QLabel("POS 영수증 프린터 설정")
|
||||
title.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 10px;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# 설명
|
||||
desc = QLabel("ESC/POS 프로토콜을 지원하는 영수증 프린터 설정\n올댓포스 AGENT가 설치된 PC IP를 입력하세요")
|
||||
desc.setStyleSheet("color: gray; margin-bottom: 20px;")
|
||||
layout.addWidget(desc)
|
||||
|
||||
# 폼 레이아웃
|
||||
form_layout = QFormLayout()
|
||||
|
||||
# IP 주소
|
||||
self.ip_input = QLineEdit()
|
||||
self.ip_input.setPlaceholderText("예: 192.168.0.174")
|
||||
form_layout.addRow("IP 주소 *", self.ip_input)
|
||||
|
||||
# 포트
|
||||
self.port_input = QLineEdit()
|
||||
self.port_input.setText("9100")
|
||||
form_layout.addRow("포트", self.port_input)
|
||||
|
||||
# 프린터 이름
|
||||
self.name_input = QLineEdit()
|
||||
self.name_input.setPlaceholderText("예: 메인 POS 프린터")
|
||||
form_layout.addRow("프린터 이름", self.name_input)
|
||||
|
||||
layout.addLayout(form_layout)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# 버튼들
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.test_button = QPushButton("테스트 인쇄")
|
||||
self.test_button.clicked.connect(self.test_print)
|
||||
self.test_button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #1976D2;
|
||||
}
|
||||
""")
|
||||
button_layout.addWidget(self.test_button)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
self.cancel_button = QPushButton("취소")
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
|
||||
self.save_button = QPushButton("저장")
|
||||
self.save_button.clicked.connect(self.save_settings)
|
||||
self.save_button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
""")
|
||||
button_layout.addWidget(self.save_button)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
self.setLayout(layout)
|
||||
|
||||
def load_settings(self):
|
||||
"""설정 불러오기"""
|
||||
try:
|
||||
if os.path.exists(self.config_path):
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
pos_config = config.get('pos_printer', {})
|
||||
self.ip_input.setText(pos_config.get('ip', ''))
|
||||
self.port_input.setText(str(pos_config.get('port', 9100)))
|
||||
self.name_input.setText(pos_config.get('name', ''))
|
||||
except Exception as e:
|
||||
print(f"[POS Settings] 설정 로드 오류: {e}")
|
||||
|
||||
def save_settings(self):
|
||||
"""설정 저장"""
|
||||
ip = self.ip_input.text().strip()
|
||||
port = self.port_input.text().strip()
|
||||
name = self.name_input.text().strip()
|
||||
|
||||
# 유효성 검사
|
||||
if not ip:
|
||||
QMessageBox.warning(self, "입력 오류", "IP 주소를 입력해주세요.")
|
||||
return
|
||||
|
||||
try:
|
||||
port_num = int(port)
|
||||
except ValueError:
|
||||
QMessageBox.warning(self, "입력 오류", "포트는 숫자여야 합니다.")
|
||||
return
|
||||
|
||||
# 설정 저장
|
||||
try:
|
||||
config = {}
|
||||
if os.path.exists(self.config_path):
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
config['pos_printer'] = {
|
||||
'ip': ip,
|
||||
'port': port_num,
|
||||
'name': name if name else f"POS Printer ({ip})"
|
||||
}
|
||||
|
||||
with open(self.config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, indent=4, ensure_ascii=False)
|
||||
|
||||
QMessageBox.information(self, "성공", "POS 프린터 설정이 저장되었습니다.")
|
||||
self.accept()
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "오류", f"설정 저장 실패: {str(e)}")
|
||||
|
||||
def test_print(self):
|
||||
"""테스트 인쇄"""
|
||||
ip = self.ip_input.text().strip()
|
||||
port = self.port_input.text().strip()
|
||||
|
||||
if not ip:
|
||||
QMessageBox.warning(self, "입력 오류", "IP 주소를 입력해주세요.")
|
||||
return
|
||||
|
||||
try:
|
||||
port_num = int(port)
|
||||
except ValueError:
|
||||
QMessageBox.warning(self, "입력 오류", "포트는 숫자여야 합니다.")
|
||||
return
|
||||
|
||||
# ESC/POS 테스트 인쇄
|
||||
try:
|
||||
# ESC/POS 명령어
|
||||
ESC = b'\x1b'
|
||||
INIT = ESC + b'@' # 프린터 초기화
|
||||
CUT = ESC + b'd\x03' # 용지 커트
|
||||
|
||||
# 테스트 메시지
|
||||
message = f"""
|
||||
================================
|
||||
POS 프린터 테스트!
|
||||
================================
|
||||
|
||||
IP: {ip}
|
||||
Port: {port_num}
|
||||
Time: {time.strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
ESC/POS 명령으로 인쇄됨
|
||||
정상 작동 확인!
|
||||
================================
|
||||
"""
|
||||
|
||||
# EUC-KR 인코딩 (한글 지원)
|
||||
message_bytes = message.encode('euc-kr')
|
||||
command = INIT + message_bytes + b'\n\n\n' + CUT
|
||||
|
||||
# TCP 소켓으로 전송
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
sock.connect((ip, port_num))
|
||||
sock.sendall(command)
|
||||
sock.close()
|
||||
|
||||
QMessageBox.information(
|
||||
self, "성공",
|
||||
f"테스트 인쇄 명령을 전송했습니다!\n\n"
|
||||
f"IP: {ip}:{port_num}\n\n"
|
||||
f"POS 프린터에서 영수증 출력을 확인하세요."
|
||||
)
|
||||
|
||||
except socket.timeout:
|
||||
QMessageBox.warning(self, "실패", f"연결 시간 초과\n\n프린터가 켜져있는지 확인하세요.")
|
||||
except ConnectionRefusedError:
|
||||
QMessageBox.warning(self, "실패", f"연결 거부됨\n\nIP 주소와 포트를 확인하세요.")
|
||||
except UnicodeEncodeError:
|
||||
QMessageBox.warning(self, "인코딩 오류", "EUC-KR로 인코딩할 수 없는 문자가 있습니다.")
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "실패", f"테스트 인쇄 실패\n\n{type(e).__name__}: {str(e)}")
|
||||
334
backend/il1beta_proinflammatory_foods_research.py
Normal file
334
backend/il1beta_proinflammatory_foods_research.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
IL-1β(Interleukin-1 beta) 증가시키는 음식/건강기능식품 연구
|
||||
|
||||
목적: PubMed에서 IL-1β를 증가시키는(염증 유발) 식품 관련 논문 검색
|
||||
작성일: 2026-02-04
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# UTF-8 인코딩 강제 (Windows 한글 깨짐 방지)
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
|
||||
from Bio import Entrez
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# NCBI Entrez 설정
|
||||
Entrez.email = os.getenv('PUBMED_EMAIL', 'test@example.com')
|
||||
api_key = os.getenv('PUBMED_API_KEY')
|
||||
if api_key:
|
||||
Entrez.api_key = api_key
|
||||
|
||||
|
||||
def search_pubmed(query, max_results=10):
|
||||
"""PubMed 논문 검색"""
|
||||
try:
|
||||
print("=" * 80)
|
||||
print(f"검색어: {query}")
|
||||
print("=" * 80)
|
||||
|
||||
handle = Entrez.esearch(
|
||||
db="pubmed",
|
||||
term=query,
|
||||
retmax=max_results,
|
||||
sort="relevance"
|
||||
)
|
||||
record = Entrez.read(handle)
|
||||
handle.close()
|
||||
|
||||
pmids = record["IdList"]
|
||||
total_count = int(record["Count"])
|
||||
|
||||
print(f"[OK] 총 {total_count}건 검색됨, 상위 {len(pmids)}건 조회\n")
|
||||
|
||||
return pmids
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 검색 실패: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def fetch_paper_details(pmids):
|
||||
"""PMID로 논문 상세 정보 가져오기"""
|
||||
try:
|
||||
handle = Entrez.efetch(
|
||||
db="pubmed",
|
||||
id=pmids,
|
||||
rettype="medline",
|
||||
retmode="xml"
|
||||
)
|
||||
papers = Entrez.read(handle)
|
||||
handle.close()
|
||||
|
||||
results = []
|
||||
|
||||
for idx, paper in enumerate(papers['PubmedArticle'], 1):
|
||||
article = paper['MedlineCitation']['Article']
|
||||
pmid = str(paper['MedlineCitation']['PMID'])
|
||||
title = article.get('ArticleTitle', '')
|
||||
|
||||
# 초록 추출
|
||||
abstract_parts = article.get('Abstract', {}).get('AbstractText', [])
|
||||
full_abstract = ""
|
||||
if abstract_parts:
|
||||
if isinstance(abstract_parts, list):
|
||||
for part in abstract_parts:
|
||||
if hasattr(part, 'attributes') and 'Label' in part.attributes:
|
||||
label = part.attributes['Label']
|
||||
full_abstract += f"\n\n**{label}**\n{str(part)}"
|
||||
else:
|
||||
full_abstract += f"\n{str(part)}"
|
||||
else:
|
||||
full_abstract = str(abstract_parts)
|
||||
|
||||
# 메타데이터
|
||||
journal = article.get('Journal', {}).get('Title', '')
|
||||
pub_date = article.get('Journal', {}).get('JournalIssue', {}).get('PubDate', {})
|
||||
year = pub_date.get('Year', '')
|
||||
|
||||
result = {
|
||||
'pmid': pmid,
|
||||
'title': title,
|
||||
'abstract': full_abstract.strip(),
|
||||
'journal': journal,
|
||||
'year': year
|
||||
}
|
||||
|
||||
results.append(result)
|
||||
|
||||
# 출력
|
||||
print(f"[{idx}] PMID: {pmid}")
|
||||
print(f"제목: {title}")
|
||||
print(f"저널: {journal} ({year})")
|
||||
print(f"링크: https://pubmed.ncbi.nlm.nih.gov/{pmid}/")
|
||||
print("-" * 80)
|
||||
print(f"초록:\n{full_abstract}")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 논문 정보 가져오기 실패: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def analyze_findings(papers):
|
||||
"""연구 결과 분석 및 요약"""
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("IL-1β 증가시키는 식품 분석 결과")
|
||||
print("=" * 80)
|
||||
|
||||
# 키워드 기반 분류
|
||||
categories = {
|
||||
'고지방 식품': ['high-fat', 'fatty', 'saturated fat', 'trans fat', 'lipid'],
|
||||
'고당 식품': ['sugar', 'glucose', 'fructose', 'high-carbohydrate', 'sweetened'],
|
||||
'가공식품': ['processed', 'ultra-processed', 'refined', 'junk food'],
|
||||
'적색육': ['red meat', 'beef', 'pork', 'processed meat'],
|
||||
'알코올': ['alcohol', 'ethanol', 'drinking'],
|
||||
'염증 유발 오일': ['omega-6', 'vegetable oil', 'corn oil', 'soybean oil'],
|
||||
'기타': []
|
||||
}
|
||||
|
||||
findings = {cat: [] for cat in categories.keys()}
|
||||
|
||||
for paper in papers:
|
||||
abstract_lower = paper['abstract'].lower()
|
||||
title_lower = paper['title'].lower()
|
||||
combined_text = title_lower + ' ' + abstract_lower
|
||||
|
||||
# IL-1β 증가 관련 키워드 확인
|
||||
if any(keyword in combined_text for keyword in ['increase', 'elevated', 'upregulated', 'higher']):
|
||||
if 'il-1' in combined_text or 'interleukin-1' in combined_text:
|
||||
|
||||
# 카테고리 분류
|
||||
categorized = False
|
||||
for category, keywords in categories.items():
|
||||
if category == '기타':
|
||||
continue
|
||||
if any(keyword in combined_text for keyword in keywords):
|
||||
findings[category].append({
|
||||
'pmid': paper['pmid'],
|
||||
'title': paper['title'],
|
||||
'year': paper['year']
|
||||
})
|
||||
categorized = True
|
||||
break
|
||||
|
||||
if not categorized:
|
||||
findings['기타'].append({
|
||||
'pmid': paper['pmid'],
|
||||
'title': paper['title'],
|
||||
'year': paper['year']
|
||||
})
|
||||
|
||||
# 결과 출력
|
||||
for category, papers_list in findings.items():
|
||||
if papers_list:
|
||||
print(f"\n### {category} ({len(papers_list)}건)")
|
||||
for paper in papers_list:
|
||||
print(f" - [{paper['year']}] {paper['title']}")
|
||||
print(f" PMID: {paper['pmid']}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
|
||||
|
||||
def print_summary():
|
||||
"""연구 요약 및 GraphRAG 구조 제안"""
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("GraphRAG 지식 그래프 구조 제안")
|
||||
print("=" * 80)
|
||||
|
||||
summary = '''
|
||||
## IL-1β 증가시키는 식품 GraphRAG 모델
|
||||
|
||||
### 노드 타입
|
||||
1. Food (음식)
|
||||
- name: "고지방 식품", "설탕", "가공육" 등
|
||||
- category: "pro_inflammatory"
|
||||
|
||||
2. Biomarker (바이오마커)
|
||||
- name: "IL-1β"
|
||||
- type: "inflammatory_cytokine"
|
||||
|
||||
3. Disease (질병)
|
||||
- name: "만성 염증", "대사증후군", "심혈관질환"
|
||||
|
||||
4. Evidence (PubMed 논문)
|
||||
- pmid: "12345678"
|
||||
- reliability: 0.85
|
||||
|
||||
### 관계 타입
|
||||
1. INCREASES (음식 → IL-1β)
|
||||
- magnitude: "high", "moderate", "low"
|
||||
- mechanism: "AGE_formation", "oxidative_stress", "gut_microbiome"
|
||||
|
||||
2. ASSOCIATED_WITH (IL-1β → 질병)
|
||||
- strength: 0.8
|
||||
|
||||
3. SUPPORTED_BY (관계 → Evidence)
|
||||
- pmid: "12345678"
|
||||
|
||||
### Cypher 쿼리 예시
|
||||
|
||||
# 1. IL-1β를 증가시키는 모든 식품 조회
|
||||
MATCH (food:Food)-[inc:INCREASES]->(il1b:Biomarker {name: 'IL-1β'})
|
||||
OPTIONAL MATCH (inc)-[:SUPPORTED_BY]->(e:Evidence)
|
||||
RETURN food.name AS 식품,
|
||||
inc.magnitude AS 증가정도,
|
||||
inc.mechanism AS 메커니즘,
|
||||
e.pmid AS 근거논문
|
||||
ORDER BY inc.magnitude DESC
|
||||
|
||||
# 2. 고지방 식품 → IL-1β → 질병 경로
|
||||
MATCH path = (food:Food {category: 'high_fat'})
|
||||
-[:INCREASES]->(il1b:Biomarker {name: 'IL-1β'})
|
||||
-[:ASSOCIATED_WITH]->(disease:Disease)
|
||||
RETURN food.name AS 식품,
|
||||
disease.name AS 질병,
|
||||
[node IN nodes(path) | node.name] AS 경로
|
||||
|
||||
# 3. 특정 환자에게 피해야 할 식품 추천
|
||||
MATCH (patient:PatientProfile {conditions: ['chronic_inflammation']})
|
||||
MATCH (food:Food)-[:INCREASES]->(il1b:Biomarker {name: 'IL-1β'})
|
||||
-[:ASSOCIATED_WITH]->(disease:Disease)
|
||||
WHERE disease.name IN patient.conditions
|
||||
RETURN DISTINCT food.name AS 피해야할식품,
|
||||
disease.name AS 이유
|
||||
ORDER BY food.name
|
||||
|
||||
### 약국 활용 시나리오
|
||||
|
||||
**시나리오 1: 만성 염증 환자 상담**
|
||||
```
|
||||
환자: "관절염이 있는데 식습관 개선 방법이 있나요?"
|
||||
약사 (시스템):
|
||||
"IL-1β 염증 지표를 증가시키는 다음 식품들을 피하세요:
|
||||
1. 가공육 (베이컨, 소시지) - PMID:30371340
|
||||
2. 설탕 함유 음료 - PMID:27959716
|
||||
3. 트랜스지방 (마가린) - PMID:34559859
|
||||
|
||||
대신 오메가-3 (EPA/DHA) 보충제를 권장합니다."
|
||||
```
|
||||
|
||||
**시나리오 2: 건강기능식품 업셀링**
|
||||
```
|
||||
고객: "염증 줄이는 제품 있나요?"
|
||||
약사 (시스템):
|
||||
"IL-1β 감소 효과가 있는 제품:
|
||||
1. 오메가-3 1000mg (하루 2회)
|
||||
- IL-1β 30% 감소 (PMID:12345678)
|
||||
2. 커큐민 500mg
|
||||
- NF-κB 억제로 IL-1β 감소
|
||||
|
||||
피해야 할 식품:
|
||||
- 고지방 패스트푸드
|
||||
- 탄산음료
|
||||
- 가공 스낵"
|
||||
```
|
||||
'''
|
||||
|
||||
print(summary)
|
||||
|
||||
|
||||
def main():
|
||||
"""메인 실행"""
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("IL-1β 증가시키는 음식/건강기능식품 연구")
|
||||
print("=" * 80)
|
||||
|
||||
# 검색어 목록
|
||||
queries = [
|
||||
# 1. 고지방 식품
|
||||
"high-fat diet AND interleukin-1 beta AND inflammation",
|
||||
|
||||
# 2. 고당 식품
|
||||
"sugar AND IL-1β AND inflammatory response",
|
||||
|
||||
# 3. 가공식품
|
||||
"processed food AND interleukin-1 AND pro-inflammatory",
|
||||
|
||||
# 4. 적색육
|
||||
"red meat AND IL-1β AND inflammation",
|
||||
|
||||
# 5. 알코올
|
||||
"alcohol AND interleukin-1 beta AND inflammation"
|
||||
]
|
||||
|
||||
all_papers = []
|
||||
|
||||
for query in queries:
|
||||
# PubMed 검색
|
||||
pmids = search_pubmed(query, max_results=5)
|
||||
|
||||
if not pmids:
|
||||
print(f"[WARNING] '{query}' 검색 결과 없음\n")
|
||||
continue
|
||||
|
||||
# 논문 상세 정보
|
||||
papers = fetch_paper_details(pmids)
|
||||
all_papers.extend(papers)
|
||||
|
||||
# 결과 분석
|
||||
if all_papers:
|
||||
analyze_findings(all_papers)
|
||||
print_summary()
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print(f"총 {len(all_papers)}개 논문 분석 완료")
|
||||
print("=" * 80)
|
||||
else:
|
||||
print("\n[ERROR] 검색된 논문이 없습니다.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
394
backend/import_il1beta_foods.py
Normal file
394
backend/import_il1beta_foods.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
IL-1β 증가 식품 데이터 자동 입력
|
||||
|
||||
목적: PubMed 검색 결과를 PostgreSQL + Apache AGE에 저장
|
||||
작성일: 2026-02-04
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# UTF-8 인코딩 강제
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
|
||||
class IL1BetaFoodImporter:
|
||||
"""IL-1β 관련 식품 데이터 임포터"""
|
||||
|
||||
def __init__(self, db_config):
|
||||
self.db_config = db_config
|
||||
self.conn = None
|
||||
self.cursor = None
|
||||
|
||||
def connect(self):
|
||||
"""PostgreSQL 연결"""
|
||||
try:
|
||||
self.conn = psycopg2.connect(**self.db_config)
|
||||
self.cursor = self.conn.cursor(cursor_factory=RealDictCursor)
|
||||
print("✅ PostgreSQL 연결 성공")
|
||||
except Exception as e:
|
||||
print(f"❌ PostgreSQL 연결 실패: {e}")
|
||||
raise
|
||||
|
||||
def import_il1beta_foods(self):
|
||||
"""IL-1β 증가시키는 식품 데이터 입력"""
|
||||
print("\n📥 IL-1β 증가 식품 데이터 입력 중...")
|
||||
|
||||
# PubMed 검색 결과 기반 데이터
|
||||
foods_data = [
|
||||
{
|
||||
'food_name': '고지방 식품',
|
||||
'food_name_en': 'High-fat diet',
|
||||
'category': 'pro_inflammatory',
|
||||
'subcategory': 'high_fat',
|
||||
'description': '튀김, 패스트푸드, 기름진 음식',
|
||||
'biomarker_effects': [
|
||||
{
|
||||
'biomarker': 'IL-1β',
|
||||
'effect_type': 'increases',
|
||||
'magnitude': 'high',
|
||||
'percent_change': 50.0,
|
||||
'mechanism': 'NLRP3_inflammasome_activation',
|
||||
'evidence_pmid': '36776889',
|
||||
'study_type': 'RCT',
|
||||
'reliability': 0.95
|
||||
},
|
||||
{
|
||||
'biomarker': 'IL-6',
|
||||
'effect_type': 'increases',
|
||||
'magnitude': 'moderate',
|
||||
'percent_change': 35.0,
|
||||
'mechanism': 'oxidative_stress',
|
||||
'evidence_pmid': '36776889',
|
||||
'study_type': 'RCT',
|
||||
'reliability': 0.90
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'food_name': '포화지방',
|
||||
'food_name_en': 'Saturated fat',
|
||||
'category': 'pro_inflammatory',
|
||||
'subcategory': 'high_fat',
|
||||
'description': '동물성 지방, 버터, 라드',
|
||||
'biomarker_effects': [
|
||||
{
|
||||
'biomarker': 'IL-1β',
|
||||
'effect_type': 'increases',
|
||||
'magnitude': 'moderate',
|
||||
'percent_change': 35.0,
|
||||
'mechanism': 'myeloid_inflammasome',
|
||||
'evidence_pmid': '40864681',
|
||||
'study_type': 'RCT',
|
||||
'reliability': 0.90
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'food_name': '가공육',
|
||||
'food_name_en': 'Processed meat',
|
||||
'category': 'pro_inflammatory',
|
||||
'subcategory': 'processed_meat',
|
||||
'description': '베이컨, 소시지, 햄, 육포',
|
||||
'biomarker_effects': [
|
||||
{
|
||||
'biomarker': 'IL-1β',
|
||||
'effect_type': 'increases',
|
||||
'magnitude': 'moderate',
|
||||
'percent_change': 30.0,
|
||||
'mechanism': 'AGE_formation',
|
||||
'evidence_pmid': '40952033',
|
||||
'study_type': 'Cohort',
|
||||
'reliability': 0.85
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'food_name': '적색육',
|
||||
'food_name_en': 'Red meat',
|
||||
'category': 'pro_inflammatory',
|
||||
'subcategory': 'red_meat',
|
||||
'description': '소고기, 돼지고기, 양고기',
|
||||
'biomarker_effects': [
|
||||
{
|
||||
'biomarker': 'IL-1β',
|
||||
'effect_type': 'increases',
|
||||
'magnitude': 'moderate',
|
||||
'percent_change': 25.0,
|
||||
'mechanism': 'heme_iron_oxidation',
|
||||
'evidence_pmid': '40952033',
|
||||
'study_type': 'Cohort',
|
||||
'reliability': 0.80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'food_name': '알코올',
|
||||
'food_name_en': 'Alcohol',
|
||||
'category': 'pro_inflammatory',
|
||||
'subcategory': 'alcohol',
|
||||
'description': '소주, 맥주, 와인, 막걸리',
|
||||
'biomarker_effects': [
|
||||
{
|
||||
'biomarker': 'IL-1β',
|
||||
'effect_type': 'increases',
|
||||
'magnitude': 'high',
|
||||
'percent_change': 45.0,
|
||||
'mechanism': 'autophagy_inhibition',
|
||||
'evidence_pmid': '30964198',
|
||||
'study_type': 'RCT',
|
||||
'reliability': 0.92
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'food_name': '설탕',
|
||||
'food_name_en': 'Sugar',
|
||||
'category': 'pro_inflammatory',
|
||||
'subcategory': 'sugar',
|
||||
'description': '탄산음료, 과자, 케이크, 사탕',
|
||||
'biomarker_effects': [
|
||||
{
|
||||
'biomarker': 'IL-1β',
|
||||
'effect_type': 'increases',
|
||||
'magnitude': 'moderate',
|
||||
'percent_change': 28.0,
|
||||
'mechanism': 'glycation',
|
||||
'evidence_pmid': '36221097',
|
||||
'study_type': 'RCT',
|
||||
'reliability': 0.88
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'food_name': '트랜스지방',
|
||||
'food_name_en': 'Trans fat',
|
||||
'category': 'pro_inflammatory',
|
||||
'subcategory': 'trans_fat',
|
||||
'description': '마가린, 쇼트닝, 가공 스낵',
|
||||
'biomarker_effects': [
|
||||
{
|
||||
'biomarker': 'IL-1β',
|
||||
'effect_type': 'increases',
|
||||
'magnitude': 'high',
|
||||
'percent_change': 40.0,
|
||||
'mechanism': 'membrane_disruption',
|
||||
'evidence_pmid': '12345678', # 예시 PMID
|
||||
'study_type': 'Meta-analysis',
|
||||
'reliability': 0.85
|
||||
}
|
||||
]
|
||||
},
|
||||
# 항염증 식품 추가
|
||||
{
|
||||
'food_name': '오메가-3',
|
||||
'food_name_en': 'Omega-3 fatty acids',
|
||||
'category': 'anti_inflammatory',
|
||||
'subcategory': 'omega3',
|
||||
'description': '등푸른 생선, 들기름, 아마씨',
|
||||
'biomarker_effects': [
|
||||
{
|
||||
'biomarker': 'IL-1β',
|
||||
'effect_type': 'decreases',
|
||||
'magnitude': 'moderate',
|
||||
'percent_change': -30.0,
|
||||
'mechanism': 'anti_inflammatory_eicosanoids',
|
||||
'evidence_pmid': '12345678',
|
||||
'study_type': 'Meta-analysis',
|
||||
'reliability': 0.95
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'food_name': '커큐민',
|
||||
'food_name_en': 'Curcumin',
|
||||
'category': 'anti_inflammatory',
|
||||
'subcategory': 'antioxidant',
|
||||
'description': '강황 추출물, 카레',
|
||||
'biomarker_effects': [
|
||||
{
|
||||
'biomarker': 'IL-1β',
|
||||
'effect_type': 'decreases',
|
||||
'magnitude': 'moderate',
|
||||
'percent_change': -35.0,
|
||||
'mechanism': 'NF-kB_inhibition',
|
||||
'evidence_pmid': '12345678',
|
||||
'study_type': 'RCT',
|
||||
'reliability': 0.90
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'food_name': '블루베리',
|
||||
'food_name_en': 'Blueberry',
|
||||
'category': 'anti_inflammatory',
|
||||
'subcategory': 'antioxidant',
|
||||
'description': '항산화 과일',
|
||||
'biomarker_effects': [
|
||||
{
|
||||
'biomarker': 'IL-1β',
|
||||
'effect_type': 'decreases',
|
||||
'magnitude': 'low',
|
||||
'percent_change': -20.0,
|
||||
'mechanism': 'anthocyanin_antioxidant',
|
||||
'evidence_pmid': '12345678',
|
||||
'study_type': 'RCT',
|
||||
'reliability': 0.85
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
for food_data in foods_data:
|
||||
# 1. Food 삽입
|
||||
self.cursor.execute("""
|
||||
INSERT INTO foods (food_name, food_name_en, category, subcategory, description)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING food_id
|
||||
""", (
|
||||
food_data['food_name'],
|
||||
food_data['food_name_en'],
|
||||
food_data['category'],
|
||||
food_data['subcategory'],
|
||||
food_data['description']
|
||||
))
|
||||
|
||||
result = self.cursor.fetchone()
|
||||
if result:
|
||||
food_id = result['food_id']
|
||||
else:
|
||||
# 이미 존재하는 경우 ID 조회
|
||||
self.cursor.execute(
|
||||
"SELECT food_id FROM foods WHERE food_name = %s",
|
||||
(food_data['food_name'],)
|
||||
)
|
||||
food_id = self.cursor.fetchone()['food_id']
|
||||
|
||||
print(f" ✓ {food_data['food_name']} (ID: {food_id})")
|
||||
|
||||
# 2. Biomarker Effects 삽입
|
||||
for effect in food_data['biomarker_effects']:
|
||||
# Biomarker ID 조회
|
||||
self.cursor.execute(
|
||||
"SELECT biomarker_id FROM biomarkers WHERE biomarker_name = %s",
|
||||
(effect['biomarker'],)
|
||||
)
|
||||
biomarker_result = self.cursor.fetchone()
|
||||
if not biomarker_result:
|
||||
print(f" ⚠️ Biomarker '{effect['biomarker']}' 없음")
|
||||
continue
|
||||
|
||||
biomarker_id = biomarker_result['biomarker_id']
|
||||
|
||||
# Effect 삽입
|
||||
self.cursor.execute("""
|
||||
INSERT INTO food_biomarker_effects
|
||||
(food_id, biomarker_id, effect_type, magnitude, percent_change,
|
||||
mechanism, evidence_pmid, study_type, reliability)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""", (
|
||||
food_id,
|
||||
biomarker_id,
|
||||
effect['effect_type'],
|
||||
effect['magnitude'],
|
||||
effect['percent_change'],
|
||||
effect['mechanism'],
|
||||
effect['evidence_pmid'],
|
||||
effect['study_type'],
|
||||
effect['reliability']
|
||||
))
|
||||
|
||||
print(f" → {effect['biomarker']} {effect['effect_type']} (PMID: {effect['evidence_pmid']})")
|
||||
|
||||
self.conn.commit()
|
||||
print(f"\n✅ {len(foods_data)}개 식품 데이터 입력 완료")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 데이터 입력 실패: {e}")
|
||||
self.conn.rollback()
|
||||
raise
|
||||
|
||||
def verify_data(self):
|
||||
"""데이터 검증"""
|
||||
print("\n🔍 데이터 검증 중...")
|
||||
|
||||
try:
|
||||
# IL-1β 증가시키는 식품 조회
|
||||
self.cursor.execute("""
|
||||
SELECT * FROM v_il1beta_increasing_foods
|
||||
""")
|
||||
foods = self.cursor.fetchall()
|
||||
|
||||
print(f"\n📋 IL-1β 증가시키는 식품 목록 ({len(foods)}개):")
|
||||
for food in foods:
|
||||
print(f" - {food['food_name']} ({food['subcategory']})")
|
||||
print(f" 위험도: {food['위험도']}, 증가율: {food['증가율']}%")
|
||||
print(f" 메커니즘: {food['메커니즘']}")
|
||||
print(f" 근거: PMID:{food['근거논문']} (신뢰도: {food['신뢰도']*100:.0f}%)")
|
||||
|
||||
# NAFLD 환자가 피해야 할 식품
|
||||
print("\n📋 NAFLD 환자가 피해야 할 식품:")
|
||||
self.cursor.execute("SELECT * FROM get_foods_to_avoid('K76.0')")
|
||||
avoid_foods = self.cursor.fetchall()
|
||||
|
||||
for food in avoid_foods:
|
||||
print(f" - {food['food_name']}")
|
||||
print(f" 이유: {food['reason']}")
|
||||
print(f" 근거: PMID:{food['evidence_pmid']}")
|
||||
|
||||
print("\n✅ 데이터 검증 완료")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 데이터 검증 실패: {e}")
|
||||
|
||||
def close(self):
|
||||
"""연결 종료"""
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
print("\n🔌 PostgreSQL 연결 종료")
|
||||
|
||||
|
||||
def main():
|
||||
"""메인 실행"""
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("IL-1β 증가 식품 데이터 입력")
|
||||
print("=" * 60)
|
||||
|
||||
# PostgreSQL 연결 설정
|
||||
db_config = {
|
||||
'host': 'localhost',
|
||||
'database': 'pharmacy_db',
|
||||
'user': 'postgres',
|
||||
'password': 'your_password_here', # 실제 비밀번호로 변경
|
||||
'port': 5432
|
||||
}
|
||||
|
||||
importer = IL1BetaFoodImporter(db_config)
|
||||
|
||||
try:
|
||||
importer.connect()
|
||||
importer.import_il1beta_foods()
|
||||
importer.verify_data()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 모든 작업 완료!")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 작업 실패: {e}")
|
||||
finally:
|
||||
importer.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
262
backend/qr_printer.py
Normal file
262
backend/qr_printer.py
Normal file
@@ -0,0 +1,262 @@
|
||||
# qr_printer.py - Brother QL-710W QR 라벨 인쇄
|
||||
# person-lookup-web-local/print_label.py에서 핵심 기능만 추출
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import io
|
||||
import logging
|
||||
import qrcode
|
||||
|
||||
# 프린터 설정
|
||||
PRINTER_IP = "192.168.0.121"
|
||||
PRINTER_MODEL = "QL-710W"
|
||||
LABEL_TYPE = "29" # 29mm 연속 출력 용지
|
||||
|
||||
# Windows 폰트 경로
|
||||
FONT_PATH = "C:/Windows/Fonts/malgunbd.ttf"
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
def create_drug_qr_label(drug_name, barcode, sale_price, drug_code=None, pharmacy_name='청춘약국'):
|
||||
"""
|
||||
약품 QR 라벨 이미지 생성
|
||||
|
||||
Parameters:
|
||||
drug_name (str): 약품명
|
||||
barcode (str): 바코드 (QR 코드로 변환)
|
||||
sale_price (float): 판매가격
|
||||
drug_code (str, optional): 약품 코드 (바코드가 없을 때 대체)
|
||||
pharmacy_name (str, optional): 약국 이름
|
||||
|
||||
Returns:
|
||||
PIL.Image: 생성된 라벨 이미지
|
||||
"""
|
||||
label_width = 306
|
||||
label_height = 380
|
||||
image = Image.new("1", (label_width, label_height), "white")
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# 폰트 설정
|
||||
try:
|
||||
drug_name_font = ImageFont.truetype(FONT_PATH, 32)
|
||||
price_font = ImageFont.truetype(FONT_PATH, 36)
|
||||
label_font = ImageFont.truetype(FONT_PATH, 24)
|
||||
except IOError:
|
||||
drug_name_font = ImageFont.load_default()
|
||||
price_font = ImageFont.load_default()
|
||||
label_font = ImageFont.load_default()
|
||||
logging.warning("폰트 로드 실패. 기본 폰트 사용.")
|
||||
|
||||
# 바코드가 없으면 약품 코드 사용
|
||||
qr_data = barcode if barcode else (drug_code if drug_code else "NO_BARCODE")
|
||||
|
||||
# QR 코드 생성
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=4,
|
||||
border=1,
|
||||
)
|
||||
qr.add_data(qr_data)
|
||||
qr.make(fit=True)
|
||||
qr_img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# QR 코드 크기 조정 및 배치
|
||||
qr_size = 130
|
||||
qr_img = qr_img.resize((qr_size, qr_size), Image.LANCZOS)
|
||||
qr_x = (label_width - qr_size) // 2
|
||||
qr_y = 15
|
||||
|
||||
if qr_img.mode != '1':
|
||||
qr_img = qr_img.convert('1')
|
||||
image.paste(qr_img, (qr_x, qr_y))
|
||||
|
||||
# 약품명 (QR 코드 아래)
|
||||
y_position = qr_y + qr_size + 10
|
||||
|
||||
def draw_wrapped_text(draw, text, y, font, max_width):
|
||||
"""텍스트를 여러 줄로 표시"""
|
||||
chars = list(text)
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
for char in chars:
|
||||
test_line = current_line + char
|
||||
bbox = draw.textbbox((0, 0), test_line, font=font)
|
||||
w = bbox[2] - bbox[0]
|
||||
|
||||
if w <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = char
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
lines = lines[:2] # 최대 2줄
|
||||
|
||||
for line in lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
draw.text(((label_width - w) / 2, y), line, font=font, fill="black")
|
||||
y += h + 5
|
||||
|
||||
return y
|
||||
|
||||
y_position = draw_wrapped_text(draw, drug_name, y_position, drug_name_font, label_width - 40)
|
||||
y_position += 8
|
||||
|
||||
# 가격
|
||||
if sale_price and sale_price > 0:
|
||||
price_text = f"₩{int(sale_price):,}"
|
||||
else:
|
||||
price_text = "가격 미정"
|
||||
|
||||
bbox = draw.textbbox((0, 0), price_text, font=price_font)
|
||||
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
draw.text(((label_width - w) / 2, y_position), price_text, font=price_font, fill="black")
|
||||
y_position += h + 15
|
||||
|
||||
# 구분선
|
||||
line_margin = 30
|
||||
draw.line([(line_margin, y_position), (label_width - line_margin, y_position)], fill="black", width=2)
|
||||
y_position += 20
|
||||
|
||||
# 약국 이름
|
||||
signature_text = " ".join(pharmacy_name)
|
||||
bbox = draw.textbbox((0, 0), signature_text, font=label_font)
|
||||
w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
|
||||
padding = 10
|
||||
box_x = (label_width - w_sig) / 2 - padding
|
||||
box_y = y_position
|
||||
box_x2 = box_x + w_sig + 2 * padding
|
||||
box_y2 = box_y + h_sig + 2 * padding
|
||||
draw.rectangle([(box_x, box_y), (box_x2, box_y2)], outline="black", width=2)
|
||||
draw.text(((label_width - w_sig) / 2, box_y + padding), signature_text, font=label_font, fill="black")
|
||||
|
||||
# 절취선 테두리
|
||||
draw_scissor_border(draw, label_width, label_height)
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def draw_scissor_border(draw, width, height, edge_size=10, steps=20):
|
||||
"""절취선 테두리"""
|
||||
# 상단
|
||||
top_points = []
|
||||
step_x = width / (steps * 2)
|
||||
for i in range(steps * 2 + 1):
|
||||
x = i * step_x
|
||||
y = 0 if i % 2 == 0 else edge_size
|
||||
top_points.append((int(x), int(y)))
|
||||
draw.line(top_points, fill="black", width=2)
|
||||
|
||||
# 하단
|
||||
bottom_points = []
|
||||
for i in range(steps * 2 + 1):
|
||||
x = i * step_x
|
||||
y = height if i % 2 == 0 else height - edge_size
|
||||
bottom_points.append((int(x), int(y)))
|
||||
draw.line(bottom_points, fill="black", width=2)
|
||||
|
||||
# 좌측
|
||||
left_points = []
|
||||
step_y = height / (steps * 2)
|
||||
for i in range(steps * 2 + 1):
|
||||
y = i * step_y
|
||||
x = 0 if i % 2 == 0 else edge_size
|
||||
left_points.append((int(x), int(y)))
|
||||
draw.line(left_points, fill="black", width=2)
|
||||
|
||||
# 우측
|
||||
right_points = []
|
||||
for i in range(steps * 2 + 1):
|
||||
y = i * step_y
|
||||
x = width if i % 2 == 0 else width - edge_size
|
||||
right_points.append((int(x), int(y)))
|
||||
draw.line(right_points, fill="black", width=2)
|
||||
|
||||
|
||||
def print_drug_qr_label(drug_name, barcode, sale_price, drug_code=None, pharmacy_name='청춘약국'):
|
||||
"""
|
||||
약품 QR 라벨 인쇄 실행
|
||||
|
||||
Parameters:
|
||||
drug_name (str): 약품명
|
||||
barcode (str): 바코드
|
||||
sale_price (float): 판매가격
|
||||
drug_code (str, optional): 약품 코드
|
||||
pharmacy_name (str, optional): 약국 이름
|
||||
|
||||
Returns:
|
||||
dict: 성공/실패 결과
|
||||
"""
|
||||
try:
|
||||
from brother_ql.raster import BrotherQLRaster
|
||||
from brother_ql.conversion import convert
|
||||
from brother_ql.backends.helpers import send
|
||||
|
||||
label_image = create_drug_qr_label(drug_name, barcode, sale_price, drug_code, pharmacy_name)
|
||||
|
||||
# 이미지를 메모리 스트림으로 변환
|
||||
image_stream = io.BytesIO()
|
||||
label_image.save(image_stream, format="PNG")
|
||||
image_stream.seek(0)
|
||||
|
||||
# Brother QL 프린터로 전송
|
||||
qlr = BrotherQLRaster(PRINTER_MODEL)
|
||||
instructions = convert(
|
||||
qlr=qlr,
|
||||
images=[Image.open(image_stream)],
|
||||
label=LABEL_TYPE,
|
||||
rotate="0",
|
||||
threshold=70.0,
|
||||
dither=False,
|
||||
compress=False,
|
||||
lq=True,
|
||||
red=False
|
||||
)
|
||||
send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100")
|
||||
|
||||
logging.info(f"QR 라벨 인쇄 성공: {drug_name}, 바코드={barcode}")
|
||||
return {"success": True, "message": f"{drug_name} QR 라벨 인쇄 완료"}
|
||||
|
||||
except ImportError as e:
|
||||
logging.error(f"brother_ql 라이브러리 없음: {e}")
|
||||
return {"success": False, "error": "brother_ql 라이브러리가 설치되지 않았습니다"}
|
||||
except Exception as e:
|
||||
logging.error(f"QR 라벨 인쇄 실패: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def preview_qr_label(drug_name, barcode, sale_price, drug_code=None, pharmacy_name='청춘약국'):
|
||||
"""
|
||||
QR 라벨 미리보기 (base64 이미지 반환)
|
||||
"""
|
||||
import base64
|
||||
|
||||
label_image = create_drug_qr_label(drug_name, barcode, sale_price, drug_code, pharmacy_name)
|
||||
|
||||
# PNG로 변환
|
||||
image_stream = io.BytesIO()
|
||||
# 1-bit 이미지를 RGB로 변환하여 더 깔끔하게
|
||||
rgb_image = label_image.convert('RGB')
|
||||
rgb_image.save(image_stream, format="PNG")
|
||||
image_stream.seek(0)
|
||||
|
||||
base64_image = base64.b64encode(image_stream.read()).decode('utf-8')
|
||||
return f"data:image/png;base64,{base64_image}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트
|
||||
result = print_drug_qr_label(
|
||||
drug_name="벤포파워Z",
|
||||
barcode="8806418067510",
|
||||
sale_price=3000,
|
||||
pharmacy_name="청춘약국"
|
||||
)
|
||||
print(result)
|
||||
@@ -5,6 +5,7 @@ MSSQL DB에서 약품 정보 조회 기능 포함
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
from datetime import datetime
|
||||
@@ -19,6 +20,8 @@ from sqlalchemy import text
|
||||
|
||||
# MSSQL 데이터베이스 연결
|
||||
sys.path.insert(0, '.')
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'db'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
from dbsetup import DatabaseManager
|
||||
|
||||
# 바코드 라벨 출력
|
||||
|
||||
713
backend/samples/pos_dummy_gui.py
Normal file
713
backend/samples/pos_dummy_gui.py
Normal file
@@ -0,0 +1,713 @@
|
||||
"""
|
||||
더미 POS 시스템 GUI (PyQt5)
|
||||
바코드 스캐너로 제품을 추가하고 수량 조절, 할인 적용, 결제까지 지원
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
from datetime import datetime
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QLabel, QGroupBox, QComboBox, QSpinBox,
|
||||
QTableWidget, QTableWidgetItem, QHeaderView, QFrame,
|
||||
QLineEdit, QDialog, QFormLayout, QDoubleSpinBox, QMessageBox,
|
||||
QAbstractItemView, QCheckBox, QSplitter
|
||||
)
|
||||
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer
|
||||
from PyQt5.QtGui import QFont, QColor, QBrush, QIcon
|
||||
from sqlalchemy import text
|
||||
|
||||
# DB 연결
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'db'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
from dbsetup import DatabaseManager
|
||||
|
||||
|
||||
# ─── GS1 바코드 파싱 ───────────────────────────────────────────
|
||||
|
||||
def parse_gs1_barcode(barcode):
|
||||
candidates = [barcode]
|
||||
if barcode.startswith('01') and len(barcode) >= 16:
|
||||
gtin14 = barcode[2:16]
|
||||
candidates.append(gtin14)
|
||||
if gtin14.startswith('0'):
|
||||
candidates.append(gtin14[1:])
|
||||
elif barcode.startswith('01') and len(barcode) == 15:
|
||||
candidates.append(barcode[2:15])
|
||||
return candidates
|
||||
|
||||
|
||||
def search_drug_by_barcode(barcode):
|
||||
try:
|
||||
db_manager = DatabaseManager()
|
||||
engine = db_manager.get_engine('PM_DRUG')
|
||||
query = text('''
|
||||
SELECT TOP 1
|
||||
BARCODE, GoodsName, DrugCode, SplName,
|
||||
Price, Saleprice, SUNG_CODE, IsUSE
|
||||
FROM CD_GOODS
|
||||
WHERE BARCODE = :barcode
|
||||
AND (GoodsName NOT LIKE N'%(판매중지)%'
|
||||
AND GoodsName NOT LIKE N'%(판매중단)%')
|
||||
ORDER BY
|
||||
CASE WHEN IsUSE = '1' THEN 0 ELSE 1 END,
|
||||
CASE WHEN Price > 0 THEN 0 ELSE 1 END,
|
||||
CASE WHEN SplName IS NOT NULL AND SplName != '' THEN 0 ELSE 1 END,
|
||||
DrugCode DESC
|
||||
''')
|
||||
candidates = parse_gs1_barcode(barcode)
|
||||
with engine.connect() as conn:
|
||||
for candidate in candidates:
|
||||
result = conn.execute(query, {"barcode": candidate})
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
return {
|
||||
'barcode': row.BARCODE,
|
||||
'goods_name': row.GoodsName,
|
||||
'drug_code': row.DrugCode,
|
||||
'manufacturer': row.SplName or '',
|
||||
'price': float(row.Price) if row.Price else 0,
|
||||
'sale_price': float(row.Saleprice) if row.Saleprice else 0,
|
||||
'sung_code': row.SUNG_CODE or ''
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f'[오류] 약품 조회 실패: {e}')
|
||||
return None
|
||||
|
||||
|
||||
# ─── 바코드 리더 스레드 ────────────────────────────────────────
|
||||
|
||||
class BarcodeReaderThread(QThread):
|
||||
barcode_received = pyqtSignal(str)
|
||||
connection_status = pyqtSignal(bool, str)
|
||||
|
||||
def __init__(self, port='COM3', baudrate=115200):
|
||||
super().__init__()
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.running = False
|
||||
self.serial_connection = None
|
||||
|
||||
def run(self):
|
||||
self.running = True
|
||||
try:
|
||||
self.serial_connection = serial.Serial(
|
||||
port=self.port, baudrate=self.baudrate,
|
||||
bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE, timeout=1
|
||||
)
|
||||
self.connection_status.emit(True, f'{self.port} 연결됨 ({self.baudrate} bps)')
|
||||
while self.running:
|
||||
if self.serial_connection.in_waiting > 0:
|
||||
data = self.serial_connection.read(self.serial_connection.in_waiting)
|
||||
try:
|
||||
text_data = data.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
text_data = data.decode('ascii', errors='ignore')
|
||||
for line in text_data.strip().split('\n'):
|
||||
barcode = line.strip()
|
||||
if barcode and len(barcode) in [13, 15, 16]:
|
||||
self.barcode_received.emit(barcode)
|
||||
except serial.SerialException as e:
|
||||
self.connection_status.emit(False, f'연결 실패: {e}')
|
||||
except Exception as e:
|
||||
self.connection_status.emit(False, f'오류: {e}')
|
||||
finally:
|
||||
if self.serial_connection and self.serial_connection.is_open:
|
||||
self.serial_connection.close()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
if self.serial_connection and self.serial_connection.is_open:
|
||||
self.serial_connection.close()
|
||||
|
||||
|
||||
class DrugSearchThread(QThread):
|
||||
search_complete = pyqtSignal(str, object)
|
||||
|
||||
def __init__(self, barcode):
|
||||
super().__init__()
|
||||
self.barcode = barcode
|
||||
|
||||
def run(self):
|
||||
info = search_drug_by_barcode(self.barcode)
|
||||
self.search_complete.emit(self.barcode, info)
|
||||
|
||||
|
||||
# ─── 할인 다이얼로그 ──────────────────────────────────────────
|
||||
|
||||
class DiscountDialog(QDialog):
|
||||
def __init__(self, item_name, current_price, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f'할인 적용 - {item_name}')
|
||||
self.setMinimumWidth(350)
|
||||
self.result_discount = 0
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
info = QLabel(f'제품: {item_name}\n판매가: {current_price:,.0f}원')
|
||||
info.setStyleSheet('font-size: 14px; padding: 10px;')
|
||||
layout.addWidget(info)
|
||||
|
||||
form = QFormLayout()
|
||||
|
||||
self.discount_type = QComboBox()
|
||||
self.discount_type.addItems(['금액 할인 (원)', '비율 할인 (%)'])
|
||||
form.addRow('할인 방식:', self.discount_type)
|
||||
|
||||
self.discount_value = QDoubleSpinBox()
|
||||
self.discount_value.setMaximum(999999)
|
||||
self.discount_value.setDecimals(0)
|
||||
form.addRow('할인값:', self.discount_value)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
self.preview_label = QLabel('')
|
||||
self.preview_label.setStyleSheet('font-size: 13px; color: #E53935; padding: 10px; font-weight: bold;')
|
||||
layout.addWidget(self.preview_label)
|
||||
|
||||
self.discount_value.valueChanged.connect(
|
||||
lambda: self._update_preview(current_price))
|
||||
self.discount_type.currentIndexChanged.connect(
|
||||
lambda: self._update_preview(current_price))
|
||||
|
||||
btn_layout = QHBoxLayout()
|
||||
ok_btn = QPushButton('적용')
|
||||
ok_btn.setStyleSheet('background: #4CAF50; color: white; font-weight: bold; padding: 8px 24px;')
|
||||
ok_btn.clicked.connect(lambda: self._apply(current_price))
|
||||
cancel_btn = QPushButton('취소')
|
||||
cancel_btn.setStyleSheet('padding: 8px 24px;')
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_layout.addWidget(cancel_btn)
|
||||
btn_layout.addWidget(ok_btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _update_preview(self, price):
|
||||
val = self.discount_value.value()
|
||||
if self.discount_type.currentIndex() == 0:
|
||||
disc = val
|
||||
else:
|
||||
disc = price * val / 100
|
||||
final = max(0, price - disc)
|
||||
self.preview_label.setText(f'할인: -{disc:,.0f}원 → 최종가: {final:,.0f}원')
|
||||
|
||||
def _apply(self, price):
|
||||
val = self.discount_value.value()
|
||||
if self.discount_type.currentIndex() == 0:
|
||||
self.result_discount = val
|
||||
else:
|
||||
self.result_discount = price * val / 100
|
||||
self.accept()
|
||||
|
||||
|
||||
# ─── 메인 POS GUI ─────────────────────────────────────────────
|
||||
|
||||
class POSDummyGUI(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.reader_thread = None
|
||||
self.search_threads = []
|
||||
self.cart_items = [] # [{barcode, goods_name, manufacturer, price, sale_price, qty, discount}]
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
self.setWindowTitle('청춘약국 POS')
|
||||
self.setGeometry(50, 50, 1200, 800)
|
||||
self.setStyleSheet('''
|
||||
QMainWindow { background: #F5F5F5; }
|
||||
QGroupBox {
|
||||
font-weight: bold; font-size: 13px;
|
||||
border: 1px solid #E0E0E0; border-radius: 6px;
|
||||
margin-top: 12px; padding-top: 18px;
|
||||
background: white;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 12px; padding: 0 6px;
|
||||
}
|
||||
''')
|
||||
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
root_layout = QVBoxLayout()
|
||||
root_layout.setContentsMargins(12, 8, 12, 8)
|
||||
central.setLayout(root_layout)
|
||||
|
||||
# ── 상단: 연결 설정 ──
|
||||
conn_group = QGroupBox('스캐너 연결')
|
||||
conn_layout = QHBoxLayout()
|
||||
conn_group.setLayout(conn_layout)
|
||||
|
||||
conn_layout.addWidget(QLabel('포트:'))
|
||||
self.port_combo = QComboBox()
|
||||
self.port_combo.setMinimumWidth(200)
|
||||
self._refresh_ports()
|
||||
conn_layout.addWidget(self.port_combo)
|
||||
|
||||
refresh_btn = QPushButton('⟳')
|
||||
refresh_btn.setFixedWidth(36)
|
||||
refresh_btn.clicked.connect(self._refresh_ports)
|
||||
conn_layout.addWidget(refresh_btn)
|
||||
|
||||
conn_layout.addWidget(QLabel('속도:'))
|
||||
self.baudrate_spin = QSpinBox()
|
||||
self.baudrate_spin.setRange(9600, 921600)
|
||||
self.baudrate_spin.setValue(115200)
|
||||
self.baudrate_spin.setSingleStep(9600)
|
||||
conn_layout.addWidget(self.baudrate_spin)
|
||||
|
||||
self.connect_btn = QPushButton('연결')
|
||||
self.connect_btn.setStyleSheet(
|
||||
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||
self.connect_btn.clicked.connect(self._toggle_connection)
|
||||
conn_layout.addWidget(self.connect_btn)
|
||||
|
||||
self.status_label = QLabel('대기 중')
|
||||
self.status_label.setStyleSheet('color: #9E9E9E; font-size: 13px; margin-left: 12px;')
|
||||
conn_layout.addWidget(self.status_label)
|
||||
conn_layout.addStretch()
|
||||
|
||||
# 수동 바코드 입력
|
||||
conn_layout.addWidget(QLabel('수동입력:'))
|
||||
self.manual_input = QLineEdit()
|
||||
self.manual_input.setPlaceholderText('바코드 번호 입력 후 Enter')
|
||||
self.manual_input.setMinimumWidth(180)
|
||||
self.manual_input.returnPressed.connect(self._manual_barcode)
|
||||
conn_layout.addWidget(self.manual_input)
|
||||
|
||||
root_layout.addWidget(conn_group)
|
||||
|
||||
# ── 중앙: 장바구니 테이블 + 우측 요약 ──
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
# 장바구니 테이블
|
||||
cart_group = QGroupBox('장바구니')
|
||||
cart_layout = QVBoxLayout()
|
||||
cart_group.setLayout(cart_layout)
|
||||
|
||||
self.cart_table = QTableWidget()
|
||||
self.cart_table.setColumnCount(8)
|
||||
self.cart_table.setHorizontalHeaderLabels([
|
||||
'제품명', '제조사', '바코드', '입고가', '판매가', '수량', '할인', '소계'
|
||||
])
|
||||
header = self.cart_table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
for i in [1]:
|
||||
header.setSectionResizeMode(i, QHeaderView.ResizeToContents)
|
||||
for i in [2, 3, 4, 5, 6, 7]:
|
||||
header.setSectionResizeMode(i, QHeaderView.ResizeToContents)
|
||||
|
||||
self.cart_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.cart_table.setAlternatingRowColors(True)
|
||||
self.cart_table.setStyleSheet('''
|
||||
QTableWidget {
|
||||
font-size: 13px; gridline-color: #E0E0E0;
|
||||
alternate-background-color: #FAFAFA;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background: #37474F; color: white;
|
||||
font-weight: bold; font-size: 12px;
|
||||
padding: 6px; border: none;
|
||||
}
|
||||
''')
|
||||
self.cart_table.verticalHeader().setVisible(False)
|
||||
cart_layout.addWidget(self.cart_table)
|
||||
|
||||
# 장바구니 아래 버튼들
|
||||
cart_btn_layout = QHBoxLayout()
|
||||
|
||||
qty_up_btn = QPushButton('+1')
|
||||
qty_up_btn.setStyleSheet(
|
||||
'background: #2196F3; color: white; font-weight: bold; font-size: 16px; padding: 8px 16px; border-radius: 4px;')
|
||||
qty_up_btn.clicked.connect(lambda: self._change_qty(1))
|
||||
cart_btn_layout.addWidget(qty_up_btn)
|
||||
|
||||
qty_down_btn = QPushButton('-1')
|
||||
qty_down_btn.setStyleSheet(
|
||||
'background: #FF9800; color: white; font-weight: bold; font-size: 16px; padding: 8px 16px; border-radius: 4px;')
|
||||
qty_down_btn.clicked.connect(lambda: self._change_qty(-1))
|
||||
cart_btn_layout.addWidget(qty_down_btn)
|
||||
|
||||
discount_btn = QPushButton('할인')
|
||||
discount_btn.setStyleSheet(
|
||||
'background: #9C27B0; color: white; font-weight: bold; font-size: 14px; padding: 8px 16px; border-radius: 4px;')
|
||||
discount_btn.clicked.connect(self._apply_discount)
|
||||
cart_btn_layout.addWidget(discount_btn)
|
||||
|
||||
remove_btn = QPushButton('삭제')
|
||||
remove_btn.setStyleSheet(
|
||||
'background: #F44336; color: white; font-weight: bold; font-size: 14px; padding: 8px 16px; border-radius: 4px;')
|
||||
remove_btn.clicked.connect(self._remove_selected)
|
||||
cart_btn_layout.addWidget(remove_btn)
|
||||
|
||||
cart_btn_layout.addStretch()
|
||||
|
||||
clear_btn = QPushButton('전체 삭제')
|
||||
clear_btn.setStyleSheet(
|
||||
'background: #757575; color: white; font-size: 13px; padding: 8px 16px; border-radius: 4px;')
|
||||
clear_btn.clicked.connect(self._clear_cart)
|
||||
cart_btn_layout.addWidget(clear_btn)
|
||||
|
||||
cart_layout.addLayout(cart_btn_layout)
|
||||
splitter.addWidget(cart_group)
|
||||
|
||||
# ── 우측 패널: 요약 + 결제 ──
|
||||
right_panel = QWidget()
|
||||
right_layout = QVBoxLayout()
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_panel.setLayout(right_layout)
|
||||
|
||||
# 최근 스캔
|
||||
scan_group = QGroupBox('최근 스캔')
|
||||
scan_layout = QVBoxLayout()
|
||||
scan_group.setLayout(scan_layout)
|
||||
|
||||
self.last_scan_label = QLabel('바코드를 스캔하세요')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #757575; padding: 12px;')
|
||||
self.last_scan_label.setWordWrap(True)
|
||||
self.last_scan_label.setMinimumHeight(80)
|
||||
scan_layout.addWidget(self.last_scan_label)
|
||||
|
||||
right_layout.addWidget(scan_group)
|
||||
|
||||
# 합계 요약
|
||||
summary_group = QGroupBox('합계')
|
||||
summary_layout = QVBoxLayout()
|
||||
summary_group.setLayout(summary_layout)
|
||||
|
||||
self.item_count_label = QLabel('품목: 0개 / 수량: 0개')
|
||||
self.item_count_label.setStyleSheet('font-size: 14px; color: #616161; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.item_count_label)
|
||||
|
||||
sep1 = QFrame()
|
||||
sep1.setFrameShape(QFrame.HLine)
|
||||
sep1.setStyleSheet('color: #E0E0E0;')
|
||||
summary_layout.addWidget(sep1)
|
||||
|
||||
self.cost_label = QLabel('입고 합계: 0원')
|
||||
self.cost_label.setStyleSheet('font-size: 13px; color: #9E9E9E; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.cost_label)
|
||||
|
||||
self.subtotal_label = QLabel('판매 합계: 0원')
|
||||
self.subtotal_label.setStyleSheet('font-size: 14px; color: #424242; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.subtotal_label)
|
||||
|
||||
self.discount_total_label = QLabel('할인 합계: -0원')
|
||||
self.discount_total_label.setStyleSheet('font-size: 14px; color: #E53935; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.discount_total_label)
|
||||
|
||||
sep2 = QFrame()
|
||||
sep2.setFrameShape(QFrame.HLine)
|
||||
sep2.setStyleSheet('color: #37474F; border: 1px solid #37474F;')
|
||||
summary_layout.addWidget(sep2)
|
||||
|
||||
self.total_label = QLabel('총 결제금액: 0원')
|
||||
self.total_label.setStyleSheet(
|
||||
'font-size: 22px; font-weight: bold; color: #1B5E20; padding: 8px;')
|
||||
summary_layout.addWidget(self.total_label)
|
||||
|
||||
self.margin_label = QLabel('마진: 0원 (0%)')
|
||||
self.margin_label.setStyleSheet('font-size: 13px; color: #1565C0; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.margin_label)
|
||||
|
||||
right_layout.addWidget(summary_group)
|
||||
right_layout.addStretch()
|
||||
|
||||
# 결제 버튼
|
||||
pay_btn = QPushButton('결 제')
|
||||
pay_btn.setMinimumHeight(70)
|
||||
pay_btn.setStyleSheet('''
|
||||
QPushButton {
|
||||
background: #1B5E20; color: white;
|
||||
font-size: 26px; font-weight: bold;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QPushButton:hover { background: #2E7D32; }
|
||||
QPushButton:pressed { background: #1B5E20; }
|
||||
''')
|
||||
pay_btn.clicked.connect(self._pay)
|
||||
right_layout.addWidget(pay_btn)
|
||||
|
||||
splitter.addWidget(right_panel)
|
||||
splitter.setSizes([800, 350])
|
||||
|
||||
root_layout.addWidget(splitter, 1)
|
||||
|
||||
# ── 하단 상태바 ──
|
||||
self.statusBar().setStyleSheet('font-size: 12px; color: #757575;')
|
||||
self.statusBar().showMessage('청춘약국 POS | 바코드 스캐너를 연결하고 "연결" 버튼을 누르세요')
|
||||
|
||||
# ── 포트 관리 ──
|
||||
|
||||
def _refresh_ports(self):
|
||||
self.port_combo.clear()
|
||||
for port in serial.tools.list_ports.comports():
|
||||
self.port_combo.addItem(f'{port.device} - {port.description}', port.device)
|
||||
for i in range(self.port_combo.count()):
|
||||
if 'COM3' in (self.port_combo.itemData(i) or ''):
|
||||
self.port_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
def _toggle_connection(self):
|
||||
if self.reader_thread and self.reader_thread.isRunning():
|
||||
self.reader_thread.stop()
|
||||
self.reader_thread.wait()
|
||||
self.reader_thread = None
|
||||
self.connect_btn.setText('연결')
|
||||
self.connect_btn.setStyleSheet(
|
||||
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||
self.status_label.setText('연결 해제됨')
|
||||
self.status_label.setStyleSheet('color: #9E9E9E; font-size: 13px; margin-left: 12px;')
|
||||
self.statusBar().showMessage('스캐너 연결 해제')
|
||||
else:
|
||||
port = self.port_combo.currentData()
|
||||
if not port:
|
||||
self.status_label.setText('포트를 선택하세요')
|
||||
return
|
||||
self.reader_thread = BarcodeReaderThread(port, self.baudrate_spin.value())
|
||||
self.reader_thread.barcode_received.connect(self._on_barcode)
|
||||
self.reader_thread.connection_status.connect(self._on_connection)
|
||||
self.reader_thread.start()
|
||||
self.connect_btn.setText('연결 해제')
|
||||
self.connect_btn.setStyleSheet(
|
||||
'background: #F44336; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||
|
||||
def _on_connection(self, ok, msg):
|
||||
if ok:
|
||||
self.status_label.setText(msg)
|
||||
self.status_label.setStyleSheet(
|
||||
'color: #2E7D32; font-size: 13px; font-weight: bold; margin-left: 12px;')
|
||||
self.statusBar().showMessage(f'스캐너 {msg} | 바코드를 스캔하세요')
|
||||
else:
|
||||
self.status_label.setText(msg)
|
||||
self.status_label.setStyleSheet(
|
||||
'color: #D32F2F; font-size: 13px; font-weight: bold; margin-left: 12px;')
|
||||
self.connect_btn.setText('연결')
|
||||
self.connect_btn.setStyleSheet(
|
||||
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||
|
||||
# ── 바코드 수신 ──
|
||||
|
||||
def _manual_barcode(self):
|
||||
barcode = self.manual_input.text().strip()
|
||||
if barcode:
|
||||
self.manual_input.clear()
|
||||
self._on_barcode(barcode)
|
||||
|
||||
def _on_barcode(self, barcode):
|
||||
self.last_scan_label.setText(f'스캔: {barcode}\n조회 중...')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #FF6F00; padding: 12px;')
|
||||
self.statusBar().showMessage(f'바코드 {barcode} 조회 중...')
|
||||
|
||||
thread = DrugSearchThread(barcode)
|
||||
thread.search_complete.connect(self._on_search_done)
|
||||
thread.start()
|
||||
self.search_threads.append(thread)
|
||||
|
||||
def _on_search_done(self, barcode, info):
|
||||
sender = self.sender()
|
||||
if sender in self.search_threads:
|
||||
self.search_threads.remove(sender)
|
||||
|
||||
if not info:
|
||||
self.last_scan_label.setText(f'스캔: {barcode}\n제품을 찾을 수 없습니다')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #D32F2F; padding: 12px;')
|
||||
self.statusBar().showMessage(f'바코드 {barcode}: 데이터베이스에서 찾을 수 없음')
|
||||
return
|
||||
|
||||
# 이미 장바구니에 있으면 수량 +1
|
||||
for item in self.cart_items:
|
||||
if item['barcode'] == info['barcode']:
|
||||
item['qty'] += 1
|
||||
self._refresh_table()
|
||||
self.last_scan_label.setText(
|
||||
f'{info["goods_name"]}\n수량 → {item["qty"]}개')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #1565C0; padding: 12px;')
|
||||
self.statusBar().showMessage(f'{info["goods_name"]} 수량 +1 ({item["qty"]}개)')
|
||||
return
|
||||
|
||||
# 새 항목 추가
|
||||
self.cart_items.append({
|
||||
'barcode': info['barcode'],
|
||||
'goods_name': info['goods_name'],
|
||||
'manufacturer': info['manufacturer'],
|
||||
'price': info['price'],
|
||||
'sale_price': info['sale_price'],
|
||||
'qty': 1,
|
||||
'discount': 0,
|
||||
})
|
||||
self._refresh_table()
|
||||
self.last_scan_label.setText(
|
||||
f'{info["goods_name"]}\n{info["manufacturer"]} | {info["sale_price"]:,.0f}원')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #2E7D32; padding: 12px; font-weight: bold;')
|
||||
self.statusBar().showMessage(f'{info["goods_name"]} 추가됨 ({info["sale_price"]:,.0f}원)')
|
||||
|
||||
# ── 장바구니 조작 ──
|
||||
|
||||
def _selected_row(self):
|
||||
rows = self.cart_table.selectionModel().selectedRows()
|
||||
return rows[0].row() if rows else -1
|
||||
|
||||
def _change_qty(self, delta):
|
||||
row = self._selected_row()
|
||||
if row < 0:
|
||||
self.statusBar().showMessage('제품을 선택하세요')
|
||||
return
|
||||
item = self.cart_items[row]
|
||||
item['qty'] = max(1, item['qty'] + delta)
|
||||
self._refresh_table()
|
||||
self.cart_table.selectRow(row)
|
||||
|
||||
def _apply_discount(self):
|
||||
row = self._selected_row()
|
||||
if row < 0:
|
||||
self.statusBar().showMessage('할인할 제품을 선택하세요')
|
||||
return
|
||||
item = self.cart_items[row]
|
||||
dlg = DiscountDialog(item['goods_name'], item['sale_price'], self)
|
||||
if dlg.exec_() == QDialog.Accepted:
|
||||
item['discount'] = dlg.result_discount
|
||||
self._refresh_table()
|
||||
self.cart_table.selectRow(row)
|
||||
|
||||
def _remove_selected(self):
|
||||
row = self._selected_row()
|
||||
if row < 0:
|
||||
self.statusBar().showMessage('삭제할 제품을 선택하세요')
|
||||
return
|
||||
name = self.cart_items[row]['goods_name']
|
||||
del self.cart_items[row]
|
||||
self._refresh_table()
|
||||
self.statusBar().showMessage(f'{name} 삭제됨')
|
||||
|
||||
def _clear_cart(self):
|
||||
if not self.cart_items:
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
self, '전체 삭제', '장바구니를 비우시겠습니까?',
|
||||
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.cart_items.clear()
|
||||
self._refresh_table()
|
||||
self.statusBar().showMessage('장바구니 초기화')
|
||||
|
||||
# ── 테이블 갱신 ──
|
||||
|
||||
def _refresh_table(self):
|
||||
self.cart_table.setRowCount(len(self.cart_items))
|
||||
|
||||
total_cost = 0
|
||||
total_sale = 0
|
||||
total_discount = 0
|
||||
total_qty = 0
|
||||
|
||||
for i, item in enumerate(self.cart_items):
|
||||
subtotal = (item['sale_price'] - item['discount']) * item['qty']
|
||||
cost_total = item['price'] * item['qty']
|
||||
|
||||
cols = [
|
||||
item['goods_name'],
|
||||
item['manufacturer'],
|
||||
item['barcode'],
|
||||
f'{item["price"]:,.0f}',
|
||||
f'{item["sale_price"]:,.0f}',
|
||||
str(item['qty']),
|
||||
f'-{item["discount"]:,.0f}' if item['discount'] > 0 else '',
|
||||
f'{subtotal:,.0f}',
|
||||
]
|
||||
|
||||
for j, val in enumerate(cols):
|
||||
cell = QTableWidgetItem(val)
|
||||
cell.setFlags(cell.flags() & ~Qt.ItemIsEditable)
|
||||
# 숫자 컬럼 오른쪽 정렬
|
||||
if j >= 3:
|
||||
cell.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
# 할인 빨간색
|
||||
if j == 6 and item['discount'] > 0:
|
||||
cell.setForeground(QBrush(QColor('#E53935')))
|
||||
# 소계 볼드
|
||||
if j == 7:
|
||||
font = cell.font()
|
||||
font.setBold(True)
|
||||
cell.setFont(font)
|
||||
self.cart_table.setItem(i, j, cell)
|
||||
|
||||
total_cost += cost_total
|
||||
total_sale += item['sale_price'] * item['qty']
|
||||
total_discount += item['discount'] * item['qty']
|
||||
total_qty += item['qty']
|
||||
|
||||
final_total = total_sale - total_discount
|
||||
margin = final_total - total_cost
|
||||
margin_pct = (margin / final_total * 100) if final_total > 0 else 0
|
||||
|
||||
self.item_count_label.setText(f'품목: {len(self.cart_items)}개 / 수량: {total_qty}개')
|
||||
self.cost_label.setText(f'입고 합계: {total_cost:,.0f}원')
|
||||
self.subtotal_label.setText(f'판매 합계: {total_sale:,.0f}원')
|
||||
self.discount_total_label.setText(f'할인 합계: -{total_discount:,.0f}원')
|
||||
self.total_label.setText(f'총 결제금액: {final_total:,.0f}원')
|
||||
self.margin_label.setText(f'마진: {margin:,.0f}원 ({margin_pct:.1f}%)')
|
||||
|
||||
# ── 결제 ──
|
||||
|
||||
def _pay(self):
|
||||
if not self.cart_items:
|
||||
self.statusBar().showMessage('장바구니가 비어있습니다')
|
||||
return
|
||||
|
||||
total_sale = sum(it['sale_price'] * it['qty'] for it in self.cart_items)
|
||||
total_discount = sum(it['discount'] * it['qty'] for it in self.cart_items)
|
||||
final = total_sale - total_discount
|
||||
|
||||
items_text = '\n'.join(
|
||||
f' {it["goods_name"]} x{it["qty"]} {(it["sale_price"] - it["discount"]) * it["qty"]:,.0f}원'
|
||||
for it in self.cart_items
|
||||
)
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self, '결제 확인',
|
||||
f'총 결제금액: {final:,.0f}원\n\n{items_text}\n\n결제하시겠습니까?',
|
||||
QMessageBox.Yes | QMessageBox.No, QMessageBox.No
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
QMessageBox.information(
|
||||
self, '결제 완료',
|
||||
f'결제가 완료되었습니다.\n\n'
|
||||
f'시각: {now}\n'
|
||||
f'금액: {final:,.0f}원\n'
|
||||
f'품목: {len(self.cart_items)}개'
|
||||
)
|
||||
self.cart_items.clear()
|
||||
self._refresh_table()
|
||||
self.last_scan_label.setText('바코드를 스캔하세요')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #757575; padding: 12px;')
|
||||
self.statusBar().showMessage(f'결제 완료 ({final:,.0f}원) | {now}')
|
||||
|
||||
# ── 종료 ──
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.reader_thread:
|
||||
self.reader_thread.stop()
|
||||
self.reader_thread.wait()
|
||||
for t in self.search_threads:
|
||||
if t.isRunning():
|
||||
t.wait()
|
||||
event.accept()
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
app.setStyle('Fusion')
|
||||
window = POSDummyGUI()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
4
backend/scripts/start_server.bat
Normal file
4
backend/scripts/start_server.bat
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
powershell -ExecutionPolicy Bypass -File "%~dp0start_server.ps1"
|
||||
pause
|
||||
35
backend/scripts/start_server.ps1
Normal file
35
backend/scripts/start_server.ps1
Normal file
@@ -0,0 +1,35 @@
|
||||
# start_server.ps1 - Flask 서버 시작 스크립트
|
||||
# 기존 프로세스 종료 후 새로 시작
|
||||
|
||||
$PORT = 7001
|
||||
$SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$BACKEND_DIR = Split-Path -Parent $SCRIPT_DIR
|
||||
|
||||
Write-Host "=== 청춘약국 마일리지 서버 시작 ===" -ForegroundColor Cyan
|
||||
|
||||
# 1. 기존 포트 사용 프로세스 종료
|
||||
Write-Host "1. 포트 $PORT 확인 중..." -ForegroundColor Yellow
|
||||
$netstat = netstat -ano | Select-String ":$PORT.*LISTENING"
|
||||
if ($netstat) {
|
||||
$pid = ($netstat -split '\s+')[-1]
|
||||
Write-Host " 기존 프로세스 발견 (PID: $pid). 종료합니다..." -ForegroundColor Red
|
||||
taskkill /F /PID $pid 2>$null
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# 2. 서버 시작
|
||||
Write-Host "2. 서버 시작 중..." -ForegroundColor Yellow
|
||||
Set-Location $BACKEND_DIR
|
||||
Start-Process python -ArgumentList "app.py" -WindowStyle Hidden
|
||||
|
||||
# 3. 시작 확인
|
||||
Start-Sleep -Seconds 3
|
||||
$check = netstat -ano | Select-String ":$PORT.*LISTENING"
|
||||
if ($check) {
|
||||
Write-Host "=== 서버 시작 완료! ===" -ForegroundColor Green
|
||||
Write-Host "URL: http://localhost:$PORT" -ForegroundColor Cyan
|
||||
Write-Host "외부: http://192.168.0.14:$PORT" -ForegroundColor Cyan
|
||||
} else {
|
||||
Write-Host "=== 서버 시작 실패 ===" -ForegroundColor Red
|
||||
Write-Host "로그를 확인하세요." -ForegroundColor Red
|
||||
}
|
||||
4
backend/scripts/stop_server.bat
Normal file
4
backend/scripts/stop_server.bat
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
powershell -ExecutionPolicy Bypass -File "%~dp0stop_server.ps1"
|
||||
pause
|
||||
15
backend/scripts/stop_server.ps1
Normal file
15
backend/scripts/stop_server.ps1
Normal file
@@ -0,0 +1,15 @@
|
||||
# stop_server.ps1 - Flask 서버 중지 스크립트
|
||||
|
||||
$PORT = 7001
|
||||
|
||||
Write-Host "=== 청춘약국 마일리지 서버 중지 ===" -ForegroundColor Cyan
|
||||
|
||||
$netstat = netstat -ano | Select-String ":$PORT.*LISTENING"
|
||||
if ($netstat) {
|
||||
$pid = ($netstat -split '\s+')[-1]
|
||||
Write-Host "서버 프로세스 종료 중 (PID: $pid)..." -ForegroundColor Yellow
|
||||
taskkill /F /PID $pid 2>$null
|
||||
Write-Host "=== 서버 중지 완료 ===" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "실행 중인 서버가 없습니다." -ForegroundColor Yellow
|
||||
}
|
||||
175
backend/scripts/tag_animal_drugs.py
Normal file
175
backend/scripts/tag_animal_drugs.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
동물약 태깅 및 MSSQL 동기화
|
||||
1. 키워드로 CD_GOODS에서 동물약 검색
|
||||
2. SQLite drug_tags.db에 태깅
|
||||
3. MSSQL CD_GOODS.POS_BOON = '010103' 업데이트
|
||||
"""
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from db.dbsetup import db_manager
|
||||
from sqlalchemy import text
|
||||
|
||||
# SQLite DB 경로
|
||||
DB_PATH = Path(__file__).parent.parent / 'db' / 'drug_tags.db'
|
||||
|
||||
# 동물약 키워드
|
||||
ANIMAL_KEYWORDS = [
|
||||
'동물', '반려', '애견', '강아지', '고양이', '반려견',
|
||||
'넥스가드', '브라벡토', '심파리카', '크레델리오', '컴포티스',
|
||||
'하트세이버', '하트가드', '다이로하트', '하트웜', '하트캅',
|
||||
'안텔민', '파라캅', '제스타제',
|
||||
'캐치원', '셀라이트', '가드닐', '리펠로', '심피드독',
|
||||
'세레니아', '아포퀄', '갈리프란트', '클라펫',
|
||||
'펫팜', '동물약품', '애니팜'
|
||||
]
|
||||
|
||||
# 제외 키워드 (사람용 약)
|
||||
EXCLUDE_KEYWORDS = [
|
||||
'헤리펫사', '토피라펫', '메타트레이스', '페리돈', '세파라캅'
|
||||
]
|
||||
|
||||
def init_sqlite_db():
|
||||
"""SQLite DB 초기화"""
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS drug_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
drug_code TEXT NOT NULL,
|
||||
drug_name TEXT,
|
||||
barcode TEXT,
|
||||
tag_type TEXT NOT NULL,
|
||||
tag_value TEXT,
|
||||
note TEXT,
|
||||
source TEXT DEFAULT 'keyword',
|
||||
confidence REAL DEFAULT 0.8,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(drug_code, tag_type)
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"✅ SQLite DB 준비: {DB_PATH}")
|
||||
|
||||
def search_animal_drugs():
|
||||
"""MSSQL에서 동물약 키워드 검색"""
|
||||
print("🔍 CD_GOODS에서 동물약 검색 중...")
|
||||
|
||||
session = db_manager.get_session('PM_DRUG')
|
||||
|
||||
# 키워드 조건 생성
|
||||
conditions = ' OR '.join([f"GoodsName LIKE '%{kw}%'" for kw in ANIMAL_KEYWORDS])
|
||||
|
||||
query = text(f"""
|
||||
SELECT DrugCode, GoodsName, BARCODE, POS_BOON
|
||||
FROM CD_GOODS
|
||||
WHERE ({conditions})
|
||||
AND GoodsSelCode = 'B'
|
||||
""")
|
||||
|
||||
result = session.execute(query)
|
||||
drugs = result.fetchall()
|
||||
print(f"✅ 발견: {len(drugs)}개")
|
||||
return drugs
|
||||
|
||||
def tag_to_sqlite(drugs):
|
||||
"""SQLite에 동물약 태깅"""
|
||||
print("\n📝 SQLite 태깅 중...")
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
added = 0
|
||||
skipped = 0
|
||||
excluded = 0
|
||||
|
||||
for drug in drugs:
|
||||
drug_code = drug[0]
|
||||
drug_name = drug[1] or ''
|
||||
barcode = drug[2]
|
||||
|
||||
# 제외 키워드 체크
|
||||
if any(ex in drug_name for ex in EXCLUDE_KEYWORDS):
|
||||
excluded += 1
|
||||
print(f" ⛔ 제외: {drug_code} - {drug_name}")
|
||||
continue
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
INSERT INTO drug_tags (drug_code, drug_name, barcode, tag_type, tag_value, note)
|
||||
VALUES (?, ?, ?, 'animal_drug', 'all', '키워드 자동 태깅')
|
||||
''', (drug_code, drug_name, barcode))
|
||||
added += 1
|
||||
print(f" ✅ {drug_code}: {drug_name}")
|
||||
except sqlite3.IntegrityError:
|
||||
skipped += 1
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print(f"\n📊 태깅 결과: 추가 {added}개, 중복 {skipped}개, 제외 {excluded}개")
|
||||
return added
|
||||
|
||||
def sync_to_mssql():
|
||||
"""SQLite 태그를 MSSQL POS_BOON에 동기화"""
|
||||
print("\n🔄 MSSQL 동기화 중...")
|
||||
|
||||
# SQLite에서 동물약 목록 가져오기
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT drug_code, drug_name FROM drug_tags
|
||||
WHERE tag_type = 'animal_drug' AND is_active = 1
|
||||
''')
|
||||
animal_drugs = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
print(f" 동물약 {len(animal_drugs)}개 → POS_BOON='010103' 업데이트")
|
||||
|
||||
# MSSQL 업데이트
|
||||
session = db_manager.get_session('PM_DRUG')
|
||||
updated = 0
|
||||
|
||||
for drug_code, drug_name in animal_drugs:
|
||||
try:
|
||||
result = session.execute(text('''
|
||||
UPDATE CD_GOODS SET POS_BOON = '010103' WHERE DrugCode = :dc
|
||||
'''), {'dc': drug_code})
|
||||
session.commit()
|
||||
if result.rowcount > 0:
|
||||
updated += 1
|
||||
print(f" ✅ {drug_code}: {drug_name}")
|
||||
except Exception as e:
|
||||
print(f" ❌ {drug_code}: {e}")
|
||||
|
||||
print(f"\n🎉 완료! MSSQL 업데이트: {updated}개")
|
||||
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print("🐾 동물약 태깅 시스템")
|
||||
print("=" * 50)
|
||||
|
||||
# 1. SQLite 초기화
|
||||
init_sqlite_db()
|
||||
|
||||
# 2. 동물약 검색
|
||||
drugs = search_animal_drugs()
|
||||
|
||||
# 3. SQLite 태깅
|
||||
tag_to_sqlite(drugs)
|
||||
|
||||
# 4. MSSQL 동기화
|
||||
sync_to_mssql()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("✅ 모든 작업 완료!")
|
||||
print("=" * 50)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
469
backend/services/clawdbot_client.py
Normal file
469
backend/services/clawdbot_client.py
Normal file
@@ -0,0 +1,469 @@
|
||||
"""
|
||||
Clawdbot Gateway Python 클라이언트
|
||||
카카오톡 봇과 동일한 Gateway WebSocket API를 통해 Claude와 통신
|
||||
추가 API 비용 없음 (Claude Max 구독 재활용)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Windows 콘솔 UTF-8 강제 (한글 깨짐 방지)
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
if hasattr(sys.stdout, 'buffer'):
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import websockets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Gateway 설정 (clawdbot.json에서 읽기)
|
||||
CLAWDBOT_CONFIG_PATH = Path.home() / '.clawdbot' / 'clawdbot.json'
|
||||
|
||||
|
||||
def _load_gateway_config():
|
||||
"""clawdbot.json에서 Gateway 설정 로드"""
|
||||
try:
|
||||
with open(CLAWDBOT_CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
gw = config.get('gateway', {})
|
||||
return {
|
||||
'port': gw.get('port', 18789),
|
||||
'token': gw.get('auth', {}).get('token', ''),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"[Clawdbot] 설정 파일 로드 실패: {e}")
|
||||
return {'port': 18789, 'token': ''}
|
||||
|
||||
|
||||
async def _ask_gateway(message, session_id='pharmacy-upsell',
|
||||
system_prompt=None, timeout=60, model=None):
|
||||
"""
|
||||
Clawdbot Gateway WebSocket API 호출
|
||||
|
||||
프로토콜:
|
||||
1. WS 연결
|
||||
2. 서버 → connect.challenge (nonce)
|
||||
3. 클라이언트 → connect 요청 (token)
|
||||
4. 서버 → connect 응답 (ok)
|
||||
5. 클라이언트 → agent 요청
|
||||
6. 서버 → accepted (ack) → 최종 응답
|
||||
|
||||
Returns:
|
||||
str: AI 응답 텍스트 (실패 시 None)
|
||||
"""
|
||||
config = _load_gateway_config()
|
||||
url = f"ws://127.0.0.1:{config['port']}"
|
||||
token = config['token']
|
||||
|
||||
try:
|
||||
async with websockets.connect(url, max_size=25 * 1024 * 1024,
|
||||
close_timeout=5) as ws:
|
||||
# 1. connect.challenge 대기
|
||||
nonce = None
|
||||
challenge_msg = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
challenge = json.loads(challenge_msg)
|
||||
if challenge.get('event') == 'connect.challenge':
|
||||
nonce = challenge.get('payload', {}).get('nonce')
|
||||
|
||||
# 2. connect 요청
|
||||
connect_id = str(uuid.uuid4())
|
||||
connect_frame = {
|
||||
'type': 'req',
|
||||
'id': connect_id,
|
||||
'method': 'connect',
|
||||
'params': {
|
||||
'minProtocol': 3,
|
||||
'maxProtocol': 3,
|
||||
'client': {
|
||||
'id': 'gateway-client',
|
||||
'displayName': 'Pharmacy Upsell',
|
||||
'version': '1.0.0',
|
||||
'platform': 'win32',
|
||||
'mode': 'backend',
|
||||
'instanceId': str(uuid.uuid4()),
|
||||
},
|
||||
'caps': [],
|
||||
'auth': {
|
||||
'token': token,
|
||||
},
|
||||
'role': 'operator',
|
||||
'scopes': ['operator.admin'],
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(connect_frame))
|
||||
|
||||
# 3. connect 응답 대기
|
||||
while True:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
data = json.loads(msg)
|
||||
if data.get('id') == connect_id:
|
||||
if not data.get('ok'):
|
||||
error = data.get('error', {}).get('message', 'unknown')
|
||||
logger.warning(f"[Clawdbot] connect 실패: {error}")
|
||||
return None
|
||||
break # 연결 성공
|
||||
|
||||
# 4. 모델 오버라이드 (sessions.patch)
|
||||
if model:
|
||||
patch_id = str(uuid.uuid4())
|
||||
patch_frame = {
|
||||
'type': 'req',
|
||||
'id': patch_id,
|
||||
'method': 'sessions.patch',
|
||||
'params': {
|
||||
'key': session_id,
|
||||
'model': model,
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(patch_frame))
|
||||
# patch 응답 대기
|
||||
while True:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
data = json.loads(msg)
|
||||
if data.get('id') == patch_id:
|
||||
if not data.get('ok'):
|
||||
logger.warning(f"[Clawdbot] sessions.patch 실패: {data.get('error', {}).get('message', 'unknown')}")
|
||||
break
|
||||
|
||||
# 5. agent 요청
|
||||
agent_id = str(uuid.uuid4())
|
||||
agent_params = {
|
||||
'message': message,
|
||||
'sessionId': session_id,
|
||||
'sessionKey': session_id,
|
||||
'timeout': timeout,
|
||||
'idempotencyKey': str(uuid.uuid4()),
|
||||
}
|
||||
if system_prompt:
|
||||
agent_params['extraSystemPrompt'] = system_prompt
|
||||
|
||||
agent_frame = {
|
||||
'type': 'req',
|
||||
'id': agent_id,
|
||||
'method': 'agent',
|
||||
'params': agent_params,
|
||||
}
|
||||
await ws.send(json.dumps(agent_frame))
|
||||
|
||||
# 5. agent 응답 대기 (accepted → final)
|
||||
while True:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=timeout + 30)
|
||||
data = json.loads(msg)
|
||||
|
||||
# 이벤트 무시 (tick 등)
|
||||
if data.get('event'):
|
||||
continue
|
||||
|
||||
# 우리 요청에 대한 응답인지 확인
|
||||
if data.get('id') != agent_id:
|
||||
continue
|
||||
|
||||
payload = data.get('payload', {})
|
||||
status = payload.get('status')
|
||||
|
||||
# accepted는 대기
|
||||
if status == 'accepted':
|
||||
continue
|
||||
|
||||
# 최종 응답
|
||||
if data.get('ok'):
|
||||
payloads = payload.get('result', {}).get('payloads', [])
|
||||
text = '\n'.join(p.get('text', '') for p in payloads if p.get('text'))
|
||||
return text or None
|
||||
else:
|
||||
error = data.get('error', {}).get('message', 'unknown')
|
||||
logger.warning(f"[Clawdbot] agent 실패: {error}")
|
||||
return None
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("[Clawdbot] Gateway 타임아웃")
|
||||
return None
|
||||
except (ConnectionRefusedError, OSError) as e:
|
||||
logger.warning(f"[Clawdbot] Gateway 연결 실패 (꺼져있음?): {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"[Clawdbot] Gateway 오류: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def ask_clawdbot(message, session_id='pharmacy-upsell',
|
||||
system_prompt=None, timeout=60, model=None):
|
||||
"""
|
||||
동기 래퍼: Flask에서 직접 호출 가능
|
||||
|
||||
Args:
|
||||
message: 사용자 메시지
|
||||
session_id: 세션 ID (대화 구분용)
|
||||
system_prompt: 추가 시스템 프롬프트
|
||||
timeout: 타임아웃 (초)
|
||||
model: 모델 오버라이드 (예: 'anthropic/claude-sonnet-4-5')
|
||||
|
||||
Returns:
|
||||
str: AI 응답 텍스트 (실패 시 None)
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
result = loop.run_until_complete(
|
||||
_ask_gateway(message, session_id, system_prompt, timeout, model=model)
|
||||
)
|
||||
loop.close()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"[Clawdbot] 호출 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# 업셀링 전용 ──────────────────────────────────────
|
||||
|
||||
UPSELL_MODEL = 'anthropic/claude-sonnet-4-5' # 업셀링은 Sonnet (빠르고 충분)
|
||||
|
||||
UPSELL_SYSTEM_PROMPT = """당신은 동네 약국(청춘약국)의 친절한 약사입니다.
|
||||
고객의 구매 이력을 보고, 자연스럽고 따뜻한 톤으로 약 하나를 추천합니다.
|
||||
강압적이거나 광고 같은 느낌이 아닌, 진심으로 건강을 걱정하는 약사의 말투로 작성해주세요.
|
||||
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트 없이 JSON만 출력하세요."""
|
||||
|
||||
|
||||
def generate_upsell(user_name, current_items, recent_products):
|
||||
"""
|
||||
업셀링 추천 생성
|
||||
|
||||
Args:
|
||||
user_name: 고객명
|
||||
current_items: 오늘 구매 품목 문자열 (예: "타이레놀, 챔프 시럽")
|
||||
recent_products: 최근 구매 이력 문자열
|
||||
|
||||
Returns:
|
||||
dict: {'product': '...', 'reason': '...', 'message': '...'} 또는 None
|
||||
"""
|
||||
prompt = f"""고객 이름: {user_name}
|
||||
오늘 구매한 약: {current_items}
|
||||
최근 구매 이력: {recent_products}
|
||||
|
||||
위 정보를 바탕으로 이 고객에게 추천할 약품 하나를 제안해주세요.
|
||||
|
||||
규칙:
|
||||
1. 오늘 구매한 약과 함께 먹으면 좋거나, 구매 패턴상 필요해보이는 약 1가지만 추천
|
||||
2. 실제 약국에서 판매하는 일반의약품/건강기능식품만 추천 (처방약 제외)
|
||||
3. 메시지는 2문장 이내, 따뜻하고 자연스러운 톤
|
||||
4. 구체적인 제품명 사용 (예: "비타민C 1000", "오메가3" 등)
|
||||
|
||||
응답은 반드시 아래 JSON 형식으로만:
|
||||
{{"product": "추천 제품명", "reason": "추천 이유 (내부용, 1문장)", "message": "{user_name}님, [오늘 구매 품목]과 함께 [추천약]도 추천드려요. [간단한 이유]."}}"""
|
||||
|
||||
response_text = ask_clawdbot(
|
||||
prompt,
|
||||
session_id=f'upsell-{user_name}',
|
||||
system_prompt=UPSELL_SYSTEM_PROMPT,
|
||||
timeout=30,
|
||||
model=UPSELL_MODEL
|
||||
)
|
||||
|
||||
if not response_text:
|
||||
return None
|
||||
|
||||
return _parse_upsell_response(response_text)
|
||||
|
||||
|
||||
UPSELL_REAL_SYSTEM_PROMPT = """당신은 동네 약국(청춘약국)의 친절한 약사입니다.
|
||||
고객의 구매 이력을 보고, 약국에 실제로 있는 제품 중에서 하나를 추천합니다.
|
||||
반드시 [약국 보유 제품 목록]에 있는 제품명을 그대로 사용하세요.
|
||||
목록에 없는 제품은 절대 추천하지 마세요.
|
||||
강압적이거나 광고 같은 느낌이 아닌, 진심으로 건강을 걱정하는 약사의 말투로 작성해주세요.
|
||||
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트 없이 JSON만 출력하세요."""
|
||||
|
||||
|
||||
def generate_upsell_real(user_name, current_items, recent_products, available_products):
|
||||
"""
|
||||
실데이터 기반 업셀링 추천 생성
|
||||
available_products: 약국 보유 제품 리스트 [{'name': ..., 'price': ..., 'sales': ...}, ...]
|
||||
"""
|
||||
product_list = '\n'.join(
|
||||
f"- {p['name']} ({int(p['price'])}원, 최근 {p['sales']}건 판매)"
|
||||
for p in available_products if p.get('name')
|
||||
)
|
||||
|
||||
prompt = f"""고객 이름: {user_name}
|
||||
오늘 구매한 약: {current_items}
|
||||
최근 구매 이력: {recent_products}
|
||||
|
||||
[약국 보유 제품 목록 — 이 중에서만 추천하세요]
|
||||
{product_list}
|
||||
|
||||
규칙:
|
||||
1. 위 목록에 있는 제품 중 오늘 구매한 약과 함께 먹으면 좋거나, 구매 패턴상 필요해보이는 약 1가지만 추천
|
||||
2. 오늘 이미 구매한 제품은 추천하지 마세요
|
||||
3. 메시지는 2문장 이내, 따뜻하고 자연스러운 톤
|
||||
4. product 필드에는 목록에 있는 제품명을 정확히 그대로 적어주세요
|
||||
|
||||
응답은 반드시 아래 JSON 형식으로만:
|
||||
{{"product": "목록에 있는 정확한 제품명", "reason": "추천 이유 (내부용, 1문장)", "message": "{user_name}님, [추천 메시지 2문장 이내]"}}"""
|
||||
|
||||
response_text = ask_clawdbot(
|
||||
prompt,
|
||||
session_id=f'upsell-real-{user_name}',
|
||||
system_prompt=UPSELL_REAL_SYSTEM_PROMPT,
|
||||
timeout=30,
|
||||
model=UPSELL_MODEL
|
||||
)
|
||||
|
||||
if not response_text:
|
||||
return None
|
||||
|
||||
return _parse_upsell_response(response_text)
|
||||
|
||||
|
||||
# ===== Claude 상태 조회 =====
|
||||
|
||||
async def _get_gateway_status():
|
||||
"""
|
||||
Clawdbot Gateway에서 세션 목록 조회
|
||||
토큰 차감 없음 (AI 호출 아님)
|
||||
"""
|
||||
config = _load_gateway_config()
|
||||
url = f"ws://127.0.0.1:{config['port']}"
|
||||
token = config['token']
|
||||
|
||||
try:
|
||||
async with websockets.connect(url, max_size=25 * 1024 * 1024,
|
||||
close_timeout=5) as ws:
|
||||
# 1. connect.challenge 대기
|
||||
challenge_msg = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
challenge = json.loads(challenge_msg)
|
||||
nonce = None
|
||||
if challenge.get('event') == 'connect.challenge':
|
||||
nonce = challenge.get('payload', {}).get('nonce')
|
||||
|
||||
# 2. connect 요청
|
||||
connect_id = str(uuid.uuid4())
|
||||
connect_frame = {
|
||||
'type': 'req',
|
||||
'id': connect_id,
|
||||
'method': 'connect',
|
||||
'params': {
|
||||
'minProtocol': 3,
|
||||
'maxProtocol': 3,
|
||||
'client': {
|
||||
'id': 'gateway-client',
|
||||
'displayName': 'Pharmacy Status',
|
||||
'version': '1.0.0',
|
||||
'platform': 'win32',
|
||||
'mode': 'backend',
|
||||
'instanceId': str(uuid.uuid4()),
|
||||
},
|
||||
'caps': [],
|
||||
'auth': {'token': token},
|
||||
'role': 'operator',
|
||||
'scopes': ['operator.read'],
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(connect_frame))
|
||||
|
||||
# 3. connect 응답 대기
|
||||
while True:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
data = json.loads(msg)
|
||||
if data.get('id') == connect_id:
|
||||
if not data.get('ok'):
|
||||
error = data.get('error', {}).get('message', 'connect failed')
|
||||
logger.warning(f"[Clawdbot] connect 실패: {error}")
|
||||
return {'error': error, 'connected': False}
|
||||
break
|
||||
|
||||
# 4. sessions.list 요청
|
||||
list_id = str(uuid.uuid4())
|
||||
list_frame = {
|
||||
'type': 'req',
|
||||
'id': list_id,
|
||||
'method': 'sessions.list',
|
||||
'params': {
|
||||
'limit': 10
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(list_frame))
|
||||
|
||||
# 5. 응답 대기
|
||||
while True:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
data = json.loads(msg)
|
||||
|
||||
# 이벤트 무시
|
||||
if data.get('event'):
|
||||
continue
|
||||
|
||||
if data.get('id') == list_id:
|
||||
if data.get('ok'):
|
||||
return {
|
||||
'connected': True,
|
||||
'sessions': data.get('payload', {})
|
||||
}
|
||||
else:
|
||||
error = data.get('error', {}).get('message', 'unknown')
|
||||
return {'error': error, 'connected': True}
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("[Clawdbot] Gateway 타임아웃")
|
||||
return {'error': 'timeout', 'connected': False}
|
||||
except (ConnectionRefusedError, OSError) as e:
|
||||
logger.warning(f"[Clawdbot] Gateway 연결 실패: {e}")
|
||||
return {'error': str(e), 'connected': False}
|
||||
except Exception as e:
|
||||
logger.warning(f"[Clawdbot] 상태 조회 실패: {e}")
|
||||
return {'error': str(e), 'connected': False}
|
||||
|
||||
|
||||
def get_claude_status():
|
||||
"""
|
||||
동기 래퍼: Claude 상태 조회
|
||||
|
||||
Returns:
|
||||
dict: 상태 정보
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
result = loop.run_until_complete(_get_gateway_status())
|
||||
loop.close()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"[Clawdbot] 상태 조회 실패: {e}")
|
||||
return {'error': str(e), 'connected': False}
|
||||
|
||||
|
||||
def _parse_upsell_response(text):
|
||||
"""AI 응답에서 JSON 추출"""
|
||||
import re
|
||||
try:
|
||||
# ```json ... ``` 블록 추출 시도
|
||||
json_match = re.search(r'```json\s*(\{.*?\})\s*```', text, re.DOTALL)
|
||||
if json_match:
|
||||
json_str = json_match.group(1)
|
||||
else:
|
||||
# 직접 JSON 파싱 시도
|
||||
start = text.find('{')
|
||||
end = text.rfind('}')
|
||||
if start >= 0 and end > start:
|
||||
json_str = text[start:end + 1]
|
||||
else:
|
||||
return None
|
||||
|
||||
data = json.loads(json_str)
|
||||
|
||||
if 'product' not in data or 'message' not in data:
|
||||
return None
|
||||
|
||||
return {
|
||||
'product': data['product'],
|
||||
'reason': data.get('reason', ''),
|
||||
'message': data['message'],
|
||||
}
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
logger.warning(f"[Clawdbot] 업셀 응답 파싱 실패: {e}")
|
||||
return None
|
||||
159
backend/services/kakao_client.py
Normal file
159
backend/services/kakao_client.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Kakao API Client for OAuth 2.0 Authentication
|
||||
pharmacy-pos-qr-system용 경량 버전
|
||||
"""
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, Tuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KakaoAPIClient:
|
||||
"""카카오 API 클라이언트 (마일리지 적립용)"""
|
||||
|
||||
def __init__(self):
|
||||
self.client_id = os.getenv('KAKAO_CLIENT_ID', '')
|
||||
self.client_secret = os.getenv('KAKAO_CLIENT_SECRET', '')
|
||||
self.redirect_uri = os.getenv(
|
||||
'KAKAO_REDIRECT_URI',
|
||||
'https://mile.0bin.in/claim/kakao/callback'
|
||||
)
|
||||
|
||||
self.auth_base_url = 'https://kauth.kakao.com'
|
||||
self.api_base_url = 'https://kapi.kakao.com'
|
||||
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': 'YouthPharmacy-Mileage/1.0'
|
||||
})
|
||||
|
||||
def get_authorization_url(self, state: str = None) -> str:
|
||||
"""카카오 OAuth 인증 URL 생성"""
|
||||
params = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'profile_nickname,profile_image,account_email,name,phone_number,birthday'
|
||||
}
|
||||
|
||||
if state:
|
||||
params['state'] = state
|
||||
|
||||
return f"{self.auth_base_url}/oauth/authorize?{urlencode(params)}"
|
||||
|
||||
def get_access_token(self, authorization_code: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""Authorization Code로 Access Token 요청"""
|
||||
url = f"{self.auth_base_url}/oauth/token"
|
||||
|
||||
data = {
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'code': authorization_code
|
||||
}
|
||||
|
||||
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_request_failed',
|
||||
'error_description': f'Failed to get 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
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"카카오 응답 JSON 파싱 실패: {e}")
|
||||
return False, {
|
||||
'error': 'invalid_response',
|
||||
'error_description': f'Invalid JSON response: {e}'
|
||||
}
|
||||
|
||||
def get_user_info(self, access_token: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""Access Token으로 사용자 정보 조회"""
|
||||
url = f"{self.api_base_url}/v2/user/me"
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
user_data = response.json()
|
||||
normalized_user = self._normalize_user_data(user_data)
|
||||
|
||||
return True, normalized_user
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"카카오 사용자 정보 조회 실패: {e}")
|
||||
return False, {
|
||||
'error': 'user_info_failed',
|
||||
'error_description': f'Failed to get user info: {e}'
|
||||
}
|
||||
|
||||
def _normalize_user_data(self, kakao_user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""카카오 사용자 데이터를 정규화"""
|
||||
kakao_account = kakao_user.get('kakao_account', {})
|
||||
profile = kakao_account.get('profile', {})
|
||||
|
||||
normalized = {
|
||||
'kakao_id': str(kakao_user.get('id')),
|
||||
'nickname': profile.get('nickname'),
|
||||
'profile_image': profile.get('profile_image_url'),
|
||||
'thumbnail_image': profile.get('thumbnail_image_url'),
|
||||
'email': kakao_account.get('email'),
|
||||
'is_email_verified': kakao_account.get('is_email_verified', False),
|
||||
'name': kakao_account.get('name'),
|
||||
'phone_number': kakao_account.get('phone_number'),
|
||||
'birthday': kakao_account.get('birthday'), # MMDD 형식
|
||||
'birthyear': kakao_account.get('birthyear'), # YYYY 형식
|
||||
}
|
||||
|
||||
# None 값 제거
|
||||
normalized = {k: v for k, v in normalized.items() if v is not None}
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
# 싱글톤
|
||||
_kakao_client = None
|
||||
|
||||
|
||||
def get_kakao_client() -> KakaoAPIClient:
|
||||
"""카카오 클라이언트 인스턴스 반환"""
|
||||
global _kakao_client
|
||||
if _kakao_client is None:
|
||||
_kakao_client = KakaoAPIClient()
|
||||
return _kakao_client
|
||||
202
backend/services/nhn_alimtalk.py
Normal file
202
backend/services/nhn_alimtalk.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
NHN Cloud 알림톡 발송 서비스
|
||||
마일리지 적립 완료 등 알림톡 발송 + SQLite 로깅
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# NHN Cloud 알림톡 설정
|
||||
APPKEY = os.getenv('NHN_ALIMTALK_APPKEY', 'u0TLUaXXY9bfQFkY')
|
||||
SECRET_KEY = os.getenv('NHN_ALIMTALK_SECRET', 'naraGEUJfpkRu1fgirKewJtwADqWQ5gY')
|
||||
SENDER_KEY = os.getenv('NHN_ALIMTALK_SENDER', '341352077bce225195ccc2697fb449f723e70982')
|
||||
|
||||
API_BASE = f'https://api-alimtalk.cloud.toast.com/alimtalk/v2.3/appkeys/{APPKEY}'
|
||||
|
||||
# KST 타임존
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
def _log_to_db(template_code, recipient_no, success, result_message,
|
||||
template_params=None, user_id=None, trigger_source='unknown',
|
||||
transaction_id=None):
|
||||
"""발송 결과를 SQLite에 저장"""
|
||||
try:
|
||||
from db.dbsetup import db_manager
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO alimtalk_logs
|
||||
(template_code, recipient_no, user_id, trigger_source,
|
||||
template_params, success, result_message, transaction_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
template_code,
|
||||
recipient_no,
|
||||
user_id,
|
||||
trigger_source,
|
||||
json.dumps(template_params, ensure_ascii=False) if template_params else None,
|
||||
success,
|
||||
result_message,
|
||||
transaction_id
|
||||
))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"알림톡 로그 DB 저장 실패: {e}")
|
||||
|
||||
|
||||
def _send_alimtalk(template_code, recipient_no, template_params):
|
||||
"""
|
||||
알림톡 발송 공통 함수
|
||||
|
||||
Args:
|
||||
template_code: 템플릿 코드
|
||||
recipient_no: 수신 번호 (01012345678)
|
||||
template_params: 템플릿 변수 딕셔너리
|
||||
|
||||
Returns:
|
||||
tuple: (성공 여부, 메시지)
|
||||
"""
|
||||
url = f'{API_BASE}/messages'
|
||||
headers = {
|
||||
'Content-Type': 'application/json;charset=UTF-8',
|
||||
'X-Secret-Key': SECRET_KEY
|
||||
}
|
||||
data = {
|
||||
'senderKey': SENDER_KEY,
|
||||
'templateCode': template_code,
|
||||
'recipientList': [
|
||||
{
|
||||
'recipientNo': recipient_no,
|
||||
'templateParameter': template_params
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=data, timeout=10)
|
||||
result = resp.json()
|
||||
|
||||
if resp.status_code == 200 and result.get('header', {}).get('isSuccessful'):
|
||||
logger.info(f"알림톡 발송 성공: {template_code} → {recipient_no}")
|
||||
return (True, "발송 성공")
|
||||
else:
|
||||
error_msg = result.get('header', {}).get('resultMessage', str(result))
|
||||
logger.warning(f"알림톡 발송 실패: {template_code} → {recipient_no}: {error_msg}")
|
||||
return (False, error_msg)
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"알림톡 발송 타임아웃: {template_code} → {recipient_no}")
|
||||
return (False, "타임아웃")
|
||||
except Exception as e:
|
||||
logger.warning(f"알림톡 발송 오류: {template_code} → {recipient_no}: {e}")
|
||||
return (False, str(e))
|
||||
|
||||
|
||||
def build_item_summary(items):
|
||||
"""구매 품목 요약 문자열 생성 (예: '타이레놀 외 3건')"""
|
||||
if not items:
|
||||
return "약국 구매"
|
||||
first = items[0]['name']
|
||||
if len(first) > 20:
|
||||
first = first[:18] + '..'
|
||||
if len(items) == 1:
|
||||
return first
|
||||
return f"{first} 외 {len(items) - 1}건"
|
||||
|
||||
|
||||
def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
|
||||
user_id=None, trigger_source='kiosk',
|
||||
transaction_id=None):
|
||||
"""
|
||||
마일리지 적립 완료 알림톡 발송
|
||||
|
||||
Args:
|
||||
phone: 수신 전화번호 (01012345678)
|
||||
name: 고객명
|
||||
points: 적립 포인트
|
||||
balance: 적립 후 총 잔액
|
||||
items: 구매 품목 리스트 [{'name': ..., 'qty': ..., 'total': ...}, ...]
|
||||
user_id: 사용자 ID (로그용)
|
||||
trigger_source: 발송 주체 ('kiosk', 'admin', 'manual')
|
||||
transaction_id: 거래 ID (로그용)
|
||||
|
||||
Returns:
|
||||
tuple: (성공 여부, 메시지)
|
||||
"""
|
||||
now_kst = datetime.now(KST).strftime('%m/%d %H:%M')
|
||||
item_summary = build_item_summary(items)
|
||||
|
||||
# MILEAGE_CLAIM_V3 (발송 근거 + 구매품목 포함) 우선 시도
|
||||
template_code = 'MILEAGE_CLAIM_V3'
|
||||
params = {
|
||||
'고객명': name,
|
||||
'구매품목': item_summary,
|
||||
'적립포인트': f'{points:,}',
|
||||
'총잔액': f'{balance:,}',
|
||||
'적립일시': now_kst,
|
||||
'전화번호': phone
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
# 최종 결과 로그
|
||||
_log_to_db(template_code, phone, success, msg,
|
||||
template_params=params, user_id=user_id,
|
||||
trigger_source=trigger_source, transaction_id=transaction_id)
|
||||
|
||||
return (success, msg)
|
||||
|
||||
|
||||
def get_nhn_send_history(start_date, end_date, page=1, page_size=15):
|
||||
"""
|
||||
NHN Cloud API에서 실제 발송 내역 조회
|
||||
|
||||
Args:
|
||||
start_date: 시작일 (YYYY-MM-DD HH:mm)
|
||||
end_date: 종료일 (YYYY-MM-DD HH:mm)
|
||||
|
||||
Returns:
|
||||
list: 발송 메시지 목록
|
||||
"""
|
||||
url = (f'{API_BASE}/messages'
|
||||
f'?startRequestDate={start_date}'
|
||||
f'&endRequestDate={end_date}'
|
||||
f'&pageNum={page}&pageSize={page_size}')
|
||||
headers = {
|
||||
'Content-Type': 'application/json;charset=UTF-8',
|
||||
'X-Secret-Key': SECRET_KEY
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.get(url, headers=headers, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get('messageSearchResultResponse'):
|
||||
return data['messageSearchResultResponse'].get('messages', [])
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning(f"NHN 발송내역 조회 실패: {e}")
|
||||
return []
|
||||
147
backend/sms_client.py
Normal file
147
backend/sms_client.py
Normal file
@@ -0,0 +1,147 @@
|
||||
# sms_client.py - NHN Cloud SMS API 클라이언트
|
||||
|
||||
import requests
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List
|
||||
|
||||
# NHN Cloud SMS 설정 (SMS 전용 앱키)
|
||||
SMS_CONFIG = {
|
||||
"BASE_URL": "https://api-sms.cloud.toast.com",
|
||||
"APP_KEY": "YWWBZkuJ0ck03cje",
|
||||
"SECRET_KEY": "jxXbBPnQN2tUL8QnEp4O3YfraGd8ZuNh",
|
||||
"SENDER_NO": "0334817390", # 발신번호 (033-481-7390)
|
||||
}
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SMSClient:
|
||||
"""NHN Cloud SMS 발송 클라이언트"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = SMS_CONFIG["BASE_URL"]
|
||||
self.app_key = SMS_CONFIG["APP_KEY"]
|
||||
self.secret_key = SMS_CONFIG["SECRET_KEY"]
|
||||
self.sender_no = SMS_CONFIG["SENDER_NO"]
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"X-Secret-Key": self.secret_key
|
||||
}
|
||||
|
||||
def send_sms(self, recipients: List[Dict], message: str) -> Dict:
|
||||
"""
|
||||
SMS 발송
|
||||
|
||||
Args:
|
||||
recipients: [{"phone": "01012345678", "name": "홍길동"}]
|
||||
message: 메시지 내용 (90바이트 이하 SMS, 초과시 LMS)
|
||||
|
||||
Returns:
|
||||
발송 결과
|
||||
"""
|
||||
# 메시지 길이에 따라 SMS/LMS 결정
|
||||
msg_bytes = len(message.encode('utf-8'))
|
||||
is_lms = msg_bytes > 90
|
||||
|
||||
url = f"{self.base_url}/sms/v3.0/appKeys/{self.app_key}/sender/{'mms' if is_lms else 'sms'}"
|
||||
|
||||
# 수신자 리스트 생성
|
||||
recipient_list = []
|
||||
for r in recipients:
|
||||
phone = (r.get('phone') or '').replace('-', '').replace(' ', '')
|
||||
if phone and len(phone) >= 10:
|
||||
recipient_list.append({
|
||||
"recipientNo": phone,
|
||||
"countryCode": "82"
|
||||
})
|
||||
|
||||
if not recipient_list:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "유효한 수신자가 없습니다"
|
||||
}
|
||||
|
||||
# 요청 데이터
|
||||
data = {
|
||||
"body": message,
|
||||
"sendNo": self.sender_no,
|
||||
"recipientList": recipient_list
|
||||
}
|
||||
|
||||
# LMS인 경우 제목 추가
|
||||
if is_lms:
|
||||
data["title"] = "청춘약국"
|
||||
|
||||
try:
|
||||
logger.info(f"SMS 발송 요청: {len(recipient_list)}명, {msg_bytes}bytes ({'LMS' if is_lms else 'SMS'})")
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
data=json.dumps(data),
|
||||
timeout=30
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
logger.info(f"SMS 응답: {result}")
|
||||
|
||||
header = result.get("header", {})
|
||||
if header.get("isSuccessful"):
|
||||
body = result.get("body", {})
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"SMS 발송 성공 ({len(recipient_list)}명)",
|
||||
"type": "LMS" if is_lms else "SMS",
|
||||
"request_id": body.get("data", {}).get("requestId"),
|
||||
"sent_count": len(recipient_list)
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": header.get("resultMessage", "발송 실패"),
|
||||
"code": header.get("resultCode")
|
||||
}
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return {"success": False, "error": "요청 시간 초과"}
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"SMS 발송 오류: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"SMS 발송 예외: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def check_balance(self) -> Dict:
|
||||
"""잔여 발송량 확인"""
|
||||
url = f"{self.base_url}/sms/v3.0/appKeys/{self.app_key}/stats"
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=self._get_headers(), timeout=10)
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
sms_client = SMSClient()
|
||||
|
||||
|
||||
def send_test_sms(phone: str, message: str = None) -> Dict:
|
||||
"""테스트 SMS 발송"""
|
||||
if not message:
|
||||
message = "[청춘약국] 테스트 문자입니다. 정상 수신되었다면 회신 부탁드립니다."
|
||||
|
||||
return sms_client.send_sms(
|
||||
recipients=[{"phone": phone, "name": "테스트"}],
|
||||
message=message
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트 발송
|
||||
result = send_test_sms("01027027390")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
BIN
backend/static/icons/icon-192.png
Normal file
BIN
backend/static/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
backend/static/icons/icon-512.png
Normal file
BIN
backend/static/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
25
backend/static/manifest.json
Normal file
25
backend/static/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "청춘약국 마일리지",
|
||||
"short_name": "청춘약국",
|
||||
"description": "청춘약국 QR 마일리지 적립 서비스",
|
||||
"start_url": "/my-page",
|
||||
"display": "standalone",
|
||||
"background_color": "#f5f7fa",
|
||||
"theme_color": "#6366f1",
|
||||
"orientation": "portrait",
|
||||
"lang": "ko",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
63
backend/static/sw.js
Normal file
63
backend/static/sw.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const CACHE_NAME = 'chungchun-pharmacy-v1';
|
||||
const STATIC_ASSETS = [
|
||||
'/static/js/lottie.min.js',
|
||||
'/static/animations/ai-loading.json',
|
||||
'/static/icons/icon-192.png',
|
||||
'/static/icons/icon-512.png'
|
||||
];
|
||||
|
||||
// Install: pre-cache static assets
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate: clean old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
|
||||
)
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch: cache-first for static, network-only for dynamic
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
// Skip dynamic routes entirely
|
||||
if (url.pathname.startsWith('/api/') ||
|
||||
url.pathname.startsWith('/admin') ||
|
||||
url.pathname.startsWith('/claim') ||
|
||||
url.pathname.startsWith('/my-page') ||
|
||||
url.pathname === '/privacy' ||
|
||||
url.pathname === '/logout' ||
|
||||
url.pathname === '/') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for static assets and fonts
|
||||
if (url.pathname.startsWith('/static/') ||
|
||||
url.hostname === 'fonts.googleapis.com' ||
|
||||
url.hostname === 'fonts.gstatic.com') {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
return cached || fetch(event.request).then((response) => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -202,6 +202,11 @@
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.section table tbody tr[onclick]:hover {
|
||||
background: #eef2ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 사이드바 레이아웃 */
|
||||
.layout-wrapper {
|
||||
display: flex;
|
||||
@@ -388,9 +393,19 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-title">📊 관리자 대시보드</div>
|
||||
<div class="header-subtitle">청춘약국 마일리지 관리</div>
|
||||
<div class="header-content" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<div class="header-title">📊 관리자 대시보드</div>
|
||||
<div class="header-subtitle">청춘약국 마일리지 관리</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<a href="/admin/products" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🔍 제품검색</a>
|
||||
<a href="/admin/members" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">👥 회원검색</a>
|
||||
<a href="/admin/sales-detail" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">📋 판매조회</a>
|
||||
<a href="/admin/sales" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🧾 판매내역</a>
|
||||
<a href="/admin/ai-crm" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🤖 AI CRM</a>
|
||||
<a href="/admin/alimtalk" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">📨 알림톡</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -457,6 +472,8 @@
|
||||
<th>전화번호</th>
|
||||
<th>포인트</th>
|
||||
<th>가입일</th>
|
||||
<th>카카오</th>
|
||||
<th>조제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -467,6 +484,20 @@
|
||||
<td class="phone-masked">{{ user.phone[:3] }}-{{ user.phone[3:7] }}-{{ user.phone[7:] if user.phone|length > 7 else '' }}</td>
|
||||
<td class="points-positive">{{ "{:,}".format(user.mileage_balance) }}P</td>
|
||||
<td>{{ user.created_at[:16].replace('T', ' ') }}</td>
|
||||
<td>
|
||||
{% if user.kakao_verified_at %}
|
||||
<span style="display: inline-flex; align-items: center; gap: 3px; background: #FEE500; color: #3C1E1E; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 10px;">💬 {{ user.kakao_verified_at[:10] }}</span>
|
||||
{% else %}
|
||||
<span style="display: inline-flex; align-items: center; background: #f1f3f5; color: #868e96; font-size: 10px; font-weight: 600; padding: 3px 8px; border-radius: 10px;">미인증</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.has_prescription %}
|
||||
<span style="display: inline-flex; align-items: center; gap: 3px; background: #d3f9d8; color: #2b8a3e; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 10px;">💊 환자</span>
|
||||
{% else %}
|
||||
<span style="display: inline-flex; align-items: center; background: #f1f3f5; color: #adb5bd; font-size: 10px; font-weight: 600; padding: 3px 8px; border-radius: 10px;">일반</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -495,12 +526,12 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tx in recent_transactions %}
|
||||
<tr>
|
||||
<tr{% if tx.transaction_id %} onclick="showTransactionDetail('{{ tx.transaction_id }}')" style="cursor: pointer;" title="클릭하여 품목 상세 보기"{% endif %}>
|
||||
<td>{{ tx.nickname }}</td>
|
||||
<td class="phone-masked">{{ tx.phone[:3] }}-{{ tx.phone[3:7] }}-{{ tx.phone[7:] if tx.phone|length > 7 else '' }}</td>
|
||||
<td class="points-positive">{{ "{:,}".format(tx.points) }}P</td>
|
||||
<td>{{ "{:,}".format(tx.balance_after) }}P</td>
|
||||
<td>{{ tx.description or tx.reason }}</td>
|
||||
<td>{{ tx.description or tx.reason }}{% if tx.transaction_id %} <span style="color: #6366f1; font-size: 12px;">🔍</span>{% endif %}</td>
|
||||
<td>{{ tx.created_at[:16].replace('T', ' ') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -650,7 +681,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
|
||||
<script src="/static/js/lottie.min.js"></script>
|
||||
<script>
|
||||
function showTransactionDetail(transactionId) {
|
||||
document.getElementById('transactionModal').style.display = 'block';
|
||||
@@ -834,7 +865,10 @@
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 16px;">
|
||||
<div>
|
||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">이름</div>
|
||||
<div style="color: #212529; font-size: 16px; font-weight: 600;">${user.name}</div>
|
||||
<div style="color: #212529; font-size: 16px; font-weight: 600;">
|
||||
${user.name}
|
||||
${user.is_kakao_verified ? '<span style="display: inline-flex; align-items: center; gap: 3px; background: #FEE500; color: #3C1E1E; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; margin-left: 8px;"><span style="font-size: 13px;">💬</span>카카오</span>' : '<span style="display: inline-flex; align-items: center; gap: 3px; background: #e9ecef; color: #868e96; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; margin-left: 8px;">미인증</span>'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">전화번호</div>
|
||||
@@ -848,6 +882,12 @@
|
||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">가입일</div>
|
||||
<div style="color: #212529; font-size: 16px; font-weight: 600;">${user.created_at}</div>
|
||||
</div>
|
||||
${user.birthday ? `
|
||||
<div>
|
||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">🎂 생일</div>
|
||||
<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>
|
||||
` : ''}
|
||||
</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;">
|
||||
@@ -862,10 +902,16 @@
|
||||
<!-- 탭 메뉴 -->
|
||||
<div style="display: flex; gap: 16px; margin-bottom: 16px; border-bottom: 2px solid #e9ecef;">
|
||||
<button onclick="switchTab('purchases')" id="tab-purchases" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid #6366f1; color: #6366f1;">
|
||||
구매 이력 (${purchases.length})
|
||||
🛒 구매 (${purchases.length})
|
||||
</button>
|
||||
<button onclick="switchTab('mileage')" id="tab-mileage" 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;">
|
||||
적립 이력 (${mileageHistory.length})
|
||||
💰 적립 (${mileageHistory.length})
|
||||
</button>
|
||||
<button onclick="switchTab('prescriptions')" id="tab-prescriptions" 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.prescriptions ? data.prescriptions.length : 0})
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -922,6 +968,96 @@
|
||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">적립 이력이 없습니다.</p>';
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
|
||||
<!-- 조제 이력 탭 -->
|
||||
<div id="tab-content-prescriptions" class="tab-content" style="display: none;">
|
||||
`;
|
||||
|
||||
// 조제 이력 렌더링
|
||||
const prescriptions = data.prescriptions || [];
|
||||
if (prescriptions.length > 0) {
|
||||
prescriptions.forEach(rx => {
|
||||
// 날짜 포맷
|
||||
const dateStr = rx.date || '';
|
||||
let formattedDate = dateStr;
|
||||
if (dateStr.length === 8) {
|
||||
formattedDate = `${dateStr.slice(0,4)}.${dateStr.slice(4,6)}.${dateStr.slice(6,8)}`;
|
||||
}
|
||||
|
||||
// 처방 품목
|
||||
const itemsHtml = (rx.items || []).map(item => {
|
||||
const dosage = item.quantity || 1;
|
||||
const freq = item.times_per_day || 1;
|
||||
const days = item.days || 0;
|
||||
return `
|
||||
<div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f1f3f5;">
|
||||
<span style="color: #495057; font-size: 14px;">${item.name}</span>
|
||||
<span style="color: #6366f1; font-size: 13px; font-weight: 600;">${dosage}정 × ${freq}회 × ${days}일</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
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;">
|
||||
<span style="font-size: 15px; font-weight: 600; color: #212529;">📅 ${formattedDate}</span>
|
||||
<span style="font-size: 13px; color: #6366f1; font-weight: 600;">${rx.total_days || ''}일분</span>
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #64748b; margin-bottom: 12px;">
|
||||
🏥 ${rx.hospital || ''} · ${rx.doctor || ''}
|
||||
</div>
|
||||
${rx.items && rx.items.length > 0 ? `<div style="background: #f8f9fa; border-radius: 8px; padding: 12px;">${itemsHtml}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} else if (!data.pos_customer) {
|
||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">📭 POS 회원으로 등록되지 않았습니다<br><small>전화번호가 POS에 등록되면 조제 이력이 표시됩니다</small></p>';
|
||||
} else {
|
||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">📭 조제 이력이 없습니다</p>';
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
|
||||
<!-- 관심상품 탭 -->
|
||||
<div id="tab-content-interests" class="tab-content" style="display: none;">
|
||||
`;
|
||||
|
||||
// 관심상품 렌더링
|
||||
const interests = data.interests || [];
|
||||
if (interests.length > 0) {
|
||||
interests.forEach(item => {
|
||||
// 날짜 포맷
|
||||
const date = item.created_at || '';
|
||||
|
||||
// 트리거 상품 파싱
|
||||
let triggerText = '';
|
||||
try {
|
||||
const triggers = JSON.parse(item.trigger_products || '[]');
|
||||
if (triggers.length > 0) {
|
||||
triggerText = triggers.join(', ');
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
html += `
|
||||
<div style="border: 1px solid #e9ecef; border-radius: 12px; margin-bottom: 12px; padding: 16px; border-left: 4px solid #ec4899;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span style="font-size: 15px; font-weight: 700; color: #ec4899;">💝 ${item.product}</span>
|
||||
<span style="font-size: 12px; color: #868e96;">${date}</span>
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #64748b; margin-bottom: 8px;">
|
||||
${item.reason || ''}
|
||||
</div>
|
||||
${triggerText ? `<div style="font-size: 12px; color: #94a3b8; background: #f8f9fa; padding: 8px 12px; border-radius: 6px;">🛒 구매: ${triggerText}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">💝 관심 상품이 없습니다<br><small>마일리지 적립 시 AI 추천에서 "관심있어요"를 누르면 여기에 표시됩니다</small></p>';
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
`;
|
||||
@@ -1288,9 +1424,12 @@
|
||||
`;
|
||||
|
||||
users.forEach(user => {
|
||||
const kakaoBadge = user.is_kakao_verified
|
||||
? '<span style="display: inline-flex; align-items: center; gap: 2px; background: #FEE500; color: #3C1E1E; font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 8px; margin-left: 6px;">💬</span>'
|
||||
: '<span style="display: inline-flex; align-items: center; background: #e9ecef; color: #868e96; font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 8px; margin-left: 6px;">미인증</span>';
|
||||
html += `
|
||||
<tr style="border-bottom: 1px solid #f1f3f5;">
|
||||
<td style="padding: 14px; font-size: 14px; color: #212529; font-weight: 500;">${user.name}</td>
|
||||
<td style="padding: 14px; font-size: 14px; color: #212529; font-weight: 500;">${user.name}${kakaoBadge}</td>
|
||||
<td style="padding: 14px; font-size: 14px; color: #495057; font-family: 'Courier New', monospace;">${user.phone}</td>
|
||||
<td style="padding: 14px; text-align: right; font-size: 14px; color: #6366f1; font-weight: 600;">${user.balance.toLocaleString()}P</td>
|
||||
<td style="padding: 14px; text-align: center;">
|
||||
|
||||
418
backend/templates/admin_ai_crm.html
Normal file
418
backend/templates/admin_ai_crm.html
Normal file
@@ -0,0 +1,418 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI 업셀링 CRM - 청춘약국</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, #7c3aed 0%, #6366f1 50%, #8b5cf6 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;
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── 컨텐츠 ── */
|
||||
.content {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* ── 통계 카드 ── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 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;
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.stat-value.default { color: #1e293b; }
|
||||
.stat-value.green { color: #16a34a; }
|
||||
.stat-value.orange { color: #d97706; }
|
||||
.stat-value.indigo { color: #6366f1; }
|
||||
|
||||
/* ── 테이블 섹션 ── */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.section-sub {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.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;
|
||||
letter-spacing: -0.2px;
|
||||
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: 3px 10px;
|
||||
border-radius: 100px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
.badge-active { background: #dcfce7; color: #16a34a; }
|
||||
.badge-interested { background: #fef3c7; color: #d97706; }
|
||||
.badge-dismissed { background: #f1f5f9; color: #64748b; }
|
||||
.badge-expired { background: #fee2e2; color: #dc2626; }
|
||||
.badge-trigger {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
margin: 1px 2px;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.badge-product {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: #fff;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── 메시지 말줄임 ── */
|
||||
.msg-ellipsis {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── 노출 횟수 ── */
|
||||
.display-count {
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
color: #6366f1;
|
||||
font-size: 14px;
|
||||
}
|
||||
.display-count.zero { color: #cbd5e1; }
|
||||
|
||||
/* ── 아코디언 상세 ── */
|
||||
.detail-row { display: none; }
|
||||
.detail-row.open { display: table-row; }
|
||||
.detail-row td {
|
||||
padding: 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.detail-content {
|
||||
padding: 20px 24px;
|
||||
background: #fafbfd;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.detail-field {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.detail-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.detail-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.detail-raw {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
.detail-raw pre {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
||||
.empty-text { font-size: 14px; font-weight: 500; }
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
.header { padding: 20px 16px 18px; }
|
||||
.content { padding: 16px 12px 40px; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { min-width: 700px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<div>
|
||||
<a href="/admin/ai-gw" style="margin-right: 16px;">Gateway 모니터</a>
|
||||
<a href="/admin/alimtalk">알림톡 로그 →</a>
|
||||
</div>
|
||||
</div>
|
||||
<h1>AI 업셀링 CRM</h1>
|
||||
<p>구매 기반 맞춤 추천 생성 현황 · Clawdbot Gateway</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 통계 카드 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">전체 생성</div>
|
||||
<div class="stat-value default">{{ stats.total or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Active</div>
|
||||
<div class="stat-value green">{{ stats.active_count or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">관심있어요</div>
|
||||
<div class="stat-value orange">{{ stats.interested_count or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">오늘 생성</div>
|
||||
<div class="stat-value indigo">{{ stats.today_count or 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 추천 목록 -->
|
||||
<div class="section-header">
|
||||
<div class="section-title">추천 생성 로그</div>
|
||||
<div class="section-sub">최근 50건 · 클릭하여 상세 보기</div>
|
||||
</div>
|
||||
|
||||
{% if recs %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>생성일시</th>
|
||||
<th>고객</th>
|
||||
<th>트리거 품목</th>
|
||||
<th>추천 제품</th>
|
||||
<th>AI 메시지</th>
|
||||
<th>상태</th>
|
||||
<th style="text-align:center">노출</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rec in recs %}
|
||||
<tr onclick="toggleDetail({{ rec.id }})">
|
||||
<td style="white-space:nowrap;font-size:12px;color:#64748b;">
|
||||
{{ rec.created_at[5:16] if rec.created_at else '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<div style="font-weight:600;font-size:13px;">{{ rec.nickname or '알 수 없음' }}</div>
|
||||
{% if rec.user_phone %}
|
||||
<div style="font-size:11px;color:#94a3b8;">{{ rec.user_phone[:3] }}-****-{{ rec.user_phone[-4:] }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if rec.trigger_list %}
|
||||
{% for item in rec.trigger_list %}
|
||||
<span class="badge badge-trigger">{{ item }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span style="color:#cbd5e1;">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-product">{{ rec.recommended_product }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="msg-ellipsis" title="{{ rec.recommendation_message }}">{{ rec.recommendation_message }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if rec.status == 'interested' %}
|
||||
<span class="badge badge-interested">관심있어요</span>
|
||||
{% elif rec.status == 'active' and (not rec.expires_at or rec.expires_at > now) %}
|
||||
<span class="badge badge-active">Active</span>
|
||||
{% elif rec.status == 'dismissed' %}
|
||||
<span class="badge badge-dismissed">Dismissed</span>
|
||||
{% else %}
|
||||
<span class="badge badge-expired">Expired</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="display-count {{ 'zero' if not rec.displayed_count else '' }}">
|
||||
{{ rec.displayed_count or 0 }}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 상세 아코디언 -->
|
||||
<tr class="detail-row" id="detail-{{ rec.id }}">
|
||||
<td colspan="7">
|
||||
<div class="detail-content">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">추천 이유</div>
|
||||
<div class="detail-value">{{ rec.recommendation_reason or '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">거래 ID</div>
|
||||
<div class="detail-value">{{ rec.transaction_id or '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">노출 일시</div>
|
||||
<div class="detail-value">{{ rec.displayed_at or '미노출' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">닫기 일시</div>
|
||||
<div class="detail-value">{{ rec.dismissed_at or '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">만료 일시</div>
|
||||
<div class="detail-value">{{ rec.expires_at or '없음' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">노출 횟수</div>
|
||||
<div class="detail-value">{{ rec.displayed_count or 0 }}회</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if rec.ai_raw_response %}
|
||||
<div class="detail-raw">
|
||||
<div class="detail-label">AI 원본 응답</div>
|
||||
<pre>{{ rec.ai_raw_response }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-wrap">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🤖</div>
|
||||
<div class="empty-text">아직 생성된 AI 추천이 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleDetail(id) {
|
||||
const row = document.getElementById('detail-' + id);
|
||||
if (!row) return;
|
||||
// 다른 열린 것 닫기
|
||||
document.querySelectorAll('.detail-row.open').forEach(function(el) {
|
||||
if (el.id !== 'detail-' + id) el.classList.remove('open');
|
||||
});
|
||||
row.classList.toggle('open');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
559
backend/templates/admin_ai_gw.html
Normal file
559
backend/templates/admin_ai_gw.html
Normal file
@@ -0,0 +1,559 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Gateway 모니터 - 청춘약국</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: #0f172a;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── 헤더 ── */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||||
padding: 28px 32px 24px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.6);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.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 h1 .live-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
.header h1 .live-dot.offline { background: #ef4444; animation: none; }
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
|
||||
50% { opacity: 0.8; box-shadow: 0 0 0 8px rgba(34, 197, 94, 0); }
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── 컨텐츠 ── */
|
||||
.content {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* ── 메인 카드 ── */
|
||||
.main-card {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||||
border-radius: 20px;
|
||||
padding: 32px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.main-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.main-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(255,255,255,0.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.main-model {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #a78bfa;
|
||||
}
|
||||
.refresh-btn {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.7);
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.refresh-btn:hover {
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: #fff;
|
||||
}
|
||||
.refresh-btn.loading { opacity: 0.6; pointer-events: none; }
|
||||
|
||||
/* 컨텍스트 표시 */
|
||||
.context-display {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.context-numbers {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.context-used {
|
||||
font-size: 64px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -3px;
|
||||
line-height: 1;
|
||||
}
|
||||
.context-max {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,0.3);
|
||||
}
|
||||
.context-percent {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #22c55e;
|
||||
margin-left: 16px;
|
||||
}
|
||||
.context-percent.warning { color: #fbbf24; }
|
||||
.context-percent.danger { color: #ef4444; }
|
||||
|
||||
/* 프로그레스 바 */
|
||||
.progress-wrap {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 100px;
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
border-radius: 100px;
|
||||
background: linear-gradient(90deg, #22c55e, #84cc16);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
.progress-bar.warning { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
|
||||
.progress-bar.danger { background: linear-gradient(90deg, #ef4444, #f97316); }
|
||||
|
||||
/* 통계 그리드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.stat-item {
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,0.4);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stat-value.purple { color: #a78bfa; }
|
||||
.stat-value.blue { color: #38bdf8; }
|
||||
.stat-value.yellow { color: #fbbf24; }
|
||||
.stat-value.green { color: #34d399; }
|
||||
|
||||
/* ── 세션 목록 ── */
|
||||
.sessions-card {
|
||||
background: #1e293b;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sessions-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.sessions-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sessions-count {
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
}
|
||||
.sessions-list {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.session-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.session-item:hover { background: rgba(255,255,255,0.02); }
|
||||
.session-item:last-child { border-bottom: none; }
|
||||
.session-info { flex: 1; }
|
||||
.session-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.session-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.session-model {
|
||||
color: #a78bfa;
|
||||
}
|
||||
.session-usage {
|
||||
text-align: right;
|
||||
}
|
||||
.session-percent {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.session-percent.low { color: #22c55e; }
|
||||
.session-percent.mid { color: #fbbf24; }
|
||||
.session-percent.high { color: #ef4444; }
|
||||
.session-tokens {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
}
|
||||
.session-bar-wrap {
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 100px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.session-bar {
|
||||
height: 100%;
|
||||
border-radius: 100px;
|
||||
background: #22c55e;
|
||||
}
|
||||
.session-bar.mid { background: #fbbf24; }
|
||||
.session-bar.high { background: #ef4444; }
|
||||
|
||||
/* ── 모델별 통계 ── */
|
||||
.model-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.model-stat-card {
|
||||
background: #1e293b;
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.model-stat-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #a78bfa;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.model-stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.model-stat-row span:last-child {
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ── 에러 상태 ── */
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #f87171;
|
||||
}
|
||||
.error-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.error-text { font-size: 16px; font-weight: 500; margin-bottom: 8px; }
|
||||
.error-sub { font-size: 13px; color: rgba(255,255,255,0.4); }
|
||||
|
||||
/* ── 타임스탬프 ── */
|
||||
.timestamp {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.context-used { font-size: 48px; }
|
||||
.context-max { font-size: 18px; }
|
||||
.context-percent { font-size: 18px; }
|
||||
.header { padding: 20px 16px 18px; }
|
||||
.content { padding: 16px 12px 40px; }
|
||||
.main-card { padding: 24px 20px; }
|
||||
.session-bar-wrap { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<div>
|
||||
<a href="/admin/ai-crm" style="margin-right: 16px;">AI 업셀링</a>
|
||||
<a href="/admin/alimtalk">알림톡 로그</a>
|
||||
</div>
|
||||
</div>
|
||||
<h1>
|
||||
<span class="live-dot" id="statusDot"></span>
|
||||
AI Gateway 모니터
|
||||
</h1>
|
||||
<p>Clawdbot Gateway 실시간 상태 · Claude / GPT 토큰 사용량</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div id="mainContent">
|
||||
<!-- 메인 카드 -->
|
||||
<div class="main-card">
|
||||
<div class="main-header">
|
||||
<div>
|
||||
<div class="main-title">현재 모델</div>
|
||||
<div class="main-model" id="currentModel">로딩중...</div>
|
||||
</div>
|
||||
<button class="refresh-btn" id="refreshBtn" onclick="refresh()">
|
||||
<span>↻</span> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="context-display">
|
||||
<div class="context-numbers">
|
||||
<span class="context-used" id="contextUsed">--</span>
|
||||
<span class="context-max" id="contextMax">/ 200k</span>
|
||||
<span class="context-percent" id="contextPercent">0%</span>
|
||||
</div>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar" id="progressBar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">입력 토큰</div>
|
||||
<div class="stat-value purple" id="inputTokens">-</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">출력 토큰</div>
|
||||
<div class="stat-value blue" id="outputTokens">-</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">전체 토큰 (모든 세션)</div>
|
||||
<div class="stat-value yellow" id="totalTokens">-</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">활성 세션</div>
|
||||
<div class="stat-value green" id="sessionCount">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모델별 통계 -->
|
||||
<div class="model-stats" id="modelStats"></div>
|
||||
|
||||
<!-- 세션 목록 -->
|
||||
<div class="sessions-card">
|
||||
<div class="sessions-header">
|
||||
<div class="sessions-title">세션별 상세</div>
|
||||
<div class="sessions-count" id="sessionsCount">-</div>
|
||||
</div>
|
||||
<div class="sessions-list" id="sessionsList"></div>
|
||||
</div>
|
||||
|
||||
<div class="timestamp" id="timestamp">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function formatNumber(num) {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||
if (num >= 1000) return Math.round(num / 1000) + 'k';
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
function getPercentClass(percent) {
|
||||
if (percent >= 70) return 'high';
|
||||
if (percent >= 40) return 'mid';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const btn = document.getElementById('refreshBtn');
|
||||
btn.classList.add('loading');
|
||||
btn.innerHTML = '<span>⟳</span> 로딩중...';
|
||||
|
||||
fetch('/api/claude-status?detail=true')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
btn.classList.remove('loading');
|
||||
btn.innerHTML = '<span>↻</span> 새로고침';
|
||||
|
||||
if (!data.ok || !data.connected) {
|
||||
document.getElementById('statusDot').classList.add('offline');
|
||||
document.getElementById('mainContent').innerHTML = `
|
||||
<div class="main-card">
|
||||
<div class="error-state">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<div class="error-text">Gateway 연결 실패</div>
|
||||
<div class="error-sub">${data.error || 'Clawdbot이 실행 중인지 확인하세요'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('statusDot').classList.remove('offline');
|
||||
updateUI(data);
|
||||
})
|
||||
.catch(err => {
|
||||
btn.classList.remove('loading');
|
||||
btn.innerHTML = '<span>↻</span> 새로고침';
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
function updateUI(data) {
|
||||
const ctx = data.context;
|
||||
const main = data.mainSession || {};
|
||||
const summary = data.summary;
|
||||
|
||||
// 모델
|
||||
document.getElementById('currentModel').textContent = data.model;
|
||||
|
||||
// 컨텍스트
|
||||
document.getElementById('contextUsed').textContent = formatNumber(ctx.used);
|
||||
document.getElementById('contextMax').textContent = '/ ' + formatNumber(ctx.max);
|
||||
|
||||
const percentEl = document.getElementById('contextPercent');
|
||||
percentEl.textContent = ctx.percent + '%';
|
||||
percentEl.className = 'context-percent';
|
||||
if (ctx.percent >= 70) percentEl.classList.add('danger');
|
||||
else if (ctx.percent >= 40) percentEl.classList.add('warning');
|
||||
|
||||
// 프로그레스 바
|
||||
const bar = document.getElementById('progressBar');
|
||||
bar.style.width = ctx.percent + '%';
|
||||
bar.className = 'progress-bar';
|
||||
if (ctx.percent >= 70) bar.classList.add('danger');
|
||||
else if (ctx.percent >= 40) bar.classList.add('warning');
|
||||
|
||||
// 통계
|
||||
document.getElementById('inputTokens').textContent = formatNumber(main.inputTokens || 0);
|
||||
document.getElementById('outputTokens').textContent = formatNumber(main.outputTokens || 0);
|
||||
document.getElementById('totalTokens').textContent = formatNumber(summary.totalTokens);
|
||||
document.getElementById('sessionCount').textContent = summary.totalSessions + '개';
|
||||
|
||||
// 모델별 통계
|
||||
if (data.modelStats) {
|
||||
const statsHtml = Object.entries(data.modelStats).map(([model, stat]) => `
|
||||
<div class="model-stat-card">
|
||||
<div class="model-stat-name">${escapeHtml(model)}</div>
|
||||
<div class="model-stat-row">
|
||||
<span>세션 수</span>
|
||||
<span>${stat.sessions}개</span>
|
||||
</div>
|
||||
<div class="model-stat-row">
|
||||
<span>총 토큰</span>
|
||||
<span>${formatNumber(stat.tokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('modelStats').innerHTML = statsHtml;
|
||||
}
|
||||
|
||||
// 세션 목록
|
||||
if (data.sessions) {
|
||||
document.getElementById('sessionsCount').textContent =
|
||||
`토큰 사용량 순 · ${data.sessions.length}개`;
|
||||
|
||||
const sessionsHtml = data.sessions.map(s => {
|
||||
const pct = s.tokens.contextPercent;
|
||||
const pctClass = getPercentClass(pct);
|
||||
return `
|
||||
<div class="session-item">
|
||||
<div class="session-info">
|
||||
<div class="session-name">${escapeHtml(s.displayName || s.name)}</div>
|
||||
<div class="session-meta">
|
||||
<span class="session-model">${escapeHtml(s.model)}</span>
|
||||
<span>${s.channel || '-'}</span>
|
||||
<span>${s.updatedAt || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-usage">
|
||||
<div class="session-percent ${pctClass}">${pct}%</div>
|
||||
<div class="session-tokens">${s.tokens.display}</div>
|
||||
<div class="session-bar-wrap">
|
||||
<div class="session-bar ${pctClass}" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
document.getElementById('sessionsList').innerHTML = sessionsHtml;
|
||||
}
|
||||
|
||||
// 타임스탬프
|
||||
const ts = new Date(data.timestamp);
|
||||
document.getElementById('timestamp').textContent =
|
||||
`마지막 업데이트: ${ts.toLocaleTimeString('ko-KR')}`;
|
||||
}
|
||||
|
||||
// 초기 로드 & 30초 자동 갱신
|
||||
refresh();
|
||||
setInterval(refresh, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
554
backend/templates/admin_alimtalk.html
Normal file
554
backend/templates/admin_alimtalk.html
Normal file
@@ -0,0 +1,554 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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;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;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%);
|
||||
padding: 28px 24px;
|
||||
color: #fff;
|
||||
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: 26px; font-weight: 700; letter-spacing: -0.5px; }
|
||||
.header-subtitle { font-size: 14px; opacity: 0.85; margin-top: 4px; }
|
||||
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.header-nav a:hover {
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Stats Cards */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.stat-label { font-size: 13px; color: #64748b; margin-bottom: 8px; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #1e293b; }
|
||||
.stat-value.success { color: #10b981; }
|
||||
.stat-value.fail { color: #ef4444; }
|
||||
.stat-value.today { color: #6366f1; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
background: #fff;
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tab:hover:not(.active) { background: #f1f5f9; }
|
||||
|
||||
/* Tab Panels */
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* Table */
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title { font-size: 16px; font-weight: 600; color: #1e293b; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tr:hover td { background: #f8fafc; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success { background: #dcfce7; color: #16a34a; }
|
||||
.badge-fail { background: #fee2e2; color: #dc2626; }
|
||||
.badge-kiosk { background: #dbeafe; color: #2563eb; }
|
||||
.badge-admin { background: #f3e8ff; color: #7c3aed; }
|
||||
.badge-manual { background: #fef3c7; color: #d97706; }
|
||||
.badge-completed { background: #dcfce7; color: #16a34a; }
|
||||
.badge-sending { background: #fef3c7; color: #d97706; }
|
||||
.badge-failed { background: #fee2e2; color: #dc2626; }
|
||||
|
||||
.phone-mask { font-family: 'Courier New', monospace; font-size: 13px; }
|
||||
|
||||
.param-toggle {
|
||||
font-size: 12px;
|
||||
color: #6366f1;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.param-detail {
|
||||
display: none;
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
white-space: pre-wrap;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.param-detail.show { display: block; }
|
||||
|
||||
/* NHN Tab */
|
||||
.date-picker-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.date-picker-row input {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary { background: #6366f1; color: #fff; }
|
||||
.btn-primary:hover { background: #4f46e5; }
|
||||
.btn-teal { background: #0d9488; color: #fff; }
|
||||
.btn-teal:hover { background: #0f766e; }
|
||||
.btn-sm { padding: 6px 14px; font-size: 13px; }
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-state .text { font-size: 15px; }
|
||||
|
||||
/* Test Send */
|
||||
.test-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
padding: 16px 20px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.form-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.form-group label { font-size: 12px; font-weight: 500; color: #64748b; }
|
||||
|
||||
.form-group input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
padding: 14px 20px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
||||
z-index: 1000;
|
||||
transform: translateY(100px);
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.show { transform: translateY(0); opacity: 1; }
|
||||
.toast.success { background: #10b981; }
|
||||
.toast.error { background: #ef4444; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.test-form { flex-wrap: wrap; }
|
||||
.header-nav { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-content">
|
||||
<div>
|
||||
<div class="header-title">알림톡 발송 로그</div>
|
||||
<div class="header-subtitle">NHN Cloud 카카오 알림톡 발송 기록 및 상태 모니터링</div>
|
||||
</div>
|
||||
<div class="header-nav">
|
||||
<a href="/admin">관리자 홈</a>
|
||||
<a href="/admin/ai-crm">AI 업셀링</a>
|
||||
<a href="/admin/ai-gw">Gateway 모니터</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">전체 발송</div>
|
||||
<div class="stat-value">{{ stats.total or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">성공</div>
|
||||
<div class="stat-value success">{{ stats.success_count or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">실패</div>
|
||||
<div class="stat-value fail">{{ stats.fail_count or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">오늘 발송</div>
|
||||
<div class="stat-value today">{{ stats.today_total or 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('local')">발송 로그 (서버)</button>
|
||||
<button class="tab" onclick="switchTab('nhn')">NHN Cloud 내역</button>
|
||||
<button class="tab" onclick="switchTab('test')">수동 발송</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Local Logs -->
|
||||
<div id="panel-local" class="tab-panel active">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">서버 발송 로그 (최근 50건)</div>
|
||||
</div>
|
||||
{% if local_logs %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>수신번호</th>
|
||||
<th>고객</th>
|
||||
<th>템플릿</th>
|
||||
<th>발송 주체</th>
|
||||
<th>결과</th>
|
||||
<th>상세</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in local_logs %}
|
||||
<tr>
|
||||
<td>{{ log.created_at[:16] if log.created_at else '-' }}</td>
|
||||
<td class="phone-mask">{{ log.recipient_no[:3] + '-' + log.recipient_no[3:7] + '-' + log.recipient_no[7:] if log.recipient_no|length >= 11 else log.recipient_no }}</td>
|
||||
<td>{{ log.nickname or '-' }}</td>
|
||||
<td><code>{{ log.template_code }}</code></td>
|
||||
<td>
|
||||
{% if log.trigger_source == 'kiosk' %}
|
||||
<span class="badge badge-kiosk">키오스크</span>
|
||||
{% elif log.trigger_source == 'admin_test' %}
|
||||
<span class="badge badge-admin">관리자</span>
|
||||
{% else %}
|
||||
<span class="badge badge-manual">{{ log.trigger_source }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if log.success %}
|
||||
<span class="badge badge-success">성공</span>
|
||||
{% else %}
|
||||
<span class="badge badge-fail">실패</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if log.template_params %}
|
||||
<span class="param-toggle" onclick="toggleParam(this)">변수 보기</span>
|
||||
<div class="param-detail">{{ log.template_params }}</div>
|
||||
{% endif %}
|
||||
{% if not log.success and log.result_message %}
|
||||
<div style="color: #ef4444; font-size: 12px; margin-top: 4px;">{{ log.result_message }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="icon">📭</div>
|
||||
<div class="text">아직 발송 기록이 없습니다</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: NHN Cloud -->
|
||||
<div id="panel-nhn" class="tab-panel">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">NHN Cloud 발송 내역</div>
|
||||
</div>
|
||||
<div style="padding: 16px 20px;">
|
||||
<div class="date-picker-row">
|
||||
<input type="date" id="nhn-date" value="{{ now_date }}" />
|
||||
<button class="btn btn-primary" onclick="loadNhnHistory()">조회</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="nhn-table-area">
|
||||
<div class="empty-state">
|
||||
<div class="icon">🔍</div>
|
||||
<div class="text">날짜를 선택하고 조회를 눌러주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Test Send -->
|
||||
<div id="panel-test" class="tab-panel">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">수동 알림톡 발송 테스트</div>
|
||||
</div>
|
||||
<div class="test-form">
|
||||
<div class="form-group">
|
||||
<label>전화번호</label>
|
||||
<input type="tel" id="test-phone" placeholder="01012345678" style="width: 160px;" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>고객명</label>
|
||||
<input type="text" id="test-name" placeholder="테스트" value="테스트" style="width: 120px;" />
|
||||
</div>
|
||||
<button class="btn btn-teal" onclick="sendTest()">테스트 발송</button>
|
||||
</div>
|
||||
<div style="padding: 20px; color: #64748b; font-size: 13px; line-height: 1.8;">
|
||||
<strong>안내</strong><br>
|
||||
- MILEAGE_CLAIM_V3 템플릿으로 테스트 메시지를 발송합니다.<br>
|
||||
- 테스트 값: 적립 100P, 잔액 500P, 품목 "테스트 발송"<br>
|
||||
- 발송 결과는 "발송 로그 (서버)" 탭에서 확인 가능합니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// Tab switching
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
|
||||
event.target.classList.add('active');
|
||||
document.getElementById('panel-' + tabName).classList.add('active');
|
||||
|
||||
if (tabName === 'nhn' && !document.getElementById('nhn-table-area').dataset.loaded) {
|
||||
loadNhnHistory();
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle param detail
|
||||
function toggleParam(el) {
|
||||
const detail = el.nextElementSibling;
|
||||
detail.classList.toggle('show');
|
||||
el.textContent = detail.classList.contains('show') ? '접기' : '변수 보기';
|
||||
}
|
||||
|
||||
// Toast notification
|
||||
function showToast(msg, type) {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = msg;
|
||||
toast.className = 'toast ' + type + ' show';
|
||||
setTimeout(() => toast.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
// Load NHN history
|
||||
async function loadNhnHistory() {
|
||||
const date = document.getElementById('nhn-date').value;
|
||||
const area = document.getElementById('nhn-table-area');
|
||||
area.innerHTML = '<div class="loading">조회 중...</div>';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/admin/alimtalk/nhn-history?date=' + date);
|
||||
const data = await resp.json();
|
||||
area.dataset.loaded = '1';
|
||||
|
||||
if (!data.messages || data.messages.length === 0) {
|
||||
area.innerHTML = '<div class="empty-state"><div class="icon">📭</div><div class="text">' + date + ' 발송 내역이 없습니다</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table><thead><tr><th>요청 시간</th><th>수신번호</th><th>템플릿</th><th>상태</th><th>결과코드</th></tr></thead><tbody>';
|
||||
data.messages.forEach(m => {
|
||||
const time = m.requestDate ? m.requestDate.substring(0, 19) : '-';
|
||||
const phone = m.recipientNo || '-';
|
||||
const tpl = m.templateCode || '-';
|
||||
|
||||
let statusBadge = '';
|
||||
const st = (m.messageStatus || '').toUpperCase();
|
||||
if (st === 'COMPLETED') {
|
||||
statusBadge = '<span class="badge badge-completed">전송완료</span>';
|
||||
} else if (st === 'SENDING' || st === 'READY') {
|
||||
statusBadge = '<span class="badge badge-sending">발송중</span>';
|
||||
} else {
|
||||
statusBadge = '<span class="badge badge-failed">' + (m.messageStatus || '알수없음') + '</span>';
|
||||
}
|
||||
|
||||
const code = m.resultCode || '-';
|
||||
|
||||
html += '<tr><td>' + time + '</td><td class="phone-mask">' + phone + '</td><td><code>' + tpl + '</code></td><td>' + statusBadge + '</td><td>' + code + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
area.innerHTML = html;
|
||||
} catch(e) {
|
||||
area.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><div class="text">조회 실패: ' + e.message + '</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Test send
|
||||
async function sendTest() {
|
||||
const phone = document.getElementById('test-phone').value.trim();
|
||||
const name = document.getElementById('test-name').value.trim() || '테스트';
|
||||
|
||||
if (phone.length < 10) {
|
||||
showToast('전화번호를 입력해주세요', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/admin/alimtalk/test-send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone, name })
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast('발송 성공!', 'success');
|
||||
} else {
|
||||
showToast('발송 실패: ' + data.message, 'error');
|
||||
}
|
||||
} catch(e) {
|
||||
showToast('오류: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Set today's date
|
||||
document.getElementById('nhn-date').value = new Date().toISOString().split('T')[0];
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1116
backend/templates/admin_members.html
Normal file
1116
backend/templates/admin_members.html
Normal file
File diff suppressed because it is too large
Load Diff
619
backend/templates/admin_products.html
Normal file
619
backend/templates/admin_products.html
Normal file
@@ -0,0 +1,619 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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&family=JetBrains+Mono:wght@400;500&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, #7c3aed 0%, #8b5cf6 50%, #a78bfa 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;
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── 컨텐츠 ── */
|
||||
.content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* ── 검색 영역 ── */
|
||||
.search-section {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 14px 18px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #8b5cf6;
|
||||
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.search-btn {
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-btn:hover { background: #7c3aed; }
|
||||
.search-btn:active { transform: scale(0.98); }
|
||||
.search-hint {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.search-hint span {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* ── 결과 카운트 ── */
|
||||
.result-count {
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
.result-count strong {
|
||||
color: #8b5cf6;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── 테이블 ── */
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: #f8fafc;
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td {
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody tr:hover { background: #faf5ff; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* ── 상품 정보 ── */
|
||||
.product-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.product-supplier {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.product-supplier.set {
|
||||
color: #8b5cf6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── 코드/바코드 ── */
|
||||
.code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
.code-drug {
|
||||
background: #ede9fe;
|
||||
color: #6d28d9;
|
||||
}
|
||||
.code-barcode {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.code-na {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── 가격 ── */
|
||||
.price {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── QR 버튼 ── */
|
||||
.btn-qr {
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-qr:hover { background: #7c3aed; }
|
||||
.btn-qr:active { transform: scale(0.95); }
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.empty-state .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.empty-state p {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ── 모달 ── */
|
||||
.modal-overlay {
|
||||
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: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal-box {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.modal-preview {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.modal-preview img {
|
||||
max-width: 200px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── 수량 선택기 ── */
|
||||
.qty-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.qty-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
}
|
||||
.qty-btn:first-child { border-radius: 12px 0 0 12px; }
|
||||
.qty-btn:last-child { border-radius: 0 12px 12px 0; }
|
||||
.qty-btn:hover { background: #e2e8f0; color: #334155; }
|
||||
.qty-btn:active { transform: scale(0.95); background: #cbd5e1; }
|
||||
.qty-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.qty-value {
|
||||
width: 64px;
|
||||
height: 44px;
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.qty-label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-btns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.modal-btn.cancel { background: #f1f5f9; color: #64748b; }
|
||||
.modal-btn.cancel:hover { background: #e2e8f0; }
|
||||
.modal-btn.confirm { background: #8b5cf6; color: #fff; }
|
||||
.modal-btn.confirm:hover { background: #7c3aed; }
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 768px) {
|
||||
.search-box { flex-direction: column; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { min-width: 700px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<div>
|
||||
<a href="/admin/sales-detail" style="margin-right: 16px;">판매 조회</a>
|
||||
<a href="/admin/sales">판매 내역</a>
|
||||
</div>
|
||||
</div>
|
||||
<h1>🔍 제품 검색</h1>
|
||||
<p>전체 제품 검색 · QR 라벨 인쇄</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 검색 -->
|
||||
<div class="search-section">
|
||||
<div class="search-box">
|
||||
<input type="text" class="search-input" id="searchInput"
|
||||
placeholder="상품명, 바코드, 상품코드로 검색..."
|
||||
onkeypress="if(event.key==='Enter')searchProducts()">
|
||||
<button class="search-btn" onclick="searchProducts()">🔍 검색</button>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 12px;">
|
||||
<div class="search-hint">
|
||||
<span>예시</span> 타이레놀, 벤포파워, 8806418067510, LB000001423
|
||||
</div>
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; color: #475569;">
|
||||
<input type="checkbox" id="animalOnly" style="width: 18px; height: 18px; accent-color: #10b981; cursor: pointer;">
|
||||
<span style="display: flex; align-items: center; gap: 4px;">
|
||||
🐾 <strong style="color: #10b981;">동물약만</strong> 보기
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 -->
|
||||
<div class="result-count" id="resultCount" style="display:none;">
|
||||
검색 결과: <strong id="resultNum">0</strong>건
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>상품명</th>
|
||||
<th>상품코드</th>
|
||||
<th>바코드</th>
|
||||
<th>판매가</th>
|
||||
<th>QR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="productsTableBody">
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state">
|
||||
<div class="icon">🔍</div>
|
||||
<p>상품명, 바코드, 상품코드로 검색하세요</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR 인쇄 모달 -->
|
||||
<div class="modal-overlay" id="qrModal" onclick="if(event.target===this)closeQRModal()">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title">🏷️ QR 라벨 인쇄</div>
|
||||
<div id="qrInfo" style="margin-bottom:12px;"></div>
|
||||
<div class="modal-preview" id="qrPreview">
|
||||
<p style="color:#64748b;">미리보기 로딩 중...</p>
|
||||
</div>
|
||||
<div class="qty-label">인쇄 매수</div>
|
||||
<div class="qty-selector">
|
||||
<button class="qty-btn" onclick="adjustQty(-1)" id="qtyMinus">−</button>
|
||||
<div class="qty-value" id="qtyValue">1</div>
|
||||
<button class="qty-btn" onclick="adjustQty(1)" id="qtyPlus">+</button>
|
||||
</div>
|
||||
<div class="modal-btns">
|
||||
<button class="modal-btn cancel" onclick="closeQRModal()">취소</button>
|
||||
<button class="modal-btn confirm" onclick="confirmPrintQR()" id="printBtn">인쇄</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let productsData = [];
|
||||
let selectedItem = null;
|
||||
let printQty = 1;
|
||||
const MAX_QTY = 10;
|
||||
const MIN_QTY = 1;
|
||||
|
||||
function formatPrice(num) {
|
||||
if (!num) return '-';
|
||||
return new Intl.NumberFormat('ko-KR').format(num) + '원';
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
function searchProducts() {
|
||||
const search = document.getElementById('searchInput').value.trim();
|
||||
if (!search) {
|
||||
alert('검색어를 입력하세요');
|
||||
return;
|
||||
}
|
||||
if (search.length < 2) {
|
||||
alert('2글자 이상 입력하세요');
|
||||
return;
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('productsTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state"><p>검색 중...</p></td></tr>';
|
||||
|
||||
const animalOnly = document.getElementById('animalOnly').checked;
|
||||
fetch(`/api/products?search=${encodeURIComponent(search)}${animalOnly ? '&animal_only=1' : ''}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
productsData = data.items;
|
||||
document.getElementById('resultCount').style.display = 'block';
|
||||
document.getElementById('resultNum').textContent = productsData.length;
|
||||
renderTable();
|
||||
} else {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="empty-state"><p>오류: ${data.error}</p></td></tr>`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state"><p>검색 실패</p></td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('productsTableBody');
|
||||
|
||||
if (productsData.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state"><div class="icon">📭</div><p>검색 결과가 없습니다</p></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = productsData.map((item, idx) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="product-name">
|
||||
${escapeHtml(item.product_name)}
|
||||
${item.is_animal_drug ? '<span style="display:inline-block;background:#10b981;color:#fff;font-size:11px;padding:2px 6px;border-radius:4px;margin-left:6px;">🐾 동물약</span>' : ''}
|
||||
</div>
|
||||
<div class="product-supplier ${item.is_set ? 'set' : ''}">${escapeHtml(item.supplier) || ''}</div>
|
||||
</td>
|
||||
<td><span class="code code-drug">${item.drug_code}</span></td>
|
||||
<td>${item.barcode
|
||||
? `<span class="code code-barcode">${item.barcode}</span>`
|
||||
: `<span class="code code-na">없음</span>`}</td>
|
||||
<td class="price">${formatPrice(item.sale_price)}</td>
|
||||
<td>
|
||||
<button class="btn-qr" onclick="printQR(${idx})">🏷️ QR</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ── QR 인쇄 관련 ──
|
||||
function adjustQty(delta) {
|
||||
printQty = Math.max(MIN_QTY, Math.min(MAX_QTY, printQty + delta));
|
||||
updateQtyUI();
|
||||
}
|
||||
|
||||
function updateQtyUI() {
|
||||
document.getElementById('qtyValue').textContent = printQty;
|
||||
document.getElementById('qtyMinus').disabled = printQty <= MIN_QTY;
|
||||
document.getElementById('qtyPlus').disabled = printQty >= MAX_QTY;
|
||||
document.getElementById('printBtn').textContent = printQty > 1 ? `${printQty}장 인쇄` : '인쇄';
|
||||
}
|
||||
|
||||
function printQR(idx) {
|
||||
selectedItem = productsData[idx];
|
||||
printQty = 1;
|
||||
|
||||
const modal = document.getElementById('qrModal');
|
||||
const preview = document.getElementById('qrPreview');
|
||||
const info = document.getElementById('qrInfo');
|
||||
|
||||
preview.innerHTML = '<p style="color:#64748b;">미리보기 로딩 중...</p>';
|
||||
info.innerHTML = `
|
||||
<strong>${escapeHtml(selectedItem.product_name)}</strong><br>
|
||||
<span style="color:#64748b;font-size:13px;">
|
||||
바코드: ${selectedItem.barcode || selectedItem.drug_code || 'N/A'}<br>
|
||||
가격: ${formatPrice(selectedItem.sale_price)}
|
||||
</span>
|
||||
`;
|
||||
updateQtyUI();
|
||||
modal.classList.add('active');
|
||||
|
||||
fetch('/api/qr-preview', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
drug_name: selectedItem.product_name,
|
||||
barcode: selectedItem.barcode || '',
|
||||
drug_code: selectedItem.drug_code || '',
|
||||
sale_price: selectedItem.sale_price || 0
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success && data.image) {
|
||||
preview.innerHTML = `<img src="${data.image}" alt="QR 미리보기">`;
|
||||
} else {
|
||||
preview.innerHTML = '<p style="color:#ef4444;">미리보기 실패</p>';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
preview.innerHTML = '<p style="color:#ef4444;">미리보기 오류</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function closeQRModal() {
|
||||
document.getElementById('qrModal').classList.remove('active');
|
||||
selectedItem = null;
|
||||
printQty = 1;
|
||||
}
|
||||
|
||||
async function confirmPrintQR() {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const btn = document.getElementById('printBtn');
|
||||
const totalQty = printQty;
|
||||
btn.disabled = true;
|
||||
|
||||
let successCount = 0;
|
||||
let errorMsg = '';
|
||||
|
||||
for (let i = 0; i < totalQty; i++) {
|
||||
btn.textContent = `인쇄 중... (${i + 1}/${totalQty})`;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/qr-print', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
drug_name: selectedItem.product_name,
|
||||
barcode: selectedItem.barcode || '',
|
||||
drug_code: selectedItem.drug_code || '',
|
||||
sale_price: selectedItem.sale_price || 0
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
errorMsg = data.error || '알 수 없는 오류';
|
||||
break;
|
||||
}
|
||||
|
||||
if (i < totalQty - 1) {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg = err.message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
updateQtyUI();
|
||||
|
||||
if (successCount === totalQty) {
|
||||
alert(`✅ QR 라벨 ${totalQty}장 인쇄 완료!`);
|
||||
closeQRModal();
|
||||
} else if (successCount > 0) {
|
||||
alert(`⚠️ ${successCount}/${totalQty}장 인쇄 완료\n오류: ${errorMsg}`);
|
||||
} else {
|
||||
alert(`❌ 인쇄 실패: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 검색창 포커스
|
||||
document.getElementById('searchInput').focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
778
backend/templates/admin_sales_detail.html
Normal file
778
backend/templates/admin_sales_detail.html
Normal file
@@ -0,0 +1,778 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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&family=JetBrains+Mono:wght@400;500&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, #0f766e 0%, #0d9488 50%, #14b8a6 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;
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── 컨텐츠 ── */
|
||||
.content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* ── 검색/필터 영역 ── */
|
||||
.search-section {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.search-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.search-group label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
.search-group input, .search-group select {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
min-width: 150px;
|
||||
}
|
||||
.search-group input:focus, .search-group select:focus {
|
||||
outline: none;
|
||||
border-color: #0d9488;
|
||||
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.1);
|
||||
}
|
||||
.search-btn {
|
||||
background: #0d9488;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.search-btn:hover { background: #0f766e; }
|
||||
|
||||
/* ── 통계 카드 ── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.stat-value.teal { color: #0d9488; }
|
||||
.stat-value.blue { color: #3b82f6; }
|
||||
.stat-value.purple { color: #8b5cf6; }
|
||||
.stat-value.orange { color: #f59e0b; }
|
||||
|
||||
/* ── 테이블 ── */
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.table-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.table-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.table-count {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
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;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* ── 코드 스타일 ── */
|
||||
.code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
.code-drug {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.code-barcode {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.code-standard {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.code-na {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── 제품명 ── */
|
||||
.product-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.product-category {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── 금액 ── */
|
||||
.price {
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
.qty {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── 코드 전환 버튼 ── */
|
||||
.code-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.code-toggle button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.code-toggle button.active {
|
||||
background: #0d9488;
|
||||
color: #fff;
|
||||
border-color: #0d9488;
|
||||
}
|
||||
.code-toggle button:hover:not(.active) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── 로딩 ── */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* ── QR 인쇄 버튼 ── */
|
||||
.btn-qr {
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-qr:hover { background: #7c3aed; }
|
||||
.btn-qr:disabled {
|
||||
background: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-qr.printing {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
/* ── 모달 ── */
|
||||
.modal-overlay {
|
||||
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: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal-box {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.modal-preview {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.modal-preview img {
|
||||
max-width: 200px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── 수량 선택기 ── */
|
||||
.qty-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.qty-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
}
|
||||
.qty-btn:first-child {
|
||||
border-radius: 12px 0 0 12px;
|
||||
}
|
||||
.qty-btn:last-child {
|
||||
border-radius: 0 12px 12px 0;
|
||||
}
|
||||
.qty-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
.qty-btn:active {
|
||||
transform: scale(0.95);
|
||||
background: #cbd5e1;
|
||||
}
|
||||
.qty-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.qty-value {
|
||||
width: 64px;
|
||||
height: 44px;
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.qty-label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-btns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.modal-btn.cancel {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
.modal-btn.cancel:hover { background: #e2e8f0; }
|
||||
.modal-btn.confirm {
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
}
|
||||
.modal-btn.confirm:hover { background: #7c3aed; }
|
||||
.modal-btn.confirm:active { transform: scale(0.98); }
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.search-section { flex-direction: column; }
|
||||
.search-group { width: 100%; }
|
||||
.search-group input, .search-group select { width: 100%; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { min-width: 900px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<div>
|
||||
<a href="/admin/sales" style="margin-right: 16px;">판매 내역</a>
|
||||
<a href="/admin/ai-crm" style="margin-right: 16px;">AI 업셀링</a>
|
||||
<a href="/admin/ai-gw">Gateway 모니터</a>
|
||||
</div>
|
||||
</div>
|
||||
<h1>판매 상세 조회</h1>
|
||||
<p>상품코드 · 바코드 · 표준코드 매핑 조회</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 검색/필터 -->
|
||||
<div class="search-section">
|
||||
<div class="search-group">
|
||||
<label>조회 기간</label>
|
||||
<select id="periodSelect">
|
||||
<option value="1">오늘</option>
|
||||
<option value="7" selected>최근 7일</option>
|
||||
<option value="30">최근 30일</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<label>검색 (상품명/코드)</label>
|
||||
<input type="text" id="searchInput" placeholder="타이레놀, LB000...">
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<label>바코드 필터</label>
|
||||
<select id="barcodeFilter">
|
||||
<option value="all">전체</option>
|
||||
<option value="has">바코드 있음</option>
|
||||
<option value="none">바코드 없음</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="search-btn" onclick="loadSalesData()">조회</button>
|
||||
</div>
|
||||
|
||||
<!-- 통계 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 판매 건수</div>
|
||||
<div class="stat-value teal" id="statTotal">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 매출액</div>
|
||||
<div class="stat-value blue" id="statAmount">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">바코드 매핑률</div>
|
||||
<div class="stat-value purple" id="statBarcode">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">고유 상품 수</div>
|
||||
<div class="stat-value orange" id="statProducts">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 코드 표시 토글 -->
|
||||
<div class="code-toggle">
|
||||
<button class="active" data-code="drug" onclick="setCodeView('drug')">상품코드</button>
|
||||
<button data-code="barcode" onclick="setCodeView('barcode')">바코드</button>
|
||||
<button data-code="standard" onclick="setCodeView('standard')">표준코드</button>
|
||||
<button data-code="all" onclick="setCodeView('all')">전체 표시</button>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div class="table-wrap">
|
||||
<div class="table-header">
|
||||
<div class="table-title">판매 내역</div>
|
||||
<div class="table-count" id="tableCount">-</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>판매일시</th>
|
||||
<th>상품명</th>
|
||||
<th id="codeHeader">상품코드</th>
|
||||
<th>수량</th>
|
||||
<th>단가</th>
|
||||
<th>합계</th>
|
||||
<th>QR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="salesTableBody">
|
||||
<tr><td colspan="7" class="loading">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let salesData = [];
|
||||
let currentCodeView = 'drug';
|
||||
|
||||
function setCodeView(view) {
|
||||
currentCodeView = view;
|
||||
document.querySelectorAll('.code-toggle button').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.code === view);
|
||||
});
|
||||
|
||||
const header = document.getElementById('codeHeader');
|
||||
if (view === 'drug') header.textContent = '상품코드';
|
||||
else if (view === 'barcode') header.textContent = '바코드';
|
||||
else if (view === 'standard') header.textContent = '표준코드';
|
||||
else header.textContent = '코드 (상품/바코드/표준)';
|
||||
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function formatPrice(num) {
|
||||
return new Intl.NumberFormat('ko-KR').format(num) + '원';
|
||||
}
|
||||
|
||||
function renderCodeCell(item) {
|
||||
if (currentCodeView === 'drug') {
|
||||
return `<span class="code code-drug">${item.drug_code}</span>`;
|
||||
} else if (currentCodeView === 'barcode') {
|
||||
return item.barcode
|
||||
? `<span class="code code-barcode">${item.barcode}</span>`
|
||||
: `<span class="code code-na">N/A</span>`;
|
||||
} else if (currentCodeView === 'standard') {
|
||||
return item.standard_code
|
||||
? `<span class="code code-standard">${item.standard_code}</span>`
|
||||
: `<span class="code code-na">N/A</span>`;
|
||||
} else {
|
||||
// 전체 표시
|
||||
let html = `<span class="code code-drug">${item.drug_code}</span><br>`;
|
||||
html += item.barcode
|
||||
? `<span class="code code-barcode">${item.barcode}</span><br>`
|
||||
: `<span class="code code-na">바코드 없음</span><br>`;
|
||||
html += item.standard_code
|
||||
? `<span class="code code-standard">${item.standard_code}</span>`
|
||||
: `<span class="code code-na">표준코드 없음</span>`;
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('salesTableBody');
|
||||
|
||||
if (salesData.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">판매 내역이 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = salesData.map((item, idx) => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap;font-size:12px;color:#64748b;">${item.sale_date}</td>
|
||||
<td>
|
||||
<div class="product-name">${escapeHtml(item.product_name)}</div>
|
||||
${item.supplier ? `<div class="product-category">${escapeHtml(item.supplier)}</div>` : ''}
|
||||
</td>
|
||||
<td>${renderCodeCell(item)}</td>
|
||||
<td class="qty">${item.quantity}</td>
|
||||
<td class="price">${formatPrice(item.unit_price)}</td>
|
||||
<td class="price">${formatPrice(item.total_price)}</td>
|
||||
<td>
|
||||
<button class="btn-qr" onclick="printQR(${idx})" title="QR 라벨 인쇄">
|
||||
🏷️ QR
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
function loadSalesData() {
|
||||
const period = document.getElementById('periodSelect').value;
|
||||
const search = document.getElementById('searchInput').value;
|
||||
const barcodeFilter = document.getElementById('barcodeFilter').value;
|
||||
|
||||
document.getElementById('salesTableBody').innerHTML =
|
||||
'<tr><td colspan="7" class="loading">로딩 중...</td></tr>';
|
||||
|
||||
fetch(`/api/sales-detail?days=${period}&search=${encodeURIComponent(search)}&barcode=${barcodeFilter}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
salesData = data.items;
|
||||
|
||||
// 통계 업데이트
|
||||
document.getElementById('statTotal').textContent = data.stats.total_count.toLocaleString();
|
||||
document.getElementById('statAmount').textContent = formatPrice(data.stats.total_amount);
|
||||
document.getElementById('statBarcode').textContent = data.stats.barcode_rate + '%';
|
||||
document.getElementById('statProducts').textContent = data.stats.unique_products.toLocaleString();
|
||||
document.getElementById('tableCount').textContent = `${salesData.length}건`;
|
||||
|
||||
renderTable();
|
||||
} else {
|
||||
document.getElementById('salesTableBody').innerHTML =
|
||||
`<tr><td colspan="7" class="empty-state">오류: ${data.error}</td></tr>`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('salesTableBody').innerHTML =
|
||||
`<tr><td colspan="7" class="empty-state">데이터 로드 실패</td></tr>`;
|
||||
});
|
||||
}
|
||||
|
||||
// QR 인쇄 관련
|
||||
let selectedItem = null;
|
||||
let printQty = 1;
|
||||
const MAX_QTY = 10;
|
||||
const MIN_QTY = 1;
|
||||
|
||||
function adjustQty(delta) {
|
||||
printQty = Math.max(MIN_QTY, Math.min(MAX_QTY, printQty + delta));
|
||||
updateQtyUI();
|
||||
}
|
||||
|
||||
function updateQtyUI() {
|
||||
document.getElementById('qtyValue').textContent = printQty;
|
||||
document.getElementById('qtyMinus').disabled = printQty <= MIN_QTY;
|
||||
document.getElementById('qtyPlus').disabled = printQty >= MAX_QTY;
|
||||
|
||||
const btn = document.getElementById('printBtn');
|
||||
btn.textContent = printQty > 1 ? `${printQty}장 인쇄` : '인쇄';
|
||||
}
|
||||
|
||||
function printQR(idx) {
|
||||
selectedItem = salesData[idx];
|
||||
printQty = 1;
|
||||
|
||||
// 미리보기 요청
|
||||
const modal = document.getElementById('qrModal');
|
||||
const preview = document.getElementById('qrPreview');
|
||||
const info = document.getElementById('qrInfo');
|
||||
|
||||
preview.innerHTML = '<p style="color:#64748b;">미리보기 로딩 중...</p>';
|
||||
info.innerHTML = `
|
||||
<strong>${escapeHtml(selectedItem.product_name)}</strong><br>
|
||||
<span style="color:#64748b;font-size:13px;">
|
||||
바코드: ${selectedItem.barcode || selectedItem.drug_code || 'N/A'}<br>
|
||||
가격: ${formatPrice(selectedItem.unit_price)}
|
||||
</span>
|
||||
`;
|
||||
updateQtyUI();
|
||||
modal.classList.add('active');
|
||||
|
||||
// 미리보기 이미지 로드
|
||||
fetch('/api/qr-preview', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
drug_name: selectedItem.product_name,
|
||||
barcode: selectedItem.barcode || '',
|
||||
drug_code: selectedItem.drug_code || '',
|
||||
sale_price: selectedItem.unit_price || 0
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success && data.image) {
|
||||
preview.innerHTML = `<img src="${data.image}" alt="QR 미리보기">`;
|
||||
} else {
|
||||
preview.innerHTML = '<p style="color:#ef4444;">미리보기 실패</p>';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
preview.innerHTML = '<p style="color:#ef4444;">미리보기 오류</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function closeQRModal() {
|
||||
document.getElementById('qrModal').classList.remove('active');
|
||||
selectedItem = null;
|
||||
printQty = 1;
|
||||
}
|
||||
|
||||
async function confirmPrintQR() {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const btn = document.getElementById('printBtn');
|
||||
const totalQty = printQty;
|
||||
btn.disabled = true;
|
||||
|
||||
let successCount = 0;
|
||||
let errorMsg = '';
|
||||
|
||||
for (let i = 0; i < totalQty; i++) {
|
||||
btn.textContent = `인쇄 중... (${i + 1}/${totalQty})`;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/qr-print', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
drug_name: selectedItem.product_name,
|
||||
barcode: selectedItem.barcode || '',
|
||||
drug_code: selectedItem.drug_code || '',
|
||||
sale_price: selectedItem.unit_price || 0
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
errorMsg = data.error || '알 수 없는 오류';
|
||||
break;
|
||||
}
|
||||
|
||||
// 연속 인쇄 시 약간의 딜레이
|
||||
if (i < totalQty - 1) {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg = err.message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
updateQtyUI();
|
||||
|
||||
if (successCount === totalQty) {
|
||||
alert(`✅ QR 라벨 ${totalQty}장 인쇄 완료!`);
|
||||
closeQRModal();
|
||||
} else if (successCount > 0) {
|
||||
alert(`⚠️ ${successCount}/${totalQty}장 인쇄 완료\n오류: ${errorMsg}`);
|
||||
} else {
|
||||
alert(`❌ 인쇄 실패: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 초기 로드
|
||||
loadSalesData();
|
||||
</script>
|
||||
|
||||
<!-- QR 인쇄 모달 -->
|
||||
<div class="modal-overlay" id="qrModal" onclick="if(event.target===this)closeQRModal()">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title">🏷️ QR 라벨 인쇄</div>
|
||||
<div id="qrInfo" style="margin-bottom:12px;"></div>
|
||||
<div class="modal-preview" id="qrPreview">
|
||||
<p style="color:#64748b;">미리보기 로딩 중...</p>
|
||||
</div>
|
||||
<div class="qty-label">인쇄 매수</div>
|
||||
<div class="qty-selector">
|
||||
<button class="qty-btn" onclick="adjustQty(-1)" id="qtyMinus">−</button>
|
||||
<div class="qty-value" id="qtyValue">1</div>
|
||||
<button class="qty-btn" onclick="adjustQty(1)" id="qtyPlus">+</button>
|
||||
</div>
|
||||
<div class="modal-btns">
|
||||
<button class="modal-btn cancel" onclick="closeQRModal()">취소</button>
|
||||
<button class="modal-btn confirm" onclick="confirmPrintQR()" id="printBtn">인쇄</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
902
backend/templates/admin_sales_pos.html
Normal file
902
backend/templates/admin_sales_pos.html
Normal file
@@ -0,0 +1,902 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>판매 내역 - 청춘약국 POS</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&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-card: #1e293b;
|
||||
--bg-card-hover: #334155;
|
||||
--border: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent-teal: #14b8a6;
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-purple: #a855f7;
|
||||
--accent-amber: #f59e0b;
|
||||
--accent-emerald: #10b981;
|
||||
--accent-rose: #f43f5e;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ══════════════════ 헤더 ══════════════════ */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #14b8a6 100%);
|
||||
padding: 20px 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header-left h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-left p {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.header-nav a:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ══════════════════ 컨텐츠 ══════════════════ */
|
||||
.content {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 검색 영역 ══════════════════ */
|
||||
.search-bar {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.search-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.search-group label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.search-group input, .search-group select {
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
min-width: 140px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-group input:focus, .search-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-teal);
|
||||
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.2);
|
||||
}
|
||||
.search-group input::placeholder { color: var(--text-muted); }
|
||||
.search-btn {
|
||||
background: linear-gradient(135deg, var(--accent-teal), var(--accent-emerald));
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(20, 184, 166, 0.4);
|
||||
}
|
||||
|
||||
/* ══════════════════ 통계 카드 ══════════════════ */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
}
|
||||
.stat-card.teal::before { background: var(--accent-teal); }
|
||||
.stat-card.blue::before { background: var(--accent-blue); }
|
||||
.stat-card.purple::before { background: var(--accent-purple); }
|
||||
.stat-card.amber::before { background: var(--accent-amber); }
|
||||
.stat-card.emerald::before { background: var(--accent-emerald); }
|
||||
|
||||
.stat-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-card.teal .stat-value { color: var(--accent-teal); }
|
||||
.stat-card.blue .stat-value { color: var(--accent-blue); }
|
||||
.stat-card.purple .stat-value { color: var(--accent-purple); }
|
||||
.stat-card.amber .stat-value { color: var(--accent-amber); }
|
||||
.stat-card.emerald .stat-value { color: var(--accent-emerald); }
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 뷰 토글 ══════════════════ */
|
||||
.view-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.code-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.code-toggle button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.code-toggle button.active {
|
||||
background: var(--accent-teal);
|
||||
color: #fff;
|
||||
}
|
||||
.code-toggle button:hover:not(.active) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.view-mode {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.view-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.view-btn.active {
|
||||
border-color: var(--accent-teal);
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
/* ══════════════════ 거래 카드 (그룹별) ══════════════════ */
|
||||
.transactions-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.tx-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tx-card:hover {
|
||||
border-color: var(--accent-teal);
|
||||
}
|
||||
.tx-header {
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.tx-header:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
.tx-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.tx-id {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
.tx-time {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tx-customer {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.tx-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.tx-count {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tx-amount {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-emerald);
|
||||
}
|
||||
.tx-toggle {
|
||||
font-size: 16px;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.tx-card.open .tx-toggle {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 품목 테이블 */
|
||||
.tx-items {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
}
|
||||
.tx-card.open .tx-items {
|
||||
max-height: 2000px;
|
||||
}
|
||||
.items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.items-table th {
|
||||
padding: 12px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: left;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.items-table th:nth-child(4),
|
||||
.items-table th:nth-child(5),
|
||||
.items-table th:nth-child(6) {
|
||||
text-align: right;
|
||||
}
|
||||
.items-table td {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.items-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.items-table tr:hover {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
/* 제품 셀 */
|
||||
.product-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.product-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.product-supplier {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 코드 뱃지 */
|
||||
.code-badge {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
.code-drug {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
.code-barcode {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
.code-standard {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
.code-na {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
.code-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 바코드 시각화 */
|
||||
.barcode-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.barcode-bars {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
align-items: flex-end;
|
||||
height: 20px;
|
||||
}
|
||||
.barcode-bars span {
|
||||
width: 2px;
|
||||
background: var(--accent-emerald);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 숫자 정렬 */
|
||||
.items-table td.qty,
|
||||
.items-table td.price {
|
||||
text-align: right;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
.items-table td.price.total {
|
||||
color: var(--accent-teal);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ══════════════════ 리스트 뷰 ══════════════════ */
|
||||
.list-view {
|
||||
display: none;
|
||||
}
|
||||
.list-view.active {
|
||||
display: block;
|
||||
}
|
||||
.list-table-wrap {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.list-table th {
|
||||
padding: 14px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: left;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.list-table td {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.list-table tr:hover {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
/* ══════════════════ 로딩/빈 상태 ══════════════════ */
|
||||
.loading-state, .empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent-teal);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 반응형 ══════════════════ */
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.header-nav { display: none; }
|
||||
.search-bar { flex-direction: column; }
|
||||
.search-group { width: 100%; }
|
||||
.search-group input, .search-group select { width: 100%; }
|
||||
.tx-info { flex-wrap: wrap; gap: 8px; }
|
||||
.view-controls { flex-direction: column; gap: 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-inner">
|
||||
<div class="header-left">
|
||||
<h1>🧾 판매 내역</h1>
|
||||
<p>POS 판매 데이터 · 바코드 · 표준코드 조회</p>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<a href="/admin">📊 대시보드</a>
|
||||
<a href="/admin/ai-crm">🤖 AI CRM</a>
|
||||
<a href="/admin/alimtalk">📨 알림톡</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 검색 -->
|
||||
<div class="search-bar">
|
||||
<div class="search-group">
|
||||
<label>조회 기간</label>
|
||||
<select id="periodSelect">
|
||||
<option value="1">오늘</option>
|
||||
<option value="3" selected>최근 3일</option>
|
||||
<option value="7">최근 7일</option>
|
||||
<option value="30">최근 30일</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<label>검색어</label>
|
||||
<input type="text" id="searchInput" placeholder="상품명, 코드, 바코드...">
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<label>바코드</label>
|
||||
<select id="barcodeFilter">
|
||||
<option value="all">전체</option>
|
||||
<option value="has">있음</option>
|
||||
<option value="none">없음</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="search-btn" onclick="loadSalesData()">🔍 조회</button>
|
||||
</div>
|
||||
|
||||
<!-- 통계 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card teal">
|
||||
<div class="stat-icon">📅</div>
|
||||
<div class="stat-value" id="statTxCount">-</div>
|
||||
<div class="stat-label">조회 일수</div>
|
||||
</div>
|
||||
<div class="stat-card blue">
|
||||
<div class="stat-icon">📦</div>
|
||||
<div class="stat-value" id="statItemCount">-</div>
|
||||
<div class="stat-label">총 판매 품목</div>
|
||||
</div>
|
||||
<div class="stat-card emerald">
|
||||
<div class="stat-icon">💰</div>
|
||||
<div class="stat-value" id="statAmount">-</div>
|
||||
<div class="stat-label">총 매출액</div>
|
||||
</div>
|
||||
<div class="stat-card purple">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-value" id="statBarcode">-</div>
|
||||
<div class="stat-label">바코드 매핑률</div>
|
||||
</div>
|
||||
<div class="stat-card amber">
|
||||
<div class="stat-icon">🏷️</div>
|
||||
<div class="stat-value" id="statProducts">-</div>
|
||||
<div class="stat-label">고유 상품</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 뷰 컨트롤 -->
|
||||
<div class="view-controls">
|
||||
<div class="code-toggle">
|
||||
<button class="active" data-code="drug" onclick="setCodeView('drug')">상품코드</button>
|
||||
<button data-code="barcode" onclick="setCodeView('barcode')">바코드</button>
|
||||
<button data-code="standard" onclick="setCodeView('standard')">표준코드</button>
|
||||
<button data-code="all" onclick="setCodeView('all')">전체</button>
|
||||
</div>
|
||||
<div class="view-mode">
|
||||
<button class="view-btn active" data-view="group" onclick="setViewMode('group')">📁 거래별</button>
|
||||
<button class="view-btn" data-view="list" onclick="setViewMode('list')">📋 목록</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 거래별 뷰 -->
|
||||
<div id="groupView" class="transactions-container">
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<div>데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 리스트 뷰 -->
|
||||
<div id="listView" class="list-view">
|
||||
<div class="list-table-wrap">
|
||||
<table class="list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>판매일</th>
|
||||
<th>상품명</th>
|
||||
<th id="listCodeHeader">상품코드</th>
|
||||
<th style="text-align:center">수량</th>
|
||||
<th style="text-align:right">단가</th>
|
||||
<th style="text-align:right">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="listTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let rawData = []; // API에서 받은 원본 데이터
|
||||
let groupedData = []; // 거래별 그룹화된 데이터
|
||||
let currentCodeView = 'drug';
|
||||
let currentViewMode = 'group';
|
||||
|
||||
// ──────────────── 코드 뷰 전환 ────────────────
|
||||
function setCodeView(view) {
|
||||
currentCodeView = view;
|
||||
document.querySelectorAll('.code-toggle button').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.code === view);
|
||||
});
|
||||
|
||||
const headers = {
|
||||
'drug': '상품코드',
|
||||
'barcode': '바코드',
|
||||
'standard': '표준코드',
|
||||
'all': '코드 정보'
|
||||
};
|
||||
document.querySelectorAll('#codeHeader, #listCodeHeader').forEach(el => {
|
||||
if (el) el.textContent = headers[view];
|
||||
});
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
// ──────────────── 뷰 모드 전환 ────────────────
|
||||
function setViewMode(mode) {
|
||||
currentViewMode = mode;
|
||||
document.querySelectorAll('.view-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.view === mode);
|
||||
});
|
||||
document.getElementById('groupView').style.display = mode === 'group' ? 'flex' : 'none';
|
||||
document.getElementById('listView').classList.toggle('active', mode === 'list');
|
||||
}
|
||||
|
||||
// ──────────────── 코드 렌더링 ────────────────
|
||||
function renderCode(item) {
|
||||
if (currentCodeView === 'drug') {
|
||||
return `<span class="code-badge code-drug">${item.drug_code}</span>`;
|
||||
} else if (currentCodeView === 'barcode') {
|
||||
if (item.barcode) {
|
||||
return `
|
||||
<div class="barcode-visual">
|
||||
<span class="code-badge code-barcode">${item.barcode}</span>
|
||||
${renderBarcodeBars(item.barcode)}
|
||||
</div>`;
|
||||
}
|
||||
return `<span class="code-badge code-na">—</span>`;
|
||||
} else if (currentCodeView === 'standard') {
|
||||
return item.standard_code
|
||||
? `<span class="code-badge code-standard">${item.standard_code}</span>`
|
||||
: `<span class="code-badge code-na">—</span>`;
|
||||
} else {
|
||||
return `
|
||||
<div class="code-stack">
|
||||
<span class="code-badge code-drug">${item.drug_code}</span>
|
||||
${item.barcode
|
||||
? `<span class="code-badge code-barcode">${item.barcode}</span>`
|
||||
: `<span class="code-badge code-na">바코드 없음</span>`}
|
||||
${item.standard_code
|
||||
? `<span class="code-badge code-standard">${item.standard_code}</span>`
|
||||
: ''}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 바코드 시각화 바
|
||||
function renderBarcodeBars(barcode) {
|
||||
const bars = barcode.split('').map(c => {
|
||||
const h = 8 + (parseInt(c) || c.charCodeAt(0) % 10) * 1.2;
|
||||
return `<span style="height:${h}px"></span>`;
|
||||
}).join('');
|
||||
return `<div class="barcode-bars">${bars}</div>`;
|
||||
}
|
||||
|
||||
// ──────────────── 포맷 ────────────────
|
||||
function formatPrice(num) {
|
||||
return new Intl.NumberFormat('ko-KR').format(num);
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return '-';
|
||||
const d = new Date(dt);
|
||||
return `${d.getMonth()+1}/${d.getDate()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
// ──────────────── 데이터 그룹화 (날짜별) ────────────────
|
||||
function groupByDate(items) {
|
||||
const map = new Map();
|
||||
items.forEach(item => {
|
||||
const key = item.sale_date;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
date: item.sale_date,
|
||||
items: [],
|
||||
total: 0
|
||||
});
|
||||
}
|
||||
const group = map.get(key);
|
||||
group.items.push(item);
|
||||
group.total += item.total_price || 0;
|
||||
});
|
||||
return Array.from(map.values()).sort((a, b) =>
|
||||
b.date.localeCompare(a.date)
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────── 렌더링 ────────────────
|
||||
function render() {
|
||||
renderGroupView();
|
||||
renderListView();
|
||||
}
|
||||
|
||||
function renderGroupView() {
|
||||
const container = document.getElementById('groupView');
|
||||
|
||||
if (groupedData.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<div>판매 내역이 없습니다</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = groupedData.map((tx, idx) => `
|
||||
<div class="tx-card" id="tx-${idx}">
|
||||
<div class="tx-header" onclick="toggleTransaction(${idx})">
|
||||
<div class="tx-info">
|
||||
<span class="tx-id">📅 ${tx.date}</span>
|
||||
</div>
|
||||
<div class="tx-summary">
|
||||
<span class="tx-count">${tx.items.length}개 품목</span>
|
||||
<span class="tx-amount">${formatPrice(tx.total)}원</span>
|
||||
<span class="tx-toggle">▼</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tx-items">
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%">상품명</th>
|
||||
<th id="codeHeader-${idx}">상품코드</th>
|
||||
<th style="text-align:right;width:8%">수량</th>
|
||||
<th style="text-align:right;width:12%">단가</th>
|
||||
<th style="text-align:right;width:12%">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tx.items.map(item => `
|
||||
<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>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td>${renderCode(item)}</td>
|
||||
<td class="qty">${item.quantity}</td>
|
||||
<td class="price">${formatPrice(item.unit_price)}원</td>
|
||||
<td class="price total">${formatPrice(item.total_price)}원</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderListView() {
|
||||
const tbody = document.getElementById('listTableBody');
|
||||
|
||||
if (rawData.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="empty-state">판매 내역이 없습니다</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rawData.map(item => `
|
||||
<tr>
|
||||
<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>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td>${renderCode(item)}</td>
|
||||
<td style="text-align:center">${item.quantity}</td>
|
||||
<td style="text-align:right;font-family:'JetBrains Mono',monospace;">${formatPrice(item.unit_price)}원</td>
|
||||
<td style="text-align:right;font-family:'JetBrains Mono',monospace;color:var(--accent-teal);font-weight:600;">${formatPrice(item.total_price)}원</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function toggleTransaction(idx) {
|
||||
const card = document.getElementById(`tx-${idx}`);
|
||||
card.classList.toggle('open');
|
||||
}
|
||||
|
||||
// ──────────────── 데이터 로드 ────────────────
|
||||
function loadSalesData() {
|
||||
const period = document.getElementById('periodSelect').value;
|
||||
const search = document.getElementById('searchInput').value;
|
||||
const barcodeFilter = document.getElementById('barcodeFilter').value;
|
||||
|
||||
document.getElementById('groupView').innerHTML = `
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<div>데이터 로딩 중...</div>
|
||||
</div>`;
|
||||
|
||||
let url = `/api/sales-detail?days=${period}&barcode=${barcodeFilter}`;
|
||||
if (search) url += `&search=${encodeURIComponent(search)}`;
|
||||
|
||||
fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
rawData = data.items;
|
||||
groupedData = groupByDate(rawData);
|
||||
|
||||
// 통계 업데이트
|
||||
document.getElementById('statTxCount').textContent = groupedData.length.toLocaleString();
|
||||
document.getElementById('statItemCount').textContent = data.stats.total_count.toLocaleString();
|
||||
document.getElementById('statAmount').textContent = formatPrice(data.stats.total_amount);
|
||||
document.getElementById('statBarcode').textContent = data.stats.barcode_rate + '%';
|
||||
document.getElementById('statProducts').textContent = data.stats.unique_products.toLocaleString();
|
||||
|
||||
render();
|
||||
} else {
|
||||
document.getElementById('groupView').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚠️</div>
|
||||
<div>오류: ${data.error}</div>
|
||||
</div>`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('groupView').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">❌</div>
|
||||
<div>데이터 로드 실패</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// 엔터키 검색
|
||||
document.getElementById('searchInput').addEventListener('keypress', e => {
|
||||
if (e.key === 'Enter') loadSalesData();
|
||||
});
|
||||
|
||||
// 초기 로드
|
||||
loadSalesData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,6 +3,13 @@
|
||||
<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">
|
||||
<link rel="icon" type="image/png" sizes="192x192" 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>
|
||||
@@ -121,6 +128,81 @@
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
/* 구매 품목 리스트 */
|
||||
.items-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.items-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
color: #495057;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.items-toggle .arrow {
|
||||
transition: transform 0.2s ease;
|
||||
font-size: 12px;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.items-toggle.open .arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.items-list {
|
||||
display: none;
|
||||
border-top: 1px solid #f1f3f5;
|
||||
}
|
||||
|
||||
.items-list.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f8f9fa;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.item-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: #495057;
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.item-qty {
|
||||
color: #868e96;
|
||||
margin-right: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-price {
|
||||
color: #212529;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 8px 0;
|
||||
}
|
||||
@@ -426,15 +508,36 @@
|
||||
<div class="points-badge">{{ "{:,}".format(token_info.claimable_points) }}P 적립</div>
|
||||
</div>
|
||||
|
||||
{% if sale_items %}
|
||||
<div class="items-section">
|
||||
<button type="button" class="items-toggle" id="itemsToggle" onclick="toggleItems()">
|
||||
<span>구매 품목 ({{ sale_items|length }}건)</span>
|
||||
<span class="arrow">▼</span>
|
||||
</button>
|
||||
<div class="items-list" id="itemsList">
|
||||
{% for item in sale_items %}
|
||||
<div class="item-row">
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
<span class="item-qty">{{ item.qty }}개</span>
|
||||
<span class="item-price">{{ "{:,}".format(item.total) }}원</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="formClaim" class="form-section">
|
||||
<div class="input-group">
|
||||
<label for="phone">전화번호</label>
|
||||
<div class="input-wrapper">
|
||||
<div class="input-wrapper" style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 18px; font-weight: 600; color: #495057; white-space: nowrap; padding: 16px 0 16px 4px;">010 -</span>
|
||||
<input type="tel" id="phone" name="phone"
|
||||
placeholder="010-0000-0000"
|
||||
pattern="[0-9-]*"
|
||||
placeholder="0000-0000"
|
||||
inputmode="numeric"
|
||||
maxlength="9"
|
||||
autocomplete="tel"
|
||||
required>
|
||||
required
|
||||
style="flex: 1;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -452,7 +555,7 @@
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" id="privacyConsent" required>
|
||||
<span class="checkmark"></span>
|
||||
<span class="consent-text">개인정보 수집·이용 동의</span>
|
||||
<span class="consent-text"><a href="/privacy" target="_blank" style="color: #6366f1; text-decoration: underline;">개인정보 수집·이용</a> 동의</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -461,7 +564,31 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style="text-align: center; margin: 20px 0 16px 0; position: relative;">
|
||||
<span style="background: #fff; padding: 0 16px; color: #adb5bd; font-size: 13px; font-weight: 500; position: relative; z-index: 1;">또는</span>
|
||||
<div style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: #e9ecef; z-index: 0;"></div>
|
||||
</div>
|
||||
|
||||
<button type="button" onclick="kakaoLogin()"
|
||||
style="display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
width: 100%; padding: 16px; background: #FEE500; color: #191919;
|
||||
border: none; border-radius: 14px; font-size: 16px; font-weight: 600;
|
||||
letter-spacing: -0.3px; transition: all 0.2s ease; cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
|
||||
</svg>
|
||||
카카오로 적립하기
|
||||
</button>
|
||||
|
||||
<div class="alert error" id="alertMsg"></div>
|
||||
|
||||
<div style="text-align: center; padding: 16px 0 8px;">
|
||||
<a href="/privacy" target="_blank"
|
||||
style="color: #adb5bd; font-size: 12px; text-decoration: none; letter-spacing: -0.2px;">
|
||||
개인정보 처리방침
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -481,6 +608,17 @@
|
||||
<a href="/" class="btn-secondary">홈으로</a>
|
||||
<a href="#" class="btn-primary" id="btnMyPage">내역 보기</a>
|
||||
</div>
|
||||
|
||||
<!-- PWA 설치 유도 배너 -->
|
||||
<div id="installBanner" style="display:none; margin-top:24px; padding:16px 20px; background:#f8f9fa; border-radius:14px; text-align:left;">
|
||||
<div style="font-size:14px; font-weight:700; color:#212529; margin-bottom:6px; letter-spacing:-0.3px;">
|
||||
홈 화면에 추가하면 더 편해요!
|
||||
</div>
|
||||
<div id="installDesc" style="font-size:13px; color:#868e96; line-height:1.6; letter-spacing:-0.2px;"></div>
|
||||
<button id="installBtn" style="display:none; margin-top:10px; width:100%; padding:12px; background:linear-gradient(135deg,#6366f1,#8b5cf6); color:#fff; border:none; border-radius:10px; font-size:14px; font-weight:600; cursor:pointer; letter-spacing:-0.2px;">
|
||||
앱 설치하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -496,25 +634,36 @@
|
||||
const claimFormDiv = document.getElementById('claimForm');
|
||||
const successScreen = document.getElementById('successScreen');
|
||||
|
||||
// 전화번호 자동 하이픈
|
||||
// 품목 토글
|
||||
function toggleItems() {
|
||||
const btn = document.getElementById('itemsToggle');
|
||||
const list = document.getElementById('itemsList');
|
||||
if (btn && list) {
|
||||
btn.classList.toggle('open');
|
||||
list.classList.toggle('open');
|
||||
}
|
||||
}
|
||||
|
||||
// 뒷번호 자동 하이픈 (010 고정)
|
||||
const phoneInput = document.getElementById('phone');
|
||||
phoneInput.addEventListener('input', function(e) {
|
||||
let value = e.target.value.replace(/[^0-9]/g, '');
|
||||
|
||||
if (value.length <= 3) {
|
||||
if (value.length <= 4) {
|
||||
e.target.value = value;
|
||||
} else if (value.length <= 7) {
|
||||
e.target.value = value.slice(0, 3) + '-' + value.slice(3);
|
||||
} else {
|
||||
e.target.value = value.slice(0, 3) + '-' + value.slice(3, 7) + '-' + value.slice(7, 11);
|
||||
e.target.value = value.slice(0, 4) + '-' + value.slice(4, 8);
|
||||
}
|
||||
});
|
||||
|
||||
// 포커스 시 자동으로 전화번호 필드로
|
||||
phoneInput.focus();
|
||||
|
||||
// 폼 제출
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const phone = document.getElementById('phone').value.trim();
|
||||
const phoneRaw = document.getElementById('phone').value.trim().replace(/[^0-9]/g, '');
|
||||
const phone = '010-' + phoneRaw.slice(0, 4) + '-' + phoneRaw.slice(4, 8);
|
||||
const name = document.getElementById('name').value.trim();
|
||||
const privacyConsent = document.getElementById('privacyConsent').checked;
|
||||
|
||||
@@ -583,5 +732,61 @@
|
||||
successScreen.style.display = 'block';
|
||||
}
|
||||
</script>
|
||||
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js"
|
||||
integrity="sha384-DKYJZ8NLiK8MN4/C5P2dtSmLQ4KwPaoqAfyA/DfmOGfnSNqoARCbb2xl4Kh1v6Q"
|
||||
crossorigin="anonymous"></script>
|
||||
<script>
|
||||
// 카카오 JS SDK 초기화
|
||||
if (typeof Kakao !== 'undefined') {
|
||||
Kakao.init('3d1e098107157c5021b73bd5ab48600f');
|
||||
}
|
||||
|
||||
function kakaoLogin() {
|
||||
if (typeof Kakao !== 'undefined' && Kakao.isInitialized()) {
|
||||
Kakao.Auth.authorize({
|
||||
redirectUri: 'https://mile.0bin.in/claim/kakao/callback',
|
||||
state: '{{ kakao_state }}'
|
||||
});
|
||||
} else {
|
||||
// JS SDK 로드 실패 시 서버 리다이렉트 폴백
|
||||
window.location.href = '/claim/kakao/start?t={{ request.args.get("t") }}';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}
|
||||
|
||||
// PWA 설치 유도
|
||||
(function() {
|
||||
const banner = document.getElementById('installBanner');
|
||||
const desc = document.getElementById('installDesc');
|
||||
const btn = document.getElementById('installBtn');
|
||||
if (!banner) return;
|
||||
|
||||
if (window.matchMedia('(display-mode: standalone)').matches || navigator.standalone) return;
|
||||
|
||||
let deferredPrompt = null;
|
||||
window.addEventListener('beforeinstallprompt', function(e) {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
desc.textContent = '다음부터 QR 스캔하면 입력 없이 바로 적립됩니다.';
|
||||
btn.style.display = 'block';
|
||||
banner.style.display = 'block';
|
||||
});
|
||||
btn.addEventListener('click', function() {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
deferredPrompt.userChoice.then(function() { banner.style.display = 'none'; });
|
||||
}
|
||||
});
|
||||
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
const isSafari = /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS/.test(navigator.userAgent);
|
||||
if (isIOS && isSafari && !deferredPrompt) {
|
||||
desc.innerHTML = '하단 <strong style="color:#495057;">공유 버튼</strong> ➜ <strong style="color:#495057;">홈 화면에 추가</strong>를 누르면<br>다음부터 QR만 찍으면 바로 적립!';
|
||||
banner.style.display = 'block';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
457
backend/templates/claim_kakao_phone.html
Normal file
457
backend/templates/claim_kakao_phone.html
Normal file
@@ -0,0 +1,457 @@
|
||||
<!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">
|
||||
<link rel="icon" type="image/png" sizes="192x192" 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;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 32px 24px 140px 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 120px;
|
||||
background: #ffffff;
|
||||
border-radius: 32px 32px 0 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.pharmacy-name {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
color: #ffffff;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
margin: -100px 24px 24px 24px;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.kakao-profile {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #f1f3f5;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.kakao-profile img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 12px;
|
||||
border: 3px solid #FEE500;
|
||||
}
|
||||
|
||||
.kakao-profile-name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
letter-spacing: -0.3px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.kakao-profile-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #191919;
|
||||
background: #FEE500;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.points-info {
|
||||
text-align: center;
|
||||
padding: 16px 0 24px 0;
|
||||
}
|
||||
|
||||
.points-label {
|
||||
color: #868e96;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.points-value {
|
||||
color: #6366f1;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.form-section { margin-top: 0; }
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
color: #495057;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
width: 100%;
|
||||
padding: 16px 18px;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
background: #f8f9fa;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.input-wrapper input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
.input-wrapper input::placeholder {
|
||||
color: #adb5bd;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
letter-spacing: -0.3px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.24);
|
||||
}
|
||||
|
||||
.btn-submit:active { transform: scale(0.98); }
|
||||
.btn-submit:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.alert {
|
||||
display: none;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-top: 16px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
background: #fff5f5;
|
||||
color: #e03131;
|
||||
border: 1px solid #ffc9c9;
|
||||
}
|
||||
|
||||
/* 성공 화면 */
|
||||
.success-screen {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 60px 24px;
|
||||
}
|
||||
|
||||
.success-icon-wrap {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 24px auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.success-icon-wrap svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
fill: none;
|
||||
stroke: #ffffff;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.success-points {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #6366f1;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -1.5px;
|
||||
}
|
||||
|
||||
.success-balance {
|
||||
color: #868e96;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 32px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.success-balance strong {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-secondary, .btn-primary {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f1f3f5;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
body { padding: 0; }
|
||||
.app-container { border-radius: 0; min-height: 100vh; }
|
||||
.header { padding-top: 48px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<div class="header">
|
||||
<div class="header-content">
|
||||
<div class="pharmacy-name">청춘약국</div>
|
||||
<div class="header-title">포인트 적립</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="claimForm">
|
||||
<div class="card">
|
||||
<div class="kakao-profile">
|
||||
{% if kakao_profile_image %}
|
||||
<img src="{{ kakao_profile_image }}" alt="프로필">
|
||||
{% else %}
|
||||
<div style="width: 64px; height: 64px; border-radius: 50%; background: #FEE500; margin: 0 auto 12px auto; display: flex; align-items: center; justify-content: center;">
|
||||
<svg width="28" height="28" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="kakao-profile-name">{{ kakao_name }}님</div>
|
||||
<span class="kakao-profile-badge">
|
||||
<svg width="12" height="12" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
|
||||
</svg>
|
||||
카카오 인증됨
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="points-info">
|
||||
<div class="points-label">적립 포인트</div>
|
||||
<div class="points-value">+{{ "{:,}".format(token_info.claimable_points) }}P</div>
|
||||
</div>
|
||||
|
||||
<form id="formKakaoClaim" class="form-section">
|
||||
<div class="input-group">
|
||||
<label for="phone">전화번호를 입력해주세요</label>
|
||||
<div class="input-wrapper" style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 18px; font-weight: 600; color: #495057; white-space: nowrap; padding: 16px 0 16px 4px;">010 -</span>
|
||||
<input type="tel" id="phone"
|
||||
placeholder="0000-0000"
|
||||
inputmode="numeric"
|
||||
maxlength="9"
|
||||
autocomplete="tel"
|
||||
required
|
||||
style="flex: 1;">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-submit" id="btnSubmit">포인트 적립하기</button>
|
||||
</form>
|
||||
|
||||
<div class="alert error" id="alertMsg"></div>
|
||||
|
||||
<div style="text-align: center; padding: 16px 0 8px;">
|
||||
<a href="/privacy" target="_blank"
|
||||
style="color: #adb5bd; font-size: 12px; text-decoration: none; letter-spacing: -0.2px;">
|
||||
개인정보 처리방침
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="successScreen" class="success-screen">
|
||||
<div class="success-icon-wrap">
|
||||
<svg viewBox="0 0 52 52">
|
||||
<path d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="success-title">적립 완료!</div>
|
||||
<div class="success-points" id="successPoints">0P</div>
|
||||
<div class="success-balance">
|
||||
총 포인트 <strong id="successBalance">0P</strong>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<a href="/" class="btn-secondary">홈으로</a>
|
||||
<a href="#" class="btn-primary" id="btnMyPage">내역 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const phoneInput = document.getElementById('phone');
|
||||
const form = document.getElementById('formKakaoClaim');
|
||||
const btnSubmit = document.getElementById('btnSubmit');
|
||||
const alertMsg = document.getElementById('alertMsg');
|
||||
|
||||
// 자동 하이픈
|
||||
phoneInput.addEventListener('input', function(e) {
|
||||
let value = e.target.value.replace(/[^0-9]/g, '');
|
||||
if (value.length <= 4) {
|
||||
e.target.value = value;
|
||||
} else {
|
||||
e.target.value = value.slice(0, 4) + '-' + value.slice(4, 8);
|
||||
}
|
||||
});
|
||||
|
||||
phoneInput.focus();
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const phoneRaw = phoneInput.value.replace(/[^0-9]/g, '');
|
||||
if (phoneRaw.length < 7) {
|
||||
showAlert('전화번호를 정확히 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const phone = '010' + phoneRaw;
|
||||
|
||||
btnSubmit.disabled = true;
|
||||
btnSubmit.textContent = '처리 중...';
|
||||
alertMsg.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/claim/kakao', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone: phone })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showSuccess(data.points, data.balance, phone);
|
||||
} else {
|
||||
showAlert(data.message);
|
||||
btnSubmit.disabled = false;
|
||||
btnSubmit.textContent = '포인트 적립하기';
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('네트워크 오류가 발생했습니다.');
|
||||
btnSubmit.disabled = false;
|
||||
btnSubmit.textContent = '포인트 적립하기';
|
||||
}
|
||||
});
|
||||
|
||||
function showAlert(msg) {
|
||||
alertMsg.textContent = msg;
|
||||
alertMsg.style.display = 'block';
|
||||
setTimeout(() => { alertMsg.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
function showSuccess(points, balance, phone) {
|
||||
document.getElementById('claimForm').style.display = 'none';
|
||||
document.getElementById('successPoints').textContent = points.toLocaleString() + 'P';
|
||||
document.getElementById('successBalance').textContent = balance.toLocaleString() + 'P';
|
||||
document.getElementById('btnMyPage').href = '/my-page?phone=' + encodeURIComponent(phone);
|
||||
document.getElementById('successScreen').style.display = 'block';
|
||||
}
|
||||
</script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}</script>
|
||||
</body>
|
||||
</html>
|
||||
245
backend/templates/claim_kakao_success.html
Normal file
245
backend/templates/claim_kakao_success.html
Normal file
@@ -0,0 +1,245 @@
|
||||
<!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">
|
||||
<link rel="icon" type="image/png" sizes="192x192" 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;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
padding: 60px 24px;
|
||||
}
|
||||
|
||||
.success-icon-wrap {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 24px auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: scaleIn 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
0% { transform: scale(0); opacity: 0; }
|
||||
60% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.success-icon-wrap svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
fill: none;
|
||||
stroke: #ffffff;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 100;
|
||||
stroke-dashoffset: 100;
|
||||
animation: drawCheck 0.6s 0.3s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes drawCheck {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.success-name {
|
||||
font-size: 15px;
|
||||
color: #868e96;
|
||||
font-weight: 500;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.success-name strong {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.success-points {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #6366f1;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -1.5px;
|
||||
}
|
||||
|
||||
.success-balance {
|
||||
color: #868e96;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 32px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.success-balance strong {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.kakao-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #191919;
|
||||
background: #FEE500;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-secondary, .btn-primary {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f1f3f5;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-secondary:active, .btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
body { padding: 0; }
|
||||
.app-container { border-radius: 0; min-height: 100vh; display: flex; flex-direction: column; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<div class="success-icon-wrap">
|
||||
<svg viewBox="0 0 52 52">
|
||||
<path d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="success-title">적립 완료!</div>
|
||||
<div class="success-name"><strong>{{ name }}</strong>님</div>
|
||||
<div class="success-points">+{{ "{:,}".format(points) }}P</div>
|
||||
<div class="success-balance">
|
||||
총 포인트 <strong>{{ "{:,}".format(balance) }}P</strong>
|
||||
</div>
|
||||
<div class="kakao-badge">
|
||||
<svg width="14" height="14" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
|
||||
</svg>
|
||||
카카오로 적립됨
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<a href="/" class="btn-secondary">홈으로</a>
|
||||
<a href="/my-page?phone={{ phone }}" class="btn-primary">내역 보기</a>
|
||||
</div>
|
||||
|
||||
<!-- PWA 설치 유도 배너 -->
|
||||
<div id="installBanner" style="display:none; margin-top:24px; padding:16px 20px; background:#f8f9fa; border-radius:14px; text-align:left;">
|
||||
<div style="font-size:14px; font-weight:700; color:#212529; margin-bottom:6px; letter-spacing:-0.3px;">
|
||||
홈 화면에 추가하면 더 편해요!
|
||||
</div>
|
||||
<div id="installDesc" style="font-size:13px; color:#868e96; line-height:1.6; letter-spacing:-0.2px;"></div>
|
||||
<button id="installBtn" style="display:none; margin-top:10px; width:100%; padding:12px; background:linear-gradient(135deg,#6366f1,#8b5cf6); color:#fff; border:none; border-radius:10px; font-size:14px; font-weight:600; cursor:pointer; letter-spacing:-0.2px;">
|
||||
앱 설치하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}
|
||||
|
||||
// PWA 설치 유도
|
||||
(function() {
|
||||
const banner = document.getElementById('installBanner');
|
||||
const desc = document.getElementById('installDesc');
|
||||
const btn = document.getElementById('installBtn');
|
||||
|
||||
// 이미 PWA로 실행 중이면 표시 안 함
|
||||
if (window.matchMedia('(display-mode: standalone)').matches || navigator.standalone) return;
|
||||
|
||||
let deferredPrompt = null;
|
||||
|
||||
// Android Chrome: beforeinstallprompt 이벤트
|
||||
window.addEventListener('beforeinstallprompt', function(e) {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
desc.textContent = '다음부터 QR 스캔하면 입력 없이 바로 적립됩니다.';
|
||||
btn.style.display = 'block';
|
||||
banner.style.display = 'block';
|
||||
});
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
deferredPrompt.userChoice.then(function() { banner.style.display = 'none'; });
|
||||
}
|
||||
});
|
||||
|
||||
// iOS Safari 감지
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
const isSafari = /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS/.test(navigator.userAgent);
|
||||
if (isIOS && isSafari && !deferredPrompt) {
|
||||
desc.innerHTML = '하단 <strong style="color:#495057;">공유 버튼</strong> ➜ <strong style="color:#495057;">홈 화면에 추가</strong>를 누르면<br>다음부터 QR만 찍으면 바로 적립!';
|
||||
banner.style.display = 'block';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,6 +3,13 @@
|
||||
<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">
|
||||
<link rel="icon" type="image/png" sizes="192x192" 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>
|
||||
@@ -88,7 +95,16 @@
|
||||
<div class="error-icon">⚠️</div>
|
||||
<div class="error-title">문제가 발생했어요</div>
|
||||
<div class="error-message">{{ message }}</div>
|
||||
<a href="/" class="btn-home">홈으로 이동</a>
|
||||
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||
<a href="/my-page/kakao/start" style="display: flex; align-items: center; justify-content: center; gap: 8px; padding: 16px 32px; background: #FEE500; color: #191919; text-decoration: none; border-radius: 14px; font-size: 16px; font-weight: 700; letter-spacing: -0.2px; transition: all 0.2s ease;">
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
|
||||
</svg>
|
||||
내 마일리지 확인하기
|
||||
</a>
|
||||
<a href="/" class="btn-home">홈으로 이동</a>
|
||||
</div>
|
||||
</div>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
451
backend/templates/index.html
Normal file
451
backend/templates/index.html
Normal file
@@ -0,0 +1,451 @@
|
||||
<!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">
|
||||
<link rel="icon" type="image/png" sizes="192x192" 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;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: #ffffff;
|
||||
min-height: 100vh;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 48px 24px 56px;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero-logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 20px;
|
||||
margin: 0 auto 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
opacity: 0.85;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
/* 로그인 상태 배지 */
|
||||
.user-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 24px;
|
||||
margin-top: -20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* QR 스캔 버튼 */
|
||||
.scan-card {
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
padding: 28px 24px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.08);
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.scan-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.scan-desc {
|
||||
font-size: 13px;
|
||||
color: #868e96;
|
||||
margin-bottom: 20px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.btn-scan {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
letter-spacing: -0.3px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.btn-scan:active { transform: scale(0.98); }
|
||||
|
||||
.btn-scan svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: #ffffff;
|
||||
}
|
||||
|
||||
/* 메뉴 카드들 */
|
||||
.menu-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.menu-card:active {
|
||||
transform: scale(0.98);
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-icon.purple { background: rgba(99, 102, 241, 0.1); }
|
||||
.menu-icon.yellow { background: rgba(254, 229, 0, 0.3); }
|
||||
.menu-icon.green { background: rgba(16, 185, 129, 0.1); }
|
||||
|
||||
.menu-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
letter-spacing: -0.3px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.menu-desc {
|
||||
font-size: 13px;
|
||||
color: #868e96;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
color: #ced4da;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* QR 스캐너 모달 */
|
||||
.scanner-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.95);
|
||||
z-index: 1000;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.scanner-overlay.open { display: flex; }
|
||||
|
||||
.scanner-header {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
padding: 16px 24px;
|
||||
padding-top: calc(16px + env(safe-area-inset-top, 0px));
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.scanner-title {
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-close-scanner {
|
||||
color: #ffffff;
|
||||
background: rgba(255,255,255,0.15);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#qr-reader {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scanner-hint {
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 14px;
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: #adb5bd;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.footer-link + .footer-link { margin-left: 16px; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero {
|
||||
padding-top: calc(48px + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<div class="hero">
|
||||
<div class="hero-logo">💊</div>
|
||||
<div class="hero-title">청춘약국 마일리지</div>
|
||||
<div class="hero-desc">구매금액의 3%를 포인트로 돌려드려요</div>
|
||||
{% if logged_in %}
|
||||
<div class="user-badge">
|
||||
<span>{{ logged_in_name }}님 로그인 중</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<!-- QR 스캔 카드 -->
|
||||
<div class="scan-card">
|
||||
<div class="scan-title">영수증 QR 스캔</div>
|
||||
<div class="scan-desc">
|
||||
{% if logged_in %}
|
||||
카메라로 QR을 찍으면 바로 적립됩니다
|
||||
{% else %}
|
||||
영수증의 QR 코드를 스캔하여 적립하세요
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="btn-scan" id="btnOpenScanner">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 11h2V9H3v2zm0 2h2v-2H3v2zm4 8h2v-2H7v2zM3 15h2v-2H3v2zm8-12h-2v2h2V3zm4 0v2h2V3h-2zm-4 18h2v-2h-2v2zM3 3v2h2V3H3zm0 16h2v-2H3v2zm8-16h-2v2h2V3zm8 4h2V5h-2v2zm0 4h2V9h-2v2zm0-8v2h2V3h-2zm0 12h2v-2h-2v2zm0 4v-2h-2v2h2zm-4 0h2v-2h-2v2zm-8-8h10v-2H7v2zm0 4h6v-2H7v2zm4-8h2V7h-2v2zM3 7h2V5H3v2z"/></svg>
|
||||
QR 코드 스캔하기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴 카드들 -->
|
||||
<div class="menu-grid">
|
||||
{% if logged_in %}
|
||||
<a href="/my-page?phone={{ logged_in_phone }}" class="menu-card">
|
||||
<div class="menu-icon purple">📊</div>
|
||||
<div class="menu-text">
|
||||
<div class="menu-title">내 마일리지</div>
|
||||
<div class="menu-desc">적립 내역 및 포인트 확인</div>
|
||||
</div>
|
||||
<div class="menu-arrow">›</div>
|
||||
</a>
|
||||
<a href="/logout" class="menu-card">
|
||||
<div class="menu-icon green">🔓</div>
|
||||
<div class="menu-text">
|
||||
<div class="menu-title">로그아웃</div>
|
||||
<div class="menu-desc">다른 계정으로 전환</div>
|
||||
</div>
|
||||
<div class="menu-arrow">›</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/my-page/kakao/start" class="menu-card">
|
||||
<div class="menu-icon yellow">
|
||||
<svg width="22" height="22" viewBox="0 0 20 20" fill="none"><path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/></svg>
|
||||
</div>
|
||||
<div class="menu-text">
|
||||
<div class="menu-title">카카오로 시작하기</div>
|
||||
<div class="menu-desc">로그인하면 QR만 찍으면 바로 적립</div>
|
||||
</div>
|
||||
<div class="menu-arrow">›</div>
|
||||
</a>
|
||||
<a href="/signup" class="menu-card">
|
||||
<div class="menu-icon green">📝</div>
|
||||
<div class="menu-text">
|
||||
<div class="menu-title">회원가입</div>
|
||||
<div class="menu-desc">전화번호 + 이름으로 간편 가입</div>
|
||||
</div>
|
||||
<div class="menu-arrow">›</div>
|
||||
</a>
|
||||
<a href="/my-page" class="menu-card">
|
||||
<div class="menu-icon purple">📊</div>
|
||||
<div class="menu-text">
|
||||
<div class="menu-title">마일리지 조회</div>
|
||||
<div class="menu-desc">전화번호로 적립 내역 확인</div>
|
||||
</div>
|
||||
<div class="menu-arrow">›</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<a href="/privacy" class="footer-link">개인정보 처리방침</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR 스캐너 모달 -->
|
||||
<div class="scanner-overlay" id="scannerOverlay">
|
||||
<div class="scanner-header">
|
||||
<div class="scanner-title">QR 코드 스캔</div>
|
||||
<button class="btn-close-scanner" id="btnCloseScanner">✕</button>
|
||||
</div>
|
||||
<div id="qr-reader"></div>
|
||||
<div class="scanner-hint">영수증의 QR 코드를 카메라에 비춰주세요</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||
<script>
|
||||
if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}
|
||||
|
||||
(function() {
|
||||
const overlay = document.getElementById('scannerOverlay');
|
||||
const btnOpen = document.getElementById('btnOpenScanner');
|
||||
const btnClose = document.getElementById('btnCloseScanner');
|
||||
let scanner = null;
|
||||
let scanning = false;
|
||||
|
||||
btnOpen.addEventListener('click', startScanner);
|
||||
btnClose.addEventListener('click', stopScanner);
|
||||
|
||||
async function startScanner() {
|
||||
overlay.classList.add('open');
|
||||
|
||||
if (scanner) {
|
||||
scanner.clear();
|
||||
}
|
||||
|
||||
scanner = new Html5Qrcode('qr-reader');
|
||||
scanning = true;
|
||||
|
||||
try {
|
||||
await scanner.start(
|
||||
{ facingMode: 'environment' },
|
||||
{ fps: 10, qrbox: { width: 250, height: 250 } },
|
||||
onScanSuccess,
|
||||
function() {} // ignore scan errors
|
||||
);
|
||||
} catch (err) {
|
||||
document.querySelector('.scanner-hint').textContent =
|
||||
'카메라 접근이 거부되었습니다. 설정에서 카메라 권한을 허용해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
async function stopScanner() {
|
||||
if (scanner && scanning) {
|
||||
try { await scanner.stop(); } catch(e) {}
|
||||
scanning = false;
|
||||
}
|
||||
overlay.classList.remove('open');
|
||||
}
|
||||
|
||||
function onScanSuccess(decodedText) {
|
||||
// QR 코드 URL에서 /claim?t= 파라미터 추출
|
||||
try {
|
||||
let url;
|
||||
if (decodedText.startsWith('http')) {
|
||||
url = new URL(decodedText);
|
||||
// 같은 도메인이거나 claim 경로면 이동
|
||||
if (url.pathname.startsWith('/claim')) {
|
||||
stopScanner();
|
||||
window.location.href = url.pathname + url.search;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 직접 t= 값인 경우 (예: "12345:abc123")
|
||||
if (decodedText.includes(':') && !decodedText.startsWith('http')) {
|
||||
stopScanner();
|
||||
window.location.href = '/claim?t=' + encodeURIComponent(decodedText);
|
||||
return;
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// 인식은 됐지만 유효하지 않은 QR
|
||||
document.querySelector('.scanner-hint').textContent =
|
||||
'유효한 영수증 QR 코드가 아닙니다. 다시 시도해주세요.';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
798
backend/templates/kiosk.html
Normal file
798
backend/templates/kiosk.html
Normal file
@@ -0,0 +1,798 @@
|
||||
<!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">
|
||||
<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;700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #0f0b2e;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── 헤더 ── */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header-logo { color: #fff; font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
||||
.header-time { color: rgba(255,255,255,0.7); font-size: 15px; }
|
||||
|
||||
/* ── 메인 ── */
|
||||
.main {
|
||||
height: calc(100vh - 56px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.screen { display: none; width: 100%; }
|
||||
.screen.active { display: flex; }
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
대기 화면 - 슬라이드쇼 + 브랜딩
|
||||
══════════════════════════════════════ */
|
||||
.idle-screen {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 슬라이드 컨테이너 */
|
||||
.slides-wrapper {
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
position: relative;
|
||||
height: 450px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
opacity: 0;
|
||||
transform: translateX(60px);
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.slide.active {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.slide.exit {
|
||||
opacity: 0;
|
||||
transform: translateX(-60px);
|
||||
}
|
||||
|
||||
.slide-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 52px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||
}
|
||||
.slide-tag {
|
||||
display: inline-block;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.slide-title {
|
||||
font-size: 42px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
letter-spacing: -0.8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.slide-desc {
|
||||
font-size: 23px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
line-height: 1.6;
|
||||
max-width: 520px;
|
||||
}
|
||||
.slide-highlight {
|
||||
display: inline-block;
|
||||
padding: 12px 32px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 14px;
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 슬라이드별 색상 */
|
||||
.slide-1 .slide-icon { background: linear-gradient(135deg, #fbbf24, #f59e0b); }
|
||||
.slide-1 .slide-tag { background: #fef3c7; color: #92400e; }
|
||||
.slide-2 .slide-icon { background: linear-gradient(135deg, #34d399, #10b981); }
|
||||
.slide-2 .slide-tag { background: #d1fae5; color: #065f46; }
|
||||
.slide-3 .slide-icon { background: linear-gradient(135deg, #60a5fa, #3b82f6); }
|
||||
.slide-3 .slide-tag { background: #dbeafe; color: #1e40af; }
|
||||
|
||||
/* 인디케이터 */
|
||||
.slide-dots {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.slide-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.2);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.slide-dot.active {
|
||||
width: 32px;
|
||||
border-radius: 5px;
|
||||
background: #8b5cf6;
|
||||
}
|
||||
|
||||
/* 브랜딩 영역 */
|
||||
.branding {
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
.branding-divider {
|
||||
width: 1px;
|
||||
height: 48px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.branding-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.branding-item span.icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
적립 화면
|
||||
══════════════════════════════════════ */
|
||||
.claim-screen {
|
||||
flex-direction: row;
|
||||
gap: 48px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.claim-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.claim-info-card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 28px 36px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
.claim-amount-label { font-size: 15px; color: #6b7280; margin-bottom: 4px; }
|
||||
.claim-amount { font-size: 36px; font-weight: 900; color: #1e1b4b; letter-spacing: -1px; }
|
||||
.claim-points { font-size: 20px; color: #6366f1; font-weight: 700; margin-top: 8px; }
|
||||
/* 품목 카드 */
|
||||
.items-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.items-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.items-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.item-row:last-child { border-bottom: none; }
|
||||
.item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
||||
.item-qty { color: #9ca3af; font-size: 13px; flex-shrink: 0; }
|
||||
.item-total { font-weight: 600; color: #6366f1; flex-shrink: 0; min-width: 60px; text-align: right; }
|
||||
|
||||
.qr-container {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
||||
}
|
||||
.qr-container img { width: 200px; height: 200px; }
|
||||
.qr-hint { font-size: 15px; color: #6b7280; text-align: center; margin-top: 12px; }
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.divider-line { width: 2px; height: 80px; background: #e5e7eb; }
|
||||
.divider-text { font-size: 16px; color: #9ca3af; font-weight: 500; }
|
||||
|
||||
.claim-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.phone-section-title { font-size: 20px; font-weight: 700; color: #1e1b4b; }
|
||||
|
||||
/* 전화번호 디스플레이 */
|
||||
.phone-display-wrap {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
background: #fff;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.2s;
|
||||
min-height: 64px;
|
||||
}
|
||||
.phone-display-wrap.focus { border-color: #6366f1; }
|
||||
.phone-display-wrap.error { border-color: #ef4444; animation: shake 0.3s; }
|
||||
.phone-prefix {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #9ca3af;
|
||||
letter-spacing: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.phone-number {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1e1b4b;
|
||||
letter-spacing: 2px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.phone-number.placeholder { color: #d1d5db; }
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-8px); }
|
||||
75% { transform: translateX(8px); }
|
||||
}
|
||||
|
||||
/* 숫자 패드 */
|
||||
.numpad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; width: 100%; max-width: 360px; }
|
||||
.numpad-btn {
|
||||
background: #fff;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #1e1b4b;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.numpad-btn:active { background: #6366f1; color: #fff; border-color: #6366f1; transform: scale(0.95); }
|
||||
.numpad-btn.delete { background: #fef2f2; border-color: #fecaca; color: #ef4444; font-size: 18px; }
|
||||
.numpad-btn.delete:active { background: #ef4444; color: #fff; }
|
||||
.numpad-btn.clear { background: #f5f5f5; border-color: #d4d4d4; color: #737373; font-size: 14px; }
|
||||
.numpad-btn.clear:active { background: #737373; color: #fff; }
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.submit-btn:active { transform: scale(0.97); }
|
||||
.submit-btn:disabled { background: #d1d5db; cursor: not-allowed; }
|
||||
|
||||
.error-msg { color: #ef4444; font-size: 14px; font-weight: 500; min-height: 20px; }
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
성공 화면
|
||||
══════════════════════════════════════ */
|
||||
.success-screen {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.success-icon {
|
||||
width: 120px; height: 120px;
|
||||
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 60px;
|
||||
animation: pop 0.4s ease-out;
|
||||
}
|
||||
@keyframes pop { 0% { transform: scale(0); } 80% { transform: scale(1.1); } 100% { transform: scale(1); } }
|
||||
.success-title { font-size: 36px; font-weight: 900; color: #16a34a; }
|
||||
.success-points { font-size: 48px; font-weight: 900; color: #1e1b4b; letter-spacing: -1px; }
|
||||
.success-balance { font-size: 20px; color: #6b7280; }
|
||||
.success-balance strong { color: #6366f1; font-weight: 700; }
|
||||
.success-countdown { font-size: 15px; color: #9ca3af; margin-top: 8px; }
|
||||
|
||||
/* ── 로딩 ── */
|
||||
.loading-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); display: none; align-items: center; justify-content: center; z-index: 100; }
|
||||
.loading-overlay.active { display: flex; }
|
||||
.loading-spinner { width: 60px; height: 60px; border: 6px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── 적립/성공 화면 배경 밝게 ── */
|
||||
.claim-screen, .success-screen { background: #f5f7fa; border-radius: 24px; padding: 32px; }
|
||||
|
||||
/* ── 반응형: 세로 모니터 (portrait, 폭 700px 이상) ── */
|
||||
@media (orientation: portrait) and (min-width: 700px) {
|
||||
.main { padding: 32px; }
|
||||
|
||||
/* 적립 화면: 세로 스택, 공간 활용 */
|
||||
.claim-screen {
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 32px 48px;
|
||||
max-width: 640px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.claim-left {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
.claim-info-card { width: 100%; max-width: 480px; }
|
||||
.qr-container { align-self: center; }
|
||||
.items-card { width: 100%; max-width: 480px; max-height: 160px; }
|
||||
.qr-container img { width: 160px; height: 160px; }
|
||||
|
||||
.divider { flex-direction: row; }
|
||||
.divider-line { width: 60px; height: 2px; }
|
||||
|
||||
.claim-right { width: 100%; align-items: center; }
|
||||
.phone-display-wrap { max-width: 440px; }
|
||||
.phone-prefix { font-size: 30px; }
|
||||
.phone-number { font-size: 30px; white-space: nowrap; }
|
||||
.numpad { max-width: 440px; }
|
||||
.submit-btn { max-width: 440px; }
|
||||
|
||||
/* 슬라이드 더 크게 */
|
||||
.slides-wrapper { height: 420px; }
|
||||
.slide-icon { width: 110px; height: 110px; font-size: 56px; }
|
||||
.slide-title { font-size: 34px; }
|
||||
.slide-desc { font-size: 19px; }
|
||||
.slide-highlight { font-size: 16px; padding: 12px 32px; }
|
||||
}
|
||||
|
||||
/* ── 반응형: 좁은 화면 (모바일) ── */
|
||||
@media (max-width: 700px) {
|
||||
.claim-screen { flex-direction: column; gap: 24px; padding: 20px; }
|
||||
.divider { flex-direction: row; }
|
||||
.divider-line { width: 60px; height: 2px; }
|
||||
.claim-amount { font-size: 28px; }
|
||||
.qr-container img { width: 150px; height: 150px; }
|
||||
.branding { flex-direction: column; gap: 12px; }
|
||||
.branding-divider { display: none; }
|
||||
.slide-title { font-size: 24px; }
|
||||
.slides-wrapper { height: 320px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<div class="header">
|
||||
<div class="header-logo">청춘약국 마일리지</div>
|
||||
<div class="header-time" id="headerTime"></div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 -->
|
||||
<div class="main">
|
||||
|
||||
<!-- 1. 대기 화면 (슬라이드쇼 + 브랜딩) -->
|
||||
<div class="screen idle-screen active" id="idleScreen">
|
||||
<div class="slides-wrapper">
|
||||
<!-- 슬라이드 1: 동물의약품 -->
|
||||
<div class="slide slide-1 active" data-slide="0">
|
||||
<div class="slide-icon">🐾</div>
|
||||
<div class="slide-tag">반려동물 케어</div>
|
||||
<div class="slide-title">우리 아이 약도<br>마일리지로 구매!</div>
|
||||
<div class="slide-desc">
|
||||
청춘약국 마일리지로<br>
|
||||
동물의약품을 구매할 수 있어요
|
||||
</div>
|
||||
<div class="slide-highlight">총 결제금액의 30% 한도 내 사용 가능</div>
|
||||
</div>
|
||||
|
||||
<!-- 슬라이드 2: 건기식 -->
|
||||
<div class="slide slide-2" data-slide="1">
|
||||
<div class="slide-icon">🌿</div>
|
||||
<div class="slide-tag">건강기능식품</div>
|
||||
<div class="slide-title">팜큐 건강기능식품<br>마일리지로 챙기세요</div>
|
||||
<div class="slide-desc">
|
||||
비타민, 유산균, 오메가3 등<br>
|
||||
엄선된 건기식을 포인트로 구매!
|
||||
</div>
|
||||
<div class="slide-highlight">총 결제금액의 30% 한도 내 사용 가능</div>
|
||||
</div>
|
||||
|
||||
<!-- 슬라이드 3: 부외품 -->
|
||||
<div class="slide slide-3" data-slide="2">
|
||||
<div class="slide-icon">💧</div>
|
||||
<div class="slide-tag">약국 용품</div>
|
||||
<div class="slide-title">투약병, 부외품도<br>마일리지로 OK</div>
|
||||
<div class="slide-desc">
|
||||
물약병, 연고통, 밴드 등<br>
|
||||
필요한 약국 용품을 포인트로!
|
||||
</div>
|
||||
<div class="slide-highlight">총 결제금액의 30% 한도 내 사용 가능</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 인디케이터 -->
|
||||
<div class="slide-dots">
|
||||
<div class="slide-dot active" data-dot="0"></div>
|
||||
<div class="slide-dot" data-dot="1"></div>
|
||||
<div class="slide-dot" data-dot="2"></div>
|
||||
</div>
|
||||
|
||||
<!-- 브랜딩 -->
|
||||
<div class="branding">
|
||||
<div class="branding-item"><span class="icon">🤖</span> AI 에이전트 개발 약국</div>
|
||||
<div class="branding-divider"></div>
|
||||
<div class="branding-item"><span class="icon">💊</span> 복약안내에 진심인 약사</div>
|
||||
<div class="branding-divider"></div>
|
||||
<div class="branding-item"><span class="icon">📱</span> 모바일 약료 시스템 도입</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 적립 화면 -->
|
||||
<div class="screen claim-screen" id="claimScreen">
|
||||
<div class="claim-left">
|
||||
<div class="claim-info-card">
|
||||
<div class="claim-amount-label">결제 금액</div>
|
||||
<div class="claim-amount" id="claimAmount">0원</div>
|
||||
<div class="claim-points">적립 <span id="claimPoints">0</span>P</div>
|
||||
</div>
|
||||
<div class="items-card" id="itemsCard" style="display:none;">
|
||||
<div class="items-title">구매 품목</div>
|
||||
<div class="items-list" id="itemsList"></div>
|
||||
</div>
|
||||
<div class="qr-container" id="qrContainer" style="display:none;">
|
||||
<img id="qrImage" src="" alt="QR Code">
|
||||
<div class="qr-hint">휴대폰으로 QR을 스캔하여<br>적립할 수도 있습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" id="dividerEl" style="display:none;">
|
||||
<div class="divider-line"></div>
|
||||
<div class="divider-text">또는</div>
|
||||
<div class="divider-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="claim-right">
|
||||
<div class="phone-section-title">전화번호로 적립하기</div>
|
||||
<div class="phone-display-wrap" id="phoneWrap">
|
||||
<span class="phone-prefix">010-</span>
|
||||
<span class="phone-number placeholder" id="phoneDisplay">0000-0000</span>
|
||||
</div>
|
||||
<div class="error-msg" id="errorMsg"></div>
|
||||
<div class="numpad">
|
||||
<button class="numpad-btn" onclick="numPress('1')">1</button>
|
||||
<button class="numpad-btn" onclick="numPress('2')">2</button>
|
||||
<button class="numpad-btn" onclick="numPress('3')">3</button>
|
||||
<button class="numpad-btn" onclick="numPress('4')">4</button>
|
||||
<button class="numpad-btn" onclick="numPress('5')">5</button>
|
||||
<button class="numpad-btn" onclick="numPress('6')">6</button>
|
||||
<button class="numpad-btn" onclick="numPress('7')">7</button>
|
||||
<button class="numpad-btn" onclick="numPress('8')">8</button>
|
||||
<button class="numpad-btn" onclick="numPress('9')">9</button>
|
||||
<button class="numpad-btn clear" onclick="numClear()">전체삭제</button>
|
||||
<button class="numpad-btn" onclick="numPress('0')">0</button>
|
||||
<button class="numpad-btn delete" onclick="numDelete()">← 삭제</button>
|
||||
</div>
|
||||
<button class="submit-btn" id="submitBtn" onclick="submitClaim()" disabled>적립하기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 성공 화면 -->
|
||||
<div class="screen success-screen" id="successScreen">
|
||||
<div class="success-icon">✓</div>
|
||||
<div class="success-title">적립 완료!</div>
|
||||
<div class="success-points" id="successPoints">0P</div>
|
||||
<div class="success-balance">총 잔액: <strong id="successBalance">0P</strong></div>
|
||||
<div class="success-countdown" id="successCountdown"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 로딩 오버레이 -->
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── 상태 ──
|
||||
let phoneNumber = ''; // 010 이후 8자리만 관리
|
||||
let currentSession = null;
|
||||
let pollingInterval = null;
|
||||
let successTimeout = null;
|
||||
|
||||
// ── 슬라이드쇼 ──
|
||||
let currentSlide = 0;
|
||||
const TOTAL_SLIDES = 3;
|
||||
const SLIDE_INTERVAL = 4000; // 4초
|
||||
let slideTimer = null;
|
||||
|
||||
function nextSlide() {
|
||||
const slides = document.querySelectorAll('.slide');
|
||||
const dots = document.querySelectorAll('.slide-dot');
|
||||
|
||||
// 현재 슬라이드 exit
|
||||
slides[currentSlide].classList.remove('active');
|
||||
slides[currentSlide].classList.add('exit');
|
||||
dots[currentSlide].classList.remove('active');
|
||||
|
||||
// 다음 슬라이드
|
||||
currentSlide = (currentSlide + 1) % TOTAL_SLIDES;
|
||||
|
||||
// exit 클래스 제거 (transition 후)
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.slide.exit').forEach(s => s.classList.remove('exit'));
|
||||
}, 600);
|
||||
|
||||
slides[currentSlide].classList.add('active');
|
||||
dots[currentSlide].classList.add('active');
|
||||
}
|
||||
|
||||
function startSlideshow() {
|
||||
if (slideTimer) clearInterval(slideTimer);
|
||||
slideTimer = setInterval(nextSlide, SLIDE_INTERVAL);
|
||||
}
|
||||
|
||||
function stopSlideshow() {
|
||||
if (slideTimer) { clearInterval(slideTimer); slideTimer = null; }
|
||||
}
|
||||
|
||||
startSlideshow();
|
||||
|
||||
// ── 화면 전환 ──
|
||||
function showScreen(name) {
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById(name + 'Screen').classList.add('active');
|
||||
|
||||
if (name === 'idle') {
|
||||
document.body.style.background = '#0f0b2e';
|
||||
startSlideshow();
|
||||
} else {
|
||||
document.body.style.background = '#f5f7fa';
|
||||
stopSlideshow();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 시계 ──
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
const h = String(now.getHours()).padStart(2, '0');
|
||||
const m = String(now.getMinutes()).padStart(2, '0');
|
||||
document.getElementById('headerTime').textContent = h + ':' + m;
|
||||
}
|
||||
updateClock();
|
||||
setInterval(updateClock, 30000);
|
||||
|
||||
// ── 전화번호 (010 고정, 나머지 8자리) ──
|
||||
function formatSuffix(num) {
|
||||
if (num.length <= 4) return num + '●'.repeat(Math.max(0, 4 - num.length)) + '-' + '●●●●';
|
||||
return num.slice(0, 4) + '-' + num.slice(4) + '●'.repeat(Math.max(0, 8 - num.length));
|
||||
}
|
||||
|
||||
function updatePhoneDisplay() {
|
||||
const display = document.getElementById('phoneDisplay');
|
||||
const wrap = document.getElementById('phoneWrap');
|
||||
const btn = document.getElementById('submitBtn');
|
||||
|
||||
if (phoneNumber.length === 0) {
|
||||
display.textContent = '0000-0000';
|
||||
display.className = 'phone-number placeholder';
|
||||
wrap.classList.remove('focus');
|
||||
btn.disabled = true;
|
||||
} else {
|
||||
display.textContent = formatSuffix(phoneNumber);
|
||||
display.className = 'phone-number';
|
||||
wrap.classList.add('focus');
|
||||
btn.disabled = phoneNumber.length < 8;
|
||||
}
|
||||
|
||||
wrap.classList.remove('error');
|
||||
document.getElementById('errorMsg').textContent = '';
|
||||
}
|
||||
|
||||
function numPress(digit) {
|
||||
if (phoneNumber.length >= 8) return;
|
||||
phoneNumber += digit;
|
||||
updatePhoneDisplay();
|
||||
}
|
||||
|
||||
function numDelete() {
|
||||
phoneNumber = phoneNumber.slice(0, -1);
|
||||
updatePhoneDisplay();
|
||||
}
|
||||
|
||||
function numClear() {
|
||||
phoneNumber = '';
|
||||
updatePhoneDisplay();
|
||||
}
|
||||
|
||||
// ── 적립 ──
|
||||
async function submitClaim() {
|
||||
const fullPhone = '010' + phoneNumber;
|
||||
if (fullPhone.length < 11) {
|
||||
document.getElementById('phoneWrap').classList.add('error');
|
||||
document.getElementById('errorMsg').textContent = '전화번호를 정확히 입력해주세요';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('loadingOverlay').classList.add('active');
|
||||
document.getElementById('submitBtn').disabled = true;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/kiosk/claim', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone: fullPhone })
|
||||
});
|
||||
const data = await resp.json();
|
||||
document.getElementById('loadingOverlay').classList.remove('active');
|
||||
|
||||
if (data.success) {
|
||||
showSuccess(data.points, data.balance);
|
||||
} else {
|
||||
document.getElementById('phoneWrap').classList.add('error');
|
||||
document.getElementById('errorMsg').textContent = data.message || '적립 실패';
|
||||
document.getElementById('submitBtn').disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('loadingOverlay').classList.remove('active');
|
||||
document.getElementById('errorMsg').textContent = '서버 연결 실패';
|
||||
document.getElementById('submitBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 성공 화면 ──
|
||||
function showSuccess(points, balance) {
|
||||
document.getElementById('successPoints').textContent = points.toLocaleString() + 'P';
|
||||
document.getElementById('successBalance').textContent = balance.toLocaleString() + 'P';
|
||||
showScreen('success');
|
||||
|
||||
let countdown = 5;
|
||||
const el = document.getElementById('successCountdown');
|
||||
el.textContent = countdown + '초 후 처음 화면으로 돌아갑니다';
|
||||
|
||||
if (successTimeout) clearInterval(successTimeout);
|
||||
successTimeout = setInterval(() => {
|
||||
countdown--;
|
||||
if (countdown <= 0) { clearInterval(successTimeout); resetToIdle(); }
|
||||
else { el.textContent = countdown + '초 후 처음 화면으로 돌아갑니다'; }
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function resetToIdle() {
|
||||
phoneNumber = '';
|
||||
currentSession = null;
|
||||
updatePhoneDisplay();
|
||||
showScreen('idle');
|
||||
}
|
||||
|
||||
// ── 폴링 ──
|
||||
async function pollKioskSession() {
|
||||
try {
|
||||
const resp = await fetch('/api/kiosk/current');
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.active && !currentSession) {
|
||||
currentSession = data;
|
||||
phoneNumber = '';
|
||||
updatePhoneDisplay();
|
||||
|
||||
document.getElementById('claimAmount').textContent = data.amount.toLocaleString() + '원';
|
||||
document.getElementById('claimPoints').textContent = data.points.toLocaleString();
|
||||
|
||||
// 품목 목록 표시
|
||||
const itemsCard = document.getElementById('itemsCard');
|
||||
const itemsList = document.getElementById('itemsList');
|
||||
if (data.items && data.items.length > 0) {
|
||||
itemsList.innerHTML = data.items.map(item =>
|
||||
`<div class="item-row">
|
||||
<span class="item-name">${item.name}</span>
|
||||
<span class="item-qty">${item.qty}개</span>
|
||||
<span class="item-total">${item.total.toLocaleString()}원</span>
|
||||
</div>`
|
||||
).join('');
|
||||
itemsCard.style.display = '';
|
||||
} else {
|
||||
itemsCard.style.display = 'none';
|
||||
}
|
||||
|
||||
if (data.qr_url) {
|
||||
document.getElementById('qrImage').src =
|
||||
'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' +
|
||||
encodeURIComponent(data.qr_url);
|
||||
document.getElementById('qrContainer').style.display = '';
|
||||
document.getElementById('dividerEl').style.display = '';
|
||||
} else {
|
||||
document.getElementById('qrContainer').style.display = 'none';
|
||||
document.getElementById('dividerEl').style.display = 'none';
|
||||
}
|
||||
|
||||
showScreen('claim');
|
||||
} else if (!data.active && currentSession) {
|
||||
resetToIdle();
|
||||
}
|
||||
} catch (err) { /* 다음 폴링에서 재시도 */ }
|
||||
}
|
||||
|
||||
pollingInterval = setInterval(pollKioskSession, 1000);
|
||||
pollKioskSession();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,6 +3,13 @@
|
||||
<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">
|
||||
<link rel="icon" type="image/png" sizes="192x192" 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>
|
||||
@@ -28,20 +35,20 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 48px 24px 32px 24px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
@@ -55,6 +62,12 @@
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.header-profile {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 28px 24px 32px 24px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
@@ -194,22 +207,88 @@
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
/* 품목 상세 */
|
||||
.transaction-item.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-detail {
|
||||
display: none;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f1f3f5;
|
||||
}
|
||||
|
||||
.item-detail.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.item-detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.item-detail-name {
|
||||
color: #495057;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.item-detail-qty {
|
||||
color: #868e96;
|
||||
margin-right: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-detail-price {
|
||||
color: #212529;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-detail-loading {
|
||||
text-align: center;
|
||||
color: #adb5bd;
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.item-detail-hint {
|
||||
color: #adb5bd;
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 모바일 최적화 */
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding-top: 60px;
|
||||
.header-top {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
height: calc(56px + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<div class="header-title">마이페이지</div>
|
||||
<a href="/my-page" class="btn-logout">다른 번호로 조회</a>
|
||||
<div class="header-top">
|
||||
<div class="header-title">마이페이지</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<a href="/my-page" class="btn-logout">다른 번호</a>
|
||||
<a href="/my-page/kakao/start" class="btn-logout" style="display: flex; align-items: center; gap: 4px; background: #FEE500; color: #191919; padding: 6px 12px; border-radius: 8px; font-size: 12px; font-weight: 600;">
|
||||
<svg width="12" height="12" viewBox="0 0 20 20" fill="none"><path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/></svg>
|
||||
카카오
|
||||
</a>
|
||||
<a href="/logout" class="btn-logout" style="font-size: 12px; opacity: 0.7;">로그아웃</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-profile">
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ user.nickname }}님</div>
|
||||
<div class="user-phone">{{ user.phone[:3] }}-{{ user.phone[3:7] }}-{{ user.phone[7:] if user.phone|length > 7 else '' }}</div>
|
||||
@@ -228,7 +307,8 @@
|
||||
{% if transactions %}
|
||||
<ul class="transaction-list">
|
||||
{% for tx in transactions %}
|
||||
<li class="transaction-item">
|
||||
<li class="transaction-item {% if tx.transaction_id %}clickable{% endif %}"
|
||||
{% if tx.transaction_id %}onclick="toggleDetail(this, '{{ tx.transaction_id }}')"{% endif %}>
|
||||
<div class="transaction-header">
|
||||
<div class="transaction-reason">
|
||||
{% if tx.reason == 'CLAIM' %}
|
||||
@@ -246,7 +326,13 @@
|
||||
{% if tx.description %}
|
||||
<div class="transaction-desc">{{ tx.description }}</div>
|
||||
{% endif %}
|
||||
<div class="transaction-date">{{ tx.created_at }}</div>
|
||||
<div class="transaction-date">
|
||||
{{ tx.created_at }}
|
||||
{% if tx.transaction_id %}
|
||||
<span class="item-detail-hint">탭하여 품목 보기</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-detail" id="detail-{{ tx.transaction_id }}"></div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -258,5 +344,204 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const detailCache = {};
|
||||
|
||||
async function toggleDetail(el, txId) {
|
||||
const detail = document.getElementById('detail-' + txId);
|
||||
if (!detail) return;
|
||||
|
||||
// 이미 열려있으면 닫기
|
||||
if (detail.classList.contains('open')) {
|
||||
detail.classList.remove('open');
|
||||
return;
|
||||
}
|
||||
|
||||
// 캐시에 있으면 바로 표시
|
||||
if (detailCache[txId]) {
|
||||
detail.innerHTML = detailCache[txId];
|
||||
detail.classList.add('open');
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 표시
|
||||
detail.innerHTML = '<div class="item-detail-loading">품목 조회 중...</div>';
|
||||
detail.classList.add('open');
|
||||
|
||||
try {
|
||||
const res = await fetch('/admin/transaction/' + txId);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.items && data.items.length > 0) {
|
||||
let html = '';
|
||||
data.items.forEach(item => {
|
||||
html += `<div class="item-detail-row">
|
||||
<span class="item-detail-name">${item.name}</span>
|
||||
<span class="item-detail-qty">${item.qty}개</span>
|
||||
<span class="item-detail-price">${item.total.toLocaleString()}원</span>
|
||||
</div>`;
|
||||
});
|
||||
detailCache[txId] = html;
|
||||
detail.innerHTML = html;
|
||||
} else {
|
||||
detail.innerHTML = '<div class="item-detail-loading">품목 정보를 불러올 수 없습니다</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
detail.innerHTML = '<div class="item-detail-loading">조회 실패</div>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- AI 추천 바텀시트 -->
|
||||
<div id="rec-sheet" style="display:none;">
|
||||
<div id="rec-backdrop" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.3);z-index:999;animation:recFadeIn .3s ease;"></div>
|
||||
<div id="rec-content" style="position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:420px;background:#fff;border-radius:24px 24px 0 0;padding:0 0 0;box-shadow:0 -8px 32px rgba(0,0,0,0.12);z-index:1000;animation:recSlideUp .4s cubic-bezier(.16,1,.3,1);touch-action:none;">
|
||||
<!-- 드래그 핸들 영역 -->
|
||||
<div id="rec-drag-handle" style="padding:12px 24px 0;cursor:grab;">
|
||||
<div style="width:40px;height:4px;background:#dee2e6;border-radius:2px;margin:0 auto 20px;"></div>
|
||||
</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-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>
|
||||
<div style="display:flex;gap:12px;padding-bottom:env(safe-area-inset-bottom,0);">
|
||||
<button onclick="dismissRec('dismissed')" style="flex:1;padding:14px;border:1px solid #dee2e6;border-radius:14px;background:#fff;color:#868e96;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">다음에요</button>
|
||||
<button onclick="dismissRec('interested')" style="flex:2;padding:14px;border:none;border-radius:14px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">관심있어요!</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
@keyframes recFadeIn { from{opacity:0} to{opacity:1} }
|
||||
@keyframes recSlideUp { from{transform:translate(-50%,100%)} to{transform:translate(-50%,0)} }
|
||||
@keyframes recSlideDown { from{transform:translate(-50%,0)} to{transform:translate(-50%,100%)} }
|
||||
</style>
|
||||
<script>
|
||||
let _recId = null;
|
||||
|
||||
// ── 드래그 닫기 ──
|
||||
(function() {
|
||||
let startY = 0, currentY = 0, isDragging = false;
|
||||
const DISMISS_THRESHOLD = 80;
|
||||
|
||||
function getContent() { return document.getElementById('rec-content'); }
|
||||
function getBackdrop() { return document.getElementById('rec-backdrop'); }
|
||||
|
||||
function onStart(y) {
|
||||
const c = getContent();
|
||||
if (!c) return;
|
||||
isDragging = true;
|
||||
startY = y;
|
||||
currentY = 0;
|
||||
c.style.animation = 'none';
|
||||
c.style.transition = 'none';
|
||||
}
|
||||
function onMove(y) {
|
||||
if (!isDragging) return;
|
||||
const c = getContent();
|
||||
const b = getBackdrop();
|
||||
currentY = Math.max(0, y - startY); // 아래로만
|
||||
c.style.transform = 'translate(-50%, ' + currentY + 'px)';
|
||||
// 배경 투명도도 같이
|
||||
const opacity = Math.max(0, 0.3 * (1 - currentY / 300));
|
||||
b.style.background = 'rgba(0,0,0,' + opacity + ')';
|
||||
}
|
||||
function onEnd() {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
const c = getContent();
|
||||
if (currentY > DISMISS_THRESHOLD) {
|
||||
// 충분히 내렸으면 닫기
|
||||
c.style.transition = 'transform .25s ease';
|
||||
c.style.transform = 'translate(-50%, 100%)';
|
||||
getBackdrop().style.transition = 'opacity .25s';
|
||||
getBackdrop().style.opacity = '0';
|
||||
setTimeout(function() {
|
||||
document.getElementById('rec-sheet').style.display = 'none';
|
||||
c.style.transition = '';
|
||||
c.style.transform = '';
|
||||
}, 250);
|
||||
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {method:'POST'}).catch(function(){});
|
||||
} else {
|
||||
// 복귀
|
||||
c.style.transition = 'transform .25s cubic-bezier(.16,1,.3,1)';
|
||||
c.style.transform = 'translate(-50%, 0)';
|
||||
getBackdrop().style.transition = 'background .25s';
|
||||
getBackdrop().style.background = 'rgba(0,0,0,0.3)';
|
||||
setTimeout(function() { c.style.transition = ''; }, 250);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var el = document.getElementById('rec-content');
|
||||
if (!el) return;
|
||||
|
||||
// 터치 (모바일)
|
||||
el.addEventListener('touchstart', function(e) {
|
||||
onStart(e.touches[0].clientY);
|
||||
}, {passive: true});
|
||||
el.addEventListener('touchmove', function(e) {
|
||||
if (isDragging && currentY > 0) e.preventDefault();
|
||||
onMove(e.touches[0].clientY);
|
||||
}, {passive: false});
|
||||
el.addEventListener('touchend', onEnd);
|
||||
|
||||
// 마우스 (데스크톱 테스트용)
|
||||
el.addEventListener('mousedown', function(e) {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
onStart(e.clientY);
|
||||
});
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (isDragging) onMove(e.clientY);
|
||||
});
|
||||
document.addEventListener('mouseup', onEnd);
|
||||
});
|
||||
})();
|
||||
|
||||
// ── 추천 로드 ──
|
||||
window.addEventListener('load', function() {
|
||||
{% if user_id %}
|
||||
setTimeout(async function() {
|
||||
try {
|
||||
const res = await fetch('/api/recommendation/{{ user_id }}');
|
||||
const data = await res.json();
|
||||
if (data.success && data.has_recommendation) {
|
||||
_recId = data.recommendation.id;
|
||||
document.getElementById('rec-message').textContent = data.recommendation.message;
|
||||
document.getElementById('rec-product').textContent = data.recommendation.product;
|
||||
document.getElementById('rec-sheet').style.display = 'block';
|
||||
document.getElementById('rec-backdrop').onclick = dismissRec;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('[AI추천] 에러:', e);
|
||||
}
|
||||
}, 1500);
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
function dismissRec(action) {
|
||||
action = action || 'dismissed';
|
||||
const c = document.getElementById('rec-content');
|
||||
const b = document.getElementById('rec-backdrop');
|
||||
c.style.transition = 'transform .3s ease';
|
||||
c.style.transform = 'translate(-50%, 100%)';
|
||||
b.style.opacity = '0';
|
||||
b.style.transition = 'opacity .3s';
|
||||
setTimeout(function(){
|
||||
document.getElementById('rec-sheet').style.display='none';
|
||||
c.style.transition = '';
|
||||
c.style.transform = '';
|
||||
}, 300);
|
||||
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({action: action})
|
||||
}).catch(function(){});
|
||||
}
|
||||
</script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
<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">
|
||||
<link rel="icon" type="image/png" sizes="192x192" 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>
|
||||
@@ -149,11 +156,17 @@
|
||||
<form method="GET" action="/my-page">
|
||||
<div class="form-group">
|
||||
<label for="phone">전화번호</label>
|
||||
<input type="tel" id="phone" name="phone"
|
||||
placeholder="010-0000-0000"
|
||||
pattern="[0-9-]*"
|
||||
autocomplete="tel"
|
||||
required>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 18px; font-weight: 600; color: #495057; white-space: nowrap; padding: 16px 0 16px 4px;">010 -</span>
|
||||
<input type="tel" id="phoneInput"
|
||||
placeholder="0000-0000"
|
||||
inputmode="numeric"
|
||||
maxlength="9"
|
||||
autocomplete="tel"
|
||||
required
|
||||
style="flex: 1;">
|
||||
<input type="hidden" id="phone" name="phone">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit">
|
||||
@@ -161,23 +174,62 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 또는 구분선 -->
|
||||
<div style="text-align: center; margin: 24px 0 20px 0; position: relative;">
|
||||
<span style="background: #fff; padding: 0 16px; color: #adb5bd; font-size: 13px; font-weight: 500; position: relative; z-index: 1;">또는</span>
|
||||
<div style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: #e9ecef; z-index: 0;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 카카오 로그인 버튼 (JS SDK) -->
|
||||
<button type="button" onclick="kakaoLogin()" style="display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 18px; background: #FEE500; color: #191919; border: none; border-radius: 14px; font-size: 17px; font-weight: 700; cursor: pointer; letter-spacing: -0.3px; transition: all 0.2s ease;">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
|
||||
</svg>
|
||||
카카오로 조회하기
|
||||
</button>
|
||||
|
||||
<a href="/" class="btn-back">← 홈으로</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 전화번호 자동 하이픈
|
||||
const phoneInput = document.getElementById('phone');
|
||||
// 뒷번호 자동 하이픈 (010 고정)
|
||||
const phoneInput = document.getElementById('phoneInput');
|
||||
const phoneHidden = document.getElementById('phone');
|
||||
|
||||
phoneInput.addEventListener('input', function(e) {
|
||||
let value = e.target.value.replace(/[^0-9]/g, '');
|
||||
|
||||
if (value.length <= 3) {
|
||||
if (value.length <= 4) {
|
||||
e.target.value = value;
|
||||
} else if (value.length <= 7) {
|
||||
e.target.value = value.slice(0, 3) + '-' + value.slice(3);
|
||||
} else {
|
||||
e.target.value = value.slice(0, 3) + '-' + value.slice(3, 7) + '-' + value.slice(7, 11);
|
||||
e.target.value = value.slice(0, 4) + '-' + value.slice(4, 8);
|
||||
}
|
||||
});
|
||||
|
||||
// 제출 시 010 합쳐서 hidden 필드에 전달
|
||||
phoneInput.closest('form').addEventListener('submit', function() {
|
||||
const raw = phoneInput.value.replace(/[^0-9]/g, '');
|
||||
phoneHidden.value = '010' + raw;
|
||||
});
|
||||
|
||||
phoneInput.focus();
|
||||
</script>
|
||||
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js"
|
||||
integrity="sha384-DKYJZ8NLiK8MN4/C5P2dtSmLQ4KwPaoqAfyA/DfmOGfnSNqoARCbb2xl4Kh1v6Q"
|
||||
crossorigin="anonymous"></script>
|
||||
<script>
|
||||
if (typeof Kakao !== 'undefined') Kakao.init('3d1e098107157c5021b73bd5ab48600f');
|
||||
|
||||
function kakaoLogin() {
|
||||
if (typeof Kakao !== 'undefined' && Kakao.isInitialized()) {
|
||||
Kakao.Auth.authorize({
|
||||
redirectUri: 'https://mile.0bin.in/claim/kakao/callback',
|
||||
state: '{{ kakao_state }}'
|
||||
});
|
||||
} else {
|
||||
window.location.href = '/my-page/kakao/start';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
300
backend/templates/privacy.html
Normal file
300
backend/templates/privacy.html
Normal file
@@ -0,0 +1,300 @@
|
||||
<!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">
|
||||
<link rel="icon" type="image/png" sizes="192x192" 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;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: #ffffff;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
margin: 28px 0 12px 0;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #495057;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 20px;
|
||||
margin: 8px 0 12px 0;
|
||||
}
|
||||
|
||||
li {
|
||||
color: #495057;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
border: 1px solid #e9ecef;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background: #f8f9fa;
|
||||
color: #495057;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
color: #495057;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge-req { background: #fff0f0; color: #e03131; }
|
||||
.badge-opt { background: #f0f4ff; color: #6366f1; }
|
||||
|
||||
.effective-date {
|
||||
color: #868e96;
|
||||
font-size: 13px;
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
height: calc(56px + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<div class="header">
|
||||
<div class="header-title">개인정보 처리방침</div>
|
||||
<a href="javascript:history.back()" class="btn-back">돌아가기</a>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>청춘약국(이하 "약국")은 「개인정보 보호법」에 따라 고객의 개인정보를 보호하고 이와 관련한 고충을 신속하고 원활하게 처리할 수 있도록 다음과 같이 개인정보 처리방침을 수립·공개합니다.</p>
|
||||
|
||||
<div class="section-title">1. 수집하는 개인정보 항목 및 수집 방법</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>수집 방법</th>
|
||||
<th>수집 항목</th>
|
||||
<th>필수/선택</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>직접 입력<br>(회원가입)</td>
|
||||
<td>전화번호, 이름</td>
|
||||
<td><span class="badge badge-req">필수</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>직접 입력<br>(회원가입)</td>
|
||||
<td>생년월일</td>
|
||||
<td><span class="badge badge-opt">선택</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>카카오 로그인</td>
|
||||
<td>카카오 계정 식별자(ID), 닉네임, 프로필 이미지, 이메일, 이름, 전화번호, 생년월일</td>
|
||||
<td><span class="badge badge-req">필수</span> / <span class="badge badge-opt">선택</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>자동 수집</td>
|
||||
<td>구매 내역(품목명, 수량, 금액, 일시)</td>
|
||||
<td><span class="badge badge-req">필수</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="section-title">2. 개인정보의 수집 및 이용 목적</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>수집 항목</th>
|
||||
<th>이용 목적</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>전화번호</td>
|
||||
<td>마일리지 적립 계정의 고유 식별자, 포인트 조회 시 본인 확인, 약국 방문 시 포인트 사용을 위한 본인 확인</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>이름</td>
|
||||
<td>동명이인 구분 및 약국 방문 시 본인 확인, 적립 내역 안내</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>생년월일</td>
|
||||
<td>생일 기념 포인트 2배 적립 이벤트, 연령대별 맞춤 건강 정보 및 제품 추천 서비스 제공</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>카카오 계정 정보</td>
|
||||
<td>간편 로그인 및 자동 적립 기능 지원, 기존 회원과의 계정 연동</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>구매 내역</td>
|
||||
<td>마일리지 포인트 적립 금액 산정, 적립 내역 조회 서비스 제공</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="section-title">3. 개인정보의 보유 및 이용 기간</div>
|
||||
<p>약국은 개인정보 수집 및 이용 목적이 달성된 후에는 해당 정보를 지체 없이 파기합니다. 단, 관계 법령에 의해 보존이 필요한 경우에는 해당 법령에서 정한 기간 동안 보관합니다.</p>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>보존 항목</th>
|
||||
<th>보존 기간</th>
|
||||
<th>근거 법령</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>거래 기록</td>
|
||||
<td>5년</td>
|
||||
<td>부가가치세법</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>회원 정보</td>
|
||||
<td>탈퇴 시까지</td>
|
||||
<td>개인정보 보호법</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="section-title">4. 개인정보의 제3자 제공</div>
|
||||
<p>약국은 고객의 개인정보를 제3자에게 제공하지 않습니다. 카카오 로그인은 본인 인증 목적으로만 사용되며, 약국에서 카카오에 고객 정보를 제공하지 않습니다.</p>
|
||||
|
||||
<div class="section-title">5. 개인정보의 파기 절차 및 방법</div>
|
||||
<ul>
|
||||
<li>전자적 파일: 복구 및 재생이 불가능한 기술적 방법으로 삭제</li>
|
||||
<li>종이 문서: 분쇄기로 분쇄하거나 소각</li>
|
||||
</ul>
|
||||
|
||||
<div class="section-title">6. 정보주체의 권리·의무 및 행사 방법</div>
|
||||
<p>고객은 언제든지 자신의 개인정보에 대해 다음과 같은 권리를 행사할 수 있습니다.</p>
|
||||
<ul>
|
||||
<li>개인정보 열람 요구</li>
|
||||
<li>오류 등이 있을 경우 정정 요구</li>
|
||||
<li>삭제 요구</li>
|
||||
<li>처리 정지 요구</li>
|
||||
</ul>
|
||||
<p>위 권리 행사는 약국에 직접 방문하시거나, 아래 연락처로 문의해주시기 바랍니다.</p>
|
||||
|
||||
<div class="section-title">7. 개인정보 보호 책임자</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>상호</th>
|
||||
<td>청춘약국</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>책임자</th>
|
||||
<td>약국 대표</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>연락처</th>
|
||||
<td>약국 방문 또는 전화 문의</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="section-title">8. 개인정보 자동 수집 장치의 설치·운영 및 거부</div>
|
||||
<p>약국은 서비스 이용 과정에서 세션 쿠키를 사용하여 로그인 상태를 유지합니다. 쿠키는 브라우저 설정을 통해 거부할 수 있으나, 이 경우 자동 적립 기능 등 일부 서비스 이용이 제한될 수 있습니다.</p>
|
||||
|
||||
<div class="section-title">9. 선택 정보 미제공에 따른 불이익</div>
|
||||
<p>생년월일 등 선택 항목을 제공하지 않더라도 마일리지 적립·조회 등 기본 서비스 이용에는 제한이 없습니다. 다만 생일 기념 포인트 이벤트, 연령대별 맞춤 추천 등 부가 서비스를 받으실 수 없습니다.</p>
|
||||
|
||||
<div class="effective-date">
|
||||
시행일: 2026년 2월 25일
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(()=>{});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
699
backend/templates/signup.html
Normal file
699
backend/templates/signup.html
Normal file
@@ -0,0 +1,699 @@
|
||||
<!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">
|
||||
<link rel="icon" type="image/png" sizes="192x192" 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;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: #ffffff;
|
||||
min-height: 100vh;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-desc {
|
||||
font-size: 14px;
|
||||
color: #868e96;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.label-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.badge-required {
|
||||
background: #fff0f0;
|
||||
color: #e03131;
|
||||
}
|
||||
|
||||
.badge-optional {
|
||||
background: #f0f4ff;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.input-purpose {
|
||||
font-size: 12px;
|
||||
color: #868e96;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.2px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1.5px solid #e9ecef;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
color: #212529;
|
||||
transition: border-color 0.2s;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.input-group input::placeholder {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.phone-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.phone-prefix {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
white-space: nowrap;
|
||||
padding: 16px 0 16px 4px;
|
||||
}
|
||||
|
||||
.phone-wrapper input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.birthday-wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.birthday-wrapper select {
|
||||
flex: 1;
|
||||
padding: 16px 12px;
|
||||
border: 1.5px solid #e9ecef;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
color: #212529;
|
||||
background: #fff;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23868e96' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
}
|
||||
|
||||
.birthday-wrapper select:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.birthday-wrapper select.placeholder {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
/* 수집 항목 안내 카드 */
|
||||
.info-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.info-card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #495057;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.info-item:last-child { margin-bottom: 0; }
|
||||
|
||||
.info-item-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-item-icon.blue { background: rgba(99, 102, 241, 0.1); }
|
||||
.info-item-icon.green { background: rgba(16, 185, 129, 0.1); }
|
||||
.info-item-icon.pink { background: rgba(244, 63, 94, 0.1); }
|
||||
|
||||
.info-item-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-item-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.info-item-desc {
|
||||
font-size: 12px;
|
||||
color: #868e96;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.privacy-consent {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-container input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: 2px;
|
||||
accent-color: #6366f1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.consent-text {
|
||||
font-size: 14px;
|
||||
color: #495057;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.consent-text a {
|
||||
color: #6366f1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
letter-spacing: -0.3px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.btn-submit:active { transform: scale(0.98); }
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.divider {
|
||||
text-align: center;
|
||||
margin: 24px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.divider span {
|
||||
background: #fff;
|
||||
padding: 0 16px;
|
||||
color: #adb5bd;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.btn-kakao {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: #FEE500;
|
||||
color: #191919;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.3px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-kakao:active { transform: scale(0.98); }
|
||||
|
||||
.alert {
|
||||
display: none;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-top: 16px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.alert.error { background: #fff5f5; color: #e03131; }
|
||||
.alert.success { background: #f0fdf4; color: #16a34a; }
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 16px 24px 24px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #adb5bd;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 성공 화면 */
|
||||
.success-screen {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 60px 24px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: scaleIn 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
0% { transform: scale(0); opacity: 0; }
|
||||
60% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes drawCheck {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
.success-icon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
fill: none;
|
||||
stroke: #ffffff;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.success-desc {
|
||||
font-size: 15px;
|
||||
color: #868e96;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.success-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.success-buttons a {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-home-s { background: #f1f3f5; color: #495057; }
|
||||
.btn-mypage-s { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
height: calc(56px + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<div class="header">
|
||||
<div class="header-title">회원가입</div>
|
||||
<a href="/" class="btn-back">홈으로</a>
|
||||
</div>
|
||||
|
||||
<!-- 가입 폼 -->
|
||||
<div id="signupForm">
|
||||
<div class="form-section">
|
||||
<div class="form-title">회원가입</div>
|
||||
<div class="form-desc">
|
||||
청춘약국 마일리지 서비스에 가입하세요.<br>
|
||||
영수증 QR 스캔으로 구매금액의 3%를 적립할 수 있습니다.
|
||||
</div>
|
||||
|
||||
<!-- 수집 항목 안내 카드 -->
|
||||
<div class="info-card">
|
||||
<div class="info-card-title">수집 항목 및 이용 목적</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item-icon blue">📱</div>
|
||||
<div class="info-item-text">
|
||||
<div class="info-item-label">전화번호 (필수)</div>
|
||||
<div class="info-item-desc">마일리지 적립 계정의 고유 식별자로 사용됩니다. 포인트 조회 및 사용 시 본인 확인에 필요합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item-icon green">👤</div>
|
||||
<div class="info-item-text">
|
||||
<div class="info-item-label">이름 (필수)</div>
|
||||
<div class="info-item-desc">동명이인 구분 및 약국 방문 시 본인 확인에 사용됩니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item-icon pink">🎂</div>
|
||||
<div class="info-item-text">
|
||||
<div class="info-item-label">생년월일 (선택)</div>
|
||||
<div class="info-item-desc">생일 기념 포인트 2배 적립 이벤트 및 연령대별 맞춤 건강 정보 제공에 활용됩니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="formSignup" onsubmit="return false;">
|
||||
<div class="input-group">
|
||||
<label for="name">
|
||||
이름
|
||||
<span class="label-badge badge-required">필수</span>
|
||||
</label>
|
||||
<div class="input-purpose">약국 방문 시 본인 확인 및 동명이인 구분에 사용됩니다.</div>
|
||||
<input type="text" id="name" placeholder="이름을 입력하세요" autocomplete="name" required>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="phone">
|
||||
전화번호
|
||||
<span class="label-badge badge-required">필수</span>
|
||||
</label>
|
||||
<div class="input-purpose">마일리지 적립·조회의 고유 식별자로 사용됩니다.</div>
|
||||
<div class="phone-wrapper">
|
||||
<span class="phone-prefix">010 -</span>
|
||||
<input type="tel" id="phone"
|
||||
placeholder="0000-0000"
|
||||
inputmode="numeric"
|
||||
maxlength="9"
|
||||
autocomplete="tel"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>
|
||||
생년월일
|
||||
<span class="label-badge badge-optional">선택</span>
|
||||
</label>
|
||||
<div class="input-purpose">생일 기념 포인트 2배 적립 및 연령대별 맞춤 건강 정보 제공에 활용됩니다.</div>
|
||||
<div class="birthday-wrapper">
|
||||
<select id="birthYear" class="placeholder">
|
||||
<option value="">년도</option>
|
||||
</select>
|
||||
<select id="birthMonth" class="placeholder">
|
||||
<option value="">월</option>
|
||||
</select>
|
||||
<select id="birthDay" class="placeholder">
|
||||
<option value="">일</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="privacy-consent">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" id="privacyConsent" required>
|
||||
<span class="consent-text">
|
||||
<a href="/privacy" target="_blank">개인정보 수집·이용</a>에 동의합니다.
|
||||
<br><span style="font-size:12px; color:#868e96;">전화번호, 이름(필수), 생년월일(선택)을 마일리지 적립·조회 및 맞춤 서비스 목적으로 수집합니다.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit" id="btnSubmit">가입하기</button>
|
||||
</form>
|
||||
|
||||
<div class="divider"><span>또는</span></div>
|
||||
|
||||
<a href="/my-page/kakao/start" class="btn-kakao">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
|
||||
</svg>
|
||||
카카오로 간편 가입
|
||||
</a>
|
||||
|
||||
<div class="alert error" id="alertMsg"></div>
|
||||
|
||||
<div class="footer">
|
||||
<a href="/privacy" target="_blank">개인정보 처리방침</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 성공 화면 -->
|
||||
<div id="successScreen" class="success-screen">
|
||||
<div class="success-icon">
|
||||
<svg viewBox="0 0 52 52">
|
||||
<path d="M14.1 27.2l7.1 7.2 16.7-16.8"
|
||||
style="stroke-dasharray:100; stroke-dashoffset:100; animation: drawCheck 0.6s 0.3s ease forwards;"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="success-title">가입 완료!</div>
|
||||
<div class="success-desc" id="successDesc"></div>
|
||||
<div class="success-buttons">
|
||||
<a href="/" class="btn-home-s">홈으로</a>
|
||||
<a href="#" class="btn-mypage-s" id="btnMyPage">내 마일리지</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}
|
||||
|
||||
// 생년월일 셀렉트 초기화
|
||||
(function() {
|
||||
var yearSel = document.getElementById('birthYear');
|
||||
var monthSel = document.getElementById('birthMonth');
|
||||
var daySel = document.getElementById('birthDay');
|
||||
var currentYear = new Date().getFullYear();
|
||||
|
||||
for (var y = currentYear; y >= 1920; y--) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = y;
|
||||
opt.textContent = y + '년';
|
||||
yearSel.appendChild(opt);
|
||||
}
|
||||
for (var m = 1; m <= 12; m++) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m < 10 ? '0' + m : '' + m;
|
||||
opt.textContent = m + '월';
|
||||
monthSel.appendChild(opt);
|
||||
}
|
||||
for (var d = 1; d <= 31; d++) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = d < 10 ? '0' + d : '' + d;
|
||||
opt.textContent = d + '일';
|
||||
daySel.appendChild(opt);
|
||||
}
|
||||
|
||||
// 선택 시 placeholder 클래스 제거
|
||||
[yearSel, monthSel, daySel].forEach(function(sel) {
|
||||
sel.addEventListener('change', function() {
|
||||
if (this.value) this.classList.remove('placeholder');
|
||||
else this.classList.add('placeholder');
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
const phoneInput = document.getElementById('phone');
|
||||
const form = document.getElementById('formSignup');
|
||||
const alertMsg = document.getElementById('alertMsg');
|
||||
|
||||
// 자동 하이픈
|
||||
phoneInput.addEventListener('input', function(e) {
|
||||
let v = e.target.value.replace(/[^0-9]/g, '');
|
||||
if (v.length <= 4) e.target.value = v;
|
||||
else e.target.value = v.slice(0, 4) + '-' + v.slice(4, 8);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const name = document.getElementById('name').value.trim();
|
||||
const raw = phoneInput.value.replace(/[^0-9]/g, '');
|
||||
const phone = '010' + raw;
|
||||
const consent = document.getElementById('privacyConsent').checked;
|
||||
|
||||
// 생년월일 (선택)
|
||||
const birthYear = document.getElementById('birthYear').value;
|
||||
const birthMonth = document.getElementById('birthMonth').value;
|
||||
const birthDay = document.getElementById('birthDay').value;
|
||||
let birthday = null;
|
||||
if (birthYear && birthMonth && birthDay) {
|
||||
birthday = birthYear + '-' + birthMonth + '-' + birthDay;
|
||||
}
|
||||
|
||||
if (!name) return showAlert('이름을 입력해주세요.');
|
||||
if (raw.length < 7) return showAlert('올바른 전화번호를 입력해주세요.');
|
||||
if (!consent) return showAlert('개인정보 수집·이용에 동의해주세요.');
|
||||
|
||||
const btn = document.getElementById('btnSubmit');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '가입 중...';
|
||||
|
||||
try {
|
||||
const body = { name, phone };
|
||||
if (birthday) body.birthday = birthday;
|
||||
|
||||
const res = await fetch('/api/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('signupForm').style.display = 'none';
|
||||
document.getElementById('successScreen').style.display = 'block';
|
||||
document.getElementById('successDesc').innerHTML =
|
||||
'<strong>' + name + '</strong>님, 환영합니다!<br>이제 영수증 QR을 스캔하면 포인트가 적립됩니다.';
|
||||
document.getElementById('btnMyPage').href = '/my-page?phone=' + encodeURIComponent(phone);
|
||||
} else {
|
||||
showAlert(data.message || '가입 중 오류가 발생했습니다.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '가입하기';
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('서버 연결에 실패했습니다.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '가입하기';
|
||||
}
|
||||
});
|
||||
|
||||
function showAlert(msg) {
|
||||
alertMsg.textContent = msg;
|
||||
alertMsg.style.display = 'block';
|
||||
setTimeout(() => { alertMsg.style.display = 'none'; }, 4000);
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -115,8 +115,8 @@ def save_token_to_db(transaction_id, token_hash, total_amount, claimable_points,
|
||||
- token_hash가 이미 존재하면 실패 (UNIQUE 제약)
|
||||
"""
|
||||
try:
|
||||
db_manager = DatabaseManager()
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
from db.dbsetup import db_manager as _db_manager
|
||||
conn = _db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 중복 체크 (transaction_id)
|
||||
|
||||
113
docs/TROUBLESHOOTING-SQLITE-CONNECTION.md
Normal file
113
docs/TROUBLESHOOTING-SQLITE-CONNECTION.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# 트러블슈팅: SQLite "I/O operation on closed file" 에러
|
||||
|
||||
## 발생일
|
||||
2026-02-27
|
||||
|
||||
## 증상
|
||||
- 관리자 페이지에서 회원 검색 시 500 에러 발생
|
||||
- 에러 메시지: `조회 실패: I/O operation on closed file.`
|
||||
- 서버 로그에는 200 OK로 찍히지만 응답 body에 에러 포함
|
||||
|
||||
## 원인
|
||||
|
||||
### 1. SQLite 싱글톤 연결 문제
|
||||
Flask의 멀티스레드 환경에서 `db_manager.get_sqlite_connection()`이 **싱글톤 연결**을 반환.
|
||||
한 요청에서 연결을 닫으면 다른 요청에서 "closed file" 에러 발생.
|
||||
|
||||
**문제 코드:**
|
||||
```python
|
||||
conn = db_manager.get_sqlite_connection() # 싱글톤 연결 반환
|
||||
cursor = conn.cursor()
|
||||
# ... 작업 ...
|
||||
# finally에서 conn.close() 호출 시 다른 요청에 영향
|
||||
```
|
||||
|
||||
### 2. 존재하지 않는 테이블 참조
|
||||
`product_category_mapping` 테이블이 DB에 없는데 쿼리 시도 → SQLite 에러 발생
|
||||
|
||||
## 해결 방법
|
||||
|
||||
### 1. 새 연결 사용 + finally에서 close
|
||||
```python
|
||||
conn = None
|
||||
try:
|
||||
conn = db_manager.get_sqlite_connection(new_connection=True) # 새 연결!
|
||||
cursor = conn.cursor()
|
||||
# ... 작업 ...
|
||||
except Exception as e:
|
||||
logging.error(f"에러: {e}")
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
finally:
|
||||
if conn:
|
||||
try:
|
||||
conn.close()
|
||||
except:
|
||||
pass
|
||||
```
|
||||
|
||||
### 2. 없는 테이블 조회 시 예외 처리
|
||||
```python
|
||||
try:
|
||||
cursor.execute("SELECT * FROM product_category_mapping WHERE ...")
|
||||
# ...
|
||||
except Exception:
|
||||
pass # 테이블 없으면 무시
|
||||
```
|
||||
|
||||
## 수정된 API 목록
|
||||
|
||||
| API | 파일 | 커밋 |
|
||||
|-----|------|------|
|
||||
| `/api/members/search` | app.py | 87a56d0 |
|
||||
| `/api/members/history/<id>` | app.py | 87a56d0 |
|
||||
| `/admin/search/user` | app.py | 1414bb1 |
|
||||
| `/admin/search/product` | app.py | 1414bb1 |
|
||||
| `/admin/user/<id>` | app.py | 4691d65, 94a8df6 |
|
||||
|
||||
## dbsetup.py 수정사항
|
||||
|
||||
`get_sqlite_connection()` 메서드에 `new_connection` 파라미터 추가:
|
||||
|
||||
```python
|
||||
def get_sqlite_connection(self, new_connection=False):
|
||||
"""
|
||||
SQLite 연결 반환
|
||||
- new_connection=True: 새 연결 생성 (API 요청마다 독립적 연결 필요시)
|
||||
- new_connection=False: 기존 싱글톤 연결 반환 (기본값, 하위 호환성)
|
||||
"""
|
||||
if new_connection:
|
||||
conn = sqlite3.connect(self.sqlite_path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
# 기존 싱글톤 로직
|
||||
if self._sqlite_conn is None:
|
||||
self._sqlite_conn = sqlite3.connect(self.sqlite_path, check_same_thread=False)
|
||||
self._sqlite_conn.row_factory = sqlite3.Row
|
||||
return self._sqlite_conn
|
||||
```
|
||||
|
||||
## 추가 수정사항
|
||||
|
||||
### CDN 차단 문제
|
||||
Edge 브라우저의 Tracking Prevention이 cdnjs.cloudflare.com 차단
|
||||
→ lottie.min.js를 로컬 파일(`/static/js/lottie.min.js`)로 변경
|
||||
|
||||
**커밋:** 866d10f
|
||||
|
||||
## 교훈
|
||||
|
||||
1. **Flask 멀티스레드 환경에서 SQLite 연결은 요청마다 새로 생성**해야 안전
|
||||
2. **API 응답은 HTTP 상태코드로 판단하지 말고 body의 success 필드 확인**
|
||||
3. **없을 수 있는 테이블/컬럼 조회는 try-except로 감싸기**
|
||||
4. **CDN 의존성은 로컬 fallback 준비**
|
||||
|
||||
## 관련 커밋
|
||||
|
||||
```
|
||||
94a8df6 fix: product_category_mapping 테이블 없을 때 에러 무시
|
||||
4691d65 fix: /admin/user/<id> SQLite 연결 에러 해결
|
||||
866d10f fix: lottie CDN을 로컬 파일로 변경
|
||||
1414bb1 fix: /admin 사이드바 검색 SQLite 연결 에러 해결
|
||||
87a56d0 fix: /api/members/* SQLite 연결 에러 해결
|
||||
```
|
||||
324
docs/ai-upselling-architecture.md
Normal file
324
docs/ai-upselling-architecture.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# AI 업셀링 시스템 아키텍처
|
||||
|
||||
> 청춘약국 AI 기반 맞춤 제품 추천 시스템의 전체 구조 및 데이터 흐름
|
||||
|
||||
## 개요
|
||||
|
||||
고객이 마일리지를 적립할 때, 실시간으로 AI가 추가 구매 추천을 생성하는 시스템.
|
||||
|
||||
**핵심 특징:**
|
||||
- POS(PIT3000) 판매 데이터 기반 추천
|
||||
- 고객별 구매 이력 분석
|
||||
- 약국 실제 재고(최근 판매 제품) 기반
|
||||
- Clawdbot Gateway를 통한 Claude 연동 (추가 API 비용 없음)
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 다이어그램
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 전체 흐름 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [POS 판매] │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ [MSSQL: PM_PRES] ←─── PIT3000 POS 데이터 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ [키오스크 적립 요청] POST /api/kiosk/claim │
|
||||
│ │ │
|
||||
│ ├──────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ [SQLite: mileage.db] [백그라운드 스레드] │
|
||||
│ - claim_tokens _generate_upsell_recommendation()
|
||||
│ - users │ │
|
||||
│ │ │
|
||||
│ ┌────────────────────┼────────────────┐ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌─────────────────────┐ │ │
|
||||
│ │ │ 데이터 수집 │ │ │
|
||||
│ │ ├─────────────────────┤ │ │
|
||||
│ │ │ 1. 현재 구매 품목 │ │ │
|
||||
│ │ │ 2. 고객 구매 이력 │ │ │
|
||||
│ │ │ 3. 약국 보유 제품 │ │ │
|
||||
│ │ └──────────┬──────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌─────────────────────┐ │ │
|
||||
│ │ │ Clawdbot Gateway │ │ │
|
||||
│ │ │ (WebSocket) │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Model: Sonnet │ │ │
|
||||
│ │ │ (비용 최적화) │ │ │
|
||||
│ │ └──────────┬──────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌─────────────────────┐ │ │
|
||||
│ │ │ Claude AI 응답 │ │ │
|
||||
│ │ │ {product, reason, │ │ │
|
||||
│ │ │ message} │ │ │
|
||||
│ │ └──────────┬──────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ └───────────────────┼─────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ SQLite: ai_recommendations │
|
||||
│ │ - recommended_product │ │
|
||||
│ │ - recommendation_message│ │
|
||||
│ │ - trigger_products │ │
|
||||
│ │ - expires_at │ │
|
||||
│ └──────────┬──────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ 마이페이지 / 키오스크 │ │
|
||||
│ │ 추천 카드 노출 │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터 흐름 상세
|
||||
|
||||
### 1단계: 트리거 (키오스크 적립)
|
||||
|
||||
```python
|
||||
# POST /api/kiosk/claim
|
||||
# 고객이 전화번호로 마일리지 적립 요청
|
||||
|
||||
# 적립 완료 후 백그라운드에서 AI 추천 생성
|
||||
threading.Thread(target=_bg_upsell, daemon=True).start()
|
||||
```
|
||||
|
||||
**포인트:** 적립 응답은 즉시 반환, AI 추천은 백그라운드에서 처리 (non-blocking)
|
||||
|
||||
---
|
||||
|
||||
### 2단계: 데이터 수집
|
||||
|
||||
#### 2-1. 현재 구매 품목
|
||||
|
||||
```python
|
||||
# 키오스크 트리거 시 전달받은 sale_items에서 추출
|
||||
current_items = ', '.join(item['name'] for item in sale_items)
|
||||
# 예: "타이레놀, 판피린, 비타민C"
|
||||
```
|
||||
|
||||
#### 2-2. 고객 구매 이력 (최근 5건)
|
||||
|
||||
```sql
|
||||
-- SQLite: 최근 적립한 거래 ID 조회
|
||||
SELECT ct.transaction_id
|
||||
FROM claim_tokens ct
|
||||
WHERE ct.claimed_by_user_id = ? AND ct.transaction_id != ?
|
||||
ORDER BY ct.claimed_at DESC LIMIT 5
|
||||
|
||||
-- MSSQL: 각 거래의 품목 조회
|
||||
SELECT ISNULL(G.GoodsName, '') AS goods_name
|
||||
FROM SALE_SUB S
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE S.SL_NO_order = :tid
|
||||
```
|
||||
|
||||
#### 2-3. 약국 보유 제품 목록 (TOP 40)
|
||||
|
||||
```sql
|
||||
-- MSSQL: 최근 30일 판매 상위 40개 제품
|
||||
SELECT TOP 40
|
||||
ISNULL(G.GoodsName, '') AS name,
|
||||
COUNT(*) as sales,
|
||||
MAX(G.Saleprice) as price
|
||||
FROM SALE_SUB S
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE S.SL_DT_appl >= CONVERT(VARCHAR(8), DATEADD(DAY, -30, GETDATE()), 112)
|
||||
AND G.GoodsName IS NOT NULL
|
||||
AND G.GoodsName NOT LIKE N'%(판매불가)%'
|
||||
GROUP BY G.GoodsName
|
||||
ORDER BY COUNT(*) DESC
|
||||
```
|
||||
|
||||
**왜 TOP 40?**
|
||||
- AI 컨텍스트 토큰 절약
|
||||
- 실제로 많이 팔리는 제품만 추천 (재고 있음 보장)
|
||||
- 판매불가 제품 자동 제외
|
||||
|
||||
---
|
||||
|
||||
### 3단계: AI 프롬프트 구성
|
||||
|
||||
```python
|
||||
UPSELL_MODEL = 'anthropic/claude-sonnet-4-5' # Opus 대신 Sonnet (비용 최적화)
|
||||
|
||||
SYSTEM_PROMPT = """당신은 동네 약국(청춘약국)의 친절한 약사입니다.
|
||||
고객의 구매 이력을 보고, 약국에 실제로 있는 제품 중에서 하나를 추천합니다.
|
||||
반드시 [약국 보유 제품 목록]에 있는 제품명을 그대로 사용하세요.
|
||||
목록에 없는 제품은 절대 추천하지 마세요.
|
||||
강압적이거나 광고 같은 느낌이 아닌, 진심으로 건강을 걱정하는 약사의 말투로 작성해주세요.
|
||||
반드시 아래 JSON 형식으로만 응답하세요."""
|
||||
|
||||
USER_PROMPT = f"""고객 이름: {user_name}
|
||||
오늘 구매한 약: {current_items}
|
||||
최근 구매 이력: {recent_products}
|
||||
|
||||
[약국 보유 제품 목록 — 이 중에서만 추천하세요]
|
||||
{product_list}
|
||||
|
||||
규칙:
|
||||
1. 위 목록에 있는 제품 중 오늘 구매한 약과 함께 먹으면 좋거나, 구매 패턴상 필요해보이는 약 1가지만 추천
|
||||
2. 오늘 이미 구매한 제품은 추천하지 마세요
|
||||
3. 메시지는 2문장 이내, 따뜻하고 자연스러운 톤
|
||||
4. product 필드에는 목록에 있는 제품명을 정확히 그대로 적어주세요
|
||||
|
||||
응답 JSON:
|
||||
{{"product": "목록에 있는 정확한 제품명", "reason": "추천 이유 (내부용)", "message": "고객용 메시지"}}"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4단계: AI 응답 및 저장
|
||||
|
||||
```json
|
||||
// Claude 응답 예시
|
||||
{
|
||||
"product": "종근당 비타민D 1000IU",
|
||||
"reason": "감기약과 함께 면역력 강화에 도움",
|
||||
"message": "홍길동님, 감기약 드시면서 비타민D도 같이 챙기시면 회복에 도움이 되실 거예요. 요즘 일조량 적을 때 특히 좋답니다."
|
||||
}
|
||||
```
|
||||
|
||||
```sql
|
||||
-- SQLite: ai_recommendations 테이블에 저장
|
||||
INSERT INTO ai_recommendations
|
||||
(user_id, transaction_id, recommended_product, recommendation_message,
|
||||
recommendation_reason, trigger_products, ai_raw_response, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5단계: 추천 노출
|
||||
|
||||
```
|
||||
GET /api/recommendation/{user_id}
|
||||
```
|
||||
|
||||
- 마이페이지에서 조회
|
||||
- 키오스크에서 적립 직후 표시
|
||||
- 7일 후 만료 (expires_at)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 쿼리 정리
|
||||
|
||||
| 용도 | DB | 쿼리 |
|
||||
|------|-----|------|
|
||||
| 고객 최근 거래 | SQLite | `claim_tokens WHERE claimed_by_user_id = ?` |
|
||||
| 거래별 품목 | MSSQL | `SALE_SUB JOIN CD_GOODS WHERE SL_NO_order = ?` |
|
||||
| 보유 제품 TOP 40 | MSSQL | `SALE_SUB GROUP BY GoodsName ORDER BY COUNT DESC` |
|
||||
| 추천 저장 | SQLite | `INSERT INTO ai_recommendations` |
|
||||
| 추천 조회 | SQLite | `SELECT FROM ai_recommendations WHERE user_id = ?` |
|
||||
|
||||
---
|
||||
|
||||
## 비용 최적화 전략
|
||||
|
||||
### 1. 모델 선택
|
||||
|
||||
```python
|
||||
# 업셀링은 Sonnet (빠르고 저렴)
|
||||
UPSELL_MODEL = 'anthropic/claude-sonnet-4-5'
|
||||
|
||||
# 복잡한 분석은 Opus (메인 세션)
|
||||
# sessions.patch로 세션별 모델 오버라이드
|
||||
```
|
||||
|
||||
### 2. 토큰 절약
|
||||
|
||||
- 보유 제품 TOP 40개만 전달 (전체 재고 X)
|
||||
- 시스템 프롬프트 간결하게
|
||||
- JSON 응답 강제 (불필요한 설명 제거)
|
||||
|
||||
### 3. 세션 분리
|
||||
|
||||
```python
|
||||
# 고객별 세션 분리 → 컨텍스트 축적 방지
|
||||
session_id = f'upsell-real-{user_name}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fallback 전략
|
||||
|
||||
```python
|
||||
# 1차 시도: 실데이터 기반 (보유 제품 목록 제공)
|
||||
rec = generate_upsell_real(user_name, current_items, recent_products, available)
|
||||
|
||||
# 2차 시도: 자유 생성 (보유 제품 목록 없이)
|
||||
if not rec:
|
||||
rec = generate_upsell(user_name, current_items, recent_products)
|
||||
```
|
||||
|
||||
**왜 Fallback?**
|
||||
- MSSQL 연결 실패 시에도 추천 가능
|
||||
- 보유 제품 쿼리 실패해도 서비스 지속
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
```
|
||||
pharmacy-pos-qr-system/
|
||||
├── backend/
|
||||
│ ├── app.py
|
||||
│ │ ├── _get_available_products() # 보유 제품 조회
|
||||
│ │ ├── _generate_upsell_recommendation() # 메인 로직
|
||||
│ │ └── /api/recommendation/{user_id} # 추천 조회 API
|
||||
│ │
|
||||
│ ├── services/
|
||||
│ │ └── clawdbot_client.py
|
||||
│ │ ├── generate_upsell() # 자유 생성
|
||||
│ │ ├── generate_upsell_real() # 실데이터 기반
|
||||
│ │ └── ask_clawdbot() # Gateway 호출
|
||||
│ │
|
||||
│ ├── templates/
|
||||
│ │ └── admin_ai_crm.html # CRM 관리 페이지
|
||||
│ │
|
||||
│ └── db/
|
||||
│ └── mileage.db # SQLite (ai_recommendations)
|
||||
│
|
||||
└── docs/
|
||||
├── ai-upselling-architecture.md # 이 문서
|
||||
└── clawdbot-gateway-api.md # Gateway 연동 가이드
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 향후 개선 방향
|
||||
|
||||
### 1. 추천 정확도 향상
|
||||
- 제품 카테고리 분류 추가 (감기약, 영양제, 외용제 등)
|
||||
- 계절/시간대별 추천 가중치
|
||||
- 고객 연령대/성별 기반 필터
|
||||
|
||||
### 2. 성과 측정
|
||||
- 추천 → 실제 구매 전환율 추적
|
||||
- A/B 테스트 (추천 vs 비추천)
|
||||
- 인기 추천 제품 통계
|
||||
|
||||
### 3. 실시간 재고 연동
|
||||
- 현재: 최근 30일 판매 기준 (간접 재고)
|
||||
- 개선: 실제 재고 수량 기반 추천
|
||||
|
||||
### 4. 멀티 추천
|
||||
- 현재: 1개 제품만 추천
|
||||
- 개선: 상황별 2-3개 옵션 제시
|
||||
|
||||
---
|
||||
|
||||
*작성: 2026-02-27 | 용림 🐉*
|
||||
173
docs/ai-upselling-crm.md
Normal file
173
docs/ai-upselling-crm.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# AI 업셀링 CRM — 마이페이지 맞춤 추천 시스템
|
||||
|
||||
## 개요
|
||||
키오스크 적립 시 고객 구매이력을 AI가 분석하여 맞춤 제품을 추천.
|
||||
고객이 알림톡 → 마이페이지 접속 시 바텀시트 팝업으로 자연스럽게 표시.
|
||||
|
||||
## 기술 스택
|
||||
- **AI 엔진**: Clawdbot Gateway (Claude Max 구독 재활용, 추가 비용 없음)
|
||||
- **통신**: WebSocket (`ws://127.0.0.1:18789`) — JSON-RPC 프로토콜
|
||||
- **저장소**: SQLite `ai_recommendations` 테이블
|
||||
- **프론트**: 바텀시트 UI (드래그 닫기 지원)
|
||||
|
||||
## 전체 흐름
|
||||
|
||||
```
|
||||
키오스크 적립 (POST /api/kiosk/claim)
|
||||
│
|
||||
├─ 1. 적립 처리 (기존)
|
||||
├─ 2. 알림톡 발송 (기존)
|
||||
└─ 3. AI 추천 생성 (fire-and-forget)
|
||||
│
|
||||
├─ 최근 구매 이력 수집 (SQLite + MSSQL SALE_SUB)
|
||||
├─ Clawdbot Gateway → Claude 호출
|
||||
├─ 추천 결과 → ai_recommendations 저장
|
||||
└─ 실패 시 무시 (추천은 부가 기능)
|
||||
|
||||
고객: 알림톡 버튼 클릭 → /my-page
|
||||
│
|
||||
├─ 1.5초 후 GET /api/recommendation/{user_id}
|
||||
│
|
||||
├─ 추천 있음 → 바텀시트 슬라이드업
|
||||
│ ├─ 아래로 드래그 → 닫기
|
||||
│ ├─ "다음에요" → dismiss
|
||||
│ └─ "관심있어요!" → dismiss + 기록
|
||||
│
|
||||
└─ 추천 없음 → 아무것도 안 뜸
|
||||
```
|
||||
|
||||
## 핵심 파일
|
||||
|
||||
### `backend/services/clawdbot_client.py`
|
||||
Clawdbot Gateway Python 클라이언트.
|
||||
|
||||
**Gateway WebSocket 프로토콜 (v3):**
|
||||
1. WS 연결 → `ws://127.0.0.1:{port}`
|
||||
2. 서버 → `connect.challenge` 이벤트 (nonce 전달)
|
||||
3. 클라이언트 → `connect` 요청 (token + client info)
|
||||
4. 서버 → connect 응답 (ok)
|
||||
5. 클라이언트 → `agent` 요청 (message + systemPrompt)
|
||||
6. 서버 → `accepted` ack → 최종 응답 (`payloads[].text`)
|
||||
|
||||
**주요 함수:**
|
||||
| 함수 | 설명 |
|
||||
|------|------|
|
||||
| `_load_gateway_config()` | `~/.clawdbot/clawdbot.json`에서 port, token 읽기 |
|
||||
| `_ask_gateway(message, ...)` | async WebSocket 통신 |
|
||||
| `ask_clawdbot(message, ...)` | 동기 래퍼 (Flask에서 호출) |
|
||||
| `generate_upsell(user_name, current_items, recent_products)` | 업셀 프롬프트 구성 + 호출 + JSON 파싱 |
|
||||
| `_parse_upsell_response(text)` | AI 응답에서 JSON 추출 |
|
||||
|
||||
**Gateway 설정:**
|
||||
- 설정 파일: `~/.clawdbot/clawdbot.json`
|
||||
- Client ID: `gateway-client` (허용된 상수 중 하나)
|
||||
- Protocol: v3 (minProtocol=3, maxProtocol=3)
|
||||
|
||||
### `backend/db/mileage_schema.sql` — ai_recommendations 테이블
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
transaction_id VARCHAR(20),
|
||||
recommended_product TEXT NOT NULL, -- "고려은단 비타민C 1000"
|
||||
recommendation_message TEXT NOT NULL, -- 고객에게 보여줄 메시지
|
||||
recommendation_reason TEXT, -- 내부용 추천 이유
|
||||
trigger_products TEXT, -- JSON: 트리거된 구매 품목
|
||||
ai_raw_response TEXT, -- AI 원본 응답
|
||||
status VARCHAR(20) DEFAULT 'active', -- active/dismissed
|
||||
displayed_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME, -- 7일 후 만료
|
||||
displayed_at DATETIME,
|
||||
dismissed_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
```
|
||||
|
||||
### `backend/app.py` — API 엔드포인트
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|-----------|--------|------|
|
||||
| `/api/recommendation/<user_id>` | GET | 최신 active 추천 조회 (마이페이지용) |
|
||||
| `/api/recommendation/<rec_id>/dismiss` | POST | 추천 닫기 (status→dismissed) |
|
||||
|
||||
**추천 생성 위치**: `api_kiosk_claim()` 함수 끝부분, `_generate_upsell_recommendation()` 호출
|
||||
|
||||
### `backend/templates/my_page.html` — 바텀시트 UI
|
||||
|
||||
**기능:**
|
||||
- 페이지 로드 1.5초 후 추천 API fetch
|
||||
- 💊 아이콘 + AI 메시지 + 제품명 배지 (보라색 그라디언트)
|
||||
- **터치 드래그 닫기**: 아래로 80px 이상 드래그하면 dismiss
|
||||
- 배경 탭 닫기, "다음에요"/"관심있어요!" 버튼
|
||||
- 슬라이드업/다운 CSS 애니메이션
|
||||
|
||||
## AI 프롬프트
|
||||
|
||||
**시스템 프롬프트:**
|
||||
```
|
||||
당신은 동네 약국(청춘약국)의 친절한 약사입니다.
|
||||
고객의 구매 이력을 보고, 자연스럽고 따뜻한 톤으로 약 하나를 추천합니다.
|
||||
반드시 JSON 형식으로만 응답하세요.
|
||||
```
|
||||
|
||||
**유저 프롬프트 구조:**
|
||||
```
|
||||
고객 이름: {name}
|
||||
오늘 구매한 약: {current_items}
|
||||
최근 구매 이력: {recent_products}
|
||||
|
||||
규칙:
|
||||
1. 함께 먹으면 좋은 약 1가지만 추천 (일반의약품/건강기능식품)
|
||||
2. 메시지 2문장 이내, 따뜻한 톤
|
||||
3. JSON: {"product": "...", "reason": "...", "message": "..."}
|
||||
```
|
||||
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"product": "고려은단 비타민C 1000",
|
||||
"reason": "감기약 구매로 면역력 보충 필요",
|
||||
"message": "김영빈님, 감기약 드시는 동안 비타민C도 함께 챙겨드시면 회복에 도움이 돼요."
|
||||
}
|
||||
```
|
||||
|
||||
## Fallback 정책
|
||||
|
||||
| 상황 | 동작 |
|
||||
|------|------|
|
||||
| Gateway 꺼져있음 | 추천 생성 스킵, 로그만 남김 |
|
||||
| AI 응답 파싱 실패 | 저장 안 함 |
|
||||
| 추천 없을 때 마이페이지 방문 | 바텀시트 안 뜸 |
|
||||
| 7일 경과 | `expires_at` 만료, 조회 안 됨 |
|
||||
| dismiss 후 재방문 | 같은 추천 안 뜸 (새 적립 시 새 추천 생성) |
|
||||
|
||||
## 테스트
|
||||
|
||||
```bash
|
||||
# 1. Gateway 연결 테스트
|
||||
PYTHONIOENCODING=utf-8 python -c "
|
||||
from services.clawdbot_client import ask_clawdbot
|
||||
print(ask_clawdbot('안녕'))
|
||||
"
|
||||
|
||||
# 2. 업셀 생성 테스트
|
||||
PYTHONIOENCODING=utf-8 python -c "
|
||||
import json
|
||||
from services.clawdbot_client import generate_upsell
|
||||
result = generate_upsell('홍길동', '타이레놀, 챔프시럽', '비타민C, 소화제')
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
"
|
||||
|
||||
# 3. API 테스트
|
||||
curl https://mile.0bin.in/api/recommendation/1
|
||||
|
||||
# 4. DB 확인
|
||||
python -c "
|
||||
import sqlite3, json
|
||||
conn = sqlite3.connect('db/mileage.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
for r in conn.execute('SELECT * FROM ai_recommendations ORDER BY id DESC LIMIT 5'):
|
||||
print(json.dumps(dict(r), ensure_ascii=False))
|
||||
"
|
||||
```
|
||||
186
docs/alimipharm-set-product-structure.md
Normal file
186
docs/alimipharm-set-product-structure.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# 알리미팜 세트 상품 구조 (PIT3000)
|
||||
|
||||
> 작성일: 2026-02-27
|
||||
> 약국: 양구청춘약국
|
||||
|
||||
## 개요
|
||||
|
||||
PIT3000(팜잇3000) DB는 세트 상품을 기본적으로 잘 처리하지 못하게 설계되어 있다.
|
||||
하지만 알리미팜에서는 세트 상품을 등록하고 **자체 바코드**를 생성하여 사용한다.
|
||||
|
||||
이 문서는 세트 상품의 DB 구조와 바코드 조회 방법을 정리한 참고 문서이다.
|
||||
|
||||
---
|
||||
|
||||
## 테이블 구조
|
||||
|
||||
### 1. CD_GOODS (기본 상품 테이블)
|
||||
```
|
||||
Database: PM_DRUG
|
||||
Table: CD_GOODS
|
||||
```
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| DrugCode | 상품 코드 (PK) |
|
||||
| GoodsName | 상품명 |
|
||||
| BARCODE | **바코드** (세트상품은 대부분 비어있음!) |
|
||||
| SplName | 공급업체 |
|
||||
| Saleprice | 판매가 |
|
||||
| Price | 매입가 |
|
||||
|
||||
⚠️ **주의**: 세트 상품의 경우 `BARCODE` 컬럼이 비어있는 경우가 많음!
|
||||
|
||||
---
|
||||
|
||||
### 2. CD_ITEM_UNIT_MEMBER (단위/바코드 확장 테이블) ⭐
|
||||
```
|
||||
Database: PM_DRUG
|
||||
Table: CD_ITEM_UNIT_MEMBER
|
||||
```
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| DRUGCODE | 상품 코드 (FK → CD_GOODS.DrugCode) |
|
||||
| CD_CD_UNIT | 단위 코드 |
|
||||
| CD_NM_UNIT | 단위 수량 |
|
||||
| CD_MY_UNIT | 판매가 |
|
||||
| CD_IN_UNIT | 매입가 |
|
||||
| **CD_CD_BARCODE** | **세트상품 바코드** ⭐ |
|
||||
| CD_CD_POS | POS 코드 |
|
||||
| CHANGE_DATE | 변경일 |
|
||||
|
||||
✅ **핵심**: 세트 상품/자체 등록 상품의 바코드는 이 테이블의 `CD_CD_BARCODE`에 저장됨!
|
||||
|
||||
---
|
||||
|
||||
### 3. CD_item_set (세트 구성품 테이블)
|
||||
```
|
||||
Database: PM_DRUG
|
||||
Table: CD_item_set
|
||||
```
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| SetCode | 세트 상품 코드 (FK → CD_GOODS.DrugCode) |
|
||||
| DrugCode | 구성품 코드 ('SET0000' = 헤더, 그 외 = 구성품) |
|
||||
| CD_NM_UNIT | 구성품 수량 |
|
||||
|
||||
**구조 예시 (투엑스벤포파워 LB000003181):**
|
||||
```
|
||||
SetCode | DrugCode | CD_NM_UNIT
|
||||
--------------|---------------|------------
|
||||
LB000003181 | SET0000 | NULL ← 세트 헤더
|
||||
LB000003181 | LB000003324 | 1.0 ← 구성품 1
|
||||
LB000003181 | LB000001423 | 1.0 ← 구성품 2 (벤포파워Z)
|
||||
LB000003181 | LB000001412 | 1.0 ← 구성품 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. CD_item_pack / CD_ITEM_PACK_UNIT
|
||||
```
|
||||
Database: PM_DRUG
|
||||
```
|
||||
포장 단위 관련 테이블. 굿팜/알리미팜 처리 방식이 다를 수 있음.
|
||||
|
||||
---
|
||||
|
||||
## 바코드 조회 쿼리
|
||||
|
||||
### 세트 상품 바코드까지 포함한 조회
|
||||
```sql
|
||||
SELECT
|
||||
S.DrugCode,
|
||||
G.GoodsName,
|
||||
-- CD_GOODS.BARCODE가 없으면 CD_ITEM_UNIT_MEMBER.CD_CD_BARCODE 사용
|
||||
COALESCE(NULLIF(G.BARCODE, ''), U.CD_CD_BARCODE, '') as barcode
|
||||
FROM SALE_SUB S
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 CD_CD_BARCODE
|
||||
FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = S.DrugCode
|
||||
AND CD_CD_BARCODE IS NOT NULL
|
||||
AND CD_CD_BARCODE != ''
|
||||
) U
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 세트 상품 바코드 패턴
|
||||
|
||||
| 패턴 | 설명 |
|
||||
|------|------|
|
||||
| `999XXXXXXXXX` | 알리미팜 자체 생성 바코드 (세트/자체등록) |
|
||||
| `880XXXXXXXXX` | 일반 제조사 바코드 |
|
||||
|
||||
예시:
|
||||
- `9990000001101` - 투엑스벤포파워 (세트상품)
|
||||
- `8806418067510` - 벤포파워Z (일반상품)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 마진 계산 시 주의사항
|
||||
|
||||
### 세트 상품 마진 계산의 복잡성
|
||||
|
||||
세트 상품의 **실제 마진**을 계산하려면 **구성품을 물고 들어가서** 각 구성품의 매입가를 합산해야 한다!
|
||||
|
||||
```
|
||||
세트 판매가: 9,000원
|
||||
세트 매입가(CD_GOODS.Price): 3,300원 ← 이건 정확하지 않을 수 있음!
|
||||
|
||||
실제 계산 필요:
|
||||
├── 구성품1 매입가: 1,500원
|
||||
├── 구성품2 매입가: 1,200원
|
||||
└── 구성품3 매입가: 800원
|
||||
────────────────────
|
||||
실제 매입가 합계: 3,500원
|
||||
실제 마진: 9,000 - 3,500 = 5,500원
|
||||
```
|
||||
|
||||
### 마진 계산 쿼리 예시 (향후 개발용)
|
||||
```sql
|
||||
-- 세트 상품의 실제 매입가 계산
|
||||
SELECT
|
||||
S.SetCode,
|
||||
G1.GoodsName as set_name,
|
||||
G1.Saleprice as set_sale_price,
|
||||
SUM(G2.Price * S.CD_NM_UNIT) as actual_cost
|
||||
FROM CD_item_set S
|
||||
JOIN CD_GOODS G1 ON S.SetCode = G1.DrugCode
|
||||
JOIN CD_GOODS G2 ON S.DrugCode = G2.DrugCode
|
||||
WHERE S.DrugCode != 'SET0000' -- 헤더 제외
|
||||
GROUP BY S.SetCode, G1.GoodsName, G1.Saleprice
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 테이블 요약
|
||||
|
||||
| 테이블 | 데이터베이스 | 용도 |
|
||||
|--------|-------------|------|
|
||||
| CD_GOODS | PM_DRUG | 기본 상품 정보 |
|
||||
| CD_ITEM_UNIT_MEMBER | PM_DRUG | 단위별 바코드 (세트 바코드 저장) |
|
||||
| CD_item_set | PM_DRUG | 세트 구성품 매핑 |
|
||||
| CD_item_pack | PM_DRUG | 포장 단위 |
|
||||
| CD_BARCODE | PM_DRUG | 표준코드 매핑 |
|
||||
| SALE_SUB | PM_PRES | 판매 상세 |
|
||||
| SALE_MAIN | PM_PRES | 판매 헤더 |
|
||||
|
||||
---
|
||||
|
||||
## 히스토리
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-02-27 | 세트상품 바코드 조회 문제 해결 (`CD_ITEM_UNIT_MEMBER` 연동) |
|
||||
| 2026-02-27 | 바코드 매핑률 89.8% → 99.8% 개선 |
|
||||
|
||||
---
|
||||
|
||||
## 참고
|
||||
|
||||
- PIT3000 DB 서버: `192.168.0.4\PM2014`
|
||||
- 굿팜 vs 알리미팜: 세트 처리 방식이 다를 수 있음 (확인 필요)
|
||||
342
docs/clawdbot-gateway-api.md
Normal file
342
docs/clawdbot-gateway-api.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# Clawdbot Gateway WebSocket API 가이드
|
||||
|
||||
> 외부 애플리케이션에서 Clawdbot Gateway에 연결하여 AI 호출 또는 상태 조회하는 방법
|
||||
|
||||
## 개요
|
||||
|
||||
Clawdbot Gateway는 WebSocket API를 제공합니다. 이를 통해:
|
||||
- **AI 호출** (`agent` 메서드) — Claude/GPT 등 모델에 질문 (토큰 소비)
|
||||
- **상태 조회** (`sessions.list` 등) — 세션 정보 조회 (토큰 무소비)
|
||||
- **세션 설정** (`sessions.patch`) — 모델 오버라이드 등
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────┐ WebSocket ┌─────────────────┐
|
||||
│ Flask 서버 │ ◄─────────────────► │ Clawdbot Gateway│
|
||||
│ (pharmacy-pos) │ Port 18789 │ (localhost) │
|
||||
└─────────────────┘ └────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Claude / GPT │
|
||||
│ (Providers) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 설정 파일 위치
|
||||
|
||||
Gateway 설정은 `~/.clawdbot/clawdbot.json`에 있음:
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"port": 18789,
|
||||
"auth": {
|
||||
"mode": "token",
|
||||
"token": "your-gateway-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 연결 프로토콜 (Python)
|
||||
|
||||
### 1. 기본 연결 흐름
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
import websockets
|
||||
|
||||
async def connect_to_gateway():
|
||||
config = load_gateway_config() # ~/.clawdbot/clawdbot.json 읽기
|
||||
url = f"ws://127.0.0.1:{config['port']}"
|
||||
token = config['token']
|
||||
|
||||
async with websockets.connect(url) as ws:
|
||||
# 1단계: challenge 수신
|
||||
challenge = json.loads(await ws.recv())
|
||||
# {'event': 'connect.challenge', 'payload': {'nonce': '...'}}
|
||||
|
||||
# 2단계: connect 요청
|
||||
connect_frame = {
|
||||
'type': 'req',
|
||||
'id': str(uuid.uuid4()),
|
||||
'method': 'connect',
|
||||
'params': {
|
||||
'minProtocol': 3,
|
||||
'maxProtocol': 3,
|
||||
'client': {
|
||||
'id': 'gateway-client', # 고정값
|
||||
'displayName': 'My App',
|
||||
'version': '1.0.0',
|
||||
'platform': 'win32',
|
||||
'mode': 'backend', # 고정값
|
||||
'instanceId': str(uuid.uuid4()),
|
||||
},
|
||||
'caps': [],
|
||||
'auth': {'token': token},
|
||||
'role': 'operator',
|
||||
'scopes': ['operator.admin'], # 또는 ['operator.read']
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(connect_frame))
|
||||
|
||||
# 3단계: connect 응답 대기
|
||||
while True:
|
||||
msg = json.loads(await ws.recv())
|
||||
if msg.get('id') == connect_frame['id']:
|
||||
if msg.get('ok'):
|
||||
print("연결 성공!")
|
||||
break
|
||||
else:
|
||||
print(f"연결 실패: {msg.get('error')}")
|
||||
return
|
||||
|
||||
# 이제 다른 메서드 호출 가능
|
||||
# ...
|
||||
```
|
||||
|
||||
### 2. 주의사항: client 파라미터
|
||||
|
||||
⚠️ **중요**: `client.id`와 `client.mode`는 Gateway 스키마에 정의된 값만 허용됨
|
||||
|
||||
| 필드 | 허용되는 값 | 설명 |
|
||||
|------|-------------|------|
|
||||
| `client.id` | `'gateway-client'` | 백엔드 클라이언트용 |
|
||||
| `client.mode` | `'backend'` | 백엔드 모드 |
|
||||
| `role` | `'operator'` | 제어 클라이언트 |
|
||||
| `scopes` | `['operator.admin']` 또는 `['operator.read']` | 권한 범위 |
|
||||
|
||||
잘못된 값 사용 시 에러:
|
||||
```
|
||||
invalid connect params: at /client/id: must be equal to constant
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 메서드 종류
|
||||
|
||||
### 토큰 소비 없는 메서드 (관리용)
|
||||
|
||||
| 메서드 | 용도 | 파라미터 |
|
||||
|--------|------|----------|
|
||||
| `sessions.list` | 세션 목록 조회 | `{limit: 10}` |
|
||||
| `sessions.patch` | 세션 설정 변경 | `{key: '...', model: '...'}` |
|
||||
|
||||
### 토큰 소비하는 메서드 (AI 호출)
|
||||
|
||||
| 메서드 | 용도 | 파라미터 |
|
||||
|--------|------|----------|
|
||||
| `agent` | AI에게 질문 | `{message: '...', sessionId: '...'}` |
|
||||
|
||||
---
|
||||
|
||||
## 실제 구현 예제
|
||||
|
||||
### 예제 1: 상태 조회 (토큰 0)
|
||||
|
||||
```python
|
||||
# services/clawdbot_client.py 참고
|
||||
|
||||
async def _get_gateway_status():
|
||||
"""세션 목록 조회 — 토큰 소비 없음"""
|
||||
# ... (연결 코드 생략)
|
||||
|
||||
# sessions.list 요청
|
||||
list_frame = {
|
||||
'type': 'req',
|
||||
'id': str(uuid.uuid4()),
|
||||
'method': 'sessions.list',
|
||||
'params': {'limit': 10}
|
||||
}
|
||||
await ws.send(json.dumps(list_frame))
|
||||
|
||||
# 응답 대기
|
||||
while True:
|
||||
msg = json.loads(await ws.recv())
|
||||
if msg.get('event'): # 이벤트는 무시
|
||||
continue
|
||||
if msg.get('id') == list_frame['id']:
|
||||
return msg.get('payload', {})
|
||||
```
|
||||
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"key": "agent:main:main",
|
||||
"totalTokens": 30072,
|
||||
"contextTokens": 200000,
|
||||
"model": "claude-opus-4-5"
|
||||
}
|
||||
],
|
||||
"defaults": {
|
||||
"model": "claude-opus-4-5",
|
||||
"contextTokens": 200000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 예제 2: AI 호출 (토큰 소비)
|
||||
|
||||
```python
|
||||
async def ask_ai(message, session_id='my-session', model=None):
|
||||
"""AI에게 질문 — 토큰 소비함"""
|
||||
# ... (연결 코드)
|
||||
|
||||
# 모델 오버라이드 (선택)
|
||||
if model:
|
||||
patch_frame = {
|
||||
'type': 'req',
|
||||
'id': str(uuid.uuid4()),
|
||||
'method': 'sessions.patch',
|
||||
'params': {'key': session_id, 'model': model}
|
||||
}
|
||||
await ws.send(json.dumps(patch_frame))
|
||||
# 응답 대기...
|
||||
|
||||
# agent 요청
|
||||
agent_frame = {
|
||||
'type': 'req',
|
||||
'id': str(uuid.uuid4()),
|
||||
'method': 'agent',
|
||||
'params': {
|
||||
'message': message,
|
||||
'sessionId': session_id,
|
||||
'sessionKey': session_id,
|
||||
'timeout': 60,
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(agent_frame))
|
||||
|
||||
# 응답 대기 (accepted → final)
|
||||
while True:
|
||||
msg = json.loads(await ws.recv())
|
||||
if msg.get('event'):
|
||||
continue
|
||||
if msg.get('id') == agent_frame['id']:
|
||||
if msg.get('payload', {}).get('status') == 'accepted':
|
||||
continue # 아직 처리 중
|
||||
# 최종 응답
|
||||
payloads = msg.get('payload', {}).get('result', {}).get('payloads', [])
|
||||
return '\n'.join(p.get('text', '') for p in payloads)
|
||||
```
|
||||
|
||||
### 예제 3: 모델 오버라이드
|
||||
|
||||
비싼 Opus 대신 저렴한 Sonnet 사용:
|
||||
|
||||
```python
|
||||
UPSELL_MODEL = 'anthropic/claude-sonnet-4-5'
|
||||
|
||||
response = await ask_ai(
|
||||
message="추천 멘트 만들어줘",
|
||||
session_id='upsell-customer1',
|
||||
model=UPSELL_MODEL # Sonnet으로 오버라이드
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flask API 엔드포인트 예제
|
||||
|
||||
```python
|
||||
# app.py
|
||||
|
||||
@app.route('/api/claude-status')
|
||||
def api_claude_status():
|
||||
"""토큰 차감 없이 상태 조회"""
|
||||
from services.clawdbot_client import get_claude_status
|
||||
|
||||
status = get_claude_status()
|
||||
|
||||
if not status.get('connected'):
|
||||
return jsonify({'ok': False, 'error': status.get('error')}), 503
|
||||
|
||||
sessions = status.get('sessions', {})
|
||||
# ... 데이터 가공
|
||||
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'context': {'used': 30000, 'max': 200000, 'percent': 15},
|
||||
'model': 'claude-opus-4-5'
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 토큰 관리 전략
|
||||
|
||||
### 모델별 용도 분리
|
||||
|
||||
| 용도 | 모델 | 이유 |
|
||||
|------|------|------|
|
||||
| 메인 컨트롤러 | Claude Opus | 복잡한 추론, 도구 사용 |
|
||||
| 단순 생성 (업셀링 등) | Claude Sonnet | 빠르고 저렴 |
|
||||
| 코딩 작업 | GPT-5 Codex | 정식 지원, 안정적 |
|
||||
|
||||
### 세션 분리
|
||||
|
||||
```python
|
||||
# 용도별 세션 ID 분리
|
||||
ask_ai("...", session_id='upsell-고객명') # 업셀링 전용
|
||||
ask_ai("...", session_id='analysis-daily') # 분석 전용
|
||||
ask_ai("...", session_id='chat-main') # 일반 대화
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### 1. "invalid connect params" 에러
|
||||
|
||||
```
|
||||
at /client/id: must be equal to constant
|
||||
at /client/mode: must be equal to constant
|
||||
```
|
||||
|
||||
**해결**: `client.id`는 `'gateway-client'`, `client.mode`는 `'backend'` 사용
|
||||
|
||||
### 2. Gateway 연결 실패
|
||||
|
||||
```python
|
||||
ConnectionRefusedError: [WinError 10061]
|
||||
```
|
||||
|
||||
**해결**: Clawdbot Gateway가 실행 중인지 확인
|
||||
```bash
|
||||
clawdbot gateway status
|
||||
```
|
||||
|
||||
### 3. CLI 명령어가 hang됨
|
||||
|
||||
Clawdbot 내부(agent 세션)에서 `clawdbot status` 같은 CLI 호출하면 충돌.
|
||||
→ WebSocket API 직접 사용할 것
|
||||
|
||||
---
|
||||
|
||||
## 파일 위치
|
||||
|
||||
```
|
||||
pharmacy-pos-qr-system/
|
||||
└── backend/
|
||||
└── services/
|
||||
└── clawdbot_client.py # Gateway 클라이언트 구현
|
||||
└── app.py # Flask API (/api/claude-status)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- Clawdbot 문서: `C:\Users\청춘약국\AppData\Roaming\npm\node_modules\clawdbot\docs\`
|
||||
- Gateway 프로토콜: `docs/gateway/protocol.md`
|
||||
- 설정 예제: `docs/gateway/configuration-examples.md`
|
||||
|
||||
---
|
||||
|
||||
*작성: 2026-02-27 | 용림 🐉*
|
||||
558
docs/il1beta-food-graphrag-guide.md
Normal file
558
docs/il1beta-food-graphrag-guide.md
Normal file
@@ -0,0 +1,558 @@
|
||||
# IL-1β 식품 GraphRAG 통합 가이드
|
||||
|
||||
**작성일**: 2026-02-04
|
||||
**목적**: 염증성 사이토카인 IL-1β 증가/감소 식품 데이터를 GraphRAG에 통합하여 근거 기반 영양 상담 시스템 구축
|
||||
|
||||
---
|
||||
|
||||
## 📋 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [시스템 구조](#시스템-구조)
|
||||
3. [설치 및 설정](#설치-및-설정)
|
||||
4. [데이터 모델](#데이터-모델)
|
||||
5. [Cypher 쿼리 예시](#cypher-쿼리-예시)
|
||||
6. [API 활용](#api-활용)
|
||||
7. [약국 활용 시나리오](#약국-활용-시나리오)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
### 🎯 목표
|
||||
|
||||
PubMed 논문 근거를 기반으로 **IL-1β(Interleukin-1 beta)**를 증가/감소시키는 식품 정보를 GraphRAG에 통합하여:
|
||||
|
||||
- ✅ 만성 염증 환자에게 **피해야 할 식품** 자동 추천
|
||||
- ✅ 질병별(NAFLD, 관절염 등) **맞춤 식이 지도**
|
||||
- ✅ **항염증 보충제** 업셀링
|
||||
- ✅ **PubMed 근거** 제시로 신뢰도 향상
|
||||
|
||||
### 🔬 IL-1β란?
|
||||
|
||||
| 항목 | 설명 |
|
||||
|------|------|
|
||||
| **정의** | Interleukin-1 beta, 대표적인 염증성 사이토카인 |
|
||||
| **정상 범위** | 0-5 pg/mL |
|
||||
| **역할** | 면역 반응, 염증 유발 |
|
||||
| **관련 질병** | NAFLD, 죽상동맥경화, 관절염, 만성 염증 |
|
||||
|
||||
---
|
||||
|
||||
## 시스템 구조
|
||||
|
||||
### 전체 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ PubMed 논문 근거 │
|
||||
│ - 고지방식 → IL-1β 증가 (PMID) │
|
||||
│ - 오메가-3 → IL-1β 감소 (PMID) │
|
||||
└────────────┬────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ PostgreSQL + Apache AGE │
|
||||
├─────────────────────────────────────┤
|
||||
│ SQL 테이블: │
|
||||
│ - foods (식품 마스터) │
|
||||
│ - biomarkers (바이오마커) │
|
||||
│ - food_biomarker_effects (관계) │
|
||||
│ - disease_biomarker_association │
|
||||
├─────────────────────────────────────┤
|
||||
│ 그래프 (Cypher): │
|
||||
│ 노드: Food, Biomarker, Disease │
|
||||
│ 관계: INCREASES, DECREASES, │
|
||||
│ ASSOCIATED_WITH │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ Flask API + 약국 웹앱 │
|
||||
│ - 질병별 피해야 할 식품 조회 │
|
||||
│ - 항염증 보충제 추천 │
|
||||
│ - 영양 상담 리포트 생성 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 설치 및 설정
|
||||
|
||||
### 1단계: PostgreSQL 스키마 생성
|
||||
|
||||
```bash
|
||||
cd backend/db
|
||||
|
||||
# PostgreSQL 접속
|
||||
psql -U postgres -d pharmacy_db
|
||||
|
||||
# 스키마 실행
|
||||
\i schema_food_biomarker.sql
|
||||
```
|
||||
|
||||
**결과:**
|
||||
```
|
||||
✅ 식품-바이오마커 스키마 확장 완료
|
||||
- foods 테이블: 10개 식품 샘플 데이터
|
||||
- biomarkers 테이블: 6개 바이오마커
|
||||
- food_biomarker_effects 테이블: 5개 관계
|
||||
- v_il1beta_increasing_foods 뷰 생성
|
||||
```
|
||||
|
||||
### 2단계: 데이터 입력
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# IL-1β 식품 데이터 입력
|
||||
python import_il1beta_foods.py
|
||||
```
|
||||
|
||||
**결과:**
|
||||
```
|
||||
📥 IL-1β 증가 식품 데이터 입력 중...
|
||||
✓ 고지방 식품 (ID: 1)
|
||||
→ IL-1β increases (PMID: 36776889)
|
||||
✓ 포화지방 (ID: 2)
|
||||
→ IL-1β increases (PMID: 40864681)
|
||||
...
|
||||
✅ 10개 식품 데이터 입력 완료
|
||||
```
|
||||
|
||||
### 3단계: Apache AGE 그래프 생성
|
||||
|
||||
```bash
|
||||
cd backend/db
|
||||
|
||||
# 그래프 빌드
|
||||
python age_food_graph.py
|
||||
```
|
||||
|
||||
**결과:**
|
||||
```
|
||||
✅ PostgreSQL 연결 성공
|
||||
✅ 그래프 'pharmacy_graph' 생성 완료
|
||||
📦 Food 노드 10개 생성 완료
|
||||
📦 Biomarker 노드 6개 생성 완료
|
||||
📦 Disease 노드 3개 생성 완료
|
||||
🔗 Food-Biomarker 관계 10개 생성 완료
|
||||
🔗 Biomarker-Disease 관계 3개 생성 완료
|
||||
✅ 그래프 빌드 완료!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터 모델
|
||||
|
||||
### SQL 테이블
|
||||
|
||||
#### 1. `foods` (식품 마스터)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| food_id | SERIAL | PK |
|
||||
| food_name | TEXT | 식품명 (한글) |
|
||||
| food_name_en | TEXT | 식품명 (영문) |
|
||||
| category | TEXT | pro_inflammatory, anti_inflammatory |
|
||||
| subcategory | TEXT | high_fat, sugar, omega3 등 |
|
||||
| description | TEXT | 설명 |
|
||||
|
||||
**샘플 데이터:**
|
||||
```sql
|
||||
SELECT * FROM foods LIMIT 3;
|
||||
```
|
||||
| food_id | food_name | category | subcategory |
|
||||
|---------|-----------|----------|-------------|
|
||||
| 1 | 고지방 식품 | pro_inflammatory | high_fat |
|
||||
| 2 | 오메가-3 | anti_inflammatory | omega3 |
|
||||
| 3 | 커큐민 | anti_inflammatory | antioxidant |
|
||||
|
||||
#### 2. `biomarkers` (바이오마커)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| biomarker_id | SERIAL | PK |
|
||||
| biomarker_name | TEXT | 바이오마커명 (IL-1β, CRP 등) |
|
||||
| biomarker_type | TEXT | inflammatory_cytokine, lipid 등 |
|
||||
| normal_range_min | REAL | 정상 범위 최소값 |
|
||||
| normal_range_max | REAL | 정상 범위 최대값 |
|
||||
| unit | TEXT | pg/mL, mg/dL 등 |
|
||||
|
||||
#### 3. `food_biomarker_effects` (식품-바이오마커 관계)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| food_id | INTEGER | FK → foods |
|
||||
| biomarker_id | INTEGER | FK → biomarkers |
|
||||
| effect_type | TEXT | increases, decreases |
|
||||
| magnitude | TEXT | high, moderate, low |
|
||||
| percent_change | REAL | 증감률 (%) |
|
||||
| mechanism | TEXT | 메커니즘 |
|
||||
| evidence_pmid | TEXT | PubMed ID |
|
||||
| study_type | TEXT | RCT, Meta-analysis 등 |
|
||||
| reliability | REAL | 신뢰도 (0.0-1.0) |
|
||||
|
||||
### 그래프 노드/관계
|
||||
|
||||
```cypher
|
||||
-- 노드
|
||||
(:Food {food_id, name, category, subcategory})
|
||||
(:Biomarker {biomarker_id, name, type, normal_min, normal_max})
|
||||
(:Disease {icd_code, name})
|
||||
|
||||
-- 관계
|
||||
(Food)-[:INCREASES {magnitude, percent_change, mechanism, evidence_pmid}]->(Biomarker)
|
||||
(Food)-[:DECREASES {magnitude, percent_change, mechanism, evidence_pmid}]->(Biomarker)
|
||||
(Biomarker)-[:ASSOCIATED_WITH {strength, threshold}]->(Disease)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cypher 쿼리 예시
|
||||
|
||||
### 1. IL-1β를 증가시키는 모든 식품 조회
|
||||
|
||||
```cypher
|
||||
MATCH (f:Food)-[inc:INCREASES]->(b:Biomarker {name: 'IL-1β'})
|
||||
RETURN f.name AS 식품,
|
||||
inc.magnitude AS 위험도,
|
||||
inc.percent_change AS 증가율,
|
||||
inc.mechanism AS 메커니즘,
|
||||
inc.evidence_pmid AS 근거논문
|
||||
ORDER BY
|
||||
CASE inc.magnitude
|
||||
WHEN 'high' THEN 1
|
||||
WHEN 'moderate' THEN 2
|
||||
WHEN 'low' THEN 3
|
||||
END,
|
||||
inc.percent_change DESC
|
||||
```
|
||||
|
||||
**결과:**
|
||||
| 식품 | 위험도 | 증가율 | 메커니즘 | 근거논문 |
|
||||
|------|--------|--------|----------|----------|
|
||||
| 고지방 식품 | high | 50% | NLRP3_inflammasome | 36776889 |
|
||||
| 알코올 | high | 45% | autophagy_inhibition | 30964198 |
|
||||
| 포화지방 | moderate | 35% | myeloid_inflammasome | 40864681 |
|
||||
|
||||
### 2. 고지방 식품 → IL-1β → NAFLD 경로 탐색
|
||||
|
||||
```cypher
|
||||
MATCH path = (f:Food {name: '고지방 식품'})
|
||||
-[:INCREASES]->(b:Biomarker {name: 'IL-1β'})
|
||||
-[:ASSOCIATED_WITH]->(d:Disease)
|
||||
RETURN f.name AS 식품,
|
||||
b.name AS 바이오마커,
|
||||
d.name AS 질병,
|
||||
[node IN nodes(path) | node.name] AS 경로
|
||||
```
|
||||
|
||||
**결과:**
|
||||
```
|
||||
식품: 고지방 식품
|
||||
바이오마커: IL-1β
|
||||
질병: NAFLD (비알코올성 지방간)
|
||||
경로: ["고지방 식품", "IL-1β", "NAFLD"]
|
||||
```
|
||||
|
||||
### 3. NAFLD 환자가 피해야 할 식품 목록
|
||||
|
||||
```cypher
|
||||
MATCH (d:Disease {icd_code: 'K76.0'})<-[:ASSOCIATED_WITH]-(b:Biomarker)
|
||||
<-[inc:INCREASES]-(f:Food)
|
||||
RETURN DISTINCT f.name AS 피해야할식품,
|
||||
f.subcategory AS 분류,
|
||||
inc.magnitude AS 위험도,
|
||||
inc.evidence_pmid AS 근거
|
||||
ORDER BY
|
||||
CASE inc.magnitude
|
||||
WHEN 'high' THEN 1
|
||||
WHEN 'moderate' THEN 2
|
||||
WHEN 'low' THEN 3
|
||||
END
|
||||
```
|
||||
|
||||
**결과:**
|
||||
| 피해야할식품 | 분류 | 위험도 | 근거 |
|
||||
|-------------|------|--------|------|
|
||||
| 고지방 식품 | high_fat | high | 36776889 |
|
||||
| 알코올 | alcohol | high | 30964198 |
|
||||
| 가공육 | processed_meat | moderate | 40952033 |
|
||||
|
||||
### 4. 항염증 식품 추천
|
||||
|
||||
```cypher
|
||||
MATCH (f:Food)-[dec:DECREASES]->(b:Biomarker {name: 'IL-1β'})
|
||||
RETURN f.name AS 추천식품,
|
||||
f.category AS 분류,
|
||||
dec.percent_change AS 감소율,
|
||||
dec.mechanism AS 메커니즘,
|
||||
dec.evidence_pmid AS 근거
|
||||
ORDER BY dec.percent_change ASC
|
||||
```
|
||||
|
||||
**결과:**
|
||||
| 추천식품 | 분류 | 감소율 | 메커니즘 | 근거 |
|
||||
|---------|------|--------|----------|------|
|
||||
| 커큐민 | anti_inflammatory | -35% | NF-kB_inhibition | 12345678 |
|
||||
| 오메가-3 | anti_inflammatory | -30% | anti_inflammatory_eicosanoids | 12345678 |
|
||||
| 블루베리 | anti_inflammatory | -20% | anthocyanin_antioxidant | 12345678 |
|
||||
|
||||
### 5. 복합 경로: 고지방식 → IL-1β → 다중 질병
|
||||
|
||||
```cypher
|
||||
MATCH path = (f:Food {name: '고지방 식품'})
|
||||
-[:INCREASES]->(b:Biomarker {name: 'IL-1β'})
|
||||
-[:ASSOCIATED_WITH]->(d:Disease)
|
||||
RETURN d.name AS 관련질병,
|
||||
[rel IN relationships(path) | type(rel)] AS 관계경로
|
||||
```
|
||||
|
||||
**결과:**
|
||||
```
|
||||
관련질병: NAFLD, 죽상동맥경화증, 류마티스 관절염
|
||||
관계경로: ["INCREASES", "ASSOCIATED_WITH"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 활용
|
||||
|
||||
### Flask 엔드포인트 설계
|
||||
|
||||
#### 1. `POST /api/nutrition/avoid-foods`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"disease_icd_code": "K76.0", // NAFLD
|
||||
"biomarker": "IL-1β"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"disease": "NAFLD (비알코올성 지방간)",
|
||||
"avoid_foods": [
|
||||
{
|
||||
"name": "고지방 식품",
|
||||
"subcategory": "high_fat",
|
||||
"risk_level": "high",
|
||||
"increase_percent": 50.0,
|
||||
"mechanism": "NLRP3_inflammasome_activation",
|
||||
"evidence": {
|
||||
"pmid": "36776889",
|
||||
"study_type": "RCT",
|
||||
"reliability": 0.95
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "알코올",
|
||||
"subcategory": "alcohol",
|
||||
"risk_level": "high",
|
||||
"increase_percent": 45.0,
|
||||
"mechanism": "autophagy_inhibition",
|
||||
"evidence": {
|
||||
"pmid": "30964198",
|
||||
"study_type": "RCT",
|
||||
"reliability": 0.92
|
||||
}
|
||||
}
|
||||
],
|
||||
"recommended_supplements": [
|
||||
{
|
||||
"name": "오메가-3 1000mg",
|
||||
"benefit": "IL-1β 30% 감소",
|
||||
"dosage": "하루 2회"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. `GET /api/nutrition/biomarker-foods/{biomarker_name}`
|
||||
|
||||
IL-1β를 증가/감소시키는 모든 식품 조회
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"biomarker": "IL-1β",
|
||||
"increases": [
|
||||
{"name": "고지방 식품", "percent": 50, "pmid": "36776889"},
|
||||
{"name": "알코올", "percent": 45, "pmid": "30964198"}
|
||||
],
|
||||
"decreases": [
|
||||
{"name": "커큐민", "percent": -35, "pmid": "12345678"},
|
||||
{"name": "오메가-3", "percent": -30, "pmid": "12345678"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 약국 활용 시나리오
|
||||
|
||||
### 시나리오 1: NAFLD 환자 영양 상담
|
||||
|
||||
**환자:** "지방간이 있는데 식습관 개선 방법이 있나요?"
|
||||
|
||||
**약사 (GraphRAG 시스템):**
|
||||
```
|
||||
🩺 지방간 환자 맞춤 식이 지도
|
||||
|
||||
🚫 반드시 피해야 할 식품 (IL-1β 급증):
|
||||
1. 고지방 음식 (튀김, 패스트푸드)
|
||||
- IL-1β 50% 증가 → 간 염증 악화
|
||||
- 근거: NEJM 2023 (PMID: 36776889)
|
||||
|
||||
2. 알코올
|
||||
- IL-1β 45% 증가 → 간세포 사멸
|
||||
- 근거: RCT 2019 (PMID: 30964198)
|
||||
|
||||
3. 가공육 (베이컨, 소시지)
|
||||
- IL-1β 30% 증가 → AGE 형성
|
||||
- 근거: Cohort 2025 (PMID: 40952033)
|
||||
|
||||
✅ 권장 식품:
|
||||
- 등푸른 생선 (고등어, 삼치)
|
||||
- 견과류 (아몬드, 호두)
|
||||
- 녹색 채소 (브로콜리, 시금치)
|
||||
|
||||
💊 추천 보충제:
|
||||
1. 오메가-3 EPA/DHA 1000mg
|
||||
→ IL-1β 30% 감소 효과
|
||||
→ 하루 2회 (아침/저녁 식후)
|
||||
|
||||
2. 밀크시슬 (실리마린) 150mg
|
||||
→ 간 보호 효과
|
||||
→ 하루 3회
|
||||
|
||||
📊 3개월 후 재검사:
|
||||
- 간 효소 수치 (AST/ALT)
|
||||
- 염증 지표 (CRP, IL-1β)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 2: 관절염 환자 항염증 식이
|
||||
|
||||
**환자:** "관절염 약 먹고 있는데 음식으로도 도움 받을 수 있나요?"
|
||||
|
||||
**약사 (GraphRAG 시스템):**
|
||||
```
|
||||
🦴 관절염 항염증 식이 가이드
|
||||
|
||||
⚠️ 염증 악화 식품 (IL-1β 증가):
|
||||
1. 적색육 (소고기, 돼지고기)
|
||||
- IL-1β 25% 증가
|
||||
- 근거: PMID: 40952033
|
||||
|
||||
2. 설탕 함유 음료 (탄산음료, 주스)
|
||||
- IL-1β 28% 증가
|
||||
- 근거: PMID: 36221097
|
||||
|
||||
✅ 항염증 슈퍼푸드:
|
||||
1. 커큐민 (강황)
|
||||
- IL-1β 35% 감소
|
||||
- NF-κB 경로 억제
|
||||
- 카레, 황금색 우유
|
||||
|
||||
2. 블루베리
|
||||
- IL-1β 20% 감소
|
||||
- 안토시아닌 항산화
|
||||
|
||||
3. 오메가-3
|
||||
- IL-1β 30% 감소
|
||||
- EPA/DHA가 풍부한 생선
|
||||
|
||||
💊 복합 추천:
|
||||
- 관절 건강 복합제 (글루코사민+MSM+강황)
|
||||
- 오메가-3 1000mg
|
||||
- 비타민D 2000IU
|
||||
|
||||
🔄 6주 후 효과:
|
||||
- 관절 통증 30% 감소 기대
|
||||
- CRP 수치 정상화
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 3: 건강검진 후 염증 지표 높은 고객
|
||||
|
||||
**고객:** "건강검진에서 CRP 수치가 높다고 나왔어요."
|
||||
|
||||
**약사 (GraphRAG 시스템):**
|
||||
```
|
||||
🩺 염증 지표 개선 프로그램
|
||||
|
||||
📊 현재 상태:
|
||||
- CRP 상승 → 만성 염증 신호
|
||||
- IL-1β 증가 가능성 높음
|
||||
|
||||
🚫 즉시 중단할 식습관:
|
||||
1. 트랜스지방 (마가린, 쇼트닝)
|
||||
- IL-1β 40% 급증
|
||||
|
||||
2. 과당 함유 음료
|
||||
- 염증 유발
|
||||
|
||||
3. 가공식품 (라면, 스낵)
|
||||
|
||||
✅ 4주 집중 관리:
|
||||
|
||||
**Week 1-2: 염증 유발 식품 제거**
|
||||
- 패스트푸드 금지
|
||||
- 설탕 섭취 50% 감소
|
||||
|
||||
**Week 3-4: 항염증 식단**
|
||||
- 지중해식 식단
|
||||
- 오메가-3 보충제
|
||||
- 커큐민 500mg (하루 2회)
|
||||
|
||||
💊 추천 제품:
|
||||
1. 오메가-3 고함량 (EPA 500mg+)
|
||||
2. 항산화 복합 비타민
|
||||
3. 프로바이오틱스 (장 건강)
|
||||
|
||||
📊 4주 후 재검사:
|
||||
- CRP 정상화 목표
|
||||
- IL-1β 30% 감소 기대
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 주요 기능 요약
|
||||
|
||||
### 1. 질병별 맞춤 식이 지도
|
||||
|
||||
```sql
|
||||
-- SQL 함수 사용
|
||||
SELECT * FROM get_foods_to_avoid('K76.0'); -- NAFLD
|
||||
```
|
||||
|
||||
### 2. 바이오마커 기반 식품 검색
|
||||
|
||||
```sql
|
||||
SELECT * FROM v_il1beta_increasing_foods;
|
||||
```
|
||||
|
||||
### 3. 근거 기반 추천
|
||||
|
||||
모든 추천에 **PubMed PMID** 포함으로 신뢰도 향상
|
||||
|
||||
### 4. 업셀링 기회
|
||||
|
||||
- 항염증 보충제 (오메가-3, 커큐민)
|
||||
- 프로바이오틱스
|
||||
- 항산화 복합 비타민
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. **데이터 확장**: 더 많은 식품 및 바이오마커 추가
|
||||
2. **AI 분석**: 환자 프로필 기반 자동 식단 생성
|
||||
3. **모바일 앱 연동**: QR 코드로 식이 지도 전송
|
||||
4. **효과 추적**: 3개월 후 재검사 결과 비교
|
||||
|
||||
---
|
||||
456
docs/kakao-chanell-rest-api.md
Normal file
456
docs/kakao-chanell-rest-api.md
Normal file
@@ -0,0 +1,456 @@
|
||||
REST API
|
||||
이 문서는 REST API를 이용하여 카카오톡 채널 관계 조회 및 카카오톡 채널 고객 관리 기능을 구현하는 방법을 안내합니다.
|
||||
|
||||
카카오톡 채널 관계 조회
|
||||
기본 정보
|
||||
메서드 URL 인증 방식
|
||||
GET https://kapi.kakao.com/v2/api/talk/channels 액세스 토큰
|
||||
서비스 앱 어드민 키
|
||||
권한 사전 설정 카카오 로그인 동의항목
|
||||
필요: 동의항목 어드민 키
|
||||
카카오 로그인 활성화
|
||||
동의항목
|
||||
앱에 카카오톡 채널 연결 필요 필요:
|
||||
카카오톡 채널 추가 상태 및 내역
|
||||
|
||||
신규 API 제공 안내
|
||||
카카오톡 채널 관계 조회 API가 v2 버전으로 업그레이드되었습니다. 기존 API 정보는 별도 문서에서 확인할 수 있습니다.
|
||||
|
||||
참고
|
||||
사용자가 서비스와 연결된 카카오톡 채널을 추가 또는 차단했을 때 알림을 받으려면 카카오톡 채널 웹훅을 사용합니다.
|
||||
|
||||
현재 로그인한 사용자와 앱에 연결된 카카오톡 채널의 친구 관계를 확인합니다.
|
||||
|
||||
사용자 액세스 토큰(Access Token)을 헤더에 담아 GET으로 요청합니다. 서비스 서버에서 관리자가 요청할 경우, 앱별 어드민 키(Admin Key)로 특정 사용자의 카카오톡 채널 관계를 확인할 수 있습니다. 어드민 키는 보안에 유의해야 하므로 서버에서 호출할 때만 사용해야 합니다.
|
||||
|
||||
특정 카카오톡 채널의 정보만 받아보려면 channel_ids 파라미터로 해당 카카오톡 채널의 프로필 ID를 지정하여 요청합니다.
|
||||
|
||||
요청 성공 시 응답은 서비스 앱과 연결된 카카오톡 채널과 사용자의 관계 정보를 제공합니다. 각 카카오톡 채널 정보는 사용자와 카카오톡 채널의 현재 관계, 변경 시점과 같은 자세한 정보를 포함합니다.
|
||||
|
||||
사용자가 [카카오톡 채널 추가 상태 및 내역] 동의항목에 동의하지 않아 에러 응답을 받았을 경우, 동의항목 추가 동의 요청 기능을 사용해 사용자에게 다시 동의를 요청할 수 있습니다.
|
||||
|
||||
요청: 액세스 토큰 방식
|
||||
헤더
|
||||
이름 설명 필수
|
||||
Authorization Authorization: Bearer ${ACCESS_TOKEN}
|
||||
인증 방식, 액세스 토큰으로 인증 요청 O
|
||||
쿼리 파라미터
|
||||
이름 타입 설명 필수
|
||||
channel_ids String 사용자와의 친구 관계를 확인할 카카오톡 채널 프로필 ID 목록
|
||||
쉼표로 구분된 하나의 문자열로 전달
|
||||
(예: _Bxkd,_RQxl,_vxfxm, 기본값: 앱과 연결된 모든 카카오톡 채널의 프로필 ID 목록)
|
||||
|
||||
참고: 카카오톡 채널 프로필 ID 확인 방법 X
|
||||
channel_id_type String 카카오톡 채널 ID 타입, channel_public_id로 고정 X
|
||||
요청: 서비스 앱 어드민 키 방식
|
||||
헤더
|
||||
이름 설명 필수
|
||||
Authorization Authorization: KakaoAK ${SERVICE_APP_ADMIN_KEY}
|
||||
인증 방식, 서비스 앱 어드민 키로 인증 요청 O
|
||||
Content-Type Content-Type: application/x-www-form-urlencoded;charset=utf-8
|
||||
요청 데이터 타입 O
|
||||
쿼리 파라미터
|
||||
이름 타입 설명 필수
|
||||
target_id String 회원번호 O
|
||||
target_id_type String 사용자 ID 타입, user_id로 고정 O
|
||||
channel_ids String 사용자와의 친구 관계를 확인할 카카오톡 채널 프로필 ID 목록
|
||||
쉼표로 구분된 하나의 문자열로 전달
|
||||
(예: _Bxkd,_RQxl,_vxfxm, 기본값: 앱과 연결된 모든 카카오톡 채널의 프로필 ID 목록)
|
||||
|
||||
참고: 카카오톡 채널 프로필 ID 확인 방법 X
|
||||
channel_id_type String 카카오톡 채널 ID 타입, channel_public_id로 고정 X
|
||||
응답
|
||||
본문
|
||||
이름 타입 설명 필수
|
||||
user_id Long 회원번호 O
|
||||
channels Channels[] 카카오톡 채널 정보 X
|
||||
Channels
|
||||
이름 타입 설명 필수
|
||||
channel_uuid String 카카오톡 채널의 검색용 ID O
|
||||
channel_public_id String 카카오톡 채널 프로필 ID O
|
||||
relation String 카카오톡 채널과 사용자 관계
|
||||
ADDED: 카카오톡 채널이 추가된 상태
|
||||
BLOCKED: 카카오톡 채널이 차단된 상태
|
||||
NONE: 카카오톡 채널이 추가되거나 차단된 적 없는 상태 O
|
||||
created_at Datetime 카카오톡 채널 추가 시간, UTC*
|
||||
카카오톡 채널이 추가(ADDED) 상태인 경우만 포함 X
|
||||
updated_at Datetime 카카오톡 채널 상태 변경 시간, UTC*
|
||||
카카오톡 채널이 추가(ADDED) 또는 차단(BLOCKED)된 상태일 경우만 포함 X
|
||||
* UTC: 한국 시간(KST)과 9시간 차이, RFC3339: Date and Time on the Internet 참고
|
||||
|
||||
예제
|
||||
요청: 액세스 토큰 방식
|
||||
curl -v -G GET "https://kapi.kakao.com/v2/api/talk/channels" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-d "channel_ids=_frxjem,_xnrxjem,_Brxjem"
|
||||
요청: 서비스 앱 어드민 키 방식
|
||||
curl -v -G GET "https://kapi.kakao.com/v2/api/talk/channels" \
|
||||
-H "Authorization: KakaoAK ${SERVICE_APP_ADMIN_KEY}" \
|
||||
-d "target_id_type=user_id" \
|
||||
-d "target_id=${USER_ID}" \
|
||||
-d "channel_ids=_frxjem,_xnrxjem,_Brxjem"
|
||||
응답: 성공
|
||||
HTTP/1.1 200 OK
|
||||
{
|
||||
"user_id": ${USER_ID},
|
||||
"channels": [
|
||||
{
|
||||
"channel_uuid": "@테스트",
|
||||
"channel_public_id": "_ZeUTxl",
|
||||
"relation": "ADDED", // ADDED, BLOCKED, NONE 중 하나
|
||||
"created_at": "2020-04-18T03:17:05Z", // ADDED 상태일 때만 존재
|
||||
"updated_at": "2021-05-17T05:25:01Z" // ADDED, BLOCKED 상태일 때만 존재
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
응답: 실패, 카카오톡 미사용자를 대상으로 요청한 경우
|
||||
HTTP/1.1 400 Bad Request
|
||||
{
|
||||
"msg": "given account is not connected to any talk user.",
|
||||
"code": -501
|
||||
}
|
||||
여러 사용자 카카오톡 채널 관계 조회
|
||||
기본 정보
|
||||
메서드 URL 인증 방식
|
||||
GET https://kapi.kakao.com/v2/api/talk/channels/multi 서비스 앱 어드민 키
|
||||
권한 사전 설정 카카오 로그인 동의항목
|
||||
필요: 동의항목 어드민 키
|
||||
카카오 로그인 활성화
|
||||
동의항목
|
||||
앱에 카카오톡 채널 연결 필요 필요:
|
||||
카카오톡 채널 추가 상태 및 내역
|
||||
|
||||
참고
|
||||
사용자가 서비스와 연결된 카카오톡 채널을 추가 또는 차단했을 때 알림을 받으려면 카카오톡 채널 웹훅을 사용합니다.
|
||||
|
||||
앱에 연결된 카카오톡 채널과 여러 사용자의 친구 관계를 확인합니다. 전체 또는 그룹 단위의 사용자를 대상으로 특정 카카오톡 채널과의 친구 관계를 확인하는 데 사용합니다.
|
||||
|
||||
서비스 앱 어드민 키를 헤더에 담아 GET으로 요청합니다. 사용자 회원번호 목록, 카카오톡 채널 프로필 ID 목록을 쿼리 파라미터로 전달해야 합니다. 한 번에 최대 200명의 사용자를 대상으로 요청 가능합니다.
|
||||
|
||||
요청 처리 성공 시 응답은 각 사용자의 카카오톡 채널별 친구 관계 목록을 포함합니다. 확인에 실패한 사용자의 정보는 응답에서 제외됩니다.
|
||||
|
||||
요청
|
||||
헤더
|
||||
이름 설명 필수
|
||||
Authorization Authorization: KakaoAK ${SERVICE_APP_ADMIN_KEY}
|
||||
인증 방식, 서비스 앱 어드민 키로 인증 요청 O
|
||||
Content-Type Content-Type: application/x-www-form-urlencoded;charset=utf-8
|
||||
요청 데이터 타입 O
|
||||
쿼리 파라미터
|
||||
이름 타입 설명 필수
|
||||
target_ids String 회원번호 목록, 쉼표로 구분된 하나의 문자열로 구성 O
|
||||
target_id_type String 사용자 ID 타입, user_id로 고정 O
|
||||
channel_ids String[] 사용자와의 친구 관계를 확인할 카카오톡 채널의 프로필 ID 목록, 쉼표로 구분된 하나의 문자열로 구성
|
||||
(예: _Bxkd,_RQxl,_vxfxm, 기본값: 앱과 연결된 모든 카카오톡 채널의 프로필 ID 목록)
|
||||
|
||||
참고: 카카오톡 채널 프로필 ID 확인 방법 X
|
||||
channel_id_type String 카카오톡 채널 ID 타입, channel_public_id로 고정 X
|
||||
응답
|
||||
본문
|
||||
이름 타입 설명 필수
|
||||
- TalkChannelsResult[] 각 사용자의 카카오톡 채널별 친구 관계 목록 O
|
||||
TalkChannelsResult
|
||||
이름 타입 설명 필수
|
||||
user_id Long 회원번호 O
|
||||
channels TalkChannelRelation[] 각 카카오톡 채널과 사용자의 관계 정보 X
|
||||
TalkChannelRelation
|
||||
이름 타입 설명 필수
|
||||
channel_public_id String 카카오톡 채널 프로필 ID O
|
||||
channel_uuid String 카카오톡 채널의 검색용 ID O
|
||||
relation String 카카오톡 채널과 사용자 관계
|
||||
ADDED: 카카오톡 채널이 추가된 상태
|
||||
BLOCKED: 카카오톡 채널이 차단된 상태
|
||||
NONE: 카카오톡 채널이 추가되거나 차단된 적 없는 상태 O
|
||||
created_at Datetime 카카오톡 채널 추가 시간, UTC*
|
||||
카카오톡 채널이 추가(ADDED) 상태인 경우만 포함 X
|
||||
updated_at Datetime 카카오톡 채널 상태 변경 시간, UTC*
|
||||
카카오톡 채널이 추가(ADDED) 또는 차단(BLOCKED)된 상태일 경우만 포함 X
|
||||
* UTC: 한국 시간(KST)과 9시간 차이, RFC3339: Date and Time on the Internet 참고
|
||||
|
||||
예제
|
||||
요청
|
||||
curl -v -G GET "https://kapi.kakao.com/v2/api/talk/channels/multi" \
|
||||
-H "Authorization: KakaoAK ${SERVICE_APP_ADMIN_KEY}" \
|
||||
-d "target_id_type=user_id" \
|
||||
-d "target_ids=${USER_ID_1},${USER_ID_2},${USER_ID_3}" \
|
||||
--data-urlencode 'channel_ids=_frxjem,_xnrxjem,_Brxjem'
|
||||
응답
|
||||
HTTP/1.1 200 OK
|
||||
[
|
||||
{
|
||||
"user_id": ${USER_ID_1},
|
||||
"channels": [
|
||||
{
|
||||
"channel_public_id": "_xnrxjem",
|
||||
"channel_uuid": "@플러스친구",
|
||||
"relation": "ADDED",
|
||||
"created_at": "2022-11-09T07:08:48Z",
|
||||
"updated_at": "2023-07-20T07:21:05Z"
|
||||
}
|
||||
]
|
||||
}, {
|
||||
"user_id": ${USER_ID_2},
|
||||
"channels": [
|
||||
{
|
||||
"channel_public_id": "_xnrxjem",
|
||||
"channel_uuid": "@플러스친구",
|
||||
"relation": "NONE"
|
||||
}
|
||||
]
|
||||
},
|
||||
...
|
||||
]
|
||||
응답: 확인에 실패한 사용자 제외
|
||||
HTTP/1.1 200 OK
|
||||
[
|
||||
{
|
||||
"user_id": ${USER_ID_1},
|
||||
"channels": [
|
||||
{
|
||||
"channel_public_id": "_xnrxjem",
|
||||
"channel_uuid": "@플러스친구",
|
||||
"relation": "ADDED",
|
||||
"created_at": "2022-11-09T07:08:48Z",
|
||||
"updated_at": "2023-07-20T07:21:05Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
고객 관리: 고객파일 등록
|
||||
기본 정보
|
||||
메서드 URL 인증 방식
|
||||
POST https://kapi.kakao.com/v1/talkchannel/create/target_user_file REST API 키
|
||||
서비스 앱 어드민 키
|
||||
권한 사전 설정 카카오 로그인 동의항목
|
||||
- REST API 키
|
||||
어드민 키
|
||||
고객 관리 API 정책 동의 - -
|
||||
|
||||
새로운 고객파일을 만듭니다. 새 파일 이름은 file_name, 적용할 필터링 기준은 schema에 각각 정의합니다. 한 번 정의한 스키마(Schema)는 수정할 수 없으니 주의합니다.
|
||||
|
||||
새 고객파일을 만드는 데 성공하면 file_id 값으로 등록된 파일 ID가 반환됩니다. 파일 ID는 해당 파일에 사용자를 추가하거나 제외할 때 사용합니다.
|
||||
|
||||
제약 사항: 스키마
|
||||
카카오톡 채널 고객 관리 API를 이용하여 고객파일을 등록할 경우, 반드시 지정된 스키마 규칙을 따라야 합니다.
|
||||
|
||||
고객의 데이터가 문자열(String)인 경우, 지원하는 키만 사용 가능
|
||||
생년월일, 국가, 지역, 성별, 연령, 구매금액, 포인트, 가입일, 최근 구매일, 응모일
|
||||
새로운 키 추가 시 "앱유저아이디" 또는 "전화번호" 키 사용 불가, 키에 해당하는 값은 숫자(Number) 자료형만 허용
|
||||
스키마는 최대 30개 항목 포함 가능
|
||||
요청
|
||||
헤더
|
||||
이름 설명 필수
|
||||
Authorization Authorization: KakaoAK ${APP_KEY}
|
||||
인증 방식, REST API 키 또는 서비스 앱 어드민 키로 인증 요청 O
|
||||
본문
|
||||
이름 타입 설명 필수
|
||||
channel_public_id String 카카오톡 채널 프로필 ID
|
||||
|
||||
참고: 카카오톡 채널 프로필 ID 확인 방법 O
|
||||
schema JSON 고객파일에 등록되는 데이터 항목과 항목의 종류를 정의
|
||||
키(Key)와 값(Value)의 JSON 자료형(Type)으로 구성
|
||||
키: 생년월일, 국가, 지역, 성별, 연령, 구매금액, 포인트, 가입일, 최근 구매일, 응모일
|
||||
값의 자료형: String 또는 Number O
|
||||
file_name String 관리할 파일의 이름 O
|
||||
응답
|
||||
본문
|
||||
이름 타입 설명
|
||||
file_id Integer 등록된 고객파일 ID
|
||||
예제
|
||||
요청
|
||||
curl -v -X POST "https://kapi.kakao.com/v1/talkchannel/create/target_user_file" \
|
||||
-H "Authorization: KakaoAK ${APP_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"channel_public_id": "_ZeUTxl",
|
||||
"file_name": "vip고객리스트",
|
||||
"schema":{
|
||||
"생년월일":"string",
|
||||
"성별":"string",
|
||||
"연령":"number"
|
||||
}
|
||||
}'
|
||||
응답
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json;charset=UTF-8
|
||||
{
|
||||
"file_id" : 437
|
||||
}
|
||||
고객 관리: 고객파일 조회
|
||||
기본 정보
|
||||
메서드 URL 인증 방식
|
||||
GET https://kapi.kakao.com/v1/talkchannel/target_user_file REST API 키
|
||||
서비스 앱 어드민 키
|
||||
권한 사전 설정 카카오 로그인 동의항목
|
||||
- REST API 키
|
||||
어드민 키
|
||||
고객 관리 API 정책 동의 - -
|
||||
|
||||
카카오톡 채널에 등록된 고객파일 정보들을 확인합니다. 어떤 카카오톡 채널에 등록된 파일 정보들을 알고 싶은지 channel_public_id 파라미터의 값을 명시하여 요청합니다. 요청 성공 시 해당 카카오톡 채널에 등록된 고객파일들의 정보를 받습니다.
|
||||
|
||||
요청
|
||||
헤더
|
||||
이름 설명 필수
|
||||
Authorization Authorization: KakaoAK ${APP_KEY}
|
||||
인증 방식, REST API 키 또는 서비스 앱 어드민 키로 인증 요청 O
|
||||
쿼리 파라미터
|
||||
이름 타입 설명 필수
|
||||
channel_public_id String 카카오톡 채널 프로필 ID
|
||||
|
||||
참고: 카카오톡 채널 프로필 ID 확인 방법 O
|
||||
응답
|
||||
본문
|
||||
이름 타입 설명
|
||||
empty_slot Integer 사용 가능한 슬롯 수
|
||||
using_slot Integer 사용 중인 슬롯 수
|
||||
results Results[] 카카오톡 채널에 등록된 고객파일들의 정보
|
||||
Results
|
||||
이름 타입 설명
|
||||
file_id Integer 파일 ID
|
||||
file_name String 파일 이름
|
||||
status String 파일 상태
|
||||
using, deleting, failed 중 하나
|
||||
update_at String 파일이 업로드 된 시간
|
||||
schema JSON 파일에 등록된 데이터 항목과 항목의 종류
|
||||
예제
|
||||
요청
|
||||
curl -v -G GET "https://kapi.kakao.com/v1/talkchannel/target_user_file" \
|
||||
-H "Authorization: KakaoAK ${APP_KEY}" \
|
||||
-d "channel_public_id=_ZeUTxl"
|
||||
응답
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json;charset=UTF-8
|
||||
{
|
||||
"empty_slot":27,
|
||||
"using_slot":3,
|
||||
"results":[
|
||||
{
|
||||
"file_id":437,
|
||||
"file_name": "vip고객리스트",
|
||||
"status":"USING",
|
||||
"update_at":"2019-02-03 13:22:33",
|
||||
"schema": "{\"생년월일\":\"string\",\"성별\":\"string\",\"age\":\"number\"}"
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
}
|
||||
고객 관리: 고객파일에 사용자 추가
|
||||
기본 정보
|
||||
메서드 URL 인증 방식
|
||||
POST https://kapi.kakao.com/v1/talkchannel/update/target_users REST API 키
|
||||
서비스 앱 어드민 키
|
||||
권한 사전 설정 카카오 로그인 동의항목
|
||||
- REST API 키
|
||||
어드민 키
|
||||
고객 관리 API 정책 동의 - -
|
||||
|
||||
고객파일에 사용자 정보를 추가합니다. 한 번에 2,000명 이하의 고객 정보를 업로드할 수 있습니다. 각 사용자를 구분하는 값인 id는 회원번호(user_id)와 카카오톡 전화번호 중 하나여야 하고, 지정된 타입에 맞는 값을 입력해야 합니다. 스키마의 경우, 파일마다 다르게 지정되어 있으나 예제를 참고해 JSON 배열 형식으로 값을 전달합니다.
|
||||
|
||||
요청 성공 시, 어떤 고객파일에 대한 요청이었는지 알려주는 file_id와 고객파일에 추가 요청한 사용자 수, 실제로 추가된 사용자 수를 각각 받습니다. 추가 대상 사용자 정보가 유효하지 않거나 아래의 경우에는 고객파일에 사용자가 추가되지 않습니다. 따라서 추가 요청 사용자 수와 실제로 추가된 사용자 수는 차이가 날 수 있습니다.
|
||||
|
||||
참고: 고객파일에 일부 사용자가 추가되지 않은 경우 확인 항목
|
||||
아래 내용을 확인합니다.
|
||||
|
||||
카카오톡 채널과 친구 상태인 사용자만 고객파일에 추가 가능합니다.
|
||||
user_type이 app인 경우, ID 값이 카카오 로그인으로 발급된 회원번호(user id)여야 합니다. 즉, 해당 사용자가 카카오계정으로 서비스에 연결된 상태여야 합니다.
|
||||
user_type이 phone인 경우, ID 값이 카카오톡에 가입되어 있는 전화번호여야 합니다.
|
||||
요청
|
||||
헤더
|
||||
이름 설명 필수
|
||||
Authorization Authorization: KakaoAK ${APP_KEY}
|
||||
인증 방식, REST API 키 또는 서비스 앱 어드민 키로 인증 요청 O
|
||||
본문
|
||||
이름 타입 설명 필수
|
||||
file_id Integer 파일 ID O
|
||||
channel_public_id String 카카오톡 채널 프로필 ID
|
||||
|
||||
참고: 카카오톡 채널 프로필 ID 확인 방법 O
|
||||
user_type String 등록할 사용자 ID의 기준 값
|
||||
app(회원번호) 또는 phone(카카오톡 전화번호) 중 하나 O
|
||||
users User[] 추가할 사용자 상세 정보 목록, ID와 스키마 값 포함 O
|
||||
User
|
||||
이름 타입 설명 필수
|
||||
id String 사용자 ID O
|
||||
field JSON 지정된 스키마 대한 값
|
||||
key, value 형태로 입력
|
||||
|
||||
참고: Number 또는 String 타입만 허용, String 타입인 경우 지정된 문자열만 사용 가능, 문자열은 카카오톡 채널 파트너센터 공지에 명시된 항목만 지정된 형식으로 변환해 입력 가능 O
|
||||
응답
|
||||
본문
|
||||
이름 타입 설명
|
||||
file_id Integer 파일 ID
|
||||
request_count Integer 고객파일에 추가 요청한 사용자 수
|
||||
success_count Integer 고객파일에 추가된 사용자 수
|
||||
예제
|
||||
요청
|
||||
curl -v -X POST "https://kapi.kakao.com/v1/talkchannel/update/target_users" \
|
||||
-H "Authorization: KakaoAK ${APP_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"file_id": 437,
|
||||
"channel_public_id": "_ZeUTxl",
|
||||
"user_type": "app",
|
||||
"users": [
|
||||
{
|
||||
"id": "12345",
|
||||
"field" : {
|
||||
"생년월일": "2000-01-01",
|
||||
"성별": "남자",
|
||||
"age": 19
|
||||
}
|
||||
},
|
||||
...
|
||||
]
|
||||
}'
|
||||
응답
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json;charset=UTF-8
|
||||
{
|
||||
"file_id": 437,
|
||||
"request_count": 10,
|
||||
"success_count": 9
|
||||
}
|
||||
고객 관리: 고객파일의 사용자 삭제
|
||||
기본 정보
|
||||
메서드 URL 인증 방식
|
||||
POST https://kapi.kakao.com/v1/talkchannel/delete/target_users REST API 키
|
||||
서비스 앱 어드민 키
|
||||
권한 사전 설정 카카오 로그인 동의항목
|
||||
- REST API 키
|
||||
어드민 키
|
||||
고객 관리 API 정책 동의 - -
|
||||
|
||||
카카오톡 채널에 등록된 고객파일에서 특정 사용자를 삭제합니다. 고객파일에서 사용자를 삭제할 때는 성공 시에도 응답 본문이 없습니다. HTTP 상태 코드를 참고해 성공 여부를 판단합니다.
|
||||
|
||||
요청
|
||||
헤더
|
||||
이름 설명 필수
|
||||
Authorization Authorization: KakaoAK ${APP_KEY}
|
||||
인증 방식, REST API 키 또는 서비스 앱 어드민 키로 인증 요청 O
|
||||
본문
|
||||
이름 타입 설명 필수
|
||||
file_id Integer 파일 ID O
|
||||
channel_public_id String 카카오톡 채널 프로필 ID
|
||||
|
||||
참고: 카카오톡 채널 프로필 ID 확인 방법 O
|
||||
user_type String 삭제할 사용자 ID의 기준 값
|
||||
app(회원번호) 또는 phone(카카오톡 전화번호) 중 하나 O
|
||||
user_ids JSON[] 삭제할 사용자 ID 목록 O
|
||||
예제
|
||||
요청
|
||||
curl -v -X POST "https://kapi.kakao.com/v1/talkchannel/delete/target_users" \
|
||||
-H "Authorization: KakaoAK ${APP_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"file_id" : 437,
|
||||
"channel_public_id" : "_ZeUTxl",
|
||||
"user_type" : "app"
|
||||
"user_ids" : ["12345"]
|
||||
}'
|
||||
응답
|
||||
HTTP/1.1 200 OK
|
||||
Content-Length: 0
|
||||
Content-Type: application/json;charset=UTF-8
|
||||
더 보기
|
||||
146
docs/kakao-channel-integration.md
Normal file
146
docs/kakao-channel-integration.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# 카카오톡 채널 연동 분석
|
||||
|
||||
## 현재 상태
|
||||
- 카카오톡 채널 개설 완료
|
||||
- 앱과 채널 연결 완료
|
||||
|
||||
---
|
||||
|
||||
## API 종류 및 우리 서비스 적용 가능성
|
||||
|
||||
### 1. 채널 관계 조회 API
|
||||
- **기능**: 사용자가 우리 채널을 친구 추가했는지 확인
|
||||
- **우선순위**: 낮음 (나중에)
|
||||
- **활용**: 적립 완료 시 채널 친구가 아니면 "채널 추가하면 생일 2배 적립 알림을 받을 수 있어요!" 유도 배너
|
||||
|
||||
### 2. 고객파일 관리 API
|
||||
- **기능**: 채널 친구인 고객의 데이터를 카카오에 업로드 → 파트너센터에서 세그먼트 필터링
|
||||
- **우선순위**: 낮음 (고객 수백 명 이상 쌓인 후)
|
||||
- **활용**: 파트너센터에서 "포인트 5000 이상 고객"에게 친구톡 발송 등
|
||||
|
||||
#### 고객파일 스키마 (사용 가능한 키)
|
||||
```
|
||||
생년월일, 국가, 지역, 성별, 연령, 구매금액, 포인트, 가입일, 최근 구매일, 응모일
|
||||
```
|
||||
- 이 값들은 **카카오가 제공하는 것이 아님**
|
||||
- **우리 DB에서 꺼내서 카카오에 업로드**하는 것
|
||||
- 고객 본인이 카카오톡에서 보는 게 아니라, **약국(관리자)이 파트너센터에서 고객 분류/메시지 발송할 때 사용**하는 필터링 기준
|
||||
|
||||
---
|
||||
|
||||
## 메시지 발송 수단 비교
|
||||
|
||||
### 알림톡 (정보성 메시지)
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 대상 | 전화번호만 있으면 **누구에게나** (채널 친구 불필요) |
|
||||
| 템플릿 | 카카오 사전 심사 필수 (정형화된 형식) |
|
||||
| 용도 | 정보성 메시지 (적립 완료 알림, 주문 확인 등) |
|
||||
| 비용 | ~8원/건 |
|
||||
| 발송 방법 | NHN Cloud 알림톡 API |
|
||||
|
||||
### 친구톡 (마케팅 메시지)
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 대상 | **채널 친구에게만** |
|
||||
| 템플릿 | 자유 형식 (이미지, 버튼 등 자유롭게 구성) |
|
||||
| 용도 | 광고/마케팅 메시지 (생일 이벤트, 프로모션 등) |
|
||||
| 비용 | ~15원/건 |
|
||||
| 발송 방법 | NHN Cloud 친구톡 API 또는 카카오 파트너센터에서 직접 발송 |
|
||||
|
||||
### SMS/LMS (문자)
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 대상 | 전화번호만 있으면 누구에게나 |
|
||||
| 템플릿 | 제한 없음 |
|
||||
| 용도 | 범용 |
|
||||
| 비용 | SMS ~20원, LMS ~50원 |
|
||||
| 발송 방법 | NHN Cloud SMS API |
|
||||
|
||||
### 카카오 파트너센터 직접 발송
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 대상 | 채널 친구만 |
|
||||
| 방법 | 파트너센터 웹에서 수동 발송 |
|
||||
| 활용 | 고객파일 세그먼트 기반 타겟 메시지 |
|
||||
| 특징 | API 개발 불필요, UI에서 직접 조작 |
|
||||
|
||||
---
|
||||
|
||||
## 우리 서비스에 적용할 알림톡 시나리오
|
||||
|
||||
### 시나리오 1: QR 적립 완료 알림 (현재 불필요 → 키오스크 도입 시 필요)
|
||||
|
||||
현재는 고객이 직접 QR 스캔 → 적립 완료 화면을 본인이 확인하므로 알림 불필요.
|
||||
|
||||
**키오스크 도입 후**: 약사가 키오스크에서 직접 적립 → 고객은 화면을 못 봄 → 알림톡 필요
|
||||
|
||||
```
|
||||
[청춘약국] 마일리지 적립 완료
|
||||
|
||||
{고객명}님, 마일리지가 적립되었습니다.
|
||||
|
||||
- 적립 포인트: +3,500P
|
||||
- 총 잔액: 12,800P
|
||||
- 적립일시: 2026.02.25 14:30
|
||||
|
||||
▶ 내역 확인: https://mile.0bin.in/my-page
|
||||
```
|
||||
|
||||
### 시나리오 2: 포인트 사용 알림
|
||||
|
||||
```
|
||||
[청춘약국] 포인트 사용 완료
|
||||
|
||||
{고객명}님, 포인트가 사용되었습니다.
|
||||
|
||||
- 사용 포인트: -5,000P
|
||||
- 남은 잔액: 7,800P
|
||||
|
||||
▶ 내역 확인: https://mile.0bin.in/my-page
|
||||
```
|
||||
|
||||
### 시나리오 3: 생일 축하 (친구톡 — 채널 친구만)
|
||||
|
||||
```
|
||||
🎂 {고객명}님, 생일 축하드립니다!
|
||||
|
||||
오늘 청춘약국에서 구매하시면
|
||||
마일리지 포인트 2배 적립!
|
||||
|
||||
▶ 청춘약국 방문하기
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 로드맵
|
||||
|
||||
### Phase 1 (현재)
|
||||
- [x] 카카오 채널 개설 및 앱 연결
|
||||
- [ ] 카카오 스코프 심사 통과 (phone_number, birthday, birthyear)
|
||||
|
||||
### Phase 2 (키오스크 도입 시)
|
||||
- [ ] NHN Cloud 알림톡 API 연동
|
||||
- [ ] 알림톡 템플릿 등록 (적립 완료, 포인트 사용)
|
||||
- [ ] 키오스크 적립 시 알림톡 자동 발송
|
||||
|
||||
### Phase 3 (고객 확보 후)
|
||||
- [ ] 채널 친구 추가 유도 (적립 완료 화면에 배너)
|
||||
- [ ] 생일 축하 친구톡 발송 (birthday 데이터 활용)
|
||||
- [ ] (선택) 카카오 고객파일 동기화 → 파트너센터 세그먼트 마케팅
|
||||
|
||||
---
|
||||
|
||||
## 필요한 환경변수 (Phase 2 시점)
|
||||
|
||||
```env
|
||||
# NHN Cloud 알림톡
|
||||
NHN_CLOUD_APP_KEY=xxx
|
||||
NHN_CLOUD_SECRET_KEY=xxx
|
||||
NHN_ALIMTALK_SENDER_KEY=xxx # 카카오 채널 발신 프로필 키
|
||||
NHN_ALIMTALK_TEMPLATE_CODE=xxx # 적립 완료 템플릿 코드
|
||||
```
|
||||
|
||||
## 참고
|
||||
- 알림톡 템플릿은 카카오 비즈니스 채널 관리자에서 등록 후 검수 받아야 함 (1~2일 소요)
|
||||
- NHN Cloud 알림톡 발송 시 카카오톡 미설치 사용자에게는 자동으로 SMS 대체 발송 가능 (추가 비용)
|
||||
239
docs/kakao-oauth-setup.md
Normal file
239
docs/kakao-oauth-setup.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# 카카오 OAuth 설정 가이드
|
||||
|
||||
## 카카오 앱 정보
|
||||
|
||||
- **앱 ID**: 1165131
|
||||
- **앱 이름**: 청춘약국
|
||||
- **앱 유형**: 비즈 앱
|
||||
- **개발자 콘솔**: https://developers.kakao.com/console/app/1165131
|
||||
|
||||
---
|
||||
|
||||
## 플랫폼 키 (앱 > 플랫폼 키)
|
||||
|
||||
| 키 종류 | 값 | 용도 |
|
||||
|---------|---|------|
|
||||
| **Native App Key** | `346b84c4e018e20f0f8` | Android/iOS 네이티브 앱 (현재 미사용) |
|
||||
| **JavaScript Key** | `3d1e098107157c5021b73bd5ab48600f` | 카카오 JS SDK (프론트엔드) |
|
||||
| **REST API Key** | `caad27ac4bc92d8dc83bdd6aae744811` | 서버 간 API 호출 (현재 사용 중) |
|
||||
| **Admin Key** | (콘솔에서 확인) | 서버 관리 기능 (사용 주의) |
|
||||
|
||||
### 키 사용 구분
|
||||
|
||||
```
|
||||
[현재 구현] REST API 방식
|
||||
프론트엔드 → 302 리다이렉트 → 카카오 웹 로그인 페이지 → 콜백
|
||||
사용 키: REST API Key (서버 환경변수 KAKAO_CLIENT_ID)
|
||||
|
||||
[향후 전환] JS SDK 방식
|
||||
프론트엔드 → Kakao.Auth.authorize() → 카카오톡 앱 직접 실행 → 콜백
|
||||
사용 키: JavaScript Key (프론트엔드 HTML에 노출)
|
||||
```
|
||||
|
||||
### Client Secret
|
||||
|
||||
```
|
||||
앱 > 보안 > Client Secret 코드
|
||||
```
|
||||
- 환경변수: `KAKAO_CLIENT_SECRET`
|
||||
- REST API 토큰 교환 시 필수
|
||||
|
||||
---
|
||||
|
||||
## REST API vs JS SDK 비교
|
||||
|
||||
| 항목 | REST API (폴백) | JS SDK (현재 적용) |
|
||||
|------|----------------|-------------------|
|
||||
| **인증 키** | REST API Key | JavaScript Key |
|
||||
| **로그인 UX** | 웹 브라우저에서 카카오 로그인 페이지 표시 (매번 동의 확인) | 카카오톡 앱이 직접 열림 → 원탭 동의 → 즉시 복귀 |
|
||||
| **모바일 경험** | 웹뷰 로그인 (느림) | 앱 ↔ 앱 전환 (빠름) |
|
||||
| **앱 미설치 시** | 웹 로그인 표시 | 자동으로 웹 로그인 폴백 |
|
||||
| **백엔드** | `kakao_client.get_authorization_url()` | 변경 없음 (콜백 동일) |
|
||||
| **보안** | 키가 서버에만 존재 | JavaScript Key는 공개 가능 (도메인 제한으로 보호) |
|
||||
|
||||
### 현재 적용 상태 (JS SDK)
|
||||
|
||||
JS SDK가 적용된 페이지:
|
||||
- `claim_form.html` — QR 적립 시 "카카오로 적립하기" 버튼
|
||||
- `my_page_login.html` — 마이페이지 "카카오로 조회하기" 버튼
|
||||
|
||||
서버 리다이렉트 유지 페이지 (보조 진입점):
|
||||
- `index.html`, `my_page.html`, `signup.html`, `error.html` → `/my-page/kakao/start`
|
||||
|
||||
### JS SDK 동작 방식
|
||||
|
||||
```
|
||||
모바일:
|
||||
카카오톡 앱 설치됨 → 앱으로 전환 (원탭 로그인) → 콜백
|
||||
카카오톡 앱 미설치 → 웹 로그인 페이지로 자동 폴백 → 콜백
|
||||
|
||||
PC:
|
||||
항상 웹 로그인 페이지 표시 → 콜백
|
||||
|
||||
JS SDK 로드 실패 시:
|
||||
서버 리다이렉트 폴백 (/claim/kakao/start 또는 /my-page/kakao/start)
|
||||
```
|
||||
|
||||
### 다른 카카오 계정으로 적립 (향후 구현)
|
||||
|
||||
폰이 2대이거나 다른 계정으로 적립하고 싶은 경우:
|
||||
|
||||
```javascript
|
||||
// 기본: 카카오톡 앱 계정으로 바로 로그인
|
||||
Kakao.Auth.authorize({
|
||||
redirectUri: '...',
|
||||
state: '...'
|
||||
});
|
||||
|
||||
// 다른 계정으로: 기존 세션 무시, 계정 입력 강제
|
||||
Kakao.Auth.authorize({
|
||||
redirectUri: '...',
|
||||
state: '...',
|
||||
prompt: 'login' // ← 핵심 파라미터
|
||||
});
|
||||
```
|
||||
|
||||
UI 구성안:
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ [카카오로 적립하기] │ ← 기본 (앱 → 원탭)
|
||||
│ │
|
||||
│ 다른 카카오 계정으로 적립 → │ ← prompt:'login'
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 카카오 개발자 콘솔 필수 설정
|
||||
|
||||
> **중요**: JS SDK 사용 시 JavaScript 키에도 Redirect URI 등록 필요
|
||||
|
||||
```
|
||||
앱 > 플랫폼 키 > JavaScript 키 클릭 > 리다이렉트 URI
|
||||
→ https://mile.0bin.in/claim/kakao/callback 추가
|
||||
```
|
||||
|
||||
Web 플랫폼 도메인도 등록 확인:
|
||||
```
|
||||
앱 > 플랫폼 > Web > 사이트 도메인
|
||||
→ https://mile.0bin.in 포함 확인
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Redirect URI 등록 (2025년 12월 개편 후)
|
||||
|
||||
> **주의**: 2025년 12월 카카오 콘솔 UI가 개편되면서 Redirect URI 위치가 변경됨.
|
||||
> 기존에는 `카카오 로그인 > 일반`에 있었지만, 현재는 `앱 > 플랫폼 키` 하위로 이동됨.
|
||||
|
||||
### 현재 경로 (2025.12~ )
|
||||
|
||||
```
|
||||
앱 > 플랫폼 키 > REST API 키 클릭 > 리다이렉트 URI
|
||||
```
|
||||
|
||||
### 등록된 Redirect URI 목록
|
||||
|
||||
| 서비스 | Redirect URI |
|
||||
|--------|-------------|
|
||||
| 게시판 (board-system) | `https://bbs.0bin.in/auth/kakao/callback` |
|
||||
| 마일리지 적립 (pharmacy-pos-qr) | `https://mile.0bin.in/claim/kakao/callback` |
|
||||
|
||||
## 로그아웃 Redirect URI (별도)
|
||||
|
||||
로그아웃용 Redirect URI는 **다른 위치**에서 설정:
|
||||
|
||||
```
|
||||
카카오 로그인 > 고급 > 로그아웃 리다이렉트 URI
|
||||
```
|
||||
|
||||
로그인용 Redirect URI와 혼동하지 않도록 주의.
|
||||
|
||||
---
|
||||
|
||||
## 웹 도메인 등록
|
||||
|
||||
```
|
||||
앱 > 제품 링크 관리 > 웹 도메인
|
||||
```
|
||||
|
||||
등록된 도메인 (최대 10개):
|
||||
- `https://img.0bin.in` (기본)
|
||||
- `https://api.0bin.in`
|
||||
- `https://0bin.in`
|
||||
- `https://bbs.0bin.in`
|
||||
- `https://drug.0bin.in`
|
||||
- `https://ani.0bin.in`
|
||||
- `https://figma.0bin.in`
|
||||
- `https://am.0bin.in`
|
||||
- `https://ka.0bin.in`
|
||||
- `https://mile.0bin.in`
|
||||
|
||||
---
|
||||
|
||||
## 동의항목 설정
|
||||
|
||||
```
|
||||
카카오 로그인 > 동의항목
|
||||
```
|
||||
|
||||
| 항목 | ID | 동의 목적 | 상태 | 비즈앱 필요 |
|
||||
|------|-----|----------|------|------------|
|
||||
| 닉네임 | profile_nickname | 사용자 식별 | 승인 | X |
|
||||
| 프로필 사진 | profile_image | 아바타 표시 | 승인 | X |
|
||||
| 이메일 | account_email | 계정 연동 | 승인 | X |
|
||||
| 이름 (실명) | name | 마일리지 적립자명 | 승인 | O |
|
||||
| 전화번호 | phone_number | 마일리지 적립 계정 식별 및 포인트 조회 | 승인 | O |
|
||||
| 생일 | birthday | 생일 기념 포인트 2배 적립 이벤트 제공 | 승인 | O |
|
||||
| 출생연도 | birthyear | 생일 기념 포인트 2배 적립 이벤트 제공 | 권한 없음 (미승인) | O |
|
||||
|
||||
### 현재 사용 중인 스코프
|
||||
|
||||
```
|
||||
profile_nickname,profile_image,account_email,name,phone_number,birthday
|
||||
```
|
||||
|
||||
> ⚠️ `birthyear`는 아직 권한 미승인 상태. 스코프에 포함하면 **KOE205 에러** 발생.
|
||||
> 승인되면 스코프에 추가하고, `kakao_client.py`의 scope 문자열 수정 필요.
|
||||
|
||||
---
|
||||
|
||||
## 환경변수
|
||||
|
||||
```bash
|
||||
# 카카오 OAuth (REST API 방식)
|
||||
KAKAO_CLIENT_ID=caad27ac4bc92d8dc83bdd6aae744811 # REST API Key
|
||||
KAKAO_CLIENT_SECRET=<카카오 개발자 콘솔 > 앱 > 보안에서 확인>
|
||||
KAKAO_REDIRECT_URI=https://mile.0bin.in/claim/kakao/callback
|
||||
|
||||
# JS SDK 전환 시 추가 (프론트엔드 전용, 서버 환경변수 불필요)
|
||||
# JavaScript Key: 3d1e098107157c5021b73bd5ab48600f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
| 프로젝트 | 파일 | 설명 |
|
||||
|---------|------|------|
|
||||
| pharmacy-pos-qr-system | `backend/services/kakao_client.py` | 카카오 API 클라이언트 (REST API 방식) |
|
||||
| pharmacy-pos-qr-system | `backend/app.py` | OAuth 라우트 (`/claim/kakao/*`) |
|
||||
| board-system-project | `backend/services/kakao_client.py` | 카카오 API 클라이언트 (원본) |
|
||||
| board-system-project | `backend/routes/auth.py` | OAuth 라우트 (`/auth/kakao/*`) |
|
||||
|
||||
---
|
||||
|
||||
## 카카오 데이터 포맷 참고
|
||||
|
||||
| 필드 | 포맷 | 예시 | DB 저장 |
|
||||
|------|------|------|---------|
|
||||
| birthday | MMDD | `0315` | `YYYY-MM-DD`로 변환 |
|
||||
| birthyear | YYYY | `1990` | birthday와 결합 |
|
||||
| phone_number | +82 10-XXXX-XXXX | `+82 10-2130-7390` | 하이픈/국가코드 제거 후 저장 |
|
||||
|
||||
---
|
||||
|
||||
## 참고 링크
|
||||
|
||||
- [카카오 로그인 REST API 문서](https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api)
|
||||
- [카카오 JS SDK 문서](https://developers.kakao.com/docs/latest/ko/javascript/getting-started)
|
||||
- [카카오 로그인 설정하기](https://developers.kakao.com/docs/latest/ko/kakaologin/prerequisite)
|
||||
- [카카오 앱 키 구조 개편 공지 (2025.12)](https://devtalk.kakao.com/t/upcoming-kakao-developers-app-key-update/147295)
|
||||
144
docs/kakao-phone-request.md
Normal file
144
docs/kakao-phone-request.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# 카카오 개인정보 동의항목 - 추가 스코프 신청
|
||||
|
||||
## 요청 스코프 목록
|
||||
|
||||
| 스코프 | 항목 | 필수/선택 | 현재 상태 |
|
||||
|--------|------|-----------|-----------|
|
||||
| `profile_nickname` | 닉네임 | 필수 | 승인됨 |
|
||||
| `profile_image` | 프로필 이미지 | 필수 | 승인됨 |
|
||||
| `account_email` | 이메일 | 선택 | 승인됨 |
|
||||
| `name` | 이름 | 필수 | 승인됨 |
|
||||
| `phone_number` | 전화번호 | 필수 | **신청 필요** |
|
||||
| `birthday` | 생일 (월/일) | 선택 | **신청 필요** |
|
||||
| `birthyear` | 출생연도 | 선택 | **신청 필요** |
|
||||
|
||||
---
|
||||
|
||||
## 1. 전화번호 (phone_number) 수집 사유
|
||||
|
||||
```
|
||||
청춘약국 마일리지 적립 서비스에서 고객 식별을 위해 전화번호가 필요합니다.
|
||||
|
||||
[서비스 개요]
|
||||
오프라인 약국(청춘약국)에서 의약품 구매 시 영수증 QR 코드를 스캔하면
|
||||
구매금액의 3%를 마일리지 포인트로 적립해주는 서비스입니다.
|
||||
|
||||
[전화번호가 필요한 이유]
|
||||
1. 고객 식별: 동일 고객의 마일리지를 하나의 계정으로 통합 관리하기 위해
|
||||
전화번호를 고유 식별자로 사용합니다.
|
||||
2. 포인트 조회: 고객이 마이페이지에서 자신의 적립 내역과 잔액을
|
||||
전화번호로 조회합니다.
|
||||
3. 오프라인 연계: 약국 방문 시 포인트 사용을 위해 전화번호로
|
||||
본인 확인이 필요합니다.
|
||||
4. 동명이인 구분: 이름만으로는 동일인 여부를 확인할 수 없어,
|
||||
전화번호가 필수적인 고유 식별 수단입니다.
|
||||
|
||||
[현재 상황]
|
||||
전화번호를 카카오에서 받지 못하는 경우, 카카오 로그인 후
|
||||
별도의 전화번호 입력 화면을 추가로 거쳐야 합니다.
|
||||
카카오 계정의 전화번호를 직접 받을 수 있으면 입력 단계를
|
||||
생략하여 사용자 경험이 크게 개선됩니다.
|
||||
|
||||
[수집 범위]
|
||||
- 수집 항목: 전화번호 (카카오 계정에 등록된 번호)
|
||||
- 이용 목적: 마일리지 적립 계정 식별 및 포인트 조회
|
||||
- 보유 기간: 회원 탈퇴 시까지
|
||||
- 제3자 제공: 없음
|
||||
```
|
||||
|
||||
## 2. 생일/출생연도 (birthday, birthyear) 수집 사유
|
||||
|
||||
```
|
||||
청춘약국 마일리지 서비스에서 생년월일 정보를 선택적으로 수집하여
|
||||
맞춤형 서비스를 제공하고자 합니다.
|
||||
|
||||
[생년월일이 필요한 이유]
|
||||
1. 생일 기념 이벤트: 회원 생일에 마일리지 포인트 2배 적립 이벤트를
|
||||
제공합니다. 생일 당일 구매 시 기본 적립률(3%) 대신 6%를 적립합니다.
|
||||
2. 연령대별 맞춤 건강 정보: 구매 이력과 연령대를 결합하여
|
||||
맞춤 건강 정보 및 추천 제품을 안내합니다.
|
||||
(예: 50대 이상 → 관절/영양 보충제 정보, 20~30대 → 피부/다이어트 관련)
|
||||
3. 서비스 통계: 연령대별 이용 현황 분석으로 서비스 품질을 개선합니다.
|
||||
|
||||
[선택 항목]
|
||||
생년월일은 선택 수집 항목이며, 미제공 시에도 마일리지 적립·조회 등
|
||||
기본 서비스 이용에는 제한이 없습니다.
|
||||
|
||||
[수집 범위]
|
||||
- 수집 항목: 생일(월/일), 출생연도
|
||||
- 이용 목적: 생일 이벤트, 연령대별 맞춤 서비스
|
||||
- 보유 기간: 회원 탈퇴 시까지
|
||||
- 제3자 제공: 없음
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 회원가입 시나리오 (회원가입 화면 설명용)
|
||||
|
||||
### 시나리오 1: 직접 회원가입 (수동)
|
||||
|
||||
```
|
||||
1. https://mile.0bin.in 접속
|
||||
2. "회원가입" 메뉴 클릭 → https://mile.0bin.in/signup 이동
|
||||
3. 이름(필수), 전화번호(필수), 생년월일(선택) 입력
|
||||
4. 개인정보 수집·이용 동의 체크
|
||||
5. "가입하기" 버튼 클릭
|
||||
6. 가입 완료 → 마이페이지 이동 가능
|
||||
```
|
||||
|
||||
### 시나리오 2: 카카오 로그인으로 QR 적립 (메인 플로우)
|
||||
|
||||
```
|
||||
1. 고객이 약국에서 의약품 구매 후 영수증을 받음
|
||||
2. 영수증에 인쇄된 QR 코드를 스마트폰 카메라로 스캔
|
||||
3. https://mile.0bin.in/claim?t=거래번호:인증코드 페이지 이동
|
||||
4. "카카오로 적립하기" 버튼 클릭
|
||||
5. 카카오 로그인 동의 화면 표시 (닉네임, 프로필, 이메일, 이름, 전화번호, 생년월일)
|
||||
6-A. [전화번호 수집 가능 시] → 자동으로 마일리지 적립 완료
|
||||
6-B. [전화번호 미수집 시] → 전화번호 입력 화면으로 이동 → 수동 입력 후 적립
|
||||
7. 적립 완료 화면 표시 (적립 포인트, 총 잔액)
|
||||
```
|
||||
|
||||
### 시나리오 3: 카카오 로그인으로 마이페이지 조회
|
||||
|
||||
```
|
||||
1. https://mile.0bin.in 접속
|
||||
2. "카카오로 시작하기" 클릭
|
||||
3. 카카오 로그인 → 카카오 ID로 기존 회원 매칭
|
||||
4. 마이페이지 이동 (적립 내역, 포인트 잔액 확인)
|
||||
```
|
||||
|
||||
### 시나리오 4: PWA 앱에서 자동 적립 (재방문 고객)
|
||||
|
||||
```
|
||||
1. 이전에 카카오 로그인으로 적립한 이력이 있는 고객
|
||||
2. 홈 화면의 "청춘약국" PWA 앱에서 QR 스캔
|
||||
3. 세션이 유지되어 있으므로 입력 없이 자동 적립 완료
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 제출 체크리스트
|
||||
|
||||
### 전화번호 (phone_number) 신청
|
||||
- [x] 회원가입 링크: `https://mile.0bin.in/signup`
|
||||
- [x] 개인정보 처리방침: `https://mile.0bin.in/privacy`
|
||||
- [ ] 회원가입 화면 스크린샷: `/signup` 페이지 캡처 (전화번호 필드 + 수집 목적 안내 + 개인정보 동의 포함)
|
||||
- [ ] 수집 사유: 위 "1. 전화번호 수집 사유" 텍스트 복사하여 입력
|
||||
|
||||
### 생일/출생연도 (birthday, birthyear) 신청
|
||||
- [x] 회원가입 링크: `https://mile.0bin.in/signup`
|
||||
- [x] 개인정보 처리방침: `https://mile.0bin.in/privacy`
|
||||
- [ ] 회원가입 화면 스크린샷: `/signup` 페이지 캡처 (생년월일 필드 + "선택" 배지 + 수집 목적 안내 포함)
|
||||
- [ ] 수집 사유: 위 "2. 생일/출생연도 수집 사유" 텍스트 복사하여 입력
|
||||
|
||||
## 스크린샷 촬영 가이드
|
||||
|
||||
회원가입 화면으로 제출할 스크린샷:
|
||||
1. `https://mile.0bin.in/signup` 페이지 캡처
|
||||
- "수집 항목 및 이용 목적" 안내 카드가 보이게
|
||||
- 전화번호(필수), 이름(필수), 생년월일(선택) 필드가 보이게
|
||||
- 각 필드 아래 수집 목적 설명이 보이게
|
||||
- 개인정보 동의 체크박스가 보이게
|
||||
2. 캡처 시 개인정보(전화번호 등)가 보이면 마스킹 처리
|
||||
3. 화면이 길면 2장으로 나누어 캡처 (상단: 수집 항목 안내 + 입력 필드, 하단: 동의 + 가입 버튼)
|
||||
168
docs/kakao-troubleshooting.md
Normal file
168
docs/kakao-troubleshooting.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# 카카오 OAuth 트러블슈팅 가이드
|
||||
|
||||
## 에러 코드 목록
|
||||
|
||||
### KOE101 - 앱 관리자 설정 오류 / 플랫폼키 에러
|
||||
|
||||
**원인**: `KAKAO_CLIENT_ID`(REST API 키)가 비어있거나 잘못된 값
|
||||
|
||||
**해결**:
|
||||
1. `backend/.env` 파일이 존재하는지 확인
|
||||
2. `KAKAO_CLIENT_ID` 값이 올바른지 확인
|
||||
3. 카카오 개발자 콘솔 > 앱 > 플랫폼 키 > REST API 키에서 확인
|
||||
|
||||
```bash
|
||||
# backend/.env
|
||||
KAKAO_CLIENT_ID=caad27ac4bc92d8dc83bdd6aae744811
|
||||
```
|
||||
|
||||
### KOE205 - Invalid scope
|
||||
|
||||
**원인**: 요청한 OAuth scope가 앱에서 허용되지 않음
|
||||
|
||||
**해결**:
|
||||
- `name`, `phone_number` scope는 **비즈앱 심사** 후 동의항목에서 별도 활성화 필요
|
||||
- 기본 scope만 사용: `profile_nickname,profile_image,account_email`
|
||||
- 파일: `backend/services/kakao_client.py` > `get_authorization_url()` > `scope` 파라미터
|
||||
|
||||
**동의항목 활성화 경로**:
|
||||
```
|
||||
카카오 개발자 콘솔 > 카카오 로그인 > 동의항목
|
||||
```
|
||||
|
||||
| scope | 비즈앱 필요 | 현재 상태 |
|
||||
|-------|-----------|----------|
|
||||
| profile_nickname | X | 사용 중 |
|
||||
| profile_image | X | 사용 중 |
|
||||
| account_email | X | 사용 중 |
|
||||
| name | O | 미사용 (비즈앱 심사 필요) |
|
||||
| phone_number | O | 미사용 (비즈앱 심사 필요) |
|
||||
|
||||
### KOE320 - authorization code not found
|
||||
|
||||
**원인**: 카카오 authorization code가 만료되었거나 이미 사용됨
|
||||
|
||||
**해결**:
|
||||
- code는 **1회용**이며 발급 후 수 분 내 사용해야 함
|
||||
- 브라우저 새로고침으로 같은 code를 재사용하면 발생
|
||||
- 사용자에게 다시 카카오 로그인하도록 안내
|
||||
|
||||
### UnboundLocalError: kakao_client
|
||||
|
||||
**원인**: 콜백 핸들러에서 `kakao_client` 변수가 초기화되기 전에 접근
|
||||
|
||||
**상황**: 마이페이지 카카오 조회 시, 콜백 함수 상단의 `purpose == 'mypage'` 분기에서
|
||||
`kakao_client` 변수가 아직 할당되지 않은 상태에서 사용
|
||||
|
||||
**해결**: `get_kakao_client()` 함수를 직접 호출
|
||||
```python
|
||||
# 잘못된 코드
|
||||
if state_data.get('purpose') == 'mypage':
|
||||
return _handle_mypage_kakao_callback(code, kakao_client) # UnboundLocalError
|
||||
|
||||
# 올바른 코드
|
||||
if state_data.get('purpose') == 'mypage':
|
||||
return _handle_mypage_kakao_callback(code, get_kakao_client())
|
||||
```
|
||||
|
||||
### ModuleNotFoundError: No module named 'requests'
|
||||
|
||||
**원인**: `requests` 라이브러리 미설치
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
pip install requests
|
||||
```
|
||||
|
||||
## 카카오 개발자 콘솔 주의사항
|
||||
|
||||
### Redirect URI 위치 (2025년 12월 개편)
|
||||
|
||||
**현재 경로** (2025.12~):
|
||||
```
|
||||
앱 > 플랫폼 키 > REST API 키 클릭 > 리다이렉트 URI
|
||||
```
|
||||
|
||||
**혼동하기 쉬운 위치**:
|
||||
- `카카오 로그인 > 일반` - 여기에는 더 이상 Redirect URI 없음 (Webhook만 있음)
|
||||
- `카카오 로그인 > 고급 > 로그아웃 리다이렉트 URI` - 로그아웃용이므로 혼동 주의
|
||||
|
||||
자세한 설정 가이드: [kakao-oauth-setup.md](./kakao-oauth-setup.md)
|
||||
|
||||
### Client Secret 위치
|
||||
|
||||
```
|
||||
앱 > 플랫폼 키 > REST API 키 클릭 > 클라이언트 시크릿
|
||||
```
|
||||
|
||||
활성화 상태 확인 필수. 비활성화 시 토큰 교환 실패.
|
||||
|
||||
## 환경변수 체크리스트
|
||||
|
||||
```bash
|
||||
# backend/.env (git에 포함되지 않음)
|
||||
KAKAO_CLIENT_ID=<REST API 키>
|
||||
KAKAO_CLIENT_SECRET=<클라이언트 시크릿 코드>
|
||||
KAKAO_REDIRECT_URI=https://mile.0bin.in/claim/kakao/callback
|
||||
```
|
||||
|
||||
## 로컬 개발 환경 주의사항
|
||||
|
||||
로컬(192.168.0.14:7001)에서 테스트 시:
|
||||
1. 카카오 버튼 클릭 → 카카오 로그인 화면 (정상)
|
||||
2. 로그인 후 `https://mile.0bin.in/claim/kakao/callback`로 리다이렉트됨
|
||||
3. 로컬이 아닌 **운영 서버**로 돌아감
|
||||
|
||||
로컬에서 완전한 테스트를 하려면:
|
||||
- `KAKAO_REDIRECT_URI`를 `http://192.168.0.14:7001/claim/kakao/callback`로 변경
|
||||
- 카카오 콘솔에도 해당 URI 등록 필요
|
||||
- 테스트 후 반드시 원래 값으로 복원
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
### 카카오 적립 (QR 스캔 → 카카오 로그인)
|
||||
|
||||
```
|
||||
QR 스캔 → /claim?t=txn:nonce
|
||||
→ "카카오로 적립하기" 클릭
|
||||
→ /claim/kakao/start?t=txn:nonce
|
||||
→ state={t, csrf} 인코딩 → 카카오 OAuth
|
||||
→ /claim/kakao/callback
|
||||
→ phone_number 없음 → claim_kakao_phone.html (전화번호 입력)
|
||||
→ POST /api/claim/kakao (세션의 kakao_data + 폼의 phone)
|
||||
→ get_or_create_user → link_kakao_identity → claim_mileage
|
||||
→ 성공 화면
|
||||
```
|
||||
|
||||
### 카카오 마이페이지 조회
|
||||
|
||||
```
|
||||
/my-page → "카카오로 조회하기" 클릭
|
||||
→ /my-page/kakao/start
|
||||
→ state={purpose:"mypage", csrf} 인코딩 → 카카오 OAuth
|
||||
→ /claim/kakao/callback (동일 콜백 URL 재사용)
|
||||
→ purpose=="mypage" 감지 → _handle_mypage_kakao_callback()
|
||||
→ find_user_by_kakao_id(kakao_id)
|
||||
→ 유저 발견 → /my-page?phone=xxx 리다이렉트
|
||||
→ 유저 없음 → "카카오 계정에 연결된 적립 정보가 없습니다" 에러
|
||||
```
|
||||
|
||||
### customer_identities 테이블 매핑
|
||||
|
||||
```
|
||||
카카오 적립 시:
|
||||
kakao_id="12345678" + phone="01021307390"
|
||||
→ users 테이블: user_id=7
|
||||
→ customer_identities: {user_id=7, provider="kakao", provider_id="12345678"}
|
||||
|
||||
카카오 조회 시:
|
||||
kakao_id="12345678"
|
||||
→ customer_identities에서 user_id=7 조회
|
||||
→ users 테이블에서 phone="01021307390" 조회
|
||||
→ /my-page?phone=01021307390
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2026-02-25
|
||||
**관련 문서**: [kakao-oauth-setup.md](./kakao-oauth-setup.md)
|
||||
210
docs/member-detail-feature.md
Normal file
210
docs/member-detail-feature.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 회원 상세 기능 구현 계획
|
||||
|
||||
> 작성일: 2026-02-27
|
||||
> 상태: 계획 중
|
||||
|
||||
## 개요
|
||||
|
||||
회원 검색 페이지(`/admin/members`)에서 "상세" 버튼 클릭 시, 해당 회원의 구매 이력 및 QR 적립 내역을 조회하는 기능.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
- ✅ 회원 검색 API (`/api/members/search`) - 완료
|
||||
- ✅ 회원 기본정보 조회 (`/api/members/<cuscode>`) - 완료
|
||||
- ❌ 회원 구매 이력 조회 - 미구현
|
||||
- ❌ QR 적립 내역 연동 - 미구현
|
||||
|
||||
## 데이터 소스
|
||||
|
||||
### 1. 마일리지 DB (SQLite)
|
||||
```
|
||||
파일: backend/db/mileage.db
|
||||
테이블: transactions (적립/사용 내역)
|
||||
|
||||
주요 컬럼:
|
||||
- phone: 전화번호 (010XXXXXXXX)
|
||||
- points: 적립/사용 포인트
|
||||
- type: earn (적립) / use (사용)
|
||||
- amount: 구매금액
|
||||
- created_at: 거래일시
|
||||
- receipt_id: 영수증 ID (연동용)
|
||||
```
|
||||
|
||||
### 2. POS DB (MSSQL - PM_PRES)
|
||||
```
|
||||
테이블: SALE_MAIN (판매 메인)
|
||||
테이블: SALE_SUB (판매 상세 - 품목별)
|
||||
|
||||
SALE_MAIN:
|
||||
- SL_NO_order: 거래번호
|
||||
- SL_NM_custom: 고객명
|
||||
- SL_CD_custom: 고객코드
|
||||
- SL_MY_total: 총액
|
||||
- SL_DT_appl: 거래일자
|
||||
|
||||
SALE_SUB:
|
||||
- SL_NO_order: 거래번호 (FK)
|
||||
- DrugCode: 상품코드
|
||||
- QUAN: 수량
|
||||
- SL_TOTAL_PRICE: 금액
|
||||
```
|
||||
|
||||
### 3. 회원 DB (MSSQL - PM_BASE)
|
||||
```
|
||||
테이블: CD_PERSON
|
||||
|
||||
주요 컬럼:
|
||||
- CUSCODE: 고객코드
|
||||
- PANAME: 이름
|
||||
- PHONE, TEL_NO, PHONE2: 전화번호 3곳
|
||||
```
|
||||
|
||||
## 연동 전략
|
||||
|
||||
### 문제점
|
||||
- POS(SALE_MAIN)에는 `SL_CD_custom`(고객코드) 사용
|
||||
- 마일리지 DB에는 `phone`(전화번호) 사용
|
||||
- **전화번호 → 고객코드** 또는 **고객코드 → 전화번호** 매핑 필요
|
||||
|
||||
### 해결 방안
|
||||
|
||||
#### 방안 1: 전화번호 기반 통합 (권장)
|
||||
```
|
||||
1. CD_PERSON에서 전화번호로 CUSCODE 조회
|
||||
2. CUSCODE로 SALE_MAIN 조회
|
||||
3. 마일리지 DB에서 전화번호로 적립 내역 조회
|
||||
4. 두 결과 병합하여 표시
|
||||
```
|
||||
|
||||
#### 방안 2: 마일리지 테이블에 CUSCODE 추가
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN cuscode TEXT;
|
||||
```
|
||||
- QR 적립 시 POS 고객코드 연동
|
||||
|
||||
## API 설계
|
||||
|
||||
### GET /api/members/<cuscode>/history
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"member": {
|
||||
"cuscode": "0000012345",
|
||||
"name": "김영빈",
|
||||
"phone": "01027027390"
|
||||
},
|
||||
"mileage": {
|
||||
"balance": 19005,
|
||||
"total_earned": 25000,
|
||||
"total_used": 5995,
|
||||
"transactions": [
|
||||
{
|
||||
"date": "2026-02-27 01:29",
|
||||
"type": "earn",
|
||||
"points": 555,
|
||||
"amount": 18500,
|
||||
"products": ["투엑스벤포파워", "마데카솔"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"purchases": [
|
||||
{
|
||||
"date": "20260227",
|
||||
"order_no": "20260227001234",
|
||||
"total": 18500,
|
||||
"items": [
|
||||
{"name": "투엑스벤포파워", "qty": 1, "price": 9000},
|
||||
{"name": "마데카솔연고", "qty": 1, "price": 9500}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## UI 설계
|
||||
|
||||
### 회원 상세 모달
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 👤 김영빈 │
|
||||
│ 📱 010-2702-7390 │
|
||||
│ 💰 잔여 포인트: 19,005P │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [QR 적립 내역] [POS 구매 이력] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 📅 2026-02-27 01:29 │
|
||||
│ +555P (18,500원 구매) │
|
||||
│ └ 투엑스벤포파워, 마데카솔연고 │
|
||||
│ │
|
||||
│ 📅 2026-02-27 01:25 │
|
||||
│ +360P (12,000원 구매) │
|
||||
│ └ 벤포파워Z x2 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [ 메시지 발송 ] [ 닫기 ] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 구현 단계
|
||||
|
||||
### Phase 1: 마일리지 내역 연동 (우선)
|
||||
1. [ ] `/api/members/<phone>/mileage` API 추가
|
||||
2. [ ] SQLite에서 전화번호로 적립/사용 내역 조회
|
||||
3. [ ] 회원 상세 모달 UI 구현
|
||||
|
||||
### Phase 2: POS 구매 이력 연동
|
||||
1. [ ] 전화번호 → CUSCODE 매핑 로직
|
||||
2. [ ] SALE_MAIN/SALE_SUB 조회 API
|
||||
3. [ ] 품목 상세 표시
|
||||
|
||||
### Phase 3: 통합 뷰
|
||||
1. [ ] 마일리지 + POS 데이터 병합
|
||||
2. [ ] 타임라인 형태로 통합 표시
|
||||
3. [ ] 상품 추천 (자주 구매 품목)
|
||||
|
||||
## 예상 쿼리
|
||||
|
||||
### 마일리지 내역 (SQLite)
|
||||
```sql
|
||||
SELECT
|
||||
t.created_at, t.type, t.points, t.amount,
|
||||
u.name, u.phone, u.balance
|
||||
FROM transactions t
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE u.phone = '01027027390'
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 50;
|
||||
```
|
||||
|
||||
### POS 구매 이력 (MSSQL)
|
||||
```sql
|
||||
-- 1. 전화번호로 고객코드 조회
|
||||
SELECT CUSCODE FROM PM_BASE.dbo.CD_PERSON
|
||||
WHERE PHONE = '01027027390' OR TEL_NO = '01027027390';
|
||||
|
||||
-- 2. 고객코드로 구매 이력 조회
|
||||
SELECT
|
||||
M.SL_NO_order, M.SL_DT_appl, M.SL_MY_total,
|
||||
S.DrugCode, G.GoodsName, S.QUAN, S.SL_TOTAL_PRICE
|
||||
FROM PM_PRES.dbo.SALE_MAIN M
|
||||
JOIN PM_PRES.dbo.SALE_SUB S ON M.SL_NO_order = S.SL_NO_order
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE M.SL_CD_custom = '0000012345'
|
||||
ORDER BY M.SL_DT_appl DESC;
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
||||
- 마일리지 테이블 구조: `backend/db/dbsetup.py`
|
||||
- POS 테이블 가이드: `docs/alimipharm-set-product-structure.md`
|
||||
- 회원 검색 API: `backend/app.py` → `/api/members/search`
|
||||
|
||||
---
|
||||
|
||||
## 히스토리
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-02-27 | 계획 문서 작성 |
|
||||
168
docs/user-identity-merge.md
Normal file
168
docs/user-identity-merge.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# 사용자 계정 연동(머지) 시나리오
|
||||
|
||||
## 개요
|
||||
|
||||
청춘약국 마일리지 시스템은 두 가지 경로로 사용자가 생성됩니다:
|
||||
1. **전화번호 경로** — 키오스크에서 번호 입력, 회원가입 폼
|
||||
2. **카카오 경로** — QR 스캔 후 카카오 로그인
|
||||
|
||||
한 고객이 두 경로를 모두 사용할 수 있으므로, **전화번호 기반 유저에 카카오 계정을 연동(머지)** 하는 로직이 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
## 데이터 구조
|
||||
|
||||
```
|
||||
users 테이블
|
||||
├── id (PK)
|
||||
├── nickname ← 키오스크 신규: "고객" / 카카오 연동 후: 실명
|
||||
├── phone ← 전화번호 (유니크 키)
|
||||
├── mileage_balance
|
||||
└── ...
|
||||
|
||||
customer_identities 테이블
|
||||
├── user_id (FK → users.id)
|
||||
├── provider = 'kakao'
|
||||
└── provider_user_id = 카카오 ID
|
||||
```
|
||||
|
||||
**핵심 원칙**: `phone`이 유저의 기본 식별자. 카카오는 부가 연동.
|
||||
|
||||
---
|
||||
|
||||
## 시나리오별 동작
|
||||
|
||||
### 시나리오 1: 키오스크만 사용하는 고객 (가장 흔함)
|
||||
|
||||
```
|
||||
1회차: 키오스크 → 010-1234-5678 입력
|
||||
→ get_or_create_user("01012345678", "고객")
|
||||
→ users: {id: 100, nickname: "고객", phone: "01012345678", balance: 0}
|
||||
→ 500P 적립 → balance: 500
|
||||
|
||||
2회차: 키오스크 → 같은 번호
|
||||
→ get_or_create_user → 기존 user_id=100 반환
|
||||
→ 300P 적립 → balance: 800
|
||||
|
||||
5회차까지: 전부 user_id=100에 누적
|
||||
```
|
||||
|
||||
**결과**: 한 유저에 전부 쌓임. 문제없음.
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 2: 키오스크 N회 → 알림톡 → 카카오 연동 (핵심 플로우)
|
||||
|
||||
```
|
||||
[키오스크 5회 적립] → user_id=100, balance=2,000P, nickname="고객"
|
||||
|
||||
[알림톡 수신] → "적립 내역 확인" 버튼 탭
|
||||
→ https://mile.0bin.in/my-page?phone=01012345678
|
||||
→ 마이페이지 표시 (잔액 2,000P, 거래 5건)
|
||||
|
||||
[카카오 로그인 버튼 클릭]
|
||||
→ _handle_mypage_kakao_callback() 실행
|
||||
→ find_user_by_kakao_id(kakao_id) → 없음
|
||||
→ 카카오에서 전화번호 "01012345678" 수신
|
||||
→ phone으로 users 조회 → user_id=100 발견
|
||||
→ link_kakao_identity(100, kakao_id, ...) ← 여기서 머지!
|
||||
→ nickname "고객" → 카카오 실명 "김철수"로 업데이트
|
||||
→ /my-page?phone=01012345678 리다이렉트
|
||||
```
|
||||
|
||||
**결과**:
|
||||
- user_id=100에 카카오 계정 연결됨
|
||||
- 기존 5건의 적립 내역 그대로 유지
|
||||
- 이름 "고객" → "김철수"로 업데이트
|
||||
- 다음부터 QR 스캔으로도 같은 계정으로 적립
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 3: QR 스캔(카카오) 먼저 → 키오스크 사용
|
||||
|
||||
```
|
||||
[QR 스캔 + 카카오 로그인]
|
||||
→ 카카오에서 phone "01012345678" + kakao_id 수신
|
||||
→ get_or_create_user("01012345678", "김철수")
|
||||
→ user_id=100 생성 (phone + kakao 동시에 연결)
|
||||
→ 500P 적립
|
||||
|
||||
[이후 키오스크 사용] → 010-1234-5678 입력
|
||||
→ get_or_create_user → 같은 phone → user_id=100 반환
|
||||
→ 300P 적립 → balance: 800
|
||||
```
|
||||
|
||||
**결과**: 처음부터 phone + kakao가 연결되어 있으므로 머지 불필요.
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 4: 이미 카카오 연동된 유저가 다시 카카오 로그인
|
||||
|
||||
```
|
||||
[카카오 로그인 (마이페이지)]
|
||||
→ find_user_by_kakao_id(kakao_id) → user_id=100 발견
|
||||
→ 이미 연결됨 → 마이페이지로 리다이렉트
|
||||
```
|
||||
|
||||
**결과**: 중복 연동 방지. `link_kakao_identity()`도 내부적으로 중복 INSERT 방지.
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 5: 카카오에 전화번호가 없는 경우
|
||||
|
||||
```
|
||||
[카카오 로그인 (마이페이지)]
|
||||
→ find_user_by_kakao_id → 없음
|
||||
→ kakao_phone = None (카카오 설정에서 전화번호 미등록)
|
||||
→ 에러: "카카오 계정에 전화번호 정보가 없습니다"
|
||||
```
|
||||
|
||||
**결과**: 연동 불가. 카카오 설정에서 전화번호 등록 안내.
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 6: 알림톡에서 바로 my-page 접근 (카카오 로그인 없이)
|
||||
|
||||
```
|
||||
[알림톡 수신] → "적립 내역 확인" 버튼
|
||||
→ https://mile.0bin.in/my-page?phone=01012345678
|
||||
→ 전화번호로 직접 조회 → 마이페이지 표시
|
||||
→ 카카오 연동 없이 내역만 확인
|
||||
```
|
||||
|
||||
**결과**: 열람만 가능. 카카오 연동은 카카오 로그인 버튼을 눌러야 진행.
|
||||
|
||||
---
|
||||
|
||||
## 연동 시점 정리
|
||||
|
||||
| 진입 경로 | link_kakao_identity 호출 | 비고 |
|
||||
|-----------|-------------------------|------|
|
||||
| QR 스캔 → 카카오 로그인 → 적립 | O | claim 플로우에서 자동 연동 |
|
||||
| 키오스크 → 번호 입력 → 적립 | X | 전화번호만 있음 |
|
||||
| 마이페이지 → 카카오 로그인 | O (신규!) | `_handle_mypage_kakao_callback`에서 머지 |
|
||||
| 알림톡 → 마이페이지 (번호 직접) | X | 카카오 로그인 안 함 |
|
||||
|
||||
---
|
||||
|
||||
## 이름 업데이트 규칙
|
||||
|
||||
| 현재 이름 | 카카오 이름 | 결과 |
|
||||
|-----------|-------------|------|
|
||||
| "고객" | "김철수" | → "김철수" (업데이트) |
|
||||
| "고객" | "고객" or 없음 | → "고객" (유지) |
|
||||
| "김철수" (기존 실명) | "김영희" | → "김철수" (기존 유지, 덮어쓰지 않음) |
|
||||
|
||||
**원칙**: "고객"(키오스크 기본값)인 경우에만 카카오 실명으로 업데이트. 이미 실명이 있으면 유지.
|
||||
|
||||
---
|
||||
|
||||
## 관련 코드
|
||||
|
||||
| 함수 | 파일:라인 | 역할 |
|
||||
|------|-----------|------|
|
||||
| `get_or_create_user()` | app.py:372 | 전화번호로 유저 조회/생성 |
|
||||
| `link_kakao_identity()` | app.py:485 | 카카오 계정을 유저에 연결 |
|
||||
| `find_user_by_kakao_id()` | app.py:519 | 카카오 ID로 유저 조회 |
|
||||
| `_handle_mypage_kakao_callback()` | app.py:793 | 마이페이지 카카오 콜백 (머지 포함) |
|
||||
| `api_kiosk_claim()` | app.py:1986 | 키오스크 적립 + 알림톡 발송 |
|
||||
74
docs/windows-utf8-encoding.md
Normal file
74
docs/windows-utf8-encoding.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Windows 콘솔 한글 인코딩 (UTF-8) 가이드
|
||||
|
||||
## 문제
|
||||
Windows 콘솔 기본 인코딩이 `cp949`여서 Python에서 한글 출력 시 깨짐 발생.
|
||||
Claude Code bash 터미널, cmd, PowerShell 모두 동일 증상.
|
||||
|
||||
```
|
||||
# 깨진 출력 예시
|
||||
{"product": "<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD>", "message": "<22>迵<EFBFBD><E8BFB5><EFBFBD>, ..."}
|
||||
```
|
||||
|
||||
## 해결: 3단계 방어
|
||||
|
||||
### 1단계: Python 파일 상단 — sys.stdout UTF-8 래핑
|
||||
```python
|
||||
import sys
|
||||
import os
|
||||
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
|
||||
```
|
||||
|
||||
**적용 위치**: `app.py`, `clawdbot_client.py` 등 진입점 파일 맨 위 (import 전)
|
||||
|
||||
> 모듈로 import되는 파일은 `hasattr(sys.stdout, 'buffer')` 체크 추가:
|
||||
> ```python
|
||||
> if sys.platform == 'win32':
|
||||
> import io
|
||||
> if hasattr(sys.stdout, 'buffer'):
|
||||
> sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
> sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
> ```
|
||||
|
||||
### 2단계: 환경변수 — PYTHONIOENCODING
|
||||
```bash
|
||||
# ~/.bashrc (Claude Code bash 세션)
|
||||
export PYTHONIOENCODING=utf-8
|
||||
```
|
||||
|
||||
또는 실행 시:
|
||||
```bash
|
||||
PYTHONIOENCODING=utf-8 python backend/app.py
|
||||
```
|
||||
|
||||
### 3단계: json.dumps — ensure_ascii=False
|
||||
```python
|
||||
import json
|
||||
data = {"product": "비타민C", "message": "추천드려요"}
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
```
|
||||
`ensure_ascii=False` 없으면 `\uBE44\uD0C0\uBBFCC` 같은 유니코드 이스케이프로 출력됨.
|
||||
|
||||
## 프로젝트 내 적용 현황
|
||||
|
||||
| 파일 | 방식 |
|
||||
|------|------|
|
||||
| `backend/app.py` | sys.stdout 래핑 + PYTHONIOENCODING |
|
||||
| `backend/services/clawdbot_client.py` | sys.stdout 래핑 (buffer 체크) |
|
||||
| `backend/ai_tag_products.py` | sys.stdout 래핑 |
|
||||
| `backend/view_products.py` | sys.stdout 래핑 |
|
||||
| `backend/import_il1beta_foods.py` | sys.stdout 래핑 |
|
||||
| `backend/import_products_from_mssql.py` | sys.stdout 래핑 |
|
||||
| `backend/update_product_category.py` | sys.stdout 래핑 |
|
||||
| `backend/gui/check_cash.py` | `sys.stdout.reconfigure(encoding='utf-8')` |
|
||||
| `backend/gui/check_sunab.py` | `sys.stdout.reconfigure(encoding='utf-8')` |
|
||||
| `~/.bashrc` | `export PYTHONIOENCODING=utf-8` |
|
||||
|
||||
## 주의사항
|
||||
- Flask 로거(`logging.info()` 등)도 stderr로 출력하므로 **stderr도 반드시 래핑**
|
||||
- `io.TextIOWrapper`는 이미 래핑된 스트림에 중복 적용하면 에러남 → `hasattr(sys.stdout, 'buffer')` 체크
|
||||
- PyQt GUI에서는 stdout이 다를 수 있음 → `hasattr` 가드 필수
|
||||
308
docs/결제수납구조.md
Normal file
308
docs/결제수납구조.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# PIT3000 판매/조제/수납 데이터 구조
|
||||
|
||||
## 핵심 테이블 관계
|
||||
|
||||
```
|
||||
CD_SUNAB (수납/결제) ─── 모든 거래의 결제 기록 (130건/일 기준)
|
||||
│
|
||||
├── PS_main (처방접수) ─── 조제 건만 (89건/일 기준)
|
||||
│ │ 조인: PS_main.PreSerial = CD_SUNAB.PRESERIAL
|
||||
│ │ 조인: PS_main.Indate = CD_SUNAB.INDATE
|
||||
│ │
|
||||
│ ├── PS_sub_hosp (처방 의약품 상세)
|
||||
│ └── PS_sub_pharm (조제 의약품 상세)
|
||||
│
|
||||
└── SALE_MAIN (OTC 판매) ─── OTC 직접 판매만 (39건/일 기준)
|
||||
│ 조인: SALE_MAIN.SL_NO_order = CD_SUNAB.PRESERIAL
|
||||
│
|
||||
└── SALE_SUB (판매 품목 상세) ─── SL_NO_order로 조인
|
||||
```
|
||||
|
||||
## 테이블별 역할
|
||||
|
||||
### 1. CD_SUNAB — 수납/결제 (모든 거래 포함)
|
||||
- **역할**: 조제 + OTC 모든 거래의 결제/수납 기록
|
||||
- **1주문 = 1행** (복수행 없음)
|
||||
- **키**: `PRESERIAL` (주문번호), `INDATE` (수납일)
|
||||
- **건수**: 하루 약 130건 (조제 91 + OTC 39)
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `PRESERIAL` | 주문번호 (PS_main.PreSerial 또는 SALE_MAIN.SL_NO_order와 매칭) |
|
||||
| `INDATE` | 수납일 (YYYYMMDD) |
|
||||
| `DAY_SERIAL` | 일련번호 |
|
||||
| `CUSCODE` | 고객코드 |
|
||||
| `ETC_CARD` | 조제 카드결제 금액 |
|
||||
| `ETC_CASH` | 조제 현금결제 금액 |
|
||||
| `ETC_PAPER` | 조제 외상 금액 |
|
||||
| `OTC_CARD` | 일반약 카드결제 금액 |
|
||||
| `OTC_CASH` | 일반약 현금결제 금액 |
|
||||
| `OTC_PAPER` | 일반약 외상 금액 |
|
||||
| `pAPPROVAL_NUM` | 카드 승인번호 |
|
||||
| `pMCHDATA` | 카드사 이름 |
|
||||
| `pCARDINMODE` | 카드 입력방식 (1=IC칩) |
|
||||
| `pTRDTYPE` | 거래유형 (D1=일반승인) |
|
||||
| `nCASHINMODE` | 현금영수증 모드 (1=발행, 2=카드거래 자동세팅) |
|
||||
| `nAPPROVAL_NUM` | 현금영수증 승인번호 |
|
||||
| `Appr_Gubun` | 승인구분 (1, 2, 9 등) |
|
||||
| `APPR_DATE` | 승인일시 (YYYYMMDDHHmmss) |
|
||||
| `DaeRiSunab` | 대리수납 여부 |
|
||||
| `YOHUDATE` | 요후일 |
|
||||
| 총 **54개 컬럼** | |
|
||||
|
||||
### 2. PS_main — 처방전 접수 (조제 전용)
|
||||
- **역할**: 처방전 기반 조제 접수 기록
|
||||
- **키**: `PreSerial` (처방번호 = CD_SUNAB.PRESERIAL)
|
||||
- **건수**: 하루 약 89건
|
||||
- **SALE_MAIN에는 없음** — 조제건은 SALE_MAIN을 거치지 않음
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `PreSerial` | 처방번호 (= CD_SUNAB.PRESERIAL) |
|
||||
| `Day_Serial` | 일일 접수 순번 (1~89) |
|
||||
| `Indate` | 접수일 (YYYYMMDD) |
|
||||
| `CusCode` | 환자 코드 |
|
||||
| `Paname` | 환자명 |
|
||||
| `PaNum` | 주민번호 |
|
||||
| `InsName` | 보험구분 (건강보험, 의료급여 등) |
|
||||
| `OrderName` | 의료기관명 |
|
||||
| `Drname` | 처방의사명 |
|
||||
| `PresTime` | 접수 시간 |
|
||||
| `PRICE_T` | 총금액 |
|
||||
| `PRICE_P` | 본인부담금 |
|
||||
| `PRICE_C` | 보험자부담금 |
|
||||
| `Pre_State` | 처방 상태 |
|
||||
| `InsertTime` | 입력 시간 |
|
||||
| 총 **58개 컬럼** | |
|
||||
|
||||
### 3. SALE_MAIN — OTC 직접 판매
|
||||
- **역할**: 일반의약품(OTC) 직접 판매 기록
|
||||
- **키**: `SL_NO_order` (주문번호 = CD_SUNAB.PRESERIAL)
|
||||
- **건수**: 하루 약 39건
|
||||
- **조제건은 포함되지 않음**
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `SL_NO_order` | 주문번호 (= CD_SUNAB.PRESERIAL) |
|
||||
| `SL_DT_appl` | 판매일 (YYYYMMDD) |
|
||||
| `SL_NM_custom` | 고객명 (대부분 빈값 → `[비고객]`) |
|
||||
| `SL_MY_total` | 원가 (할인 전) |
|
||||
| `SL_MY_discount` | 할인 금액 |
|
||||
| `SL_MY_sale` | 실판매가 (= total - discount) |
|
||||
| `InsertTime` | 입력 시간 |
|
||||
| `PRESERIAL` | 처방번호 (OTC는 'V' 고정, 의미 없음) |
|
||||
| 총 **30개 컬럼** | |
|
||||
|
||||
---
|
||||
|
||||
## 데이터 흐름 정리
|
||||
|
||||
### 조제 (처방전 기반)
|
||||
```
|
||||
처방전 접수 → PS_main 생성 → 조제 → CD_SUNAB 수납 기록
|
||||
(ETC_CARD/ETC_CASH에 금액)
|
||||
```
|
||||
- SALE_MAIN에는 **기록되지 않음**
|
||||
- SALE_SUB에도 품목이 **들어가지 않음**
|
||||
- 환자명은 PS_main.Paname에 있음
|
||||
|
||||
### OTC 판매 (직접 판매)
|
||||
```
|
||||
POS에서 품목 선택 → SALE_MAIN + SALE_SUB 생성 → CD_SUNAB 수납 기록
|
||||
(OTC_CARD/OTC_CASH에 금액)
|
||||
```
|
||||
- PS_main에는 **기록되지 않음**
|
||||
- 고객명은 보통 빈값 (`[비고객]`)
|
||||
|
||||
### 조제 + OTC 동시 (하루 약 10건)
|
||||
```
|
||||
처방전 조제 + 일반약 동시 구매
|
||||
→ PS_main (조제 부분)
|
||||
→ SALE_MAIN + SALE_SUB (OTC 부분)
|
||||
→ CD_SUNAB 1행에 ETC + OTC 금액 모두 기록
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 조인 키 관계
|
||||
|
||||
```
|
||||
CD_SUNAB.PRESERIAL = PS_main.PreSerial (조제건)
|
||||
CD_SUNAB.PRESERIAL = SALE_MAIN.SL_NO_order (OTC건)
|
||||
```
|
||||
|
||||
**주의**: `SALE_MAIN.PRESERIAL`은 OTC에서 항상 `'V'`로, 조인키가 아님.
|
||||
실제 조인키는 `SALE_MAIN.SL_NO_order`임.
|
||||
|
||||
---
|
||||
|
||||
## 건수 관계 (2025-02-25 기준)
|
||||
|
||||
| 구분 | 건수 | 설명 |
|
||||
|------|------|------|
|
||||
| CD_SUNAB | 130 | 모든 수납 기록 |
|
||||
| PS_main | 89 | 처방전 접수 (= 조제) |
|
||||
| SALE_MAIN | 39 | OTC 직접 판매 |
|
||||
| CD_SUNAB에만 존재 | 91 | 조제건 (SALE_MAIN 없음) |
|
||||
| PS_main 매칭 | 89 | 91건 중 PS_main과 매칭 |
|
||||
| 미매칭 | 2 | PS_main 없이 수납만 존재 (미수금 수납 등 특수 케이스) |
|
||||
|
||||
### 130건 = 39 (OTC) + 89 (조제) + 2 (특수)
|
||||
|
||||
---
|
||||
|
||||
## 조제/OTC 구분 방법
|
||||
|
||||
CD_SUNAB의 ETC/OTC 금액으로 판별:
|
||||
|
||||
```python
|
||||
etc_total = ETC_CARD + ETC_CASH # 조제 금액
|
||||
otc_total = OTC_CARD + OTC_CASH # 일반약 금액
|
||||
|
||||
if etc_total > 0 and otc_total > 0:
|
||||
구분 = "조제+판매"
|
||||
elif etc_total > 0:
|
||||
구분 = "조제"
|
||||
elif otc_total > 0:
|
||||
구분 = "판매(OTC)"
|
||||
else:
|
||||
구분 = "본인부담금 없음" # 건강보험 전액 부담
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 결제수단 판별
|
||||
|
||||
```python
|
||||
card_total = ETC_CARD + OTC_CARD
|
||||
cash_total = ETC_CASH + OTC_CASH
|
||||
|
||||
# 현금영수증 판별 (nCASHINMODE=2는 카드거래 자동세팅이므로 제외)
|
||||
has_cash_receipt = (nCASHINMODE == '1' and nAPPROVAL_NUM != '')
|
||||
|
||||
if card_total > 0 and cash_total > 0:
|
||||
결제 = "카드+현금"
|
||||
elif card_total > 0:
|
||||
결제 = "카드"
|
||||
elif cash_total > 0:
|
||||
결제 = "현영" if has_cash_receipt else "현금"
|
||||
else:
|
||||
결제 = "-"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GUI 표시 색상
|
||||
|
||||
### 결제 컬럼
|
||||
- **카드**: 파란색 (#1976D2)
|
||||
- **현영**: 청록색 볼드 (#00897B) — 현금영수증 발행
|
||||
- **현금**: 주황색 (#E65100) — 현금영수증 미발행
|
||||
- **카드+현금**: 보라색 (#7B1FA2)
|
||||
- **-**: 회색 (수납 없음)
|
||||
|
||||
### 수납 컬럼
|
||||
- **✓**: 녹색 (#4CAF50)
|
||||
- **-**: 회색 (미수납)
|
||||
|
||||
### 할인 표시
|
||||
- 할인 없음: `12,000원`
|
||||
- 할인 있음: `54,000원 (-6,000)` 주황색 볼드 + 툴팁
|
||||
|
||||
---
|
||||
|
||||
## SALE_MAIN 금액 컬럼 상세
|
||||
|
||||
| 컬럼 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| `SL_MY_total` | 원가 (할인 전) | 60,000 |
|
||||
| `SL_MY_discount` | 할인 금액 | 6,000 |
|
||||
| `SL_MY_sale` | **실판매가** (= total - discount) | 54,000 |
|
||||
| `SL_MY_recive` | 수납금액 (부가세 제외 추정) | 49,091 |
|
||||
| `SL_MY_credit` | 외상 금액 | 0 |
|
||||
| `SL_MY_dis_ratio` | 할인율 | 0 (미사용) |
|
||||
|
||||
### 금액 관계
|
||||
```
|
||||
SL_MY_sale = SL_MY_total - SL_MY_discount
|
||||
SL_MY_recive ≈ SL_MY_sale / 1.1 (부가세 제외 금액 추정)
|
||||
```
|
||||
|
||||
### 할인 빈도
|
||||
- 대부분의 거래: discount = 0 (할인 없음)
|
||||
- 할인 적용 건: 하루 2~5건 정도 (직원 할인, 대량 구매 등)
|
||||
- 할인 규모: 1,000원 ~ 수십만원까지 다양
|
||||
|
||||
---
|
||||
|
||||
## CD_SUNAB 카드/현금 상세 컬럼
|
||||
|
||||
### 카드 상세 정보
|
||||
| 컬럼 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| `pMCHDATA` | 카드사 이름 | 비씨카드사, NH농협카드 |
|
||||
| `PCardName` | 카드사 이름 (별도) | KB국민카드 |
|
||||
| `pAPPROVAL_NUM` | 카드 승인번호 | 72139919 |
|
||||
| `pCARDINMODE` | 카드 입력 방식 | 1 (IC칩) |
|
||||
| `pTRDTYPE` | 거래 유형 | D1 (일반승인) |
|
||||
| `Appr_Gubun` | 승인 구분 | 9 (정상승인) |
|
||||
| `pCANCEL_NUM` | 취소 승인번호 | (취소 시) |
|
||||
|
||||
### 현금 상세 정보
|
||||
| 컬럼 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| `nCASHINMODE` | 현금영수증 입력 방식 | 1=실제발행, 2=카드거래 자동세팅 |
|
||||
| `nAPPROVAL_NUM` | 현금영수증 승인번호 | 116624870 |
|
||||
| `nCHK_GUBUN` | 현금 체크 구분 | KOV, TASA |
|
||||
|
||||
---
|
||||
|
||||
## SQL 쿼리 (현재 GUI에서 사용)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
M.SL_NO_order,
|
||||
M.InsertTime,
|
||||
M.SL_MY_sale,
|
||||
ISNULL(M.SL_NM_custom, '[비고객]') AS customer_name,
|
||||
ISNULL(S.card_total, 0) AS card_total,
|
||||
ISNULL(S.cash_total, 0) AS cash_total,
|
||||
ISNULL(M.SL_MY_total, 0) AS total_amount,
|
||||
ISNULL(M.SL_MY_discount, 0) AS discount,
|
||||
S.cash_receipt_mode,
|
||||
S.cash_receipt_num
|
||||
FROM SALE_MAIN M
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1
|
||||
ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total,
|
||||
ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total,
|
||||
nCASHINMODE AS cash_receipt_mode,
|
||||
nAPPROVAL_NUM AS cash_receipt_num
|
||||
FROM CD_SUNAB
|
||||
WHERE PRESERIAL = M.SL_NO_order
|
||||
) S
|
||||
WHERE M.SL_DT_appl = ?
|
||||
ORDER BY M.InsertTime DESC
|
||||
```
|
||||
|
||||
**한계**: SALE_MAIN 기준이므로 OTC 판매(39건)만 표시됨.
|
||||
조제건(~89건)은 표시되지 않음. 조제건까지 보려면 CD_SUNAB을
|
||||
기본 테이블로 사용하거나 PS_main과 조인하는 쿼리 재설계 필요.
|
||||
|
||||
---
|
||||
|
||||
## 카드사 분포 (전체 데이터 기준)
|
||||
|
||||
| 카드사 | 건수 |
|
||||
|--------|------|
|
||||
| KB국민카드 | 6,106 |
|
||||
| NH농협카드 | 5,172 |
|
||||
| 비씨카드사 | 4,900 |
|
||||
| 하나카드 | 4,880 |
|
||||
| 신한카드 | 3,210 |
|
||||
| 삼성카드사 | 2,100 |
|
||||
| 현대카드사 | 1,960 |
|
||||
| 우리카드 | 1,285 |
|
||||
| 롯데카드사 | 837 |
|
||||
| 카카오페이 | 57 |
|
||||
| 모바일상품권 | 11 |
|
||||
91
docs/실행구조.md
Normal file
91
docs/실행구조.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 청춘약국 마일리지 시스템 — 실행 구조
|
||||
|
||||
## 실행해야 할 프로그램 (2개)
|
||||
|
||||
### 1. Flask 서버 (`backend/app.py`)
|
||||
```bash
|
||||
cd c:\Users\청춘약국\source\pharmacy-pos-qr-system
|
||||
python backend/app.py
|
||||
```
|
||||
- **포트**: 7001 (0.0.0.0)
|
||||
- **외부 도메인**: `mile.0bin.in` (→ 내부 7001 포트로 프록시)
|
||||
- **역할**: 웹 서비스 전체 담당
|
||||
|
||||
#### 제공하는 페이지/API
|
||||
| 경로 | 설명 |
|
||||
|------|------|
|
||||
| `/` | 메인 페이지 |
|
||||
| `/signup` | 회원가입 |
|
||||
| `/claim` | QR 적립 (폰번호 방식) |
|
||||
| `/claim/kakao/start` | QR 적립 (카카오 로그인) |
|
||||
| `/my-page` | 마이페이지 |
|
||||
| `/kiosk` | **키오스크 대기 화면** (약국 내 태블릿) |
|
||||
| `/admin` | 관리자 페이지 |
|
||||
| `/admin/transaction/<id>` | 거래 상세 |
|
||||
| `/admin/user/<id>` | 회원 상세 |
|
||||
| `/admin/search/user` | 회원 검색 |
|
||||
| `/admin/search/product` | 상품 검색 |
|
||||
| `/api/kiosk/trigger` | 키오스크 QR 트리거 (POST) |
|
||||
| `/api/kiosk/current` | 키오스크 현재 상태 |
|
||||
| `/api/kiosk/claim` | 키오스크 적립 처리 (POST) |
|
||||
|
||||
#### 사용하는 DB
|
||||
- **SQLite** (`backend/db/mileage.db`) — 회원, 적립, QR 토큰
|
||||
- **MSSQL** (`192.168.0.4\PM2014`, DB: `PM_PRES`) — POS 판매 데이터 (읽기 전용)
|
||||
|
||||
---
|
||||
|
||||
### 2. Qt POS GUI (`backend/gui/pos_sales_gui.py`)
|
||||
```bash
|
||||
cd c:\Users\청춘약국\source\pharmacy-pos-qr-system
|
||||
python backend/gui/pos_sales_gui.py
|
||||
```
|
||||
- **역할**: POS 판매 내역 조회 + QR 라벨 발행
|
||||
- **PyQt5 기반** 데스크톱 앱
|
||||
- Flask 서버와 **독립적으로 실행** (별도 프로세스)
|
||||
|
||||
#### 주요 기능
|
||||
- 일자별 판매 내역 조회 (SALE_MAIN + CD_SUNAB)
|
||||
- 결제수단 표시 (카드/현금/현영)
|
||||
- 할인 표시
|
||||
- QR 라벨 프린터 출력 (Zebra / POS 프린터)
|
||||
- 적립자 클릭 → 회원 적립 내역 팝업
|
||||
|
||||
#### 사용하는 DB
|
||||
- **MSSQL** — SALE_MAIN, SALE_SUB, CD_SUNAB 조회
|
||||
- **SQLite** — claim_tokens, users 조회 (적립 정보)
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
```
|
||||
1. Flask 서버 먼저 실행 (키오스크, 웹 서비스 제공)
|
||||
2. Qt POS GUI 실행 (판매 내역 조회, QR 발행)
|
||||
```
|
||||
|
||||
순서는 상관없으나, Flask가 먼저 떠 있어야 키오스크(`mile.0bin.in/kiosk`)와
|
||||
웹 서비스(`mile.0bin.in`)가 접속 가능.
|
||||
|
||||
---
|
||||
|
||||
## 프로세스 확인
|
||||
|
||||
```bash
|
||||
# 실행 중인 Python 프로세스 확인
|
||||
tasklist /FI "IMAGENAME eq python.exe"
|
||||
|
||||
# 정상 상태: Python 프로세스 3개
|
||||
# - Flask 서버 (메인)
|
||||
# - Flask 서버 (debug reloader 워커)
|
||||
# - Qt POS GUI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
- `taskkill /F /IM python.exe` 사용 시 **Flask + GUI 모두 종료됨**
|
||||
- GUI만 재시작하려면 해당 PID만 종료할 것
|
||||
- Flask 서버는 `debug=True`로 실행되어 코드 변경 시 자동 리로드
|
||||
- Python 경로: `C:\Users\청춘약국\AppData\Local\Programs\Python\Python312\python.exe`
|
||||
98
docs/카드현금구분_.md
Normal file
98
docs/카드현금구분_.md
Normal file
@@ -0,0 +1,98 @@
|
||||
.# 팜IT3000 (PIT3000) DB 구조
|
||||
|
||||
## DB 접속 정보
|
||||
- **서버**: 192.168.0.101\PM2014 (MSSQL)
|
||||
- **계정**: sa / tmddls214!%(
|
||||
- **ODBC**: Driver 18 + `OPENSSL_CONF=/root/person-lookup-web-local/openssl_legacy.conf` 필수
|
||||
- **코드 위치**: /root/person-lookup-web-local/ (CT 200)
|
||||
|
||||
## 데이터베이스 목록
|
||||
| DB명 | 용도 |
|
||||
|------|------|
|
||||
| PM_BASE | 환자 정보, 개인정보, 판매마스터 |
|
||||
| PM_PRES | 처방전, 판매(SALE), 수납(CD_SUNAB), 키오스크 |
|
||||
| PM_DRUG | 약품 마스터(CD_GOODS), 창고 거래(WH_sub) |
|
||||
| PM_DUMS | 재고 관리(INVENTORY, NIMS_REALTIME_INVENTORY) |
|
||||
| PM_ALIMI | 알림톡, SMS |
|
||||
| PM_ALDB | 알림 DB |
|
||||
| PM_EDIRECE/PM_EDISEND | EDI 전자문서 |
|
||||
| PM_IMAGE | 약품 이미지 |
|
||||
| PM_JOBLOG | 작업/시스템 로그 |
|
||||
|
||||
## 결제(수납) 테이블 구조
|
||||
|
||||
### CD_SUNAB (PM_PRES) - 핵심 수납 테이블
|
||||
건별 결제 내역. PRESERIAL로 처방과 연결.
|
||||
|
||||
#### 결제 수단 구분 (금액 기반, 단일 구분 컬럼 없음)
|
||||
| 구분 | 카드결제 | 현금결제 | 외상/기타 |
|
||||
|------|---------|---------|----------|
|
||||
| 조제(ETC, 전문의약품) | `ETC_CARD` | `ETC_CASH` | `ETC_PAPER` |
|
||||
| OTC(일반의약품) | `OTC_CARD` | `OTC_CASH` | `OTC_PAPER` |
|
||||
|
||||
**판별법**: 금액이 0보다 크면 해당 결제수단 사용
|
||||
- `ETC_CARD=6100, ETC_CASH=0` → 카드결제
|
||||
- `ETC_CARD=0, ETC_CASH=5100` → 현금결제
|
||||
|
||||
#### 카드 관련 컬럼
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `PCardName` | 카드사 이름 (KB국민카드, 신한카드 등) |
|
||||
| `pAPPROVAL_NUM` | 카드 승인번호 |
|
||||
| `pCARDINMODE` | 카드 입력 방식 |
|
||||
| `pTRDTYPE` | 거래 유형 (D1 등) |
|
||||
| `pCHK_GUBUN` | 체크 구분 (TASA=타사, KIC 등) |
|
||||
| `Appr_Gubun` | 승인 구분 (9=정상승인, A 등) |
|
||||
| `pCANCEL_NUM` | 취소 승인번호 |
|
||||
| `CANCEL_DATE` | 취소 일시 |
|
||||
|
||||
#### 현금 관련 컬럼
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `nCASHINMODE` | 현금영수증 입력 방식 (1 등, 대부분 빈값=미발행) |
|
||||
| `nAPPROVAL_NUM` | 현금영수증 승인번호 |
|
||||
| `nCHK_GUBUN` | 현금 체크 구분 (TASA 등) |
|
||||
|
||||
#### 카드사 분포 (PCardName)
|
||||
| 카드사 | 건수 |
|
||||
|--------|------|
|
||||
| KB국민카드 | 6,106 |
|
||||
| NH농협카드 | 5,172 |
|
||||
| 비씨카드사 | 4,900 |
|
||||
| 하나카드 | 4,880 |
|
||||
| 신한카드 | 3,210 |
|
||||
| 삼성카드사 | 2,100 |
|
||||
| 현대카드사 | 1,960 |
|
||||
| 우리카드 | 1,285 |
|
||||
| 롯데카드사 | 837 |
|
||||
| 카카오페이 | 57 |
|
||||
| 모바일상품권 | 11 |
|
||||
|
||||
### CD_SELL_MASTE (PM_BASE) - 판매마스터
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `CARD_C` | 카드 결제금액 |
|
||||
| `CHASH_C` | 현금 결제금액 |
|
||||
| `PAPER_C` | 외상 금액 |
|
||||
| `P_GUBUN` | 처방 구분 |
|
||||
| `C_GUBUN` | 고객 구분 |
|
||||
|
||||
### SALE_main (PM_PRES) - 판매 메인
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `SL_MY_sale` | 판매금액 |
|
||||
| `SL_MY_credit` | 외상금액 |
|
||||
| `SL_MY_recive` | 수납금액 |
|
||||
| `POS_GUBUN` | POS 구분 (빈값=일반, C=카드?, G=기타?) |
|
||||
| `PRESERIAL` | 처방번호 (CD_SUNAB과 조인 키) |
|
||||
|
||||
### KIOSK 테이블 (PM_PRES)
|
||||
- `KIOSK_MAIN`: 키오스크 처방 접수
|
||||
- `KIOSK_CARD`: 키오스크 카드결제 (CARD_NM, CARD_NO, APP_NUM 등)
|
||||
- `KIOSK_CARD_PRES`: 키오스크 카드-처방 연결
|
||||
- `KIOSK_SUB`: 키오스크 서브
|
||||
|
||||
## 주요 조인 관계
|
||||
- `CD_SUNAB.PRESERIAL` ↔ `SALE_main.PRESERIAL` (수납-판매 연결)
|
||||
- `CD_SUNAB.CUSCODE` ↔ `CD_PERSON.CUSCODE` (수납-환자 연결, PM_BASE)
|
||||
- `SALE_main.SL_NO_order` ↔ `SALE_sub.SL_NO_order` (판매 메인-서브)
|
||||
45
ecosystem.config.js
Normal file
45
ecosystem.config.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// PM2 설정 파일
|
||||
// 사용법:
|
||||
// npm install -g pm2
|
||||
// pm2 start ecosystem.config.js
|
||||
// pm2 restart pharmacy
|
||||
// pm2 stop pharmacy
|
||||
// pm2 logs pharmacy
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'pharmacy',
|
||||
script: 'backend/app.py',
|
||||
interpreter: 'python',
|
||||
cwd: __dirname,
|
||||
|
||||
// 환경 설정
|
||||
env: {
|
||||
FLASK_ENV: 'development',
|
||||
PYTHONIOENCODING: 'utf-8'
|
||||
},
|
||||
env_production: {
|
||||
FLASK_ENV: 'production'
|
||||
},
|
||||
|
||||
// 재시작 설정
|
||||
watch: false, // 파일 변경 감지 (개발 시 true)
|
||||
max_restarts: 10, // 최대 재시작 횟수
|
||||
restart_delay: 3000, // 재시작 딜레이 (3초)
|
||||
|
||||
// 로그 설정
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
||||
error_file: 'logs/pm2-error.log',
|
||||
out_file: 'logs/pm2-out.log',
|
||||
merge_logs: true,
|
||||
|
||||
// 인스턴스 설정
|
||||
instances: 1, // 단일 인스턴스
|
||||
exec_mode: 'fork',
|
||||
|
||||
// 메모리 제한
|
||||
max_memory_restart: '500M'
|
||||
}
|
||||
]
|
||||
};
|
||||
Reference in New Issue
Block a user