Compare commits

...

3 Commits

Author SHA1 Message Date
Mazyar 1edf42187d Implement AI recommendations for multiple companies and enhance company context handling
continuous-integration/drone/push Build is passing
- Added functionality to retrieve merged AI recommendations for multiple companies, improving the relevance of tender suggestions based on company-specific data.
- Introduced normalization functions to clean and deduplicate company IDs, ensuring accurate processing of recommendations.
- Enhanced the company context resolution in customer middleware to support multiple assigned companies, improving the handling of company-specific requests.
- Updated the tender recommendation logic to utilize the new merged recommendations and handle exclusions for rejected tenders accordingly.
- Added unit tests to verify the new recommendation merging logic and company ID normalization, ensuring robust functionality.

This update significantly enhances the tender recommendation process by allowing for more comprehensive and relevant suggestions based on multiple company contexts, improving user experience and satisfaction.
2026-07-04 13:00:33 +03:30
m.nazemi f8c98da132 Merge pull request 'Enhance tender service to exclude company-rejected tenders from recommendations' (#47) from recommendation-approval into develop
Reviewed-on: https://repo.ravanertebat.com/TM/tm_back/pulls/47
2026-07-04 10:46:28 +03:30
Mazyar fe3a71ba2b Enhance tender service to exclude company-rejected tenders from recommendations
- Updated the tender service to include a new dependency for listing rejected tenders by company, allowing for more refined tender recommendations.
- Introduced a new field in the SearchForm to specify whether to exclude rejected tenders from the recommendation results.
- Enhanced the Recommend method to filter out rejected tenders based on the new exclusion logic, improving the relevance of AI-ranked tender recommendations.
- Added unit tests to verify the exclusion logic for rejected tenders, ensuring robust functionality.

This update improves the tender recommendation process by ensuring that company-rejected tenders are not included in the results, enhancing user experience and satisfaction.
2026-07-03 19:55:01 +03:30
15 changed files with 489 additions and 40 deletions
+1 -1
View File
@@ -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)
+15
View File
@@ -101,6 +101,21 @@ func (h *Handler) StartOnboarding(c echo.Context) error {
// @Security BearerAuth
// @Router /api/v1/recommend [post]
func (h *Handler) RecommendTenders(c echo.Context) error {
if companyIDs, ok := c.Get("company_ids").([]string); ok && len(companyIDs) > 0 {
result, err := h.service.GetMergedAIRecommendations(c.Request().Context(), companyIDs)
if err != nil {
if errors.Is(err, ErrAIRecommendationNotConfigured) {
return response.ServiceUnavailable(c, "AI recommendation service is not configured")
}
if err.Error() == "company not found" {
return response.NotFound(c, "Company not found")
}
return response.InternalServerError(c, "Failed to retrieve AI recommendations")
}
return response.Success(c, result, "AI recommendations retrieved successfully")
}
companyID, err := user.GetCompanyIDFromContext(c)
if err != nil {
return response.Unauthorized(c, "User not authenticated")
+61
View File
@@ -24,6 +24,44 @@ const (
recommendationFetchInitialDelay = 10 * time.Second
)
func normalizeRecommendationCompanyIDs(companyIDs []string) []string {
normalized := make([]string, 0, len(companyIDs))
seen := make(map[string]struct{}, len(companyIDs))
for _, companyID := range companyIDs {
clean := strings.TrimSpace(companyID)
if clean == "" {
continue
}
if _, ok := seen[clean]; ok {
continue
}
seen[clean] = struct{}{}
normalized = append(normalized, clean)
}
return normalized
}
func mergeRecommendedTenders(recommendationLists ...[]RecommendedTenderResponse) []RecommendedTenderResponse {
merged := make([]RecommendedTenderResponse, 0)
seen := make(map[string]struct{})
for _, recommendations := range recommendationLists {
for _, recommendation := range recommendations {
tenderID := strings.TrimSpace(recommendation.TenderID)
if tenderID == "" {
continue
}
if _, ok := seen[tenderID]; ok {
continue
}
seen[tenderID] = struct{}{}
recommendation.TenderID = tenderID
recommendation.Analysis = strings.TrimSpace(recommendation.Analysis)
merged = append(merged, recommendation)
}
}
return merged
}
func aiRecommendationCacheKey(companyID string) string {
return aiRecommendationCacheKeyPrefix + companyID
}
@@ -182,6 +220,29 @@ func (s *companyService) GetAIRecommendations(ctx context.Context, companyID str
return []RecommendedTenderResponse{}, nil
}
// GetMergedAIRecommendations unions cached AI recommendations across multiple companies.
func (s *companyService) GetMergedAIRecommendations(ctx context.Context, companyIDs []string) ([]RecommendedTenderResponse, error) {
if s.aiRecommendationClient == nil {
return nil, ErrAIRecommendationNotConfigured
}
normalized := normalizeRecommendationCompanyIDs(companyIDs)
if len(normalized) == 0 {
return []RecommendedTenderResponse{}, nil
}
recommendationLists := make([][]RecommendedTenderResponse, 0, len(normalized))
for _, companyID := range normalized {
recommendations, err := s.GetAIRecommendations(ctx, companyID)
if err != nil {
return nil, err
}
recommendationLists = append(recommendationLists, recommendations)
}
return mergeRecommendedTenders(recommendationLists...), nil
}
func (s *companyService) scheduleRecommendationRefreshAfterOnboarding(companyID string) {
if s.aiRecommendationClient == nil {
return
+44
View File
@@ -0,0 +1,44 @@
package company
import "testing"
func TestMergeRecommendedTenders(t *testing.T) {
first := []RecommendedTenderResponse{
{Rank: 1, TenderID: " PROC-1 ", Analysis: " first "},
{Rank: 2, TenderID: "PROC-2", Analysis: "second"},
}
second := []RecommendedTenderResponse{
{Rank: 1, TenderID: "PROC-2", Analysis: "duplicate"},
{Rank: 3, TenderID: "PROC-3", Analysis: "third"},
{Rank: 4, TenderID: " ", Analysis: "ignored"},
}
got := mergeRecommendedTenders(first, second)
if len(got) != 3 {
t.Fatalf("mergeRecommendedTenders() len = %d, want 3", len(got))
}
if got[0].TenderID != "PROC-1" || got[0].Analysis != "first" {
t.Fatalf("first recommendation = %+v, want trimmed PROC-1/first", got[0])
}
if got[1].TenderID != "PROC-2" || got[1].Rank != 2 {
t.Fatalf("second recommendation = %+v, want original PROC-2 rank 2", got[1])
}
if got[2].TenderID != "PROC-3" {
t.Fatalf("third recommendation = %+v, want PROC-3", got[2])
}
}
func TestNormalizeRecommendationCompanyIDs(t *testing.T) {
got := normalizeRecommendationCompanyIDs([]string{" company-a ", "", "company-b", "company-a"})
want := []string{"company-a", "company-b"}
if len(got) != len(want) {
t.Fatalf("normalizeRecommendationCompanyIDs() len = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("normalizeRecommendationCompanyIDs()[%d] = %q, want %q", i, got[i], want[i])
}
}
}
+9 -8
View File
@@ -44,17 +44,18 @@ type Service interface {
// GetAIRecommendations returns ranked tender recommendations from the AI team
GetAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error)
GetMergedAIRecommendations(ctx context.Context, companyIDs []string) ([]RecommendedTenderResponse, error)
}
// companyService implements the Service interface
type companyService struct {
repository Repository
categoryService company_category.Service
fileStore filestore.FileStoreService
aiRecommendationClient AIRecommendationClient
redisClient redis.Client
recommendationCacheTTL time.Duration
logger logger.Logger
repository Repository
categoryService company_category.Service
fileStore filestore.FileStoreService
aiRecommendationClient AIRecommendationClient
redisClient redis.Client
recommendationCacheTTL time.Duration
logger logger.Logger
}
// NewService creates a new company service
@@ -68,7 +69,7 @@ func NewService(
logger logger.Logger,
) Service {
return &companyService{
repository: repository,
repository: repository,
categoryService: categoryService,
fileStore: fileStore,
aiRecommendationClient: aiRecommendationClient,
+34 -4
View File
@@ -8,10 +8,28 @@ import (
var errCompanyNotAssigned = errors.New("company is not assigned to customer")
func normalizeAssignedCompanyIDs(assigned []string) []string {
normalized := make([]string, 0, len(assigned))
seen := make(map[string]struct{}, len(assigned))
for _, id := range assigned {
clean := strings.TrimSpace(id)
if clean == "" {
continue
}
if _, ok := seen[clean]; ok {
continue
}
seen[clean] = struct{}{}
normalized = append(normalized, clean)
}
return normalized
}
// pickActiveCompanyID chooses the company context for a customer request.
// When requestedCompanyID is set it must be in assigned; otherwise a still-valid
// token company is kept; if the token company was removed, the first assignment is used.
func pickActiveCompanyID(assigned []string, tokenCompanyID, requestedCompanyID string) (string, error) {
assigned = normalizeAssignedCompanyIDs(assigned)
if len(assigned) == 0 {
return "", nil
}
@@ -38,12 +56,24 @@ func pickActiveCompanyID(assigned []string, tokenCompanyID, requestedCompanyID s
return assigned[0], nil
}
// ResolveActiveCompanyID resolves the active company for an authenticated customer.
func (s *customerService) ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error) {
// ResolveCompanyContext resolves the active company and assigned companies for an authenticated customer.
func (s *customerService) ResolveCompanyContext(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, []string, error) {
customer, err := s.repository.GetByID(ctx, customerID)
if err != nil {
return "", errors.New("customer not found")
return "", nil, errors.New("customer not found")
}
return pickActiveCompanyID(customer.Companies, tokenCompanyID, requestedCompanyID)
assigned := normalizeAssignedCompanyIDs(customer.Companies)
companyID, err := pickActiveCompanyID(assigned, tokenCompanyID, requestedCompanyID)
if err != nil {
return "", nil, err
}
return companyID, assigned, nil
}
// ResolveActiveCompanyID resolves the active company for an authenticated customer.
func (s *customerService) ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error) {
companyID, _, err := s.ResolveCompanyContext(ctx, customerID, tokenCompanyID, requestedCompanyID)
return companyID, err
}
+2 -1
View File
@@ -76,7 +76,7 @@ func (h *Handler) CompanyContextMiddleware() echo.MiddlewareFunc {
tokenCompanyID, _ := c.Get("company_id").(string)
requestedCompanyID := strings.TrimSpace(c.QueryParam("company_id"))
companyID, err := h.service.ResolveActiveCompanyID(
companyID, companyIDs, err := h.service.ResolveCompanyContext(
c.Request().Context(),
customerID,
tokenCompanyID,
@@ -90,6 +90,7 @@ func (h *Handler) CompanyContextMiddleware() echo.MiddlewareFunc {
}
c.Set("company_id", companyID)
c.Set("company_ids", companyIDs)
return next(c)
}
}
+4
View File
@@ -54,6 +54,10 @@ type Service interface {
AssignRole(ctx context.Context, customerID string, form *AssignRoleForm) (*AssignRoleResponse, error)
AssignCompanies(ctx context.Context, customerID string, Companies []string) error
// ResolveCompanyContext returns the active company and all assigned companies
// for an authenticated customer request.
ResolveCompanyContext(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, []string, error)
// ResolveActiveCompanyID returns the company context for an authenticated customer.
ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error)
}
+3
View File
@@ -32,6 +32,7 @@ type SearchForm struct {
CountryCodes []string `query:"country_codes" valid:"optional"`
RegionCodes []string `query:"region_codes" valid:"optional"`
CompanyID *string `query:"company_id" valid:"optional"`
CompanyIDs []string `query:"-" valid:"optional"`
CustomerID *string `query:"customer_id" valid:"optional"`
Status []string `query:"status" valid:"optional"`
MainClassification *string `query:"main_classification" valid:"optional"`
@@ -65,6 +66,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
+18 -3
View File
@@ -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,8 +352,24 @@ 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 != "" {
if customerIDStr, ok := c.Get("customer_id").(string); ok && customerIDStr != "" {
form.CustomerID = &customerIDStr
}
requestedCompanyID := ""
if form.CompanyID != nil {
requestedCompanyID = strings.TrimSpace(*form.CompanyID)
}
if requestedCompanyID != "" {
if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" {
form.CompanyID = &customerCompanyID
}
} else if customerCompanyIDs, ok := c.Get("company_ids").([]string); ok && len(customerCompanyIDs) > 0 {
form.CompanyIDs = append([]string(nil), customerCompanyIDs...)
} else if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" {
form.CompanyID = &customerCompanyID
}
@@ -819,4 +835,3 @@ func (h *TenderHandler) GetAllAISummaries(c echo.Context) error {
return response.Success(c, summaries, "AI summaries retrieved successfully")
}
+98
View File
@@ -0,0 +1,98 @@
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 normalizeRecommendationCompanyScope(companyIDs []string) []string {
normalized := make([]string, 0, len(companyIDs))
seen := make(map[string]struct{}, len(companyIDs))
for _, companyID := range companyIDs {
clean := strings.TrimSpace(companyID)
if clean == "" {
continue
}
if _, ok := seen[clean]; ok {
continue
}
seen[clean] = struct{}{}
normalized = append(normalized, clean)
}
return normalized
}
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 (s *tenderService) buildRejectedTenderIDSetForCompanies(ctx context.Context, companyIDs []string) (map[string]struct{}, error) {
excluded := make(map[string]struct{})
for _, companyID := range normalizeRecommendationCompanyScope(companyIDs) {
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
}
@@ -0,0 +1,97 @@
package tender
import (
"context"
"testing"
"go.mongodb.org/mongo-driver/v2/bson"
"tm/pkg/mongo"
)
type stubRejectedTenderLister struct {
byCompany map[string][]string
}
func (s stubRejectedTenderLister) ListRejectedTenderIDsByCompanyID(_ context.Context, companyID string) ([]string, error) {
return s.byCompany[companyID], nil
}
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)
}
})
}
}
func TestBuildRejectedTenderIDSetForCompanies(t *testing.T) {
service := &tenderService{
rejectedTenderLister: stubRejectedTenderLister{
byCompany: map[string][]string{
"company-a": {" tender-1 ", "tender-2"},
"company-b": {"tender-2", "tender-3"},
},
},
}
got, err := service.buildRejectedTenderIDSetForCompanies(context.Background(), []string{"company-a", "company-b", "company-a"})
if err != nil {
t.Fatalf("buildRejectedTenderIDSetForCompanies() error = %v", err)
}
for _, tenderID := range []string{"tender-1", "tender-2", "tender-3"} {
if _, ok := got[tenderID]; !ok {
t.Fatalf("expected excluded set to contain %q", tenderID)
}
}
if len(got) != 3 {
t.Fatalf("buildRejectedTenderIDSetForCompanies() len = %d, want 3", len(got))
}
}
+64 -19
View File
@@ -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),
}
}
@@ -921,19 +932,33 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
// Recommend retrieves AI-ranked tenders for the authenticated customer's company.
func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) {
if form.CompanyID == nil || strings.TrimSpace(*form.CompanyID) == "" {
companyIDs := normalizeRecommendationCompanyScope(form.CompanyIDs)
companyID := ""
if form.CompanyID != nil {
companyID = strings.TrimSpace(*form.CompanyID)
}
if companyID == "" && len(companyIDs) == 0 {
return nil, errors.New("company ID is required")
}
if pagination.Cursor != "" {
return nil, fmt.Errorf("%w: cursor pagination is not supported for recommendations", response.ErrInvalidPagination)
}
companyID := strings.TrimSpace(*form.CompanyID)
s.logger.Info("Recommend tenders via AI service", map[string]interface{}{
"company_id": companyID,
"company_id": companyID,
"company_ids": companyIDs,
})
recommendations, err := s.companyService.GetAIRecommendations(ctx, companyID)
var (
recommendations []company.RecommendedTenderResponse
err error
)
if len(companyIDs) > 0 {
recommendations, err = s.companyService.GetMergedAIRecommendations(ctx, companyIDs)
} else {
recommendations, err = s.companyService.GetAIRecommendations(ctx, companyID)
}
if err != nil {
return nil, err
}
@@ -946,6 +971,23 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat
return nil, err
}
var excluded map[string]struct{}
if form.ExcludeRejectedTenders {
if len(companyIDs) > 0 {
excluded, err = s.buildRejectedTenderIDSetForCompanies(ctx, companyIDs)
} else {
excluded, err = s.buildRejectedTenderIDSet(ctx, companyID)
}
if err != nil {
s.logger.Error("Failed to build recommendation exclusions", map[string]interface{}{
"company_id": companyID,
"company_ids": companyIDs,
"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 +997,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
}
+1 -4
View File
@@ -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
+38
View File
@@ -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
}