feat: PMR 라벨 미리보기 기능

- /pmr/api/label/preview: PIL 렌더링 → Base64 이미지
- 미리보기 버튼 + 모달 추가
- 29mm 용지 기준 라벨 이미지 생성
This commit is contained in:
thug0bin 2026-03-04 22:52:42 +09:00
parent 8d025457c0
commit c21aa956da
2 changed files with 265 additions and 1 deletions

View File

@ -1,10 +1,14 @@
# pmr_api.py - 조제관리(PMR) Blueprint API
# PharmaIT3000 MSSQL 연동 (192.168.0.4)
from flask import Blueprint, jsonify, request, render_template
from flask import Blueprint, jsonify, request, render_template, send_file
import pyodbc
from datetime import datetime, date
import logging
from PIL import Image, ImageDraw, ImageFont
import io
import base64
import os
pmr_bp = Blueprint('pmr', __name__, url_prefix='/pmr')
@ -319,3 +323,191 @@ def test_connection():
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# ─────────────────────────────────────────────────────────────
# API: 라벨 미리보기
# ─────────────────────────────────────────────────────────────
@pmr_bp.route('/api/label/preview', methods=['POST'])
def preview_label():
"""
라벨 미리보기 (PIL 렌더링 Base64 이미지)
Request Body:
- patient_name: 환자명
- med_name: 약품명
- dosage: 1 복용량
- frequency: 복용 횟수
- duration: 복용 일수
- unit: 단위 (, 캡슐, mL )
"""
try:
data = request.get_json()
patient_name = data.get('patient_name', '')
med_name = data.get('med_name', '')
dosage = float(data.get('dosage', 0))
frequency = int(data.get('frequency', 0))
duration = int(data.get('duration', 0))
unit = data.get('unit', '')
# 라벨 이미지 생성
image = create_label_image(
patient_name=patient_name,
med_name=med_name,
dosage=dosage,
frequency=frequency,
duration=duration,
unit=unit
)
# Base64 인코딩
buffer = io.BytesIO()
image.save(buffer, format='PNG')
buffer.seek(0)
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return jsonify({
'success': True,
'image': f'data:image/png;base64,{img_base64}'
})
except Exception as e:
logging.error(f"라벨 미리보기 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
def create_label_image(patient_name, med_name, dosage, frequency, duration, unit=''):
"""
라벨 이미지 생성 (29mm 용지 기준)
"""
# 라벨 크기 (29mm 용지, 300dpi 기준)
label_width = 306
label_height = 380
image = Image.new("RGB", (label_width, label_height), "white")
draw = ImageDraw.Draw(image)
# 폰트 설정 (Windows 경로)
font_path = "C:/Windows/Fonts/malgunbd.ttf"
if not os.path.exists(font_path):
font_path = "C:/Windows/Fonts/malgun.ttf"
try:
name_font = ImageFont.truetype(font_path, 36)
drug_font = ImageFont.truetype(font_path, 24)
info_font = ImageFont.truetype(font_path, 22)
small_font = ImageFont.truetype(font_path, 18)
except:
name_font = ImageFont.load_default()
drug_font = ImageFont.load_default()
info_font = ImageFont.load_default()
small_font = ImageFont.load_default()
# 중앙 정렬 텍스트 함수
def draw_centered(text, y, font, fill="black"):
bbox = draw.textbbox((0, 0), text, font=font)
w = bbox[2] - bbox[0]
x = (label_width - w) // 2
draw.text((x, y), text, font=font, fill=fill)
return y + bbox[3] - bbox[1] + 5
# 약품명 줄바꿈 처리
def wrap_text(text, font, max_width):
lines = []
words = text.split()
current_line = ""
for word in words:
test_line = f"{current_line} {word}".strip()
bbox = draw.textbbox((0, 0), test_line, font=font)
if bbox[2] - bbox[0] <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
return lines if lines else [text]
y = 15
# 환자명 (띄어쓰기)
spaced_name = " ".join(patient_name) if patient_name else ""
y = draw_centered(spaced_name, y, name_font)
y += 5
# 약품명 (줄바꿈)
# 괄호 앞에서 분리
if '(' in med_name:
main_name = med_name.split('(')[0].strip()
sub_info = '(' + med_name.split('(', 1)[1] if '(' in med_name else ''
else:
main_name = med_name
sub_info = ''
# 약품명 줄바꿈
name_lines = wrap_text(main_name, drug_font, label_width - 30)
for line in name_lines:
y = draw_centered(line, y, drug_font)
# 부가정보
if sub_info:
y = draw_centered(sub_info, y, small_font, fill="gray")
y += 5
# 총량 계산
if dosage > 0 and frequency > 0 and duration > 0:
total = dosage * frequency * duration
total_str = str(int(total)) if total == int(total) else f"{total:.1f}"
total_text = f"{total_str}{unit} / {duration}일분"
y = draw_centered(total_text, y, info_font)
y += 5
# 용법 박스
box_margin = 20
box_top = y
box_bottom = y + 70
draw.rectangle(
[(box_margin, box_top), (label_width - box_margin, box_bottom)],
outline="black",
width=2
)
# 박스 내용
dosage_str = str(int(dosage)) if dosage == int(dosage) else f"{dosage:.2f}".rstrip('0').rstrip('.')
dosage_text = f"{dosage_str}{unit}"
# 복용 시간
if frequency == 1:
time_text = "아침"
elif frequency == 2:
time_text = "아침, 저녁"
elif frequency == 3:
time_text = "아침, 점심, 저녁"
else:
time_text = f"1일 {frequency}"
box_center_y = (box_top + box_bottom) // 2
draw_centered(dosage_text, box_center_y - 20, info_font)
draw_centered(time_text, box_center_y + 5, info_font)
y = box_bottom + 10
# 조제일
today = datetime.now().strftime('%Y-%m-%d')
y = draw_centered(f"조제일: {today}", y, small_font)
# 약국명 (하단)
pharmacy_y = label_height - 40
draw.rectangle(
[(50, pharmacy_y - 5), (label_width - 50, pharmacy_y + 25)],
outline="black",
width=1
)
draw_centered("청 춘 약 국", pharmacy_y, info_font)
return image

View File

@ -295,8 +295,21 @@
</div>
<div class="action-bar" id="actionBar" style="display:none;">
<button class="btn btn-secondary" onclick="selectAll()">전체 선택</button>
<button class="btn btn-secondary" onclick="previewLabels()" style="background:#3b82f6;color:#fff;">👁️ 미리보기</button>
<button class="btn btn-primary" onclick="printLabels()">🖨️ 라벨 인쇄</button>
</div>
<!-- 미리보기 모달 -->
<div id="previewModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:1000;overflow-y:auto;">
<div style="max-width:400px;margin:50px auto;background:#fff;border-radius:12px;padding:20px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;">
<h3 style="margin:0;color:#4c1d95;">🏷️ 라벨 미리보기</h3>
<button onclick="closePreview()" style="background:none;border:none;font-size:1.5rem;cursor:pointer;">×</button>
</div>
<div id="previewContent" style="display:flex;flex-direction:column;gap:15px;align-items:center;">
</div>
</div>
</div>
</div>
</div>
@ -466,6 +479,65 @@
if (checkAll) checkAll.checked = true;
}
// 라벨 미리보기
async function previewLabels() {
const rows = document.querySelectorAll('.med-check:checked');
if (rows.length === 0) {
alert('미리보기할 약품을 선택하세요');
return;
}
const container = document.getElementById('previewContent');
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
document.getElementById('previewModal').style.display = 'block';
// 현재 선택된 환자명
const patientName = document.getElementById('detailName').textContent;
container.innerHTML = '';
for (const checkbox of rows) {
const tr = checkbox.closest('tr');
const medName = tr.querySelector('.med-name')?.textContent || '';
const dosage = parseFloat(tr.cells[3]?.querySelector('.med-dosage')?.textContent) || 0;
const frequency = parseInt(tr.cells[4]?.textContent) || 0;
const duration = parseInt(tr.cells[5]?.textContent) || 0;
try {
const res = await fetch('/pmr/api/label/preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
patient_name: patientName,
med_name: medName,
dosage: dosage,
frequency: frequency,
duration: duration,
unit: '정'
})
});
const data = await res.json();
if (data.success) {
const img = document.createElement('img');
img.src = data.image;
img.style.cssText = 'max-width:100%;border:1px solid #ddd;border-radius:8px;';
container.appendChild(img);
}
} catch (err) {
console.error('Preview error:', err);
}
}
if (container.children.length === 0) {
container.innerHTML = '<p style="color:#999;">미리보기 생성 실패</p>';
}
}
function closePreview() {
document.getElementById('previewModal').style.display = 'none';
}
// 라벨 인쇄 (TODO: 구현)
function printLabels() {
const selected = Array.from(document.querySelectorAll('.med-check:checked')).map(c => c.dataset.code);