Ajouter une période d'occupation
@@ -238,27 +267,27 @@
{#if error}
{error}
{/if}
-
-
-
- Intitulé
+
@@ -273,3 +302,43 @@
{/if}
+
+
+{#if showBookingImport}
+
+
showBookingImport = false}>
+
+
+
Importer réservations Booking.com
+
+
+
+ {#if bookingError}
{bookingError}
{/if}
+
+
+
+
+ {#each properties as p}{/each}
+
+
+
+
+
bookingFile = e.target.files[0]}
+ class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-medium file:bg-teal-50 file:text-teal-700 hover:file:bg-teal-100 dark:file:bg-teal-900/30 dark:file:text-teal-300"/>
+
Extranet Booking.com → Réservations → Exporter → CSV
+
+
Les réservations annulées sont ignorées. L'import remplace les données Booking.com précédentes pour ce bien.
+
+
+
+
+
+
+
+{/if}
diff --git a/go.mod b/go.mod
index 4dadc80..67e78a8 100644
--- a/go.mod
+++ b/go.mod
@@ -6,16 +6,26 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
- golang.org/x/crypto v0.24.0
+ golang.org/x/crypto v0.48.0
modernc.org/sqlite v1.47.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
+ github.com/extrame/xls v0.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/richardlehane/mscfb v1.0.6 // indirect
+ github.com/richardlehane/msoleps v1.0.6 // indirect
+ github.com/tiendc/go-deepcopy v1.7.2 // indirect
+ github.com/xuri/efp v0.0.1 // indirect
+ github.com/xuri/excelize/v2 v2.10.1 // indirect
+ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
+ golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.42.0 // indirect
+ golang.org/x/text v0.34.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/go.sum b/go.sum
index da934e6..e2747a4 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,9 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 h1:n+nk0bNe2+gVbRI8WRbLFVwwcBQ0rr5p+gzkKb6ol8c=
+github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8IdQ1/R2uIRBsNfnPnwsYE9YYI5WyY1zw=
+github.com/extrame/xls v0.0.1 h1:jI7L/o3z73TyyENPopsLS/Jlekm3nF1a/kF5hKBvy/k=
+github.com/extrame/xls v0.0.1/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@@ -13,11 +17,29 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
+github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
+github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
+github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
+github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
+github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
+github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
+github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
+github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
+github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go
index 6feebbc..a63a22e 100644
--- a/internal/calendar/calendar.go
+++ b/internal/calendar/calendar.go
@@ -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)
diff --git a/internal/db/db.go b/internal/db/db.go
index 6ffb1b1..8bfdf18 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"log"
+ "strings"
_ "modernc.org/sqlite"
)
@@ -36,10 +37,53 @@ func Migrate(db *sql.DB) error {
return err
}
}
+ if err := migrateAddBookingSource(db); err != nil {
+ log.Printf("⚠ migration booking source: %v", err)
+ }
log.Println("✓ Migrations appliquées")
return nil
}
+// migrateAddBookingSource ajoute 'booking' aux sources autorisées de calendar_events.
+// Vérifie d'abord le DDL pour ne s'exécuter qu'une seule fois.
+func migrateAddBookingSource(db *sql.DB) error {
+ var ddl string
+ if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE name='calendar_events' AND type='table'`).Scan(&ddl); err != nil {
+ return nil // table absente, rien à faire
+ }
+ if strings.Contains(ddl, "'booking'") {
+ return nil // déjà migré
+ }
+ tx, err := db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ for _, step := range []string{
+ `CREATE TABLE calendar_events_new (
+ id TEXT PRIMARY KEY,
+ property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
+ title TEXT,
+ start_date DATE NOT NULL,
+ end_date DATE NOT NULL,
+ source TEXT NOT NULL CHECK(source IN ('airbnb','manual','booking')),
+ ical_uid TEXT,
+ notes TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(property_id, ical_uid)
+ )`,
+ `INSERT INTO calendar_events_new SELECT * FROM calendar_events`,
+ `DROP TABLE calendar_events`,
+ `ALTER TABLE calendar_events_new RENAME TO calendar_events`,
+ } {
+ if _, err := tx.Exec(step); err != nil {
+ return err
+ }
+ }
+ log.Println("✓ Migration calendar_events: source 'booking' ajouté")
+ return tx.Commit()
+}
+
const sqlCreateUsers = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,