Advanced search
This commit is contained in:
+207
-23
@@ -2,6 +2,8 @@ package tender
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"tm/pkg/logger"
|
||||
orm "tm/pkg/mongo"
|
||||
@@ -54,6 +56,7 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Te
|
||||
*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}}),
|
||||
@@ -77,7 +80,7 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Te
|
||||
{Key: "additional_classifications", Value: 1},
|
||||
{Key: "publication_date", Value: -1},
|
||||
}),
|
||||
// Text index for search
|
||||
// Text index (legacy; list search uses $regex across title, description, and AI summary fields for unified matching)
|
||||
*orm.CreateTextIndex("search_idx", "title", "description"),
|
||||
}
|
||||
|
||||
@@ -549,37 +552,176 @@ func (r *tenderRepository) GetTenderCountByClassification(ctx context.Context) (
|
||||
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 {
|
||||
filter["$text"] = bson.M{"$search": *form.Search}
|
||||
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 {
|
||||
filter["notice_type_code"] = form.NoticeType
|
||||
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
|
||||
}
|
||||
|
||||
if len(form.CountryCodes) > 0 {
|
||||
filter["country_code"] = bson.M{"$in": form.CountryCodes}
|
||||
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": form.RegionCodes}
|
||||
filter["region_code"] = bson.M{"$in": dedupeNonEmptyStrings(form.RegionCodes)}
|
||||
}
|
||||
|
||||
if len(form.Classifications) > 0 {
|
||||
filter["$or"] = []bson.M{
|
||||
{"main_classification": bson.M{"$in": form.Classifications}},
|
||||
{"additional_classifications": bson.M{"$in": form.Classifications}},
|
||||
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 {
|
||||
@@ -595,37 +737,79 @@ func (r *tenderRepository) buildSearchFilter(form *SearchForm) bson.M {
|
||||
filter["currency"] = form.Currency
|
||||
}
|
||||
|
||||
if form.DeadlineFrom != nil || form.DeadlineTo != nil || form.OnlyActiveDeadlines {
|
||||
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 form.DeadlineFrom != nil {
|
||||
deadlineFilter["$gte"] = *form.DeadlineFrom
|
||||
if tenderDeadlineFrom != nil {
|
||||
deadlineFilter["$gte"] = *tenderDeadlineFrom
|
||||
}
|
||||
if form.DeadlineTo != nil {
|
||||
deadlineFilter["$lte"] = *form.DeadlineTo
|
||||
if tenderDeadlineTo != nil {
|
||||
deadlineFilter["$lte"] = *tenderDeadlineTo
|
||||
}
|
||||
filter["tender_deadline"] = deadlineFilter
|
||||
}
|
||||
|
||||
if form.PublicationDateFrom != nil || form.PublicationDateTo != nil {
|
||||
publicationDateFrom, publicationDateTo := exactOrRange(form.PublicationDate, form.PublicationDateFrom, form.PublicationDateTo)
|
||||
if publicationDateFrom != nil || publicationDateTo != nil {
|
||||
pubDateFilter := bson.M{}
|
||||
if form.PublicationDateFrom != nil {
|
||||
pubDateFilter["$gte"] = *form.PublicationDateFrom
|
||||
if publicationDateFrom != nil {
|
||||
pubDateFilter["$gte"] = *publicationDateFrom
|
||||
}
|
||||
if form.PublicationDateTo != nil {
|
||||
pubDateFilter["$lte"] = *form.PublicationDateTo
|
||||
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": form.Status}
|
||||
filter["status"] = bson.M{"$in": dedupeNonEmptyStrings(form.Status)}
|
||||
}
|
||||
|
||||
if len(form.Source) > 0 {
|
||||
filter["source"] = bson.M{"$in": form.Source}
|
||||
filter["source"] = bson.M{"$in": dedupeNonEmptyStrings(form.Source)}
|
||||
}
|
||||
|
||||
if form.BuyerOrganizationID != nil {
|
||||
@@ -633,7 +817,7 @@ func (r *tenderRepository) buildSearchFilter(form *SearchForm) bson.M {
|
||||
}
|
||||
|
||||
if len(form.Languages) > 0 {
|
||||
filter["notice_language_code"] = bson.M{"$in": form.Languages}
|
||||
filter["notice_language_code"] = bson.M{"$in": dedupeNonEmptyStrings(form.Languages)}
|
||||
}
|
||||
|
||||
return filter
|
||||
|
||||
Reference in New Issue
Block a user