Files
tm_back/internal/tender/repository.go
T
Mazyar 45cfa24a72 Enhance document scraper service with AI portal integration and error handling
- Updated the document scraper service to include a new ScrapePortalsProvider interface, allowing for dynamic retrieval of supported scraping portals.
- Modified the ListPendingTenders and GetTenderByNoticeID methods to filter tenders based on document URLs that match the configured portals.
- Introduced new error handling for cases when the scrape portals provider is not configured, returning appropriate service unavailable responses.
- Enhanced API documentation to reflect changes in tender retrieval logic and added error response details for unsupported portal scenarios.

This update improves the document scraping functionality by integrating AI portal support, enhancing the overall reliability and flexibility of the tender management system.
2026-06-21 09:58:11 +03:30

1148 lines
34 KiB
Go

package tender
import (
"context"
"regexp"
"strings"
"time"
"tm/pkg/logger"
orm "tm/pkg/mongo"
"tm/pkg/response"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// TenderRepository interface defines tender data access methods
type TenderRepository interface {
// Tender CRUD operations
Create(ctx context.Context, tender *Tender) error
GetByID(ctx context.Context, id string) (*Tender, error)
GetByIDs(ctx context.Context, ids []string) ([]Tender, error)
GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Tender, error)
GetByNoticePublicationID(ctx context.Context, noticePublicationID string) (*Tender, error)
GetByContractFolderID(ctx context.Context, contractFolderID string) (*Tender, error)
// GetLatestByContractFolderIDs returns the most recently updated tender per distinct contract_folder_id.
GetLatestByContractFolderIDs(ctx context.Context, contractFolderIDs []string) (map[string]*Tender, error)
GetByProcurementProjectID(ctx context.Context, procurementProjectID string) (*Tender, error)
FindTendersWithContentXML(ctx context.Context, limit, skip int) ([]Tender, error)
GetTenderCountByCountry(ctx context.Context) (map[string]int64, error)
GetTenderCountByType(ctx context.Context) (map[string]int64, error)
GetTenderCountByClassification(ctx context.Context) (map[string]int64, error)
Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchPageResult, error)
GetExpiredTenders(ctx context.Context, beforeDate int64) ([]Tender, error)
GetPendingDocumentScrapeTenders(ctx context.Context, filter PendingDocumentScrapeFilter) ([]Tender, bool, error)
GetUnSummarizedTenders(ctx context.Context, limit, skip int) ([]Tender, int64, error)
GetUnTranslatedTenders(ctx context.Context, language string, limit, skip int) ([]Tender, int64, error)
Update(ctx context.Context, tender *Tender) error
Delete(ctx context.Context, id string) error
GetStatistics(ctx context.Context) (*TenderStatistics, error)
}
func collectionName() string {
return "tenders"
}
// SearchPageResult is the paginated outcome of a tender search/list query.
type SearchPageResult struct {
Items []Tender
TotalCount int64 // -1 when count was skipped
NextCursor string
HasMore bool
PageOffset int
}
// tenderSearchListProjection excludes large fields not needed for list/search API payloads.
func tenderSearchListProjection() bson.M {
return bson.M{
"content_xml": 0,
"document_summaries": 0,
"scraped_documents": 0,
"selection_criteria": 0,
"modifications": 0,
"source_file_url": 0,
"source_file_name": 0,
}
}
// documentScraperListProjection loads only fields required by the document scraper API.
func documentScraperListProjection() bson.M {
return bson.M{
"contract_folder_id": 1,
"notice_publication_id": 1,
"document_uri": 1,
"tender_url": 1,
"title": 1,
"description": 1,
}
}
// tenderRepository implements TenderRepository interface using MongoDB ORM
type tenderRepository struct {
ormRepo orm.Repository[Tender]
mongoManager *orm.ConnectionManager
logger logger.Logger
}
// NewTenderRepository creates a new tender repository
func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) TenderRepository {
// Create indexes for tenders collection
tenderIndexes := []orm.Index{
*orm.CreateUniqueIndex("tender_id_idx", bson.D{{Key: "tender_id", Value: 1}}),
*orm.NewIndex("project_name_idx", bson.D{{Key: "project_name", Value: 1}}),
*orm.NewIndex("contract_notice_id_idx", bson.D{{Key: "contract_notice_id", Value: 1}}),
// One tender row per TED ContractNoticeID when present (parallel ingest / lookup gaps).
*orm.CreateUniqueIndex("contract_notice_id_unique", bson.D{{Key: "contract_notice_id", Value: 1}}).
WithPartialFilterExpression(orm.NonEmptyStringPartialFilter("contract_notice_id")),
*orm.NewIndex("contract_folder_id_idx", bson.D{{Key: "contract_folder_id", Value: 1}}),
// One tender row per TED publication / procedure when ids are present (prevents parallel worker races).
*orm.CreateUniqueIndex("notice_publication_id_unique", bson.D{{Key: "notice_publication_id", Value: 1}}).
WithPartialFilterExpression(orm.NonEmptyStringPartialFilter("notice_publication_id")),
*orm.CreateUniqueIndex("contract_folder_id_unique", bson.D{{Key: "contract_folder_id", Value: 1}}).
WithPartialFilterExpression(orm.NonEmptyStringPartialFilter("contract_folder_id")),
// One tender per UBL procurement project (merges per-lot notices that share ProcurementProject/ID).
*orm.CreateUniqueIndex("procurement_project_id_unique", bson.D{{Key: "procurement_project_id", Value: 1}}).
WithPartialFilterExpression(orm.NonEmptyStringPartialFilter("procurement_project_id")),
*orm.NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}),
*orm.NewIndex("source_idx", bson.D{{Key: "source", Value: 1}}),
*orm.NewIndex("country_code_idx", bson.D{{Key: "country_code", Value: 1}}),
*orm.NewIndex("notice_type_code_idx", bson.D{{Key: "notice_type_code", Value: 1}}),
*orm.NewIndex("form_type_idx", bson.D{{Key: "form_type", Value: 1}}),
*orm.NewIndex("procurement_type_code_idx", bson.D{{Key: "procurement_type_code", Value: 1}}),
*orm.NewIndex("main_classification_idx", bson.D{{Key: "main_classification", Value: 1}}),
*orm.NewIndex("publication_date_idx", bson.D{{Key: "publication_date", Value: -1}}),
*orm.NewIndex("tender_deadline_idx", bson.D{{Key: "tender_deadline", Value: 1}}),
*orm.NewIndex("estimated_value_idx", bson.D{{Key: "estimated_value", Value: -1}}),
*orm.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
// Stable keyset pagination for default admin list (created_at desc).
*orm.NewIndex("created_at_id_idx", bson.D{
{Key: "created_at", Value: -1},
{Key: "_id", Value: -1},
}),
*orm.NewIndex("status_created_at_id_idx", bson.D{
{Key: "status", Value: 1},
{Key: "created_at", Value: -1},
{Key: "_id", Value: -1},
}),
*orm.NewIndex("country_code_created_at_id_idx", bson.D{
{Key: "country_code", Value: 1},
{Key: "created_at", Value: -1},
{Key: "_id", Value: -1},
}),
*orm.NewIndex("notice_type_code_created_at_id_idx", bson.D{
{Key: "notice_type_code", Value: 1},
{Key: "created_at", Value: -1},
{Key: "_id", Value: -1},
}),
*orm.NewIndex("updated_at_idx", bson.D{{Key: "updated_at", Value: -1}}),
// Compound indexes for common queries
*orm.NewIndex("status_country_idx", bson.D{
{Key: "status", Value: 1},
{Key: "country_code", Value: 1},
}),
*orm.NewIndex("notice_type_status_idx", bson.D{
{Key: "notice_type_code", Value: 1},
{Key: "status", Value: 1},
}),
*orm.NewIndex("doc_scrape_pending_idx", bson.D{
{Key: "country_code", Value: 1},
{Key: "processing_metadata.documents_scraped", Value: 1},
{Key: "tender_deadline", Value: 1},
{Key: "created_at", Value: 1},
}),
*orm.NewIndex("documents_scraped_at_idx", bson.D{
{Key: "processing_metadata.documents_scraped", Value: 1},
{Key: "processing_metadata.documents_scraped_at", Value: -1},
}),
*orm.NewIndex("scraped_documents_scraped_at_idx", bson.D{
{Key: "scraped_documents.scraped_at", Value: 1},
}),
// Index for CPV matching queries
*orm.NewIndex("cpv_matching_idx", bson.D{
{Key: "status", Value: 1},
{Key: "main_classification", Value: 1},
{Key: "additional_classifications", Value: 1},
{Key: "publication_date", Value: -1},
}),
// Text index (legacy; list search uses $regex across title, description, and AI summary fields for unified matching)
*orm.CreateTextIndex("search_idx", "title", "description"),
}
// Create indexes
if err := mongoManager.CreateIndexes(collectionName(), tenderIndexes); err != nil {
logger.Warn("Failed to create tender indexes", map[string]interface{}{
"error": err.Error(),
})
}
// Create ORM repositories
tenderOrmRepo := orm.NewRepository[Tender](mongoManager.GetCollection(collectionName()), logger)
return &tenderRepository{
ormRepo: tenderOrmRepo,
mongoManager: mongoManager,
logger: logger,
}
}
// Create creates a new tender
func (r *tenderRepository) Create(ctx context.Context, tender *Tender) error {
now := time.Now().Unix()
tender.SetCreatedAt(now)
err := r.ormRepo.Create(ctx, tender)
if err != nil {
r.logger.Error("Failed to create tender", map[string]interface{}{
"error": err.Error(),
"notice_publication_id": tender.NoticePublicationID,
})
return err
}
r.logger.Info("Tender created successfully", map[string]interface{}{
"tender_id": tender.ID,
"notice_publication_id": tender.NoticePublicationID,
})
return nil
}
// GetByID retrieves a tender by ID
func (r *tenderRepository) GetByID(ctx context.Context, id string) (*Tender, error) {
tender, err := r.ormRepo.FindByID(ctx, id)
if err != nil {
r.logger.Error("Failed to get tender by ID", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return nil, err
}
return tender, nil
}
// GetByIDs retrieves tenders by IDs in a single query.
func (r *tenderRepository) GetByIDs(ctx context.Context, ids []string) ([]Tender, error) {
if len(ids) == 0 {
return nil, nil
}
objectIDs := make([]bson.ObjectID, 0, len(ids))
for _, id := range ids {
objectID, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, err
}
objectIDs = append(objectIDs, objectID)
}
result, err := r.ormRepo.FindAll(ctx, bson.M{"_id": bson.M{"$in": objectIDs}}, orm.NewPaginationBuilder().Build())
if err != nil {
r.logger.Error("Failed to get tenders by IDs", map[string]interface{}{
"count": len(ids),
"error": err.Error(),
})
return nil, err
}
return result.Items, nil
}
// GetByContractNoticeID retrieves a tender by contract notice ID
func (r *tenderRepository) GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Tender, error) {
filter := bson.M{"contract_notice_id": contractNoticeID}
pagination := orm.Pagination{Limit: 1}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get tender by contract notice ID", map[string]interface{}{
"contract_notice_id": contractNoticeID,
"error": err.Error(),
})
return nil, err
}
if len(result.Items) == 0 {
return nil, orm.ErrDocumentNotFound
}
return &result.Items[0], nil
}
// GetByNoticePublicationID retrieves a tender by notice publication ID.
// Matches both the canonical NoticePublicationID and any id retained in RelatedNoticePublicationIDs
// so historical TED detail links keep resolving after a tender update from a follow-up notice.
func (r *tenderRepository) GetByNoticePublicationID(ctx context.Context, noticePublicationID string) (*Tender, error) {
filter := bson.M{
"$or": []bson.M{
{"notice_publication_id": noticePublicationID},
{"related_notice_publication_ids": noticePublicationID},
},
}
pagination := orm.Pagination{Limit: 1}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get tender by notice publication ID", map[string]interface{}{
"notice_publication_id": noticePublicationID,
"error": err.Error(),
})
return nil, err
}
if len(result.Items) == 0 {
return nil, orm.ErrDocumentNotFound
}
return &result.Items[0], nil
}
// GetByContractFolderID retrieves a tender by its procedure-level identifier (ContractFolderID).
// TED groups multiple notice publications (e.g. competition notice + result notice) under the same
// ContractFolderID, so this lookup is the authoritative way to find the existing tender for a procedure
// when a follow-up notice arrives. Returns the most recently updated match if there are several.
func (r *tenderRepository) GetByContractFolderID(ctx context.Context, contractFolderID string) (*Tender, error) {
filter := bson.M{"contract_folder_id": contractFolderID}
pagination := orm.Pagination{
Limit: 1,
SortField: "updated_at",
SortOrder: -1,
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get tender by contract folder ID", map[string]interface{}{
"contract_folder_id": contractFolderID,
"error": err.Error(),
})
return nil, err
}
if len(result.Items) == 0 {
return nil, orm.ErrDocumentNotFound
}
return &result.Items[0], nil
}
// GetLatestByContractFolderIDs returns one tender per distinct contract_folder_id (latest updated_at).
func (r *tenderRepository) GetLatestByContractFolderIDs(ctx context.Context, contractFolderIDs []string) (map[string]*Tender, error) {
out := make(map[string]*Tender)
if len(contractFolderIDs) == 0 {
return out, nil
}
uniq := uniqueNonEmptyTrimmedStrings(contractFolderIDs)
if len(uniq) == 0 {
return out, nil
}
// Allow a few rows per folder in case duplicate folder rows exist despite partial uniques.
findLimit := len(uniq) * 3
if findLimit < len(uniq)+1 {
findLimit = len(uniq) + 1
}
if findLimit > 100000 {
findLimit = 100000
}
result, err := r.ormRepo.FindAll(ctx,
bson.M{"contract_folder_id": bson.M{"$in": uniq}},
orm.Pagination{
Limit: findLimit,
Skip: 0,
SortField: "updated_at",
SortOrder: -1,
},
)
if err != nil {
r.logger.Error("Failed to load tenders by contract folder ids", map[string]interface{}{
"folder_count": len(uniq),
"error": err.Error(),
})
return nil, err
}
for i := range result.Items {
t := &result.Items[i]
cid := strings.TrimSpace(t.ContractFolderID)
if cid == "" {
continue
}
prev, ok := out[cid]
if !ok || t.UpdatedAt > prev.UpdatedAt {
out[cid] = t
}
}
return out, nil
}
func uniqueNonEmptyTrimmedStrings(ids []string) []string {
seen := make(map[string]struct{}, len(ids))
out := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
// GetByProcurementProjectID finds a tender by UBL ProcurementProject ID (shared across per-lot notices).
func (r *tenderRepository) GetByProcurementProjectID(ctx context.Context, procurementProjectID string) (*Tender, error) {
filter := bson.M{"procurement_project_id": procurementProjectID}
pagination := orm.Pagination{
Limit: 1,
SortField: "updated_at",
SortOrder: -1,
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get tender by procurement project ID", map[string]interface{}{
"procurement_project_id": procurementProjectID,
"error": err.Error(),
})
return nil, err
}
if len(result.Items) == 0 {
return nil, orm.ErrDocumentNotFound
}
return &result.Items[0], nil
}
// FindTendersWithContentXML returns tenders that still have TED XML (for backfill after notices were deleted).
func (r *tenderRepository) FindTendersWithContentXML(ctx context.Context, limit, skip int) ([]Tender, error) {
filter := bson.M{"content_xml": bson.M{"$exists": true, "$ne": ""}}
pagination := orm.Pagination{
Limit: limit,
Skip: skip,
SortField: "_id",
SortOrder: 1,
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to list tenders with content_xml", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
return result.Items, nil
}
// Update updates an existing tender
func (r *tenderRepository) Update(ctx context.Context, tender *Tender) error {
tender.UpdatedAt = time.Now().Unix()
err := r.ormRepo.Update(ctx, tender)
if err != nil {
r.logger.Error("Failed to update tender", map[string]interface{}{
"tender_id": tender.ID,
"error": err.Error(),
})
return err
}
r.logger.Info("Tender updated successfully", map[string]interface{}{
"tender_id": tender.ID,
})
return nil
}
// Delete deletes a tender by ID
func (r *tenderRepository) Delete(ctx context.Context, id string) error {
err := r.ormRepo.Delete(ctx, id)
if err != nil {
r.logger.Error("Failed to delete tender", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return err
}
r.logger.Info("Tender deleted successfully", map[string]interface{}{
"tender_id": id,
})
return nil
}
// Search searches tenders based on criteria
func (r *tenderRepository) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchPageResult, error) {
filter := r.buildSearchFilter(form)
sort := "created_at"
if pagination.SortBy != "" {
sort = pagination.SortBy
}
sortOrder := "desc"
if pagination.SortOrder != "" {
sortOrder = pagination.SortOrder
}
mongoPagination, err := orm.BuildListPagination(
pagination.Limit,
pagination.Offset,
pagination.Cursor,
sort,
sortOrder,
filter,
orm.ListPaginationOptions{Projection: tenderSearchListProjection()},
)
if err != nil {
return nil, err
}
result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination)
if err != nil {
r.logger.Error("Failed to search tenders", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
return &SearchPageResult{
Items: result.Items,
TotalCount: result.TotalCount,
NextCursor: result.NextCursor,
HasMore: result.HasMore,
PageOffset: result.PageOffset,
}, nil
}
// GetExpiredTenders retrieves tenders that are expired
func (r *tenderRepository) GetExpiredTenders(ctx context.Context, beforeDate int64) ([]Tender, error) {
filter := bson.M{
"$or": []bson.M{
{"status": TenderStatusExpired},
{
"tender_deadline": bson.M{
"$lt": beforeDate,
},
"status": bson.M{"$ne": TenderStatusExpired},
},
},
}
pagination := orm.Pagination{
Limit: 1000, // Large limit for maintenance operations
SortField: "tender_deadline",
SortOrder: 1,
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get expired tenders", map[string]interface{}{
"before_date": beforeDate,
"error": err.Error(),
})
return nil, err
}
return result.Items, nil
}
// PendingDocumentScrapeFilter defines query criteria for pending document scrape tenders.
type PendingDocumentScrapeFilter struct {
Portals []string
MinDeadline *int64 // when set, tender_deadline must be greater than this value
CreatedAtFrom *int64
CreatedAtTo *int64
Limit int
Skip int
}
func scrapePortalURLFilter(portals []string) bson.M {
conditions := make([]bson.M, 0, len(portals)*2)
for _, portal := range portals {
portal = strings.TrimSpace(portal)
if portal == "" {
continue
}
regex := bson.M{"$regex": regexp.QuoteMeta(portal), "$options": "i"}
conditions = append(conditions,
bson.M{"document_uri": regex},
bson.M{"tender_url": regex},
)
}
if len(conditions) == 0 {
return nil
}
return bson.M{"$or": conditions}
}
// GetPendingDocumentScrapeTenders returns unscraped tenders whose document URL matches a supported portal.
// When MinDeadline is set, only tenders with tender_deadline greater than that value are returned.
func (r *tenderRepository) GetPendingDocumentScrapeTenders(ctx context.Context, filter PendingDocumentScrapeFilter) ([]Tender, bool, error) {
portalFilter := scrapePortalURLFilter(filter.Portals)
if portalFilter == nil {
return nil, false, nil
}
query := bson.M{
"processing_metadata.documents_scraped": bson.M{"$ne": true},
}
for key, value := range portalFilter {
query[key] = value
}
if filter.MinDeadline != nil {
query["tender_deadline"] = bson.M{
"$gt": *filter.MinDeadline,
}
}
if filter.CreatedAtFrom != nil || filter.CreatedAtTo != nil {
createdFilter := bson.M{}
if filter.CreatedAtFrom != nil {
createdFilter["$gte"] = *filter.CreatedAtFrom
}
if filter.CreatedAtTo != nil {
createdFilter["$lte"] = *filter.CreatedAtTo
}
query["created_at"] = createdFilter
}
pagination := orm.Pagination{
Limit: filter.Limit,
Skip: filter.Skip,
SortField: "created_at",
SortOrder: 1,
SkipCount: true,
Projection: documentScraperListProjection(),
}
result, err := r.ormRepo.FindAll(ctx, query, pagination)
if err != nil {
r.logger.Error("Failed to get pending document scrape tenders", map[string]interface{}{
"portals": filter.Portals,
"min_deadline": filter.MinDeadline,
"created_at_from": filter.CreatedAtFrom,
"created_at_to": filter.CreatedAtTo,
"limit": filter.Limit,
"skip": filter.Skip,
"error": err.Error(),
})
return nil, false, err
}
return result.Items, result.HasMore, nil
}
// GetUnSummarizedTenders retrieves tenders that have documents scraped but not summarized
func (r *tenderRepository) GetUnSummarizedTenders(ctx context.Context, limit, skip int) ([]Tender, int64, error) {
filter := bson.M{
"processing_metadata.documents_scraped": true,
"$or": []bson.M{
{"processing_metadata.documents_summarized": bson.M{"$exists": false}},
{"processing_metadata.documents_summarized": false},
},
}
pagination := orm.Pagination{
Limit: limit,
Skip: skip,
SortField: "processing_metadata.documents_scraped_at",
SortOrder: 1, // Oldest first (process oldest scraped documents first)
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get un-summarized tenders", map[string]interface{}{
"limit": limit,
"skip": skip,
"error": err.Error(),
})
return nil, 0, err
}
return result.Items, result.TotalCount, nil
}
// GetUnTranslatedTenders returns tenders eligible for translation backfill.
func (r *tenderRepository) GetUnTranslatedTenders(ctx context.Context, language string, limit, skip int) ([]Tender, int64, error) {
_ = language
filter := bson.M{
"notice_publication_id": bson.M{"$ne": ""},
"contract_folder_id": bson.M{"$ne": ""},
"title": bson.M{"$ne": ""},
"description": bson.M{"$ne": ""},
}
pagination := orm.Pagination{
Limit: limit,
Skip: skip,
SortField: "created_at",
SortOrder: 1, // Oldest first
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get untranslated tenders", map[string]interface{}{
"language": language,
"limit": limit,
"skip": skip,
"error": err.Error(),
})
return nil, 0, err
}
return result.Items, result.TotalCount, nil
}
// GetStatistics calculates tender statistics using aggregation
func (r *tenderRepository) GetStatistics(ctx context.Context) (*TenderStatistics, error) {
stats := &TenderStatistics{
LastUpdated: time.Now().Unix(),
}
// Get total count
total, err := r.ormRepo.Count(ctx, bson.M{})
if err != nil {
return nil, err
}
stats.TotalTenders = total
// Get active count
active, err := r.ormRepo.Count(ctx, bson.M{"status": TenderStatusActive})
if err != nil {
return nil, err
}
stats.ActiveTenders = active
// Get expired count
expired, err := r.ormRepo.Count(ctx, bson.M{"status": TenderStatusExpired})
if err != nil {
return nil, err
}
stats.ExpiredTenders = expired
// Get statistics by country, type, and classification
stats.TendersByCountry, _ = r.GetTenderCountByCountry(ctx)
stats.TendersByType, _ = r.GetTenderCountByType(ctx)
stats.TendersByClassification, _ = r.GetTenderCountByClassification(ctx)
// Calculate value statistics using aggregation
pipeline := mongo.Pipeline{
{
{Key: "$group", Value: bson.M{
"_id": nil,
"total_estimated_value": bson.M{"$sum": "$estimated_value"},
"count": bson.M{"$sum": 1},
}},
},
}
result, err := r.ormRepo.Aggregate(ctx, pipeline)
if err == nil && len(result) > 0 {
if totalValue, ok := result[0]["total_estimated_value"].(float64); ok {
stats.TotalEstimatedValue = totalValue
if count, ok := result[0]["count"].(int32); ok && count > 0 {
stats.AverageEstimatedValue = totalValue / float64(count)
}
}
}
return stats, nil
}
// GetTenderCountByCountry gets tender count grouped by country
func (r *tenderRepository) GetTenderCountByCountry(ctx context.Context) (map[string]int64, error) {
pipeline := mongo.Pipeline{
{
{Key: "$group", Value: bson.M{
"_id": "$country_code",
"count": bson.M{"$sum": 1},
}},
},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
counts := make(map[string]int64)
for _, result := range results {
if country, ok := result["_id"].(string); ok {
if count, ok := result["count"].(int32); ok {
counts[country] = int64(count)
}
}
}
return counts, nil
}
// GetTenderCountByType gets tender count grouped by type
func (r *tenderRepository) GetTenderCountByType(ctx context.Context) (map[string]int64, error) {
pipeline := mongo.Pipeline{
{
{Key: "$group", Value: bson.M{
"_id": "$notice_type_code",
"count": bson.M{"$sum": 1},
}},
},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
counts := make(map[string]int64)
for _, result := range results {
if noticeType, ok := result["_id"].(string); ok {
if count, ok := result["count"].(int32); ok {
counts[noticeType] = int64(count)
}
}
}
return counts, nil
}
// GetTenderCountByClassification gets tender count grouped by classification
func (r *tenderRepository) GetTenderCountByClassification(ctx context.Context) (map[string]int64, error) {
pipeline := mongo.Pipeline{
{
{Key: "$group", Value: bson.M{
"_id": "$main_classification",
"count": bson.M{"$sum": 1},
}},
},
{
{Key: "$limit", Value: 20},
},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
counts := make(map[string]int64)
for _, result := range results {
if classification, ok := result["_id"].(string); ok {
if count, ok := result["count"].(int32); ok {
counts[classification] = int64(count)
}
}
}
return counts, nil
}
func dedupeNonEmptyStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
// normalizeUnixTimestamp accepts seconds or milliseconds and always returns seconds.
func normalizeUnixTimestamp(value int64) int64 {
// 1e12 is safely above current Unix seconds and in millisecond range for modern dates.
if value > 1_000_000_000_000 {
return value / 1000
}
return value
}
func exactOrRange(exact, from, to *int64) (*int64, *int64) {
if exact != nil {
v := normalizeUnixTimestamp(*exact)
return &v, &v
}
var normalizedFrom *int64
if from != nil {
v := normalizeUnixTimestamp(*from)
normalizedFrom = &v
}
var normalizedTo *int64
if to != nil {
v := normalizeUnixTimestamp(*to)
normalizedTo = &v
}
return normalizedFrom, normalizedTo
}
// buildSearchFilter builds MongoDB filter from search form
func (r *tenderRepository) buildSearchFilter(form *SearchForm) bson.M {
filter := bson.M{}
var keywordOr []bson.M
if form.Search != nil {
q := strings.TrimSpace(*form.Search)
if q != "" {
pattern := regexp.QuoteMeta(q)
keywordOr = []bson.M{
{"title": bson.M{"$regex": pattern, "$options": "i"}},
{"description": bson.M{"$regex": pattern, "$options": "i"}},
{"document_summaries.summary": bson.M{"$regex": pattern, "$options": "i"}},
{"ai_overall_summary": bson.M{"$regex": pattern, "$options": "i"}},
}
}
}
if form.Title != nil {
title := strings.TrimSpace(*form.Title)
if title != "" {
filter["title"] = bson.M{
"$regex": regexp.QuoteMeta(title),
"$options": "i",
}
}
}
if form.Description != nil {
description := strings.TrimSpace(*form.Description)
if description != "" {
filter["description"] = bson.M{
"$regex": regexp.QuoteMeta(description),
"$options": "i",
}
}
}
var noticeCodes []string
if form.NoticeType != nil {
if t := strings.TrimSpace(*form.NoticeType); t != "" {
noticeCodes = append(noticeCodes, t)
}
}
noticeCodes = append(noticeCodes, form.NoticeTypes...)
noticeCodes = dedupeNonEmptyStrings(noticeCodes)
if len(noticeCodes) == 1 {
filter["notice_type_code"] = noticeCodes[0]
} else if len(noticeCodes) > 1 {
filter["notice_type_code"] = bson.M{"$in": noticeCodes}
}
formTypes := dedupeNonEmptyStrings(form.FormTypes)
if len(formTypes) == 1 {
filter["form_type"] = formTypes[0]
} else if len(formTypes) > 1 {
filter["form_type"] = bson.M{"$in": formTypes}
}
if form.ProcurementType != nil {
filter["procurement_type_code"] = form.ProcurementType
}
countryCodes := dedupeNonEmptyStrings(form.CountryCodes)
if form.CountryCode != nil {
if c := strings.TrimSpace(*form.CountryCode); c != "" {
countryCodes = append(countryCodes, c)
countryCodes = dedupeNonEmptyStrings(countryCodes)
}
}
if form.Country != nil {
if c := strings.TrimSpace(*form.Country); c != "" {
countryCodes = append(countryCodes, c)
countryCodes = dedupeNonEmptyStrings(countryCodes)
}
}
if len(countryCodes) == 1 {
filter["country_code"] = countryCodes[0]
} else if len(countryCodes) > 1 {
filter["country_code"] = bson.M{"$in": countryCodes}
}
if len(form.RegionCodes) > 0 {
filter["region_code"] = bson.M{"$in": dedupeNonEmptyStrings(form.RegionCodes)}
}
if form.MainClassification != nil {
if mainClassification := strings.TrimSpace(*form.MainClassification); mainClassification != "" {
filter["main_classification"] = mainClassification
}
}
cpvCodes := dedupeNonEmptyStrings(form.Classifications)
cpvCodes = append(cpvCodes, dedupeNonEmptyStrings(form.CpvCodes)...)
cpvCodes = dedupeNonEmptyStrings(cpvCodes)
var cpvOr []bson.M
if len(cpvCodes) > 0 {
cpvOr = []bson.M{
{"main_classification": bson.M{"$in": cpvCodes}},
{"additional_classifications": bson.M{"$in": cpvCodes}},
{
"procurement_lots": bson.M{
"$elemMatch": bson.M{
"$or": []bson.M{
{"main_classification": bson.M{"$in": cpvCodes}},
{"additional_classifications": bson.M{"$in": cpvCodes}},
},
},
},
},
}
}
switch {
case len(keywordOr) > 0 && len(cpvOr) > 0:
filter["$and"] = []bson.M{
{"$or": keywordOr},
{"$or": cpvOr},
}
case len(keywordOr) > 0:
filter["$or"] = keywordOr
case len(cpvOr) > 0:
filter["$or"] = cpvOr
}
if form.MinEstimatedValue != nil || form.MaxEstimatedValue != nil {
valueFilter := bson.M{}
if form.MinEstimatedValue != nil {
valueFilter["$gte"] = *form.MinEstimatedValue
}
if form.MaxEstimatedValue != nil {
valueFilter["$lte"] = *form.MaxEstimatedValue
}
filter["estimated_value"] = valueFilter
}
if form.Currency != "" {
filter["currency"] = form.Currency
}
createdAtFrom, createdAtTo := exactOrRange(form.CreatedAt, form.CreatedAtFrom, form.CreatedAtTo)
if createdAtFrom != nil || createdAtTo != nil {
createdFilter := bson.M{}
if createdAtFrom != nil {
createdFilter["$gte"] = *createdAtFrom
}
if createdAtTo != nil {
createdFilter["$lte"] = *createdAtTo
}
filter["created_at"] = createdFilter
}
deadlineFrom := form.DeadlineFrom
if form.TenderDeadlineFrom != nil {
deadlineFrom = form.TenderDeadlineFrom
}
deadlineTo := form.DeadlineTo
if form.TenderDeadlineTo != nil {
deadlineTo = form.TenderDeadlineTo
}
tenderDeadlineFrom, tenderDeadlineTo := exactOrRange(form.TenderDeadline, deadlineFrom, deadlineTo)
if tenderDeadlineFrom != nil || tenderDeadlineTo != nil || form.OnlyActiveDeadlines {
deadlineFilter := bson.M{}
if form.OnlyActiveDeadlines {
deadlineFilter["$gt"] = time.Now().Unix()
}
if tenderDeadlineFrom != nil {
deadlineFilter["$gte"] = *tenderDeadlineFrom
}
if tenderDeadlineTo != nil {
deadlineFilter["$lte"] = *tenderDeadlineTo
}
filter["tender_deadline"] = deadlineFilter
}
publicationDateFrom, publicationDateTo := exactOrRange(form.PublicationDate, form.PublicationDateFrom, form.PublicationDateTo)
if publicationDateFrom != nil || publicationDateTo != nil {
pubDateFilter := bson.M{}
if publicationDateFrom != nil {
pubDateFilter["$gte"] = *publicationDateFrom
}
if publicationDateTo != nil {
pubDateFilter["$lte"] = *publicationDateTo
}
filter["publication_date"] = pubDateFilter
}
submissionDateFrom := form.SubmissionDateFrom
if form.SubmissionDateAliasFrom != nil {
submissionDateFrom = form.SubmissionDateAliasFrom
}
submissionDateTo := form.SubmissionDateTo
if form.SubmissionDateAliasTo != nil {
submissionDateTo = form.SubmissionDateAliasTo
}
submissionDeadlineFrom, submissionDeadlineTo := exactOrRange(form.SubmissionDeadline, submissionDateFrom, submissionDateTo)
if submissionDeadlineFrom != nil || submissionDeadlineTo != nil {
subFilter := bson.M{}
if submissionDeadlineFrom != nil {
subFilter["$gte"] = *submissionDeadlineFrom
}
if submissionDeadlineTo != nil {
subFilter["$lte"] = *submissionDeadlineTo
}
filter["submission_deadline"] = subFilter
}
if len(form.Status) > 0 {
filter["status"] = bson.M{"$in": dedupeNonEmptyStrings(form.Status)}
}
if len(form.Source) > 0 {
filter["source"] = bson.M{"$in": dedupeNonEmptyStrings(form.Source)}
}
if form.BuyerOrganizationID != nil {
filter["buyer_organization.id"] = *form.BuyerOrganizationID
}
if form.NoticePublicationID != nil {
if noticePublicationID := strings.TrimSpace(*form.NoticePublicationID); noticePublicationID != "" {
filter["notice_publication_id"] = noticePublicationID
}
}
if len(form.Languages) > 0 {
filter["notice_language_code"] = bson.M{"$in": dedupeNonEmptyStrings(form.Languages)}
}
if form.DocumentsScraped {
folderIDs := dedupeNonEmptyStrings(form.ContractFolderIDsWithDocuments)
scrapedClause := bson.M{"contract_folder_id": bson.M{"$in": folderIDs}}
if len(folderIDs) == 0 {
scrapedClause = bson.M{"_id": bson.M{"$exists": false}}
}
if len(filter) == 0 {
return scrapedClause
}
if and, ok := filter["$and"].([]bson.M); ok {
filter["$and"] = append(and, scrapedClause)
} else {
existing := bson.M{}
for k, v := range filter {
existing[k] = v
}
filter = bson.M{
"$and": []bson.M{existing, scrapedClause},
}
}
}
return filter
}