Compare commits
2 Commits
550f11a77e
...
e31bccced6
| Author | SHA1 | Date | |
|---|---|---|---|
| e31bccced6 | |||
| 45cfa24a72 |
+1
-1
@@ -231,7 +231,7 @@ func main() {
|
||||
contactService := contact.NewService(contactRepository, logger, notificationSDK)
|
||||
cmsService := cms.NewService(cmsRepository, logger)
|
||||
kanbanService := kanban.NewService(boardRepository, columnRepository, cardRepository, goRulesClient, conf.GoRules.RuleID, logger)
|
||||
documentScraperService := document_scraper.NewService(tenderRepository, logger)
|
||||
documentScraperService := document_scraper.NewService(tenderRepository, aiPipelineClient, logger)
|
||||
dashboardRepository := dashboard.NewRepository(mongoManager, logger, procedureDocumentsLister)
|
||||
dashboardService := dashboard.NewService(dashboardRepository, logger)
|
||||
aiPipelineService := ai_pipeline.NewService(aiPipelineClient, logger, tenderService)
|
||||
|
||||
@@ -149,24 +149,20 @@ func (r *repository) Summary(ctx context.Context, closingWindowSec int64) (*Summ
|
||||
}
|
||||
|
||||
func facetCount(facet bson.M, key string) int64 {
|
||||
rows, ok := facet[key].(bson.A)
|
||||
if !ok || len(rows) == 0 {
|
||||
rows := facetRows(facet, key)
|
||||
if len(rows) == 0 {
|
||||
return 0
|
||||
}
|
||||
row, ok := rows[0].(bson.M)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return aggregateCount(row, "count")
|
||||
return aggregateCount(asBSONMap(rows[0]), "count")
|
||||
}
|
||||
|
||||
func facetCurrencyValue(facet bson.M) (string, float64) {
|
||||
rows, ok := facet["currency_value"].(bson.A)
|
||||
if !ok || len(rows) == 0 {
|
||||
rows := facetRows(facet, "currency_value")
|
||||
if len(rows) == 0 {
|
||||
return defaultValueCurrency, 0
|
||||
}
|
||||
row, ok := rows[0].(bson.M)
|
||||
if !ok {
|
||||
row := asBSONMap(rows[0])
|
||||
if row == nil {
|
||||
return defaultValueCurrency, 0
|
||||
}
|
||||
|
||||
@@ -177,6 +173,38 @@ func facetCurrencyValue(facet bson.M) (string, float64) {
|
||||
return currency, toFloat64(row["total"])
|
||||
}
|
||||
|
||||
func facetRows(facet bson.M, key string) []interface{} {
|
||||
raw, ok := facet[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch rows := raw.(type) {
|
||||
case bson.A:
|
||||
return []interface{}(rows)
|
||||
case []interface{}:
|
||||
return rows
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func asBSONMap(v interface{}) bson.M {
|
||||
switch doc := v.(type) {
|
||||
case bson.M:
|
||||
return doc
|
||||
case bson.D:
|
||||
out := make(bson.M, len(doc))
|
||||
for _, elem := range doc {
|
||||
out[elem.Key] = elem.Value
|
||||
}
|
||||
return out
|
||||
case map[string]interface{}:
|
||||
return doc
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *repository) Trend(ctx context.Context, days int, metric string, startUnix int64) (map[string]int64, error) {
|
||||
field := trendTimestampField(metric)
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestFacetCountDecodesBSOND(t *testing.T) {
|
||||
facet := bson.M{
|
||||
"total": bson.A{
|
||||
bson.D{{Key: "count", Value: int32(42)}},
|
||||
},
|
||||
"active": bson.A{
|
||||
bson.D{{Key: "count", Value: int64(7)}},
|
||||
},
|
||||
}
|
||||
|
||||
if got := facetCount(facet, "total"); got != 42 {
|
||||
t.Fatalf("expected total 42, got %d", got)
|
||||
}
|
||||
if got := facetCount(facet, "active"); got != 7 {
|
||||
t.Fatalf("expected active 7, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacetCurrencyValueDecodesBSOND(t *testing.T) {
|
||||
facet := bson.M{
|
||||
"currency_value": bson.A{
|
||||
bson.D{
|
||||
{Key: "_id", Value: "eur"},
|
||||
{Key: "total", Value: float64(12345.67)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
currency, total := facetCurrencyValue(facet)
|
||||
if currency != "EUR" {
|
||||
t.Fatalf("expected EUR, got %q", currency)
|
||||
}
|
||||
if total != 12345.67 {
|
||||
t.Fatalf("expected 12345.67, got %f", total)
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func (s *service) Summary(ctx context.Context, query SummaryQuery) (*SummaryResp
|
||||
"closing_window_hours": windowHours,
|
||||
})
|
||||
|
||||
result, err := s.repo.Summary(ctx, windowSec)
|
||||
result, err := s.repo.Summary(context.WithoutCancel(ctx), windowSec)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to fetch dashboard summary", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
@@ -215,7 +215,7 @@ func (s *service) Statistics(ctx context.Context, query StatisticsQuery) (*Stati
|
||||
"days": days,
|
||||
})
|
||||
|
||||
result, err := s.repo.Statistics(ctx, days, startUnix)
|
||||
result, err := s.repo.Statistics(context.WithoutCancel(ctx), days, startUnix)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to fetch dashboard statistics report", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
|
||||
@@ -20,6 +20,12 @@ const noticesCollectionName = "notices"
|
||||
// keeping counts aligned with the tender panel (MinIO is the source of truth for scraped docs).
|
||||
const scrapedScopeCacheTTL = 5 * time.Minute
|
||||
|
||||
const (
|
||||
scrapedScopeResolveTimeout = 2 * time.Minute
|
||||
statisticsQueryTimeout = 2 * time.Minute
|
||||
maxScrapedFolderInClause = 5000
|
||||
)
|
||||
|
||||
// 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 {
|
||||
@@ -28,12 +34,15 @@ type scrapedTendersScope struct {
|
||||
}
|
||||
|
||||
func (r *repository) Statistics(ctx context.Context, days int, startUnix int64) (*StatisticsReportResponse, error) {
|
||||
queryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), statisticsQueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
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)
|
||||
scrapedScope, err := r.cachedScrapedTendersScope(queryCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -46,7 +55,7 @@ func (r *repository) Statistics(ctx context.Context, days int, startUnix int64)
|
||||
totalTranslated int64
|
||||
)
|
||||
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
g, gctx := errgroup.WithContext(queryCtx)
|
||||
|
||||
g.Go(func() error {
|
||||
// Use created_at (first ingest time). processing_metadata.scraped_at is reset on
|
||||
@@ -221,7 +230,10 @@ func (r *repository) cachedScrapedTendersScope(ctx context.Context) (scrapedTend
|
||||
}
|
||||
r.scrapedScopeMu.Unlock()
|
||||
|
||||
scope, resolveErr := r.resolveScrapedTendersScope(ctx)
|
||||
resolveCtx, cancel := context.WithTimeout(context.Background(), scrapedScopeResolveTimeout)
|
||||
defer cancel()
|
||||
|
||||
scope, resolveErr := r.resolveScrapedTendersScope(resolveCtx)
|
||||
if resolveErr != nil {
|
||||
r.logger.Warn("MinIO scraped scope unavailable, using Mongo fallback for dashboard statistics", map[string]interface{}{
|
||||
"error": resolveErr.Error(),
|
||||
@@ -250,19 +262,18 @@ func (r *repository) resolveScrapedTendersScope(ctx context.Context) (scrapedTen
|
||||
return scrapedTendersScope{}, err
|
||||
}
|
||||
if len(folderIDs) > 0 {
|
||||
if len(folderIDs) > maxScrapedFolderInClause {
|
||||
r.logger.Warn("Too many MinIO procedure folders for dashboard statistics, using Mongo fallback", map[string]interface{}{
|
||||
"folder_count": len(folderIDs),
|
||||
"max_in_clause": maxScrapedFolderInClause,
|
||||
})
|
||||
return mongoScrapedTendersScope(), nil
|
||||
}
|
||||
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
|
||||
return mongoScrapedTendersScope(), nil
|
||||
}
|
||||
|
||||
func (r *repository) scrapedDocumentsFolderIDs(ctx context.Context) ([]string, error) {
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestResolveScrapedTendersScopeUsesMinIOFolders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveScrapedTendersScopeZeroWhenMinIOEmpty(t *testing.T) {
|
||||
func TestResolveScrapedTendersScopeMongoFallbackWhenMinIOEmpty(t *testing.T) {
|
||||
repo := &repository{
|
||||
procedureLister: &mockProcedureDocumentsLister{},
|
||||
}
|
||||
@@ -110,8 +110,11 @@ func TestResolveScrapedTendersScopeZeroWhenMinIOEmpty(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !scope.zero {
|
||||
t.Fatalf("expected zero scope, got %#v", scope)
|
||||
if scope.zero {
|
||||
t.Fatal("expected mongo fallback scope, not zero scope")
|
||||
}
|
||||
if scope.match["processing_metadata.documents_scraped"] != true {
|
||||
t.Fatalf("expected mongo fallback filter, got %#v", scope.match)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,4 +5,6 @@ import "errors"
|
||||
var (
|
||||
// ErrInvalidDateRange is returned when created_at_from is after created_at_to.
|
||||
ErrInvalidDateRange = errors.New("invalid date range: created_at_from must be before or equal to created_at_to")
|
||||
// ErrScrapePortalsUnavailable is returned when the scrape portals provider is not configured.
|
||||
ErrScrapePortalsUnavailable = errors.New("scrape portals provider is not configured")
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ func NewHandler(service Service, logger logger.Logger) *Handler {
|
||||
|
||||
// ListPendingTenders retrieves tenders that need document scraping
|
||||
// @Summary List pending tenders for document scraping
|
||||
// @Description Retrieve tenders that have not yet been scraped for documents. Each item includes contract_folder_id, notice_publication_id, document_url, title, and description. Optionally filter by created_at range (Unix timestamps in seconds). By default only tenders with active deadlines are returned; set include_expired=true to include expired tenders.
|
||||
// @Description Retrieve tenders that have not yet been scraped for documents and whose document URL matches a portal supported by the AI scraping service. Each item includes contract_folder_id, notice_publication_id, document_url, title, and description. Optionally filter by created_at range (Unix timestamps in seconds). By default only tenders with active deadlines are returned; set include_expired=true to include expired tenders.
|
||||
// @Tags Document-Scraper
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of items per page (default: 20, max: 100)"
|
||||
@@ -35,6 +35,7 @@ func NewHandler(service Service, logger logger.Logger) *Handler {
|
||||
// @Success 200 {object} response.APIResponse{data=DocumentScraperListResponse} "List of pending tenders"
|
||||
// @Failure 400 {object} response.APIResponse "Invalid query parameters"
|
||||
// @Failure 401 {object} response.APIResponse "Invalid or missing API key"
|
||||
// @Failure 503 {object} response.APIResponse "Scrape portals service is not configured"
|
||||
// @Failure 500 {object} response.APIResponse "Internal server error"
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /api/v1/scraper/tenders [get]
|
||||
@@ -49,6 +50,9 @@ func (h *Handler) ListPendingTenders(c echo.Context) error {
|
||||
if errors.Is(err, ErrInvalidDateRange) {
|
||||
return response.BadRequest(c, "Invalid date range", err.Error())
|
||||
}
|
||||
if errors.Is(err, ErrScrapePortalsUnavailable) {
|
||||
return response.ServiceUnavailable(c, "Scrape portals service is not configured")
|
||||
}
|
||||
return response.InternalServerError(c, "Failed to list pending tenders")
|
||||
}
|
||||
|
||||
@@ -57,7 +61,7 @@ func (h *Handler) ListPendingTenders(c echo.Context) error {
|
||||
|
||||
// GetTenderByNoticeID retrieves a specific tender by its notice publication ID
|
||||
// @Summary Get tender by notice ID for document scraping
|
||||
// @Description Retrieve a specific tender by its notice publication ID (contract_folder_id, notice_publication_id, document_url, title, description).
|
||||
// @Description Retrieve a specific unexpired tender whose document URL matches a supported scrape portal.
|
||||
// @Tags Document-Scraper
|
||||
// @Produce json
|
||||
// @Param notice_id path string true "Notice Publication ID"
|
||||
@@ -65,6 +69,7 @@ func (h *Handler) ListPendingTenders(c echo.Context) error {
|
||||
// @Failure 400 {object} response.APIResponse "Invalid notice ID"
|
||||
// @Failure 401 {object} response.APIResponse "Invalid or missing API key"
|
||||
// @Failure 404 {object} response.APIResponse "Tender not found"
|
||||
// @Failure 503 {object} response.APIResponse "Scrape portals service is not configured"
|
||||
// @Failure 500 {object} response.APIResponse "Internal server error"
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /api/v1/scraper/tenders/{notice_id} [get]
|
||||
@@ -76,6 +81,9 @@ func (h *Handler) GetTenderByNoticeID(c echo.Context) error {
|
||||
|
||||
tender, err := h.service.GetTenderByNoticeID(c.Request().Context(), noticeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrScrapePortalsUnavailable) {
|
||||
return response.ServiceUnavailable(c, "Scrape portals service is not configured")
|
||||
}
|
||||
if err.Error() == "tender not found" {
|
||||
return response.NotFound(c, "Tender not found")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package document_scraper
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"tm/internal/tender"
|
||||
)
|
||||
|
||||
// documentURL returns the URL used for portal matching and the scraper API payload.
|
||||
func documentURL(t *tender.Tender) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
if u := strings.TrimSpace(t.DocumentURI); u != "" {
|
||||
return u
|
||||
}
|
||||
return strings.TrimSpace(t.TenderURL)
|
||||
}
|
||||
|
||||
// matchesScrapePortals reports whether url contains any supported portal identifier.
|
||||
func matchesScrapePortals(url string, portals []string) bool {
|
||||
if url == "" || len(portals) == 0 {
|
||||
return false
|
||||
}
|
||||
urlLower := strings.ToLower(url)
|
||||
for _, portal := range portals {
|
||||
p := strings.TrimSpace(portal)
|
||||
if p != "" && strings.Contains(urlLower, strings.ToLower(p)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3,21 +3,17 @@ package document_scraper
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"tm/internal/tender"
|
||||
"tm/pkg/ai_summarizer"
|
||||
"tm/pkg/logger"
|
||||
"tm/pkg/response"
|
||||
)
|
||||
|
||||
var documentScraperCountryCodes = []string{"SWE", "FIN", "NOR"}
|
||||
|
||||
func isDocumentScraperCountry(countryCode string) bool {
|
||||
for _, code := range documentScraperCountryCodes {
|
||||
if countryCode == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
// ScrapePortalsProvider fetches supported document scraping portal identifiers.
|
||||
type ScrapePortalsProvider interface {
|
||||
GetScrapePortals(ctx context.Context) (*ai_summarizer.ScrapePortalsResponse, error)
|
||||
}
|
||||
|
||||
// Service defines business logic for the document scraper API
|
||||
@@ -29,21 +25,49 @@ type Service interface {
|
||||
}
|
||||
|
||||
type service struct {
|
||||
tenderRepo tender.TenderRepository
|
||||
logger logger.Logger
|
||||
tenderRepo tender.TenderRepository
|
||||
portalsProvider ScrapePortalsProvider
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
// NewService creates a new document scraper service
|
||||
func NewService(tenderRepo tender.TenderRepository, logger logger.Logger) Service {
|
||||
func NewService(tenderRepo tender.TenderRepository, portalsProvider ScrapePortalsProvider, logger logger.Logger) Service {
|
||||
return &service{
|
||||
tenderRepo: tenderRepo,
|
||||
logger: logger,
|
||||
tenderRepo: tenderRepo,
|
||||
portalsProvider: portalsProvider,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) fetchScrapePortals(ctx context.Context) ([]string, error) {
|
||||
if s.portalsProvider == nil {
|
||||
s.logger.Warn("Scrape portals provider not configured", map[string]interface{}{})
|
||||
return nil, ErrScrapePortalsUnavailable
|
||||
}
|
||||
|
||||
resp, err := s.portalsProvider.GetScrapePortals(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to fetch scrape portals", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("fetch scrape portals: %w", err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
portals := make([]string, 0, len(*resp))
|
||||
for _, portal := range *resp {
|
||||
if p := strings.TrimSpace(portal); p != "" {
|
||||
portals = append(portals, p)
|
||||
}
|
||||
}
|
||||
return portals, nil
|
||||
}
|
||||
|
||||
// ListPendingTenders retrieves tenders that have not yet been scraped for documents.
|
||||
// Only returns tenders from Sweden, Finland, and Norway. By default only tenders with active
|
||||
// deadlines are included; set IncludeExpired to include tenders whose deadline has passed.
|
||||
// Only returns tenders whose document URL matches a portal supported by the AI service.
|
||||
// By default only tenders with active deadlines are included; set IncludeExpired to include expired tenders.
|
||||
func (s *service) ListPendingTenders(ctx context.Context, req DocumentScraperListRequest) (*DocumentScraperListResponse, *response.Meta, error) {
|
||||
req.Normalize()
|
||||
|
||||
@@ -51,16 +75,30 @@ func (s *service) ListPendingTenders(ctx context.Context, req DocumentScraperLis
|
||||
return nil, nil, ErrInvalidDateRange
|
||||
}
|
||||
|
||||
portals, err := s.fetchScrapePortals(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(portals) == 0 {
|
||||
s.logger.Info("No scrape portals configured; returning empty pending tender list", map[string]interface{}{})
|
||||
meta := &response.Meta{Total: 0, Limit: req.Limit, Offset: req.Offset, HasMore: false}
|
||||
if meta.Limit > 0 {
|
||||
meta.Page = (req.Offset / meta.Limit) + 1
|
||||
}
|
||||
return &DocumentScraperListResponse{Tenders: []DocumentScraperTenderResponse{}}, meta, nil
|
||||
}
|
||||
|
||||
s.logger.Info("Listing pending tenders for document scraper", map[string]interface{}{
|
||||
"limit": req.Limit,
|
||||
"offset": req.Offset,
|
||||
"created_at_from": req.CreatedAtFrom,
|
||||
"created_at_to": req.CreatedAtTo,
|
||||
"include_expired": req.IncludeExpired,
|
||||
"limit": req.Limit,
|
||||
"offset": req.Offset,
|
||||
"created_at_from": req.CreatedAtFrom,
|
||||
"created_at_to": req.CreatedAtTo,
|
||||
"include_expired": req.IncludeExpired,
|
||||
"portal_count": len(portals),
|
||||
})
|
||||
|
||||
filter := tender.PendingDocumentScrapeFilter{
|
||||
CountryCodes: documentScraperCountryCodes,
|
||||
Portals: portals,
|
||||
CreatedAtFrom: req.CreatedAtFrom,
|
||||
CreatedAtTo: req.CreatedAtTo,
|
||||
Limit: req.Limit,
|
||||
@@ -106,12 +144,17 @@ func (s *service) ListPendingTenders(ctx context.Context, req DocumentScraperLis
|
||||
}
|
||||
|
||||
// GetTenderByNoticeID retrieves a specific tender by its notice publication ID.
|
||||
// Only returns tenders from Sweden, Finland, and Norway with active deadlines (deadline not yet reached).
|
||||
// Only returns unexpired tenders whose document URL matches a supported scrape portal.
|
||||
func (s *service) GetTenderByNoticeID(ctx context.Context, noticeID string) (*DocumentScraperTenderResponse, error) {
|
||||
s.logger.Info("Getting tender by notice ID for document scraper", map[string]interface{}{
|
||||
"notice_id": noticeID,
|
||||
})
|
||||
|
||||
portals, err := s.fetchScrapePortals(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t, err := s.tenderRepo.GetByNoticePublicationID(ctx, noticeID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get tender by notice ID", map[string]interface{}{
|
||||
@@ -126,10 +169,9 @@ func (s *service) GetTenderByNoticeID(ctx context.Context, noticeID string) (*Do
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
if !isDocumentScraperCountry(t.CountryCode) || t.TenderDeadline <= now {
|
||||
s.logger.Info("Tender filtered out: unsupported country or deadline passed", map[string]interface{}{
|
||||
if t.TenderDeadline <= now || !matchesScrapePortals(documentURL(t), portals) {
|
||||
s.logger.Info("Tender filtered out: unsupported portal or deadline passed", map[string]interface{}{
|
||||
"notice_id": noticeID,
|
||||
"country_code": t.CountryCode,
|
||||
"tender_deadline": t.TenderDeadline,
|
||||
"now": now,
|
||||
})
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package document_scraper
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"tm/internal/tender"
|
||||
)
|
||||
import "tm/internal/tender"
|
||||
|
||||
// BuildDocumentScraperTenderResponse maps a stored tender to the scraper API contract.
|
||||
func BuildDocumentScraperTenderResponse(t *tender.Tender) *DocumentScraperTenderResponse {
|
||||
@@ -12,15 +8,10 @@ func BuildDocumentScraperTenderResponse(t *tender.Tender) *DocumentScraperTender
|
||||
return &DocumentScraperTenderResponse{}
|
||||
}
|
||||
|
||||
documentURL := strings.TrimSpace(t.DocumentURI)
|
||||
if documentURL == "" {
|
||||
documentURL = strings.TrimSpace(t.TenderURL)
|
||||
}
|
||||
|
||||
return &DocumentScraperTenderResponse{
|
||||
ContractFolderID: t.ContractFolderID,
|
||||
NoticePublicationID: t.NoticePublicationID,
|
||||
DocumentURL: documentURL,
|
||||
DocumentURL: documentURL(t),
|
||||
Title: t.Title,
|
||||
Description: t.Description,
|
||||
}
|
||||
|
||||
@@ -557,7 +557,7 @@ func (r *tenderRepository) GetExpiredTenders(ctx context.Context, beforeDate int
|
||||
|
||||
// PendingDocumentScrapeFilter defines query criteria for pending document scrape tenders.
|
||||
type PendingDocumentScrapeFilter struct {
|
||||
CountryCodes []string
|
||||
Portals []string
|
||||
MinDeadline *int64 // when set, tender_deadline must be greater than this value
|
||||
CreatedAtFrom *int64
|
||||
CreatedAtTo *int64
|
||||
@@ -565,17 +565,39 @@ type PendingDocumentScrapeFilter struct {
|
||||
Skip int
|
||||
}
|
||||
|
||||
// GetPendingDocumentScrapeTenders returns unscraped tenders for the given countries.
|
||||
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) {
|
||||
if len(filter.CountryCodes) == 0 {
|
||||
portalFilter := scrapePortalURLFilter(filter.Portals)
|
||||
if portalFilter == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
query := bson.M{
|
||||
"country_code": bson.M{"$in": filter.CountryCodes},
|
||||
"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{
|
||||
@@ -606,7 +628,7 @@ func (r *tenderRepository) GetPendingDocumentScrapeTenders(ctx context.Context,
|
||||
result, err := r.ormRepo.FindAll(ctx, query, pagination)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to get pending document scrape tenders", map[string]interface{}{
|
||||
"country_codes": filter.CountryCodes,
|
||||
"portals": filter.Portals,
|
||||
"min_deadline": filter.MinDeadline,
|
||||
"created_at_from": filter.CreatedAtFrom,
|
||||
"created_at_to": filter.CreatedAtTo,
|
||||
|
||||
Reference in New Issue
Block a user