15f6a6c713
Add operator health checks (make doctor) wrapping platform CLIs, and the fixtures-only daily board skill library with unit tests (make verify).
122 lines
3.9 KiB
Python
122 lines
3.9 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 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
|
|
|
|
|
|
@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 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,
|
|
}
|
|
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,
|
|
}
|