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 # pmr_api.py - 조제관리(PMR) Blueprint API
# PharmaIT3000 MSSQL 연동 (192.168.0.4) # 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 import pyodbc
from datetime import datetime, date from datetime import datetime, date
import logging import logging
from PIL import Image, ImageDraw, ImageFont
import io
import base64
import os
pmr_bp = Blueprint('pmr', __name__, url_prefix='/pmr') pmr_bp = Blueprint('pmr', __name__, url_prefix='/pmr')
@ -319,3 +323,191 @@ def test_connection():
}) })
except Exception as e: except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500 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>
<div class="action-bar" id="actionBar" style="display:none;"> <div class="action-bar" id="actionBar" style="display:none;">
<button class="btn btn-secondary" onclick="selectAll()">전체 선택</button> <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> <button class="btn btn-primary" onclick="printLabels()">🖨️ 라벨 인쇄</button>
</div> </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>
</div> </div>
@ -466,6 +479,65 @@
if (checkAll) checkAll.checked = true; 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: 구현) // 라벨 인쇄 (TODO: 구현)
function printLabels() { function printLabels() {
const selected = Array.from(document.querySelectorAll('.med-check:checked')).map(c => c.dataset.code); const selected = Array.from(document.querySelectorAll('.med-check:checked')).map(c => c.dataset.code);