from datetime import datetime
from PySide6.QtWidgets import QMessageBox
from typing import Optional, List, Dict
from pypos.core.base_controller import BaseController
from pypos.modules.printer.models.printer_settings_model import PrinterSettingsModel
from PySide6.QtPrintSupport import QPrinter, QPrintPreviewDialog
from PySide6.QtGui import (
QPageSize, QPageLayout, QTextDocument, QPainter, QFont, QFontMetricsF
)
from PySide6.QtCore import QDateTime, QSizeF, QMarginsF
import base64
from io import BytesIO
import os
try:
import qrcode
QRCODE_AVAILABLE = True
except ImportError:
QRCODE_AVAILABLE = False
print("[WARNING] qrcode library not installed. QR codes will not be generated.")
from PySide6.QtPrintSupport import QPrinterInfo
from PySide6.QtPrintSupport import QPrinter, QPrinterInfo
from pypos.core.utils.path_utils import get_resources_path
# upgraded: inherit base class
class PrinterSettingsController(BaseController):
def _mm_to_points(self, mm: float) -> float:
"""Konversi milimeter ke point (1 mm ≈ 2.83465 pt)."""
return mm * 2.83465
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.model = PrinterSettingsModel()
# ------------------ Print Mode (Preview/Auto) ------------------
def get_print_mode(self) -> str:
"""Ambil mode cetak dari setting_struk.csv"""
import os
from pypos.core.utils.path_utils import get_resources_path
csv_path = os.path.join(get_resources_path(), "setting_struk.csv")
csv_path = os.path.abspath(csv_path)
mode = "preview" # default
if os.path.exists(csv_path):
with open(csv_path, encoding="utf-8") as f:
# logika pembacaan mode dari file CSV
for line in f:
if "mode=" in line:
mode = line.strip().split("=")[-1]
break
return mode
# Seluruh logika di atas harus berada di dalam fungsi _build_receipt_html atau fungsi lain, bukan di luar scope class/fungsi.
# Silakan pastikan seluruh variabel dan pemanggilan method hanya digunakan di dalam fungsi yang sesuai.
# ...existing code...
# Seluruh fungsi di bawah ini diindentasi 4 spasi agar berada di dalam blok class
def _create_doc(self,items: List[Dict],payment: Dict,wifi_code: str,qr_data: str,cols: int,setting: Dict,transaksi_data_dict: Dict) -> QTextDocument:
"""Buat QTextDocument berisi struk HTML dengan setting & transaksi nyata."""
html = self._build_receipt_html(
items, payment, wifi_code, qr_data, cols,
setting=setting,
transaksi_data_dict=transaksi_data_dict
)
doc = QTextDocument()
doc.setHtml(html)
return doc
def _render_doc_to_printer(self, printer: QPrinter, doc: QTextDocument) -> bool:
"""
Render QTextDocument ke printer thermal dengan DPI scaling fix.
PATCH[ThermalFix]: NO SCALING jika DPI > 300 (high DPI thermal printer)
"""
full_width = doc.textWidth()
print(f"[DEBUG RENDER]")
print(f" Doc textWidth: {full_width:.2f} pt")
print(f" Printer DPI: {printer.logicalDpiX()}")
painter = QPainter()
if not painter.begin(printer):
print(" ❌ Cannot start painter")
return False
# PATCH: NO SCALING jika DPI tinggi - printer driver sudah handle
printer_dpi = printer.logicalDpiX()
if printer_dpi < 300:
# Only scale if low DPI printer
thermal_dpi = 203
scale_factor = printer_dpi / thermal_dpi
painter.scale(scale_factor, scale_factor)
print(f" DPI scale applied: {scale_factor:.3f}")
else:
print(f" High DPI printer ({printer_dpi}) - NO SCALING")
doc.drawContents(painter)
painter.end()
print(f" ✅ Render complete")
return True
# ------------------ PREVIEW ------------------
def preview_print(self, index: int, parent=None) -> bool:
printers = self.model.load_printers()
if not (0 <= index < len(printers)):
return False
cfg = printers[index]
paper_label = cfg.get("paper_size", "")
# Map label -> lebar mm
label = (paper_label or "").lower().strip()
if label in ("56", "56mm"):
w_mm = 56.0
elif label in ("58", "58mm"):
w_mm = 58.0
elif label in ("80", "80mm"):
w_mm = 80.0
elif label in ("100x150", "100x150mm"):
w_mm = 100.0
else:
w_mm = 58.0
# 1) Printer khusus PREVIEW (PdfFormat) dengan ukuran roll
preview_printer = self._make_preview_printer(w_mm)
# 2) Data contoh (ganti dengan data real jika perlu)
items = [
{"name": "Aqua Botol 600ml", "qty": 2, "price": 4500, "disc": 1000, "free": False},
{"name": "Indomie Goreng", "qty": 5, "price": 3000, "disc": 0, "free": False},
{"name": "Taro Snack", "qty": 1, "price": 7000, "disc": 0, "free": True},
{"name": "Silverqueen 58g", "qty": 1, "price": 14500,"disc": 1500, "free": False},
]
payment = {"method": "Debit Card", "card_brand": "BCA", "last4": "1234", "approval_code": "A1B2C3"}
wifi_code = "WIFI-9F3K-72BA"
qr_data = "https://contoh.toko/struk/INV-23001?wifi=" + wifi_code
cols = self._estimate_cols(preview_printer, css_font_pt=10.0, family="DejaVu Sans Mono", paper_label=paper_label)
# doc = self._create_doc(items, payment, wifi_code, qr_data, cols)
# Data dummy untuk preview
setting = {
"header1": "CV. SUMBER BOGA",
"header2": "Jl. Kapten Patimura No 232, Purwokerto Barat",
"header3": "Telp: 0811-26-99-776",
"footer1": "BARANG YANG SUDAH DIBELI TIDAK DAPAT DITUKAR / DIKEMBALIKAN",
"footer2": "IG: @sumberboga_karanglewas",
"footer3": "Terima kasih atas kunjungan Anda!",
"logo": "logo_boga_ori.png"
}
transaksi_data_dict = {
"nomer": "INV-TEST-001",
"dtime": QDateTime.currentDateTime().toString("yyyy-MM-dd HH:mm:ss"),
"oleh_nama": "Kasir Demo",
"customers_nama": "Customer Preview",
"transaksi_bulat": 100000,
"ppn_persen": 11,
"diskon_persen": 5,
"transaksi_nilai": 95000
}
# (opsional) kunci lebar doc ke lebar roll dalam point agar preview pasti pas
w_pt = self._mm_to_points(w_mm)
doc = self._create_doc(items, payment, wifi_code, qr_data, cols, setting, transaksi_data_dict, w_pt)
# PATCH: Hanya set pageSize, JANGAN set textWidth agar content full width
doc.setPageSize(QSizeF(w_pt, 1_000_000.0))
preview = QPrintPreviewDialog(preview_printer, parent)
preview.setWindowTitle("Preview Struk")
try:
preview.setZoomMode(QPrintPreviewDialog.FitToWidth)
except Exception:
pass
preview.paintRequested.connect(lambda p: self._render_doc_to_printer(p, doc))
preview.exec()
return True
def _get_default_printer_name(self):
"""
Ambil default printer dari aplikasi (printers.json) dulu.
Jika tidak ditemukan atau tidak tersedia di sistem, fallback ke default printer Windows.
"""
import json, os
from PySide6.QtPrintSupport import QPrinterInfo
try:
printers_file = os.path.join(get_resources_path(), "printers.json")
if os.path.exists(printers_file):
with open(printers_file, "r", encoding="utf-8") as f:
data = json.load(f)
for p in data.get("printers", []):
if p.get("default", False):
app_default = p.get("name")
# cek apakah printer ini benar-benar tersedia di sistem
available = [x.printerName() for x in QPrinterInfo.availablePrinters()]
if app_default in available:
print(f"[Printer] Default printer dari aplikasi: {app_default}")
return app_default
else:
print(f"[Printer] Default printer di JSON tidak ditemukan di sistem: {app_default}")
# fallback ke default printer sistem
default_info = QPrinterInfo.defaultPrinter()
if default_info and not default_info.isNull():
sys_default = default_info.printerName()
print(f"[Printer] Default printer dari sistem: {sys_default}")
return sys_default
except Exception as e:
print(f"[Printer] Gagal ambil default printer: {e}")
def test_print(self, index: int) -> bool:
"""
Test print printer: controller interpretasi hasil dari model
Return: (status: bool, message: str)
"""
printers = self.model.load_printers()
if not (0 <= index < len(printers)):
return False, "Printer tidak ditemukan."
cfg = printers[index]
koneksi = cfg.get("koneksi", "usb")
address = cfg.get("address", "")
status, code = self.model.test_connection(koneksi, address)
# Interpretasi kode hasil
code_map = {
"OK_USB": "✅ Test print USB berhasil",
"OK_LAN": "✅ Test print LAN berhasil",
"ERR_FORMAT_USB": "❌ Format USB harus 'VID:PID' (hex)",
"ERR_USB_NOT_FOUND": "❌ Printer USB tidak ditemukan",
"ERR_USB_PRINT_FAIL": "❌ Gagal print ke printer USB",
"ERR_FORMAT_LAN": "❌ Format LAN harus 'IP:PORT'",
"ERR_LAN_PRINT_FAIL": "❌ Gagal print ke printer LAN",
"ERR_CONN_TYPE_UNSUPPORTED": "❌ Jenis koneksi belum didukung",
"ERR_UNKNOWN": "❌ Gagal konek printer: error tidak diketahui"
}
message = code_map.get(code, code)
return status, message
print("[Printer] Tidak ada default printer ditemukan.")
return None
def _create_doc_raw_painter(self, items: List[Dict], payment: Dict, wifi_code: str, qr_data: str, cols: int, setting: Dict, transaksi_data_dict: Dict, width_pt: float = None, printer: QPrinter = None):
"""
PATCH: Gunakan raw QPainter untuk thermal printer - bypass QTextDocument shrink-to-fit bug.
Returns: Fungsi render yang akan dipanggil dengan QPainter
"""
from PySide6.QtCore import Qt, QRect
def render_with_painter(painter: QPainter):
# Get paper width dari printer
if printer:
paper_width = printer.pageRect(QPrinter.DevicePixel).width()
elif width_pt:
# Fallback: convert pt to pixels
paper_width = int(width_pt * (printer.logicalDpiX() / 72.0) if printer else width_pt * 2.83)
else:
paper_width = 450 # Default fallback
# Margins - kurangi margin untuk content lebih lebar
margin_left = 5
margin_right = 5
content_width = paper_width - margin_left - margin_right
print(f"[RawPainter] Paper width: {paper_width}px, Content width: {content_width}px, Margin L/R: {margin_left}/{margin_right}px")
# Font setup - ukuran disesuaikan untuk thermal printer 58mm
font_normal = QFont("DejaVu Sans Mono", 9)
font_bold = QFont("DejaVu Sans Mono", 10)
font_bold.setBold(True)
font_small = QFont("DejaVu Sans Mono", 8)
painter.setFont(font_normal)
# Get font metrics untuk line height yang tepat
fm = painter.fontMetrics()
line_height = fm.height() + 2 # Tambah 2px spacing
y = 10 # Start position
# === LOGO (CENTER) ===
logo_path = setting.get("logo", "")
if logo_path:
full_logo_path = os.path.join(get_resources_path(), "logo", logo_path)
if os.path.exists(full_logo_path):
from PySide6.QtGui import QPixmap
pixmap = QPixmap(full_logo_path)
if not pixmap.isNull():
scaled = pixmap.scaled(75, 48, Qt.KeepAspectRatio, Qt.SmoothTransformation)
x_center = margin_left + (content_width - scaled.width()) // 2
painter.drawPixmap(x_center, y, scaled)
y += scaled.height() + 10
# === HEADER (CENTER ALIGN) ===
for i in range(1, 4):
header_text = setting.get(f"header{i}", "")
if header_text:
if i == 1:
painter.setFont(font_bold)
fm = painter.fontMetrics()
lh = fm.height() + 2
else:
painter.setFont(font_normal)
fm = painter.fontMetrics()
lh = fm.height() + 2
rect = QRect(margin_left, y, content_width, lh)
painter.drawText(rect, Qt.AlignHCenter | Qt.AlignTop, header_text)
y += lh
painter.setFont(font_normal)
fm = painter.fontMetrics()
line_height = fm.height() + 2
# Separator
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, "-" * cols)
y += line_height
# === TRANSACTION INFO (LEFT) ===
inv_no = transaksi_data_dict.get("nomer", "-")
dtime = transaksi_data_dict.get("dtime", "-")
kasir = transaksi_data_dict.get("oleh_nama", "-")
customer = transaksi_data_dict.get("customers_nama", "Umum")
def fmt_line(left: str, right: str, width: int = cols) -> str:
left = str(left)[:width-len(str(right))-1]
spaces = width - len(left) - len(str(right))
return left + (" " * max(spaces, 1)) + str(right)
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, fmt_line('No. Inv', inv_no))
y += line_height
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, fmt_line('Tanggal', dtime))
y += line_height
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, fmt_line('Kasir', kasir))
y += line_height
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, fmt_line('Customer', customer))
y += line_height
# Separator
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, "-" * cols)
y += line_height
# === ITEMS ===
for item in items:
name = item.get("name", "Item")
qty = item.get("qty", 1)
price = item.get("price", 0)
disc = item.get("disc", 0)
is_free = item.get("free", False)
subtotal = (price * qty) - disc
if is_free:
subtotal = 0
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, name[:cols])
y += line_height
qty_price = f"{qty} x {price:,}"
if disc > 0:
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, fmt_line(qty_price, f'-{disc:,}'))
y += line_height
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, fmt_line('', f'{subtotal:,}'))
y += line_height
elif is_free:
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, fmt_line(qty_price, 'FREE'))
y += line_height
else:
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, fmt_line(qty_price, f'{subtotal:,}'))
y += line_height
# Separator
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, "-" * cols)
y += line_height
# === TOTAL ===
nilai_total = transaksi_data_dict.get("transaksi_nilai", 0)
bulat = transaksi_data_dict.get("transaksi_bulat", nilai_total)
painter.setFont(font_bold)
fm_bold = painter.fontMetrics()
line_height_bold = fm_bold.height() + 2
rect = QRect(margin_left, y, content_width, line_height_bold)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, fmt_line('TOTAL', f'Rp {nilai_total:,}'))
y += line_height_bold
rect = QRect(margin_left, y, content_width, line_height_bold)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, fmt_line('GRAND TOTAL', f'Rp {bulat:,}'))
y += line_height_bold
painter.setFont(font_normal)
line_height = fm.height() + 2 # Reset to normal line height
# Separator
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, "-" * cols)
y += line_height
# === PAYMENT ===
method = payment.get("method", "Tunai")
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, fmt_line('Metode', method))
y += line_height
# Separator
rect = QRect(margin_left, y, content_width, line_height)
painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, "-" * cols)
y += line_height + 5
# === FOOTER (CENTER ALIGN) ===
painter.setFont(font_small)
fm_small = painter.fontMetrics()
line_height_small = fm_small.height() + 2
for i in range(1, 4):
footer_text = setting.get(f"footer{i}", "")
if footer_text:
rect = QRect(margin_left, y, content_width, line_height_small)
painter.drawText(rect, Qt.AlignHCenter | Qt.AlignTop, footer_text)
y += line_height_small
if wifi_code and wifi_code != "-":
rect = QRect(margin_left, y, content_width, line_height_small)
painter.drawText(rect, Qt.AlignHCenter | Qt.AlignTop, f"WiFi: {wifi_code}")
y += line_height_small
# === QR CODE (CENTER ALIGN) ===
if qr_data and QRCODE_AVAILABLE:
try:
import tempfile
from PySide6.QtGui import QPixmap
qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=3, border=2)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as tmp:
img.save(tmp.name)
pixmap = QPixmap(tmp.name)
if not pixmap.isNull():
qr_size = 120
scaled = pixmap.scaled(qr_size, qr_size, Qt.KeepAspectRatio)
x_center = margin_left + (content_width - scaled.width()) // 2
painter.drawPixmap(x_center, y, scaled)
y += scaled.height() + 5
rect = QRect(margin_left, y, content_width, line_height_small)
painter.drawText(rect, Qt.AlignHCenter | Qt.AlignTop, qr_data[:50])
except Exception as e:
print(f"[QR Error] {e}")
return render_with_painter
def _create_doc(self, items: List[Dict], payment: Dict, wifi_code: str, qr_data: str, cols: int, setting: Dict, transaksi_data_dict: Dict, width_pt: float = None) -> QTextDocument:
"""Buat QTextDocument berisi struk HTML dengan setting & transaksi nyata."""
# PATCH: Gunakan QTextCursor untuk header/footer dengan alignment proper
from PySide6.QtGui import QTextCursor, QTextBlockFormat, QTextCharFormat, QTextImageFormat
from PySide6.QtCore import Qt
doc = QTextDocument()
doc.setDefaultFont(QFont("DejaVu Sans Mono", 10))
# PATCH: Set textWidth (WAJIB) untuk full width - pageSize diabaikan oleh drawContents()
if width_pt:
doc.setTextWidth(width_pt)
doc.setPageSize(QSizeF(width_pt, 999999))
# PATCH: Margin 0 untuk full width content
doc.setDocumentMargin(0)
cursor = QTextCursor(doc)
# Format untuk alignment
fmt_center = QTextBlockFormat()
fmt_center.setAlignment(Qt.AlignCenter)
fmt_center.setTopMargin(2)
fmt_center.setBottomMargin(2)
fmt_right = QTextBlockFormat()
fmt_right.setAlignment(Qt.AlignRight)
fmt_right.setTopMargin(1)
fmt_right.setBottomMargin(1)
# PATCH: Hapus rightMargin karena doc sudah margin 0
fmt_left = QTextBlockFormat()
fmt_left.setAlignment(Qt.AlignLeft)
fmt_left.setTopMargin(1)
fmt_left.setBottomMargin(1)
# === LOGO (CENTER) ===
logo_path = setting.get("logo", "")
if logo_path:
full_logo_path = os.path.join(get_resources_path(), "logo", logo_path)
if os.path.exists(full_logo_path):
cursor.insertBlock(fmt_center)
img_fmt = QTextImageFormat()
img_fmt.setName(full_logo_path)
img_fmt.setWidth(75)
img_fmt.setHeight(48)
cursor.insertImage(img_fmt)
cursor.insertBlock()
# === HEADER (RIGHT ALIGN) ===
char_fmt_hdr = QTextCharFormat()
char_fmt_hdr.setFontWeight(QFont.Bold)
char_fmt_hdr.setFontPointSize(12)
char_fmt_sub = QTextCharFormat()
char_fmt_sub.setFontPointSize(10)
for i in range(1, 4):
header_text = setting.get(f"header{i}", "")
if header_text:
cursor.insertBlock(fmt_right)
if i == 1:
cursor.insertText(header_text, char_fmt_hdr)
else:
cursor.insertText(header_text, char_fmt_sub)
cursor.insertBlock(fmt_left)
cursor.insertText("-" * cols)
# === TRANSACTION INFO (LEFT) ===
inv_no = transaksi_data_dict.get("nomer", "-")
dtime = transaksi_data_dict.get("dtime", "-")
kasir = transaksi_data_dict.get("oleh_nama", "-")
customer = transaksi_data_dict.get("customers_nama", "Umum")
def fmt_line(left: str, right: str, width: int = cols) -> str:
left = str(left)[:width-len(str(right))-1]
spaces = width - len(left) - len(str(right))
return left + (" " * max(spaces, 1)) + str(right)
cursor.insertBlock(fmt_left)
cursor.insertText(fmt_line('No. Inv', inv_no))
cursor.insertBlock(fmt_left)
cursor.insertText(fmt_line('Tanggal', dtime))
cursor.insertBlock(fmt_left)
cursor.insertText(fmt_line('Kasir', kasir))
cursor.insertBlock(fmt_left)
cursor.insertText(fmt_line('Customer', customer))
cursor.insertBlock(fmt_left)
cursor.insertText("-" * cols)
# === ITEMS ===
for item in items:
name = item.get("name", "Item")
qty = item.get("qty", 1)
price = item.get("price", 0)
disc = item.get("disc", 0)
is_free = item.get("free", False)
subtotal = (price * qty) - disc
if is_free:
subtotal = 0
cursor.insertBlock(fmt_left)
cursor.insertText(name[:cols])
qty_price = f"{qty} x {price:,}"
if disc > 0:
cursor.insertBlock(fmt_left)
cursor.insertText(fmt_line(qty_price, f'-{disc:,}'))
cursor.insertBlock(fmt_left)
cursor.insertText(fmt_line('', f'{subtotal:,}'))
elif is_free:
cursor.insertBlock(fmt_left)
cursor.insertText(fmt_line(qty_price, 'FREE'))
else:
cursor.insertBlock(fmt_left)
cursor.insertText(fmt_line(qty_price, f'{subtotal:,}'))
cursor.insertBlock(fmt_left)
cursor.insertText("-" * cols)
# === TOTAL ===
nilai_total = transaksi_data_dict.get("transaksi_nilai", 0)
bulat = transaksi_data_dict.get("transaksi_bulat", nilai_total)
char_fmt_bold = QTextCharFormat()
char_fmt_bold.setFontWeight(QFont.Bold)
cursor.insertBlock(fmt_left)
cursor.insertText(fmt_line('TOTAL', f'Rp {nilai_total:,}'), char_fmt_bold)
cursor.insertBlock(fmt_left)
cursor.insertText(fmt_line('GRAND TOTAL', f'Rp {bulat:,}'), char_fmt_bold)
cursor.insertBlock(fmt_left)
cursor.insertText("-" * cols)
# === PAYMENT ===
method = payment.get("method", "Tunai")
cursor.insertBlock(fmt_left)
cursor.insertText(fmt_line('Metode', method))
cursor.insertBlock(fmt_left)
cursor.insertText("-" * cols)
# === FOOTER (RIGHT ALIGN) ===
char_fmt_ftr = QTextCharFormat()
char_fmt_ftr.setFontPointSize(9)
for i in range(1, 4):
footer_text = setting.get(f"footer{i}", "")
if footer_text:
cursor.insertBlock(fmt_right)
cursor.insertText(footer_text, char_fmt_ftr)
if wifi_code and wifi_code != "-":
cursor.insertBlock(fmt_right)
cursor.insertText(f"WiFi: {wifi_code}", char_fmt_ftr)
# === QR CODE (RIGHT ALIGN) ===
if qr_data and QRCODE_AVAILABLE:
try:
import tempfile
qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=3, border=2)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Save to temp file
with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as tmp:
img.save(tmp.name)
tmp_path = tmp.name
cursor.insertBlock(fmt_right)
img_fmt = QTextImageFormat()
img_fmt.setName(tmp_path)
img_fmt.setWidth(120)
img_fmt.setHeight(120)
cursor.insertImage(img_fmt)
cursor.insertBlock(fmt_right)
cursor.insertText(qr_data[:50], char_fmt_ftr)
except Exception as e:
print(f"[_create_doc] Error generating QR: {e}")
return doc
def test_print(self, index: int) -> bool:
from PySide6.QtPrintSupport import QPrinter, QPrinterInfo # pastikan ini di atas
infos = QPrinterInfo.availablePrinters()
print("[Printer List Detected by Qt:]")
for p in infos:
print(" -", p.printerName())
printers = self.model.load_printers()
if not (0 <= index < len(printers)):
return False
cfg = printers[index]
printer_name = cfg.get("name", "")
paper_label = cfg.get("paper_size", "")
# from PySide6.QtPrintSupport import QPrinter, QPrinterInfo
printer = QPrinter(QPrinter.HighResolution)
# Ambil printer default sistem (misal EPPOS 58)
default_name = self._get_default_printer_name()
if default_name:
printer.setPrinterName(default_name)
print(f"[Printer] Default printer terdeteksi: {default_name}")
else:
print("[Printer] ⚠️ Tidak ada printer default, fallback ke PDF.")
printer.setOutputFormat(QPrinter.PdfFormat)
printer.setOutputFileName("test_output.pdf")
# Pastikan gunakan NativeFormat untuk kirim langsung ke printer fisik
try:
printer.setOutputFormat(QPrinter.NativeFormat)
except Exception:
pass
# Terapkan ukuran kertas
self._apply_paper_size(printer, cfg.get("paper_size", "80"))
# printer = QPrinter(QPrinter.HighResolution)
# printer.setPrinterName(printer_name)
printer.setResolution(203)
# pastikan format native untuk cetak nyata
try:
printer.setOutputFormat(QPrinter.NativeFormat)
except Exception:
pass
self._apply_paper_size(printer, paper_label)
items = [
{"name": "Aqua Botol 600ml", "qty": 2, "price": 4500, "disc": 1000, "free": False},
{"name": "Indomie Goreng", "qty": 5, "price": 3000, "disc": 0, "free": False},
{"name": "Taro Snack", "qty": 1, "price": 7000, "disc": 0, "free": True},
{"name": "Silverqueen 58g", "qty": 1, "price": 14500,"disc": 1500, "free": False},
]
payment = {"method": "Debit Card", "card_brand": "BCA", "last4": "1234", "approval_code": "A1B2C3"}
wifi_code = "WIFI-9F3K-72BA"
qr_data = "https://contoh.toko/struk/INV-23001?wifi=" + wifi_code
cols = self._estimate_cols(printer, css_font_pt=10.0, family="DejaVu Sans Mono", paper_label=paper_label)
# doc = self._create_doc(items, payment, wifi_code, qr_data, cols)
setting = {
"header1": "CV. SUMBER BOGA",
"header2": "Jl. Kapten Patimura No 232, Purwokerto Barat",
"header3": "Telp: 0811-26-99-776",
"footer1": "BARANG YANG SUDAH DIBELI TIDAK DAPAT DITUKAR / DIKEMBALIKAN",
"footer2": "IG: @sumberboga_karanglewas",
"footer3": "Terima kasih atas kunjungan Anda!",
"logo": "logo_boga_ori.png"
}
transaksi_data_dict = {
"nomer": "INV-TEST-001",
"dtime": QDateTime.currentDateTime().toString("yyyy-MM-dd HH:mm:ss"),
"oleh_nama": "Kasir Demo",
"customers_nama": "Customer Test",
"transaksi_bulat": 100000,
"ppn_persen": 11,
"diskon_persen": 5,
"transaksi_nilai": 95000
}
# Calculate width in points
w_pt = self._mm_to_points(w_mm) if 'w_mm' in locals() else None
doc = self._create_doc(items, payment, wifi_code, qr_data, cols, setting, transaksi_data_dict, w_pt)
try:
return self._render_doc_to_printer(printer, doc)
except Exception as e:
print(f"[TestPrint] Print error: {e}")
return False
# ------------------ ambil printer default ------------------
def get_default_printer(self) -> Optional[Dict]:
printers = self.model.load_printers()
for p in printers:
if p.get("default"):
return p
# fallback ke default printer OS
try:
sys_printer = QPrinterInfo.defaultPrinter()
if not sys_printer.isNull():
return {
"name": sys_printer.printerName(),
"paper_size": "80mm", # default asumsi, bisa kamu ganti
"default": True
}
except Exception:
pass
return None
# ------------------ cetak langsung ------------------
def print_struk(self, items, payment, wifi_code, qr_data, setting, transaksi_data_dict, copy_no: int = 0) -> bool:
cfg = self.get_default_printer()
if not cfg:
QMessageBox.warning(None, "Printer", "⚠️ Belum ada default printer, silakan setting dulu!")
return False
printer_name = cfg.get("name", "")
paper_label = cfg.get("paper_size", "")
printer = QPrinter(QPrinter.HighResolution)
printer.setPrinterName(printer_name)
printer.setResolution(203)
try:
printer.setOutputFormat(QPrinter.NativeFormat)
except Exception:
pass
self._apply_paper_size(printer, paper_label)
# hitung kolom
cols = self._estimate_cols(printer, css_font_pt=10.0, family="DejaVu Sans Mono", paper_label=paper_label)
# Calculate paper width in points
label = (paper_label or "").lower().strip()
if label in ("56", "56mm"):
w_mm = 56.0
elif label in ("58", "58mm"):
w_mm = 58.0
elif label in ("80", "80mm"):
w_mm = 80.0
else:
w_mm = 58.0
w_pt = self._mm_to_points(w_mm)
# buat doc
doc = self._create_doc(items, payment, wifi_code, qr_data, cols, setting, transaksi_data_dict, w_pt)
# tambahkan tanda copy jika perlu
if copy_no > 0:
html = doc.toHtml()
html = html.replace("
", f"*** COPY KE-{copy_no} ***
")
doc.setHtml(html)
try:
return self._render_doc_to_printer(printer, doc)
except Exception as e:
print(f"[PrintStruk] Error cetak: {e}")
return False
def _apply_paper_size(self, printer: QPrinter, paper_label: str):
"""Mengatur ukuran kertas printer thermal sesuai label."""
label = (paper_label or "").lower().strip()
if label in ("56", "56mm"):
w_mm = 56.0
elif label in ("58", "58mm"):
w_mm = 58.0
elif label in ("80", "80mm"):
w_mm = 80.0
elif label in ("100x150", "100x150mm"):
w_mm = 100.0
else:
w_mm = 58.0 # default
# Konversi mm ke point (1 mm ≈ 2.83465 pt)
w_pt = w_mm * 2.83465
printer.setPageSize(QPageSize(QSizeF(w_mm, 1000), QPageSize.Millimeter))
printer.setPageMargins(QMarginsF(0, 0, 0, 0), QPageLayout.Millimeter)
# PATCH[FrontEndAgent|2025-01-XX]: Restore missing _estimate_cols method
# Method ini hilang/terhapus sehingga menyebabkan AttributeError saat print
def _estimate_cols(self, printer: QPrinter, css_font_pt: float = 10.0, family: str = "DejaVu Sans Mono", paper_label: str = "58mm") -> int:
"""
Estimasi jumlah kolom (chars) yang muat pada thermal printer.
Args:
printer: QPrinter object
css_font_pt: Ukuran font dalam points
family: Font family name
paper_label: Label ukuran kertas (58mm, 80mm, etc.)
Returns:
int: Jumlah karakter yang muat per baris
"""
# Parse paper width from label
label = (paper_label or "").lower().strip()
if label in ("56", "56mm"):
w_mm = 56.0
elif label in ("58", "58mm"):
w_mm = 58.0
elif label in ("80", "80mm"):
w_mm = 80.0
elif label in ("100x150", "100x150mm"):
w_mm = 100.0
else:
w_mm = 58.0
# PATCH[FrontEndAgent|2025-12-02]: Gunakan standard thermal printer cols
# Thermal printer 58mm biasanya 32-42 chars, 80mm biasanya 48 chars
# Jangan gunakan font metrics karena tidak akurat untuk thermal printer
if w_mm <= 58:
cols = 32 # Standard 58mm thermal
elif w_mm <= 80:
cols = 48 # Standard 80mm thermal
else:
cols = 42 # Label printer
print(f"[_estimate_cols] Paper: {w_mm}mm, Standard cols: {cols}")
return cols
# PATCH[FrontEndAgent|2025-01-XX]: Restore missing _make_preview_printer method
def _make_preview_printer(self, w_mm: float) -> QPrinter:
"""
Buat QPrinter object untuk preview dengan paper size tertentu.
Args:
w_mm: Lebar paper dalam milimeter
Returns:
QPrinter: Printer object configured untuk preview
"""
preview_printer = QPrinter(QPrinter.HighResolution)
preview_printer.setResolution(203)
# PATCH[FrontEndAgent|2025-12-02]: Gunakan NativeFormat untuk konsistensi dengan print
try:
preview_printer.setOutputFormat(QPrinter.NativeFormat)
except Exception:
pass
preview_printer.setPageSize(QPageSize(QSizeF(w_mm, 297), QPageSize.Millimeter))
preview_printer.setPageMargins(QMarginsF(0, 0, 0, 0), QPageLayout.Millimeter)
preview_printer.setFullPage(True)
return preview_printer
# PATCH[FrontEndAgent|2025-01-XX]: Restore missing _build_receipt_html method
def _build_receipt_html(
self,
items: List[Dict],
payment: Dict,
wifi_code: str,
qr_data: str,
cols: int,
setting: Dict = None,
transaksi_data_dict: Dict = None
) -> str:
"""
Generate HTML untuk struk thermal printer.
Args:
items: List of items [{name, qty, price, disc, free}, ...]
payment: Payment info {method, card_brand, last4, approval_code}
wifi_code: WiFi access code
qr_data: QR code data/URL
cols: Number of columns (characters per line)
setting: Dict containing header/footer settings
transaksi_data_dict: Transaction data
Returns:
str: HTML string for receipt
"""
if setting is None:
setting = {}
if transaksi_data_dict is None:
transaksi_data_dict = {}
# Helper function untuk format baris dengan padding
def fmt_line(left: str, right: str, width: int = cols) -> str:
"""Format line with left-right alignment."""
left = str(left)[:width-len(str(right))-1]
spaces = width - len(left) - len(str(right))
return left + (" " * max(spaces, 1)) + str(right)
# Load CSS dari file
css = ""
try:
css_path = os.path.join(get_resources_path(), "styles", "struk_style.css")
if os.path.exists(css_path):
with open(css_path, "r", encoding="utf-8") as f:
css = f.read()
except Exception as e:
print(f"[_build_receipt_html] Warning: Gagal load CSS: {e}")
# Build HTML parts
html_parts = []
# PATCH: Tambahkan timestamp untuk force refresh CSS
import time
timestamp = int(time.time())
html_parts.append(f"")
html_parts.append("")
# === HEADER ===
logo_path = setting.get("logo", "")
if logo_path:
full_logo_path = os.path.join(get_resources_path(), "logo", logo_path)
if os.path.exists(full_logo_path):
try:
with open(full_logo_path, "rb") as img_file:
img_base64 = base64.b64encode(img_file.read()).decode()
# PATCH: Center gunakan CSS class text-center
html_parts.append(f"
")
except Exception as e:
print(f"[_build_receipt_html] Warning: Gagal load logo: {e}")
for i in range(1, 4):
header_text = setting.get(f"header{i}", "")
if header_text:
cls = "hdr" if i == 1 else "sub"
# PATCH: Qt fixed-layout table - 2 kolom untuk force right align
html_parts.append(f"
")
html_parts.append("
")
# === TRANSACTION INFO ===
inv_no = transaksi_data_dict.get("nomer", "-")
dtime = transaksi_data_dict.get("dtime", "-")
kasir = transaksi_data_dict.get("oleh_nama", "-")
customer = transaksi_data_dict.get("customers_nama", "Umum")
html_parts.append("
")
html_parts.append(f"
{fmt_line('No. Inv', inv_no)}")
html_parts.append(f"
{fmt_line('Tanggal', dtime)}")
html_parts.append(f"
{fmt_line('Kasir', kasir)}")
html_parts.append(f"
{fmt_line('Customer', customer)}")
html_parts.append("
")
html_parts.append("
")
# === ITEMS ===
html_parts.append("
")
for item in items:
name = item.get("name", "Item")
qty = item.get("qty", 1)
price = item.get("price", 0)
disc = item.get("disc", 0)
is_free = item.get("free", False)
subtotal = (price * qty) - disc
if is_free:
subtotal = 0
# Line 1: Nama barang
html_parts.append(f"
{name[:cols]}")
# Line 2: qty x price = subtotal
qty_price = f"{qty} x {price:,}"
if disc > 0:
html_parts.append(f"
{fmt_line(qty_price, f'-{disc:,}')}")
html_parts.append(f"
{fmt_line('', f'{subtotal:,}')}")
elif is_free:
html_parts.append(f"
{fmt_line(qty_price, 'FREE')}")
else:
html_parts.append(f"
{fmt_line(qty_price, f'{subtotal:,}')}")
html_parts.append("
")
html_parts.append("
")
# === TOTAL ===
nilai_total = transaksi_data_dict.get("transaksi_nilai", 0)
diskon_persen = transaksi_data_dict.get("diskon_persen", 0)
ppn_persen = transaksi_data_dict.get("ppn_persen", 0)
bulat = transaksi_data_dict.get("transaksi_bulat", nilai_total)
html_parts.append("
")
html_parts.append(f"
{fmt_line('TOTAL', f'Rp {nilai_total:,}')}")
if diskon_persen > 0:
html_parts.append(f"
{fmt_line(f'Diskon ({diskon_persen}%)', f'- Rp XXX')}")
if ppn_persen > 0:
html_parts.append(f"
{fmt_line(f'PPN ({ppn_persen}%)', f'Rp XXX')}")
html_parts.append(f"
{fmt_line('GRAND TOTAL', f'Rp {bulat:,}')}")
html_parts.append("
")
html_parts.append("
")
# === PAYMENT ===
method = payment.get("method", "Tunai")
html_parts.append("
")
html_parts.append(f"
{fmt_line('Metode', method)}")
if method in ("Debit Card", "Kredit", "Debit"):
card_brand = payment.get("card_brand", "-")
last4 = payment.get("last4", "XXXX")
approval = payment.get("approval_code", "-")
html_parts.append(f"
{fmt_line('Card', f'{card_brand} ***{last4}')}")
html_parts.append(f"
{fmt_line('Approval', approval)}")
html_parts.append("
")
html_parts.append("
")
# === FOOTER ===
for i in range(1, 4):
footer_text = setting.get(f"footer{i}", "")
if footer_text:
# PATCH: Qt fixed-layout table - 2 kolom untuk force right align
html_parts.append(f"
")
if wifi_code and wifi_code != "-":
# PATCH: Qt fixed-layout table - 2 kolom untuk force right align
html_parts.append(f"
")
# PATCH[FrontEndAgent|2025-12-02]: Generate actual QR code image
if qr_data and QRCODE_AVAILABLE:
try:
# Generate QR code
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=3,
border=2,
)
qr.add_data(qr_data)
qr.make(fit=True)
# Create image
img = qr.make_image(fill_color="black", back_color="white")
# Convert to base64
buffered = BytesIO()
img.save(buffered, format="PNG")
img_base64 = base64.b64encode(buffered.getvalue()).decode()
# Add QR code image to receipt - PATCH: Qt fixed-layout table 2 kolom
html_parts.append(f"
| ")
html_parts.append(f" ")
html_parts.append(f" {qr_data[:50]}")
html_parts.append(f" |
")
except Exception as e:
print(f"[_build_receipt_html] Error generating QR code: {e}")
# Fallback to text only
html_parts.append(f"
Scan QR: {qr_data[:40]}...
")
elif qr_data:
# Fallback jika qrcode library tidak tersedia
html_parts.append(f"
URL: {qr_data[:40]}...
")
html_parts.append("
")
return "".join(html_parts)