Implement TED Scraper and Configuration Management

- Introduced a new TED scraper in `cmd/scraper` to handle downloading and parsing TED XML files.
- Added configuration management for the scraper, including a new `Config` struct and YAML configuration file.
- Created necessary handler, service, and repository layers for tender management, adhering to Clean Architecture principles.
- Implemented comprehensive logging and error handling throughout the scraper functionality.
- Updated API routes to include tender management operations, enhancing the overall system capabilities.
This commit is contained in:
n.nakhostin
2025-08-13 16:52:24 +03:30
parent b7fa012d13
commit 96c21b8b78
16 changed files with 6970 additions and 7 deletions
+418
View File
@@ -0,0 +1,418 @@
package tender
import (
"fmt"
"strconv"
"strings"
"time"
"tm/pkg/mongo"
)
// Tender represents a tender/contract notice entity
type Tender 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"` // Unix milliseconds
IssueTime int64 `bson:"issue_time" json:"issue_time"` // Unix milliseconds
PublicationDate int64 `bson:"publication_date" json:"publication_date"` // Unix milliseconds
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"`
TenderDeadline int64 `bson:"tender_deadline" json:"tender_deadline"` // Unix milliseconds
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"`
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"`
SubmissionDeadline int64 `bson:"submission_deadline" json:"submission_deadline"` // Unix milliseconds
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}
}
// 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"` // Unix milliseconds
ProcessedAt int64 `bson:"processed_at" json:"processed_at"` // Unix milliseconds
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"`
}
// 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"
)
// 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"
)
// TenderSearchCriteria represents search criteria for tenders
type TenderSearchCriteria struct {
Query string `json:"query,omitempty"`
NoticeType string `json:"notice_type,omitempty"`
ProcurementType string `json:"procurement_type,omitempty"`
CountryCodes []string `json:"country_codes,omitempty"`
RegionCodes []string `json:"region_codes,omitempty"`
Classifications []string `json:"classifications,omitempty"`
MinEstimatedValue *float64 `json:"min_estimated_value,omitempty"`
MaxEstimatedValue *float64 `json:"max_estimated_value,omitempty"`
Currency string `json:"currency,omitempty"`
DeadlineFrom *int64 `json:"deadline_from,omitempty"` // Unix milliseconds
DeadlineTo *int64 `json:"deadline_to,omitempty"` // Unix milliseconds
PublicationDateFrom *int64 `json:"publication_date_from,omitempty"` // Unix milliseconds
PublicationDateTo *int64 `json:"publication_date_to,omitempty"` // Unix milliseconds
Status []TenderStatus `json:"status,omitempty"`
Source []TenderSource `json:"source,omitempty"`
BuyerOrganizationID string `json:"buyer_organization_id,omitempty"`
Languages []string `json:"languages,omitempty"`
}
// 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 *Tender) GetID() string {
return t.ID.Hex()
}
// IsActive returns true if the tender is active
func (t *Tender) IsActive() bool {
return t.Status == TenderStatusActive
}
// IsExpired returns true if the tender deadline has passed
func (t *Tender) 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().UnixMilli()
return t.TenderDeadline < now
}
// GetTenderURL returns the TED tender URL
func (t *Tender) 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 *Tender) GetMainOrganization() *Organization {
return t.BuyerOrganization
}
// HasEstimatedValue returns true if the tender has an estimated value
func (t *Tender) HasEstimatedValue() bool {
return t.EstimatedValue > 0
}
// GetFormattedEstimatedValue returns formatted estimated value with currency
func (t *Tender) 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 *Tender) UpdateStatus(status TenderStatus) {
t.Status = status
t.UpdatedAt = time.Now().UnixMilli()
}
// AddProcessingError adds a processing error to metadata
func (t *Tender) AddProcessingError(error string) {
if t.ProcessingMetadata.ParsingErrors == nil {
t.ProcessingMetadata.ParsingErrors = make([]string, 0)
}
t.ProcessingMetadata.ParsingErrors = append(t.ProcessingMetadata.ParsingErrors, error)
t.UpdatedAt = time.Now().UnixMilli()
}
// AddValidationError adds a validation error to metadata
func (t *Tender) AddValidationError(error string) {
if t.ProcessingMetadata.ValidationErrors == nil {
t.ProcessingMetadata.ValidationErrors = make([]string, 0)
}
t.ProcessingMetadata.ValidationErrors = append(t.ProcessingMetadata.ValidationErrors, error)
t.UpdatedAt = time.Now().UnixMilli()
}
// HasErrors returns true if the tender has processing or validation errors
func (t *Tender) HasErrors() bool {
return len(t.ProcessingMetadata.ParsingErrors) > 0 || len(t.ProcessingMetadata.ValidationErrors) > 0
}
// GetAllErrors returns all processing and validation errors
func (t *Tender) GetAllErrors() []string {
var allErrors []string
allErrors = append(allErrors, t.ProcessingMetadata.ParsingErrors...)
allErrors = append(allErrors, t.ProcessingMetadata.ValidationErrors...)
return allErrors
}
// ScrapingJobType represents the type of scraping job
type ScrapingJobType string
const (
ScrapingJobTypeTEDScraper ScrapingJobType = "ted_scraper"
ScrapingJobTypeManual ScrapingJobType = "manual"
ScrapingJobTypeAPI ScrapingJobType = "api"
ScrapingJobTypeBulkImport ScrapingJobType = "bulk_import"
)
// ScrapingJobStatus represents the status of a scraping job
type ScrapingJobStatus string
const (
ScrapingJobStatusPending ScrapingJobStatus = "pending"
ScrapingJobStatusRunning ScrapingJobStatus = "running"
ScrapingJobStatusCompleted ScrapingJobStatus = "completed"
ScrapingJobStatusFailed ScrapingJobStatus = "failed"
ScrapingJobStatusCancelled ScrapingJobStatus = "cancelled"
)
// JobProgress represents the progress of a scraping job
type JobProgress struct {
ProcessedCount int `bson:"processed_count" json:"processed_count"`
TotalCount int `bson:"total_count" json:"total_count"`
SuccessCount int `bson:"success_count" json:"success_count"`
ErrorCount int `bson:"error_count" json:"error_count"`
Percentage float64 `bson:"percentage" json:"percentage"`
CurrentStep string `bson:"current_step" json:"current_step"`
}
// JobResults represents the results of a scraping job
type JobResults struct {
TotalProcessed int `bson:"total_processed" json:"total_processed"`
TotalCreated int `bson:"total_created" json:"total_created"`
TotalUpdated int `bson:"total_updated" json:"total_updated"`
TotalSkipped int `bson:"total_skipped" json:"total_skipped"`
TotalErrors int `bson:"total_errors" json:"total_errors"`
ProcessingErrors []string `bson:"processing_errors,omitempty" json:"processing_errors,omitempty"`
Summary string `bson:"summary,omitempty" json:"summary,omitempty"`
}
// ScrapingJob represents a scraping job entity
type ScrapingJob struct {
ID string `bson:"_id,omitempty" json:"id"`
Type ScrapingJobType `bson:"type" json:"type"`
Status ScrapingJobStatus `bson:"status" json:"status"`
StartedAt int64 `bson:"started_at" json:"started_at"` // Unix seconds
CompletedAt *int64 `bson:"completed_at,omitempty" json:"completed_at,omitempty"` // Unix seconds
Duration *int64 `bson:"duration,omitempty" json:"duration,omitempty"` // Duration in seconds
Progress JobProgress `bson:"progress" json:"progress"`
Results JobResults `bson:"results" json:"results"`
Errors []string `bson:"errors,omitempty" json:"errors,omitempty"`
Config map[string]interface{} `bson:"config,omitempty" json:"config,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"` // Unix seconds
UpdatedAt int64 `bson:"updated_at" json:"updated_at"` // Unix seconds
}
// ScrapingState represents the state of the scraping process
type ScrapingState struct {
ID string `bson:"_id,omitempty" json:"id"`
LastProcessedYear int `bson:"last_processed_year" json:"last_processed_year"`
LastProcessedNumber int `bson:"last_processed_number" json:"last_processed_number"`
LastRunAt int64 `bson:"last_run_at" json:"last_run_at"` // Unix seconds
NextRunAt int64 `bson:"next_run_at" json:"next_run_at"` // Unix seconds
IsRunning bool `bson:"is_running" json:"is_running"`
CurrentJobID string `bson:"current_job_id,omitempty" json:"current_job_id,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"` // Unix seconds
UpdatedAt int64 `bson:"updated_at" json:"updated_at"` // Unix seconds
}
// GetJobID returns the scraping job ID
func (j *ScrapingJob) GetJobID() string {
return j.ID
}
// IsActive returns true if the job is actively running
func (j *ScrapingJob) IsActive() bool {
return j.Status == ScrapingJobStatusRunning || j.Status == ScrapingJobStatusPending
}
// IsCompleted returns true if the job has completed (successfully or with failure)
func (j *ScrapingJob) IsCompleted() bool {
return j.Status == ScrapingJobStatusCompleted || j.Status == ScrapingJobStatusFailed || j.Status == ScrapingJobStatusCancelled
}
// GetProgressPercentage calculates and returns the progress percentage
func (j *ScrapingJob) GetProgressPercentage() float64 {
if j.Progress.TotalCount == 0 {
return 0
}
return float64(j.Progress.ProcessedCount) / float64(j.Progress.TotalCount) * 100
}
// UpdateProgress updates the job progress
func (j *ScrapingJob) UpdateProgress(processed, total, success, errors int, step string) {
j.Progress.ProcessedCount = processed
j.Progress.TotalCount = total
j.Progress.SuccessCount = success
j.Progress.ErrorCount = errors
j.Progress.CurrentStep = step
j.Progress.Percentage = j.GetProgressPercentage()
j.UpdatedAt = time.Now().Unix()
}
// AddError adds an error to the job
func (j *ScrapingJob) AddError(error string) {
if j.Errors == nil {
j.Errors = make([]string, 0)
}
j.Errors = append(j.Errors, error)
j.UpdatedAt = time.Now().Unix()
}
// MarkCompleted marks the job as completed
func (j *ScrapingJob) MarkCompleted(status ScrapingJobStatus, results JobResults) {
j.Status = status
j.Results = results
completedAt := time.Now().Unix()
j.CompletedAt = &completedAt
if j.StartedAt > 0 {
duration := completedAt - j.StartedAt
j.Duration = &duration
}
j.UpdatedAt = completedAt
}
// IsStateRunning returns true if the scraping state indicates a job is running
func (s *ScrapingState) IsStateRunning() bool {
return s.IsRunning
}
// MarkAsRunning marks the state as running with a job ID
func (s *ScrapingState) MarkAsRunning(jobID string) {
s.IsRunning = true
s.CurrentJobID = jobID
s.LastRunAt = time.Now().Unix()
s.UpdatedAt = time.Now().Unix()
}
// MarkAsCompleted marks the state as completed
func (s *ScrapingState) MarkAsCompleted(year, number int) {
s.IsRunning = false
s.CurrentJobID = ""
s.LastProcessedYear = year
s.LastProcessedNumber = number
s.NextRunAt = time.Now().Add(4 * time.Hour).Unix() // Schedule next run in 4 hours
s.UpdatedAt = time.Now().Unix()
}
+293
View File
@@ -0,0 +1,293 @@
package tender
// CreateTenderRequest represents a request to create a new tender
type CreateTenderRequest struct {
ContractNoticeID string `json:"contract_notice_id" valid:"required~Contract notice ID is required"`
NoticePublicationID string `json:"notice_publication_id" valid:"required~Notice publication ID is required"`
ContractFolderID string `json:"contract_folder_id" valid:"required~Contract folder ID is required"`
NoticeTypeCode string `json:"notice_type_code" valid:"required~Notice type code is required"`
NoticeSubTypeCode string `json:"notice_sub_type_code,omitempty"`
NoticeLanguageCode string `json:"notice_language_code,omitempty" valid:"stringlength(3|3)~Language code must be 3 characters"`
IssueDate int64 `json:"issue_date" valid:"required~Issue date is required"` // Unix milliseconds
IssueTime int64 `json:"issue_time,omitempty"` // Unix milliseconds
PublicationDate int64 `json:"publication_date,omitempty"` // Unix milliseconds
GazetteID string `json:"gazette_id,omitempty"`
Title string `json:"title" valid:"required~Title is required,stringlength(1|500)~Title must be between 1 and 500 characters"`
Description string `json:"description,omitempty" valid:"stringlength(0|5000)~Description must not exceed 5000 characters"`
ProcurementTypeCode string `json:"procurement_type_code,omitempty"`
ProcedureCode string `json:"procedure_code,omitempty"`
MainClassification string `json:"main_classification,omitempty"`
AdditionalClassifications []string `json:"additional_classifications,omitempty"`
EstimatedValue float64 `json:"estimated_value,omitempty" valid:"range(0|999999999999)~Estimated value must be between 0 and 999999999999"`
Currency string `json:"currency,omitempty" valid:"stringlength(3|3)~Currency must be 3 characters"`
Duration string `json:"duration,omitempty"`
DurationUnit string `json:"duration_unit,omitempty"`
TenderDeadline int64 `json:"tender_deadline,omitempty"` // Unix milliseconds
PlaceOfPerformance string `json:"place_of_performance,omitempty"`
CountryCode string `json:"country_code,omitempty" valid:"stringlength(2|2)~Country code must be 2 characters"`
RegionCode string `json:"region_code,omitempty"`
CityName string `json:"city_name,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
DocumentURI string `json:"document_uri,omitempty" valid:"url~Document URI must be a valid URL"`
TenderURL string `json:"tender_url,omitempty" valid:"url~Tender URL must be a valid URL"`
BuyerOrganization *Organization `json:"buyer_organization,omitempty"`
ReviewOrganization *Organization `json:"review_organization,omitempty"`
Organizations []Organization `json:"organizations,omitempty"`
SelectionCriteria []SelectionCriterion `json:"selection_criteria,omitempty"`
OfficialLanguages []string `json:"official_languages,omitempty"`
Source TenderSource `json:"source" valid:"required~Source is required"`
SourceFileURL string `json:"source_file_url,omitempty" valid:"url~Source file URL must be a valid URL"`
SourceFileName string `json:"source_file_name,omitempty"`
}
// UpdateTenderRequest represents a request to update an existing tender
type UpdateTenderRequest struct {
ID string `json:"id" valid:"required~Tender ID is required"`
Title *string `json:"title,omitempty" valid:"stringlength(1|500)~Title must be between 1 and 500 characters"`
Description *string `json:"description,omitempty" valid:"stringlength(0|5000)~Description must not exceed 5000 characters"`
Status *TenderStatus `json:"status,omitempty"`
EstimatedValue *float64 `json:"estimated_value,omitempty" valid:"range(0|999999999999)~Estimated value must be between 0 and 999999999999"`
Currency *string `json:"currency,omitempty" valid:"stringlength(3|3)~Currency must be 3 characters"`
TenderDeadline *int64 `json:"tender_deadline,omitempty"` // Unix milliseconds
}
// ListTendersRequest represents a request to list tenders with pagination and filtering
type ListTendersRequest struct {
Criteria TenderSearchCriteria `json:"criteria"`
Limit int `json:"limit" valid:"range(1|100)~Limit must be between 1 and 100"`
Offset int `json:"offset" valid:"range(0|999999)~Offset must be non-negative"`
}
// ListTendersResponse represents the response for listing tenders
type ListTendersResponse struct {
Tenders []Tender `json:"tenders"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// SearchTendersRequest represents a request to search tenders
type SearchTendersRequest struct {
Query string `json:"query" valid:"required~Search query is required,stringlength(1|200)~Query must be between 1 and 200 characters"`
Criteria TenderSearchCriteria `json:"criteria"`
}
// SearchTendersResponse represents the response for searching tenders
type SearchTendersResponse struct {
Tenders []Tender `json:"tenders"`
Query string `json:"query"`
Total int `json:"total"`
}
// StartScrapingJobRequest represents a request to start a scraping job
type StartScrapingJobRequest struct {
Type ScrapingJobType `json:"type" valid:"required~Job type is required"`
Config map[string]interface{} `json:"config,omitempty"`
}
// ListScrapingJobsRequest represents a request to list scraping jobs
type ListScrapingJobsRequest struct {
Limit int `json:"limit" valid:"range(1|100)~Limit must be between 1 and 100"`
Offset int `json:"offset" valid:"range(0|999999)~Offset must be non-negative"`
}
// ListScrapingJobsResponse represents the response for listing scraping jobs
type ListScrapingJobsResponse struct {
Jobs []ScrapingJob `json:"jobs"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// BatchProcessingResult represents the result of batch processing
type BatchProcessingResult struct {
Total int `json:"total"`
Processed int `json:"processed"`
Errors []string `json:"errors"`
Tenders []*Tender `json:"tenders"`
SuccessRate float64 `json:"success_rate"`
}
// DashboardData represents dashboard data
type DashboardData struct {
Statistics *TenderStatistics `json:"statistics"`
RecentTenders []Tender `json:"recent_tenders"`
ActiveJobs []ScrapingJob `json:"active_jobs"`
}
// TenderResponse represents a tender in API responses
type TenderResponse struct {
ID string `json:"id"`
ContractNoticeID string `json:"contract_notice_id"`
NoticePublicationID string `json:"notice_publication_id"`
ContractFolderID string `json:"contract_folder_id"`
NoticeTypeCode string `json:"notice_type_code"`
NoticeSubTypeCode string `json:"notice_sub_type_code"`
NoticeLanguageCode string `json:"notice_language_code"`
IssueDate int64 `json:"issue_date"`
IssueTime int64 `json:"issue_time"`
PublicationDate int64 `json:"publication_date"`
GazetteID string `json:"gazette_id"`
Title string `json:"title"`
Description string `json:"description"`
ProcurementTypeCode string `json:"procurement_type_code"`
ProcedureCode string `json:"procedure_code"`
MainClassification string `json:"main_classification"`
AdditionalClassifications []string `json:"additional_classifications"`
EstimatedValue float64 `json:"estimated_value"`
Currency string `json:"currency"`
FormattedEstimatedValue string `json:"formatted_estimated_value"`
Duration string `json:"duration"`
DurationUnit string `json:"duration_unit"`
TenderDeadline int64 `json:"tender_deadline"`
PlaceOfPerformance string `json:"place_of_performance"`
CountryCode string `json:"country_code"`
RegionCode string `json:"region_code"`
CityName string `json:"city_name"`
PostalCode string `json:"postal_code"`
DocumentURI string `json:"document_uri"`
TenderURL string `json:"tender_url"`
BuyerOrganization *Organization `json:"buyer_organization"`
ReviewOrganization *Organization `json:"review_organization,omitempty"`
Organizations []Organization `json:"organizations"`
SelectionCriteria []SelectionCriterion `json:"selection_criteria"`
OfficialLanguages []string `json:"official_languages"`
Status TenderStatus `json:"status"`
Source TenderSource `json:"source"`
SourceFileURL string `json:"source_file_url"`
SourceFileName string `json:"source_file_name"`
ProcessingMetadata ProcessingMetadata `json:"processing_metadata"`
IsActive bool `json:"is_active"`
IsExpired bool `json:"is_expired"`
HasErrors bool `json:"has_errors"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// ScrapingJobResponse represents a scraping job in API responses
type ScrapingJobResponse struct {
ID string `json:"id"`
Type ScrapingJobType `json:"type"`
Status ScrapingJobStatus `json:"status"`
StartedAt int64 `json:"started_at"`
CompletedAt *int64 `json:"completed_at,omitempty"`
Duration *int64 `json:"duration,omitempty"`
Progress JobProgress `json:"progress"`
Results JobResults `json:"results"`
Errors []string `json:"errors,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// ScrapingStateResponse represents scraping state in API responses
type ScrapingStateResponse struct {
ID string `json:"id"`
LastProcessedYear int `json:"last_processed_year"`
LastProcessedNumber int `json:"last_processed_number"`
LastRunAt int64 `json:"last_run_at"`
NextRunAt int64 `json:"next_run_at"`
IsRunning bool `json:"is_running"`
CurrentJobID string `json:"current_job_id,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// ValidationErrorResponse represents validation errors in API responses
type ValidationErrorResponse struct {
Field string `json:"field"`
Message string `json:"message"`
Value string `json:"value,omitempty"`
}
// ErrorResponse represents error responses
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
ValidationErrors []ValidationErrorResponse `json:"validation_errors,omitempty"`
Timestamp int64 `json:"timestamp"`
}
// ToTenderResponse converts a Tender entity to TenderResponse
func (t *Tender) ToTenderResponse() *TenderResponse {
return &TenderResponse{
ID: t.ID.Hex(),
ContractNoticeID: t.ContractNoticeID,
NoticePublicationID: t.NoticePublicationID,
ContractFolderID: t.ContractFolderID,
NoticeTypeCode: t.NoticeTypeCode,
NoticeSubTypeCode: t.NoticeSubTypeCode,
NoticeLanguageCode: t.NoticeLanguageCode,
IssueDate: t.IssueDate,
IssueTime: t.IssueTime,
PublicationDate: t.PublicationDate,
GazetteID: t.GazetteID,
Title: t.Title,
Description: t.Description,
ProcurementTypeCode: t.ProcurementTypeCode,
ProcedureCode: t.ProcedureCode,
MainClassification: t.MainClassification,
AdditionalClassifications: t.AdditionalClassifications,
EstimatedValue: t.EstimatedValue,
Currency: t.Currency,
FormattedEstimatedValue: t.GetFormattedEstimatedValue(),
Duration: t.Duration,
DurationUnit: t.DurationUnit,
TenderDeadline: t.TenderDeadline,
PlaceOfPerformance: t.PlaceOfPerformance,
CountryCode: t.CountryCode,
RegionCode: t.RegionCode,
CityName: t.CityName,
PostalCode: t.PostalCode,
DocumentURI: t.DocumentURI,
TenderURL: t.TenderURL,
BuyerOrganization: t.BuyerOrganization,
ReviewOrganization: t.ReviewOrganization,
Organizations: t.Organizations,
SelectionCriteria: t.SelectionCriteria,
OfficialLanguages: t.OfficialLanguages,
Status: t.Status,
Source: t.Source,
SourceFileURL: t.SourceFileURL,
SourceFileName: t.SourceFileName,
ProcessingMetadata: t.ProcessingMetadata,
IsActive: t.IsActive(),
IsExpired: t.IsExpired(),
HasErrors: t.HasErrors(),
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
}
}
// ToScrapingJobResponse converts a ScrapingJob entity to ScrapingJobResponse
func (j *ScrapingJob) ToScrapingJobResponse() *ScrapingJobResponse {
return &ScrapingJobResponse{
ID: j.ID,
Type: j.Type,
Status: j.Status,
StartedAt: j.StartedAt,
CompletedAt: j.CompletedAt,
Duration: j.Duration,
Progress: j.Progress,
Results: j.Results,
Errors: j.Errors,
Config: j.Config,
CreatedAt: j.CreatedAt,
UpdatedAt: j.UpdatedAt,
}
}
// ToScrapingStateResponse converts a ScrapingState entity to ScrapingStateResponse
func (s *ScrapingState) ToScrapingStateResponse() *ScrapingStateResponse {
return &ScrapingStateResponse{
ID: s.ID,
LastProcessedYear: s.LastProcessedYear,
LastProcessedNumber: s.LastProcessedNumber,
LastRunAt: s.LastRunAt,
NextRunAt: s.NextRunAt,
IsRunning: s.IsRunning,
CurrentJobID: s.CurrentJobID,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
}
}
+660
View File
@@ -0,0 +1,660 @@
package tender
import (
"strconv"
"strings"
"tm/pkg/logger"
"tm/pkg/response"
"github.com/asaskevich/govalidator"
"github.com/labstack/echo/v4"
)
// TenderHandler handles tender-related HTTP requests
type TenderHandler struct {
service TenderService
logger logger.Logger
}
// NewTenderHandler creates a new tender handler
func NewTenderHandler(service TenderService, logger logger.Logger) *TenderHandler {
return &TenderHandler{
service: service,
logger: logger,
}
}
// CreateTender creates a new tender
// @Summary Create a new tender
// @Description Create a new tender with the provided information
// @Tags Admin-Tenders
// @Accept json
// @Produce json
// @Param tender body CreateTenderRequest true "Tender information"
// @Success 201 {object} response.APIResponse{data=TenderResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 409 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/tenders [post]
// @Security BearerAuth
func (h *TenderHandler) CreateTender(c echo.Context) error {
var req CreateTenderRequest
if err := c.Bind(&req); err != nil {
return response.ValidationError(c, "Invalid request format", err.Error())
}
if _, err := govalidator.ValidateStruct(req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
tender, err := h.service.CreateTender(c.Request().Context(), req)
if err != nil {
if err.Error() == "tender with notice publication ID already exists" {
return response.Conflict(c, "Tender already exists")
}
return response.InternalServerError(c, "Failed to create tender")
}
return response.Created(c, tender.ToTenderResponse(), "Tender created successfully")
}
// GetTender retrieves a tender by ID
// @Summary Get tender by ID
// @Description Retrieve a specific tender by its ID
// @Tags Admin-Tenders
// @Produce json
// @Param id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=TenderResponse}
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/tenders/{id} [get]
// @Security BearerAuth
func (h *TenderHandler) GetTender(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
tender, err := h.service.GetTenderByID(c.Request().Context(), id)
if err != nil {
return response.NotFound(c, "Tender not found")
}
return response.Success(c, tender.ToTenderResponse(), "Tender retrieved successfully")
}
// UpdateTender updates an existing tender
// @Summary Update tender
// @Description Update an existing tender with the provided information
// @Tags Admin-Tenders
// @Accept json
// @Produce json
// @Param id path string true "Tender ID"
// @Param tender body UpdateTenderRequest true "Updated tender information"
// @Success 200 {object} response.APIResponse{data=TenderResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/tenders/{id} [put]
// @Security BearerAuth
func (h *TenderHandler) UpdateTender(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
var req UpdateTenderRequest
if err := c.Bind(&req); err != nil {
return response.ValidationError(c, "Invalid request format", err.Error())
}
req.ID = id
if _, err := govalidator.ValidateStruct(req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
tender, err := h.service.UpdateTender(c.Request().Context(), req)
if err != nil {
if err.Error() == "tender not found" {
return response.NotFound(c, "Tender not found")
}
return response.InternalServerError(c, "Failed to update tender")
}
return response.Success(c, tender.ToTenderResponse(), "Tender updated successfully")
}
// DeleteTender deletes a tender
// @Summary Delete tender
// @Description Delete a tender by its ID
// @Tags Admin-Tenders
// @Param id path string true "Tender ID"
// @Success 200 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/tenders/{id} [delete]
// @Security BearerAuth
func (h *TenderHandler) DeleteTender(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
err := h.service.DeleteTender(c.Request().Context(), id)
if err != nil {
if err.Error() == "tender not found" {
return response.NotFound(c, "Tender not found")
}
return response.InternalServerError(c, "Failed to delete tender")
}
return response.Success(c, map[string]interface{}{"deleted": true}, "Tender deleted successfully")
}
// ListTenders retrieves tenders with pagination and filtering
// @Summary List tenders
// @Description Retrieve tenders with pagination, filtering, and search capabilities
// @Tags Admin-Tenders
// @Produce json
// @Param limit query int false "Number of items per page (default: 20, max: 100)"
// @Param offset query int false "Number of items to skip (default: 0)"
// @Param query query string false "Search query for title and description"
// @Param notice_type query string false "Filter by notice type code"
// @Param procurement_type query string false "Filter by procurement type code"
// @Param country_codes query []string false "Filter by country codes (comma-separated)"
// @Param status query []string false "Filter by status (comma-separated)"
// @Param source query []string false "Filter by source (comma-separated)"
// @Param min_estimated_value query number false "Minimum estimated value"
// @Param max_estimated_value query number false "Maximum estimated value"
// @Param currency query string false "Filter by currency"
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/tenders [get]
// @Security BearerAuth
func (h *TenderHandler) ListTenders(c echo.Context) error {
// Parse pagination parameters
limit := 20
if l := c.QueryParam("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
limit = parsed
}
}
offset := 0
if o := c.QueryParam("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
}
}
// Build search criteria
criteria := TenderSearchCriteria{
Query: c.QueryParam("query"),
NoticeType: c.QueryParam("notice_type"),
ProcurementType: c.QueryParam("procurement_type"),
Currency: c.QueryParam("currency"),
}
// Parse array parameters
if countryCodes := c.QueryParam("country_codes"); countryCodes != "" {
criteria.CountryCodes = parseStringArray(countryCodes)
}
if statuses := c.QueryParam("status"); statuses != "" {
statusStrings := parseStringArray(statuses)
criteria.Status = make([]TenderStatus, len(statusStrings))
for i, s := range statusStrings {
criteria.Status[i] = TenderStatus(s)
}
}
if sources := c.QueryParam("source"); sources != "" {
sourceStrings := parseStringArray(sources)
criteria.Source = make([]TenderSource, len(sourceStrings))
for i, s := range sourceStrings {
criteria.Source[i] = TenderSource(s)
}
}
// Parse numeric parameters
if minValue := c.QueryParam("min_estimated_value"); minValue != "" {
if parsed, err := strconv.ParseFloat(minValue, 64); err == nil {
criteria.MinEstimatedValue = &parsed
}
}
if maxValue := c.QueryParam("max_estimated_value"); maxValue != "" {
if parsed, err := strconv.ParseFloat(maxValue, 64); err == nil {
criteria.MaxEstimatedValue = &parsed
}
}
req := ListTendersRequest{
Criteria: criteria,
Limit: limit,
Offset: offset,
}
result, err := h.service.ListTenders(c.Request().Context(), req)
if err != nil {
return response.InternalServerError(c, "Failed to retrieve tenders")
}
// Convert tenders to response format
tenderResponses := make([]TenderResponse, len(result.Tenders))
for i, tender := range result.Tenders {
tenderResponses[i] = *tender.ToTenderResponse()
}
responseData := map[string]interface{}{
"tenders": tenderResponses,
"total": result.Total,
"limit": result.Limit,
"offset": result.Offset,
}
return response.Success(c, responseData, "Tenders retrieved successfully")
}
// SearchTenders searches tenders
// @Summary Search tenders
// @Description Search tenders using full-text search and filtering
// @Tags Admin-Tenders
// @Accept json
// @Produce json
// @Param search body SearchTendersRequest true "Search criteria"
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/tenders/search [post]
// @Security BearerAuth
func (h *TenderHandler) SearchTenders(c echo.Context) error {
var req SearchTendersRequest
if err := c.Bind(&req); err != nil {
return response.ValidationError(c, "Invalid request format", err.Error())
}
if _, err := govalidator.ValidateStruct(req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
result, err := h.service.SearchTenders(c.Request().Context(), req)
if err != nil {
return response.InternalServerError(c, "Failed to search tenders")
}
// Convert tenders to response format
tenderResponses := make([]TenderResponse, len(result.Tenders))
for i, tender := range result.Tenders {
tenderResponses[i] = *tender.ToTenderResponse()
}
responseData := map[string]interface{}{
"tenders": tenderResponses,
"query": result.Query,
"total": result.Total,
}
return response.Success(c, responseData, "Tenders found successfully")
}
// GetTenderStatistics retrieves tender statistics
// @Summary Get tender statistics
// @Description Retrieve comprehensive statistics about tenders
// @Tags Admin-Tenders
// @Produce json
// @Success 200 {object} response.APIResponse{data=TenderStatistics}
// @Failure 500 {object} response.APIResponse
// @Router /admin/tenders/statistics [get]
// @Security BearerAuth
func (h *TenderHandler) GetTenderStatistics(c echo.Context) error {
stats, err := h.service.GetTenderStatistics(c.Request().Context())
if err != nil {
return response.InternalServerError(c, "Failed to retrieve statistics")
}
return response.Success(c, stats, "Statistics retrieved successfully")
}
// GetDashboard retrieves dashboard data
// @Summary Get dashboard data
// @Description Retrieve dashboard data including statistics, recent tenders, and active jobs
// @Tags Admin-Tenders
// @Produce json
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
// @Failure 500 {object} response.APIResponse
// @Router /admin/tenders/dashboard [get]
// @Security BearerAuth
func (h *TenderHandler) GetDashboard(c echo.Context) error {
dashboard, err := h.service.GetDashboardData(c.Request().Context())
if err != nil {
return response.InternalServerError(c, "Failed to retrieve dashboard data")
}
// Convert recent tenders to response format
recentTenderResponses := make([]TenderResponse, len(dashboard.RecentTenders))
for i, tender := range dashboard.RecentTenders {
recentTenderResponses[i] = *tender.ToTenderResponse()
}
// Convert active jobs to response format
activeJobResponses := make([]ScrapingJobResponse, len(dashboard.ActiveJobs))
for i, job := range dashboard.ActiveJobs {
activeJobResponses[i] = *job.ToScrapingJobResponse()
}
responseData := map[string]interface{}{
"statistics": dashboard.Statistics,
"recent_tenders": recentTenderResponses,
"active_jobs": activeJobResponses,
}
return response.Success(c, responseData, "Dashboard data retrieved successfully")
}
// StartScrapingJob starts a new scraping job
// @Summary Start scraping job
// @Description Start a new TED XML scraping job
// @Tags Admin-Scraping
// @Accept json
// @Produce json
// @Param job body StartScrapingJobRequest true "Scraping job configuration"
// @Success 201 {object} response.APIResponse{data=ScrapingJobResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/scraping/jobs [post]
// @Security BearerAuth
func (h *TenderHandler) StartScrapingJob(c echo.Context) error {
var req StartScrapingJobRequest
if err := c.Bind(&req); err != nil {
return response.ValidationError(c, "Invalid request format", err.Error())
}
if _, err := govalidator.ValidateStruct(req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
job, err := h.service.StartScrapingJob(c.Request().Context(), req)
if err != nil {
return response.InternalServerError(c, "Failed to start scraping job")
}
return response.Created(c, job.ToScrapingJobResponse(), "Scraping job started successfully")
}
// GetScrapingJob retrieves a scraping job by ID
// @Summary Get scraping job
// @Description Retrieve a specific scraping job by its ID
// @Tags Admin-Scraping
// @Produce json
// @Param id path string true "Scraping job ID"
// @Success 200 {object} response.APIResponse{data=ScrapingJobResponse}
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/scraping/jobs/{id} [get]
// @Security BearerAuth
func (h *TenderHandler) GetScrapingJob(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Scraping job ID is required", "ID parameter cannot be empty")
}
job, err := h.service.GetScrapingJobStatus(c.Request().Context(), id)
if err != nil {
return response.NotFound(c, "Scraping job not found")
}
return response.Success(c, job.ToScrapingJobResponse(), "Scraping job retrieved successfully")
}
// ListScrapingJobs retrieves scraping jobs with pagination
// @Summary List scraping jobs
// @Description Retrieve scraping jobs with pagination
// @Tags Admin-Scraping
// @Produce json
// @Param limit query int false "Number of items per page (default: 20, max: 100)"
// @Param offset query int false "Number of items to skip (default: 0)"
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/scraping/jobs [get]
// @Security BearerAuth
func (h *TenderHandler) ListScrapingJobs(c echo.Context) error {
// Parse pagination parameters
limit := 20
if l := c.QueryParam("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
limit = parsed
}
}
offset := 0
if o := c.QueryParam("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
}
}
req := ListScrapingJobsRequest{
Limit: limit,
Offset: offset,
}
result, err := h.service.ListScrapingJobs(c.Request().Context(), req)
if err != nil {
return response.InternalServerError(c, "Failed to retrieve scraping jobs")
}
// Convert jobs to response format
jobResponses := make([]ScrapingJobResponse, len(result.Jobs))
for i, job := range result.Jobs {
jobResponses[i] = *job.ToScrapingJobResponse()
}
responseData := map[string]interface{}{
"jobs": jobResponses,
"total": result.Total,
"limit": result.Limit,
"offset": result.Offset,
}
return response.Success(c, responseData, "Scraping jobs retrieved successfully")
}
// CancelScrapingJob cancels a running scraping job
// @Summary Cancel scraping job
// @Description Cancel a running or pending scraping job
// @Tags Admin-Scraping
// @Param id path string true "Scraping job ID"
// @Success 200 {object} response.APIResponse
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/scraping/jobs/{id}/cancel [post]
// @Security BearerAuth
func (h *TenderHandler) CancelScrapingJob(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Scraping job ID is required", "ID parameter cannot be empty")
}
err := h.service.CancelScrapingJob(c.Request().Context(), id)
if err != nil {
if err.Error() == "scraping job not found" {
return response.NotFound(c, "Scraping job not found")
}
return response.InternalServerError(c, "Failed to cancel scraping job")
}
return response.Success(c, map[string]interface{}{"cancelled": true}, "Scraping job cancelled successfully")
}
// GetPublicTenders retrieves public tenders for mobile app
// @Summary Get public tenders
// @Description Retrieve active tenders for mobile application users
// @Tags Tenders
// @Produce json
// @Param limit query int false "Number of items per page (default: 20, max: 50)"
// @Param offset query int false "Number of items to skip (default: 0)"
// @Param country_code query string false "Filter by country code"
// @Param min_estimated_value query number false "Minimum estimated value"
// @Param max_estimated_value query number false "Maximum estimated value"
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /tenders [get]
func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
// Parse pagination parameters (more restrictive for public API)
limit := 20
if l := c.QueryParam("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 50 {
limit = parsed
}
}
offset := 0
if o := c.QueryParam("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
}
}
// Build search criteria for public API (only active tenders)
criteria := TenderSearchCriteria{
Status: []TenderStatus{TenderStatusActive},
}
if countryCode := c.QueryParam("country_code"); countryCode != "" {
criteria.CountryCodes = []string{countryCode}
}
// Parse numeric parameters
if minValue := c.QueryParam("min_estimated_value"); minValue != "" {
if parsed, err := strconv.ParseFloat(minValue, 64); err == nil {
criteria.MinEstimatedValue = &parsed
}
}
if maxValue := c.QueryParam("max_estimated_value"); maxValue != "" {
if parsed, err := strconv.ParseFloat(maxValue, 64); err == nil {
criteria.MaxEstimatedValue = &parsed
}
}
req := ListTendersRequest{
Criteria: criteria,
Limit: limit,
Offset: offset,
}
result, err := h.service.ListTenders(c.Request().Context(), req)
if err != nil {
return response.InternalServerError(c, "Failed to retrieve tenders")
}
// Convert tenders to public response format (limited fields)
publicTenders := make([]map[string]interface{}, len(result.Tenders))
for i, tender := range result.Tenders {
publicTenders[i] = map[string]interface{}{
"id": tender.ID,
"title": tender.Title,
"description": tender.Description,
"procurement_type_code": tender.ProcurementTypeCode,
"main_classification": tender.MainClassification,
"estimated_value": tender.EstimatedValue,
"currency": tender.Currency,
"formatted_estimated_value": tender.GetFormattedEstimatedValue(),
"tender_deadline": tender.TenderDeadline,
"country_code": tender.CountryCode,
"city_name": tender.CityName,
"tender_url": tender.TenderURL,
"publication_date": tender.PublicationDate,
"buyer_organization": tender.BuyerOrganization,
"is_expired": tender.IsExpired(),
}
}
responseData := map[string]interface{}{
"tenders": publicTenders,
"total": result.Total,
"limit": result.Limit,
"offset": result.Offset,
}
return response.Success(c, responseData, "Tenders retrieved successfully")
}
// GetPublicTenderDetails retrieves public tender details for mobile app
// @Summary Get public tender details
// @Description Retrieve detailed information about a specific tender for mobile application users
// @Tags Tenders
// @Produce json
// @Param id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /tenders/{id} [get]
func (h *TenderHandler) GetPublicTenderDetails(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
tender, err := h.service.GetTenderByID(c.Request().Context(), id)
if err != nil {
return response.NotFound(c, "Tender not found")
}
// Return public-safe tender details
publicTender := map[string]interface{}{
"id": tender.ID,
"notice_publication_id": tender.NoticePublicationID,
"title": tender.Title,
"description": tender.Description,
"procurement_type_code": tender.ProcurementTypeCode,
"procedure_code": tender.ProcedureCode,
"main_classification": tender.MainClassification,
"additional_classifications": tender.AdditionalClassifications,
"estimated_value": tender.EstimatedValue,
"currency": tender.Currency,
"formatted_estimated_value": tender.GetFormattedEstimatedValue(),
"duration": tender.Duration,
"duration_unit": tender.DurationUnit,
"tender_deadline": tender.TenderDeadline,
"place_of_performance": tender.PlaceOfPerformance,
"country_code": tender.CountryCode,
"region_code": tender.RegionCode,
"city_name": tender.CityName,
"postal_code": tender.PostalCode,
"document_uri": tender.DocumentURI,
"tender_url": tender.TenderURL,
"buyer_organization": tender.BuyerOrganization,
"organizations": tender.Organizations,
"selection_criteria": tender.SelectionCriteria,
"official_languages": tender.OfficialLanguages,
"publication_date": tender.PublicationDate,
"gazette_id": tender.GazetteID,
"is_active": tender.IsActive(),
"is_expired": tender.IsExpired(),
}
return response.Success(c, publicTender, "Tender details retrieved successfully")
}
// parseStringArray parses a comma-separated string into a string array
func parseStringArray(s string) []string {
if s == "" {
return nil
}
result := make([]string, 0)
for _, item := range strings.Split(s, ",") {
if trimmed := strings.TrimSpace(item); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
+828
View File
@@ -0,0 +1,828 @@
package tender
import (
"context"
"time"
"tm/pkg/logger"
mongopkg "tm/pkg/mongo"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
// TenderRepository interface defines tender data access methods
type TenderRepository interface {
// Tender CRUD operations
Create(ctx context.Context, tender *Tender) error
GetByID(ctx context.Context, id string) (*Tender, error)
GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Tender, error)
GetByNoticePublicationID(ctx context.Context, noticePublicationID string) (*Tender, error)
Update(ctx context.Context, tender *Tender) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, criteria TenderSearchCriteria, limit, offset int) ([]Tender, int64, error)
// Bulk operations
CreateBatch(ctx context.Context, tenders []Tender) error
UpdateBatch(ctx context.Context, tenders []Tender) error
// Search and filtering
Search(ctx context.Context, criteria TenderSearchCriteria) ([]Tender, error)
GetByCountryCode(ctx context.Context, countryCode string, limit, offset int) ([]Tender, error)
GetByStatus(ctx context.Context, status TenderStatus, limit, offset int) ([]Tender, error)
GetExpiredTenders(ctx context.Context, beforeDate int64) ([]Tender, error)
GetActiveTenders(ctx context.Context, limit, offset int) ([]Tender, error)
// Statistics and aggregations
GetStatistics(ctx context.Context) (*TenderStatistics, error)
GetTenderCountByCountry(ctx context.Context) (map[string]int64, error)
GetTenderCountByType(ctx context.Context) (map[string]int64, error)
GetTenderCountByClassification(ctx context.Context) (map[string]int64, error)
// Scraping state management
GetScrapingState(ctx context.Context) (*ScrapingState, error)
SaveScrapingState(ctx context.Context, state *ScrapingState) error
// Scraping jobs management
CreateScrapingJob(ctx context.Context, job *ScrapingJob) error
GetScrapingJob(ctx context.Context, id string) (*ScrapingJob, error)
UpdateScrapingJob(ctx context.Context, job *ScrapingJob) error
ListScrapingJobs(ctx context.Context, limit, offset int) ([]ScrapingJob, int64, error)
GetActiveScrapingJobs(ctx context.Context) ([]ScrapingJob, error)
}
// tenderRepository implements TenderRepository interface using MongoDB ORM
type tenderRepository struct {
ormRepo mongopkg.Repository[Tender]
stateOrmRepo mongopkg.Repository[ScrapingState]
jobOrmRepo mongopkg.Repository[ScrapingJob]
mongoManager *mongopkg.ConnectionManager
logger logger.Logger
}
// NewTenderRepository creates a new tender repository
func NewTenderRepository(mongoManager *mongopkg.ConnectionManager, logger logger.Logger) TenderRepository {
// Create indexes for tenders collection
tenderIndexes := []mongopkg.Index{
*mongopkg.CreateUniqueIndex("notice_publication_id_idx", bson.D{{Key: "notice_publication_id", Value: 1}}),
*mongopkg.NewIndex("contract_notice_id_idx", bson.D{{Key: "contract_notice_id", Value: 1}}),
*mongopkg.NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}),
*mongopkg.NewIndex("source_idx", bson.D{{Key: "source", Value: 1}}),
*mongopkg.NewIndex("country_code_idx", bson.D{{Key: "country_code", Value: 1}}),
*mongopkg.NewIndex("notice_type_code_idx", bson.D{{Key: "notice_type_code", Value: 1}}),
*mongopkg.NewIndex("procurement_type_code_idx", bson.D{{Key: "procurement_type_code", Value: 1}}),
*mongopkg.NewIndex("main_classification_idx", bson.D{{Key: "main_classification", Value: 1}}),
*mongopkg.NewIndex("publication_date_idx", bson.D{{Key: "publication_date", Value: -1}}),
*mongopkg.NewIndex("tender_deadline_idx", bson.D{{Key: "tender_deadline", Value: 1}}),
*mongopkg.NewIndex("estimated_value_idx", bson.D{{Key: "estimated_value", Value: -1}}),
*mongopkg.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
*mongopkg.NewIndex("updated_at_idx", bson.D{{Key: "updated_at", Value: -1}}),
// Compound indexes for common queries
*mongopkg.NewIndex("status_country_idx", bson.D{
{Key: "status", Value: 1},
{Key: "country_code", Value: 1},
}),
*mongopkg.NewIndex("notice_type_status_idx", bson.D{
{Key: "notice_type_code", Value: 1},
{Key: "status", Value: 1},
}),
// Text index for search
*mongopkg.CreateTextIndex("search_idx", "title", "description"),
}
// Create indexes for scraping jobs collection
jobIndexes := []mongopkg.Index{
*mongopkg.NewIndex("type_idx", bson.D{{Key: "type", Value: 1}}),
*mongopkg.NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}),
*mongopkg.NewIndex("started_at_idx", bson.D{{Key: "started_at", Value: -1}}),
*mongopkg.NewIndex("type_status_idx", bson.D{
{Key: "type", Value: 1},
{Key: "status", Value: 1},
}),
}
// Create indexes
if err := mongoManager.CreateIndexes("tenders", tenderIndexes); err != nil {
logger.Warn("Failed to create tender indexes", map[string]interface{}{
"error": err.Error(),
})
}
if err := mongoManager.CreateIndexes("scraping_jobs", jobIndexes); err != nil {
logger.Warn("Failed to create scraping job indexes", map[string]interface{}{
"error": err.Error(),
})
}
// Create ORM repositories
tenderOrmRepo := mongopkg.NewRepository[Tender](mongoManager.GetCollection("tenders"), logger)
stateOrmRepo := mongopkg.NewRepository[ScrapingState](mongoManager.GetCollection("scraping_state"), logger)
jobOrmRepo := mongopkg.NewRepository[ScrapingJob](mongoManager.GetCollection("scraping_jobs"), logger)
return &tenderRepository{
ormRepo: tenderOrmRepo,
stateOrmRepo: stateOrmRepo,
jobOrmRepo: jobOrmRepo,
mongoManager: mongoManager,
logger: logger,
}
}
// Create creates a new tender
func (r *tenderRepository) Create(ctx context.Context, tender *Tender) error {
err := r.ormRepo.Create(ctx, tender)
if err != nil {
r.logger.Error("Failed to create tender", map[string]interface{}{
"error": err.Error(),
"notice_publication_id": tender.NoticePublicationID,
})
return err
}
r.logger.Info("Tender created successfully", map[string]interface{}{
"tender_id": tender.ID,
"notice_publication_id": tender.NoticePublicationID,
})
return nil
}
// GetByID retrieves a tender by ID
func (r *tenderRepository) GetByID(ctx context.Context, id string) (*Tender, error) {
tender, err := r.ormRepo.FindByID(ctx, id)
if err != nil {
r.logger.Error("Failed to get tender by ID", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return nil, err
}
return tender, nil
}
// GetByContractNoticeID retrieves a tender by contract notice ID
func (r *tenderRepository) GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Tender, error) {
filter := bson.M{"contract_notice_id": contractNoticeID}
pagination := mongopkg.Pagination{Limit: 1}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get tender by contract notice ID", map[string]interface{}{
"contract_notice_id": contractNoticeID,
"error": err.Error(),
})
return nil, err
}
if len(result.Items) == 0 {
return nil, mongopkg.ErrDocumentNotFound
}
return &result.Items[0], nil
}
// GetByNoticePublicationID retrieves a tender by notice publication ID
func (r *tenderRepository) GetByNoticePublicationID(ctx context.Context, noticePublicationID string) (*Tender, error) {
filter := bson.M{"notice_publication_id": noticePublicationID}
pagination := mongopkg.Pagination{Limit: 1}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get tender by notice publication ID", map[string]interface{}{
"notice_publication_id": noticePublicationID,
"error": err.Error(),
})
return nil, err
}
if len(result.Items) == 0 {
return nil, mongopkg.ErrDocumentNotFound
}
return &result.Items[0], nil
}
// Update updates an existing tender
func (r *tenderRepository) Update(ctx context.Context, tender *Tender) error {
tender.UpdatedAt = time.Now().Unix()
err := r.ormRepo.Update(ctx, tender)
if err != nil {
r.logger.Error("Failed to update tender", map[string]interface{}{
"tender_id": tender.ID,
"error": err.Error(),
})
return err
}
r.logger.Info("Tender updated successfully", map[string]interface{}{
"tender_id": tender.ID,
})
return nil
}
// Delete deletes a tender by ID
func (r *tenderRepository) Delete(ctx context.Context, id string) error {
err := r.ormRepo.Delete(ctx, id)
if err != nil {
r.logger.Error("Failed to delete tender", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return err
}
r.logger.Info("Tender deleted successfully", map[string]interface{}{
"tender_id": id,
})
return nil
}
// List retrieves tenders with pagination and filtering
func (r *tenderRepository) List(ctx context.Context, criteria TenderSearchCriteria, limit, offset int) ([]Tender, int64, error) {
filter := r.buildSearchFilter(criteria)
pagination := mongopkg.Pagination{
Limit: limit,
Skip: offset,
SortField: "created_at",
SortOrder: -1,
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to list tenders", map[string]interface{}{
"error": err.Error(),
})
return nil, 0, err
}
return result.Items, result.TotalCount, nil
}
// CreateBatch creates multiple tenders in a single operation
func (r *tenderRepository) CreateBatch(ctx context.Context, tenders []Tender) error {
if len(tenders) == 0 {
return nil
}
for i := range tenders {
if err := r.ormRepo.Create(ctx, &tenders[i]); err != nil {
r.logger.Error("Failed to create tender in batch", map[string]interface{}{
"tender_id": tenders[i].ID,
"error": err.Error(),
})
return err
}
}
r.logger.Info("Tender batch created successfully", map[string]interface{}{
"count": len(tenders),
})
return nil
}
// UpdateBatch updates multiple tenders
func (r *tenderRepository) UpdateBatch(ctx context.Context, tenders []Tender) error {
if len(tenders) == 0 {
return nil
}
now := time.Now().Unix()
for i := range tenders {
tenders[i].UpdatedAt = now
if err := r.ormRepo.Update(ctx, &tenders[i]); err != nil {
r.logger.Error("Failed to update tender in batch", map[string]interface{}{
"tender_id": tenders[i].ID,
"error": err.Error(),
})
return err
}
}
r.logger.Info("Tender batch updated successfully", map[string]interface{}{
"count": len(tenders),
})
return nil
}
// Search searches tenders based on criteria
func (r *tenderRepository) Search(ctx context.Context, criteria TenderSearchCriteria) ([]Tender, error) {
filter := r.buildSearchFilter(criteria)
pagination := mongopkg.Pagination{
Limit: 100,
SortField: "created_at",
SortOrder: -1,
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to search tenders", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
return result.Items, nil
}
// GetByCountryCode retrieves tenders by country code
func (r *tenderRepository) GetByCountryCode(ctx context.Context, countryCode string, limit, offset int) ([]Tender, error) {
filter := bson.M{"country_code": countryCode}
pagination := mongopkg.Pagination{
Limit: limit,
Skip: offset,
SortField: "created_at",
SortOrder: -1,
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get tenders by country code", map[string]interface{}{
"country_code": countryCode,
"error": err.Error(),
})
return nil, err
}
return result.Items, nil
}
// GetByStatus retrieves tenders by status
func (r *tenderRepository) GetByStatus(ctx context.Context, status TenderStatus, limit, offset int) ([]Tender, error) {
filter := bson.M{"status": status}
pagination := mongopkg.Pagination{
Limit: limit,
Skip: offset,
SortField: "created_at",
SortOrder: -1,
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get tenders by status", map[string]interface{}{
"status": status,
"error": err.Error(),
})
return nil, err
}
return result.Items, nil
}
// GetExpiredTenders retrieves tenders that are expired
func (r *tenderRepository) GetExpiredTenders(ctx context.Context, beforeDate int64) ([]Tender, error) {
filter := bson.M{
"$or": []bson.M{
{"status": TenderStatusExpired},
{
"tender_deadline": bson.M{
"$lt": beforeDate,
},
"status": bson.M{"$ne": TenderStatusExpired},
},
},
}
pagination := mongopkg.Pagination{
Limit: 1000, // Large limit for maintenance operations
SortField: "tender_deadline",
SortOrder: 1,
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get expired tenders", map[string]interface{}{
"before_date": beforeDate,
"error": err.Error(),
})
return nil, err
}
return result.Items, nil
}
// GetActiveTenders retrieves active tenders
func (r *tenderRepository) GetActiveTenders(ctx context.Context, limit, offset int) ([]Tender, error) {
filter := bson.M{"status": TenderStatusActive}
pagination := mongopkg.Pagination{
Limit: limit,
Skip: offset,
SortField: "tender_deadline",
SortOrder: 1,
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get active tenders", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
return result.Items, nil
}
// GetStatistics calculates tender statistics using aggregation
func (r *tenderRepository) GetStatistics(ctx context.Context) (*TenderStatistics, error) {
stats := &TenderStatistics{
LastUpdated: time.Now().Unix(),
}
// Get total count
total, err := r.ormRepo.Count(ctx, bson.M{})
if err != nil {
return nil, err
}
stats.TotalTenders = total
// Get active count
active, err := r.ormRepo.Count(ctx, bson.M{"status": TenderStatusActive})
if err != nil {
return nil, err
}
stats.ActiveTenders = active
// Get expired count
expired, err := r.ormRepo.Count(ctx, bson.M{"status": TenderStatusExpired})
if err != nil {
return nil, err
}
stats.ExpiredTenders = expired
// Get statistics by country, type, and classification
stats.TendersByCountry, _ = r.GetTenderCountByCountry(ctx)
stats.TendersByType, _ = r.GetTenderCountByType(ctx)
stats.TendersByClassification, _ = r.GetTenderCountByClassification(ctx)
// Calculate value statistics using aggregation
pipeline := mongo.Pipeline{
{
{Key: "$group", Value: bson.M{
"_id": nil,
"total_estimated_value": bson.M{"$sum": "$estimated_value"},
"count": bson.M{"$sum": 1},
}},
},
}
result, err := r.ormRepo.Aggregate(ctx, pipeline)
if err == nil && len(result) > 0 {
if totalValue, ok := result[0]["total_estimated_value"].(float64); ok {
stats.TotalEstimatedValue = totalValue
if count, ok := result[0]["count"].(int32); ok && count > 0 {
stats.AverageEstimatedValue = totalValue / float64(count)
}
}
}
return stats, nil
}
// GetTenderCountByCountry gets tender count grouped by country
func (r *tenderRepository) GetTenderCountByCountry(ctx context.Context) (map[string]int64, error) {
pipeline := mongo.Pipeline{
{
{Key: "$group", Value: bson.M{
"_id": "$country_code",
"count": bson.M{"$sum": 1},
}},
},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
counts := make(map[string]int64)
for _, result := range results {
if country, ok := result["_id"].(string); ok {
if count, ok := result["count"].(int32); ok {
counts[country] = int64(count)
}
}
}
return counts, nil
}
// GetTenderCountByType gets tender count grouped by type
func (r *tenderRepository) GetTenderCountByType(ctx context.Context) (map[string]int64, error) {
pipeline := mongo.Pipeline{
{
{Key: "$group", Value: bson.M{
"_id": "$notice_type_code",
"count": bson.M{"$sum": 1},
}},
},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
counts := make(map[string]int64)
for _, result := range results {
if noticeType, ok := result["_id"].(string); ok {
if count, ok := result["count"].(int32); ok {
counts[noticeType] = int64(count)
}
}
}
return counts, nil
}
// GetTenderCountByClassification gets tender count grouped by classification
func (r *tenderRepository) GetTenderCountByClassification(ctx context.Context) (map[string]int64, error) {
pipeline := mongo.Pipeline{
{
{Key: "$group", Value: bson.M{
"_id": "$main_classification",
"count": bson.M{"$sum": 1},
}},
},
{
{Key: "$limit", Value: 20},
},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
counts := make(map[string]int64)
for _, result := range results {
if classification, ok := result["_id"].(string); ok {
if count, ok := result["count"].(int32); ok {
counts[classification] = int64(count)
}
}
}
return counts, nil
}
// GetScrapingState retrieves the current scraping state
func (r *tenderRepository) GetScrapingState(ctx context.Context) (*ScrapingState, error) {
// Try to find existing state
filter := bson.M{}
pagination := mongopkg.Pagination{Limit: 1}
result, err := r.stateOrmRepo.FindAll(ctx, filter, pagination)
if err != nil {
return nil, err
}
if len(result.Items) == 0 {
// Return default state if none exists
return &ScrapingState{
ID: "default",
LastProcessedYear: time.Now().Year(),
LastProcessedNumber: 0,
LastRunAt: 0,
NextRunAt: time.Now().Add(4 * time.Hour).Unix(),
IsRunning: false,
CreatedAt: time.Now().Unix(),
UpdatedAt: time.Now().Unix(),
}, nil
}
return &result.Items[0], nil
}
// SaveScrapingState saves the scraping state
func (r *tenderRepository) SaveScrapingState(ctx context.Context, state *ScrapingState) error {
if state.ID == "" {
state.ID = "default"
}
now := time.Now().Unix()
if state.CreatedAt == 0 {
state.CreatedAt = now
}
state.UpdatedAt = now
// Try to update existing state first
existing, err := r.GetScrapingState(ctx)
if err == nil && existing.ID != "" {
state.ID = existing.ID
state.CreatedAt = existing.CreatedAt
return r.stateOrmRepo.Update(ctx, state)
}
// Create new state if none exists
return r.stateOrmRepo.Create(ctx, state)
}
// CreateScrapingJob creates a new scraping job
func (r *tenderRepository) CreateScrapingJob(ctx context.Context, job *ScrapingJob) error {
if job.ID == "" {
job.ID = primitive.NewObjectID().Hex()
}
now := time.Now().Unix()
job.CreatedAt = now
job.UpdatedAt = now
err := r.jobOrmRepo.Create(ctx, job)
if err != nil {
r.logger.Error("Failed to create scraping job", map[string]interface{}{
"error": err.Error(),
"type": job.Type,
})
return err
}
r.logger.Info("Scraping job created successfully", map[string]interface{}{
"job_id": job.ID,
"type": job.Type,
})
return nil
}
// GetScrapingJob retrieves a scraping job by ID
func (r *tenderRepository) GetScrapingJob(ctx context.Context, id string) (*ScrapingJob, error) {
job, err := r.jobOrmRepo.FindByID(ctx, id)
if err != nil {
r.logger.Error("Failed to get scraping job", map[string]interface{}{
"job_id": id,
"error": err.Error(),
})
return nil, err
}
return job, nil
}
// UpdateScrapingJob updates a scraping job
func (r *tenderRepository) UpdateScrapingJob(ctx context.Context, job *ScrapingJob) error {
job.UpdatedAt = time.Now().Unix()
err := r.jobOrmRepo.Update(ctx, job)
if err != nil {
r.logger.Error("Failed to update scraping job", map[string]interface{}{
"job_id": job.ID,
"error": err.Error(),
})
return err
}
r.logger.Info("Scraping job updated successfully", map[string]interface{}{
"job_id": job.ID,
})
return nil
}
// ListScrapingJobs retrieves scraping jobs with pagination
func (r *tenderRepository) ListScrapingJobs(ctx context.Context, limit, offset int) ([]ScrapingJob, int64, error) {
filter := bson.M{}
pagination := mongopkg.Pagination{
Limit: limit,
Skip: offset,
SortField: "started_at",
SortOrder: -1,
}
result, err := r.jobOrmRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to list scraping jobs", map[string]interface{}{
"error": err.Error(),
})
return nil, 0, err
}
return result.Items, result.TotalCount, nil
}
// GetActiveScrapingJobs retrieves currently active scraping jobs
func (r *tenderRepository) GetActiveScrapingJobs(ctx context.Context) ([]ScrapingJob, error) {
filter := bson.M{
"status": bson.M{
"$in": []ScrapingJobStatus{
ScrapingJobStatusPending,
ScrapingJobStatusRunning,
},
},
}
pagination := mongopkg.Pagination{
Limit: 100,
SortField: "started_at",
SortOrder: -1,
}
result, err := r.jobOrmRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get active scraping jobs", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
return result.Items, nil
}
// buildSearchFilter builds MongoDB filter from search criteria
func (r *tenderRepository) buildSearchFilter(criteria TenderSearchCriteria) bson.M {
filter := bson.M{}
if criteria.Query != "" {
filter["$text"] = bson.M{"$search": criteria.Query}
}
if criteria.NoticeType != "" {
filter["notice_type_code"] = criteria.NoticeType
}
if criteria.ProcurementType != "" {
filter["procurement_type_code"] = criteria.ProcurementType
}
if len(criteria.CountryCodes) > 0 {
filter["country_code"] = bson.M{"$in": criteria.CountryCodes}
}
if len(criteria.RegionCodes) > 0 {
filter["region_code"] = bson.M{"$in": criteria.RegionCodes}
}
if len(criteria.Classifications) > 0 {
filter["$or"] = []bson.M{
{"main_classification": bson.M{"$in": criteria.Classifications}},
{"additional_classifications": bson.M{"$in": criteria.Classifications}},
}
}
if criteria.MinEstimatedValue != nil || criteria.MaxEstimatedValue != nil {
valueFilter := bson.M{}
if criteria.MinEstimatedValue != nil {
valueFilter["$gte"] = *criteria.MinEstimatedValue
}
if criteria.MaxEstimatedValue != nil {
valueFilter["$lte"] = *criteria.MaxEstimatedValue
}
filter["estimated_value"] = valueFilter
}
if criteria.Currency != "" {
filter["currency"] = criteria.Currency
}
if criteria.DeadlineFrom != nil || criteria.DeadlineTo != nil {
deadlineFilter := bson.M{}
if criteria.DeadlineFrom != nil {
deadlineFilter["$gte"] = *criteria.DeadlineFrom
}
if criteria.DeadlineTo != nil {
deadlineFilter["$lte"] = *criteria.DeadlineTo
}
filter["tender_deadline"] = deadlineFilter
}
if criteria.PublicationDateFrom != nil || criteria.PublicationDateTo != nil {
pubDateFilter := bson.M{}
if criteria.PublicationDateFrom != nil {
pubDateFilter["$gte"] = *criteria.PublicationDateFrom
}
if criteria.PublicationDateTo != nil {
pubDateFilter["$lte"] = *criteria.PublicationDateTo
}
filter["publication_date"] = pubDateFilter
}
if len(criteria.Status) > 0 {
filter["status"] = bson.M{"$in": criteria.Status}
}
if len(criteria.Source) > 0 {
filter["source"] = bson.M{"$in": criteria.Source}
}
if criteria.BuyerOrganizationID != "" {
filter["buyer_organization.id"] = criteria.BuyerOrganizationID
}
if len(criteria.Languages) > 0 {
filter["notice_language_code"] = bson.M{"$in": criteria.Languages}
}
return filter
}
+523
View File
@@ -0,0 +1,523 @@
package tender
import (
"context"
"fmt"
"time"
"tm/pkg/logger"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// TenderService interface defines tender business logic operations
type TenderService interface {
// Tender operations
CreateTender(ctx context.Context, req CreateTenderRequest) (*Tender, error)
GetTenderByID(ctx context.Context, id string) (*Tender, error)
UpdateTender(ctx context.Context, req UpdateTenderRequest) (*Tender, error)
DeleteTender(ctx context.Context, id string) error
ListTenders(ctx context.Context, req ListTendersRequest) (*ListTendersResponse, error)
SearchTenders(ctx context.Context, req SearchTendersRequest) (*SearchTendersResponse, error)
// Statistics and reporting
GetTenderStatistics(ctx context.Context) (*TenderStatistics, error)
GetDashboardData(ctx context.Context) (*DashboardData, error)
// Scraping management
StartScrapingJob(ctx context.Context, req StartScrapingJobRequest) (*ScrapingJob, error)
GetScrapingJobStatus(ctx context.Context, jobID string) (*ScrapingJob, error)
ListScrapingJobs(ctx context.Context, req ListScrapingJobsRequest) (*ListScrapingJobsResponse, error)
CancelScrapingJob(ctx context.Context, jobID string) error
// Maintenance operations
UpdateExpiredTenders(ctx context.Context) error
CleanupOldData(ctx context.Context, olderThan time.Duration) error
}
// tenderService implements TenderService interface
type tenderService struct {
repository TenderRepository
logger logger.Logger
}
// NewTenderService creates a new tender service
func NewTenderService(repository TenderRepository, logger logger.Logger) TenderService {
return &tenderService{
repository: repository,
logger: logger,
}
}
// CreateTender creates a new tender
func (s *tenderService) CreateTender(ctx context.Context, req CreateTenderRequest) (*Tender, error) {
s.logger.Info("Creating new tender", map[string]interface{}{
"notice_publication_id": req.NoticePublicationID,
"title": req.Title,
})
// Check if tender with same notice publication ID already exists
if req.NoticePublicationID != "" {
existing, err := s.repository.GetByNoticePublicationID(ctx, req.NoticePublicationID)
if err == nil && existing != nil {
s.logger.Warn("Tender with same notice publication ID already exists", map[string]interface{}{
"notice_publication_id": req.NoticePublicationID,
"existing_id": existing.ID,
})
return nil, fmt.Errorf("tender with notice publication ID %s already exists", req.NoticePublicationID)
}
}
tender := &Tender{
ContractNoticeID: req.ContractNoticeID,
NoticePublicationID: req.NoticePublicationID,
ContractFolderID: req.ContractFolderID,
NoticeTypeCode: req.NoticeTypeCode,
NoticeSubTypeCode: req.NoticeSubTypeCode,
NoticeLanguageCode: req.NoticeLanguageCode,
IssueDate: req.IssueDate,
IssueTime: req.IssueTime,
PublicationDate: req.PublicationDate,
GazetteID: req.GazetteID,
Title: req.Title,
Description: req.Description,
ProcurementTypeCode: req.ProcurementTypeCode,
ProcedureCode: req.ProcedureCode,
MainClassification: req.MainClassification,
AdditionalClassifications: req.AdditionalClassifications,
EstimatedValue: req.EstimatedValue,
Currency: req.Currency,
Duration: req.Duration,
DurationUnit: req.DurationUnit,
TenderDeadline: req.TenderDeadline,
PlaceOfPerformance: req.PlaceOfPerformance,
CountryCode: req.CountryCode,
RegionCode: req.RegionCode,
CityName: req.CityName,
PostalCode: req.PostalCode,
DocumentURI: req.DocumentURI,
TenderURL: req.TenderURL,
BuyerOrganization: req.BuyerOrganization,
ReviewOrganization: req.ReviewOrganization,
Organizations: req.Organizations,
SelectionCriteria: req.SelectionCriteria,
OfficialLanguages: req.OfficialLanguages,
Status: TenderStatusActive,
Source: req.Source,
SourceFileURL: req.SourceFileURL,
SourceFileName: req.SourceFileName,
ProcessingMetadata: ProcessingMetadata{
ProcessedAt: time.Now().Unix(),
ProcessingVersion: "1.0",
},
}
// Generate tender URL if not provided
if tender.TenderURL == "" && tender.NoticePublicationID != "" {
tender.TenderURL = generateTenderURL(tender.NoticePublicationID)
}
if err := s.repository.Create(ctx, tender); err != nil {
s.logger.Error("Failed to create tender", map[string]interface{}{
"error": err.Error(),
"notice_publication_id": req.NoticePublicationID,
})
return nil, fmt.Errorf("failed to create tender: %w", err)
}
s.logger.Info("Tender created successfully", map[string]interface{}{
"tender_id": tender.ID,
"notice_publication_id": tender.NoticePublicationID,
})
return tender, nil
}
// GetTenderByID retrieves a tender by ID
func (s *tenderService) GetTenderByID(ctx context.Context, id string) (*Tender, error) {
s.logger.Info("Retrieving tender by ID", map[string]interface{}{
"tender_id": id,
})
tender, err := s.repository.GetByID(ctx, id)
if err != nil {
s.logger.Error("Failed to retrieve tender", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to retrieve tender: %w", err)
}
return tender, nil
}
// UpdateTender updates an existing tender
func (s *tenderService) UpdateTender(ctx context.Context, req UpdateTenderRequest) (*Tender, error) {
s.logger.Info("Updating tender", map[string]interface{}{
"tender_id": req.ID,
})
// Get existing tender
tender, err := s.repository.GetByID(ctx, req.ID)
if err != nil {
s.logger.Error("Failed to find tender for update", map[string]interface{}{
"tender_id": req.ID,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to find tender: %w", err)
}
// Update fields if provided
if req.Title != nil {
tender.Title = *req.Title
}
if req.Description != nil {
tender.Description = *req.Description
}
if req.Status != nil {
tender.Status = *req.Status
}
if req.EstimatedValue != nil {
tender.EstimatedValue = *req.EstimatedValue
}
if req.Currency != nil {
tender.Currency = *req.Currency
}
if req.TenderDeadline != nil {
tender.TenderDeadline = *req.TenderDeadline
}
if err := s.repository.Update(ctx, tender); err != nil {
s.logger.Error("Failed to update tender", map[string]interface{}{
"tender_id": req.ID,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to update tender: %w", err)
}
s.logger.Info("Tender updated successfully", map[string]interface{}{
"tender_id": tender.ID,
})
return tender, nil
}
// DeleteTender deletes a tender
func (s *tenderService) DeleteTender(ctx context.Context, id string) error {
s.logger.Info("Deleting tender", map[string]interface{}{
"tender_id": id,
})
if err := s.repository.Delete(ctx, id); err != nil {
s.logger.Error("Failed to delete tender", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return fmt.Errorf("failed to delete tender: %w", err)
}
s.logger.Info("Tender deleted successfully", map[string]interface{}{
"tender_id": id,
})
return nil
}
// ListTenders retrieves tenders with pagination and filtering
func (s *tenderService) ListTenders(ctx context.Context, req ListTendersRequest) (*ListTendersResponse, error) {
s.logger.Info("Listing tenders", map[string]interface{}{
"limit": req.Limit,
"offset": req.Offset,
})
if req.Limit <= 0 {
req.Limit = 20
}
if req.Limit > 100 {
req.Limit = 100
}
tenders, total, err := s.repository.List(ctx, req.Criteria, req.Limit, req.Offset)
if err != nil {
s.logger.Error("Failed to list tenders", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to list tenders: %w", err)
}
return &ListTendersResponse{
Tenders: tenders,
Total: total,
Limit: req.Limit,
Offset: req.Offset,
}, nil
}
// SearchTenders searches tenders based on criteria
func (s *tenderService) SearchTenders(ctx context.Context, req SearchTendersRequest) (*SearchTendersResponse, error) {
s.logger.Info("Searching tenders", map[string]interface{}{
"query": req.Query,
})
tenders, err := s.repository.Search(ctx, req.Criteria)
if err != nil {
s.logger.Error("Failed to search tenders", map[string]interface{}{
"error": err.Error(),
"query": req.Query,
})
return nil, fmt.Errorf("failed to search tenders: %w", err)
}
return &SearchTendersResponse{
Tenders: tenders,
Query: req.Query,
Total: len(tenders),
}, nil
}
// GetTenderStatistics retrieves tender statistics
func (s *tenderService) GetTenderStatistics(ctx context.Context) (*TenderStatistics, error) {
s.logger.Info("Retrieving tender statistics", map[string]interface{}{})
stats, err := s.repository.GetStatistics(ctx)
if err != nil {
s.logger.Error("Failed to retrieve tender statistics", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to retrieve statistics: %w", err)
}
return stats, nil
}
// GetDashboardData retrieves dashboard data
func (s *tenderService) GetDashboardData(ctx context.Context) (*DashboardData, error) {
s.logger.Info("Retrieving dashboard data", map[string]interface{}{})
stats, err := s.GetTenderStatistics(ctx)
if err != nil {
return nil, err
}
// Get recent tenders
recentTenders, _, err := s.repository.List(ctx, TenderSearchCriteria{}, 10, 0)
if err != nil {
s.logger.Error("Failed to retrieve recent tenders", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to retrieve recent tenders: %w", err)
}
// Get active scraping jobs
activeJobs, err := s.repository.GetActiveScrapingJobs(ctx)
if err != nil {
s.logger.Error("Failed to retrieve active scraping jobs", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to retrieve active jobs: %w", err)
}
return &DashboardData{
Statistics: stats,
RecentTenders: recentTenders,
ActiveJobs: activeJobs,
}, nil
}
// StartScrapingJob starts a new scraping job
func (s *tenderService) StartScrapingJob(ctx context.Context, req StartScrapingJobRequest) (*ScrapingJob, error) {
s.logger.Info("Starting scraping job", map[string]interface{}{
"type": req.Type,
})
job := &ScrapingJob{
ID: primitive.NewObjectID().Hex(),
Type: req.Type,
Status: ScrapingJobStatusPending,
StartedAt: time.Now().Unix(),
Progress: JobProgress{},
Results: JobResults{},
Config: req.Config,
}
if err := s.repository.CreateScrapingJob(ctx, job); err != nil {
s.logger.Error("Failed to create scraping job", map[string]interface{}{
"error": err.Error(),
"type": req.Type,
})
return nil, fmt.Errorf("failed to create scraping job: %w", err)
}
s.logger.Info("Scraping job created successfully", map[string]interface{}{
"job_id": job.ID,
"type": job.Type,
})
return job, nil
}
// GetScrapingJobStatus retrieves scraping job status
func (s *tenderService) GetScrapingJobStatus(ctx context.Context, jobID string) (*ScrapingJob, error) {
s.logger.Info("Retrieving scraping job status", map[string]interface{}{
"job_id": jobID,
})
job, err := s.repository.GetScrapingJob(ctx, jobID)
if err != nil {
s.logger.Error("Failed to retrieve scraping job", map[string]interface{}{
"job_id": jobID,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to retrieve scraping job: %w", err)
}
return job, nil
}
// ListScrapingJobs lists scraping jobs with pagination
func (s *tenderService) ListScrapingJobs(ctx context.Context, req ListScrapingJobsRequest) (*ListScrapingJobsResponse, error) {
s.logger.Info("Listing scraping jobs", map[string]interface{}{
"limit": req.Limit,
"offset": req.Offset,
})
if req.Limit <= 0 {
req.Limit = 20
}
if req.Limit > 100 {
req.Limit = 100
}
jobs, total, err := s.repository.ListScrapingJobs(ctx, req.Limit, req.Offset)
if err != nil {
s.logger.Error("Failed to list scraping jobs", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to list scraping jobs: %w", err)
}
return &ListScrapingJobsResponse{
Jobs: jobs,
Total: total,
Limit: req.Limit,
Offset: req.Offset,
}, nil
}
// CancelScrapingJob cancels a running scraping job
func (s *tenderService) CancelScrapingJob(ctx context.Context, jobID string) error {
s.logger.Info("Cancelling scraping job", map[string]interface{}{
"job_id": jobID,
})
job, err := s.repository.GetScrapingJob(ctx, jobID)
if err != nil {
s.logger.Error("Failed to find scraping job for cancellation", map[string]interface{}{
"job_id": jobID,
"error": err.Error(),
})
return fmt.Errorf("failed to find scraping job: %w", err)
}
if job.Status != ScrapingJobStatusRunning && job.Status != ScrapingJobStatusPending {
s.logger.Warn("Cannot cancel scraping job in current status", map[string]interface{}{
"job_id": jobID,
"status": job.Status,
})
return fmt.Errorf("cannot cancel job in status: %s", job.Status)
}
job.Status = ScrapingJobStatusCancelled
completedAt := time.Now().Unix()
job.CompletedAt = &completedAt
duration := completedAt - job.StartedAt
job.Duration = &duration
if err := s.repository.UpdateScrapingJob(ctx, job); err != nil {
s.logger.Error("Failed to update cancelled scraping job", map[string]interface{}{
"job_id": jobID,
"error": err.Error(),
})
return fmt.Errorf("failed to update scraping job: %w", err)
}
s.logger.Info("Scraping job cancelled successfully", map[string]interface{}{
"job_id": jobID,
})
return nil
}
// UpdateExpiredTenders updates status of expired tenders
func (s *tenderService) UpdateExpiredTenders(ctx context.Context) error {
s.logger.Info("Updating expired tenders", map[string]interface{}{})
// Get tenders that should be expired
expiredTenders, err := s.repository.GetExpiredTenders(ctx, time.Now().Unix())
if err != nil {
s.logger.Error("Failed to get expired tenders", map[string]interface{}{
"error": err.Error(),
})
return fmt.Errorf("failed to get expired tenders: %w", err)
}
updatedCount := 0
for _, tender := range expiredTenders {
if tender.Status != TenderStatusExpired {
tender.UpdateStatus(TenderStatusExpired)
if err := s.repository.Update(ctx, &tender); err != nil {
s.logger.Error("Failed to update expired tender", map[string]interface{}{
"tender_id": tender.ID,
"error": err.Error(),
})
continue
}
updatedCount++
}
}
s.logger.Info("Expired tenders updated", map[string]interface{}{
"total_expired": len(expiredTenders),
"updated": updatedCount,
})
return nil
}
// CleanupOldData removes old data based on retention policy
func (s *tenderService) CleanupOldData(ctx context.Context, olderThan time.Duration) error {
s.logger.Info("Cleaning up old data", map[string]interface{}{
"older_than": olderThan.String(),
})
cutoffTime := time.Now().Add(-olderThan).Unix()
// For now, we'll just log what would be cleaned up
// In production, you might want to actually delete old data
criteria := TenderSearchCriteria{
Status: []TenderStatus{TenderStatusExpired, TenderStatusCancelled, TenderStatusAwarded},
}
oldTenders, err := s.repository.Search(ctx, criteria)
if err != nil {
s.logger.Error("Failed to find old tenders for cleanup", map[string]interface{}{
"error": err.Error(),
})
return fmt.Errorf("failed to find old tenders: %w", err)
}
cleanupCount := 0
for _, tender := range oldTenders {
if tender.CreatedAt < cutoffTime {
cleanupCount++
// Here you would delete the tender if implementing actual cleanup
// s.repository.Delete(ctx, tender.ID)
}
}
s.logger.Info("Old data cleanup completed", map[string]interface{}{
"found_old_tenders": cleanupCount,
"cutoff_time": cutoffTime,
})
return nil
}