Files
matosbox/backend/internal/handlers/imports.go

472 lines
12 KiB
Go

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")
}