package tender import ( "archive/zip" "context" "errors" "fmt" "io" "mime" "path" "path/filepath" "strings" "time" "tm/internal/company" "tm/internal/customer" "tm/pkg/ai_summarizer" "tm/pkg/logger" "tm/pkg/response" ) // AISummarizerClient defines the interface for on-demand AI summarization type AISummarizerClient interface { FetchSummaryOnDemand(ctx context.Context, reqBody ai_summarizer.SummarizeRequest) (*ai_summarizer.SummarizeResponse, error) FetchTranslationOnDemand(ctx context.Context, reqBody ai_summarizer.TranslateRequest) (*ai_summarizer.TranslateResponse, error) TriggerPipelineTranslate(ctx context.Context, languages []string) (*ai_summarizer.PipelineTranslateResponse, error) } // AISummarizerStorage defines the interface for fetching summaries from MinIO storage. // Paths follow the AI team's procedure-scoped layout: artefacts for a notice live under // PROC_// in the shared bucket. type AISummarizerStorage interface { GetSummaryFromStorage(ctx context.Context, contractFolderID, noticePublicationID string) (string, error) GetTranslationFromStorage(ctx context.Context, contractFolderID, noticePublicationID, language string) (ai_summarizer.StoredTranslation, error) ListDocuments(ctx context.Context, contractFolderID, noticePublicationID string) ([]ai_summarizer.StoredDocument, error) DownloadDocument(ctx context.Context, objectName string) (io.ReadCloser, error) } // TenderDocumentDownload contains an authorized document stream. type TenderDocumentDownload struct { Reader io.ReadCloser Filename string ContentType string Size int64 } // TenderService interface defines tender business logic operations type Service interface { // Tender operations GetByID(ctx context.Context, id string, language string) (*TenderResponse, error) GetByIDs(ctx context.Context, ids []string) (map[string]*TenderResponse, error) Update(ctx context.Context, req UpdateTenderRequest) (*TenderResponse, error) Delete(ctx context.Context, id string) error Search(ctx context.Context, req *SearchForm, pagination *response.Pagination) (*SearchResponse, error) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) GetDocumentSummaries(ctx context.Context, id string) ([]DocumentSummary, error) GetDocuments(ctx context.Context, id string) (*TenderDocumentsResponse, error) DownloadDocuments(ctx context.Context, id string) (*TenderDocumentDownload, error) DownloadDocument(ctx context.Context, id, filename string) (*TenderDocumentDownload, error) // AI summarization operations GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error) TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, error) TriggerAITranslate(ctx context.Context, id, language string) (*AITranslateResponse, error) GetAllAISummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error) // ListTendersWithScrapedDocuments lists tenders that have documents in MinIO. When syncMongoMetadata is true, // each tender is re-listed from MinIO and scraped document metadata is persisted to Mongo (slow). ListTendersWithScrapedDocuments(ctx context.Context, pagination *response.Pagination, syncMongoMetadata bool) (*TendersWithScrapedDocumentsResponse, error) // Maintenance operations UpdateExpiredTenders(ctx context.Context) error CleanupOldData(ctx context.Context, olderThan time.Duration, pagination *response.Pagination) error } // tenderService implements TenderService interface type tenderService struct { repository TenderRepository companyService company.Service customerService customer.Service logger logger.Logger aiSummarizerClient AISummarizerClient aiSummarizerStorage AISummarizerStorage defaultLanguage string } const ( aiSummaryEnrichmentTimeout = 2 * time.Second aiTranslationEnrichmentTimeout = 2 * time.Second ) var ( ErrTenderDocumentStorageUnavailable = errors.New("tender document storage is unavailable") ErrTenderDocumentNotFound = errors.New("tender document not found") ) // NewService creates a new tender service func NewService(repository TenderRepository, companyService company.Service, customerService customer.Service, logger logger.Logger, aiClient AISummarizerClient, aiStorage AISummarizerStorage, defaultLanguage string) Service { if strings.TrimSpace(defaultLanguage) == "" { defaultLanguage = "en" } return &tenderService{ repository: repository, companyService: companyService, customerService: customerService, logger: logger, aiSummarizerClient: aiClient, aiSummarizerStorage: aiStorage, defaultLanguage: strings.ToLower(defaultLanguage), } } // GetByID retrieves a tender by ID. When language is empty, the service default (usually en) // is used to overlay title/description from MinIO translations when available. func (s *tenderService) GetByID(ctx context.Context, id string, language string) (*TenderResponse, error) { s.logger.Info("Retrieving tender by ID", map[string]interface{}{ "tender_id": id, "language": s.resolveDetailLanguage(language), }) tender, err := s.repository.GetByID(ctx, id) if err != nil { s.logger.Error("Failed to retrieve tender", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) return nil, fmt.Errorf("failed to retrieve tender: %w", err) } return s.buildDetailResponse(ctx, tender, language), nil } // GetByIDs loads multiple tenders for list endpoints without MinIO or AI enrichment. func (s *tenderService) GetByIDs(ctx context.Context, ids []string) (map[string]*TenderResponse, error) { if len(ids) == 0 { return map[string]*TenderResponse{}, nil } tenders, err := s.repository.GetByIDs(ctx, ids) if err != nil { s.logger.Error("Failed to retrieve tenders by IDs", map[string]interface{}{ "count": len(ids), "error": err.Error(), }) return nil, fmt.Errorf("failed to retrieve tenders: %w", err) } out := make(map[string]*TenderResponse, len(tenders)) for i := range tenders { resp := tenders[i].ToResponseWithLanguage("") out[resp.ID] = resp } return out, nil } // buildDetailResponse maps a tender to API form and enriches translation (MinIO) and summary. func (s *tenderService) buildDetailResponse(ctx context.Context, tender *Tender, language string) *TenderResponse { resp := tender.ToResponseWithLanguage("") s.enrichWithTranslation(ctx, tender, resp, language) s.enrichWithAISummary(ctx, tender, resp) return resp } // enrichWithAISummary loads overall_summary from MinIO (PROC_//tender.json) // and attaches it to the response. The primary notice publication id is tried first, then related ids. // A previously stored ai_overall_summary on the tender (already mapped in ToResponse) is kept when MinIO // has no ready summary. On success the tender document is updated so list/search can return it without // another MinIO round-trip. func (s *tenderService) enrichWithAISummary(ctx context.Context, t *Tender, resp *TenderResponse) { if t == nil || resp == nil { return } if s.aiSummarizerStorage == nil || strings.TrimSpace(t.ContractFolderID) == "" { return } enrichCtx, cancel := context.WithTimeout(ctx, aiSummaryEnrichmentTimeout) defer cancel() summary, usedNotice, err := s.lookupSummaryForTender(enrichCtx, t) if err != nil { s.logger.Debug("AI summary not available for tender", map[string]interface{}{ "tender_id": resp.ID, "contract_folder_id": t.ContractFolderID, "notice_id": t.NoticePublicationID, "error": err.Error(), }) return } resp.OverallSummary = summary if usedNotice != t.NoticePublicationID { s.logger.Debug("AI summary resolved from related notice", map[string]interface{}{ "tender_id": resp.ID, "contract_folder_id": t.ContractFolderID, "primary_notice_id": t.NoticePublicationID, "used_notice_id": usedNotice, }) } s.persistAIOverallSummary(enrichCtx, t, summary) } // enrichWithTranslation overlays title/description from MinIO when the pipeline has finished // for the requested language. Does not call the AI translate API on GET. func (s *tenderService) enrichWithTranslation(ctx context.Context, t *Tender, resp *TenderResponse, language string) { if t == nil || resp == nil { return } if s.aiSummarizerStorage == nil || strings.TrimSpace(t.ContractFolderID) == "" { return } targetLanguage := s.resolveDetailLanguage(language) enrichCtx, cancel := context.WithTimeout(ctx, aiTranslationEnrichmentTimeout) defer cancel() stored, usedNotice, err := s.lookupTranslationForTender(enrichCtx, t, targetLanguage) if err != nil { s.logger.Debug("Translation not available for tender detail", map[string]interface{}{ "tender_id": resp.ID, "contract_folder_id": t.ContractFolderID, "notice_id": t.NoticePublicationID, "language": targetLanguage, "error": err.Error(), }) return } if strings.TrimSpace(stored.Title) == "" { return } resp.Title = stored.Title resp.Description = stored.Description resp.Language = targetLanguage if usedNotice != t.NoticePublicationID { s.logger.Debug("Translation resolved from related notice", map[string]interface{}{ "tender_id": resp.ID, "contract_folder_id": t.ContractFolderID, "primary_notice_id": t.NoticePublicationID, "used_notice_id": usedNotice, "language": targetLanguage, }) } } // lookupTranslationForTender returns stored translation text, trying primary then related notice ids. func (s *tenderService) lookupTranslationForTender(ctx context.Context, t *Tender, language string) (ai_summarizer.StoredTranslation, string, error) { if s.aiSummarizerStorage == nil { return ai_summarizer.StoredTranslation{}, "", fmt.Errorf("AI summarizer storage is not configured") } candidates := orderedNoticeCandidates(t) if len(candidates) == 0 { return ai_summarizer.StoredTranslation{}, "", ai_summarizer.ErrObjectNotFound } var lastErr error for _, noticeID := range candidates { stored, err := s.aiSummarizerStorage.GetTranslationFromStorage(ctx, t.ContractFolderID, noticeID, language) if err == nil { return stored, noticeID, nil } lastErr = err if !errors.Is(err, ai_summarizer.ErrTranslationNotReady) && !errors.Is(err, ai_summarizer.ErrObjectNotFound) && !errors.Is(err, ai_summarizer.ErrNoTranslation) { if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) { s.logger.Error("MinIO unavailable while fetching translation", map[string]interface{}{ "tender_id": t.ID.Hex(), "contract_folder_id": t.ContractFolderID, "notice_id": noticeID, "language": language, "error": err.Error(), }) } return ai_summarizer.StoredTranslation{}, "", err } } if lastErr == nil { lastErr = ai_summarizer.ErrObjectNotFound } return ai_summarizer.StoredTranslation{}, "", lastErr } // resolveDetailLanguage returns the language code used for detail responses. func (s *tenderService) resolveDetailLanguage(language string) string { clean := strings.ToLower(strings.TrimSpace(language)) if clean == "" { return s.defaultLanguage } return clean } // persistAIOverallSummary stores the resolved summary on the tender for list/search responses. func (s *tenderService) persistAIOverallSummary(ctx context.Context, t *Tender, summary string) { summary = strings.TrimSpace(summary) if summary == "" || summary == strings.TrimSpace(t.AIOverallSummary) { return } t.AIOverallSummary = summary t.UpdatedAt = time.Now().Unix() if err := s.repository.Update(ctx, t); err != nil { s.logger.Warn("Failed to persist AI overall summary on tender", map[string]interface{}{ "tender_id": t.ID.Hex(), "error": err.Error(), }) } } // lookupSummaryForTender returns the overall_summary for a tender by trying its // primary notice publication id first, then each related publication id in // reverse (newest-first) order. The notice id that actually served the summary // is returned alongside. func (s *tenderService) lookupSummaryForTender(ctx context.Context, t *Tender) (string, string, error) { if s.aiSummarizerStorage == nil { return "", "", fmt.Errorf("AI summarizer storage is not configured") } candidates := orderedNoticeCandidates(t) if len(candidates) == 0 { return "", "", ai_summarizer.ErrObjectNotFound } var lastErr error for _, noticeID := range candidates { summary, err := s.aiSummarizerStorage.GetSummaryFromStorage(ctx, t.ContractFolderID, noticeID) if err == nil { return summary, noticeID, nil } lastErr = err // Only "not found" / "not ready" justify trying the next candidate. // Anything else (network/storage error) is propagated immediately. if !errors.Is(err, ai_summarizer.ErrObjectNotFound) && !errors.Is(err, ai_summarizer.ErrSummaryNotReady) && !errors.Is(err, ai_summarizer.ErrNoOverallSummary) { if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) { s.logger.Error("MinIO unavailable while fetching AI summary", map[string]interface{}{ "tender_id": t.ID, "contract_folder_id": t.ContractFolderID, "notice_id": noticeID, "error": err.Error(), }) } return "", noticeID, err } } if lastErr == nil { lastErr = ai_summarizer.ErrObjectNotFound } return "", "", lastErr } // orderedNoticeCandidates returns the publication ids to try when resolving // AI-side artefacts for a (possibly merged) tender, newest-first and deduped. // The primary NoticePublicationID is tried first, then any older ids stored in // RelatedNoticePublicationIDs (which we append oldest-first; reverse so newest // wins). func orderedNoticeCandidates(t *Tender) []string { if t == nil { return nil } seen := make(map[string]struct{}, len(t.RelatedNoticePublicationIDs)+1) out := make([]string, 0, len(t.RelatedNoticePublicationIDs)+1) add := func(id string) { id = strings.TrimSpace(id) if id == "" { return } if _, ok := seen[id]; ok { return } seen[id] = struct{}{} out = append(out, id) } add(t.NoticePublicationID) for i := len(t.RelatedNoticePublicationIDs) - 1; i >= 0; i-- { add(t.RelatedNoticePublicationIDs[i]) } return out } // UpdateTender updates an existing tender func (s *tenderService) Update(ctx context.Context, req UpdateTenderRequest) (*TenderResponse, error) { s.logger.Info("Updating tender", map[string]interface{}{ "tender_id": req.ID, }) // Get existing tender tender, err := s.repository.GetByID(ctx, req.ID) if err != nil { s.logger.Error("Failed to find tender for update", map[string]interface{}{ "tender_id": req.ID, "error": err.Error(), }) return nil, fmt.Errorf("failed to find tender: %w", err) } // Update fields if provided if req.Title != nil { tender.Title = *req.Title } if req.Description != nil { tender.Description = *req.Description } if req.Status != nil { tender.Status = *req.Status } if req.EstimatedValue != nil { tender.EstimatedValue = *req.EstimatedValue } if req.Currency != nil { tender.Currency = *req.Currency } if req.TenderDeadline != nil { tender.TenderDeadline = *req.TenderDeadline } if err := s.repository.Update(ctx, tender); err != nil { s.logger.Error("Failed to update tender", map[string]interface{}{ "tender_id": req.ID, "error": err.Error(), }) return nil, fmt.Errorf("failed to update tender: %w", err) } s.logger.Info("Tender updated successfully", map[string]interface{}{ "tender_id": tender.ID, }) return s.buildDetailResponse(ctx, tender, ""), nil } // Delete deletes a tender func (s *tenderService) Delete(ctx context.Context, id string) error { s.logger.Info("Deleting tender", map[string]interface{}{ "tender_id": id, }) if err := s.repository.Delete(ctx, id); err != nil { s.logger.Error("Failed to delete tender", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) return fmt.Errorf("failed to delete tender: %w", err) } s.logger.Info("Tender deleted successfully", map[string]interface{}{ "tender_id": id, }) return nil } // GetDocumentSummaries retrieves document summaries for a specific tender func (s *tenderService) GetDocumentSummaries(ctx context.Context, id string) ([]DocumentSummary, error) { if id == "" { return nil, fmt.Errorf("tender ID cannot be empty") } // Get tender from repository tender, err := s.repository.GetByID(ctx, id) if err != nil { s.logger.Error("Failed to get tender for document summaries", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) return nil, fmt.Errorf("failed to get tender: %w", err) } if tender == nil { return nil, fmt.Errorf("tender not found") } // Return document summaries (empty slice if none exist) if tender.DocumentSummaries == nil { return []DocumentSummary{}, nil } s.logger.Info("Retrieved document summaries for tender", map[string]interface{}{ "tender_id": id, "summaries_count": len(tender.DocumentSummaries), }) return tender.DocumentSummaries, nil } // GetDocuments retrieves scraped document metadata for a tender. func (s *tenderService) GetDocuments(ctx context.Context, id string) (*TenderDocumentsResponse, error) { if id == "" { return nil, fmt.Errorf("tender ID cannot be empty") } tender, err := s.repository.GetByID(ctx, id) if err != nil { s.logger.Error("Failed to get tender for documents", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) return nil, fmt.Errorf("failed to get tender: %w", err) } empty := func() *TenderDocumentsResponse { return &TenderDocumentsResponse{ TenderID: id, Documents: []TenderDocumentResponse{}, } } if s.aiSummarizerStorage == nil { s.logger.Debug("AI summarizer storage not configured; returning empty tender documents list", map[string]interface{}{ "tender_id": id, }) return empty(), nil } if strings.TrimSpace(tender.ContractFolderID) == "" { return empty(), nil } storedDocuments, err := s.listDocumentsForTender(ctx, tender) if err != nil { s.logger.Warn("Listing tender documents failed; returning empty list", map[string]interface{}{ "tender_id": id, "contract_folder_id": tender.ContractFolderID, "notice_id": tender.NoticePublicationID, "error": err.Error(), }) storedDocuments = nil } documents := make([]TenderDocumentResponse, 0, len(storedDocuments)) for _, doc := range storedDocuments { documents = append(documents, TenderDocumentResponse{ Filename: doc.DocumentName, Size: doc.Size, LastModified: doc.LastModified, DocumentType: doc.DocumentType, }) } s.logger.Info("Retrieved scraped documents for tender", map[string]interface{}{ "tender_id": id, "contract_folder_id": tender.ContractFolderID, "notice_id": tender.NoticePublicationID, "documents_count": len(documents), }) return &TenderDocumentsResponse{ TenderID: id, Documents: documents, }, nil } // listDocumentsForTender unions document listings across every notice tied to a // tender (primary + RelatedNoticePublicationIDs), deduplicating by object name. // This way, a merged tender (e.g. CN + Result for the same procedure) exposes // the documents from every notice that contributed to it. func (s *tenderService) listDocumentsForTender(ctx context.Context, t *Tender) ([]ai_summarizer.StoredDocument, error) { if s.aiSummarizerStorage == nil { return nil, ErrTenderDocumentStorageUnavailable } candidates := orderedNoticeCandidates(t) if len(candidates) == 0 { return nil, nil } seen := make(map[string]struct{}) var aggregated []ai_summarizer.StoredDocument var firstErr error for _, noticeID := range candidates { docs, err := s.aiSummarizerStorage.ListDocuments(ctx, t.ContractFolderID, noticeID) if err != nil { // Remember the first error, but keep trying other notices so a // missing folder for an older notice does not hide newer docs. if firstErr == nil { firstErr = err } continue } for _, doc := range docs { if _, ok := seen[doc.ObjectName]; ok { continue } seen[doc.ObjectName] = struct{}{} aggregated = append(aggregated, doc) } } if len(aggregated) == 0 && firstErr != nil { return nil, firstErr } return aggregated, nil } // DownloadDocuments returns a ZIP stream containing all documents for a tender. func (s *tenderService) DownloadDocuments(ctx context.Context, id string) (*TenderDocumentDownload, error) { if id == "" { return nil, fmt.Errorf("tender ID cannot be empty") } if s.aiSummarizerStorage == nil { return nil, ErrTenderDocumentStorageUnavailable } tender, err := s.repository.GetByID(ctx, id) if err != nil { s.logger.Error("Failed to get tender for document download", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) return nil, fmt.Errorf("failed to get tender: %w", err) } if tender == nil { return nil, fmt.Errorf("tender not found") } if strings.TrimSpace(tender.ContractFolderID) == "" { return nil, ErrTenderDocumentNotFound } if strings.TrimSpace(tender.NoticePublicationID) == "" { return nil, ErrTenderDocumentNotFound } documents, err := s.listDocumentsForTender(ctx, tender) if err != nil { s.logger.Error("Failed to list tender documents for archive", map[string]interface{}{ "tender_id": id, "contract_folder_id": tender.ContractFolderID, "notice_id": tender.NoticePublicationID, "error": err.Error(), }) return nil, fmt.Errorf("failed to list tender documents: %w", err) } if len(documents) == 0 { return nil, ErrTenderDocumentNotFound } reader, writer := io.Pipe() go s.writeDocumentsArchive(ctx, writer, documents) filename := fmt.Sprintf("%s-documents.zip", tender.NoticePublicationID) s.logger.Info("Tender documents archive download prepared", map[string]interface{}{ "tender_id": id, "notice_id": tender.NoticePublicationID, "documents_count": len(documents), }) return &TenderDocumentDownload{ Reader: reader, Filename: filename, ContentType: "application/zip", }, nil } // DownloadDocument streams a single scraped document for a tender. func (s *tenderService) DownloadDocument(ctx context.Context, id, filename string) (*TenderDocumentDownload, error) { if id == "" { return nil, fmt.Errorf("tender ID cannot be empty") } if strings.TrimSpace(filename) == "" { return nil, fmt.Errorf("document filename cannot be empty") } if s.aiSummarizerStorage == nil { return nil, ErrTenderDocumentStorageUnavailable } tender, err := s.repository.GetByID(ctx, id) if err != nil { s.logger.Error("Failed to get tender for document download", map[string]interface{}{ "tender_id": id, "filename": filename, "error": err.Error(), }) return nil, fmt.Errorf("failed to get tender: %w", err) } if tender == nil { return nil, fmt.Errorf("tender not found") } if strings.TrimSpace(tender.ContractFolderID) == "" { return nil, ErrTenderDocumentNotFound } documents, err := s.listDocumentsForTender(ctx, tender) if err != nil { s.logger.Error("Failed to list tender documents for download", map[string]interface{}{ "tender_id": id, "filename": filename, "contract_folder_id": tender.ContractFolderID, "error": err.Error(), }) return nil, fmt.Errorf("failed to list tender documents: %w", err) } doc, ok := findStoredDocumentByFilename(documents, filename) if !ok { return nil, ErrTenderDocumentNotFound } reader, err := s.aiSummarizerStorage.DownloadDocument(ctx, doc.ObjectName) if err != nil { s.logger.Error("Failed to open tender document from storage", map[string]interface{}{ "tender_id": id, "filename": filename, "object_name": doc.ObjectName, "error": err.Error(), }) return nil, fmt.Errorf("failed to download document: %w", err) } downloadName := path.Base(doc.DocumentName) contentType := mime.TypeByExtension(filepath.Ext(downloadName)) if contentType == "" { contentType = "application/octet-stream" } s.logger.Info("Tender document download prepared", map[string]interface{}{ "tender_id": id, "filename": downloadName, "size": doc.Size, }) return &TenderDocumentDownload{ Reader: reader, Filename: downloadName, ContentType: contentType, Size: doc.Size, }, nil } func findStoredDocumentByFilename(documents []ai_summarizer.StoredDocument, filename string) (ai_summarizer.StoredDocument, bool) { filename = strings.TrimSpace(filename) if filename == "" { return ai_summarizer.StoredDocument{}, false } wantBase := path.Base(filename) for _, doc := range documents { if doc.DocumentName == filename || path.Base(doc.DocumentName) == wantBase { return doc, true } } return ai_summarizer.StoredDocument{}, false } func (s *tenderService) writeDocumentsArchive(ctx context.Context, writer *io.PipeWriter, documents []ai_summarizer.StoredDocument) { zipWriter := zip.NewWriter(writer) usedNames := make(map[string]int, len(documents)) for _, doc := range documents { select { case <-ctx.Done(): zipWriter.Close() writer.CloseWithError(ctx.Err()) return default: } if err := s.addDocumentToArchive(ctx, zipWriter, usedNames, doc); err != nil { zipWriter.Close() writer.CloseWithError(err) return } } if err := zipWriter.Close(); err != nil { writer.CloseWithError(err) return } writer.Close() } func (s *tenderService) addDocumentToArchive(ctx context.Context, zipWriter *zip.Writer, usedNames map[string]int, doc ai_summarizer.StoredDocument) error { reader, err := s.aiSummarizerStorage.DownloadDocument(ctx, doc.ObjectName) if err != nil { return fmt.Errorf("failed to open document %s: %w", doc.DocumentName, err) } defer reader.Close() entry, err := zipWriter.Create(uniqueArchiveName(usedNames, doc.DocumentName)) if err != nil { return fmt.Errorf("failed to create archive entry for %s: %w", doc.DocumentName, err) } if _, err := io.Copy(entry, reader); err != nil { return fmt.Errorf("failed to write document %s to archive: %w", doc.DocumentName, err) } return nil } func uniqueArchiveName(usedNames map[string]int, filename string) string { cleanName := path.Base(filename) if cleanName == "." || cleanName == "/" || cleanName == "" { cleanName = "document" } count := usedNames[cleanName] usedNames[cleanName] = count + 1 if count == 0 { return cleanName } extension := filepath.Ext(cleanName) base := strings.TrimSuffix(cleanName, extension) return fmt.Sprintf("%s-%d%s", base, count+1, extension) } // ListTenders retrieves tenders with pagination and filtering func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) { s.logger.Info("Listing tenders", map[string]interface{}{ "limit": pagination.Limit, "offset": pagination.Offset, "company_id": form.CompanyID, "customer_id": form.CustomerID, "from": form.DeadlineFrom, "to": form.DeadlineTo, }) // If customer ID is provided, get customer keywords and add to search if form.CustomerID != nil && *form.CustomerID != "" { customerResp, err := s.customerService.GetByID(ctx, *form.CustomerID) if err == nil && customerResp != nil { // Only use keywords if status is ready if customerResp.KeywordsStatus == "ready" && len(customerResp.Keywords) > 0 { // Combine customer keywords with existing search query keywordsQuery := strings.Join(customerResp.Keywords, " ") if form.Search != nil && *form.Search != "" { combinedSearch := *form.Search + " " + keywordsQuery form.Search = &combinedSearch } else { form.Search = &keywordsQuery } s.logger.Info("Using customer keywords for search", map[string]interface{}{ "customer_id": *form.CustomerID, "keywords": customerResp.Keywords, }) } else { s.logger.Info("Customer keywords not ready, skipping keyword-based search", map[string]interface{}{ "customer_id": *form.CustomerID, "keywords_status": customerResp.KeywordsStatus, }) } } } page, err := s.repository.Search(ctx, form, pagination) if err != nil { s.logger.Error("Failed to list tenders", map[string]interface{}{ "error": err.Error(), }) return nil, fmt.Errorf("failed to list tenders: %w", err) } // If no company ID provided, return tenders without match percentage if form.CompanyID == nil || *form.CompanyID == "" { tenderResponses := make([]TenderResponse, 0, len(page.Items)) for _, tender := range page.Items { tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language))) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } // Get company CPV codes for match calculation comp, err := s.companyService.GetByID(ctx, *form.CompanyID) if err != nil { s.logger.Error("Failed to get company for match calculation", map[string]interface{}{ "company_id": *form.CompanyID, "error": err.Error(), }) // Continue without match percentage if company not found tenderResponses := make([]TenderResponse, 0, len(page.Items)) for _, tender := range page.Items { tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language))) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } var companyCPVCodes []string if comp.Tags != nil && comp.Tags.CPVCodes != nil { companyCPVCodes = comp.Tags.CPVCodes } s.logger.Info("Tenders sorted by match percentage", map[string]interface{}{ "company_id": *form.CompanyID, "company_cpv_codes": len(companyCPVCodes), "total_tenders": len(page.Items), }) tenderResponses := make([]TenderResponse, 0, len(page.Items)) for _, tender := range page.Items { tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language))) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } // Recommend retrieves tenders with pagination and filtering func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) { s.logger.Info("Recommend tenders", map[string]interface{}{ "company_id": form.CompanyID, "customer_id": form.CustomerID, }) form.Status = []string{string(TenderStatusActive)} // Priority: Use customer keywords if available, otherwise use company CPV codes if form.CustomerID != nil && *form.CustomerID != "" { customerResp, err := s.customerService.GetByID(ctx, *form.CustomerID) if err == nil && customerResp != nil { // Only use keywords if status is ready if customerResp.KeywordsStatus == "ready" && len(customerResp.Keywords) > 0 { // Use customer keywords for recommendation keywordsQuery := strings.Join(customerResp.Keywords, " ") form.Search = &keywordsQuery s.logger.Info("Using customer keywords for recommendation", map[string]interface{}{ "customer_id": *form.CustomerID, "keywords": customerResp.Keywords, }) } else { s.logger.Info("Customer keywords not ready, falling back to company CPV codes", map[string]interface{}{ "customer_id": *form.CustomerID, "keywords_status": customerResp.KeywordsStatus, }) } } } // Fallback to company CPV codes if no customer keywords if form.Search == nil || *form.Search == "" { if form.CompanyID != nil && *form.CompanyID != "" { company, err := s.companyService.GetByID(ctx, *form.CompanyID) if err != nil { s.logger.Error("Failed to get company for CPV codes, skipping CPV-based filtering", map[string]interface{}{ "company_id": form.CompanyID, "error": err.Error(), }) // Continue without CPV-based filtering instead of failing } else if company.Tags != nil && len(company.Tags.CPVCodes) > 0 { form.Classifications = company.Tags.CPVCodes } } } page, err := s.repository.Search(ctx, form, pagination) if err != nil { s.logger.Error("Failed to list tenders", map[string]interface{}{ "error": err.Error(), }) return nil, fmt.Errorf("failed to list tenders: %w", err) } tenderResponses := make([]TenderResponse, 0, len(page.Items)) for _, tender := range page.Items { tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language))) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset), }, nil } // Delete deletes a tender // GetTenderStatistics retrieves tender statistics func (s *tenderService) GetTenderStatistics(ctx context.Context) (*TenderStatistics, error) { s.logger.Info("Retrieving tender statistics", map[string]interface{}{}) stats, err := s.repository.GetStatistics(ctx) if err != nil { s.logger.Error("Failed to retrieve tender statistics", map[string]interface{}{ "error": err.Error(), }) return nil, fmt.Errorf("failed to retrieve statistics: %w", err) } return stats, nil } // UpdateExpiredTenders updates status of expired tenders func (s *tenderService) UpdateExpiredTenders(ctx context.Context) error { s.logger.Info("Updating expired tenders", map[string]interface{}{}) // Get tenders that should be expired expiredTenders, err := s.repository.GetExpiredTenders(ctx, time.Now().Unix()) if err != nil { s.logger.Error("Failed to get expired tenders", map[string]interface{}{ "error": err.Error(), }) return fmt.Errorf("failed to get expired tenders: %w", err) } updatedCount := 0 for _, tender := range expiredTenders { if tender.Status != TenderStatusExpired { tender.UpdateStatus(TenderStatusExpired) if err := s.repository.Update(ctx, &tender); err != nil { s.logger.Error("Failed to update expired tender", map[string]interface{}{ "tender_id": tender.ID, "error": err.Error(), }) continue } updatedCount++ } } s.logger.Info("Expired tenders updated", map[string]interface{}{ "total_expired": len(expiredTenders), "updated": updatedCount, }) return nil } // CleanupOldData removes old data based on retention policy func (s *tenderService) CleanupOldData(ctx context.Context, olderThan time.Duration, pagination *response.Pagination) error { s.logger.Info("Cleaning up old data", map[string]interface{}{ "older_than": olderThan.String(), "limit": pagination.Limit, "offset": pagination.Offset, }) cutoffTime := time.Now().Add(-olderThan).Unix() // For now, we'll just log what would be cleaned up // In production, you might want to actually delete old data form := &SearchForm{ Status: []string{string(TenderStatusExpired), string(TenderStatusCancelled), string(TenderStatusAwarded)}, } oldPage, err := s.repository.Search(ctx, form, pagination) if err != nil { s.logger.Error("Failed to find old tenders for cleanup", map[string]interface{}{ "error": err.Error(), }) return fmt.Errorf("failed to find old tenders: %w", err) } cleanupCount := 0 for _, tender := range oldPage.Items { if tender.CreatedAt < cutoffTime { cleanupCount++ // Here you would delete the tender if implementing actual cleanup // s.repository.Delete(ctx, tender.ID) } } s.logger.Info("Old data cleanup completed", map[string]interface{}{ "found_old_tenders": cleanupCount, "cutoff_time": cutoffTime, }) return nil } // GetAISummary retrieves the AI-generated summary for a tender. // It loads from MinIO storage (async pipeline result). When no summary exists yet, // the pipeline is still running, or storage is unavailable, it returns 200-compatible // data with an empty overall_summary and a Source hint instead of an error. func (s *tenderService) GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error) { if id == "" { return nil, fmt.Errorf("tender ID cannot be empty") } t, err := s.repository.GetByID(ctx, id) if err != nil { s.logger.Error("Failed to get tender for AI summary", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) return nil, fmt.Errorf("failed to get tender: %w", err) } noticeID := strings.TrimSpace(t.NoticePublicationID) emptyResp := func(source string) *AISummaryResponse { return &AISummaryResponse{ NoticeID: noticeID, OverallSummary: "", Source: source, } } if noticeID == "" || strings.TrimSpace(t.ContractFolderID) == "" { return emptyResp("unavailable"), nil } if s.aiSummarizerStorage == nil { s.logger.Debug("AI summarizer storage not configured; returning empty AI summary", map[string]interface{}{ "tender_id": id, }) return emptyResp("unavailable"), nil } summary, usedNotice, err := s.lookupSummaryForTender(ctx, t) if err == nil { return &AISummaryResponse{ NoticeID: usedNotice, OverallSummary: summary, Source: "storage", }, nil } s.logger.Info("AI summary not available from storage", map[string]interface{}{ "tender_id": id, "contract_folder_id": t.ContractFolderID, "notice_id": noticeID, "error": err.Error(), }) if errors.Is(err, ai_summarizer.ErrSummaryNotReady) { return emptyResp("pending"), nil } if errors.Is(err, ai_summarizer.ErrObjectNotFound) || errors.Is(err, ai_summarizer.ErrNoOverallSummary) { return emptyResp("unavailable"), nil } if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) { s.logger.Error("Cannot retrieve AI summary: MinIO connection unavailable", map[string]interface{}{ "tender_id": id, "contract_folder_id": t.ContractFolderID, "notice_id": noticeID, "error": err.Error(), }) return nil, fmt.Errorf("failed to get AI summary: MinIO connection unavailable: %w", err) } return nil, fmt.Errorf("failed to get AI summary: %w", err) } // TriggerAISummarize triggers on-demand AI summarization for a tender. // It calls the external AI service HTTP API to generate summaries. func (s *tenderService) TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, error) { if id == "" { return nil, fmt.Errorf("tender ID cannot be empty") } if s.aiSummarizerClient == nil { return nil, fmt.Errorf("AI summarizer service is not configured") } // Get tender to build the request t, err := s.repository.GetByID(ctx, id) if err != nil { s.logger.Error("Failed to get tender for on-demand summarization", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) return nil, fmt.Errorf("failed to get tender: %w", err) } if t == nil { return nil, fmt.Errorf("tender not found") } if t.NoticePublicationID == "" { return nil, fmt.Errorf("tender has no notice publication ID") } if strings.TrimSpace(t.ContractFolderID) == "" { return nil, fmt.Errorf("tender has no contract folder ID") } authorityName := "" if t.BuyerOrganization != nil { authorityName = t.BuyerOrganization.Name } reqBody := ai_summarizer.NewSummarizeRequest(ai_summarizer.SummarizeRequestInput{ ContractFolderID: t.ContractFolderID, NoticePublicationID: t.NoticePublicationID, DocumentURL: t.DocumentURI, Title: t.Title, Description: t.Description, Country: t.CountryCode, Currency: t.Currency, SubmissionDeadline: t.SubmissionDeadline, EstimatedValue: t.EstimatedValue, AuthorityName: authorityName, }) s.logger.Info("Triggering on-demand AI summarization", map[string]interface{}{ "tender_id": id, "contract_folder_id": t.ContractFolderID, "notice_publication_id": t.NoticePublicationID, }) // Call the AI service resp, err := s.aiSummarizerClient.FetchSummaryOnDemand(ctx, reqBody) if err != nil { s.logger.Error("On-demand AI summarization failed", map[string]interface{}{ "tender_id": id, "contract_folder_id": t.ContractFolderID, "notice_publication_id": t.NoticePublicationID, "error": err.Error(), }) return nil, fmt.Errorf("AI summarization request failed: %w", err) } // Map the response to our response type summaries := make([]AIDocumentSummary, 0, len(resp.Summaries)) for _, s := range resp.Summaries { summaries = append(summaries, AIDocumentSummary{ DocumentName: s.DocumentName, DocumentType: s.DocumentType, Summary: s.Summary, SummaryModel: s.SummaryModel, Error: s.Error, }) } s.logger.Info("On-demand AI summarization completed", map[string]interface{}{ "tender_id": id, "contract_folder_id": t.ContractFolderID, "notice_publication_id": t.NoticePublicationID, "summaries_count": len(summaries), }) noticePublicationID := resp.ResolvedNoticePublicationID() if noticePublicationID == "" { noticePublicationID = t.NoticePublicationID } contractFolderID := strings.TrimSpace(resp.ContractFolderID) if contractFolderID == "" { contractFolderID = t.ContractFolderID } return &AISummarizeResponse{ ContractFolderID: contractFolderID, NoticePublicationID: noticePublicationID, Summaries: summaries, OverallSummary: resp.OverallSummary, }, nil } // GetAllAISummaries retrieves all AI-generated summaries from the MinIO storage. // It lists all tender.json files in the bucket and returns summaries for tenders // where the AI pipeline has completed processing. func (s *tenderService) GetAllAISummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error) { s.logger.Info("Retrieving all AI summaries from storage", map[string]interface{}{}) // We need to access the underlying StorageClient to call GetAllSummaries. // Since aiSummarizerStorage is an interface, we type-assert to access GetAllSummaries. type allSummaries interface { GetAllSummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, error) } storage, ok := s.aiSummarizerStorage.(allSummaries) if !ok { return nil, fmt.Errorf("AI summarizer storage does not support batch retrieval") } results, err := storage.GetAllSummaries(ctx) if err != nil { fields := map[string]interface{}{ "error": err.Error(), } if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) { s.logger.Error("Failed to list AI summaries: MinIO connection unavailable", fields) return nil, fmt.Errorf("failed to get all AI summaries: MinIO connection unavailable: %w", err) } s.logger.Error("Failed to get all AI summaries from MinIO", fields) return nil, fmt.Errorf("failed to get all AI summaries: %w", err) } s.logger.Info("Retrieved all AI summaries", map[string]interface{}{ "total_count": len(results), }) return results, nil } // ListTendersWithScrapedDocuments lists database tenders whose contract_folder_id has // documents in MinIO. By default uses counts from the initial MinIO procedure scan only (fast). // Set syncMongoMetadata=true to list every notice folder again and persist ScrapedDocuments to Mongo (slow). func (s *tenderService) ListTendersWithScrapedDocuments(ctx context.Context, pagination *response.Pagination, syncMongoMetadata bool) (*TendersWithScrapedDocumentsResponse, error) { if s.aiSummarizerStorage == nil { return nil, fmt.Errorf("document storage is not configured") } type procedureLister interface { ListProceduresWithDocuments(ctx context.Context) ([]ai_summarizer.ProcedureDocumentsSummary, error) } storage, ok := s.aiSummarizerStorage.(procedureLister) if !ok { return nil, fmt.Errorf("document storage does not support procedure listing") } s.logger.Info("Listing tenders with scraped documents from MinIO", map[string]interface{}{ "limit": pagination.Limit, "offset": pagination.Offset, "sync_mongo_metadata": syncMongoMetadata, }) procedures, err := storage.ListProceduresWithDocuments(ctx) if err != nil { fields := map[string]interface{}{"error": err.Error()} if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) { s.logger.Error("Failed to list scraped documents: MinIO unavailable", fields) return nil, fmt.Errorf("failed to list tenders with scraped documents: MinIO connection unavailable: %w", err) } s.logger.Error("Failed to list procedure documents from MinIO", fields) return nil, fmt.Errorf("failed to list tenders with scraped documents: %w", err) } folderIDs := make([]string, 0, len(procedures)) for _, proc := range procedures { folderIDs = append(folderIDs, proc.ContractFolderID) } tenderByFolder, err := s.repository.GetLatestByContractFolderIDs(ctx, folderIDs) if err != nil { return nil, fmt.Errorf("failed to load tenders by contract_folder_id: %w", err) } matched := make([]TenderScrapedDocumentsItem, 0, len(procedures)) for _, proc := range procedures { if proc.DocumentCount <= 0 { continue } tender, ok := tenderByFolder[strings.TrimSpace(proc.ContractFolderID)] if tender == nil || !ok { continue } item := tender.ToTenderScrapedDocumentsItem() item.DocumentCount = proc.DocumentCount item.DocumentsScrapedAt = proc.LatestModified if syncMongoMetadata { docs, listErr := s.listDocumentsForTender(ctx, tender) if listErr != nil { if errors.Is(listErr, ai_summarizer.ErrMinIOUnavailable) { return nil, fmt.Errorf("failed to list tenders with scraped documents: MinIO connection unavailable: %w", listErr) } s.logger.Debug("Skipping tender: could not list documents from MinIO", map[string]interface{}{ "tender_id": tender.ID.Hex(), "contract_folder_id": proc.ContractFolderID, "error": listErr.Error(), }) continue } if len(docs) == 0 { continue } s.persistScrapedDocuments(ctx, tender, docs) item.DocumentCount = len(docs) if tender.ProcessingMetadata.DocumentsScrapedAt != 0 { item.DocumentsScrapedAt = tender.ProcessingMetadata.DocumentsScrapedAt } } matched = append(matched, item) } total := int64(len(matched)) start := pagination.Offset if start > len(matched) { start = len(matched) } end := start + pagination.Limit if end > len(matched) { end = len(matched) } page := matched[start:end] hasMore := int64(end) < total s.logger.Info("Listed tenders with scraped documents", map[string]interface{}{ "count": len(page), "total_count": total, }) return &TendersWithScrapedDocumentsResponse{ Tenders: page, Metadata: pagination.ListMeta(total, "", hasMore, pagination.Offset), }, nil } // persistScrapedDocuments updates the tender when MinIO has scraped documents for its procedure. func (s *tenderService) persistScrapedDocuments(ctx context.Context, t *Tender, docs []ai_summarizer.StoredDocument) { if t == nil || len(docs) == 0 { return } bucket := "" if named, ok := s.aiSummarizerStorage.(interface{ BucketName() string }); ok { bucket = named.BucketName() } scraped := make([]ScrapedDocument, len(docs)) var latestModified int64 for i, doc := range docs { scraped[i] = ScrapedDocument{ Filename: doc.DocumentName, ObjectName: doc.ObjectName, BucketName: bucket, Size: doc.Size, ScrapedAt: doc.LastModified, } if doc.LastModified > latestModified { latestModified = doc.LastModified } } unchanged := t.ProcessingMetadata.DocumentsScraped && len(t.ScrapedDocuments) == len(scraped) if unchanged { return } t.ProcessingMetadata.DocumentsScraped = true if t.ProcessingMetadata.DocumentsScrapedAt == 0 { if latestModified > 0 { t.ProcessingMetadata.DocumentsScrapedAt = latestModified } else { t.ProcessingMetadata.DocumentsScrapedAt = time.Now().Unix() } } t.ScrapedDocuments = scraped t.UpdatedAt = time.Now().Unix() if err := s.repository.Update(ctx, t); err != nil { s.logger.Warn("Failed to persist scraped documents on tender", map[string]interface{}{ "tender_id": t.ID.Hex(), "contract_folder_id": t.ContractFolderID, "error": err.Error(), }) } } // TriggerAITranslate triggers on-demand AI translation for a tender. Results are // read from MinIO when available, otherwise fetched from the AI service (which persists to MinIO). func (s *tenderService) TriggerAITranslate(ctx context.Context, id, language string) (*AITranslateResponse, error) { if id == "" { return nil, fmt.Errorf("tender ID cannot be empty") } if s.aiSummarizerClient == nil { return nil, fmt.Errorf("AI summarizer service is not configured") } targetLanguage := strings.ToLower(strings.TrimSpace(language)) if targetLanguage == "" { targetLanguage = s.defaultLanguage } t, err := s.repository.GetByID(ctx, id) if err != nil { s.logger.Error("Failed to get tender for AI translation", map[string]interface{}{ "tender_id": id, "error": err.Error(), }) return nil, fmt.Errorf("failed to get tender: %w", err) } if t == nil { return nil, fmt.Errorf("tender not found") } if t.NoticePublicationID == "" { return nil, fmt.Errorf("tender has no notice publication ID") } if strings.TrimSpace(t.ContractFolderID) == "" { return nil, fmt.Errorf("tender has no contract folder ID") } title, description, err := s.resolveTranslation(ctx, t, targetLanguage) if err != nil { s.logger.Error("AI translation failed", map[string]interface{}{ "tender_id": id, "notice_id": t.NoticePublicationID, "language": targetLanguage, "error": err.Error(), }) return nil, fmt.Errorf("AI translation request failed: %w", err) } return s.buildAITranslateResponse(t, targetLanguage, title, description), nil } func (s *tenderService) buildAITranslateResponse(t *Tender, language, title, description string) *AITranslateResponse { return &AITranslateResponse{ ContractFolderID: t.ContractFolderID, NoticePublicationID: t.NoticePublicationID, Language: language, TranslatedTitle: title, TranslatedDescription: description, } } func (s *tenderService) resolveTranslation(ctx context.Context, t *Tender, language string) (string, string, error) { if s.aiSummarizerStorage != nil { stored, err := s.aiSummarizerStorage.GetTranslationFromStorage(ctx, t.ContractFolderID, t.NoticePublicationID, language) if err == nil { return stored.Title, stored.Description, nil } if !errors.Is(err, ai_summarizer.ErrTranslationNotReady) && !errors.Is(err, ai_summarizer.ErrObjectNotFound) && !errors.Is(err, ai_summarizer.ErrNoTranslation) { s.logger.Debug("Translation not available from storage", map[string]interface{}{ "tender_id": t.ID.Hex(), "language": language, "error": err.Error(), }) } } resp, err := s.aiSummarizerClient.FetchTranslationOnDemand(ctx, ai_summarizer.TranslateRequest{ ContractFolderID: t.ContractFolderID, NoticePublicationID: t.NoticePublicationID, Title: t.Title, Description: t.Description, Language: language, }) if err != nil { return "", "", err } return resp.TranslatedTitle, resp.TranslatedDescription, nil } func (s *tenderService) pickResponseLanguage(language *string) string { if language != nil { clean := strings.ToLower(strings.TrimSpace(*language)) if clean != "" { return clean } } return s.defaultLanguage }