Backend: table groups with prefix/color, auto-numbering, has_active_order flag

TableGroup gains prefix and color columns for display in the PWA zone filter.
Table creation now assigns a global auto-increment number; batch creation uses
group-local label numbering (avoids gaps/conflicts when adding to existing groups).
DELETE table now blocks if an active order exists (soft or hard delete).
Hard delete cascades past orders before removing the table row.
list_tables enriches each TableOut with has_active_order computed server-side.
TableOut no longer requires number in the input payload; TableCreate simplified.

Migration runner refactored to give each ALTER TABLE its own connection so a
no-op (column already exists) doesn't leave a dirty transaction blocking later
migrations. New migrations added for all new columns.

Order.print_logs relationship gains cascade="all, delete-orphan" so print logs
are removed when an order is deleted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 09:28:56 +03:00
parent 2c9276e654
commit d07c7634e6
5 changed files with 99 additions and 37 deletions

View File

@@ -19,24 +19,28 @@ from routers import auth, tables, products, orders, waiters, reports, system
def _run_migrations():
"""Apply additive schema changes that create_all won't handle."""
"""Apply additive schema changes that create_all won't handle.
Each migration gets its own connection so a no-op (column already exists)
doesn't leave a dirty transaction that blocks subsequent migrations."""
from sqlalchemy import text
migrations = [
"ALTER TABLE product_ingredients ADD COLUMN extra_cost REAL NOT NULL DEFAULT 0.0",
"ALTER TABLE products ADD COLUMN image_url VARCHAR",
"ALTER TABLE tables ADD COLUMN group_id INTEGER REFERENCES table_groups(id)",
"ALTER TABLE table_groups ADD COLUMN prefix VARCHAR",
"ALTER TABLE table_groups ADD COLUMN color VARCHAR",
"ALTER TABLE products ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_preference_sets ADD COLUMN default_choice_id INTEGER",
"ALTER TABLE product_preference_choices ADD COLUMN sub_choices TEXT",
"ALTER TABLE product_preference_choices ADD COLUMN disables_subset INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_preference_sets ADD COLUMN shared_subset TEXT",
"ALTER TABLE product_options ADD COLUMN sub_choices TEXT",
]
for sql in migrations:
try:
with engine.connect() as conn:
# Add extra_cost to product_ingredients if missing
try:
conn.execute(text("ALTER TABLE product_ingredients ADD COLUMN extra_cost REAL NOT NULL DEFAULT 0.0"))
conn.commit()
except Exception:
pass
# Add image_url to products if missing
try:
conn.execute(text("ALTER TABLE products ADD COLUMN image_url VARCHAR"))
conn.commit()
except Exception:
pass
# Add group_id to tables if missing (added in Phase 3 table groups)
try:
conn.execute(text("ALTER TABLE tables ADD COLUMN group_id INTEGER REFERENCES table_groups(id)"))
conn.execute(text(sql))
conn.commit()
except Exception:
pass

View File

@@ -21,7 +21,7 @@ class Order(Base):
closer = relationship("User", foreign_keys=[closed_by], back_populates="orders_closed")
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan")
print_logs = relationship("PrintLog", back_populates="order")
print_logs = relationship("PrintLog", back_populates="order", cascade="all, delete-orphan")
class OrderWaiter(Base):

View File

@@ -8,7 +8,9 @@ class TableGroup(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, unique=True)
prefix = Column(String, nullable=True)
sort_order = Column(Integer, default=0)
color = Column(String, nullable=True)
tables = relationship("Table", back_populates="group")

View File

