Compare commits
3 Commits
ec7db8b9f0
...
1edf42187d
| Author | SHA1 | Date | |
|---|---|---|---|
| 1edf42187d | |||
| f8c98da132 | |||
| fe3a71ba2b |
+1
-1
@@ -242,7 +242,7 @@ func main() {
|
|||||||
categoryService := company_category.NewService(categoryRepository, logger)
|
categoryService := company_category.NewService(categoryRepository, logger)
|
||||||
companyService := company.NewService(companyRepository, categoryService, fileStoreService, aiRecommendationClient, redisClient, conf.AISummarizer.RecommendationCacheTTL, logger)
|
companyService := company.NewService(companyRepository, categoryService, fileStoreService, aiRecommendationClient, redisClient, conf.AISummarizer.RecommendationCacheTTL, logger)
|
||||||
customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, auditLogger)
|
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)
|
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
|
||||||
tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger)
|
tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger)
|
||||||
inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger, xssPolicy)
|
inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger, xssPolicy)
|
||||||
|
|||||||
@@ -101,6 +101,21 @@ func (h *Handler) StartOnboarding(c echo.Context) error {
|
|||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/recommend [post]
|
// @Router /api/v1/recommend [post]
|
||||||
func (h *Handler) RecommendTenders(c echo.Context) error {
|
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)
|
companyID, err := user.GetCompanyIDFromContext(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response.Unauthorized(c, "User not authenticated")
|
return response.Unauthorized(c, "User not authenticated")
|
||||||
|
|||||||
@@ -24,6 +24,44 @@ const (
|
|||||||
recommendationFetchInitialDelay = 10 * time.Second
|
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 {
|
func aiRecommendationCacheKey(companyID string) string {
|
||||||
return aiRecommendationCacheKeyPrefix + companyID
|
return aiRecommendationCacheKeyPrefix + companyID
|
||||||
}
|
}
|
||||||
@@ -182,6 +220,29 @@ func (s *companyService) GetAIRecommendations(ctx context.Context, companyID str
|
|||||||
return []RecommendedTenderResponse{}, nil
|
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) {
|
func (s *companyService) scheduleRecommendationRefreshAfterOnboarding(companyID string) {
|
||||||
if s.aiRecommendationClient == nil {
|
if s.aiRecommendationClient == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,17 +44,18 @@ type Service interface {
|
|||||||
|
|
||||||
// GetAIRecommendations returns ranked tender recommendations from the AI team
|
// GetAIRecommendations returns ranked tender recommendations from the AI team
|
||||||
GetAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error)
|
GetAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error)
|
||||||
|
GetMergedAIRecommendations(ctx context.Context, companyIDs []string) ([]RecommendedTenderResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// companyService implements the Service interface
|
// companyService implements the Service interface
|
||||||
type companyService struct {
|
type companyService struct {
|
||||||
repository Repository
|
repository Repository
|
||||||
categoryService company_category.Service
|
categoryService company_category.Service
|
||||||
fileStore filestore.FileStoreService
|
fileStore filestore.FileStoreService
|
||||||
aiRecommendationClient AIRecommendationClient
|
aiRecommendationClient AIRecommendationClient
|
||||||
redisClient redis.Client
|
redisClient redis.Client
|
||||||
recommendationCacheTTL time.Duration
|
recommendationCacheTTL time.Duration
|
||||||
logger logger.Logger
|
logger logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new company service
|
// NewService creates a new company service
|
||||||
@@ -68,7 +69,7 @@ func NewService(
|
|||||||
logger logger.Logger,
|
logger logger.Logger,
|
||||||
) Service {
|
) Service {
|
||||||
return &companyService{
|
return &companyService{
|
||||||
repository: repository,
|
repository: repository,
|
||||||
categoryService: categoryService,
|
categoryService: categoryService,
|
||||||
fileStore: fileStore,
|
fileStore: fileStore,
|
||||||
aiRecommendationClient: aiRecommendationClient,
|
aiRecommendationClient: aiRecommendationClient,
|
||||||
|
|||||||
@@ -8,10 +8,28 @@ import (
|
|||||||
|
|
||||||
var errCompanyNotAssigned = errors.New("company is not assigned to customer")
|
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.
|
// pickActiveCompanyID chooses the company context for a customer request.
|
||||||
// When requestedCompanyID is set it must be in assigned; otherwise a still-valid
|
// 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.
|
// token company is kept; if the token company was removed, the first assignment is used.
|
||||||
func pickActiveCompanyID(assigned []string, tokenCompanyID, requestedCompanyID string) (string, error) {
|
func pickActiveCompanyID(assigned []string, tokenCompanyID, requestedCompanyID string) (string, error) {
|
||||||
|
assigned = normalizeAssignedCompanyIDs(assigned)
|
||||||
if len(assigned) == 0 {
|
if len(assigned) == 0 {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -38,12 +56,24 @@ func pickActiveCompanyID(assigned []string, tokenCompanyID, requestedCompanyID s
|
|||||||
return assigned[0], nil
|
return assigned[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveActiveCompanyID resolves the active company for an authenticated customer.
|
// ResolveCompanyContext resolves the active company and assigned companies for an authenticated customer.
|
||||||
func (s *customerService) ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error) {
|
func (s *customerService) ResolveCompanyContext(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, []string, error) {
|
||||||
customer, err := s.repository.GetByID(ctx, customerID)
|
customer, err := s.repository.GetByID(ctx, customerID)
|
||||||
if err != nil {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func (h *Handler) CompanyContextMiddleware() echo.MiddlewareFunc {
|
|||||||
tokenCompanyID, _ := c.Get("company_id").(string)
|
tokenCompanyID, _ := c.Get("company_id").(string)
|
||||||
requestedCompanyID := strings.TrimSpace(c.QueryParam("company_id"))
|
requestedCompanyID := strings.TrimSpace(c.QueryParam("company_id"))
|
||||||
|
|
||||||
companyID, err := h.service.ResolveActiveCompanyID(
|
companyID, companyIDs, err := h.service.ResolveCompanyContext(
|
||||||
c.Request().Context(),
|
c.Request().Context(),
|
||||||
customerID,
|
customerID,
|
||||||
tokenCompanyID,
|
tokenCompanyID,
|
||||||
@@ -90,6 +90,7 @@ func (h *Handler) CompanyContextMiddleware() echo.MiddlewareFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.Set("company_id", companyID)
|
c.Set("company_id", companyID)
|
||||||
|
c.Set("company_ids", companyIDs)
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ type Service interface {
|
|||||||
AssignRole(ctx context.Context, customerID string, form *AssignRoleForm) (*AssignRoleResponse, error)
|
AssignRole(ctx context.Context, customerID string, form *AssignRoleForm) (*AssignRoleResponse, error)
|
||||||
AssignCompanies(ctx context.Context, customerID string, Companies []string) 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 returns the company context for an authenticated customer.
|
||||||
ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error)
|
ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type SearchForm struct {
|
|||||||
CountryCodes []string `query:"country_codes" valid:"optional"`
|
CountryCodes []string `query:"country_codes" valid:"optional"`
|
||||||
RegionCodes []string `query:"region_codes" valid:"optional"`
|
RegionCodes []string `query:"region_codes" valid:"optional"`
|
||||||
CompanyID *string `query:"company_id" valid:"optional"`
|
CompanyID *string `query:"company_id" valid:"optional"`
|
||||||
|
CompanyIDs []string `query:"-" valid:"optional"`
|
||||||
CustomerID *string `query:"customer_id" valid:"optional"`
|
CustomerID *string `query:"customer_id" valid:"optional"`
|
||||||
Status []string `query:"status" valid:"optional"`
|
Status []string `query:"status" valid:"optional"`
|
||||||
MainClassification *string `query:"main_classification" valid:"optional"`
|
MainClassification *string `query:"main_classification" valid:"optional"`
|
||||||
@@ -65,6 +66,8 @@ type SearchForm struct {
|
|||||||
DocumentsScraped bool `query:"documents_scraped" valid:"optional"`
|
DocumentsScraped bool `query:"documents_scraped" valid:"optional"`
|
||||||
// ContractFolderIDsWithDocuments is populated by the service from MinIO when DocumentsScraped is true.
|
// ContractFolderIDsWithDocuments is populated by the service from MinIO when DocumentsScraped is true.
|
||||||
ContractFolderIDsWithDocuments []string `query:"-" valid:"optional"`
|
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
|
// SearchResponse represents the response for listing tenders
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
|
|||||||
|
|
||||||
// RecommendTenders returns AI-ranked tender recommendations for the authenticated customer's company
|
// RecommendTenders returns AI-ranked tender recommendations for the authenticated customer's company
|
||||||
// @Summary Get recommended tenders
|
// @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
|
// @Tags Tenders
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param limit query int false "Number of items per page (default: 10, max: 100)"
|
// @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.Status = []string{string(TenderStatusActive)}
|
||||||
form.OnlyActiveDeadlines = true
|
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
|
form.CompanyID = &customerCompanyID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,4 +835,3 @@ func (h *TenderHandler) GetAllAISummaries(c echo.Context) error {
|
|||||||
|
|
||||||
return response.Success(c, summaries, "AI summaries retrieved successfully")
|
return response.Success(c, summaries, "AI summaries retrieved successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -80,13 +80,14 @@ type Service interface {
|
|||||||
|
|
||||||
// tenderService implements TenderService interface
|
// tenderService implements TenderService interface
|
||||||
type tenderService struct {
|
type tenderService struct {
|
||||||
repository TenderRepository
|
repository TenderRepository
|
||||||
companyService company.Service
|
companyService company.Service
|
||||||
customerService customer.Service
|
customerService customer.Service
|
||||||
logger logger.Logger
|
rejectedTenderLister CompanyRejectedTenderLister
|
||||||
aiSummarizerClient AISummarizerClient
|
logger logger.Logger
|
||||||
aiSummarizerStorage AISummarizerStorage
|
aiSummarizerClient AISummarizerClient
|
||||||
defaultLanguage string
|
aiSummarizerStorage AISummarizerStorage
|
||||||
|
defaultLanguage string
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -100,19 +101,29 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NewService creates a new tender service
|
// 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) == "" {
|
if strings.TrimSpace(defaultLanguage) == "" {
|
||||||
defaultLanguage = "en"
|
defaultLanguage = "en"
|
||||||
}
|
}
|
||||||
|
|
||||||
return &tenderService{
|
return &tenderService{
|
||||||
repository: repository,
|
repository: repository,
|
||||||
companyService: companyService,
|
companyService: companyService,
|
||||||
customerService: customerService,
|
customerService: customerService,
|
||||||
logger: logger,
|
rejectedTenderLister: rejectedTenderLister,
|
||||||
aiSummarizerClient: aiClient,
|
logger: logger,
|
||||||
aiSummarizerStorage: aiStorage,
|
aiSummarizerClient: aiClient,
|
||||||
defaultLanguage: strings.ToLower(defaultLanguage),
|
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.
|
// 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) {
|
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")
|
return nil, errors.New("company ID is required")
|
||||||
}
|
}
|
||||||
if pagination.Cursor != "" {
|
if pagination.Cursor != "" {
|
||||||
return nil, fmt.Errorf("%w: cursor pagination is not supported for recommendations", response.ErrInvalidPagination)
|
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{}{
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -946,6 +971,23 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat
|
|||||||
return nil, err
|
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)
|
lang := s.pickResponseLanguage(form.Language)
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
ordered := make([]TenderResponse, 0, len(recommendations))
|
ordered := make([]TenderResponse, 0, len(recommendations))
|
||||||
@@ -955,6 +997,9 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if isRecommendedTenderExcluded(tender, tenderRef, excluded) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if form.OnlyActiveDeadlines && !tender.IsRecommendable(now) {
|
if form.OnlyActiveDeadlines && !tender.IsRecommendable(now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ func (h *Handler) PublicGetTenderApprovals(c echo.Context) error {
|
|||||||
submissionMode = &mode
|
submissionMode = &mode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse status filter; default to submitted so "Your Tenders" excludes rejected approvals
|
// Parse status filter
|
||||||
var status *ApprovalStatus
|
var status *ApprovalStatus
|
||||||
if statusStr != "" {
|
if statusStr != "" {
|
||||||
statusVal := ApprovalStatus(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'")
|
return response.BadRequest(c, "Invalid status value", "Must be 'submitted' or 'rejected'")
|
||||||
}
|
}
|
||||||
status = &statusVal
|
status = &statusVal
|
||||||
} else {
|
|
||||||
defaultStatus := ApprovalStatusSubmitted
|
|
||||||
status = &defaultStatus
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var from *int64
|
var from *int64
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package tender_approval
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
"tm/pkg/mongo"
|
"tm/pkg/mongo"
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository defines the interface for tender approval data operations
|
// Repository defines the interface for tender approval data operations
|
||||||
@@ -30,6 +32,7 @@ type Repository interface {
|
|||||||
GetTenderApprovalStats(ctx context.Context) (*TenderApprovalStatsResponse, error)
|
GetTenderApprovalStats(ctx context.Context) (*TenderApprovalStatsResponse, error)
|
||||||
GetCompanyTenderApprovalStats(ctx context.Context, companyID string) (*CompanyTenderApprovalStatsResponse, error)
|
GetCompanyTenderApprovalStats(ctx context.Context, companyID string) (*CompanyTenderApprovalStatsResponse, error)
|
||||||
SearchByCriteria(ctx context.Context, criteria *TenderApprovalSearchCriteria) ([]*TenderApproval, 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
|
// tenderApprovalRepository implements the Repository interface using the MongoDB ORM
|
||||||
@@ -533,6 +536,28 @@ func (r *tenderApprovalRepository) GetCompanyTenderApprovalStats(ctx context.Con
|
|||||||
return stats, nil
|
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
|
// SearchByCriteria searches tender approvals using search criteria
|
||||||
func (r *tenderApprovalRepository) SearchByCriteria(ctx context.Context, criteria *TenderApprovalSearchCriteria) ([]*TenderApproval, error) {
|
func (r *tenderApprovalRepository) SearchByCriteria(ctx context.Context, criteria *TenderApprovalSearchCriteria) ([]*TenderApproval, error) {
|
||||||
// Convert criteria to search parameters
|
// Convert criteria to search parameters
|
||||||
@@ -549,3 +574,16 @@ func (r *tenderApprovalRepository) SearchByCriteria(ctx context.Context, criteri
|
|||||||
// Use the search method
|
// 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")
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user