Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e31bccced6 | |||
| 45cfa24a72 |
+1
-1
@@ -231,7 +231,7 @@ func main() {
|
|||||||
contactService := contact.NewService(contactRepository, logger, notificationSDK)
|
contactService := contact.NewService(contactRepository, logger, notificationSDK)
|
||||||
cmsService := cms.NewService(cmsRepository, logger)
|
cmsService := cms.NewService(cmsRepository, logger)
|
||||||
kanbanService := kanban.NewService(boardRepository, columnRepository, cardRepository, goRulesClient, conf.GoRules.RuleID, 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)
|
dashboardRepository := dashboard.NewRepository(mongoManager, logger, procedureDocumentsLister)
|
||||||
dashboardService := dashboard.NewService(dashboardRepository, logger)
|
dashboardService := dashboard.NewService(dashboardRepository, logger)
|
||||||
aiPipelineService := ai_pipeline.NewService(aiPipelineClient, logger, tenderService)
|
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 {
|
func facetCount(facet bson.M, key string) int64 {
|
||||||
rows, ok := facet[key].(bson.A)
|
rows := facetRows(facet, key)
|
||||||
if !ok || len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
row, ok := rows[0].(bson.M)
|
return aggregateCount(asBSONMap(rows[0]), "count")
|
||||||
if !ok {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return aggregateCount(row, "count")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func facetCurrencyValue(facet bson.M) (string, float64) {
|
func facetCurrencyValue(facet bson.M) (string, float64) {
|
||||||
rows, ok := facet["currency_value"].(bson.A)
|
rows := facetRows(facet, "currency_value")
|
||||||
if !ok || len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
return defaultValueCurrency, 0
|
return defaultValueCurrency, 0
|
||||||
}
|
}
|
||||||
row, ok := rows[0].(bson.M)
|
row := asBSONMap(rows[0])
|
||||||
if !ok {
|
if row == nil {
|
||||||
return defaultValueCurrency, 0
|
return defaultValueCurrency, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +173,38 @@ func facetCurrencyValue(facet bson.M) (string, float64) {
|
|||||||
return currency, toFloat64(row["total"])
|
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) {
|
func (r *repository) Trend(ctx context.Context, days int, metric string, startUnix int64) (map[string]int64, error) {
|
||||||
field := trendTimestampField(metric)
|
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,
|
"closing_window_hours": windowHours,
|
||||||
})
|
})
|
||||||
|
|
||||||
result, err := s.repo.Summary(ctx, windowSec)
|
result, err := s.repo.Summary(context.WithoutCancel(ctx), windowSec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to fetch dashboard summary", map[string]interface{}{
|
s.logger.Error("Failed to fetch dashboard summary", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@@ -215,7 +215,7 @@ func (s *service) Statistics(ctx context.Context, query StatisticsQuery) (*Stati
|
|||||||
"days": days,
|
"days": days,
|
||||||
})
|
})
|
||||||
|
|
||||||
result, err := s.repo.Statistics(ctx, days, startUnix)
|
result, err := s.repo.Statistics(context.WithoutCancel(ctx), days, startUnix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to fetch dashboard statistics report", map[string]interface{}{
|
s.logger.Error("Failed to fetch dashboard statistics report", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"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).
|
// keeping counts aligned with the tender panel (MinIO is the source of truth for scraped docs).
|
||||||
const scrapedScopeCacheTTL = 5 * time.Minute
|
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.
|
// 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.
|
// zero is set when MinIO reports no procedure folders; callers must return zero without querying.
|
||||||
type scrapedTendersScope struct {
|
type scrapedTendersScope struct {
|
||||||
@@ -28,12 +34,15 @@ type scrapedTendersScope struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *repository) Statistics(ctx context.Context, days int, startUnix int64) (*StatisticsReportResponse, error) {
|
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()
|
now := time.Now().UTC()
|
||||||
endDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
endDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
startDay := endDay.AddDate(0, 0, -(days - 1))
|
startDay := endDay.AddDate(0, 0, -(days - 1))
|
||||||
|
|
||||||
// Resolve scraped-document scope from MinIO (cached); falls back to Mongo flags on error.
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -46,7 +55,7 @@ func (r *repository) Statistics(ctx context.Context, days int, startUnix int64)
|
|||||||
totalTranslated int64
|
totalTranslated int64
|
||||||
)
|
)
|
||||||
|
|
||||||
g, gctx := errgroup.WithContext(ctx)
|
g, gctx := errgroup.WithContext(queryCtx)
|
||||||
|
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
// Use created_at (first ingest time). processing_metadata.scraped_at is reset on
|
// 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()
|
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 {
|
if resolveErr != nil {
|
||||||
r.logger.Warn("MinIO scraped scope unavailable, using Mongo fallback for dashboard statistics", map[string]interface{}{
|
r.logger.Warn("MinIO scraped scope unavailable, using Mongo fallback for dashboard statistics", map[string]interface{}{
|
||||||
"error": resolveErr.Error(),
|
"error": resolveErr.Error(),
|
||||||
@@ -250,19 +262,18 @@ func (r *repository) resolveScrapedTendersScope(ctx context.Context) (scrapedTen
|
|||||||
return scrapedTendersScope{}, err
|
return scrapedTendersScope{}, err
|
||||||
}
|
}
|
||||||
if len(folderIDs) > 0 {
|
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{
|
return scrapedTendersScope{
|
||||||
match: bson.M{"contract_folder_id": bson.M{"$in": folderIDs}},
|
match: bson.M{"contract_folder_id": bson.M{"$in": folderIDs}},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
if r.procedureLister != nil {
|
return mongoScrapedTendersScope(), 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) {
|
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{
|
repo := &repository{
|
||||||
procedureLister: &mockProcedureDocumentsLister{},
|
procedureLister: &mockProcedureDocumentsLister{},
|
||||||
}
|
}
|
||||||
@@ -110,8 +110,11 @@ func TestResolveScrapedTendersScopeZeroWhenMinIOEmpty(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if !scope.zero {
|
if scope.zero {
|
||||||
t.Fatalf("expected zero scope, got %#v", scope)
|
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 (
|
var (
|
||||||
// ErrInvalidDateRange is returned when created_at_from is after created_at_to.
|
// 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")
|
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
|
// ListPendingTenders retrieves tenders that need document scraping
|
||||||
// @Summary List pending tenders for 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
|
// @Tags Document-Scraper
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param limit query int false "Number of items per page (default: 20, max: 100)"
|
// @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"
|
// @Success 200 {object} response.APIResponse{data=DocumentScraperListResponse} "List of pending tenders"
|
||||||
// @Failure 400 {object} response.APIResponse "Invalid query parameters"
|
// @Failure 400 {object} response.APIResponse "Invalid query parameters"
|
||||||
// @Failure 401 {object} response.APIResponse "Invalid or missing API key"
|
// @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"
|
// @Failure 500 {object} response.APIResponse "Internal server error"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Router /api/v1/scraper/tenders [get]
|
// @Router /api/v1/scraper/tenders [get]
|
||||||
@@ -49,6 +50,9 @@ func (h *Handler) ListPendingTenders(c echo.Context) error {
|
|||||||
if errors.Is(err, ErrInvalidDateRange) {
|
if errors.Is(err, ErrInvalidDateRange) {
|
||||||
return response.BadRequest(c, "Invalid date range", err.Error())
|
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")
|
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
|
// GetTenderByNoticeID retrieves a specific tender by its notice publication ID
|
||||||
// @Summary Get tender by notice ID for document scraping
|
// @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
|
// @Tags Document-Scraper
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param notice_id path string true "Notice Publication ID"
|
// @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 400 {object} response.APIResponse "Invalid notice ID"
|
||||||
// @Failure 401 {object} response.APIResponse "Invalid or missing API key"
|
// @Failure 401 {object} response.APIResponse "Invalid or missing API key"
|
||||||
// @Failure 404 {object} response.APIResponse "Tender not found"
|
// @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"
|
// @Failure 500 {object} response.APIResponse "Internal server error"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Router /api/v1/scraper/tenders/{notice_id} [get]
|
// @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)
|
tender, err := h.service.GetTenderByNoticeID(c.Request().Context(), noticeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrScrapePortalsUnavailable) {
|
||||||
|
return response.ServiceUnavailable(c, "Scrape portals service is not configured")
|
||||||
|
}
|
||||||
if err.Error() == "tender not found" {
|
if err.Error() == "tender not found" {
|
||||||
return response.NotFound(c, "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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"tm/internal/tender"
|
"tm/internal/tender"
|
||||||
|
"tm/pkg/ai_summarizer"
|
||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
"tm/pkg/response"
|
"tm/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
var documentScraperCountryCodes = []string{"SWE", "FIN", "NOR"}
|
// ScrapePortalsProvider fetches supported document scraping portal identifiers.
|
||||||
|
type ScrapePortalsProvider interface {
|
||||||
func isDocumentScraperCountry(countryCode string) bool {
|
GetScrapePortals(ctx context.Context) (*ai_summarizer.ScrapePortalsResponse, error)
|
||||||
for _, code := range documentScraperCountryCodes {
|
|
||||||
if countryCode == code {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service defines business logic for the document scraper API
|
// Service defines business logic for the document scraper API
|
||||||
@@ -30,20 +26,48 @@ type Service interface {
|
|||||||
|
|
||||||
type service struct {
|
type service struct {
|
||||||
tenderRepo tender.TenderRepository
|
tenderRepo tender.TenderRepository
|
||||||
|
portalsProvider ScrapePortalsProvider
|
||||||
logger logger.Logger
|
logger logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new document scraper service
|
// 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{
|
return &service{
|
||||||
tenderRepo: tenderRepo,
|
tenderRepo: tenderRepo,
|
||||||
|
portalsProvider: portalsProvider,
|
||||||
logger: logger,
|
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.
|
// 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
|
// Only returns tenders whose document URL matches a portal supported by the AI service.
|
||||||
// deadlines are included; set IncludeExpired to include tenders whose deadline has passed.
|
// 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) {
|
func (s *service) ListPendingTenders(ctx context.Context, req DocumentScraperListRequest) (*DocumentScraperListResponse, *response.Meta, error) {
|
||||||
req.Normalize()
|
req.Normalize()
|
||||||
|
|
||||||
@@ -51,16 +75,30 @@ func (s *service) ListPendingTenders(ctx context.Context, req DocumentScraperLis
|
|||||||
return nil, nil, ErrInvalidDateRange
|
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{}{
|
s.logger.Info("Listing pending tenders for document scraper", map[string]interface{}{
|
||||||
"limit": req.Limit,
|
"limit": req.Limit,
|
||||||
"offset": req.Offset,
|
"offset": req.Offset,
|
||||||
"created_at_from": req.CreatedAtFrom,
|
"created_at_from": req.CreatedAtFrom,
|
||||||
"created_at_to": req.CreatedAtTo,
|
"created_at_to": req.CreatedAtTo,
|
||||||
"include_expired": req.IncludeExpired,
|
"include_expired": req.IncludeExpired,
|
||||||
|
"portal_count": len(portals),
|
||||||
})
|
})
|
||||||
|
|
||||||
filter := tender.PendingDocumentScrapeFilter{
|
filter := tender.PendingDocumentScrapeFilter{
|
||||||
CountryCodes: documentScraperCountryCodes,
|
Portals: portals,
|
||||||
CreatedAtFrom: req.CreatedAtFrom,
|
CreatedAtFrom: req.CreatedAtFrom,
|
||||||
CreatedAtTo: req.CreatedAtTo,
|
CreatedAtTo: req.CreatedAtTo,
|
||||||
Limit: req.Limit,
|
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.
|
// 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) {
|
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{}{
|
s.logger.Info("Getting tender by notice ID for document scraper", map[string]interface{}{
|
||||||
"notice_id": noticeID,
|
"notice_id": noticeID,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
portals, err := s.fetchScrapePortals(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
t, err := s.tenderRepo.GetByNoticePublicationID(ctx, noticeID)
|
t, err := s.tenderRepo.GetByNoticePublicationID(ctx, noticeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to get tender by notice ID", map[string]interface{}{
|
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()
|
now := time.Now().Unix()
|
||||||
if !isDocumentScraperCountry(t.CountryCode) || t.TenderDeadline <= now {
|
if t.TenderDeadline <= now || !matchesScrapePortals(documentURL(t), portals) {
|
||||||
s.logger.Info("Tender filtered out: unsupported country or deadline passed", map[string]interface{}{
|
s.logger.Info("Tender filtered out: unsupported portal or deadline passed", map[string]interface{}{
|
||||||
"notice_id": noticeID,
|
"notice_id": noticeID,
|
||||||
"country_code": t.CountryCode,
|
|
||||||
"tender_deadline": t.TenderDeadline,
|
"tender_deadline": t.TenderDeadline,
|
||||||
"now": now,
|
"now": now,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
package document_scraper
|
package document_scraper
|
||||||
|
|
||||||
import (
|
import "tm/internal/tender"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"tm/internal/tender"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BuildDocumentScraperTenderResponse maps a stored tender to the scraper API contract.
|
// BuildDocumentScraperTenderResponse maps a stored tender to the scraper API contract.
|
||||||
func BuildDocumentScraperTenderResponse(t *tender.Tender) *DocumentScraperTenderResponse {
|
func BuildDocumentScraperTenderResponse(t *tender.Tender) *DocumentScraperTenderResponse {
|
||||||
@@ -12,15 +8,10 @@ func BuildDocumentScraperTenderResponse(t *tender.Tender) *DocumentScraperTender
|
|||||||
return &DocumentScraperTenderResponse{}
|
return &DocumentScraperTenderResponse{}
|
||||||
}
|
}
|
||||||
|
|
||||||
documentURL := strings.TrimSpace(t.DocumentURI)
|
|
||||||
if documentURL == "" {
|
|
||||||
documentURL = strings.TrimSpace(t.TenderURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &DocumentScraperTenderResponse{
|
return &DocumentScraperTenderResponse{
|
||||||
ContractFolderID: t.ContractFolderID,
|
ContractFolderID: t.ContractFolderID,
|
||||||
NoticePublicationID: t.NoticePublicationID,
|
NoticePublicationID: t.NoticePublicationID,
|
||||||
DocumentURL: documentURL,
|
DocumentURL: documentURL(t),
|
||||||
Title: t.Title,
|
Title: t.Title,
|
||||||
Description: t.Description,
|
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.
|
// PendingDocumentScrapeFilter defines query criteria for pending document scrape tenders.
|
||||||
type PendingDocumentScrapeFilter struct {
|
type PendingDocumentScrapeFilter struct {
|
||||||
CountryCodes []string
|
Portals []string
|
||||||
MinDeadline *int64 // when set, tender_deadline must be greater than this value
|
MinDeadline *int64 // when set, tender_deadline must be greater than this value
|
||||||
CreatedAtFrom *int64
|
CreatedAtFrom *int64
|
||||||
CreatedAtTo *int64
|
CreatedAtTo *int64
|
||||||
@@ -565,17 +565,39 @@ type PendingDocumentScrapeFilter struct {
|
|||||||
Skip int
|
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.
|
// 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) {
|
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
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
query := bson.M{
|
query := bson.M{
|
||||||
"country_code": bson.M{"$in": filter.CountryCodes},
|
|
||||||
"processing_metadata.documents_scraped": bson.M{"$ne": true},
|
"processing_metadata.documents_scraped": bson.M{"$ne": true},
|
||||||
}
|
}
|
||||||
|
for key, value := range portalFilter {
|
||||||
|
query[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
if filter.MinDeadline != nil {
|
if filter.MinDeadline != nil {
|
||||||
query["tender_deadline"] = bson.M{
|
query["tender_deadline"] = bson.M{
|
||||||
@@ -606,7 +628,7 @@ func (r *tenderRepository) GetPendingDocumentScrapeTenders(ctx context.Context,
|
|||||||
result, err := r.ormRepo.FindAll(ctx, query, pagination)
|
result, err := r.ormRepo.FindAll(ctx, query, pagination)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.Error("Failed to get pending document scrape tenders", map[string]interface{}{
|
r.logger.Error("Failed to get pending document scrape tenders", map[string]interface{}{
|
||||||
"country_codes": filter.CountryCodes,
|
"portals": filter.Portals,
|
||||||
"min_deadline": filter.MinDeadline,
|
"min_deadline": filter.MinDeadline,
|
||||||
"created_at_from": filter.CreatedAtFrom,
|
"created_at_from": filter.CreatedAtFrom,
|
||||||
"created_at_to": filter.CreatedAtTo,
|
"created_at_to": filter.CreatedAtTo,
|
||||||
|
|||||||
Reference in New Issue
Block a user