diff --git a/backend/gestion/db/db_commands.go b/backend/gestion/db/db_commands.go index 80a80308..16ff7ceb 100644 --- a/backend/gestion/db/db_commands.go +++ b/backend/gestion/db/db_commands.go @@ -892,13 +892,25 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str return fmt.Errorf("commande non trouvée") } - if cmd.Status != "livre" { - return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status) + // Historiquement restreint à "livre" seul (cf. commentaire de + // TestApproveDeliveryAtomicByStaff dans les tests) — élargi après un + // incident réel où une vérification GPS en amont (coordonnées de + // destination périmées après un changement d'adresse, cf. + // updateCommandDestinationCoords) a bloqué la transition du livreur + // vers "livre" : la commande restait alors coincée, sans qu'admin ni + // cabine ne puissent confirmer la réception. On accepte désormais tout + // statut non terminal ("arrived" inclus), à l'image de + // ValidateDeliveryAtomic (qui accepte déjà pending/assigned/en_route), + // pour que le staff garde toujours un moyen de débloquer une commande + // légitime indépendamment d'un blocage en amont côté livreur. + validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre"} + if !slices.Contains(validStatuses, cmd.Status) { + return fmt.Errorf("statut invalide pour confirmation de réception: %s", cmd.Status) } result := tx.Exec(` UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP - WHERE id = ? AND status = 'livre'`, commandID) + WHERE id = ? AND status = ?`, commandID, cmd.Status) if result.Error != nil { return fmt.Errorf("erreur mise à jour statut: %w", result.Error) } diff --git a/backend/gestion/db/db_stat.go b/backend/gestion/db/db_stat.go index f02e09ab..a0d52e4d 100644 --- a/backend/gestion/db/db_stat.go +++ b/backend/gestion/db/db_stat.go @@ -145,11 +145,19 @@ type DailyMonthStatRow struct { Quantity float64 } -func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetAt time.Time) error { +// StatsByDayForMonth applique resetCommandes au comptage (count) et à la +// quantité (quantity, qui reflète le volume de commandes comme count), et +// resetRevenus au revenu (revenue) — chaque métrique doit respecter la même +// section de reset que son équivalent dans le résumé global (TotalOrders / +// TotalRevenue), sous peine d'afficher des chiffres incohérents entre eux +// après une réinitialisation partielle. +func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetCommandes time.Time, resetRevenus time.Time) error { start := time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location()) end := start.AddDate(0, 1, 0) - where, whereArgs := statusFilterClause("status != 'cancelled'", resetAt, "created_at") + whereCount, argsCount := statusFilterClause("status != 'cancelled'", resetCommandes, "created_at") + whereRevenue, argsRevenue := statusFilterClause("status = 'approved'", resetRevenus, "created_at") + whereQuantity, argsQuantity := statusFilterClause("c.status != 'cancelled'", resetCommandes, "c.created_at") query := ` SELECT @@ -161,7 +169,7 @@ func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time SELECT DATE(created_at) AS day, COUNT(*) AS count FROM commandes WHERE created_at >= ? AND created_at < ? - AND ` + where + ` + AND ` + whereCount + ` GROUP BY DATE(created_at) ) d LEFT JOIN ( @@ -169,7 +177,7 @@ func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue FROM commandes WHERE created_at >= ? AND created_at < ? - AND status = 'approved' + AND ` + whereRevenue + ` GROUP BY DATE(created_at) ) rv ON rv.day = d.day LEFT JOIN ( @@ -177,18 +185,20 @@ func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time FROM commandes c JOIN command_items ci ON ci.command_id = c.id WHERE c.created_at >= ? AND c.created_at < ? - AND c.status != 'cancelled' + AND ` + whereQuantity + ` GROUP BY DATE(c.created_at) ) qt ON qt.day = d.day ORDER BY d.day ` - // Ordre des "?" dans la requête : (start, end, [reset]) pour le bloc "d", - // puis (start, end) pour "rv", puis (start, end) pour "qt". + // Ordre des "?" dans la requête : (start, end, [resetCommandes]) pour "d", + // puis (start, end, [resetRevenus]) pour "rv", puis (start, end, [resetCommandes]) pour "qt". args := []interface{}{start, end} - args = append(args, whereArgs...) + args = append(args, argsCount...) args = append(args, start, end) + args = append(args, argsRevenue...) args = append(args, start, end) + args = append(args, argsQuantity...) return d.GDB.Raw(query, args...).Scan(rows).Error } diff --git a/backend/gestion/handlers/commands.go b/backend/gestion/handlers/commands.go index 62932761..38c4dbc4 100644 --- a/backend/gestion/handlers/commands.go +++ b/backend/gestion/handlers/commands.go @@ -73,8 +73,47 @@ func validateAddress(address string) error { return nil } +// updateCommandDestinationCoords regéocode l'adresse et met à jour +// dest_latitude/dest_longitude après tout changement d'adresse de livraison. +// Sans cet appel, ces coordonnées restent celles de l'ANCIENNE adresse +// (géocodées une seule fois à l'assignation) : la vérification GPS de +// handlers/deleviry.go compare alors la position réelle du livreur à un point +// périmé et peut refuser à tort une validation "trop loin de la destination" +// alors que le livreur est bien arrivé à la nouvelle adresse. En cas d'échec +// de géocodage, on réinitialise les coordonnées plutôt que de laisser +// l'ancienne valeur périmée : le contrôle GPS est alors ignoré (comportement +// déjà prévu quand dest_latitude/dest_longitude sont absentes) au lieu de +// bloquer sur un point qui ne correspond plus à l'adresse réelle. +func updateCommandDestinationCoords(database *db.Database, geoService *services.GeoService, commandID int, address string) { + if geoService == nil || strings.TrimSpace(address) == "" { + return + } + + location, err := geoService.GeocodeAddress(address) + if err != nil || location == nil { + log.Printf("⚠️ [ADDR_GEOCODE] Échec géocodage cmd %d (%q): %v — coordonnées de destination réinitialisées", commandID, address, err) + if err := database.GDB.Exec( + `UPDATE commandes SET dest_latitude = NULL, dest_longitude = NULL WHERE id = ?`, + commandID, + ).Error; err != nil { + log.Printf("⚠️ [ADDR_GEOCODE] Erreur reset coordonnées cmd %d: %v", commandID, err) + } + return + } + + if err := database.GDB.Exec( + `UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`, + location.Latitude, location.Longitude, commandID, + ).Error; err != nil { + log.Printf("⚠️ [ADDR_GEOCODE] Erreur mise à jour coordonnées cmd %d: %v", commandID, err) + return + } + log.Printf("✅ [ADDR_GEOCODE] Coordonnées de destination mises à jour pour cmd %d", commandID) +} + func UpdateCommandAddress(c *gin.Context) { database := c.MustGet("database").(*db.Database) + geoService := c.MustGet("geoService").(*services.GeoService) userRole := c.GetString("role") if !utils.CheckRoleAdmin(c, userRole) { @@ -138,6 +177,8 @@ func UpdateCommandAddress(c *gin.Context) { return } + updateCommandDestinationCoords(database, geoService, commandID, req.DeliveryAddress) + database.AddCommandLog(commandID, "address_updated", fmt.Sprintf("Adresse mise à jour par admin %s", adminUsername), adminUsername) @@ -220,6 +261,7 @@ func ProposeAddressChange(c *gin.Context) { // POST /api/v1/commands/:id/address/respond func RespondToAddressProposal(c *gin.Context) { database := c.MustGet("database").(*db.Database) + geoService := c.MustGet("geoService").(*services.GeoService) userRole := c.GetString("role") if !utils.CheckRoleClient(c, userRole) { @@ -246,11 +288,25 @@ func RespondToAddressProposal(c *gin.Context) { return } + // La colonne proposed_address est vidée par RespondToAddressProposal dès + // qu'elle est traitée : on la lit avant l'appel pour pouvoir regéocoder la + // nouvelle adresse en cas d'acceptation. + var proposedAddress string + if req.Accepted { + if command, err := database.GetCommandByID(commandID); err == nil { + proposedAddress, _ = command["proposed_address"].(string) + } + } + if err := database.RespondToAddressProposal(commandID, clientUsername, req.Accepted); err != nil { utils.ServerErr(c, "Impossible de traiter la réponse", err) return } + if req.Accepted && proposedAddress != "" { + updateCommandDestinationCoords(database, geoService, commandID, proposedAddress) + } + action := "refusée" if req.Accepted { action = "acceptée" @@ -312,6 +368,8 @@ func UpdateOwnCommandAddress(c *gin.Context) { return } + updateCommandDestinationCoords(database, geoService, commandID, req.DeliveryAddress) + log.Printf("✅ [UPD_OWN_ADDR] Commande %d mise à jour par %s", commandID, clientUsername) c.JSON(http.StatusOK, gin.H{ "success": true, diff --git a/backend/gestion/handlers/stats.go b/backend/gestion/handlers/stats.go index cbfe6269..20fe969b 100644 --- a/backend/gestion/handlers/stats.go +++ b/backend/gestion/handlers/stats.go @@ -70,10 +70,10 @@ func GetAdminStatsByMonth(c *gin.Context) { } monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location()) - resetCmd := database.ReadResetAt("stats_reset_commandes_at") + filters := database.LoadAdminStatsFilters() var rows []db.DailyMonthStatRow - if err := database.StatsByDayForMonth(&rows, monthStart, resetCmd); err != nil { + if err := database.StatsByDayForMonth(&rows, monthStart, filters.ResetCommandes, filters.ResetRevenus); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération des statistiques mensuelles: %s", err)}) return } diff --git a/backend/gestion/tests/commands_handlers_coverage_test.go b/backend/gestion/tests/commands_handlers_coverage_test.go index 23ef2091..37ae6764 100644 --- a/backend/gestion/tests/commands_handlers_coverage_test.go +++ b/backend/gestion/tests/commands_handlers_coverage_test.go @@ -220,3 +220,162 @@ func TestGetCommandItemsWithDetails_NoItemsReturns404(t *testing.T) { t.Errorf("commande sans item doit retourner 404: got=%d", rec.Code) } } + +// ── Régression : les changements d'adresse doivent regéocoder dest_latitude/ +// dest_longitude ────────────────────────────────────────────────────────── +// +// Incident réel : ces coordonnées n'étaient géocodées qu'une seule fois, à +// l'assignation du livreur. Une correction d'adresse ultérieure ne les +// touchait pas, si bien que la vérification GPS de confirmation de livraison +// (handlers/deleviry.go) comparait la position réelle du livreur à un point +// périmé et pouvait refuser à tort une validation "trop loin de la +// destination" alors que le livreur était bien arrivé à la nouvelle adresse. +// Ces tests appellent le vrai service de géocodage (Nominatim) — sautés en +// mode -short, comme TestResolveAddress_RealNantesAddresses. + +// staleDestCoords sont volontairement celles de Paris : n'importe quelle +// adresse de test à Nantes en est assez éloignée pour distinguer un vrai +// regéocodage d'une valeur restée périmée. +const ( + staleDestLat = 48.8566 + staleDestLon = 2.3522 +) + +func seedStaleDestCoords(t *testing.T, commandID int) { + t.Helper() + if err := testDB.GDB.Exec( + `UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`, + staleDestLat, staleDestLon, commandID, + ).Error; err != nil { + t.Fatalf("seedStaleDestCoords: %v", err) + } +} + +type destCoords struct { + DestLatitude float64 `gorm:"column:dest_latitude"` + DestLongitude float64 `gorm:"column:dest_longitude"` +} + +func getDestCoords(t *testing.T, commandID int) destCoords { + t.Helper() + var c destCoords + if err := testDB.GDB.Raw( + `SELECT COALESCE(dest_latitude, 0) AS dest_latitude, COALESCE(dest_longitude, 0) AS dest_longitude + FROM commandes WHERE id = ?`, commandID, + ).Scan(&c).Error; err != nil { + t.Fatalf("getDestCoords: %v", err) + } + return c +} + +func TestUpdateCommandAddress_Handler_RegeocodesStaleDestinationCoords(t *testing.T) { + if testing.Short() { + t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short") + } + cleanupStockTestData(t) + username := newTestClient(t, "upd_addr_regeo") + productID := newTestProduct(t, "UpdAddrRegeo", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + seedStaleDestCoords(t, cmdID) + + body := []byte(`{"delivery_address":"12 rue Crebillon, 44000 Nantes"}`) + req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/commands/address", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("geoService", ensureTestGeoService()) + c.Set("username", testUserPrefix+"upd_addr_regeo_admin") + c.Set("role", "admin") + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}} + + handlers.UpdateCommandAddress(c) + if rec.Code != http.StatusOK { + t.Fatalf("UpdateCommandAddress doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + + coords := getDestCoords(t, cmdID) + if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon { + t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après changement d'adresse, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude) + } + if coords.DestLatitude == 0 || coords.DestLongitude == 0 { + t.Errorf("le géocodage de la nouvelle adresse a échoué (coordonnées à 0): got=%+v", coords) + } +} + +func TestRespondToAddressProposal_Handler_AcceptedRegeocodesStaleDestinationCoords(t *testing.T) { + if testing.Short() { + t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short") + } + cleanupStockTestData(t) + username := newTestClient(t, "respond_addr_regeo") + productID := newTestProduct(t, "RespondAddrRegeo", 10) + cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10) + seedStaleDestCoords(t, cmdID) + + if err := testDB.ProposeAddressChange(cmdID, "12 rue Crebillon, 44000 Nantes", "admin_test"); err != nil { + t.Fatalf("ProposeAddressChange: %v", err) + } + + body := []byte(`{"accepted":true}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/commands/address/respond", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("geoService", ensureTestGeoService()) + c.Set("username", username) + c.Set("role", "client") + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}} + + handlers.RespondToAddressProposal(c) + if rec.Code != http.StatusOK { + t.Fatalf("RespondToAddressProposal doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + + coords := getDestCoords(t, cmdID) + if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon { + t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après acceptation de la proposition, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude) + } + if coords.DestLatitude == 0 || coords.DestLongitude == 0 { + t.Errorf("le géocodage de l'adresse acceptée a échoué (coordonnées à 0): got=%+v", coords) + } +} + +func TestUpdateOwnCommandAddress_Handler_RegeocodesStaleDestinationCoords(t *testing.T) { + if testing.Short() { + t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short") + } + cleanupStockTestData(t) + username := newTestClient(t, "upd_own_addr_regeo") + productID := newTestProduct(t, "UpdOwnAddrRegeo", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + seedStaleDestCoords(t, cmdID) + + body := []byte(`{"delivery_address":"12 rue Crebillon, 44000 Nantes"}`) + req := httptest.NewRequest(http.MethodPut, "/api/v1/commands/address", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("geoService", ensureTestGeoService()) + c.Set("username", username) + c.Set("role", "client") + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}} + + handlers.UpdateOwnCommandAddress(c) + if rec.Code != http.StatusOK { + t.Fatalf("UpdateOwnCommandAddress doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + + coords := getDestCoords(t, cmdID) + if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon { + t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après auto-correction, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude) + } + if coords.DestLatitude == 0 || coords.DestLongitude == 0 { + t.Errorf("le géocodage de l'adresse corrigée a échoué (coordonnées à 0): got=%+v", coords) + } +} diff --git a/backend/gestion/tests/points_approval_staff_test.go b/backend/gestion/tests/points_approval_staff_test.go index 5fc107d7..3d30573f 100644 --- a/backend/gestion/tests/points_approval_staff_test.go +++ b/backend/gestion/tests/points_approval_staff_test.go @@ -10,9 +10,16 @@ import ( // ── ApproveDeliveryAtomicByStaff : confirmation de réception par admin/cabine // à la place du client. Contrairement au chemin client (ApproveDeliveryAtomic), // aucune vérification de propriétaire n'est faite ici (le staff agit au nom -// du client) — mais la contrainte de statut ('livre' uniquement) est -// identique, et la double-approbation renvoie une VRAIE erreur (pas un no-op -// silencieux comme côté client). +// du client) — et la double-approbation renvoie une VRAIE erreur (pas un +// no-op silencieux comme côté client). +// +// La contrainte de statut a été volontairement élargie (n'exige plus 'livre' +// seul) après un incident où un blocage GPS en amont (coordonnées de +// destination périmées) empêchait le livreur d'atteindre 'livre', laissant le +// staff sans recours pour confirmer une commande par ailleurs légitime — voir +// db.ApproveDeliveryAtomicByStaff. Tout statut non terminal est accepté — y +// compris "arrived", que ValidateDeliveryAtomic (validation admin en masse, +// non modifiée) continue lui de rejeter. func TestApproveDeliveryAtomicByStaff_CreditsPointsExactlyOnApproval(t *testing.T) { cleanupStockTestData(t) @@ -41,13 +48,44 @@ func TestApproveDeliveryAtomicByStaff_CreditsPointsExactlyOnApproval(t *testing. } } -func TestApproveDeliveryAtomicByStaff_RejectsNonLivreStatus_NoPointsCredited(t *testing.T) { +// Tout statut non terminal doit permettre au staff de confirmer la +// réception — y compris avant "livre" (voir le commentaire de section +// ci-dessus pour le contexte de cet élargissement volontaire). +func TestApproveDeliveryAtomicByStaff_AcceptsAnyNonTerminalStatus_CreditsPoints(t *testing.T) { cleanupStockTestData(t) setPointsPoolsSettings(t, []models.PointsPool{ {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, }) for _, status := range []string{"pending", "assigned", "en_route", "arrived"} { + t.Run(status, func(t *testing.T) { + username := newTestClient(t, "staff_accept_"+status) + productID := newTestProduct(t, "StaffAccept"+status, 20) + cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10) + + if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err != nil { + t.Fatalf("ApproveDeliveryAtomicByStaff depuis le statut %q: %v", status, err) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points depuis statut %q: got=%d want=6", status, got) + } + if got := commandStatus(t, cmdID); got != "approved" { + t.Errorf("statut final depuis %q: got=%s want=approved", status, got) + } + }) + } +} + +// Les statuts réellement terminaux (annulée, désactivée) restent, eux, +// rejetés : l'élargissement de la règle ne doit pas permettre de "confirmer +// la réception" d'une commande qui ne peut plus en avoir une. +func TestApproveDeliveryAtomicByStaff_RejectsTerminalStatus_NoPointsCredited(t *testing.T) { + cleanupStockTestData(t) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + + for _, status := range []string{"cancelled", "disabled"} { t.Run(status, func(t *testing.T) { username := newTestClient(t, "staff_reject_"+status) productID := newTestProduct(t, "StaffReject"+status, 20) diff --git a/backend/gestion/tests/stats_revenue_bugs_test.go b/backend/gestion/tests/stats_revenue_bugs_test.go new file mode 100644 index 00000000..63aa5450 --- /dev/null +++ b/backend/gestion/tests/stats_revenue_bugs_test.go @@ -0,0 +1,347 @@ +package tests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gestion/db" + "gestion/handlers" + "gestion/models" + + "github.com/gin-gonic/gin" +) + +// ── Régression : cohérence des resets par section dans la vue mensuelle ──── +// +// Les tests ci-dessous couvrent la vue mensuelle (StatsByDayForMonth / +// GetAdminStatsByMonth), qui a longtemps ignoré le filtre de reset pour le +// revenu et la quantité (seul le comptage de commandes le respectait) et +// n'appliquait jamais la section "revenus" (contrairement au résumé global +// GetAdminStats, qui utilise filters.ResetRevenus pour TotalRevenue). Depuis +// le correctif, count/quantity suivent la section "commandes" et revenue suit +// sa propre section "revenus", exactement comme TotalOrders/TotalRevenue. + +func monthStatsContext(month string) (*gin.Context, *httptest.ResponseRecorder) { + target := "/api/v1/admin/stats/month" + if month != "" { + target += "?month=" + month + } + req := httptest.NewRequest(http.MethodGet, target, nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + return c, rec +} + +type monthStatsResponse struct { + Summary struct { + TotalOrders int `json:"total_orders"` + TotalRevenue float64 `json:"total_revenue"` + TotalQuantity float64 `json:"total_quantity"` + } `json:"summary"` +} + +func getMonthStats(t *testing.T, month string) monthStatsResponse { + t.Helper() + c, rec := monthStatsContext(month) + handlers.GetAdminStatsByMonth(c) + if rec.Code != http.StatusOK { + t.Fatalf("GetAdminStatsByMonth doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + var resp monthStatsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("réponse JSON invalide: %v", err) + } + return resp +} + +// count et quantity doivent respecter le reset de la section "commandes" — +// tandis que revenue, lui, reste indépendant de cette section et ne doit +// bouger que lorsque la section "revenus" est réinitialisée séparément +// (même principe d'indépendance des sections que TestResetAdminStat_DifferentSectionsAreIndependent). +func TestStatsByDayForMonth_CountAndQuantityFollowCommandesReset_RevenueFollowsRevenusReset(t *testing.T) { + cleanupStockTestData(t) + cleanupStatsResetKey(t, statsResetTestKey) // "stats_reset_commandes_at" + cleanupStatsResetKey(t, "stats_reset_revenus_at") + username := newTestClient(t, "stats_month_reset_sections") + productID := newTestProduct(t, "StatsMonthResetSections", 100) + + // .UTC() est indispensable ici : ResetAdminStat stocke sa marque en UTC, et + // newTestOrderForStats lie created_at comme paramètre (contrairement à + // newTestCommandWithItem qui utilise NOW() côté SQL) — sur une colonne + // "timestamp without time zone", le driver écrit les composants d'horloge + // tels quels sans conversion. Sans .UTC() explicite, la comparaison avec + // resetAt dépend du fuseau horaire local de la machine qui exécute les tests. + newTestOrderForStats(t, username, "approved", productID, 5, 100, 0, time.Now().UTC()) // avant tout reset : qty=5, revenue=100 + time.Sleep(1100 * time.Millisecond) // marge : reset stocké sans fraction de seconde (RFC3339) + + if err := testDB.ResetAdminStat(statsResetTestKey); err != nil { // reset "commandes" + t.Fatalf("ResetAdminStat(commandes): %v", err) + } + time.Sleep(1100 * time.Millisecond) + + newTestOrderForStats(t, username, "approved", productID, 2, 50, 0, time.Now().UTC()) // après reset commandes, avant reset revenus : qty=2, revenue=50 + + resetCommandes := testDB.ReadResetAt(statsResetTestKey) + if resetCommandes.IsZero() { + t.Fatal("ReadResetAt(commandes) ne doit pas être zero") + } + + var rows []db.DailyMonthStatRow + if err := testDB.StatsByDayForMonth(&rows, time.Now(), resetCommandes, time.Time{}); err != nil { + t.Fatalf("StatsByDayForMonth: %v", err) + } + if len(rows) != 1 { + t.Fatalf("les deux commandes sont le même jour, une seule ligne attendue: got=%d", len(rows)) + } + r := rows[0] + if r.Count != 1 { + t.Errorf("count doit exclure la commande d'avant le reset commandes: got=%d want=1", r.Count) + } + if r.Quantity != 2 { + t.Errorf("quantity doit suivre le même reset (commandes) que count: got=%.2f want=2", r.Quantity) + } + // La section "revenus" n'a pas encore été réinitialisée : revenue reste + // indépendant du reset "commandes" et inclut donc les deux commandes. + if r.Revenue != 150 { + t.Errorf("revenue ne doit pas être affecté par le reset de la section commandes (indépendance des sections): got=%.2f want=150", r.Revenue) + } + + time.Sleep(1100 * time.Millisecond) + if err := testDB.ResetAdminStat("stats_reset_revenus_at"); err != nil { // reset "revenus" + t.Fatalf("ResetAdminStat(revenus): %v", err) + } + time.Sleep(1100 * time.Millisecond) + + newTestOrderForStats(t, username, "approved", productID, 1, 20, 0, time.Now().UTC()) // après reset revenus : qty=1, revenue=20 + + resetRevenus := testDB.ReadResetAt("stats_reset_revenus_at") + if resetRevenus.IsZero() { + t.Fatal("ReadResetAt(revenus) ne doit pas être zero") + } + + rows = nil + if err := testDB.StatsByDayForMonth(&rows, time.Now(), resetCommandes, resetRevenus); err != nil { + t.Fatalf("StatsByDayForMonth (2e appel): %v", err) + } + if len(rows) != 1 { + t.Fatalf("les trois commandes sont le même jour, une seule ligne attendue: got=%d", len(rows)) + } + r = rows[0] + if r.Count != 2 { + t.Errorf("count doit inclure les deux commandes postérieures au reset commandes: got=%d want=2", r.Count) + } + if r.Quantity != 3 { + t.Errorf("quantity doit inclure les deux commandes postérieures au reset commandes (2+1): got=%.2f want=3", r.Quantity) + } + // Le reset "revenus" exclut désormais les commandes créées avant lui : + // seule la dernière (revenue=20) doit rester. + if r.Revenue != 20 { + t.Errorf("revenue doit exclure les commandes créées avant le reset revenus: got=%.2f want=20", r.Revenue) + } +} + +// Le reset de la section "revenus" doit se répercuter sur le total du mois +// affiché par GetAdminStatsByMonth, exactement comme il se répercute sur le +// résumé global (GetAdminStats / TotalRevenue) — sans quoi l'admin voit un +// résumé à zéro mais une vue mensuelle qui continue d'afficher l'historique. +func TestGetAdminStatsByMonth_TotalRevenueRespectsRevenusResetSection(t *testing.T) { + cleanupStockTestData(t) + cleanupStatsResetKey(t, "stats_reset_revenus_at") + username := newTestClient(t, "stats_month_revenus_reset") + productID := newTestProduct(t, "StatsMonthRevenusReset", 100) + + before := getMonthStats(t, "") + + newTestOrderForStats(t, username, "approved", productID, 1, 111, 0, time.Now().UTC()) + + afterOrder := getMonthStats(t, "") + if afterOrder.Summary.TotalRevenue != before.Summary.TotalRevenue+111 { + t.Fatalf("précondition: le revenu du mois doit augmenter de 111: before=%.2f after=%.2f", + before.Summary.TotalRevenue, afterOrder.Summary.TotalRevenue) + } + + time.Sleep(1100 * time.Millisecond) // marge : reset stocké sans fraction de seconde (RFC3339) + if err := testDB.ResetAdminStat("stats_reset_revenus_at"); err != nil { + t.Fatalf("ResetAdminStat: %v", err) + } + + afterReset := getMonthStats(t, "") + if afterReset.Summary.TotalRevenue != before.Summary.TotalRevenue { + t.Errorf("après reset de la section revenus, le revenu du mois doit revenir à sa valeur d'avant l'ajout de la commande: got=%.2f want=%.2f", + afterReset.Summary.TotalRevenue, before.Summary.TotalRevenue) + } +} + +// ── Tests de couverture / non-régression (comportement correct attendu) ──── + +// Une commande avec plusieurs articles à prix différents et un crédit de +// parrainage partiel doit répartir le revenu net proportionnellement entre +// les articles, et la somme des parts doit correspondre exactement au revenu +// net de la commande — sur les trois vues qui font cette répartition +// (TopProducts, QuantityBreakdown, DailyProductDetailForDate). +func TestProductBreakdowns_MultiItemOrder_ProportionalSplitMatchesNetRevenue(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "stats_multi_item") + productA := newTestProduct(t, "StatsMultiItemA", 100) + productB := newTestProduct(t, "StatsMultiItemB", 100) + now := time.Now() + + // Une commande à deux articles : total_prix = 80 (30+50), referral_used=20 → net 60. + var cmdID int + if err := testDB.GDB.Raw( + `INSERT INTO commandes (username, status, adresse, total_prix, referral_used, created_at, updated_at) + VALUES (?, 'approved', 'Adresse test', 80, 20, ?, ?) RETURNING id`, + username, now, now, + ).Scan(&cmdID).Error; err != nil { + t.Fatalf("création commande multi-articles: %v", err) + } + if err := testDB.GDB.Exec( + `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) + VALUES (?, ?, 'item A', 3, 30, 'pending')`, cmdID, productA, + ).Error; err != nil { + t.Fatalf("création item A: %v", err) + } + if err := testDB.GDB.Exec( + `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) + VALUES (?, ?, 'item B', 5, 50, 'pending')`, cmdID, productB, + ).Error; err != nil { + t.Fatalf("création item B: %v", err) + } + + wantRevenueA := 30.0 * 60.0 / 80.0 // 22.5 + wantRevenueB := 50.0 * 60.0 / 80.0 // 37.5 + + var prodRows []models.ProductRow + if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil { + t.Fatalf("TopProducts: %v", err) + } + revByProduct := map[int]float64{} + sumTop := 0.0 + for _, r := range prodRows { + revByProduct[r.ProductID] = r.Revenue + sumTop += r.Revenue + } + if got := revByProduct[productA]; got != wantRevenueA { + t.Errorf("TopProducts revenue produit A: got=%.4f want=%.4f", got, wantRevenueA) + } + if got := revByProduct[productB]; got != wantRevenueB { + t.Errorf("TopProducts revenue produit B: got=%.4f want=%.4f", got, wantRevenueB) + } + + totalRevenue, err := testDB.TotalRevenue(time.Time{}) + if err != nil { + t.Fatalf("TotalRevenue: %v", err) + } + if totalRevenue != 60 { + t.Fatalf("précondition TotalRevenue: got=%.2f want=60", totalRevenue) + } + if sumTop != totalRevenue { + t.Errorf("somme TopProducts.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumTop, totalRevenue) + } + + var qtyRows []models.QuantityBreakdownRow + if err := testDB.QuantityBreakdown(&qtyRows, time.Time{}); err != nil { + t.Fatalf("QuantityBreakdown: %v", err) + } + sumQty := 0.0 + for _, r := range qtyRows { + sumQty += r.Revenue + } + if sumQty != totalRevenue { + t.Errorf("somme QuantityBreakdown.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumQty, totalRevenue) + } + + var dailyRows []models.DailyProductRow + if err := testDB.DailyProductDetailForDate(&dailyRows, now); err != nil { + t.Fatalf("DailyProductDetailForDate: %v", err) + } + sumDaily := 0.0 + for _, r := range dailyRows { + sumDaily += r.Revenue + } + if sumDaily != totalRevenue { + t.Errorf("somme DailyProductDetailForDate.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumDaily, totalRevenue) + } +} + +// Commande dont le total est entièrement composé d'articles récompense +// (total_prix = 0) : aucune division par zéro ni valeur NULL ne doit +// remonter comme revenu — le résultat attendu est 0, exactement comme pour +// TotalRevenue sur la même commande. +func TestProductBreakdowns_ZeroTotalPrixYieldsZeroRevenueNoDivideByZero(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "stats_zero_total") + productID := newTestProduct(t, "StatsZeroTotal", 100) + now := time.Now() + + newTestOrderForStats(t, username, "approved", productID, 4, 0, 0, now) + + totalRevenue, err := testDB.TotalRevenue(time.Time{}) + if err != nil { + t.Fatalf("TotalRevenue: %v", err) + } + if totalRevenue != 0 { + t.Fatalf("précondition TotalRevenue: got=%.2f want=0", totalRevenue) + } + + var prodRows []models.ProductRow + if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil { + t.Fatalf("TopProducts: %v", err) + } + if len(prodRows) != 1 { + t.Fatalf("attendu 1 produit, got=%d", len(prodRows)) + } + if prodRows[0].Revenue != 0 { + t.Errorf("revenu attendu à 0 pour total_prix=0: got=%.2f", prodRows[0].Revenue) + } + + var dailyRows []models.DailyProductRow + if err := testDB.DailyProductDetailForDate(&dailyRows, now); err != nil { + t.Fatalf("DailyProductDetailForDate: %v", err) + } + if len(dailyRows) != 1 || dailyRows[0].Revenue != 0 { + t.Errorf("DailyProductDetailForDate revenu attendu à 0 pour total_prix=0: got=%+v", dailyRows) + } +} + +// Le résumé du mois (summary.total_revenue) doit toujours être égal à la +// somme des revenus journaliers renvoyés dans by_day, pour rester cohérent +// avec ce qui est effectivement affiché à l'utilisateur. +func TestGetAdminStatsByMonth_SummaryRevenueMatchesSumOfByDay(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "stats_month_sum_consistency") + productID := newTestProduct(t, "StatsMonthSumConsistency", 100) + + newTestOrderForStats(t, username, "approved", productID, 1, 42, 2, time.Now()) // net 40 + + c, rec := monthStatsContext("") + handlers.GetAdminStatsByMonth(c) + if rec.Code != http.StatusOK { + t.Fatalf("GetAdminStatsByMonth doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + Summary struct { + TotalRevenue float64 `json:"total_revenue"` + } `json:"summary"` + ByDay []struct { + Revenue float64 `json:"revenue"` + } `json:"by_day"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("réponse JSON invalide: %v", err) + } + + sum := 0.0 + for _, d := range resp.ByDay { + sum += d.Revenue + } + if sum != resp.Summary.TotalRevenue { + t.Errorf("summary.total_revenue (%.2f) doit correspondre à la somme de by_day[].revenue (%.2f)", resp.Summary.TotalRevenue, sum) + } +}