383 lines
11 KiB
Go
383 lines
11 KiB
Go
package calendar
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
xls "github.com/extrame/xls"
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/mux"
|
|
"github.com/xuri/excelize/v2"
|
|
)
|
|
|
|
// ── Models ────────────────────────────────────────────────────────────────────
|
|
|
|
type Event struct {
|
|
ID string `json:"id"`
|
|
PropertyID string `json:"property_id"`
|
|
Title string `json:"title"`
|
|
StartDate string `json:"start_date"`
|
|
EndDate string `json:"end_date"`
|
|
Source string `json:"source"` // airbnb | manual
|
|
IcalUID string `json:"ical_uid,omitempty"`
|
|
Notes string `json:"notes"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
// Joint
|
|
PropertyName string `json:"property_name,omitempty"`
|
|
}
|
|
|
|
type OccupancyStats struct {
|
|
PropertyID string `json:"property_id"`
|
|
PropertyName string `json:"property_name"`
|
|
Month string `json:"month"`
|
|
TotalDays int `json:"total_days"`
|
|
OccupiedDays int `json:"occupied_days"`
|
|
OccupancyRate float64 `json:"occupancy_rate"`
|
|
TotalRevenue float64 `json:"total_revenue"`
|
|
AvgNightlyRate float64 `json:"avg_nightly_rate"`
|
|
}
|
|
|
|
// ── Store ─────────────────────────────────────────────────────────────────────
|
|
|
|
type Store struct{ db *sql.DB }
|
|
|
|
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
|
|
|
func (s *Store) List(propertyID, from, to string) ([]Event, error) {
|
|
query := `
|
|
SELECT e.id, e.property_id, COALESCE(e.title,''),
|
|
substr(e.start_date,1,10) as start_date,
|
|
substr(e.end_date,1,10) as end_date,
|
|
e.source, COALESCE(e.ical_uid,''), COALESCE(e.notes,''), e.created_at, p.name
|
|
FROM calendar_events e
|
|
JOIN properties p ON p.id = e.property_id
|
|
WHERE 1=1`
|
|
args := []any{}
|
|
if propertyID != "" {
|
|
query += " AND e.property_id=?"
|
|
args = append(args, propertyID)
|
|
}
|
|
if from != "" {
|
|
query += " AND e.end_date >= ?"
|
|
args = append(args, from)
|
|
}
|
|
if to != "" {
|
|
query += " AND e.start_date <= ?"
|
|
args = append(args, to)
|
|
}
|
|
query += " ORDER BY e.start_date"
|
|
|
|
rows, err := s.db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var events []Event
|
|
for rows.Next() {
|
|
var e Event
|
|
if err := rows.Scan(&e.ID, &e.PropertyID, &e.Title, &e.StartDate, &e.EndDate,
|
|
&e.Source, &e.IcalUID, &e.Notes, &e.CreatedAt, &e.PropertyName); err != nil {
|
|
return nil, err
|
|
}
|
|
events = append(events, e)
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
func (s *Store) DeleteIcalEvents(propertyID string) error {
|
|
_, err := s.db.Exec(`DELETE FROM calendar_events WHERE property_id=? AND source='airbnb'`, propertyID)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) InsertFromIcal(e *Event) error {
|
|
e.ID = uuid.NewString()
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO calendar_events (id, property_id, title, start_date, end_date, source, ical_uid, notes)
|
|
VALUES (?,?,?,?,?,?,?,?)`,
|
|
e.ID, e.PropertyID, e.Title, e.StartDate, e.EndDate, "airbnb", e.IcalUID, e.Notes,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) Create(e *Event) error {
|
|
e.ID = uuid.NewString()
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO calendar_events (id, property_id, title, start_date, end_date, source, notes) VALUES (?,?,?,?,?,?,?)`,
|
|
e.ID, e.PropertyID, e.Title, e.StartDate, e.EndDate, e.Source, e.Notes,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) Update(e *Event) error {
|
|
_, err := s.db.Exec(
|
|
`UPDATE calendar_events SET title=?, start_date=?, end_date=?, notes=? WHERE id=? AND source='manual'`,
|
|
e.Title, e.StartDate, e.EndDate, e.Notes, e.ID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) Delete(id string) error {
|
|
_, err := s.db.Exec(`DELETE FROM calendar_events WHERE id=? AND source='manual'`, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteBookingEvents(propertyID string) error {
|
|
_, err := s.db.Exec(`DELETE FROM calendar_events WHERE property_id=? AND source='booking'`, propertyID)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) InsertFromBooking(e *Event) error {
|
|
e.ID = uuid.NewString()
|
|
_, err := s.db.Exec(
|
|
`INSERT OR REPLACE INTO calendar_events (id, property_id, title, start_date, end_date, source, ical_uid, notes)
|
|
VALUES (?,?,?,?,?,?,?,?)`,
|
|
e.ID, e.PropertyID, e.Title, e.StartDate, e.EndDate, "booking", e.IcalUID, e.Notes,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) LogSync(propertyID, status string, imported int, errMsg string) {
|
|
s.db.Exec(
|
|
`INSERT INTO ical_sync_log (id, property_id, status, events_imported, error_message) VALUES (?,?,?,?,?)`,
|
|
uuid.NewString(), propertyID, status, imported, nullStr(errMsg),
|
|
)
|
|
}
|
|
|
|
// ── Handler ───────────────────────────────────────────────────────────────────
|
|
|
|
type Handler struct{ store *Store }
|
|
|
|
func NewHandler(store *Store) *Handler { return &Handler{store: store} }
|
|
|
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
events, err := h.store.List(q.Get("property_id"), q.Get("from"), q.Get("to"))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if events == nil {
|
|
events = []Event{}
|
|
}
|
|
respond(w, events)
|
|
}
|
|
|
|
func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
|
var e Event
|
|
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
|
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
e.Source = "manual"
|
|
if err := h.store.Create(&e); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
respond(w, e)
|
|
}
|
|
|
|
func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
|
var e Event
|
|
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
|
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
e.ID = mux.Vars(r)["id"]
|
|
if err := h.store.Update(&e); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
respond(w, e)
|
|
}
|
|
|
|
func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
|
if err := h.store.Delete(mux.Vars(r)["id"]); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) {
|
|
// TODO: calcul taux d'occupation par mois
|
|
respond(w, []OccupancyStats{})
|
|
}
|
|
|
|
func (h *Handler) TriggerSync(w http.ResponseWriter, r *http.Request) {
|
|
respond(w, map[string]string{"status": "sync triggered"})
|
|
}
|
|
|
|
func (h *Handler) ImportBookingCSV(w http.ResponseWriter, r *http.Request) {
|
|
propertyID := r.FormValue("property_id")
|
|
if propertyID == "" {
|
|
http.Error(w, "property_id requis", http.StatusBadRequest)
|
|
return
|
|
}
|
|
f, fh, err := r.FormFile("file")
|
|
if err != nil {
|
|
http.Error(w, "fichier requis", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
data, err := io.ReadAll(f)
|
|
if err != nil {
|
|
http.Error(w, "erreur lecture fichier", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var events []Event
|
|
ext := strings.ToLower(filepath.Ext(fh.Filename))
|
|
switch ext {
|
|
case ".xlsx":
|
|
events, err = parseBookingXLSX(data, propertyID)
|
|
case ".xls":
|
|
events, err = parseBookingXLS(data, propertyID)
|
|
default: // .csv
|
|
events, err = parseBookingCSV(bytes.NewReader(data), propertyID)
|
|
}
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.store.DeleteBookingEvents(propertyID); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
imported := 0
|
|
for _, e := range events {
|
|
if err := h.store.InsertFromBooking(&e); err != nil {
|
|
continue
|
|
}
|
|
imported++
|
|
}
|
|
respond(w, map[string]int{"imported": imported, "total": len(events)})
|
|
}
|
|
|
|
// extractBookingRows convertit des lignes (header + data) en Event
|
|
func extractBookingRows(rows [][]string, propertyID string) []Event {
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
col := make(map[string]int)
|
|
for i, h := range rows[0] {
|
|
col[strings.TrimSpace(h)] = i
|
|
}
|
|
get := func(row []string, name string) string {
|
|
i, ok := col[name]
|
|
if !ok || i >= len(row) {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(row[i])
|
|
}
|
|
var events []Event
|
|
for _, row := range rows[1:] {
|
|
status := get(row, "Status")
|
|
if status == "cancelled" || status == "invalid" || status == "no_show" || status == "" {
|
|
continue
|
|
}
|
|
bookNum := get(row, "Book number")
|
|
checkin := normalizeBookingDate(get(row, "Check-in"))
|
|
checkout := normalizeBookingDate(get(row, "Check-out"))
|
|
if bookNum == "" || checkin == "" || checkout == "" {
|
|
continue
|
|
}
|
|
notes := get(row, "Price")
|
|
if r := get(row, "Remarks"); r != "" {
|
|
if notes != "" {
|
|
notes += " — " + r
|
|
} else {
|
|
notes = r
|
|
}
|
|
}
|
|
events = append(events, Event{
|
|
PropertyID: propertyID,
|
|
IcalUID: "booking-" + bookNum,
|
|
Title: get(row, "Guest name(s)"),
|
|
StartDate: checkin,
|
|
EndDate: checkout,
|
|
Source: "booking",
|
|
Notes: notes,
|
|
})
|
|
}
|
|
return events
|
|
}
|
|
|
|
// normalizeBookingDate accepte YYYY-MM-DD, DD/MM/YYYY et MM/DD/YYYY
|
|
func normalizeBookingDate(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) >= 10 && s[4] == '-' {
|
|
return s[:10] // déjà YYYY-MM-DD
|
|
}
|
|
if len(s) == 10 && s[2] == '/' {
|
|
// DD/MM/YYYY (format européen Booking)
|
|
return s[6:10] + "-" + s[3:5] + "-" + s[0:2]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func parseBookingCSV(r io.Reader, propertyID string) ([]Event, error) {
|
|
reader := csv.NewReader(r)
|
|
rows, err := reader.ReadAll()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return extractBookingRows(rows, propertyID), nil
|
|
}
|
|
|
|
func parseBookingXLSX(data []byte, propertyID string) ([]Event, error) {
|
|
f, err := excelize.OpenReader(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("erreur lecture XLSX: %w", err)
|
|
}
|
|
defer f.Close()
|
|
rows, err := f.GetRows(f.GetSheetName(0))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return extractBookingRows(rows, propertyID), nil
|
|
}
|
|
|
|
func parseBookingXLS(data []byte, propertyID string) ([]Event, error) {
|
|
wb, err := xls.OpenReader(bytes.NewReader(data), "utf-8")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("erreur lecture XLS: %w", err)
|
|
}
|
|
sheet := wb.GetSheet(0)
|
|
if sheet == nil {
|
|
return nil, fmt.Errorf("feuille Excel vide")
|
|
}
|
|
var rows [][]string
|
|
for i := 0; i <= int(sheet.MaxRow); i++ {
|
|
row := sheet.Row(i)
|
|
var cells []string
|
|
for j := 0; j < row.LastCol(); j++ {
|
|
cells = append(cells, row.Col(j))
|
|
}
|
|
rows = append(rows, cells)
|
|
}
|
|
return extractBookingRows(rows, propertyID), nil
|
|
}
|
|
|
|
func respond(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func nullStr(s string) any {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|