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
This commit is contained in:
m.nazemi
2026-07-04 10:46:28 +03:30
8 changed files with 212 additions and 21 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)
+2
View File
@@ -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
+2 -1
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,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
+64
View File
@@ -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
}
@@ -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)
}
})
}
}
+27 -1
View File
@@ -83,6 +83,7 @@ type tenderService struct {
repository TenderRepository
companyService company.Service
customerService customer.Service
rejectedTenderLister CompanyRejectedTenderLister
logger logger.Logger
aiSummarizerClient AISummarizerClient
aiSummarizerStorage AISummarizerStorage
@@ -100,7 +101,16 @@ 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"
}
@@ -109,6 +119,7 @@ func NewService(repository TenderRepository, companyService company.Service, cus
repository: repository,
companyService: companyService,
customerService: customerService,
rejectedTenderLister: rejectedTenderLister,
logger: logger,
aiSummarizerClient: aiClient,
aiSummarizerStorage: aiStorage,
@@ -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
}
+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
}