Files
Salon_Assistant/skills/_lib/lumina_skills/domain.py
T
Ty 00addc540d improve domain.py: add validation, repr, missing serialization, utilization rate
- Add __post_init__ validation so Appointment rejects negative durations
- Add __repr__ to Appointment, Gap, and DayBoard for readable debugging
- Fix DayBoard.to_dict to serialize preceding_appointment_id and
  following_appointment_id on Gap (were previously dropped)
- Add utilization_rate property (booked / total) with 0.0 guard
2026-07-29 03:16:36 +00:00

163 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Deterministic domain types for scheduling / board building.
These are pure data classes — no model inference, no network calls.
See design/det-vs-inf.md for the deterministic boundary.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, time
from enum import Enum
from typing import Optional
class AppointmentStatus(str, Enum):
"""Standardized appointment status."""
CONFIRMED = "confirmed"
PENDING = "pending"
CANCELLED = "cancelled"
COMPLETED = "completed"
NO_SHOW = "no_show"
@dataclass(frozen=True)
class Appointment:
"""A single salon appointment — the core domain object.
Fields match what the daily-board (A1) needs to display:
time, client, service, staff, status, confirmation flag.
"""
appointment_id: str
start_time: datetime
end_time: datetime
client_name: str
service_name: str
staff_name: str
status: AppointmentStatus
notes: str = ""
# Whether the client still needs a confirmation call/message.
# Derived at build time from status + last_contact, but stored here
# for fixture convenience.
needs_confirmation: bool = False
def __post_init__(self) -> None:
"""Validate that end_time is after start_time."""
if self.end_time < self.start_time:
raise ValueError(
f"end_time ({self.end_time}) must be >= start_time ({self.start_time}) "
f"for appointment {self.appointment_id}"
)
def __repr__(self) -> str:
return (
f"Appointment(id={self.appointment_id!r}, "
f"{self.start_time:%H:%M}-{self.end_time:%H:%M}, "
f"{self.client_name!r}, {self.status.value})"
)
def duration_minutes(self) -> int:
"""Appointment duration in whole minutes."""
delta = self.end_time - self.start_time
return int(delta.total_seconds() // 60)
def start_time_only(self) -> time:
return self.start_time.time()
def end_time_only(self) -> time:
return self.end_time.time()
@dataclass(frozen=True)
class Gap:
"""An unbooked time slot between two appointments (or day boundary)."""
start_time: time
end_time: time
duration_minutes: int
staff_name: str
# The appointment immediately before this gap (if any).
preceding_appointment_id: Optional[str] = None
# The appointment immediately after this gap (if any).
following_appointment_id: Optional[str] = None
def __repr__(self) -> str:
return (
f"Gap({self.start_time:%H:%M}-{self.end_time:%H:%M}, "
f"{self.duration_minutes}min, {self.staff_name!r})"
)
@dataclass(frozen=True)
class DayBoard:
"""The complete daily board for one staff member or the whole salon.
This is the structured output that the daily-board skill presents.
All data is deterministic — no model inference.
"""
date: str # YYYY-MM-DD
salon_name: str
source: str # "fixtures" | "offline" | "vagaro" | "square" (future)
is_offline: bool # True when source is fixtures or offline
appointments: list[Appointment] = field(default_factory=list)
gaps: list[Gap] = field(default_factory=list)
needs_confirmation: list[Appointment] = field(default_factory=list)
total_booked_minutes: int = 0
total_gap_minutes: int = 0
def __repr__(self) -> str:
return (
f"DayBoard({self.date}, {self.salon_name!r}, "
f"{len(self.appointments)} appts, {len(self.gaps)} gaps)"
)
@property
def utilization_rate(self) -> float:
"""Fraction of the day that is booked (0.01.0).
Returns 0.0 when there is no total time to compute against.
"""
total = self.total_booked_minutes + self.total_gap_minutes
if total == 0:
return 0.0
return self.total_booked_minutes / total
def to_dict(self) -> dict:
"""Serialize to a plain dict for JSON output."""
return {
"date": self.date,
"salon_name": self.salon_name,
"source": self.source,
"is_offline": self.is_offline,
"appointments": [
{
"id": a.appointment_id,
"start": a.start_time.isoformat(),
"end": a.end_time.isoformat(),
"client": a.client_name,
"service": a.service_name,
"staff": a.staff_name,
"status": a.status.value,
"needs_confirmation": a.needs_confirmation,
"notes": a.notes,
}
for a in self.appointments
],
"gaps": [
{
"start": g.start_time.isoformat(),
"end": g.end_time.isoformat(),
"duration_minutes": g.duration_minutes,
"staff": g.staff_name,
"preceding_appointment_id": g.preceding_appointment_id,
"following_appointment_id": g.following_appointment_id,
}
for g in self.gaps
],
"needs_confirmation": [
a.appointment_id for a in self.needs_confirmation
],
"total_booked_minutes": self.total_booked_minutes,
"total_gap_minutes": self.total_gap_minutes,
"utilization_rate": round(self.utilization_rate, 4),
}