Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.3.1
1.4.0
6 changes: 6 additions & 0 deletions cloud/backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ def apply_schema_patches() -> None:
"ALTER TABLE event_cash_registers ADD COLUMN pin VARCHAR NOT NULL DEFAULT '0000'",
"ALTER TABLE event_cash_registers ADD COLUMN IF NOT EXISTS pin VARCHAR NOT NULL DEFAULT '0000'",
)
_add_column_if_missing(
"event_cash_registers",
"cash_drawer_command",
"ALTER TABLE event_cash_registers ADD COLUMN cash_drawer_command VARCHAR(32) NOT NULL DEFAULT 'none'",
"ALTER TABLE event_cash_registers ADD COLUMN IF NOT EXISTS cash_drawer_command VARCHAR(32) NOT NULL DEFAULT 'none'",
)
_backfill_baseline_in_stock()
_patch_entity_uuids("event_stations")
_patch_entity_uuids("event_waiters")
Expand Down
4 changes: 4 additions & 0 deletions cloud/backend/app/event_config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ def assert_cash_registers_valid(db: Session, event: Event, registers_payload: li
if layout_uuid not in layout_uuids:
raise api_error("cash_register_layout_invalid", status.HTTP_422_UNPROCESSABLE_CONTENT)
assert_printer_eligible(db, event, getattr(reg, "receipt_printer_appliance_id", None))
drawer_cmd = str(getattr(reg, "cash_drawer_command", None) or "none").strip().lower()
if drawer_cmd not in ("", "none") and not getattr(reg, "receipt_printer_appliance_id", None):
raise api_error("cash_drawer_requires_receipt_printer", status.HTTP_422_UNPROCESSABLE_CONTENT)


def assert_layout_cells_within_grid(layouts_payload: list) -> None:
Expand Down Expand Up @@ -407,6 +410,7 @@ def replace_event_configuration(
reg.pin = str(getattr(reg_in, "pin", None) or "0000").strip() or "0000"
reg.layout_uuid = str(reg_in.layout_uuid or "").strip()
reg.receipt_printer_appliance_id = getattr(reg_in, "receipt_printer_appliance_id", None)
reg.cash_drawer_command = str(getattr(reg_in, "cash_drawer_command", None) or "none").strip().lower() or "none"
reg.subsidiary_code = (getattr(reg_in, "subsidiary_code", None) or "").strip() or None
kept_register_uuids.add(reg.uuid)

Expand Down
1 change: 1 addition & 0 deletions cloud/backend/app/event_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ def _cash_registers_payload(event: Event, layout_uuid_map: dict[str, str]) -> li
pin=getattr(reg, "pin", None) or "0000",
layout_uuid=new_layout_uuid,
receipt_printer_appliance_id=reg.receipt_printer_appliance_id,
cash_drawer_command=getattr(reg, "cash_drawer_command", None) or "none",
subsidiary_code=getattr(reg, "subsidiary_code", None),
)
)
Expand Down
48 changes: 47 additions & 1 deletion cloud/backend/app/event_transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ def _has_order_lines(payload: dict) -> bool:


