Compare commits
47 Commits
a7bcf46aaa
...
aed0c314b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aed0c314b7 | ||
|
|
2ad4ad05f3 | ||
|
|
0b81999cb4 | ||
|
|
2ca35cdc82 | ||
|
|
c9f89cb9b0 | ||
|
|
591af31da9 | ||
|
|
93c643cb8e | ||
|
|
94dea2ab3a | ||
|
|
d901c67125 | ||
|
|
80f7f0ac80 | ||
|
|
4944669470 | ||
|
|
2cc9ec6bb1 | ||
|
|
58408c9f5c | ||
|
|
17a29f05b8 | ||
|
|
98d370104b | ||
|
|
9531b74d0e | ||
|
|
e254c5c23d | ||
|
|
4c033b0584 | ||
|
|
4a529fc891 | ||
|
|
9f10f8fdbb | ||
|
|
1deba9e631 | ||
|
|
e6f4d5b1e7 | ||
|
|
7b71ea0179 | ||
|
|
849ce4c3c0 | ||
|
|
80b3919ac9 | ||
|
|
91f36273e9 | ||
|
|
f46071132c | ||
|
|
88a23c26c1 | ||
|
|
6db31785fa | ||
|
|
04bf7a8535 | ||
|
|
688bdb40f2 | ||
|
|
83ecf88bd4 | ||
|
|
e470deaefc | ||
|
|
f92abf94c8 | ||
|
|
2ef418ed7c | ||
|
|
0bcae4ec72 | ||
|
|
90f88450be | ||
|
|
7e7d06f32e | ||
|
|
03481dadae | ||
|
|
e1711d9176 | ||
|
|
5d7a8fc3f4 | ||
|
|
be1e6c2bb7 | ||
|
|
3631da2953 | ||
|
|
3507d17dc5 | ||
|
|
90cb91d644 | ||
|
|
aef867645e | ||
|
|
4614fc4c0d |
2943
backend/app.py
2943
backend/app.py
File diff suppressed because it is too large
Load Diff
32
backend/check_2024_apc.py
Normal file
32
backend/check_2024_apc.py
Normal file
@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import pyodbc
|
||||
|
||||
conn_str = (
|
||||
'DRIVER={ODBC Driver 17 for SQL Server};'
|
||||
'SERVER=192.168.0.4\\PM2014;'
|
||||
'DATABASE=PM_DRUG;'
|
||||
'UID=sa;'
|
||||
'PWD=tmddls214!%(;'
|
||||
'TrustServerCertificate=yes;'
|
||||
)
|
||||
conn = pyodbc.connect(conn_str, timeout=10)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 2024년 이후 APC (9xx로 시작) 확인
|
||||
cursor.execute('''
|
||||
SELECT G.GoodsName, U.CD_CD_BARCODE
|
||||
FROM CD_GOODS G
|
||||
JOIN CD_ITEM_UNIT_MEMBER U ON G.DrugCode = U.DRUGCODE
|
||||
WHERE G.POS_BOON = '010103'
|
||||
AND G.GoodsSelCode = 'B'
|
||||
AND U.CD_CD_BARCODE LIKE '9%'
|
||||
AND LEN(U.CD_CD_BARCODE) = 13
|
||||
ORDER BY G.GoodsName
|
||||
''')
|
||||
|
||||
rows = cursor.fetchall()
|
||||
print(f'=== 2024년 이후 APC 제품: {len(rows)}건 ===')
|
||||
for row in rows:
|
||||
print(f' {row.GoodsName} | APC: {row.CD_CD_BARCODE}')
|
||||
|
||||
conn.close()
|
||||
18
backend/check_chunks.py
Normal file
18
backend/check_chunks.py
Normal file
@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from utils.animal_rag import get_animal_rag
|
||||
|
||||
rag = get_animal_rag()
|
||||
rag._init_db()
|
||||
|
||||
df = rag.table.to_pandas()
|
||||
|
||||
# 개시딘 청크들 확인
|
||||
gaesidin = df[df['source'] == 'gaesidin_gel_pyoderma_fusidic_acid.md']
|
||||
print(f'개시딘 청크 수: {len(gaesidin)}개')
|
||||
print('=' * 60)
|
||||
|
||||
for i, row in gaesidin.head(5).iterrows():
|
||||
section = row['section']
|
||||
text = row['text'][:200].replace('\n', ' ')
|
||||
print(f'\n[섹션] {section}')
|
||||
print(f' → {text}...')
|
||||
25
backend/check_oridermyl.py
Normal file
25
backend/check_oridermyl.py
Normal file
@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
|
||||
with engine.connect() as conn:
|
||||
# 오리더밀 검색
|
||||
result = conn.execute(text("""
|
||||
SELECT apc, product_name, item_seq,
|
||||
llm_pharm->>'분류' as category,
|
||||
llm_pharm->>'간이분류' as easy_category,
|
||||
image_url1
|
||||
FROM apc
|
||||
WHERE product_name ILIKE '%오리더밀%'
|
||||
ORDER BY apc
|
||||
"""))
|
||||
|
||||
print('=== PostgreSQL 오리더밀 검색 결과 ===')
|
||||
for row in result:
|
||||
print(f'APC: {row.apc}')
|
||||
print(f' 제품명: {row.product_name}')
|
||||
print(f' item_seq: {row.item_seq}')
|
||||
print(f' 분류: {row.category}')
|
||||
print(f' 간이분류: {row.easy_category}')
|
||||
print(f' 이미지: {row.image_url1}')
|
||||
print()
|
||||
35
backend/check_real_2024_apc.py
Normal file
35
backend/check_real_2024_apc.py
Normal file
@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import pyodbc
|
||||
|
||||
conn_str = (
|
||||
'DRIVER={ODBC Driver 17 for SQL Server};'
|
||||
'SERVER=192.168.0.4\\PM2014;'
|
||||
'DATABASE=PM_DRUG;'
|
||||
'UID=sa;'
|
||||
'PWD=tmddls214!%(;'
|
||||
'TrustServerCertificate=yes;'
|
||||
)
|
||||
conn = pyodbc.connect(conn_str, timeout=10)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 정식 2024년 APC (92%로 시작) 확인
|
||||
cursor.execute('''
|
||||
SELECT G.GoodsName, U.CD_CD_BARCODE
|
||||
FROM CD_GOODS G
|
||||
JOIN CD_ITEM_UNIT_MEMBER U ON G.DrugCode = U.DRUGCODE
|
||||
WHERE G.POS_BOON = '010103'
|
||||
AND G.GoodsSelCode = 'B'
|
||||
AND U.CD_CD_BARCODE LIKE '92%'
|
||||
AND LEN(U.CD_CD_BARCODE) = 13
|
||||
ORDER BY G.GoodsName
|
||||
''')
|
||||
|
||||
rows = cursor.fetchall()
|
||||
print(f'=== 정식 2024년 APC (92%) 제품: {len(rows)}건 ===')
|
||||
for row in rows:
|
||||
print(f' {row.GoodsName} | APC: {row.CD_CD_BARCODE}')
|
||||
|
||||
if len(rows) == 0:
|
||||
print(' (없음 - 아직 2024년 이후 허가 제품이 등록 안 됨)')
|
||||
|
||||
conn.close()
|
||||
28
backend/check_tiergard.py
Normal file
28
backend/check_tiergard.py
Normal file
@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import pyodbc
|
||||
|
||||
conn_str = (
|
||||
'DRIVER={ODBC Driver 17 for SQL Server};'
|
||||
'SERVER=192.168.0.4\\PM2014;'
|
||||
'DATABASE=PM_DRUG;'
|
||||
'UID=sa;'
|
||||
'PWD=tmddls214!%(;'
|
||||
'TrustServerCertificate=yes;'
|
||||
)
|
||||
conn = pyodbc.connect(conn_str, timeout=10)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT G.GoodsName, G.Saleprice, ISNULL(IT.IM_QT_sale_debit, 0) AS Stock
|
||||
FROM CD_GOODS G
|
||||
LEFT JOIN IM_total IT ON G.DrugCode = IT.DrugCode
|
||||
WHERE G.GoodsName LIKE '%티어가드%'
|
||||
ORDER BY G.GoodsName
|
||||
''')
|
||||
|
||||
rows = cursor.fetchall()
|
||||
print('=== 티어가드 보유 현황 ===')
|
||||
for row in rows:
|
||||
print(f'{row.GoodsName} | {row.Saleprice:,.0f}원 | 재고: {int(row.Stock)}개')
|
||||
|
||||
conn.close()
|
||||
26
backend/check_tiergard_detail.py
Normal file
26
backend/check_tiergard_detail.py
Normal file
@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy import create_engine, text
|
||||
import json
|
||||
|
||||
engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text("""
|
||||
SELECT apc, product_name, llm_pharm, main_ingredient, component_name_ko
|
||||
FROM apc
|
||||
WHERE product_name ILIKE '%티어가드%60mg%'
|
||||
ORDER BY apc
|
||||
LIMIT 3
|
||||
"""))
|
||||
|
||||
print('=== 티어가드 60mg 허가사항 상세 ===')
|
||||
for row in result:
|
||||
print(f'APC: {row.apc}')
|
||||
print(f'제품명: {row.product_name}')
|
||||
print(f'main_ingredient: {row.main_ingredient}')
|
||||
print(f'component_name_ko: {row.component_name_ko}')
|
||||
if row.llm_pharm:
|
||||
llm = row.llm_pharm if isinstance(row.llm_pharm, dict) else json.loads(row.llm_pharm)
|
||||
print('llm_pharm 내용:')
|
||||
for k, v in llm.items():
|
||||
print(f' {k}: {v}')
|
||||
print()
|
||||
27
backend/check_tiergard_llm.py
Normal file
27
backend/check_tiergard_llm.py
Normal file
@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy import create_engine, text
|
||||
import json
|
||||
|
||||
engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
|
||||
with engine.connect() as conn:
|
||||
# llm_pharm이 있는 티어가드 확인
|
||||
result = conn.execute(text("""
|
||||
SELECT apc, product_name, llm_pharm
|
||||
FROM apc
|
||||
WHERE product_name ILIKE '%티어가드%'
|
||||
AND llm_pharm IS NOT NULL
|
||||
AND llm_pharm::text != '{}'
|
||||
ORDER BY apc
|
||||
"""))
|
||||
|
||||
print('=== 티어가드 llm_pharm 있는 항목 ===')
|
||||
for row in result:
|
||||
print(f'APC: {row.apc}')
|
||||
print(f'제품명: {row.product_name}')
|
||||
if row.llm_pharm:
|
||||
llm = row.llm_pharm if isinstance(row.llm_pharm, dict) else json.loads(row.llm_pharm)
|
||||
print('llm_pharm:')
|
||||
for k, v in llm.items():
|
||||
if v:
|
||||
print(f' {k}: {v}')
|
||||
print()
|
||||
21
backend/check_tiergard_pg.py
Normal file
21
backend/check_tiergard_pg.py
Normal file
@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text("""
|
||||
SELECT apc, product_name,
|
||||
llm_pharm->>'체중/부위' as dosage,
|
||||
llm_pharm->>'주성분' as ingredient
|
||||
FROM apc
|
||||
WHERE product_name ILIKE '%티어가드%'
|
||||
ORDER BY apc
|
||||
"""))
|
||||
|
||||
print('=== PostgreSQL 티어가드 전체 규격 ===')
|
||||
for row in result:
|
||||
print(f'APC: {row.apc}')
|
||||
print(f' 제품명: {row.product_name}')
|
||||
print(f' 용량: {row.dosage}')
|
||||
print(f' 성분: {row.ingredient}')
|
||||
print()
|
||||
47
backend/db/animal_chat_logs_schema.sql
Normal file
47
backend/db/animal_chat_logs_schema.sql
Normal file
@ -0,0 +1,47 @@
|
||||
-- 동물약 챗봇 로그 스키마
|
||||
-- 생성일: 2026-03-08
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
|
||||
-- 입력
|
||||
user_message TEXT,
|
||||
history_length INTEGER,
|
||||
|
||||
-- MSSQL (보유 동물약)
|
||||
mssql_drug_count INTEGER,
|
||||
mssql_duration_ms INTEGER,
|
||||
|
||||
-- PostgreSQL (RAG)
|
||||
pgsql_rag_count INTEGER,
|
||||
pgsql_duration_ms INTEGER,
|
||||
|
||||
-- LanceDB (벡터 검색)
|
||||
vector_results_count INTEGER,
|
||||
vector_top_scores TEXT, -- JSON: [0.92, 0.85, 0.78]
|
||||
vector_sources TEXT, -- JSON: ["file1.md#section", ...]
|
||||
vector_duration_ms INTEGER,
|
||||
|
||||
-- OpenAI
|
||||
openai_model TEXT,
|
||||
openai_prompt_tokens INTEGER,
|
||||
openai_completion_tokens INTEGER,
|
||||
openai_total_tokens INTEGER,
|
||||
openai_cost_usd REAL,
|
||||
openai_duration_ms INTEGER,
|
||||
|
||||
-- 출력
|
||||
assistant_response TEXT,
|
||||
products_mentioned TEXT, -- JSON array
|
||||
|
||||
-- 메타
|
||||
total_duration_ms INTEGER,
|
||||
error TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_created ON chat_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_session ON chat_logs(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_error ON chat_logs(error);
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -2,6 +2,8 @@
|
||||
PIT3000 Database Setup
|
||||
SQLAlchemy 기반 데이터베이스 연결 및 스키마 정의
|
||||
Windows/Linux 크로스 플랫폼 지원
|
||||
|
||||
PostgreSQL 지원 추가: 건조시럽 환산계수 조회 (drysyrup 테이블)
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, MetaData, text
|
||||
@ -87,6 +89,9 @@ class DatabaseConfig:
|
||||
|
||||
# URL 인코딩된 드라이버
|
||||
DRIVER_ENCODED = urllib.parse.quote_plus(DRIVER)
|
||||
|
||||
# PostgreSQL 연결 정보 (건조시럽 환산계수 DB)
|
||||
POSTGRES_URL = "postgresql+psycopg2://admin:trajet6640@192.168.0.39:5432/label10"
|
||||
|
||||
# 데이터베이스별 연결 문자열 (동적 드라이버 사용)
|
||||
@classmethod
|
||||
@ -135,6 +140,10 @@ class DatabaseManager:
|
||||
# SQLite 연결 추가
|
||||
self.sqlite_conn = None
|
||||
self.sqlite_db_path = Path(__file__).parent / 'mileage.db'
|
||||
|
||||
# PostgreSQL 연결 (건조시럽 환산계수)
|
||||
self.postgres_engine = None
|
||||
self.postgres_session = None
|
||||
|
||||
def get_engine(self, database='PM_BASE'):
|
||||
"""특정 데이터베이스 엔진 반환"""
|
||||
@ -220,6 +229,132 @@ class DatabaseManager:
|
||||
# 새 세션 생성
|
||||
return self.get_session(database)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# PostgreSQL 연결 (건조시럽 환산계수)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
def get_postgres_engine(self):
|
||||
"""
|
||||
PostgreSQL 엔진 반환 (건조시럽 환산계수 DB)
|
||||
|
||||
Returns:
|
||||
Engine 또는 None (연결 실패 시)
|
||||
"""
|
||||
if self.postgres_engine is not None:
|
||||
return self.postgres_engine
|
||||
|
||||
try:
|
||||
self.postgres_engine = create_engine(
|
||||
DatabaseConfig.POSTGRES_URL,
|
||||
pool_size=5,
|
||||
max_overflow=5,
|
||||
pool_timeout=30,
|
||||
pool_recycle=1800,
|
||||
pool_pre_ping=True,
|
||||
echo=False
|
||||
)
|
||||
# 연결 테스트
|
||||
with self.postgres_engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
print("[DB Manager] PostgreSQL 연결 성공")
|
||||
return self.postgres_engine
|
||||
except Exception as e:
|
||||
print(f"[DB Manager] PostgreSQL 연결 실패 (무시됨): {e}")
|
||||
self.postgres_engine = None
|
||||
return None
|
||||
|
||||
def get_postgres_session(self):
|
||||
"""
|
||||
PostgreSQL 세션 반환 (건조시럽 환산계수 조회용)
|
||||
|
||||
Returns:
|
||||
Session 또는 None (연결 실패 시)
|
||||
"""
|
||||
engine = self.get_postgres_engine()
|
||||
if engine is None:
|
||||
return None
|
||||
|
||||
if self.postgres_session is None:
|
||||
try:
|
||||
Session = sessionmaker(bind=engine)
|
||||
self.postgres_session = Session()
|
||||
except Exception as e:
|
||||
print(f"[DB Manager] PostgreSQL 세션 생성 실패: {e}")
|
||||
return None
|
||||
else:
|
||||
# 세션 상태 체크
|
||||
try:
|
||||
self.postgres_session.execute(text("SELECT 1"))
|
||||
except Exception as e:
|
||||
print(f"[DB Manager] PostgreSQL 세션 복구 시도: {e}")
|
||||
try:
|
||||
self.postgres_session.rollback()
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
self.postgres_session.close()
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
Session = sessionmaker(bind=engine)
|
||||
self.postgres_session = Session()
|
||||
except:
|
||||
self.postgres_session = None
|
||||
return None
|
||||
|
||||
return self.postgres_session
|
||||
|
||||
def get_conversion_factor(self, sung_code):
|
||||
"""
|
||||
건조시럽 환산계수 및 보관조건 조회
|
||||
|
||||
Args:
|
||||
sung_code: SUNG_CODE (예: "535000ASY")
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'conversion_factor': float 또는 None,
|
||||
'ingredient_name': str 또는 None,
|
||||
'product_name': str 또는 None,
|
||||
'storage_conditions': str (기본값 '실온보관')
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
'conversion_factor': None,
|
||||
'ingredient_name': None,
|
||||
'product_name': None,
|
||||
'storage_conditions': '실온보관' # 기본값
|
||||
}
|
||||
|
||||
session = self.get_postgres_session()
|
||||
if session is None:
|
||||
return result
|
||||
|
||||
try:
|
||||
query = text("""
|
||||
SELECT conversion_factor, ingredient_name, product_name, storage_conditions
|
||||
FROM drysyrup
|
||||
WHERE ingredient_code = :sung_code
|
||||
LIMIT 1
|
||||
""")
|
||||
row = session.execute(query, {'sung_code': sung_code}).fetchone()
|
||||
|
||||
if row:
|
||||
result['conversion_factor'] = float(row[0]) if row[0] is not None else None
|
||||
result['ingredient_name'] = row[1]
|
||||
result['product_name'] = row[2]
|
||||
# storage_conditions: 값이 있으면 사용, 없으면 기본값 '실온보관' 유지
|
||||
if row[3]:
|
||||
result['storage_conditions'] = row[3]
|
||||
except Exception as e:
|
||||
print(f"[DB Manager] 환산계수 조회 실패 (SUNG_CODE={sung_code}): {e}")
|
||||
# 세션 롤백
|
||||
try:
|
||||
session.rollback()
|
||||
except:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def get_sqlite_connection(self, new_connection=False):
|
||||
"""
|
||||
SQLite mileage.db 연결 반환
|
||||
@ -442,6 +577,20 @@ class DatabaseManager:
|
||||
if self.sqlite_conn:
|
||||
self.sqlite_conn.close()
|
||||
self.sqlite_conn = None
|
||||
|
||||
# PostgreSQL 연결 종료
|
||||
if self.postgres_session:
|
||||
try:
|
||||
self.postgres_session.close()
|
||||
except:
|
||||
pass
|
||||
self.postgres_session = None
|
||||
if self.postgres_engine:
|
||||
try:
|
||||
self.postgres_engine.dispose()
|
||||
except:
|
||||
pass
|
||||
self.postgres_engine = None
|
||||
|
||||
# 전역 데이터베이스 매니저 인스턴스
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
267
backend/dongwon_api.py
Normal file
267
backend/dongwon_api.py
Normal file
@ -0,0 +1,267 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
동원약품 도매상 API - Flask Blueprint
|
||||
|
||||
핵심 로직은 wholesale 패키지에서 가져옴
|
||||
이 파일은 Flask 웹 API 연동만 담당
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, request as flask_request
|
||||
|
||||
# wholesale 패키지 경로 설정
|
||||
import wholesale_path
|
||||
|
||||
# wholesale 패키지에서 핵심 클래스 가져오기
|
||||
from wholesale import DongwonSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Blueprint 생성
|
||||
dongwon_bp = Blueprint('dongwon', __name__, url_prefix='/api/dongwon')
|
||||
|
||||
|
||||
# ========== 세션 관리 ==========
|
||||
|
||||
_dongwon_session = None
|
||||
|
||||
def get_dongwon_session():
|
||||
global _dongwon_session
|
||||
if _dongwon_session is None:
|
||||
_dongwon_session = DongwonSession()
|
||||
return _dongwon_session
|
||||
|
||||
|
||||
def search_dongwon_stock(keyword: str, search_type: str = 'name'):
|
||||
"""동원약품 재고 검색"""
|
||||
try:
|
||||
session = get_dongwon_session()
|
||||
result = session.search_products(keyword)
|
||||
|
||||
if result.get('success'):
|
||||
return {
|
||||
'success': True,
|
||||
'keyword': keyword,
|
||||
'search_type': search_type,
|
||||
'count': result.get('total', 0),
|
||||
'items': result.get('items', [])
|
||||
}
|
||||
else:
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"동원약품 검색 오류: {e}")
|
||||
return {'success': False, 'error': 'SEARCH_ERROR', 'message': str(e)}
|
||||
|
||||
|
||||
# ========== Flask API Routes ==========
|
||||
|
||||
@dongwon_bp.route('/stock', methods=['GET'])
|
||||
def api_dongwon_stock():
|
||||
"""
|
||||
동원약품 재고 조회 API
|
||||
|
||||
GET /api/dongwon/stock?keyword=타이레놀
|
||||
"""
|
||||
keyword = flask_request.args.get('keyword', '').strip()
|
||||
|
||||
if not keyword:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'MISSING_PARAM',
|
||||
'message': 'keyword 파라미터가 필요합니다.'
|
||||
}), 400
|
||||
|
||||
result = search_dongwon_stock(keyword)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@dongwon_bp.route('/session', methods=['GET'])
|
||||
def api_dongwon_session():
|
||||
"""동원약품 세션 상태 확인"""
|
||||
session = get_dongwon_session()
|
||||
return jsonify({
|
||||
'logged_in': getattr(session, '_logged_in', False),
|
||||
'last_login': getattr(session, '_last_login', 0),
|
||||
'session_age_sec': int(time.time() - session._last_login) if getattr(session, '_last_login', 0) else None
|
||||
})
|
||||
|
||||
|
||||
@dongwon_bp.route('/balance', methods=['GET'])
|
||||
def api_dongwon_balance():
|
||||
"""
|
||||
동원약품 잔고 조회 API
|
||||
|
||||
GET /api/dongwon/balance
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"balance": 7080018, // 당월 잔고
|
||||
"prev_balance": 5407528, // 전월 잔고
|
||||
"trade_amount": 1672490, // 거래 금액
|
||||
"payment_amount": 0 // 결제 금액
|
||||
}
|
||||
"""
|
||||
try:
|
||||
session = get_dongwon_session()
|
||||
result = session.get_balance()
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"동원약품 잔고 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'BALANCE_ERROR',
|
||||
'message': str(e),
|
||||
'balance': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@dongwon_bp.route('/monthly-orders', methods=['GET'])
|
||||
def api_dongwon_monthly_orders():
|
||||
"""
|
||||
동원약품 월간 주문 조회 API
|
||||
|
||||
GET /api/dongwon/monthly-orders?year=2026&month=3
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"year": 2026,
|
||||
"month": 3,
|
||||
"total_amount": 1815115, // 주문 총액
|
||||
"approved_amount": 1672490, // 승인 금액
|
||||
"order_count": 23 // 주문 건수
|
||||
}
|
||||
"""
|
||||
year = flask_request.args.get('year', type=int)
|
||||
month = flask_request.args.get('month', type=int)
|
||||
|
||||
# 기본값: 현재 월
|
||||
if not year or not month:
|
||||
now = datetime.now()
|
||||
year = year or now.year
|
||||
month = month or now.month
|
||||
|
||||
try:
|
||||
session = get_dongwon_session()
|
||||
result = session.get_monthly_orders(year, month)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"동원약품 월간 주문 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'MONTHLY_ORDERS_ERROR',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@dongwon_bp.route('/cart', methods=['GET'])
|
||||
def api_dongwon_cart():
|
||||
"""
|
||||
동원약품 장바구니 조회 API
|
||||
|
||||
GET /api/dongwon/cart
|
||||
"""
|
||||
try:
|
||||
session = get_dongwon_session()
|
||||
result = session.get_cart()
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"동원약품 장바구니 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'CART_ERROR',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@dongwon_bp.route('/cart/add', methods=['POST'])
|
||||
def api_dongwon_cart_add():
|
||||
"""
|
||||
동원약품 장바구니 추가 API
|
||||
|
||||
POST /api/dongwon/cart/add
|
||||
{
|
||||
"item_code": "A4394",
|
||||
"quantity": 2
|
||||
}
|
||||
"""
|
||||
data = flask_request.get_json() or {}
|
||||
item_code = data.get('item_code', '').strip()
|
||||
quantity = data.get('quantity', 1)
|
||||
|
||||
if not item_code:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'MISSING_PARAM',
|
||||
'message': 'item_code가 필요합니다.'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
session = get_dongwon_session()
|
||||
result = session.add_to_cart(item_code, quantity)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"동원약품 장바구니 추가 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'CART_ADD_ERROR',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@dongwon_bp.route('/orders/summary-by-kd', methods=['GET'])
|
||||
def api_dongwon_orders_by_kd():
|
||||
"""
|
||||
동원약품 주문량 KD코드별 집계 API
|
||||
|
||||
GET /api/dongwon/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-07
|
||||
|
||||
흐름:
|
||||
1. 주문 목록 API → 주문번호 목록
|
||||
2. 각 주문번호 → HTML 파싱 → ItemCode 목록
|
||||
3. 각 ItemCode → itemInfoAx → KD코드, 규격, 수량
|
||||
4. KD코드별 집계
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"order_count": 4,
|
||||
"by_kd_code": {
|
||||
"642900680": {
|
||||
"product_name": "사미온정10mg",
|
||||
"spec": "30정(병)",
|
||||
"boxes": 3,
|
||||
"units": 90
|
||||
}
|
||||
},
|
||||
"total_products": 15
|
||||
}
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now()
|
||||
start_date = flask_request.args.get('start_date', today.strftime("%Y-%m-%d")).strip()
|
||||
end_date = flask_request.args.get('end_date', today.strftime("%Y-%m-%d")).strip()
|
||||
|
||||
try:
|
||||
session = get_dongwon_session()
|
||||
|
||||
# 새로운 get_orders_by_kd_code 메서드 사용
|
||||
result = session.get_orders_by_kd_code(start_date, end_date)
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"동원약품 주문량 집계 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'API_ERROR',
|
||||
'message': str(e),
|
||||
'by_kd_code': {},
|
||||
'order_count': 0
|
||||
}), 500
|
||||
@ -143,6 +143,8 @@ def api_submit_order():
|
||||
# 도매상별 주문 처리
|
||||
if wholesaler_id == 'geoyoung':
|
||||
result = submit_geoyoung_order(order, dry_run)
|
||||
elif wholesaler_id == 'dongwon':
|
||||
result = submit_dongwon_order(order, dry_run)
|
||||
else:
|
||||
result = {
|
||||
'success': False,
|
||||
@ -517,6 +519,8 @@ def api_quick_submit():
|
||||
submit_result = submit_sooin_order(order, dry_run, cart_only=cart_only)
|
||||
elif order['wholesaler_id'] == 'baekje':
|
||||
submit_result = submit_baekje_order(order, dry_run, cart_only=cart_only)
|
||||
elif order['wholesaler_id'] == 'dongwon':
|
||||
submit_result = submit_dongwon_order(order, dry_run, cart_only=cart_only)
|
||||
else:
|
||||
submit_result = {'success': False, 'error': f"Wholesaler {order['wholesaler_id']} not supported"}
|
||||
|
||||
@ -1387,3 +1391,226 @@ def api_drugs_preferred_vendors():
|
||||
'count': len(results),
|
||||
'results': results
|
||||
})
|
||||
|
||||
|
||||
def submit_dongwon_order(order: dict, dry_run: bool, cart_only: bool = True) -> dict:
|
||||
"""
|
||||
동원약품 주문 제출
|
||||
|
||||
Args:
|
||||
order: 주문 정보
|
||||
dry_run: True=시뮬레이션만, False=실제 주문
|
||||
cart_only: True=장바구니만, False=주문 확정까지
|
||||
"""
|
||||
order_id = order['id']
|
||||
items = order['items']
|
||||
|
||||
# 상태 업데이트
|
||||
update_order_status(order_id, 'pending',
|
||||
f'동원 주문 시작 (dry_run={dry_run}, cart_only={cart_only})')
|
||||
|
||||
results = []
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
|
||||
try:
|
||||
from dongwon_api import get_dongwon_session
|
||||
dongwon_session = get_dongwon_session()
|
||||
|
||||
if dry_run:
|
||||
# ─────────────────────────────────────────
|
||||
# DRY RUN: 재고 확인만
|
||||
# ─────────────────────────────────────────
|
||||
for item in items:
|
||||
kd_code = item.get('kd_code') or item.get('drug_code')
|
||||
spec = item.get('specification', '')
|
||||
|
||||
# 재고 검색
|
||||
search_result = dongwon_session.search_products(kd_code)
|
||||
|
||||
matched = None
|
||||
available_specs = []
|
||||
spec_stocks = {}
|
||||
|
||||
if search_result.get('success'):
|
||||
for dongwon_item in search_result.get('items', []):
|
||||
s = dongwon_item.get('spec', '')
|
||||
available_specs.append(s)
|
||||
spec_stocks[s] = dongwon_item.get('stock', 0)
|
||||
|
||||
# 규격 매칭
|
||||
if spec in s or s in spec:
|
||||
if matched is None or dongwon_item.get('stock', 0) > matched.get('stock', 0):
|
||||
matched = dongwon_item
|
||||
|
||||
if matched:
|
||||
stock = matched.get('stock', 0)
|
||||
if stock >= item['order_qty']:
|
||||
status = 'success'
|
||||
result_code = 'OK'
|
||||
result_message = f"[DRY RUN] 주문 가능: 재고 {stock}, 단가 {matched.get('price', 0):,}원"
|
||||
success_count += 1
|
||||
elif stock > 0:
|
||||
status = 'failed'
|
||||
result_code = 'LOW_STOCK'
|
||||
result_message = f"[DRY RUN] 재고 부족: {stock}개 (요청: {item['order_qty']})"
|
||||
failed_count += 1
|
||||
else:
|
||||
status = 'failed'
|
||||
result_code = 'OUT_OF_STOCK'
|
||||
result_message = f"[DRY RUN] 재고 없음"
|
||||
failed_count += 1
|
||||
else:
|
||||
status = 'failed'
|
||||
result_code = 'NOT_FOUND'
|
||||
result_message = f"[DRY RUN] 동원에서 규격 {spec} 미발견"
|
||||
failed_count += 1
|
||||
|
||||
update_item_result(item['id'], status, result_code, result_message)
|
||||
|
||||
results.append({
|
||||
'item_id': item['id'],
|
||||
'drug_code': item.get('drug_code') or item.get('kd_code'),
|
||||
'product_name': item.get('product_name') or item.get('drug_name', ''),
|
||||
'specification': spec,
|
||||
'order_qty': item['order_qty'],
|
||||
'status': status,
|
||||
'result_code': result_code,
|
||||
'result_message': result_message,
|
||||
'matched_spec': matched.get('spec') if matched else None,
|
||||
'stock': matched.get('stock') if matched else 0,
|
||||
'price': matched.get('price') if matched else 0
|
||||
})
|
||||
|
||||
update_order_status(order_id, 'dry_run_complete',
|
||||
f'[DRY RUN] 완료: 성공 {success_count}, 실패 {failed_count}')
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'dry_run': dry_run,
|
||||
'cart_only': cart_only,
|
||||
'order_id': order_id,
|
||||
'order_no': order['order_no'],
|
||||
'wholesaler': 'dongwon',
|
||||
'total_items': len(items),
|
||||
'success_count': success_count,
|
||||
'failed_count': failed_count,
|
||||
'results': results
|
||||
}
|
||||
|
||||
else:
|
||||
# ─────────────────────────────────────────
|
||||
# 실제 주문: 장바구니 담기 (또는 주문 확정)
|
||||
# ─────────────────────────────────────────
|
||||
cart_items = []
|
||||
|
||||
for item in items:
|
||||
kd_code = item.get('kd_code') or item.get('drug_code')
|
||||
internal_code = item.get('dongwon_code') or item.get('internal_code')
|
||||
spec = item.get('specification', '')
|
||||
order_qty = item['order_qty']
|
||||
|
||||
# internal_code가 없으면 검색해서 찾기
|
||||
if not internal_code:
|
||||
search_result = dongwon_session.search_products(kd_code)
|
||||
if search_result.get('success') and search_result.get('items'):
|
||||
for dongwon_item in search_result['items']:
|
||||
s = dongwon_item.get('spec', '')
|
||||
if spec in s or s in spec:
|
||||
internal_code = dongwon_item.get('internal_code')
|
||||
break
|
||||
# 규격 매칭 안 되면 첫 번째 결과 사용
|
||||
if not internal_code and search_result['items']:
|
||||
internal_code = search_result['items'][0].get('internal_code')
|
||||
|
||||
product_name = item.get('product_name') or item.get('drug_name', '')
|
||||
|
||||
if internal_code:
|
||||
cart_items.append({
|
||||
'internal_code': internal_code,
|
||||
'quantity': order_qty
|
||||
})
|
||||
|
||||
update_item_result(item['id'], 'success', 'CART_READY',
|
||||
f'장바구니 준비 완료: {internal_code}')
|
||||
results.append({
|
||||
'item_id': item['id'],
|
||||
'drug_code': kd_code,
|
||||
'product_name': product_name,
|
||||
'specification': spec,
|
||||
'order_qty': order_qty,
|
||||
'status': 'success',
|
||||
'result_code': 'CART_READY',
|
||||
'result_message': f'장바구니 준비 완료: {internal_code}',
|
||||
'internal_code': internal_code
|
||||
})
|
||||
success_count += 1
|
||||
else:
|
||||
update_item_result(item['id'], 'failed', 'NOT_FOUND',
|
||||
f'동원에서 제품 미발견: {kd_code}')
|
||||
results.append({
|
||||
'item_id': item['id'],
|
||||
'drug_code': kd_code,
|
||||
'product_name': product_name,
|
||||
'specification': spec,
|
||||
'order_qty': order_qty,
|
||||
'status': 'failed',
|
||||
'result_code': 'NOT_FOUND',
|
||||
'result_message': f'동원에서 제품 미발견'
|
||||
})
|
||||
failed_count += 1
|
||||
|
||||
# safe_order 사용 (장바구니 백업/복구)
|
||||
if cart_items:
|
||||
if cart_only:
|
||||
# 장바구니만 담기
|
||||
for cart_item in cart_items:
|
||||
dongwon_session.add_to_cart(
|
||||
cart_item['internal_code'],
|
||||
cart_item['quantity']
|
||||
)
|
||||
update_order_status(order_id, 'cart_added',
|
||||
f'동원 장바구니 담기 완료: {len(cart_items)}개 품목')
|
||||
else:
|
||||
# safe_order로 주문 (기존 장바구니 백업/복구)
|
||||
order_result = dongwon_session.safe_order(
|
||||
items_to_order=cart_items,
|
||||
memo=order.get('memo', ''),
|
||||
dry_run=False
|
||||
)
|
||||
if order_result.get('success'):
|
||||
update_order_status(order_id, 'completed',
|
||||
f'동원 주문 완료: {order_result.get("ordered_count", 0)}개 품목')
|
||||
else:
|
||||
update_order_status(order_id, 'failed',
|
||||
f'동원 주문 실패: {order_result.get("error", "unknown")}')
|
||||
|
||||
# 응답 생성
|
||||
if cart_only:
|
||||
note = '동원약품 장바구니에 담김. 동원몰에서 최종 확정 필요.'
|
||||
else:
|
||||
note = None
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'dry_run': dry_run,
|
||||
'cart_only': cart_only,
|
||||
'order_id': order_id,
|
||||
'order_no': order['order_no'],
|
||||
'wholesaler': 'dongwon',
|
||||
'total_items': len(items),
|
||||
'success_count': success_count,
|
||||
'failed_count': failed_count,
|
||||
'results': results,
|
||||
'note': note
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"동원 주문 오류: {e}", exc_info=True)
|
||||
update_order_status(order_id, 'error', f'동원 주문 오류: {str(e)}')
|
||||
return {
|
||||
'success': False,
|
||||
'order_id': order_id,
|
||||
'wholesaler': 'dongwon',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
@ -1,5 +1,33 @@
|
||||
# pmr_api.py - 조제관리(PMR) Blueprint API
|
||||
# PharmaIT3000 MSSQL 연동 (192.168.0.4)
|
||||
#
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 📋 주요 함수 가이드
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
#
|
||||
# 🏷️ 라벨 관련:
|
||||
# - normalize_medication_name(med_name)
|
||||
# 약품명 정규화: 밀리그램→mg, 언더스코어 제거 등
|
||||
# 예: "케이발린캡슐75밀리그램_" → "케이발린캡슐75mg"
|
||||
#
|
||||
# - get_drug_unit(goods_name, sung_code) [utils/drug_unit.py]
|
||||
# SUNG_CODE 기반 단위 판별
|
||||
# 예: SUNG_CODE "123456TB" → "정" (TB=정제)
|
||||
# FormCode: TB=정, CA/CH/CS=캡슐, SY=mL, GA/GB=포 등
|
||||
#
|
||||
# - create_label_image(patient_name, med_name, ...)
|
||||
# PIL로 29mm 라벨 이미지 생성
|
||||
# 지그재그 테두리, 동적 폰트, 복용량 박스 등
|
||||
#
|
||||
# 📊 SUNG_CODE FormCode 참조 (마지막 2자리):
|
||||
# 정제류: TA, TB, TC, TD, TE, TH, TJ, TR → "정"
|
||||
# 캡슐류: CA, CB, CH, CI, CJ, CS → "캡슐"
|
||||
# 액제류: SS, SY, LQ → "mL" (시럽)
|
||||
# 산제류: GA, GB, GC, GN, PD → "포"
|
||||
# 점안제: EY, OS → "병" 또는 "개"
|
||||
# 외용제: XT, XO, XL → "g" 또는 "개"
|
||||
#
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
from flask import Blueprint, jsonify, request, render_template, send_file
|
||||
import pyodbc
|
||||
@ -8,9 +36,14 @@ from pathlib import Path
|
||||
from datetime import datetime, date
|
||||
import logging
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Pillow 10+ 호환성 패치 (brother_ql용)
|
||||
if not hasattr(Image, 'ANTIALIAS'):
|
||||
Image.ANTIALIAS = Image.Resampling.LANCZOS
|
||||
import io
|
||||
import base64
|
||||
import os
|
||||
from utils.drug_unit import get_drug_unit
|
||||
|
||||
pmr_bp = Blueprint('pmr', __name__, url_prefix='/pmr')
|
||||
|
||||
@ -363,6 +396,7 @@ def get_prescription_detail(prescription_id):
|
||||
'total_qty': float(row.INV_QUAN) if row.INV_QUAN else 0,
|
||||
'type': '급여' if row.PS_Type in ['0', '4'] else '비급여' if row.PS_Type == '1' else row.PS_Type,
|
||||
'sung_code': row.SUNG_CODE or '',
|
||||
'unit': get_drug_unit(row.GoodsName or '', row.SUNG_CODE or ''),
|
||||
'ps_type': row.PS_Type or '0',
|
||||
'unit_code': unit_code,
|
||||
'is_substituted': is_substituted,
|
||||
@ -401,20 +435,24 @@ def get_prescription_detail(prescription_id):
|
||||
'name_2': disease_name_2
|
||||
}
|
||||
|
||||
# 환자 특이사항(CUSETC) 조회 - CD_PERSON 테이블
|
||||
# 환자 특이사항(CUSETC) + 전화번호 조회 - CD_PERSON 테이블
|
||||
cusetc = ''
|
||||
phone = ''
|
||||
cus_code = rx_row.CusCode
|
||||
if cus_code:
|
||||
try:
|
||||
# PM_BASE.dbo.CD_PERSON에서 조회
|
||||
cursor.execute("""
|
||||
SELECT CUSETC FROM PM_BASE.dbo.CD_PERSON WHERE CUSCODE = ?
|
||||
SELECT CUSETC, PHONE, TEL_NO, PHONE2 FROM PM_BASE.dbo.CD_PERSON WHERE CUSCODE = ?
|
||||
""", (cus_code,))
|
||||
person_row = cursor.fetchone()
|
||||
if person_row and person_row.CUSETC:
|
||||
cusetc = person_row.CUSETC
|
||||
if person_row:
|
||||
if person_row.CUSETC:
|
||||
cusetc = person_row.CUSETC
|
||||
# 전화번호 (PHONE, TEL_NO, PHONE2 중 하나)
|
||||
phone = person_row.PHONE or person_row.TEL_NO or person_row.PHONE2 or ''
|
||||
except Exception as e:
|
||||
logging.warning(f"특이사항 조회 실패: {e}")
|
||||
logging.warning(f"환자정보 조회 실패: {e}")
|
||||
|
||||
conn.close()
|
||||
|
||||
@ -437,7 +475,8 @@ def get_prescription_detail(prescription_id):
|
||||
'cus_code': rx_row.CusCode, # 호환성
|
||||
'age': age,
|
||||
'gender': gender,
|
||||
'cusetc': cusetc # 특이사항
|
||||
'cusetc': cusetc, # 특이사항
|
||||
'phone': phone # 전화번호
|
||||
},
|
||||
'disease_info': disease_info,
|
||||
'medications': medications,
|
||||
@ -536,6 +575,7 @@ def preview_label():
|
||||
- frequency: 복용 횟수
|
||||
- duration: 복용 일수
|
||||
- unit: 단위 (정, 캡슐, mL 등)
|
||||
- sung_code: 성분코드 (환산계수 조회용, 선택)
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
@ -547,6 +587,19 @@ def preview_label():
|
||||
frequency = int(data.get('frequency', 0))
|
||||
duration = int(data.get('duration', 0))
|
||||
unit = data.get('unit', '정')
|
||||
sung_code = data.get('sung_code', '')
|
||||
|
||||
# 환산계수 및 보관조건 조회 (sung_code가 있는 경우)
|
||||
conversion_factor = None
|
||||
storage_conditions = '실온보관'
|
||||
if sung_code:
|
||||
try:
|
||||
from db.dbsetup import db_manager
|
||||
cf_result = db_manager.get_conversion_factor(sung_code)
|
||||
conversion_factor = cf_result.get('conversion_factor')
|
||||
storage_conditions = cf_result.get('storage_conditions', '실온보관')
|
||||
except Exception as cf_err:
|
||||
logging.warning(f"환산계수 조회 실패 (무시): {cf_err}")
|
||||
|
||||
# 라벨 이미지 생성
|
||||
image = create_label_image(
|
||||
@ -556,7 +609,9 @@ def preview_label():
|
||||
dosage=dosage,
|
||||
frequency=frequency,
|
||||
duration=duration,
|
||||
unit=unit
|
||||
unit=unit,
|
||||
conversion_factor=conversion_factor,
|
||||
storage_conditions=storage_conditions
|
||||
)
|
||||
|
||||
# Base64 인코딩
|
||||
@ -567,7 +622,9 @@ def preview_label():
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'image': f'data:image/png;base64,{img_base64}'
|
||||
'image': f'data:image/png;base64,{img_base64}',
|
||||
'conversion_factor': conversion_factor,
|
||||
'storage_conditions': storage_conditions
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@ -575,10 +632,197 @@ def preview_label():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=0, duration=0, unit='정'):
|
||||
# API: 라벨 인쇄 (Brother QL 프린터)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@pmr_bp.route('/api/label/print', methods=['POST'])
|
||||
def print_label():
|
||||
"""
|
||||
라벨 이미지 생성 (29mm 용지 기준)
|
||||
라벨 인쇄 (PIL 렌더링 → Brother QL 프린터 전송)
|
||||
|
||||
Request Body:
|
||||
- patient_name, med_name, dosage, frequency, duration, unit, sung_code
|
||||
- printer: 프린터 선택 (선택, 기본값 '168')
|
||||
- '121': QL-710W (192.168.0.121)
|
||||
- '168': QL-810W (192.168.0.168)
|
||||
- orientation: 출력 방향 (선택, 기본값 'portrait')
|
||||
- 'portrait': 세로 모드 (QR 라벨과 동일, 회전 없음)
|
||||
- 'landscape': 가로 모드 (90도 회전)
|
||||
"""
|
||||
try:
|
||||
from brother_ql.raster import BrotherQLRaster
|
||||
from brother_ql.conversion import convert
|
||||
from brother_ql.backends.helpers import send
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
patient_name = data.get('patient_name', '')
|
||||
med_name = data.get('med_name', '')
|
||||
add_info = data.get('add_info', '')
|
||||
dosage = float(data.get('dosage', 0))
|
||||
frequency = int(data.get('frequency', 0))
|
||||
duration = int(data.get('duration', 0))
|
||||
unit = data.get('unit', '정')
|
||||
sung_code = data.get('sung_code', '')
|
||||
printer = data.get('printer', '168') # 기본값: QL-810W
|
||||
orientation = data.get('orientation', 'portrait') # 기본값: 세로 모드
|
||||
|
||||
# 프린터 설정
|
||||
if printer == '121':
|
||||
printer_ip = '192.168.0.121'
|
||||
printer_model = 'QL-710W'
|
||||
else:
|
||||
printer_ip = '192.168.0.168'
|
||||
printer_model = 'QL-810W'
|
||||
|
||||
# 환산계수 및 보관조건 조회
|
||||
conversion_factor = None
|
||||
storage_conditions = '실온보관'
|
||||
if sung_code:
|
||||
try:
|
||||
from db.dbsetup import db_manager
|
||||
cf_result = db_manager.get_conversion_factor(sung_code)
|
||||
conversion_factor = cf_result.get('conversion_factor')
|
||||
storage_conditions = cf_result.get('storage_conditions', '실온보관')
|
||||
except Exception as cf_err:
|
||||
logging.warning(f"환산계수 조회 실패 (무시): {cf_err}")
|
||||
|
||||
# 1. 라벨 이미지 생성
|
||||
label_image = create_label_image(
|
||||
patient_name=patient_name,
|
||||
med_name=med_name,
|
||||
add_info=add_info,
|
||||
dosage=dosage,
|
||||
frequency=frequency,
|
||||
duration=duration,
|
||||
unit=unit,
|
||||
conversion_factor=conversion_factor,
|
||||
storage_conditions=storage_conditions
|
||||
)
|
||||
|
||||
# 2. 방향 설정 (portrait: 회전 없음, landscape: 90도 회전)
|
||||
if orientation == 'landscape':
|
||||
# 가로 모드: 90도 회전 (기존 방식)
|
||||
label_final = label_image.rotate(90, expand=True)
|
||||
else:
|
||||
# 세로 모드: 회전 없음 (QR 라벨과 동일)
|
||||
label_final = label_image
|
||||
|
||||
# 3. Brother QL 프린터로 전송
|
||||
qlr = BrotherQLRaster(printer_model)
|
||||
instructions = convert(
|
||||
qlr=qlr,
|
||||
images=[label_final],
|
||||
label='29',
|
||||
rotate='0',
|
||||
threshold=70.0,
|
||||
dither=False,
|
||||
compress=False,
|
||||
red=False,
|
||||
dpi_600=False,
|
||||
hq=True,
|
||||
cut=True
|
||||
)
|
||||
send(instructions, printer_identifier=f"tcp://{printer_ip}:9100")
|
||||
|
||||
logging.info(f"[SUCCESS] PMR 라벨 인쇄 성공: {med_name} → {printer_model} ({orientation})")
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'{med_name} 라벨 인쇄 완료 ({printer_model})',
|
||||
'printer': printer_model,
|
||||
'orientation': orientation
|
||||
})
|
||||
|
||||
except ImportError as e:
|
||||
logging.error(f"brother_ql 라이브러리 없음: {e}")
|
||||
return jsonify({'success': False, 'error': 'brother_ql 라이브러리가 설치되지 않았습니다'}), 500
|
||||
except Exception as e:
|
||||
logging.error(f"라벨 인쇄 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
def normalize_medication_name(med_name):
|
||||
"""
|
||||
약품명 정제 - 밀리그램 등을 mg로 변환, 불필요한 부분 제거
|
||||
"""
|
||||
import re
|
||||
if not med_name:
|
||||
return med_name
|
||||
|
||||
# 언더스코어 뒤 내용 제거 (예: 휴니즈레바미피드정_ → 휴니즈레바미피드정)
|
||||
med_name = re.sub(r'_.*$', '', med_name)
|
||||
|
||||
# 대괄호 및 내용 제거
|
||||
med_name = re.sub(r'\[.*?\]', '', med_name)
|
||||
med_name = re.sub(r'\[.*$', '', med_name)
|
||||
|
||||
# 밀리그램 변환
|
||||
med_name = re.sub(r'밀리그램|밀리그람|미리그램|미리그람', 'mg', med_name)
|
||||
# 마이크로그램 변환
|
||||
med_name = re.sub(r'마이크로그램|마이크로그람', 'μg', med_name)
|
||||
# 그램 변환 (mg/μg 제외)
|
||||
med_name = re.sub(r'(?<!m)(?<!μ)그램|그람', 'g', med_name)
|
||||
# 밀리리터 변환
|
||||
med_name = re.sub(r'밀리리터|밀리리타|미리리터|미리리타', 'mL', med_name)
|
||||
|
||||
# 공백 정리
|
||||
med_name = re.sub(r'\s+', ' ', med_name).strip()
|
||||
|
||||
return med_name
|
||||
|
||||
|
||||
def draw_scissor_border(draw, width, height, edge_size=5, 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 create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=0, duration=0, unit='정', conversion_factor=None, storage_conditions='실온보관'):
|
||||
"""
|
||||
라벨 이미지 생성 (29mm 용지 기준) - 레거시 디자인 적용
|
||||
|
||||
Args:
|
||||
conversion_factor: 건조시럽 환산계수 (mL→g 변환용, 선택)
|
||||
- 예: 0.11이면 120ml * 0.11 = 13.2g
|
||||
- 총량 옆에 괄호로 표시: "총120mL (13.2g)/5일분"
|
||||
storage_conditions: 보관조건 (예: '냉장보관', '실온보관')
|
||||
- 용법 박스와 조제일 사이 여백에 표시
|
||||
"""
|
||||
# 약품명 정제 (밀리그램 → mg 등)
|
||||
med_name = normalize_medication_name(med_name)
|
||||
|
||||
# 라벨 크기 (29mm 용지, 300dpi 기준)
|
||||
label_width = 306
|
||||
label_height = 380
|
||||
@ -592,15 +836,38 @@ def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=
|
||||
font_path = "C:/Windows/Fonts/malgun.ttf"
|
||||
|
||||
try:
|
||||
name_font = ImageFont.truetype(font_path, 36)
|
||||
drug_font = ImageFont.truetype(font_path, 24)
|
||||
info_font = ImageFont.truetype(font_path, 22)
|
||||
small_font = ImageFont.truetype(font_path, 18)
|
||||
name_font = ImageFont.truetype(font_path, 44) # 환자명 폰트 크게
|
||||
drug_font = ImageFont.truetype(font_path, 32) # 약품명
|
||||
info_font = ImageFont.truetype(font_path, 30) # 복용 정보
|
||||
small_font = ImageFont.truetype(font_path, 20) # 조제일
|
||||
additional_font = ImageFont.truetype(font_path, 27) # 총량/효능
|
||||
signature_font = ImageFont.truetype(font_path, 32) # 시그니처
|
||||
except:
|
||||
name_font = ImageFont.load_default()
|
||||
drug_font = ImageFont.load_default()
|
||||
info_font = ImageFont.load_default()
|
||||
small_font = ImageFont.load_default()
|
||||
additional_font = ImageFont.load_default()
|
||||
signature_font = ImageFont.load_default()
|
||||
|
||||
# 동적 폰트 크기 조정 함수
|
||||
def get_adaptive_font(text, max_width, initial_font_size, min_font_size=20):
|
||||
"""텍스트가 max_width를 초과하지 않도록 폰트 크기를 동적으로 조정"""
|
||||
current_size = initial_font_size
|
||||
while current_size >= min_font_size:
|
||||
try:
|
||||
test_font = ImageFont.truetype(font_path, current_size)
|
||||
except:
|
||||
return ImageFont.load_default()
|
||||
bbox = draw.textbbox((0, 0), text, font=test_font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
if text_width <= max_width:
|
||||
return test_font
|
||||
current_size -= 2
|
||||
try:
|
||||
return ImageFont.truetype(font_path, min_font_size)
|
||||
except:
|
||||
return ImageFont.load_default()
|
||||
|
||||
# 중앙 정렬 텍스트 함수
|
||||
def draw_centered(text, y, font, fill="black"):
|
||||
@ -611,6 +878,25 @@ def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=
|
||||
return y + bbox[3] - bbox[1] + 5
|
||||
|
||||
# 약품명 줄바꿈 처리
|
||||
def wrap_text_korean(text, font, max_width, draw):
|
||||
"""한글/영문 혼합 텍스트 줄바꿈 (글자 단위)"""
|
||||
if not text:
|
||||
return [text]
|
||||
lines = []
|
||||
current_line = ""
|
||||
for char in text:
|
||||
test_line = current_line + char
|
||||
bbox = draw.textbbox((0, 0), test_line, font=font)
|
||||
if bbox[2] - bbox[0] <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = char
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
return lines if lines else [text]
|
||||
|
||||
def wrap_text(text, font, max_width):
|
||||
lines = []
|
||||
words = text.split()
|
||||
@ -628,7 +914,7 @@ def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=
|
||||
lines.append(current_line)
|
||||
return lines if lines else [text]
|
||||
|
||||
y = 15
|
||||
y = 8 # 상단 지그재그 ↔ 이름 간격 (공간 확보를 위해 축소)
|
||||
|
||||
# 환자명 (띄어쓰기)
|
||||
spaced_name = " ".join(patient_name) if patient_name else ""
|
||||
@ -637,44 +923,64 @@ def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=
|
||||
y += 5
|
||||
|
||||
# 약품명 (줄바꿈)
|
||||
# 괄호 앞에서 분리
|
||||
if '(' in med_name:
|
||||
main_name = med_name.split('(')[0].strip()
|
||||
else:
|
||||
# 앞에 있는 (숫자mg) 패턴 제거 후, 뒤의 괄호 앞에서 분리
|
||||
import re
|
||||
# (2.5mg)노바스크정 → 노바스크정
|
||||
main_name = re.sub(r'^\([^)]+\)', '', med_name).strip()
|
||||
# 노바스크정(고혈압) → 노바스크정
|
||||
if '(' in main_name:
|
||||
main_name = main_name.split('(')[0].strip()
|
||||
# 빈 문자열이면 원본 사용
|
||||
if not main_name:
|
||||
main_name = med_name
|
||||
|
||||
# 약품명 줄바꿈
|
||||
name_lines = wrap_text(main_name, drug_font, label_width - 30)
|
||||
# 약품명 - 동적 폰트 크기 적용 (긴 이름 자동 축소)
|
||||
adaptive_drug_font = get_adaptive_font(main_name, label_width - 30, 32, 18)
|
||||
name_lines = wrap_text_korean(main_name, adaptive_drug_font, label_width - 30, draw)
|
||||
for line in name_lines:
|
||||
y = draw_centered(line, y, drug_font)
|
||||
y = draw_centered(line, y, adaptive_drug_font)
|
||||
|
||||
# 효능효과 (add_info)
|
||||
# 효능효과 (add_info) - 동적 폰트 크기 적용
|
||||
if add_info:
|
||||
y = draw_centered(f"({add_info})", y, small_font, fill="gray")
|
||||
efficacy_text = f"({add_info})"
|
||||
adaptive_efficacy_font = get_adaptive_font(efficacy_text, label_width - 40, 30, 20)
|
||||
y = draw_centered(efficacy_text, y, adaptive_efficacy_font, fill="black")
|
||||
|
||||
y += 5
|
||||
|
||||
# 총량 계산
|
||||
# 총량 계산 및 표시 (환산계수 반영)
|
||||
if dosage > 0 and frequency > 0 and duration > 0:
|
||||
total = dosage * frequency * duration
|
||||
total_str = str(int(total)) if total == int(total) else f"{total:.1f}"
|
||||
total_text = f"총 {total_str}{unit} / {duration}일분"
|
||||
y = draw_centered(total_text, y, info_font)
|
||||
total_str = str(int(total)) if total == int(total) else f"{total:.2f}".rstrip('0').rstrip('.')
|
||||
|
||||
# 환산계수가 있으면 변환된 총량도 표시 (예: "총120mL (13.2g)/5일분")
|
||||
if conversion_factor is not None and conversion_factor > 0:
|
||||
converted_total = total * conversion_factor
|
||||
if converted_total == int(converted_total):
|
||||
converted_str = str(int(converted_total))
|
||||
else:
|
||||
converted_str = f"{converted_total:.2f}".rstrip('0').rstrip('.')
|
||||
total_text = f"총{total_str}{unit} ({converted_str}g)/{duration}일분"
|
||||
else:
|
||||
total_text = f"총{total_str}{unit}/{duration}일분"
|
||||
y = draw_centered(total_text, y, additional_font)
|
||||
|
||||
y += 5
|
||||
|
||||
# 용법 박스
|
||||
# 용법 박스 (테두리 있는 박스)
|
||||
box_margin = 20
|
||||
box_height = 75
|
||||
box_top = y
|
||||
box_bottom = y + 70
|
||||
box_bottom = y + box_height
|
||||
box_width = label_width - 2 * box_margin
|
||||
draw.rectangle(
|
||||
[(box_margin, box_top), (label_width - box_margin, box_bottom)],
|
||||
outline="black",
|
||||
width=2
|
||||
)
|
||||
|
||||
# 박스 내용
|
||||
dosage_str = str(int(dosage)) if dosage == int(dosage) else f"{dosage:.2f}".rstrip('0').rstrip('.')
|
||||
# 박스 내용 - 1회 복용량
|
||||
dosage_str = str(int(dosage)) if dosage == int(dosage) else f"{dosage:.4f}".rstrip('0').rstrip('.')
|
||||
dosage_text = f"{dosage_str}{unit}"
|
||||
|
||||
# 복용 시간
|
||||
@ -687,24 +993,65 @@ def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=
|
||||
else:
|
||||
time_text = f"1일 {frequency}회"
|
||||
|
||||
box_center_y = (box_top + box_bottom) // 2
|
||||
draw_centered(dosage_text, box_center_y - 20, info_font)
|
||||
draw_centered(time_text, box_center_y + 5, info_font)
|
||||
# 박스 내 텍스트 중앙 배치 (수직 중앙 정렬)
|
||||
line_spacing = 6 # 1회복용량 ↔ 복용횟수 간격
|
||||
bbox1 = draw.textbbox((0, 0), dosage_text, font=info_font)
|
||||
text1_height = bbox1[3] - bbox1[1]
|
||||
bbox2 = draw.textbbox((0, 0), time_text, font=info_font)
|
||||
text2_height = bbox2[3] - bbox2[1]
|
||||
# 방법 2: 폰트 최대 높이 기준 고정 (글자 내용과 무관하게 일정한 레이아웃)
|
||||
fixed_line_height = 32 # 폰트 크기 30 기반 고정 라인 높이
|
||||
fixed_total_height = fixed_line_height * 2 + line_spacing
|
||||
center_y = (box_top + box_bottom) // 2
|
||||
start_y = center_y - (fixed_total_height // 2) - 5 # 박스 내 텍스트 전체 위로 조정
|
||||
|
||||
y = box_bottom + 10
|
||||
draw_centered(dosage_text, start_y, info_font)
|
||||
draw_centered(time_text, start_y + fixed_line_height + line_spacing, info_font)
|
||||
|
||||
# 조제일
|
||||
# 조제일 (시그니처 위쪽에 배치) - 먼저 위치 계산
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
y = draw_centered(f"조제일: {today}", y, small_font)
|
||||
print_date_text = f"조제일 : {today}"
|
||||
bbox = draw.textbbox((0, 0), print_date_text, font=small_font)
|
||||
date_w, date_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
print_date_y = label_height - date_h - 70 # 시그니처 위쪽
|
||||
|
||||
# 약국명 (하단)
|
||||
pharmacy_y = label_height - 40
|
||||
draw.rectangle(
|
||||
[(50, pharmacy_y - 5), (label_width - 50, pharmacy_y + 25)],
|
||||
outline="black",
|
||||
width=1
|
||||
)
|
||||
draw_centered("청 춘 약 국", pharmacy_y, info_font)
|
||||
# 보관조건 표시 (조제일 바로 위에 고정 배치)
|
||||
if storage_conditions:
|
||||
storage_text = f"* {storage_conditions}"
|
||||
try:
|
||||
storage_font = ImageFont.truetype(font_path, 28)
|
||||
except:
|
||||
storage_font = ImageFont.load_default()
|
||||
bbox_storage = draw.textbbox((0, 0), storage_text, font=storage_font)
|
||||
storage_w = bbox_storage[2] - bbox_storage[0]
|
||||
storage_h = bbox_storage[3] - bbox_storage[1]
|
||||
storage_y = print_date_y - storage_h - 8 # 조제일 위 8px 간격
|
||||
draw.text(((label_width - storage_w) / 2, storage_y), storage_text, font=storage_font, fill="black")
|
||||
|
||||
# 조제일 그리기
|
||||
draw.text(((label_width - date_w) / 2, print_date_y), print_date_text, font=small_font, fill="black")
|
||||
|
||||
# 시그니처 박스 (하단 - 약국명)
|
||||
signature_text = "청 춘 약 국"
|
||||
bbox = draw.textbbox((0, 0), signature_text, font=signature_font)
|
||||
w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
|
||||
# 시그니처 박스 패딩 및 위치 계산
|
||||
padding_top = int(h_sig * 0.1)
|
||||
padding_bottom = int(h_sig * 0.5)
|
||||
padding_sides = int(h_sig * 0.2)
|
||||
|
||||
box_x = (label_width - w_sig) / 2 - padding_sides
|
||||
box_y = label_height - h_sig - padding_top - padding_bottom - 10
|
||||
box_x2 = box_x + w_sig + 2 * padding_sides
|
||||
box_y2 = box_y + h_sig + padding_top + padding_bottom
|
||||
|
||||
# 시그니처 테두리 및 텍스트
|
||||
draw.rectangle([(box_x, box_y), (box_x2, box_y2)], outline="black", width=1)
|
||||
draw.text(((label_width - w_sig) / 2, box_y + padding_top), signature_text, font=signature_font, fill="black")
|
||||
|
||||
# 지그재그 테두리 (가위로 자른 느낌)
|
||||
draw_scissor_border(draw, label_width, label_height, edge_size=10, steps=20)
|
||||
|
||||
return image
|
||||
|
||||
|
||||
@ -1,25 +1,102 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
동물약 일괄 APC 매칭 - 후보 찾기
|
||||
동물약 일괄 APC 매칭 (개선판)
|
||||
- 띄어쓰기 무시 매칭
|
||||
- 체중 범위로 정밀 매칭
|
||||
- dry-run 모드 (검증용)
|
||||
"""
|
||||
import sys, io
|
||||
import sys, io, re
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from db.dbsetup import get_db_session
|
||||
from sqlalchemy import text, create_engine
|
||||
from datetime import datetime
|
||||
|
||||
DRY_RUN = True # True: 검증만, False: 실제 INSERT
|
||||
|
||||
# ── 유틸 함수 ──
|
||||
|
||||
def normalize(name):
|
||||
"""띄어쓰기/특수문자 제거하여 비교용 문자열 생성"""
|
||||
# 공백, 하이픈, 점 제거
|
||||
return re.sub(r'[\s\-\.]+', '', name).lower()
|
||||
|
||||
def extract_base_name(mssql_name):
|
||||
"""MSSQL 제품명에서 검색용 기본명 추출 (여러 후보 반환)
|
||||
예: '다이로하트정M(12~22kg)' → ['다이로하트정', '다이로하트']
|
||||
'하트캅츄어블(11kg이하)' → ['하트캅츄어블', '하트캅']
|
||||
'클라펫정50(100정)' → ['클라펫정50', '클라펫정', '클라펫']
|
||||
"""
|
||||
name = mssql_name.replace('(판)', '')
|
||||
# 사이즈 라벨(XS/SS/S/M/L/XL/mini) + 괄호 이전까지
|
||||
m = re.match(r'^(.+?)(XS|SS|XL|xs|mini|S|M|L)?\s*[\(/]', name)
|
||||
if m:
|
||||
base = m.group(1)
|
||||
else:
|
||||
base = re.sub(r'[\(/].*', '', name)
|
||||
base = base.strip()
|
||||
|
||||
candidates = [base]
|
||||
# 끝의 숫자 제거: 클라펫정50 → 클라펫정
|
||||
no_num = re.sub(r'\d+$', '', base)
|
||||
if no_num and no_num != base:
|
||||
candidates.append(no_num)
|
||||
# 제형 접미사 제거: 다이로하트정 → 다이로하트, 하트캅츄어블 → 하트캅
|
||||
for suffix in ['츄어블', '정', '액', '캡슐', '산', '시럽']:
|
||||
for c in list(candidates):
|
||||
stripped = re.sub(suffix + r'$', '', c)
|
||||
if stripped and stripped != c and stripped not in candidates:
|
||||
candidates.append(stripped)
|
||||
return candidates
|
||||
|
||||
def extract_weight_range(mssql_name):
|
||||
"""MSSQL 제품명에서 체중 범위 추출
|
||||
'가드닐L(20~40kg)' → (20, 40)
|
||||
'셀라이트액SS(2.5kg이하)' → (0, 2.5)
|
||||
'파라캅L(5kg이상)' → (5, 999)
|
||||
'하트웜솔루션츄어블S(11kg이하)' → (0, 11)
|
||||
'다이로하트정S(5.6~11kg)' → (5.6, 11)
|
||||
"""
|
||||
# 범위: (5.6~11kg), (2~10kg)
|
||||
m = re.search(r'\((\d+\.?\d*)[-~](\d+\.?\d*)\s*kg\)', mssql_name)
|
||||
if m:
|
||||
return float(m.group(1)), float(m.group(2))
|
||||
|
||||
# 이하: (2.5kg이하), (11kg이하)
|
||||
m = re.search(r'\((\d+\.?\d*)\s*kg\s*이하\)', mssql_name)
|
||||
if m:
|
||||
return 0, float(m.group(1))
|
||||
|
||||
# 이상: (5kg이상)
|
||||
m = re.search(r'\((\d+\.?\d*)\s*kg\s*이상\)', mssql_name)
|
||||
if m:
|
||||
return float(m.group(1)), 999
|
||||
|
||||
return None, None
|
||||
|
||||
def weight_match(mssql_min, mssql_max, pg_min, pg_max):
|
||||
"""체중 범위가 일치하는지 확인 (약간의 오차 허용)"""
|
||||
if pg_min is None or pg_max is None:
|
||||
return False
|
||||
# 이상(999)인 경우 pg_max도 큰 값이면 OK
|
||||
if mssql_max == 999 and pg_max >= 50:
|
||||
return abs(mssql_min - pg_min) <= 1
|
||||
return abs(mssql_min - pg_min) <= 1 and abs(mssql_max - pg_max) <= 1
|
||||
|
||||
|
||||
# ── 1. MSSQL 동물약 (APC 없는 것만) ──
|
||||
|
||||
# 1. MSSQL 동물약 (APC 없는 것만)
|
||||
session = get_db_session('PM_DRUG')
|
||||
result = session.execute(text("""
|
||||
SELECT
|
||||
SELECT
|
||||
G.DrugCode,
|
||||
G.GoodsName,
|
||||
G.Saleprice,
|
||||
(
|
||||
SELECT TOP 1 U.CD_CD_BARCODE
|
||||
FROM CD_ITEM_UNIT_MEMBER U
|
||||
WHERE U.DRUGCODE = G.DrugCode
|
||||
SELECT TOP 1 U.CD_CD_BARCODE
|
||||
FROM CD_ITEM_UNIT_MEMBER U
|
||||
WHERE U.DRUGCODE = G.DrugCode
|
||||
AND U.CD_CD_BARCODE LIKE '023%'
|
||||
) AS APC_CODE
|
||||
FROM CD_GOODS G
|
||||
@ -39,44 +116,190 @@ for row in result:
|
||||
|
||||
session.close()
|
||||
|
||||
print(f'=== APC 없는 동물약: {len(no_apc)}개 ===\n')
|
||||
print(f'=== APC 없는 동물약: {len(no_apc)}개 ===')
|
||||
print(f'=== 모드: {"DRY-RUN (검증만)" if DRY_RUN else "실제 INSERT"} ===\n')
|
||||
|
||||
# ── 2. PostgreSQL에서 매칭 ──
|
||||
|
||||
# 2. PostgreSQL에서 매칭 후보 찾기
|
||||
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
|
||||
|
||||
matches = []
|
||||
matched = [] # 확정 매칭
|
||||
ambiguous = [] # 후보 여러 개 (수동 확인 필요)
|
||||
no_match = [] # 매칭 없음
|
||||
|
||||
for drug in no_apc:
|
||||
name = drug['name']
|
||||
# 제품명에서 검색 키워드 추출
|
||||
# (판) 제거, 괄호 내용 제거
|
||||
search_name = name.replace('(판)', '').split('(')[0].strip()
|
||||
|
||||
# PostgreSQL 검색
|
||||
result = pg.execute(text("""
|
||||
SELECT apc, product_name,
|
||||
llm_pharm->>'사용가능 동물' as target,
|
||||
llm_pharm->>'분류' as category
|
||||
FROM apc
|
||||
WHERE product_name ILIKE :pattern
|
||||
ORDER BY LENGTH(product_name)
|
||||
LIMIT 5
|
||||
"""), {'pattern': f'%{search_name}%'})
|
||||
|
||||
candidates = list(result)
|
||||
if candidates:
|
||||
matches.append({
|
||||
base_names = extract_base_name(name)
|
||||
w_min, w_max = extract_weight_range(name)
|
||||
|
||||
# 여러 기본명 후보로 검색 (좁은 것부터 시도)
|
||||
candidates = []
|
||||
used_base = None
|
||||
for bn in base_names:
|
||||
norm_base = normalize(bn)
|
||||
result = pg.execute(text("""
|
||||
SELECT apc, product_name,
|
||||
weight_min_kg, weight_max_kg,
|
||||
dosage,
|
||||
llm_pharm->>'사용가능 동물' as target
|
||||
FROM apc
|
||||
WHERE REGEXP_REPLACE(LOWER(product_name), '[\\s\\-\\.]+', '', 'g') LIKE :pattern
|
||||
ORDER BY product_name
|
||||
"""), {'pattern': f'%{norm_base}%'})
|
||||
candidates = list(result)
|
||||
if candidates:
|
||||
used_base = bn
|
||||
break
|
||||
if not used_base:
|
||||
used_base = base_names[0]
|
||||
|
||||
if not candidates:
|
||||
no_match.append(drug)
|
||||
print(f'❌ {name}')
|
||||
print(f' 기본명: {base_names} → 매칭 없음')
|
||||
continue
|
||||
|
||||
# ── 단계별 필터링 ──
|
||||
|
||||
# (A) 제형 필터: MSSQL 이름에 "정"이 있으면 PG에서도 "정" 포함 우선
|
||||
filtered = candidates
|
||||
for form in ['정', '액', '캡슐']:
|
||||
if form in name.split('(')[0]:
|
||||
form_match = [c for c in filtered if form in c.product_name]
|
||||
if form_match:
|
||||
filtered = form_match
|
||||
break
|
||||
|
||||
# (B) 체중 범위로 정밀 매칭
|
||||
if w_min is not None:
|
||||
exact = [c for c in filtered
|
||||
if weight_match(w_min, w_max, c.weight_min_kg, c.weight_max_kg)]
|
||||
if exact:
|
||||
filtered = exact
|
||||
|
||||
# (C) 포장단위 여러 개면 최소 포장 선택 (낱개 판매 기준)
|
||||
# "/ 6 정", "/ 1 피펫" 등에서 숫자 추출
|
||||
if len(filtered) > 1:
|
||||
def extract_pack_qty(pname):
|
||||
m = re.search(r'/\s*(\d+)\s*(정|피펫|개|포)', pname)
|
||||
return int(m.group(1)) if m else 0
|
||||
has_qty = [(c, extract_pack_qty(c.product_name)) for c in filtered]
|
||||
# 포장수량이 있는 것들만 필터
|
||||
with_qty = [(c, q) for c, q in has_qty if q > 0]
|
||||
if with_qty:
|
||||
min_qty = min(q for _, q in with_qty)
|
||||
filtered = [c for c, q in with_qty if q == min_qty]
|
||||
|
||||
# (D) 그래도 여러 개면 대표 APC (product_name이 가장 짧은 것) 선택
|
||||
if len(filtered) > 1:
|
||||
# 포장수량 정보가 없는 대표 코드가 있으면 우선
|
||||
no_qty = [c for c in filtered if '/' not in c.product_name]
|
||||
if len(no_qty) == 1:
|
||||
filtered = no_qty
|
||||
|
||||
# ── 결과 판정 ──
|
||||
if len(filtered) == 1:
|
||||
method = '체중매칭' if w_min is not None and filtered[0].weight_min_kg is not None else '유일후보'
|
||||
matched.append({
|
||||
'mssql': drug,
|
||||
'candidates': candidates
|
||||
'apc': filtered[0],
|
||||
'method': method
|
||||
})
|
||||
print(f'✅ {name}')
|
||||
for c in candidates[:2]:
|
||||
print(f' → {c.apc}: {c.product_name[:40]}... [{c.target or "?"}]')
|
||||
else:
|
||||
print(f'❌ {name} - 매칭 없음')
|
||||
print(f' → {filtered[0].apc}: {filtered[0].product_name}')
|
||||
if w_min is not None and filtered[0].weight_min_kg is not None:
|
||||
print(f' 체중: MSSQL({w_min}~{w_max}kg) = PG({filtered[0].weight_min_kg}~{filtered[0].weight_max_kg}kg)')
|
||||
continue
|
||||
|
||||
# 후보가 0개 (필터가 너무 강했으면 원래 candidates로 복구)
|
||||
if len(filtered) == 0:
|
||||
filtered = candidates
|
||||
|
||||
# 수동 확인
|
||||
ambiguous.append({
|
||||
'mssql': drug,
|
||||
'candidates': filtered,
|
||||
'reason': f'후보 {len(filtered)}건'
|
||||
})
|
||||
print(f'⚠️ {name} - 후보 {len(filtered)}건 (수동 확인)')
|
||||
for c in filtered[:5]:
|
||||
wt = f'({c.weight_min_kg}~{c.weight_max_kg}kg)' if c.weight_min_kg else ''
|
||||
print(f' → {c.apc}: {c.product_name} {wt}')
|
||||
|
||||
pg.close()
|
||||
|
||||
print(f'\n=== 요약 ===')
|
||||
# ── 3. 요약 ──
|
||||
|
||||
print(f'\n{"="*50}')
|
||||
print(f'=== 매칭 요약 ===')
|
||||
print(f'APC 없는 제품: {len(no_apc)}개')
|
||||
print(f'매칭 후보 있음: {len(matches)}개')
|
||||
print(f'매칭 없음: {len(no_apc) - len(matches)}개')
|
||||
print(f'✅ 확정 매칭: {len(matched)}개')
|
||||
print(f'⚠️ 수동 확인: {len(ambiguous)}개')
|
||||
print(f'❌ 매칭 없음: {len(no_match)}개')
|
||||
|
||||
if matched:
|
||||
print(f'\n{"="*50}')
|
||||
print(f'=== 확정 매칭 목록 (INSERT 대상) ===')
|
||||
for m in matched:
|
||||
d = m['mssql']
|
||||
a = m['apc']
|
||||
print(f' {d["name"]:40s} → {a.apc} [{m["method"]}]')
|
||||
|
||||
# ── 4. INSERT (DRY_RUN=False일 때만) ──
|
||||
|
||||
if matched and not DRY_RUN:
|
||||
print(f'\n{"="*50}')
|
||||
print(f'=== INSERT 실행 ===')
|
||||
session = get_db_session('PM_DRUG')
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
for m in matched:
|
||||
drugcode = m['mssql']['code']
|
||||
apc = m['apc'].apc
|
||||
|
||||
# 기존 가격 조회
|
||||
existing = session.execute(text("""
|
||||
SELECT TOP 1 CD_MY_UNIT, CD_IN_UNIT
|
||||
FROM CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = :dc
|
||||
ORDER BY SN DESC
|
||||
"""), {'dc': drugcode}).fetchone()
|
||||
|
||||
if not existing:
|
||||
print(f' ❌ {m["mssql"]["name"]}: 기존 레코드 없음')
|
||||
continue
|
||||
|
||||
# 중복 확인
|
||||
check = session.execute(text("""
|
||||
SELECT 1 FROM CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = :dc AND CD_CD_BARCODE = :apc
|
||||
"""), {'dc': drugcode, 'apc': apc}).fetchone()
|
||||
|
||||
if check:
|
||||
print(f' ⏭️ {m["mssql"]["name"]}: 이미 등록됨')
|
||||
continue
|
||||
|
||||
try:
|
||||
session.execute(text("""
|
||||
INSERT INTO CD_ITEM_UNIT_MEMBER (
|
||||
DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT,
|
||||
CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE
|
||||
) VALUES (
|
||||
:drugcode, '015', 1.0, :my_unit, :in_unit,
|
||||
:barcode, '', :change_date
|
||||
)
|
||||
"""), {
|
||||
'drugcode': drugcode,
|
||||
'my_unit': existing.CD_MY_UNIT,
|
||||
'in_unit': existing.CD_IN_UNIT,
|
||||
'barcode': apc,
|
||||
'change_date': today
|
||||
})
|
||||
session.commit()
|
||||
print(f' ✅ {m["mssql"]["name"]} → {apc}')
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
print(f' ❌ {m["mssql"]["name"]}: {e}')
|
||||
|
||||
session.close()
|
||||
print('\n완료!')
|
||||
|
||||
@ -18,6 +18,15 @@ MAPPINGS = [
|
||||
# 세레니아
|
||||
('세레니아정16mg(개멀미약)', 'LB000003353', '0231884610109'), # 세레니아 정 16mg / 4정
|
||||
('세레니아정24mg(개멀미약)', 'LB000003354', '0231884620107'), # 세레니아 정 24mg / 4정
|
||||
# ── 2차 매칭 (2026-03-08) ──
|
||||
# 클라펫 (유일후보)
|
||||
('(판)클라펫정50(100정)', 'LB000003504', '0232065900005'), # 클라펫 정
|
||||
# 넥스가드 (체중매칭)
|
||||
('넥스가드L(15~30kg)', 'LB000003531', '0232155400009'), # 넥스가드 스펙트라 츄어블 정 대형견용
|
||||
('넥스가드xs(2~3.5kg)', 'LB000003530', '0232169000004'), # 넥스가드 츄어블 정 소형견용
|
||||
# 하트웜 (체중매칭)
|
||||
('하트웜솔루션츄어블M(12~22kg)', 'LB000003155', '0230758520105'), # 하트웜 솔루션 츄어블 0.136mg / 114mg / 6 정
|
||||
('하트웜솔루션츄어블S(11kg이하)', 'LB000003156', '0230758510107'), # 하트웜 솔루션 츄어블 0.068mg / 57mg / 6 정
|
||||
]
|
||||
|
||||
session = get_db_session('PM_DRUG')
|
||||
|
||||
545
backend/scripts/fill_weight_from_dosage.py
Normal file
545
backend/scripts/fill_weight_from_dosage.py
Normal file
@ -0,0 +1,545 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
APDB weight_min_kg / weight_max_kg 일괄 채우기
|
||||
- dosage_instructions에서 (사이즈라벨, 체중구간) 쌍을 파싱
|
||||
- APC 레코드의 product_name에 포함된 사이즈 라벨로 매칭
|
||||
|
||||
매칭 전략:
|
||||
1. 제품명에 사이즈 라벨(소형견, 중형견 등)이 있으면 → 해당 체중구간 적용
|
||||
2. 체중 구간이 1개뿐이면 → 전체 APC에 적용
|
||||
3. 다중 구간인데 제품명에 라벨 없으면 → SKIP (안전)
|
||||
|
||||
예외 처리:
|
||||
- 사료/축산 관련(톤당 kg) → SKIP
|
||||
- 축산용(max > 60kg) → SKIP
|
||||
- 체중 구간 파싱 불가 → SKIP
|
||||
|
||||
실행: python scripts/fill_weight_from_dosage.py [--commit] [--verbose]
|
||||
기본: dry-run (DB 변경 없음)
|
||||
--commit: 실제 DB 업데이트 수행
|
||||
--verbose: 상세 로그
|
||||
"""
|
||||
import sys
|
||||
import io
|
||||
import re
|
||||
import argparse
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from sqlalchemy import text, create_engine
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 1. 사이즈 라벨 정의
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
SIZE_LABELS = {
|
||||
'초소형견': 'XS', '초소형': 'XS',
|
||||
'소형견': 'S', '소형': 'S',
|
||||
'중형견': 'M', '중형': 'M',
|
||||
'대형견': 'L', '대형': 'L',
|
||||
'초대형견': 'XL', '초대형': 'XL',
|
||||
}
|
||||
|
||||
# 제품명에서 사이즈 감지용 (긴 것부터 먼저 매칭)
|
||||
PRODUCT_NAME_SIZE_PATTERNS = [
|
||||
(r'초소형견', 'XS'),
|
||||
(r'초소형', 'XS'),
|
||||
(r'소형견', 'S'),
|
||||
(r'소형', 'S'),
|
||||
(r'중형견', 'M'),
|
||||
(r'중형', 'M'),
|
||||
(r'초대형견', 'XL'),
|
||||
(r'초대형', 'XL'),
|
||||
(r'대형견', 'L'),
|
||||
(r'대형', 'L'),
|
||||
# 영문/약어
|
||||
(r'\bSS\b', 'XS'),
|
||||
(r'\bXS\b', 'XS'),
|
||||
(r'[-\s]S\b', 'S'),
|
||||
(r'\bS\(', 'S'),
|
||||
(r'[-\s]M\b', 'M'),
|
||||
(r'\bM\(', 'M'),
|
||||
(r'[-\s]L\b', 'L'),
|
||||
(r'\bL\(', 'L'),
|
||||
(r'\bXL\b', 'XL'),
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 2. 체중 구간 파싱
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def strip_html(html_text):
|
||||
"""HTML 태그 제거, 줄 단위 텍스트 반환"""
|
||||
if not html_text:
|
||||
return ""
|
||||
t = html_text.replace('<p class="indent0">', '\n').replace('</p>', '')
|
||||
t = re.sub(r'<[^>]+>', '', t)
|
||||
return t
|
||||
|
||||
|
||||
def is_livestock_context(text_content):
|
||||
"""축산/사료 관련인지 판단"""
|
||||
# 톤당 kg은 사료 관련
|
||||
if '톤당' in text_content and 'kg' in text_content:
|
||||
# 체중 구간이 별도로 있는 경우는 반려동물일 수 있음
|
||||
if '체중' not in text_content and '형견' not in text_content:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def parse_weight_ranges(dosage_instructions):
|
||||
"""
|
||||
dosage_instructions에서 (사이즈라벨, 체중min, 체중max) 리스트를 추출.
|
||||
체중 구간만 추출하며, 성분 용량은 무시.
|
||||
|
||||
Returns:
|
||||
list of dict: [{'min': 0, 'max': 11, 'size': 'S', 'label': '소형견'}, ...]
|
||||
"""
|
||||
if not dosage_instructions:
|
||||
return []
|
||||
|
||||
txt = strip_html(dosage_instructions)
|
||||
|
||||
# 축산/사료 관련 제외
|
||||
if is_livestock_context(txt):
|
||||
return []
|
||||
|
||||
ranges = []
|
||||
seen = set() # (size, min, max) 중복 방지
|
||||
|
||||
# ── 전처리: 줄 분리된 사이즈+체중 합치기 ──
|
||||
# HTML 변환 후 빈 줄이 끼어있을 수 있음:
|
||||
# "소형견 1chewable 68㎍ 57mg\n\n(체중0-11kg)"
|
||||
# → "소형견 1chewable 68㎍ 57mg (체중0-11kg)"
|
||||
lines = txt.split('\n')
|
||||
# 빈 줄 제거한 리스트 (인덱스 보존)
|
||||
non_empty = [(i, line.strip()) for i, line in enumerate(lines) if line.strip()]
|
||||
|
||||
merged_set = set() # 합쳐진 줄 인덱스 (원본 기준)
|
||||
merged_lines = []
|
||||
|
||||
for idx, (orig_i, stripped) in enumerate(non_empty):
|
||||
if orig_i in merged_set:
|
||||
continue
|
||||
# 현재 줄에 사이즈 라벨이 있고, 다음 비어있지 않은 줄이 (체중...) 패턴이면 합치기
|
||||
if idx + 1 < len(non_empty):
|
||||
next_orig_i, next_stripped = non_empty[idx + 1]
|
||||
if (re.match(r'\(체중', next_stripped)
|
||||
and re.search(r'(초소형|소형|중형|대형|초대형)견?', stripped)):
|
||||
merged_lines.append(stripped + ' ' + next_stripped)
|
||||
merged_set.add(next_orig_i)
|
||||
continue
|
||||
merged_lines.append(stripped)
|
||||
|
||||
txt = '\n'.join(merged_lines)
|
||||
|
||||
def add_range(size, wmin, wmax, label):
|
||||
"""중복 방지하며 범위 추가"""
|
||||
if wmax > 60: # 반려동물 체중 범위 초과 → 축산용
|
||||
return
|
||||
if wmax <= wmin:
|
||||
return
|
||||
key = (size, wmin, wmax)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
ranges.append({'min': wmin, 'max': wmax, 'size': size, 'label': label})
|
||||
|
||||
def get_size(label_text):
|
||||
"""라벨 텍스트 → 사이즈 코드"""
|
||||
return SIZE_LABELS.get(label_text + '견', SIZE_LABELS.get(label_text))
|
||||
|
||||
# ── 패턴1: "X형견(체중A-Bkg)" / "X형견 ... (체중A-Bkg)" ──
|
||||
# 예: "소형견(체중0-11kg)", "중형견(체중12-22kg)"
|
||||
# 예(줄 합침): "소형견 1chewable 68㎍ 57mg (체중0-11kg)"
|
||||
for m in re.finditer(
|
||||
r'(초소형|소형|중형|대형|초대형)견?\s*(?:용\s*)?\(?(?:체중\s*)?'
|
||||
r'(\d+\.?\d*)\s*[-~~]\s*(\d+\.?\d*)\s*kg',
|
||||
txt
|
||||
):
|
||||
label = m.group(1)
|
||||
add_range(get_size(label), float(m.group(2)), float(m.group(3)), label + '견')
|
||||
|
||||
# ── 패턴1b: 같은 줄에 라벨과 (체중...)이 먼 경우 ──
|
||||
# 예: "소형견 1chewable 68㎍ 57mg 1개월 (체중0-11kg)"
|
||||
for m in re.finditer(
|
||||
r'(초소형|소형|중형|대형|초대형)견?\b[^\n]*?\(체중\s*'
|
||||
r'(\d+\.?\d*)\s*[-~~]\s*(\d+\.?\d*)\s*kg\)',
|
||||
txt
|
||||
):
|
||||
label = m.group(1)
|
||||
add_range(get_size(label), float(m.group(2)), float(m.group(3)), label + '견')
|
||||
|
||||
# ── 패턴2: "체중A~Bkg X형견용" ──
|
||||
# 예: "체중12~22kg 중형견용(M)"
|
||||
for m in re.finditer(
|
||||
r'(?:체중\s*)?(\d+\.?\d*)\s*[-~~]\s*(\d+\.?\d*)\s*kg\s*(?:의\s*)?'
|
||||
r'(초소형|소형|중형|대형|초대형)견?',
|
||||
txt
|
||||
):
|
||||
label = m.group(3)
|
||||
add_range(get_size(label), float(m.group(1)), float(m.group(2)), label + '견')
|
||||
|
||||
# ── 패턴3: "Akg이하 X형견" / "~Akg X형견" ──
|
||||
# 예: "11kg이하 소형견용"
|
||||
for m in re.finditer(
|
||||
r'(?:체중\s*)?[~~]?\s*(\d+\.?\d*)\s*kg\s*(?:이하|까지)?\s*(?:의\s*)?'
|
||||
r'(초소형|소형|중형|대형|초대형)견?',
|
||||
txt
|
||||
):
|
||||
label = m.group(2)
|
||||
add_range(get_size(label), 0, float(m.group(1)), label + '견')
|
||||
|
||||
# ── 패턴4: "(Akg~Bkg의 X형견에게)" ──
|
||||
# 예: "(5.7kg ~11kg의 소형견에게 본제 1정 투여)"
|
||||
for m in re.finditer(
|
||||
r'\(\s*(\d+\.?\d*)\s*kg?\s*[-~~]\s*(\d+\.?\d*)\s*kg\s*(?:의\s*)?'
|
||||
r'(초소형|소형|중형|대형|초대형)견',
|
||||
txt
|
||||
):
|
||||
label = m.group(3)
|
||||
add_range(get_size(label), float(m.group(1)), float(m.group(2)), label + '견')
|
||||
|
||||
# ── 패턴4b: "(Akg.Bkg의 X형견에게)" - 마침표 구분자 ──
|
||||
# 예: "(12kg.22kg의 중형견에게)"
|
||||
for m in re.finditer(
|
||||
r'\(\s*(\d+\.?\d*)\s*kg\s*\.\s*(\d+\.?\d*)\s*kg\s*(?:의\s*)?'
|
||||
r'(초소형|소형|중형|대형|초대형)견',
|
||||
txt
|
||||
):
|
||||
label = m.group(3)
|
||||
add_range(get_size(label), float(m.group(1)), float(m.group(2)), label + '견')
|
||||
|
||||
# ── 패턴5: "Akg이하의 X형견에게" ──
|
||||
# 예: "(5.6kg이하의 초소형견에게)"
|
||||
for m in re.finditer(
|
||||
r'(\d+\.?\d*)\s*kg\s*이하\s*(?:의\s*)?(초소형|소형|중형|대형|초대형)견',
|
||||
txt
|
||||
):
|
||||
label = m.group(2)
|
||||
add_range(get_size(label), 0, float(m.group(1)), label + '견')
|
||||
|
||||
# ── 패턴6: 테이블 "A~B | 제품명 X형견용" ──
|
||||
# 예: "2~3.5 넥스가드 스펙트라 츄어블 초소형견용 1"
|
||||
# 예: "2-5 | 프론트라인 트리액트 초소형견용 | 0.5ml"
|
||||
for m in re.finditer(
|
||||
r'(\d+\.?\d*)\s*[-~~]\s*(\d+\.?\d*)\s*[\s|]*[^\n]*?'
|
||||
r'(초소형|소형|중형|대형|초대형)견?용?',
|
||||
txt
|
||||
):
|
||||
label = m.group(3)
|
||||
wmin, wmax = float(m.group(1)), float(m.group(2))
|
||||
if wmax <= 60:
|
||||
add_range(get_size(label), wmin, wmax, label + '견')
|
||||
|
||||
# ── 패턴7: "체중 Akg 미만 X형견용" ──
|
||||
# 예: "체중 15kg 미만 소, 중형견용"
|
||||
for m in re.finditer(
|
||||
r'체중\s*(\d+\.?\d*)\s*kg\s*미만\s*[^\n]*?'
|
||||
r'(초소형|소형|중형|대형|초대형)견',
|
||||
txt
|
||||
):
|
||||
label = m.group(2)
|
||||
add_range(get_size(label), 0, float(m.group(1)), label + '견')
|
||||
|
||||
# ── 패턴8: 라벨 없이 체중 구간만 (반려동물 키워드 있을 때) ──
|
||||
if not ranges and ('개' in txt or '고양이' in txt or '반려' in txt or '애완' in txt):
|
||||
# 8a: "Xkg 초과-Ykg 이하" / "Xkg 초과 ~ Ykg" (먼저 처리)
|
||||
for m in re.finditer(
|
||||
r'(\d+\.?\d*)\s*kg\s*초과\s*[-~~]?\s*(\d+\.?\d*)\s*kg(?:\s*(?:이하|까지))?',
|
||||
txt
|
||||
):
|
||||
wmin, wmax = float(m.group(1)), float(m.group(2))
|
||||
if wmax <= 60 and wmax > wmin:
|
||||
add_range(None, wmin, wmax, None)
|
||||
|
||||
# 8b: "X-Ykg" / "X~Ykg" 일반 범위
|
||||
for m in re.finditer(
|
||||
r'(?:체중\s*)?(\d+\.?\d*)\s*[-~~]\s*(\d+\.?\d*)\s*kg',
|
||||
txt
|
||||
):
|
||||
wmin, wmax = float(m.group(1)), float(m.group(2))
|
||||
if wmax <= 60 and wmax > wmin:
|
||||
add_range(None, wmin, wmax, None)
|
||||
|
||||
# 8c: "Xkg 이하" / "~Xkg" (최소=0)
|
||||
# 단, "Akg 초과-Xkg 이하"는 8a에서 이미 처리되었으므로 제외
|
||||
for m in re.finditer(
|
||||
r'(?:체중\s*)?[~~]\s*(\d+\.?\d*)\s*kg|(\d+\.?\d*)\s*kg\s*(?:이하|까지)',
|
||||
txt
|
||||
):
|
||||
val = m.group(1) or m.group(2)
|
||||
wmax = float(val)
|
||||
if wmax <= 60:
|
||||
# "초과-Xkg 이하" 컨텍스트인지 확인 → 이미 8a에서 처리됨
|
||||
start = max(0, m.start() - 15)
|
||||
before = txt[start:m.start()]
|
||||
if '초과' in before:
|
||||
continue
|
||||
add_range(None, 0, wmax, None)
|
||||
|
||||
# 정렬 (min 기준)
|
||||
ranges.sort(key=lambda x: x['min'])
|
||||
return ranges
|
||||
|
||||
|
||||
def is_multi_size_product_name(product_name):
|
||||
"""
|
||||
제품명에 여러 사이즈가 함께 들어있는 통합 제품인지 판단.
|
||||
예: "하트커버(SS,S,M,L)정" → True
|
||||
"""
|
||||
if not product_name:
|
||||
return False
|
||||
# 여러 사이즈 약어가 한 제품명에 있는 경우
|
||||
if re.search(r'[(\(].*(?:SS|XS).*[,/].*(?:S|M|L).*[)\)]', product_name):
|
||||
return True
|
||||
# 소형/중형/대형 등이 2개 이상 포함된 경우
|
||||
size_count = sum(1 for kw in ['초소형', '소형', '중형', '대형', '초대형']
|
||||
if kw in product_name)
|
||||
if size_count >= 2:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def detect_size_from_product_name(product_name):
|
||||
"""
|
||||
제품명에서 사이즈 라벨을 감지.
|
||||
Returns: 'XS', 'S', 'M', 'L', 'XL' 또는 None
|
||||
통합 제품(SS,S,M,L 등 여러 사이즈)은 None 반환.
|
||||
"""
|
||||
if not product_name:
|
||||
return None
|
||||
# 통합 제품 제외
|
||||
if is_multi_size_product_name(product_name):
|
||||
return None
|
||||
for pattern, size in PRODUCT_NAME_SIZE_PATTERNS:
|
||||
if re.search(pattern, product_name):
|
||||
return size
|
||||
return None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 3. 메인 로직
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='APDB weight_min_kg/weight_max_kg 일괄 채우기')
|
||||
parser.add_argument('--commit', action='store_true', help='실제 DB 업데이트 수행 (기본: dry-run)')
|
||||
parser.add_argument('--verbose', '-v', action='store_true', help='상세 로그')
|
||||
args = parser.parse_args()
|
||||
|
||||
dry_run = not args.commit
|
||||
|
||||
if dry_run:
|
||||
print("=" * 60)
|
||||
print(" DRY-RUN 모드 (DB 변경 없음)")
|
||||
print(" 실제 업데이트: python scripts/fill_weight_from_dosage.py --commit")
|
||||
print("=" * 60)
|
||||
else:
|
||||
print("=" * 60)
|
||||
print(" COMMIT 모드 - DB에 실제 업데이트합니다")
|
||||
print("=" * 60)
|
||||
|
||||
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
|
||||
conn = pg.connect()
|
||||
|
||||
# ── 동물용의약품 중 dosage_instructions에 kg 있고 weight 미입력인 APC 전체 조회 ──
|
||||
apcs = conn.execute(text('''
|
||||
SELECT apc, item_seq, product_name, dosage, dosage_instructions,
|
||||
product_type
|
||||
FROM apc
|
||||
WHERE product_type = '동물용의약품'
|
||||
AND dosage_instructions ILIKE '%%kg%%'
|
||||
AND weight_min_kg IS NULL
|
||||
ORDER BY item_seq, apc
|
||||
''')).fetchall()
|
||||
|
||||
print(f'\n대상 APC 레코드: {len(apcs)}건')
|
||||
|
||||
# item_seq별로 그룹핑
|
||||
from collections import defaultdict
|
||||
items = defaultdict(list)
|
||||
di_cache = {}
|
||||
for row in apcs:
|
||||
items[row.item_seq].append(row)
|
||||
if row.item_seq not in di_cache:
|
||||
di_cache[row.item_seq] = row.dosage_instructions
|
||||
|
||||
print(f'대상 item_seq: {len(items)}건\n')
|
||||
|
||||
stats = {
|
||||
'total_items': len(items),
|
||||
'updated': 0,
|
||||
'matched_by_name': 0,
|
||||
'matched_by_dosage_order': 0,
|
||||
'matched_single': 0,
|
||||
'skipped_no_parse': 0,
|
||||
'skipped_livestock': 0,
|
||||
'skipped_multi_no_label': 0,
|
||||
}
|
||||
|
||||
updates = [] # (apc, weight_min, weight_max, product_name, reason)
|
||||
|
||||
for item_seq, apc_rows in items.items():
|
||||
di = di_cache[item_seq]
|
||||
first_name = apc_rows[0].product_name
|
||||
|
||||
# 체중 구간 파싱
|
||||
weight_ranges = parse_weight_ranges(di)
|
||||
|
||||
if not weight_ranges:
|
||||
stats['skipped_no_parse'] += 1
|
||||
if args.verbose:
|
||||
print(f' SKIP (파싱불가): {first_name} ({item_seq})')
|
||||
continue
|
||||
|
||||
# 축산용 필터 (max > 60kg인 구간이 있으면 전체 SKIP)
|
||||
if any(r['max'] > 60 for r in weight_ranges):
|
||||
stats['skipped_livestock'] += 1
|
||||
if args.verbose:
|
||||
large = [r for r in weight_ranges if r['max'] > 60]
|
||||
print(f' SKIP (축산용): {first_name} ({item_seq}) max={large[0]["max"]}kg')
|
||||
continue
|
||||
|
||||
if len(weight_ranges) == 1:
|
||||
# ── 체중 구간 1개 → 전체 APC에 적용 ──
|
||||
wr = weight_ranges[0]
|
||||
for row in apc_rows:
|
||||
updates.append((row.apc, wr['min'], wr['max'], row.product_name, '단일구간'))
|
||||
stats['matched_single'] += len(apc_rows)
|
||||
stats['updated'] += len(apc_rows)
|
||||
if args.verbose:
|
||||
print(f' 적용 (단일구간): {first_name} → {wr["min"]}~{wr["max"]}kg ({len(apc_rows)}건)')
|
||||
|
||||
else:
|
||||
# ── 체중 구간 여러 개 → 제품명의 사이즈 라벨로 매칭 ──
|
||||
size_to_weight = {}
|
||||
for wr in weight_ranges:
|
||||
if wr['size']:
|
||||
size_to_weight[wr['size']] = (wr['min'], wr['max'])
|
||||
|
||||
# 먼저 제품명 라벨로 매칭 시도
|
||||
unmatched_rows = []
|
||||
for row in apc_rows:
|
||||
size = detect_size_from_product_name(row.product_name)
|
||||
if size and size in size_to_weight:
|
||||
wmin, wmax = size_to_weight[size]
|
||||
updates.append((row.apc, wmin, wmax, row.product_name, f'제품명→{size}'))
|
||||
stats['matched_by_name'] += 1
|
||||
stats['updated'] += 1
|
||||
if args.verbose:
|
||||
print(f' 적용 (제품명 {size}): {row.product_name} → {wmin}~{wmax}kg')
|
||||
else:
|
||||
unmatched_rows.append(row)
|
||||
|
||||
# ── 제품명 매칭 실패한 것들 → dosage 순서 매칭 시도 ──
|
||||
if unmatched_rows:
|
||||
# dosage 값이 있는 APC만 추출 (NaN 제외)
|
||||
rows_with_dosage = [r for r in unmatched_rows
|
||||
if r.dosage and r.dosage != 'NaN']
|
||||
rows_no_dosage = [r for r in unmatched_rows
|
||||
if not r.dosage or r.dosage == 'NaN']
|
||||
|
||||
if rows_with_dosage and len(weight_ranges) >= 2:
|
||||
# dosage에서 첫 번째 숫자 추출하여 정렬 키로 사용
|
||||
def dosage_sort_key(dosage_str):
|
||||
nums = re.findall(r'(\d+\.?\d+)', dosage_str)
|
||||
return float(nums[0]) if nums else 0
|
||||
|
||||
# 고유 dosage 값 추출 (순서 유지)
|
||||
unique_dosages = sorted(
|
||||
set(r.dosage for r in rows_with_dosage),
|
||||
key=dosage_sort_key
|
||||
)
|
||||
# 체중 구간도 min 기준 정렬 (이미 정렬됨)
|
||||
sorted_ranges = sorted(weight_ranges, key=lambda x: x['min'])
|
||||
|
||||
if len(unique_dosages) == len(sorted_ranges):
|
||||
# 개수 일치 → 순서 매칭 (작은 용량 = 작은 체중)
|
||||
dosage_to_weight = {}
|
||||
for d, wr in zip(unique_dosages, sorted_ranges):
|
||||
dosage_to_weight[d] = (wr['min'], wr['max'])
|
||||
|
||||
for row in rows_with_dosage:
|
||||
if row.dosage in dosage_to_weight:
|
||||
wmin, wmax = dosage_to_weight[row.dosage]
|
||||
updates.append((row.apc, wmin, wmax, row.product_name,
|
||||
f'dosage순서→{wmin}~{wmax}'))
|
||||
stats['matched_by_dosage_order'] += 1
|
||||
stats['updated'] += 1
|
||||
if args.verbose:
|
||||
print(f' 적용 (dosage순서): {row.product_name} '
|
||||
f'dosage={row.dosage} → {wmin}~{wmax}kg')
|
||||
else:
|
||||
stats['skipped_multi_no_label'] += 1
|
||||
if args.verbose:
|
||||
print(f' SKIP (dosage매칭실패): {row.product_name}')
|
||||
|
||||
# dosage 없는 APC (대표 품목 등)
|
||||
for row in rows_no_dosage:
|
||||
stats['skipped_multi_no_label'] += 1
|
||||
if args.verbose:
|
||||
print(f' SKIP (다중구간+dosage없음): {row.product_name}')
|
||||
|
||||
if args.verbose and dosage_to_weight:
|
||||
print(f' dosage 매핑: {dict((d, f"{w[0]}~{w[1]}kg") for d, w in dosage_to_weight.items())}')
|
||||
else:
|
||||
# 개수 불일치 → SKIP
|
||||
for row in unmatched_rows:
|
||||
stats['skipped_multi_no_label'] += 1
|
||||
if args.verbose:
|
||||
print(f' SKIP (dosage수≠구간수): {row.product_name} '
|
||||
f'(dosage {len(unique_dosages)}종 vs 구간 {len(sorted_ranges)}개)')
|
||||
else:
|
||||
# dosage 없는 APC만 남음
|
||||
for row in unmatched_rows:
|
||||
stats['skipped_multi_no_label'] += 1
|
||||
if args.verbose:
|
||||
print(f' SKIP (다중구간+라벨없음): {row.product_name} '
|
||||
f'(감지={detect_size_from_product_name(row.product_name)}, '
|
||||
f'가용={list(size_to_weight.keys())})')
|
||||
|
||||
# ── 결과 출력 ──
|
||||
print('\n' + '=' * 60)
|
||||
print(' 결과 요약')
|
||||
print('=' * 60)
|
||||
print(f' 대상 item_seq: {stats["total_items"]}건')
|
||||
print(f' 업데이트할 APC: {stats["updated"]}건')
|
||||
print(f' - 단일구간 적용: {stats["matched_single"]}건')
|
||||
print(f' - 제품명 라벨 매칭: {stats["matched_by_name"]}건')
|
||||
print(f' - dosage 순서 매칭: {stats["matched_by_dosage_order"]}건')
|
||||
print(f' SKIP - 파싱 불가: {stats["skipped_no_parse"]}건')
|
||||
print(f' SKIP - 축산용 (>60kg): {stats["skipped_livestock"]}건')
|
||||
print(f' SKIP - 다중구간+라벨없음: {stats["skipped_multi_no_label"]}건')
|
||||
|
||||
if updates:
|
||||
print(f'\n === 업데이트 미리보기 (처음 30건) ===')
|
||||
for apc, wmin, wmax, pname, reason in updates[:30]:
|
||||
print(f' {apc} | {pname[:35]:35s} → {wmin}~{wmax}kg [{reason}]')
|
||||
if len(updates) > 30:
|
||||
print(f' ... 외 {len(updates) - 30}건')
|
||||
|
||||
# ── DB 업데이트 ──
|
||||
if not dry_run and updates:
|
||||
print(f'\n DB 업데이트 시작...')
|
||||
conn.close()
|
||||
with pg.begin() as tx_conn:
|
||||
for apc_code, wmin, wmax, _, _ in updates:
|
||||
tx_conn.execute(text('''
|
||||
UPDATE apc
|
||||
SET weight_min_kg = :wmin, weight_max_kg = :wmax
|
||||
WHERE apc = :apc
|
||||
'''), {'wmin': wmin, 'wmax': wmax, 'apc': apc_code})
|
||||
print(f' 완료: {len(updates)}건 업데이트')
|
||||
elif not dry_run and not updates:
|
||||
print('\n 업데이트할 항목이 없습니다.')
|
||||
conn.close()
|
||||
else:
|
||||
conn.close()
|
||||
print('\n완료.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -26,6 +26,11 @@ ANIMAL_KEYWORDS = [
|
||||
'펫팜', '동물약품', '애니팜'
|
||||
]
|
||||
|
||||
# 동물약 공급처 (SplName이 이 값이면 전부 동물약)
|
||||
ANIMAL_SUPPLIERS = [
|
||||
'펫팜'
|
||||
]
|
||||
|
||||
# 제외 키워드 (사람용 약)
|
||||
EXCLUDE_KEYWORDS = [
|
||||
'헤리펫사', '토피라펫', '메타트레이스', '페리돈', '세파라캅'
|
||||
@ -58,24 +63,38 @@ def init_sqlite_db():
|
||||
print(f"✅ SQLite DB 준비: {DB_PATH}")
|
||||
|
||||
def search_animal_drugs():
|
||||
"""MSSQL에서 동물약 키워드 검색"""
|
||||
"""MSSQL에서 동물약 검색 (키워드 + 공급처)"""
|
||||
print("🔍 CD_GOODS에서 동물약 검색 중...")
|
||||
|
||||
|
||||
session = db_manager.get_session('PM_DRUG')
|
||||
|
||||
# 키워드 조건 생성
|
||||
conditions = ' OR '.join([f"GoodsName LIKE '%{kw}%'" for kw in ANIMAL_KEYWORDS])
|
||||
|
||||
|
||||
# 키워드 조건
|
||||
keyword_conds = ' OR '.join([f"GoodsName LIKE '%{kw}%'" for kw in ANIMAL_KEYWORDS])
|
||||
|
||||
# 공급처 조건
|
||||
supplier_conds = ' OR '.join([f"SplName = '{sp}'" for sp in ANIMAL_SUPPLIERS])
|
||||
|
||||
query = text(f"""
|
||||
SELECT DrugCode, GoodsName, BARCODE, POS_BOON
|
||||
SELECT DrugCode, GoodsName, BARCODE, POS_BOON, SplName
|
||||
FROM CD_GOODS
|
||||
WHERE ({conditions})
|
||||
WHERE (({keyword_conds}) OR ({supplier_conds}))
|
||||
AND GoodsSelCode = 'B'
|
||||
""")
|
||||
|
||||
|
||||
result = session.execute(query)
|
||||
drugs = result.fetchall()
|
||||
print(f"✅ 발견: {len(drugs)}개")
|
||||
|
||||
# 키워드 vs 공급처 통계
|
||||
by_keyword = [d for d in drugs if any(kw in (d.GoodsName or '') for kw in ANIMAL_KEYWORDS)]
|
||||
by_supplier = [d for d in drugs if d.SplName in ANIMAL_SUPPLIERS]
|
||||
supplier_only = [d for d in by_supplier if not any(kw in (d.GoodsName or '') for kw in ANIMAL_KEYWORDS)]
|
||||
|
||||
print(f"✅ 발견: {len(drugs)}개 (키워드: {len(by_keyword)}, 공급처 추가: {len(supplier_only)})")
|
||||
if supplier_only:
|
||||
print(" 📦 공급처 기반 신규:")
|
||||
for d in supplier_only:
|
||||
print(f" {d.DrugCode}: {d.GoodsName} ({d.SplName})")
|
||||
|
||||
return drugs
|
||||
|
||||
def tag_to_sqlite(drugs):
|
||||
@ -93,20 +112,27 @@ def tag_to_sqlite(drugs):
|
||||
drug_code = drug[0]
|
||||
drug_name = drug[1] or ''
|
||||
barcode = drug[2]
|
||||
|
||||
spl_name = drug[4] if len(drug) > 4 else ''
|
||||
|
||||
# 제외 키워드 체크
|
||||
if any(ex in drug_name for ex in EXCLUDE_KEYWORDS):
|
||||
excluded += 1
|
||||
print(f" ⛔ 제외: {drug_code} - {drug_name}")
|
||||
continue
|
||||
|
||||
|
||||
# 매칭 소스 구분
|
||||
by_kw = any(kw in drug_name for kw in ANIMAL_KEYWORDS)
|
||||
by_sp = spl_name in ANIMAL_SUPPLIERS
|
||||
source = 'keyword' if by_kw else 'supplier'
|
||||
note = '키워드 자동 태깅' if by_kw else f'공급처({spl_name}) 자동 태깅'
|
||||
|
||||
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))
|
||||
INSERT INTO drug_tags (drug_code, drug_name, barcode, tag_type, tag_value, note, source)
|
||||
VALUES (?, ?, ?, 'animal_drug', 'all', ?, ?)
|
||||
''', (drug_code, drug_name, barcode, note, source))
|
||||
added += 1
|
||||
print(f" ✅ {drug_code}: {drug_name}")
|
||||
print(f" ✅ {drug_code}: {drug_name} [{source}]")
|
||||
except sqlite3.IntegrityError:
|
||||
skipped += 1
|
||||
|
||||
|
||||
@ -596,8 +596,42 @@ def api_sooin_orders_by_kd():
|
||||
end_date = flask_request.args.get('end_date', today).strip()
|
||||
|
||||
def parse_spec(spec: str) -> int:
|
||||
"""
|
||||
규격에서 박스당 단위 수 추출
|
||||
|
||||
정량 단위 (T, 정, 캡슐, C, PTP, 포 등): 숫자 추출
|
||||
용량 단위 (g, ml, mL, mg, L 등): 1 반환 (튜브/병 단위)
|
||||
|
||||
예시:
|
||||
- '30T' → 30 (정제 30정)
|
||||
- '100정(PTP)' → 100
|
||||
- '15g' → 1 (튜브 1개)
|
||||
- '10ml' → 1 (병 1개)
|
||||
- '500mg' → 1 (용량 표시)
|
||||
"""
|
||||
if not spec:
|
||||
return 1
|
||||
|
||||
spec_lower = spec.lower()
|
||||
|
||||
# 용량 단위 패턴: 숫자 + g/ml/mg/l (단독 또는 끝)
|
||||
# 이 경우 튜브/병 단위이므로 1 반환
|
||||
volume_pattern = r'^\d+\s*(g|ml|mg|l)(\s|$|\)|/)'
|
||||
if re.search(volume_pattern, spec_lower):
|
||||
return 1
|
||||
|
||||
# 정량 단위 패턴: 숫자 + T/정/캡슐/C/PTP/포
|
||||
qty_pattern = r'(\d+)\s*(t|정|캡슐?|c|ptp|포|tab|cap)'
|
||||
qty_match = re.search(qty_pattern, spec_lower)
|
||||
if qty_match:
|
||||
return int(qty_match.group(1))
|
||||
|
||||
# 기본: 숫자만 있으면 추출하되, 용량 단위 재확인
|
||||
# 끝에 g/ml이 있으면 1 반환
|
||||
if re.search(r'\d+(g|ml)$', spec_lower):
|
||||
return 1
|
||||
|
||||
# 그 외 숫자 추출
|
||||
match = re.search(r'(\d+)', spec)
|
||||
return int(match.group(1)) if match else 1
|
||||
|
||||
|
||||
@ -1354,13 +1354,16 @@
|
||||
html += `
|
||||
<div style="border: 1px solid #e9ecef; border-radius: 12px; margin-bottom: 12px; overflow: hidden;">
|
||||
<!-- 아코디언 헤더 -->
|
||||
<div onclick="toggleAccordion('${accordionId}')" style="padding: 16px; background: #fff; cursor: pointer; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div onclick="toggleAccordion('${accordionId}')" style="padding: 16px; background: #fff; cursor: pointer; display: flex; justify-content: space-between; align-items: center; position: relative;">
|
||||
<div style="flex: 1;">
|
||||
<div style="font-size: 15px; font-weight: 600; color: #212529; margin-bottom: 6px;">
|
||||
${purchase.items_summary}
|
||||
${purchase.source === 'pos'
|
||||
? '<span style="position: relative; top: -2px; margin-left: 8px; padding: 2px 6px; background: linear-gradient(135deg, #94a3b8, #64748b); color: white; border-radius: 4px; font-size: 10px; font-weight: 600;">POS</span>'
|
||||
: '<span style="position: relative; top: -2px; margin-left: 8px; padding: 2px 6px; background: linear-gradient(135deg, #22c55e, #16a34a); color: white; border-radius: 4px; font-size: 10px; font-weight: 600;">QR</span>'}
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #868e96;">
|
||||
${purchase.date} | ${purchase.amount.toLocaleString()}원 구매 | ${purchase.points.toLocaleString()}P 적립
|
||||
${purchase.date} | ${purchase.amount.toLocaleString()}원 구매 | ${purchase.points > 0 ? purchase.points.toLocaleString() + 'P 적립' : '적립 안됨'}
|
||||
</div>
|
||||
</div>
|
||||
<div id="${accordionId}-icon" style="width: 24px; height: 24px; color: #868e96; transition: transform 0.3s;">
|
||||
|
||||
597
backend/templates/admin_animal_chat_logs.html
Normal file
597
backend/templates/admin_animal_chat_logs.html
Normal file
@ -0,0 +1,597 @@
|
||||
<!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&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, #10b981 0%, #059669 100%);
|
||||
padding: 28px 32px 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.8);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header-nav a:hover { color: #fff; }
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 컨텐츠 */
|
||||
.content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* 통계 카드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.stat-value.green { color: #10b981; }
|
||||
.stat-value.blue { color: #3b82f6; }
|
||||
.stat-value.orange { color: #f59e0b; }
|
||||
.stat-value.red { color: #ef4444; }
|
||||
.stat-sub {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 필터 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-input {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.filter-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.filter-btn.primary {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
.filter-btn.primary:hover { background: #059669; }
|
||||
.filter-btn.secondary {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
.filter-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* 테이블 */
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
th {
|
||||
background: #f8fafc;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
tr:hover { background: #f8fafc; }
|
||||
tr.error { background: #fef2f2; }
|
||||
.time-cell {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.msg-cell {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
.token-cell {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.cost-cell {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #f59e0b;
|
||||
}
|
||||
.duration-cell {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
.error-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #fecaca;
|
||||
color: #dc2626;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal.show { display: flex; }
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
max-width: 800px;
|
||||
width: 95%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.detail-section h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.detail-item {
|
||||
background: #f8fafc;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.detail-label {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.message-box {
|
||||
background: #f8fafc;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.message-box.user {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
}
|
||||
.message-box.assistant {
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
/* 로딩 */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top-color: #10b981;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<a href="/admin/products">제품 관리</a>
|
||||
</div>
|
||||
<h1>🐾 동물약 챗봇 로그</h1>
|
||||
<p>RAG 기반 동물약 상담 기록 · 토큰 사용량 · 비용 분석</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 통계 카드 -->
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 대화</div>
|
||||
<div class="stat-value green" id="statTotal">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">평균 응답시간</div>
|
||||
<div class="stat-value blue" id="statDuration">-</div>
|
||||
<div class="stat-sub">ms</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 토큰</div>
|
||||
<div class="stat-value" id="statTokens">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 비용</div>
|
||||
<div class="stat-value orange" id="statCost">-</div>
|
||||
<div class="stat-sub">USD</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">평균 벡터 검색</div>
|
||||
<div class="stat-value blue" id="statVector">-</div>
|
||||
<div class="stat-sub">ms</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">에러</div>
|
||||
<div class="stat-value red" id="statErrors">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="filter-bar">
|
||||
<input type="date" class="filter-input" id="dateFrom" />
|
||||
<span style="color:#94a3b8;">~</span>
|
||||
<input type="date" class="filter-input" id="dateTo" />
|
||||
<label class="filter-checkbox">
|
||||
<input type="checkbox" id="errorOnly" />
|
||||
에러만 보기
|
||||
</label>
|
||||
<button class="filter-btn primary" onclick="loadLogs()">검색</button>
|
||||
<button class="filter-btn secondary" onclick="resetFilters()">초기화</button>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>질문</th>
|
||||
<th>응답</th>
|
||||
<th>토큰</th>
|
||||
<th>비용</th>
|
||||
<th>소요시간</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logsTable">
|
||||
<tr>
|
||||
<td colspan="7" class="loading">
|
||||
<div class="spinner"></div>
|
||||
로딩 중...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 모달 -->
|
||||
<div class="modal" id="detailModal" onclick="if(event.target===this)closeModal()">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>🔍 대화 상세</h2>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<!-- 동적 내용 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 초기 로드
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 기본 날짜: 오늘
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('dateTo').value = today;
|
||||
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
async function loadLogs() {
|
||||
const dateFrom = document.getElementById('dateFrom').value;
|
||||
const dateTo = document.getElementById('dateTo').value;
|
||||
const errorOnly = document.getElementById('errorOnly').checked;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (dateFrom) params.append('date_from', dateFrom);
|
||||
if (dateTo) params.append('date_to', dateTo);
|
||||
if (errorOnly) params.append('error_only', 'true');
|
||||
params.append('limit', '200');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/animal-chat-logs?${params}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
renderStats(data.stats);
|
||||
renderLogs(data.logs);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('로그 조회 실패:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(stats) {
|
||||
document.getElementById('statTotal').textContent = (stats.total_chats || 0).toLocaleString();
|
||||
document.getElementById('statDuration').textContent = (stats.avg_duration_ms || 0).toLocaleString();
|
||||
document.getElementById('statTokens').textContent = (stats.total_tokens || 0).toLocaleString();
|
||||
document.getElementById('statCost').textContent = '$' + (stats.total_cost_usd || 0).toFixed(4);
|
||||
document.getElementById('statVector').textContent = (stats.avg_vector_ms || 0).toLocaleString();
|
||||
document.getElementById('statErrors').textContent = stats.error_count || 0;
|
||||
}
|
||||
|
||||
function renderLogs(logs) {
|
||||
const tbody = document.getElementById('logsTable');
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:40px;color:#94a3b8;">로그가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = logs.map(log => `
|
||||
<tr class="${log.error ? 'error' : ''}" onclick='showDetail(${JSON.stringify(log).replace(/'/g, "'")})' style="cursor:pointer;">
|
||||
<td class="time-cell">${formatTime(log.created_at)}</td>
|
||||
<td class="msg-cell" title="${escapeHtml(log.user_message || '')}">${escapeHtml(truncate(log.user_message, 40))}</td>
|
||||
<td class="msg-cell" title="${escapeHtml(log.assistant_response || '')}">${escapeHtml(truncate(log.assistant_response, 50))}</td>
|
||||
<td class="token-cell">${log.openai_total_tokens || 0}</td>
|
||||
<td class="cost-cell">$${(log.openai_cost_usd || 0).toFixed(4)}</td>
|
||||
<td class="duration-cell">${log.total_duration_ms || 0}ms</td>
|
||||
<td>${log.error ? '<span class="error-badge">에러</span>' : '✅'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function showDetail(log) {
|
||||
const vectorScores = JSON.parse(log.vector_top_scores || '[]');
|
||||
const vectorSources = JSON.parse(log.vector_sources || '[]');
|
||||
const products = JSON.parse(log.products_mentioned || '[]');
|
||||
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<div class="detail-section">
|
||||
<h3>📊 처리 시간</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">MSSQL (보유 동물약)</div>
|
||||
<div class="detail-value">${log.mssql_duration_ms || 0}ms (${log.mssql_drug_count || 0}개)</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">PostgreSQL (RAG)</div>
|
||||
<div class="detail-value">${log.pgsql_duration_ms || 0}ms (${log.pgsql_rag_count || 0}개)</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">벡터 검색 (LanceDB)</div>
|
||||
<div class="detail-value">${log.vector_duration_ms || 0}ms (${log.vector_results_count || 0}개)</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">OpenAI API</div>
|
||||
<div class="detail-value">${log.openai_duration_ms || 0}ms</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">총 소요시간</div>
|
||||
<div class="detail-value" style="color:#10b981;">${log.total_duration_ms || 0}ms</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">모델</div>
|
||||
<div class="detail-value">${log.openai_model || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>🎯 토큰 & 비용</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">입력 토큰</div>
|
||||
<div class="detail-value">${log.openai_prompt_tokens || 0}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">출력 토큰</div>
|
||||
<div class="detail-value">${log.openai_completion_tokens || 0}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">비용</div>
|
||||
<div class="detail-value" style="color:#f59e0b;">$${(log.openai_cost_usd || 0).toFixed(6)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${vectorSources.length > 0 ? `
|
||||
<div class="detail-section">
|
||||
<h3>📚 벡터 검색 결과</h3>
|
||||
<div style="font-size:13px;">
|
||||
${vectorSources.map((src, i) => `
|
||||
<div style="padding:8px 12px;background:#f0fdf4;border-radius:6px;margin-bottom:6px;">
|
||||
<strong>[${(vectorScores[i] * 100 || 0).toFixed(0)}%]</strong> ${src}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>💬 사용자 질문</h3>
|
||||
<div class="message-box user">${escapeHtml(log.user_message || '')}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>🤖 AI 응답</h3>
|
||||
<div class="message-box assistant">${escapeHtml(log.assistant_response || '')}</div>
|
||||
</div>
|
||||
|
||||
${products.length > 0 ? `
|
||||
<div class="detail-section">
|
||||
<h3>📦 언급된 제품</h3>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
${products.map(p => `<span style="background:#10b981;color:#fff;padding:4px 12px;border-radius:20px;font-size:13px;">${p}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${log.error ? `
|
||||
<div class="detail-section">
|
||||
<h3>⚠️ 에러</h3>
|
||||
<div class="message-box" style="background:#fef2f2;color:#dc2626;">${escapeHtml(log.error)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
document.getElementById('detailModal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('detailModal').classList.remove('show');
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
document.getElementById('dateFrom').value = '';
|
||||
document.getElementById('dateTo').value = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('errorOnly').checked = false;
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
// 유틸
|
||||
function formatTime(dt) {
|
||||
if (!dt) return '-';
|
||||
return dt.replace('T', ' ').substring(5, 16);
|
||||
}
|
||||
|
||||
function truncate(str, len) {
|
||||
if (!str) return '';
|
||||
return str.length > len ? str.substring(0, len) + '...' : str;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ESC로 모달 닫기
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1038
backend/templates/admin_drug_usage.html
Normal file
1038
backend/templates/admin_drug_usage.html
Normal file
File diff suppressed because it is too large
Load Diff
980
backend/templates/admin_drysyrup.html
Normal file
980
backend/templates/admin_drysyrup.html
Normal file
@ -0,0 +1,980 @@
|
||||
<!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;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;
|
||||
--accent-orange: #f97316;
|
||||
--accent-cyan: #06b6d4;
|
||||
}
|
||||
|
||||
* { 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, #a855f7 0%, #8b5cf6 50%, #7c3aed 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;
|
||||
}
|
||||
|
||||
/* ══════════════════ 검색 & 액션 바 ══════════════════ */
|
||||
.action-bar {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
}
|
||||
.search-group input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-purple);
|
||||
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.2);
|
||||
}
|
||||
.search-group input::placeholder { color: var(--text-muted); }
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent-purple), #7c3aed);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(168, 85, 247, 0.4);
|
||||
}
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, var(--accent-emerald), var(--accent-teal));
|
||||
color: #fff;
|
||||
}
|
||||
.btn-success:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, var(--accent-rose), #dc2626);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(244, 63, 94, 0.4);
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* ══════════════════ 통계 ══════════════════ */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
}
|
||||
.stat-card.purple::before { background: var(--accent-purple); }
|
||||
.stat-card.cyan::before { background: var(--accent-cyan); }
|
||||
.stat-card.amber::before { background: var(--accent-amber); }
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-card.purple .stat-value { color: var(--accent-purple); }
|
||||
.stat-card.cyan .stat-value { color: var(--accent-cyan); }
|
||||
.stat-card.amber .stat-value { color: var(--accent-amber); }
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 테이블 ══════════════════ */
|
||||
.table-container {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.table-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.table-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.badge {
|
||||
background: linear-gradient(135deg, var(--accent-purple), #7c3aed);
|
||||
color: #fff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 14px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
td {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
vertical-align: middle;
|
||||
}
|
||||
tr:hover td {
|
||||
background: rgba(168, 85, 247, 0.05);
|
||||
}
|
||||
|
||||
.code-cell {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
.factor-cell {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 600;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
.storage-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.storage-badge.cold {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
.storage-badge.room {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--accent-emerald);
|
||||
}
|
||||
|
||||
.action-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.action-btns button {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-edit {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
.btn-edit:hover {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
.btn-delete {
|
||||
background: rgba(244, 63, 94, 0.2);
|
||||
color: var(--accent-rose);
|
||||
}
|
||||
.btn-delete:hover {
|
||||
background: rgba(244, 63, 94, 0.3);
|
||||
}
|
||||
|
||||
/* ══════════════════ 빈 상태 ══════════════════ */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.empty-state .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.empty-state h3 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.empty-state p {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 에러 메시지 ══════════════════ */
|
||||
.error-banner {
|
||||
background: rgba(244, 63, 94, 0.1);
|
||||
border: 1px solid rgba(244, 63, 94, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--accent-rose);
|
||||
}
|
||||
.error-banner.hidden { display: none; }
|
||||
.error-banner .icon { font-size: 20px; }
|
||||
|
||||
/* ══════════════════ 모달 ══════════════════ */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.modal-overlay.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 20px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
transform: scale(0.9) translateY(20px);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.modal-overlay.active .modal {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
.modal-header {
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal-close:hover { color: var(--text-primary); }
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-purple);
|
||||
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.2);
|
||||
}
|
||||
.form-group input:disabled {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 20px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 토스트 ══════════════════ */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 2000;
|
||||
}
|
||||
.toast {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
margin-top: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
.toast.success { border-left: 4px solid var(--accent-emerald); }
|
||||
.toast.error { border-left: 4px solid var(--accent-rose); }
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ══════════════════ 로딩 ══════════════════ */
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent-purple);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ══════════════════ 반응형 ══════════════════ */
|
||||
@media (max-width: 768px) {
|
||||
.action-bar { flex-direction: column; }
|
||||
.search-group { max-width: 100%; width: 100%; }
|
||||
.stats-row { flex-direction: column; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
th, td { padding: 10px 12px; }
|
||||
.header-nav { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 헤더 -->
|
||||
<header class="header">
|
||||
<div class="header-inner">
|
||||
<div class="header-left">
|
||||
<h1>💧 건조시럽 환산계수 관리</h1>
|
||||
<p>건조시럽 mL → g 환산계수 데이터 관리</p>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<a href="/admin">관리자 홈</a>
|
||||
<a href="/pmr">PMR</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 컨텐츠 -->
|
||||
<div class="content">
|
||||
<!-- 에러 배너 -->
|
||||
<div id="errorBanner" class="error-banner hidden">
|
||||
<span class="icon">⚠️</span>
|
||||
<span id="errorMessage">PostgreSQL 연결에 실패했습니다.</span>
|
||||
</div>
|
||||
|
||||
<!-- 액션 바 -->
|
||||
<div class="action-bar">
|
||||
<div class="search-group">
|
||||
<input type="text" id="searchInput" placeholder="성분명, 제품명, 성분코드로 검색..." autocomplete="off">
|
||||
<button class="btn btn-primary" onclick="loadData()">🔍 검색</button>
|
||||
</div>
|
||||
<button class="btn btn-success" onclick="openCreateModal()">➕ 신규 등록</button>
|
||||
</div>
|
||||
|
||||
<!-- 통계 -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card purple">
|
||||
<div class="stat-value" id="statTotal">-</div>
|
||||
<div class="stat-label">전체 등록</div>
|
||||
</div>
|
||||
<div class="stat-card cyan">
|
||||
<div class="stat-value" id="statCold">-</div>
|
||||
<div class="stat-label">냉장보관</div>
|
||||
</div>
|
||||
<div class="stat-card amber">
|
||||
<div class="stat-value" id="statRoom">-</div>
|
||||
<div class="stat-label">실온보관</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div class="table-container">
|
||||
<div class="table-header">
|
||||
<div class="table-title">
|
||||
<span>환산계수 목록</span>
|
||||
<span class="badge" id="countBadge">0건</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tableWrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 120px;">성분코드</th>
|
||||
<th>성분명</th>
|
||||
<th>제품명</th>
|
||||
<th style="width: 100px;">환산계수</th>
|
||||
<th style="width: 100px;">보관조건</th>
|
||||
<th style="width: 100px;">유효기간</th>
|
||||
<th style="width: 130px;">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dataBody">
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<div class="empty-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p style="margin-top: 12px;">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 등록/수정 모달 -->
|
||||
<div class="modal-overlay" id="editModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">건조시럽 등록</h2>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="editMode" value="create">
|
||||
|
||||
<div class="form-group">
|
||||
<label>성분코드 (SUNG_CODE) *</label>
|
||||
<input type="text" id="formSungCode" placeholder="예: 535000ASY">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>성분명</label>
|
||||
<input type="text" id="formIngredientName" placeholder="예: 아목시실린수화물·클라불란산칼륨">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>제품명</label>
|
||||
<input type="text" id="formProductName" placeholder="예: 일성오구멘틴듀오시럽">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>환산계수 (g/mL)</label>
|
||||
<input type="number" id="formConversionFactor" step="0.001" placeholder="예: 0.11">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>조제 후 유효기간</label>
|
||||
<input type="text" id="formExpiration" placeholder="예: 7일">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>조제 후 함량</label>
|
||||
<input type="text" id="formPostPrepAmount" placeholder="예: 228mg/5ml">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>분말 중 주성분량</label>
|
||||
<input type="text" id="formMainIngredientAmt" placeholder="예: 200mg/g">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>보관조건</label>
|
||||
<select id="formStorageConditions">
|
||||
<option value="실온">실온</option>
|
||||
<option value="냉장">냉장</option>
|
||||
<option value="냉동">냉동</option>
|
||||
<option value="차광">차광</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal()">취소</button>
|
||||
<button class="btn btn-success" onclick="saveData()">💾 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 삭제 확인 모달 -->
|
||||
<div class="modal-overlay" id="deleteModal">
|
||||
<div class="modal" style="max-width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h2>삭제 확인</h2>
|
||||
<button class="modal-close" onclick="closeDeleteModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="text-align: center;">
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">🗑️</div>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 8px;">
|
||||
<strong id="deleteTarget" style="color: var(--accent-rose);"></strong>
|
||||
</p>
|
||||
<p style="color: var(--text-muted); font-size: 13px;">
|
||||
이 항목을 정말 삭제하시겠습니까?<br>삭제 후 복구할 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer" style="justify-content: center;">
|
||||
<button class="btn btn-secondary" onclick="closeDeleteModal()">취소</button>
|
||||
<button class="btn btn-danger" onclick="confirmDelete()">🗑️ 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 컨테이너 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<script>
|
||||
// 전역 변수
|
||||
let allData = [];
|
||||
let deleteSungCode = null;
|
||||
|
||||
// 페이지 로드 시
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadData();
|
||||
|
||||
// 엔터키로 검색
|
||||
document.getElementById('searchInput').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') loadData();
|
||||
});
|
||||
});
|
||||
|
||||
// 데이터 로드
|
||||
async function loadData() {
|
||||
const searchQuery = document.getElementById('searchInput').value.trim();
|
||||
const url = searchQuery
|
||||
? `/api/drug-info/drysyrup?q=${encodeURIComponent(searchQuery)}`
|
||||
: '/api/drug-info/drysyrup';
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
showError(result.error || 'PostgreSQL 연결에 실패했습니다.');
|
||||
renderEmptyState('데이터베이스 연결 오류');
|
||||
return;
|
||||
}
|
||||
|
||||
hideError();
|
||||
allData = result.data || [];
|
||||
renderTable(allData);
|
||||
updateStats(allData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 오류:', error);
|
||||
showError('서버 연결에 실패했습니다.');
|
||||
renderEmptyState('서버 연결 오류');
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 렌더링
|
||||
function renderTable(data) {
|
||||
const tbody = document.getElementById('dataBody');
|
||||
document.getElementById('countBadge').textContent = `${data.length}건`;
|
||||
|
||||
if (data.length === 0) {
|
||||
renderEmptyState('등록된 환산계수가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.map(item => `
|
||||
<tr>
|
||||
<td class="code-cell">${escapeHtml(item.sung_code || '')}</td>
|
||||
<td>${escapeHtml(item.ingredient_name || '-')}</td>
|
||||
<td>${escapeHtml(item.product_name || '-')}</td>
|
||||
<td class="factor-cell">${item.conversion_factor !== null ? item.conversion_factor.toFixed(3) : '-'}</td>
|
||||
<td>
|
||||
<span class="storage-badge ${getStorageClass(item.storage_conditions)}">
|
||||
${escapeHtml(item.storage_conditions || '실온')}
|
||||
</span>
|
||||
</td>
|
||||
<td>${escapeHtml(item.expiration_date || '-')}</td>
|
||||
<td>
|
||||
<div class="action-btns">
|
||||
<button class="btn-edit" onclick="openEditModal('${escapeHtml(item.sung_code)}')">✏️ 수정</button>
|
||||
<button class="btn-delete" onclick="openDeleteModal('${escapeHtml(item.sung_code)}')">🗑️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 빈 상태 렌더링
|
||||
function renderEmptyState(message) {
|
||||
document.getElementById('dataBody').innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<div class="empty-state">
|
||||
<div class="icon">📭</div>
|
||||
<h3>${escapeHtml(message)}</h3>
|
||||
<p>신규 등록 버튼을 눌러 환산계수를 추가하세요.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
document.getElementById('countBadge').textContent = '0건';
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
function updateStats(data) {
|
||||
const total = data.length;
|
||||
const cold = data.filter(d => (d.storage_conditions || '').includes('냉')).length;
|
||||
const room = total - cold;
|
||||
|
||||
document.getElementById('statTotal').textContent = total.toLocaleString();
|
||||
document.getElementById('statCold').textContent = cold.toLocaleString();
|
||||
document.getElementById('statRoom').textContent = room.toLocaleString();
|
||||
}
|
||||
|
||||
// 보관조건 클래스
|
||||
function getStorageClass(storage) {
|
||||
if (!storage) return 'room';
|
||||
return storage.includes('냉') ? 'cold' : 'room';
|
||||
}
|
||||
|
||||
// 신규 등록 모달 열기
|
||||
function openCreateModal() {
|
||||
document.getElementById('modalTitle').textContent = '건조시럽 신규 등록';
|
||||
document.getElementById('editMode').value = 'create';
|
||||
document.getElementById('formSungCode').value = '';
|
||||
document.getElementById('formSungCode').disabled = false;
|
||||
document.getElementById('formIngredientName').value = '';
|
||||
document.getElementById('formProductName').value = '';
|
||||
document.getElementById('formConversionFactor').value = '';
|
||||
document.getElementById('formExpiration').value = '';
|
||||
document.getElementById('formPostPrepAmount').value = '';
|
||||
document.getElementById('formMainIngredientAmt').value = '';
|
||||
document.getElementById('formStorageConditions').value = '실온';
|
||||
|
||||
document.getElementById('editModal').classList.add('active');
|
||||
}
|
||||
|
||||
// 수정 모달 열기
|
||||
async function openEditModal(sungCode) {
|
||||
try {
|
||||
const response = await fetch(`/api/drug-info/drysyrup/${encodeURIComponent(sungCode)}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.exists) {
|
||||
showToast('데이터를 불러올 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('modalTitle').textContent = '건조시럽 수정';
|
||||
document.getElementById('editMode').value = 'edit';
|
||||
document.getElementById('formSungCode').value = result.sung_code;
|
||||
document.getElementById('formSungCode').disabled = true;
|
||||
document.getElementById('formIngredientName').value = result.ingredient_name || '';
|
||||
document.getElementById('formProductName').value = result.product_name || '';
|
||||
document.getElementById('formConversionFactor').value = result.conversion_factor || '';
|
||||
document.getElementById('formExpiration').value = result.expiration_date || '';
|
||||
document.getElementById('formPostPrepAmount').value = result.post_prep_amount || '';
|
||||
document.getElementById('formMainIngredientAmt').value = result.main_ingredient_amt || '';
|
||||
document.getElementById('formStorageConditions').value = result.storage_conditions || '실온';
|
||||
|
||||
document.getElementById('editModal').classList.add('active');
|
||||
|
||||
} catch (error) {
|
||||
console.error('수정 모달 오류:', error);
|
||||
showToast('데이터 로드 실패', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
function closeModal() {
|
||||
document.getElementById('editModal').classList.remove('active');
|
||||
}
|
||||
|
||||
// 데이터 저장
|
||||
async function saveData() {
|
||||
const mode = document.getElementById('editMode').value;
|
||||
const sungCode = document.getElementById('formSungCode').value.trim();
|
||||
|
||||
if (!sungCode) {
|
||||
showToast('성분코드는 필수입니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
sung_code: sungCode,
|
||||
ingredient_name: document.getElementById('formIngredientName').value.trim(),
|
||||
product_name: document.getElementById('formProductName').value.trim(),
|
||||
conversion_factor: parseFloat(document.getElementById('formConversionFactor').value) || null,
|
||||
expiration_date: document.getElementById('formExpiration').value.trim(),
|
||||
post_prep_amount: document.getElementById('formPostPrepAmount').value.trim(),
|
||||
main_ingredient_amt: document.getElementById('formMainIngredientAmt').value.trim(),
|
||||
storage_conditions: document.getElementById('formStorageConditions').value
|
||||
};
|
||||
|
||||
try {
|
||||
const url = mode === 'create'
|
||||
? '/api/drug-info/drysyrup'
|
||||
: `/api/drug-info/drysyrup/${encodeURIComponent(sungCode)}`;
|
||||
const method = mode === 'create' ? 'POST' : 'PUT';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast(mode === 'create' ? '등록 완료' : '수정 완료', 'success');
|
||||
closeModal();
|
||||
loadData();
|
||||
} else {
|
||||
showToast(result.error || '저장 실패', 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
showToast('서버 오류', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 삭제 모달 열기
|
||||
function openDeleteModal(sungCode) {
|
||||
deleteSungCode = sungCode;
|
||||
document.getElementById('deleteTarget').textContent = sungCode;
|
||||
document.getElementById('deleteModal').classList.add('active');
|
||||
}
|
||||
|
||||
// 삭제 모달 닫기
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').classList.remove('active');
|
||||
deleteSungCode = null;
|
||||
}
|
||||
|
||||
// 삭제 확인
|
||||
async function confirmDelete() {
|
||||
if (!deleteSungCode) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/drug-info/drysyrup/${encodeURIComponent(deleteSungCode)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('삭제 완료', 'success');
|
||||
closeDeleteModal();
|
||||
loadData();
|
||||
} else {
|
||||
showToast(result.error || '삭제 실패', 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
showToast('서버 오류', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 에러 표시/숨김
|
||||
function showError(message) {
|
||||
document.getElementById('errorMessage').textContent = message;
|
||||
document.getElementById('errorBanner').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
document.getElementById('errorBanner').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 토스트 메시지
|
||||
function showToast(message, type = 'success') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = `
|
||||
<span>${type === 'success' ? '✅' : '❌'}</span>
|
||||
<span>${escapeHtml(message)}</span>
|
||||
`;
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// HTML 이스케이프
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -263,11 +263,159 @@
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.customer {
|
||||
.customer-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.customer-badge:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.customer-badge.has-name {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.customer-badge.has-name:hover {
|
||||
background: #bfdbfe;
|
||||
}
|
||||
.customer-badge.no-name {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
border: 1px dashed #cbd5e1;
|
||||
}
|
||||
.customer-badge.no-name:hover {
|
||||
background: #e2e8f0;
|
||||
border-color: #3b82f6;
|
||||
color: #2563eb;
|
||||
}
|
||||
.mileage-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
margin-left: 6px;
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 고객 검색 모달 */
|
||||
.customer-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 2000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.customer-modal.show { display: flex; }
|
||||
.customer-modal-content {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.customer-modal h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #1e40af;
|
||||
font-size: 18px;
|
||||
}
|
||||
.customer-search-box {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.customer-search-input {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.customer-search-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.customer-search-btn {
|
||||
padding: 12px 20px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
.customer.non-member {
|
||||
.customer-search-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.customer-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
.customer-result-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.customer-result-item:hover {
|
||||
background: #f0f9ff;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.customer-result-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.customer-result-birth {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.customer-result-activity {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.customer-result-activity.recent {
|
||||
color: #22c55e;
|
||||
}
|
||||
.customer-modal-close {
|
||||
margin-top: 16px;
|
||||
padding: 10px 20px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
.customer-modal-close:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
.customer-no-results {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.customer-order-info {
|
||||
background: #f0f9ff;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* 결제수단 뱃지 */
|
||||
@ -917,7 +1065,13 @@
|
||||
|
||||
const rows = data.sales.map((sale, idx) => {
|
||||
const payBadge = getPayBadge(sale.pay_method);
|
||||
const customerClass = sale.customer === '[비고객]' ? 'customer non-member' : 'customer';
|
||||
const hasCustomer = sale.customer && sale.customer !== '[비고객]' && sale.customer_code !== '0000000000';
|
||||
const mileageSpan = hasCustomer
|
||||
? `<span class="mileage-badge" id="mileage-${sale.customer_code}" style="display:none;"></span>`
|
||||
: '';
|
||||
const customerBadge = hasCustomer
|
||||
? `<span class="customer-badge has-name" onclick="event.stopPropagation();openCustomerModal('${sale.order_no}', '${escapeHtml(sale.customer)}', '${sale.customer_code}')">${escapeHtml(sale.customer)}</span>${mileageSpan}`
|
||||
: `<span class="customer-badge no-name" onclick="event.stopPropagation();openCustomerModal('${sale.order_no}', '', '0000000000')">미입력</span>`;
|
||||
const qrIcon = sale.qr_issued
|
||||
? '<span class="status-icon qr-yes">✓</span>'
|
||||
: '<span class="status-icon qr-no">✗</span>';
|
||||
@ -944,7 +1098,7 @@
|
||||
</td>
|
||||
<td onclick="showDetail('${sale.order_no}', ${idx})"><span class="time">${sale.time}</span></td>
|
||||
<td class="right" onclick="showDetail('${sale.order_no}', ${idx})"><span class="amount">₩${Math.floor(sale.amount).toLocaleString()}</span></td>
|
||||
<td onclick="showDetail('${sale.order_no}', ${idx})"><span class="${customerClass}">${sale.customer}</span></td>
|
||||
<td>${customerBadge}</td>
|
||||
<td class="center" onclick="showDetail('${sale.order_no}', ${idx})">${payBadge}</td>
|
||||
<td class="center" onclick="showDetail('${sale.order_no}', ${idx})">${sale.item_count}</td>
|
||||
<td class="center" onclick="showDetail('${sale.order_no}', ${idx})">${qrIcon}</td>
|
||||
@ -955,6 +1109,37 @@
|
||||
|
||||
document.getElementById('salesTable').innerHTML = rows;
|
||||
updateSelectedCount();
|
||||
|
||||
// 비동기로 마일리지 조회
|
||||
fetchMileagesAsync(data.sales);
|
||||
}
|
||||
|
||||
async function fetchMileagesAsync(sales) {
|
||||
// 고객코드 있는 건들만 수집 (중복 제거)
|
||||
const cusCodes = [...new Set(
|
||||
sales
|
||||
.filter(s => s.customer_code && s.customer_code !== '0000000000')
|
||||
.map(s => s.customer_code)
|
||||
)];
|
||||
|
||||
// 각 고객코드에 대해 비동기 조회
|
||||
for (const code of cusCodes) {
|
||||
try {
|
||||
const res = await fetch(`/api/customers/${code}/mileage`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.mileage !== null) {
|
||||
// 해당 코드의 모든 mileage-badge 업데이트
|
||||
const badges = document.querySelectorAll(`#mileage-${code}`);
|
||||
badges.forEach(badge => {
|
||||
badge.textContent = data.mileage.toLocaleString() + 'P';
|
||||
badge.style.display = 'inline-block';
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`마일리지 조회 실패 (${code}):`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPayBadge(method) {
|
||||
@ -1481,6 +1666,149 @@
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(toastStyle);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 고객 검색/매핑 모달
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
let currentOrderNo = null;
|
||||
let currentCustomerName = '';
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function openCustomerModal(orderNo, customerName, customerCode) {
|
||||
currentOrderNo = orderNo;
|
||||
currentCustomerName = customerName;
|
||||
|
||||
document.getElementById('customerOrderInfo').textContent =
|
||||
`주문번호: ${orderNo} | 현재: ${customerName || '미입력'}`;
|
||||
document.getElementById('customerSearchInput').value = customerName || '';
|
||||
document.getElementById('customerResults').innerHTML =
|
||||
'<div class="customer-no-results">이름을 검색하세요</div>';
|
||||
|
||||
document.getElementById('customerModal').classList.add('show');
|
||||
document.getElementById('customerSearchInput').focus();
|
||||
}
|
||||
|
||||
function closeCustomerModal() {
|
||||
document.getElementById('customerModal').classList.remove('show');
|
||||
currentOrderNo = null;
|
||||
}
|
||||
|
||||
async function searchCustomers() {
|
||||
const name = document.getElementById('customerSearchInput').value.trim();
|
||||
if (name.length < 2) {
|
||||
document.getElementById('customerResults').innerHTML =
|
||||
'<div class="customer-no-results">2자 이상 입력하세요</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/customers/search?name=${encodeURIComponent(name)}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
document.getElementById('customerResults').innerHTML =
|
||||
`<div class="customer-no-results">${data.error}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.results.length === 0) {
|
||||
document.getElementById('customerResults').innerHTML =
|
||||
'<div class="customer-no-results">검색 결과가 없습니다</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = data.results.map(c => {
|
||||
const birthDisplay = c.birth
|
||||
? `(${c.birth.substring(0,2)}.${c.birth.substring(2,4)}.${c.birth.substring(4,6)})`
|
||||
: '';
|
||||
let activityHtml = '';
|
||||
if (c.activity_type && c.days_ago !== null) {
|
||||
const isRecent = c.days_ago <= 30;
|
||||
activityHtml = `<div class="customer-result-activity ${isRecent ? 'recent' : ''}">
|
||||
${c.activity_type === '조제' ? '📋' : '🛒'}
|
||||
${c.activity_type} ${c.days_ago === 0 ? '오늘' : c.days_ago + '일 전'}
|
||||
</div>`;
|
||||
} else {
|
||||
activityHtml = '<div class="customer-result-activity">활동 기록 없음</div>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="customer-result-item" onclick="selectCustomer('${c.cus_code}', '${escapeHtml(c.name)}')">
|
||||
<span class="customer-result-name">${escapeHtml(c.name)}</span>
|
||||
<span class="customer-result-birth">${birthDisplay}</span>
|
||||
${activityHtml}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('customerResults').innerHTML = html;
|
||||
|
||||
} catch (err) {
|
||||
document.getElementById('customerResults').innerHTML =
|
||||
`<div class="customer-no-results">오류: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectCustomer(cusCode, cusName) {
|
||||
if (!currentOrderNo) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/pos-live/${currentOrderNo}/customer`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cus_code: cusCode, cus_name: cusName })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast(`${cusName}님으로 업데이트됨`, 'success');
|
||||
closeCustomerModal();
|
||||
loadSales(); // 테이블 새로고침
|
||||
} else {
|
||||
showToast('업데이트 실패: ' + data.error, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('오류: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Enter 키로 검색
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('customerSearchInput')?.addEventListener('keypress', e => {
|
||||
if (e.key === 'Enter') searchCustomers();
|
||||
});
|
||||
});
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
document.getElementById('customerModal')?.addEventListener('click', e => {
|
||||
if (e.target.id === 'customerModal') closeCustomerModal();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- 고객 검색 모달 -->
|
||||
<div class="customer-modal" id="customerModal">
|
||||
<div class="customer-modal-content">
|
||||
<h3>👤 고객 매핑</h3>
|
||||
<div class="customer-order-info" id="customerOrderInfo">주문번호: -</div>
|
||||
<div class="customer-search-box">
|
||||
<input type="text" class="customer-search-input" id="customerSearchInput"
|
||||
placeholder="이름 검색..." autocomplete="off">
|
||||
<button class="customer-search-btn" onclick="searchCustomers()">검색</button>
|
||||
</div>
|
||||
<div class="customer-results" id="customerResults">
|
||||
<div class="customer-no-results">이름을 검색하세요</div>
|
||||
</div>
|
||||
<button class="customer-modal-close" onclick="closeCustomerModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -712,6 +712,57 @@
|
||||
.purchase-modal-btn:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ── 제품명 링크 스타일 ── */
|
||||
.product-name-link {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.product-name-link:hover {
|
||||
color: #8b5cf6;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── 환자명 링크 스타일 ── */
|
||||
.patient-name-link {
|
||||
cursor: pointer;
|
||||
color: #1e293b;
|
||||
transition: all 0.15s;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.patient-name-link:hover {
|
||||
color: #8b5cf6;
|
||||
background: #f3e8ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── 페이지네이션 버튼 ── */
|
||||
.pagination-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
.pagination-btn.active {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
border-color: #10b981;
|
||||
}
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
@ -1151,6 +1202,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용이력 모달 -->
|
||||
<!-- 환자 최근 처방 모달 (z-index 더 높게 - 제품 모달 위에 표시) -->
|
||||
<div class="purchase-modal" id="patientPrescriptionsModal" style="z-index: 2100;" onclick="if(event.target===this)closePatientPrescriptionsModal()">
|
||||
<div class="purchase-modal-content" style="max-width: 850px;">
|
||||
<div class="purchase-modal-header" style="background: linear-gradient(135deg, #8b5cf6, #7c3aed);">
|
||||
<h3>📋 환자 최근 처방</h3>
|
||||
<div class="drug-name" id="patientPrescriptionsName">-</div>
|
||||
</div>
|
||||
<div class="purchase-modal-body" id="patientPrescriptionsBody" style="max-height: 500px; overflow-y: auto;">
|
||||
<div class="purchase-empty"><div class="icon">📭</div><p>로딩 중...</p></div>
|
||||
</div>
|
||||
<div class="purchase-modal-footer">
|
||||
<button class="purchase-modal-btn" onclick="closePatientPrescriptionsModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="purchase-modal" id="usageHistoryModal" onclick="if(event.target===this)closeUsageHistoryModal()">
|
||||
<div class="purchase-modal-content" style="max-width: 700px;">
|
||||
<div class="purchase-modal-header" style="background: linear-gradient(135deg, #10b981, #059669);">
|
||||
<h3>💊 처방 사용이력</h3>
|
||||
<div class="drug-name" id="usageHistoryDrugName">-</div>
|
||||
</div>
|
||||
<div class="purchase-modal-body">
|
||||
<table class="purchase-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>환자명</th>
|
||||
<th>처방일</th>
|
||||
<th>수량</th>
|
||||
<th>횟수</th>
|
||||
<th>일수</th>
|
||||
<th>총투약량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usageHistoryBody">
|
||||
<tr><td colspan="6" class="purchase-empty"><div class="icon">📭</div><p>로딩 중...</p></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="purchase-modal-footer" style="flex-direction: column; gap: 12px;">
|
||||
<div id="usageHistoryPagination" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: center;"></div>
|
||||
<button class="purchase-modal-btn" onclick="closeUsageHistoryModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let productsData = [];
|
||||
let selectedItem = null;
|
||||
@ -1236,7 +1334,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="product-name">
|
||||
${escapeHtml(item.product_name)}
|
||||
<span class="product-name-link" onclick="event.stopPropagation();openUsageHistoryModal('${item.drug_code}', '${escapeHtml(item.product_name).replace(/'/g, "\\'")}')" title="클릭하여 사용이력 보기">${escapeHtml(item.product_name)}</span>
|
||||
${item.is_animal_drug ? `<span class="animal-badge ${item.apc ? 'clickable' : ''}" ${item.apc ? `onclick="event.stopPropagation();printAnimalDrugInfo('${item.apc}', '${escapeHtml(item.product_name)}')" title="클릭하면 약품 안내서 인쇄"` : 'title="APC 없음"'}>🐾 동물약</span>` : ''}
|
||||
${categoryBadge}
|
||||
</div>
|
||||
@ -1710,6 +1808,7 @@
|
||||
|
||||
function closeImageModal() {
|
||||
stopCamera();
|
||||
stopQrPolling();
|
||||
document.getElementById('imageModal').classList.remove('show');
|
||||
imgModalBarcode = null;
|
||||
imgModalDrugCode = null;
|
||||
@ -1721,6 +1820,106 @@
|
||||
document.querySelectorAll('.img-tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab));
|
||||
document.querySelectorAll('.img-tab-content').forEach(c => c.classList.toggle('active', c.id === 'imgTab' + tab.charAt(0).toUpperCase() + tab.slice(1)));
|
||||
if (tab === 'camera') startCamera(); else stopCamera();
|
||||
if (tab === 'qr') startQrSession(); else stopQrPolling();
|
||||
}
|
||||
|
||||
// ═══ QR 모바일 업로드 ═══
|
||||
let qrSessionId = null;
|
||||
let qrPollingInterval = null;
|
||||
|
||||
async function startQrSession() {
|
||||
const barcode = imgModalBarcode;
|
||||
if (!barcode) return;
|
||||
|
||||
document.getElementById('qrLoading').style.display = 'flex';
|
||||
document.getElementById('qrCanvas').style.display = 'none';
|
||||
document.getElementById('qrStatus').style.display = 'none';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ barcode, product_name: imgModalName || barcode })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
qrSessionId = data.session_id;
|
||||
generateQrCode(data.qr_url);
|
||||
startQrPolling();
|
||||
} else {
|
||||
document.getElementById('qrLoading').textContent = '❌ QR 생성 실패';
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('qrLoading').textContent = '❌ 네트워크 오류';
|
||||
}
|
||||
}
|
||||
|
||||
function generateQrCode(url) {
|
||||
const canvas = document.getElementById('qrCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = 180;
|
||||
canvas.height = 180;
|
||||
|
||||
// QR 라이브러리 없이 API 사용
|
||||
const img = new Image();
|
||||
img.onload = function() {
|
||||
ctx.drawImage(img, 0, 0, 180, 180);
|
||||
document.getElementById('qrLoading').style.display = 'none';
|
||||
canvas.style.display = 'block';
|
||||
};
|
||||
img.onerror = function() {
|
||||
document.getElementById('qrLoading').textContent = '❌ QR 생성 실패';
|
||||
};
|
||||
img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodeURIComponent(url);
|
||||
}
|
||||
|
||||
function startQrPolling() {
|
||||
stopQrPolling();
|
||||
qrPollingInterval = setInterval(checkQrSession, 2000);
|
||||
}
|
||||
|
||||
function stopQrPolling() {
|
||||
if (qrPollingInterval) {
|
||||
clearInterval(qrPollingInterval);
|
||||
qrPollingInterval = null;
|
||||
}
|
||||
qrSessionId = null;
|
||||
}
|
||||
|
||||
async function checkQrSession() {
|
||||
if (!qrSessionId) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload-session/' + qrSessionId);
|
||||
const data = await res.json();
|
||||
|
||||
const statusEl = document.getElementById('qrStatus');
|
||||
|
||||
if (data.status === 'uploaded') {
|
||||
statusEl.style.display = 'block';
|
||||
statusEl.style.background = '#d1fae5';
|
||||
statusEl.style.color = '#065f46';
|
||||
statusEl.textContent = '✅ 이미지 업로드 완료!';
|
||||
|
||||
stopQrPolling();
|
||||
|
||||
// 1.5초 후 모달 닫고 목록 새로고침
|
||||
setTimeout(() => {
|
||||
closeImageModal();
|
||||
searchProducts();
|
||||
showToast('📱 모바일 이미지 등록 완료!', 'success');
|
||||
}, 1500);
|
||||
} else if (data.status === 'expired') {
|
||||
statusEl.style.display = 'block';
|
||||
statusEl.style.background = '#fee2e2';
|
||||
statusEl.style.color = '#991b1b';
|
||||
statusEl.textContent = '⏰ 세션 만료 - 탭을 다시 선택하세요';
|
||||
stopQrPolling();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('QR 세션 확인 오류:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function startCamera() {
|
||||
@ -1783,13 +1982,15 @@
|
||||
if (!capturedImageData) { alert('촬영된 이미지가 없습니다'); return; }
|
||||
const code = imgModalBarcode || imgModalDrugCode;
|
||||
const name = imgModalName;
|
||||
closeImageModal();
|
||||
const drugCode = imgModalDrugCode;
|
||||
const imageData = capturedImageData; // 먼저 저장!
|
||||
closeImageModal(); // 이 후 capturedImageData = null 됨
|
||||
showToast(`"${name}" 이미지 저장 중...`);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/product-images/${code}/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image_data: capturedImageData, product_name: name, drug_code: imgModalDrugCode })
|
||||
body: JSON.stringify({ image_data: imageData, product_name: name, drug_code: drugCode })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) { showToast('✅ 이미지 저장 완료!', 'success'); searchProducts(); }
|
||||
@ -1840,8 +2041,9 @@
|
||||
</div>
|
||||
|
||||
<div class="image-modal-tabs">
|
||||
<button class="img-tab-btn active" data-tab="url" onclick="switchImageTab('url')">🔗 URL 입력</button>
|
||||
<button class="img-tab-btn" data-tab="camera" onclick="switchImageTab('camera')">📸 촬영</button>
|
||||
<button class="img-tab-btn active" data-tab="url" onclick="switchImageTab('url')">🔗 URL</button>
|
||||
<button class="img-tab-btn" data-tab="camera" onclick="switchImageTab('camera')">📸 PC촬영</button>
|
||||
<button class="img-tab-btn" data-tab="qr" onclick="switchImageTab('qr')">📱 모바일</button>
|
||||
</div>
|
||||
|
||||
<div class="img-tab-content active" id="imgTabUrl">
|
||||
@ -1871,6 +2073,22 @@
|
||||
<button class="img-modal-btn primary" onclick="submitCapturedImage()">저장하기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="img-tab-content" id="imgTabQr">
|
||||
<div style="text-align:center;padding:20px 0;">
|
||||
<div id="qrContainer" style="display:inline-block;background:#fff;padding:16px;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,0.1);">
|
||||
<div id="qrLoading" style="width:180px;height:180px;display:flex;align-items:center;justify-content:center;color:#94a3b8;">
|
||||
QR 생성 중...
|
||||
</div>
|
||||
<canvas id="qrCanvas" style="display:none;"></canvas>
|
||||
</div>
|
||||
<p style="margin-top:16px;color:#64748b;font-size:14px;">📱 휴대폰으로 QR 스캔하여 촬영</p>
|
||||
<div id="qrStatus" style="margin-top:12px;padding:8px 16px;border-radius:8px;font-size:13px;display:none;"></div>
|
||||
</div>
|
||||
<div class="img-modal-btns">
|
||||
<button class="img-modal-btn secondary" onclick="closeImageModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2035,6 +2253,177 @@
|
||||
function closePurchaseModal() {
|
||||
document.getElementById('purchaseModal').classList.remove('show');
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// 사용이력 모달
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
let currentUsageDrugCode = '';
|
||||
let currentUsageDrugName = '';
|
||||
let currentUsagePage = 1;
|
||||
|
||||
async function openUsageHistoryModal(drugCode, drugName) {
|
||||
currentUsageDrugCode = drugCode;
|
||||
currentUsageDrugName = drugName;
|
||||
currentUsagePage = 1;
|
||||
|
||||
const modal = document.getElementById('usageHistoryModal');
|
||||
const nameEl = document.getElementById('usageHistoryDrugName');
|
||||
|
||||
nameEl.textContent = drugName || drugCode;
|
||||
modal.classList.add('show');
|
||||
|
||||
await loadUsageHistoryPage(1);
|
||||
}
|
||||
|
||||
async function loadUsageHistoryPage(page) {
|
||||
currentUsagePage = page;
|
||||
const tbody = document.getElementById('usageHistoryBody');
|
||||
const pagination = document.getElementById('usageHistoryPagination');
|
||||
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="purchase-empty"><div class="icon">⏳</div><p>사용이력 조회 중...</p></td></tr>';
|
||||
pagination.innerHTML = '';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/products/${currentUsageDrugCode}/usage-history?page=${page}&per_page=20&months=12`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
if (data.items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="purchase-empty"><div class="icon">📭</div><p>최근 12개월간 사용이력이 없습니다</p></td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = data.items.map(item => `
|
||||
<tr>
|
||||
<td>
|
||||
<span class="patient-name-link" onclick="event.stopPropagation();openPatientPrescriptionsModal('${item.cus_code}', '${escapeHtml(item.patient_name).replace(/'/g, "\\'")}')" title="클릭하여 최근 처방 보기">${escapeHtml(item.patient_name)}</span>
|
||||
</td>
|
||||
<td class="purchase-date">${item.rx_date}</td>
|
||||
<td style="text-align: center;">${item.quantity}</td>
|
||||
<td style="text-align: center;">${item.times}</td>
|
||||
<td style="text-align: center;">${item.days}</td>
|
||||
<td style="text-align: center; font-weight: 600; color: #10b981;">${item.total_dose}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 페이지네이션 렌더링
|
||||
renderUsageHistoryPagination(data.pagination);
|
||||
} else {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="purchase-empty"><div class="icon">⚠️</div><p>조회 실패: ${data.error}</p></td></tr>`;
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="purchase-empty"><div class="icon">❌</div><p>오류: ${err.message}</p></td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsageHistoryPagination(pg) {
|
||||
const pagination = document.getElementById('usageHistoryPagination');
|
||||
if (pg.total_pages <= 1) {
|
||||
pagination.innerHTML = `<span style="color: #64748b; font-size: 13px;">총 ${pg.total_count}건</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
// 이전 버튼
|
||||
html += `<button class="pagination-btn" ${pg.page <= 1 ? 'disabled' : ''} onclick="loadUsageHistoryPage(${pg.page - 1})">◀ 이전</button>`;
|
||||
|
||||
// 페이지 번호들
|
||||
const maxPages = 5;
|
||||
let startPage = Math.max(1, pg.page - Math.floor(maxPages / 2));
|
||||
let endPage = Math.min(pg.total_pages, startPage + maxPages - 1);
|
||||
if (endPage - startPage < maxPages - 1) {
|
||||
startPage = Math.max(1, endPage - maxPages + 1);
|
||||
}
|
||||
|
||||
if (startPage > 1) {
|
||||
html += `<button class="pagination-btn" onclick="loadUsageHistoryPage(1)">1</button>`;
|
||||
if (startPage > 2) html += `<span style="color: #94a3b8;">...</span>`;
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
html += `<button class="pagination-btn ${i === pg.page ? 'active' : ''}" onclick="loadUsageHistoryPage(${i})">${i}</button>`;
|
||||
}
|
||||
|
||||
if (endPage < pg.total_pages) {
|
||||
if (endPage < pg.total_pages - 1) html += `<span style="color: #94a3b8;">...</span>`;
|
||||
html += `<button class="pagination-btn" onclick="loadUsageHistoryPage(${pg.total_pages})">${pg.total_pages}</button>`;
|
||||
}
|
||||
|
||||
// 다음 버튼
|
||||
html += `<button class="pagination-btn" ${pg.page >= pg.total_pages ? 'disabled' : ''} onclick="loadUsageHistoryPage(${pg.page + 1})">다음 ▶</button>`;
|
||||
|
||||
// 총 건수
|
||||
html += `<span style="color: #64748b; font-size: 13px; margin-left: 12px;">총 ${pg.total_count}건</span>`;
|
||||
|
||||
pagination.innerHTML = html;
|
||||
}
|
||||
|
||||
function closeUsageHistoryModal() {
|
||||
document.getElementById('usageHistoryModal').classList.remove('show');
|
||||
}
|
||||
|
||||
// 환자 최근 처방 모달
|
||||
async function openPatientPrescriptionsModal(cusCode, patientName) {
|
||||
const modal = document.getElementById('patientPrescriptionsModal');
|
||||
const nameEl = document.getElementById('patientPrescriptionsName');
|
||||
const bodyEl = document.getElementById('patientPrescriptionsBody');
|
||||
|
||||
nameEl.textContent = patientName || cusCode;
|
||||
bodyEl.innerHTML = '<div class="purchase-empty"><div class="icon">⏳</div><p>처방 내역 조회 중...</p></div>';
|
||||
modal.classList.add('show');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/patients/${cusCode}/recent-prescriptions`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.prescriptions.length > 0) {
|
||||
let html = '';
|
||||
data.prescriptions.forEach(rx => {
|
||||
html += `
|
||||
<div class="rx-card" style="margin-bottom: 16px; padding: 16px; background: #f8fafc; border-radius: 12px; border-left: 4px solid #8b5cf6;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<div>
|
||||
<span style="font-weight: 600; color: #1e293b; font-size: 15px;">📅 ${rx.rx_date}</span>
|
||||
<span style="color: #64748b; font-size: 13px; margin-left: 12px;">${escapeHtml(rx.hospital_name || '')}</span>
|
||||
</div>
|
||||
<span style="color: #8b5cf6; font-size: 13px; font-weight: 500;">${escapeHtml(rx.doctor_name || '')}</span>
|
||||
</div>
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
|
||||
<thead>
|
||||
<tr style="background: #e2e8f0;">
|
||||
<th style="padding: 8px; text-align: left; border-radius: 6px 0 0 6px;">약품명</th>
|
||||
<th style="padding: 8px; text-align: center; width: 80px;">용법</th>
|
||||
<th style="padding: 8px; text-align: center; width: 60px; border-radius: 0 6px 6px 0;">총량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rx.items.map(item => `
|
||||
<tr style="border-bottom: 1px solid #e2e8f0;">
|
||||
<td style="padding: 8px;">
|
||||
<div style="color: #334155; font-weight: 500;">${escapeHtml(item.drug_name)}</div>
|
||||
${item.category ? `<div style="font-size: 11px; color: #8b5cf6; margin-top: 2px;">${escapeHtml(item.category)}</div>` : ''}
|
||||
</td>
|
||||
<td style="padding: 8px; text-align: center; color: #475569; font-size: 12px;">${item.quantity}×${item.times}×${item.days}</td>
|
||||
<td style="padding: 8px; text-align: center; font-weight: 600; color: #8b5cf6;">${item.total_dose}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
bodyEl.innerHTML = html;
|
||||
} else {
|
||||
bodyEl.innerHTML = '<div class="purchase-empty"><div class="icon">📭</div><p>최근 6개월간 처방 내역이 없습니다</p></div>';
|
||||
}
|
||||
} catch (err) {
|
||||
bodyEl.innerHTML = `<div class="purchase-empty"><div class="icon">❌</div><p>오류: ${err.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function closePatientPrescriptionsModal() {
|
||||
document.getElementById('patientPrescriptionsModal').classList.remove('show');
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
|
||||
@ -48,6 +48,87 @@
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ══════════════════ 주문량 툴팁 ══════════════════ */
|
||||
.order-qty-cell {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.order-qty-tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
min-width: 140px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.order-qty-cell:hover .order-qty-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
bottom: calc(100% + 8px);
|
||||
}
|
||||
.order-qty-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
.order-qty-tooltip-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.order-qty-tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.order-qty-tooltip-row:not(:last-child) {
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.order-qty-vendor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.order-qty-vendor-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.order-qty-vendor-dot.geoyoung { background: #06b6d4; }
|
||||
.order-qty-vendor-dot.sooin { background: #a855f7; }
|
||||
.order-qty-vendor-dot.baekje { background: #f59e0b; }
|
||||
.order-qty-vendor-dot.dongwon { background: #22c55e; }
|
||||
.order-qty-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.order-qty-total {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* ══════════════════ 헤더 ══════════════════ */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 100%);
|
||||
@ -939,7 +1020,7 @@
|
||||
loadOrderData(); // 수인약품 주문량 로드
|
||||
});
|
||||
|
||||
// ──────────────── 도매상 주문량 조회 (지오영 + 수인 + 백제 합산) ────────────────
|
||||
// ──────────────── 도매상 주문량 조회 (지오영 + 수인 + 백제 + 동원 합산) ────────────────
|
||||
async function loadOrderData() {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
@ -948,58 +1029,58 @@
|
||||
orderDataByKd = {};
|
||||
|
||||
try {
|
||||
// 지오영 + 수인 + 백제 병렬 조회
|
||||
const [geoRes, sooinRes, baekjeRes] = await Promise.all([
|
||||
// 지오영 + 수인 + 백제 + 동원 병렬 조회
|
||||
const [geoRes, sooinRes, baekjeRes, dongwonRes] = await Promise.all([
|
||||
fetch(`/api/geoyoung/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })),
|
||||
fetch(`/api/sooin/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })),
|
||||
fetch(`/api/baekje/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false }))
|
||||
fetch(`/api/baekje/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })),
|
||||
fetch(`/api/dongwon/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false }))
|
||||
]);
|
||||
|
||||
let totalOrders = 0;
|
||||
|
||||
// 지오영 데이터 합산
|
||||
if (geoRes.success && geoRes.by_kd_code) {
|
||||
for (const [kd, data] of Object.entries(geoRes.by_kd_code)) {
|
||||
if (!orderDataByKd[kd]) {
|
||||
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
|
||||
// 도매상 정보 (확장 가능)
|
||||
const vendorConfig = {
|
||||
geoyoung: { name: '지오영', icon: '🏭', res: geoRes },
|
||||
sooin: { name: '수인', icon: '💜', res: sooinRes },
|
||||
baekje: { name: '백제', icon: '💉', res: baekjeRes },
|
||||
dongwon: { name: '동원', icon: '🟠', res: dongwonRes }
|
||||
};
|
||||
|
||||
// 각 도매상 데이터 합산 (상세 정보 포함)
|
||||
for (const [vendorId, config] of Object.entries(vendorConfig)) {
|
||||
const res = config.res;
|
||||
if (res.success && res.by_kd_code) {
|
||||
for (const [kd, data] of Object.entries(res.by_kd_code)) {
|
||||
if (!orderDataByKd[kd]) {
|
||||
orderDataByKd[kd] = {
|
||||
product_name: data.product_name,
|
||||
spec: data.spec,
|
||||
boxes: 0,
|
||||
units: 0,
|
||||
details: [] // 도매상별 상세 배열
|
||||
};
|
||||
}
|
||||
const boxes = data.boxes || 0;
|
||||
const units = data.units || 0;
|
||||
orderDataByKd[kd].boxes += boxes;
|
||||
orderDataByKd[kd].units += units;
|
||||
// 상세 정보 추가 (수량이 있는 경우만)
|
||||
if (units > 0 || boxes > 0) {
|
||||
orderDataByKd[kd].details.push({
|
||||
vendor: vendorId,
|
||||
name: config.name,
|
||||
boxes: boxes,
|
||||
units: units
|
||||
});
|
||||
}
|
||||
}
|
||||
orderDataByKd[kd].boxes += data.boxes || 0;
|
||||
orderDataByKd[kd].units += data.units || 0;
|
||||
orderDataByKd[kd].sources.push('지오영');
|
||||
totalOrders += res.order_count || 0;
|
||||
console.log(`${config.icon} ${config.name} 주문량:`, Object.keys(res.by_kd_code).length, '품목,', res.order_count, '건');
|
||||
}
|
||||
totalOrders += geoRes.order_count || 0;
|
||||
console.log('🏭 지오영 주문량:', Object.keys(geoRes.by_kd_code).length, '품목,', geoRes.order_count, '건');
|
||||
}
|
||||
|
||||
// 수인 데이터 합산
|
||||
if (sooinRes.success && sooinRes.by_kd_code) {
|
||||
for (const [kd, data] of Object.entries(sooinRes.by_kd_code)) {
|
||||
if (!orderDataByKd[kd]) {
|
||||
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
|
||||
}
|
||||
orderDataByKd[kd].boxes += data.boxes || 0;
|
||||
orderDataByKd[kd].units += data.units || 0;
|
||||
orderDataByKd[kd].sources.push('수인');
|
||||
}
|
||||
totalOrders += sooinRes.order_count || 0;
|
||||
console.log('💜 수인 주문량:', Object.keys(sooinRes.by_kd_code).length, '품목,', sooinRes.order_count, '건');
|
||||
}
|
||||
|
||||
// 백제 데이터 합산
|
||||
if (baekjeRes.success && baekjeRes.by_kd_code) {
|
||||
for (const [kd, data] of Object.entries(baekjeRes.by_kd_code)) {
|
||||
if (!orderDataByKd[kd]) {
|
||||
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
|
||||
}
|
||||
orderDataByKd[kd].boxes += data.boxes || 0;
|
||||
orderDataByKd[kd].units += data.units || 0;
|
||||
orderDataByKd[kd].sources.push('백제');
|
||||
}
|
||||
totalOrders += baekjeRes.order_count || 0;
|
||||
console.log('💉 백제 주문량:', Object.keys(baekjeRes.by_kd_code).length, '품목,', baekjeRes.order_count, '건');
|
||||
}
|
||||
|
||||
console.log('📦 3사 합산 주문량:', Object.keys(orderDataByKd).length, '품목,', totalOrders, '건 주문');
|
||||
console.log('📦 4사 합산 주문량:', Object.keys(orderDataByKd).length, '품목,', totalOrders, '건 주문');
|
||||
|
||||
} catch(err) {
|
||||
console.warn('주문량 조회 실패:', err);
|
||||
@ -1010,14 +1091,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
// KD코드로 주문량 조회
|
||||
// KD코드로 주문량 조회 (툴팁 포함)
|
||||
let orderDataLoading = true; // 로딩 상태
|
||||
|
||||
function getOrderedQty(kdCode) {
|
||||
if (orderDataLoading) return '<span class="order-loading">···</span>';
|
||||
const order = orderDataByKd[kdCode];
|
||||
if (!order) return '-';
|
||||
return order.units.toLocaleString();
|
||||
if (!order || order.units === 0) return '-';
|
||||
|
||||
// 상세 정보가 없거나 1개만 있으면 단순 표시
|
||||
if (!order.details || order.details.length <= 1) {
|
||||
const vendorName = order.details && order.details[0] ? order.details[0].name : '';
|
||||
return `<span title="${vendorName}">${order.units.toLocaleString()}</span>`;
|
||||
}
|
||||
|
||||
// 2개 이상 도매상이면 툴팁 표시
|
||||
let tooltipHtml = `<div class="order-qty-tooltip">
|
||||
<div class="order-qty-tooltip-title">도매상별 주문</div>`;
|
||||
|
||||
for (const detail of order.details) {
|
||||
tooltipHtml += `
|
||||
<div class="order-qty-tooltip-row">
|
||||
<span class="order-qty-vendor">
|
||||
<span class="order-qty-vendor-dot ${detail.vendor}"></span>
|
||||
${detail.name}
|
||||
</span>
|
||||
<span class="order-qty-value">${detail.units.toLocaleString()}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
tooltipHtml += `
|
||||
<div class="order-qty-tooltip-row order-qty-total">
|
||||
<span>합계</span>
|
||||
<span>${order.units.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return `<div class="order-qty-cell">
|
||||
${order.units.toLocaleString()}
|
||||
${tooltipHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ──────────────── 데이터 로드 ────────────────
|
||||
@ -1269,6 +1382,16 @@
|
||||
gradient: 'linear-gradient(135deg, #d97706, #f59e0b)',
|
||||
filterFn: (item) => item.supplier === '백제약품' || item.wholesaler === 'baekje',
|
||||
getCode: (item) => item.baekje_code || item.drug_code
|
||||
},
|
||||
dongwon: {
|
||||
id: 'dongwon',
|
||||
name: '동원약품',
|
||||
icon: '🏥',
|
||||
logo: '/static/img/logo_dongwon.png',
|
||||
color: '#22c55e',
|
||||
gradient: 'linear-gradient(135deg, #16a34a, #22c55e)',
|
||||
filterFn: (item) => item.supplier === '동원약품' || item.wholesaler === 'dongwon',
|
||||
getCode: (item) => item.dongwon_code || item.internal_code || item.drug_code
|
||||
}
|
||||
};
|
||||
|
||||
@ -2043,9 +2166,9 @@
|
||||
if (e.key === 'Enter') loadUsageData();
|
||||
});
|
||||
|
||||
// ──────────────── 도매상 재고 조회 (지오영 + 수인 + 백제) ────────────────
|
||||
// ──────────────── 도매상 재고 조회 (지오영 + 수인 + 백제 + 동원) ────────────────
|
||||
let currentWholesaleItem = null;
|
||||
window.wholesaleItems = { geoyoung: [], sooin: [], baekje: [] };
|
||||
window.wholesaleItems = { geoyoung: [], sooin: [], baekje: [], dongwon: [] };
|
||||
|
||||
function openWholesaleModal(idx) {
|
||||
const item = usageData[idx];
|
||||
@ -2065,7 +2188,7 @@
|
||||
document.getElementById('geoResultBody').innerHTML = `
|
||||
<div class="geo-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<div>도매상 재고 조회 중... (지오영 + 수인 + 백제)</div>
|
||||
<div>도매상 재고 조회 중... (지오영 + 수인 + 백제 + 동원)</div>
|
||||
</div>`;
|
||||
document.getElementById('geoSearchKeyword').style.display = 'none';
|
||||
|
||||
@ -2081,22 +2204,24 @@
|
||||
async function searchAllWholesalers(kdCode, productName) {
|
||||
const resultBody = document.getElementById('geoResultBody');
|
||||
|
||||
// 세 도매상 동시 호출
|
||||
const [geoResult, sooinResult, baekjeResult] = await Promise.all([
|
||||
// 네 도매상 동시 호출
|
||||
const [geoResult, sooinResult, baekjeResult, dongwonResult] = await Promise.all([
|
||||
searchGeoyoungAPI(kdCode, productName),
|
||||
searchSooinAPI(kdCode),
|
||||
searchBaekjeAPI(kdCode)
|
||||
searchBaekjeAPI(kdCode),
|
||||
searchDongwonAPI(kdCode, productName)
|
||||
]);
|
||||
|
||||
// 결과 저장
|
||||
window.wholesaleItems = {
|
||||
geoyoung: geoResult.items || [],
|
||||
sooin: sooinResult.items || [],
|
||||
baekje: baekjeResult.items || []
|
||||
baekje: baekjeResult.items || [],
|
||||
dongwon: dongwonResult.items || []
|
||||
};
|
||||
|
||||
// 통합 렌더링
|
||||
renderWholesaleResults(geoResult, sooinResult, baekjeResult);
|
||||
renderWholesaleResults(geoResult, sooinResult, baekjeResult, dongwonResult);
|
||||
}
|
||||
|
||||
async function searchGeoyoungAPI(kdCode, productName) {
|
||||
@ -2136,18 +2261,42 @@
|
||||
return { success: false, error: err.message, items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function searchDongwonAPI(kdCode, productName) {
|
||||
try {
|
||||
// 1차: KD코드(보험코드)로 검색 (searchType=0)
|
||||
let response = await fetch(`/api/dongwon/stock?keyword=${encodeURIComponent(kdCode)}`);
|
||||
let data = await response.json();
|
||||
|
||||
// 결과 없으면 제품명으로 재검색
|
||||
if (data.success && data.count === 0 && productName) {
|
||||
// 제품명 정제: "휴니즈레바미피드정_(0.1g/1정)" → "휴니즈레바미피드정"
|
||||
let cleanName = productName.split('_')[0].split('(')[0].trim();
|
||||
if (cleanName) {
|
||||
response = await fetch(`/api/dongwon/stock?keyword=${encodeURIComponent(cleanName)}`);
|
||||
data = await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message, items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function renderWholesaleResults(geoResult, sooinResult, baekjeResult) {
|
||||
function renderWholesaleResults(geoResult, sooinResult, baekjeResult, dongwonResult) {
|
||||
const resultBody = document.getElementById('geoResultBody');
|
||||
|
||||
const geoItems = geoResult.items || [];
|
||||
const sooinItems = sooinResult.items || [];
|
||||
const baekjeItems = (baekjeResult && baekjeResult.items) || [];
|
||||
const dongwonItems = (dongwonResult && dongwonResult.items) || [];
|
||||
|
||||
// 재고 있는 것 먼저 정렬
|
||||
geoItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock);
|
||||
sooinItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock);
|
||||
baekjeItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock);
|
||||
dongwonItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock);
|
||||
|
||||
let html = '';
|
||||
|
||||
@ -2260,6 +2409,44 @@
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// ═══════ 동원약품 섹션 ═══════
|
||||
html += `<div class="ws-section">
|
||||
<div class="ws-header dongwon">
|
||||
<span class="ws-logo">🏥</span>
|
||||
<span class="ws-name">동원약품</span>
|
||||
<span class="ws-count">${dongwonItems.length}건</span>
|
||||
</div>`;
|
||||
|
||||
if (dongwonItems.length > 0) {
|
||||
html += `<table class="geo-table">
|
||||
<thead><tr><th>제품명</th><th>규격</th><th>단가</th><th>재고</th><th></th></tr></thead>
|
||||
<tbody>`;
|
||||
|
||||
dongwonItems.forEach((item, idx) => {
|
||||
const hasStock = item.stock > 0;
|
||||
// 동원: code=KD코드(보험코드), internal_code=내부코드(주문용)
|
||||
const displayCode = item.code || item.internal_code || '';
|
||||
html += `
|
||||
<tr class="${hasStock ? '' : 'no-stock'}">
|
||||
<td>
|
||||
<div class="geo-product">
|
||||
<span class="geo-name">${escapeHtml(item.name)}</span>
|
||||
<span class="geo-code">${displayCode} · ${item.manufacturer || ''}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="geo-spec">${item.spec || '-'}</td>
|
||||
<td class="geo-price">${item.price ? item.price.toLocaleString() + '원' : '-'}</td>
|
||||
<td class="geo-stock ${hasStock ? 'in-stock' : 'out-stock'}">${item.stock}</td>
|
||||
<td>${hasStock ? `<button class="geo-add-btn dongwon" onclick="addToCartFromWholesale('dongwon', ${idx})">담기</button>` : ''}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
} else {
|
||||
html += `<div class="ws-empty">📭 검색 결과 없음</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
resultBody.innerHTML = html;
|
||||
}
|
||||
|
||||
@ -2277,7 +2464,7 @@
|
||||
const needed = currentWholesaleItem.total_dose;
|
||||
const suggestedQty = Math.ceil(needed / specQty);
|
||||
|
||||
const supplierNames = { geoyoung: '지오영', sooin: '수인약품', baekje: '백제약품' };
|
||||
const supplierNames = { geoyoung: '지오영', sooin: '수인약품', baekje: '백제약품', dongwon: '동원약품' };
|
||||
const supplierName = supplierNames[wholesaler] || wholesaler;
|
||||
const productName = wholesaler === 'geoyoung' ? item.product_name : item.name;
|
||||
|
||||
@ -2298,6 +2485,7 @@
|
||||
geoyoung_code: wholesaler === 'geoyoung' ? item.insurance_code : null,
|
||||
sooin_code: wholesaler === 'sooin' ? item.code : null,
|
||||
baekje_code: wholesaler === 'baekje' ? item.internal_code : null,
|
||||
dongwon_code: wholesaler === 'dongwon' ? item.internal_code : null, // 동원: 내부코드로 주문
|
||||
unit_price: unitPrice // 💰 단가 추가
|
||||
};
|
||||
|
||||
@ -2542,6 +2730,12 @@
|
||||
.geo-add-btn.baekje:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
.geo-add-btn.dongwon {
|
||||
background: #22c55e;
|
||||
}
|
||||
.geo-add-btn.dongwon:hover {
|
||||
background: #16a34a;
|
||||
}
|
||||
.geo-price {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
@ -2576,6 +2770,10 @@
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.2), rgba(217, 119, 6, 0.1));
|
||||
border-left: 3px solid var(--accent-amber);
|
||||
}
|
||||
.ws-header.dongwon {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(22, 163, 74, 0.1));
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
.ws-logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@ -2778,6 +2976,9 @@
|
||||
.multi-ws-card.baekje {
|
||||
border-left: 3px solid var(--accent-amber);
|
||||
}
|
||||
.multi-ws-card.dongwon {
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
.multi-ws-card.other {
|
||||
border-left: 3px solid var(--text-muted);
|
||||
opacity: 0.7;
|
||||
@ -3077,9 +3278,16 @@
|
||||
color: '#a855f7',
|
||||
balanceApi: '/api/sooin/balance',
|
||||
salesApi: '/api/sooin/monthly-sales'
|
||||
},
|
||||
dongwon: {
|
||||
id: 'dongwon', name: '동원약품', icon: '🏥',
|
||||
logo: '/static/img/logo_dongwon.png',
|
||||
color: '#22c55e',
|
||||
balanceApi: '/api/dongwon/balance',
|
||||
salesApi: '/api/dongwon/monthly-orders'
|
||||
}
|
||||
};
|
||||
const WHOLESALER_ORDER = ['baekje', 'geoyoung', 'sooin'];
|
||||
const WHOLESALER_ORDER = ['baekje', 'geoyoung', 'sooin', 'dongwon'];
|
||||
|
||||
async function loadBalances() {
|
||||
const content = document.getElementById('balanceContent');
|
||||
|
||||
1549
backend/templates/admin_stock_analytics.html
Normal file
1549
backend/templates/admin_stock_analytics.html
Normal file
File diff suppressed because it is too large
Load Diff
56
backend/templates/partials/drysyrup_modal.html
Normal file
56
backend/templates/partials/drysyrup_modal.html
Normal file
@ -0,0 +1,56 @@
|
||||
<!-- 건조시럽 환산계수 모달 -->
|
||||
<div id="drysyrupModal" class="drysyrup-modal">
|
||||
<div class="drysyrup-modal-content">
|
||||
<div class="drysyrup-modal-header">
|
||||
<h3>🧪 건조시럽 환산계수</h3>
|
||||
<button class="drysyrup-modal-close" onclick="closeDrysyrupModal()">×</button>
|
||||
</div>
|
||||
<div class="drysyrup-modal-body">
|
||||
<div class="drysyrup-form">
|
||||
<div class="drysyrup-form-row">
|
||||
<label>성분코드</label>
|
||||
<input type="text" id="drysyrup_sung_code" readonly class="readonly">
|
||||
</div>
|
||||
<div class="drysyrup-form-row">
|
||||
<label>성분명</label>
|
||||
<input type="text" id="drysyrup_ingredient_name" placeholder="예: 아목시실린">
|
||||
</div>
|
||||
<div class="drysyrup-form-row">
|
||||
<label>제품명</label>
|
||||
<input type="text" id="drysyrup_product_name" placeholder="예: 오구멘틴듀오시럽">
|
||||
</div>
|
||||
<div class="drysyrup-form-row">
|
||||
<label>환산계수 (g/ml)</label>
|
||||
<input type="number" id="drysyrup_conversion_factor" step="0.001" placeholder="예: 0.11">
|
||||
<span class="hint">ml × 환산계수 = g</span>
|
||||
</div>
|
||||
<div class="drysyrup-form-row">
|
||||
<label>조제 후 함량</label>
|
||||
<input type="text" id="drysyrup_post_prep_amount" placeholder="예: 4.8mg/ml">
|
||||
</div>
|
||||
<div class="drysyrup-form-row">
|
||||
<label>분말 중 주성분량</label>
|
||||
<input type="text" id="drysyrup_main_ingredient_amt" placeholder="예: 0.787g/100g">
|
||||
</div>
|
||||
<div class="drysyrup-form-row">
|
||||
<label>보관조건</label>
|
||||
<select id="drysyrup_storage_conditions">
|
||||
<option value="실온">실온</option>
|
||||
<option value="냉장">냉장</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="drysyrup-form-row">
|
||||
<label>조제 후 유효기간</label>
|
||||
<input type="text" id="drysyrup_expiration_date" placeholder="예: 15일">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drysyrup-modal-footer">
|
||||
<span id="drysyrup_status" class="status-text"></span>
|
||||
<div class="button-group">
|
||||
<button class="btn-cancel" onclick="closeDrysyrupModal()">취소</button>
|
||||
<button class="btn-save" onclick="saveDrysyrup()">💾 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -254,6 +254,70 @@
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* 전화번호 뱃지 */
|
||||
.detail-header .phone-badge {
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.detail-header .phone-badge.has-phone {
|
||||
background: rgba(255,255,255,0.25);
|
||||
color: #fff;
|
||||
}
|
||||
.detail-header .phone-badge.no-phone {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: rgba(255,255,255,0.7);
|
||||
border: 1px dashed rgba(255,255,255,0.4);
|
||||
}
|
||||
.detail-header .phone-badge:hover {
|
||||
background: rgba(255,255,255,0.35);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 키오스크 스타일 전화번호 입력 */
|
||||
.phone-input-kiosk {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.phone-input-kiosk .phone-prefix {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #4c1d95;
|
||||
background: #f3e8ff;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.phone-input-kiosk .phone-hyphen {
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.phone-input-kiosk input {
|
||||
width: 80px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 12px 8px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.phone-input-kiosk input:focus {
|
||||
border-color: #f59e0b;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(245,158,11,0.2);
|
||||
}
|
||||
.phone-input-kiosk input::placeholder {
|
||||
color: #d1d5db;
|
||||
font-weight: 400;
|
||||
}
|
||||
.detail-header .cusetc-inline .cusetc-label {
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
@ -878,6 +942,13 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
.med-table tr:hover { background: #f8fafc; }
|
||||
.med-num {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
background: #7c3aed; color: #fff;
|
||||
font-size: 0.7rem; font-weight: 700;
|
||||
margin-right: 6px; flex-shrink: 0;
|
||||
}
|
||||
.med-name { font-weight: 600; color: #1e293b; }
|
||||
.med-code { font-size: 0.75rem; color: #94a3b8; }
|
||||
.med-dosage {
|
||||
@ -888,6 +959,7 @@
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.med-form {
|
||||
background: #fef3c7;
|
||||
@ -1154,6 +1226,133 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 건조시럽 환산계수 모달 */
|
||||
.drysyrup-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1100;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.drysyrup-modal.show { display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px; }
|
||||
.drysyrup-modal-content {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.drysyrup-modal-header {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: #fff;
|
||||
padding: 18px 25px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.drysyrup-modal-header h3 { margin: 0; font-size: 1.2rem; }
|
||||
.drysyrup-modal-close {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 1.5rem;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.drysyrup-modal-close:hover { background: rgba(255,255,255,0.3); }
|
||||
.drysyrup-modal-body { padding: 25px; }
|
||||
.drysyrup-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.drysyrup-form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.drysyrup-form-row label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
.drysyrup-form-row input,
|
||||
.drysyrup-form-row select {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.drysyrup-form-row input:focus,
|
||||
.drysyrup-form-row select:focus {
|
||||
outline: none;
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
.drysyrup-form-row input.readonly {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.drysyrup-form-row .hint {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.drysyrup-modal-footer {
|
||||
background: #f9fafb;
|
||||
padding: 16px 25px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.drysyrup-modal-footer .status-text {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.drysyrup-modal-footer .button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.drysyrup-modal-footer .btn-cancel {
|
||||
padding: 10px 20px;
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.drysyrup-modal-footer .btn-cancel:hover { background: #d1d5db; }
|
||||
.drysyrup-modal-footer .btn-save {
|
||||
padding: 10px 24px;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.drysyrup-modal-footer .btn-save:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
/* 약품명 더블클릭 힌트 */
|
||||
.med-name[data-sung-code]:not([data-sung-code=""]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.med-name[data-sung-code]:not([data-sung-code=""]):hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -1305,6 +1504,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전화번호 모달 (키오스크 스타일) -->
|
||||
<div class="cusetc-modal" id="phoneModal">
|
||||
<div class="cusetc-modal-content" style="max-width:360px;">
|
||||
<div class="cusetc-modal-header">
|
||||
<h3>📞 전화번호 입력</h3>
|
||||
<button class="cusetc-modal-close" onclick="closePhoneModal()">×</button>
|
||||
</div>
|
||||
<div class="cusetc-modal-body">
|
||||
<div class="cusetc-patient-info" id="phonePatientInfo"></div>
|
||||
<div class="phone-input-kiosk">
|
||||
<span class="phone-prefix">010</span>
|
||||
<span class="phone-hyphen">-</span>
|
||||
<input type="tel" id="phoneMid" maxlength="4" placeholder="0000" inputmode="numeric" pattern="[0-9]*">
|
||||
<span class="phone-hyphen">-</span>
|
||||
<input type="tel" id="phoneLast" maxlength="4" placeholder="0000" inputmode="numeric" pattern="[0-9]*">
|
||||
</div>
|
||||
</div>
|
||||
<div class="cusetc-modal-footer">
|
||||
<button class="cusetc-btn-cancel" onclick="closePhoneModal()">취소</button>
|
||||
<button class="cusetc-btn-save" onclick="savePhone()">💾 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OTC 구매 이력 모달 -->
|
||||
<div class="otc-modal" id="otcModal">
|
||||
<div class="otc-modal-content">
|
||||
@ -1493,19 +1716,28 @@
|
||||
pre_serial: prescriptionId,
|
||||
cus_code: data.patient.cus_code || data.patient.code,
|
||||
name: data.patient.name,
|
||||
age: data.patient.age,
|
||||
gender: data.patient.gender,
|
||||
st1: data.disease_info?.code_1 || '',
|
||||
st1_name: data.disease_info?.name_1 || '',
|
||||
st2: data.disease_info?.code_2 || '',
|
||||
st2_name: data.disease_info?.name_2 || '',
|
||||
medications: data.medications || [],
|
||||
cusetc: data.patient.cusetc || '' // 특이사항
|
||||
cusetc: data.patient.cusetc || '', // 특이사항
|
||||
phone: data.patient.phone || '' // 전화번호
|
||||
};
|
||||
|
||||
// 헤더 업데이트
|
||||
document.getElementById('detailHeader').style.display = 'block';
|
||||
document.getElementById('detailName').textContent = data.patient.name || '이름없음';
|
||||
document.getElementById('detailInfo').textContent =
|
||||
`${data.patient.age || '-'}세 / ${data.patient.gender || '-'} / ${data.patient.birthdate || '-'}`;
|
||||
|
||||
// 나이/성별 + 전화번호 표시
|
||||
const phone = data.patient.phone || '';
|
||||
const phoneDisplay = phone
|
||||
? `<span class="phone-badge has-phone" onclick="event.stopPropagation();openPhoneModal()" title="${phone}">📞 ${phone}</span>`
|
||||
: `<span class="phone-badge no-phone" onclick="event.stopPropagation();openPhoneModal()">📞 전화번호 추가</span>`;
|
||||
document.getElementById('detailInfo').innerHTML =
|
||||
`${data.patient.age || '-'}세 / ${data.patient.gender || '-'} ${phoneDisplay}`;
|
||||
|
||||
// 질병 정보 표시 (각각 별도 뱃지)
|
||||
let diseaseHtml = '';
|
||||
@ -1548,19 +1780,18 @@
|
||||
<tr>
|
||||
<th style="width:40px;"><input type="checkbox" id="checkAll" onchange="toggleAll(this)"></th>
|
||||
<th>약품명</th>
|
||||
<th>제형</th>
|
||||
<th>용량</th>
|
||||
<th>횟수</th>
|
||||
<th>일수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.medications.map(m => `
|
||||
<tr data-add-info="${escapeHtml(m.add_info || '')}" ${m.is_substituted ? 'class="substituted-row"' : ''}>
|
||||
${data.medications.map((m, i) => `
|
||||
<tr data-add-info="${escapeHtml(m.add_info || '')}" data-unit="${m.unit || '정'}" data-sung-code="${m.sung_code || ''}" data-med-name="${escapeHtml(m.med_name || m.medication_code)}" ${m.is_substituted ? 'class="substituted-row"' : ''}>
|
||||
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${m.is_auto_print ? 'checked' : ''}></td>
|
||||
<td>
|
||||
<div class="med-name">
|
||||
${m.unit_code === 2 ? '<span class="noncov-badge" title="비급여">비)</span> ' : ''}${[3,4,5,6,7].includes(m.unit_code) ? '<span class="copay-badge" title="본인부담률">' + {3:'100',4:'50',5:'80',6:'30',7:'90'}[m.unit_code] + ')</span> ' : ''}${m.ps_type === '1' ? '<span class="subst-badge" title="일반 대체조제">대)</span> ' : ''}${m.is_substituted ? '<span class="lowcost-badge" title="저가대체 인센티브">저)</span> ' : ''}${m.med_name || m.medication_code}
|
||||
<span class="med-num">${i+1}</span>${m.unit_code === 2 ? '<span class="noncov-badge" title="비급여">비)</span> ' : ''}${[3,4,5,6,7].includes(m.unit_code) ? '<span class="copay-badge" title="본인부담률">' + {3:'100',4:'50',5:'80',6:'30',7:'90'}[m.unit_code] + ')</span> ' : ''}${m.ps_type === '1' ? '<span class="subst-badge" title="일반 대체조제">대)</span> ' : ''}${m.is_substituted ? '<span class="lowcost-badge" title="저가대체 인센티브">저)</span> ' : ''}${m.med_name || m.medication_code}
|
||||
</div>
|
||||
<div class="med-code">${m.medication_code}</div>
|
||||
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
||||
@ -1574,8 +1805,7 @@
|
||||
</details>
|
||||
` : ''}
|
||||
</td>
|
||||
<td>${m.formulation ? `<span class="med-form">${m.formulation}</span>` : '-'}</td>
|
||||
<td><span class="med-dosage">${m.dosage || '-'}</span></td>
|
||||
<td><span class="med-dosage">${m.dosage || '-'}${m.unit || '정'}</span></td>
|
||||
<td>${m.frequency || '-'}회</td>
|
||||
<td>${m.duration || '-'}일</td>
|
||||
</tr>
|
||||
@ -1666,10 +1896,10 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${h.medications.map(m => `
|
||||
${h.medications.map((m, i) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="med-name">${m.med_name || m.medication_code}</div>
|
||||
<div class="med-name"><span class="med-num">${i+1}</span>${m.med_name || m.medication_code}</div>
|
||||
${m.add_info ? `<div style="font-size:0.7rem;color:#94a3b8;">${escapeHtml(m.add_info)}</div>` : ''}
|
||||
</td>
|
||||
<td>${m.dosage || '-'}</td>
|
||||
@ -1790,7 +2020,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${compared.map(m => {
|
||||
${compared.map((m, i) => {
|
||||
const rowClass = 'row-' + m.status;
|
||||
const statusLabel = {
|
||||
added: '<span class="med-status status-added">🆕 추가</span>',
|
||||
@ -1814,10 +2044,10 @@
|
||||
const disabled = m.status === 'removed' ? 'disabled' : '';
|
||||
|
||||
return `
|
||||
<tr class="${rowClass}" data-add-info="${escapeHtml(m.add_info || '')}">
|
||||
<tr class="${rowClass}" data-add-info="${escapeHtml(m.add_info || '')}" data-unit="${m.unit || '정'}" data-sung-code="${m.sung_code || ''}">
|
||||
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${disabled}></td>
|
||||
<td>
|
||||
<div class="med-name">${m.med_name || m.medication_code}</div>
|
||||
<div class="med-name"><span class="med-num">${i+1}</span>${m.med_name || m.medication_code}</div>
|
||||
<div class="med-code">${m.medication_code}</div>
|
||||
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
||||
</td>
|
||||
@ -1851,11 +2081,11 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${currentMedications.map(m => `
|
||||
<tr data-add-info="${escapeHtml(m.add_info || '')}">
|
||||
${currentMedications.map((m, i) => `
|
||||
<tr data-add-info="${escapeHtml(m.add_info || '')}" data-unit="${m.unit || '정'}" data-sung-code="${m.sung_code || ''}">
|
||||
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}"></td>
|
||||
<td>
|
||||
<div class="med-name">${m.med_name || m.medication_code}</div>
|
||||
<div class="med-name"><span class="med-num">${i+1}</span>${m.med_name || m.medication_code}</div>
|
||||
<div class="med-code">${m.medication_code}</div>
|
||||
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
||||
</td>
|
||||
@ -2059,6 +2289,106 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 전화번호 모달 함수들
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
window.openPhoneModal = function() {
|
||||
if (!currentPrescriptionData) {
|
||||
alert('❌ 먼저 환자를 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.getElementById('phoneModal');
|
||||
const patientInfo = document.getElementById('phonePatientInfo');
|
||||
const phoneMid = document.getElementById('phoneMid');
|
||||
const phoneLast = document.getElementById('phoneLast');
|
||||
|
||||
patientInfo.innerHTML = `
|
||||
<strong>${currentPrescriptionData.name || '환자'}</strong>
|
||||
<span style="margin-left: 10px; color: #6b7280;">고객코드: ${currentPrescriptionData.cus_code || '-'}</span>
|
||||
`;
|
||||
|
||||
// 기존 전화번호 파싱 (010-1234-5678 또는 01012345678)
|
||||
const existingPhone = currentPrescriptionData.phone || '';
|
||||
const digits = existingPhone.replace(/\D/g, '');
|
||||
if (digits.length >= 10) {
|
||||
phoneMid.value = digits.slice(3, 7);
|
||||
phoneLast.value = digits.slice(7, 11);
|
||||
} else {
|
||||
phoneMid.value = '';
|
||||
phoneLast.value = '';
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
phoneMid.focus();
|
||||
|
||||
// 4자리 입력 시 자동 포커스 이동
|
||||
phoneMid.oninput = function() {
|
||||
this.value = this.value.replace(/\D/g, '');
|
||||
if (this.value.length >= 4) phoneLast.focus();
|
||||
};
|
||||
phoneLast.oninput = function() {
|
||||
this.value = this.value.replace(/\D/g, '');
|
||||
};
|
||||
};
|
||||
|
||||
window.closePhoneModal = function() {
|
||||
document.getElementById('phoneModal').style.display = 'none';
|
||||
};
|
||||
|
||||
window.savePhone = async function() {
|
||||
if (!currentPrescriptionData || !currentPrescriptionData.cus_code) {
|
||||
alert('❌ 환자 정보가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const phoneMid = document.getElementById('phoneMid').value.trim();
|
||||
const phoneLast = document.getElementById('phoneLast').value.trim();
|
||||
|
||||
// 유효성 검사
|
||||
if (phoneMid.length !== 4 || phoneLast.length !== 4) {
|
||||
alert('❌ 전화번호 8자리를 모두 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 010-XXXX-XXXX 형식으로 조합
|
||||
const newPhone = `010-${phoneMid}-${phoneLast}`;
|
||||
const cusCode = currentPrescriptionData.cus_code;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/members/${cusCode}/phone`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone: newPhone })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
currentPrescriptionData.phone = newPhone;
|
||||
updatePhoneBadge(newPhone);
|
||||
closePhoneModal();
|
||||
showPaaiToast(currentPrescriptionData.name, '전화번호가 저장되었습니다.', 'completed');
|
||||
} else {
|
||||
alert('❌ ' + (data.error || '저장 실패'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('❌ 오류: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
function updatePhoneBadge(phone) {
|
||||
const detailInfo = document.getElementById('detailInfo');
|
||||
if (!detailInfo || !currentPrescriptionData) return;
|
||||
|
||||
const phoneDisplay = phone
|
||||
? `<span class="phone-badge has-phone" onclick="event.stopPropagation();openPhoneModal()" title="${phone}">📞 ${phone}</span>`
|
||||
: `<span class="phone-badge no-phone" onclick="event.stopPropagation();openPhoneModal()">📞 전화번호 추가</span>`;
|
||||
detailInfo.innerHTML =
|
||||
`${currentPrescriptionData.age || '-'}세 / ${currentPrescriptionData.gender || '-'} ${phoneDisplay}`;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PAAI (Pharmacist Assistant AI) 함수들 - 비동기 토스트 방식
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@ -2595,20 +2925,25 @@
|
||||
const tr = checkbox.closest('tr');
|
||||
const cells = tr.querySelectorAll('td');
|
||||
|
||||
// 약품명: 두 번째 셀의 .med-name
|
||||
const medName = tr.querySelector('.med-name')?.textContent?.trim() || '';
|
||||
// 약품명: data-med-name 속성에서 (번호/뱃지 제외된 순수 약품명)
|
||||
const medName = tr.dataset.medName || '';
|
||||
const addInfo = tr.dataset.addInfo || '';
|
||||
// 용량: 네 번째 셀 (index 3)
|
||||
const dosageText = cells[3]?.textContent?.replace(/[^0-9.]/g, '') || '0';
|
||||
// 용량: 세 번째 셀 (index 2) - 제형 컬럼 제거됨
|
||||
const dosageText = cells[2]?.textContent?.replace(/[^0-9.]/g, '') || '0';
|
||||
const dosage = parseFloat(dosageText) || 0;
|
||||
// 횟수: 다섯 번째 셀 (index 4)
|
||||
const freqText = cells[4]?.textContent?.replace(/[^0-9]/g, '') || '0';
|
||||
// 횟수: 네 번째 셀 (index 3)
|
||||
const freqText = cells[3]?.textContent?.replace(/[^0-9]/g, '') || '0';
|
||||
const frequency = parseInt(freqText) || 0;
|
||||
// 일수: 여섯 번째 셀 (index 5)
|
||||
const durText = cells[5]?.textContent?.replace(/[^0-9]/g, '') || '0';
|
||||
// 일수: 다섯 번째 셀 (index 4)
|
||||
const durText = cells[4]?.textContent?.replace(/[^0-9]/g, '') || '0';
|
||||
const duration = parseInt(durText) || 0;
|
||||
|
||||
console.log('Preview data:', { patientName, medName, addInfo, dosage, frequency, duration });
|
||||
// 단위: data-unit 속성에서 가져오기 (SUNG_CODE 기반 자동 판별)
|
||||
const unit = tr.dataset.unit || '정';
|
||||
// 성분코드: 환산계수 조회용
|
||||
const sungCode = tr.dataset.sungCode || '';
|
||||
|
||||
console.log('Preview data:', { patientName, medName, addInfo, dosage, frequency, duration, unit, sungCode });
|
||||
|
||||
try {
|
||||
const res = await fetch('/pmr/api/label/preview', {
|
||||
@ -2621,7 +2956,8 @@
|
||||
dosage: dosage,
|
||||
frequency: frequency,
|
||||
duration: duration,
|
||||
unit: '정'
|
||||
unit: unit,
|
||||
sung_code: sungCode
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@ -2649,14 +2985,76 @@
|
||||
document.getElementById('previewModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// 라벨 인쇄 (TODO: 구현)
|
||||
function printLabels() {
|
||||
const selected = Array.from(document.querySelectorAll('.med-check:checked')).map(c => c.dataset.code);
|
||||
if (selected.length === 0) {
|
||||
// 라벨 인쇄 (Brother QL 프린터)
|
||||
async function printLabels() {
|
||||
const checkboxes = document.querySelectorAll('.med-check:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
alert('인쇄할 약품을 선택하세요');
|
||||
return;
|
||||
}
|
||||
alert(`선택된 약품 ${selected.length}개 인쇄 기능은 추후 구현 예정입니다.\n\n${selected.join('\n')}`);
|
||||
|
||||
const patientName = document.getElementById('detailName')?.textContent?.trim() || '';
|
||||
let printedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const checkbox of checkboxes) {
|
||||
const tr = checkbox.closest('tr');
|
||||
if (!tr) continue;
|
||||
|
||||
const cells = tr.querySelectorAll('td');
|
||||
const medName = tr.dataset.medName || cells[1]?.querySelector('.med-name')?.textContent?.replace(/^\d+/, '').trim() || '';
|
||||
const addInfo = tr.dataset.addInfo || '';
|
||||
const sungCode = tr.dataset.sungCode || '';
|
||||
const unit = tr.dataset.unit || '정';
|
||||
|
||||
// 용량 파싱 (1회 투약량)
|
||||
const doseText = cells[2]?.textContent || '0';
|
||||
const dosage = parseFloat(doseText.replace(/[^0-9.]/g, '')) || 0;
|
||||
|
||||
// 횟수 파싱
|
||||
const freqText = cells[3]?.textContent || '0';
|
||||
const frequency = parseInt(freqText.replace(/[^0-9]/g, '')) || 0;
|
||||
|
||||
// 일수 파싱
|
||||
const durText = cells[4]?.textContent?.replace(/[^0-9]/g, '') || '0';
|
||||
const duration = parseInt(durText) || 0;
|
||||
|
||||
try {
|
||||
const res = await fetch('/pmr/api/label/print', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
patient_name: patientName,
|
||||
med_name: medName,
|
||||
add_info: addInfo,
|
||||
dosage: dosage,
|
||||
frequency: frequency,
|
||||
duration: duration,
|
||||
unit: unit,
|
||||
sung_code: sungCode,
|
||||
printer: '168' // 기본: QL-810W
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
printedCount++;
|
||||
console.log('Print success:', medName);
|
||||
} else {
|
||||
failedCount++;
|
||||
console.error('Print failed:', medName, data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
failedCount++;
|
||||
console.error('Print error:', medName, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedCount === 0) {
|
||||
alert(`✅ ${printedCount}개 라벨 인쇄 완료!`);
|
||||
} else {
|
||||
alert(`⚠️ 인쇄 완료: ${printedCount}개\n실패: ${failedCount}개`);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@ -3069,5 +3467,190 @@
|
||||
})();
|
||||
|
||||
</script>
|
||||
|
||||
<!-- 건조시럽 환산계수 모달 -->
|
||||
<div id="drysyrupModal" class="drysyrup-modal">
|
||||
<div class="drysyrup-modal-content">
|
||||
<div class="drysyrup-modal-header">
|
||||
<h3>🧪 건조시럽 환산계수</h3>
|
||||
<button class="drysyrup-modal-close" onclick="closeDrysyrupModal()">×</button>
|
||||
</div>
|
||||
<div class="drysyrup-modal-body">
|
||||
<div class="drysyrup-form">
|
||||
<div class="drysyrup-form-row">
|
||||
<label>성분코드</label>
|
||||
<input type="text" id="drysyrup_sung_code" readonly class="readonly">
|
||||
</div>
|
||||
<div class="drysyrup-form-row">
|
||||
<label>성분명</label>
|
||||
<input type="text" id="drysyrup_ingredient_name" placeholder="예: 아목시실린">
|
||||
</div>
|
||||
<div class="drysyrup-form-row">
|
||||
<label>제품명 <span style="font-size:0.75rem;color:#6b7280;">(MSSQL 원본)</span></label>
|
||||
<input type="text" id="drysyrup_product_name" placeholder="예: 오구멘틴듀오시럽" readonly style="background:#f3f4f6;cursor:not-allowed;">
|
||||
</div>
|
||||
<div class="drysyrup-form-row">
|
||||
<label>환산계수 (g/ml)</label>
|
||||
<input type="number" id="drysyrup_conversion_factor" step="0.001" placeholder="예: 0.11">
|
||||
<span class="hint">ml × 환산계수 = g</span>
|
||||
</div>
|
||||
<div class="drysyrup-form-row">
|
||||
<label>조제 후 함량</label>
|
||||
<input type="text" id="drysyrup_post_prep_amount" placeholder="예: 4.8mg/ml">
|
||||
</div>
|
||||
<div class="drysyrup-form-row">
|
||||
<label>분말 중 주성분량</label>
|
||||
<input type="text" id="drysyrup_main_ingredient_amt" placeholder="예: 0.787g/100g">
|
||||
</div>
|
||||
<div class="drysyrup-form-row">
|
||||
<label>보관조건</label>
|
||||
<select id="drysyrup_storage_conditions">
|
||||
<option value="실온">실온</option>
|
||||
<option value="냉장">냉장</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="drysyrup-form-row">
|
||||
<label>조제 후 유효기간</label>
|
||||
<input type="text" id="drysyrup_expiration_date" placeholder="예: 15일">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drysyrup-modal-footer">
|
||||
<span id="drysyrup_status" class="status-text"></span>
|
||||
<div class="button-group">
|
||||
<button class="btn-cancel" onclick="closeDrysyrupModal()">취소</button>
|
||||
<button class="btn-save" onclick="saveDrysyrup()">💾 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ==================== 건조시럽 환산계수 모달 ====================
|
||||
let drysyrupIsNew = false;
|
||||
|
||||
// 약품명 더블클릭 이벤트 등록
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 동적으로 생성되는 요소를 위해 이벤트 위임 사용
|
||||
document.addEventListener('dblclick', function(e) {
|
||||
// 약품 행(tr)에서 더블클릭 감지
|
||||
const row = e.target.closest('tr[data-sung-code]');
|
||||
if (row) {
|
||||
const sungCode = row.dataset.sungCode;
|
||||
const medName = row.dataset.medName || '';
|
||||
if (sungCode) {
|
||||
openDrysyrupModal(sungCode, medName);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 모달 열기
|
||||
async function openDrysyrupModal(sungCode, medName) {
|
||||
const modal = document.getElementById('drysyrupModal');
|
||||
const statusEl = document.getElementById('drysyrup_status');
|
||||
|
||||
// 폼 초기화
|
||||
document.getElementById('drysyrup_sung_code').value = sungCode;
|
||||
document.getElementById('drysyrup_ingredient_name').value = '';
|
||||
document.getElementById('drysyrup_product_name').value = medName || '';
|
||||
document.getElementById('drysyrup_conversion_factor').value = '';
|
||||
document.getElementById('drysyrup_post_prep_amount').value = '';
|
||||
document.getElementById('drysyrup_main_ingredient_amt').value = '';
|
||||
document.getElementById('drysyrup_storage_conditions').value = '실온';
|
||||
document.getElementById('drysyrup_expiration_date').value = '';
|
||||
statusEl.textContent = '로딩 중...';
|
||||
|
||||
modal.classList.add('show');
|
||||
|
||||
// API 호출
|
||||
try {
|
||||
const resp = await fetch('/api/drug-info/drysyrup/' + encodeURIComponent(sungCode));
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.exists) {
|
||||
// 기존 데이터 채우기
|
||||
document.getElementById('drysyrup_ingredient_name').value = data.ingredient_name || '';
|
||||
document.getElementById('drysyrup_product_name').value = data.product_name || '';
|
||||
document.getElementById('drysyrup_conversion_factor').value = data.conversion_factor || '';
|
||||
document.getElementById('drysyrup_post_prep_amount').value = data.post_prep_amount || '';
|
||||
document.getElementById('drysyrup_main_ingredient_amt').value = data.main_ingredient_amt || '';
|
||||
document.getElementById('drysyrup_storage_conditions').value = data.storage_conditions || '실온';
|
||||
document.getElementById('drysyrup_expiration_date').value = data.expiration_date || '';
|
||||
statusEl.textContent = '✅ 등록된 데이터';
|
||||
drysyrupIsNew = false;
|
||||
} else {
|
||||
statusEl.textContent = '🆕 신규 등록';
|
||||
drysyrupIsNew = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('드라이시럽 조회 오류:', err);
|
||||
statusEl.textContent = '⚠️ 조회 실패 (신규 등록 가능)';
|
||||
drysyrupIsNew = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
function closeDrysyrupModal() {
|
||||
document.getElementById('drysyrupModal').classList.remove('show');
|
||||
}
|
||||
|
||||
// 저장
|
||||
async function saveDrysyrup() {
|
||||
const sungCode = document.getElementById('drysyrup_sung_code').value;
|
||||
const statusEl = document.getElementById('drysyrup_status');
|
||||
|
||||
const data = {
|
||||
sung_code: sungCode,
|
||||
ingredient_name: document.getElementById('drysyrup_ingredient_name').value,
|
||||
product_name: document.getElementById('drysyrup_product_name').value,
|
||||
conversion_factor: parseFloat(document.getElementById('drysyrup_conversion_factor').value) || null,
|
||||
post_prep_amount: document.getElementById('drysyrup_post_prep_amount').value,
|
||||
main_ingredient_amt: document.getElementById('drysyrup_main_ingredient_amt').value,
|
||||
storage_conditions: document.getElementById('drysyrup_storage_conditions').value,
|
||||
expiration_date: document.getElementById('drysyrup_expiration_date').value
|
||||
};
|
||||
|
||||
statusEl.textContent = '저장 중...';
|
||||
|
||||
try {
|
||||
const url = drysyrupIsNew ? '/api/drug-info/drysyrup' : '/api/drug-info/drysyrup/' + encodeURIComponent(sungCode);
|
||||
const method = drysyrupIsNew ? 'POST' : 'PUT';
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
|
||||
if (result.success) {
|
||||
statusEl.textContent = '✅ 저장 완료!';
|
||||
window.showToast && window.showToast('환산계수 저장 완료', 'success');
|
||||
setTimeout(closeDrysyrupModal, 1000);
|
||||
} else {
|
||||
statusEl.textContent = '❌ ' + (result.error || '저장 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('드라이시럽 저장 오류:', err);
|
||||
statusEl.textContent = '❌ 저장 오류';
|
||||
}
|
||||
}
|
||||
|
||||
// ESC 키로 모달 닫기
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeDrysyrupModal();
|
||||
}
|
||||
});
|
||||
|
||||
// 모달 바깥 클릭시 닫기
|
||||
document.getElementById('drysyrupModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeDrysyrupModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
49
backend/test_chatbot_api.py
Normal file
49
backend/test_chatbot_api.py
Normal file
@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""실제 챗봇 API 테스트"""
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
|
||||
API_URL = "http://localhost:7001/api/animal-chat"
|
||||
|
||||
questions = [
|
||||
"우리 강아지가 피부에 뭐가 났어요. 빨갛고 진물이 나요",
|
||||
"고양이 심장사상충 예방약 뭐가 좋아요?",
|
||||
"개시딘 어떻게 사용해요?",
|
||||
"강아지가 구토를 해요 약 있나요?",
|
||||
"진드기 예방약 추천해주세요",
|
||||
]
|
||||
|
||||
print("=" * 70)
|
||||
print("🐾 동물의약품 챗봇 API 테스트")
|
||||
print("=" * 70)
|
||||
|
||||
for q in questions:
|
||||
print(f"\n💬 질문: {q}")
|
||||
print("-" * 50)
|
||||
|
||||
try:
|
||||
start = time.time()
|
||||
resp = requests.post(API_URL, json={
|
||||
"messages": [{"role": "user", "content": q}]
|
||||
}, timeout=30)
|
||||
elapsed = time.time() - start
|
||||
|
||||
data = resp.json()
|
||||
if data.get("success"):
|
||||
msg = data.get("message", "")
|
||||
products = data.get("products", [])
|
||||
|
||||
# 응답 앞부분만
|
||||
print(f"🤖 응답 ({elapsed:.1f}초):")
|
||||
print(msg[:500] + "..." if len(msg) > 500 else msg)
|
||||
|
||||
if products:
|
||||
print(f"\n📦 추천 제품: {', '.join([p['name'] for p in products[:3]])}")
|
||||
else:
|
||||
print(f"❌ 에러: {data.get('message')}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 요청 실패: {e}")
|
||||
|
||||
print()
|
||||
21
backend/test_chroma.py
Normal file
21
backend/test_chroma.py
Normal file
@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
import chromadb
|
||||
|
||||
print('1. creating client...', flush=True)
|
||||
client = chromadb.PersistentClient(path='./db/chroma_test3')
|
||||
print('2. client created', flush=True)
|
||||
|
||||
# 임베딩 없이 컬렉션 생성
|
||||
col = client.get_or_create_collection('test3')
|
||||
print('3. collection created (no ef)', flush=True)
|
||||
|
||||
col.add(ids=['1'], documents=['hello world'], embeddings=[[0.1]*384])
|
||||
print('4. document added with manual embedding', flush=True)
|
||||
|
||||
result = col.query(query_embeddings=[[0.1]*384], n_results=1)
|
||||
print(f'5. query result: {len(result["documents"][0])} docs', flush=True)
|
||||
print('Done!')
|
||||
32
backend/test_final.py
Normal file
32
backend/test_final.py
Normal file
@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""개선된 RAG 테스트"""
|
||||
import importlib
|
||||
import utils.animal_rag
|
||||
importlib.reload(utils.animal_rag)
|
||||
|
||||
rag = utils.animal_rag.AnimalDrugRAG()
|
||||
|
||||
queries = [
|
||||
'가이시딘',
|
||||
'개시딘',
|
||||
'개시딘 피부염',
|
||||
'심장사상충 예방약',
|
||||
'강아지 구토약',
|
||||
'고양이 귀진드기',
|
||||
'넥스가드',
|
||||
'후시딘 동물용',
|
||||
]
|
||||
|
||||
print("=" * 70)
|
||||
print("🎯 개선된 RAG 테스트 (prefix 추가 후)")
|
||||
print("=" * 70)
|
||||
|
||||
for q in queries:
|
||||
results = rag.search(q)
|
||||
print(f'\n🔍 "{q}" - {len(results)}개 결과')
|
||||
for r in results[:3]: # 상위 3개만
|
||||
product = r.get('product_name', '')[:20] if 'product_name' in r else ''
|
||||
print(f" [{r['score']:.0%}] {r['source'][:35]}")
|
||||
# 청크 prefix 확인
|
||||
text_preview = r['text'][:80].replace('\n', ' ')
|
||||
print(f" → {text_preview}...")
|
||||
27
backend/test_rag.py
Normal file
27
backend/test_rag.py
Normal file
@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
print("1. Starting...")
|
||||
print(f" CWD: {os.getcwd()}")
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
print(f"2. API Key: {os.getenv('OPENAI_API_KEY', 'NOT SET')[:20]}...")
|
||||
|
||||
from utils.animal_rag import AnimalDrugRAG
|
||||
print("3. Module imported")
|
||||
|
||||
rag = AnimalDrugRAG()
|
||||
print("4. RAG created")
|
||||
|
||||
try:
|
||||
count = rag.index_md_files()
|
||||
print(f"5. Indexed: {count} chunks")
|
||||
except Exception as e:
|
||||
print(f"5. Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print("6. Done")
|
||||
0
backend/test_rag_output.txt
Normal file
0
backend/test_rag_output.txt
Normal file
33
backend/test_rag_quality.py
Normal file
33
backend/test_rag_quality.py
Normal file
@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from utils.animal_rag import get_animal_rag
|
||||
|
||||
rag = get_animal_rag()
|
||||
|
||||
queries = [
|
||||
'가이시딘 어떻게 써?',
|
||||
'심장사상충 예방약 추천',
|
||||
'고양이 구충제',
|
||||
'강아지 진통제',
|
||||
'귀진드기 약',
|
||||
'피부염 치료',
|
||||
'구토 멈추는 약',
|
||||
'항생제 추천',
|
||||
'넥스가드 용법',
|
||||
'셀라멕틴 스팟온'
|
||||
]
|
||||
|
||||
print("=" * 60)
|
||||
print("RAG 검색 품질 테스트")
|
||||
print("=" * 60)
|
||||
|
||||
for q in queries:
|
||||
results = rag.search(q, n_results=3)
|
||||
print(f'\n🔍 "{q}"')
|
||||
if not results:
|
||||
print(' ❌ 검색 결과 없음 (score < 0.3)')
|
||||
else:
|
||||
for r in results:
|
||||
print(f" [{r['score']:.0%}] {r['source']} - {r['section']}")
|
||||
# 첫 100자 미리보기
|
||||
preview = r['text'][:100].replace('\n', ' ')
|
||||
print(f" → {preview}...")
|
||||
18
backend/test_rag_search.py
Normal file
18
backend/test_rag_search.py
Normal file
@ -0,0 +1,18 @@
|
||||
from utils.animal_rag import get_animal_rag
|
||||
|
||||
rag = get_animal_rag()
|
||||
|
||||
# 테스트 쿼리
|
||||
queries = [
|
||||
"피부 붉고 염증",
|
||||
"피부염 치료",
|
||||
"피부 발적 연고",
|
||||
]
|
||||
|
||||
for query in queries:
|
||||
print(f"\n=== 검색: {query} ===")
|
||||
results = rag.search(query, n_results=5)
|
||||
if not results:
|
||||
print(" (결과 없음)")
|
||||
for r in results:
|
||||
print(f" [{r['score']:.0%}] {r['source']} - {r['section']}")
|
||||
26
backend/test_skincasol.py
Normal file
26
backend/test_skincasol.py
Normal file
@ -0,0 +1,26 @@
|
||||
from utils.animal_rag import get_animal_rag
|
||||
|
||||
rag = get_animal_rag()
|
||||
|
||||
queries = [
|
||||
"스킨카솔",
|
||||
"센텔라",
|
||||
"피부 재생",
|
||||
"피부 보호",
|
||||
"피부 진정",
|
||||
"상처 회복",
|
||||
"피부 케어",
|
||||
"습진",
|
||||
"아토피",
|
||||
"티트리오일",
|
||||
]
|
||||
|
||||
for query in queries:
|
||||
results = rag.search(query, n_results=3)
|
||||
has_skincasol = any("skincasol" in r["source"].lower() for r in results)
|
||||
mark = "O" if has_skincasol else "X"
|
||||
print(f"[{mark}] {query}")
|
||||
if has_skincasol:
|
||||
for r in results:
|
||||
if "skincasol" in r["source"].lower():
|
||||
print(f" -> {r['score']:.0%} {r['section']}")
|
||||
31
backend/test_threshold.py
Normal file
31
backend/test_threshold.py
Normal file
@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""임계값 없이 raw 검색 결과 확인"""
|
||||
from utils.animal_rag import get_animal_rag
|
||||
|
||||
rag = get_animal_rag()
|
||||
rag._init_db()
|
||||
|
||||
queries = [
|
||||
'가이시딘', # 오타 버전
|
||||
'개시딘', # 정확한 이름
|
||||
'개시딘 겔',
|
||||
'피부 농피증',
|
||||
'후시딘', # 사람용 약 이름으로 검색
|
||||
]
|
||||
|
||||
print("=" * 70)
|
||||
print("임계값 제거 후 RAW 검색 결과 (상위 5개)")
|
||||
print("=" * 70)
|
||||
|
||||
for q in queries:
|
||||
# 임계값 없이 raw 검색
|
||||
query_emb = rag._get_embedding(q)
|
||||
results = rag.table.search(query_emb).limit(5).to_list()
|
||||
|
||||
print(f'\n🔍 "{q}"')
|
||||
for r in results:
|
||||
distance = r.get("_distance", 10)
|
||||
score = 1 / (1 + distance)
|
||||
source = r["source"]
|
||||
section = r["section"]
|
||||
print(f" [{score:.1%}] (dist:{distance:.2f}) {source} - {section}")
|
||||
28
backend/test_tuned.py
Normal file
28
backend/test_tuned.py
Normal file
@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""튜닝 후 테스트 (기본값 사용)"""
|
||||
from utils.animal_rag import get_animal_rag
|
||||
|
||||
# 새로 import해서 변경사항 적용
|
||||
import importlib
|
||||
import utils.animal_rag
|
||||
importlib.reload(utils.animal_rag)
|
||||
|
||||
rag = utils.animal_rag.get_animal_rag()
|
||||
|
||||
queries = [
|
||||
'가이시딘',
|
||||
'개시딘 피부염',
|
||||
'심장사상충 예방약',
|
||||
'강아지 구토약',
|
||||
]
|
||||
|
||||
print("=" * 60)
|
||||
print("튜닝 후 테스트 (n_results=5, threshold=0.2)")
|
||||
print("=" * 60)
|
||||
|
||||
for q in queries:
|
||||
# 기본값 사용 (n_results=5)
|
||||
results = rag.search(q)
|
||||
print(f'\n🔍 "{q}" - {len(results)}개 결과')
|
||||
for r in results:
|
||||
print(f" [{r['score']:.0%}] {r['source'][:30]} - {r['section'][:20]}")
|
||||
21
backend/test_vomit.py
Normal file
21
backend/test_vomit.py
Normal file
@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import importlib
|
||||
import utils.animal_rag as ar
|
||||
importlib.reload(ar)
|
||||
|
||||
rag = ar.AnimalDrugRAG()
|
||||
rag._init_db()
|
||||
|
||||
queries = ['구토 멈추는 약', '구토 치료제', '마로피턴트', '세레니아']
|
||||
|
||||
for q in queries:
|
||||
query_emb = rag._get_embedding(q)
|
||||
results = rag.table.search(query_emb).limit(5).to_list()
|
||||
|
||||
print(f'\n🔍 "{q}"')
|
||||
for r in results:
|
||||
dist = r.get('_distance', 10)
|
||||
score = 1 / (1 + dist)
|
||||
source = r['source'][:40]
|
||||
section = r['section'][:25]
|
||||
print(f' [{score:.0%}] {source} - {section}')
|
||||
277
backend/utils/animal_chat_logger.py
Normal file
277
backend/utils/animal_chat_logger.py
Normal file
@ -0,0 +1,277 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
동물약 챗봇 로깅 모듈
|
||||
- SQLite에 대화 로그 저장
|
||||
- 각 단계별 소요시간, 토큰, 비용 기록
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass, field, asdict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# DB 경로
|
||||
DB_PATH = Path(__file__).parent.parent / "db" / "animal_chat_logs.db"
|
||||
SCHEMA_PATH = Path(__file__).parent.parent / "db" / "animal_chat_logs_schema.sql"
|
||||
|
||||
# GPT-4o-mini 가격 (USD per 1K tokens)
|
||||
INPUT_COST_PER_1K = 0.00015 # $0.15 / 1M = $0.00015 / 1K
|
||||
OUTPUT_COST_PER_1K = 0.0006 # $0.60 / 1M = $0.0006 / 1K
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatLogEntry:
|
||||
"""챗봇 로그 엔트리"""
|
||||
session_id: str = ""
|
||||
|
||||
# 입력
|
||||
user_message: str = ""
|
||||
history_length: int = 0
|
||||
|
||||
# MSSQL
|
||||
mssql_drug_count: int = 0
|
||||
mssql_duration_ms: int = 0
|
||||
|
||||
# PostgreSQL
|
||||
pgsql_rag_count: int = 0
|
||||
pgsql_duration_ms: int = 0
|
||||
|
||||
# LanceDB
|
||||
vector_results_count: int = 0
|
||||
vector_top_scores: List[float] = field(default_factory=list)
|
||||
vector_sources: List[str] = field(default_factory=list)
|
||||
vector_duration_ms: int = 0
|
||||
|
||||
# OpenAI
|
||||
openai_model: str = ""
|
||||
openai_prompt_tokens: int = 0
|
||||
openai_completion_tokens: int = 0
|
||||
openai_total_tokens: int = 0
|
||||
openai_cost_usd: float = 0.0
|
||||
openai_duration_ms: int = 0
|
||||
|
||||
# 출력
|
||||
assistant_response: str = ""
|
||||
products_mentioned: List[str] = field(default_factory=list)
|
||||
|
||||
# 메타
|
||||
total_duration_ms: int = 0
|
||||
error: str = ""
|
||||
|
||||
def calculate_cost(self):
|
||||
"""토큰 기반 비용 계산"""
|
||||
self.openai_cost_usd = (
|
||||
self.openai_prompt_tokens * INPUT_COST_PER_1K / 1000 +
|
||||
self.openai_completion_tokens * OUTPUT_COST_PER_1K / 1000
|
||||
)
|
||||
|
||||
|
||||
def init_db():
|
||||
"""DB 초기화 (테이블 생성)"""
|
||||
try:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
|
||||
if SCHEMA_PATH.exists():
|
||||
schema = SCHEMA_PATH.read_text(encoding='utf-8')
|
||||
conn.executescript(schema)
|
||||
else:
|
||||
# 스키마 파일 없으면 인라인 생성
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS chat_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
user_message TEXT,
|
||||
history_length INTEGER,
|
||||
mssql_drug_count INTEGER,
|
||||
mssql_duration_ms INTEGER,
|
||||
pgsql_rag_count INTEGER,
|
||||
pgsql_duration_ms INTEGER,
|
||||
vector_results_count INTEGER,
|
||||
vector_top_scores TEXT,
|
||||
vector_sources TEXT,
|
||||
vector_duration_ms INTEGER,
|
||||
openai_model TEXT,
|
||||
openai_prompt_tokens INTEGER,
|
||||
openai_completion_tokens INTEGER,
|
||||
openai_total_tokens INTEGER,
|
||||
openai_cost_usd REAL,
|
||||
openai_duration_ms INTEGER,
|
||||
assistant_response TEXT,
|
||||
products_mentioned TEXT,
|
||||
total_duration_ms INTEGER,
|
||||
error TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_created ON chat_logs(created_at);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"동물약 챗봇 로그 DB 초기화: {DB_PATH}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DB 초기화 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def log_chat(entry: ChatLogEntry) -> Optional[int]:
|
||||
"""
|
||||
챗봇 대화 로그 저장
|
||||
|
||||
Returns:
|
||||
저장된 로그 ID (실패시 None)
|
||||
"""
|
||||
try:
|
||||
# 비용 계산
|
||||
entry.calculate_cost()
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO chat_logs (
|
||||
session_id, user_message, history_length,
|
||||
mssql_drug_count, mssql_duration_ms,
|
||||
pgsql_rag_count, pgsql_duration_ms,
|
||||
vector_results_count, vector_top_scores, vector_sources, vector_duration_ms,
|
||||
openai_model, openai_prompt_tokens, openai_completion_tokens,
|
||||
openai_total_tokens, openai_cost_usd, openai_duration_ms,
|
||||
assistant_response, products_mentioned,
|
||||
total_duration_ms, error
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
entry.session_id,
|
||||
entry.user_message,
|
||||
entry.history_length,
|
||||
entry.mssql_drug_count,
|
||||
entry.mssql_duration_ms,
|
||||
entry.pgsql_rag_count,
|
||||
entry.pgsql_duration_ms,
|
||||
entry.vector_results_count,
|
||||
json.dumps(entry.vector_top_scores),
|
||||
json.dumps(entry.vector_sources),
|
||||
entry.vector_duration_ms,
|
||||
entry.openai_model,
|
||||
entry.openai_prompt_tokens,
|
||||
entry.openai_completion_tokens,
|
||||
entry.openai_total_tokens,
|
||||
entry.openai_cost_usd,
|
||||
entry.openai_duration_ms,
|
||||
entry.assistant_response,
|
||||
json.dumps(entry.products_mentioned),
|
||||
entry.total_duration_ms,
|
||||
entry.error
|
||||
))
|
||||
|
||||
log_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.debug(f"챗봇 로그 저장: ID={log_id}, tokens={entry.openai_total_tokens}")
|
||||
return log_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"로그 저장 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_logs(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
date_from: str = None,
|
||||
date_to: str = None,
|
||||
error_only: bool = False
|
||||
) -> List[Dict]:
|
||||
"""로그 조회"""
|
||||
try:
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM chat_logs WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if date_from:
|
||||
query += " AND created_at >= ?"
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
query += " AND created_at <= ?"
|
||||
params.append(date_to + " 23:59:59")
|
||||
if error_only:
|
||||
query += " AND error IS NOT NULL AND error != ''"
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"로그 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_stats(date_from: str = None, date_to: str = None) -> Dict:
|
||||
"""통계 조회"""
|
||||
try:
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) as total_chats,
|
||||
AVG(total_duration_ms) as avg_duration_ms,
|
||||
SUM(openai_total_tokens) as total_tokens,
|
||||
SUM(openai_cost_usd) as total_cost_usd,
|
||||
AVG(openai_total_tokens) as avg_tokens,
|
||||
SUM(CASE WHEN error IS NOT NULL AND error != '' THEN 1 ELSE 0 END) as error_count,
|
||||
AVG(vector_duration_ms) as avg_vector_ms,
|
||||
AVG(openai_duration_ms) as avg_openai_ms
|
||||
FROM chat_logs
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
|
||||
if date_from:
|
||||
query += " AND created_at >= ?"
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
query += " AND created_at <= ?"
|
||||
params.append(date_to + " 23:59:59")
|
||||
|
||||
cursor.execute(query, params)
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
return {
|
||||
'total_chats': row[0] or 0,
|
||||
'avg_duration_ms': round(row[1] or 0),
|
||||
'total_tokens': row[2] or 0,
|
||||
'total_cost_usd': round(row[3] or 0, 4),
|
||||
'avg_tokens': round(row[4] or 0),
|
||||
'error_count': row[5] or 0,
|
||||
'avg_vector_ms': round(row[6] or 0),
|
||||
'avg_openai_ms': round(row[7] or 0)
|
||||
}
|
||||
return {}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"통계 조회 실패: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
# 모듈 로드 시 DB 초기화
|
||||
init_db()
|
||||
402
backend/utils/animal_rag.py
Normal file
402
backend/utils/animal_rag.py
Normal file
@ -0,0 +1,402 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
동물약 벡터 DB RAG 모듈
|
||||
- LanceDB + OpenAI text-embedding-3-small
|
||||
- MD 파일 청킹 및 임베딩
|
||||
- 유사도 검색
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
# .env 로드
|
||||
from dotenv import load_dotenv
|
||||
env_path = Path(__file__).parent.parent / ".env"
|
||||
load_dotenv(env_path)
|
||||
|
||||
# LanceDB
|
||||
import lancedb
|
||||
from openai import OpenAI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 설정
|
||||
LANCE_DB_PATH = Path(__file__).parent.parent / "db" / "lance_animal_drugs"
|
||||
MD_DOCS_PATH = Path("C:/Users/청춘약국/source/new_anipharm")
|
||||
TABLE_NAME = "animal_drugs"
|
||||
CHUNK_SIZE = 1500 # 약 500 토큰
|
||||
CHUNK_OVERLAP = 300 # 약 100 토큰
|
||||
EMBEDDING_DIM = 1536 # text-embedding-3-small
|
||||
|
||||
|
||||
class AnimalDrugRAG:
|
||||
"""동물약 RAG 클래스 (LanceDB 버전)"""
|
||||
|
||||
def __init__(self, openai_api_key: str = None):
|
||||
"""
|
||||
Args:
|
||||
openai_api_key: OpenAI API 키 (없으면 환경변수에서 가져옴)
|
||||
"""
|
||||
self.api_key = openai_api_key or os.getenv('OPENAI_API_KEY')
|
||||
self.db = None
|
||||
self.table = None
|
||||
self.openai_client = None
|
||||
self._initialized = False
|
||||
|
||||
def _init_db(self):
|
||||
"""DB 초기화 (lazy loading)"""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
# LanceDB 연결
|
||||
LANCE_DB_PATH.mkdir(parents=True, exist_ok=True)
|
||||
self.db = lancedb.connect(str(LANCE_DB_PATH))
|
||||
|
||||
# OpenAI 클라이언트
|
||||
if self.api_key:
|
||||
self.openai_client = OpenAI(api_key=self.api_key)
|
||||
else:
|
||||
logger.warning("OpenAI API 키 없음")
|
||||
|
||||
# 기존 테이블 열기
|
||||
if TABLE_NAME in self.db.table_names():
|
||||
self.table = self.db.open_table(TABLE_NAME)
|
||||
logger.info(f"기존 테이블 열림 (행 수: {len(self.table)})")
|
||||
else:
|
||||
logger.info("테이블 없음 - index_md_files() 호출 필요")
|
||||
|
||||
self._initialized = True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AnimalDrugRAG 초기화 실패: {e}")
|
||||
raise
|
||||
|
||||
def _get_embedding(self, text: str) -> List[float]:
|
||||
"""OpenAI 임베딩 생성"""
|
||||
if not self.openai_client:
|
||||
raise ValueError("OpenAI 클라이언트 없음")
|
||||
|
||||
response = self.openai_client.embeddings.create(
|
||||
model="text-embedding-3-small",
|
||||
input=text
|
||||
)
|
||||
return response.data[0].embedding
|
||||
|
||||
def _get_embeddings_batch(self, texts: List[str]) -> List[List[float]]:
|
||||
"""배치 임베딩 생성"""
|
||||
if not self.openai_client:
|
||||
raise ValueError("OpenAI 클라이언트 없음")
|
||||
|
||||
# OpenAI는 한 번에 최대 2048개 텍스트 처리
|
||||
embeddings = []
|
||||
batch_size = 100
|
||||
|
||||
for i in range(0, len(texts), batch_size):
|
||||
batch = texts[i:i+batch_size]
|
||||
response = self.openai_client.embeddings.create(
|
||||
model="text-embedding-3-small",
|
||||
input=batch
|
||||
)
|
||||
embeddings.extend([d.embedding for d in response.data])
|
||||
logger.info(f"임베딩 생성: {i+len(batch)}/{len(texts)}")
|
||||
|
||||
return embeddings
|
||||
|
||||
def _extract_product_info(self, content: str) -> Dict[str, str]:
|
||||
"""
|
||||
MD 파일 상단에서 제품 정보 추출
|
||||
- 제품명 (한글/영문)
|
||||
- 성분
|
||||
- 대상 동물
|
||||
"""
|
||||
info = {"product_name": "", "ingredients": "", "target_animal": ""}
|
||||
|
||||
# # 제목에서 제품명 추출 (예: "# 복합 개시딘 겔 - 표면성...")
|
||||
title_match = re.search(r'^# (.+?)(?:\s*[-–—]|$)', content, re.MULTILINE)
|
||||
if title_match:
|
||||
info["product_name"] = title_match.group(1).strip()
|
||||
|
||||
# > 성분: 라인에서 추출
|
||||
ingredient_match = re.search(r'>\s*성분[:\s]+(.+?)(?:\n|$)', content)
|
||||
if ingredient_match:
|
||||
info["ingredients"] = ingredient_match.group(1).strip()[:100] # 100자 제한
|
||||
|
||||
# 대상 동물 추출 (테이블에서)
|
||||
animal_match = re.search(r'\*\*대상\s*동물\*\*[^\|]*\|\s*([^\|]+)', content)
|
||||
if animal_match:
|
||||
info["target_animal"] = animal_match.group(1).strip()
|
||||
|
||||
return info
|
||||
|
||||
def _make_chunk_prefix(self, product_info: Dict[str, str]) -> str:
|
||||
"""청크 prefix 생성"""
|
||||
parts = []
|
||||
if product_info["product_name"]:
|
||||
parts.append(f"제품명: {product_info['product_name']}")
|
||||
if product_info["target_animal"]:
|
||||
parts.append(f"대상: {product_info['target_animal']}")
|
||||
if product_info["ingredients"]:
|
||||
parts.append(f"성분: {product_info['ingredients']}")
|
||||
|
||||
if parts:
|
||||
return "[" + " | ".join(parts) + "]\n\n"
|
||||
return ""
|
||||
|
||||
def chunk_markdown(self, content: str, source_file: str) -> List[Dict]:
|
||||
"""
|
||||
마크다운 청킹 (섹션 기반 + 제품명 prefix)
|
||||
"""
|
||||
chunks = []
|
||||
|
||||
# 제품 정보 추출 & prefix 생성
|
||||
product_info = self._extract_product_info(content)
|
||||
prefix = self._make_chunk_prefix(product_info)
|
||||
|
||||
# ## 헤더 기준 분리
|
||||
sections = re.split(r'\n(?=## )', content)
|
||||
|
||||
for i, section in enumerate(sections):
|
||||
if not section.strip():
|
||||
continue
|
||||
|
||||
# 섹션 제목 추출
|
||||
title_match = re.match(r'^## (.+?)(?:\n|$)', section)
|
||||
section_title = title_match.group(1).strip() if title_match else f"섹션{i+1}"
|
||||
|
||||
# prefix + section 결합
|
||||
prefixed_section = prefix + section
|
||||
|
||||
# 큰 섹션은 추가 분할
|
||||
if len(prefixed_section) > CHUNK_SIZE:
|
||||
sub_chunks = self._split_by_size(prefixed_section, CHUNK_SIZE, CHUNK_OVERLAP)
|
||||
for j, sub_chunk in enumerate(sub_chunks):
|
||||
# 분할된 청크에도 prefix 보장 (overlap으로 잘렸을 경우)
|
||||
if j > 0 and not sub_chunk.startswith("["):
|
||||
sub_chunk = prefix + sub_chunk
|
||||
chunk_id = f"{source_file}#{section_title}#{j}"
|
||||
chunks.append({
|
||||
"id": chunk_id,
|
||||
"text": sub_chunk,
|
||||
"source": source_file,
|
||||
"section": section_title,
|
||||
"chunk_index": j,
|
||||
"product_name": product_info["product_name"]
|
||||
})
|
||||
else:
|
||||
chunk_id = f"{source_file}#{section_title}"
|
||||
chunks.append({
|
||||
"id": chunk_id,
|
||||
"text": prefixed_section,
|
||||
"source": source_file,
|
||||
"section": section_title,
|
||||
"chunk_index": 0,
|
||||
"product_name": product_info["product_name"]
|
||||
})
|
||||
|
||||
return chunks
|
||||
|
||||
def _split_by_size(self, text: str, size: int, overlap: int) -> List[str]:
|
||||
"""텍스트를 크기 기준으로 분할"""
|
||||
chunks = []
|
||||
start = 0
|
||||
|
||||
while start < len(text):
|
||||
end = start + size
|
||||
|
||||
# 문장 경계에서 자르기
|
||||
if end < len(text):
|
||||
last_break = text.rfind('\n', start, end)
|
||||
if last_break == -1:
|
||||
last_break = text.rfind('. ', start, end)
|
||||
if last_break > start + size // 2:
|
||||
end = last_break + 1
|
||||
|
||||
chunks.append(text[start:end])
|
||||
start = end - overlap
|
||||
|
||||
return chunks
|
||||
|
||||
def index_md_files(self, md_path: Path = None) -> int:
|
||||
"""
|
||||
MD 파일들을 인덱싱
|
||||
"""
|
||||
self._init_db()
|
||||
|
||||
md_path = md_path or MD_DOCS_PATH
|
||||
if not md_path.exists():
|
||||
logger.error(f"MD 파일 경로 없음: {md_path}")
|
||||
return 0
|
||||
|
||||
# 기존 테이블 삭제
|
||||
if TABLE_NAME in self.db.table_names():
|
||||
self.db.drop_table(TABLE_NAME)
|
||||
logger.info("기존 테이블 삭제")
|
||||
|
||||
# 모든 청크 수집
|
||||
all_chunks = []
|
||||
md_files = list(md_path.glob("*.md"))
|
||||
|
||||
for md_file in md_files:
|
||||
try:
|
||||
content = md_file.read_text(encoding='utf-8')
|
||||
chunks = self.chunk_markdown(content, md_file.name)
|
||||
all_chunks.extend(chunks)
|
||||
logger.info(f"청킹: {md_file.name} ({len(chunks)}개)")
|
||||
except Exception as e:
|
||||
logger.error(f"청킹 실패 ({md_file.name}): {e}")
|
||||
|
||||
if not all_chunks:
|
||||
logger.warning("청크 없음")
|
||||
return 0
|
||||
|
||||
# 임베딩 생성
|
||||
texts = [c["text"] for c in all_chunks]
|
||||
logger.info(f"총 {len(texts)}개 청크 임베딩 시작...")
|
||||
embeddings = self._get_embeddings_batch(texts)
|
||||
|
||||
# 데이터 준비
|
||||
data = []
|
||||
for chunk, emb in zip(all_chunks, embeddings):
|
||||
data.append({
|
||||
"id": chunk["id"],
|
||||
"text": chunk["text"],
|
||||
"source": chunk["source"],
|
||||
"section": chunk["section"],
|
||||
"chunk_index": chunk["chunk_index"],
|
||||
"product_name": chunk.get("product_name", ""),
|
||||
"vector": emb
|
||||
})
|
||||
|
||||
# 테이블 생성
|
||||
self.table = self.db.create_table(TABLE_NAME, data)
|
||||
logger.info(f"인덱싱 완료: {len(data)}개 청크")
|
||||
|
||||
return len(data)
|
||||
|
||||
def search(self, query: str, n_results: int = 5) -> List[Dict]:
|
||||
"""
|
||||
유사도 검색
|
||||
"""
|
||||
self._init_db()
|
||||
|
||||
if self.table is None:
|
||||
logger.warning("테이블 없음 - index_md_files() 필요")
|
||||
return []
|
||||
|
||||
try:
|
||||
# 쿼리 임베딩
|
||||
query_emb = self._get_embedding(query)
|
||||
|
||||
# 검색
|
||||
results = self.table.search(query_emb).limit(n_results).to_list()
|
||||
|
||||
output = []
|
||||
for r in results:
|
||||
# L2 거리 (0~∞) → 유사도 (1~0)
|
||||
# 거리가 작을수록 유사도 높음
|
||||
distance = r.get("_distance", 10)
|
||||
score = 1 / (1 + distance) # 0~1 범위로 변환
|
||||
|
||||
# 임계값: 유사도 0.2 미만은 제외 (관련 없는 문서)
|
||||
# L2 거리 4.0 이상이면 제외
|
||||
if score < 0.2:
|
||||
continue
|
||||
|
||||
output.append({
|
||||
"text": r["text"],
|
||||
"source": r["source"],
|
||||
"section": r["section"],
|
||||
"score": score
|
||||
})
|
||||
|
||||
return output
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"검색 실패: {e}")
|
||||
return []
|
||||
|
||||
def get_context_for_chat(self, query: str, n_results: int = 3) -> str:
|
||||
"""
|
||||
챗봇용 컨텍스트 생성
|
||||
"""
|
||||
results = self.search(query, n_results)
|
||||
|
||||
if not results:
|
||||
return ""
|
||||
|
||||
context_parts = ["## 📚 관련 문서 (RAG 검색 결과)"]
|
||||
|
||||
for i, r in enumerate(results, 1):
|
||||
source = r["source"].replace(".md", "")
|
||||
section = r["section"]
|
||||
score = r["score"]
|
||||
text = r["text"][:1500]
|
||||
|
||||
context_parts.append(f"\n### [{i}] {source} - {section} (관련도: {score:.0%})")
|
||||
context_parts.append(text)
|
||||
|
||||
return "\n".join(context_parts)
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""통계 정보 반환"""
|
||||
self._init_db()
|
||||
|
||||
count = len(self.table) if self.table else 0
|
||||
return {
|
||||
"table_name": TABLE_NAME,
|
||||
"document_count": count,
|
||||
"db_path": str(LANCE_DB_PATH)
|
||||
}
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
_rag_instance: Optional[AnimalDrugRAG] = None
|
||||
|
||||
|
||||
def get_animal_rag(api_key: str = None) -> AnimalDrugRAG:
|
||||
"""싱글톤 RAG 인스턴스 반환"""
|
||||
global _rag_instance
|
||||
if _rag_instance is None:
|
||||
_rag_instance = AnimalDrugRAG(api_key)
|
||||
return _rag_instance
|
||||
|
||||
|
||||
# CLI 테스트
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
rag = AnimalDrugRAG()
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
cmd = sys.argv[1]
|
||||
|
||||
if cmd == "index":
|
||||
count = rag.index_md_files()
|
||||
print(f"\n✅ {count}개 청크 인덱싱 완료")
|
||||
|
||||
elif cmd == "search" and len(sys.argv) > 2:
|
||||
query = " ".join(sys.argv[2:])
|
||||
results = rag.search(query)
|
||||
print(f"\n🔍 검색: {query}")
|
||||
for r in results:
|
||||
print(f"\n[{r['score']:.0%}] {r['source']} - {r['section']}")
|
||||
print(r['text'][:300] + "...")
|
||||
|
||||
elif cmd == "stats":
|
||||
stats = rag.get_stats()
|
||||
print(f"\n📊 통계:")
|
||||
print(f" - 테이블: {stats['table_name']}")
|
||||
print(f" - 문서 수: {stats['document_count']}")
|
||||
print(f" - DB 경로: {stats['db_path']}")
|
||||
|
||||
else:
|
||||
print("사용법:")
|
||||
print(" python animal_rag.py index # MD 파일 인덱싱")
|
||||
print(" python animal_rag.py search 질문 # 검색")
|
||||
print(" python animal_rag.py stats # 통계")
|
||||
160
backend/utils/drug_unit.py
Normal file
160
backend/utils/drug_unit.py
Normal file
@ -0,0 +1,160 @@
|
||||
"""
|
||||
약품 포장단위 판별 유틸리티
|
||||
SUNG_CODE 기반으로 약품의 단위(정, 캡슐, mL, 포 등)를 판별
|
||||
|
||||
참고: person-lookup-web-local/dev_docs/pharmit_3000db_sung_code.md
|
||||
"""
|
||||
import re
|
||||
|
||||
# FormCode -> 기본 단위 매핑
|
||||
FORM_CODE_UNIT_MAP = {
|
||||
# 정제류
|
||||
'TA': '정', 'TB': '정', 'TC': '정', 'TD': '정', 'TE': '정',
|
||||
'TF': '정', 'TG': '정', 'TH': '정', 'TL': '정', 'TR': '정',
|
||||
|
||||
# 캡슐류
|
||||
'CA': '캡슐', 'CB': '캡슐', 'CC': '캡슐', 'CD': '캡슐', 'CE': '캡슐',
|
||||
'CH': '캡슐', 'CR': '캡슐', 'CS': '캡슐',
|
||||
|
||||
# 과립/산제
|
||||
'GA': '포', 'GB': '포', 'GC': '포', 'GN': '포', 'PD': '포',
|
||||
|
||||
# 액상제
|
||||
'SS': 'mL', 'SY': 'mL', 'LQ': 'mL', 'SI': '앰플',
|
||||
|
||||
# 외용제
|
||||
'EY': '병', 'EN': '병', 'EO': '병', 'OS': '병', 'OO': '튜브',
|
||||
'GT': '포', 'OT': '개', 'OM': '개', 'CT': '개', 'CM': '개',
|
||||
'LT': '개', 'PT': '매', 'PC': '매', 'SP': '병',
|
||||
|
||||
# 좌제/질정
|
||||
'SU': '개', 'VT': '개',
|
||||
|
||||
# 주사제
|
||||
'IN': '바이알', 'IA': '앰플', 'IJ': '바이알', 'IP': '프리필드',
|
||||
|
||||
# 흡입제
|
||||
'IH': '개', 'NE': '앰플',
|
||||
}
|
||||
|
||||
|
||||
def get_drug_unit(goods_name: str, sung_code: str) -> str:
|
||||
"""
|
||||
약품명과 SUNG_CODE를 기반으로 포장단위를 판별
|
||||
|
||||
Args:
|
||||
goods_name: 약품명 (예: "씨투스건조시럽_(0.5g)")
|
||||
sung_code: SUNG_CODE (예: "100701ATB" - 마지막 2자리가 FormCode)
|
||||
|
||||
Returns:
|
||||
포장단위 문자열 (예: "정", "캡슐", "mL", "포" 등)
|
||||
"""
|
||||
if not sung_code or len(sung_code) < 2:
|
||||
return '개' # 기본값
|
||||
|
||||
# FormCode 추출 (SUNG_CODE 마지막 2자리)
|
||||
form_code = sung_code[-2:].upper()
|
||||
|
||||
# 건조시럽(SS) / 시럽(SY) 특수 처리
|
||||
if form_code in ('SS', 'SY'):
|
||||
return _get_syrup_unit(goods_name)
|
||||
|
||||
# 점안액(EY, OS) 특수 처리
|
||||
if form_code in ('EY', 'OS'):
|
||||
return _get_eye_drop_unit(goods_name)
|
||||
|
||||
# 안연고(OO) 특수 처리
|
||||
if form_code == 'OO':
|
||||
if '안연고' in goods_name or '눈연고' in goods_name:
|
||||
return '튜브'
|
||||
return '개'
|
||||
|
||||
# 액제(LQ) 특수 처리
|
||||
if form_code == 'LQ':
|
||||
return _get_liquid_unit(goods_name)
|
||||
|
||||
# 파우더/산제(PD, GN) 특수 처리
|
||||
if form_code in ('PD', 'GN'):
|
||||
return _get_powder_unit(goods_name)
|
||||
|
||||
# 흡입제/스프레이(SI) 특수 처리
|
||||
if form_code == 'SI':
|
||||
if '흡입액' in goods_name or '네뷸' in goods_name:
|
||||
return '앰플'
|
||||
return '개'
|
||||
|
||||
# 기본 매핑에서 찾기
|
||||
return FORM_CODE_UNIT_MAP.get(form_code, '개')
|
||||
|
||||
|
||||
def _get_syrup_unit(goods_name: str) -> str:
|
||||
"""시럽/건조시럽 단위 판별"""
|
||||
# 개별 g 포장: (0.5g), (0.7g) 등 -> 포
|
||||
if re.search(r'\([\d.]+g\)', goods_name):
|
||||
return '포'
|
||||
|
||||
# g/Xg 벌크 패턴 -> g
|
||||
if re.search(r'_\([^)]+/\d+g\)', goods_name):
|
||||
return 'g'
|
||||
|
||||
# 건조시럽/현탁용분말 -> mL
|
||||
if '건조시럽' in goods_name or '현탁용분말' in goods_name:
|
||||
return 'mL'
|
||||
|
||||
# 소용량 mL (5~30mL) -> 포
|
||||
match = re.search(r'[_(/](\d+)mL\)', goods_name, re.IGNORECASE)
|
||||
if match:
|
||||
volume = int(match.group(1))
|
||||
if volume <= 30:
|
||||
return '포'
|
||||
|
||||
return 'mL'
|
||||
|
||||
|
||||
def _get_eye_drop_unit(goods_name: str) -> str:
|
||||
"""점안액 단위 판별"""
|
||||
# 소용량 (1mL 이하) = 일회용 -> 개
|
||||
match = re.search(r'[_/\(]([\d.]+)mL\)', goods_name)
|
||||
if match:
|
||||
try:
|
||||
volume = float(match.group(1))
|
||||
if volume <= 1.0:
|
||||
return '개'
|
||||
except ValueError:
|
||||
pass
|
||||
return '병'
|
||||
|
||||
|
||||
def _get_liquid_unit(goods_name: str) -> str:
|
||||
"""액제 단위 판별"""
|
||||
# 알긴산/거드액 -> 포
|
||||
if '알긴' in goods_name or '거드' in goods_name:
|
||||
return '포'
|
||||
|
||||
# 외용액 -> 병
|
||||
if any(k in goods_name for k in ['외용', '네일', '라카', '베이트', '더마톱', '라미실']):
|
||||
return '병'
|
||||
|
||||
# 점이/점비액 -> 병
|
||||
if '점비' in goods_name or '이용액' in goods_name:
|
||||
return '병'
|
||||
|
||||
# 흡입액 -> 앰플
|
||||
if '흡입' in goods_name or '네뷸' in goods_name:
|
||||
return '앰플'
|
||||
|
||||
return 'mL'
|
||||
|
||||
|
||||
def _get_powder_unit(goods_name: str) -> str:
|
||||
"""파우더/산제 단위 판별"""
|
||||
# 분모 10g 이상 = 벌크 -> g
|
||||
match = re.search(r'_\([^)]+/(\d+(?:\.\d+)?)g\)', goods_name)
|
||||
if match:
|
||||
try:
|
||||
denominator = float(match.group(1))
|
||||
if denominator >= 10:
|
||||
return 'g'
|
||||
except ValueError:
|
||||
pass
|
||||
return '포'
|
||||
260
docs/API_DEVELOPMENT_GUIDE.md
Normal file
260
docs/API_DEVELOPMENT_GUIDE.md
Normal file
@ -0,0 +1,260 @@
|
||||
# API 개발 가이드 및 트러블슈팅
|
||||
|
||||
## 📋 목차
|
||||
1. [도매상 주문 API 응답 형식](#도매상-주문-api-응답-형식)
|
||||
2. [동원약품 API 버그 수정](#동원약품-api-버그-수정)
|
||||
|
||||
---
|
||||
|
||||
## 도매상 주문 API 응답 형식
|
||||
|
||||
### `/api/order/quick-submit` 응답 표준
|
||||
|
||||
모든 도매상(지오영, 수인, 백제, 동원)의 주문 응답은 **동일한 형식**을 따라야 합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"dry_run": true,
|
||||
"cart_only": false,
|
||||
"order_id": 123,
|
||||
"order_no": "ORD-20260308-001",
|
||||
"wholesaler": "dongwon",
|
||||
"total_items": 1,
|
||||
"success_count": 1,
|
||||
"failed_count": 0,
|
||||
"results": [
|
||||
{
|
||||
"item_id": 456,
|
||||
"drug_code": "643900470",
|
||||
"product_name": "부루펜정200mg",
|
||||
"specification": "500정(병)",
|
||||
"order_qty": 1,
|
||||
"status": "success",
|
||||
"result_code": "OK",
|
||||
"result_message": "[DRY RUN] 주문 가능: 재고 9, 단가 17,000원",
|
||||
"price": 17000
|
||||
}
|
||||
],
|
||||
"note": "장바구니에 담김. 도매상 사이트에서 최종 확정 필요."
|
||||
}
|
||||
```
|
||||
|
||||
### ⚠️ 필수 필드
|
||||
|
||||
| 필드 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| `wholesaler` | 도매상 ID | 프론트엔드에서 결과 모달 표시에 사용 |
|
||||
| `success_count` | 성공 개수 | 최상위 레벨에 있어야 함 (summary 안에만 있으면 안됨) |
|
||||
| `failed_count` | 실패 개수 | 최상위 레벨에 있어야 함 |
|
||||
| `order_no` | 주문번호 | 프론트엔드 결과 모달에 표시 |
|
||||
|
||||
---
|
||||
|
||||
## 동원약품 API 버그 수정
|
||||
|
||||
### 📅 수정일: 2026-03-08
|
||||
|
||||
### 🐛 문제
|
||||
|
||||
**증상:**
|
||||
- 동원약품으로 주문하면 결과 모달에 "**지오영 주문 결과**"로 표시됨
|
||||
- 성공/실패 개수가 "**undefined**"로 표시됨
|
||||
|
||||
**원인:**
|
||||
`submit_dongwon_order()` 함수의 응답에 다음 필드가 누락됨:
|
||||
1. `wholesaler` 필드 없음
|
||||
2. `success_count`, `failed_count`가 `summary` 객체 안에만 있음 (최상위에 없음)
|
||||
3. `order_no` 필드 없음
|
||||
|
||||
### 🔧 수정 내용
|
||||
|
||||
**파일:** `backend/order_api.py`
|
||||
|
||||
**수정 전 (dry_run 응답):**
|
||||
```python
|
||||
return {
|
||||
'success': True,
|
||||
'dry_run': True,
|
||||
'results': results,
|
||||
'summary': {
|
||||
'total': len(items),
|
||||
'success': success_count,
|
||||
'failed': failed_count
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**수정 후:**
|
||||
```python
|
||||
return {
|
||||
'success': True,
|
||||
'dry_run': dry_run,
|
||||
'cart_only': cart_only,
|
||||
'order_id': order_id,
|
||||
'order_no': order['order_no'],
|
||||
'wholesaler': 'dongwon',
|
||||
'total_items': len(items),
|
||||
'success_count': success_count,
|
||||
'failed_count': failed_count,
|
||||
'results': results
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ 검증
|
||||
|
||||
테스트 절차:
|
||||
1. `http://localhost:7001/admin/rx-usage` 접속
|
||||
2. 테이블에서 약품 더블클릭 → 도매상 재고 모달 열기
|
||||
3. 동원약품 섹션에서 "담기" 버튼 클릭
|
||||
4. 장바구니에서 "주문서 생성하기" 클릭
|
||||
5. "🧪 테스트" 버튼 클릭
|
||||
6. 결과 모달에서 확인:
|
||||
- 제목: "🏥 **동원약품** 주문 결과"
|
||||
- 성공: "1개"
|
||||
- 실패: "0개"
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드 장바구니 구조
|
||||
|
||||
### `addToCartFromWholesale()` 함수
|
||||
|
||||
동원약품에서 "담기" 버튼 클릭 시 장바구니에 추가되는 아이템 구조:
|
||||
|
||||
```javascript
|
||||
const cartItem = {
|
||||
drug_code: '643900470',
|
||||
product_name: '부루펜정200mg',
|
||||
supplier: '동원약품',
|
||||
qty: 1,
|
||||
specification: '500정(병)',
|
||||
wholesaler: 'dongwon', // ← 필터링에 사용
|
||||
internal_code: '16045',
|
||||
dongwon_code: '16045', // ← 동원 API 호출에 사용
|
||||
unit_price: 17000
|
||||
};
|
||||
```
|
||||
|
||||
### 도매상 필터링 로직
|
||||
|
||||
```javascript
|
||||
const WHOLESALERS = {
|
||||
dongwon: {
|
||||
filterFn: (item) => item.supplier === '동원약품' || item.wholesaler === 'dongwon'
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 개발 시 체크리스트
|
||||
|
||||
새로운 도매상 API 추가 시:
|
||||
|
||||
- [ ] `submit_xxx_order()` 함수 응답에 `wholesaler` 필드 포함
|
||||
- [ ] `success_count`, `failed_count` 최상위 레벨에 포함
|
||||
- [ ] `order_no` 필드 포함
|
||||
- [ ] 프론트엔드 `WHOLESALERS` 객체에 도매상 추가
|
||||
- [ ] `filterFn` 함수 정의
|
||||
- [ ] E2E 테스트 수행
|
||||
|
||||
---
|
||||
|
||||
## 주문량 조회 API (summary-by-kd)
|
||||
|
||||
### 📅 추가일: 2025-07-14
|
||||
|
||||
### 📋 개요
|
||||
|
||||
전문의약품 사용량 페이지(`/admin/rx-usage`)의 "주문량" 컬럼은 도매상별 주문량을 KD 코드 기준으로 합산하여 표시합니다.
|
||||
|
||||
### ⚠️ 필수 구현: `/orders/summary-by-kd` 엔드포인트
|
||||
|
||||
**새로운 도매상 추가 시 반드시 구현해야 합니다!**
|
||||
|
||||
#### 요청
|
||||
|
||||
```
|
||||
GET /api/{wholesaler}/orders/summary-by-kd?start_date=2025-07-01&end_date=2025-07-14
|
||||
```
|
||||
|
||||
#### 응답 형식 (표준)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"order_count": 4,
|
||||
"period": {
|
||||
"start": "2025-07-01",
|
||||
"end": "2025-07-14"
|
||||
},
|
||||
"by_kd_code": {
|
||||
"670400830": {
|
||||
"product_name": "레바미피드정100mg",
|
||||
"spec": "100T",
|
||||
"boxes": 2,
|
||||
"units": 200
|
||||
},
|
||||
"643900470": {
|
||||
"product_name": "부루펜정200mg",
|
||||
"spec": "500정(병)",
|
||||
"boxes": 1,
|
||||
"units": 500
|
||||
}
|
||||
},
|
||||
"total_products": 2
|
||||
}
|
||||
```
|
||||
|
||||
### 현재 구현 상태
|
||||
|
||||
| 도매상 | 엔드포인트 | KD 코드 집계 | 비고 |
|
||||
|--------|------------|--------------|------|
|
||||
| 지오영 | `/api/geoyoung/orders/summary-by-kd` | ✅ | 정상 작동 |
|
||||
| 수인 | `/api/sooin/orders/summary-by-kd` | ✅ | 정상 작동 |
|
||||
| 백제 | `/api/baekje/orders/summary-by-kd` | ✅ | 정상 작동 |
|
||||
| 동원 | `/api/dongwon/orders/summary-by-kd` | ⚠️ | 주문 건수만 제공, 품목별 집계 불가 |
|
||||
|
||||
### 동원약품 한계
|
||||
|
||||
동원약품 API(`onLineOrderListAX`)는 주문 목록만 반환하고, 각 주문의 상세 품목(items)을 제공하지 않습니다.
|
||||
|
||||
**향후 개선 필요:**
|
||||
- 동원 주문 상세 조회 API 탐색 필요
|
||||
- 또는 주문 상세 페이지 크롤링 구현
|
||||
|
||||
### 프론트엔드 연동
|
||||
|
||||
`admin_rx_usage.html`의 `loadOrderData()` 함수:
|
||||
|
||||
```javascript
|
||||
// 4사 병렬 조회
|
||||
const [geoRes, sooinRes, baekjeRes, dongwonRes] = await Promise.all([
|
||||
fetch(`/api/geoyoung/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`),
|
||||
fetch(`/api/sooin/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`),
|
||||
fetch(`/api/baekje/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`),
|
||||
fetch(`/api/dongwon/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`)
|
||||
]);
|
||||
|
||||
// 각 도매상 데이터를 KD 코드 기준으로 합산
|
||||
if (dongwonRes.success && dongwonRes.by_kd_code) {
|
||||
for (const [kd, data] of Object.entries(dongwonRes.by_kd_code)) {
|
||||
orderDataByKd[kd].boxes += data.boxes || 0;
|
||||
orderDataByKd[kd].units += data.units || 0;
|
||||
orderDataByKd[kd].sources.push('동원');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 📝 새 도매상 추가 시 체크리스트
|
||||
|
||||
- [ ] `{wholesaler}_api.py`에 `/orders/summary-by-kd` 엔드포인트 구현
|
||||
- [ ] 응답 형식 표준 준수 (`by_kd_code`, `order_count` 등)
|
||||
- [ ] `admin_rx_usage.html`의 `loadOrderData()`에 새 도매상 추가
|
||||
- [ ] 합산 로직에 새 도매상 데이터 추가
|
||||
- [ ] API 테스트 수행
|
||||
|
||||
---
|
||||
|
||||
*마지막 업데이트: 2025-07-14*
|
||||
410
docs/DB구조_제품입고판매마진.md
Normal file
410
docs/DB구조_제품입고판매마진.md
Normal file
@ -0,0 +1,410 @@
|
||||
# 약국 POS DB 구조 - 제품/입고/판매/마진
|
||||
|
||||
> **분석일**: 2026-03-13
|
||||
> **DB**: PM_DRUG, PM_PRES (MSSQL, 192.168.0.4\PM2014)
|
||||
> **목적**: 입고 기록 없이 판매 시 마진이 0으로 나오는 문제 원인 파악
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
약국 POS 시스템(PharmIT3000)은 크게 3개의 DB를 사용합니다:
|
||||
|
||||
| DB명 | 용도 | 주요 테이블 |
|
||||
|------|------|------------|
|
||||
| **PM_DRUG** | 약품 마스터, 재고, 입고 | CD_GOODS, IM_total, WH_main/sub |
|
||||
| **PM_PRES** | 판매, 처방 | SALE_MAIN/SUB, PS_main/sub_pharm |
|
||||
| **PM_BASE** | 고객, 도매상 정보 | CD_PERSON, CD_custom |
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 테이블
|
||||
|
||||
### 2.1 제품 마스터 - PM_DRUG.CD_GOODS
|
||||
|
||||
```sql
|
||||
-- 핵심 컬럼
|
||||
DrugCode NVARCHAR(20) -- 제품코드 (PK)
|
||||
GoodsName NVARCHAR(80) -- 제품명
|
||||
BARCODE NVARCHAR(20) -- 대표 바코드
|
||||
Price DECIMAL -- 입고가 (매입가) ⭐ 마진 계산의 핵심
|
||||
Saleprice DECIMAL -- 판매가
|
||||
SUNG_CODE NVARCHAR(9) -- 성분코드
|
||||
POS_BOON NVARCHAR(6) -- 분류 (010103=동물약)
|
||||
GoodsSelCode NVARCHAR(2) -- 사용여부 (B=사용, !=미사용)
|
||||
```
|
||||
|
||||
### 2.2 바코드 매핑
|
||||
|
||||
#### CD_BARCODE - 표준코드 ↔ 바코드 매핑
|
||||
```sql
|
||||
BARCODE NVARCHAR(20) -- 바코드
|
||||
BASECODE NVARCHAR(10) -- 표준코드 (EDI 코드)
|
||||
DRUGCODE NVARCHAR(10) -- 제품코드
|
||||
TITLECODE NVARCHAR(20) -- 대표코드
|
||||
```
|
||||
|
||||
#### CD_ITEM_UNIT_MEMBER - 단위별 바코드 (낱개/박스)
|
||||
```sql
|
||||
DRUGCODE NVARCHAR(20) -- 제품코드
|
||||
CD_CD_UNIT NVARCHAR(3) -- 단위코드
|
||||
CD_NM_UNIT REAL -- 단위수량
|
||||
CD_CD_BARCODE NVARCHAR(20) -- 단위 바코드 ⭐ APC 코드 (02로 시작)
|
||||
CD_MY_UNIT DECIMAL -- 단위가격
|
||||
```
|
||||
|
||||
> **APC 코드**: `02`로 시작하는 13자리 바코드 = 동물약 식별 코드
|
||||
> 예: CD_ITEM_UNIT_MEMBER에서 `CD_CD_BARCODE LIKE '02%'`
|
||||
|
||||
### 2.3 재고 테이블
|
||||
|
||||
#### IM_total - 현재 재고
|
||||
```sql
|
||||
DrugCode NVARCHAR(12) -- 제품코드
|
||||
IM_QT_sale_debit FLOAT -- 현재 재고 수량
|
||||
```
|
||||
|
||||
#### IM_date_total - 일별 재고 변동
|
||||
```sql
|
||||
IM_DT_appl NVARCHAR(8) -- 날짜 (YYYYMMDD)
|
||||
DrugCode NVARCHAR(12) -- 제품코드
|
||||
IM_QT_sale_credit DECIMAL -- 입고량
|
||||
im_qt_sale_debit DECIMAL -- 출고량
|
||||
```
|
||||
|
||||
### 2.4 입고 테이블 (PM_DRUG)
|
||||
|
||||
#### WH_main - 입고 마스터
|
||||
```sql
|
||||
WH_NO_stock NVARCHAR(14) -- 입고번호 (PK)
|
||||
WH_DT_appl NVARCHAR(8) -- 입고일 (YYYYMMDD)
|
||||
WH_CD_cust_sale NVARCHAR(10) -- 도매상코드 (→ PM_BASE.CD_custom)
|
||||
WH_BUSINAME NVARCHAR(200) -- 도매상명
|
||||
WH_MY_amount_t DECIMAL -- 입고 총액
|
||||
```
|
||||
|
||||
#### WH_sub - 입고 상세
|
||||
```sql
|
||||
WH_SR_stock NVARCHAR(14) -- 입고번호 (FK → WH_main)
|
||||
DrugCode NVARCHAR(20) -- 제품코드
|
||||
WH_DT_appl NVARCHAR(8) -- 입고일
|
||||
WH_NM_item_a DECIMAL -- 입고 수량 ⭐
|
||||
WH_MY_unit_a DECIMAL -- 입고 단가 ⭐
|
||||
WH_MY_amount_a DECIMAL -- 입고 금액 (수량 × 단가)
|
||||
WH_END_validity NVARCHAR(8) -- 유효기한
|
||||
WH_LOT_NO NVARCHAR(20) -- LOT 번호
|
||||
```
|
||||
|
||||
### 2.5 판매 테이블 (PM_PRES)
|
||||
|
||||
#### SALE_MAIN - 판매 마스터
|
||||
```sql
|
||||
SL_NO_order NVARCHAR(14) -- 거래번호 (PK)
|
||||
SL_DT_appl NVARCHAR(8) -- 판매일
|
||||
SL_CD_custom NVARCHAR(10) -- 고객코드
|
||||
InsertTime DATETIME -- 등록시간
|
||||
SL_MY_total DECIMAL -- 총액
|
||||
SL_MY_sale DECIMAL -- 판매액 ⭐
|
||||
SL_MY_sale_cost DECIMAL -- 원가 합계 ⭐ (마진 계산용)
|
||||
SL_MY_discount DECIMAL -- 할인액
|
||||
```
|
||||
|
||||
#### SALE_SUB - 판매 상세
|
||||
```sql
|
||||
SL_NO_order NVARCHAR(14) -- 거래번호 (FK)
|
||||
DrugCode NVARCHAR(20) -- 제품코드
|
||||
SL_NM_item DECIMAL -- 판매 수량
|
||||
SL_NM_cost_a DECIMAL -- 판매 단가
|
||||
SL_TOTAL_PRICE DECIMAL -- 판매 금액 (수량 × 단가)
|
||||
INPRICE DECIMAL -- 입고가 ⭐⭐⭐ 마진 계산의 핵심!
|
||||
SL_MY_in_cost DECIMAL -- 입고 원가 (= INPRICE)
|
||||
SL_INPUT_PRICE DECIMAL -- 입력가격
|
||||
BARCODE NVARCHAR(20) -- 판매 시 사용된 바코드
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 제품/바코드 구조
|
||||
|
||||
### 3.1 코드 체계
|
||||
|
||||
```
|
||||
제품코드 (DrugCode)
|
||||
├── 전문의약품: 9자리 숫자 (예: 652606580)
|
||||
├── 일반의약품: 9자리 숫자
|
||||
└── 자체등록: LB로 시작 (예: LB000003778)
|
||||
|
||||
바코드 종류
|
||||
├── 대표바코드: CD_GOODS.BARCODE
|
||||
├── 단위바코드: CD_ITEM_UNIT_MEMBER.CD_CD_BARCODE
|
||||
├── APC 코드: 02/92로 시작하는 13자리 (동물약)
|
||||
└── 표준코드: CD_BARCODE.BASECODE (EDI 연동용)
|
||||
```
|
||||
|
||||
### 3.2 바코드 조회 순서
|
||||
|
||||
```sql
|
||||
-- 제품의 바코드 찾기 (우선순위)
|
||||
1. CD_GOODS.BARCODE -- 대표 바코드
|
||||
2. CD_ITEM_UNIT_MEMBER.CD_CD_BARCODE -- 단위 바코드 (낱개/박스)
|
||||
3. CD_BARCODE.BARCODE -- 표준코드 매핑
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 입고 흐름
|
||||
|
||||
### 4.1 입고 기록 생성
|
||||
|
||||
```
|
||||
도매상 발주 → 입고 등록
|
||||
↓
|
||||
┌─────────────┐
|
||||
│ WH_main │ 입고 마스터 생성
|
||||
│ (입고번호) │ - 도매상코드
|
||||
└─────┬───────┘ - 입고일
|
||||
│
|
||||
↓
|
||||
┌─────────────┐
|
||||
│ WH_sub │ 입고 상세 생성
|
||||
│ (품목별) │ - 제품코드
|
||||
└─────┬───────┘ - 수량, 단가 ⭐
|
||||
│
|
||||
↓
|
||||
┌─────────────┐
|
||||
│ IM_total │ 재고 증가
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### 4.2 입고 시 가격 업데이트
|
||||
|
||||
입고 처리 시 **CD_GOODS.Price** (입고가)가 업데이트될 수 있음:
|
||||
|
||||
```sql
|
||||
-- 최근 입고 단가로 CD_GOODS.Price 업데이트 (POS 설정에 따름)
|
||||
UPDATE CD_GOODS
|
||||
SET Price = (최근 입고 단가)
|
||||
WHERE DrugCode = ?
|
||||
```
|
||||
|
||||
> **중요**: POS 설정에 따라 입고 시 자동 업데이트 여부가 결정됨
|
||||
|
||||
---
|
||||
|
||||
## 5. 판매 흐름
|
||||
|
||||
### 5.1 판매 기록 생성
|
||||
|
||||
```
|
||||
바코드 스캔 → 판매 등록
|
||||
↓
|
||||
┌─────────────┐
|
||||
│ SALE_MAIN │ 판매 마스터 생성
|
||||
│ (거래번호) │ - 고객코드, 날짜
|
||||
└─────┬───────┘ - 총액, 원가합계 (SL_MY_sale_cost)
|
||||
│
|
||||
↓
|
||||
┌─────────────┐
|
||||
│ SALE_SUB │ 판매 상세 생성 (품목별)
|
||||
│ │ - 제품코드
|
||||
│ │ - 판매가 (SL_NM_cost_a)
|
||||
│ │ - 입고가 (INPRICE) ⭐
|
||||
└─────┬───────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────┐
|
||||
│ IM_total │ 재고 감소
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### 5.2 판매 시 INPRICE 설정 (핵심!)
|
||||
|
||||
**판매 시 SALE_SUB.INPRICE는 CD_GOODS.Price에서 가져옴**
|
||||
|
||||
```sql
|
||||
-- 판매 등록 시 (POS 시스템 내부 로직 추정)
|
||||
INSERT INTO SALE_SUB (
|
||||
SL_NO_order, DrugCode, SL_NM_item, SL_NM_cost_a,
|
||||
INPRICE, -- CD_GOODS.Price 값 사용
|
||||
...
|
||||
) VALUES (
|
||||
@거래번호, @제품코드, @수량, @판매단가,
|
||||
(SELECT Price FROM CD_GOODS WHERE DrugCode = @제품코드),
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
> **실제 확인 결과**: SALE_SUB.INPRICE ≈ CD_GOODS.Price (99% 일치)
|
||||
|
||||
---
|
||||
|
||||
## 6. 마진 계산 로직
|
||||
|
||||
### 6.1 거래별 마진 계산
|
||||
|
||||
```sql
|
||||
-- SALE_MAIN에서 마진 계산
|
||||
마진액 = SL_MY_sale (판매액) - SL_MY_sale_cost (원가합계)
|
||||
마진율 = (판매액 - 원가합계) / 판매액 × 100
|
||||
|
||||
-- SL_MY_sale_cost 계산 (내부 로직)
|
||||
SL_MY_sale_cost = SUM(SALE_SUB.INPRICE × SALE_SUB.SL_NM_item)
|
||||
-- 입고가 × 판매수량의 합계
|
||||
```
|
||||
|
||||
### 6.2 품목별 마진 계산
|
||||
|
||||
```sql
|
||||
-- SALE_SUB에서 품목별 마진
|
||||
품목_마진 = SL_TOTAL_PRICE - (INPRICE × SL_NM_item)
|
||||
= 판매금액 - (입고가 × 수량)
|
||||
```
|
||||
|
||||
### 6.3 마진 계산 예시
|
||||
|
||||
```
|
||||
거래 20260313000076:
|
||||
├── 판매액: 64,500원
|
||||
├── 원가: 31,650원 ← SALE_SUB.INPRICE 합계
|
||||
├── 마진: 32,850원
|
||||
└── 마진율: 50.9%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 문제점: 입고 없이 판매 시 마진 0
|
||||
|
||||
### 7.1 문제 원인
|
||||
|
||||
**INPRICE = 0이 되는 경우:**
|
||||
|
||||
1. **CD_GOODS.Price가 0 또는 NULL인 경우**
|
||||
- 제품 등록 시 입고가를 설정하지 않음
|
||||
- 입고 기록 없이 제품만 등록
|
||||
|
||||
2. **POS 시스템 특수 케이스**
|
||||
- 일부 상황에서 INPRICE가 0으로 설정됨
|
||||
- (정확한 조건은 POS 내부 로직에 따름)
|
||||
|
||||
### 7.2 실제 데이터 분석 (2026-03-13 기준)
|
||||
|
||||
```
|
||||
최근 1개월 판매 건수: 1,767건
|
||||
├── INPRICE > 0: 1,749건 (98.98%)
|
||||
└── INPRICE = 0: 18건 (1.02%) ← 마진 0 문제 발생!
|
||||
```
|
||||
|
||||
### 7.3 INPRICE=0 사례 분석
|
||||
|
||||
| 제품코드 | 제품명 | CD_GOODS.Price | SALE_SUB.INPRICE | 입고기록 |
|
||||
|----------|--------|----------------|------------------|----------|
|
||||
| LB000003658 | 수리팍(제로슈거) | 1,450 | **0** | 있음 |
|
||||
| LB000003575 | 알파플러스정 | 1 | **0** | 있음 |
|
||||
| LB000001822 | 헤파토스시럽 | 1,613 | **0** | 있음 |
|
||||
|
||||
> **특이사항**: 입고 기록이 있고 CD_GOODS.Price도 있는데 INPRICE=0인 경우 존재
|
||||
> → POS 특수 상황에서 발생 (추가 조사 필요)
|
||||
|
||||
### 7.4 해결 방안
|
||||
|
||||
#### 방안 1: 제품 등록 시 입고가 필수 입력
|
||||
```sql
|
||||
-- 제품 등록 시 Price 필수 체크
|
||||
IF Price IS NULL OR Price = 0 THEN
|
||||
ERROR '입고가를 입력하세요'
|
||||
```
|
||||
|
||||
#### 방안 2: 판매 전 입고 기록 확인
|
||||
```sql
|
||||
-- 판매 시 입고 기록 확인
|
||||
IF NOT EXISTS (SELECT 1 FROM WH_sub WHERE DrugCode = @코드) THEN
|
||||
WARNING '입고 기록 없음. 마진 계산 불가'
|
||||
```
|
||||
|
||||
#### 방안 3: INPRICE 자동 채우기
|
||||
```sql
|
||||
-- INPRICE=0인 경우 CD_GOODS.Price로 업데이트
|
||||
UPDATE SALE_SUB
|
||||
SET INPRICE = (SELECT Price FROM CD_GOODS WHERE DrugCode = SALE_SUB.DrugCode)
|
||||
WHERE INPRICE = 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 관련 API 목록 (app.py)
|
||||
|
||||
| 엔드포인트 | 기능 | 사용 테이블 |
|
||||
|------------|------|-------------|
|
||||
| `/api/products` | 제품 검색 | CD_GOODS, IM_total, CD_ITEM_UNIT_MEMBER |
|
||||
| `/api/drugs/<code>/purchase-history` | 입고 이력 | WH_main, WH_sub |
|
||||
| `/api/sales-detail` | 판매 상세 | SALE_SUB, CD_GOODS |
|
||||
| `/api/usage` | 기간별 사용량 | SALE_SUB, CD_GOODS |
|
||||
| `/api/rx-usage` | 처방 사용량 | PS_sub_pharm, PS_main |
|
||||
| `/admin/transaction/<id>` | 거래 상세 | SALE_MAIN, SALE_SUB |
|
||||
|
||||
---
|
||||
|
||||
## 9. 테이블 관계도
|
||||
|
||||
```
|
||||
PM_DRUG PM_PRES
|
||||
======== ========
|
||||
|
||||
CD_GOODS ────────────────────────┐
|
||||
│ DrugCode (PK) │
|
||||
│ │
|
||||
├── CD_ITEM_UNIT_MEMBER │
|
||||
│ (단위바코드) │
|
||||
│ │
|
||||
├── CD_BARCODE │
|
||||
│ (표준코드 매핑) │
|
||||
│ │
|
||||
├── IM_total │
|
||||
│ (현재 재고) │
|
||||
│ │
|
||||
├── WH_sub ◄─── WH_main │
|
||||
│ (입고 상세) (입고 마스터)│
|
||||
│ │
|
||||
└──────────────────────────────┼──► SALE_SUB ◄─── SALE_MAIN
|
||||
│ (판매 상세) (판매 마스터)
|
||||
│ │
|
||||
│ │ INPRICE = CD_GOODS.Price
|
||||
│ │
|
||||
└─────────┘
|
||||
|
||||
마진 계산:
|
||||
SALE_MAIN.SL_MY_sale_cost = Σ(SALE_SUB.INPRICE × 수량)
|
||||
마진 = SL_MY_sale - SL_MY_sale_cost
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 핵심 요약
|
||||
|
||||
### 10.1 마진 계산 흐름
|
||||
```
|
||||
입고 등록 (WH_sub)
|
||||
↓
|
||||
CD_GOODS.Price 업데이트 (입고가)
|
||||
↓
|
||||
판매 등록 (SALE_SUB)
|
||||
↓
|
||||
SALE_SUB.INPRICE ← CD_GOODS.Price ⭐
|
||||
↓
|
||||
SALE_MAIN.SL_MY_sale_cost = Σ(INPRICE × 수량)
|
||||
↓
|
||||
마진 = 판매액 - 원가
|
||||
```
|
||||
|
||||
### 10.2 문제 원인
|
||||
- **INPRICE = 0**이면 마진 = 판매액 (100% 마진처럼 보이지만 실제로는 잘못된 데이터)
|
||||
- **CD_GOODS.Price = 0**이면 판매 시 INPRICE도 0
|
||||
|
||||
### 10.3 권장 조치
|
||||
1. 제품 등록 시 입고가(Price) 필수 입력 강제
|
||||
2. 입고 처리 후 판매 권장 (입고 기록 없으면 경고)
|
||||
3. 마진 리포트에서 INPRICE=0인 건 별도 표시/경고
|
||||
|
||||
---
|
||||
|
||||
*분석: 용림 (Yongrim) | 2026-03-13*
|
||||
129
docs/DONGWON_TROUBLESHOOTING.md
Normal file
129
docs/DONGWON_TROUBLESHOOTING.md
Normal file
@ -0,0 +1,129 @@
|
||||
# 동원약품 rx-usage 프론트엔드 연동 트러블슈팅
|
||||
|
||||
**작성일**: 2025-07-14
|
||||
**수정 파일**: `backend/templates/admin_rx_usage.html`
|
||||
|
||||
## 발견된 문제점 3가지
|
||||
|
||||
### 문제 1: 재고 모달에서 KD코드가 아닌 내부코드 표시
|
||||
|
||||
**증상**: 동원약품만 재고 모달에서 내부코드(예: 16045, A4394)가 표시됨. 다른 도매상(지오영, 수인, 백제)은 KD코드(보험코드)가 정상 표시됨.
|
||||
|
||||
**원인**: `renderWholesaleResults()` 함수의 동원 섹션에서 `item.internal_code`를 표시함
|
||||
|
||||
```javascript
|
||||
// 잘못된 코드
|
||||
<span class="geo-code">${item.internal_code || ''} · ${item.manufacturer || ''}</span>
|
||||
```
|
||||
|
||||
**해결**: 동원 API는 `code`에 KD코드(보험코드)를, `internal_code`에 내부코드를 반환함. 표시용은 `code` 사용.
|
||||
|
||||
```javascript
|
||||
// 수정된 코드
|
||||
const displayCode = item.code || item.internal_code || '';
|
||||
<span class="geo-code">${displayCode} · ${item.manufacturer || ''}</span>
|
||||
```
|
||||
|
||||
### 문제 2: 장바구니 "주문서 생성하기"에 동원 미포함
|
||||
|
||||
**증상**: 장바구니에 동원 상품을 담아도 "주문서 생성하기" 모달에 동원이 나오지 않음.
|
||||
|
||||
**원인**: `WHOLESALERS` 객체에 동원 설정 누락
|
||||
|
||||
```javascript
|
||||
// 기존 코드 - 동원 없음
|
||||
const WHOLESALERS = {
|
||||
geoyoung: {...},
|
||||
sooin: {...},
|
||||
baekje: {...}
|
||||
};
|
||||
```
|
||||
|
||||
**해결**: `WHOLESALERS`에 동원 추가
|
||||
|
||||
```javascript
|
||||
dongwon: {
|
||||
id: 'dongwon',
|
||||
name: '동원약품',
|
||||
icon: '🏥',
|
||||
logo: '/static/img/logo_dongwon.png',
|
||||
color: '#22c55e',
|
||||
gradient: 'linear-gradient(135deg, #16a34a, #22c55e)',
|
||||
filterFn: (item) => item.supplier === '동원약품' || item.wholesaler === 'dongwon',
|
||||
getCode: (item) => item.dongwon_code || item.internal_code || item.drug_code
|
||||
}
|
||||
```
|
||||
|
||||
### 문제 3: 장바구니에서 "dongwon"으로 표시
|
||||
|
||||
**증상**: 동원 상품이 장바구니에 담기면 "동원약품" 대신 "dongwon"으로 표시됨.
|
||||
|
||||
**원인**: `addToCartFromWholesale()` 함수의 `supplierNames` 객체에 동원 누락
|
||||
|
||||
```javascript
|
||||
// 기존
|
||||
const supplierNames = { geoyoung: '지오영', sooin: '수인약품', baekje: '백제약품' };
|
||||
```
|
||||
|
||||
**해결**: 동원 추가
|
||||
|
||||
```javascript
|
||||
const supplierNames = {
|
||||
geoyoung: '지오영',
|
||||
sooin: '수인약품',
|
||||
baekje: '백제약품',
|
||||
dongwon: '동원약품'
|
||||
};
|
||||
```
|
||||
|
||||
## 추가 수정 사항
|
||||
|
||||
### 1. 장바구니 아이템에 dongwon_code 필드 추가
|
||||
|
||||
```javascript
|
||||
const cartItem = {
|
||||
...
|
||||
dongwon_code: wholesaler === 'dongwon' ? item.internal_code : null,
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
동원은 장바구니 담기/주문 시 `internal_code`를 사용해야 함.
|
||||
|
||||
### 2. CSS 스타일 - 다중 도매상 모달 카드 색상
|
||||
|
||||
```css
|
||||
.multi-ws-card.dongwon {
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
```
|
||||
|
||||
## 동원약품 API 필드 매핑
|
||||
|
||||
| API 필드 | 의미 | 용도 |
|
||||
|----------|------|------|
|
||||
| `code` | KD코드 (보험코드) | 화면 표시용 |
|
||||
| `internal_code` | 동원 내부코드 | 장바구니 담기/주문 시 사용 |
|
||||
| `name` | 제품명 | 표시용 |
|
||||
| `manufacturer` | 제조사 | 표시용 |
|
||||
| `spec` | 규격 | 표시용 |
|
||||
| `price` | 단가 | 표시용 |
|
||||
| `stock` | 재고 | 표시용 |
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- **프론트엔드**: `backend/templates/admin_rx_usage.html`
|
||||
- **동원 API**: `backend/dongwon_api.py`
|
||||
- **동원 세션**: `pharmacy-wholesale-api/wholesale/dongwon.py`
|
||||
|
||||
## 테스트 방법
|
||||
|
||||
1. 전문의약품 사용량 페이지 접속: http://localhost:7001/admin/rx-usage
|
||||
2. 약품 행 더블클릭하여 재고 모달 열기
|
||||
3. 동원약품 섹션에서:
|
||||
- KD코드(9자리 숫자)가 표시되는지 확인
|
||||
- "담기" 버튼 클릭하여 장바구니 추가
|
||||
4. 장바구니 열어서:
|
||||
- "동원약품"으로 표시되는지 확인
|
||||
5. "주문서 생성하기" 클릭하여:
|
||||
- 동원약품이 도매상 목록에 나타나는지 확인
|
||||
193
docs/DRYSYRUP_CONVERSION.md
Normal file
193
docs/DRYSYRUP_CONVERSION.md
Normal file
@ -0,0 +1,193 @@
|
||||
# 건조시럽 환산계수 기능
|
||||
|
||||
## 개요
|
||||
|
||||
건조시럽(dry syrup)은 물로 희석하여 복용하는 시럽 형태의 의약품입니다. 복용량을 mL로 표시하지만, 실제 약 성분의 양은 g(그램)으로 환산해야 정확합니다.
|
||||
|
||||
**환산계수(conversion_factor)**를 사용하여 총 복용량(mL)을 실제 성분량(g)으로 변환합니다.
|
||||
|
||||
### 예시
|
||||
- 오구멘틴듀오시럽 228mg/5ml
|
||||
- 환산계수: 0.11
|
||||
- 총량 120mL × 0.11 = **13.2g**
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Flask Backend (7001) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ /api/drug-info/conversion-factor/<sung_code> │
|
||||
│ /pmr/api/label/preview (sung_code 파라미터 추가) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ DatabaseManager │
|
||||
│ ├── MSSQL (192.168.0.4) - PIT3000 │
|
||||
│ │ └── CD_GOODS.SUNG_CODE (성분코드) │
|
||||
│ ├── PostgreSQL (192.168.0.39:5432/label10) │
|
||||
│ │ └── drysyrup 테이블 (환산계수 23건) │
|
||||
│ └── SQLite - 마일리지 등 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
### PostgreSQL 연결 정보
|
||||
```
|
||||
Host: 192.168.0.39
|
||||
Port: 5432
|
||||
Database: label10
|
||||
User: admin
|
||||
Password: trajet6640
|
||||
```
|
||||
|
||||
### drysyrup 테이블 스키마
|
||||
| 컬럼명 | 타입 | 설명 |
|
||||
|--------|------|------|
|
||||
| ingredient_code | VARCHAR | 성분코드 (SUNG_CODE와 매칭) |
|
||||
| conversion_factor | DECIMAL | 환산계수 (mL → g) |
|
||||
| ingredient_name | VARCHAR | 성분명 |
|
||||
| product_name | VARCHAR | 대표 제품명 |
|
||||
|
||||
### 매핑 관계
|
||||
- MSSQL `PM_DRUG.CD_GOODS.SUNG_CODE` = PostgreSQL `drysyrup.ingredient_code`
|
||||
|
||||
## API 명세
|
||||
|
||||
### 1. 환산계수 조회 API
|
||||
|
||||
**Endpoint:** `GET /api/drug-info/conversion-factor/<sung_code>`
|
||||
|
||||
**응답 (성공):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"sung_code": "535000ASY",
|
||||
"conversion_factor": 0.11,
|
||||
"ingredient_name": "아목시실린수화물·클라불란산칼륨",
|
||||
"product_name": "일성오구멘틴듀오시럽 228mg/5ml"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 (데이터 없음/연결 실패):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"sung_code": "NOTEXIST",
|
||||
"conversion_factor": null,
|
||||
"ingredient_name": null,
|
||||
"product_name": null
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ 연결 실패나 데이터 없음에도 에러 없이 null 반환 (서비스 안정성 우선)
|
||||
|
||||
### 2. 라벨 미리보기 API (확장)
|
||||
|
||||
**Endpoint:** `POST /pmr/api/label/preview`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"patient_name": "홍길동",
|
||||
"med_name": "오구멘틴듀오시럽",
|
||||
"dosage": 8,
|
||||
"frequency": 3,
|
||||
"duration": 5,
|
||||
"unit": "mL",
|
||||
"sung_code": "535000ASY"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"image": "data:image/png;base64,...",
|
||||
"conversion_factor": 0.11
|
||||
}
|
||||
```
|
||||
|
||||
### 라벨 출력 예시
|
||||
|
||||
환산계수가 있는 경우:
|
||||
```
|
||||
총120mL (13.2g)/5일분
|
||||
```
|
||||
|
||||
환산계수가 없는 경우:
|
||||
```
|
||||
총120mL/5일분
|
||||
```
|
||||
|
||||
## 코드 위치
|
||||
|
||||
### 수정된 파일
|
||||
1. **dbsetup.py** - PostgreSQL 연결 관리
|
||||
- `DatabaseConfig.POSTGRES_URL` 추가
|
||||
- `DatabaseManager.get_postgres_engine()` 추가
|
||||
- `DatabaseManager.get_postgres_session()` 추가
|
||||
- `DatabaseManager.get_conversion_factor(sung_code)` 추가
|
||||
|
||||
2. **app.py** - 환산계수 조회 API
|
||||
- `GET /api/drug-info/conversion-factor/<sung_code>`
|
||||
|
||||
3. **pmr_api.py** - 라벨 미리보기
|
||||
- `preview_label()` - sung_code 파라미터 추가
|
||||
- `create_label_image()` - conversion_factor 파라미터 추가
|
||||
|
||||
## 유료/무료 버전 구분 설계 (추후)
|
||||
|
||||
환산계수는 추후 유료 기능으로 분리 가능합니다.
|
||||
|
||||
### 설계 방안
|
||||
1. **라이선스 체크**
|
||||
- 환산계수 조회 전 라이선스 확인
|
||||
- 무료 버전: `conversion_factor: null` 반환
|
||||
- 유료 버전: 실제 값 반환
|
||||
|
||||
2. **API 분리**
|
||||
- `/api/drug-info/conversion-factor` → 유료 전용
|
||||
- 무료 버전은 API 자체를 비활성화
|
||||
|
||||
3. **현재 구현**
|
||||
- 환산계수가 없어도 라벨 출력 정상 동작
|
||||
- null 체크 후 기존 포맷 유지
|
||||
|
||||
## 예외처리
|
||||
|
||||
| 상황 | 동작 |
|
||||
|------|------|
|
||||
| PostgreSQL 연결 실패 | null 반환, 에러 로그 |
|
||||
| 데이터 없음 | null 반환 |
|
||||
| sung_code 미전달 | 환산계수 조회 skip |
|
||||
| 환산계수 0 또는 음수 | 적용 안 함 (기존 포맷) |
|
||||
|
||||
## 테스트 방법
|
||||
|
||||
```bash
|
||||
# 환산계수 조회 테스트
|
||||
curl http://localhost:7001/api/drug-info/conversion-factor/535000ASY
|
||||
|
||||
# 존재하지 않는 코드 테스트
|
||||
curl http://localhost:7001/api/drug-info/conversion-factor/NOTEXIST
|
||||
|
||||
# 라벨 미리보기 테스트 (PowerShell)
|
||||
$body = @{
|
||||
patient_name = "홍길동"
|
||||
med_name = "오구멘틴듀오시럽"
|
||||
dosage = 8
|
||||
frequency = 3
|
||||
duration = 5
|
||||
unit = "mL"
|
||||
sung_code = "535000ASY"
|
||||
} | ConvertTo-Json
|
||||
|
||||
Invoke-RestMethod -Uri "http://localhost:7001/pmr/api/label/preview" `
|
||||
-Method Post -ContentType "application/json" -Body $body
|
||||
```
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-12 | 최초 구현 |
|
||||
63
docs/RX_USAGE_ORDER_DETAIL_IMPL.md
Normal file
63
docs/RX_USAGE_ORDER_DETAIL_IMPL.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Rx-Usage 주문량 상세 (도매상별) 툴팁 기능
|
||||
|
||||
## 구현일: 2026-06-19
|
||||
|
||||
## 배경
|
||||
- `/admin/rx-usage` 페이지에서 주문량이 합계로만 표시됨
|
||||
- 사용자가 어떤 도매상에 얼마나 주문했는지 확인 필요
|
||||
|
||||
## 구현 내용
|
||||
|
||||
### 1. 데이터 구조 변경 (`loadOrderData` 함수)
|
||||
|
||||
**기존:**
|
||||
```javascript
|
||||
orderDataByKd[kd] = {
|
||||
product_name, spec, boxes, units,
|
||||
sources: ['지오영', '수인'] // 이름만 저장
|
||||
};
|
||||
```
|
||||
|
||||
**변경:**
|
||||
```javascript
|
||||
orderDataByKd[kd] = {
|
||||
product_name, spec, boxes, units,
|
||||
details: [
|
||||
{ vendor: 'geoyoung', name: '지오영', boxes: 10, units: 100 },
|
||||
{ vendor: 'sooin', name: '수인', boxes: 5, units: 50 }
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 툴팁 CSS 추가
|
||||
|
||||
```css
|
||||
.order-qty-cell { position: relative; cursor: pointer; }
|
||||
.order-qty-tooltip { /* 툴팁 스타일 */ }
|
||||
.order-qty-vendor-dot.geoyoung { background: #06b6d4; }
|
||||
.order-qty-vendor-dot.sooin { background: #a855f7; }
|
||||
.order-qty-vendor-dot.baekje { background: #f59e0b; }
|
||||
.order-qty-vendor-dot.dongwon { background: #22c55e; }
|
||||
```
|
||||
|
||||
### 3. `getOrderedQty()` 함수 수정
|
||||
|
||||
- 단일 도매상: 단순 숫자 표시
|
||||
- 복수 도매상: hover 시 도매상별 상세 툴팁 표시
|
||||
|
||||
## 수정 파일
|
||||
- `backend/templates/admin_rx_usage.html`
|
||||
|
||||
## 동작
|
||||
1. 주문량 셀에 마우스 hover
|
||||
2. 2개 이상 도매상에서 주문한 경우 툴팁 표시
|
||||
3. 각 도매상별 수량과 합계 표시
|
||||
|
||||
## 확장 포인트
|
||||
- `vendorConfig` 객체에 새 도매상 추가 시 자동 지원
|
||||
- 도매상별 색상은 CSS의 `.order-qty-vendor-dot` 클래스로 관리
|
||||
|
||||
## 테스트
|
||||
- URL: http://localhost:7001/admin/rx-usage
|
||||
- 기간 조회 후 "주문량" 컬럼 확인
|
||||
- 여러 도매상 주문이 있는 품목에서 hover 시 툴팁 확인
|
||||
130
docs/SUIN_API_FIX.md
Normal file
130
docs/SUIN_API_FIX.md
Normal file
@ -0,0 +1,130 @@
|
||||
# 수인 API 주문 수량 파싱 문제 수정
|
||||
|
||||
**날짜**: 2026-03-09
|
||||
**문제**: 라미실크림 15g 주문 시 **1개 → 15개**로 잘못 표시
|
||||
**원인**: `parse_spec` 함수에서 용량 단위(g, ml)를 정량 단위(T, 정)로 착각
|
||||
|
||||
## 📋 문제 상황
|
||||
|
||||
| 도매상 | 제품 | 실제 주문 | 표시된 수량 |
|
||||
|--------|------|----------|------------|
|
||||
| 동원 | 라미실크림 15g | 1개 | **1개** ✅ |
|
||||
| 수인 | 라미실크림 15g | 1개 | **15개** ❌ |
|
||||
|
||||
## 🔍 원인 분석
|
||||
|
||||
### 문제 코드 위치
|
||||
- **파일**: `sooin_api.py` (Flask Blueprint)
|
||||
- **API**: `GET /api/sooin/orders/summary-by-kd`
|
||||
- **함수**: `parse_spec()`
|
||||
|
||||
### 기존 코드 (문제)
|
||||
```python
|
||||
def parse_spec(spec: str) -> int:
|
||||
if not spec:
|
||||
return 1
|
||||
match = re.search(r'(\d+)', spec)
|
||||
return int(match.group(1)) if match else 1
|
||||
```
|
||||
|
||||
**문제점**: 규격에서 **숫자만 추출**
|
||||
- `'30T'` → 30 (정제 30정) ✅
|
||||
- `'15g'` → 15 🚨 **문제!** (튜브 15그램인데 15개로 계산)
|
||||
|
||||
### 계산 과정
|
||||
```
|
||||
수인 라미실크림 15g 1박스 주문
|
||||
→ quantity = 1
|
||||
→ per_unit = parse_spec('15g') = 15
|
||||
→ total_units = 1 × 15 = 15개 ❌
|
||||
```
|
||||
|
||||
### 동원 API는 정상인 이유
|
||||
동원의 `parse_spec()` (wholesale/dongwon.py:1718-1720):
|
||||
```python
|
||||
# mg/ml 등의 용량 단위는 1로 처리
|
||||
if re.search(r'\d+\s*(mg|ml|g)\b', spec, re.IGNORECASE):
|
||||
return 1
|
||||
```
|
||||
|
||||
## ✅ 수정 내용
|
||||
|
||||
### 수정된 코드
|
||||
```python
|
||||
def parse_spec(spec: str) -> int:
|
||||
"""
|
||||
규격에서 박스당 단위 수 추출
|
||||
|
||||
정량 단위 (T, 정, 캡슐, C, PTP, 포 등): 숫자 추출
|
||||
용량 단위 (g, ml, mL, mg, L 등): 1 반환 (튜브/병 단위)
|
||||
|
||||
예시:
|
||||
- '30T' → 30 (정제 30정)
|
||||
- '100정(PTP)' → 100
|
||||
- '15g' → 1 (튜브 1개)
|
||||
- '10ml' → 1 (병 1개)
|
||||
- '500mg' → 1 (용량 표시)
|
||||
"""
|
||||
if not spec:
|
||||
return 1
|
||||
|
||||
spec_lower = spec.lower()
|
||||
|
||||
# 용량 단위 패턴: 숫자 + g/ml/mg/l (단독 또는 끝)
|
||||
# 이 경우 튜브/병 단위이므로 1 반환
|
||||
volume_pattern = r'^\d+\s*(g|ml|mg|l)(\s|$|\)|/)'
|
||||
if re.search(volume_pattern, spec_lower):
|
||||
return 1
|
||||
|
||||
# 정량 단위 패턴: 숫자 + T/정/캡슐/C/PTP/포
|
||||
qty_pattern = r'(\d+)\s*(t|정|캡슐?|c|ptp|포|tab|cap)'
|
||||
qty_match = re.search(qty_pattern, spec_lower)
|
||||
if qty_match:
|
||||
return int(qty_match.group(1))
|
||||
|
||||
# 기본: 숫자만 있으면 추출하되, 용량 단위 재확인
|
||||
# 끝에 g/ml이 있으면 1 반환
|
||||
if re.search(r'\d+(g|ml)$', spec_lower):
|
||||
return 1
|
||||
|
||||
# 그 외 숫자 추출
|
||||
match = re.search(r'(\d+)', spec)
|
||||
return int(match.group(1)) if match else 1
|
||||
```
|
||||
|
||||
### 수정 결과
|
||||
| 규격 | 기존 결과 | 수정 후 결과 |
|
||||
|------|----------|-------------|
|
||||
| `'30T'` | 30 | 30 ✅ |
|
||||
| `'100정(PTP)'` | 100 | 100 ✅ |
|
||||
| `'15g'` | 15 ❌ | **1** ✅ |
|
||||
| `'10ml'` | 10 ❌ | **1** ✅ |
|
||||
| `'500mg'` | 500 ❌ | **1** ✅ |
|
||||
|
||||
## 📁 관련 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `backend/sooin_api.py` | Flask Blueprint (수정됨) |
|
||||
| `wholesale/sooin.py` | 수인약품 핵심 API 클래스 |
|
||||
| `wholesale/dongwon.py` | 동원약품 API (참고) |
|
||||
|
||||
## 🔄 적용 방법
|
||||
|
||||
```bash
|
||||
# Flask 서버 재시작
|
||||
pm2 restart flask-pharmacy
|
||||
```
|
||||
|
||||
## 🧪 테스트
|
||||
|
||||
```bash
|
||||
# 수인 주문 조회 API 테스트
|
||||
curl "http://localhost:7001/api/sooin/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-09"
|
||||
```
|
||||
|
||||
## 📝 참고
|
||||
|
||||
- **도매상 API 문서**: `docs/WHOLESALE_API_INTEGRATION.md`
|
||||
- **수인 API 문서**: `docs/SOOIN_API.md`
|
||||
- **동원 API**: 이미 올바른 `parse_spec` 로직 적용됨
|
||||
278
docs/postgresql-apdb.md
Normal file
278
docs/postgresql-apdb.md
Normal file
@ -0,0 +1,278 @@
|
||||
# PostgreSQL APDB (apdb_master) 데이터베이스 문서
|
||||
|
||||
## 접속 정보
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| Host | 192.168.0.87 |
|
||||
| Port | 5432 |
|
||||
| Database | apdb_master |
|
||||
| User | admin |
|
||||
| Password | trajet6640 |
|
||||
| Connection String | `postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master` |
|
||||
|
||||
```python
|
||||
from sqlalchemy import create_engine
|
||||
engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 테이블
|
||||
|
||||
### apc — 동물약품 마스터 (16,326건)
|
||||
|
||||
APC(Animal Product Code) 기반 동물약품 정보. 모든 동물약의 기준 테이블.
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| idx | INTEGER PK | 일련번호 |
|
||||
| apc | VARCHAR(100) | APC 코드 (13자리, '023'으로 시작) |
|
||||
| item_seq | VARCHAR(100) | 품목기준코드 |
|
||||
| item_code | VARCHAR(100) | 품목코드 (APC 앞 8자리 = item_code) |
|
||||
| product_name | VARCHAR(200) | 제품명 (한글) |
|
||||
| product_english_name | VARCHAR(200) | 제품 영문명 |
|
||||
| company_name | VARCHAR(100) | 제조/수입사명 |
|
||||
| approval_number | VARCHAR(100) | 허가번호 |
|
||||
| ac | VARCHAR(100) | AC 코드 |
|
||||
| dosage_code | VARCHAR(100) | 제형코드 |
|
||||
| packaging_code | VARCHAR(100) | 포장코드 |
|
||||
| pc | VARCHAR(100) | PC 코드 |
|
||||
| dosage | VARCHAR(100) | 제형 (정, 액, 캡슐 등) |
|
||||
| packaging | VARCHAR(100) | 포장단위 |
|
||||
| approval_date | VARCHAR(100) | 허가일자 |
|
||||
| product_type | VARCHAR(500) | 제품유형 |
|
||||
| main_ingredient | VARCHAR(500) | 주성분 |
|
||||
| finished_material | VARCHAR(500) | 완제원료 |
|
||||
| manufacture_import | VARCHAR(100) | 제조/수입 구분 |
|
||||
| country_of_manufacture | VARCHAR(100) | 제조국 |
|
||||
| basic_info | TEXT | 기본정보 |
|
||||
| raw_material | TEXT | 원료약품 |
|
||||
| efficacy_effect | TEXT | 효능효과 |
|
||||
| dosage_instructions | TEXT | 용법용량 |
|
||||
| precautions | TEXT | 주의사항 |
|
||||
| component_code | VARCHAR(100) | 성분코드 |
|
||||
| component_name_ko | VARCHAR(200) | 성분명(한글) |
|
||||
| component_name_en | VARCHAR(200) | 성분명(영문) |
|
||||
| dosage_factor | VARCHAR(100) | 용량계수 |
|
||||
| llm_pharm | JSONB | LLM 생성 약사용 정보 (투여량, 주의사항 등) |
|
||||
| llm_user | VARCHAR(500) | LLM 생성 사용자용 설명 |
|
||||
| image_url1~3 | VARCHAR(500) | 제품 이미지 URL |
|
||||
| list_price | NUMERIC(10,2) | 정가 |
|
||||
| weight_min_kg | DOUBLE PRECISION | 체중 하한 (kg) |
|
||||
| weight_max_kg | DOUBLE PRECISION | 체중 상한 (kg) |
|
||||
| pet_size_label | VARCHAR(100) | 체중 라벨 (소형견용, 대형견용 등) |
|
||||
| pet_size_code | VARCHAR(10) | 체중 코드 |
|
||||
| for_pets | BOOLEAN | 반려동물용 여부 |
|
||||
| prescription_target | BOOLEAN | 처방대상 여부 |
|
||||
| is_not_medicine | BOOLEAN | 비의약품 여부 |
|
||||
| usage_guide | JSONB | 사용 가이드 (구조화) |
|
||||
| godoimage_url_f/b/d | VARCHAR(500) | 고도몰 이미지 URL |
|
||||
| pill_color | VARCHAR(100) | 알약 색상 |
|
||||
| updated_at | TIMESTAMP | 수정일시 |
|
||||
| parent_item_id | INTEGER | 부모 품목 ID |
|
||||
|
||||
**APC 코드 구조**: `023XXXXXYYZZZ`
|
||||
- 앞 8자리 (`023XXXXX`) = item_code (품목코드, 대표 APC)
|
||||
- 나머지 = 포장단위별 구분
|
||||
|
||||
---
|
||||
|
||||
### component_code — 성분 정보 (1,105건)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| idx | INTEGER PK | 일련번호 |
|
||||
| code | VARCHAR(500) | 성분코드 |
|
||||
| component_name_ko | VARCHAR(500) | 성분명(한글) |
|
||||
| component_name_en | VARCHAR(500) | 성분명(영문) |
|
||||
| description | VARCHAR(500) | 설명 |
|
||||
| efficacy | TEXT | 효능 |
|
||||
| target_animals | JSONB | 대상 동물 |
|
||||
| precautions | TEXT | 주의사항 |
|
||||
| additional_precautions | TEXT | 추가 주의사항 |
|
||||
| prohibited_breeds | VARCHAR(500) | 금기 품종 |
|
||||
| offlabel | TEXT | 오프라벨 사용 |
|
||||
|
||||
### component_guide — 성분별 투여 가이드 (1건)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| component_code | VARCHAR(50) PK | 성분코드 |
|
||||
| component_name_ko/en | VARCHAR(200) | 성분명 |
|
||||
| dosing_interval_adult | VARCHAR(200) | 성체 투여간격 |
|
||||
| dosing_interval_high_risk | VARCHAR(200) | 고위험군 투여간격 |
|
||||
| dosing_interval_puppy | VARCHAR(200) | 유아 투여간격 |
|
||||
| dosing_interval_source | VARCHAR(500) | 출처 |
|
||||
| withdrawal_period | VARCHAR(200) | 휴약기간 |
|
||||
| contraindication | VARCHAR(500) | 금기사항 |
|
||||
| companion_drugs | VARCHAR(500) | 병용약물 |
|
||||
|
||||
### dosage_info — 용량 정보 (152건)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | 일련번호 |
|
||||
| apdb_idx | INTEGER | apc 테이블 idx 참조 |
|
||||
| component_code | VARCHAR(100) | 성분코드 |
|
||||
| dose_per_kg | DOUBLE PRECISION | kg당 용량 |
|
||||
| dose_per_kg_min/max | DOUBLE PRECISION | kg당 용량 범위 |
|
||||
| dose_unit | VARCHAR(20) | 용량 단위 |
|
||||
| unit_dose | DOUBLE PRECISION | 단위 용량 |
|
||||
| unit_type | VARCHAR(20) | 단위 타입 |
|
||||
| frequency | VARCHAR(50) | 투여 빈도 |
|
||||
| route | VARCHAR(30) | 투여 경로 |
|
||||
| weight_min/max_kg | DOUBLE PRECISION | 적용 체중 범위 |
|
||||
| animal_type | VARCHAR(10) | 동물 종류 |
|
||||
| source | VARCHAR(20) | 출처 |
|
||||
| verified | BOOLEAN | 검증 여부 |
|
||||
| raw_text | TEXT | 원문 |
|
||||
|
||||
### symptoms — 증상 코드 (51건)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| idx | INTEGER PK | 일련번호 |
|
||||
| prefix | VARCHAR(1) | 카테고리 접두사 |
|
||||
| prefix_description | VARCHAR(50) | 카테고리 설명 |
|
||||
| symptom_code | VARCHAR(10) | 증상 코드 |
|
||||
| symptom_description | VARCHAR(255) | 증상 설명 |
|
||||
| disease_description | VARCHAR(255) | 질병 설명 |
|
||||
|
||||
### symptom_component_mapping — 증상-성분 매핑 (111건)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| symptom_code | VARCHAR(10) | 증상 코드 |
|
||||
| component_code | VARCHAR(500) | 성분 코드 |
|
||||
|
||||
---
|
||||
|
||||
## 재고/유통 테이블
|
||||
|
||||
### inventory — 재고 (656건)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | 일련번호 |
|
||||
| apdb_id | INTEGER | apc.idx 참조 |
|
||||
| supplier_cost | NUMERIC(12,2) | 공급가 |
|
||||
| wholesaler_price | NUMERIC(12,2) | 도매가 |
|
||||
| retail_price | NUMERIC(12,2) | 소매가 |
|
||||
| quantity | INTEGER | 수량 |
|
||||
| transaction_type | VARCHAR(20) | 거래유형 |
|
||||
| order_no | VARCHAR(100) | 주문번호 |
|
||||
| serial_number | VARCHAR(100) | 시리얼번호 |
|
||||
| expiration_date | DATE | 유효기간 |
|
||||
| receipt_id | INTEGER | 입고전표 ID |
|
||||
| entity_id | VARCHAR(50) | 거래처 ID |
|
||||
| entity_type | VARCHAR(20) | 거래처 유형 |
|
||||
| location_id | INTEGER | 보관위치 ID |
|
||||
| goods_no | INTEGER | 고도몰 상품번호 |
|
||||
|
||||
### receipt — 입고전표 (21건)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| idx | INTEGER PK | 일련번호 |
|
||||
| receipt_number | VARCHAR(100) | 전표번호 |
|
||||
| receipt_date | TIMESTAMP | 입고일 |
|
||||
| total_quantity | INTEGER | 총수량 |
|
||||
| total_amount | NUMERIC(10,2) | 총금액 |
|
||||
| entity_id | VARCHAR(50) | 거래처 ID |
|
||||
| entity_type | VARCHAR(20) | 거래처 유형 |
|
||||
|
||||
### vendor — 거래처 (3건)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| idx | INTEGER PK | 일련번호 |
|
||||
| vendor_code | VARCHAR(50) | 거래처 코드 |
|
||||
| name | VARCHAR(200) | 거래처명 |
|
||||
| business_reg_no | VARCHAR(50) | 사업자번호 |
|
||||
|
||||
---
|
||||
|
||||
## 약국/회원 테이블
|
||||
|
||||
### animal_pharmacies — 동물약국 목록 (18,955건)
|
||||
|
||||
전국 동물약국 데이터 (공공데이터 기반).
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | 일련번호 |
|
||||
| management_number | VARCHAR(50) | 관리번호 |
|
||||
| name | VARCHAR(200) | 약국명 |
|
||||
| phone | VARCHAR(20) | 전화번호 |
|
||||
| address_old/new | VARCHAR(500) | 주소 |
|
||||
| latitude/longitude | NUMERIC | 위경도 |
|
||||
| business_status | VARCHAR(10) | 영업상태 |
|
||||
|
||||
### p_member — 약국 회원 (31건)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| idx | INTEGER PK | 일련번호 |
|
||||
| memno | INTEGER | 회원번호 |
|
||||
| pharmacyname | VARCHAR(100) | 약국명 |
|
||||
| businessregno | VARCHAR(20) | 사업자번호 |
|
||||
| kioskusage | BOOLEAN | 키오스크 사용 |
|
||||
| mem_nm | VARCHAR(100) | 회원명 |
|
||||
|
||||
---
|
||||
|
||||
## 기타 테이블
|
||||
|
||||
| 테이블 | 행수 | 설명 |
|
||||
|--------|------|------|
|
||||
| apc_subnames | 0 | APC 별칭 (미사용) |
|
||||
| cs_memo | 13 | CS 메모 |
|
||||
| excluded_pharmacies | 15 | 제외 약국 |
|
||||
| evidence_reference | 0 | 근거 문헌 참조 |
|
||||
| recommendation_log | 3 | 추천 로그 |
|
||||
| supplementary_product | 5 | 보조제품 |
|
||||
| optimal_stock | 3 | 적정재고 설정 |
|
||||
| sync_status | 168 | 동기화 상태 |
|
||||
| system_log | 438 | 시스템 로그 |
|
||||
| location | 4 | 보관 위치 |
|
||||
| region / subregion | 3/8 | 지역 구분 |
|
||||
| member_group_change_logs | 4 | 회원그룹 변경 이력 |
|
||||
|
||||
---
|
||||
|
||||
## 주요 쿼리 예시
|
||||
|
||||
```sql
|
||||
-- APC로 제품 조회
|
||||
SELECT * FROM apc WHERE apc = '0230338510101';
|
||||
|
||||
-- 제품명 검색 (띄어쓰기 무시)
|
||||
SELECT apc, product_name
|
||||
FROM apc
|
||||
WHERE REGEXP_REPLACE(LOWER(product_name), '[\s\-\.]+', '', 'g')
|
||||
LIKE '%파라캅%';
|
||||
|
||||
-- 체중별 제품 검색
|
||||
SELECT apc, product_name, weight_min_kg, weight_max_kg
|
||||
FROM apc
|
||||
WHERE weight_min_kg IS NOT NULL
|
||||
ORDER BY product_name;
|
||||
|
||||
-- 대표 APC → 포장단위 APC 조회 (앞 8자리 기준)
|
||||
SELECT apc, product_name, packaging
|
||||
FROM apc
|
||||
WHERE LEFT(apc, 8) = '02303385';
|
||||
|
||||
-- 성분별 제품 검색
|
||||
SELECT a.apc, a.product_name, a.component_name_ko
|
||||
FROM apc a
|
||||
WHERE a.component_code = 'P001';
|
||||
|
||||
-- 증상 → 성분 → 제품 검색
|
||||
SELECT s.symptom_description, cc.component_name_ko, a.product_name
|
||||
FROM symptoms s
|
||||
JOIN symptom_component_mapping scm ON s.symptom_code = scm.symptom_code
|
||||
JOIN component_code cc ON cc.code = scm.component_code
|
||||
JOIN apc a ON a.component_code = cc.code;
|
||||
```
|
||||
153
docs/통계최적화노트.md
Normal file
153
docs/통계최적화노트.md
Normal file
@ -0,0 +1,153 @@
|
||||
# 📊 재고 분석 통계 최적화 노트
|
||||
|
||||
> 재고량 vs 사용량 비교 그래프의 추세 분석 로직을 최적화하는 과정 기록
|
||||
|
||||
---
|
||||
|
||||
## 📌 커밋 히스토리 (롤백 포인트)
|
||||
|
||||
| 커밋 | Hash | 설명 | 특징 |
|
||||
|------|------|------|------|
|
||||
| 재고 변화만 | `2ca35cd` | 재고 변화 추이 그래프 (단일 Y축) | 보라색 라인만, 깔끔함 |
|
||||
| 이중 Y축 v1 | `0b81999` | 재고 + 사용량 비교 (전반부/후반부 합) | 피크에 민감한 문제 |
|
||||
| 이중 Y축 v2 | *(현재)* | 재고 + 사용량 비교 (최근 3개월 평균) | 피크 영향 줄임 |
|
||||
|
||||
### 롤백 방법
|
||||
```bash
|
||||
# 재고 변화만 있던 깔끔한 버전으로 돌아가기
|
||||
git checkout 2ca35cd -- backend/templates/admin_stock_analytics.html backend/app.py
|
||||
|
||||
# 이중 Y축 v1으로 돌아가기
|
||||
git checkout 0b81999 -- backend/templates/admin_stock_analytics.html backend/app.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 추세 분석 로직 변천사
|
||||
|
||||
### v1: 전반부 합 vs 후반부 합 (❌ 폐기)
|
||||
|
||||
```javascript
|
||||
// 데이터를 반으로 나눠서 총합 비교
|
||||
const half = Math.floor(items.length / 2);
|
||||
const firstHalfUsage = items.slice(0, half).reduce((sum, i) => sum + i.rx_usage, 0);
|
||||
const secondHalfUsage = items.slice(half).reduce((sum, i) => sum + i.rx_usage, 0);
|
||||
const usageChange = secondHalfUsage - firstHalfUsage;
|
||||
```
|
||||
|
||||
**문제점:**
|
||||
- 19개월 데이터 → 앞 9개월 vs 뒤 10개월
|
||||
- 후반부에 피크(예: 2025-06)가 하나만 있어도 "증가 추세"로 판정
|
||||
- 최근 3개월이 계속 떨어져도 피크 하나 때문에 잘못된 결과
|
||||
|
||||
**실제 사례 (테라펜세미정):**
|
||||
- 눈으로 보면: 명백한 감소 추세 (6,500 → 1,000)
|
||||
- 로직 결과: "+13,457 (+37%)" 증가 추세 ← 틀림!
|
||||
- 원인: 2025-06 피크(~7,000)가 후반부 총합을 뻥튀기
|
||||
|
||||
---
|
||||
|
||||
### v2: 최근 3개월 평균 vs 이전 3개월 평균 (현재)
|
||||
|
||||
```javascript
|
||||
// 최근 3개 기간 vs 그 이전 3개 기간의 평균 비교
|
||||
const recentCount = Math.min(3, Math.floor(items.length / 2));
|
||||
const recentItems = items.slice(-recentCount);
|
||||
const previousItems = items.slice(-recentCount * 2, -recentCount);
|
||||
|
||||
const recentAvg = recentItems.reduce((sum, i) => sum + i.rx_usage, 0) / recentItems.length;
|
||||
const previousAvg = previousItems.reduce((sum, i) => sum + i.rx_usage, 0) / previousItems.length;
|
||||
|
||||
const usageChange = Math.round(recentAvg - previousAvg);
|
||||
const usageChangePercent = previousAvg > 0 ? Math.round((usageChange / previousAvg) * 100) : 0;
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- 중간의 피크에 영향 안 받음
|
||||
- "최근" 변화를 정확히 감지
|
||||
- 직관적인 결과
|
||||
|
||||
**단점:**
|
||||
- 월별 분석 시 6개월만 봄 (더 긴 추세 놓칠 수 있음)
|
||||
- 계절성 반영 안 됨
|
||||
|
||||
---
|
||||
|
||||
## 🎯 고려 중인 대안들
|
||||
|
||||
### 옵션 A: 전월 대비 (MoM)
|
||||
```javascript
|
||||
const lastMonth = items[items.length - 1].rx_usage;
|
||||
const prevMonth = items[items.length - 2].rx_usage;
|
||||
const change = lastMonth - prevMonth;
|
||||
```
|
||||
- ✅ 가장 직관적 ("지난달 대비 얼마?")
|
||||
- ✅ 비즈니스에서 표준
|
||||
- ❌ 단기 변동에 민감
|
||||
|
||||
### 옵션 B: 이동평균 (Moving Average)
|
||||
```javascript
|
||||
// 3개월 이동평균 계산 후 기울기 비교
|
||||
const ma3 = items.map((item, i, arr) => {
|
||||
if (i < 2) return null;
|
||||
return (arr[i].rx_usage + arr[i-1].rx_usage + arr[i-2].rx_usage) / 3;
|
||||
}).filter(v => v !== null);
|
||||
```
|
||||
- ✅ 노이즈 제거
|
||||
- ✅ 그래프에 추세선으로 표시 가능
|
||||
- ❌ 구현 복잡
|
||||
|
||||
### 옵션 C: 선형 회귀 기울기
|
||||
```javascript
|
||||
// 최소자승법으로 기울기 계산
|
||||
function linearRegression(data) {
|
||||
const n = data.length;
|
||||
const sumX = data.reduce((s, _, i) => s + i, 0);
|
||||
const sumY = data.reduce((s, v) => s + v, 0);
|
||||
const sumXY = data.reduce((s, v, i) => s + i * v, 0);
|
||||
const sumX2 = data.reduce((s, _, i) => s + i * i, 0);
|
||||
return (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
|
||||
}
|
||||
```
|
||||
- ✅ 통계적으로 정확
|
||||
- ✅ 전체 데이터 활용
|
||||
- ❌ 학술적, 직관성 떨어짐
|
||||
|
||||
### 옵션 D: 복합 (추천 예정)
|
||||
- **전월 대비**: 카드에 숫자로 표시
|
||||
- **3개월 이동평균**: 그래프에 추세선으로 표시
|
||||
- **추세 판정**: 이동평균 기울기로
|
||||
|
||||
---
|
||||
|
||||
## 📝 TODO
|
||||
|
||||
- [ ] 전월 대비 수치 추가
|
||||
- [ ] 이동평균 추세선 그래프에 표시
|
||||
- [ ] 계절성 고려 (전년 동월 대비)
|
||||
- [ ] 일별 분석 시 7일 이동평균 적용
|
||||
- [ ] 추세 판정 기준값 튜닝 (현재 ±10%)
|
||||
|
||||
---
|
||||
|
||||
## 📅 변경 이력
|
||||
|
||||
| 날짜 | 변경 내용 |
|
||||
|------|-----------|
|
||||
| 2026-03-13 | 이중 Y축 그래프 최초 구현 (전반부/후반부 합) |
|
||||
| 2026-03-13 | 추세 로직 수정 (최근 3개월 평균으로 변경) |
|
||||
| 2026-03-13 | 최적화 노트 문서 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ 참고: 이전 버전 그래프
|
||||
|
||||
### 재고 변화만 (단일 Y축) - `2ca35cd`
|
||||
- 보라색 라인 하나로 재고 추이만 표시
|
||||
- 깔끔하고 심플함
|
||||
- 사용량 정보 없음
|
||||
|
||||
### 이중 Y축 v1 - `0b81999`
|
||||
- 왼쪽 Y축: 재고량 (보라색 라인)
|
||||
- 오른쪽 Y축: 처방 사용량 (파란색 바)
|
||||
- 추세 분석: 전반부/후반부 합 비교 (문제 있음)
|
||||
Loading…
Reference in New Issue
Block a user