Files
xenia-pos-local/local_backend/services/fiscal_service.py
bonamin 34ae328b0d feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:00:14 +03:00

281 lines
10 KiB
Python

"""
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,
}