feat: PMR 라벨 미리보기 기능
- /pmr/api/label/preview: PIL 렌더링 → Base64 이미지 - 미리보기 버튼 + 모달 추가 - 29mm 용지 기준 라벨 이미지 생성
This commit is contained in:
parent
8d025457c0
commit
c21aa956da
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user