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:
parent
4581ebb7c5
commit
b4de6ff791
372
backend/samples/barcode_print.py
Normal file
372
backend/samples/barcode_print.py
Normal 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 '실패'}")
|
||||||
150
backend/samples/barcode_reader.py
Normal file
150
backend/samples/barcode_reader.py
Normal 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)
|
||||||
142
backend/samples/barcode_reader_README.md
Normal file
142
backend/samples/barcode_reader_README.md
Normal 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)를 전송합니다.
|
||||||
692
backend/samples/barcode_reader_gui.py
Normal file
692
backend/samples/barcode_reader_gui.py
Normal 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()
|
||||||
957
backend/samples/print_label.py
Normal file
957
backend/samples/print_label.py
Normal 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()
|
||||||
28
backend/samples/printers.json
Normal file
28
backend/samples/printers.json
Normal 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": "투약대"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1368
backend/samples/product_label.py
Normal file
1368
backend/samples/product_label.py
Normal file
File diff suppressed because it is too large
Load Diff
98
backend/test_integration.py
Normal file
98
backend/test_integration.py
Normal 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)
|
||||||
Loading…
Reference in New Issue
Block a user