""" Fiscal printer service for dTEC100extra ΦΗΜ machines. Protocol: plain TXT files dropped into an OUT folder. The fiscal driver reads them, processes the receipt, then writes a reply into the IN folder. Files must be encoded in CP737 (Greek) with CRLF line endings. All item text must be UPPER CASE. File naming: fp-YYMMDD-HHMM-NNNNNNN.txt (NNNNNNN = zero-padded counter) The driver deletes the command file after reading it and will never re-read the same filename, so uniqueness is critical. """ import os import json import time import threading import logging from datetime import datetime, timezone logger = logging.getLogger(__name__) # Shared counter — persisted only in-process; restarts reset to 0 but the # date+time prefix keeps filenames unique across restarts. _counter_lock = threading.Lock() _counter = 0 def _next_counter() -> int: global _counter with _counter_lock: _counter += 1 return _counter def _unique_filename() -> str: now = datetime.now() date_part = now.strftime("%y%m%d") time_part = now.strftime("%H%M") seq = _next_counter() return f"fp-{date_part}-{time_part}-{seq:07d}.txt" def _get_folders(db) -> tuple[str, str]: """Return (out_folder, in_folder) from pos_settings. Raises if not configured.""" from models.settings import PosSettings rows = { r.key: r.value for r in db.query(PosSettings).filter( PosSettings.key.in_(["fiscal.out_folder", "fiscal.in_folder"]) ).all() } out_folder = rows.get("fiscal.out_folder", "").strip() in_folder = rows.get("fiscal.in_folder", "").strip() if not out_folder or not in_folder: raise ValueError("Fiscal folder paths are not configured") return out_folder, in_folder def _get_setting(db, key: str, default: str = "") -> str: from models.settings import PosSettings row = db.query(PosSettings).filter(PosSettings.key == key).first() return row.value if row else default def is_fiscal_enabled(db) -> bool: return _get_setting(db, "fiscal.enabled", "false") == "true" def _build_command_file(items: list[dict], payment_method: str, total: float, db) -> str: """ Build the fiscal command file content as a string. items: list of {"name": str, "qty": int|float, "unit_price": float, "vat_group_id": int} payment_method: "cash" | "card" total: sum of all items (already computed by caller) """ clerk_id = _get_setting(db, "fiscal.clerk_id", "2") eftpos_id = _get_setting(db, "fiscal.eftpos_id", "1") end_message_raw = _get_setting(db, "fiscal.end_message", "[]") try: end_lines: list[str] = json.loads(end_message_raw) except Exception: end_lines = [] lines = ["FR"] for item in items: name = (item["name"] or "").upper().strip() qty = item["qty"] price = item["unit_price"] vat = item["vat_group_id"] # Quantity: integer if whole, otherwise 3 decimal places (kg/liter support) if isinstance(qty, float) and qty != int(qty): qty_str = f"{qty:.3f}" else: qty_str = str(int(qty)) price_str = f"{price:.2f}" lines.append(f"SI|{name}|{qty_str}|{price_str}|{vat}") # End message lines (FM commands go BEFORE the close command) for i, msg_line in enumerate(end_lines, start=1): if msg_line.strip(): lines.append(f"FM|{i}|{msg_line.upper()}") if payment_method == "card": total_str = f"{total:.2f}" lines.append(f"CD|{clerk_id}|{eftpos_id}|1|{total_str}") else: lines.append(f"CR|{clerk_id}") return "\r\n".join(lines) + "\r\n" def _write_and_poll(items: list[dict], payment_method: str, total: float, out_folder: str, in_folder: str, content: str, filename: str, timeout_seconds: int) -> dict: """Write the command file and poll for a reply. Pure logic, no DB access.""" out_path = os.path.join(out_folder, filename) reply_filename = f"answer{filename}" in_path = os.path.join(in_folder, reply_filename) try: with open(out_path, "w", encoding="cp737", errors="replace") as f: f.write(content) except OSError as e: return {"success": False, "filename": filename, "reply": None, "error": f"Failed to write fiscal file: {e}"} logger.info("Fiscal: wrote %s (%d bytes)", out_path, len(content)) deadline = time.monotonic() + timeout_seconds reply_content = None while time.monotonic() < deadline: if os.path.exists(in_path): try: with open(in_path, "r", encoding="cp737", errors="replace") as f: reply_content = f.read().strip() break except OSError: pass time.sleep(0.5) if reply_content is None: return {"success": False, "filename": filename, "reply": None, "error": f"Fiscal timeout: no reply after {timeout_seconds}s"} first_line = reply_content.splitlines()[0].strip() if reply_content else "1" success = first_line == "0" logger.info("Fiscal: reply for %s — success=%s content=%r", filename, success, reply_content) return {"success": success, "filename": filename, "reply": reply_content, "error": None if success else f"ΦΗΜ error: {reply_content}"} def send_fiscal_receipt(items: list[dict], payment_method: str, total: float, db, timeout_seconds: int = 30) -> dict: """ Build and send a fiscal receipt, then block waiting for the reply. Returns: {"success": bool, "filename": str, "reply": str | None, "error": str | None} """ out_folder, in_folder = _get_folders(db) os.makedirs(out_folder, exist_ok=True) os.makedirs(in_folder, exist_ok=True) filename = _unique_filename() content = _build_command_file(items, payment_method, total, db) return _write_and_poll(items, payment_method, total, out_folder, in_folder, content, filename, timeout_seconds) def send_fiscal_receipt_background(items: list[dict], payment_method: str, total: float, db, order_id: int, db_factory, timeout_seconds: int = 60) -> None: """ Write the fiscal file and poll for the reply in a background thread. Updates order.fiscal_status in the DB when done (success or failure/timeout). db_factory: a callable () -> Session (e.g. SessionLocal) so the thread can open its own DB session independently of the request session. """ try: out_folder, in_folder = _get_folders(db) os.makedirs(out_folder, exist_ok=True) os.makedirs(in_folder, exist_ok=True) filename = _unique_filename() content = _build_command_file(items, payment_method, total, db) except Exception as e: logger.error("Fiscal background setup failed for order %s: %s", order_id, e) _update_fiscal_status(order_id, "failed", db_factory) return def _run(): result = _write_and_poll(items, payment_method, total, out_folder, in_folder, content, filename, timeout_seconds) status = "success" if result["success"] else "failed" _update_fiscal_status(order_id, status, db_factory) thread = threading.Thread(target=_run, daemon=True) thread.start() def _update_fiscal_status(order_id: int, status: str, db_factory) -> None: """Open a fresh DB session and set order.fiscal_status.""" try: session = db_factory() try: from models.order import Order order = session.query(Order).filter(Order.id == order_id).first() if order: order.fiscal_status = status session.commit() logger.info("Fiscal: order %s fiscal_status → %s", order_id, status) finally: session.close() except Exception as e: logger.error("Fiscal: failed to update fiscal_status for order %s: %s", order_id, e) def validate_items_for_fiscal(order_items, db) -> list[str]: """ Check that every non-cancelled item in the list has a fiscal_vat_group_id. Returns a list of product names that are missing it (empty = all OK). """ missing = [] for item in order_items: product = item.product if product is None: missing.append(f"#product_{item.product_id}") continue vat_group = getattr(product, "fiscal_vat_group_id", None) if not vat_group: missing.append(product.name) return missing def build_fiscal_items(order_items) -> list[dict]: """ Convert a list of OrderItem objects into the dicts that send_fiscal_receipt expects. Uses fiscal_name if set, else falls back to product.name. Applies price_adjustment to the unit price. """ result = [] for item in order_items: product = item.product name = (getattr(product, "fiscal_name", None) or "").strip() or (product.name if product else f"#{item.product_id}") adj = getattr(item, "price_adjustment", 0.0) or 0.0 unit_price = item.unit_price + adj result.append({ "name": name, "qty": item.quantity, "unit_price": unit_price, "vat_group_id": product.fiscal_vat_group_id, }) return result def test_folders(out_folder: str, in_folder: str) -> dict: """ Verify the two folders are accessible (read + write). Returns {"ok": bool, "out_folder": str|None, "in_folder": str|None} where each value is None if accessible, or an error string if not. """ def check(path): try: os.makedirs(path, exist_ok=True) test_file = os.path.join(path, ".fiscal_test") with open(test_file, "w") as f: f.write("test") os.remove(test_file) return None except Exception as e: return str(e) out_err = check(out_folder) in_err = check(in_folder) return { "ok": out_err is None and in_err is None, "out_folder_error": out_err, "in_folder_error": in_err, }