feat: AI 기반 제품 카테고리 자동 태깅 및 UI 표시
- OpenAI GPT-4o-mini로 31개 제품 자동 분류 (100% 커버리지) - 관리자 페이지 사용자 상세 모달에 카테고리 뱃지 추가 - BARCODE 기반 제품-카테고리 매핑 (many-to-many) - 카테고리별 색상 구분 (10가지 그라디언트 디자인) - 제품 수동 분류 도구 추가 (update_product_category.py) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6026f0aae8
commit
70d18a1954
232
backend/ai_tag_products.py
Normal file
232
backend/ai_tag_products.py
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
"""
|
||||||
|
AI 기반 제품 자동 카테고리 태깅
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# UTF-8 인코딩 강제 (Windows 한글 깨짐 방지)
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
import io
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
from openai import OpenAI
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# .env 파일 로드
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# OpenAI 클라이언트 초기화
|
||||||
|
client = OpenAI(api_key=os.getenv('OPENAI_API_KEY'))
|
||||||
|
|
||||||
|
def get_uncategorized_products():
|
||||||
|
"""카테고리가 없는 제품 조회"""
|
||||||
|
db_path = os.path.join(os.path.dirname(__file__), 'db', 'mileage.db')
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 카테고리가 없는 제품 조회
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT p.barcode, p.product_name
|
||||||
|
FROM product_master p
|
||||||
|
LEFT JOIN product_category_mapping m ON p.barcode = m.barcode
|
||||||
|
WHERE m.barcode IS NULL
|
||||||
|
ORDER BY p.product_name
|
||||||
|
""")
|
||||||
|
|
||||||
|
products = cursor.fetchall()
|
||||||
|
return [(barcode, name) for barcode, name in products]
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_categories():
|
||||||
|
"""사용 가능한 카테고리 목록 조회"""
|
||||||
|
db_path = os.path.join(os.path.dirname(__file__), 'db', 'mileage.db')
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT category_name, description FROM product_categories ORDER BY category_name")
|
||||||
|
categories = cursor.fetchall()
|
||||||
|
return categories
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def ai_categorize_product(product_name, available_categories):
|
||||||
|
"""OpenAI API로 제품 카테고리 분류"""
|
||||||
|
|
||||||
|
# 카테고리 목록 포맷팅
|
||||||
|
category_list = "\n".join([f"- {cat[0]}: {cat[1] or '(설명 없음)'}" for cat in available_categories])
|
||||||
|
|
||||||
|
prompt = f"""당신은 약국 제품 분류 전문가입니다.
|
||||||
|
|
||||||
|
제품명: {product_name}
|
||||||
|
|
||||||
|
아래 카테고리 목록에서 이 제품에 가장 적합한 카테고리를 1~3개 선택하고, 각 카테고리의 관련도 점수(0.0~1.0)를 매겨주세요.
|
||||||
|
|
||||||
|
사용 가능한 카테고리:
|
||||||
|
{category_list}
|
||||||
|
|
||||||
|
응답은 반드시 아래 JSON 형식으로만 작성해주세요:
|
||||||
|
{{
|
||||||
|
"categories": [
|
||||||
|
{{"name": "카테고리명", "score": 1.0, "reason": "선택 이유"}},
|
||||||
|
{{"name": "카테고리명", "score": 0.8, "reason": "선택 이유"}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
주의사항:
|
||||||
|
1. 가장 관련 있는 카테고리 1~3개만 선택
|
||||||
|
2. score는 0.0~1.0 범위 (주 카테고리는 1.0, 부 카테고리는 0.5~0.9)
|
||||||
|
3. 카테고리명은 위 목록에 있는 것만 사용
|
||||||
|
4. JSON 형식 외 다른 텍스트 추가하지 말 것"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model="gpt-4o-mini",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "당신은 의약품 분류 전문가입니다. 항상 JSON 형식으로만 응답하세요."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=500
|
||||||
|
)
|
||||||
|
|
||||||
|
result_text = response.choices[0].message.content.strip()
|
||||||
|
|
||||||
|
# JSON 파싱
|
||||||
|
result = json.loads(result_text)
|
||||||
|
return result['categories']
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"[ERROR] JSON 파싱 실패: {e}")
|
||||||
|
print(f"응답: {result_text}")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] AI 분류 실패: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def update_product_categories(barcode, categories):
|
||||||
|
"""제품 카테고리 매핑 업데이트"""
|
||||||
|
db_path = os.path.join(os.path.dirname(__file__), 'db', 'mileage.db')
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for cat in categories:
|
||||||
|
# 카테고리가 존재하는지 확인
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT category_name FROM product_categories WHERE category_name = ?",
|
||||||
|
(cat['name'],)
|
||||||
|
)
|
||||||
|
|
||||||
|
if cursor.fetchone():
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO product_category_mapping
|
||||||
|
(barcode, category_name, relevance_score)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""", (barcode, cat['name'], cat['score']))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] DB 업데이트 실패: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""메인 실행"""
|
||||||
|
print("="*80)
|
||||||
|
print("AI 기반 제품 자동 카테고리 태깅")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
# 1. 카테고리가 없는 제품 조회
|
||||||
|
print("\n1단계: 카테고리 없는 제품 조회 중...")
|
||||||
|
uncategorized = get_uncategorized_products()
|
||||||
|
|
||||||
|
if not uncategorized:
|
||||||
|
print("[OK] 모든 제품이 이미 카테고리가 있습니다!")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"[OK] {len(uncategorized)}개 제품 발견\n")
|
||||||
|
|
||||||
|
# 2. 사용 가능한 카테고리 조회
|
||||||
|
print("2단계: 사용 가능한 카테고리 조회 중...")
|
||||||
|
categories = get_available_categories()
|
||||||
|
print(f"[OK] {len(categories)}개 카테고리 사용 가능\n")
|
||||||
|
|
||||||
|
# 3. 각 제품 AI 태깅
|
||||||
|
print("3단계: AI 기반 제품 분류 시작...\n")
|
||||||
|
print("-"*80)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
|
||||||
|
for idx, (barcode, product_name) in enumerate(uncategorized, 1):
|
||||||
|
print(f"\n[{idx}/{len(uncategorized)}] {product_name}")
|
||||||
|
|
||||||
|
# AI로 카테고리 분류
|
||||||
|
suggested_categories = ai_categorize_product(product_name, categories)
|
||||||
|
|
||||||
|
if not suggested_categories:
|
||||||
|
print(f" [SKIP] AI 분류 실패")
|
||||||
|
fail_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 카테고리 출력
|
||||||
|
for cat in suggested_categories:
|
||||||
|
print(f" ├─ {cat['name']} (관련도: {cat['score']:.1f}) - {cat.get('reason', '')}")
|
||||||
|
|
||||||
|
# DB 업데이트
|
||||||
|
if update_product_categories(barcode, suggested_categories):
|
||||||
|
print(f" └─ [OK] 카테고리 매핑 완료")
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
print(f" └─ [ERROR] DB 업데이트 실패")
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
# 4. 결과 요약
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print("AI 태깅 완료")
|
||||||
|
print("="*80)
|
||||||
|
print(f"성공: {success_count}개")
|
||||||
|
print(f"실패: {fail_count}개")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
# 5. 최종 통계
|
||||||
|
db_path = os.path.join(os.path.dirname(__file__), 'db', 'mileage.db')
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM product_master")
|
||||||
|
total = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(DISTINCT barcode) FROM product_category_mapping")
|
||||||
|
categorized = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
print(f"\n최종 통계:")
|
||||||
|
print(f" 전체 제품: {total}개")
|
||||||
|
print(f" 카테고리 있는 제품: {categorized}개")
|
||||||
|
print(f" 카테고리 없는 제품: {total - categorized}개")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@ -818,9 +818,10 @@ def admin_user_detail(user_id):
|
|||||||
else:
|
else:
|
||||||
transaction_date = '-'
|
transaction_date = '-'
|
||||||
|
|
||||||
# SALE_SUB + CD_GOODS JOIN
|
# SALE_SUB + CD_GOODS JOIN (BARCODE 추가)
|
||||||
sale_items_query = text("""
|
sale_items_query = text("""
|
||||||
SELECT
|
SELECT
|
||||||
|
S.BARCODE,
|
||||||
S.DrugCode,
|
S.DrugCode,
|
||||||
ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name,
|
ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name,
|
||||||
S.SL_NM_item AS quantity,
|
S.SL_NM_item AS quantity,
|
||||||
@ -837,17 +838,36 @@ def admin_user_detail(user_id):
|
|||||||
{'transaction_id': transaction_id}
|
{'transaction_id': transaction_id}
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
# 상품 리스트 변환
|
# 상품 리스트 변환 (카테고리 포함)
|
||||||
items = [
|
items = []
|
||||||
{
|
for item in items_raw:
|
||||||
|
barcode = item.BARCODE
|
||||||
|
|
||||||
|
# SQLite에서 제품 카테고리 조회
|
||||||
|
categories = []
|
||||||
|
if barcode:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT category_name, relevance_score
|
||||||
|
FROM product_category_mapping
|
||||||
|
WHERE barcode = ?
|
||||||
|
ORDER BY relevance_score DESC
|
||||||
|
""", (barcode,))
|
||||||
|
|
||||||
|
for cat_row in cursor.fetchall():
|
||||||
|
categories.append({
|
||||||
|
'name': cat_row[0],
|
||||||
|
'score': cat_row[1]
|
||||||
|
})
|
||||||
|
|
||||||
|
items.append({
|
||||||
'code': item.DrugCode,
|
'code': item.DrugCode,
|
||||||
|
'barcode': barcode,
|
||||||
'name': item.goods_name,
|
'name': item.goods_name,
|
||||||
'qty': int(item.quantity or 0),
|
'qty': int(item.quantity or 0),
|
||||||
'price': int(item.price or 0),
|
'price': int(item.price or 0),
|
||||||
'total': int(item.total or 0)
|
'total': int(item.total or 0),
|
||||||
}
|
'categories': categories
|
||||||
for item in items_raw
|
})
|
||||||
]
|
|
||||||
|
|
||||||
# 상품 요약 생성 ("첫번째상품명 외 N개")
|
# 상품 요약 생성 ("첫번째상품명 외 N개")
|
||||||
if items:
|
if items:
|
||||||
|
|||||||
@ -352,6 +352,38 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 카테고리 뱃지 스타일 */
|
||||||
|
.category-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
margin: 2px 4px 2px 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 제품 카테고리별 색상 */
|
||||||
|
.category-badge.cat-진통제 { background: linear-gradient(135deg, #f06292 0%, #e91e63 100%); }
|
||||||
|
.category-badge.cat-소화제 { background: linear-gradient(135deg, #64b5f6 0%, #1976d2 100%); }
|
||||||
|
.category-badge.cat-감기약 { background: linear-gradient(135deg, #4db6ac 0%, #00796b 100%); }
|
||||||
|
.category-badge.cat-복합비타민 { background: linear-gradient(135deg, #ffb74d 0%, #f57c00 100%); }
|
||||||
|
.category-badge.cat-피로회복제 { background: linear-gradient(135deg, #a1887f 0%, #6d4c41 100%); }
|
||||||
|
.category-badge.cat-소염제 { background: linear-gradient(135deg, #ff8a65 0%, #d84315 100%); }
|
||||||
|
.category-badge.cat-연고 { background: linear-gradient(135deg, #90a4ae 0%, #546e7a 100%); }
|
||||||
|
.category-badge.cat-파스 { background: linear-gradient(135deg, #81c784 0%, #388e3c 100%); }
|
||||||
|
.category-badge.cat-간영양제 { background: linear-gradient(135deg, #ba68c8 0%, #8e24aa 100%); }
|
||||||
|
.category-badge.cat-위장약 { background: linear-gradient(135deg, #4fc3f7 0%, #0288d1 100%); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -960,10 +992,21 @@
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
purchase.items.forEach(item => {
|
purchase.items.forEach(item => {
|
||||||
|
// 카테고리 뱃지 생성
|
||||||
|
let categoriesBadges = '';
|
||||||
|
if (item.categories && item.categories.length > 0) {
|
||||||
|
item.categories.forEach(cat => {
|
||||||
|
categoriesBadges += `<span class="category-badge cat-${cat.name}">${cat.name}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<tr style="border-bottom: 1px solid #f1f3f5;">
|
<tr style="border-bottom: 1px solid #f1f3f5;">
|
||||||
<td style="padding: 10px; font-size: 13px; color: #495057;">${item.code}</td>
|
<td style="padding: 10px; font-size: 13px; color: #495057;">${item.code}</td>
|
||||||
<td style="padding: 10px; font-size: 13px; color: #212529; font-weight: 500;">${item.name}</td>
|
<td style="padding: 10px; font-size: 13px; color: #212529; font-weight: 500;">
|
||||||
|
<div style="margin-bottom: 4px;">${item.name}</div>
|
||||||
|
${categoriesBadges ? `<div style="margin-top: 6px;">${categoriesBadges}</div>` : ''}
|
||||||
|
</td>
|
||||||
<td style="padding: 10px; text-align: right; font-size: 13px; color: #495057;">${item.qty}</td>
|
<td style="padding: 10px; text-align: right; font-size: 13px; color: #495057;">${item.qty}</td>
|
||||||
<td style="padding: 10px; text-align: right; font-size: 13px; color: #495057;">${item.price.toLocaleString()}원</td>
|
<td style="padding: 10px; text-align: right; font-size: 13px; color: #495057;">${item.price.toLocaleString()}원</td>
|
||||||
<td style="padding: 10px; text-align: right; font-size: 13px; color: #6366f1; font-weight: 600;">${item.total.toLocaleString()}원</td>
|
<td style="padding: 10px; text-align: right; font-size: 13px; color: #6366f1; font-weight: 600;">${item.total.toLocaleString()}원</td>
|
||||||
|
|||||||
107
backend/update_product_category.py
Normal file
107
backend/update_product_category.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"""
|
||||||
|
제품 카테고리 수동 업데이트
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
# UTF-8 인코딩 강제 (Windows 한글 깨짐 방지)
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
import io
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def update_category(product_name, category_name, relevance_score=1.0):
|
||||||
|
"""제품 카테고리 업데이트"""
|
||||||
|
db_path = os.path.join(os.path.dirname(__file__), 'db', 'mileage.db')
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 제품 바코드 조회
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT barcode FROM product_master WHERE product_name = ?",
|
||||||
|
(product_name,)
|
||||||
|
)
|
||||||
|
result = cursor.fetchone()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
print(f"[ERROR] '{product_name}' 제품을 찾을 수 없습니다.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
barcode = result[0]
|
||||||
|
|
||||||
|
# 카테고리 매핑 추가 (이미 있으면 업데이트)
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO product_category_mapping
|
||||||
|
(barcode, category_name, relevance_score)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""", (barcode, category_name, relevance_score))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(f"[OK] '{product_name}' → '{category_name}' (관련도: {relevance_score})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] {e}")
|
||||||
|
conn.rollback()
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def show_stats():
|
||||||
|
"""제품 통계 조회"""
|
||||||
|
db_path = os.path.join(os.path.dirname(__file__), 'db', 'mileage.db')
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 전체 제품 수
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM product_master")
|
||||||
|
total = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# 카테고리가 있는 제품 수
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(DISTINCT barcode)
|
||||||
|
FROM product_category_mapping
|
||||||
|
""")
|
||||||
|
categorized = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# 카테고리가 없는 제품 수
|
||||||
|
uncategorized = total - categorized
|
||||||
|
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print("제품 통계")
|
||||||
|
print("="*80)
|
||||||
|
print(f"전체 제품 수: {total}개")
|
||||||
|
print(f"카테고리 있는 제품: {categorized}개")
|
||||||
|
print(f"카테고리 없는 제품: {uncategorized}개")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
# 카테고리별 제품 수
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT category_name, COUNT(*) as count
|
||||||
|
FROM product_category_mapping
|
||||||
|
GROUP BY category_name
|
||||||
|
ORDER BY count DESC
|
||||||
|
""")
|
||||||
|
print("\n카테고리별 제품 수:")
|
||||||
|
for cat, count in cursor.fetchall():
|
||||||
|
print(f" {cat:20} : {count:3}개")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("제품 카테고리 업데이트")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
# 소하자임플러스정 → 소화제
|
||||||
|
update_category("소하자임플러스정", "소화제", 1.0)
|
||||||
|
|
||||||
|
# 통계 출력
|
||||||
|
show_stats()
|
||||||
Loading…
Reference in New Issue
Block a user