package handlers import ( "bytes" "context" "encoding/csv" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" "time" "gitea.maison43.duckdns.org/gilles/matosbox/internal/data/ent/objet" "github.com/gin-gonic/gin" ) type importAchat struct { Nom string `json:"nom"` Description string `json:"description,omitempty"` Quantite int `json:"quantite,omitempty"` PrixAchat float64 `json:"prix_achat,omitempty"` DateAchat string `json:"date_achat,omitempty"` Boutique string `json:"boutique,omitempty"` NumeroSerie string `json:"numero_serie,omitempty"` NumeroModele string `json:"numero_modele,omitempty"` Fabricant string `json:"fabricant,omitempty"` Caracteristiques map[string]any `json:"caracteristiques,omitempty"` } type importAchatPayload struct { Boutique string `json:"boutique"` Achats []importAchat `json:"achats"` } type importErreur struct { Ligne int `json:"ligne,omitempty"` Message string `json:"message"` Nom string `json:"nom,omitempty"` } type importResultat struct { Importes int `json:"importes"` Crees int `json:"crees"` Doublons int `json:"doublons"` Erreurs []importErreur `json:"erreurs"` } // @Summary Importer des achats (CSV/JSON) // @Tags Imports // @Accept multipart/form-data,json // @Produce json // @Param boutique formData string false "Boutique (amazon, aliexpress, generic)" // @Param fichier formData file false "Fichier CSV ou JSON" // @Success 200 {object} importResultat // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /imports/achats [post] func (h *Handler) ImportAchats(c *gin.Context) { contentType := c.GetHeader("Content-Type") if strings.Contains(contentType, "application/json") { h.handleImportJSON(c) return } h.handleImportMultipart(c) } func (h *Handler) handleImportJSON(c *gin.Context) { raw, err := io.ReadAll(c.Request.Body) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"erreur": "lecture JSON impossible"}) return } raw = bytes.TrimSpace(raw) if len(raw) == 0 { c.JSON(http.StatusBadRequest, gin.H{"erreur": "corps JSON vide"}) return } var payload importAchatPayload if err := json.Unmarshal(raw, &payload); err == nil && len(payload.Achats) > 0 { if payload.Boutique != "" { for i := range payload.Achats { if payload.Achats[i].Boutique == "" { payload.Achats[i].Boutique = payload.Boutique } } } result := h.persistImportAchats(c, payload.Achats) c.JSON(http.StatusOK, result) return } var achats []importAchat if err := json.Unmarshal(raw, &achats); err != nil { c.JSON(http.StatusBadRequest, gin.H{"erreur": "format JSON invalide"}) return } result := h.persistImportAchats(c, achats) c.JSON(http.StatusOK, result) } func (h *Handler) handleImportMultipart(c *gin.Context) { boutique := strings.TrimSpace(c.PostForm("boutique")) file, header, err := c.Request.FormFile("fichier") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"erreur": "fichier manquant"}) return } defer file.Close() data, err := io.ReadAll(file) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"erreur": "lecture fichier impossible"}) return } if len(data) == 0 { c.JSON(http.StatusBadRequest, gin.H{"erreur": "fichier vide"}) return } extension := strings.ToLower(header.Filename) if strings.HasSuffix(extension, ".json") { var payload importAchatPayload if err := json.Unmarshal(data, &payload); err == nil && len(payload.Achats) > 0 { if boutique == "" { boutique = payload.Boutique } if boutique != "" { for i := range payload.Achats { if payload.Achats[i].Boutique == "" { payload.Achats[i].Boutique = boutique } } } result := h.persistImportAchats(c, payload.Achats) c.JSON(http.StatusOK, result) return } var achats []importAchat if err := json.Unmarshal(data, &achats); err != nil { c.JSON(http.StatusBadRequest, gin.H{"erreur": "JSON invalide"}) return } if boutique != "" { for i := range achats { if achats[i].Boutique == "" { achats[i].Boutique = boutique } } } result := h.persistImportAchats(c, achats) c.JSON(http.StatusOK, result) return } achats, erreurs := parseCSVImports(data, boutique) result := h.persistImportAchats(c, achats) result.Erreurs = append(result.Erreurs, erreurs...) c.JSON(http.StatusOK, result) } func (h *Handler) persistImportAchats(c *gin.Context, achats []importAchat) importResultat { result := importResultat{Importes: len(achats)} ctx := c.Request.Context() for index, achat := range achats { line := index + 1 achat.Nom = strings.TrimSpace(achat.Nom) if achat.Nom == "" { result.Erreurs = append(result.Erreurs, importErreur{ Ligne: line, Message: "nom manquant", }) continue } if achat.Quantite <= 0 { achat.Quantite = 1 } var parsedDate *time.Time if achat.DateAchat != "" { value, err := parseImportDate(achat.DateAchat) if err != nil { result.Erreurs = append(result.Erreurs, importErreur{ Ligne: line, Message: "date invalide", Nom: achat.Nom, }) continue } parsedDate = &value } if achat.PrixAchat < 0 { result.Erreurs = append(result.Erreurs, importErreur{ Ligne: line, Message: "prix invalide", Nom: achat.Nom, }) continue } duplicate, err := h.isDuplicateAchat(ctx, achat, parsedDate) if err != nil { result.Erreurs = append(result.Erreurs, importErreur{ Ligne: line, Message: "verification doublon impossible", Nom: achat.Nom, }) continue } if duplicate { result.Doublons++ continue } create := h.client.Objet.Create(). SetNom(achat.Nom). SetQuantite(achat.Quantite) if achat.Description != "" { create.SetDescription(achat.Description) } if achat.PrixAchat > 0 { create.SetPrixAchat(achat.PrixAchat) } if parsedDate != nil { create.SetDateAchat(*parsedDate) } if achat.Boutique != "" { create.SetBoutique(achat.Boutique) } if achat.NumeroSerie != "" { create.SetNumeroSerie(achat.NumeroSerie) } if achat.NumeroModele != "" { create.SetNumeroModele(achat.NumeroModele) } if achat.Fabricant != "" { create.SetFabricant(achat.Fabricant) } if len(achat.Caracteristiques) > 0 { create.SetCaracteristiques(achat.Caracteristiques) } if _, err := create.Save(ctx); err != nil { result.Erreurs = append(result.Erreurs, importErreur{ Ligne: line, Message: "creation impossible", Nom: achat.Nom, }) continue } result.Crees++ } return result } func (h *Handler) isDuplicateAchat(ctx context.Context, achat importAchat, date *time.Time) (bool, error) { query := h.client.Objet.Query().Where(objet.NomEQ(achat.Nom)) if achat.Boutique != "" { query = query.Where(objet.BoutiqueEQ(achat.Boutique)) } if date != nil { query = query.Where(objet.DateAchatEQ(*date)) } if achat.PrixAchat > 0 { query = query.Where(objet.PrixAchatEQ(achat.PrixAchat)) } count, err := query.Count(ctx) return count > 0, err } func parseCSVImports(data []byte, boutique string) ([]importAchat, []importErreur) { separator := detectCSVSeparator(data) reader := csv.NewReader(bytes.NewReader(data)) reader.Comma = separator reader.FieldsPerRecord = -1 reader.TrimLeadingSpace = true header, err := reader.Read() if err != nil { return nil, []importErreur{{Message: "lecture entete impossible"}} } headerIndex := mapCSVHeader(header, boutique) if len(headerIndex) == 0 { return nil, []importErreur{{Message: "entete CSV non reconnue"}} } var achats []importAchat var erreurs []importErreur line := 1 for { record, err := reader.Read() if errors.Is(err, io.EOF) { break } line++ if err != nil { erreurs = append(erreurs, importErreur{Ligne: line, Message: "ligne invalide"}) continue } achat, err := achatFromCSV(record, headerIndex, boutique) if err != nil { erreurs = append(erreurs, importErreur{Ligne: line, Message: err.Error()}) continue } achats = append(achats, achat) } return achats, erreurs } func detectCSVSeparator(data []byte) rune { firstLine := string(data) if idx := strings.Index(firstLine, "\n"); idx > 0 { firstLine = firstLine[:idx] } if strings.Count(firstLine, ";") > strings.Count(firstLine, ",") { return ';' } return ',' } func mapCSVHeader(header []string, boutique string) map[string]int { index := make(map[string]int) for i, col := range header { key := normalizeCSVHeader(col) switch key { case "item name", "product name", "item", "nom", "designation": index["nom"] = i case "quantity", "qty", "quantite": index["quantite"] = i case "item price", "unit price", "price", "prix", "item total", "total price", "total": if _, ok := index["prix"]; !ok { index["prix"] = i } case "order date", "purchase date", "date", "date achat", "order time": index["date"] = i case "order id", "order number", "commande", "numero commande": index["order_id"] = i case "brand", "fabricant", "manufacturer": index["fabricant"] = i case "store name", "seller", "boutique": index["boutique"] = i } } if _, ok := index["nom"]; !ok { return nil } if boutique == "" { if _, ok := index["boutique"]; !ok { index["boutique"] = -1 } } return index } func normalizeCSVHeader(value string) string { value = strings.TrimSpace(strings.ToLower(value)) value = strings.ReplaceAll(value, "\ufeff", "") return value } func achatFromCSV(record []string, index map[string]int, fallbackBoutique string) (importAchat, error) { nom := csvValue(record, index["nom"]) if nom == "" { return importAchat{}, fmt.Errorf("nom manquant") } quantite := parseIntOrDefault(csvValue(record, index["quantite"]), 1) prix, err := parsePrice(csvValue(record, index["prix"])) if err != nil && csvValue(record, index["prix"]) != "" { return importAchat{}, fmt.Errorf("prix invalide") } dateValue := csvValue(record, index["date"]) if dateValue != "" { if _, err := parseImportDate(dateValue); err != nil { return importAchat{}, fmt.Errorf("date invalide") } } boutique := fallbackBoutique if boutique == "" { boutique = csvValue(record, index["boutique"]) } achat := importAchat{ Nom: nom, Quantite: quantite, PrixAchat: prix, DateAchat: dateValue, Boutique: boutique, } orderID := csvValue(record, index["order_id"]) if orderID != "" { achat.Caracteristiques = map[string]any{ "order_id": orderID, "source": boutique, } } if fabricant := csvValue(record, index["fabricant"]); fabricant != "" { achat.Fabricant = fabricant } return achat, nil } func csvValue(record []string, idx int) string { if idx < 0 || idx >= len(record) { return "" } return strings.TrimSpace(record[idx]) } func parseIntOrDefault(value string, fallback int) int { if value == "" { return fallback } parsed, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil || parsed <= 0 { return fallback } return parsed } func parsePrice(value string) (float64, error) { value = strings.TrimSpace(value) if value == "" { return 0, nil } value = strings.ReplaceAll(value, "€", "") value = strings.ReplaceAll(value, "$", "") value = strings.ReplaceAll(value, "EUR", "") value = strings.ReplaceAll(value, "USD", "") value = strings.ReplaceAll(value, " ", "") value = strings.ReplaceAll(value, ",", ".") value = strings.TrimSpace(value) if value == "" { return 0, nil } return strconv.ParseFloat(value, 64) } func parseImportDate(value string) (time.Time, error) { value = strings.TrimSpace(value) formats := []string{ time.RFC3339, "2006-01-02", "02/01/2006", "01/02/2006", "2006/01/02", "02-01-2006", "01-02-2006", } for _, format := range formats { if parsed, err := time.Parse(format, value); err == nil { return parsed, nil } } return time.Time{}, fmt.Errorf("format date inconnu") }