diff --git a/cmd/scraper/bootstrap/bootstrap.go b/cmd/scraper/bootstrap/bootstrap.go index 2fa2a2c..9cbdf52 100644 --- a/cmd/scraper/bootstrap/bootstrap.go +++ b/cmd/scraper/bootstrap/bootstrap.go @@ -7,7 +7,7 @@ import ( "os/signal" "syscall" "time" - "tm/internal/tender" + "tm/internal/notice" "tm/pkg/config" "tm/pkg/logger" "tm/pkg/mongo" @@ -78,7 +78,7 @@ func InitMongoDB(conf config.MongoConfig, log logger.Logger) *mongo.ConnectionMa // init TED scraper func InitTEDScraper(config Config, mongoManager *mongo.ConnectionManager, appLogger logger.Logger, notify notification.SDK) { // Initialize tender repository - tenderRepo := tender.NewRepository(mongoManager, appLogger) + noticeRepo := notice.NewRepository(mongoManager, appLogger) // Initialize TED scraper tedScraper := ted.NewTEDScraper( @@ -95,12 +95,12 @@ func InitTEDScraper(config Config, mongoManager *mongo.ConnectionManager, appLog }, appLogger, mongoManager, - tenderRepo, + noticeRepo, notify, ) // start TED scraper job - schedule.NewCronScheduler(appLogger, mongoManager, tenderRepo).AddJob(schedule.Job{ + schedule.NewCronScheduler(appLogger, mongoManager, noticeRepo).AddJob(schedule.Job{ Name: "TED Scraper Job", Func: func() { _ = tedScraper.Run(context.Background()) diff --git a/cmd/web/bootstrap/bootstrap.go b/cmd/web/bootstrap/bootstrap.go index c42c98c..63402b0 100644 --- a/cmd/web/bootstrap/bootstrap.go +++ b/cmd/web/bootstrap/bootstrap.go @@ -8,6 +8,7 @@ import ( "tm/pkg/logger" "tm/pkg/mongo" "tm/pkg/notification" + "tm/pkg/ollama" "tm/pkg/redis" "github.com/labstack/echo/v4" @@ -213,3 +214,38 @@ func InitNotificationService(conf config.NotificationConfig, log logger.Logger) return *notificationSDK } + +// Init Ollama Service +func InitOllamaService(conf config.OllamaConfig, log logger.Logger) ollama.SDK { + ollamaConfig := &ollama.Config{ + BaseURL: conf.BaseURL, + Timeout: conf.Timeout, + RetryAttempts: conf.RetryAttempts, + RetryDelay: conf.RetryDelay, + EnableLogging: conf.EnableLogging, + UserAgent: conf.UserAgent, + DefaultModel: conf.DefaultModel, + DefaultTemperature: conf.DefaultTemperature, + DefaultTopP: conf.DefaultTopP, + DefaultMaxTokens: conf.DefaultMaxTokens, + EnableStreaming: conf.EnableStreaming, + } + + ollamaSDK, err := ollama.New(ollamaConfig, log) + if err != nil { + log.Error("Failed to initialize Ollama SDK", map[string]interface{}{ + "error": err.Error(), + }) + } + + log.Info("Ollama SDK initialized successfully", map[string]interface{}{ + "base_url": conf.BaseURL, + "timeout": conf.Timeout, + "retry_attempts": conf.RetryAttempts, + "retry_delay": conf.RetryDelay, + "enable_logging": conf.EnableLogging, + "user_agent": conf.UserAgent, + }) + + return *ollamaSDK +} diff --git a/cmd/web/bootstrap/config.go b/cmd/web/bootstrap/config.go index 0f9f9b7..51dbad5 100644 --- a/cmd/web/bootstrap/config.go +++ b/cmd/web/bootstrap/config.go @@ -13,6 +13,7 @@ type Config struct { RateLimit config.RateLimitConfig Assets config.AssetsConfig Notification config.NotificationConfig + Ollama config.OllamaConfig UserAuth AuthConfig CustomerAuth AuthConfig } diff --git a/cmd/web/main.go b/cmd/web/main.go index c03f5ab..f300820 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -129,6 +129,9 @@ func main() { // Initialize notification service notificationSDK := bootstrap.InitNotificationService(conf.Notification, logger) + // Initialize ollama service + ollamaSDK := bootstrap.InitOllamaService(conf.Ollama, logger) + // Initialize authorization service userAuthService := bootstrap.InitAuthorizationService(conf.UserAuth.JWT, redisClient, logger) customerAuthService := bootstrap.InitAuthorizationService(conf.CustomerAuth.JWT, redisClient, logger) @@ -155,7 +158,7 @@ func main() { categoryService := company_category.NewService(categoryRepository, logger) companyService := company.NewService(companyRepository, categoryService, logger) customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator) - tenderService := tender.NewService(tenderRepository, companyService, logger) + tenderService := tender.NewService(tenderRepository, companyService, ollamaSDK, logger) feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger) tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger) inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger) diff --git a/internal/notice/entity.go b/internal/notice/entity.go new file mode 100644 index 0000000..5f8b0bf --- /dev/null +++ b/internal/notice/entity.go @@ -0,0 +1,276 @@ +package notice + +import ( + "fmt" + "strconv" + "strings" + "time" + "tm/pkg/mongo" +) + +// Tender represents a tender/contract notice entity +type Notice struct { + mongo.Model `bson:",inline"` + ContractNoticeID string `bson:"contract_notice_id" json:"contract_notice_id"` + NoticePublicationID string `bson:"notice_publication_id" json:"notice_publication_id"` + ContractFolderID string `bson:"contract_folder_id" json:"contract_folder_id"` + NoticeTypeCode string `bson:"notice_type_code" json:"notice_type_code"` + NoticeSubTypeCode string `bson:"notice_sub_type_code" json:"notice_sub_type_code"` + NoticeLanguageCode string `bson:"notice_language_code" json:"notice_language_code"` + IssueDate int64 `bson:"issue_date" json:"issue_date"` + IssueTime int64 `bson:"issue_time" json:"issue_time"` + TenderDeadline int64 `bson:"tender_deadline" json:"tender_deadline"` + PublicationDate int64 `bson:"publication_date" json:"publication_date"` + SubmissionDeadline int64 `bson:"submission_deadline" json:"submission_deadline"` + ApplicationDeadline int64 `bson:"application_deadline" json:"application_deadline"` + GazetteID string `bson:"gazette_id" json:"gazette_id"` + Title string `bson:"title" json:"title"` + Description string `bson:"description" json:"description"` + ProcurementTypeCode string `bson:"procurement_type_code" json:"procurement_type_code"` + ProcedureCode string `bson:"procedure_code" json:"procedure_code"` + MainClassification string `bson:"main_classification" json:"main_classification"` + AdditionalClassifications []string `bson:"additional_classifications" json:"additional_classifications"` + EstimatedValue float64 `bson:"estimated_value" json:"estimated_value"` + Currency string `bson:"currency" json:"currency"` + Duration string `bson:"duration" json:"duration"` + DurationUnit string `bson:"duration_unit" json:"duration_unit"` + PlaceOfPerformance string `bson:"place_of_performance" json:"place_of_performance"` + CountryCode string `bson:"country_code" json:"country_code"` + RegionCode string `bson:"region_code" json:"region_code"` + CityName string `bson:"city_name" json:"city_name"` + PostalCode string `bson:"postal_code" json:"postal_code"` + DocumentURI string `bson:"document_uri" json:"document_uri"` + TenderURL string `bson:"tender_url" json:"tender_url"` + SubmissionURL string `bson:"submission_url" json:"submission_url"` + BuyerOrganization *Organization `bson:"buyer_organization" json:"buyer_organization"` + ReviewOrganization *Organization `bson:"review_organization,omitempty" json:"review_organization,omitempty"` + Organizations []Organization `bson:"organizations" json:"organizations"` + SelectionCriteria []SelectionCriterion `bson:"selection_criteria" json:"selection_criteria"` + OfficialLanguages []string `bson:"official_languages" json:"official_languages"` + Status TenderStatus `bson:"status" json:"status"` + Source TenderSource `bson:"source" json:"source"` + 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} + + // Status-specific fields + CancellationReason string `bson:"cancellation_reason,omitempty" json:"cancellation_reason,omitempty"` + CancellationDate int64 `bson:"cancellation_date,omitempty" json:"cancellation_date,omitempty"` + AwardDate int64 `bson:"award_date,omitempty" json:"award_date,omitempty"` + AwardedValue float64 `bson:"awarded_value,omitempty" json:"awarded_value,omitempty"` + WinningTenderer *Organization `bson:"winning_tenderer,omitempty" json:"winning_tenderer,omitempty"` + ContractNumber string `bson:"contract_number,omitempty" json:"contract_number,omitempty"` + SuspensionReason string `bson:"suspension_reason,omitempty" json:"suspension_reason,omitempty"` + SuspensionDate int64 `bson:"suspension_date,omitempty" json:"suspension_date,omitempty"` + Modifications []TenderModification `bson:"modifications,omitempty" json:"modifications,omitempty"` + AwardedEntities []Awarded `bson:"awarded,omitempty" json:"awarded,omitempty"` +} + +// Organization represents organization information +type Organization struct { + ID string `bson:"id" json:"id"` + Name string `bson:"name" json:"name"` + CompanyID string `bson:"company_id,omitempty" json:"company_id,omitempty"` + WebsiteURI string `bson:"website_uri,omitempty" json:"website_uri,omitempty"` + ContactName string `bson:"contact_name,omitempty" json:"contact_name,omitempty"` + ContactTelephone string `bson:"contact_telephone,omitempty" json:"contact_telephone,omitempty"` + ContactEmail string `bson:"contact_email,omitempty" json:"contact_email,omitempty"` + ContactFax string `bson:"contact_fax,omitempty" json:"contact_fax,omitempty"` + Address Address `bson:"address" json:"address"` + Role string `bson:"role,omitempty" json:"role,omitempty"` +} + +// Address represents address information +type Address struct { + StreetName string `bson:"street_name,omitempty" json:"street_name,omitempty"` + CityName string `bson:"city_name,omitempty" json:"city_name,omitempty"` + PostalZone string `bson:"postal_zone,omitempty" json:"postal_zone,omitempty"` + CountrySubentityCode string `bson:"country_subentity_code,omitempty" json:"country_subentity_code,omitempty"` + Department string `bson:"department,omitempty" json:"department,omitempty"` + Region string `bson:"region,omitempty" json:"region,omitempty"` + CountryCode string `bson:"country_code" json:"country_code"` +} + +// SelectionCriterion represents selection criteria +type SelectionCriterion struct { + TypeCode string `bson:"type_code" json:"type_code"` + Description string `bson:"description,omitempty" json:"description,omitempty"` + LanguageID string `bson:"language_id,omitempty" json:"language_id,omitempty"` +} + +// ProcessingMetadata contains metadata about how the tender was processed +type ProcessingMetadata struct { + ScrapedAt int64 `bson:"scraped_at" json:"scraped_at"` + ProcessedAt int64 `bson:"processed_at" json:"processed_at"` + ProcessingVersion string `bson:"processing_version" json:"processing_version"` + ParsingErrors []string `bson:"parsing_errors,omitempty" json:"parsing_errors,omitempty"` + ValidationErrors []string `bson:"validation_errors,omitempty" json:"validation_errors,omitempty"` + EnrichmentData map[string]string `bson:"enrichment_data,omitempty" json:"enrichment_data,omitempty"` + TranslatedData map[string]string `bson:"translated_data,omitempty" json:"translated_data,omitempty"` + TranslatedAt int64 `bson:"translated_at,omitempty" json:"translated_at,omitempty"` + Processed bool `bson:"processed" json:"processed"` +} + +// TenderModification represents a modification made to a tender +type TenderModification struct { + ModificationDate int64 `bson:"modification_date" json:"modification_date"` // Unix milliseconds + ModificationReason string `bson:"modification_reason" json:"modification_reason"` + Description string `bson:"description,omitempty" json:"description,omitempty"` + LanguageID string `bson:"language_id,omitempty" json:"language_id,omitempty"` +} + +// AwardedEntity represents an entity that has been awarded a contract +type Awarded struct { + Name string `bson:"name" json:"name"` + Address string `bson:"address,omitempty" json:"address,omitempty"` + Country string `bson:"country,omitempty" json:"country,omitempty"` + Amount float64 `bson:"amount" json:"amount"` + Currency string `bson:"currency,omitempty" json:"currency,omitempty"` + Share float64 `bson:"share,omitempty" json:"share,omitempty"` // Percentage share for multiple winners + AwardDate int64 `bson:"award_date" json:"award_date"` // Unix milliseconds + ContractID string `bson:"contract_id,omitempty" json:"contract_id,omitempty"` // Contract reference ID + TenderID string `bson:"tender_id,omitempty" json:"tender_id,omitempty"` // Tender reference ID + LotID string `bson:"lot_id,omitempty" json:"lot_id,omitempty"` // Lot reference ID + CompanyID string `bson:"company_id,omitempty" json:"company_id,omitempty"` // Company registration ID + OrganizationID string `bson:"organization_id,omitempty" json:"organization_id,omitempty"` // Organization reference ID +} + +// TenderStatus represents the status of a tender +type TenderStatus string + +const ( + TenderStatusActive TenderStatus = "active" + TenderStatusExpired TenderStatus = "expired" + TenderStatusCancelled TenderStatus = "cancelled" + TenderStatusAwarded TenderStatus = "awarded" + TenderStatusDraft TenderStatus = "draft" + TenderStatusClosed TenderStatus = "closed" + TenderStatusModified TenderStatus = "modified" + TenderStatusSuspended TenderStatus = "suspended" + TenderStatusPublished TenderStatus = "published" +) + +// TenderSource represents the source of tender data +type TenderSource string + +const ( + TenderSourceTEDScraper TenderSource = "ted_scraper" + TenderSourceManual TenderSource = "manual" + TenderSourceAPI TenderSource = "api" + TenderSourceBulkImport TenderSource = "bulk_import" +) + +// TenderStatistics represents tender statistics +type TenderStatistics struct { + TotalTenders int64 `json:"total_tenders"` + ActiveTenders int64 `json:"active_tenders"` + ExpiredTenders int64 `json:"expired_tenders"` + TendersByCountry map[string]int64 `json:"tenders_by_country"` + TendersByType map[string]int64 `json:"tenders_by_type"` + TendersByClassification map[string]int64 `json:"tenders_by_classification"` + AverageEstimatedValue float64 `json:"average_estimated_value"` + TotalEstimatedValue float64 `json:"total_estimated_value"` + LastUpdated int64 `json:"last_updated"` // Unix milliseconds +} + +// GetID returns the tender ID +func (t *Notice) GetID() string { + return t.ID.Hex() +} + +// IsActive returns true if the tender is active +func (t *Notice) IsActive() bool { + return t.Status == TenderStatusActive +} + +// IsExpired returns true if the tender deadline has passed +func (t *Notice) IsExpired() bool { + if t.Status == TenderStatusExpired { + return true + } + + if t.TenderDeadline == 0 { + return false + } + + // Check if deadline (in milliseconds) is in the past + now := time.Now().Unix() + return t.TenderDeadline < now +} + +// GetTenderURL returns the TED tender URL +func (t *Notice) GetTenderURL() string { + if t.TenderURL != "" { + return t.TenderURL + } + + if t.NoticePublicationID != "" { + return generateTenderURL(t.NoticePublicationID) + } + + return "" +} + +// generateTenderURL generates the TED tender URL from NoticePublicationID +func generateTenderURL(noticePublicationID string) string { + // Remove leading zeros and format for URL + // Example: 00522942-2025 -> https://ted.europa.eu/en/notice/-/detail/522942-2025 + parts := strings.Split(noticePublicationID, "-") + if len(parts) != 2 { + return "" + } + + // Convert to int and back to string to remove leading zeros + if num, err := strconv.Atoi(parts[0]); err == nil { + cleanID := fmt.Sprintf("%d-%s", num, parts[1]) + return fmt.Sprintf("https://ted.europa.eu/en/notice/-/detail/%s", cleanID) + } + + return "" +} + +// GetMainOrganization returns the buyer organization +func (t *Notice) GetMainOrganization() *Organization { + return t.BuyerOrganization +} + +// HasEstimatedValue returns true if the tender has an estimated value +func (t *Notice) HasEstimatedValue() bool { + return t.EstimatedValue > 0 +} + +// GetFormattedEstimatedValue returns formatted estimated value with currency +func (t *Notice) GetFormattedEstimatedValue() string { + if !t.HasEstimatedValue() { + return "" + } + + if t.Currency != "" { + return fmt.Sprintf("%.2f %s", t.EstimatedValue, t.Currency) + } + + return fmt.Sprintf("%.2f", t.EstimatedValue) +} + +// UpdateStatus updates the tender status and updated timestamp +func (t *Notice) UpdateStatus(status TenderStatus) { + t.Status = status + t.UpdatedAt = time.Now().Unix() +} + +// GetDaysUntilDeadline returns the number of days until the tender deadline +func (t *Notice) GetDaysUntilDeadline() int { + if t.TenderDeadline == 0 { + return 0 + } + + now := time.Now().Unix() + if t.TenderDeadline <= now { + return 0 // Already expired + } + + diffMillis := t.TenderDeadline - now + diffDays := diffMillis / (24 * 60 * 60 * 1000) + return int(diffDays) +} diff --git a/internal/notice/repository.go b/internal/notice/repository.go new file mode 100644 index 0000000..ac235ad --- /dev/null +++ b/internal/notice/repository.go @@ -0,0 +1,160 @@ +package notice + +import ( + "context" + "time" + "tm/pkg/logger" + orm "tm/pkg/mongo" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// NoticeRepository interface defines notice data access methods +type Repository interface { + Import(ctx context.Context, notice *Notice) error + BulkImport(ctx context.Context, notices []Notice) error + GetByID(ctx context.Context, id string) (*Notice, error) + GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Notice, error) + Delete(ctx context.Context, id string) error +} + +func collectionName() string { + return "notices" +} + +// noticeRepository implements NoticeRepository interface using MongoDB ORM +type noticeRepository struct { + ormRepo orm.Repository[Notice] + mongoManager *orm.ConnectionManager + logger logger.Logger +} + +// NewRepository creates a new notice repository +func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Repository { + // Create indexes for notices collection + noticeIndexes := []orm.Index{} + + // Create indexes + if err := mongoManager.CreateIndexes(collectionName(), noticeIndexes); err != nil { + logger.Warn("Failed to create notice indexes", map[string]interface{}{ + "error": err.Error(), + }) + } + + // Create ORM repositories + noticeOrmRepo := orm.NewRepository[Notice](mongoManager.GetCollection(collectionName()), logger) + + return ¬iceRepository{ + ormRepo: noticeOrmRepo, + mongoManager: mongoManager, + logger: logger, + } +} + +// Import imports a new notice +func (r *noticeRepository) Import(ctx context.Context, notice *Notice) error { + now := time.Now().Unix() + notice.SetCreatedAt(now) + + err := r.ormRepo.Create(ctx, notice) + if err != nil { + r.logger.Error("Failed to create notice", map[string]interface{}{ + "error": err.Error(), + "notice_publication_id": notice.NoticePublicationID, + }) + return err + } + + r.logger.Info("Notice created successfully", map[string]interface{}{ + "notice_id": notice.ID, + "notice_publication_id": notice.NoticePublicationID, + }) + + return nil +} + +// BulkImport imports a new notice +func (r *noticeRepository) BulkImport(ctx context.Context, notices []Notice) error { + err := r.ormRepo.CreateMany(ctx, notices) + if err != nil { + r.logger.Error("Failed to bulk import notices", map[string]interface{}{ + "error": err.Error(), + }) + return err + } + + return nil +} + +// GetByID retrieves a notice by ID +func (r *noticeRepository) GetByID(ctx context.Context, id string) (*Notice, error) { + notice, err := r.ormRepo.FindByID(ctx, id) + if err != nil { + r.logger.Error("Failed to get notice by ID", map[string]interface{}{ + "notice_id": id, + "error": err.Error(), + }) + return nil, err + } + + return notice, nil +} + +// GetByContractNoticeID retrieves a notice by contract notice ID +func (r *noticeRepository) GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Notice, error) { + filter := bson.M{"contract_notice_id": contractNoticeID} + pagination := orm.Pagination{Limit: 1} + + result, err := r.ormRepo.FindAll(ctx, filter, pagination) + if err != nil { + r.logger.Error("Failed to get notice by contract notice ID", map[string]interface{}{ + "contract_notice_id": contractNoticeID, + "error": err.Error(), + }) + return nil, err + } + + if len(result.Items) == 0 { + return nil, orm.ErrDocumentNotFound + } + + return &result.Items[0], nil +} + +// Update updates an existing notice +func (r *noticeRepository) Update(ctx context.Context, notice *Notice) error { + notice.UpdatedAt = time.Now().Unix() + + err := r.ormRepo.Update(ctx, notice) + if err != nil { + r.logger.Error("Failed to update notice", map[string]interface{}{ + "notice_id": notice.ID, + "error": err.Error(), + }) + return err + } + + r.logger.Info("Notice updated successfully", map[string]interface{}{ + "notice_id": notice.ID, + }) + + return nil +} + +// Delete deletes a notice by ID +func (r *noticeRepository) Delete(ctx context.Context, id string) error { + err := r.ormRepo.Delete(ctx, id) + if err != nil { + r.logger.Error("Failed to delete notice", map[string]interface{}{ + "notice_id": id, + "error": err.Error(), + }) + return err + } + + r.logger.Info("Notice deleted successfully", map[string]interface{}{ + "notice_id": id, + }) + + return nil +} diff --git a/internal/notice/service.go b/internal/notice/service.go new file mode 100644 index 0000000..6a8d5c6 --- /dev/null +++ b/internal/notice/service.go @@ -0,0 +1,36 @@ +package notice + +import ( + "context" + "tm/pkg/logger" +) + +// Service defines business logic for notice operations +type Service interface { + Import(ctx context.Context, form *Notice) error + BulkImport(ctx context.Context, form []Notice) error +} + +// noticeService implements the Service interface +type noticeService struct { + repository Repository + logger logger.Logger +} + +// NewService creates a new notice service +func NewService(repository Repository, logger logger.Logger) Service { + return ¬iceService{ + repository: repository, + logger: logger, + } +} + +// Import imports a new notice +func (s *noticeService) Import(ctx context.Context, form *Notice) error { + return s.repository.Import(ctx, form) +} + +// BulkImport imports a new notice +func (s *noticeService) BulkImport(ctx context.Context, form []Notice) error { + return s.repository.BulkImport(ctx, form) +} diff --git a/internal/tender/service.go b/internal/tender/service.go index e21f4ea..77faef6 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -6,6 +6,7 @@ import ( "time" "tm/internal/company" "tm/pkg/logger" + "tm/pkg/ollama" "tm/pkg/response" ) @@ -27,14 +28,16 @@ type Service interface { type tenderService struct { repository TenderRepository companyService company.Service + ollamaSDK ollama.SDK logger logger.Logger } // NewService creates a new tender service -func NewService(repository TenderRepository, companyService company.Service, logger logger.Logger) Service { +func NewService(repository TenderRepository, companyService company.Service, ollamaSDK ollama.SDK, logger logger.Logger) Service { return &tenderService{ repository: repository, companyService: companyService, + ollamaSDK: ollamaSDK, logger: logger, } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 85ce26f..e785796 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -74,6 +74,21 @@ type NotificationConfig struct { UserAgent string `env:"NOTIFICATION_USER_AGENT"` } +// OllamaConfig represents the configuration for the Ollama SDK +type OllamaConfig struct { + BaseURL string `env:"OLLAMA_BASE_URL" default:"http://localhost:11434"` + Timeout time.Duration `env:"OLLAMA_TIMEOUT" default:"300s"` + RetryAttempts int `env:"OLLAMA_RETRY_ATTEMPTS" default:"3"` + RetryDelay time.Duration `env:"OLLAMA_RETRY_DELAY" default:"2s"` + EnableLogging bool `env:"OLLAMA_ENABLE_LOGGING" default:"true"` + UserAgent string `env:"OLLAMA_USER_AGENT" default:"TenderManagement-OllamaSDK/1.0"` + DefaultModel string `env:"OLLAMA_DEFAULT_MODEL" default:"llama2"` + DefaultTemperature float64 `env:"OLLAMA_DEFAULT_TEMPERATURE" default:"0.7"` + DefaultTopP float64 `env:"OLLAMA_DEFAULT_TOP_P" default:"0.9"` + DefaultMaxTokens int `env:"OLLAMA_DEFAULT_MAX_TOKENS" default:"1000"` + EnableStreaming bool `env:"OLLAMA_ENABLE_STREAMING" default:"false"` +} + // LoadConfig loads configuration with priority: OS environment variables > .env file // OS environment variables take precedence over .env file values func LoadConfig[T any](path string, config T) (T, error) { diff --git a/pkg/mongo/repository.go b/pkg/mongo/repository.go index f9553b3..3ee1538 100644 --- a/pkg/mongo/repository.go +++ b/pkg/mongo/repository.go @@ -47,6 +47,9 @@ type Repository[T any] interface { // Create inserts a new document Create(ctx context.Context, model *T) error + // CreateMany inserts multiple documents + CreateMany(ctx context.Context, models []T) error + // Update updates an existing document Update(ctx context.Context, model *T) error @@ -239,6 +242,32 @@ func (r *repository[T]) Create(ctx context.Context, model *T) error { return nil } +// CreateMany inserts multiple documents +func (r *repository[T]) CreateMany(ctx context.Context, models []T) error { + // Set timestamps if the model implements Timestampable + for _, v := range models { + if timestampable, ok := any(v).(Timestampable); ok { + now := time.Now().Unix() + timestampable.SetCreatedAt(now) + timestampable.SetUpdatedAt(now) + } + } + + result, err := r.collection.InsertMany(ctx, models) + if err != nil { + r.logger.Error("Failed to create multiple documents", map[string]interface{}{ + "error": err.Error(), + }) + return fmt.Errorf("failed to create multiple documents: %w", err) + } + + r.logger.Info("Documents created successfully", map[string]interface{}{ + "ids": result.InsertedIDs, + }) + + return nil +} + // Update updates an existing document func (r *repository[T]) Update(ctx context.Context, model *T) error { // Set updated timestamp if the model implements Timestampable diff --git a/pkg/ollama/service.go b/pkg/ollama/service.go new file mode 100644 index 0000000..53e4bd4 --- /dev/null +++ b/pkg/ollama/service.go @@ -0,0 +1,444 @@ +package ollama + +import ( + "context" + "fmt" + "tm/pkg/logger" +) + +// Service implements the OllamaService interface +type Service struct { + client *Client + config *Config + logger logger.Logger +} + +// NewService creates a new Ollama service +func NewService(client *Client) OllamaService { + return &Service{ + client: client, + config: client.config, + logger: client.logger, + } +} + +// Generate generates text using a specified model and prompt +func (s *Service) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) { + if s.logger != nil { + s.logger.Info("Generating text", map[string]interface{}{ + "model": req.Model, + "prompt_length": len(req.Prompt), + }) + } + + // Set defaults + if req.Model == "" { + req.Model = s.config.DefaultModel + } + + var resp GenerateResponse + err := s.client.Post(ctx, "/api/generate", req, &resp) + if err != nil { + if s.logger != nil { + s.logger.Error("Text generation failed", map[string]interface{}{ + "model": req.Model, + "error": err.Error(), + }) + } + return nil, ErrGenerationFailed(err.Error()) + } + + if s.logger != nil { + s.logger.Info("Text generation completed", map[string]interface{}{ + "model": req.Model, + "response_length": len(resp.Response), + "eval_count": resp.EvalCount, + }) + } + + return &resp, nil +} + +// GenerateWithOptions generates text with additional options +func (s *Service) GenerateWithOptions(ctx context.Context, req *GenerateRequest, opts *GenerateOptions) (*GenerateResponse, error) { + if s.logger != nil { + s.logger.Info("Generating text with options", map[string]interface{}{ + "model": req.Model, + "prompt_length": len(req.Prompt), + }) + } + + // Apply options to request + if opts != nil { + if req.Options == nil { + req.Options = make(map[string]interface{}) + } + + if opts.Temperature != nil { + req.Options["temperature"] = *opts.Temperature + } + if opts.TopP != nil { + req.Options["top_p"] = *opts.TopP + } + if opts.TopK != nil { + req.Options["top_k"] = *opts.TopK + } + if opts.RepeatPenalty != nil { + req.Options["repeat_penalty"] = *opts.RepeatPenalty + } + if opts.Seed != nil { + req.Options["seed"] = *opts.Seed + } + if opts.Stop != nil { + req.Options["stop"] = opts.Stop + } + if opts.Format != "" { + req.Format = opts.Format + } + + // Merge additional options + for k, v := range opts.Options { + req.Options[k] = v + } + } + + return s.Generate(ctx, req) +} + +// Chat performs a conversational chat with the model +func (s *Service) Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { + if s.logger != nil { + s.logger.Info("Starting chat", map[string]interface{}{ + "model": req.Model, + "message_count": len(req.Messages), + }) + } + + // Set defaults + if req.Model == "" { + req.Model = s.config.DefaultModel + } + + var resp ChatResponse + err := s.client.Post(ctx, "/api/chat", req, &resp) + if err != nil { + if s.logger != nil { + s.logger.Error("Chat failed", map[string]interface{}{ + "model": req.Model, + "error": err.Error(), + }) + } + return nil, ErrChatFailed(err.Error()) + } + + if s.logger != nil { + s.logger.Info("Chat completed", map[string]interface{}{ + "model": req.Model, + "response_length": len(resp.Message.Content), + "eval_count": resp.EvalCount, + }) + } + + return &resp, nil +} + +// Embed generates embeddings for the given text +func (s *Service) Embed(ctx context.Context, req *EmbedRequest) (*EmbedResponse, error) { + if s.logger != nil { + s.logger.Info("Generating embeddings", map[string]interface{}{ + "model": req.Model, + "text_length": len(req.Prompt), + }) + } + + var resp EmbedResponse + err := s.client.Post(ctx, "/api/embeddings", req, &resp) + if err != nil { + if s.logger != nil { + s.logger.Error("Embedding generation failed", map[string]interface{}{ + "model": req.Model, + "error": err.Error(), + }) + } + return nil, ErrEmbeddingFailed(err.Error()) + } + + if s.logger != nil { + s.logger.Info("Embeddings generated", map[string]interface{}{ + "model": req.Model, + "embedding_dimension": len(resp.Embedding), + }) + } + + return &resp, nil +} + +// PullModel pulls a model from the registry +func (s *Service) PullModel(ctx context.Context, model string) (*PullResponse, error) { + if s.logger != nil { + s.logger.Info("Pulling model", map[string]interface{}{ + "model": model, + }) + } + + req := &PullRequest{ + Name: model, + } + + var resp PullResponse + err := s.client.Post(ctx, "/api/pull", req, &resp) + if err != nil { + if s.logger != nil { + s.logger.Error("Model pull failed", map[string]interface{}{ + "model": model, + "error": err.Error(), + }) + } + return nil, ErrModelPullFailed(model, err.Error()) + } + + if s.logger != nil { + s.logger.Info("Model pulled successfully", map[string]interface{}{ + "model": model, + "status": resp.Status, + }) + } + + return &resp, nil +} + +// ListModels lists available models +func (s *Service) ListModels(ctx context.Context) (*ListModelsResponse, error) { + if s.logger != nil { + s.logger.Debug("Listing models", map[string]interface{}{}) + } + + var resp ListModelsResponse + err := s.client.Get(ctx, "/api/tags", &resp) + if err != nil { + if s.logger != nil { + s.logger.Error("Failed to list models", map[string]interface{}{ + "error": err.Error(), + }) + } + return nil, fmt.Errorf("failed to list models: %w", err) + } + + if s.logger != nil { + s.logger.Debug("Models listed", map[string]interface{}{ + "model_count": len(resp.Models), + }) + } + + return &resp, nil +} + +// DeleteModel deletes a model +func (s *Service) DeleteModel(ctx context.Context, model string) (*DeleteResponse, error) { + if s.logger != nil { + s.logger.Info("Deleting model", map[string]interface{}{ + "model": model, + }) + } + + req := &DeleteRequest{ + Name: model, + } + + var resp DeleteResponse + err := s.client.Delete(ctx, "/api/delete", req, &resp) + if err != nil { + if s.logger != nil { + s.logger.Error("Model deletion failed", map[string]interface{}{ + "model": model, + "error": err.Error(), + }) + } + return nil, ErrModelDeleteFailed(model, err.Error()) + } + + if s.logger != nil { + s.logger.Info("Model deleted successfully", map[string]interface{}{ + "model": model, + "status": resp.Status, + }) + } + + return &resp, nil +} + +// ShowModel shows model information +func (s *Service) ShowModel(ctx context.Context, model string) (*ShowModelResponse, error) { + if s.logger != nil { + s.logger.Debug("Showing model information", map[string]interface{}{ + "model": model, + }) + } + + req := &ShowModelRequest{ + Name: model, + } + + var resp ShowModelResponse + err := s.client.Post(ctx, "/api/show", req, &resp) + if err != nil { + if s.logger != nil { + s.logger.Error("Failed to show model", map[string]interface{}{ + "model": model, + "error": err.Error(), + }) + } + return nil, fmt.Errorf("failed to show model %s: %w", model, err) + } + + if s.logger != nil { + s.logger.Debug("Model information retrieved", map[string]interface{}{ + "model": model, + }) + } + + return &resp, nil +} + +// Health checks if the Ollama service is available +func (s *Service) Health(ctx context.Context) error { + if s.logger != nil { + s.logger.Debug("Checking Ollama health", map[string]interface{}{}) + } + + var resp HealthResponse + err := s.client.Get(ctx, "/api/version", &resp) + if err != nil { + if s.logger != nil { + s.logger.Error("Health check failed", map[string]interface{}{ + "error": err.Error(), + }) + } + return ErrConnectionFailed(err.Error()) + } + + if s.logger != nil { + s.logger.Debug("Health check passed", map[string]interface{}{}) + } + + return nil +} + +// NewChatBuilder creates a new chat builder for conversational interactions +func (s *Service) NewChatBuilder() ChatBuilder { + return &chatBuilder{ + service: s, + request: &ChatRequest{ + Model: s.config.DefaultModel, + Messages: make([]Message, 0), + Options: ChatOptions{ + Temperature: &s.config.DefaultTemperature, + TopP: &s.config.DefaultTopP, + }, + }, + } +} + +// chatBuilder implements the ChatBuilder interface +type chatBuilder struct { + service OllamaService + request *ChatRequest +} + +func (b *chatBuilder) SetModel(model string) ChatBuilder { + b.request.Model = model + return b +} + +func (b *chatBuilder) SetSystem(system string) ChatBuilder { + // Add or update system message + for i, msg := range b.request.Messages { + if msg.Role == MessageRoleSystem { + b.request.Messages[i].Content = system + return b + } + } + + // Add new system message at the beginning + systemMsg := Message{ + Role: MessageRoleSystem, + Content: system, + } + b.request.Messages = append([]Message{systemMsg}, b.request.Messages...) + return b +} + +func (b *chatBuilder) AddMessage(role MessageRole, content string) ChatBuilder { + msg := Message{ + Role: role, + Content: content, + } + b.request.Messages = append(b.request.Messages, msg) + return b +} + +func (b *chatBuilder) SetTemperature(temperature float64) ChatBuilder { + b.request.Options.Temperature = &temperature + return b +} + +func (b *chatBuilder) SetTopP(topP float64) ChatBuilder { + b.request.Options.TopP = &topP + return b +} + +func (b *chatBuilder) SetMaxTokens(maxTokens int) ChatBuilder { + b.request.Options.NumPredict = &maxTokens + return b +} + +func (b *chatBuilder) SetStream(stream bool) ChatBuilder { + b.request.Stream = &stream + return b +} + +func (b *chatBuilder) SetFormat(format string) ChatBuilder { + b.request.Format = format + return b +} + +func (b *chatBuilder) SetOptions(options map[string]interface{}) ChatBuilder { + if b.request.Options.Options == nil { + b.request.Options.Options = make(map[string]interface{}) + } + for k, v := range options { + b.request.Options.Options[k] = v + } + return b +} + +func (b *chatBuilder) Build() (*ChatRequest, error) { + if len(b.request.Messages) == 0 { + return nil, ErrInvalidRequest("at least one message is required") + } + return b.request, nil +} + +func (b *chatBuilder) Send(ctx context.Context) (*ChatResponse, error) { + req, err := b.Build() + if err != nil { + return nil, err + } + return b.service.Chat(ctx, req) +} + +func (b *chatBuilder) Continue(ctx context.Context, userMessage string) (*ChatResponse, error) { + // Add user message + b.AddMessage(MessageRoleUser, userMessage) + + // Send chat request + resp, err := b.Send(ctx) + if err != nil { + return nil, err + } + + // Add assistant response to conversation history + b.AddMessage(MessageRoleAssistant, resp.Message.Content) + + return resp, nil +} diff --git a/pkg/schedule/cron.go b/pkg/schedule/cron.go index 2f9220d..ab9ac46 100644 --- a/pkg/schedule/cron.go +++ b/pkg/schedule/cron.go @@ -2,7 +2,7 @@ package schedule import ( "time" - "tm/internal/tender" + "tm/internal/notice" "tm/pkg/logger" "tm/pkg/mongo" @@ -13,7 +13,7 @@ import ( type CronScheduler struct { cron *cron.Cron mongoManager *mongo.ConnectionManager - tenderRepo tender.TenderRepository + noticeRepo notice.Repository logger logger.Logger } @@ -31,14 +31,14 @@ type Job struct { } // NewCronScheduler creates a new cron scheduler instance -func NewCronScheduler(logger logger.Logger, mongoManager *mongo.ConnectionManager, tenderRepo tender.TenderRepository) *CronScheduler { +func NewCronScheduler(logger logger.Logger, mongoManager *mongo.ConnectionManager, noticeRepo notice.Repository) *CronScheduler { // Create cron with timezone support and seconds precision c := cron.New(cron.WithLocation(time.UTC), cron.WithSeconds()) return &CronScheduler{ cron: c, mongoManager: mongoManager, - tenderRepo: tenderRepo, + noticeRepo: noticeRepo, logger: logger, } } diff --git a/ted/mapper.go b/ted/mapper.go index 2134d86..2d254c4 100644 --- a/ted/mapper.go +++ b/ted/mapper.go @@ -4,11 +4,11 @@ import ( "strconv" "strings" "time" - "tm/internal/tender" + "tm/internal/notice" ) // mapToTenderFromParsedDoc maps a parsed TED document to tender entity -func (s *TEDScraper) mapToTenderFromParsedDoc(parsedDoc *ParsedDocument, sourceFileURL, sourceFileName string) *tender.Tender { +func (s *TEDScraper) mapToTenderFromParsedDoc(parsedDoc *ParsedDocument, sourceFileURL, sourceFileName string) *notice.Notice { switch parsedDoc.Type { case DocumentTypeContractNotice: if parsedDoc.ContractNotice == nil { @@ -45,7 +45,7 @@ func (s *TEDScraper) mapToTenderFromParsedDoc(parsedDoc *ParsedDocument, sourceF } // mapGenericNoticeToTender maps any TED document type to tender entity using the common interface -func (s *TEDScraper) mapGenericNoticeToTender(doc TEDDocument, sourceFileURL, sourceFileName string) *tender.Tender { +func (s *TEDScraper) mapGenericNoticeToTender(doc TEDDocument, sourceFileURL, sourceFileName string) *notice.Notice { if doc == nil { s.logger.Error("Document is nil in mapGenericNoticeToTender", map[string]interface{}{}) return nil @@ -53,7 +53,7 @@ func (s *TEDScraper) mapGenericNoticeToTender(doc TEDDocument, sourceFileURL, so now := time.Now().Unix() - t := &tender.Tender{ + t := ¬ice.Notice{ ContractNoticeID: doc.GetID(), ContractFolderID: "", // Not available in generic interface NoticeTypeCode: doc.GetNoticeType(), @@ -68,11 +68,11 @@ func (s *TEDScraper) mapGenericNoticeToTender(doc TEDDocument, sourceFileURL, so // Basic tender information Title: "Generic Notice " + doc.GetNoticeType(), Description: "Processed from " + doc.GetNoticeType() + " document", - Status: tender.TenderStatusActive, - Source: tender.TenderSourceTEDScraper, + Status: notice.TenderStatusActive, + Source: notice.TenderSourceTEDScraper, SourceFileURL: sourceFileURL, SourceFileName: sourceFileName, - ProcessingMetadata: tender.ProcessingMetadata{ + ProcessingMetadata: notice.ProcessingMetadata{ ScrapedAt: now, ProcessedAt: now, ProcessingVersion: "1.0", @@ -86,10 +86,10 @@ func (s *TEDScraper) mapGenericNoticeToTender(doc TEDDocument, sourceFileURL, so } // mapToTender maps a TED ContractNotice to tender entity -func (s *TEDScraper) mapToTender(cn *ContractNotice, sourceFileURL, sourceFileName string) *tender.Tender { +func (s *TEDScraper) mapToTender(cn *ContractNotice, sourceFileURL, sourceFileName string) *notice.Notice { now := time.Now().Unix() - t := &tender.Tender{ + t := ¬ice.Notice{ ContractNoticeID: cn.ID, ContractFolderID: cn.ContractFolderID, NoticeTypeCode: cn.GetNoticeSubType(), @@ -105,11 +105,11 @@ func (s *TEDScraper) mapToTender(cn *ContractNotice, sourceFileURL, sourceFileNa MainClassification: cn.GetMainClassification(), PlaceOfPerformance: cn.GetPlaceOfPerformance(), DocumentURI: cn.GetDocumentURI(), - Status: tender.TenderStatusActive, - Source: tender.TenderSourceTEDScraper, + Status: notice.TenderStatusActive, + Source: notice.TenderSourceTEDScraper, SourceFileURL: sourceFileURL, SourceFileName: sourceFileName, - ProcessingMetadata: tender.ProcessingMetadata{ + ProcessingMetadata: notice.ProcessingMetadata{ ScrapedAt: now, ProcessedAt: now, ProcessingVersion: "1.0", @@ -215,7 +215,7 @@ func (s *TEDScraper) mapToTender(cn *ContractNotice, sourceFileURL, sourceFileNa // Set selection criteria if criteria := cn.GetSelectionCriteria(); criteria != nil { for _, criterion := range criteria { - t.SelectionCriteria = append(t.SelectionCriteria, tender.SelectionCriterion{ + t.SelectionCriteria = append(t.SelectionCriteria, notice.SelectionCriterion{ TypeCode: criterion.CriterionTypeCode, Description: criterion.Description, LanguageID: criterion.LanguageID, @@ -235,10 +235,10 @@ func (s *TEDScraper) mapToTender(cn *ContractNotice, sourceFileURL, sourceFileNa } // mapContractAwardNoticeToTender maps a TED ContractAwardNotice to tender entity -func (s *TEDScraper) mapContractAwardNoticeToTender(can *ContractAwardNotice, sourceFileURL, sourceFileName string) *tender.Tender { +func (s *TEDScraper) mapContractAwardNoticeToTender(can *ContractAwardNotice, sourceFileURL, sourceFileName string) *notice.Notice { now := time.Now().Unix() - t := &tender.Tender{ + t := ¬ice.Notice{ ContractNoticeID: can.ID, ContractFolderID: can.ContractFolderID, NoticeTypeCode: can.NoticeTypeCode, @@ -252,11 +252,11 @@ func (s *TEDScraper) mapContractAwardNoticeToTender(can *ContractAwardNotice, so ProcurementTypeCode: can.GetContractNature(), ProcedureCode: can.GetProcedureCode(), MainClassification: can.GetMainClassification(), - Status: tender.TenderStatusAwarded, // Award notices are for completed tenders - Source: tender.TenderSourceTEDScraper, + Status: notice.TenderStatusAwarded, // Award notices are for completed tenders + Source: notice.TenderSourceTEDScraper, SourceFileURL: sourceFileURL, SourceFileName: sourceFileName, - ProcessingMetadata: tender.ProcessingMetadata{ + ProcessingMetadata: notice.ProcessingMetadata{ ScrapedAt: now, ProcessedAt: now, ProcessingVersion: "1.0", @@ -315,13 +315,13 @@ func (s *TEDScraper) mapContractAwardNoticeToTender(can *ContractAwardNotice, so } // mapOrganization maps a TED Organization to tender Organization -func (s *TEDScraper) mapOrganization(tedOrg *Organization, role string) *tender.Organization { +func (s *TEDScraper) mapOrganization(tedOrg *Organization, role string) *notice.Organization { if tedOrg == nil || tedOrg.Company == nil { return nil } company := tedOrg.Company - org := &tender.Organization{ + org := ¬ice.Organization{ Role: role, } @@ -351,7 +351,7 @@ func (s *TEDScraper) mapOrganization(tedOrg *Organization, role string) *tender. // Set address if company.PostalAddress != nil { - org.Address = tender.Address{ + org.Address = notice.Address{ StreetName: company.PostalAddress.StreetName, CityName: company.PostalAddress.CityName, PostalZone: company.PostalAddress.PostalZone, diff --git a/ted/scraper.go b/ted/scraper.go index 8f5a18a..c37bd6e 100644 --- a/ted/scraper.go +++ b/ted/scraper.go @@ -10,7 +10,7 @@ import ( "path/filepath" "strings" "time" - "tm/internal/tender" + "tm/internal/notice" "tm/pkg/logger" "tm/pkg/mongo" "tm/pkg/notification" @@ -37,7 +37,7 @@ type TEDScraper struct { httpClient *http.Client xmlParser *TEDParser mongoManager *mongo.ConnectionManager - tenderRepo tender.TenderRepository + tenderRepo notice.Repository } // NewTEDScraper creates a new TED scraper instance @@ -45,7 +45,7 @@ func NewTEDScraper( config *Config, logger logger.Logger, mongoManager *mongo.ConnectionManager, - tenderRepo tender.TenderRepository, + tenderRepo notice.Repository, notify notification.SDK, ) *TEDScraper { httpClient := &http.Client{ @@ -275,7 +275,7 @@ func (s *TEDScraper) tarGzFileProcessor(ctx context.Context, reader io.Reader, o return result, nil } -func (s *TEDScraper) findTenderByContractNoticeID(ctx context.Context, contractNoticeID string) (*tender.Tender, error) { +func (s *TEDScraper) findTenderByContractNoticeID(ctx context.Context, contractNoticeID string) (*notice.Notice, error) { existingTender, err := s.tenderRepo.GetByContractNoticeID(ctx, contractNoticeID) if err != nil { if err.Error() == "document not found" { @@ -308,60 +308,60 @@ func (s *TEDScraper) processXMLContent(ctx context.Context, content []byte, file // Map to tender entity t := s.mapToTenderFromParsedDoc(notice, "", fileName) - existingTender, err := s.findTenderByContractNoticeID(ctx, t.ContractNoticeID) + // existingTender, err := s.findTenderByContractNoticeID(ctx, t.ContractNoticeID) + // if err != nil { + // s.logger.Warn("Error checking for existing tender", map[string]interface{}{ + // "contract_notice_id": t.ContractNoticeID, + // "error": err.Error(), + // }) + // } + + // if err == nil && existingTender != nil { + // // Update existing tender + // s.logger.Info("Found existing tender, updating", map[string]interface{}{ + // "existing_tender_id": existingTender.ID.Hex(), + // "contract_notice_id": t.ContractNoticeID, + // }) + + // t.ID = existingTender.ID + // t.UpdatedAt = time.Now().Unix() + // err = s.tenderRepo.Update(ctx, t) + // if err != nil { + // s.logger.Error("Failed to update existing tender", map[string]interface{}{ + // "tender_id": t.ID.Hex(), + // "error": err.Error(), + // }) + // return fmt.Errorf("failed to update existing tender: %w", err) + // } + + // s.logger.Info("Successfully updated tender", map[string]interface{}{ + // "tender_id": t.ID.Hex(), + // }) + // } else { + // // Create new tender + s.logger.Info("Creating new tender", map[string]interface{}{ + "contract_notice_id": t.ContractNoticeID, + "tender_id": t.TenderID, + }) + + t.CreatedAt = time.Now().Unix() + t.UpdatedAt = t.CreatedAt + + err = s.tenderRepo.Import(ctx, t) if err != nil { - s.logger.Warn("Error checking for existing tender", map[string]interface{}{ + s.logger.Error("Failed to create new tender", map[string]interface{}{ "contract_notice_id": t.ContractNoticeID, + "tender_id": t.TenderID, "error": err.Error(), }) + return fmt.Errorf("failed to create tender: %w", err) } - if err == nil && existingTender != nil { - // Update existing tender - s.logger.Info("Found existing tender, updating", map[string]interface{}{ - "existing_tender_id": existingTender.ID.Hex(), - "contract_notice_id": t.ContractNoticeID, - }) - - t.ID = existingTender.ID - t.UpdatedAt = time.Now().Unix() - err = s.tenderRepo.Update(ctx, t) - if err != nil { - s.logger.Error("Failed to update existing tender", map[string]interface{}{ - "tender_id": t.ID.Hex(), - "error": err.Error(), - }) - return fmt.Errorf("failed to update existing tender: %w", err) - } - - s.logger.Info("Successfully updated tender", map[string]interface{}{ - "tender_id": t.ID.Hex(), - }) - } else { - // Create new tender - s.logger.Info("Creating new tender", map[string]interface{}{ - "contract_notice_id": t.ContractNoticeID, - "tender_id": t.TenderID, - }) - - t.CreatedAt = time.Now().Unix() - t.UpdatedAt = t.CreatedAt - - err = s.tenderRepo.Create(ctx, t) - if err != nil { - s.logger.Error("Failed to create new tender", map[string]interface{}{ - "contract_notice_id": t.ContractNoticeID, - "tender_id": t.TenderID, - "error": err.Error(), - }) - return fmt.Errorf("failed to create tender: %w", err) - } - - s.logger.Info("Successfully created new tender", map[string]interface{}{ - "tender_id": t.TenderID, - "contract_notice_id": t.ContractNoticeID, - }) - } + s.logger.Info("Successfully created new tender", map[string]interface{}{ + "tender_id": t.TenderID, + "contract_notice_id": t.ContractNoticeID, + }) + // } // Log detailed parsed information based on notice type s.logger.Info("Successfully parsed notice", map[string]interface{}{ diff --git a/ted/service.go b/ted/service.go index 622b16e..ef691d0 100644 --- a/ted/service.go +++ b/ted/service.go @@ -5,7 +5,7 @@ import ( "strconv" "strings" "time" - "tm/internal/tender" + "tm/internal/notice" ) // parseDate parses various date formats used in TED XML @@ -146,7 +146,7 @@ func GenerateTenderURL(noticePublicationID string) string { // generateTenderID generates a unique tender ID using the format: // {Source}{BUYER_CODE}{TED_CODE}{DATE}{LOCATION_CODE} -func GenerateTenderID(t *tender.Tender) string { +func GenerateTenderID(t *notice.Notice) string { var parts []string // Source (TED)