diff --git a/internal/tender/ai_reference_test.go b/internal/tender/ai_reference_test.go index 43e94c2..7dfc6ae 100644 --- a/internal/tender/ai_reference_test.go +++ b/internal/tender/ai_reference_test.go @@ -47,3 +47,40 @@ func TestFormatAIProcedureRef(t *testing.T) { t.Fatalf("FormatAIProcedureRef() = %q want %q", got, want) } } + +func TestMapProcedureReferencesToTenders(t *testing.T) { + candidates := []Tender{ + { + ContractFolderID: "folder-1", + NoticePublicationID: "notice-new", + RelatedNoticePublicationIDs: []string{"notice-old"}, + }, + { + ContractFolderID: "folder-1", + NoticePublicationID: "notice-legacy", + }, + { + ContractFolderID: "folder-2", + NoticePublicationID: "notice-2", + }, + } + refs := []ProcedureReference{ + {Ref: "PROC_folder-1/notice-old", ContractFolderID: "folder-1", NoticePublicationID: "notice-old"}, + {Ref: "PROC_folder-1/notice-legacy", ContractFolderID: "folder-1", NoticePublicationID: "notice-legacy"}, + {Ref: "PROC_folder-2/notice-2", ContractFolderID: "folder-2", NoticePublicationID: "notice-2"}, + } + + got := mapProcedureReferencesToTenders(candidates, refs) + if len(got) != 3 { + t.Fatalf("mapProcedureReferencesToTenders() len = %d, want 3", len(got)) + } + if got["PROC_folder-1/notice-old"].NoticePublicationID != "notice-new" { + t.Fatalf("expected notice-old ref to resolve to latest merged tender, got %+v", got["PROC_folder-1/notice-old"]) + } + if got["PROC_folder-1/notice-legacy"].NoticePublicationID != "notice-legacy" { + t.Fatalf("expected legacy ref to resolve to legacy tender, got %+v", got["PROC_folder-1/notice-legacy"]) + } + if got["PROC_folder-2/notice-2"].NoticePublicationID != "notice-2" { + t.Fatalf("expected folder-2 ref to resolve, got %+v", got["PROC_folder-2/notice-2"]) + } +} diff --git a/internal/tender/repository.go b/internal/tender/repository.go index 80d11cd..d3416d5 100644 --- a/internal/tender/repository.go +++ b/internal/tender/repository.go @@ -65,11 +65,11 @@ func tenderSearchListProjection() bson.M { "source_file_url": 0, "source_file_name": 0, // Not used by list response mapping; can be large per tender. - "translations": 0, - "organizations": 0, - "review_organization": 0, - "processing_metadata.translated_data": 0, - "processing_metadata.parsing_errors": 0, + "translations": 0, + "organizations": 0, + "review_organization": 0, + "processing_metadata.translated_data": 0, + "processing_metadata.parsing_errors": 0, "processing_metadata.validation_errors": 0, } } @@ -350,28 +350,31 @@ func (r *tenderRepository) GetByProcedureReferences(ctx context.Context, refs [] return out, nil } - orClauses := make([]bson.M, 0, len(refs)) + contractFolderIDs := make([]string, 0, len(refs)) for _, ref := range refs { folder := strings.TrimSpace(ref.ContractFolderID) notice := strings.TrimSpace(ref.NoticePublicationID) if folder == "" || notice == "" { continue } - orClauses = append(orClauses, bson.M{ - "contract_folder_id": folder, - "$or": []bson.M{ - {"notice_publication_id": notice}, - {"related_notice_publication_ids": notice}, - }, - }) + contractFolderIDs = append(contractFolderIDs, folder) } - if len(orClauses) == 0 { + uniqueFolders := uniqueNonEmptyTrimmedStrings(contractFolderIDs) + if len(uniqueFolders) == 0 { return out, nil } - filter := bson.M{"$or": orClauses} + findLimit := len(uniqueFolders) * 5 + if minLimit := len(refs) + len(uniqueFolders); findLimit < minLimit { + findLimit = minLimit + } + if findLimit > 100000 { + findLimit = 100000 + } + + filter := bson.M{"contract_folder_id": bson.M{"$in": uniqueFolders}} pagination := orm.Pagination{ - Limit: len(orClauses) * 2, + Limit: findLimit, SortField: "updated_at", SortOrder: -1, } @@ -379,22 +382,26 @@ func (r *tenderRepository) GetByProcedureReferences(ctx context.Context, refs [] result, err := r.ormRepo.FindAll(ctx, filter, pagination) if err != nil { r.logger.Error("Failed to get tenders by procedure references", map[string]interface{}{ - "count": len(orClauses), + "count": len(uniqueFolders), "error": err.Error(), }) return nil, err } + return mapProcedureReferencesToTenders(result.Items, refs), nil +} + +func mapProcedureReferencesToTenders(candidates []Tender, refs []ProcedureReference) map[string]Tender { + out := make(map[string]Tender) for _, ref := range refs { - for i := range result.Items { - if tenderMatchesProcedureRef(result.Items[i], ref.ContractFolderID, ref.NoticePublicationID) { - out[ref.Ref] = result.Items[i] + for i := range candidates { + if tenderMatchesProcedureRef(candidates[i], ref.ContractFolderID, ref.NoticePublicationID) { + out[ref.Ref] = candidates[i] break } } } - - return out, nil + return out } // GetByContractFolderID retrieves a tender by its procedure-level identifier (ContractFolderID). diff --git a/internal/tender/service.go b/internal/tender/service.go index 0ce99bd..25b0494 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -17,6 +17,8 @@ import ( "tm/pkg/logger" orm "tm/pkg/mongo" "tm/pkg/response" + + "golang.org/x/sync/errgroup" ) // AISummarizerClient defines the interface for on-demand AI operations. @@ -1754,29 +1756,50 @@ func (s *tenderService) resolveRecommendedTenders(ctx context.Context, recommend }) } + var ( + byProcedure = make(map[string]Tender) + byMongoID = make(map[string]Tender) + ) + + group, groupCtx := errgroup.WithContext(ctx) if len(procRefs) > 0 { - byProcedure, err := s.repository.GetByProcedureReferences(ctx, procRefs) - if err != nil { - return nil, fmt.Errorf("failed to load recommended tenders by procedure reference: %w", err) - } - for ref, tender := range byProcedure { - out[ref] = tender - } - } - - if len(mongoIDs) == 0 { - return out, nil - } - - tenders, err := s.repository.GetByIDs(ctx, mongoIDs) - if err != nil { - s.logger.Error("Failed to load recommended tenders by Mongo ID", map[string]interface{}{ - "error": err.Error(), + group.Go(func() error { + byProcedureResult, err := s.repository.GetByProcedureReferences(groupCtx, procRefs) + if err != nil { + return fmt.Errorf("failed to load recommended tenders by procedure reference: %w", err) + } + for ref, tender := range byProcedureResult { + byProcedure[ref] = tender + } + return nil }) - return nil, fmt.Errorf("failed to load recommended tenders: %w", err) } - for i := range tenders { - out[tenders[i].GetID()] = tenders[i] + + if len(mongoIDs) > 0 { + group.Go(func() error { + tenders, err := s.repository.GetByIDs(groupCtx, mongoIDs) + if err != nil { + s.logger.Error("Failed to load recommended tenders by Mongo ID", map[string]interface{}{ + "error": err.Error(), + }) + return fmt.Errorf("failed to load recommended tenders: %w", err) + } + for i := range tenders { + byMongoID[tenders[i].GetID()] = tenders[i] + } + return nil + }) + } + + if err := group.Wait(); err != nil { + return nil, err + } + + for ref, tender := range byProcedure { + out[ref] = tender + } + for ref, tender := range byMongoID { + out[ref] = tender } return out, nil