|
|
|
@@ -0,0 +1,125 @@
|
|
|
|
|
"""Fixture provider for scheduling data.
|
|
|
|
|
|
|
|
|
|
Loads appointment fixtures from JSON files under data/fixtures/scheduling/.
|
|
|
|
|
This is the *only* data source for the daily-board until live SaaS adapters
|
|
|
|
|
(Vagaro, Square) are implemented.
|
|
|
|
|
|
|
|
|
|
All output is labeled `source: fixtures` / `is_offline: True` so the owner
|
|
|
|
|
never sees silent fake live data.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import pathlib
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from lumina_skills.domain import Appointment, AppointmentStatus
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Mapping from fixture status strings to domain enum.
|
|
|
|
|
_STATUS_MAP: dict[str, AppointmentStatus] = {
|
|
|
|
|
"confirmed": AppointmentStatus.CONFIRMED,
|
|
|
|
|
"pending": AppointmentStatus.PENDING,
|
|
|
|
|
"cancelled": AppointmentStatus.CANCELLED,
|
|
|
|
|
"completed": AppointmentStatus.COMPLETED,
|
|
|
|
|
"no_show": AppointmentStatus.NO_SHOW,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_status(raw: str) -> AppointmentStatus:
|
|
|
|
|
"""Convert a fixture status string to AppointmentStatus.
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
ValueError: If the status string is not recognized.
|
|
|
|
|
"""
|
|
|
|
|
key = raw.lower()
|
|
|
|
|
if key not in _STATUS_MAP:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"Unknown appointment status {raw!r}. "
|
|
|
|
|
f"Expected one of: {', '.join(sorted(_STATUS_MAP))}"
|
|
|
|
|
)
|
|
|
|
|
return _STATUS_MAP[key]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_datetime(raw: str) -> datetime:
|
|
|
|
|
"""Parse ISO-format datetime strings from fixtures."""
|
|
|
|
|
return datetime.fromisoformat(raw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_fixtures(fixture_path: str | pathlib.Path) -> list[Appointment]:
|
|
|
|
|
"""Load appointments from a fixture JSON file.
|
|
|
|
|
|
|
|
|
|
Expected top-level shape:
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"salon_name": "Lumina Hair Studio & Spa",
|
|
|
|
|
"date": "2026-07-28",
|
|
|
|
|
"business_hours": {"open": "09:00", "close": "18:00"},
|
|
|
|
|
"staff": [{"name": "Claire Bennett", "role": "owner-stylist"}],
|
|
|
|
|
"appointments": [
|
|
|
|
|
{
|
|
|
|
|
"id": "APT-001",
|
|
|
|
|
"start": "2026-07-28T09:00:00",
|
|
|
|
|
"end": "2026-07-28T10:00:00",
|
|
|
|
|
"client_name": "Elena Rossi",
|
|
|
|
|
"service_name": "Balayage + Cut",
|
|
|
|
|
"staff_name": "Claire Bennett",
|
|
|
|
|
"status": "confirmed",
|
|
|
|
|
"needs_confirmation": false,
|
|
|
|
|
"notes": "Formula: 9.1 + 0-45 gloss"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
fixture_path: Path to a JSON fixture file.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
List of Appointment domain objects.
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
FileNotFoundError: If the fixture file does not exist.
|
|
|
|
|
ValueError: If the fixture JSON is malformed.
|
|
|
|
|
"""
|
|
|
|
|
path = pathlib.Path(fixture_path)
|
|
|
|
|
if not path.exists():
|
|
|
|
|
raise FileNotFoundError(f"Fixture not found: {path}")
|
|
|
|
|
|
|
|
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
|
|
|
|
appointments: list[Appointment] = []
|
|
|
|
|
for apt_raw in raw.get("appointments", []):
|
|
|
|
|
appointments.append(Appointment(
|
|
|
|
|
appointment_id=apt_raw["id"],
|
|
|
|
|
start_time=_parse_datetime(apt_raw["start"]),
|
|
|
|
|
end_time=_parse_datetime(apt_raw["end"]),
|
|
|
|
|
client_name=apt_raw["client_name"],
|
|
|
|
|
service_name=apt_raw["service_name"],
|
|
|
|
|
staff_name=apt_raw["staff_name"],
|
|
|
|
|
status=_parse_status(apt_raw.get("status", "pending")),
|
|
|
|
|
notes=apt_raw.get("notes", ""),
|
|
|
|
|
needs_confirmation=apt_raw.get("needs_confirmation", False),
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
return appointments
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_fixture_metadata(fixture_path: str | pathlib.Path) -> dict[str, Any]:
|
|
|
|
|
"""Load non-appointment metadata from a fixture file.
|
|
|
|
|
|
|
|
|
|
Returns salon_name, date, business_hours, staff list, etc.
|
|
|
|
|
"""
|
|
|
|
|
path = pathlib.Path(fixture_path)
|
|
|
|
|
if not path.exists():
|
|
|
|
|
raise FileNotFoundError(f"Fixture not found: {path}")
|
|
|
|
|
|
|
|
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
return {
|
|
|
|
|
"salon_name": raw.get("salon_name", "Unknown Salon"),
|
|
|
|
|
"date": raw.get("date", ""),
|
|
|
|
|
"business_hours": raw.get("business_hours", {}),
|
|
|
|
|
"staff": raw.get("staff", []),
|
|
|
|
|
}
|