@@ -28,7 +28,7 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
raise HTTPException(status_code=400, detail="Group name already exists")
sort_order = db.query(TableGroup).count()
group = TableGroup(name=body.name, sort_order=sort_order)
group = TableGroup(name=body.name, prefix=body.prefix, sort_order=sort_order)
db.add(group)
db.commit()
db.refresh(group)
@@ -59,17 +59,36 @@ def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depe
# ── Tables ────────────────────────────────────────────────────────────────────
def _next_global_number(db: Session) -> int:
last = db.query(Table).order_by(Table.number.desc()).first()
return (last.number + 1) if last else 1
@router.get("/", response_model=List[TableOut])
def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
q = db.query(Table)
if not include_inactive:
q = q.filter(Table.is_active == True)
return q.order_by(Table.group_id, Table.number).all()
tables = q.order_by(Table.group_id, Table.number).all()
active_table_ids = {
row[0] for row in db.query(Order.table_id).filter(
Order.status.in_(["open", "partially_paid"])
).all()
}
result = []
for t in tables:
out = TableOut.model_validate(t)
out.has_active_order = t.id in active_table_ids
result.append(out)
return result
@router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED)
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
table = Table(**body.model_dump())
number = _next_global_number(db)
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True)
db.add(table)
db.commit()
db.refresh(table)
@@ -80,12 +99,30 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
if body.count < 1 or body.count > 200:
raise HTTPException(status_code=400, detail="Count must be between 1 and 200")
# Group-local label numbering: find highest suffix already used in this group
existing_in_group = (
db.query(Table)
.filter(Table.group_id == body.group_id)
.all()
) if body.group_id else []
# Extract trailing integers from existing labels that start with this prefix
used = []
for t in existing_in_group:
if t.label and t.label.startswith(body.name_prefix):
suffix = t.label[len(body.name_prefix):]
if suffix.isdigit():
used.append(int(suffix))
start_label_n = (max(used) + 1) if used else 1
created = []
for i in range(body.count):
n = body.start_number + i
label_n = start_label_n + i
global_number = _next_global_number(db)
table = Table(
number=n,
label=f"{body.name_prefix}{n}",
number=global_number,
label=f"{body.name_prefix}{label_n}",
group_id=body.group_id,
is_active=True,
)
@@ -115,13 +152,22 @@ def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db
table = db.query(Table).filter(Table.id == table_id).first()
if not table:
raise HTTPException(status_code=404, detail="Table not found")
if hard:
active_order = db.query(Order).filter(
Order.table_id == table_id,
Order.status.in_(["open", "partially_paid"])
).first()
if active_order:
raise HTTPException(status_code=400, detail="Cannot delete table with active order")
raise HTTPException(
status_code=400,
detail="Cannot delete or deactivate a table with an active order"
)
if hard:
# Delete all past (non-active) orders for this table so FK constraint doesn't block deletion.
# Active orders are already blocked above. Items/waiters/print_logs cascade via ORM.
past_orders = db.query(Order).filter(Order.table_id == table_id).all()
for order in past_orders:
db.delete(order)
db.flush()
db.delete(table)
else:
table.is_active = False

View File

@@ -4,40 +4,45 @@ from typing import Optional, List
class TableGroupCreate(BaseModel):
name: str
prefix: Optional[str] = None
color: Optional[str] = None
class TableGroupUpdate(BaseModel):
name: Optional[str] = None
prefix: Optional[str] = None
color: Optional[str] = None
class TableGroupOut(BaseModel):
id: int
name: str
prefix: Optional[str] = None
sort_order: int = 0
color: Optional[str] = None
model_config = {"from_attributes": True}
class TableBase(BaseModel):
number: int
label: Optional[str] = None
group_id: Optional[int] = None
is_active: bool = True
class TableCreate(TableBase):
pass
class TableCreate(BaseModel):
label: Optional[str] = None
group_id: Optional[int] = None
class TableBatchCreate(BaseModel):
group_id: Optional[int] = None
count: int
name_prefix: str # e.g. "Out-" → Out-1, Out-2 ...
start_number: int = 1
name_prefix: str # e.g. "TBL-" → TBL-1, TBL-2 ...
# start_number is computed on the backend from existing tables in the group
class TableUpdate(BaseModel):
number: Optional[int] = None
label: Optional[str] = None
group_id: Optional[int] = None
is_active: Optional[bool] = None
@@ -48,10 +53,15 @@ class TableFloorplanUpdate(BaseModel):
floor_y: float
class TableOut(TableBase):
class TableOut(BaseModel):
id: int
number: int
label: Optional[str] = None
group_id: Optional[int] = None
is_active: bool = True
floor_x: Optional[float] = None
floor_y: Optional[float] = None
group: Optional[TableGroupOut] = None
has_active_order: bool = False
model_config = {"from_attributes": True}