05/06/2026 Initial commit
This commit is contained in:
@@ -0,0 +1,744 @@
|
||||
"""
|
||||
models/salon.py — Tenant-level models (all carry tenant_id).
|
||||
Soft delete convention: all tenant models include deleted_at.
|
||||
Queries always filter WHERE deleted_at IS NULL.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from app.extensions import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# User & Location
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class User(db.Model):
|
||||
"""Tenant admin and manager accounts (email + password login)."""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
email = db.Column(db.String(255), nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
role = db.Column(db.String(30), nullable=False)
|
||||
# role: 'tenant_admin' | 'tenant_manager'
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
failed_login_attempts = db.Column(db.Integer, nullable=False, default=0)
|
||||
locked_until = db.Column(db.DateTime, nullable=True)
|
||||
password_reset_token = db.Column(db.String(255), nullable=True)
|
||||
password_reset_expires_at = db.Column(db.DateTime, nullable=True)
|
||||
last_login_at = db.Column(db.DateTime, nullable=True)
|
||||
created_at = db.Column(db.DateTime, nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
deleted_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
||||
)
|
||||
|
||||
# Flask-Login interface
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_anonymous(self):
|
||||
return False
|
||||
|
||||
def get_id(self):
|
||||
return f"user:{self.id}"
|
||||
|
||||
def is_locked(self):
|
||||
if self.locked_until is None:
|
||||
return False
|
||||
return datetime.now(timezone.utc) < self.locked_until.replace(tzinfo=timezone.utc)
|
||||
|
||||
def record_login(self):
|
||||
self.failed_login_attempts = 0
|
||||
self.locked_until = None
|
||||
self.last_login_at = datetime.now(timezone.utc)
|
||||
|
||||
def record_failed_login(self, max_attempts: int, lockout_minutes: int):
|
||||
from datetime import timedelta
|
||||
self.failed_login_attempts = (self.failed_login_attempts or 0) + 1
|
||||
if self.failed_login_attempts >= max_attempts:
|
||||
self.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_minutes)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.email} tenant={self.tenant_id}>"
|
||||
|
||||
|
||||
class TenantSetting(db.Model):
|
||||
__tablename__ = "tenant_settings"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
setting_key = db.Column(db.String(100), nullable=False)
|
||||
setting_value = db.Column(db.Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("tenant_id", "setting_key",
|
||||
name="uq_tenant_settings_key"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TenantSetting {self.setting_key}={self.setting_value}>"
|
||||
|
||||
|
||||
class Location(db.Model):
|
||||
__tablename__ = "locations"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
address = db.Column(db.String(255), nullable=True)
|
||||
phone = db.Column(db.String(30), nullable=True)
|
||||
email = db.Column(db.String(255), nullable=True)
|
||||
timezone = db.Column(db.String(60), nullable=False, default="America/New_York")
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
is_primary = db.Column(db.Boolean, nullable=False, default=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
deleted_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
settings = db.relationship("LocationSetting", back_populates="location",
|
||||
lazy="dynamic")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Location {self.name} tenant={self.tenant_id}>"
|
||||
|
||||
|
||||
class LocationSetting(db.Model):
|
||||
__tablename__ = "location_settings"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||
nullable=False, index=True)
|
||||
setting_key = db.Column(db.String(100), nullable=False)
|
||||
setting_value = db.Column(db.Text, nullable=True)
|
||||
|
||||
location = db.relationship("Location", back_populates="settings")
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("location_id", "setting_key",
|
||||
name="uq_location_settings_key"),
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Customers
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class Customer(db.Model):
|
||||
__tablename__ = "customers"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
phone = db.Column(db.String(30), nullable=True)
|
||||
email = db.Column(db.String(255), nullable=True)
|
||||
date_of_birth = db.Column(db.Date, nullable=True)
|
||||
preferred_staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
|
||||
nullable=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
loyalty_points = db.Column(db.Integer, nullable=False, default=0)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
no_show_count = db.Column(db.Integer, nullable=False, default=0)
|
||||
created_at = db.Column(db.DateTime, nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
deleted_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
def soft_delete(self):
|
||||
self.deleted_at = datetime.now(timezone.utc)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Customer {self.name} tenant={self.tenant_id}>"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Services, Products & Promotions
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class Service(db.Model):
|
||||
__tablename__ = "services"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
category = db.Column(db.String(100), nullable=True)
|
||||
duration_min = db.Column(db.Integer, nullable=False, default=30)
|
||||
price = db.Column(db.Numeric(8, 2), nullable=False)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
deleted_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Service {self.name}>"
|
||||
|
||||
|
||||
class Product(db.Model):
|
||||
__tablename__ = "products"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
sku = db.Column(db.String(100), nullable=True)
|
||||
category = db.Column(db.String(100), nullable=True)
|
||||
sale_price = db.Column(db.Numeric(8, 2), nullable=False)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
deleted_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Product {self.name}>"
|
||||
|
||||
|
||||
class Promotion(db.Model):
|
||||
"""
|
||||
applies_to: 'service' | 'product' | 'all_services' | 'all_products' | 'all'
|
||||
target_ids_json: list of IDs when applies_to is 'service' or 'product'; else null.
|
||||
discount_percent: 1–100 integer.
|
||||
Active window: starts_at <= NOW() <= ends_at AND is_active = true.
|
||||
"""
|
||||
__tablename__ = "promotions"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
discount_percent = db.Column(db.Integer, nullable=False)
|
||||
applies_to = db.Column(db.String(30), nullable=False)
|
||||
target_ids_json = db.Column(db.JSON, nullable=True)
|
||||
starts_at = db.Column(db.DateTime, nullable=False)
|
||||
ends_at = db.Column(db.DateTime, nullable=False)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||
created_at = db.Column(db.DateTime, nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Promotion {self.name} {self.discount_percent}%>"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Staff
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class Staff(db.Model):
|
||||
"""
|
||||
staff_type: 'salon_manager' | 'full_time' | 'part_time' | 'seasonal' | 'receptionist'
|
||||
pay_type: 'hourly' | 'salary' | 'guarantee'
|
||||
pay_period: 'weekly' | 'biweekly' | 'monthly'
|
||||
passcode_hash: bcrypt-hashed 4–6 digit PIN; set by tenant_admin at creation.
|
||||
phone: unique within tenant; used for staff-login.
|
||||
"""
|
||||
__tablename__ = "staff"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
phone = db.Column(db.String(30), nullable=False)
|
||||
passcode_hash = db.Column(db.String(255), nullable=False)
|
||||
staff_type = db.Column(db.String(30), nullable=False)
|
||||
pay_type = db.Column(db.String(20), nullable=False, default="hourly")
|
||||
hourly_rate = db.Column(db.Numeric(8, 2), nullable=True)
|
||||
salary_amount = db.Column(db.Numeric(10, 2), nullable=True)
|
||||
guarantee_amount = db.Column(db.Numeric(10, 2), nullable=True)
|
||||
pay_period = db.Column(db.String(20), nullable=False, default="biweekly")
|
||||
commission_rate = db.Column(db.Numeric(5, 2), nullable=True)
|
||||
commission_enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
passcode_failed_attempts = db.Column(db.Integer, nullable=False, default=0)
|
||||
passcode_locked_until = db.Column(db.DateTime, nullable=True)
|
||||
deleted_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
locations = db.relationship("StaffLocation", back_populates="staff",
|
||||
lazy="dynamic")
|
||||
schedules = db.relationship("StaffSchedule", back_populates="staff",
|
||||
lazy="dynamic")
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("tenant_id", "phone", name="uq_staff_tenant_phone"),
|
||||
)
|
||||
|
||||
# Flask-Login interface (for staff portal session)
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_anonymous(self):
|
||||
return False
|
||||
|
||||
def get_id(self):
|
||||
return f"staff:{self.id}"
|
||||
|
||||
def is_passcode_locked(self):
|
||||
if self.passcode_locked_until is None:
|
||||
return False
|
||||
return datetime.now(timezone.utc) < self.passcode_locked_until.replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
|
||||
def record_passcode_success(self):
|
||||
self.passcode_failed_attempts = 0
|
||||
self.passcode_locked_until = None
|
||||
|
||||
def record_passcode_failure(self, max_attempts: int, lockout_minutes: int):
|
||||
from datetime import timedelta
|
||||
self.passcode_failed_attempts = (self.passcode_failed_attempts or 0) + 1
|
||||
if self.passcode_failed_attempts >= max_attempts:
|
||||
self.passcode_locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_minutes)
|
||||
|
||||
def soft_delete(self):
|
||||
self.deleted_at = datetime.now(timezone.utc)
|
||||
|
||||
def is_assigned_to_location(self, location_id: int) -> bool:
|
||||
return any(loc.id == location_id for loc in self.locations)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Staff {self.name} tenant={self.tenant_id}>"
|
||||
|
||||
|
||||
class StaffLocation(db.Model):
|
||||
__tablename__ = "staff_locations"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
|
||||
nullable=False, index=True)
|
||||
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||
nullable=False, index=True)
|
||||
|
||||
staff = db.relationship("Staff", back_populates="locations")
|
||||
location = db.relationship("Location")
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("staff_id", "location_id",
|
||||
name="uq_staff_location"),
|
||||
)
|
||||
|
||||
|
||||
class StaffSchedule(db.Model):
|
||||
__tablename__ = "staff_schedules"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
|
||||
nullable=False, index=True)
|
||||
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||
nullable=False, index=True)
|
||||
day_of_week = db.Column(db.Integer, nullable=False) # 0=Mon … 6=Sun
|
||||
start_time = db.Column(db.Time, nullable=False)
|
||||
end_time = db.Column(db.Time, nullable=False)
|
||||
|
||||
staff = db.relationship("Staff", back_populates="schedules")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Appointments
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class Appointment(db.Model):
|
||||
"""
|
||||
status: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled' | 'no_show'
|
||||
rebook_source: 'checkout' | 'online' | 'manual' | 'kiosk' | null
|
||||
"""
|
||||
__tablename__ = "appointments"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||
nullable=False, index=True)
|
||||
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
|
||||
nullable=True)
|
||||
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
|
||||
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
|
||||
start_time = db.Column(db.DateTime, nullable=False)
|
||||
end_time = db.Column(db.DateTime, nullable=True)
|
||||
is_walk_in = db.Column(db.Boolean, nullable=False, default=False)
|
||||
status = db.Column(db.String(20), nullable=False, default="pending")
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
cancellation_reason = db.Column(db.Text, nullable=True)
|
||||
cancelled_at = db.Column(db.DateTime, nullable=True)
|
||||
rebook_source = db.Column(db.String(20), nullable=True)
|
||||
rebooked_from_transaction_id = db.Column(
|
||||
db.Integer, db.ForeignKey("transactions.id"), nullable=True
|
||||
)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||
created_at = db.Column(db.DateTime, nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
customer = db.relationship("Customer")
|
||||
staff = db.relationship("Staff")
|
||||
service = db.relationship("Service")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Appointment {self.id} status={self.status}>"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Transactions & POS
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class Transaction(db.Model):
|
||||
"""
|
||||
payment_method: 'cash' | 'zelle' | 'venmo' | 'cashapp' | 'gift_card' | 'other'
|
||||
"""
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||
nullable=False, index=True)
|
||||
appointment_id = db.Column(db.Integer, db.ForeignKey("appointments.id"),
|
||||
nullable=True)
|
||||
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
|
||||
nullable=True)
|
||||
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
|
||||
subtotal = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
discount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||
tip_amount = db.Column(db.Numeric(8, 2), nullable=False, default=0)
|
||||
gift_card_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||
total = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
payment_method = db.Column(db.String(20), nullable=False)
|
||||
payment_reference = db.Column(db.String(255), nullable=True)
|
||||
gift_card_id = db.Column(db.Integer, db.ForeignKey("gift_cards.id"),
|
||||
nullable=True)
|
||||
review_request_sent_at = db.Column(db.DateTime, nullable=True)
|
||||
voided_at = db.Column(db.DateTime, nullable=True)
|
||||
voided_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||
void_reason = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
items = db.relationship("TransactionItem", back_populates="transaction",
|
||||
lazy="dynamic")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Transaction {self.id} total={self.total}>"
|
||||
|
||||
|
||||
class TransactionItem(db.Model):
|
||||
__tablename__ = "transaction_items"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
|
||||
nullable=False, index=True)
|
||||
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
|
||||
product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=True)
|
||||
qty = db.Column(db.Integer, nullable=False, default=1)
|
||||
unit_price = db.Column(db.Numeric(8, 2), nullable=False)
|
||||
# unit_price: final price after promotion
|
||||
original_price = db.Column(db.Numeric(8, 2), nullable=False)
|
||||
# original_price: price before promotion
|
||||
discount_percent = db.Column(db.Numeric(5, 2), nullable=False, default=0)
|
||||
promotion_id = db.Column(db.Integer, db.ForeignKey("promotions.id"),
|
||||
nullable=True)
|
||||
|
||||
transaction = db.relationship("Transaction", back_populates="items")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TransactionItem tx={self.transaction_id}>"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Inventory
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class Inventory(db.Model):
|
||||
__tablename__ = "inventory"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||
nullable=False, index=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
sku = db.Column(db.String(100), nullable=True)
|
||||
category = db.Column(db.String(100), nullable=True)
|
||||
qty_on_hand = db.Column(db.Integer, nullable=False, default=0)
|
||||
reorder_level = db.Column(db.Integer, nullable=False, default=5)
|
||||
cost_price = db.Column(db.Numeric(8, 2), nullable=True)
|
||||
sale_price = db.Column(db.Numeric(8, 2), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Inventory {self.name} qty={self.qty_on_hand}>"
|
||||
|
||||
|
||||
class InventoryLog(db.Model):
|
||||
__tablename__ = "inventory_log"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||
nullable=False, index=True)
|
||||
inventory_id = db.Column(db.Integer, db.ForeignKey("inventory.id"),
|
||||
nullable=False)
|
||||
delta = db.Column(db.Integer, nullable=False)
|
||||
reason = db.Column(db.String(255), nullable=True)
|
||||
created_at = db.Column(db.DateTime, nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Commission & Pay
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class CommissionLog(db.Model):
|
||||
__tablename__ = "commission_log"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||
nullable=False, index=True)
|
||||
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
|
||||
nullable=False, index=True)
|
||||
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
|
||||
nullable=False)
|
||||
amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
period = db.Column(db.String(20), nullable=True)
|
||||
|
||||
|
||||
class StaffPayPeriod(db.Model):
|
||||
"""
|
||||
status: 'draft' | 'approved' | 'paid'
|
||||
pay_type: mirrors staff.pay_type at the time of the period.
|
||||
guarantee_topup: max(0, guarantee_amount - commission_amount) for 'guarantee' pay_type.
|
||||
"""
|
||||
__tablename__ = "staff_pay_periods"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
|
||||
nullable=False, index=True)
|
||||
period_start = db.Column(db.Date, nullable=False)
|
||||
period_end = db.Column(db.Date, nullable=False)
|
||||
pay_type = db.Column(db.String(20), nullable=False)
|
||||
base_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||
commission_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||
guarantee_topup = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||
total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||
status = db.Column(db.String(20), nullable=False, default="draft")
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
|
||||
class StaffClocking(db.Model):
|
||||
"""One row per shift. clocked_out_at NULL = currently clocked in."""
|
||||
__tablename__ = "staff_clockings"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||
nullable=False, index=True)
|
||||
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
|
||||
nullable=False, index=True)
|
||||
clocked_in_at = db.Column(db.DateTime, nullable=False)
|
||||
clocked_out_at = db.Column(db.DateTime, nullable=True)
|
||||
total_minutes = db.Column(db.Integer, nullable=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Marketing
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class MarketingCampaign(db.Model):
|
||||
"""
|
||||
channel: 'email' (SMS deferred)
|
||||
status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled'
|
||||
"""
|
||||
__tablename__ = "marketing_campaigns"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
channel = db.Column(db.String(20), nullable=False, default="email")
|
||||
status = db.Column(db.String(20), nullable=False, default="draft")
|
||||
audience_filter_json = db.Column(db.JSON, nullable=True)
|
||||
subject = db.Column(db.String(255), nullable=True)
|
||||
message_body = db.Column(db.Text, nullable=True)
|
||||
scheduled_at = db.Column(db.DateTime, nullable=True)
|
||||
sent_at = db.Column(db.DateTime, nullable=True)
|
||||
sent_count = db.Column(db.Integer, nullable=False, default=0)
|
||||
open_count = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Gift Cards
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class GiftCard(db.Model):
|
||||
__tablename__ = "gift_cards"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
code = db.Column(db.String(50), nullable=False)
|
||||
original_value = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
remaining_balance = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
issued_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||
issued_to_customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
|
||||
nullable=True)
|
||||
expires_at = db.Column(db.DateTime, nullable=True)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
created_at = db.Column(db.DateTime, nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("tenant_id", "code", name="uq_gift_card_tenant_code"),
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Check-in Kiosk & Waitlist
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class CheckinQueue(db.Model):
|
||||
"""
|
||||
status: 'waiting' | 'acknowledged' | 'seated' | 'expired'
|
||||
Surfaced to receptionist dashboard via 5-second polling.
|
||||
"""
|
||||
__tablename__ = "checkin_queue"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||
nullable=False, index=True)
|
||||
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
|
||||
nullable=True)
|
||||
customer_name = db.Column(db.String(150), nullable=False)
|
||||
customer_phone = db.Column(db.String(30), nullable=False)
|
||||
service_requested = db.Column(db.String(150), nullable=True)
|
||||
checked_in_at = db.Column(db.DateTime, nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
status = db.Column(db.String(20), nullable=False, default="waiting")
|
||||
acknowledged_by = db.Column(db.Integer, db.ForeignKey("users.id"),
|
||||
nullable=True)
|
||||
acknowledged_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
|
||||
class Waitlist(db.Model):
|
||||
"""status: 'waiting' | 'notified' | 'booked' | 'expired'"""
|
||||
__tablename__ = "waitlist"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||
nullable=False, index=True)
|
||||
customer_name = db.Column(db.String(150), nullable=False)
|
||||
customer_phone = db.Column(db.String(30), nullable=True)
|
||||
customer_email = db.Column(db.String(255), nullable=True)
|
||||
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
|
||||
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
|
||||
requested_date = db.Column(db.Date, nullable=True)
|
||||
status = db.Column(db.String(20), nullable=False, default="waiting")
|
||||
notified_at = db.Column(db.DateTime, nullable=True)
|
||||
created_at = db.Column(db.DateTime, nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Reviews & Reconciliation
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class CheckoutReview(db.Model):
|
||||
"""
|
||||
rating: 1–5 integer.
|
||||
is_public_suggested: True if rating >= 4 (platform links shown).
|
||||
"""
|
||||
__tablename__ = "checkout_reviews"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||
nullable=False, index=True)
|
||||
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
|
||||
nullable=False)
|
||||
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
|
||||
nullable=True)
|
||||
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
|
||||
rating = db.Column(db.Integer, nullable=False)
|
||||
comment = db.Column(db.Text, nullable=True)
|
||||
is_public_suggested = db.Column(db.Boolean, nullable=False, default=False)
|
||||
google_clicked = db.Column(db.Boolean, nullable=False, default=False)
|
||||
facebook_clicked = db.Column(db.Boolean, nullable=False, default=False)
|
||||
yelp_clicked = db.Column(db.Boolean, nullable=False, default=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class DailyReconciliation(db.Model):
|
||||
"""variance = actual_cash_counted - expected_cash_in_drawer (negative = shortage)"""
|
||||
__tablename__ = "daily_reconciliations"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||
nullable=False, index=True)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
total_cash = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||
total_app_payments = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||
total_tips = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||
total_gift_card_redemptions = db.Column(db.Numeric(10, 2), nullable=False,
|
||||
default=0)
|
||||
expected_cash_in_drawer = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||
actual_cash_counted = db.Column(db.Numeric(10, 2), nullable=True)
|
||||
variance = db.Column(db.Numeric(10, 2), nullable=True)
|
||||
closed_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||
closed_at = db.Column(db.DateTime, nullable=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("tenant_id", "location_id", "date",
|
||||
name="uq_reconciliation_location_date"),
|
||||
)
|
||||
|
||||
|
||||
class AppointmentReminder(db.Model):
|
||||
"""
|
||||
reminder_type: '24h' | '2h'
|
||||
channel: 'email' (SMS deferred)
|
||||
status: 'pending' | 'sent' | 'failed' | 'cancelled'
|
||||
"""
|
||||
__tablename__ = "appointment_reminders"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||
nullable=False, index=True)
|
||||
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||
nullable=False, index=True)
|
||||
appointment_id = db.Column(db.Integer, db.ForeignKey("appointments.id"),
|
||||
nullable=False, index=True)
|
||||
reminder_type = db.Column(db.String(10), nullable=False)
|
||||
scheduled_for = db.Column(db.DateTime, nullable=False)
|
||||
sent_at = db.Column(db.DateTime, nullable=True)
|
||||
channel = db.Column(db.String(20), nullable=False, default="email")
|
||||
status = db.Column(db.String(20), nullable=False, default="pending")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user