def _transaction_kind(payload: dict) -> str:
if str(payload.get("entity_type") or "").lower() == "cash_drawer":
return "kassenschublade"
if payload.get("partial_settlement"):
return "teilzahlung"
status = str(payload.get("payment_status") or "open").lower()
Expand Down Expand Up @@ -149,6 +151,31 @@ def _moved_lines_for_order(
return _infer_moved_lines_from_snapshots(order, prior, arts)


def _cash_drawer_payment_methods(payload: dict) -> str:
register_name = str(payload.get("cash_register_name") or "").strip()
command = str(payload.get("cash_drawer_command") or "").strip()
payment_id = payload.get("payment_id")
parts = ["Kassenschublade"]
if register_name:
parts.append(register_name)
if command:
parts.append(command)
if payment_id is not None:
parts.append(f"Beleg #{payment_id}")
return " · ".join(parts)


def _cash_drawer_paid_cents(payload: dict) -> int:
total = 0
for payment in payload.get("payments") or []:
if not isinstance(payment, dict):
continue
if str(payment.get("type") or "").lower() != "cash":
continue
total += int(payment.get("amount_cents") or 0)
return total


def _transaction_row(
order: EdgeSubmittedOrder,
*,
Expand All @@ -158,6 +185,25 @@ def _transaction_row(
) -> dict[str, Any]:
payload = _payload_dict(order)
kind = _transaction_kind(payload)
if kind == "kassenschublade":
register_name = str(payload.get("cash_register_name") or "").strip()
return {
"id": order.id,
"created_at": payload.get("opened_at") or (order.created_at.isoformat() if order.created_at else None),
"kind": kind,
"client_order_id": str(payload.get("client_order_id") or order.client_order_id),
"table_number": None,
"collective_bill_name": None,
"waiter_name": register_name or "—",
"payment_status": "paid",
"line_cents": 0,
"moved_line_cents": 0,
"paid_cents": _cash_drawer_paid_cents(payload),
"payment_methods": _cash_drawer_payment_methods(payload),
"line_count": 0,
"lines": [],
"moved_lines": [],
}
raw_lines = payload.get("lines") or []
line_count = sum(
1
Expand Down Expand Up @@ -274,7 +320,7 @@ def build_event_transactions_page(
base = _apply_payment_status_filter(base, payment_status)

kind_norm = (kind or "").strip().lower() or None
if kind_norm and kind_norm not in ("bestellung", "teilzahlung", "zahlung"):
if kind_norm and kind_norm not in ("bestellung", "teilzahlung", "zahlung", "kassenschublade"):
kind_norm = None

sort_key = (sort_by or "created_at").strip()
Expand Down
1 change: 1 addition & 0 deletions cloud/backend/app/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"source_waiter_wrong_organisation": "source_waiter_id muss einen Kellner derselben Organisation wie die Veranstaltung referenzieren.",
"exactly_one_default_layout": "Genau ein App-Layout muss is_default=true haben.",
"pickup_prefix_invalid": "Abhol-Präfix der Kasse muss 1–3 Buchstaben A–Z enthalten.",
"cash_drawer_requires_receipt_printer": "Kassenschublade erfordert einen konfigurierten Kundendrucker.",
"cash_register_layout_invalid": "Kasse muss ein App-Layout dieser Veranstaltung referenzieren.",
"cell_out_of_bounds": "Zelle ({row},{col}) liegt ausserhalb des Rasters {width}x{height}.",
"duplicate_cell_position": "Doppelte Zellenposition ({row},{col}) in einem Layout.",
Expand Down
1 change: 1 addition & 0 deletions cloud/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,7 @@ class EventCashRegister(Base):
subsidiary_code = Column(String(32), nullable=True)
layout_uuid = Column(String(36), nullable=False)
receipt_printer_appliance_id = Column(Integer, ForeignKey("appliances.id", ondelete="SET NULL"), nullable=True)
cash_drawer_command = Column(String(32), nullable=False, default="none", server_default="none")
event = relationship("Event", back_populates="cash_registers")
receipt_printer_appliance = relationship("Appliance", foreign_keys=[receipt_printer_appliance_id])

Expand Down
4 changes: 4 additions & 0 deletions cloud/backend/app/routers/edge.py
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,10 @@ def submit_operational_chunk(
commit_or_raise(db)
return EdgeOperationalChunkAck(chunk_id=body.chunk_id, status="acked", accepted=1)

if entity_type == "cash_drawer":
commit_or_raise(db)
return EdgeOperationalChunkAck(chunk_id=body.chunk_id, status="acked", accepted=1)

if entity_type == "kitchen_tickets":
upsert_edge_kitchen_ticket_snapshot(
db,
Expand Down
1 change: 1 addition & 0 deletions cloud/backend/app/routers/events_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ def serialize_event_configuration(
pin=getattr(reg, "pin", None) or "0000",
layout_uuid=reg.layout_uuid,
receipt_printer_appliance_id=reg.receipt_printer_appliance_id,
cash_drawer_command=getattr(reg, "cash_drawer_command", None) or "none",
subsidiary_code=getattr(reg, "subsidiary_code", None),
)
for reg in sorted(event.cash_registers, key=lambda r: (r.sort_order, r.id))
Expand Down
5 changes: 4 additions & 1 deletion cloud/backend/app/schemas/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
from __future__ import annotations

from datetime import datetime
from typing import Any
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field, model_validator

from ..event_status import ALLOWED_STATUSES
from ..payment_types_config import normalize_payment_types

PAYMENT_MODES = {"instant", "pay_now", "pay_later"}
CASH_DRAWER_COMMANDS = Literal["none", "escp_pin2", "escp_pin5", "escp_pin2_long", "escp_pin5_long"]

class EventBase(BaseModel):
name: str = Field(..., min_length=1)
Expand Down Expand Up @@ -188,6 +189,7 @@ class CashRegisterRead(BaseModel):
pin: str
layout_uuid: str
receipt_printer_appliance_id: int | None
cash_drawer_command: CASH_DRAWER_COMMANDS = "none"
subsidiary_code: str | None = None


Expand Down Expand Up @@ -267,6 +269,7 @@ class CashRegisterIn(BaseModel):
pin: str = Field("0000", min_length=1, max_length=32)
layout_uuid: str = Field(..., min_length=1)
receipt_printer_appliance_id: int | None = None
cash_drawer_command: CASH_DRAWER_COMMANDS = "none"
subsidiary_code: str | None = Field(None, max_length=32)


Expand Down
110 changes: 110 additions & 0 deletions cloud/backend/tests/test_cash_drawer_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Cash register cash drawer command configuration."""

from datetime import UTC, datetime
from types import SimpleNamespace

import pytest
from app.database import Base
from app.event_config_validation import assert_cash_registers_valid, replace_event_configuration
from app.models import Event, EventAppLayout, EventCashRegister, HireCompany, Organisation
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from tests.helpers import ensure_country


@pytest.fixture
def db_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
db = Session()
ch_country_id = ensure_country(db, "CH", country_id=1)
hc = HireCompany(id=1, name="HC")
db.add(hc)
org = Organisation(id=1, name="Org", country_id=ch_country_id, hire_company_id=1, currency="CHF")
db.add(org)
now = datetime.now(UTC)
ev = Event(
id=1,
name="Fest",
status="config",
start=now,
end=now,
organisation_id=1,
cash_registers_enabled=True,
)
db.add(ev)
layout = EventAppLayout(
id=1,
event_id=1,
uuid="layout-1",
name="Kasse",
is_default=True,
grid_width=1,
grid_height=1,
)
db.add(layout)
db.commit()
yield db, ev, layout
db.close()


def _register(layout_uuid: str, **kwargs):
defaults = {
"name": "Hauptkasse",
"pickup_code_prefix": "A",
"pin": "0000",
"layout_uuid": layout_uuid,
"receipt_printer_appliance_id": None,
"cash_drawer_command": "none",
}
defaults.update(kwargs)
return SimpleNamespace(**defaults)


def test_drawer_command_requires_receipt_printer(db_session):
db, event, layout = db_session
with pytest.raises(HTTPException) as exc:
assert_cash_registers_valid(
db,
event,
[_register(layout.uuid, cash_drawer_command="escp_pin2")],
[layout],
)
assert exc.value.detail["code"] == "cash_drawer_requires_receipt_printer"


def test_replace_configuration_persists_cash_drawer_command(db_session, monkeypatch):
db, event, layout = db_session
monkeypatch.setattr(
"app.event_config_validation.event_printer_candidates",
lambda _db, _event: [SimpleNamespace(id=99)],
)
replace_event_configuration(
db,
event,
stations_in=[],
event_waiters_in=[],
app_layouts_in=[
SimpleNamespace(
uuid=layout.uuid,
name=layout.name,
is_default=True,
grid_width=1,
grid_height=1,
cells=[],
)
],
cash_registers_in=[
_register(
layout.uuid,
receipt_printer_appliance_id=99,
cash_drawer_command="escp_pin5",
)
],
)
db.commit()
reg = db.query(EventCashRegister).filter(EventCashRegister.event_id == event.id).one()
assert reg.cash_drawer_command == "escp_pin5"
28 changes: 28 additions & 0 deletions cloud/backend/tests/test_event_transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,34 @@ def test_transactions_transfer_events(db_session):
assert row["moved_lines"][0]["name"] == "Bier"


def test_transactions_cash_drawer_kind(db_session):
db, event = db_session
_add_order(
db,
chunk_id="drawer-1",
created_at=datetime(2026, 6, 1, 12, 0, tzinfo=UTC),
payload={
"entity_type": "cash_drawer",
"opened_at": "2026-06-01T12:00:00+00:00",
"cash_register_uuid": "reg-1",
"cash_register_name": "Hauptkasse",
"cash_drawer_command": "escp_pin2",
"payment_id": 42,
"client_order_id": "pwa-abc",
"payments": [{"type": "cash", "amount_cents": 500}],
},
)
db.commit()

page = build_event_transactions_page(db, event, kind="kassenschublade")
assert page["total"] == 1
row = page["items"][0]
assert row["kind"] == "kassenschublade"
assert row["waiter_name"] == "Hauptkasse"
assert row["paid_cents"] == 500
assert "Beleg #42" in row["payment_methods"]


def test_transactions_sort_by_line_cents(db_session):
db, event = db_session
_add_order(
Expand Down
24 changes: 24 additions & 0 deletions cloud/frontend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10894,6 +10894,18 @@
],
"title": "Receipt Printer Appliance Id"
},
"cash_drawer_command": {
"type": "string",
"enum": [
"none",
"escp_pin2",
"escp_pin5",
"escp_pin2_long",
"escp_pin5_long"
],
"title": "Cash Drawer Command",
"default": "none"
},
"subsidiary_code": {
"anyOf": [
{
Expand Down Expand Up @@ -10952,6 +10964,18 @@
],
"title": "Receipt Printer Appliance Id"
},
"cash_drawer_command": {
"type": "string",
"enum": [
"none",
"escp_pin2",
"escp_pin5",
"escp_pin2_long",
"escp_pin5_long"
],
"title": "Cash Drawer Command",
"default": "none"
},
"subsidiary_code": {
"anyOf": [
{
Expand Down
Loading
Loading