From 7065cb394564f26d00d2ee5be1b3a6c451cef900 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 19 Apr 2026 12:59:18 +0200 Subject: [PATCH] up --- .gitignore | 1 + cmd/server/main.go | 1 + frontend/src/routes/+page.svelte | 2 +- frontend/src/routes/calendar/+page.svelte | 113 +++++++++++--- go.mod | 12 +- go.sum | 22 +++ internal/calendar/calendar.go | 177 +++++++++++++++++++++- internal/db/db.go | 44 ++++++ 8 files changed, 347 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index f9c472c..fe50ef9 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ frontend/.env.*.local # ── Web embed (build artifact) ──────────────────────────────────────────────── web/dist/ +web/build/ # ── Environnement ───────────────────────────────────────────────────────────── .env diff --git a/cmd/server/main.go b/cmd/server/main.go index 53625dc..ace9c5c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -165,6 +165,7 @@ func main() { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(results) }).Methods("POST") + protected.HandleFunc("/calendar/import-booking", calendarHandler.ImportBookingCSV).Methods("POST") protected.HandleFunc("/calendar/{id}", calendarHandler.UpdateEvent).Methods("PUT") protected.HandleFunc("/calendar/{id}", calendarHandler.DeleteEvent).Methods("DELETE") diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 00d6b63..c237519 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -207,7 +207,7 @@ let occupied = 0; for (let d = 1; d <= days; d++) { const ds = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`; - if (calEvents.some(e => ds >= e.start_date && ds < e.end_date)) occupied++; + if (calEvents.some(e => ds >= e.start_date && (e.source === 'airbnb' ? ds < e.end_date : ds <= e.end_date))) occupied++; } return days > 0 ? Math.round((occupied/days)*100) : 0; })(); diff --git a/frontend/src/routes/calendar/+page.svelte b/frontend/src/routes/calendar/+page.svelte index f75196f..7bf754c 100644 --- a/frontend/src/routes/calendar/+page.svelte +++ b/frontend/src/routes/calendar/+page.svelte @@ -1,11 +1,10 @@
@@ -144,6 +164,10 @@ class="flex items-center gap-2 px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"> Synchroniser + +
+
+ {#if bookingError}

{bookingError}

{/if} +
+ + +
+
+ + 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,