00addc540d
- 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
163 lines
5.4 KiB
Python
163 lines
5.4 KiB
Python
"""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.0–1.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),
|
||
}
|