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>
281 lines
10 KiB
Python
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,
|
|
}
|