diff --git a/internal/tender/ai_reference.go b/internal/tender/ai_reference.go index c3cdfa5..5bd6561 100644 --- a/internal/tender/ai_reference.go +++ b/internal/tender/ai_reference.go @@ -19,6 +19,13 @@ func FormatAIProcedureRef(contractFolderID, noticePublicationID string) string { return aiProcedurePrefix + contractFolderID + "/" + noticePublicationID } +// ProcedureReference identifies a tender using AI procedure coordinates. +type ProcedureReference struct { + Ref string + ContractFolderID string + NoticePublicationID string +} + // ParseAIProcedureRef parses PROC_{contract_folder_id}/{notice_publication_id}. func ParseAIProcedureRef(ref string) (contractFolderID, noticePublicationID string, ok bool) { ref = strings.TrimSpace(ref) @@ -45,6 +52,21 @@ func ParseAIProcedureRef(ref string) (contractFolderID, noticePublicationID stri return contractFolderID, noticePublicationID, true } +func tenderMatchesProcedureRef(t Tender, contractFolderID, noticePublicationID string) bool { + if strings.TrimSpace(t.ContractFolderID) != strings.TrimSpace(contractFolderID) { + return false + } + if t.NoticePublicationID == noticePublicationID { + return true + } + for _, related := range t.RelatedNoticePublicationIDs { + if related == noticePublicationID { + return true + } + } + return false +} + // IsMongoObjectIDRef reports whether ref is a 24-char hex MongoDB ObjectId. func IsMongoObjectIDRef(ref string) bool { ref = strings.TrimSpace(ref) diff --git a/internal/tender/repository.go b/internal/tender/repository.go index 67b2583..e21699a 100644 --- a/internal/tender/repository.go +++ b/internal/tender/repository.go @@ -22,6 +22,7 @@ type TenderRepository interface { GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Tender, error) GetByNoticePublicationID(ctx context.Context, noticePublicationID string) (*Tender, error) GetByProcedureReference(ctx context.Context, contractFolderID, noticePublicationID string) (*Tender, error) + GetByProcedureReferences(ctx context.Context, refs []ProcedureReference) (map[string]Tender, error) GetByContractFolderID(ctx context.Context, contractFolderID string) (*Tender, error) // GetLatestByContractFolderIDs returns the most recently updated tender per distinct contract_folder_id. GetLatestByContractFolderIDs(ctx context.Context, contractFolderIDs []string) (map[string]*Tender, error) @@ -333,6 +334,60 @@ func (r *tenderRepository) GetByProcedureReference(ctx context.Context, contract return &result.Items[0], nil } +// GetByProcedureReferences loads tenders for multiple AI procedure refs in one query. +func (r *tenderRepository) GetByProcedureReferences(ctx context.Context, refs []ProcedureReference) (map[string]Tender, error) { + out := make(map[string]Tender) + if len(refs) == 0 { + return out, nil + } + + orClauses := make([]bson.M, 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}, + }, + }) + } + if len(orClauses) == 0 { + return out, nil + } + + filter := bson.M{"$or": orClauses} + pagination := orm.Pagination{ + Limit: len(orClauses) * 2, + SortField: "updated_at", + SortOrder: -1, + } + + 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), + "error": err.Error(), + }) + return nil, err + } + + for _, ref := range refs { + for i := range result.Items { + if tenderMatchesProcedureRef(result.Items[i], ref.ContractFolderID, ref.NoticePublicationID) { + out[ref.Ref] = result.Items[i] + break + } + } + } + + return out, nil +} + // GetByContractFolderID retrieves a tender by its procedure-level identifier (ContractFolderID). // TED groups multiple notice publications (e.g. competition notice + result notice) under the same // ContractFolderID, so this lookup is the authoritative way to find the existing tender for a procedure diff --git a/internal/tender/service.go b/internal/tender/service.go index 065e604..d741622 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -1672,6 +1672,7 @@ func (s *tenderService) enrichDocumentsScrapedFilter(ctx context.Context, form * func (s *tenderService) resolveRecommendedTenders(ctx context.Context, recommendations []company.RecommendedTenderResponse) (map[string]Tender, error) { out := make(map[string]Tender) + procRefs := make([]ProcedureReference, 0, len(recommendations)) mongoIDs := make([]string, 0) for _, rec := range recommendations { @@ -1681,19 +1682,11 @@ func (s *tenderService) resolveRecommendedTenders(ctx context.Context, recommend } if folder, notice, ok := ParseAIProcedureRef(ref); ok { - tender, err := s.repository.GetByProcedureReference(ctx, folder, notice) - if err != nil { - if errors.Is(err, orm.ErrDocumentNotFound) { - s.logger.Warn("Recommended tender not found by procedure reference", map[string]interface{}{ - "tender_ref": ref, - "contract_folder_id": folder, - "notice_publication_id": notice, - }) - continue - } - return nil, fmt.Errorf("failed to load recommended tender %s: %w", ref, err) - } - out[ref] = *tender + procRefs = append(procRefs, ProcedureReference{ + Ref: ref, + ContractFolderID: folder, + NoticePublicationID: notice, + }) continue } @@ -1707,6 +1700,16 @@ func (s *tenderService) resolveRecommendedTenders(ctx context.Context, recommend }) } + 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 }