diff --git a/backend/app.py b/backend/app.py index cfa7d90..9ea2a76 100644 --- a/backend/app.py +++ b/backend/app.py @@ -5375,6 +5375,267 @@ def api_admin_qr_print(): return jsonify({'success': False, 'error': str(e)}), 500 +# ============================================================================ +# OTC 용법 라벨 시스템 API +# ============================================================================ + +@app.route('/admin/otc-labels') +def admin_otc_labels(): + """OTC 용법 라벨 관리 페이지""" + return render_template('admin_otc_labels.html') + + +@app.route('/api/admin/otc-labels', methods=['GET']) +def api_get_otc_labels(): + """OTC 라벨 프리셋 목록 조회""" + try: + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, barcode, drug_code, display_name, effect, + dosage_instruction, usage_tip, use_wide_format, + print_count, last_printed_at, created_at, updated_at + FROM otc_label_presets + ORDER BY updated_at DESC + """) + + rows = cursor.fetchall() + labels = [dict(row) for row in rows] + + return jsonify({ + 'success': True, + 'count': len(labels), + 'labels': labels + }) + + except Exception as e: + logging.error(f"OTC 라벨 목록 조회 오류: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/admin/otc-labels/', methods=['GET']) +def api_get_otc_label(barcode): + """OTC 라벨 프리셋 단건 조회 (바코드 기준)""" + try: + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, barcode, drug_code, display_name, effect, + dosage_instruction, usage_tip, use_wide_format, + print_count, last_printed_at, created_at, updated_at + FROM otc_label_presets + WHERE barcode = ? + """, (barcode,)) + + row = cursor.fetchone() + + if not row: + return jsonify({'success': False, 'error': '등록된 프리셋이 없습니다.', 'exists': False}), 404 + + return jsonify({ + 'success': True, + 'exists': True, + 'label': dict(row) + }) + + except Exception as e: + logging.error(f"OTC 라벨 조회 오류: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/admin/otc-labels', methods=['POST']) +def api_upsert_otc_label(): + """OTC 라벨 프리셋 등록/수정 (Upsert)""" + try: + data = request.get_json() + + if not data or not data.get('barcode'): + return jsonify({'success': False, 'error': 'barcode는 필수입니다.'}), 400 + + barcode = data['barcode'] + drug_code = data.get('drug_code', '') + display_name = data.get('display_name', '') + effect = data.get('effect', '') + dosage_instruction = data.get('dosage_instruction', '') + usage_tip = data.get('usage_tip', '') + use_wide_format = data.get('use_wide_format', True) + + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + + # Upsert (INSERT OR REPLACE) + cursor.execute(""" + INSERT INTO otc_label_presets + (barcode, drug_code, display_name, effect, dosage_instruction, usage_tip, use_wide_format, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(barcode) DO UPDATE SET + drug_code = excluded.drug_code, + display_name = excluded.display_name, + effect = excluded.effect, + dosage_instruction = excluded.dosage_instruction, + usage_tip = excluded.usage_tip, + use_wide_format = excluded.use_wide_format, + updated_at = CURRENT_TIMESTAMP + """, (barcode, drug_code, display_name, effect, dosage_instruction, usage_tip, use_wide_format)) + + conn.commit() + + return jsonify({ + 'success': True, + 'message': f'라벨 프리셋 저장 완료 ({barcode})' + }) + + except Exception as e: + logging.error(f"OTC 라벨 저장 오류: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/admin/otc-labels/', methods=['DELETE']) +def api_delete_otc_label(barcode): + """OTC 라벨 프리셋 삭제""" + try: + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + + cursor.execute("DELETE FROM otc_label_presets WHERE barcode = ?", (barcode,)) + conn.commit() + + if cursor.rowcount > 0: + return jsonify({'success': True, 'message': f'삭제 완료 ({barcode})'}) + else: + return jsonify({'success': False, 'error': '존재하지 않는 프리셋'}), 404 + + except Exception as e: + logging.error(f"OTC 라벨 삭제 오류: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/admin/otc-labels/preview', methods=['POST']) +def api_preview_otc_label(): + """OTC 라벨 미리보기 이미지 생성""" + try: + data = request.get_json() + + if not data: + return jsonify({'success': False, 'error': '요청 데이터가 없습니다.'}), 400 + + drug_name = data.get('drug_name', '약품명') + effect = data.get('effect', '') + dosage_instruction = data.get('dosage_instruction', '') + usage_tip = data.get('usage_tip', '') + + from utils.otc_label_printer import generate_preview_image + + preview_url = generate_preview_image(drug_name, effect, dosage_instruction, usage_tip) + + if preview_url: + return jsonify({ + 'success': True, + 'preview_url': preview_url + }) + else: + return jsonify({'success': False, 'error': '미리보기 생성 실패'}), 500 + + except Exception as e: + logging.error(f"OTC 라벨 미리보기 오류: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/admin/otc-labels/print', methods=['POST']) +def api_print_otc_label(): + """OTC 라벨 인쇄 (Brother QL-810W)""" + try: + data = request.get_json() + + if not data: + return jsonify({'success': False, 'error': '요청 데이터가 없습니다.'}), 400 + + barcode = data.get('barcode') + drug_name = data.get('drug_name', '약품명') + effect = data.get('effect', '') + dosage_instruction = data.get('dosage_instruction', '') + usage_tip = data.get('usage_tip', '') + + from utils.otc_label_printer import print_otc_label + + success = print_otc_label(drug_name, effect, dosage_instruction, usage_tip) + + if success: + # 인쇄 횟수 업데이트 + if barcode: + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE otc_label_presets + SET print_count = print_count + 1, last_printed_at = CURRENT_TIMESTAMP + WHERE barcode = ? + """, (barcode,)) + conn.commit() + + return jsonify({ + 'success': True, + 'message': f'라벨 인쇄 완료: {drug_name}' + }) + else: + return jsonify({'success': False, 'error': '프린터 전송 실패'}), 500 + + except Exception as e: + logging.error(f"OTC 라벨 인쇄 오류: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/admin/otc-labels/search-mssql', methods=['GET']) +def api_search_mssql_drug(): + """MSSQL에서 약품 검색 (바코드 또는 이름)""" + try: + query = request.args.get('q', '').strip() + + if not query: + return jsonify({'success': False, 'error': '검색어를 입력해주세요.'}), 400 + + mssql_session = db_manager.get_session('PM_DRUG') + + # 바코드 또는 이름으로 검색 + sql = text(""" + SELECT TOP 20 + DrugCode, Barcode, GoodsName, Saleprice, StockQty + FROM CD_GOODS + WHERE (Barcode LIKE :query OR GoodsName LIKE :query) + AND Barcode IS NOT NULL + AND Barcode != '' + ORDER BY + CASE WHEN Barcode = :exact THEN 0 ELSE 1 END, + GoodsName + """) + + rows = mssql_session.execute(sql, { + 'query': f'%{query}%', + 'exact': query + }).fetchall() + + drugs = [] + for row in rows: + drugs.append({ + 'drug_code': row.DrugCode, + 'barcode': row.Barcode, + 'goods_name': row.GoodsName, + 'sale_price': float(row.Saleprice or 0), + 'stock_qty': int(row.StockQty or 0) + }) + + return jsonify({ + 'success': True, + 'count': len(drugs), + 'drugs': drugs + }) + + except Exception as e: + logging.error(f"MSSQL 약품 검색 오류: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + if __name__ == '__main__': import os diff --git a/backend/db/dbsetup.py b/backend/db/dbsetup.py index ab6886f..66c1415 100644 --- a/backend/db/dbsetup.py +++ b/backend/db/dbsetup.py @@ -397,6 +397,30 @@ class DatabaseManager: self.sqlite_conn.commit() print("[DB Manager] SQLite 마이그레이션: pets 테이블 생성") + # otc_label_presets 테이블 생성 (OTC 용법 라벨) + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='otc_label_presets'") + if not cursor.fetchone(): + cursor.executescript(""" + CREATE TABLE IF NOT EXISTS otc_label_presets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + barcode VARCHAR(20) NOT NULL UNIQUE, + drug_code VARCHAR(20), + display_name VARCHAR(100), + effect VARCHAR(100), + dosage_instruction TEXT, + usage_tip TEXT, + use_wide_format BOOLEAN DEFAULT TRUE, + print_count INTEGER DEFAULT 0, + last_printed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_otc_label_barcode ON otc_label_presets(barcode); + CREATE INDEX IF NOT EXISTS idx_otc_label_drug_code ON otc_label_presets(drug_code); + """) + self.sqlite_conn.commit() + print("[DB Manager] SQLite 마이그레이션: otc_label_presets 테이블 생성") + def test_connection(self, database='PM_BASE'): """연결 테스트""" try: diff --git a/backend/db/mileage_schema.sql b/backend/db/mileage_schema.sql index 64e5174..f0af9c6 100644 --- a/backend/db/mileage_schema.sql +++ b/backend/db/mileage_schema.sql @@ -145,3 +145,22 @@ CREATE TABLE IF NOT EXISTS pets ( CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id); CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species); + +-- 9. OTC 용법 라벨 테이블 (바코드 기준 오버라이드 데이터) +CREATE TABLE IF NOT EXISTS otc_label_presets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + barcode VARCHAR(20) NOT NULL UNIQUE, -- 바코드 (PK 역할) + drug_code VARCHAR(20), -- MSSQL DrugCode (참조용) + display_name VARCHAR(100), -- 표시 이름 (오버라이드, NULL이면 MSSQL 이름 사용) + effect VARCHAR(100), -- 효능 (예: "치통, 두통") + dosage_instruction TEXT, -- 용법 (예: "1일 3회, 1회 1정, 식후 30분") + usage_tip TEXT, -- 부가 설명 (예: "[통증 시에만 복용]") + use_wide_format BOOLEAN DEFAULT TRUE, -- 와이드 포맷 사용 여부 + print_count INTEGER DEFAULT 0, -- 인쇄 횟수 (통계용) + last_printed_at DATETIME, -- 마지막 인쇄 시간 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_otc_label_barcode ON otc_label_presets(barcode); +CREATE INDEX IF NOT EXISTS idx_otc_label_drug_code ON otc_label_presets(drug_code); diff --git a/backend/templates/admin_otc_labels.html b/backend/templates/admin_otc_labels.html new file mode 100644 index 0000000..ce4d1c3 --- /dev/null +++ b/backend/templates/admin_otc_labels.html @@ -0,0 +1,628 @@ + + + + + + OTC 용법 라벨 관리 - 청춘약국 + + + + + +
+
+
💊 OTC 용법 라벨 관리
+ +
+
+ +
+ +
+
✏️ 라벨 편집
+
+ + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ +
+
+
+
+ + +
+ +
+
👁️ 라벨 미리보기
+
+
+
미리보기를 클릭하면 라벨이 표시됩니다
+
+
+
+ + +
+
📋 저장된 라벨 프리셋
+
+
+ + + + + + + + + + + +
약품명효능인쇄
로딩 중...
+
+
+
+
+
+ + + + diff --git a/backend/utils/otc_label_printer.py b/backend/utils/otc_label_printer.py new file mode 100644 index 0000000..e587cb5 --- /dev/null +++ b/backend/utils/otc_label_printer.py @@ -0,0 +1,283 @@ +""" +OTC 용법 라벨 출력 모듈 +Brother QL-810W 프린터용 가로형 와이드 라벨 생성 및 출력 + +기반: person-lookup-web-local/print_label.py +""" + +from PIL import Image, ImageDraw, ImageFont +import logging +import re +from pathlib import Path + +# 프린터 설정 (QL-810W) +PRINTER_IP = "192.168.0.109" +PRINTER_MODEL = "QL-810W" +LABEL_TYPE = "29" # 29mm 연속 출력 용지 + +# 폰트 경로 (Windows/Linux 크로스 플랫폼) +FONT_PATHS = [ + "C:/Windows/Fonts/malgunbd.ttf", # Windows + "/srv/person-lookup-web-local/pop_maker/fonts/malgunbd.ttf", # Linux + "/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf", # Linux 대체 +] + +def get_font_path(): + """사용 가능한 폰트 경로 반환""" + for path in FONT_PATHS: + if Path(path).exists(): + return path + return None + + +def create_otc_label_image(drug_name, effect="", dosage_instruction="", usage_tip=""): + """ + OTC 용법 라벨 이미지 생성 (800 x 306px) + + 레이아웃: + - 효능: 중앙 상단에 크게 강조 (72pt) + - 약품명: 오른쪽 중간 (36pt) + - 용법: 왼쪽 하단 체크박스 (40pt) + - 약국명: 오른쪽 하단 테두리 박스 (32pt) + + Args: + drug_name (str): 약품명 + effect (str): 효능 + dosage_instruction (str): 복용 방법 + usage_tip (str): 사용 팁 + + Returns: + PIL.Image: 가로형 와이드 라벨 이미지 (800 x 306px, mode='1') + """ + try: + # 1. 캔버스 생성 (가로로 긴 형태) + width = 800 + height = 306 # Brother QL 29mm 용지 폭 + + img = Image.new('1', (width, height), 1) # 흰색 배경 + draw = ImageDraw.Draw(img) + + # 2. 폰트 로드 + font_path = get_font_path() + try: + font_effect = ImageFont.truetype(font_path, 72) # 효능 (매우 크게!) + font_drugname = ImageFont.truetype(font_path, 36) # 약품명 (중간) + font_dosage = ImageFont.truetype(font_path, 40) # 용법 (크게) + font_pharmacy = ImageFont.truetype(font_path, 32) # 약국명 (크게) + font_small = ImageFont.truetype(font_path, 26) # 사용팁 + except (IOError, TypeError): + font_effect = ImageFont.load_default() + font_drugname = ImageFont.load_default() + font_dosage = ImageFont.load_default() + font_pharmacy = ImageFont.load_default() + font_small = ImageFont.load_default() + logging.warning("폰트 로드 실패. 기본 폰트 사용.") + + # 3. 레이아웃 + x_margin = 25 + + # 효능 - 중앙 상단에 크게 (매우 강조!) + if effect: + effect_bbox = draw.textbbox((0, 0), effect, font=font_effect) + effect_width = effect_bbox[2] - effect_bbox[0] + effect_x = (width - effect_width) // 2 + # 굵게 표시 (offset) + for offset in [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]: + draw.text((effect_x + offset[0], 20 + offset[1]), effect, font=font_effect, fill=0) + + # 약품명 - 오른쪽 중간 여백에 배치 + drugname_bbox = draw.textbbox((0, 0), drug_name, font=font_drugname) + drugname_width = drugname_bbox[2] - drugname_bbox[0] + drugname_x = width - drugname_width - 30 # 오른쪽에서 30px 여백 + drugname_y = 195 + draw.text((drugname_x, drugname_y), drug_name, font=font_drugname, fill=0) + + # 용법 - 왼쪽 하단에 크게 표시 + y = 120 # 효능 아래부터 시작 + + # 사용팁이 없으면 복용방법을 더 크게 + if not usage_tip: + try: + font_dosage_adjusted = ImageFont.truetype(font_path, 50) + except: + font_dosage_adjusted = font_dosage + else: + font_dosage_adjusted = font_dosage + + if dosage_instruction: + # 대괄호로 묶인 부분을 별도 줄로 분리 + dosage_text = re.sub(r'\s*(\[.*?\])\s*', r'\n\1\n', dosage_instruction) + + # 여러 줄 처리 + max_chars_per_line = 32 + dosage_lines = [] + + text_parts = dosage_text.split('\n') + for part in text_parts: + part = part.strip() + if not part: + continue + + if part.startswith('[') and part.endswith(']'): + dosage_lines.append(part) + elif len(part) > max_chars_per_line: + words = part.split() + current_line = "" + for word in words: + if len(current_line + word) <= max_chars_per_line: + current_line += word + " " + else: + if current_line: + dosage_lines.append(current_line.strip()) + current_line = word + " " + if current_line: + dosage_lines.append(current_line.strip()) + else: + dosage_lines.append(part) + + # 첫 줄에 체크박스 추가 + if dosage_lines: + first_line = f"□ {dosage_lines[0]}" + draw.text((x_margin, y), first_line, font=font_dosage_adjusted, fill=0) + + line_spacing = 60 if not usage_tip else 50 + y += line_spacing + + for line in dosage_lines[1:]: + indent = 0 if (line.startswith('[') and line.endswith(']')) else 30 + draw.text((x_margin + indent, y), line, font=font_dosage_adjusted, fill=0) + y += line_spacing + 2 + + # 사용팁 (체크박스 + 텍스트) + if usage_tip and y < height - 60: + tip_text = f"□ {usage_tip}" + if len(tip_text) > 55: + tip_text = tip_text[:52] + "..." + draw.text((x_margin, y), tip_text, font=font_small, fill=0) + + # 약국명 - 오른쪽 하단에 크게 (테두리 박스) + sign_text = "청춘약국" + sign_bbox = draw.textbbox((0, 0), sign_text, font=font_pharmacy) + sign_width = sign_bbox[2] - sign_bbox[0] + sign_height = sign_bbox[3] - sign_bbox[1] + + sign_padding_lr = 10 + sign_padding_top = 5 + sign_padding_bottom = 10 + + sign_x = width - sign_width - x_margin - 10 - sign_padding_lr + sign_y = height - 55 + + # 테두리 박스 그리기 + box_x1 = sign_x - sign_padding_lr + box_y1 = sign_y - sign_padding_top + box_x2 = sign_x + sign_width + sign_padding_lr + box_y2 = sign_y + sign_height + sign_padding_bottom + draw.rectangle([box_x1, box_y1, box_x2, box_y2], outline=0, width=2) + + # 약국명 텍스트 (굵게) + for offset in [(0, 0), (1, 0), (0, 1), (1, 1)]: + draw.text((sign_x + offset[0], sign_y + offset[1]), sign_text, font=font_pharmacy, fill=0) + + # 5. 테두리 (가위선 스타일) + for i in range(3): + draw.rectangle([5 + i, 5 + i, width - 5 - i, height - 5 - i], outline=0) + + logging.info(f"OTC 라벨 이미지 생성 성공: {drug_name}") + return img + + except Exception as e: + logging.error(f"OTC 라벨 이미지 생성 실패: {e}") + raise + + +def print_otc_label(drug_name, effect="", dosage_instruction="", usage_tip=""): + """ + OTC 용법 라벨을 Brother QL-810W 프린터로 출력 + + Args: + drug_name (str): 약품명 + effect (str): 효능 + dosage_instruction (str): 복용 방법 + usage_tip (str): 사용 팁 + + Returns: + bool: 성공 여부 + """ + try: + from brother_ql.raster import BrotherQLRaster + from brother_ql.conversion import convert + from brother_ql.backends.helpers import send + + # 1. 라벨 이미지 생성 + label_img = create_otc_label_image(drug_name, effect, dosage_instruction, usage_tip) + + # 2. 이미지 90도 회전 (Brother QL이 세로 방향 기준이므로) + label_img_rotated = label_img.rotate(90, expand=True) + + logging.info(f"이미지 회전 완료: {label_img_rotated.size}") + + # 3. Brother QL 프린터로 전송 + qlr = BrotherQLRaster(PRINTER_MODEL) + instructions = convert( + qlr=qlr, + images=[label_img_rotated], + label='29', + rotate='0', + threshold=70.0, + dither=False, + compress=False, + red=False, + dpi_600=False, + hq=True, + cut=True + ) + send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100") + + logging.info(f"[SUCCESS] OTC 용법 라벨 인쇄 성공: {drug_name}") + return True + + except ImportError: + logging.error("brother_ql 라이브러리가 설치되지 않았습니다.") + return False + except Exception as e: + logging.error(f"[ERROR] OTC 용법 라벨 인쇄 실패: {e}") + return False + + +def generate_preview_image(drug_name, effect="", dosage_instruction="", usage_tip=""): + """ + 미리보기용 PNG 이미지 생성 (Base64 인코딩) + + Args: + drug_name (str): 약품명 + effect (str): 효능 + dosage_instruction (str): 복용 방법 + usage_tip (str): 사용 팁 + + Returns: + str: Base64 인코딩된 PNG 이미지 (data:image/png;base64,... 형태) + """ + import base64 + from io import BytesIO + + try: + # 라벨 이미지 생성 + label_img = create_otc_label_image(drug_name, effect, dosage_instruction, usage_tip) + + # RGB로 변환 (1-bit → RGB) + label_img_rgb = label_img.convert('RGB') + + # PNG로 인코딩 + buffer = BytesIO() + label_img_rgb.save(buffer, format='PNG') + buffer.seek(0) + + # Base64 인코딩 + img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') + + return f"data:image/png;base64,{img_base64}" + + except Exception as e: + logging.error(f"미리보기 이미지 생성 실패: {e}") + return None