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>
This commit is contained in:
280
local_backend/services/fiscal_service.py
Normal file
280
local_backend/services/fiscal_service.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user