diff --git a/cmd/worker/workers/notice.go b/cmd/worker/workers/notice.go index 479434c..d54c8fd 100644 --- a/cmd/worker/workers/notice.go +++ b/cmd/worker/workers/notice.go @@ -2,6 +2,7 @@ package workers import ( "context" + "fmt" "strings" "time" "tm/internal/notice" @@ -10,6 +11,9 @@ import ( "tm/pkg/logger" "tm/pkg/mongo" "tm/pkg/notification" + + "go.mongodb.org/mongo-driver/v2/bson" + mongodriver "go.mongodb.org/mongo-driver/v2/mongo" ) type NoticeWorker struct { @@ -309,44 +313,139 @@ func (w *NoticeWorker) ToTender(n *notice.Notice) (*tender.Tender, error) { t.WinningTenderer = winningTenderer t.Modifications = modifications t.AwardedEntities = awardedEntities - t.TenderID = GenerateTenderID(n) + t.TenderID = w.GenerateTenderID(context.Background(), n) + t.ProjectName = w.GenerateProjectName(t) return t, nil } -// generateTenderID generates a unique tender ID using the format: -// {Source}{BUYER_CODE}{TED_CODE}{DATE}{LOCATION_CODE} -func GenerateTenderID(t *notice.Notice) string { - var parts []string +// GenerateTenderID generates a unique tender ID using the PBL naming convention: SCDYYNNN +// SCD = Source Code (3 letters), YY = Year (2 digits), NNN = Sequential number (3 digits) +func (w *NoticeWorker) GenerateTenderID(ctx context.Context, t *notice.Notice) string { + sourceCode := getSourceCode(t) + year := getCurrentYear() + sequentialNumber := w.getNextSequentialNumber(ctx, sourceCode, year) - // Source (TED) - parts = append(parts, "1") + return fmt.Sprintf("%s%s%03d", sourceCode, year, sequentialNumber) +} - // TED code (Notice Publication ID, first 8 chars) - tedCode := "00000000" - if t.NoticePublicationID != "" { - tedCode = PadOrTruncate(t.NoticePublicationID, 8) +// getSourceCode determines the 3-letter source code based on tender characteristics +func getSourceCode(t *notice.Notice) string { + // Since all notices come from TED (Tenders Electronic Daily), use PTD for Public Tender + return "PTD" +} + +// getCurrentYear returns the last two digits of the current year +func getCurrentYear() string { + now := time.Now() + return fmt.Sprintf("%02d", now.Year()%100) +} + +// GenerateProjectName generates the project name using PBL naming convention: +// __ +func (w *NoticeWorker) GenerateProjectName(t *tender.Tender) string { + clientName := w.getClientName(t) + opportunityName := w.getOpportunityName(t) + + // Clean and format the names (replace spaces with hyphens, remove special chars) + clientName = cleanForProjectName(clientName) + opportunityName = cleanForProjectName(opportunityName) + + // If client name and opportunity name are the same, just use one of them + if clientName == opportunityName { + return fmt.Sprintf("%s_%s_PBL", t.TenderID, clientName) } - parts = append(parts, tedCode) - // Buyer code (first 8 chars of buyer organization ID, or 8 zeros if not available) - // buyerCode := "00000000" - // if t.BuyerOrganization != nil && t.BuyerOrganization.ID != "" { - // buyerCode = padOrTruncate(t.BuyerOrganization.ID, 8) - // } - // parts = append(parts, buyerCode) + return fmt.Sprintf("%s_%s-%s_PBL", t.TenderID, clientName, opportunityName) +} - // Date (YYYYMMDD format from issue date) - parts = append(parts, PadOrTruncate(UnixToDateString(t.IssueDate), 4)) - - // Location code (country code + region code, padded to 6 chars) - locationCode := "EU" - if t.CountryCode != "" { - locationCode = t.CountryCode +// getClientName extracts the client name from the tender +func (w *NoticeWorker) getClientName(t *tender.Tender) string { + if t.BuyerOrganization != nil && t.BuyerOrganization.Name != "" { + return t.BuyerOrganization.Name } - parts = append(parts, locationCode) + return "UnknownClient" +} - return strings.Join(parts, "") +// getOpportunityName extracts the opportunity name from the tender title +func (w *NoticeWorker) getOpportunityName(t *tender.Tender) string { + if t.Title != "" { + // Take first few words of the title as opportunity name + words := strings.Fields(t.Title) + if len(words) > 3 { + return strings.Join(words[:3], " ") + } + return t.Title + } + return "UnknownOpportunity" +} + +// cleanForProjectName cleans a string for use in project names (removes special chars, replaces spaces with hyphens) +func cleanForProjectName(s string) string { + // Replace spaces with hyphens + s = strings.ReplaceAll(s, " ", "-") + // Remove special characters except hyphens and alphanumeric + clean := "" + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + clean += string(r) + } + } + // Remove multiple consecutive hyphens + for strings.Contains(clean, "--") { + clean = strings.ReplaceAll(clean, "--", "-") + } + // Trim hyphens from start and end + clean = strings.Trim(clean, "-") + return clean +} + +// getNextSequentialNumber returns the next sequential number for the given source code and year +func (w *NoticeWorker) getNextSequentialNumber(ctx context.Context, sourceCode, year string) int { + // Query tenders with tender IDs that match the pattern: {sourceCode}{year}* + // We need to find the highest NNN value for this source code and year + + // Pattern to match: e.g., "PTD25" followed by 3 digits + pattern := fmt.Sprintf("^%s%s\\d{3}$", sourceCode, year) + + // Create aggregation pipeline to find the highest sequential number + pipeline := mongodriver.Pipeline{ + bson.D{{"$match", bson.M{ + "tender_id": bson.M{"$regex": pattern}, + }}}, + bson.D{{"$project", bson.M{ + "sequential_num": bson.M{"$substr": bson.A{"$tender_id", 5, 3}}, // Extract last 3 chars (NNN) + }}}, + bson.D{{"$group", bson.M{ + "_id": nil, + "max_num": bson.M{"$max": bson.M{"$toInt": "$sequential_num"}}, + }}}, + } + + // Execute aggregation + cursor, err := w.Mongo.GetCollection("tenders").Aggregate(ctx, pipeline) + if err != nil { + w.Logger.Error("Failed to aggregate tenders for sequential numbering", map[string]interface{}{ + "error": err.Error(), + "source_code": sourceCode, + "year": year, + }) + return 1 // Return 1 as fallback + } + defer cursor.Close(ctx) + + // Parse result + var result struct { + MaxNum int `bson:"max_num"` + } + + if cursor.Next(ctx) { + if err := cursor.Decode(&result); err == nil { + return result.MaxNum + 1 + } + } + + return 1 // No existing tenders found, start with 1 } // padOrTruncate pads a string with zeros or truncates it to the specified length diff --git a/internal/tender/entity.go b/internal/tender/entity.go index 21b56c2..507100b 100644 --- a/internal/tender/entity.go +++ b/internal/tender/entity.go @@ -52,7 +52,8 @@ type Tender struct { SourceFileURL string `bson:"source_file_url" json:"source_file_url"` SourceFileName string `bson:"source_file_name" json:"source_file_name"` ProcessingMetadata ProcessingMetadata `bson:"processing_metadata" json:"processing_metadata"` - TenderID string `bson:"tender_id" json:"tender_id"` // Implement logic to generate a unique tender_id using the following format: {Source}{BUYER_CODE}{TED_CODE}{DATE}{LOCATION_CODE} + TenderID string `bson:"tender_id" json:"tender_id"` // PBL Project Number (SCDYYNNN format) + ProjectName string `bson:"project_name" json:"project_name"` // PBL Project Name (__) // Status-specific fields CancellationReason string `bson:"cancellation_reason,omitempty" json:"cancellation_reason,omitempty"` diff --git a/internal/tender/repository.go b/internal/tender/repository.go index 74d8ee7..3c2f903 100644 --- a/internal/tender/repository.go +++ b/internal/tender/repository.go @@ -44,6 +44,7 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Te // Create indexes for tenders collection tenderIndexes := []orm.Index{ *orm.CreateUniqueIndex("tender_id_idx", bson.D{{Key: "tender_id", Value: 1}}), + *orm.NewIndex("project_name_idx", bson.D{{Key: "project_name", Value: 1}}), *orm.NewIndex("contract_notice_id_idx", bson.D{{Key: "contract_notice_id", Value: 1}}), *orm.NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}), *orm.NewIndex("source_idx", bson.D{{Key: "source", Value: 1}}),