diff --git a/cmd/web/main.go b/cmd/web/main.go index 81fa6ad..a442f91 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -242,7 +242,7 @@ func main() { categoryService := company_category.NewService(categoryRepository, logger) companyService := company.NewService(companyRepository, categoryService, fileStoreService, aiRecommendationClient, redisClient, conf.AISummarizer.RecommendationCacheTTL, logger) customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, auditLogger) - tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage) + tenderService := tender.NewService(tenderRepository, companyService, customerService, tenderApprovalRepository, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage) feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger) tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger) inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger, xssPolicy) diff --git a/internal/tender/form.go b/internal/tender/form.go index 7a712e0..493c0bb 100644 --- a/internal/tender/form.go +++ b/internal/tender/form.go @@ -65,6 +65,8 @@ type SearchForm struct { DocumentsScraped bool `query:"documents_scraped" valid:"optional"` // ContractFolderIDsWithDocuments is populated by the service from MinIO when DocumentsScraped is true. ContractFolderIDsWithDocuments []string `query:"-" valid:"optional"` + // ExcludeRejectedTenders hides company-rejected tenders from recommendation results. + ExcludeRejectedTenders bool `query:"-" valid:"optional"` } // SearchResponse represents the response for listing tenders diff --git a/internal/tender/handler.go b/internal/tender/handler.go index 6c5dbeb..13b6574 100644 --- a/internal/tender/handler.go +++ b/internal/tender/handler.go @@ -326,7 +326,7 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error { // RecommendTenders returns AI-ranked tender recommendations for the authenticated customer's company // @Summary Get recommended tenders -// @Description Retrieve AI-ranked tenders with full tender details. Only active tenders whose publication-based submission window has not passed are returned (submission_deadline, or 48 working hours after publication_date). AI tender_id values use PROC_{contract_folder_id}/{notice_publication_id}; each item includes rank, analysis, procedure_ref, and the standard tender fields. +// @Description Retrieve AI-ranked tenders with full tender details. Only active tenders whose publication-based submission window has not passed are returned (submission_deadline, or 48 working hours after publication_date). Company-rejected tenders are excluded. AI tender_id values use PROC_{contract_folder_id}/{notice_publication_id}; each item includes rank, analysis, procedure_ref, and the standard tender fields. // @Tags Tenders // @Produce json // @Param limit query int false "Number of items per page (default: 10, max: 100)" @@ -352,6 +352,7 @@ func (h *TenderHandler) RecommendTenders(c echo.Context) error { form.Status = []string{string(TenderStatusActive)} form.OnlyActiveDeadlines = true + form.ExcludeRejectedTenders = true if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" { form.CompanyID = &customerCompanyID diff --git a/internal/tender/recommendation_filter.go b/internal/tender/recommendation_filter.go new file mode 100644 index 0000000..d15a504 --- /dev/null +++ b/internal/tender/recommendation_filter.go @@ -0,0 +1,64 @@ +package tender + +import ( + "context" + "fmt" + "strings" +) + +// CompanyRejectedTenderLister returns tender IDs the company has rejected. +type CompanyRejectedTenderLister interface { + ListRejectedTenderIDsByCompanyID(ctx context.Context, companyID string) ([]string, error) +} + +func (s *tenderService) buildRejectedTenderIDSet(ctx context.Context, companyID string) (map[string]struct{}, error) { + excluded := make(map[string]struct{}) + + if s.rejectedTenderLister == nil { + return excluded, nil + } + + ids, err := s.rejectedTenderLister.ListRejectedTenderIDsByCompanyID(ctx, companyID) + if err != nil { + return nil, fmt.Errorf("failed to list rejected tender exclusions: %w", err) + } + + addTenderIDsToSet(excluded, ids) + return excluded, nil +} + +func addTenderIDsToSet(set map[string]struct{}, ids []string) { + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + set[id] = struct{}{} + } +} + +func isRecommendedTenderExcluded(tender Tender, tenderRef string, excluded map[string]struct{}) bool { + if len(excluded) == 0 { + return false + } + + if _, ok := excluded[tender.GetID()]; ok { + return true + } + + tenderRef = strings.TrimSpace(tenderRef) + if tenderRef != "" { + if _, ok := excluded[tenderRef]; ok { + return true + } + } + + procRef := FormatAIProcedureRef(tender.ContractFolderID, tender.NoticePublicationID) + if procRef != "" { + if _, ok := excluded[procRef]; ok { + return true + } + } + + return false +} diff --git a/internal/tender/recommendation_filter_test.go b/internal/tender/recommendation_filter_test.go new file mode 100644 index 0000000..dc1654f --- /dev/null +++ b/internal/tender/recommendation_filter_test.go @@ -0,0 +1,63 @@ +package tender + +import ( + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "tm/pkg/mongo" +) + +func TestIsRecommendedTenderExcluded(t *testing.T) { + tender := Tender{ + Model: mongo.Model{}, + ContractFolderID: "055329e0-3d8f-4eeb-946a-85a451f2e36b", + NoticePublicationID: "00423458-2026", + } + tender.ID, _ = bson.ObjectIDFromHex("507f1f77bcf86cd799439011") + + procRef := FormatAIProcedureRef(tender.ContractFolderID, tender.NoticePublicationID) + + tests := []struct { + name string + excluded map[string]struct{} + tenderRef string + want bool + }{ + { + name: "empty exclusions", + excluded: map[string]struct{}{}, + want: false, + }, + { + name: "excluded by mongo id", + excluded: map[string]struct{}{tender.GetID(): {}}, + want: true, + }, + { + name: "excluded by procedure ref", + excluded: map[string]struct{}{procRef: {}}, + want: true, + }, + { + name: "excluded by recommendation ref", + excluded: map[string]struct{}{procRef: {}}, + tenderRef: procRef, + want: true, + }, + { + name: "not excluded", + excluded: map[string]struct{}{"other-tender-id": {}}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isRecommendedTenderExcluded(tender, tt.tenderRef, tt.excluded) + if got != tt.want { + t.Fatalf("isRecommendedTenderExcluded() = %v want %v", got, tt.want) + } + }) + } +} diff --git a/internal/tender/service.go b/internal/tender/service.go index e2764c0..3f1817e 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -80,13 +80,14 @@ type Service interface { // tenderService implements TenderService interface type tenderService struct { - repository TenderRepository - companyService company.Service - customerService customer.Service - logger logger.Logger - aiSummarizerClient AISummarizerClient - aiSummarizerStorage AISummarizerStorage - defaultLanguage string + repository TenderRepository + companyService company.Service + customerService customer.Service + rejectedTenderLister CompanyRejectedTenderLister + logger logger.Logger + aiSummarizerClient AISummarizerClient + aiSummarizerStorage AISummarizerStorage + defaultLanguage string } const ( @@ -100,19 +101,29 @@ var ( ) // 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 { +func NewService( + repository TenderRepository, + companyService company.Service, + customerService customer.Service, + rejectedTenderLister CompanyRejectedTenderLister, + 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), + repository: repository, + companyService: companyService, + customerService: customerService, + rejectedTenderLister: rejectedTenderLister, + logger: logger, + aiSummarizerClient: aiClient, + aiSummarizerStorage: aiStorage, + defaultLanguage: strings.ToLower(defaultLanguage), } } @@ -946,6 +957,18 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat return nil, err } + var excluded map[string]struct{} + if form.ExcludeRejectedTenders { + excluded, err = s.buildRejectedTenderIDSet(ctx, companyID) + if err != nil { + s.logger.Error("Failed to build recommendation exclusions", map[string]interface{}{ + "company_id": companyID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to build recommendation exclusions: %w", err) + } + } + lang := s.pickResponseLanguage(form.Language) now := time.Now().Unix() ordered := make([]TenderResponse, 0, len(recommendations)) @@ -955,6 +978,9 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat if !ok { continue } + if isRecommendedTenderExcluded(tender, tenderRef, excluded) { + continue + } if form.OnlyActiveDeadlines && !tender.IsRecommendable(now) { continue } diff --git a/internal/tender_approval/handler.go b/internal/tender_approval/handler.go index 33d0ed6..a973e3b 100644 --- a/internal/tender_approval/handler.go +++ b/internal/tender_approval/handler.go @@ -176,7 +176,7 @@ func (h *Handler) PublicGetTenderApprovals(c echo.Context) error { submissionMode = &mode } - // Parse status filter; default to submitted so "Your Tenders" excludes rejected approvals + // Parse status filter var status *ApprovalStatus if statusStr != "" { statusVal := ApprovalStatus(statusStr) @@ -184,9 +184,6 @@ func (h *Handler) PublicGetTenderApprovals(c echo.Context) error { return response.BadRequest(c, "Invalid status value", "Must be 'submitted' or 'rejected'") } status = &statusVal - } else { - defaultStatus := ApprovalStatusSubmitted - status = &defaultStatus } var from *int64 diff --git a/internal/tender_approval/repository.go b/internal/tender_approval/repository.go index f3aa24b..8c4a4be 100644 --- a/internal/tender_approval/repository.go +++ b/internal/tender_approval/repository.go @@ -3,11 +3,13 @@ package tender_approval import ( "context" "errors" + "strings" "time" "tm/pkg/logger" "tm/pkg/mongo" "go.mongodb.org/mongo-driver/v2/bson" + mongodriver "go.mongodb.org/mongo-driver/v2/mongo" ) // Repository defines the interface for tender approval data operations @@ -30,6 +32,7 @@ type Repository interface { GetTenderApprovalStats(ctx context.Context) (*TenderApprovalStatsResponse, error) GetCompanyTenderApprovalStats(ctx context.Context, companyID string) (*CompanyTenderApprovalStatsResponse, error) SearchByCriteria(ctx context.Context, criteria *TenderApprovalSearchCriteria) ([]*TenderApproval, error) + ListRejectedTenderIDsByCompanyID(ctx context.Context, companyID string) ([]string, error) } // tenderApprovalRepository implements the Repository interface using the MongoDB ORM @@ -533,6 +536,28 @@ func (r *tenderApprovalRepository) GetCompanyTenderApprovalStats(ctx context.Con return stats, nil } +// ListRejectedTenderIDsByCompanyID returns distinct tender IDs rejected by a company. +func (r *tenderApprovalRepository) ListRejectedTenderIDsByCompanyID(ctx context.Context, companyID string) ([]string, error) { + pipeline := mongodriver.Pipeline{ + {{Key: "$match", Value: bson.M{ + "company_id": companyID, + "status": string(ApprovalStatusRejected), + }}}, + {{Key: "$group", Value: bson.M{"_id": "$tender_id"}}}, + } + + results, err := r.ormRepo.Aggregate(ctx, pipeline) + if err != nil { + r.logger.Error("Failed to list rejected tender IDs by company", map[string]interface{}{ + "company_id": companyID, + "error": err.Error(), + }) + return nil, err + } + + return aggregateStringIDs(results), nil +} + // SearchByCriteria searches tender approvals using search criteria func (r *tenderApprovalRepository) SearchByCriteria(ctx context.Context, criteria *TenderApprovalSearchCriteria) ([]*TenderApproval, error) { // Convert criteria to search parameters @@ -549,3 +574,16 @@ func (r *tenderApprovalRepository) SearchByCriteria(ctx context.Context, criteri // Use the search method return r.Search(ctx, criteria.TenderID, criteria.CompanyID, status, submissionMode, criteria.CreatedAtFrom, criteria.CreatedAtTo, criteria.Limit, criteria.Offset, "created_at", "desc") } + +func aggregateStringIDs(results []bson.M) []string { + ids := make([]string, 0, len(results)) + for _, result := range results { + switch id := result["_id"].(type) { + case string: + if strings.TrimSpace(id) != "" { + ids = append(ids, id) + } + } + } + return ids +}