feat: ESC/POS 영수증 프린터로 특이사항 인쇄 기능
- pos_printer.py: ESC/POS 유틸리티 (192.168.0.174:9100)
- POST /api/print/cusetc API 추가
- admin.html: 특이사항 옆 [🖨️ 인쇄] 버튼 추가
- EUC-KR 인코딩으로 한글 지원
This commit is contained in:
parent
50825c597e
commit
5074adce20
@ -3929,6 +3929,44 @@ def api_update_cusetc(cuscode):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/print/cusetc', methods=['POST'])
|
||||
def api_print_cusetc():
|
||||
"""특이(참고)사항 영수증 인쇄 API"""
|
||||
try:
|
||||
from pos_printer import print_cusetc
|
||||
|
||||
data = request.get_json() or {}
|
||||
customer_name = data.get('customer_name', '').strip()
|
||||
cusetc = data.get('cusetc', '').strip()
|
||||
phone = data.get('phone', '').strip()
|
||||
|
||||
if not customer_name:
|
||||
return jsonify({'success': False, 'error': '고객 이름이 필요합니다.'}), 400
|
||||
|
||||
if not cusetc:
|
||||
return jsonify({'success': False, 'error': '특이사항이 비어있습니다.'}), 400
|
||||
|
||||
# ESC/POS 프린터로 출력
|
||||
result = print_cusetc(customer_name, cusetc, phone)
|
||||
|
||||
if result:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'{customer_name}님의 특이사항이 인쇄되었습니다.'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '프린터 연결 실패. 프린터가 켜져있는지 확인하세요.'
|
||||
}), 500
|
||||
|
||||
except ImportError:
|
||||
return jsonify({'success': False, 'error': 'pos_printer 모듈을 찾을 수 없습니다.'}), 500
|
||||
except Exception as e:
|
||||
logging.error(f"특이사항 인쇄 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/members/history/<phone>')
|
||||
def api_member_history(phone):
|
||||
"""
|
||||
|
||||
208
backend/pos_printer.py
Normal file
208
backend/pos_printer.py
Normal file
@ -0,0 +1,208 @@
|
||||
# pos_printer.py - ESC/POS 영수증 프린터 유틸리티
|
||||
# 0bin-label-app/src/pos_settings_dialog.py 기반
|
||||
|
||||
import socket
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
# 프린터 설정 (config에서 불러올 수도 있음)
|
||||
POS_PRINTER_IP = "192.168.0.174"
|
||||
POS_PRINTER_PORT = 9100
|
||||
POS_PRINTER_NAME = "올댓포스 오른쪽"
|
||||
|
||||
# ESC/POS 명령어
|
||||
ESC = b'\x1b'
|
||||
GS = b'\x1d'
|
||||
|
||||
# 기본 명령
|
||||
INIT = ESC + b'@' # 프린터 초기화
|
||||
CUT = ESC + b'd\x03' # 피드 + 커트 (원본 방식)
|
||||
FEED = b'\n\n\n' # 줄바꿈
|
||||
|
||||
# 정렬
|
||||
ALIGN_LEFT = ESC + b'a\x00'
|
||||
ALIGN_CENTER = ESC + b'a\x01'
|
||||
ALIGN_RIGHT = ESC + b'a\x02'
|
||||
|
||||
# 폰트 스타일
|
||||
BOLD_ON = ESC + b'E\x01'
|
||||
BOLD_OFF = ESC + b'E\x00'
|
||||
DOUBLE_HEIGHT = ESC + b'!\x10'
|
||||
DOUBLE_WIDTH = ESC + b'!\x20'
|
||||
DOUBLE_SIZE = ESC + b'!\x30' # 가로세로 2배
|
||||
NORMAL_SIZE = ESC + b'!\x00'
|
||||
|
||||
# 로깅
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
def print_raw(data: bytes, ip: str = None, port: int = None) -> bool:
|
||||
"""
|
||||
ESC/POS 바이트 데이터를 프린터로 전송
|
||||
|
||||
Args:
|
||||
data: ESC/POS 명령어 + 텍스트 바이트
|
||||
ip: 프린터 IP (기본값: POS_PRINTER_IP)
|
||||
port: 프린터 포트 (기본값: POS_PRINTER_PORT)
|
||||
|
||||
Returns:
|
||||
bool: 성공 여부
|
||||
"""
|
||||
ip = ip or POS_PRINTER_IP
|
||||
port = port or POS_PRINTER_PORT
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
sock.connect((ip, port))
|
||||
sock.sendall(data)
|
||||
sock.close()
|
||||
logging.info(f"[POS Printer] 전송 성공: {ip}:{port}")
|
||||
return True
|
||||
except socket.timeout:
|
||||
logging.error(f"[POS Printer] 연결 시간 초과: {ip}:{port}")
|
||||
return False
|
||||
except ConnectionRefusedError:
|
||||
logging.error(f"[POS Printer] 연결 거부됨: {ip}:{port}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.error(f"[POS Printer] 전송 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def print_text(text: str, cut: bool = True) -> bool:
|
||||
"""
|
||||
텍스트를 영수증 프린터로 출력
|
||||
|
||||
Args:
|
||||
text: 출력할 텍스트 (한글 지원)
|
||||
cut: 출력 후 용지 커트 여부
|
||||
|
||||
Returns:
|
||||
bool: 성공 여부
|
||||
"""
|
||||
try:
|
||||
# EUC-KR 인코딩 (한글 지원)
|
||||
text_bytes = text.encode('euc-kr', errors='replace')
|
||||
|
||||
# 명령어 조합
|
||||
command = INIT + text_bytes + b'\n\n\n'
|
||||
if cut:
|
||||
command += CUT
|
||||
|
||||
return print_raw(command)
|
||||
except Exception as e:
|
||||
logging.error(f"[POS Printer] 텍스트 인쇄 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def print_cusetc(customer_name: str, cusetc: str, phone: str = None) -> bool:
|
||||
"""
|
||||
특이(참고)사항 영수증 출력
|
||||
|
||||
Args:
|
||||
customer_name: 고객 이름
|
||||
cusetc: 특이사항 내용
|
||||
phone: 전화번호 (선택)
|
||||
|
||||
Returns:
|
||||
bool: 성공 여부
|
||||
"""
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
# 전화번호 포맷팅
|
||||
phone_display = ""
|
||||
if phone:
|
||||
phone_clean = phone.replace("-", "").replace(" ", "")
|
||||
if len(phone_clean) == 11:
|
||||
phone_display = f"{phone_clean[:3]}-{phone_clean[3:7]}-{phone_clean[7:]}"
|
||||
else:
|
||||
phone_display = phone
|
||||
|
||||
try:
|
||||
# ESC/POS 명령어 조합
|
||||
commands = bytearray()
|
||||
commands.extend(INIT)
|
||||
|
||||
# 헤더 (중앙 정렬, 크게)
|
||||
commands.extend(ALIGN_CENTER)
|
||||
commands.extend(DOUBLE_SIZE)
|
||||
commands.extend("[ 특이사항 ]\n".encode('euc-kr'))
|
||||
commands.extend(NORMAL_SIZE)
|
||||
|
||||
# 구분선
|
||||
commands.extend("================================\n".encode('euc-kr'))
|
||||
|
||||
# 고객 정보 (왼쪽 정렬)
|
||||
commands.extend(ALIGN_LEFT)
|
||||
commands.extend(BOLD_ON)
|
||||
commands.extend(f"고객: {customer_name}\n".encode('euc-kr'))
|
||||
commands.extend(BOLD_OFF)
|
||||
|
||||
if phone_display:
|
||||
commands.extend(f"연락처: {phone_display}\n".encode('euc-kr'))
|
||||
|
||||
commands.extend(f"출력: {now}\n".encode('euc-kr'))
|
||||
|
||||
# 구분선
|
||||
commands.extend("--------------------------------\n".encode('euc-kr'))
|
||||
|
||||
# 특이사항 내용 (굵게)
|
||||
commands.extend(BOLD_ON)
|
||||
|
||||
# 긴 텍스트 줄바꿈 처리 (32자 기준)
|
||||
lines = []
|
||||
for line in cusetc.split('\n'):
|
||||
while len(line) > 32:
|
||||
lines.append(line[:32])
|
||||
line = line[32:]
|
||||
lines.append(line)
|
||||
|
||||
for line in lines:
|
||||
commands.extend(f"{line}\n".encode('euc-kr', errors='replace'))
|
||||
|
||||
commands.extend(BOLD_OFF)
|
||||
|
||||
# 하단 구분선
|
||||
commands.extend("================================\n".encode('euc-kr'))
|
||||
|
||||
# 약국명 (중앙 정렬)
|
||||
commands.extend(ALIGN_CENTER)
|
||||
commands.extend("청춘약국\n".encode('euc-kr'))
|
||||
|
||||
# 피드 + 커트
|
||||
commands.extend(b'\n\n\n')
|
||||
commands.extend(CUT)
|
||||
|
||||
return print_raw(bytes(commands))
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[POS Printer] 특이사항 인쇄 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_print() -> bool:
|
||||
"""테스트 인쇄"""
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
test_message = f"""
|
||||
================================
|
||||
POS 프린터 테스트
|
||||
================================
|
||||
|
||||
IP: {POS_PRINTER_IP}
|
||||
Port: {POS_PRINTER_PORT}
|
||||
Time: {now}
|
||||
|
||||
청춘약국 마일리지 시스템
|
||||
ESC/POS 정상 작동!
|
||||
================================
|
||||
"""
|
||||
return print_text(test_message, cut=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트
|
||||
print("POS 프린터 테스트 인쇄...")
|
||||
result = test_print()
|
||||
print(f"결과: {'성공' if result else '실패'}")
|
||||
@ -943,6 +943,34 @@
|
||||
const editBtn = document.querySelector('#cusetc-view').parentElement.querySelector('button');
|
||||
if (editBtn) editBtn.style.display = 'inline-block';
|
||||
}
|
||||
|
||||
// 특이사항 인쇄
|
||||
async function printCusetc(customerName, cusetc, phone) {
|
||||
if (!confirm(`${customerName}님의 특이사항을 인쇄하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/print/cusetc', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
customer_name: customerName,
|
||||
cusetc: cusetc,
|
||||
phone: phone
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('🖨️ ' + data.message);
|
||||
} else {
|
||||
alert('❌ 인쇄 실패: ' + (data.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('❌ 오류: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUserDetail(data) {
|
||||
// 전역 변수에 데이터 저장
|
||||
@ -987,6 +1015,7 @@
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
|
||||
<span style="color: #d97706; font-size: 13px;">⚠️ 특이사항</span>
|
||||
<button onclick="editCusetc('${data.pos_customer.cuscode}', this)" style="background: none; border: 1px solid #d97706; color: #d97706; font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer;">✏️ 수정</button>
|
||||
${data.pos_customer.cusetc ? `<button onclick="printCusetc('${data.pos_customer.name}', '${(data.pos_customer.cusetc || '').replace(/'/g, "\\'")}', '${user.phone || ''}')" style="background: none; border: 1px solid #6b7280; color: #6b7280; font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer;">🖨️ 인쇄</button>` : ''}
|
||||
</div>
|
||||
<div id="cusetc-view" onclick="toggleCusetc(this)" style="color: #92400e; font-size: 14px; font-weight: 500; cursor: ${(data.pos_customer.cusetc || '').length > 30 ? 'pointer' : 'default'}; ${(data.pos_customer.cusetc || '').length > 30 ? 'max-height: 40px; overflow: hidden;' : ''}" title="${(data.pos_customer.cusetc || '').length > 30 ? '클릭하여 펼치기' : ''}">
|
||||
${data.pos_customer.cusetc || '<span style="color: #9ca3af; font-weight: normal;">없음</span>'}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user