package dashboard import ( "context" "fmt" "strings" "time" "tm/internal/notice" orm "tm/pkg/mongo" "go.mongodb.org/mongo-driver/v2/bson" mongodriver "go.mongodb.org/mongo-driver/v2/mongo" "golang.org/x/sync/errgroup" ) const noticesCollectionName = "notices" // scrapedScopeCacheTTL avoids listing every MinIO object on each statistics request while // keeping counts aligned with the tender panel (MinIO is the source of truth for scraped docs). const scrapedScopeCacheTTL = 5 * time.Minute // scrapedTendersScope is the resolved Mongo filter for tenders with scraped documents. // zero is set when MinIO reports no procedure folders; callers must return zero without querying. type scrapedTendersScope struct { match bson.M zero bool } func (r *repository) Statistics(ctx context.Context, days int, startUnix int64) (*StatisticsReportResponse, error) { now := time.Now().UTC() endDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) startDay := endDay.AddDate(0, 0, -(days - 1)) // Resolve scraped-document scope from MinIO (cached); falls back to Mongo flags on error. scrapedScope, err := r.cachedScrapedTendersScope(ctx) if err != nil { return nil, err } var ( scrapedTED map[string]int64 scrapedDocuments map[string]int64 translatedNotices map[string]int64 totalDocuments int64 totalTranslated int64 ) g, gctx := errgroup.WithContext(ctx) g.Go(func() error { // Use created_at (first ingest time). processing_metadata.scraped_at is reset on // notice refresh merges, which would collapse historical scrapes onto the latest day. counts, err := r.dailyCountByTimestamp(gctx, noticesCollectionName, bson.M{ "source": notice.TenderSourceTEDScraper, "created_at": bson.M{"$gte": startUnix, "$gt": 0}, }, "created_at") if err != nil { return fmt.Errorf("scraped ted per day: %w", err) } scrapedTED = counts return nil }) g.Go(func() error { counts, err := r.scrapedDocumentsPerDay(gctx, startUnix, scrapedScope) if err != nil { return fmt.Errorf("scraped documents per day: %w", err) } scrapedDocuments = counts return nil }) g.Go(func() error { counts, err := r.translatedNoticesPerDay(gctx, startDay, endDay) if err != nil { return fmt.Errorf("translated notices per day: %w", err) } translatedNotices = counts return nil }) g.Go(func() error { total, err := r.totalScrapedTenders(gctx, scrapedScope) if err != nil { return fmt.Errorf("total scraped tenders: %w", err) } totalDocuments = total return nil }) g.Go(func() error { total, err := r.metricsCounter.Get(gctx, orm.AITranslationSuccessCounterKey) if err != nil { return fmt.Errorf("total translated tenders: %w", err) } totalTranslated = total return nil }) if err := g.Wait(); err != nil { return nil, err } return &StatisticsReportResponse{ Days: days, GeneratedAt: now.Unix(), Daily: StatisticsDailySeries{ ScrapedTED: fillTrendSeries(days, scrapedTED), ScrapedDocuments: fillTrendSeries(days, scrapedDocuments), TranslatedNotices: fillTrendSeries(days, translatedNotices), }, Totals: StatisticsLifetimeTotals{ ScrapedDocuments: totalDocuments, TranslatedTenders: totalTranslated, }, }, nil } func (r *repository) dailyCountByTimestamp(ctx context.Context, collection string, match bson.M, timestampField string) (map[string]int64, error) { pipeline := mongodriver.Pipeline{ {{Key: "$match", Value: match}}, {{Key: "$addFields", Value: bson.M{ "metric_ts": normalizeTimestampExpr(timestampField), }}}, {{Key: "$group", Value: bson.M{ "_id": bson.M{ "$dateToString": bson.M{ "format": "%Y-%m-%d", "date": bson.M{"$toDate": bson.M{"$multiply": bson.A{"$metric_ts", 1000}}}, "timezone": "UTC", }, }, "count": bson.M{"$sum": 1}, }}}, } cursor, err := r.mongoManager.GetCollection(collection).Aggregate(ctx, pipeline) if err != nil { return nil, err } defer cursor.Close(ctx) return decodeDailyCounts(ctx, cursor) } func (r *repository) scrapedDocumentsPerDay(ctx context.Context, startUnix int64, scope scrapedTendersScope) (map[string]int64, error) { if scope.zero { return map[string]int64{}, nil } // Daily buckets use documents_scraped_at only. Tenders present in MinIO but not yet // synced to Mongo are included in the lifetime total but omitted from the daily series. pipeline := mongodriver.Pipeline{ {{Key: "$match", Value: scope.match}}, {{Key: "$addFields", Value: bson.M{ "metric_ts": normalizeTimestampExpr("processing_metadata.documents_scraped_at"), }}}, {{Key: "$match", Value: bson.M{ "metric_ts": bson.M{"$gte": startUnix, "$gt": 0}, }}}, {{Key: "$group", Value: bson.M{ "_id": bson.M{ "$dateToString": bson.M{ "format": "%Y-%m-%d", "date": bson.M{"$toDate": bson.M{"$multiply": bson.A{"$metric_ts", 1000}}}, "timezone": "UTC", }, }, "count": bson.M{"$sum": 1}, }}}, } cursor, err := r.mongoManager.GetCollection(tenderCollectionName()).Aggregate(ctx, pipeline) if err != nil { return nil, err } defer cursor.Close(ctx) return decodeDailyCounts(ctx, cursor) } func (r *repository) translatedNoticesPerDay(ctx context.Context, startDay, endDay time.Time) (map[string]int64, error) { return r.metricsCounter.GetDailyCounts(ctx, startDay, endDay) } func (r *repository) totalScrapedTenders(ctx context.Context, scope scrapedTendersScope) (int64, error) { if scope.zero { return 0, nil } return r.ormRepo.Count(ctx, scope.match) } func mongoScrapedTendersScope() scrapedTendersScope { return scrapedTendersScope{ match: bson.M{ "processing_metadata.documents_scraped": true, "scraped_documents": bson.M{"$exists": true, "$ne": bson.A{}}, }, } } func (r *repository) cachedScrapedTendersScope(ctx context.Context) (scrapedTendersScope, error) { now := time.Now() r.scrapedScopeMu.Lock() if r.scrapedScopeCached && now.Before(r.scrapedScopeExpiry) { scope := r.scrapedScope r.scrapedScopeMu.Unlock() return scope, nil } r.scrapedScopeMu.Unlock() v, err, _ := r.scrapedScopeGroup.Do("scraped-scope", func() (interface{}, error) { r.scrapedScopeMu.Lock() if r.scrapedScopeCached && time.Now().Before(r.scrapedScopeExpiry) { scope := r.scrapedScope r.scrapedScopeMu.Unlock() return scope, nil } r.scrapedScopeMu.Unlock() scope, resolveErr := r.resolveScrapedTendersScope(ctx) if resolveErr != nil { r.logger.Warn("MinIO scraped scope unavailable, using Mongo fallback for dashboard statistics", map[string]interface{}{ "error": resolveErr.Error(), }) return mongoScrapedTendersScope(), nil } r.scrapedScopeMu.Lock() r.scrapedScope = scope r.scrapedScopeExpiry = time.Now().Add(scrapedScopeCacheTTL) r.scrapedScopeCached = true r.scrapedScopeMu.Unlock() return scope, nil }) if err != nil { return scrapedTendersScope{}, err } return v.(scrapedTendersScope), nil } func (r *repository) resolveScrapedTendersScope(ctx context.Context) (scrapedTendersScope, error) { folderIDs, err := r.scrapedDocumentsFolderIDs(ctx) if err != nil { return scrapedTendersScope{}, err } if len(folderIDs) > 0 { return scrapedTendersScope{ match: bson.M{"contract_folder_id": bson.M{"$in": folderIDs}}, }, nil } if r.procedureLister != nil { return scrapedTendersScope{zero: true}, nil } return scrapedTendersScope{ match: bson.M{ "processing_metadata.documents_scraped": true, "scraped_documents": bson.M{"$exists": true, "$ne": bson.A{}}, }, }, nil } func (r *repository) scrapedDocumentsFolderIDs(ctx context.Context) ([]string, error) { if r.procedureLister == nil { return nil, nil } procedures, err := r.procedureLister.ListProceduresWithDocuments(ctx) if err != nil { return nil, err } folderIDs := make([]string, 0, len(procedures)) seen := make(map[string]struct{}, len(procedures)) for _, proc := range procedures { if proc.DocumentCount <= 0 { continue } id := strings.TrimSpace(proc.ContractFolderID) if id == "" { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} folderIDs = append(folderIDs, id) } return folderIDs, nil } func decodeDailyCounts(ctx context.Context, cursor *mongodriver.Cursor) (map[string]int64, error) { counts := make(map[string]int64) for cursor.Next(ctx) { var row bson.M if err := cursor.Decode(&row); err != nil { return nil, err } date, ok := row["_id"].(string) if !ok || date == "" { continue } counts[date] = aggregateCount(row, "count") } if err := cursor.Err(); err != nil { return nil, err } return counts, nil }