8035118f44
- Introduced a new `scrapedTendersScope` type to encapsulate the MongoDB filter for tenders with scraped documents, improving clarity and maintainability. - Updated the `Statistics` method to utilize the new scope resolution, allowing for more accurate data retrieval based on the presence of scraped documents. - Implemented multiple tests for the `resolveScrapedTendersScope` method, ensuring correct behavior for various scenarios, including empty and fallback cases. This update enhances the dashboard's ability to manage scraped document statistics, improving overall data accuracy and system performance.
259 lines
6.8 KiB
Go
259 lines
6.8 KiB
Go
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"
|
|
|
|
// 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))
|
|
|
|
scrapedScope, err := r.resolveScrapedTendersScope(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 (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
|
|
}
|