This commit is contained in:
2026-04-19 12:59:18 +02:00
parent c01876ad81
commit 7065cb3945
8 changed files with 347 additions and 25 deletions
+176 -1
View File
@@ -1,13 +1,21 @@
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 ────────────────────────────────────────────────────────────────────
@@ -121,6 +129,21 @@ func (s *Store) Delete(id string) error {
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 (?,?,?,?,?)`,
@@ -190,10 +213,162 @@ func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) TriggerSync(w http.ResponseWriter, r *http.Request) {
// Le service iCal expose un endpoint pour forcer la sync
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)