package tender import ( "context" "errors" "fmt" "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) } // AISummarizerStorage defines the interface for fetching summaries from MinIO storage type AISummarizerStorage interface { GetSummaryFromStorage(ctx context.Context, noticeID string) (string, error) } // TenderService interface defines tender business logic operations type Service interface { // Tender operations GetByID(ctx context.Context, id 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) // AI summarization operations GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error) TriggerAISummarize(ctx context.Context, id string) (*AISummarizeResponse, error) GetAllAISummaries(ctx context.Context) ([]ai_summarizer.TenderSummaryItem, 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 } // NewService creates a new tender service func NewService(repository TenderRepository, companyService company.Service, customerService customer.Service, logger logger.Logger, aiClient AISummarizerClient, aiStorage AISummarizerStorage) Service { return &tenderService{ repository: repository, companyService: companyService, customerService: customerService, logger: logger, aiSummarizerClient: aiClient, aiSummarizerStorage: aiStorage, } } // GetTenderByID retrieves a tender by ID func (s *tenderService) GetByID(ctx context.Context, id string) (*TenderResponse, error) { s.logger.Info("Retrieving tender by ID", map[string]interface{}{ "tender_id": id, }) 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) } resp := tender.ToResponse() // Enrich with AI summary from MinIO storage (best-effort, non-blocking) s.enrichWithAISummary(ctx, resp) return resp, nil } // enrichWithAISummary attempts to fetch the AI-generated overall summary from // MinIO storage and attaches it to the response. Failures are logged but do not // cause the request to fail — the field will simply be omitted. func (s *tenderService) enrichWithAISummary(ctx context.Context, resp *TenderResponse) { if s.aiSummarizerStorage == nil || resp.NoticePublicationID == "" { return } summary, err := s.aiSummarizerStorage.GetSummaryFromStorage(ctx, resp.NoticePublicationID) if err != nil { s.logger.Debug("AI summary not available for tender", map[string]interface{}{ "tender_id": resp.ID, "notice_id": resp.NoticePublicationID, "error": err.Error(), }) return } resp.OverallSummary = summary } // 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 tender.ToResponse(), 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 } // 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, }) } } } tenders, total, 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(tenders)) for _, tender := range tenders { tenderResponses = append(tenderResponses, *tender.ToResponse()) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.Response(total), }, 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(tenders)) for _, tender := range tenders { tenderResponses = append(tenderResponses, *tender.ToResponse()) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.Response(total), }, 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(tenders), }) tenderResponses := make([]TenderResponse, 0, len(tenders)) for _, tender := range tenders { tenderResponses = append(tenderResponses, *tender.ToResponse()) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.Response(total), }, 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 } } } tenders, total, 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(tenders)) for _, tender := range tenders { tenderResponses = append(tenderResponses, *tender.ToResponse()) } return &SearchResponse{ Tenders: tenderResponses, Metadata: pagination.Response(total), }, 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)}, } oldTenders, _, 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 oldTenders { 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 first tries to fetch from MinIO storage (async pipeline result). // If not found or not ready, it returns an appropriate error. func (s *tenderService) GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error) { if id == "" { return nil, fmt.Errorf("tender ID cannot be empty") } // Get tender to find the notice_publication_id 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) } if t == nil { return nil, fmt.Errorf("tender not found") } noticeID := t.NoticePublicationID if noticeID == "" { return nil, fmt.Errorf("tender has no notice publication ID") } // Try to get summary from MinIO storage (async pipeline) if s.aiSummarizerStorage != nil { summary, err := s.aiSummarizerStorage.GetSummaryFromStorage(ctx, noticeID) if err == nil { return &AISummaryResponse{ NoticeID: noticeID, OverallSummary: summary, Source: "storage", }, nil } // Log but don't fail — the summary might just not be ready yet s.logger.Info("AI summary not available from storage", map[string]interface{}{ "tender_id": id, "notice_id": noticeID, "error": err.Error(), }) // Return specific error if summary not ready if errors.Is(err, ai_summarizer.ErrSummaryNotReady) { return nil, fmt.Errorf("AI summary is not ready yet, the pipeline is still processing") } if errors.Is(err, ai_summarizer.ErrObjectNotFound) { return nil, fmt.Errorf("AI summary not found — this tender has not been processed by the AI pipeline yet") } return nil, fmt.Errorf("failed to get AI summary: %w", err) } return nil, fmt.Errorf("AI summarizer storage is not configured") } // 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") } noticeID := t.NoticePublicationID if noticeID == "" { return nil, fmt.Errorf("tender has no notice publication ID") } // Build the AI summarizer request reqBody := ai_summarizer.SummarizeRequest{ NoticeID: noticeID, DocumentURL: t.DocumentURI, Title: t.Title, Description: t.Description, Country: t.CountryCode, SubmissionDeadline: time.UnixMilli(t.SubmissionDeadline).Format(time.RFC3339), } if t.EstimatedValue > 0 { ev := t.EstimatedValue reqBody.EstimatedValue = &ev } if t.Currency != "" { reqBody.Currency = t.Currency } if t.BuyerOrganization != nil { reqBody.AuthorityName = t.BuyerOrganization.Name } s.logger.Info("Triggering on-demand AI summarization", map[string]interface{}{ "tender_id": id, "notice_id": noticeID, }) // 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, "notice_id": noticeID, "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, "notice_id": noticeID, "summaries_count": len(summaries), }) return &AISummarizeResponse{ NoticeID: resp.NoticeID, 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) { if s.aiSummarizerStorage == nil { return nil, fmt.Errorf("AI summarizer storage is not configured") } 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 { s.logger.Error("Failed to get all AI summaries", map[string]interface{}{ "error": err.Error(), }) 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 }