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"
{header_text}
") 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"
{footer_text}
") if wifi_code and wifi_code != "-": # PATCH: Qt fixed-layout table - 2 kolom untuk force right align html_parts.append(f"
WiFi: {wifi_code}
") # 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)