feat: 제품 검색 페이지 및 QR 라벨 인쇄 기능
- /admin/products: 전체 제품 검색 페이지 (OTC) - /api/products: 제품 검색 API (세트상품 바코드 포함) - qr_printer.py: Brother QL-710W 프린터 연동 - /api/qr-print, /api/qr-preview: QR 라벨 인쇄/미리보기 API - 판매상세 페이지에 QR 인쇄 버튼 추가 - 수량 선택 UI (+/- 버튼, 최대 10장) - 세트상품 제조사 표시 개선 - 대시보드 헤더에 제품검색/판매조회 탭 추가
This commit is contained in:
262
backend/qr_printer.py
Normal file
262
backend/qr_printer.py
Normal file
@@ -0,0 +1,262 @@
|
||||
# qr_printer.py - Brother QL-710W QR 라벨 인쇄
|
||||
# person-lookup-web-local/print_label.py에서 핵심 기능만 추출
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import io
|
||||
import logging
|
||||
import qrcode
|
||||
|
||||
# 프린터 설정
|
||||
PRINTER_IP = "192.168.0.121"
|
||||
PRINTER_MODEL = "QL-710W"
|
||||
LABEL_TYPE = "29" # 29mm 연속 출력 용지
|
||||
|
||||
# Windows 폰트 경로
|
||||
FONT_PATH = "C:/Windows/Fonts/malgunbd.ttf"
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
def create_drug_qr_label(drug_name, barcode, sale_price, drug_code=None, pharmacy_name='청춘약국'):
|
||||
"""
|
||||
약품 QR 라벨 이미지 생성
|
||||
|
||||
Parameters:
|
||||
drug_name (str): 약품명
|
||||
barcode (str): 바코드 (QR 코드로 변환)
|
||||
sale_price (float): 판매가격
|
||||
drug_code (str, optional): 약품 코드 (바코드가 없을 때 대체)
|
||||
pharmacy_name (str, optional): 약국 이름
|
||||
|
||||
Returns:
|
||||
PIL.Image: 생성된 라벨 이미지
|
||||
"""
|
||||
label_width = 306
|
||||
label_height = 380
|
||||
image = Image.new("1", (label_width, label_height), "white")
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# 폰트 설정
|
||||
try:
|
||||
drug_name_font = ImageFont.truetype(FONT_PATH, 32)
|
||||
price_font = ImageFont.truetype(FONT_PATH, 36)
|
||||
label_font = ImageFont.truetype(FONT_PATH, 24)
|
||||
except IOError:
|
||||
drug_name_font = ImageFont.load_default()
|
||||
price_font = ImageFont.load_default()
|
||||
label_font = ImageFont.load_default()
|
||||
logging.warning("폰트 로드 실패. 기본 폰트 사용.")
|
||||
|
||||
# 바코드가 없으면 약품 코드 사용
|
||||
qr_data = barcode if barcode else (drug_code if drug_code else "NO_BARCODE")
|
||||
|
||||
# QR 코드 생성
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=4,
|
||||
border=1,
|
||||
)
|
||||
qr.add_data(qr_data)
|
||||
qr.make(fit=True)
|
||||
qr_img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# QR 코드 크기 조정 및 배치
|
||||
qr_size = 130
|
||||
qr_img = qr_img.resize((qr_size, qr_size), Image.LANCZOS)
|
||||
qr_x = (label_width - qr_size) // 2
|
||||
qr_y = 15
|
||||
|
||||
if qr_img.mode != '1':
|
||||
qr_img = qr_img.convert('1')
|
||||
image.paste(qr_img, (qr_x, qr_y))
|
||||
|
||||
# 약품명 (QR 코드 아래)
|
||||
y_position = qr_y + qr_size + 10
|
||||
|
||||
def draw_wrapped_text(draw, text, y, font, max_width):
|
||||
"""텍스트를 여러 줄로 표시"""
|
||||
chars = list(text)
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
for char in chars:
|
||||
test_line = current_line + char
|
||||
bbox = draw.textbbox((0, 0), test_line, font=font)
|
||||
w = bbox[2] - bbox[0]
|
||||
|
||||
if w <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = char
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
lines = lines[:2] # 최대 2줄
|
||||
|
||||
for line in lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
draw.text(((label_width - w) / 2, y), line, font=font, fill="black")
|
||||
y += h + 5
|
||||
|
||||
return y
|
||||
|
||||
y_position = draw_wrapped_text(draw, drug_name, y_position, drug_name_font, label_width - 40)
|
||||
y_position += 8
|
||||
|
||||
# 가격
|
||||
if sale_price and sale_price > 0:
|
||||
price_text = f"₩{int(sale_price):,}"
|
||||
else:
|
||||
price_text = "가격 미정"
|
||||
|
||||
bbox = draw.textbbox((0, 0), price_text, font=price_font)
|
||||
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
draw.text(((label_width - w) / 2, y_position), price_text, font=price_font, fill="black")
|
||||
y_position += h + 15
|
||||
|
||||
# 구분선
|
||||
line_margin = 30
|
||||
draw.line([(line_margin, y_position), (label_width - line_margin, y_position)], fill="black", width=2)
|
||||
y_position += 20
|
||||
|
||||
# 약국 이름
|
||||
signature_text = " ".join(pharmacy_name)
|
||||
bbox = draw.textbbox((0, 0), signature_text, font=label_font)
|
||||
w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
|
||||
padding = 10
|
||||
box_x = (label_width - w_sig) / 2 - padding
|
||||
box_y = y_position
|
||||
box_x2 = box_x + w_sig + 2 * padding
|
||||
box_y2 = box_y + h_sig + 2 * padding
|
||||
draw.rectangle([(box_x, box_y), (box_x2, box_y2)], outline="black", width=2)
|
||||
draw.text(((label_width - w_sig) / 2, box_y + padding), signature_text, font=label_font, fill="black")
|
||||
|
||||
# 절취선 테두리
|
||||
draw_scissor_border(draw, label_width, label_height)
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def draw_scissor_border(draw, width, height, edge_size=10, steps=20):
|
||||
"""절취선 테두리"""
|
||||
# 상단
|
||||
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 print_drug_qr_label(drug_name, barcode, sale_price, drug_code=None, pharmacy_name='청춘약국'):
|
||||
"""
|
||||
약품 QR 라벨 인쇄 실행
|
||||
|
||||
Parameters:
|
||||
drug_name (str): 약품명
|
||||
barcode (str): 바코드
|
||||
sale_price (float): 판매가격
|
||||
drug_code (str, optional): 약품 코드
|
||||
pharmacy_name (str, optional): 약국 이름
|
||||
|
||||
Returns:
|
||||
dict: 성공/실패 결과
|
||||
"""
|
||||
try:
|
||||
from brother_ql.raster import BrotherQLRaster
|
||||
from brother_ql.conversion import convert
|
||||
from brother_ql.backends.helpers import send
|
||||
|
||||
label_image = create_drug_qr_label(drug_name, barcode, sale_price, drug_code, pharmacy_name)
|
||||
|
||||
# 이미지를 메모리 스트림으로 변환
|
||||
image_stream = io.BytesIO()
|
||||
label_image.save(image_stream, format="PNG")
|
||||
image_stream.seek(0)
|
||||
|
||||
# Brother QL 프린터로 전송
|
||||
qlr = BrotherQLRaster(PRINTER_MODEL)
|
||||
instructions = convert(
|
||||
qlr=qlr,
|
||||
images=[Image.open(image_stream)],
|
||||
label=LABEL_TYPE,
|
||||
rotate="0",
|
||||
threshold=70.0,
|
||||
dither=False,
|
||||
compress=False,
|
||||
lq=True,
|
||||
red=False
|
||||
)
|
||||
send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100")
|
||||
|
||||
logging.info(f"QR 라벨 인쇄 성공: {drug_name}, 바코드={barcode}")
|
||||
return {"success": True, "message": f"{drug_name} QR 라벨 인쇄 완료"}
|
||||
|
||||
except ImportError as e:
|
||||
logging.error(f"brother_ql 라이브러리 없음: {e}")
|
||||
return {"success": False, "error": "brother_ql 라이브러리가 설치되지 않았습니다"}
|
||||
except Exception as e:
|
||||
logging.error(f"QR 라벨 인쇄 실패: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def preview_qr_label(drug_name, barcode, sale_price, drug_code=None, pharmacy_name='청춘약국'):
|
||||
"""
|
||||
QR 라벨 미리보기 (base64 이미지 반환)
|
||||
"""
|
||||
import base64
|
||||
|
||||
label_image = create_drug_qr_label(drug_name, barcode, sale_price, drug_code, pharmacy_name)
|
||||
|
||||
# PNG로 변환
|
||||
image_stream = io.BytesIO()
|
||||
# 1-bit 이미지를 RGB로 변환하여 더 깔끔하게
|
||||
rgb_image = label_image.convert('RGB')
|
||||
rgb_image.save(image_stream, format="PNG")
|
||||
image_stream.seek(0)
|
||||
|
||||
base64_image = base64.b64encode(image_stream.read()).decode('utf-8')
|
||||
return f"data:image/png;base64,{base64_image}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트
|
||||
result = print_drug_qr_label(
|
||||
drug_name="벤포파워Z",
|
||||
barcode="8806418067510",
|
||||
sale_price=3000,
|
||||
pharmacy_name="청춘약국"
|
||||
)
|
||||
print(result)
|
||||
Reference in New Issue
Block a user