feat: 통합 테스트 및 샘플 코드 추가

- test_integration.py: QR 토큰 생성 및 라벨 테스트
- samples/barcode_print.py: Brother QL 프린터 예제
- samples/barcode_reader_gui.py: 바코드 리더 GUI 참고 코드

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
시골약사 2026-01-23 16:36:41 +09:00
parent 4581ebb7c5
commit b4de6ff791
8 changed files with 3807 additions and 0 deletions

View File

@ -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'(?<!m)(?<!μ)그램|그람', 'g', med_name)
# 공백 정리
med_name = re.sub(r'\s+', ' ', med_name).strip()
return med_name
def create_wide_label(goods_name, sale_price):
"""
가로형 와이드 라벨 이미지 생성 (product_label.py 기반)
- 크기: 800 x 306px (Brother QL 29mm 가로형)
- 하드코딩: 효능 "치통/진통제", 용법, 사용팁
- 동적: 약품명만 스캔된 사용
Args:
goods_name: 약품명 (스캔된 실제 약품명)
sale_price: 판매가 (사용하지 않음, 호환성 유지)
Returns:
PIL.Image: 생성된 가로형 라벨 이미지 (800x306px, mode='1')
"""
try:
# 1. 캔버스 생성 (가로로 긴 형태)
width = 800
height = 306 # Brother QL 29mm 용지 폭
img = Image.new('1', (width, height), 1) # 흰색 배경
draw = ImageDraw.Draw(img)
# 2. 폰트 로드
font_path = os.path.join(os.path.dirname(__file__), "fonts", "malgunbd.ttf")
try:
font_effect = ImageFont.truetype(font_path, 72) # 효능 (매우 크게!)
font_drugname = ImageFont.truetype(font_path, 36) # 약품명 (중간)
font_dosage = ImageFont.truetype(font_path, 50) # 용법 (크게, 사용팁 없으므로)
font_pharmacy = ImageFont.truetype(font_path, 32) # 약국명 (크게)
font_small = ImageFont.truetype(font_path, 26) # 사용팁
except IOError:
logging.warning("폰트 로드 실패. 기본 폰트 사용.")
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()
# 3. 하드코딩 데이터
effect = "치통/진통제"
dosage_instruction = "1캡슐 또는 2캡슐 복용, 1일 최대 5캡슐 [다른 NSAID와 복용시 약사와 상담]"
usage_tip = "식후 복용 권장"
# 4. 약품명 정제
goods_name = normalize_medication_name(goods_name)
# 5. 레이아웃 시작
x_margin = 25
# 효능 - 중앙 상단에 크게 (매우 강조!)
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_text = f"({goods_name})"
# 효능 텍스트 끝 위치 계산
effect_end_x = effect_x + effect_width + 30 # 효능 끝에서 30px 여백
# 동적 폰트 크기 조정 (박스 안에 들어오도록)
max_drugname_width = width - effect_end_x - 50 # 오른쪽 여백 50px
drugname_font_size = 48 # 초기 폰트 크기 (크게 시작)
while drugname_font_size > 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 '실패'}")

View File

@ -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)

View File

@ -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)를 전송합니다.

View File

@ -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()

View File

@ -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'(?<!m)(?<!μ)그램|그람', 'g', med_name)
# 6. 공백 정리 (연속된 공백을 하나로)
med_name = re.sub(r'\s+', ' ', med_name).strip()
return med_name
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: 생성된 라벨 이미지
"""
# 약품 이름 정제 (밀리그램 → mg 등)
med_name = normalize_medication_name(med_name)
# 라벨 이미지 설정
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 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "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
def draw_fitted_single_line(draw, text, y, font, max_width, min_font_size=24):
"""
텍스트를 1줄로 강제 표시하되, 폰트 크기 자동 축소 잘라내기 순으로 처리
Parameters:
draw: ImageDraw.Draw 객체
text (str): 표시할 텍스트
y (int): Y 좌표
font: 기본 폰트
max_width (int): 최대 너비
min_font_size (int): 최소 폰트 크기 (기본값: 24)
Returns:
int: 다음 줄의 Y 좌표
"""
if not text:
return y
font_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts", "malgunbd.ttf")
original_font_size = 30 # info_font 기본 크기
# 1단계: 폰트 크기 자동 축소 (30px → 24px)
for font_size in range(original_font_size, min_font_size - 1, -1):
try:
test_font = ImageFont.truetype(font_path, font_size)
except IOError:
test_font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), text, font=test_font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
if w <= max_width:
# 크기가 맞으면 중앙 정렬로 그리기
draw.text(((label_width - w) / 2, y), text, font=test_font, fill="black")
return y + h + 5
# 2단계: 최소 폰트에도 안 맞으면 텍스트 잘라내기
try:
final_font = ImageFont.truetype(font_path, min_font_size)
except IOError:
final_font = ImageFont.load_default()
original_text = text
while len(text) > 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()

View File

@ -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": "투약대"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -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)