diff --git a/backend/samples/barcode_print.py b/backend/samples/barcode_print.py new file mode 100644 index 0000000..22a3cad --- /dev/null +++ b/backend/samples/barcode_print.py @@ -0,0 +1,372 @@ +""" +바코드 스캔 시 간단한 라벨 자동 출력 모듈 +Brother QL-810W 프린터용 +""" + +from PIL import Image, ImageDraw, ImageFont +from brother_ql.raster import BrotherQLRaster +from brother_ql.conversion import convert +from brother_ql.backends.helpers import send +import os +import logging +import re +from datetime import datetime +import glob + +# 프린터 설정 +PRINTER_IP = "192.168.0.168" +PRINTER_PORT = 9100 +PRINTER_MODEL = "QL-810W" +LABEL_TYPE = "29" + +# 로깅 설정 +logging.basicConfig(level=logging.INFO, format='[BARCODE_PRINT] %(levelname)s: %(message)s') + + +def normalize_medication_name(med_name): + """ + 약품명 정제 (print_label.py의 함수 복사) + - 괄호 제거 + - 밀리그램 → mg 변환 + - 대괄호 제거 + + Args: + med_name: 약품명 + + Returns: + str: 정제된 약품명 + """ + 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'\(.*$', '', 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'(? 20: # 최소 20pt까지 축소 + font_drugname_dynamic = ImageFont.truetype(font_path, drugname_font_size) + drugname_bbox = draw.textbbox((0, 0), drugname_text, font=font_drugname_dynamic) + drugname_width = drugname_bbox[2] - drugname_bbox[0] + + if drugname_width <= max_drugname_width: + break + drugname_font_size -= 2 # 2pt씩 축소 + + # 효능과 같은 Y 위치 (중앙 정렬) + drugname_height = drugname_bbox[3] - drugname_bbox[1] + drugname_y = 20 + (72 - drugname_height) // 2 + draw.text((effect_end_x, drugname_y), drugname_text, font=font_drugname_dynamic, fill=0) + + # 용법 - 왼쪽 하단에 크게 표시 (동적 폰트 크기 조정) + y = 120 # 효능 아래부터 시작 + + if dosage_instruction: + # 대괄호로 묶인 부분을 별도 줄로 분리 + dosage_text = re.sub(r'\s*(\[.*?\])\s*', r'\n\1\n', dosage_instruction) + + # 여러 줄 처리 + 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) + # 일반 텍스트는 그대로 추가 (폰트 크기로 조정) + else: + dosage_lines.append(part) + + # 동적 폰트 크기 조정 (박스 안에 들어오도록) + max_dosage_width = width - x_margin - 50 # 좌우 여백 + dosage_font_size = 50 # 초기 폰트 크기 + + # 가장 긴 줄을 기준으로 폰트 크기 조정 + longest_line = max(dosage_lines, key=len) if dosage_lines else "" + test_line = f"□ {longest_line}" + + while dosage_font_size > 30: # 최소 30pt까지 축소 + font_dosage_dynamic = ImageFont.truetype(font_path, dosage_font_size) + test_bbox = draw.textbbox((0, 0), test_line, font=font_dosage_dynamic) + test_width = test_bbox[2] - test_bbox[0] + + if test_width <= max_dosage_width: + break + dosage_font_size -= 2 # 2pt씩 축소 + + # 첫 줄에 체크박스 추가 + if dosage_lines: + first_line = f"□ {dosage_lines[0]}" + draw.text((x_margin, y), first_line, font=font_dosage_dynamic, fill=0) + + # 줄 간격 조정 (폰트 크기에 비례) + line_spacing = int(dosage_font_size * 1.2) + 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_dynamic, 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) + + # 테두리 (가위선 스타일) + for i in range(3): + draw.rectangle([5 + i, 5 + i, width - 5 - i, height - 5 - i], outline=0) + + logging.info(f"가로형 와이드 라벨 이미지 생성 성공: {goods_name}") + return img + + except Exception as e: + logging.error(f"가로형 와이드 라벨 이미지 생성 실패: {e}") + raise + + +def cleanup_old_preview_files(max_files=10): + """ + 임시 미리보기 파일 정리 (최대 개수 초과 시 오래된 파일 삭제) + + Args: + max_files: 유지할 최대 파일 개수 + """ + try: + temp_dir = os.path.join(os.path.dirname(__file__), "temp") + if not os.path.exists(temp_dir): + return + + # label_preview_*.png 파일 목록 가져오기 + preview_files = glob.glob(os.path.join(temp_dir, "label_preview_*.png")) + + # 파일 개수가 max_files를 초과하면 오래된 파일 삭제 + if len(preview_files) > max_files: + # 생성 시간 기준으로 정렬 (오래된 순) + preview_files.sort(key=os.path.getmtime) + + # 초과된 파일 삭제 + files_to_delete = preview_files[:len(preview_files) - max_files] + for file_path in files_to_delete: + try: + os.remove(file_path) + logging.info(f"오래된 미리보기 파일 삭제: {file_path}") + except Exception as e: + logging.warning(f"파일 삭제 실패: {file_path} - {e}") + + except Exception as e: + logging.warning(f"미리보기 파일 정리 실패: {e}") + + +def print_barcode_label(goods_name, sale_price, preview_mode=False): + """ + 바코드 스캔 시 가로형 와이드 라벨 출력 또는 미리보기 + + Args: + goods_name: 약품명 + sale_price: 판매가 (호환성 유지용, 내부 미사용) + preview_mode: True = 이미지 파일 경로 반환, False = 프린터 전송 + + Returns: + preview_mode=True: (성공 여부, 이미지 파일 경로) + preview_mode=False: 성공 여부 (bool) + """ + try: + logging.info(f"가로형 와이드 라벨 {'미리보기' if preview_mode else '출력'} 시작: {goods_name}") + + # 1. 가로형 라벨 이미지 생성 + label_image = create_wide_label(goods_name, sale_price) + + # 2. 미리보기 모드: PNG 파일로 저장 + if preview_mode: + # temp 디렉터리 생성 + temp_dir = os.path.join(os.path.dirname(__file__), "temp") + os.makedirs(temp_dir, exist_ok=True) + + # 파일명 생성 (타임스탬프 포함) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + filename = f"label_preview_{timestamp}.png" + file_path = os.path.join(temp_dir, filename) + + # PNG로 저장 (회전하지 않은 가로형 이미지) + label_image.save(file_path, "PNG") + + logging.info(f"미리보기 이미지 저장 완료: {file_path}") + + # 오래된 파일 정리 + cleanup_old_preview_files(max_files=10) + + return True, file_path + + # 3. 실제 인쇄 모드: 프린터로 전송 + else: + # 이미지 90도 회전 (시계 반대방향) + # Brother QL은 세로 방향 기준이므로 가로형 이미지를 회전 + label_image_rotated = label_image.rotate(90, expand=True) + + logging.info(f"이미지 회전 완료: {label_image_rotated.size}") + + # Brother QL Raster 객체 생성 + qlr = BrotherQLRaster(PRINTER_MODEL) + + # PIL 이미지를 Brother QL 형식으로 변환 + instructions = convert( + qlr=qlr, + images=[label_image_rotated], + label=LABEL_TYPE, + rotate="0", # 이미 회전했으므로 0 + threshold=70.0, # 흑백 변환 임계값 + dither=False, # 디더링 비활성화 (선명한 텍스트) + compress=False, # 압축 비활성화 + red=False, # 흑백 전용 + dpi_600=False, + hq=True, # 고품질 모드 + cut=True # 자동 절단 + ) + + # 프린터로 전송 + printer_identifier = f"tcp://{PRINTER_IP}:{PRINTER_PORT}" + send(instructions, printer_identifier=printer_identifier) + + logging.info(f"가로형 와이드 라벨 출력 성공: {goods_name}") + return True + + except Exception as e: + logging.error(f"가로형 와이드 라벨 {'미리보기' if preview_mode else '출력'} 실패: {e}") + if preview_mode: + return False, None + else: + return False + + +if __name__ == "__main__": + # 테스트 코드 + test_result = print_barcode_label("타이레놀정500mg", 3000.0) + print(f"테스트 결과: {'성공' if test_result else '실패'}") diff --git a/backend/samples/barcode_reader.py b/backend/samples/barcode_reader.py new file mode 100644 index 0000000..86210b9 --- /dev/null +++ b/backend/samples/barcode_reader.py @@ -0,0 +1,150 @@ +""" +허니웰 바코드 리더기 COM3 포트 리딩 프로그램 +바코드 스캔 시 터미널에 실시간 출력 +""" + +import serial +import sys +import io +from datetime import datetime + +# Windows cp949 인코딩 문제 해결 +if sys.platform == 'win32': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') + +def read_barcode_from_com3(port='COM3', baudrate=9600, timeout=1): + """ + COM3 포트에서 바코드 데이터를 읽어 터미널에 출력 + + Args: + port: COM 포트 번호 (기본값: COM3) + baudrate: 통신 속도 (기본값: 9600, 허니웰 기본값) + timeout: 읽기 타임아웃 (초) + """ + + print(f'[시작] 바코드 리더기 연결 중...') + print(f'포트: {port}') + print(f'속도: {baudrate} bps') + print('-' * 60) + + try: + # 시리얼 포트 열기 + ser = serial.Serial( + port=port, + baudrate=baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=timeout + ) + + print(f'[성공] {port} 포트 연결 완료!') + print('[대기] 바코드를 스캔해주세요... (종료: Ctrl+C)') + print('=' * 60) + print() + + scan_count = 0 + + while True: + # 시리얼 포트에서 데이터 읽기 + if ser.in_waiting > 0: + # 바코드 데이터 읽기 (개행문자까지) + barcode_data = ser.readline() + + # 바이트를 문자열로 디코딩 + try: + barcode_str = barcode_data.decode('utf-8').strip() + except UnicodeDecodeError: + # UTF-8 실패 시 ASCII로 시도 + barcode_str = barcode_data.decode('ascii', errors='ignore').strip() + + if barcode_str: + scan_count += 1 + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + print(f'[스캔 #{scan_count}] {timestamp}') + print(f'바코드: {barcode_str}') + print(f'길이: {len(barcode_str)}자') + print(f'원본(HEX): {barcode_data.hex()}') + print('-' * 60) + + except serial.SerialException as e: + print(f'[오류] 포트 연결 실패: {e}') + print() + print('가능한 해결 방법:') + print(' 1. COM3 포트가 다른 프로그램에서 사용 중인지 확인') + print(' 2. 바코드 리더기가 제대로 연결되어 있는지 확인') + print(' 3. 장치 관리자에서 포트 번호 확인 (COM3이 맞는지)') + print(' 4. USB 케이블을 다시 연결해보기') + return 1 + + except KeyboardInterrupt: + print() + print('=' * 60) + print(f'[종료] 총 {scan_count}개의 바코드를 스캔했습니다.') + print('[완료] 프로그램을 종료합니다.') + ser.close() + return 0 + + except Exception as e: + print(f'[오류] 예상치 못한 오류 발생: {e}') + return 1 + + finally: + if 'ser' in locals() and ser.is_open: + ser.close() + print('[정리] 포트 연결 종료') + + +def list_available_ports(): + """사용 가능한 COM 포트 목록 출력""" + import serial.tools.list_ports + + ports = serial.tools.list_ports.comports() + + if not ports: + print('[알림] 사용 가능한 COM 포트가 없습니다.') + return + + print('[사용 가능한 COM 포트]') + print('-' * 60) + for port in ports: + print(f'포트: {port.device}') + print(f' 설명: {port.description}') + print(f' 제조사: {port.manufacturer}') + print() + + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser( + description='허니웰 바코드 리더기 COM 포트 리딩 프로그램' + ) + parser.add_argument( + '--port', + default='COM3', + help='COM 포트 번호 (기본값: COM3)' + ) + parser.add_argument( + '--baudrate', + type=int, + default=9600, + help='통신 속도 (기본값: 9600)' + ) + parser.add_argument( + '--list-ports', + action='store_true', + help='사용 가능한 COM 포트 목록 출력' + ) + + args = parser.parse_args() + + if args.list_ports: + list_available_ports() + else: + exit_code = read_barcode_from_com3( + port=args.port, + baudrate=args.baudrate + ) + sys.exit(exit_code) diff --git a/backend/samples/barcode_reader_README.md b/backend/samples/barcode_reader_README.md new file mode 100644 index 0000000..b8ab8db --- /dev/null +++ b/backend/samples/barcode_reader_README.md @@ -0,0 +1,142 @@ +# 허니웰 바코드 리더기 COM 포트 리딩 프로그램 + +COM3 포트에 연결된 허니웰 바코드 리더기에서 바코드를 실시간으로 읽어 터미널에 출력하는 Python 프로그램입니다. + +## 필수 라이브러리 설치 + +```bash +pip install pyserial +``` + +## 사용 방법 + +### 1. 기본 실행 (COM3 포트, 9600 bps) + +```bash +python barcode_reader.py +``` + +### 2. 다른 COM 포트 사용 + +```bash +python barcode_reader.py --port COM5 +``` + +### 3. 통신 속도 변경 + +```bash +python barcode_reader.py --baudrate 115200 +``` + +### 4. 사용 가능한 COM 포트 목록 확인 + +```bash +python barcode_reader.py --list-ports +``` + +## 출력 예시 + +``` +[시작] 바코드 리더기 연결 중... +포트: COM3 +속도: 9600 bps +------------------------------------------------------------ +[성공] COM3 포트 연결 완료! +[대기] 바코드를 스캔해주세요... (종료: Ctrl+C) +============================================================ + +[스캔 #1] 2026-01-07 15:30:45 +바코드: 8801234567890 +길이: 13자 +원본(HEX): 383830313233343536373839300d0a +------------------------------------------------------------ +[스캔 #2] 2026-01-07 15:30:52 +바코드: ABC123XYZ +길이: 9자 +원본(HEX): 4142433132335859 5a0d0a +------------------------------------------------------------ +``` + +## 프로그램 종료 + +- **Ctrl + C** 키를 눌러 프로그램을 종료합니다. +- 종료 시 총 스캔한 바코드 개수가 표시됩니다. + +## 트러블슈팅 + +### 1. "포트 연결 실패" 오류 + +**원인:** +- COM3 포트가 다른 프로그램에서 사용 중 +- 바코드 리더기가 제대로 연결되지 않음 +- 잘못된 포트 번호 + +**해결 방법:** +```bash +# 1. 사용 가능한 포트 목록 확인 +python barcode_reader.py --list-ports + +# 2. 올바른 포트 번호로 실행 +python barcode_reader.py --port COM5 +``` + +### 2. 바코드가 읽히지 않음 + +**확인 사항:** +- 바코드 리더기의 LED가 켜지는지 확인 +- 바코드 리더기 설정 확인 (USB-COM 모드인지) +- 케이블 연결 상태 확인 + +### 3. 글자가 깨져서 나옴 + +**원인:** +- 잘못된 통신 속도(baudrate) 설정 + +**해결 방법:** +```bash +# 다른 통신 속도 시도 +python barcode_reader.py --baudrate 115200 +python barcode_reader.py --baudrate 19200 +``` + +허니웰 바코드 리더기의 일반적인 통신 속도: +- 9600 bps (기본값) +- 19200 bps +- 38400 bps +- 115200 bps + +## 허니웰 바코드 리더기 설정 + +일부 허니웰 바코드 리더기는 USB-COM 모드로 전환해야 할 수 있습니다. + +### USB-COM 모드 활성화 방법: + +1. 바코드 리더기 매뉴얼에서 "USB Serial Emulation" 설정 바코드 찾기 +2. 해당 바코드 스캔하여 USB-COM 모드 활성화 +3. 컴퓨터 재연결 후 장치 관리자에서 COM 포트 확인 + +## 장치 관리자에서 COM 포트 확인 + +1. `Windows + X` → **장치 관리자** 실행 +2. **포트(COM & LPT)** 항목 확장 +3. 바코드 리더기의 COM 포트 번호 확인 (예: COM3, COM5 등) + +## 코드 구조 + +```python +# 주요 함수 +read_barcode_from_com3(port, baudrate, timeout) + ├─ 시리얼 포트 열기 + ├─ 바코드 데이터 실시간 읽기 + ├─ UTF-8/ASCII 디코딩 + └─ 터미널 출력 + +list_available_ports() + └─ 사용 가능한 COM 포트 목록 출력 +``` + +## 참고 + +- 프로그램은 바코드 스캔 시 자동으로 감지하여 출력합니다. +- 각 바코드마다 스캔 번호, 시간, 내용, HEX 값이 표시됩니다. +- 바코드 리더기는 일반적으로 스캔 후 개행문자(\r\n)를 전송합니다. diff --git a/backend/samples/barcode_reader_gui.py b/backend/samples/barcode_reader_gui.py new file mode 100644 index 0000000..db9d2a1 --- /dev/null +++ b/backend/samples/barcode_reader_gui.py @@ -0,0 +1,692 @@ +""" +허니웰 바코드 리더기 GUI 프로그램 (PyQt5) +COM3 포트에서 바코드를 실시간으로 읽어 화면에 표시 +MSSQL DB에서 약품 정보 조회 기능 포함 +""" + +import sys +import serial +import serial.tools.list_ports +from datetime import datetime +from PyQt5.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QTextEdit, QComboBox, QLabel, QGroupBox, QSpinBox, + QCheckBox, QDialog +) +from PyQt5.QtCore import QThread, pyqtSignal, Qt +from PyQt5.QtGui import QFont, QTextCursor, QPixmap +from sqlalchemy import text + +# MSSQL 데이터베이스 연결 +sys.path.insert(0, '.') +from dbsetup import DatabaseManager + +# 바코드 라벨 출력 +from barcode_print import print_barcode_label + + +def parse_gs1_barcode(barcode): + """ + GS1-128 바코드 파싱 + + Args: + barcode: 원본 바코드 문자열 + + Returns: + list: 파싱된 바코드 후보 리스트 (우선순위 순) + """ + candidates = [barcode] # 원본 바코드를 첫 번째 후보로 + + # GS1-128: 01로 시작하는 경우 (GTIN) + if barcode.startswith('01') and len(barcode) >= 16: + # 01 + 14자리 GTIN + gtin14 = barcode[2:16] + candidates.append(gtin14) + + # GTIN-14를 GTIN-13으로 변환 (앞자리가 0인 경우) + if gtin14.startswith('0'): + gtin13 = gtin14[1:] + candidates.append(gtin13) + + # GS1-128: 01로 시작하지만 13자리인 경우 + elif barcode.startswith('01') and len(barcode) == 15: + gtin13 = barcode[2:15] + candidates.append(gtin13) + + return candidates + + +def search_drug_by_barcode(barcode): + """ + 바코드로 약품 정보 조회 (MSSQL PM_DRUG.CD_GOODS) + GS1-128 바코드 자동 파싱 지원 + + Args: + barcode: 바코드 번호 + + Returns: + tuple: (약품 정보 dict 또는 None, 파싱 정보 dict) + """ + try: + db_manager = DatabaseManager() + engine = db_manager.get_engine('PM_DRUG') + + query = text(''' + SELECT TOP 1 + BARCODE, + GoodsName, + DrugCode, + SplName, + Price, + Saleprice, + SUNG_CODE, + IsUSE + FROM CD_GOODS + WHERE BARCODE = :barcode + AND (GoodsName NOT LIKE N'%(판매중지)%' AND GoodsName NOT LIKE N'%(판매중단)%') + ORDER BY + CASE WHEN IsUSE = '1' THEN 0 ELSE 1 END, -- 1. 사용중인 제품 우선 + CASE WHEN Price > 0 THEN 0 ELSE 1 END, -- 2. 가격 정보 있는 제품 우선 + CASE WHEN SplName IS NOT NULL AND SplName != '' THEN 0 ELSE 1 END, -- 3. 제조사 정보 있는 제품 우선 + DrugCode DESC -- 4. 약품코드 내림차순 + ''') + + # GS1 바코드 파싱 + candidates = parse_gs1_barcode(barcode) + parse_info = { + 'original': barcode, + 'candidates': candidates, + 'matched_barcode': None, + 'is_gs1': len(candidates) > 1 + } + + with engine.connect() as conn: + # 여러 후보 바코드로 순차 검색 + for candidate in candidates: + result = conn.execute(query, {"barcode": candidate}) + row = result.fetchone() + + if row: + parse_info['matched_barcode'] = candidate + drug_info = { + 'barcode': row.BARCODE, + 'goods_name': row.GoodsName, + 'drug_code': row.DrugCode, + 'manufacturer': row.SplName, + 'price': float(row.Price) if row.Price else 0, + 'sale_price': float(row.Saleprice) if row.Saleprice else 0, + 'sung_code': row.SUNG_CODE if row.SUNG_CODE else '' + } + return drug_info, parse_info + + return None, parse_info + + except Exception as e: + print(f'[오류] 약품 조회 실패: {e}') + return None, {'original': barcode, 'error': str(e)} + + +class DrugSearchThread(QThread): + """약품 정보 조회 전용 백그라운드 스레드""" + + # 시그널: (바코드, 타임스탬프, 원본 데이터, 약품 정보, 파싱 정보) + search_complete = pyqtSignal(str, str, bytes, object, object) + + def __init__(self, barcode, timestamp, raw_data): + super().__init__() + self.barcode = barcode + self.timestamp = timestamp + self.raw_data = raw_data + + def run(self): + """백그라운드에서 DB 조회""" + drug_info, parse_info = search_drug_by_barcode(self.barcode) + self.search_complete.emit(self.barcode, self.timestamp, self.raw_data, drug_info, parse_info) + + +class LabelGeneratorThread(QThread): + """라벨 이미지 생성 전용 백그라운드 스레드""" + + # 시그널: (성공 여부, 이미지 경로, 약품명, 에러 메시지) + image_ready = pyqtSignal(bool, str, str, str) + + def __init__(self, goods_name, sale_price, preview_mode=False): + super().__init__() + self.goods_name = goods_name + self.sale_price = sale_price + self.preview_mode = preview_mode + + def run(self): + """백그라운드에서 이미지 생성""" + try: + if self.preview_mode: + # 미리보기 모드 + success, image_path = print_barcode_label( + self.goods_name, + self.sale_price, + preview_mode=True + ) + if success: + self.image_ready.emit(True, image_path, self.goods_name, "") + else: + self.image_ready.emit(False, "", self.goods_name, "이미지 생성 실패") + else: + # 실제 인쇄 모드 + success = print_barcode_label( + self.goods_name, + self.sale_price, + preview_mode=False + ) + if success: + self.image_ready.emit(True, "", self.goods_name, "") + else: + self.image_ready.emit(False, "", self.goods_name, "라벨 출력 실패") + except Exception as e: + self.image_ready.emit(False, "", self.goods_name, str(e)) + + +class LabelPreviewDialog(QDialog): + """라벨 미리보기 팝업 창""" + + def __init__(self, image_path, goods_name, parent=None): + """ + Args: + image_path: 미리보기 이미지 파일 경로 + goods_name: 약품명 + parent: 부모 위젯 + """ + super().__init__(parent) + self.image_path = image_path + self.goods_name = goods_name + self.init_ui() + + def init_ui(self): + """UI 초기화""" + self.setWindowTitle(f'라벨 미리보기 - {self.goods_name}') + self.setModal(False) # 모달 아님 (계속 스캔 가능) + + # 레이아웃 + layout = QVBoxLayout() + + # 상단 안내 라벨 + info_label = QLabel('[미리보기] 실제 인쇄하려면 "미리보기 모드" 체크를 해제하세요.') + info_label.setStyleSheet('color: #2196F3; font-size: 12px; padding: 10px;') + layout.addWidget(info_label) + + # 이미지 표시 (QLabel + QPixmap) + pixmap = QPixmap(self.image_path) + + # 화면 크기에 맞게 스케일링 (최대 1000px 폭) + if pixmap.width() > 1000: + pixmap = pixmap.scaledToWidth(1000, Qt.SmoothTransformation) + + image_label = QLabel() + image_label.setPixmap(pixmap) + image_label.setAlignment(Qt.AlignCenter) + layout.addWidget(image_label) + + # 버튼 레이아웃 + button_layout = QHBoxLayout() + + # 닫기 버튼 + close_btn = QPushButton('닫기') + close_btn.setStyleSheet('background-color: #2196F3; color: white; font-weight: bold; padding: 8px 20px;') + close_btn.clicked.connect(self.close) + button_layout.addWidget(close_btn) + + layout.addLayout(button_layout) + + self.setLayout(layout) + + # 창 크기 자동 조정 + self.adjustSize() + + +class BarcodeReaderThread(QThread): + """바코드 읽기 스레드 (DB 조회 없이 바코드만 읽음)""" + barcode_received = pyqtSignal(str, str, bytes) # 바코드, 시간, 원본 (DB 조회 제외!) + connection_status = pyqtSignal(bool, str) # 연결 상태, 메시지 + raw_data_received = pyqtSignal(str) # 시리얼 포트 RAW 데이터 (디버깅용) + + def __init__(self, port='COM3', baudrate=115200): + super().__init__() + self.port = port + self.baudrate = baudrate + self.running = False + self.serial_connection = None + + def run(self): + """스레드 실행""" + self.running = True + + try: + # 시리얼 포트 열기 + self.serial_connection = serial.Serial( + port=self.port, + baudrate=self.baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=1 + ) + + self.connection_status.emit(True, f'{self.port} 연결 성공! (속도: {self.baudrate} bps)') + + # 바코드 읽기 루프 + while self.running: + if self.serial_connection.in_waiting > 0: + buffer_size = self.serial_connection.in_waiting + timestamp_ms = datetime.now().strftime('%H:%M:%S.%f')[:-3] + + # 즉시 GUI에 표시 + self.raw_data_received.emit(f'[{timestamp_ms}] 버퍼: {buffer_size} bytes') + + # 버퍼의 모든 데이터를 한 번에 읽기 (연속 스캔 대응) + all_data = self.serial_connection.read(buffer_size) + + # 즉시 GUI에 표시 + self.raw_data_received.emit(f' → 읽음: {all_data.hex()} ({len(all_data)} bytes)') + + # 디코딩 + try: + all_text = all_data.decode('utf-8') + except UnicodeDecodeError: + all_text = all_data.decode('ascii', errors='ignore') + + # 개행문자로 분리 (여러 바코드가 함께 들어온 경우) + lines = all_text.strip().split('\n') + self.raw_data_received.emit(f' → 분리된 라인 수: {len(lines)}') + + for line in lines: + barcode_str = line.strip() + + if not barcode_str: + continue + + # 즉시 GUI에 표시 + self.raw_data_received.emit(f' → 처리: "{barcode_str}" (길이: {len(barcode_str)})') + + # 바코드 길이 검증 (13자리 EAN-13, 16자리 GS1-128만 허용) + valid_lengths = [13, 15, 16] # EAN-13, GS1-128 (01+13), GS1-128 (01+14) + + if len(barcode_str) not in valid_lengths: + # 비정상 길이: 무시 + self.raw_data_received.emit(f' → [무시] 비정상 길이 {len(barcode_str)}') + continue + + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + # 바코드 데이터만 메인 스레드로 전달 + self.raw_data_received.emit(f' → [OK] 시그널 전송!') + self.barcode_received.emit(barcode_str, timestamp, barcode_str.encode('utf-8')) + + # 처리 완료 후 버퍼 확인 + remaining = self.serial_connection.in_waiting + if remaining > 0: + self.raw_data_received.emit(f' → [주의] 처리 완료 후 버퍼에 {remaining} bytes 남음 (다음 루프에서 처리)') + + except serial.SerialException as e: + self.connection_status.emit(False, f'포트 연결 실패: {str(e)}') + + except Exception as e: + self.connection_status.emit(False, f'오류 발생: {str(e)}') + + finally: + if self.serial_connection and self.serial_connection.is_open: + self.serial_connection.close() + + def stop(self): + """스레드 중지""" + self.running = False + if self.serial_connection and self.serial_connection.is_open: + self.serial_connection.close() + + +class BarcodeReaderGUI(QMainWindow): + """바코드 리더 GUI 메인 윈도우""" + + def __init__(self): + super().__init__() + self.reader_thread = None + self.scan_count = 0 + self.search_threads = [] # 약품 조회 스레드 목록 + self.generator_threads = [] # 라벨 생성 스레드 목록 + self.init_ui() + + def init_ui(self): + """UI 초기화""" + self.setWindowTitle('허니웰 바코드 리더 - COM 포트') + self.setGeometry(100, 100, 900, 700) + + # 중앙 위젯 + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # 메인 레이아웃 + main_layout = QVBoxLayout() + central_widget.setLayout(main_layout) + + # === 1. 설정 그룹 === + settings_group = QGroupBox('연결 설정') + settings_layout = QHBoxLayout() + settings_group.setLayout(settings_layout) + + # COM 포트 선택 + settings_layout.addWidget(QLabel('COM 포트:')) + self.port_combo = QComboBox() + self.refresh_ports() + settings_layout.addWidget(self.port_combo) + + # 새로고침 버튼 + refresh_btn = QPushButton('새로고침') + refresh_btn.clicked.connect(self.refresh_ports) + settings_layout.addWidget(refresh_btn) + + # 통신 속도 + settings_layout.addWidget(QLabel('속도 (bps):')) + self.baudrate_spin = QSpinBox() + self.baudrate_spin.setMinimum(9600) + self.baudrate_spin.setMaximum(921600) + self.baudrate_spin.setValue(115200) + self.baudrate_spin.setSingleStep(9600) + settings_layout.addWidget(self.baudrate_spin) + + # 수직 구분선 + settings_layout.addWidget(QLabel('|')) + + # 미리보기 모드 토글 + self.preview_mode_checkbox = QCheckBox('미리보기 모드 (인쇄 안 함)') + self.preview_mode_checkbox.setChecked(True) # 기본값: 미리보기 (종이 절약!) + self.preview_mode_checkbox.setStyleSheet('font-size: 14px; color: #4CAF50; font-weight: bold;') + settings_layout.addWidget(self.preview_mode_checkbox) + + settings_layout.addStretch() + + main_layout.addWidget(settings_group) + + # === 2. 제어 버튼 === + control_layout = QHBoxLayout() + + self.start_btn = QPushButton('시작') + self.start_btn.setStyleSheet('background-color: #4CAF50; color: white; font-weight: bold; padding: 10px;') + self.start_btn.clicked.connect(self.start_reading) + control_layout.addWidget(self.start_btn) + + self.stop_btn = QPushButton('중지') + self.stop_btn.setStyleSheet('background-color: #f44336; color: white; font-weight: bold; padding: 10px;') + self.stop_btn.setEnabled(False) + self.stop_btn.clicked.connect(self.stop_reading) + control_layout.addWidget(self.stop_btn) + + self.clear_btn = QPushButton('화면 지우기') + self.clear_btn.setStyleSheet('background-color: #2196F3; color: white; font-weight: bold; padding: 10px;') + self.clear_btn.clicked.connect(self.clear_output) + control_layout.addWidget(self.clear_btn) + + main_layout.addLayout(control_layout) + + # === 3. 상태 표시 === + status_group = QGroupBox('상태') + status_layout = QVBoxLayout() + status_group.setLayout(status_layout) + + self.status_label = QLabel('대기 중...') + self.status_label.setStyleSheet('color: gray; font-size: 14px; padding: 5px;') + status_layout.addWidget(self.status_label) + + self.scan_count_label = QLabel('스캔 횟수: 0') + self.scan_count_label.setStyleSheet('color: blue; font-size: 14px; font-weight: bold; padding: 5px;') + status_layout.addWidget(self.scan_count_label) + + main_layout.addWidget(status_group) + + # === 4. 바코드 출력 영역 === + output_group = QGroupBox('바코드 스캔 결과') + output_layout = QVBoxLayout() + output_group.setLayout(output_layout) + + self.output_text = QTextEdit() + self.output_text.setReadOnly(True) + self.output_text.setFont(QFont('Consolas', 10)) + self.output_text.setStyleSheet('background-color: #f5f5f5;') + output_layout.addWidget(self.output_text) + + main_layout.addWidget(output_group) + + def refresh_ports(self): + """사용 가능한 COM 포트 새로고침""" + self.port_combo.clear() + ports = serial.tools.list_ports.comports() + + for port in ports: + self.port_combo.addItem(f'{port.device} - {port.description}', port.device) + + # COM3이 있으면 선택 + for i in range(self.port_combo.count()): + if 'COM3' in self.port_combo.itemData(i): + self.port_combo.setCurrentIndex(i) + break + + def start_reading(self): + """바코드 읽기 시작""" + if self.reader_thread and self.reader_thread.isRunning(): + return + + # 선택된 포트와 속도 가져오기 + port = self.port_combo.currentData() + if not port: + self.append_output('[오류] COM 포트를 선택해주세요.') + return + + baudrate = self.baudrate_spin.value() + + # 스레드 시작 + self.reader_thread = BarcodeReaderThread(port, baudrate) + self.reader_thread.barcode_received.connect(self.on_barcode_received) + self.reader_thread.connection_status.connect(self.on_connection_status) + self.reader_thread.raw_data_received.connect(self.on_raw_data) # RAW 데이터 표시 + self.reader_thread.start() + + # UI 업데이트 + self.start_btn.setEnabled(False) + self.stop_btn.setEnabled(True) + self.port_combo.setEnabled(False) + self.baudrate_spin.setEnabled(False) + + self.status_label.setText(f'연결 시도 중... ({port}, {baudrate} bps)') + self.status_label.setStyleSheet('color: orange; font-size: 14px; padding: 5px;') + + def stop_reading(self): + """바코드 읽기 중지""" + if self.reader_thread: + self.reader_thread.stop() + self.reader_thread.wait() + + # UI 업데이트 + self.start_btn.setEnabled(True) + self.stop_btn.setEnabled(False) + self.port_combo.setEnabled(True) + self.baudrate_spin.setEnabled(True) + + self.status_label.setText('중지됨') + self.status_label.setStyleSheet('color: gray; font-size: 14px; padding: 5px;') + + self.append_output('[시스템] 바코드 리더를 중지했습니다.\n') + + def on_connection_status(self, success, message): + """연결 상태 업데이트""" + if success: + self.status_label.setText(f'연결됨: {message}') + self.status_label.setStyleSheet('color: green; font-size: 14px; font-weight: bold; padding: 5px;') + self.append_output(f'[시스템] {message}\n') + self.append_output('[대기] 바코드를 스캔해주세요...\n') + else: + self.status_label.setText(f'오류: {message}') + self.status_label.setStyleSheet('color: red; font-size: 14px; font-weight: bold; padding: 5px;') + self.append_output(f'[오류] {message}\n') + self.stop_reading() + + def on_raw_data(self, log_message): + """시리얼 포트 RAW 데이터 즉시 표시 (디버깅용)""" + self.append_output(log_message + '\n') + + def on_barcode_received(self, barcode, timestamp, raw_data): + """바코드 수신 처리 (DB 조회는 백그라운드 스레드로)""" + self.scan_count += 1 + self.scan_count_label.setText(f'스캔 횟수: {self.scan_count}') + + # 즉시 로그 출력 (DB 조회 전) + output = f'{"=" * 80}\n' + output += f'[스캔 #{self.scan_count}] {timestamp}\n' + output += f'바코드: {barcode}\n' + output += f'길이: {len(barcode)}자\n' + output += f'[조회 중...] 약품 정보 검색 중\n' + self.append_output(output) + + # 백그라운드 스레드로 DB 조회 작업 위임 + search_thread = DrugSearchThread(barcode, timestamp, raw_data) + search_thread.search_complete.connect(self.on_search_complete) + search_thread.start() + self.search_threads.append(search_thread) + + def on_search_complete(self, barcode, timestamp, raw_data, drug_info, parse_info): + """약품 조회 완료 시그널 핸들러 (백그라운드 스레드에서 호출)""" + # 출력 + output = '' + + # GS1 파싱 정보 출력 + if parse_info and parse_info.get('is_gs1'): + output += f'[GS1-128 바코드 감지]\n' + output += f' 원본 바코드: {parse_info["original"]}\n' + if parse_info.get('matched_barcode'): + output += f' 매칭된 바코드: {parse_info["matched_barcode"]}\n' + if len(parse_info.get('candidates', [])) > 1: + output += f' 검색 시도: {", ".join(parse_info["candidates"])}\n' + output += '\n' + + # 약품 정보 출력 + if drug_info: + output += f'[약품 정보]\n' + output += f' 약품명: {drug_info["goods_name"]}\n' + output += f' 약품코드: {drug_info["drug_code"]}\n' + output += f' 제조사: {drug_info["manufacturer"]}\n' + output += f' 매입가: {drug_info["price"]:,.0f}원\n' + output += f' 판매가: {drug_info["sale_price"]:,.0f}원\n' + if drug_info["sung_code"]: + output += f' 성분코드: {drug_info["sung_code"]}\n' + + # 라벨 출력 또는 미리보기 (백그라운드 스레드) + try: + is_preview = self.preview_mode_checkbox.isChecked() + + # 백그라운드 스레드로 이미지 생성 작업 위임 + generator_thread = LabelGeneratorThread( + drug_info["goods_name"], + drug_info["sale_price"], + preview_mode=is_preview + ) + + # 완료 시그널 연결 + generator_thread.image_ready.connect(self.on_label_generated) + + # 스레드 시작 및 목록에 추가 + generator_thread.start() + self.generator_threads.append(generator_thread) + + # 로그 출력 + if is_preview: + output += f'\n[미리보기] 이미지 생성 중...\n' + else: + output += f'\n[출력] 라벨 출력 중...\n' + + except Exception as e: + output += f'\n[출력 오류] {str(e)}\n' + else: + output += f'[약품 정보] 데이터베이스에서 찾을 수 없습니다.\n' + + output += f'\n원본(HEX): {raw_data.hex()}\n' + output += f'{"-" * 80}\n\n' + + self.append_output(output) + + # 완료된 스레드 정리 + sender_thread = self.sender() + if sender_thread in self.search_threads: + self.search_threads.remove(sender_thread) + + def on_label_generated(self, success, image_path, goods_name, error_msg): + """ + 라벨 생성 완료 시그널 핸들러 (백그라운드 스레드에서 호출) + + Args: + success: 성공 여부 + image_path: 미리보기 이미지 경로 (미리보기 모드일 때만) + goods_name: 약품명 + error_msg: 에러 메시지 (실패 시) + """ + if success: + if image_path: + # 미리보기 모드: Dialog 표시 + self.append_output(f'[미리보기 완료] {goods_name}\n') + preview_dialog = LabelPreviewDialog(image_path, goods_name, self) + preview_dialog.show() + else: + # 실제 인쇄 모드: 성공 로그 + self.append_output(f'[출력 완료] {goods_name} (192.168.0.168)\n') + else: + # 실패 + self.append_output(f'[오류] {goods_name}: {error_msg}\n') + + # 완료된 스레드 정리 + sender_thread = self.sender() + if sender_thread in self.generator_threads: + self.generator_threads.remove(sender_thread) + + def append_output(self, text): + """출력 영역에 텍스트 추가""" + self.output_text.append(text) + # 스크롤을 맨 아래로 + self.output_text.moveCursor(QTextCursor.End) + + def clear_output(self): + """출력 화면 지우기""" + self.output_text.clear() + self.scan_count = 0 + self.scan_count_label.setText('스캔 횟수: 0') + + def closeEvent(self, event): + """프로그램 종료 시 스레드 정리""" + # 바코드 리더 스레드 종료 + if self.reader_thread: + self.reader_thread.stop() + self.reader_thread.wait() + + # 활성 약품 조회 스레드 종료 + for thread in self.search_threads: + if thread.isRunning(): + thread.wait() + + # 활성 라벨 생성 스레드 종료 + for thread in self.generator_threads: + if thread.isRunning(): + thread.wait() + + event.accept() + + +def main(): + """메인 함수""" + app = QApplication(sys.argv) + + # 애플리케이션 스타일 + app.setStyle('Fusion') + + # 메인 윈도우 생성 및 표시 + window = BarcodeReaderGUI() + window.show() + + sys.exit(app.exec_()) + + +if __name__ == '__main__': + main() diff --git a/backend/samples/print_label.py b/backend/samples/print_label.py new file mode 100644 index 0000000..28e278c --- /dev/null +++ b/backend/samples/print_label.py @@ -0,0 +1,957 @@ +# print_label.py + +from PIL import Image, ImageDraw, ImageFont +import io +import os +import logging +from brother_ql.raster import BrotherQLRaster +from brother_ql.conversion import convert +from brother_ql.backends.helpers import send +import datetime # 날짜 처리를 위해 추가 +import pytz +import re +import qrcode +import json + +# 프린터 기본 설정 (프린터 정보가 전달되지 않을 때 사용) +DEFAULT_PRINTER_IP = "192.168.0.121" # QL-710W 프린터의 IP 주소 +DEFAULT_PRINTER_MODEL = "QL-710W" +DEFAULT_LABEL_TYPE = "29" # 29mm 연속 출력 용지 + +# 로깅 설정 +logging.basicConfig(level=logging.DEBUG, format='%(levelname)s:%(message)s') + +# KOR 시간대 설정 +KOR_TZ = pytz.timezone('Asia/Seoul') + + +def format_total_amount(dosage, frequency, duration): + """ + 1회 복용량, 복용 횟수, 총 복용 일수를 기반으로 총량을 계산하고, + 1/4 단위로 반올림하거나 그대로 반환합니다. + + Parameters: + dosage (float): 1회 복용량 (소수 넷째 자리까지 포함 가능) + frequency (int): 1일 복용 횟수 + duration (int): 복용 일수 + + Returns: + str: 포맷팅된 총량 문자열 + """ + if frequency > 0 and duration > 0: + # 1일 복용량 = 1회 복용량 * 1일 복용 횟수 + daily_dosage = dosage * frequency + # 총량 = 1일 복용량 * 총 복용 일수 + total_amount = daily_dosage * duration + + # 1회 복용량이 소수 넷째 자리까지 있는 경우 1/4 단위로 반올림 + if round(dosage, 4) != round(dosage, 3): # 소수 넷째 자리 여부 확인 + total_amount = round(total_amount * 4) / 4 + + # 정수인 경우 소수점 없이 표시, 소수가 있는 경우 둘째 자리까지 표시 + return str(int(total_amount)) if total_amount.is_integer() else f"{total_amount:.2f}".rstrip('0').rstrip('.') + return "0" # 복용 횟수나 복용 일수가 0인 경우 + + +def format_dosage(dosage): + """ + 1회 복용량을 포맷팅합니다. + + Parameters: + dosage (float): 1회 복용량 + + Returns: + str: 포맷팅된 복용량 문자열 + """ + if dosage.is_integer(): + return str(int(dosage)) + else: + # 최대 4자리 소수까지 표시, 불필요한 0 제거 + return f"{dosage:.4f}".rstrip('0').rstrip('.') + + +def format_converted_total(dosage, frequency, duration, conversion_factor): + """ + 총량을 계산하고 환산계수를 곱한 변환된 총량을 포맷팅합니다. + + Parameters: + dosage (float): 1회 복용량 (소수 넷째 자리까지 포함 가능) + frequency (int): 1일 복용 횟수 + duration (int): 복용 일수 + conversion_factor (float): 환산계수 + + Returns: + str: 변환된 총량을 포함한 포맷팅된 문자열 + """ + if frequency > 0 and duration > 0 and conversion_factor is not None: + total_amount = dosage * frequency * duration + if round(dosage, 4) != round(dosage, 3): + total_amount = round(total_amount * 4) / 4 + converted_total = total_amount * conversion_factor + if converted_total.is_integer(): + return str(int(converted_total)) + else: + return f"{converted_total:.2f}".rstrip('0').rstrip('.') + return None + + +def draw_scissor_border(draw, width, height, edge_size=5, steps=230): + """ + 라벨 이미지의 테두리에 톱니 모양의 절취선을 그립니다. + + Parameters: + draw (ImageDraw.Draw): 이미지에 그리기 위한 Draw 객체 + width (int): 라벨 너비 + height (int): 라벨 높이 + edge_size (int): 톱니 크기 + steps (int): 톱니 반복 횟수 + """ + 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 split_med_name(med_name): + """ + 약품 이름을 표시용 이름과 시그니처 정보로 분리합니다. + + Parameters: + med_name (str): 약품 이름 + + Returns: + tuple: (표시용 약품 이름, 시그니처 정보, 분리 여부) + """ + units = ['mg', 'g', 'ml', '%'] + pattern = r'(\d+(?:\.\d+)?(?:/\d+(?:\.\d+)?)*)\s*(' + '|'.join(units) + r')(?:/(' + '|'.join(units) + r'))?$' + korean_only = re.fullmatch(r'[가-힣]+', med_name) is not None + korean_and_num_eng = re.fullmatch(r'[가-힣a-zA-Z0-9/\.]+', med_name) is not None and not korean_only + med_name_display = med_name + signature_info = "청 춘 약 국" + split_occurred = False + if korean_only: + if len(med_name) >= 10: + match = re.search(pattern, med_name) + if match and match.start() >= 10: + med_name_display = med_name[:match.start()].strip() + signature_info = match.group(1) + match.group(2) + (f"/{match.group(3)}" if match.group(3) else "") + split_occurred = True + else: + med_name_display = med_name[:10] + # else 그대로 사용 + elif korean_and_num_eng: + if len(med_name) >= 13: + match = re.search(pattern, med_name) + if match: + med_name_display = med_name[:match.start()].strip() + signature_info = match.group(1) + match.group(2) + (f"/{match.group(3)}" if match.group(3) else "") + split_occurred = True + else: + med_name_display = med_name[:12] + return med_name_display, signature_info, split_occurred + + +def should_left_align(med_name_display): + """ + 약품 이름의 길이와 구성을 기반으로 좌측 정렬 여부를 결정합니다. + + Parameters: + med_name_display (str): 분리된 약품 이름 표시 부분 + + Returns: + bool: 좌측 정렬 여부 + """ + korean_only = re.fullmatch(r'[가-힣]+', med_name_display) is not None + korean_and_num_eng = re.fullmatch(r'[가-힣a-zA-Z0-9/\.]+', med_name_display) is not None and not korean_only + + if korean_only and len(med_name_display) >= 10: # 10글자부터 좌측정렬 (한글단독) + return True + if korean_and_num_eng and len(med_name_display) >= 13: # 13글자부터 좌측정렬 (한글+숫자+영문) + return True + return False + + +def normalize_medication_name(med_name): + """ + 약품 이름을 정제하여 라벨에 표시할 형태로 변환 + - 괄호 안 내용 제거: "디오탄정80밀리그램(발사르탄)" → "디오탄정80mg" + - 밀리그램 계열: "밀리그램", "밀리그람", "미리그램" → "mg" + - 마이크로그램 계열: "마이크로그램", "마이크로그람" → "μg" + - 그램 계열: "그램", "그람" → "g" + - 대괄호 제거: "[애엽이소프]" → "" + """ + if not med_name: + return med_name + + # 1. 대괄호 및 내용 제거 (예: "오티렌F정[애엽이소프]" → "오티렌F정") + med_name = re.sub(r'\[.*?\]', '', med_name) # 완전한 대괄호 쌍 제거 + med_name = re.sub(r'\[.*$', '', med_name) # 여는 괄호부터 끝까지 제거 + + # 2. 소괄호 및 내용 제거 (예: "디오탄정80밀리그램(발사르탄)" → "디오탄정80밀리그램") + med_name = re.sub(r'\(.*?\)', '', med_name) # 완전한 소괄호 쌍 제거 + med_name = re.sub(r'\(.*$', '', med_name) # 여는 괄호부터 끝까지 제거 + + # 2-1. 언더스코어 뒤 내용 제거 (예: "리피토정10mg_(10.85mg/1정)" → "리피토정10mg") + med_name = re.sub(r'_.*$', '', med_name) # 언더스코어부터 끝까지 제거 + + # 3. 밀리그램 변환 (숫자와 함께 있는 경우 포함) + # "80밀리그램" → "80mg", "밀리그램" → "mg" + med_name = re.sub(r'밀리그램|밀리그람|미리그램|미리그람', 'mg', med_name) + + # 4. 마이크로그램 변환 + med_name = re.sub(r'마이크로그램|마이크로그람', 'μg', med_name) + + # 5. 그램 변환 (단, mg/μg로 이미 변환된 것은 제외) + med_name = re.sub(r'(? 3: + ellipsized = text + "..." + bbox = draw.textbbox((0, 0), ellipsized, font=final_font) + w, h = bbox[2] - bbox[0], bbox[3] - bbox[1] + + if w <= max_width: + draw.text(((label_width - w) / 2, y), ellipsized, font=final_font, fill="black") + return y + h + 5 + + text = text[:-1] + + # 최악의 경우: "..." 만 표시 + bbox = draw.textbbox((0, 0), "...", font=final_font) + w, h = bbox[2] - bbox[0], bbox[3] - bbox[1] + draw.text(((label_width - w) / 2, y), "...", font=final_font, fill="black") + return y + h + 5 + + # 환자 이름 처리: 한글은 띄워쓰기, 영문은 동적 크기 조정 + is_korean_name = all(ord('가') <= ord(char) <= ord('힣') or char.isspace() for char in patient_name if char.strip()) + + if is_korean_name: + # 한글 이름: 한 글자씩 띄우기 (기존 방식) + y_position = 10 + formatted_patient_name = " ".join(patient_name) + y_position = draw_centered_text(draw, formatted_patient_name, y_position, patient_name_font, max_width=label_width - 40) + else: + # 영문 이름: 1줄에 맞추기 위해 폰트 크기 동적 조정 (44px → 최소 28px) + max_width_for_name = label_width - 40 + min_font_size_1line = 28 # 1줄일 때 최소 폰트 + min_font_size_2line = 20 # 2줄일 때 최소 폰트 (18px → 20px) + original_font_size = 44 + + # 폰트 크기를 줄여가며 1줄에 맞는 크기 찾기 + fitted_font = patient_name_font + for font_size in range(original_font_size, min_font_size_1line - 1, -1): + try: + test_font = ImageFont.truetype(font_path, font_size) + except IOError: + test_font = ImageFont.load_default() + + bbox = draw.textbbox((0, 0), patient_name, font=test_font) + w = bbox[2] - bbox[0] + + if w <= max_width_for_name: + fitted_font = test_font + break + + # 28px에도 안 맞으면 띄어쓰기 기준으로 2줄 처리 (22px 최소 폰트로) + bbox = draw.textbbox((0, 0), patient_name, font=fitted_font) + w = bbox[2] - bbox[0] + + if w > max_width_for_name and ' ' in patient_name: + # 2줄 처리: 29px ~ 20px 범위에서 동적 조정 + y_position = 5 # 2줄일 때는 위에서 시작 + + max_font_size_2line = 29 # 2줄일 때 최대 폰트 + fitted_font_2line = patient_name_font + + for font_size in range(max_font_size_2line, min_font_size_2line - 1, -1): + try: + test_font = ImageFont.truetype(font_path, font_size) + except IOError: + test_font = ImageFont.load_default() + + # 각 줄이 라벨 너비에 맞는지 확인 + words = patient_name.split(' ') + line1 = words[0] + line2 = ' '.join(words[1:]) if len(words) > 1 else '' + + bbox1 = draw.textbbox((0, 0), line1, font=test_font) + w1 = bbox1[2] - bbox1[0] + + bbox2 = draw.textbbox((0, 0), line2, font=test_font) + w2 = bbox2[2] - bbox2[0] + + if w1 <= max_width_for_name and w2 <= max_width_for_name: + fitted_font_2line = test_font + break + + # 띄어쓰기로 분리하여 2줄로 처리 + words = patient_name.split(' ') + line1 = words[0] + line2 = ' '.join(words[1:]) if len(words) > 1 else '' + + # 첫 번째 줄 + bbox1 = draw.textbbox((0, 0), line1, font=fitted_font_2line) + w1, h1 = bbox1[2] - bbox1[0], bbox1[3] - bbox1[1] + draw.text(((label_width - w1) / 2, y_position), line1, font=fitted_font_2line, fill="black") + y_position += h1 + 1 # 줄 간격 축소 (2 → 1) + + # 두 번째 줄 + if line2: + bbox2 = draw.textbbox((0, 0), line2, font=fitted_font_2line) + w2, h2 = bbox2[2] - bbox2[0], bbox2[3] - bbox2[1] + draw.text(((label_width - w2) / 2, y_position), line2, font=fitted_font_2line, fill="black") + y_position += h2 + 5 + else: + # 1줄로 표시: 한글과 동일한 위치에서 시작 + y_position = 10 + h = bbox[3] - bbox[1] + draw.text(((label_width - w) / 2, y_position), patient_name, font=fitted_font, fill="black") + y_position += h + 5 + + # 약품명 시작 위치 고정 (이름 길이와 관계없이 일정한 위치 보장) + DRUG_NAME_START_Y = 60 # 약품명 시작 위치를 y=60으로 고정 + if y_position < DRUG_NAME_START_Y: + y_position = DRUG_NAME_START_Y + + # 약품명 정제 (괄호 제거, 단위 변환 등) + med_name = normalize_medication_name(med_name) + + med_name_display, signature_info, split_occurred = split_med_name(med_name) + if should_left_align(med_name_display): + y_position = draw_left_aligned_text(draw, med_name_display, y_position, drug_name_font, max_width=label_width - 40) + y_position = draw_fitted_single_line(draw, f"({add_info})", y_position, info_font, max_width=label_width - 40) + else: + y_position = draw_centered_text(draw, med_name_display, y_position, drug_name_font, max_width=label_width - 40) + y_position = draw_fitted_single_line(draw, f"({add_info})", y_position, info_font, max_width=label_width - 40) + if dosage and frequency and duration and unit: + formatted_dosage = format_dosage(dosage) + daily_dosage = dosage * frequency + total_amount = daily_dosage * duration + if round(dosage, 4) != round(dosage, 3): + total_amount = round(total_amount * 4) / 4 + formatted_total_amount = str(int(total_amount)) if total_amount.is_integer() else f"{total_amount:.2f}".rstrip('0').rstrip('.') + converted_total = format_converted_total(dosage, frequency, duration, conversion_factor) + if converted_total is not None: + total_label = f"총{formatted_total_amount}{unit}/{duration}일분({converted_total})" + else: + total_label = f"총{formatted_total_amount}{unit}/{duration}일분" + y_position = draw_centered_text(draw, total_label, y_position, additional_info_font, max_width=label_width - 40) + box_height = 75 # 70 → 75로 증가 (하단 여백 확보) + box_margin = 10 + box_width = label_width - 40 + box_x1 = (label_width - box_width) // 2 + box_x2 = box_x1 + box_width + box_y1 = y_position + box_margin + box_y2 = box_y1 + box_height + draw.rectangle([box_x1, box_y1, box_x2, box_y2], outline="black", width=2) + box_padding = 10 + line_spacing = 5 + box_text1 = f"{formatted_dosage}{unit}" + text1_size = info_font.getbbox(box_text1) + text1_height = text1_size[3] - text1_size[1] + frequency_text = custom_dosage_instruction.strip() + if not frequency_text: + if frequency == 1: + frequency_text = "아침" + elif frequency == 2: + frequency_text = "아침, 저녁" + elif frequency == 3: + frequency_text = "아침, 점심, 저녁" + elif frequency == 4: + frequency_text = "아침, 점심, 저녁, 취침" + + # 4회 복용일 때는 작은 폰트 사용 (30px -> 24px) + frequency_font = info_font + if frequency == 4 and not custom_dosage_instruction.strip(): + try: + frequency_font = ImageFont.truetype(font_path, 24) + except IOError: + frequency_font = info_font + + text2_height = 0 + if frequency_text: + text2_size = frequency_font.getbbox(frequency_text) + text2_height = text2_size[3] - text2_size[1] + total_text_height = text1_height + line_spacing + text2_height + center_y = (box_y1 + box_y2) // 2 + adjustment = 7 + start_y = center_y - (total_text_height // 2) - adjustment + y_temp = draw_centered_text(draw, box_text1, start_y, info_font, max_width=box_width) + if frequency_text: + text2_y = y_temp + line_spacing + draw_centered_text(draw, frequency_text, text2_y, frequency_font, max_width=box_width) + y_position = box_y2 + box_margin + if storage_condition: + storage_condition_text = f"{storage_condition}" + y_position = draw_centered_text(draw, storage_condition_text, y_position, storage_condition_font, max_width=label_width - 40) + + # === 동적 레이아웃 시스템 === + # 상단 컨텐츠의 최종 y_position과 하단 고정 영역의 공간을 계산하여 겹침 방지 + + # 1. 시그니처 텍스트 및 크기 계산 + signature_text = signature_info if signature_info else "청 춘 약 국" + margin_val = int(0.1 * label_width) + box_width_sig = label_width - 2 * margin_val + try: + bbox = draw.textbbox((0, 0), signature_text, font=signature_font) + w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1] + scale_factor = box_width_sig / w_sig if w_sig != 0 else 1 + scaled_font_size = max(1, int(22 * scale_factor)) + scaled_font = ImageFont.truetype(font_path, scaled_font_size) + except IOError: + scaled_font = ImageFont.load_default() + logging.warning("시그니처 폰트 로드 실패. 기본 폰트 사용.") + bbox = draw.textbbox((0, 0), signature_text, font=scaled_font) + w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1] + + # 2. 조제일 텍스트 크기 계산 + print_date_text = f"조제일 : {datetime.datetime.now(KOR_TZ).strftime('%Y-%m-%d')}" + bbox = draw.textbbox((0, 0), print_date_text, font=print_date_font) + date_w, date_h = bbox[2] - bbox[0], bbox[3] - bbox[1] + + # 3. 하단 고정 영역 필요 공간 계산 + # 기본 패딩 설정 + padding_top = int(h_sig * 0.1) + padding_bottom = int(h_sig * 0.5) + padding_sides = int(h_sig * 0.2) + + # 시그니처 박스 높이 + signature_box_height = h_sig + padding_top + padding_bottom + + # 조제일과 시그니처 사이 간격 + date_signature_gap = 5 + + # 하단 고정 영역 전체 높이 (조제일 + 간격 + 시그니처 + 하단 여백) + bottom_fixed_height = date_h + date_signature_gap + signature_box_height + 10 + + # 4. 충돌 감지 및 조정 + # 상단 컨텐츠 하단 (y_position) + 최소 여백(2px) + content_bottom = y_position + 2 + + # 하단 고정 영역 시작점 + bottom_fixed_start = label_height - bottom_fixed_height + + # 겹침 여부 확인 (10px 이상 겹칠 때만 조정) + overlap = content_bottom - bottom_fixed_start + + if overlap > 10: + # 겹침 발생! 조제일 제거로 공간 확보 + logging.info(f"레이아웃 충돌 감지: {overlap}px 겹침. 조제일 제거로 조정.") + + # 조제일 없이 하단 고정 영역 재계산 + bottom_fixed_height = signature_box_height + 10 + bottom_fixed_start = label_height - bottom_fixed_height + + # 조제일 표시 안 함 + show_date = False + + # 여전히 겹치면 시그니처 패딩 축소 + overlap = content_bottom - bottom_fixed_start + if overlap > 0: + logging.info(f"추가 충돌 감지: {overlap}px 겹침. 시그니처 패딩 축소.") + padding_top = max(2, int(h_sig * 0.05)) + padding_bottom = max(2, int(h_sig * 0.2)) + signature_box_height = h_sig + padding_top + padding_bottom + bottom_fixed_height = signature_box_height + 5 + bottom_fixed_start = label_height - bottom_fixed_height + else: + # 여유 공간 충분 + show_date = True + + # 5. 조제일 그리기 (공간이 충분한 경우에만) + if show_date: + print_date_x = (label_width - date_w) / 2 + print_date_y = label_height - bottom_fixed_height + draw.text((print_date_x, print_date_y), print_date_text, font=print_date_font, fill="black") + + # 시그니처는 조제일 아래에 배치 + signature_y_start = print_date_y + date_h + date_signature_gap + else: + # 시그니처는 하단 고정 시작점에 배치 + signature_y_start = bottom_fixed_start + + # 6. 시그니처 박스 그리기 + box_x = (label_width - w_sig) / 2 - padding_sides + box_y = signature_y_start + 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") + draw.text(((label_width - w_sig) / 2, box_y + padding_top), signature_text, font=scaled_font, fill="black") + + draw_scissor_border(draw, label_width, label_height, edge_size=10, steps=20) + return image + + +def print_label(patient_name, med_name, add_info, frequency, dosage, duration, + formulation_type, main_ingredient_code, dosage_form, administration_route, label_name, + unit=None, conversion_factor=None, storage_condition="실온보관", custom_dosage_instruction="", + printer_ip=None, printer_model=None, label_type=None): + """ + 라벨 이미지를 생성하여 프린터로 인쇄합니다. + + Parameters: + patient_name (str): 환자 이름 + med_name (str): 약품 이름 + add_info (str): 약품 효능 정보 + frequency (int): 복용 횟수 + dosage (float): 복용량 + duration (int): 복용 일수 + formulation_type (str): 제형 타입 + main_ingredient_code (str): 주성분 코드 + dosage_form (str): 복용 형태 + administration_route (str): 투여 경로 + label_name (str): 라벨 명칭 + unit (str, optional): 복용 단위 + conversion_factor (float, optional): 환산계수 + storage_condition (str, optional): 보관 조건 + custom_dosage_instruction (str, optional): 커스텀 용법 텍스트 + printer_ip (str, optional): 프린터 IP 주소 (기본값: DEFAULT_PRINTER_IP) + printer_model (str, optional): 프린터 모델 (기본값: DEFAULT_PRINTER_MODEL) + label_type (str, optional): 라벨 타입 (기본값: DEFAULT_LABEL_TYPE) + """ + try: + # 프린터 설정 적용 (전달되지 않으면 기본값 사용) + printer_ip = printer_ip or DEFAULT_PRINTER_IP + printer_model = printer_model or DEFAULT_PRINTER_MODEL + label_type = label_type or DEFAULT_LABEL_TYPE + + if not unit: + if "캡슐" in med_name: + unit = "캡슐" + elif "정" in med_name: + unit = "정" + elif "시럽" in med_name: + unit = "ml" + elif "과립" in med_name or "시럽" in med_name: + unit = "g" + else: + unit = "개" + label_image = create_label_image( + patient_name=patient_name, + med_name=med_name, + add_info=add_info, + frequency=frequency, + dosage=dosage, + duration=duration, + formulation_type=formulation_type, + main_ingredient_code=main_ingredient_code, + dosage_form=dosage_form, + administration_route=administration_route, + label_name=label_name, + unit=unit, + conversion_factor=conversion_factor, + storage_condition=storage_condition, + custom_dosage_instruction=custom_dosage_instruction + ) + image_stream = io.BytesIO() + label_image.save(image_stream, format="PNG") + image_stream.seek(0) + from brother_ql.raster import BrotherQLRaster + from brother_ql.conversion import convert + from brother_ql.backends.helpers import send + + qlr = BrotherQLRaster(printer_model) + instructions = convert( + qlr=qlr, + images=[Image.open(image_stream)], + label=label_type, + rotate="0", + threshold=70.0, + dither=False, + compress=False, + lq=True, + red=False + ) + send(instructions, printer_identifier=f"tcp://{printer_ip}:9100") + logging.info(f"라벨 인쇄 성공: 환자={patient_name}, 약품={med_name}, 커스텀 용법={custom_dosage_instruction}") + print(f"[SUCCESS] 라벨 인쇄 성공: 환자={patient_name}, 약품={med_name}, 커스텀 용법={custom_dosage_instruction}") + except Exception as e: + logging.error(f"라벨 인쇄 실패: {e}") + print(f"[ERROR] 라벨 인쇄 실패: {e}") + + +def print_custom_image(pil_image, printer_ip=None, printer_model=None, label_type=None): + """ + PIL 이미지를 받아 Brother QL 프린터로 인쇄합니다. + + Parameters: + pil_image (PIL.Image): 인쇄할 이미지 + printer_ip (str, optional): 프린터 IP 주소 (기본값: DEFAULT_PRINTER_IP) + printer_model (str, optional): 프린터 모델 (기본값: DEFAULT_PRINTER_MODEL) + label_type (str, optional): 라벨 타입 (기본값: DEFAULT_LABEL_TYPE) + """ + try: + # 프린터 설정 적용 (전달되지 않으면 기본값 사용) + printer_ip = printer_ip or DEFAULT_PRINTER_IP + printer_model = printer_model or DEFAULT_PRINTER_MODEL + label_type = label_type or DEFAULT_LABEL_TYPE + + logging.info(f"이미지 모드: {pil_image.mode}") + if pil_image.mode in ('RGBA', 'LA'): + logging.info("알파 채널 있음 (RGBA 또는 LA 모드)") + elif pil_image.mode == 'P' and 'transparency' in pil_image.info: + logging.info("알파 채널 있음 (팔레트 모드, transparency 키 확인됨)") + else: + logging.info("알파 채널 없음") + pil_image = pil_image.rotate(90, expand=True) + width, height = pil_image.size + new_height = int((306 / width) * height) + pil_image = pil_image.resize((306, new_height), Image.LANCZOS) + if pil_image.mode in ('RGBA', 'LA') or (pil_image.mode == 'P' and 'transparency' in pil_image.info): + background = Image.new("RGB", pil_image.size, "white") + background.paste(pil_image, mask=pil_image.split()[-1]) + pil_image = background + image_stream = io.BytesIO() + pil_image.convert('1').save(image_stream, format="PNG") + image_stream.seek(0) + from brother_ql.raster import BrotherQLRaster + from brother_ql.conversion import convert + from brother_ql.backends.helpers import send + + # Brother QL 프린터로 전송 + qlr = BrotherQLRaster(printer_model) + instructions = convert( + qlr=qlr, + images=[Image.open(image_stream)], + label=label_type, + rotate="0", # 라벨 회전 없음 + threshold=70.0, # 흑백 변환 임계값 + dither=False, + compress=False, + lq=True, # 저화질 인쇄 옵션 + red=False + ) + send(instructions, printer_identifier=f"tcp://{printer_ip}:9100") + logging.info("커스텀 이미지 인쇄 성공") + print("[SUCCESS] 커스텀 이미지 인쇄 성공") + except Exception as e: + logging.error(f"커스텀 이미지 인쇄 실패: {e}") + print(f"[ERROR] 커스텀 이미지 인쇄 실패: {e}") + + +if __name__ == "__main__": + # 인터랙티브 메뉴를 통해 샘플 인쇄 선택 + + samples = { + "1": { + "patient_name": "이영희", + "med_name": "아모크라정375mg", + "add_info": "고혈압", + "frequency": 1, + "dosage": 375.0, + "duration": 30, + "formulation_type": "정제", + "main_ingredient_code": "AMO375", + "dosage_form": "경구", + "administration_route": "경구", + "label_name": "고혈압용", + "unit": None, + "conversion_factor": 1.0, + "storage_condition": "실온보관", + "custom_dosage_instruction": "" + }, + "2": { + "patient_name": "박지성", + "med_name": "삼남아세트아미노펜정500mg", + "add_info": "통증 완화", + "frequency": 2, + "dosage": 500.0, + "duration": 5, + "formulation_type": "정제", + "main_ingredient_code": "MED001", # 예시용 + "dosage_form": "경구", + "administration_route": "경구", + "label_name": "통증용", + "unit": None, + "conversion_factor": 1.0, + "storage_condition": "서늘한 곳에 보관", + "custom_dosage_instruction": "" + }, + "3": { + "patient_name": "최민수", + "med_name": "세레타이드125에보할러", + "add_info": "알레르기 치료", + "frequency": 3, + "dosage": 125.0, + "duration": 10, + "formulation_type": "정제", + "main_ingredient_code": "SER125", + "dosage_form": "흡입", + "administration_route": "흡입", + "label_name": "알레르기용", + "unit": None, + "conversion_factor": 1.0, + "storage_condition": "냉장보관", + "custom_dosage_instruction": "" + }, + "4": { + "patient_name": "최민수", + "med_name": "트윈스타정40/5mg", + "add_info": "혈압 조절", + "frequency": 2, + "dosage": 40.0, + "duration": 10, + "formulation_type": "정제", + "main_ingredient_code": "TW40", + "dosage_form": "경구", + "administration_route": "경구", + "label_name": "고혈압용", + "unit": None, + "conversion_factor": 1.0, + "storage_condition": "실온보관", + "custom_dosage_instruction": "" + }, + "5": { + "patient_name": "최우주", + "med_name": "오셀타원현탁용분말6mg/mL", + "add_info": "오셀타미", + "frequency": 2, + "dosage": 4.0, + "duration": 5, + "formulation_type": "현탁용분말", + "main_ingredient_code": "358907ASS", + "dosage_form": "SS", + "administration_route": "A", + "label_name": "오셀타원현탁용분말6mg/mL", + "unit": "ml", + "conversion_factor": 0.126, + "storage_condition": "실온및(냉장)", + "custom_dosage_instruction": "" + }, + "6": { + "patient_name": "최우주", + "med_name": "어린이타이레놀현탁액", + "add_info": "해열,진통제", + "frequency": 3, + "dosage": 3.0, + "duration": 3, + "formulation_type": "현탁액", + "main_ingredient_code": "101330ASS", + "dosage_form": "SS", + "administration_route": "A", + "label_name": "어린이타이레놀현탁액", + "unit": "ml", + "conversion_factor": None, + "storage_condition": "실온보관", + "custom_dosage_instruction": "" + } + } + + print("=======================================") + print(" 라벨 인쇄 샘플 선택 ") + print("=======================================") + for key, sample in samples.items(): + print(f"{key}: {sample['patient_name']} / {sample['med_name']} / {sample['add_info']}") + print("q: 종료") + + choice = input("인쇄할 샘플 번호를 선택하세요: ").strip() + while choice.lower() != 'q': + if choice in samples: + sample = samples[choice] + print(f"선택한 샘플: {sample['patient_name']} / {sample['med_name']}") + print_label( + patient_name=sample["patient_name"], + med_name=sample["med_name"], + add_info=sample["add_info"], + frequency=sample["frequency"], + dosage=sample["dosage"], + duration=sample["duration"], + formulation_type=sample["formulation_type"], + main_ingredient_code=sample["main_ingredient_code"], + dosage_form=sample["dosage_form"], + administration_route=sample["administration_route"], + label_name=sample["label_name"], + unit=sample["unit"], + conversion_factor=sample["conversion_factor"], + storage_condition=sample["storage_condition"], + custom_dosage_instruction=sample["custom_dosage_instruction"] + ) + else: + print("올바른 번호를 입력하세요.") + choice = input("인쇄할 샴플 번호를 선택하세요 (종료하려면 q 입력): ").strip() \ No newline at end of file diff --git a/backend/samples/printers.json b/backend/samples/printers.json new file mode 100644 index 0000000..de2e88c --- /dev/null +++ b/backend/samples/printers.json @@ -0,0 +1,28 @@ +{ + "printers": [ + { + "id": "printer_1", + "name": "메인 프린터 (QL-710W)", + "model": "QL-710W", + "ip_address": "192.168.0.121", + "port": 9100, + "label_type": "29", + "is_default": true, + "is_active": true, + "description": "1층 조제실 메인 프린터", + "location": "조제실" + }, + { + "id": "printer_2", + "name": "보조 프린터 (QL-810W)", + "model": "QL-810W", + "ip_address": "192.168.0.168", + "port": 9100, + "label_type": "29", + "is_default": false, + "is_active": true, + "description": "2층 조제실 보조 프린터", + "location": "투약대" + } + ] +} \ No newline at end of file diff --git a/backend/samples/product_label.py b/backend/samples/product_label.py new file mode 100644 index 0000000..1d38485 --- /dev/null +++ b/backend/samples/product_label.py @@ -0,0 +1,1368 @@ +# print_label.py + +from PIL import Image, ImageDraw, ImageFont +import io +import logging +from brother_ql.raster import BrotherQLRaster +from brother_ql.conversion import convert +from brother_ql.backends.helpers import send +import datetime # 날짜 처리를 위해 추가 +import pytz +import re +import qrcode +import json + +# 프린터 설정 +PRINTER_IP = "192.168.0.121" # QL-710W 프린터의 IP 주소 +PRINTER_MODEL = "QL-710W" +LABEL_TYPE = "29" # 29mm 연속 출력 용지 + +# 로깅 설정 +logging.basicConfig(level=logging.DEBUG, format='%(levelname)s:%(message)s') + +# KOR 시간대 설정 +KOR_TZ = pytz.timezone('Asia/Seoul') + + +def format_total_amount(dosage, frequency, duration): + """ + 1회 복용량, 복용 횟수, 총 복용 일수를 기반으로 총량을 계산하고, + 1/4 단위로 반올림하거나 그대로 반환합니다. + + Parameters: + dosage (float): 1회 복용량 (소수 넷째 자리까지 포함 가능) + frequency (int): 1일 복용 횟수 + duration (int): 복용 일수 + + Returns: + str: 포맷팅된 총량 문자열 + """ + if frequency > 0 and duration > 0: + # 1일 복용량 = 1회 복용량 * 1일 복용 횟수 + daily_dosage = dosage * frequency + # 총량 = 1일 복용량 * 총 복용 일수 + total_amount = daily_dosage * duration + + # 1회 복용량이 소수 넷째 자리까지 있는 경우 1/4 단위로 반올림 + if round(dosage, 4) != round(dosage, 3): # 소수 넷째 자리 여부 확인 + total_amount = round(total_amount * 4) / 4 + + # 정수인 경우 소수점 없이 표시, 소수가 있는 경우 둘째 자리까지 표시 + return str(int(total_amount)) if total_amount.is_integer() else f"{total_amount:.2f}".rstrip('0').rstrip('.') + return "0" # 복용 횟수나 복용 일수가 0인 경우 + + +def format_dosage(dosage): + """ + 1회 복용량을 포맷팅합니다. + + Parameters: + dosage (float): 1회 복용량 + + Returns: + str: 포맷팅된 복용량 문자열 + """ + if dosage.is_integer(): + return str(int(dosage)) + else: + # 최대 4자리 소수까지 표시, 불필요한 0 제거 + return f"{dosage:.4f}".rstrip('0').rstrip('.') + + +def format_converted_total(dosage, frequency, duration, conversion_factor): + """ + 총량을 계산하고 환산계수를 곱한 변환된 총량을 포맷팅합니다. + + Parameters: + dosage (float): 1회 복용량 (소수 넷째 자리까지 포함 가능) + frequency (int): 1일 복용 횟수 + duration (int): 복용 일수 + conversion_factor (float): 환산계수 + + Returns: + str: 변환된 총량을 포함한 포맷팅된 문자열 + """ + if frequency > 0 and duration > 0 and conversion_factor is not None: + total_amount = dosage * frequency * duration + if round(dosage, 4) != round(dosage, 3): + total_amount = round(total_amount * 4) / 4 + converted_total = total_amount * conversion_factor + if converted_total.is_integer(): + return str(int(converted_total)) + else: + return f"{converted_total:.2f}".rstrip('0').rstrip('.') + return None + + +def draw_scissor_border(draw, width, height, edge_size=5, steps=230): + """ + 라벨 이미지의 테두리에 톱니 모양의 절취선을 그립니다. + + Parameters: + draw (ImageDraw.Draw): 이미지에 그리기 위한 Draw 객체 + width (int): 라벨 너비 + height (int): 라벨 높이 + edge_size (int): 톱니 크기 + steps (int): 톱니 반복 횟수 + """ + 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 split_med_name(med_name): + """ + 약품 이름을 표시용 이름과 시그니처 정보로 분리합니다. + + Parameters: + med_name (str): 약품 이름 + + Returns: + tuple: (표시용 약품 이름, 시그니처 정보, 분리 여부) + """ + units = ['mg', 'g', 'ml', '%'] + pattern = r'(\d+(?:\.\d+)?(?:/\d+(?:\.\d+)?)*)\s*(' + '|'.join(units) + r')(?:/(' + '|'.join(units) + r'))?$' + korean_only = re.fullmatch(r'[가-힣]+', med_name) is not None + korean_and_num_eng = re.fullmatch(r'[가-힣a-zA-Z0-9/\.]+', med_name) is not None and not korean_only + med_name_display = med_name + signature_info = "청 춘 약 국" + split_occurred = False + if korean_only: + if len(med_name) >= 10: + match = re.search(pattern, med_name) + if match and match.start() >= 10: + med_name_display = med_name[:match.start()].strip() + signature_info = match.group(1) + match.group(2) + (f"/{match.group(3)}" if match.group(3) else "") + split_occurred = True + else: + med_name_display = med_name[:10] + # else 그대로 사용 + elif korean_and_num_eng: + if len(med_name) >= 13: + match = re.search(pattern, med_name) + if match: + med_name_display = med_name[:match.start()].strip() + signature_info = match.group(1) + match.group(2) + (f"/{match.group(3)}" if match.group(3) else "") + split_occurred = True + else: + med_name_display = med_name[:12] + return med_name_display, signature_info, split_occurred + + +def should_left_align(med_name_display): + """ + 약품 이름의 길이와 구성을 기반으로 좌측 정렬 여부를 결정합니다. + + Parameters: + med_name_display (str): 분리된 약품 이름 표시 부분 + + Returns: + bool: 좌측 정렬 여부 + """ + korean_only = re.fullmatch(r'[가-힣]+', med_name_display) is not None + korean_and_num_eng = re.fullmatch(r'[가-힣a-zA-Z0-9/\.]+', med_name_display) is not None and not korean_only + + if korean_only and len(med_name_display) >= 10: # 10글자부터 좌측정렬 (한글단독) + return True + if korean_and_num_eng and len(med_name_display) >= 13: # 13글자부터 좌측정렬 (한글+숫자+영문) + return True + return False + + +def create_label_image(patient_name, med_name, add_info, frequency, dosage, duration, + formulation_type, main_ingredient_code, dosage_form, administration_route, + label_name, unit, conversion_factor=None, storage_condition="실온보관", + custom_dosage_instruction=""): + """ + 라벨 이미지를 생성합니다. + + Parameters: + patient_name (str): 환자 이름 + med_name (str): 약품 이름 + add_info (str): 약품 효능 정보 + frequency (int): 복용 횟수 + dosage (float): 복용량 + duration (int): 복용 일수 + formulation_type (str): 제형 타입 + main_ingredient_code (str): 주성분 코드 + dosage_form (str): 복용 형태 + administration_route (str): 투여 경로 + label_name (str): 라벨 명칭 + unit (str): 복용 단위 + conversion_factor (float, optional): 환산계수 + storage_condition (str, optional): 보관 조건 + custom_dosage_instruction (str, optional): 커스텀 용법 텍스트 + + Returns: + PIL.Image: 생성된 라벨 이미지 + """ + # 라벨 이미지 설정 + label_width = 306 # 29mm 용지에 해당하는 너비 픽셀 수 (300 dpi 기준) + label_height = 380 # 라벨 높이를 380으로 확장하여 추가 정보 포함 (Glabel 기준 380 적당) + image = Image.new("1", (label_width, label_height), "white") + draw = ImageDraw.Draw(image) + + # 폰트 설정 (여기서 폰트를 규정하고 요소에서 불러서 사용) + font_path = "/root/project/react_cclabel/backend/fonts/malgunbd.ttf" + try: + patient_name_font = ImageFont.truetype(font_path, 44) + drug_name_font = ImageFont.truetype(font_path, 32) + info_font = ImageFont.truetype(font_path, 30) + signature_font = ImageFont.truetype(font_path, 32) + print_date_font = ImageFont.truetype(font_path, 20) # 조제일 폰트 추가 + additional_info_font = ImageFont.truetype(font_path, 27) # 추가 정보 폰트 + storage_condition_font = ImageFont.truetype(font_path, 27) # 보관 조건 폰트 + except IOError: + patient_name_font = ImageFont.load_default() + drug_name_font = ImageFont.load_default() + info_font = ImageFont.load_default() + signature_font = ImageFont.load_default() + print_date_font = ImageFont.load_default() # 조제일 폰트 기본값 사용 + additional_info_font = ImageFont.load_default() # 추가 정보 폰트 기본값 사용 + storage_condition_font = ImageFont.load_default() # 보관 조건 폰트 기본값 사용 + logging.warning("폰트 로드 실패. 기본 폰트 사용.") + + # 중앙 정렬된 텍스트 출력 함수 + def draw_centered_text(draw, text, y, font, max_width=None): + if not text: + return y + lines = [] + if max_width: + words = re.findall(r'\S+', text) + current_line = "" + for word in words: + test_line = f"{current_line} {word}".strip() + bbox = draw.textbbox((0, 0), test_line, font=font) + w = bbox[2] - bbox[0] + if w <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + if current_line: + lines.append(current_line) + else: + lines = [text] + for line in lines: + bbox = draw.textbbox((0, 0), line, font=font) + w, h = bbox[2] - bbox[0], bbox[3] - bbox[1] + draw.text(((label_width - w) / 2, y), line, font=font, fill="black") + y += h + 5 + return y + + def draw_left_aligned_text(draw, text, y, font, max_width=None): + if not text: + return y + lines = [] + if max_width: + words = re.findall(r'\S+', text) + current_line = "" + for word in words: + test_line = f"{current_line} {word}".strip() + bbox = draw.textbbox((0, 0), test_line, font=font) + w = bbox[2] - bbox[0] + if w <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + if current_line: + lines.append(current_line) + else: + lines = [text] + for line in lines: + bbox = draw.textbbox((0, 0), line, font=font) + w, h = bbox[2] - bbox[0], bbox[3] - bbox[1] + draw.text((10, y), line, font=font, fill="black") + y += h + 5 + return y + + y_position = 10 + y_position = draw_centered_text(draw, " ".join(patient_name), y_position, patient_name_font, max_width=label_width - 40) + med_name_display, signature_info, split_occurred = split_med_name(med_name) + if should_left_align(med_name_display): + y_position = draw_left_aligned_text(draw, med_name_display, y_position, drug_name_font, max_width=label_width - 40) + y_position = draw_centered_text(draw, f"({add_info})", y_position, info_font, max_width=label_width - 40) + else: + y_position = draw_centered_text(draw, med_name_display, y_position, drug_name_font, max_width=label_width - 40) + y_position = draw_centered_text(draw, f"({add_info})", y_position, info_font, max_width=label_width - 40) + if dosage and frequency and duration and unit: + formatted_dosage = format_dosage(dosage) + daily_dosage = dosage * frequency + total_amount = daily_dosage * duration + if round(dosage, 4) != round(dosage, 3): + total_amount = round(total_amount * 4) / 4 + formatted_total_amount = str(int(total_amount)) if total_amount.is_integer() else f"{total_amount:.2f}".rstrip('0').rstrip('.') + converted_total = format_converted_total(dosage, frequency, duration, conversion_factor) + if converted_total is not None: + total_label = f"총{formatted_total_amount}{unit}/{duration}일분({converted_total})" + else: + total_label = f"총{formatted_total_amount}{unit}/{duration}일분" + y_position = draw_centered_text(draw, total_label, y_position, additional_info_font, max_width=label_width - 40) + box_height = 70 + box_margin = 10 + box_width = label_width - 40 + box_x1 = (label_width - box_width) // 2 + box_x2 = box_x1 + box_width + box_y1 = y_position + box_margin + box_y2 = box_y1 + box_height + draw.rectangle([box_x1, box_y1, box_x2, box_y2], outline="black", width=2) + box_padding = 10 + line_spacing = 5 + box_text1 = f"{formatted_dosage}{unit}" + text1_size = info_font.getbbox(box_text1) + text1_height = text1_size[3] - text1_size[1] + frequency_text = custom_dosage_instruction.strip() + if not frequency_text: + if frequency == 1: + frequency_text = "아침" + elif frequency == 2: + frequency_text = "아침, 저녁" + elif frequency == 3: + frequency_text = "아침, 점심, 저녁" + text2_height = 0 + if frequency_text: + text2_size = info_font.getbbox(frequency_text) + text2_height = text2_size[3] - text2_size[1] + total_text_height = text1_height + line_spacing + text2_height + center_y = (box_y1 + box_y2) // 2 + adjustment = 7 + start_y = center_y - (total_text_height // 2) - adjustment + y_temp = draw_centered_text(draw, box_text1, start_y, info_font, max_width=box_width) + if frequency_text: + text2_y = y_temp + line_spacing + draw_centered_text(draw, frequency_text, text2_y, info_font, max_width=box_width) + y_position = box_y2 + box_margin + if storage_condition: + storage_condition_text = f"{storage_condition}" + y_position = draw_centered_text(draw, storage_condition_text, y_position, storage_condition_font, max_width=label_width - 40) + print_date_text = f"조제일 : {datetime.datetime.now(KOR_TZ).strftime('%Y-%m-%d')}" + bbox = draw.textbbox((0, 0), print_date_text, font=print_date_font) + w, h = bbox[2] - bbox[0], bbox[3] - bbox[1] + print_date_x = (label_width - w) / 2 + print_date_y = label_height - h - 70 + draw.text((print_date_x, print_date_y), print_date_text, font=print_date_font, fill="black") + signature_text = signature_info if signature_info else "청 춘 약 국" + margin_val = int(0.1 * label_width) + box_width_sig = label_width - 2 * margin_val + try: + bbox = draw.textbbox((0, 0), signature_text, font=signature_font) + w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1] + scale_factor = box_width_sig / w_sig if w_sig != 0 else 1 + scaled_font_size = max(1, int(22 * scale_factor)) + scaled_font = ImageFont.truetype(font_path, scaled_font_size) + except IOError: + scaled_font = ImageFont.load_default() + logging.warning("시그니처 폰트 로드 실패. 기본 폰트 사용.") + bbox = draw.textbbox((0, 0), signature_text, font=scaled_font) + w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1] + + # 테두리 여백 설정 + padding_top = int(h_sig * 0.1) # 위쪽 여백: 텍스트 높이의 10% + padding_bottom = int(h_sig * 0.5) # 아래쪽 여백: 텍스트 높이의 50% + padding_sides = int(h_sig * 0.2) # 좌우 여백: 텍스트 높이의 20% + + # 테두리 좌표 계산 + 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") + draw.text(((label_width - w_sig) / 2, box_y + padding_top), signature_text, font=scaled_font, fill="black") + draw_scissor_border(draw, label_width, label_height, edge_size=10, steps=20) + return image + + +def print_label(patient_name, med_name, add_info, frequency, dosage, duration, + formulation_type, main_ingredient_code, dosage_form, administration_route, label_name, + unit=None, conversion_factor=None, storage_condition="실온보관", custom_dosage_instruction=""): + """ + 라벨 이미지를 생성하여 프린터로 인쇄합니다. + + Parameters: + patient_name (str): 환자 이름 + med_name (str): 약품 이름 + add_info (str): 약품 효능 정보 + frequency (int): 복용 횟수 + dosage (float): 복용량 + duration (int): 복용 일수 + formulation_type (str): 제형 타입 + main_ingredient_code (str): 주성분 코드 + dosage_form (str): 복용 형태 + administration_route (str): 투여 경로 + label_name (str): 라벨 명칭 + unit (str, optional): 복용 단위 + conversion_factor (float, optional): 환산계수 + storage_condition (str, optional): 보관 조건 + custom_dosage_instruction (str, optional): 커스텀 용법 텍스트 + """ + try: + if not unit: + if "캡슐" in med_name: + unit = "캡슐" + elif "정" in med_name: + unit = "정" + elif "시럽" in med_name: + unit = "ml" + elif "과립" in med_name or "시럽" in med_name: + unit = "g" + else: + unit = "개" + label_image = create_label_image( + patient_name=patient_name, + med_name=med_name, + add_info=add_info, + frequency=frequency, + dosage=dosage, + duration=duration, + formulation_type=formulation_type, + main_ingredient_code=main_ingredient_code, + dosage_form=dosage_form, + administration_route=administration_route, + label_name=label_name, + unit=unit, + conversion_factor=conversion_factor, + storage_condition=storage_condition, + custom_dosage_instruction=custom_dosage_instruction + ) + image_stream = io.BytesIO() + label_image.save(image_stream, format="PNG") + image_stream.seek(0) + from brother_ql.raster import BrotherQLRaster + from brother_ql.conversion import convert + from brother_ql.backends.helpers import send + + qlr = BrotherQLRaster(PRINTER_MODEL) + instructions = convert( + qlr=qlr, + images=[Image.open(image_stream)], + label=LABEL_TYPE, + rotate="0", + threshold=70.0, + dither=False, + compress=False, + lq=True, + red=False + ) + send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100") + logging.info(f"라벨 인쇄 성공: 환자={patient_name}, 약품={med_name}, 커스텀 용법={custom_dosage_instruction}") + print(f"[SUCCESS] 라벨 인쇄 성공: 환자={patient_name}, 약품={med_name}, 커스텀 용법={custom_dosage_instruction}") + except Exception as e: + logging.error(f"라벨 인쇄 실패: {e}") + print(f"[ERROR] 라벨 인쇄 실패: {e}") + + +def print_custom_image(pil_image): + """ + PIL 이미지를 받아 Brother QL 프린터로 인쇄합니다. + + Parameters: + pil_image (PIL.Image): 인쇄할 이미지 + """ + try: + logging.info(f"이미지 모드: {pil_image.mode}") + if pil_image.mode in ('RGBA', 'LA'): + logging.info("알파 채널 있음 (RGBA 또는 LA 모드)") + elif pil_image.mode == 'P' and 'transparency' in pil_image.info: + logging.info("알파 채널 있음 (팔레트 모드, transparency 키 확인됨)") + else: + logging.info("알파 채널 없음") + pil_image = pil_image.rotate(90, expand=True) + width, height = pil_image.size + new_height = int((306 / width) * height) + pil_image = pil_image.resize((306, new_height), Image.LANCZOS) + if pil_image.mode in ('RGBA', 'LA') or (pil_image.mode == 'P' and 'transparency' in pil_image.info): + background = Image.new("RGB", pil_image.size, "white") + background.paste(pil_image, mask=pil_image.split()[-1]) + pil_image = background + image_stream = io.BytesIO() + pil_image.convert('1').save(image_stream, format="PNG") + image_stream.seek(0) + from brother_ql.raster import BrotherQLRaster + from brother_ql.conversion import convert + from brother_ql.backends.helpers import send + + # Brother QL 프린터로 전송 + qlr = BrotherQLRaster(PRINTER_MODEL) + instructions = convert( + qlr=qlr, + images=[Image.open(image_stream)], + label=LABEL_TYPE, + rotate="0", # 라벨 회전 없음 + threshold=70.0, # 흑백 변환 임계값 + dither=False, + compress=False, + lq=True, # 저화질 인쇄 옵션 + red=False + ) + send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100") + logging.info("커스텀 이미지 인쇄 성공") + print("[SUCCESS] 커스텀 이미지 인쇄 성공") + except Exception as e: + logging.error(f"커스텀 이미지 인쇄 실패: {e}") + print(f"[ERROR] 커스텀 이미지 인쇄 실패: {e}") + + +if __name__ == "__main__": + # 인터랙티브 메뉴를 통해 샘플 인쇄 선택 + + samples = { + "1": { + "patient_name": "이영희", + "med_name": "아모크라정375mg", + "add_info": "고혈압", + "frequency": 1, + "dosage": 375.0, + "duration": 30, + "formulation_type": "정제", + "main_ingredient_code": "AMO375", + "dosage_form": "경구", + "administration_route": "경구", + "label_name": "고혈압용", + "unit": None, + "conversion_factor": 1.0, + "storage_condition": "실온보관", + "custom_dosage_instruction": "" + }, + "2": { + "patient_name": "박지성", + "med_name": "삼남아세트아미노펜정500mg", + "add_info": "통증 완화", + "frequency": 2, + "dosage": 500.0, + "duration": 5, + "formulation_type": "정제", + "main_ingredient_code": "MED001", # 예시용 + "dosage_form": "경구", + "administration_route": "경구", + "label_name": "통증용", + "unit": None, + "conversion_factor": 1.0, + "storage_condition": "서늘한 곳에 보관", + "custom_dosage_instruction": "" + }, + "3": { + "patient_name": "최민수", + "med_name": "세레타이드125에보할러", + "add_info": "알레르기 치료", + "frequency": 3, + "dosage": 125.0, + "duration": 10, + "formulation_type": "정제", + "main_ingredient_code": "SER125", + "dosage_form": "흡입", + "administration_route": "흡입", + "label_name": "알레르기용", + "unit": None, + "conversion_factor": 1.0, + "storage_condition": "냉장보관", + "custom_dosage_instruction": "" + }, + "4": { + "patient_name": "최민수", + "med_name": "트윈스타정40/5mg", + "add_info": "혈압 조절", + "frequency": 2, + "dosage": 40.0, + "duration": 10, + "formulation_type": "정제", + "main_ingredient_code": "TW40", + "dosage_form": "경구", + "administration_route": "경구", + "label_name": "고혈압용", + "unit": None, + "conversion_factor": 1.0, + "storage_condition": "실온보관", + "custom_dosage_instruction": "" + }, + "5": { + "patient_name": "최우주", + "med_name": "오셀타원현탁용분말6mg/mL", + "add_info": "오셀타미", + "frequency": 2, + "dosage": 4.0, + "duration": 5, + "formulation_type": "현탁용분말", + "main_ingredient_code": "358907ASS", + "dosage_form": "SS", + "administration_route": "A", + "label_name": "오셀타원현탁용분말6mg/mL", + "unit": "ml", + "conversion_factor": 0.126, + "storage_condition": "실온및(냉장)", + "custom_dosage_instruction": "" + }, + "6": { + "patient_name": "최우주", + "med_name": "어린이타이레놀현탁액", + "add_info": "해열,진통제", + "frequency": 3, + "dosage": 3.0, + "duration": 3, + "formulation_type": "현탁액", + "main_ingredient_code": "101330ASS", + "dosage_form": "SS", + "administration_route": "A", + "label_name": "어린이타이레놀현탁액", + "unit": "ml", + "conversion_factor": None, + "storage_condition": "실온보관", + "custom_dosage_instruction": "" + } + } + + print("=======================================") + print(" 라벨 인쇄 샘플 선택 ") + print("=======================================") + for key, sample in samples.items(): + print(f"{key}: {sample['patient_name']} / {sample['med_name']} / {sample['add_info']}") + print("q: 종료") + + choice = input("인쇄할 샘플 번호를 선택하세요: ").strip() + while choice.lower() != 'q': + if choice in samples: + sample = samples[choice] + print(f"선택한 샘플: {sample['patient_name']} / {sample['med_name']}") + print_label( + patient_name=sample["patient_name"], + med_name=sample["med_name"], + add_info=sample["add_info"], + frequency=sample["frequency"], + dosage=sample["dosage"], + duration=sample["duration"], + formulation_type=sample["formulation_type"], + main_ingredient_code=sample["main_ingredient_code"], + dosage_form=sample["dosage_form"], + administration_route=sample["administration_route"], + label_name=sample["label_name"], + unit=sample["unit"], + conversion_factor=sample["conversion_factor"], + storage_condition=sample["storage_condition"], + custom_dosage_instruction=sample["custom_dosage_instruction"] + ) + else: + print("올바른 번호를 입력하세요.") + choice = input("인쇄할 샴플 번호를 선택하세요 (종료하려면 q 입력): ").strip() + + +# ================================================================================== +# 약품 QR 라벨 인쇄 기능 (신규 추가) +# ================================================================================== +# 목적: 약품 검색 후 바코드 기반 QR 코드, 약품명, 판매가격이 포함된 라벨 인쇄 +# 기존 처방전 라벨 인쇄 기능과 완전히 분리된 독립 기능 +# ================================================================================== + +def create_drug_qr_label(drug_name, barcode, sale_price, drug_code=None): + """ + 약품 QR 라벨 이미지 생성 + + Parameters: + drug_name (str): 약품명 + barcode (str): 바코드 (QR 코드로 변환) + sale_price (float): 판매가격 + drug_code (str, optional): 약품 코드 (바코드가 없을 때 대체) + + Returns: + PIL.Image: 생성된 라벨 이미지 + + 라벨 구조 (3단): + ┌─────────────────┐ + │ [QR CODE] │ ← 바코드 기반 QR 코드 (상단) + ├─────────────────┤ + │ 약품명 │ ← 중앙 정렬 (중간) + ├─────────────────┤ + │ ₩12,000 │ ← 판매가격 (하단) + └─────────────────┘ + """ + # 라벨 크기 설정 (29mm 용지 기준) + label_width = 306 + label_height = 380 + image = Image.new("1", (label_width, label_height), "white") + draw = ImageDraw.Draw(image) + + # 폰트 설정 + font_path = "/srv/for-windows/person-lookup-web/prescription_monitoring/malgunbd.ttf" + try: + drug_name_font = ImageFont.truetype(font_path, 32) + price_font = ImageFont.truetype(font_path, 36) + label_font = ImageFont.truetype(font_path, 24) + except IOError: + drug_name_font = ImageFont.load_default() + price_font = ImageFont.load_default() + label_font = ImageFont.load_default() + logging.warning("폰트 로드 실패. 기본 폰트 사용.") + + # 바코드가 없으면 약품 코드 사용 + qr_data = barcode if barcode else (drug_code if drug_code else "NO_BARCODE") + + # QR 코드 생성 + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=4, + border=1, + ) + qr.add_data(qr_data) + qr.make(fit=True) + qr_img = qr.make_image(fill_color="black", back_color="white") + + # QR 코드 크기 조정 및 배치 (상단 영역) - 크기 축소 + qr_size = 130 # 180 → 130으로 축소 + qr_img = qr_img.resize((qr_size, qr_size), Image.LANCZOS) + qr_x = (label_width - qr_size) // 2 + qr_y = 15 # 상단 여백 약간 줄임 + + # QR 코드를 메인 이미지에 붙이기 + if qr_img.mode != '1': + qr_img = qr_img.convert('1') + image.paste(qr_img, (qr_x, qr_y)) + + # 약품명 그리기 (QR 코드 아래) + y_position = qr_y + qr_size + 10 # 간격 줄임 + + # 약품명이 길면 2줄로 나누기 (괄호 기준 자동 분리) + def draw_wrapped_text(draw, text, y, font, max_width): + """ + 약품명을 2줄로 표시하되, 괄호가 있으면 괄호 앞뒤로 자동 분리 + + 예시: + - "안텔민뽀삐(5kg이하)" → ["안텔민뽀삐", "(5kg이하)"] + - "타이레놀정500mg" → 폭 기준으로 자동 분리 + """ + import re + + lines = [] + + # 괄호가 있으면 괄호 기준으로 먼저 분리 + if '(' in text and ')' in text: + # 괄호 앞부분과 괄호 포함 뒷부분으로 분리 + match = re.match(r'^(.+?)(\(.+\))$', text) + if match: + main_part = match.group(1).strip() + bracket_part = match.group(2).strip() + + # 첫 줄이 max_width를 초과하면 글자 단위로 자르기 + bbox = draw.textbbox((0, 0), main_part, font=font) + w = bbox[2] - bbox[0] + + if w <= max_width: + # 괄호 앞 부분이 한 줄에 들어감 + lines.append(main_part) + lines.append(bracket_part) + else: + # 괄호 앞 부분이 너무 길면 글자 단위로 자르기 + chars = list(main_part) + current_line = "" + + for char in chars: + test_line = current_line + char + bbox = draw.textbbox((0, 0), test_line, font=font) + w = bbox[2] - bbox[0] + + if w <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = char + + if current_line: + lines.append(current_line) + + # 괄호 부분 추가 (최대 2줄이므로 1개만) + if len(lines) < 2: + lines.append(bracket_part) + else: + # 괄호 패턴 매칭 실패 시 기존 로직 + lines = _split_text_by_width(draw, text, font, max_width) + else: + # 괄호가 없으면 폭 기준으로 자동 분리 + lines = _split_text_by_width(draw, text, font, max_width) + + # 최대 2줄만 표시 + lines = lines[:2] + + for line in lines: + bbox = draw.textbbox((0, 0), line, font=font) + w, h = bbox[2] - bbox[0], bbox[3] - bbox[1] + draw.text(((label_width - w) / 2, y), line, font=font, fill="black") + y += h + 5 + + return y + + def _split_text_by_width(draw, text, font, max_width): + """폭 기준으로 텍스트를 여러 줄로 분리 (헬퍼 함수)""" + chars = list(text) + lines = [] + current_line = "" + + for char in chars: + test_line = current_line + char + bbox = draw.textbbox((0, 0), test_line, font=font) + w = bbox[2] - bbox[0] + + if w <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = char + + if current_line: + lines.append(current_line) + + return lines + + y_position = draw_wrapped_text(draw, drug_name, y_position, drug_name_font, label_width - 40) + + # 약간의 여백 + y_position += 8 + + # 가격 그리기 (약품명 바로 아래) + if sale_price > 0: + price_text = f"₩{int(sale_price):,}" + else: + price_text = "가격 미정" + + bbox = draw.textbbox((0, 0), price_text, font=price_font) + w, h = bbox[2] - bbox[0], bbox[3] - bbox[1] + draw.text(((label_width - w) / 2, y_position), price_text, font=price_font, fill="black") + y_position += h + 15 # 가격 다음 여백 + + # 구분선 그리기 (가격과 청춘약국 사이) + line_margin = 30 + draw.line([(line_margin, y_position), (label_width - line_margin, y_position)], fill="black", width=2) + y_position += 12 + + # 청춘약국 서명 (하단에 고정하지 않고 가격 아래 배치) + signature_text = "청 춘 약 국" + bbox = draw.textbbox((0, 0), signature_text, font=label_font) + w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1] + + # 테두리 박스 그리기 + padding = 10 + box_x = (label_width - w_sig) / 2 - padding + box_y = y_position - padding + box_x2 = box_x + w_sig + 2 * padding + box_y2 = box_y + h_sig + 2 * padding + draw.rectangle([(box_x, box_y), (box_x2, box_y2)], outline="black", width=2) + draw.text(((label_width - w_sig) / 2, y_position), signature_text, font=label_font, fill="black") + + # 절취선 테두리 + draw_scissor_border(draw, label_width, label_height, edge_size=10, steps=20) + + return image + + +def print_drug_qr_label(drug_name, barcode, sale_price, drug_code=None): + """ + 약품 QR 라벨 인쇄 실행 + + Parameters: + drug_name (str): 약품명 + barcode (str): 바코드 + sale_price (float): 판매가격 + drug_code (str, optional): 약품 코드 + """ + try: + label_image = create_drug_qr_label(drug_name, barcode, sale_price, drug_code) + + # 이미지를 메모리 스트림으로 변환 + image_stream = io.BytesIO() + label_image.save(image_stream, format="PNG") + image_stream.seek(0) + + # Brother QL 프린터로 전송 + qlr = BrotherQLRaster(PRINTER_MODEL) + instructions = convert( + qlr=qlr, + images=[Image.open(image_stream)], + label=LABEL_TYPE, + rotate="0", + threshold=70.0, + dither=False, + compress=False, + lq=True, + red=False + ) + send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100") + + logging.info(f"약품 QR 라벨 인쇄 성공: 약품={drug_name}, 바코드={barcode}, 가격={sale_price}") + print(f"[SUCCESS] 약품 QR 라벨 인쇄 성공: {drug_name}") + return True + + except Exception as e: + logging.error(f"약품 QR 라벨 인쇄 실패: {e}") + print(f"[ERROR] 약품 QR 라벨 인쇄 실패: {e}") + return False + + +# ================================================================================== +# 약품 QR 라벨 인쇄 기능 끝 +# ================================================================================== + + +# ================================================================================== +# 상품 라벨 인쇄 기능 (v2.0 - 효과/용법/꿀팁 포함) +# ================================================================================== + +def create_product_label(drug_name, effect="", dosage_instruction="", usage_tip=""): + """ + 상품 라벨 이미지 생성 + + Parameters: + drug_name (str): 약품명 (필수) + effect (str): 효과 (예: "치통약") + dosage_instruction (str): 용법 (예: "2정 또는 3정 복용...") + usage_tip (str): 복용 꿀팁 (예: "이튼돌과 함께...") + + Returns: + PIL.Image: 생성된 라벨 이미지 (306x400px, mode='1') + """ + # 라벨 크기 설정 + label_width = 306 + label_height = 400 + image = Image.new("1", (label_width, label_height), "white") + draw = ImageDraw.Draw(image) + + # 폰트 설정 + font_path = "/srv/for-windows/person-lookup-web/prescription_monitoring/malgunbd.ttf" + try: + drug_name_font = ImageFont.truetype(font_path, 32) + label_font = ImageFont.truetype(font_path, 24) + tip_font = ImageFont.truetype(font_path, 22) + signature_font = ImageFont.truetype(font_path, 24) + except IOError: + drug_name_font = ImageFont.load_default() + label_font = ImageFont.load_default() + tip_font = ImageFont.load_default() + signature_font = ImageFont.load_default() + logging.warning("폰트 로드 실패. 기본 폰트 사용.") + + # 좌측 정렬 텍스트 출력 함수 + def draw_left_aligned_text(draw, text, y, font, max_width=266, max_lines=4): + if not text: + return y + + lines = [] + current_line = "" + + # 텍스트를 줄바꿈 기준으로 먼저 분리 + text_lines = text.split('\n') + + for text_line in text_lines: + # 각 줄을 글자 단위로 처리 + for char in text_line: + test_line = current_line + char + bbox = draw.textbbox((0, 0), test_line, font=font) + w = bbox[2] - bbox[0] + + if w <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = char + + # 줄 끝에서 현재 라인 추가 + if current_line: + lines.append(current_line) + current_line = "" + + # 최대 줄 수 제한 + lines = lines[:max_lines] + + for line in lines: + bbox = draw.textbbox((0, 0), line, font=font) + h = bbox[3] - bbox[1] + draw.text((20, y), line, font=font, fill="black") + y += h + 3 + + return y + + # 중앙 정렬 텍스트 출력 함수 + def draw_centered_text(draw, text, y, font, max_width=286, max_lines=2): + if not text: + return y + + lines = [] + current_line = "" + + for char in text: + test_line = current_line + char + bbox = draw.textbbox((0, 0), test_line, font=font) + w = bbox[2] - bbox[0] + + if w <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = char + + if current_line: + lines.append(current_line) + + lines = lines[:max_lines] + + for line in lines: + bbox = draw.textbbox((0, 0), line, font=font) + w, h = bbox[2] - bbox[0], bbox[3] - bbox[1] + draw.text(((label_width - w) / 2, y), line, font=font, fill="black") + y += h + 5 + + return y + + y_position = 15 + + # 1. 약품명 (중앙 정렬, 최대 2줄) + y_position = draw_centered_text(draw, drug_name, y_position, drug_name_font, max_width=286, max_lines=2) + y_position += 10 + + # 구분선 + draw.line([(30, y_position), (276, y_position)], fill="black", width=2) + y_position += 10 + + # 2. 효과 (좌측 정렬, 최대 2줄) + if effect: + effect_text = f"효과: {effect}" + y_position = draw_left_aligned_text(draw, effect_text, y_position, label_font, max_width=266, max_lines=2) + y_position += 8 + + # 3. 용법 (좌측 정렬, 최대 4줄) + if dosage_instruction: + dosage_text = f"용법:\n{dosage_instruction}" + y_position = draw_left_aligned_text(draw, dosage_text, y_position, label_font, max_width=266, max_lines=4) + y_position += 8 + + # 구분선 + if usage_tip: + draw.line([(30, y_position), (276, y_position)], fill="black", width=2) + y_position += 10 + + # 4. 복용 꿀팁 (좌측 정렬, 최대 4줄) + if usage_tip: + tip_text = f"💡 복용 꿀팁:\n{usage_tip}" + y_position = draw_left_aligned_text(draw, tip_text, y_position, tip_font, max_width=266, max_lines=4) + y_position += 12 + + # 구분선 + draw.line([(30, y_position), (276, y_position)], fill="black", width=2) + y_position += 15 + + # 5. 청춘약국 서명 (하단 고정, 테두리 박스) + 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 = 10 + box_x = (label_width - w_sig) / 2 - padding + box_y = label_height - h_sig - 40 + box_x2 = box_x + w_sig + 2 * padding + box_y2 = box_y + h_sig + 2 * padding + draw.rectangle([(box_x, box_y), (box_x2, box_y2)], outline="black", width=2) + draw.text(((label_width - w_sig) / 2, box_y + padding), signature_text, font=signature_font, fill="black") + + # 6. 절취선 테두리 + draw_scissor_border(draw, label_width, label_height, edge_size=10, steps=20) + + return image + + +def print_product_label(drug_name, effect="", dosage_instruction="", usage_tip=""): + """ + 상품 라벨 인쇄 실행 + + Parameters: + drug_name (str): 약품명 + effect (str): 효과 + dosage_instruction (str): 용법 + usage_tip (str): 복용 꿀팁 + + Returns: + bool: 인쇄 성공 여부 + """ + try: + label_image = create_product_label(drug_name, effect, dosage_instruction, usage_tip) + + # 이미지를 메모리 스트림으로 변환 + image_stream = io.BytesIO() + label_image.save(image_stream, format="PNG") + image_stream.seek(0) + + # Brother QL 프린터로 전송 + qlr = BrotherQLRaster(PRINTER_MODEL) + instructions = convert( + qlr=qlr, + images=[Image.open(image_stream)], + label=LABEL_TYPE, + rotate="0", + threshold=70.0, + dither=False, + compress=False, + lq=True, + red=False + ) + send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100") + + logging.info(f"상품 라벨 인쇄 성공: {drug_name}") + print(f"[SUCCESS] 상품 라벨 인쇄 성공: {drug_name}") + return True + + except Exception as e: + logging.error(f"상품 라벨 인쇄 실패: {e}") + print(f"[ERROR] 상품 라벨 인쇄 실패: {e}") + return False + + +def create_product_label_wide(drug_name, effect="", dosage_instruction="", usage_tip=""): + """ + 가로형 와이드 상품 라벨 이미지 생성 (800 x 306px) + + 레이아웃 구조: + - 효능: 중앙 상단에 크게 강조 + - 약품명: 오른쪽 여백 공간에 배치 + - 용법: 왼쪽 하단에 크게 + - 약국명: 오른쪽 하단에 크게 + + Args: + drug_name (str): 약품명 + effect (str): 효능 + dosage_instruction (str): 복용 방법 + usage_tip (str): 사용 팁 + + Returns: + PIL.Image: 가로형 와이드 라벨 이미지 (800 x 306px, mode='1') + """ + try: + # 1. 캔버스 생성 (가로로 긴 형태, 고정 800px) + width = 800 + height = 306 # Brother QL 29mm 용지 폭 + + img = Image.new('1', (width, height), 1) # 흰색 배경 + draw = ImageDraw.Draw(img) + + # 2. 폰트 로드 (새로운 레이아웃에 맞게 최적화) + font_path = "/srv/for-windows/person-lookup-web/prescription_monitoring/malgunbd.ttf" + 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: + 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: + font_dosage_adjusted = ImageFont.truetype(font_path, 50) # 더 큰 폰트 + else: + font_dosage_adjusted = font_dosage # 기본 폰트 (40pt) + + if dosage_instruction: + # 대괄호로 묶인 부분을 별도 줄로 분리 + import re + # [텍스트] 패턴을 찾아서 줄바꿈으로 치환 + 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"가로형 와이드 라벨 이미지 생성 성공: {drug_name}") + return img + + except Exception as e: + logging.error(f"가로형 와이드 라벨 이미지 생성 실패: {e}") + raise + + +def print_product_label_wide(drug_name, effect="", dosage_instruction="", usage_tip=""): + """ + 가로형 와이드 상품 라벨 인쇄 + + 이미지를 90도 회전하여 Brother QL 프린터로 전송 + + Args: + drug_name (str): 약품명 + effect (str): 효능 + dosage_instruction (str): 복용 방법 + usage_tip (str): 사용 팁 + + Returns: + bool: 성공 여부 + """ + try: + # 1. 가로형 라벨 이미지 생성 + label_img = create_product_label_wide(drug_name, effect, dosage_instruction, usage_tip) + + # 2. 이미지 90도 회전 (Brother QL이 세로 방향 기준이므로) + # 시계 반대방향 90도 회전 + 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', # 29mm 용지 + rotate='0', # 이미 회전했으므로 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] 가로형 와이드 상품 라벨 인쇄 성공: {drug_name}") + print(f"[SUCCESS] 가로형 와이드 상품 라벨 인쇄 성공: {drug_name}") + return True + + except Exception as e: + logging.error(f"[ERROR] 가로형 와이드 라벨 인쇄 실패: {e}") + print(f"[ERROR] 가로형 와이드 라벨 인쇄 실패: {e}") + return False + + +# ================================================================================== +# 상품 라벨 인쇄 기능 끝 +# ================================================================================== \ No newline at end of file diff --git a/backend/test_integration.py b/backend/test_integration.py new file mode 100644 index 0000000..95f42d2 --- /dev/null +++ b/backend/test_integration.py @@ -0,0 +1,98 @@ +""" +통합 테스트: QR 라벨 전체 흐름 +토큰 생성 → DB 저장 → QR 라벨 이미지 생성 +""" + +import sys +import os +from datetime import datetime + +# Path setup +sys.path.insert(0, os.path.join(os.path.dirname(__file__))) + +from utils.qr_token_generator import generate_claim_token, save_token_to_db +from utils.qr_label_printer import print_qr_label + +def test_full_flow(): + """전체 흐름 테스트""" + + # 1. 테스트 데이터 (새로운 거래 ID) + test_tx_id = datetime.now().strftime("TEST%Y%m%d%H%M%S") + test_amount = 75000.0 + test_time = datetime.now() + + print("=" * 80) + print("QR 라벨 통합 테스트") + print("=" * 80) + print(f"거래 ID: {test_tx_id}") + print(f"판매 금액: {test_amount:,}원") + print() + + # 2. 토큰 생성 + print("[1/3] Claim Token 생성...") + token_info = generate_claim_token(test_tx_id, test_amount) + + print(f" [OK] 토큰 원문: {token_info['token_raw'][:50]}...") + print(f" [OK] 토큰 해시: {token_info['token_hash'][:32]}...") + print(f" [OK] QR URL: {token_info['qr_url']}") + print(f" [OK] URL 길이: {len(token_info['qr_url'])} 문자") + print(f" [OK] 적립 포인트: {token_info['claimable_points']}P") + print() + + # 3. DB 저장 + print("[2/3] SQLite DB 저장...") + success, error = save_token_to_db( + test_tx_id, + token_info['token_hash'], + test_amount, + token_info['claimable_points'], + token_info['expires_at'], + token_info['pharmacy_id'] + ) + + if not success: + print(f" [ERROR] DB 저장 실패: {error}") + return False + + print(f" [OK] DB 저장 성공") + print() + + # 4. QR 라벨 생성 (미리보기 모드) + print("[3/3] QR 라벨 이미지 생성...") + success, image_path = print_qr_label( + token_info['qr_url'], + test_tx_id, + test_amount, + token_info['claimable_points'], + test_time, + preview_mode=True + ) + + if not success: + print(f" [ERROR] 이미지 생성 실패") + return False + + print(f" [OK] 이미지 저장: {image_path}") + print() + + # 5. 결과 요약 + print("=" * 80) + print("[SUCCESS] 통합 테스트 성공!") + print("=" * 80) + print(f"QR URL: {token_info['qr_url']}") + print(f"이미지 파일: {image_path}") + print(f"\n다음 명령으로 확인:") + print(f" start {image_path}") + print("=" * 80) + + return True + +if __name__ == "__main__": + try: + success = test_full_flow() + sys.exit(0 if success else 1) + except Exception as e: + print(f"\n[ERROR] 테스트 실패: {e}") + import traceback + traceback.print_exc() + sys.exit(1)