diff --git a/cmd/scraper/README.md b/cmd/scraper/README.md new file mode 100644 index 0000000..ab1f6aa --- /dev/null +++ b/cmd/scraper/README.md @@ -0,0 +1,349 @@ +# TED XML Scraper + +A comprehensive TED (Tenders Electronic Daily) XML scraper that downloads and parses daily calendar XML files, converting them to tender entities and storing them in MongoDB. + +## Features + +- **Daily Scheduled Scraping**: Runs automatically at 10:00 AM daily using cron jobs +- **TED XML Format Support**: Handles eForms XML, Contract Notices, and TED Calendar formats +- **XML Parser Integration**: Uses the custom `xmlparser` package with generic type support +- **Robust Error Handling**: Comprehensive error handling, retry mechanisms, and logging +- **Data Conversion**: Converts TED XML structures to internal tender entity models +- **MongoDB Integration**: Stores processed data with proper indexing and validation +- **Progress Tracking**: Job tracking, status monitoring, and statistics +- **Configurable**: Environment variables and YAML configuration support + +## Architecture + +``` +cmd/scraper/ +├── main.go # Application entry point +├── config.yaml # Default configuration +├── bootstrap/ +│ ├── bootstrap.go # Application initialization +│ └── config.go # Configuration structures +└── README.md # This file + +internal/scraper/ +├── service.go # Core scraping service logic +├── repository.go # MongoDB operations for scraping jobs +└── scheduler.go # Cron job scheduling + +pkg/xmlparser/ted/ +├── models.go # TED XML data structures +└── converter.go # XML to entity conversion +``` + +## Installation + +1. **Prerequisites**: + - Go 1.21+ + - MongoDB 4.4+ + +2. **Dependencies**: + ```bash + cd cmd/scraper + go mod tidy + ``` + +3. **Configuration**: + Create a `config.yaml` file or set environment variables: + ```yaml + database: + mongodb: + uri: "mongodb://localhost:27017" + name: "tender_management" + + ted: + base_url: "https://ted.europa.eu" + timeout: 30s + max_retries: 3 + ``` + +## Usage + +### Running the Scraper + +```bash +# Run with default configuration +go run main.go + +# Run with custom config file +CONFIG_PATH=./custom-config.yaml go run main.go + +# Run with environment variables +TED_BASE_URL=https://ted.europa.eu go run main.go +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `TED_BASE_URL` | `https://ted.europa.eu` | TED website base URL | +| `TED_TIMEOUT` | `30s` | HTTP request timeout | +| `TED_MAX_RETRIES` | `3` | Maximum retry attempts | +| `TED_RETRY_DELAY` | `5s` | Delay between retries | +| `TED_USER_AGENT` | `TM-TED-Scraper/1.0` | HTTP User-Agent header | +| `TED_MAX_CONCURRENCY` | `5` | Maximum concurrent downloads | +| `TED_DOWNLOAD_DIR` | `./downloads` | Download directory | +| `TED_CLEANUP_AFTER` | `24h` | File cleanup interval | + +### Manual Trigger + +To manually trigger a scraping job outside the schedule: + +```bash +# Using the scheduler API (if implemented) +curl -X POST http://localhost:8080/api/scraper/trigger + +# Or restart the application to trigger immediate run +``` + +## How It Works + +### 1. Daily Calendar Download + +The scraper downloads TED daily packages using this URL pattern: +``` +https://ted.europa.eu/packages/daily/{yyyynnnnn} +``` + +Where `{yyyynnnnn}` is the year + 5-digit day of year (e.g., `202500001` for January 1st, 2025). + +### 2. XML Parsing + +The scraper supports multiple TED XML formats: + +- **Contract Notice**: Individual tender announcements +- **Tender Calendar**: Daily collections of multiple notices +- **eForms**: New European standard format +- **Legacy TED Schema**: Older format support + +### 3. Data Conversion + +XML data is converted to internal tender entities with: + +- **Field Mapping**: TED XML fields → Tender entity fields +- **Data Validation**: Required field validation and sanitization +- **Unix Timestamps**: All dates converted to Unix timestamps +- **Text Normalization**: Clean up whitespace and formatting +- **URL Generation**: Generate TED website URLs for notices + +### 4. Storage + +Processed data is stored in MongoDB with: + +- **Indexes**: Optimized for search and filtering +- **Deduplication**: Based on contract notice ID +- **Audit Trail**: Creation and update timestamps +- **Error Tracking**: Processing errors and warnings + +## TED XML Format Analysis + +### Contract Notice Structure + +```xml + + 123456-2025 + folder-123 + 2025-01-09+02:00 + cn-standard + ENG + + +``` + +### Key XML Elements Mapped + +| XML Element | Tender Field | Description | +|-------------|--------------|-------------| +| `ID` | `contract_notice_id` | Unique notice identifier | +| `ContractFolderID` | `contract_folder_id` | Contract folder reference | +| `IssueDate` | `issue_date` | Notice publication date | +| `ProcurementProject/Name` | `title` | Tender title | +| `ProcurementProject/Description` | `description` | Tender description | +| `ContractingParty` | `buyer_organization` | Buyer/contracting authority | +| `TenderSubmissionDeadlinePeriod` | `tender_deadline` | Submission deadline | + +## Scheduling + +### Cron Schedule + +- **Main Job**: `0 0 10 * * *` (10:00 AM daily UTC) +- **Cleanup Job**: `0 0 2 * * *` (2:00 AM daily UTC) + +### Job Configuration + +```go +schedulerConfig := scraper.SchedulerConfig{ + CronExpression: "0 0 10 * * *", // 10:00 AM daily (seconds, minutes, hours, day, month, weekday) + Timezone: "UTC", + JobTimeout: 2 * time.Hour, + RetryAttempts: 3, + RetryDelay: 30 * time.Minute, +} +``` + +### Retry Logic + +- **Automatic Retries**: Up to 3 attempts on failure +- **Exponential Backoff**: 30-minute delay between retries +- **Context Timeout**: 2-hour maximum per job +- **Graceful Shutdown**: Waits for running jobs to complete + +## Monitoring + +### Job Status + +Track scraping job progress and status: + +```json +{ + "id": "job-123", + "type": "ted_scraper", + "status": "running", + "progress": { + "processed_count": 150, + "total_count": 500, + "success_count": 145, + "error_count": 5, + "percentage": 30.0, + "current_step": "processing_tenders" + } +} +``` + +### Statistics + +```json +{ + "period": "Last 7 days", + "total_jobs": 7, + "completed_jobs": 6, + "failed_jobs": 1, + "total_processed": 3500, + "total_created": 2100, + "total_updated": 1200, + "total_errors": 200 +} +``` + +## Error Handling + +### Error Types + +1. **Download Errors**: Network issues, server errors +2. **Parsing Errors**: Invalid XML, schema mismatches +3. **Validation Errors**: Missing required fields +4. **Database Errors**: Connection issues, write failures + +### Error Recovery + +- **Retry Mechanism**: Automatic retries with backoff +- **Partial Success**: Continue processing valid notices +- **Error Logging**: Detailed error tracking and reporting +- **Graceful Degradation**: Skip problematic entries + +### Example Error Response + +```json +{ + "job_id": "job-123", + "status": "completed", + "results": { + "total_processed": 100, + "total_created": 85, + "total_errors": 15, + "processing_errors": [ + "notice 23: validation failed: title is required", + "notice 45: failed to parse deadline: invalid format" + ] + } +} +``` + +## Best Practices + +### Performance + +- **Batch Processing**: Process tenders in configurable batches (default: 10) +- **Connection Pooling**: MongoDB connection management +- **Memory Management**: Stream large XML files +- **Concurrent Downloads**: Configurable concurrency limits + +### Data Quality + +- **Validation**: Comprehensive field validation +- **Sanitization**: Text cleaning and normalization +- **Deduplication**: Prevent duplicate tender entries +- **Audit Trail**: Track all changes and updates + +### Reliability + +- **Health Checks**: Monitor service health +- **Graceful Shutdown**: Clean exit on signals +- **Resource Cleanup**: Automatic file cleanup +- **State Persistence**: Track scraping state across restarts + +## Development + +### Adding New XML Formats + +1. **Extend TED Models**: Add new XML structures in `pkg/xmlparser/ted/models.go` +2. **Update Converter**: Add conversion logic in `pkg/xmlparser/ted/converter.go` +3. **Add Parser Support**: Register new parsers in service +4. **Test Thoroughly**: Add comprehensive test cases + +### Custom Validation + +```go +// Add custom validators in converter.go +func (c *Converter) ValidateCustomField(value string) error { + if len(value) > 1000 { + return fmt.Errorf("field too long: %d characters", len(value)) + } + return nil +} +``` + +### Extending Scheduling + +```go +// Add new scheduled jobs in scheduler.go +_, err = s.cron.AddFunc("0 */6 * * *", s.runStatisticsJob) // Every 6 hours +``` + +## Troubleshooting + +### Common Issues + +1. **Connection Errors**: Check MongoDB connection and credentials +2. **Parsing Failures**: Verify TED XML format changes +3. **Schedule Issues**: Check timezone and cron expression +4. **Memory Usage**: Monitor for large XML files + +### Debug Mode + +Enable debug logging: + +```yaml +logging: + level: "debug" +``` + +### Manual Testing + +```bash +# Test XML parsing +go run main.go --test-xml ./sample.xml + +# Test specific date +go run main.go --test-date 2025-01-09 + +# Validate configuration +go run main.go --validate-config +``` + +## License + +This project is part of the Tender Management system and follows the same licensing terms. diff --git a/cmd/scraper/bootstrap/bootstrap.go b/cmd/scraper/bootstrap/bootstrap.go index dfa875f..2fa2a2c 100644 --- a/cmd/scraper/bootstrap/bootstrap.go +++ b/cmd/scraper/bootstrap/bootstrap.go @@ -1,140 +1,184 @@ package bootstrap -// import ( -// "context" -// "fmt" -// "os" -// "os/signal" -// "syscall" -// "time" -// "tm/internal/tender" -// "tm/pkg/config" -// "tm/pkg/logger" -// "tm/pkg/mongo" -// "tm/ted" -// ) +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" + "tm/internal/tender" + "tm/pkg/config" + "tm/pkg/logger" + "tm/pkg/mongo" + "tm/pkg/notification" + "tm/pkg/schedule" + "tm/ted" +) -// // Init Application Configuration -// func InitConfig() (*Config, error) { -// conf, err := config.LoadConfig(".", &Config{}) -// if err != nil { -// return nil, fmt.Errorf("failed to load config: %v", err) -// } +// Init Application Configuration +func InitConfig() (*Config, error) { + conf, err := config.LoadConfig(".", &Config{}) + if err != nil { + return nil, fmt.Errorf("failed to load config: %v", err) + } -// return conf, nil -// } + return conf, nil +} -// // Init Logger Service -// func InitLogger(conf config.LoggingConfig) logger.Logger { -// return logger.NewLogger(&logger.Config{ -// Level: conf.Level, -// Format: conf.Format, -// Output: conf.Output, -// File: logger.FileConfig{ -// Path: conf.File.Path, -// MaxSize: conf.File.MaxSize, -// MaxBackups: conf.File.MaxBackups, -// MaxAge: conf.File.MaxAge, -// Compress: conf.File.Compress, -// }, -// }) -// } +// Init Logger Service +func InitLogger(conf config.LoggingConfig) logger.Logger { + return logger.NewLogger(&logger.Config{ + Level: conf.Level, + Format: conf.Format, + Output: conf.Output, + File: logger.FileConfig{ + Path: conf.File.Path, + MaxSize: conf.File.MaxSize, + MaxBackups: conf.File.MaxBackups, + MaxAge: conf.File.MaxAge, + Compress: conf.File.Compress, + }, + }) +} -// // Init MongoDB Connection Manager -// func InitMongoDB(conf config.MongoConfig, log logger.Logger) *mongo.ConnectionManager { -// // Convert infra.MongoConfig to mongo.ConnectionConfig -// connectionConfig := mongo.ConnectionConfig{ -// URI: conf.URI, -// Database: conf.Name, -// MaxPoolSize: uint64(conf.MaxPoolSize), -// MinPoolSize: 5, // Default minimum pool size -// MaxConnIdleTime: 30 * time.Minute, // Default idle time -// ConnectTimeout: conf.Timeout, -// ServerSelectionTimeout: 5 * time.Second, // Default server selection timeout -// } +// Init MongoDB Connection Manager +func InitMongoDB(conf config.MongoConfig, log logger.Logger) *mongo.ConnectionManager { + // Convert infra.MongoConfig to mongo.ConnectionConfig + connectionConfig := mongo.ConnectionConfig{ + URI: conf.URI, + Database: conf.Name, + MaxPoolSize: uint64(conf.MaxPoolSize), + MinPoolSize: 5, // Default minimum pool size + MaxConnIdleTime: 30 * time.Minute, // Default idle time + ConnectTimeout: conf.Timeout, + ServerSelectionTimeout: 5 * time.Second, // Default server selection timeout + } -// // Create MongoDB connection manager -// connectionManager, err := mongo.NewConnectionManager(connectionConfig, log) -// if err != nil { -// log.Error("Failed to initialize MongoDB connection", map[string]interface{}{ -// "error": err.Error(), -// "uri": conf.URI, -// "db": conf.Name, -// }) -// panic(fmt.Sprintf("Failed to initialize MongoDB connection: %v", err)) -// } + // Create MongoDB connection manager + connectionManager, err := mongo.NewConnectionManager(connectionConfig, log) + if err != nil { + log.Error("Failed to initialize MongoDB connection", map[string]interface{}{ + "error": err.Error(), + "uri": conf.URI, + "db": conf.Name, + }) + panic(fmt.Sprintf("Failed to initialize MongoDB connection: %v", err)) + } -// log.Info("MongoDB connection manager initialized successfully", map[string]interface{}{ -// "database": conf.Name, -// "uri": conf.URI, -// "pool_size": conf.MaxPoolSize, -// }) + log.Info("MongoDB connection manager initialized successfully", map[string]interface{}{ + "database": conf.Name, + "uri": conf.URI, + "pool_size": conf.MaxPoolSize, + }) -// return connectionManager -// } + return connectionManager +} -// // init TED scraper -// func InitTEDScraper(config Config, mongoManager *mongo.ConnectionManager, appLogger logger.Logger) { -// // Initialize tender repository -// tenderRepo := tender.NewRepository(mongoManager, appLogger) +// init TED scraper +func InitTEDScraper(config Config, mongoManager *mongo.ConnectionManager, appLogger logger.Logger, notify notification.SDK) { + // Initialize tender repository + tenderRepo := tender.NewRepository(mongoManager, appLogger) -// // Initialize TED scraper -// tedScraper := ted.NewTEDScraper( -// &ted.Config{ -// BaseURL: config.TED.BaseURL, -// Timeout: config.TED.Timeout, -// MaxRetries: config.TED.MaxRetries, -// RetryDelay: config.TED.RetryDelay, -// UserAgent: config.TED.UserAgent, -// MaxConcurrency: config.TED.MaxConcurrency, -// DownloadDir: config.TED.DownloadDir, -// CleanupAfter: config.TED.CleanupAfter, -// ScrapingInterval: config.TED.ScrapingInterval, -// }, -// appLogger, -// mongoManager, -// tenderRepo, -// ) + // Initialize TED scraper + tedScraper := ted.NewTEDScraper( + &ted.Config{ + BaseURL: config.TED.BaseURL, + Timeout: config.TED.Timeout, + MaxRetries: config.TED.MaxRetries, + RetryDelay: config.TED.RetryDelay, + UserAgent: config.TED.UserAgent, + MaxConcurrency: config.TED.MaxConcurrency, + DownloadDir: config.TED.DownloadDir, + CleanupAfter: config.TED.CleanupAfter, + ScrapingInterval: config.TED.ScrapingInterval, + }, + appLogger, + mongoManager, + tenderRepo, + notify, + ) -// // Create context with cancellation -// ctx, cancel := context.WithCancel(context.Background()) -// defer cancel() + // start TED scraper job + schedule.NewCronScheduler(appLogger, mongoManager, tenderRepo).AddJob(schedule.Job{ + Name: "TED Scraper Job", + Func: func() { + _ = tedScraper.Run(context.Background()) + }, + Expr: config.TED.ScrapingInterval, + }) -// // Set up signal handling for graceful shutdown -// signalChan := make(chan os.Signal, 1) -// signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) + // Create context with cancellation + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() -// // Start the scraper in a goroutine -// scraperDone := make(chan error, 1) -// go func() { -// appLogger.Info("Starting TED scraper", map[string]interface{}{ -// "interval": config.TED.ScrapingInterval.String(), -// }) -// scraperDone <- tedScraper.RunPeriodicScraping(ctx) -// }() + // Set up signal handling for graceful shutdown + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) -// // Wait for shutdown signal or scraper completion -// select { -// case <-signalChan: -// appLogger.Info("Received shutdown signal, stopping scraper...", map[string]interface{}{}) -// cancel() + // Start the scraper in a goroutine + scraperDone := make(chan error, 1) + go func() { + appLogger.Info("Starting TED scraper", map[string]interface{}{ + "interval": config.TED.ScrapingInterval, + }) + scraperDone <- tedScraper.Run(ctx) + }() -// // Wait for scraper to finish gracefully with timeout -// shutdownTimeout := time.NewTimer(30 * time.Second) -// select { -// case <-scraperDone: -// appLogger.Info("Scraper stopped gracefully", map[string]interface{}{}) -// case <-shutdownTimeout.C: -// appLogger.Warn("Scraper shutdown timeout, forcing exit", map[string]interface{}{}) -// } + // Wait for shutdown signal or scraper completion + select { + case <-signalChan: + appLogger.Info("Received shutdown signal, stopping scraper...", map[string]interface{}{}) + cancel() -// case err := <-scraperDone: -// if err != nil { -// appLogger.Error("Scraper finished with error", map[string]interface{}{ -// "error": err.Error(), -// }) -// os.Exit(1) -// } -// appLogger.Info("Scraper finished successfully", map[string]interface{}{}) -// } -// } + // Wait for scraper to finish gracefully with timeout + shutdownTimeout := time.NewTimer(30 * time.Second) + select { + case <-scraperDone: + appLogger.Info("Scraper stopped gracefully", map[string]interface{}{}) + case <-shutdownTimeout.C: + appLogger.Warn("Scraper shutdown timeout, forcing exit", map[string]interface{}{}) + } + + case err := <-scraperDone: + + if err != nil { + appLogger.Error("Scraper finished with error", map[string]interface{}{ + "error": err.Error(), + }) + os.Exit(1) + } + appLogger.Info("Scraper finished successfully", map[string]interface{}{}) + } +} + +// Init Notification Service +func InitNotificationService(conf config.NotificationConfig, log logger.Logger) notification.SDK { + notificationConfig := ¬ification.Config{ + BaseURL: conf.BaseURL, + Timeout: conf.Timeout, + RetryAttempts: conf.RetryAttempts, + RetryDelay: conf.RetryDelay, + EnableLogging: conf.EnableLogging, + UserAgent: conf.UserAgent, + } + + notificationSDK, err := notification.New(notificationConfig, log) + if err != nil { + log.Error("Failed to initialize Notification SDK", map[string]interface{}{ + "error": err.Error(), + }) + fmt.Printf("Failed to initialize Notification SDK: %v\n", err.Error()) + } + + log.Info("Notification 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 *notificationSDK +} diff --git a/cmd/scraper/bootstrap/config.go b/cmd/scraper/bootstrap/config.go index 1a866a6..7766ae2 100644 --- a/cmd/scraper/bootstrap/config.go +++ b/cmd/scraper/bootstrap/config.go @@ -7,19 +7,20 @@ import ( // Config defines the scraper application configuration type Config struct { - Database config.DatabaseConfig - Logging config.LoggingConfig - TED ScraperConfig + Database config.DatabaseConfig + Logging config.LoggingConfig + TED ScraperConfig + Notification config.NotificationConfig } type ScraperConfig struct { - BaseURL string `env:"TED_BASE_URL"` - Timeout time.Duration `env:"TED_TIMEOUT"` - MaxRetries int `env:"TED_MAX_RETRIES"` - RetryDelay time.Duration `env:"TED_RETRY_DELAY"` - UserAgent string `env:"TED_USER_AGENT"` - MaxConcurrency int `env:"TED_MAX_CONCURRENCY"` - DownloadDir string `env:"TED_DOWNLOAD_DIR"` - CleanupAfter time.Duration `env:"TED_CLEANUP_AFTER"` - ScrapingInterval time.Duration `env:"TED_SCRAPING_INTERVAL"` + BaseURL string `env:"TED_BASE_URL" envDefault:"https://ted.europa.eu"` + Timeout time.Duration `env:"TED_TIMEOUT" envDefault:"30s"` + MaxRetries int `env:"TED_MAX_RETRIES" envDefault:"3"` + RetryDelay time.Duration `env:"TED_RETRY_DELAY" envDefault:"5s"` + UserAgent string `env:"TED_USER_AGENT" envDefault:"TM-TED-Scraper/1.0"` + MaxConcurrency int `env:"TED_MAX_CONCURRENCY" envDefault:"5"` + DownloadDir string `env:"TED_DOWNLOAD_DIR" envDefault:"./downloads"` + CleanupAfter time.Duration `env:"TED_CLEANUP_AFTER" envDefault:"24h"` + ScrapingInterval string `env:"TED_SCRAPING_INTERVAL" envDefault:"* * 10 * * *"` } diff --git a/cmd/scraper/config.yaml b/cmd/scraper/config.yaml new file mode 100644 index 0000000..39a628d --- /dev/null +++ b/cmd/scraper/config.yaml @@ -0,0 +1,29 @@ +# TED Scraper Configuration +database: + mongodb: + uri: "mongodb://localhost:27017" + name: "tender_management" + max_pool_size: 100 + timeout: 30s + +logging: + level: "info" + format: "json" + output: "stdout" + file: + path: "./logs/scraper.log" + max_size: 100 + max_backups: 10 + max_age: 30 + compress: true + +ted: + base_url: "https://ted.europa.eu" + timeout: 30s + max_retries: 3 + retry_delay: 5s + user_agent: "TM-TED-Scraper/1.0" + max_concurrency: 5 + download_dir: "./downloads" + cleanup_after: 24h + scraping_interval: 24h diff --git a/cmd/scraper/main.go b/cmd/scraper/main.go index e8bb8ba..4bf7395 100644 --- a/cmd/scraper/main.go +++ b/cmd/scraper/main.go @@ -1,31 +1,54 @@ package main +import ( + "context" + "os" + "os/signal" + "syscall" + "time" + "tm/cmd/scraper/bootstrap" +) + func main() { - // // Load configuration - // config, err := bootstrap.InitConfig() - // if err != nil { - // panic(err) - // } + // Load configuration + config, err := bootstrap.InitConfig() + if err != nil { + panic(err) + } - // // Initialize logger - // appLogger := bootstrap.InitLogger(config.Logging) - // appLogger.Info("Starting TED scraper application", map[string]interface{}{ - // "version": "1.0.0", - // }) + // Initialize logger + appLogger := bootstrap.InitLogger(config.Logging) + appLogger.Info("Starting TED scraper application", map[string]interface{}{ + "version": "1.0.0", + }) - // // Initialize MongoDB connection - // mongoManager := bootstrap.InitMongoDB(config.Database.MongoDB, appLogger) - // defer func() { - // ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - // defer cancel() - // if err := mongoManager.Close(ctx); err != nil { - // appLogger.Error("Failed to close MongoDB connection", map[string]interface{}{ - // "error": err.Error(), - // }) - // } - // }() + // Initialize notification service - // bootstrap.InitTEDScraper(*config, mongoManager, appLogger) + // Initialize MongoDB connection + mongoManager := bootstrap.InitMongoDB(config.Database.MongoDB, appLogger) + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := mongoManager.Close(ctx); err != nil { + appLogger.Error("Failed to close MongoDB connection", map[string]interface{}{ + "error": err.Error(), + }) + } + }() - // appLogger.Info("TED scraper application stopped", map[string]interface{}{}) + notificationService := bootstrap.InitNotificationService(config.Notification, appLogger) + bootstrap.InitTEDScraper(*config, mongoManager, appLogger, notificationService) + + // Set up signal handling for graceful shutdown + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) + + // Wait for shutdown signal + <-signalChan + appLogger.Info("Received shutdown signal, stopping scheduler...", map[string]interface{}{}) + + // Stop the scheduler + // scheduler.Stop() + + appLogger.Info("TED scraper application stopped", map[string]interface{}{}) } diff --git a/go.mod b/go.mod index 438eab5..23b995c 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/labstack/echo/v4 v4.13.4 github.com/redis/go-redis/v9 v9.14.0 + github.com/robfig/cron/v3 v3.0.1 github.com/stretchr/testify v1.10.0 github.com/swaggo/echo-swagger v1.4.1 github.com/swaggo/swag v1.16.6 diff --git a/go.sum b/go.sum index 476cb85..dbec14f 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= diff --git a/internal/scraping/scraping.go b/internal/scraping/scraping.go deleted file mode 100644 index 0332651..0000000 --- a/internal/scraping/scraping.go +++ /dev/null @@ -1,153 +0,0 @@ -package scraping - -import "time" - -// 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() -} diff --git a/pkg/schedule/cron.go b/pkg/schedule/cron.go new file mode 100644 index 0000000..2f9220d --- /dev/null +++ b/pkg/schedule/cron.go @@ -0,0 +1,144 @@ +package schedule + +import ( + "time" + "tm/internal/tender" + "tm/pkg/logger" + "tm/pkg/mongo" + + "github.com/robfig/cron/v3" +) + +// CronScheduler handles scheduled tasks for TED operations +type CronScheduler struct { + cron *cron.Cron + mongoManager *mongo.ConnectionManager + tenderRepo tender.TenderRepository + logger logger.Logger +} + +// Logger interface for structured logging +type Logger interface { + Info(msg string, fields map[string]interface{}) + Error(msg string, fields map[string]interface{}) + Debug(msg string, fields map[string]interface{}) +} + +type Job struct { + Name string + Func func() + Expr string +} + +// NewCronScheduler creates a new cron scheduler instance +func NewCronScheduler(logger logger.Logger, mongoManager *mongo.ConnectionManager, tenderRepo tender.TenderRepository) *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, + logger: logger, + } +} + +// Start starts the cron scheduler +func (cs *CronScheduler) Start() { + cs.logger.Info("Starting TED cron scheduler", map[string]interface{}{}) + cs.cron.Start() +} + +// Stop stops the cron scheduler gracefully +func (cs *CronScheduler) Stop() { + cs.logger.Info("Stopping TED cron scheduler", map[string]interface{}{}) + ctx := cs.cron.Stop() + <-ctx.Done() +} + +// ScheduleDailyJob schedules a job to run daily at 10:00 AM UTC +func (cs *CronScheduler) AddJob(job Job) error { + // Cron expression: "0 0 10 * * *" means: + // Second: 0 + // Minute: 0 + // Hour: 10 (10 AM) + // Day of month: * (every day) + // Month: * (every month) + // Day of week: * (every day of week) + _, err := cs.cron.AddFunc(job.Expr, func() { + now := time.Now().Local() + cs.logger.Info("Starting daily TED job", map[string]interface{}{ + "job_name": job.Name, + "cron_expression": job.Expr, + "time": time.Now().Format(time.RFC3339), + }) + + job.Func() + + cs.logger.Info("Completed job successfully", map[string]interface{}{ + "time": now.Format(time.RFC3339), + "job_name": job.Name, + "cron_expression": job.Expr, + }) + }) + + if err != nil { + cs.logger.Error("Failed to schedule daily job", map[string]interface{}{ + "error": err.Error(), + "job_name": job.Name, + "cron_expression": job.Expr, + "time": time.Now().Format(time.RFC3339), + }) + return err + } + + cs.logger.Info("Daily job scheduled successfully", map[string]interface{}{ + "job_name": job.Name, + "cron_expression": job.Expr, + "time": time.Now().Format(time.RFC3339), + }) + + return nil +} + +// ExampleTEDScrapingJob is an example job function that can be scheduled +func ExampleTEDScrapingJob() { + // now := time.Now().Local() + // // Example: Get current date for TED scraping + // currentDate := time.Now().Format("02/01/2006") + // currentYear := time.Now().Year() + + // // This is just an example - replace with actual TED scraping logic + // ojs, err := GetOJS(currentYear, currentDate) + // if err != nil { + // log.Printf("Error getting OJS: %v", err) + // return + // } + + // log.Printf("Successfully retrieved OJS: %s for date: %s", ojs, currentDate) + + // Add your actual TED scraping/processing logic here + + // ojs, err := GetOJS(now.Year(), now.Format("02/01/2006")) + // if err != nil { + // cs.logger.Error("Failed to get OJS", map[string]interface{}{ + // "error": err.Error(), + // }) + // return + // } + + // scraper := NewTEDScraper(cs.config, cs.logger, cs.mongoManager, cs.tenderRepo) + // result, err := scraper.DownloadDailyPackage(context.Background(), ojs) + // if err != nil { + // cs.logger.Error("Failed to download daily package", map[string]interface{}{ + // "error": err.Error(), + // }) + // return + // } + + // cs.logger.Info("Downloaded daily package successfully", map[string]interface{}{ + // "result": result, + // "time": now.Format(time.RFC3339), + // "ojs": ojs, + // }) +} diff --git a/ted/calendar.go b/ted/calendar.go new file mode 100644 index 0000000..3cfd3e8 --- /dev/null +++ b/ted/calendar.go @@ -0,0 +1,46 @@ +package ted + +import ( + "bytes" + "encoding/csv" + "fmt" + "io" + "net/http" + "strings" +) + +const ( + calendarUrl = "https://ted.europa.eu/en/release-calendar/-/download/file/CSV/%d" +) + +// Get OJS Number +func GetOJS(year int, date string) (string, error) { + var ( + ojs string + ) + url := fmt.Sprintf(calendarUrl, year) + + resp, err := http.Get(url) + if err != nil { + return ojs, err + } + defer resp.Body.Close() + + // read CSV + body, _ := io.ReadAll(resp.Body) + reader := csv.NewReader(bytes.NewReader(body)) + records, _ := reader.ReadAll() + + for _, row := range records[1:] { + rowDate := strings.TrimSpace(row[1]) + if rowDate == strings.TrimSpace(date) { + ojs = fmt.Sprintf("%v00%v", year, strings.TrimSpace(fmt.Sprint(row[0]))) + break + } + } + if ojs == "" { + return ojs, fmt.Errorf("can not find ojs number") + } + + return ojs, nil +} diff --git a/ted/eform.go b/ted/eform.go new file mode 100644 index 0000000..c478bb9 --- /dev/null +++ b/ted/eform.go @@ -0,0 +1,126 @@ +package ted + +import ( + "encoding/xml" + "tm/ted/eform" +) + +// ===================================================== +// ROOT NOTICE STRUCTURE +// ===================================================== + +// Notice represents the UBL ContractNotice structure used by TED +type ContractNotice struct { + XMLName xml.Name `xml:"ContractNotice"` + UBLVersionID string `xml:"UBLVersionID"` + CustomizationID string `xml:"CustomizationID"` + ID string `xml:"ID"` + ContractFolderID string `xml:"ContractFolderID"` + IssueDate string `xml:"IssueDate"` + IssueTime string `xml:"IssueTime"` + VersionID string `xml:"VersionID"` + RegulatoryDomain string `xml:"RegulatoryDomain"` + NoticeTypeCode string `xml:"NoticeTypeCode"` + NoticeLanguageCode string `xml:"NoticeLanguageCode"` + UBLExtensions eform.UBLExtensions `xml:"UBLExtensions,omitempty"` + ContractingParty eform.ContractingParty `xml:"ContractingParty"` + TenderingTerms eform.TenderingTerms `xml:"TenderingTerms,omitempty"` + TenderingProcess eform.TenderingProcess `xml:"TenderingProcess,omitempty"` + ProcurementProject eform.ProcurementProject `xml:"ProcurementProject"` + ProcurementProjectLot []eform.ProcurementProjectLot `xml:"ProcurementProjectLot,omitempty"` +} + +// ContractAwardNotice represents the UBL ContractAwardNotice structure +type ContractAwardNotice struct { + XMLName xml.Name `xml:"ContractAwardNotice"` + UBLVersionID string `json:"ubl_version_id" xml:"UBLVersionID"` + CustomizationID string `json:"customization_id" xml:"CustomizationID"` + ID string `json:"id" xml:"ID"` + ContractFolderID string `json:"contract_folder_id" xml:"ContractFolderID"` + IssueDate string `json:"issue_date" xml:"IssueDate"` + IssueTime string `json:"issue_time,omitempty" xml:"IssueTime,omitempty"` + VersionID string `json:"version_id,omitempty" xml:"VersionID,omitempty"` + RegulatoryDomain string `json:"regulatory_domain,omitempty" xml:"RegulatoryDomain,omitempty"` + NoticeTypeCode string `json:"notice_type_code" xml:"NoticeTypeCode"` + NoticeLanguageCode string `json:"notice_language_code" xml:"NoticeLanguageCode"` + ContractingParty eform.ContractingParty `json:"contracting_party" xml:"ContractingParty"` + TenderingTerms eform.TenderingTerms `json:"tendering_terms,omitempty" xml:"TenderingTerms,omitempty"` + TenderingProcess eform.TenderingProcess `json:"tendering_process,omitempty" xml:"TenderingProcess,omitempty"` + ProcurementProject eform.ProcurementProject `json:"procurement_project" xml:"ProcurementProject"` + ProcurementProjectLot []eform.ProcurementProjectLot `json:"procurement_project_lots,omitempty" xml:"ProcurementProjectLot,omitempty"` + UBLExtensions eform.UBLExtensions `json:"ubl_extensions,omitempty" xml:"UBLExtensions,omitempty"` +} + +// PriorInformationNotice represents the UBL PriorInformationNotice structure +type PriorInformationNotice struct { + XMLName xml.Name `xml:"PriorInformationNotice"` + UBLVersionID string `json:"ubl_version_id" xml:"UBLVersionID"` + CustomizationID string `json:"customization_id" xml:"CustomizationID"` + ID string `json:"id" xml:"ID"` + ContractFolderID string `json:"contract_folder_id" xml:"ContractFolderID"` + IssueDate string `json:"issue_date" xml:"IssueDate"` + IssueTime string `json:"issue_time,omitempty" xml:"IssueTime,omitempty"` + VersionID string `json:"version_id,omitempty" xml:"VersionID,omitempty"` + RegulatoryDomain string `json:"regulatory_domain,omitempty" xml:"RegulatoryDomain,omitempty"` + NoticeTypeCode string `json:"notice_type_code" xml:"NoticeTypeCode"` + NoticeLanguageCode string `json:"notice_language_code" xml:"NoticeLanguageCode"` + ContractingParty eform.ContractingParty `json:"contracting_party" xml:"ContractingParty"` + TenderingTerms eform.TenderingTerms `json:"tendering_terms,omitempty" xml:"TenderingTerms,omitempty"` + TenderingProcess eform.TenderingProcess `json:"tendering_process,omitempty" xml:"TenderingProcess,omitempty"` + ProcurementProject eform.ProcurementProject `json:"procurement_project" xml:"ProcurementProject"` + ProcurementProjectLot []eform.ProcurementProjectLot `json:"procurement_project_lots,omitempty" xml:"ProcurementProjectLot,omitempty"` + UBLExtensions eform.UBLExtensions `json:"ubl_extensions,omitempty" xml:"UBLExtensions,omitempty"` +} + +// BusinessRegistrationInformationNotice represents the UBL BusinessRegistrationInformationNotice structure +type BusinessRegistrationInformationNotice struct { + XMLName xml.Name `xml:"BusinessRegistrationInformationNotice"` + UBLVersionID string `json:"ubl_version_id" xml:"UBLVersionID"` + CustomizationID string `json:"customization_id" xml:"CustomizationID"` + ID string `json:"id" xml:"ID"` + IssueDate string `json:"issue_date" xml:"IssueDate"` + IssueTime string `json:"issue_time,omitempty" xml:"IssueTime,omitempty"` + VersionID string `json:"version_id,omitempty" xml:"VersionID,omitempty"` + RegulatoryDomain string `json:"regulatory_domain,omitempty" xml:"RegulatoryDomain,omitempty"` + NoticeTypeCode string `json:"notice_type_code" xml:"NoticeTypeCode"` + NoticeLanguageCode string `json:"notice_language_code" xml:"NoticeLanguageCode"` + UBLExtensions eform.UBLExtensions `json:"ubl_extensions,omitempty" xml:"UBLExtensions,omitempty"` + AdditionalNoticeLanguage []eform.AdditionalNoticeLanguage `json:"additional_notice_language,omitempty" xml:"AdditionalNoticeLanguage,omitempty"` + SenderParty eform.SenderParty `json:"sender_party,omitempty" xml:"SenderParty,omitempty"` + BusinessParty eform.BusinessParty `json:"business_party" xml:"BusinessParty"` + AdditionalDocumentReference []eform.AdditionalDocumentReference `json:"additional_document_reference,omitempty" xml:"AdditionalDocumentReference,omitempty"` + BusinessCapability []eform.BusinessCapability `json:"business_capability,omitempty" xml:"BusinessCapability,omitempty"` + NoticePurpose eform.NoticePurpose `json:"notice_purpose,omitempty" xml:"NoticePurpose,omitempty"` +} + +// ===================================================== +// BUSINESS LOGIC HELPER METHODS +// ===================================================== + +// GetMainBuyer returns the lead/main buyer organization +func (n *ContractNotice) GetMainBuyer() *eform.Organization { + if len(n.UBLExtensions.UBLExtension) > 0 { + orgs := n.UBLExtensions.UBLExtension[0].ExtensionContent.EformsExtension.Organizations.Organization + for i := range orgs { + for _, role := range orgs[i].Roles { + if role == eform.RoleBuyer || role == eform.RoleLeadBuyer { + return &orgs[i] + } + } + } + } + return nil +} + +// GetOrganizationByID retrieves organization by ID +func (n *ContractNotice) GetOrganizationByID(id string) *eform.Organization { + if len(n.UBLExtensions.UBLExtension) > 0 { + orgs := n.UBLExtensions.UBLExtension[0].ExtensionContent.EformsExtension.Organizations.Organization + for i := range orgs { + if orgs[i].ID == id { + return &orgs[i] + } + } + } + return nil +} diff --git a/ted/eform/award_criteria.go b/ted/eform/award_criteria.go new file mode 100644 index 0000000..3b426f1 --- /dev/null +++ b/ted/eform/award_criteria.go @@ -0,0 +1,54 @@ +package eform + +// ===================================================== +// AWARD CRITERIA +// ===================================================== + +// AwardCriteria defines how tenders will be evaluated +type AwardCriteria struct { + Type AwardCriteriaType `json:"type" xml:"type" validate:"required"` + PriceWeight *float64 `json:"price_weight,omitempty" xml:"price_weight,omitempty" validate:"omitempty,min=0,max=100"` + CriteriaStatement string `json:"criteria_statement,omitempty" xml:"criteria_statement,omitempty" validate:"max=10000"` + Criteria []AwardCriterion `json:"criteria,omitempty" xml:"criteria,omitempty" validate:"dive"` + CostCalculation string `json:"cost_calculation,omitempty" xml:"cost_calculation,omitempty" validate:"max=10000"` +} + +// AwardCriteriaType represents the evaluation approach +type AwardCriteriaType string + +const ( + AwardTypePrice AwardCriteriaType = "price" + AwardTypeCost AwardCriteriaType = "cost" + AwardTypeQualPrice AwardCriteriaType = "qual-price" +) + +// AwardCriterion represents an individual evaluation criterion +type AwardCriterion struct { + ID string `json:"id" xml:"id" validate:"required,acIDFormat"` + Name string `json:"name" xml:"name" validate:"required,max=200"` + Description string `json:"description,omitempty" xml:"description,omitempty" validate:"max=2000"` + Weight float64 `json:"weight" xml:"weight" validate:"required,min=0,max=100"` + Type AwardCriterionType `json:"type" xml:"type" validate:"required"` + SubCriteria []AwardCriterion `json:"sub_criteria,omitempty" xml:"sub_criteria,omitempty" validate:"dive"` +} + +// AwardCriterionType represents the type of criterion +type AwardCriterionType string + +const ( + CriterionQuality AwardCriterionType = "quality" + CriterionCost AwardCriterionType = "cost" + CriterionPrice AwardCriterionType = "price" + CriterionSocial AwardCriterionType = "social" + CriterionEnvironmental AwardCriterionType = "environmental" + CriterionInnovative AwardCriterionType = "innovative" +) + +// CalculateTotalAwardCriteriaWeight validates criteria weights sum to 100 +func (ac *AwardCriteria) CalculateTotalAwardCriteriaWeight() float64 { + var total float64 + for _, criterion := range ac.Criteria { + total += criterion.Weight + } + return total +} diff --git a/ted/eform/business_registration.go b/ted/eform/business_registration.go new file mode 100644 index 0000000..da5318c --- /dev/null +++ b/ted/eform/business_registration.go @@ -0,0 +1,77 @@ +package eform + +import "encoding/xml" + +// ===================================================== +// BUSINESS REGISTRATION INFORMATION NOTICE +// ===================================================== + +// AdditionalNoticeLanguage represents additional notice language +type AdditionalNoticeLanguage struct { + XMLName xml.Name `xml:"AdditionalNoticeLanguage"` + ID string `json:"id" xml:"ID"` +} + +// SenderParty represents the sender party +type SenderParty struct { + XMLName xml.Name `xml:"SenderParty"` + Contact Contact `json:"contact,omitempty" xml:"Contact,omitempty"` +} + +// BusinessParty represents the business party +type BusinessParty struct { + XMLName xml.Name `xml:"BusinessParty"` + PostalAddress PostalAddress `json:"postal_address,omitempty" xml:"PostalAddress,omitempty"` + PartyLegalEntity []PartyLegalEntityEnhanced `json:"party_legal_entity,omitempty" xml:"PartyLegalEntity,omitempty"` + Contact Contact `json:"contact,omitempty" xml:"Contact,omitempty"` + WebsiteURI string `json:"website_uri,omitempty" xml:"WebsiteURI,omitempty"` +} + +// AdditionalDocumentReference represents additional document reference +type AdditionalDocumentReference struct { + XMLName xml.Name `xml:"AdditionalDocumentReference"` + ID string `json:"id" xml:"ID"` + IssueDate string `json:"issue_date,omitempty" xml:"IssueDate,omitempty"` + ReferencedDocumentInternalAddress string `json:"referenced_document_internal_address,omitempty" xml:"ReferencedDocumentInternalAddress,omitempty"` + DocumentDescription string `json:"document_description,omitempty" xml:"DocumentDescription,omitempty"` + Attachment Attachment `json:"attachment,omitempty" xml:"Attachment,omitempty"` +} + +// BusinessCapability represents business capability +type BusinessCapability struct { + XMLName xml.Name `xml:"BusinessCapability"` + CapabilityTypeCode string `json:"capability_type_code" xml:"CapabilityTypeCode"` +} + +// NoticePurpose represents notice purpose +type NoticePurpose struct { + XMLName xml.Name `xml:"NoticePurpose"` + PurposeCode string `json:"purpose_code" xml:"PurposeCode"` +} + +// ===================================================== +// ENHANCED PARTY LEGAL ENTITY FOR BUSINESS REGISTRATION +// ===================================================== + +// PartyLegalEntityEnhanced represents enhanced party legal entity for business registration +type PartyLegalEntityEnhanced struct { + XMLName xml.Name `xml:"PartyLegalEntity"` + RegistrationName string `json:"registration_name,omitempty" xml:"RegistrationName,omitempty"` + CompanyID string `json:"company_id,omitempty" xml:"CompanyID,omitempty"` + RegistrationDate string `json:"registration_date,omitempty" xml:"RegistrationDate,omitempty"` + CorporateRegistrationScheme CorporateRegistrationScheme `json:"corporate_registration_scheme,omitempty" xml:"CorporateRegistrationScheme,omitempty"` +} + +// CorporateRegistrationScheme represents corporate registration scheme +type CorporateRegistrationScheme struct { + XMLName xml.Name `xml:"CorporateRegistrationScheme"` + JurisdictionRegionAddress JurisdictionRegionAddress `json:"jurisdiction_region_address,omitempty" xml:"JurisdictionRegionAddress,omitempty"` +} + +// JurisdictionRegionAddress represents jurisdiction region address +type JurisdictionRegionAddress struct { + XMLName xml.Name `xml:"JurisdictionRegionAddress"` + CityName string `json:"city_name,omitempty" xml:"CityName,omitempty"` + PostalZone string `json:"postal_zone,omitempty" xml:"PostalZone,omitempty"` + Country Country `json:"country,omitempty" xml:"Country,omitempty"` +} diff --git a/ted/eform/constant.go b/ted/eform/constant.go new file mode 100644 index 0000000..b830bad --- /dev/null +++ b/ted/eform/constant.go @@ -0,0 +1,96 @@ +package eform + +// ===================================================== +// ENUMERATIONS AND CONSTANTS +// ===================================================== + +// Common CPV Code Prefixes (for validation) +const ( + CPVWorks = "45" // Construction works + CPVServices = "50" // Services (general range 50-99 with exceptions) +) + +// Common NUTS Code Structure +// NUTS codes are hierarchical: CC1234 +// CC = Country code (2 chars) +// 1 = NUTS 1 level +// 12 = NUTS 2 level +// 123 = NUTS 3 level + +// ISO 4217 Currency Codes (common ones) +const ( + CurrencyEUR = "EUR" // Euro + CurrencyUSD = "USD" // US Dollar + CurrencyGBP = "GBP" // British Pound + CurrencySEK = "SEK" // Swedish Krona + CurrencyDKK = "DKK" // Danish Krone + CurrencyPLN = "PLN" // Polish Zloty + CurrencyCZK = "CZK" // Czech Koruna + CurrencyHUF = "HUF" // Hungarian Forint + CurrencyRON = "RON" // Romanian Leu + CurrencyBGN = "BGN" // Bulgarian Lev + CurrencyHRK = "HRK" // Croatian Kuna +) + +// ISO 3166-1 Alpha-2 Country Codes (EU/EEA) +const ( + CountryAT = "AT" // Austria + CountryBE = "BE" // Belgium + CountryBG = "BG" // Bulgaria + CountryCY = "CY" // Cyprus + CountryCZ = "CZ" // Czech Republic + CountryDE = "DE" // Germany + CountryDK = "DK" // Denmark + CountryEE = "EE" // Estonia + CountryES = "ES" // Spain + CountryFI = "FI" // Finland + CountryFR = "FR" // France + CountryGR = "GR" // Greece + CountryHR = "HR" // Croatia + CountryHU = "HU" // Hungary + CountryIE = "IE" // Ireland + CountryIT = "IT" // Italy + CountryLT = "LT" // Lithuania + CountryLU = "LU" // Luxembourg + CountryLV = "LV" // Latvia + CountryMT = "MT" // Malta + CountryNL = "NL" // Netherlands + CountryPL = "PL" // Poland + CountryPT = "PT" // Portugal + CountryRO = "RO" // Romania + CountrySE = "SE" // Sweden + CountrySI = "SI" // Slovenia + CountrySK = "SK" // Slovakia + CountryIS = "IS" // Iceland (EEA) + CountryLI = "LI" // Liechtenstein (EEA) + CountryNO = "NO" // Norway (EEA) + CountryGB = "GB" // United Kingdom +) + +// ISO 639-1 Language Codes (EU official) +const ( + LangBG = "BG" // Bulgarian + LangCS = "CS" // Czech + LangDA = "DA" // Danish + LangDE = "DE" // German + LangEL = "EL" // Greek + LangEN = "EN" // English + LangES = "ES" // Spanish + LangET = "ET" // Estonian + LangFI = "FI" // Finnish + LangFR = "FR" // French + LangGA = "GA" // Irish + LangHR = "HR" // Croatian + LangHU = "HU" // Hungarian + LangIT = "IT" // Italian + LangLT = "LT" // Lithuanian + LangLV = "LV" // Latvian + LangMT = "MT" // Maltese + LangNL = "NL" // Dutch + LangPL = "PL" // Polish + LangPT = "PT" // Portuguese + LangRO = "RO" // Romanian + LangSK = "SK" // Slovak + LangSL = "SL" // Slovenian + LangSV = "SV" // Swedish +) diff --git a/ted/eform/contracting_party.go b/ted/eform/contracting_party.go new file mode 100644 index 0000000..a074147 --- /dev/null +++ b/ted/eform/contracting_party.go @@ -0,0 +1,82 @@ +package eform + +import "encoding/xml" + +// ===================================================== +// CONTRACTING PARTY +// ===================================================== + +// ContractingParty represents the contracting party +type ContractingParty struct { + XMLName xml.Name `xml:"ContractingParty"` + BuyerProfileURI string `xml:"BuyerProfileURI,omitempty"` + ContractingPartyType ContractingPartyType `xml:"ContractingPartyType,omitempty"` + ContractingActivity ContractingActivity `xml:"ContractingActivity,omitempty"` + Party Party `xml:"Party,omitempty"` +} + +// ContractingPartyType contains party type information +type ContractingPartyType struct { + XMLName xml.Name `xml:"ContractingPartyType"` + PartyTypeCode string `xml:"PartyTypeCode"` +} + +// ContractingActivity represents the contracting activity +type ContractingActivity struct { + XMLName xml.Name `xml:"ContractingActivity"` + ActivityTypeCode string `xml:"ActivityTypeCode"` +} + +// Party represents a party +type Party struct { + XMLName xml.Name `xml:"Party"` + PartyIdentification []PartyIdentification `xml:"PartyIdentification,omitempty"` + PartyName []PartyName `xml:"PartyName,omitempty"` + PostalAddress PostalAddress `xml:"PostalAddress,omitempty"` + PartyLegalEntity PartyLegalEntity `xml:"PartyLegalEntity,omitempty"` + Contact Contact `xml:"Contact,omitempty"` + WebsiteURI string `xml:"WebsiteURI,omitempty"` +} + +// PartyIdentification represents party identification +type PartyIdentification struct { + XMLName xml.Name `xml:"PartyIdentification"` + ID string `xml:"ID"` +} + +// PartyName represents party name +type PartyName struct { + XMLName xml.Name `xml:"PartyName"` + Name string `xml:"Name"` +} + +// PostalAddress represents postal address +type PostalAddress struct { + XMLName xml.Name `xml:"PostalAddress"` + StreetName string `xml:"StreetName,omitempty"` + CityName string `xml:"CityName,omitempty"` + PostalZone string `xml:"PostalZone,omitempty"` + CountrySubentityCode string `xml:"CountrySubentityCode,omitempty"` + Country Country `xml:"Country,omitempty"` +} + +// Country represents country information +type Country struct { + XMLName xml.Name `xml:"Country"` + IdentificationCode string `xml:"IdentificationCode"` +} + +// PartyLegalEntity represents party legal entity +type PartyLegalEntity struct { + XMLName xml.Name `xml:"PartyLegalEntity"` + CompanyID string `xml:"CompanyID"` +} + +// Contact represents contact information +type Contact struct { + XMLName xml.Name `xml:"Contact"` + Name string `xml:"Name,omitempty"` + Telephone string `xml:"Telephone,omitempty"` + Telefax string `xml:"Telefax,omitempty"` + ElectronicMail string `xml:"ElectronicMail,omitempty"` +} diff --git a/ted/eform/document.go b/ted/eform/document.go new file mode 100644 index 0000000..e04b30b --- /dev/null +++ b/ted/eform/document.go @@ -0,0 +1,31 @@ +package eform + +// ===================================================== +// DOCUMENT STRUCTURE +// ===================================================== + +// Document represents attached documentation +type Document struct { + ID string `json:"id" xml:"id" validate:"required"` + Type DocumentType `json:"type" xml:"type" validate:"required"` + Title string `json:"title" xml:"title" validate:"required,max=500"` + Description string `json:"description,omitempty" xml:"description,omitempty" validate:"max=2000"` + URL string `json:"url,omitempty" xml:"url,omitempty" validate:"omitempty,url"` + RestrictedAccess bool `json:"restricted_access" xml:"restricted_access"` + Language LanguageCode `json:"language" xml:"language" validate:"required,iso639_1"` +} + +// DocumentType represents the type of document +type DocumentType string + +const ( + DocTypeProcurementDocs DocumentType = "procurement-docs" + DocTypeAdditionalInfo DocumentType = "additional-info" + DocTypeTenderSpecs DocumentType = "tender-specs" + DocTypeTenderForm DocumentType = "tender-form" + DocTypeContractDraft DocumentType = "contract-draft" + DocTypeEvaluationCriteria DocumentType = "evaluation-criteria" + DocTypeBillsQuantities DocumentType = "bills-quantities" + DocTypeIllustrations DocumentType = "illustrations" + DocTypeOther DocumentType = "other" +) diff --git a/ted/eform/lot.go b/ted/eform/lot.go new file mode 100644 index 0000000..0815989 --- /dev/null +++ b/ted/eform/lot.go @@ -0,0 +1,62 @@ +package eform + +// ===================================================== +// LOT STRUCTURE +// ===================================================== + +// Lot represents an individual procurement lot +type Lot struct { + ID string `json:"id" xml:"id" validate:"required,lotIDFormat"` + InternalID string `json:"internal_id,omitempty" xml:"internal_id,omitempty" validate:"max=100"` + Title string `json:"title" xml:"title" validate:"required,max=500"` + Description string `json:"description" xml:"description" validate:"required,max=10000"` + MainCPVCode string `json:"main_cpv_code" xml:"main_cpv_code" validate:"required,cpvCode"` + AdditionalCPVCodes []string `json:"additional_cpv_codes,omitempty" xml:"additional_cpv_codes,omitempty" validate:"dive,cpvCode"` + ContractNature ContractNature `json:"contract_nature" xml:"contract_nature" validate:"required"` + EstimatedValue *Money `json:"estimated_value,omitempty" xml:"estimated_value,omitempty"` + PlacesOfPerformance []Place `json:"places_of_performance" xml:"places_of_performance" validate:"required,min=1,dive"` + Duration *Duration `json:"duration,omitempty" xml:"duration,omitempty"` + Renewable bool `json:"renewable" xml:"renewable"` + RenewalDescription string `json:"renewal_description,omitempty" xml:"renewal_description,omitempty" validate:"required_if=Renewable true"` + OptionsDescription string `json:"options_description,omitempty" xml:"options_description,omitempty" validate:"max=10000"` + VariantsAccepted bool `json:"variants_accepted" xml:"variants_accepted"` + MaxLotsPerTenderer *int `json:"max_lots_per_tenderer,omitempty" xml:"max_lots_per_tenderer,omitempty" validate:"omitempty,min=1"` + MaxLotsAwarded *int `json:"max_lots_awarded,omitempty" xml:"max_lots_awarded,omitempty" validate:"omitempty,min=1"` + LotCombinations []string `json:"lot_combinations,omitempty" xml:"lot_combinations,omitempty"` + SubcontractingObligation SubcontractingObligation `json:"subcontracting_obligation" xml:"subcontracting_obligation"` + SubcontractingPercentage *float64 `json:"subcontracting_percentage,omitempty" xml:"subcontracting_percentage,omitempty" validate:"omitempty,min=0,max=100"` + TechnicalSpecifications TechnicalSpecifications `json:"technical_specifications" xml:"technical_specifications" validate:"required"` + AwardCriteria AwardCriteria `json:"award_criteria" xml:"award_criteria" validate:"required"` + TenderingTerms TenderingTerms `json:"tendering_terms" xml:"tendering_terms" validate:"required"` + Result *LotResult `json:"result,omitempty" xml:"result,omitempty"` +} + +// SubcontractingObligation represents subcontracting requirements +type SubcontractingObligation string + +const ( + SubcontractingNone SubcontractingObligation = "none" + SubcontractingMinimum SubcontractingObligation = "minimum" + SubcontractingRequired SubcontractingObligation = "required" +) + +// Place represents a place of performance +type Place struct { + StreetAddress string `json:"street_address,omitempty" xml:"street_address,omitempty" validate:"max=200"` + Locality string `json:"locality,omitempty" xml:"locality,omitempty" validate:"max=100"` + PostalCode string `json:"postal_code,omitempty" xml:"postal_code,omitempty" validate:"max=20"` + CountrySubdivision string `json:"country_subdivision,omitempty" xml:"country_subdivision,omitempty" validate:"max=100"` + CountryCode string `json:"country_code" xml:"country_code" validate:"required,iso3166_1_alpha2"` + NUTSCodes []string `json:"nuts_codes" xml:"nuts_codes" validate:"required,min=1,dive,nutsCode"` + Description string `json:"description,omitempty" xml:"description,omitempty" validate:"max=1000"` +} + +// HasSubcontractingObligation checks if subcontracting is required +func (l *Lot) HasSubcontractingObligation() bool { + return l.SubcontractingObligation != SubcontractingNone +} + +// IsReservedContract checks if lot is reserved for specific participants +func (l *Lot) IsReservedContract() bool { + return l.TechnicalSpecifications.ReservedExecution != ReservedNone +} diff --git a/ted/eform/lot_result.go b/ted/eform/lot_result.go new file mode 100644 index 0000000..4717671 --- /dev/null +++ b/ted/eform/lot_result.go @@ -0,0 +1,54 @@ +package eform + +import "time" + +// ===================================================== +// LOT RESULT STRUCTURE +// ===================================================== + +// LotResult represents the outcome of lot procurement +type LotResult struct { + ID string `json:"id" xml:"id" validate:"required,resIDFormat"` + Status ResultStatus `json:"status" xml:"status" validate:"required"` + StatusDescription string `json:"status_description,omitempty" xml:"status_description,omitempty" validate:"max=10000"` + DecisionDate *time.Time `json:"decision_date,omitempty" xml:"decision_date,omitempty"` + TendersReceived int `json:"tenders_received" xml:"tenders_received" validate:"min=0"` + TendersSME int `json:"tenders_sme" xml:"tenders_sme" validate:"min=0"` + TendersElectronic int `json:"tenders_electronic" xml:"tenders_electronic" validate:"min=0"` + TendersOtherEU int `json:"tenders_other_eu" xml:"tenders_other_eu" validate:"min=0"` + TendersNonEU int `json:"tenders_non_eu" xml:"tenders_non_eu" validate:"min=0"` + AwardedValue *Money `json:"awarded_value,omitempty" xml:"awarded_value,omitempty"` + SubcontractingShare *float64 `json:"subcontracting_share,omitempty" xml:"subcontracting_share,omitempty" validate:"omitempty,min=0,max=100"` + SubcontractingValue *Money `json:"subcontracting_value,omitempty" xml:"subcontracting_value,omitempty"` + ConcessionRevenue *Money `json:"concession_revenue,omitempty" xml:"concession_revenue,omitempty"` + Tenders []Tender `json:"tenders,omitempty" xml:"tenders,omitempty" validate:"dive"` + SettledContract *SettledContract `json:"settled_contract,omitempty" xml:"settled_contract,omitempty"` + NonAwardJustification string `json:"non_award_justification,omitempty" xml:"non_award_justification,omitempty" validate:"max=10000"` +} + +// ResultStatus represents the lot result status +type ResultStatus string + +const ( + ResultOpen ResultStatus = "open" + ResultClose ResultStatus = "close" + ResultWinner ResultStatus = "winn" + ResultNoWinner ResultStatus = "no-winn" + ResultSelected ResultStatus = "selec" + ResultClosedNoWin ResultStatus = "clos-nw" +) + +// HasWinner checks if lot has a winning tender +func (lr *LotResult) HasWinner() bool { + return lr.Status == ResultWinner +} + +// GetWinningTender returns the winning tender if exists +func (lr *LotResult) GetWinningTender() *Tender { + for i := range lr.Tenders { + if lr.Tenders[i].IsWinner { + return &lr.Tenders[i] + } + } + return nil +} diff --git a/ted/eform/modification.go b/ted/eform/modification.go new file mode 100644 index 0000000..7dd6513 --- /dev/null +++ b/ted/eform/modification.go @@ -0,0 +1,53 @@ +package eform + +import "time" + +// ===================================================== +// MODIFICATION STRUCTURE +// ===================================================== + +// Modification represents changes to existing contracts +type Modification struct { + ID string `json:"id" xml:"id" validate:"required,modIDFormat"` + OriginalContractID string `json:"original_contract_id" xml:"original_contract_id" validate:"required,conIDFormat"` + Reason ModificationReason `json:"reason" xml:"reason" validate:"required"` + ReasonDescription string `json:"reason_description" xml:"reason_description" validate:"required,max=10000"` + ValueBefore Money `json:"value_before" xml:"value_before" validate:"required"` + ValueAfter Money `json:"value_after" xml:"value_after" validate:"required"` + ValueChange Money `json:"value_change" xml:"value_change"` + PercentageChange float64 `json:"percentage_change" xml:"percentage_change"` + DurationBefore *Duration `json:"duration_before,omitempty" xml:"duration_before,omitempty"` + DurationAfter *Duration `json:"duration_after,omitempty" xml:"duration_after,omitempty"` + ModificationDescription string `json:"modification_description" xml:"modification_description" validate:"required,max=10000"` + ModificationDate time.Time `json:"modification_date" xml:"modification_date" validate:"required"` + ContractorChange bool `json:"contractor_change" xml:"contractor_change"` + NewContractorIDs []string `json:"new_contractor_ids,omitempty" xml:"new_contractor_ids,omitempty"` +} + +// ModificationReason represents the reason for contract modification +type ModificationReason string + +const ( + ModReasonAdditionalWorks ModificationReason = "add-wks" + ModReasonUnforeseen ModificationReason = "unforeseen" + ModReasonDesignDefect ModificationReason = "design-defect" + ModReasonAdditionalNeed ModificationReason = "additional-need" + ModReasonRepeatSimilar ModificationReason = "repeat-similar" + ModReasonRatePriceUpdate ModificationReason = "rate-price-update" + ModReasonSuspendCall ModificationReason = "suspend-call" + ModReasonOther ModificationReason = "other" +) + +// CalculateModificationChange calculates value change for modifications +func (m *Modification) CalculateModificationChange() { + if m.ValueBefore.Currency == m.ValueAfter.Currency { + change := m.ValueAfter.Amount - m.ValueBefore.Amount + m.ValueChange = Money{ + Amount: change, + Currency: m.ValueBefore.Currency, + } + if m.ValueBefore.Amount != 0 { + m.PercentageChange = (change / m.ValueBefore.Amount) * 100 + } + } +} diff --git a/ted/eform/notice_change.go b/ted/eform/notice_change.go new file mode 100644 index 0000000..f8d2277 --- /dev/null +++ b/ted/eform/notice_change.go @@ -0,0 +1,16 @@ +package eform + +// ===================================================== +// CHANGE/CORRECTION STRUCTURE +// ===================================================== + +// NoticeChange represents changes or corrections to notices +type NoticeChange struct { + ChangeID string `json:"change_id" xml:"change_id" validate:"required"` + SectionReference string `json:"section_reference" xml:"section_reference" validate:"required"` + LotReference string `json:"lot_reference,omitempty" xml:"lot_reference,omitempty"` + Label string `json:"label" xml:"label" validate:"required,max=200"` + OldValue string `json:"old_value,omitempty" xml:"old_value,omitempty"` + NewValue string `json:"new_value" xml:"new_value" validate:"required"` + ChangeDescription string `json:"change_description,omitempty" xml:"change_description,omitempty" validate:"max=2000"` +} diff --git a/ted/eform/notice_metadata.go b/ted/eform/notice_metadata.go new file mode 100644 index 0000000..34107c4 --- /dev/null +++ b/ted/eform/notice_metadata.go @@ -0,0 +1,144 @@ +package eform + +import "time" + +// ===================================================== +// NOTICE METADATA (Legacy - keeping for compatibility) +// ===================================================== + +// NoticeMetadata contains identification and publication information +type NoticeMetadata struct { + NoticeID string `json:"notice_id" xml:"notice_id" validate:"required,noticeIDFormat"` + NoticeType NoticeType `json:"notice_type" xml:"notice_type" validate:"required"` + NoticeVersion string `json:"notice_version" xml:"notice_version" validate:"required,semver"` + PublicationDate *time.Time `json:"publication_date,omitempty" xml:"publication_date,omitempty"` + DispatchDate time.Time `json:"dispatch_date" xml:"dispatch_date" validate:"required"` + Language LanguageCode `json:"language" xml:"language" validate:"required,iso639_1"` + TranslationLanguages []LanguageCode `json:"translation_languages,omitempty" xml:"translation_languages,omitempty" validate:"dive,iso639_1"` + LegalBasis LegalBasis `json:"legal_basis" xml:"legal_basis" validate:"required"` + PreviousNoticeID string `json:"previous_notice_id,omitempty" xml:"previous_notice_id,omitempty" validate:"omitempty,noticeIDFormat"` + ChangedNoticeID string `json:"changed_notice_id,omitempty" xml:"changed_notice_id,omitempty" validate:"omitempty,noticeIDFormat"` + CorrectionPrevious bool `json:"correction_previous" xml:"correction_previous"` +} + +// NoticeType represents the eForms subtype identifier (1-40, E1-E5) +type NoticeType string + +const ( + // Planning notices (1-6) + NoticeTypePIN1 NoticeType = "1" // Prior Information Notice - standard + NoticeTypePIN2 NoticeType = "2" // Prior Information Notice - utilities + NoticeTypePIN3 NoticeType = "3" // Prior Information Notice - social + NoticeTypePINCallComp4 NoticeType = "4" // PIN as call for competition - standard + NoticeTypePINCallComp5 NoticeType = "5" // PIN as call for competition - utilities + NoticeTypePINCallComp6 NoticeType = "6" // PIN as call for competition - social + + // Competition notices (7-14) + NoticeTypeCN7 NoticeType = "7" // Contract Notice - open/restricted + NoticeTypeCN8 NoticeType = "8" // Contract Notice - utilities + NoticeTypeCN9 NoticeType = "9" // Contract Notice - negotiated + NoticeTypeCN10 NoticeType = "10" // Contract Notice - competitive dialogue + NoticeTypeCN11 NoticeType = "11" // Contract Notice - innovation partnership + NoticeTypeCN12 NoticeType = "12" // Contract Notice - social services + NoticeTypeCN13 NoticeType = "13" // Contract Notice - concessions + NoticeTypeCN14 NoticeType = "14" // Contract Notice - design contest + + // Direct award notices (15-24) + NoticeTypeCANDirect15 NoticeType = "15" // CAN without prior - direct award + NoticeTypeCANDirect16 NoticeType = "16" // CAN without prior - utilities + NoticeTypeCANDirect17 NoticeType = "17" // CAN without prior - social + NoticeTypeCANDirect18 NoticeType = "18" // CAN without prior - negotiated + NoticeTypeCANDirect19 NoticeType = "19" // CAN without prior - concessions + NoticeTypeCANDirect20 NoticeType = "20" // Voluntary ex-ante transparency - direct award + NoticeTypeCANDirect21 NoticeType = "21" // Voluntary ex-ante transparency - utilities + NoticeTypeCANDirect22 NoticeType = "22" // Voluntary ex-ante transparency - social + NoticeTypeCANDirect23 NoticeType = "23" // Voluntary ex-ante transparency - concessions + NoticeTypeCANDirect24 NoticeType = "24" // Voluntary ex-ante transparency - defence + + // Result notices (25-35) + NoticeTypeCAN25 NoticeType = "25" // Contract Award Notice - standard + NoticeTypeCAN26 NoticeType = "26" // Contract Award Notice - utilities + NoticeTypeCAN27 NoticeType = "27" // Contract Award Notice - social + NoticeTypeCAN28 NoticeType = "28" // Contract Award Notice - concessions + NoticeTypeCAN29 NoticeType = "29" // Contract Award Notice - design contest results + NoticeTypeCAN30 NoticeType = "30" // Contract Award Notice - defence + NoticeTypeCAN31 NoticeType = "31" // Contract Award Notice - framework single + NoticeTypeCAN32 NoticeType = "32" // Contract Award Notice - DPS award + NoticeTypeCAN33 NoticeType = "33" // Contract Award Notice - modification + NoticeTypeCAN34 NoticeType = "34" // Contract Award Notice - completion + NoticeTypeCAN35 NoticeType = "35" // Contract Award Notice - quarterly DPS + + // Modification notices (36-40) + NoticeTypeMod36 NoticeType = "36" // Contract Modification Notice - standard + NoticeTypeMod37 NoticeType = "37" // Contract Modification Notice - utilities + NoticeTypeMod38 NoticeType = "38" // Contract Modification Notice - social + NoticeTypeMod39 NoticeType = "39" // Contract Modification Notice - concessions + NoticeTypeMod40 NoticeType = "40" // Contract Modification Notice - defence + + // Voluntary notices (E1-E5) + NoticeTypeVoluntaryE1 NoticeType = "E1" // Voluntary notice - below threshold + NoticeTypeVoluntaryE2 NoticeType = "E2" // Voluntary notice - modification + NoticeTypeVoluntaryE3 NoticeType = "E3" // Voluntary notice - contract completion + NoticeTypeVoluntaryE4 NoticeType = "E4" // Voluntary notice - concession award + NoticeTypeVoluntaryE5 NoticeType = "E5" // Voluntary notice - qualification system +) + +// LegalBasis represents applicable EU directives +type LegalBasis string + +const ( + LegalBasisClassicDir LegalBasis = "32014L0024" // Directive 2014/24/EU (classic) + LegalBasisUtilitiesDir LegalBasis = "32014L0025" // Directive 2014/25/EU (utilities) + LegalBasisConcessionsDir LegalBasis = "32014L0023" // Directive 2014/23/EU (concessions) + LegalBasisDefenceDir LegalBasis = "32009L0081" // Directive 2009/81/EC (defence) + LegalBasisFinancialReg LegalBasis = "32018R1046" // Financial Regulation (EU institutions) +) + +// LanguageCode represents ISO 639-1 language codes +type LanguageCode string + +// IsPlanningNotice checks if notice is a planning/PIN notice +func (nt NoticeType) IsPlanningNotice() bool { + planningTypes := []NoticeType{ + NoticeTypePIN1, NoticeTypePIN2, NoticeTypePIN3, + NoticeTypePINCallComp4, NoticeTypePINCallComp5, NoticeTypePINCallComp6, + } + for _, t := range planningTypes { + if nt == t { + return true + } + } + return false +} + +// IsCompetitionNotice checks if notice is a competition/CN notice +func (nt NoticeType) IsCompetitionNotice() bool { + return nt >= NoticeTypeCN7 && nt <= NoticeTypeCN14 +} + +// IsResultNotice checks if notice is a result/CAN notice +func (nt NoticeType) IsResultNotice() bool { + return (nt >= NoticeTypeCANDirect15 && nt <= NoticeTypeCANDirect24) || + (nt >= NoticeTypeCAN25 && nt <= NoticeTypeCAN35) +} + +// IsModificationNotice checks if notice is a modification notice +func (nt NoticeType) IsModificationNotice() bool { + return nt >= NoticeTypeMod36 && nt <= NoticeTypeMod40 +} + +// IsVoluntaryNotice checks if notice is voluntary +func (nt NoticeType) IsVoluntaryNotice() bool { + voluntaryTypes := []NoticeType{ + NoticeTypeVoluntaryE1, NoticeTypeVoluntaryE2, NoticeTypeVoluntaryE3, + NoticeTypeVoluntaryE4, NoticeTypeVoluntaryE5, + NoticeTypeCANDirect20, NoticeTypeCANDirect21, NoticeTypeCANDirect22, + NoticeTypeCANDirect23, NoticeTypeCANDirect24, + } + for _, t := range voluntaryTypes { + if nt == t { + return true + } + } + return false +} diff --git a/ted/eform/organization.go b/ted/eform/organization.go new file mode 100644 index 0000000..c50856e --- /dev/null +++ b/ted/eform/organization.go @@ -0,0 +1,97 @@ +package eform + +// ===================================================== +// ORGANIZATION STRUCTURE +// ===================================================== + +// Organization represents any party involved in the procurement +type Organization struct { + ID string `json:"id" xml:"id" validate:"required,orgIDFormat"` + Roles []OrganizationRole `json:"roles" xml:"roles" validate:"required,min=1,dive"` + LegalName string `json:"legal_name" xml:"legal_name" validate:"required,max=500"` + LegalType *OrganizationType `json:"legal_type,omitempty" xml:"legal_type,omitempty"` + RegistrationID string `json:"registration_id,omitempty" xml:"registration_id,omitempty"` + VATNumber string `json:"vat_number,omitempty" xml:"vat_number,omitempty" validate:"omitempty,vatFormat"` + NationalID string `json:"national_id,omitempty" xml:"national_id,omitempty"` + Address Address `json:"address" xml:"address" validate:"required"` + ContactPoint ContactPoint `json:"contact_point" xml:"contact_point" validate:"required"` + BuyerProfile string `json:"buyer_profile,omitempty" xml:"buyer_profile,omitempty" validate:"omitempty,url"` + ProcurementActivity *ActivityType `json:"procurement_activity,omitempty" xml:"procurement_activity,omitempty"` + SME bool `json:"sme" xml:"sme"` +} + +// OrganizationRole defines the role of an organization in the procurement +type OrganizationRole string + +const ( + RoleBuyer OrganizationRole = "buyer" + RoleServiceProvider OrganizationRole = "service-provider" + RoleTEDeSender OrganizationRole = "ted-esen" + RoleWinner OrganizationRole = "winner" + RoleSubcontractor OrganizationRole = "subcontractor" + RoleReviewBody OrganizationRole = "review-body" + RoleReviewOrg OrganizationRole = "review-org" + RoleMediationBody OrganizationRole = "mediation-body" + RoleLeadBuyer OrganizationRole = "lead-buyer" + RoleCentralBuyer OrganizationRole = "central-buyer" + RoleAcquirer OrganizationRole = "acquirer" +) + +// OrganizationType represents the legal type of organization +type OrganizationType string + +const ( + OrgTypeBodyPublicLaw OrganizationType = "body-pl" + OrgTypeCentralGov OrganizationType = "cga" + OrgTypeLocalAuthority OrganizationType = "la" + OrgTypeSubsidized OrganizationType = "org-sub" + OrgTypePublicUndertaking OrganizationType = "pub-undert" + OrgTypeEUInstitution OrganizationType = "eu-ins-bod-ag" + OrgTypeOther OrganizationType = "org-other" +) + +// ActivityType represents main procurement activity +type ActivityType string + +const ( + ActivityAuthority ActivityType = "auth-oth" + ActivityDefence ActivityType = "def" + ActivityEconomicAffairs ActivityType = "econ-aff" + ActivityEducation ActivityType = "edu" + ActivityEnvironment ActivityType = "env" + ActivityGeneralPublic ActivityType = "gen-pub" + ActivityHealth ActivityType = "health" + ActivityHousingCommunity ActivityType = "hous-com" + ActivityPublicOrderSafety ActivityType = "pub-os" + ActivityRecreationCulture ActivityType = "rec-com" + ActivitySocialProtection ActivityType = "soc-pro" + ActivityWater ActivityType = "water" + ActivityAirport ActivityType = "airport" + ActivityElectricity ActivityType = "electricity" + ActivityExplorationExtract ActivityType = "exploration-extraction" + ActivityGasHeat ActivityType = "gas-heat" + ActivityPort ActivityType = "port" + ActivityPost ActivityType = "post" + ActivityRailway ActivityType = "rail" + ActivityUrbanRailway ActivityType = "urban-railway" + ActivityWaterUtility ActivityType = "water" +) + +// Address represents physical address information +type Address struct { + StreetAddress string `json:"street_address" xml:"street_address" validate:"required,max=200"` + Locality string `json:"locality" xml:"locality" validate:"required,max=100"` + PostalCode string `json:"postal_code" xml:"postal_code" validate:"required,max=20"` + CountrySubdivision string `json:"country_subdivision,omitempty" xml:"country_subdivision,omitempty" validate:"max=100"` + CountryCode string `json:"country_code" xml:"country_code" validate:"required,iso3166_1_alpha2"` + NUTSCodes []string `json:"nuts_codes,omitempty" xml:"nuts_codes,omitempty" validate:"dive,nutsCode"` +} + +// ContactPoint represents contact information +type ContactPoint struct { + Name string `json:"name,omitempty" xml:"name,omitempty" validate:"max=200"` + Email string `json:"email" xml:"email" validate:"required,email"` + Telephone string `json:"telephone,omitempty" xml:"telephone,omitempty" validate:"max=30"` + Fax string `json:"fax,omitempty" xml:"fax,omitempty" validate:"max=30"` + URL string `json:"url,omitempty" xml:"url,omitempty" validate:"omitempty,url"` +} diff --git a/ted/eform/procedure.go b/ted/eform/procedure.go new file mode 100644 index 0000000..b03d306 --- /dev/null +++ b/ted/eform/procedure.go @@ -0,0 +1,244 @@ +package eform + +// ===================================================== +// PROCEDURE STRUCTURE +// ===================================================== + +// Procedure represents the procurement procedure +type Procedure struct { + ID string `json:"id" xml:"id" validate:"required,procIDFormat"` + Title string `json:"title" xml:"title" validate:"required,max=500"` + Description string `json:"description" xml:"description" validate:"required,max=10000"` + ProcedureType ProcedureType `json:"procedure_type" xml:"procedure_type" validate:"required"` + AcceleratedProcedure bool `json:"accelerated_procedure" xml:"accelerated_procedure"` + AcceleratedJustification string `json:"accelerated_justification,omitempty" xml:"accelerated_justification,omitempty" validate:"required_if=AcceleratedProcedure true"` + FrameworkAgreement bool `json:"framework_agreement" xml:"framework_agreement"` + FAMaxParticipants *int `json:"fa_max_participants,omitempty" xml:"fa_max_participants,omitempty" validate:"omitempty,min=1"` + FADuration *Duration `json:"fa_duration,omitempty" xml:"fa_duration,omitempty"` + DynamicPurchasingSystem bool `json:"dynamic_purchasing_system" xml:"dynamic_purchasing_system"` + ElectronicAuction bool `json:"electronic_auction" xml:"electronic_auction"` + JointProcurement bool `json:"joint_procurement" xml:"joint_procurement"` + CentralPurchasing bool `json:"central_purchasing" xml:"central_purchasing"` + GPA bool `json:"gpa" xml:"gpa"` + InternationalAgreements []string `json:"international_agreements,omitempty" xml:"international_agreements,omitempty"` + MainCPVCode string `json:"main_cpv_code" xml:"main_cpv_code" validate:"required,cpvCode"` + AdditionalCPVCodes []string `json:"additional_cpv_codes,omitempty" xml:"additional_cpv_codes,omitempty" validate:"dive,cpvCode"` + ContractNature ContractNature `json:"contract_nature" xml:"contract_nature" validate:"required"` + EstimatedValue *Money `json:"estimated_value,omitempty" xml:"estimated_value,omitempty"` + EstimatedValueRange *ValueRange `json:"estimated_value_range,omitempty" xml:"estimated_value_range,omitempty"` + ProcurementDocumentsURL string `json:"procurement_documents_url,omitempty" xml:"procurement_documents_url,omitempty" validate:"omitempty,url"` + ProcurementDocsRestricted bool `json:"procurement_docs_restricted" xml:"procurement_docs_restricted"` + SubmissionElectronic SubmissionMethod `json:"submission_electronic" xml:"submission_electronic" validate:"required"` + SubmissionLanguages []LanguageCode `json:"submission_languages" xml:"submission_languages" validate:"required,min=1,dive,iso639_1"` + TenderValidityDuration *Duration `json:"tender_validity_duration,omitempty" xml:"tender_validity_duration,omitempty"` + Lots []Lot `json:"lots,omitempty" xml:"lots,omitempty" validate:"dive"` + SecondStage *SecondStage `json:"second_stage,omitempty" xml:"second_stage,omitempty"` + Recurrence bool `json:"recurrence" xml:"recurrence"` + RecurrenceTiming string `json:"recurrence_timing,omitempty" xml:"recurrence_timing,omitempty" validate:"max=1000"` +} + +// ProcedureType represents the type of procurement procedure +type ProcedureType string + +const ( + ProcTypeOpen ProcedureType = "open" + ProcTypeRestricted ProcedureType = "restricted" + ProcTypeCompetitiveDialogue ProcedureType = "comp-dial" + ProcTypeInnovation ProcedureType = "innovation" + ProcTypeNegotiatedWithCall ProcedureType = "neg-w-call" + ProcTypeNegotiatedWoCall ProcedureType = "neg-wo-call" + ProcTypeCompWithNeg ProcedureType = "comp-w-n" + ProcTypeAwardWoCall ProcedureType = "pt-award-wo-call" + ProcTypeOtherSingle ProcedureType = "oth-single" + ProcTypeOtherMultiple ProcedureType = "oth-mult" +) + +// ContractNature represents the type of contract +type ContractNature string + +const ( + ContractNatureWorks ContractNature = "works" + ContractNatureSupplies ContractNature = "supplies" + ContractNatureServices ContractNature = "services" + ContractNatureCombined ContractNature = "combined" +) + +// SubmissionMethod represents how tenders can be submitted +type SubmissionMethod string + +const ( + SubmissionAllowed SubmissionMethod = "allowed" + SubmissionRequired SubmissionMethod = "required" + SubmissionNotAllowed SubmissionMethod = "not-allowed" +) + +// Duration represents a time duration +type Duration struct { + Type DurationType `json:"type" xml:"type" validate:"required"` + Value *int `json:"value,omitempty" xml:"value,omitempty" validate:"omitempty,min=1"` +} + +// DurationType represents the unit of duration +type DurationType string + +const ( + DurationMonths DurationType = "months" + DurationDays DurationType = "days" + DurationYears DurationType = "years" + DurationUnlimited DurationType = "unlimited" +) + +// Money represents a monetary value +type Money struct { + Amount float64 `json:"amount" xml:"amount" validate:"required,min=0"` + Currency string `json:"currency" xml:"currency" validate:"required,iso4217"` +} + +// ValueRange represents a range of monetary values +type ValueRange struct { + MinValue Money `json:"min_value" xml:"min_value" validate:"required"` + MaxValue Money `json:"max_value" xml:"max_value" validate:"required,gtfield=MinValue.Amount"` +} + +// SecondStage represents second stage of two-stage procedures +type SecondStage struct { + MinCandidates *int `json:"min_candidates,omitempty" xml:"min_candidates,omitempty" validate:"omitempty,min=1"` + MaxCandidates *int `json:"max_candidates,omitempty" xml:"max_candidates,omitempty" validate:"omitempty,min=1"` + ObjectiveCriteria string `json:"objective_criteria,omitempty" xml:"objective_criteria,omitempty" validate:"max=10000"` +} + +// IsAboveThreshold checks if procedure value is above EU thresholds +func (p *Procedure) IsAboveThreshold() bool { + if p.EstimatedValue == nil { + return false + } + + // EU thresholds 2024-2025 (in EUR) + thresholds := map[ContractNature]float64{ + ContractNatureWorks: 5382000, + ContractNatureSupplies: 143000, // central government + ContractNatureServices: 143000, // central government + } + + threshold, exists := thresholds[p.ContractNature] + if !exists { + return false + } + + // Convert to EUR if needed + valueEUR := p.EstimatedValue.Amount + if p.EstimatedValue.Currency != CurrencyEUR { + // In real implementation, would use exchange rates + // This is simplified + return true // Assume above threshold for non-EUR + } + + return valueEUR >= threshold +} + +// GetTotalEstimatedValue calculates total value from lots +func (p *Procedure) GetTotalEstimatedValue() *Money { + if len(p.Lots) == 0 { + return p.EstimatedValue + } + + var total float64 + var currency string + + for _, lot := range p.Lots { + if lot.EstimatedValue != nil { + if currency == "" { + currency = lot.EstimatedValue.Currency + } + if lot.EstimatedValue.Currency == currency { + total += lot.EstimatedValue.Amount + } + } + } + + if currency != "" { + return &Money{ + Amount: total, + Currency: currency, + } + } + + return p.EstimatedValue +} + +// IsDividedIntoLots checks if procedure has lots +func (p *Procedure) IsDividedIntoLots() bool { + return len(p.Lots) > 0 +} + +// GetLotByID retrieves lot by ID +func (p *Procedure) GetLotByID(id string) *Lot { + for i := range p.Lots { + if p.Lots[i].ID == id { + return &p.Lots[i] + } + } + return nil +} + +// IsFrameworkAgreement checks if this is a framework agreement +func (p *Procedure) IsFrameworkAgreement() bool { + return p.FrameworkAgreement +} + +// IsDynamicPurchasingSystem checks if this is a DPS +func (p *Procedure) IsDynamicPurchasingSystem() bool { + return p.DynamicPurchasingSystem +} + +// RequiresTwoStage checks if procedure is two-stage +func (p *Procedure) RequiresTwoStage() bool { + twoStageProcs := []ProcedureType{ + ProcTypeRestricted, + ProcTypeCompetitiveDialogue, + ProcTypeInnovation, + ProcTypeNegotiatedWithCall, + } + + for _, t := range twoStageProcs { + if p.ProcedureType == t { + return true + } + } + return false +} + +// GetAwardedContracts returns all awarded contracts from procedure +func (p *Procedure) GetAwardedContracts() []SettledContract { + var contracts []SettledContract + + for _, lot := range p.Lots { + if lot.Result != nil && lot.Result.SettledContract != nil { + contracts = append(contracts, *lot.Result.SettledContract) + } + } + + return contracts +} + +// GetTotalAwardedValue calculates total value of all awarded contracts +func (p *Procedure) GetTotalAwardedValue() *Money { + contracts := p.GetAwardedContracts() + if len(contracts) == 0 { + return nil + } + + var total float64 + currency := contracts[0].ContractValue.Currency + + for _, contract := range contracts { + if contract.ContractValue.Currency == currency { + total += contract.ContractValue.Amount + } + } + + return &Money{ + Amount: total, + Currency: currency, + } +} diff --git a/ted/eform/procurement_project.go b/ted/eform/procurement_project.go new file mode 100644 index 0000000..0bdf7bd --- /dev/null +++ b/ted/eform/procurement_project.go @@ -0,0 +1,49 @@ +package eform + +import "encoding/xml" + +// ===================================================== +// PROCUREMENT PROJECT +// ===================================================== + +// ProcurementProject represents the procurement project +type ProcurementProject struct { + XMLName xml.Name `xml:"ProcurementProject"` + ID string `xml:"ID"` + Name string `xml:"Name"` + Description string `xml:"Description"` + ProcurementTypeCode string `xml:"ProcurementTypeCode"` + Note string `xml:"Note,omitempty"` + RequestedTenderTotal RequestedTenderTotal `xml:"RequestedTenderTotal,omitempty"` + MainCommodityClassification MainCommodityClassification `xml:"MainCommodityClassification,omitempty"` + RealizedLocation RealizedLocation `xml:"RealizedLocation,omitempty"` + PlannedPeriod PlannedPeriod `xml:"PlannedPeriod,omitempty"` +} + +// RequestedTenderTotal represents the estimated contract amount +type RequestedTenderTotal struct { + XMLName xml.Name `xml:"RequestedTenderTotal"` + EstimatedOverallContractAmount string `xml:"EstimatedOverallContractAmount"` +} + +// MainCommodityClassification represents the CPV code +type MainCommodityClassification struct { + ItemClassificationCode string `json:"item_classification_code" xml:"ItemClassificationCode"` +} + +// RealizedLocation represents the location where work will be performed +type RealizedLocation struct { + Address PostalAddress `xml:"PostalAddress"` + Region string `xml:"Region,omitempty"` +} + +// PlannedPeriod represents the planned duration of the contract +type PlannedPeriod struct { + DurationMeasure DurationMeasure `json:"duration_measure" xml:"DurationMeasure"` +} + +// DurationMeasure represents a duration with unit +type DurationMeasure struct { + UnitCode string `json:"unit_code" xml:"unitCode,attr"` + Value string `json:"value" xml:",chardata"` +} diff --git a/ted/eform/procurement_project_lot.go b/ted/eform/procurement_project_lot.go new file mode 100644 index 0000000..94e58b0 --- /dev/null +++ b/ted/eform/procurement_project_lot.go @@ -0,0 +1,13 @@ +package eform + +// ===================================================== +// PROCUREMENT PROJECT LOT +// ===================================================== + +// ProcurementProjectLot represents a procurement project lot +type ProcurementProjectLot struct { + ID string `json:"id" xml:"ID"` + TenderingTerms TenderingTerms `json:"tendering_terms,omitempty" xml:"TenderingTerms,omitempty"` + TenderingProcess TenderingProcess `json:"tendering_process,omitempty" xml:"TenderingProcess,omitempty"` + ProcurementProject ProcurementProject `json:"procurement_project,omitempty" xml:"ProcurementProject,omitempty"` +} diff --git a/ted/eform/review_info.go b/ted/eform/review_info.go new file mode 100644 index 0000000..03d26ac --- /dev/null +++ b/ted/eform/review_info.go @@ -0,0 +1,17 @@ +package eform + +// ===================================================== +// REVIEW/APPEAL INFORMATION +// ===================================================== + +// ReviewInfo represents information about review procedures +type ReviewInfo struct { + ReviewBody string `json:"review_body" xml:"review_body" validate:"required"` + ReviewBodyID string `json:"review_body_id" xml:"review_body_id" validate:"required,orgIDFormat"` + ReviewProcedure string `json:"review_procedure" xml:"review_procedure" validate:"required,max=10000"` + ReviewDeadline string `json:"review_deadline,omitempty" xml:"review_deadline,omitempty" validate:"max=1000"` + MediationBody string `json:"mediation_body,omitempty" xml:"mediation_body,omitempty"` + MediationBodyID string `json:"mediation_body_id,omitempty" xml:"mediation_body_id,omitempty" validate:"omitempty,orgIDFormat"` + ReviewInfoURL string `json:"review_info_url,omitempty" xml:"review_info_url,omitempty" validate:"omitempty,url"` + LodgingOfAppeals string `json:"lodging_of_appeals,omitempty" xml:"lodging_of_appeals,omitempty" validate:"max=10000"` +} diff --git a/ted/eform/settled_contract.go b/ted/eform/settled_contract.go new file mode 100644 index 0000000..a3ce3c3 --- /dev/null +++ b/ted/eform/settled_contract.go @@ -0,0 +1,26 @@ +package eform + +import "time" + +// ===================================================== +// SETTLED CONTRACT STRUCTURE +// ===================================================== + +// SettledContract represents awarded contract details +type SettledContract struct { + ID string `json:"id" xml:"id" validate:"required,conIDFormat"` + ContractNumber string `json:"contract_number,omitempty" xml:"contract_number,omitempty" validate:"max=100"` + Title string `json:"title" xml:"title" validate:"required,max=500"` + ConclusionDate time.Time `json:"conclusion_date" xml:"conclusion_date" validate:"required"` + ContractValue Money `json:"contract_value" xml:"contract_value" validate:"required"` + InitialValue *Money `json:"initial_value,omitempty" xml:"initial_value,omitempty"` + FrameworkMaxValue *Money `json:"framework_max_value,omitempty" xml:"framework_max_value,omitempty"` + StartDate *time.Time `json:"start_date,omitempty" xml:"start_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty" xml:"end_date,omitempty"` + Duration *Duration `json:"duration,omitempty" xml:"duration,omitempty"` + ContractorIDs []string `json:"contractor_ids" xml:"contractor_ids" validate:"required,min=1"` + Modifications []Modification `json:"modifications,omitempty" xml:"modifications,omitempty" validate:"dive"` + FinalValue *Money `json:"final_value,omitempty" xml:"final_value,omitempty"` + CompletionDate *time.Time `json:"completion_date,omitempty" xml:"completion_date,omitempty"` + GroupID string `json:"group_id,omitempty" xml:"group_id,omitempty"` +} diff --git a/ted/eform/technical_specifications.go b/ted/eform/technical_specifications.go new file mode 100644 index 0000000..95189eb --- /dev/null +++ b/ted/eform/technical_specifications.go @@ -0,0 +1,43 @@ +package eform + +// ===================================================== +// TECHNICAL SPECIFICATIONS +// ===================================================== + +// TechnicalSpecifications contains technical and eligibility requirements +type TechnicalSpecifications struct { + Description string `json:"description" xml:"description" validate:"required,max=10000"` + EligibilityCriteria string `json:"eligibility_criteria,omitempty" xml:"eligibility_criteria,omitempty" validate:"max=10000"` + TechnicalCapability string `json:"technical_capability,omitempty" xml:"technical_capability,omitempty" validate:"max=10000"` + EconomicCapability string `json:"economic_capability,omitempty" xml:"economic_capability,omitempty" validate:"max=10000"` + References bool `json:"references" xml:"references"` + ReferencesDescription string `json:"references_description,omitempty" xml:"references_description,omitempty" validate:"required_if=References true"` + SecurityClearance bool `json:"security_clearance" xml:"security_clearance"` + SecurityClearanceDescription string `json:"security_clearance_description,omitempty" xml:"security_clearance_description,omitempty" validate:"required_if=SecurityClearance true"` + ReservedExecution ReservedExecution `json:"reserved_execution" xml:"reserved_execution"` + StrategicProcurement []StrategicType `json:"strategic_procurement,omitempty" xml:"strategic_procurement,omitempty"` + AccessibilityCriteria bool `json:"accessibility_criteria" xml:"accessibility_criteria"` + AccessibilityDescription string `json:"accessibility_description,omitempty" xml:"accessibility_description,omitempty" validate:"max=10000"` + GreenProcurement bool `json:"green_procurement" xml:"green_procurement"` + SocialProcurement bool `json:"social_procurement" xml:"social_procurement"` + InnovativeProcurement bool `json:"innovative_procurement" xml:"innovative_procurement"` +} + +// ReservedExecution represents reserved participation types +type ReservedExecution string + +const ( + ReservedNone ReservedExecution = "none" + ReservedShelteredWS ReservedExecution = "sheltered-workshop" + ReservedPublicService ReservedExecution = "public-service-mission" +) + +// StrategicType represents strategic procurement objectives +type StrategicType string + +const ( + StrategicEnvironmental StrategicType = "environmental" + StrategicSocial StrategicType = "social" + StrategicInnovation StrategicType = "innovation" + StrategicOther StrategicType = "other" +) diff --git a/ted/eform/tender.go b/ted/eform/tender.go new file mode 100644 index 0000000..65ee548 --- /dev/null +++ b/ted/eform/tender.go @@ -0,0 +1,24 @@ +package eform + +// ===================================================== +// TENDER STRUCTURE +// ===================================================== + +// Tender represents information about individual bids +type Tender struct { + ID string `json:"id" xml:"id" validate:"required,tenIDFormat"` + TenderRank *int `json:"tender_rank,omitempty" xml:"tender_rank,omitempty" validate:"omitempty,min=1"` + IsWinner bool `json:"is_winner" xml:"is_winner"` + TendererIDs []string `json:"tenderer_ids" xml:"tenderer_ids" validate:"required,min=1"` + IsConsortium bool `json:"is_consortium" xml:"is_consortium"` + VariantTender bool `json:"variant_tender" xml:"variant_tender"` + ExcludedTender bool `json:"excluded_tender" xml:"excluded_tender"` + ExclusionReason string `json:"exclusion_reason,omitempty" xml:"exclusion_reason,omitempty" validate:"required_if=ExcludedTender true"` + TenderValue *Money `json:"tender_value,omitempty" xml:"tender_value,omitempty"` + SubcontractingPercentage *float64 `json:"subcontracting_percentage,omitempty" xml:"subcontracting_percentage,omitempty" validate:"omitempty,min=0,max=100"` + SMETender bool `json:"sme_tender" xml:"sme_tender"` + ConcessionValue *Money `json:"concession_value,omitempty" xml:"concession_value,omitempty"` + SubcontractorIDs []string `json:"subcontractor_ids,omitempty" xml:"subcontractor_ids,omitempty"` + AbnormallyLow bool `json:"abnormally_low" xml:"abnormally_low"` + AbnormallyLowExplanation string `json:"abnormally_low_explanation,omitempty" xml:"abnormally_low_explanation,omitempty" validate:"max=10000"` +} diff --git a/ted/eform/tendering_process.go b/ted/eform/tendering_process.go new file mode 100644 index 0000000..58f9264 --- /dev/null +++ b/ted/eform/tendering_process.go @@ -0,0 +1,28 @@ +package eform + +import "encoding/xml" + +// ===================================================== +// TENDERING PROCESS +// ===================================================== + +// TenderingProcess represents the tendering process +type TenderingProcess struct { + XMLName xml.Name `xml:"TenderingProcess"` + ProcedureCode string `xml:"ProcedureCode"` + ProcessJustification ProcessJustification `xml:"ProcessJustification,omitempty"` + TenderSubmissionDeadlinePeriod TenderSubmissionDeadlinePeriod `xml:"TenderSubmissionDeadlinePeriod,omitempty"` +} + +// ProcessJustification represents process justification +type ProcessJustification struct { + XMLName xml.Name `xml:"ProcessJustification"` + ProcessReasonCode string `xml:"ProcessReasonCode"` +} + +// TenderSubmissionDeadlinePeriod represents the deadline for tender submission +type TenderSubmissionDeadlinePeriod struct { + XMLName xml.Name `xml:"TenderSubmissionDeadlinePeriod"` + EndDate string `xml:"EndDate"` + EndTime string `xml:"EndTime,omitempty"` +} diff --git a/ted/eform/tendering_terms.go b/ted/eform/tendering_terms.go new file mode 100644 index 0000000..8cdeddb --- /dev/null +++ b/ted/eform/tendering_terms.go @@ -0,0 +1,85 @@ +package eform + +import ( + "encoding/xml" + "time" +) + +// ===================================================== +// TENDERING TERMS +// ===================================================== + +// TenderingTerms represents tendering terms +type TenderingTerms struct { + TendererQualificationRequest []TendererQualificationRequest `json:"tenderer_qualification_request,omitempty" xml:"TendererQualificationRequest,omitempty"` + AppealTerms AppealTerms `json:"appeal_terms,omitempty" xml:"AppealTerms,omitempty"` + CallForTendersDocumentReference CallForTendersDocumentReference `json:"call_for_tenders_document_reference,omitempty" xml:"CallForTendersDocumentReference,omitempty"` + TenderRecipientParty TenderRecipientParty `json:"tender_recipient_party,omitempty" xml:"TenderRecipientParty,omitempty"` + UBLExtensions UBLExtensions `json:"ubl_extensions,omitempty" xml:"UBLExtensions,omitempty"` +} + +// TendererQualificationRequest represents qualification requirements +type TendererQualificationRequest struct { + SpecificTendererRequirement []SpecificTendererRequirement `json:"specific_tenderer_requirement,omitempty" xml:"SpecificTendererRequirement,omitempty"` +} + +// SpecificTendererRequirement represents specific requirements +type SpecificTendererRequirement struct { + XMLName xml.Name `xml:"SpecificTendererRequirement"` + TendererRequirementTypeCode string `xml:"TendererRequirementTypeCode"` + Description string `xml:"Description,omitempty"` +} + +// AppealTerms represents appeal terms for review organization +type AppealTerms struct { + XMLName xml.Name `xml:"AppealTerms"` + AppealReceiverParty AppealReceiverParty `xml:"AppealReceiverParty"` +} + +// AppealReceiverParty represents the party that receives appeals +type AppealReceiverParty struct { + XMLName xml.Name `xml:"AppealReceiverParty"` + PartyIdentification PartyIdentification `xml:"PartyIdentification"` +} + +// CallForTendersDocumentReference represents document reference +type CallForTendersDocumentReference struct { + XMLName xml.Name `xml:"CallForTendersDocumentReference"` + ID string `xml:"ID"` + DocumentType string `xml:"DocumentType,omitempty"` + Attachment Attachment `xml:"Attachment"` + UBLExtensions UBLExtensions `xml:"UBLExtensions,omitempty"` +} + +// Attachment represents document attachment +type Attachment struct { + ExternalReference ExternalReference `json:"external_reference" xml:"ExternalReference"` +} + +// ExternalReference represents external document reference +type ExternalReference struct { + XMLName xml.Name `xml:"ExternalReference"` + URI string `xml:"URI"` +} + +// TenderRecipientParty represents the party that receives tenders +type TenderRecipientParty struct { + XMLName xml.Name `xml:"TenderRecipientParty"` + EndpointID string `xml:"EndpointID"` +} + +// EOSize represents economic operator size restrictions +type EOSize string + +const ( + EOSizeNone EOSize = "none" + EOSizeSME EOSize = "sme" + EOSizeNotSME EOSize = "not-sme" +) + +// TenderOpening represents tender opening information +type TenderOpening struct { + DateTime time.Time `json:"date_time" xml:"date_time" validate:"required"` + Place string `json:"place,omitempty" xml:"place,omitempty" validate:"max=500"` + Description string `json:"description,omitempty" xml:"description,omitempty" validate:"max=2000"` +} diff --git a/ted/eform/ubl.go b/ted/eform/ubl.go new file mode 100644 index 0000000..e3ffc63 --- /dev/null +++ b/ted/eform/ubl.go @@ -0,0 +1,71 @@ +package eform + +import "encoding/xml" + +// ===================================================== +// UBL EXTENSIONS +// ===================================================== + +// UBLExtensions represents the UBL extensions element +type UBLExtensions struct { + UBLExtension []UBLExtension `json:"ubl_extension,omitempty" xml:"UBLExtension,omitempty"` +} + +// UBLExtension represents a single UBL extension +type UBLExtension struct { + ExtensionContent ExtensionContent `json:"extension_content,omitempty" xml:"ExtensionContent,omitempty"` +} + +// ExtensionContent represents the extension content +type ExtensionContent struct { + EformsExtension EformsExtension `json:"eforms_extension,omitempty" xml:"EformsExtension,omitempty"` +} + +// EformsExtension represents the eforms extension data +type EformsExtension struct { + NoticeSubType NoticeSubType `json:"notice_sub_type,omitempty" xml:"NoticeSubType,omitempty"` + Organizations Organizations `json:"organizations,omitempty" xml:"Organizations,omitempty"` + Publication Publication `json:"publication,omitempty" xml:"Publication,omitempty"` + SelectionCriteria []SelectionCriteria `json:"selection_criteria,omitempty" xml:"SelectionCriteria,omitempty"` + OfficialLanguages OfficialLanguages `json:"official_languages,omitempty" xml:"OfficialLanguages,omitempty"` +} + +// NoticeSubType represents the notice subtype +type NoticeSubType struct { + XMLName xml.Name `xml:"NoticeSubType"` + SubTypeCode string `xml:"SubTypeCode"` +} + +// Organizations represents the organizations element +type Organizations struct { + XMLName xml.Name `xml:"Organizations"` + Organization []Organization `xml:"Organization,omitempty"` +} + +// Publication represents the publication information +type Publication struct { + XMLName xml.Name `xml:"Publication"` + NoticePublicationID string `xml:"NoticePublicationID"` + GazetteID string `xml:"GazetteID"` + PublicationDate string `xml:"PublicationDate"` +} + +// SelectionCriteria represents selection criteria for tenderers +type SelectionCriteria struct { + XMLName xml.Name `xml:"SelectionCriteria"` + TendererRequirementTypeCode string `xml:"TendererRequirementTypeCode"` + Description string `xml:"Description,omitempty"` + SecondStageIndicator bool `xml:"SecondStageIndicator,omitempty"` +} + +// OfficialLanguages represents official languages for the tender +type OfficialLanguages struct { + XMLName xml.Name `xml:"OfficialLanguages"` + Language []Language `xml:"Language,omitempty"` +} + +// Language represents a language code +type Language struct { + XMLName xml.Name `xml:"Language"` + ID string `xml:"ID"` +} diff --git a/ted/eform/validation.go b/ted/eform/validation.go new file mode 100644 index 0000000..421fa17 --- /dev/null +++ b/ted/eform/validation.go @@ -0,0 +1,19 @@ +package eform + +// ===================================================== +// VALIDATION HELPER TYPES +// ===================================================== + +// ValidationError represents a validation error +type ValidationError struct { + Field string `json:"field"` + Tag string `json:"tag"` + Value string `json:"value"` + Message string `json:"message"` +} + +// ValidationResult represents the result of validation +type ValidationResult struct { + Valid bool `json:"valid"` + Errors []ValidationError `json:"errors,omitempty"` +} diff --git a/ted/email_template.go b/ted/email_template.go new file mode 100644 index 0000000..b778e25 --- /dev/null +++ b/ted/email_template.go @@ -0,0 +1,508 @@ +package ted + +import ( + "fmt" + "strings" + "time" +) + +// FileProcessingEmailTemplateData represents the data needed for the file processing email template +type FileProcessingEmailTemplateData struct { + OJS string + ProcessedCount int + SuccessCount int + Errors []string + ProcessedAt int64 + CompanyName string + CompanyLogo string + CompanyURL string + SupportEmail string +} + +// GenerateFileProcessingEmailTemplate generates a beautiful HTML email template for file processing results +func GenerateFileProcessingEmailTemplate(data *FileProcessingEmailTemplateData) string { + // Format the processing date + processedAt := time.Unix(data.ProcessedAt, 0).Format("January 2, 2006 at 3:04 PM") + + // Calculate success rate + successRate := 0.0 + if data.ProcessedCount > 0 { + successRate = float64(data.SuccessCount) / float64(data.ProcessedCount) * 100 + } + + // Determine status color and message + var statusColor, statusMessage string + if len(data.Errors) == 0 && data.ProcessedCount > 0 { + statusColor = "#10b981" // Green + statusMessage = "All files processed successfully" + } else if data.SuccessCount > 0 { + statusColor = "#f59e0b" // Amber + statusMessage = "Partial success with some errors" + } else { + statusColor = "#ef4444" // Red + statusMessage = "Processing failed" + } + + // Generate errors section + var errorsHTML string + if len(data.Errors) > 0 { + errorsHTML = ` +
+

Errors Encountered

+
` + + for i, error := range data.Errors { + if i >= 10 { // Limit to first 10 errors + errorsHTML += fmt.Sprintf(` +
+ %d. + %s +
`, i+1, error) + } + } + + if len(data.Errors) > 10 { + errorsHTML += fmt.Sprintf(` +
+ ... and %d more errors +
`, len(data.Errors)-10) + } + + errorsHTML += ` +
+
` + } + + html := ` + + + + + + TED File Processing Report - {{COMPANY_NAME}} + + + +
+ +
+

TED File Processing Report

+

Automated processing results from the TED scraper system

+
+ + +
+
+ {{STATUS_MESSAGE}} +
+ +
+
OJS: {{OJS}}
+ +
+
+
{{PROCESSED_COUNT}}
+
Files Processed
+
+ +
+
{{SUCCESS_COUNT}}
+
Successfully Processed
+
+ +
+
{{SUCCESS_RATE}}%
+
Success Rate
+
+ +
+
{{ERROR_COUNT}}
+
Errors
+
+
+ + {{ERRORS_SECTION}} + +
+ Processed on: {{PROCESSED_AT}} +
+

This report was automatically generated by the TED Scraper System

+
+
+
+ + + +
+ +` + + // Replace placeholders with actual data + html = strings.ReplaceAll(html, "{{COMPANY_NAME}}", data.CompanyName) + html = strings.ReplaceAll(html, "{{OJS}}", data.OJS) + html = strings.ReplaceAll(html, "{{PROCESSED_COUNT}}", fmt.Sprintf("%d", data.ProcessedCount)) + html = strings.ReplaceAll(html, "{{SUCCESS_COUNT}}", fmt.Sprintf("%d", data.SuccessCount)) + html = strings.ReplaceAll(html, "{{SUCCESS_RATE}}", fmt.Sprintf("%.1f", successRate)) + html = strings.ReplaceAll(html, "{{ERROR_COUNT}}", fmt.Sprintf("%d", len(data.Errors))) + html = strings.ReplaceAll(html, "{{PROCESSED_AT}}", processedAt) + html = strings.ReplaceAll(html, "{{STATUS_COLOR}}", statusColor) + html = strings.ReplaceAll(html, "{{STATUS_MESSAGE}}", statusMessage) + html = strings.ReplaceAll(html, "{{ERRORS_SECTION}}", errorsHTML) + html = strings.ReplaceAll(html, "{{SUPPORT_EMAIL}}", data.SupportEmail) + html = strings.ReplaceAll(html, "{{COMPANY_URL}}", data.CompanyURL) + + return html +} + +// GenerateFileProcessingEmailMessage generates a simple text message for the email +func GenerateFileProcessingEmailMessage(data *FileProcessingEmailTemplateData) string { + processedAt := time.Unix(data.ProcessedAt, 0).Format("January 2, 2006 at 3:04 PM") + + // Calculate success rate + successRate := 0.0 + if data.ProcessedCount > 0 { + successRate = float64(data.SuccessCount) / float64(data.ProcessedCount) * 100 + } + + // Determine status message + var statusMessage string + if len(data.Errors) == 0 && data.ProcessedCount > 0 { + statusMessage = "✅ All files processed successfully" + } else if data.SuccessCount > 0 { + statusMessage = "⚠️ Partial success with some errors" + } else { + statusMessage = "❌ Processing failed" + } + + message := fmt.Sprintf(` +TED File Processing Report + +%s + +OJS: %s +Files Processed: %d +Successfully Processed: %d +Success Rate: %.1f%% +Errors: %d + +Processed on: %s + +`, statusMessage, data.OJS, data.ProcessedCount, data.SuccessCount, successRate, len(data.Errors), processedAt) + + // Add errors if any + if len(data.Errors) > 0 { + message += "Errors encountered:\n" + for i, error := range data.Errors { + if i >= 10 { // Limit to first 10 errors + break + } + message += fmt.Sprintf("%d. %s\n", i+1, error) + } + + if len(data.Errors) > 10 { + message += fmt.Sprintf("... and %d more errors\n", len(data.Errors)-10) + } + } + + message += "\nThis report was automatically generated by the TED Scraper System." + + return message +} + +// GenerateFileProcessingEmailSubject generates an appropriate subject line for the email +func GenerateFileProcessingEmailSubject(data *FileProcessingEmailTemplateData) string { + var status string + if len(data.Errors) == 0 && data.ProcessedCount > 0 { + status = "✅ Success" + } else if data.SuccessCount > 0 { + status = "⚠️ Partial Success" + } else { + status = "❌ Failed" + } + + return fmt.Sprintf("TED Processing Report - OJS %s - %s (%d/%d files)", + data.OJS, status, data.SuccessCount, data.ProcessedCount) +} diff --git a/ted/parser.go b/ted/parser.go new file mode 100644 index 0000000..b609491 --- /dev/null +++ b/ted/parser.go @@ -0,0 +1,172 @@ +package ted + +import ( + "encoding/xml" + "fmt" + "strings" + "tm/pkg/notification" + "tm/pkg/xmlparser" +) + +type ParserType string + +const ( + ParserTypeContractNotice ParserType = "ContractNotice" + ParserTypeContractAwardNotice ParserType = "ContractAwardNotice" + ParserTypePriorInformationNotice ParserType = "PriorInformationNotice" + ParserTypeBusinessRegistrationInformationNotice ParserType = "BusinessRegistrationInformationNotice" +) + +type TEDParser struct { + Notification notification.SDK + NoticeParser xmlparser.Parser[ContractNotice] +} + +// NewTEDParser creates a new TED parser +func NewTEDParser() *TEDParser { + options := xmlparser.DefaultParserOptions() + options.MaxSize = 50 * 1024 * 1024 // 50MB limit for TED XML files + options.IgnoreUnknownElements = true // TED XML has many optional fields + + parser := xmlparser.NewParser[ContractNotice](options) + return &TEDParser{ + NoticeParser: parser, + } +} + +// ParseAnyNotice parses XML content and detects the notice type automatically +func (tp *TEDParser) Parse(content []byte) (interface{}, string, ParserType, error) { + // First, detect the root element type + rootElement, err := detectRootElement(content) + if err != nil { + return nil, "", ParserType(""), fmt.Errorf("failed to detect root element: %w", err) + } + + // Parse based on the detected type + switch ParserType(rootElement) { + case ParserTypeContractNotice: + notice, err := tp.parseContractNotice(content) + return notice, notice.GetID(), ParserTypeContractNotice, err + case ParserTypeContractAwardNotice: + notice, err := tp.parseContractAwardNotice(content) + return notice, notice.GetID(), ParserTypeContractAwardNotice, err + case ParserTypePriorInformationNotice: + notice, err := tp.parsePriorInformationNotice(content) + return notice, notice.GetID(), ParserTypePriorInformationNotice, err + case ParserTypeBusinessRegistrationInformationNotice: + notice, err := tp.parseBusinessRegistrationInformationNotice(content) + return notice, notice.GetID(), ParserTypeBusinessRegistrationInformationNotice, err + default: + return nil, "", ParserType(""), fmt.Errorf("unsupported notice type: %s", rootElement) + } +} + +// detectRootElement detects the root XML element name +func detectRootElement(content []byte) (string, error) { + decoder := xml.NewDecoder(strings.NewReader(string(content))) + for { + token, err := decoder.Token() + if err != nil { + return "", err + } + if startElement, ok := token.(xml.StartElement); ok { + return startElement.Name.Local, nil + } + } +} + +// parseContractNotice parses a ContractNotice +func (tp *TEDParser) parseContractNotice(content []byte) (*ContractNotice, error) { + options := xmlparser.DefaultParserOptions() + options.MaxSize = 50 * 1024 * 1024 + options.IgnoreUnknownElements = true + + parser := xmlparser.NewParser[ContractNotice](options) + notice, err := parser.ParseBytes(content) + if err != nil { + return nil, err + } + return notice, nil +} + +// parseContractAwardNotice parses a ContractAwardNotice +func (tp *TEDParser) parseContractAwardNotice(content []byte) (*ContractAwardNotice, error) { + options := xmlparser.DefaultParserOptions() + options.MaxSize = 50 * 1024 * 1024 + options.IgnoreUnknownElements = true + + parser := xmlparser.NewParser[ContractAwardNotice](options) + notice, err := parser.ParseBytes(content) + if err != nil { + return nil, err + } + return notice, nil +} + +// parsePriorInformationNotice parses a PriorInformationNotice +func (tp *TEDParser) parsePriorInformationNotice(content []byte) (*PriorInformationNotice, error) { + options := xmlparser.DefaultParserOptions() + options.MaxSize = 50 * 1024 * 1024 + options.IgnoreUnknownElements = true + + parser := xmlparser.NewParser[PriorInformationNotice](options) + notice, err := parser.ParseBytes(content) + if err != nil { + return nil, err + } + return notice, nil +} + +// parseBusinessRegistrationInformationNotice parses a BusinessRegistrationInformationNotice +func (tp *TEDParser) parseBusinessRegistrationInformationNotice(content []byte) (*BusinessRegistrationInformationNotice, error) { + options := xmlparser.DefaultParserOptions() + options.MaxSize = 50 * 1024 * 1024 + options.IgnoreUnknownElements = true + + parser := xmlparser.NewParser[BusinessRegistrationInformationNotice](options) + notice, err := parser.ParseBytes(content) + if err != nil { + return nil, err + } + return notice, nil +} + +// GetID returns the contract notice ID +func (cn ContractNotice) GetID() string { + return cn.ID +} + +// GetID returns the contract award notice ID +func (can ContractAwardNotice) GetID() string { + return can.ID +} + +// GetID returns the prior information notice ID +func (pin PriorInformationNotice) GetID() string { + return pin.ID +} + +// GetID returns the business registration information notice ID +func (brin BusinessRegistrationInformationNotice) GetID() string { + return brin.ID +} + +// Validate validates the parsed model +func (cn ContractNotice) Validate() error { + return nil +} + +// Validate validates the parsed model +func (can ContractAwardNotice) Validate() error { + return nil +} + +// Validate validates the parsed model +func (pin PriorInformationNotice) Validate() error { + return nil +} + +// Validate validates the parsed model +func (brin BusinessRegistrationInformationNotice) Validate() error { + return nil +} diff --git a/ted/scraper.go b/ted/scraper.go new file mode 100644 index 0000000..33cb7c7 --- /dev/null +++ b/ted/scraper.go @@ -0,0 +1,439 @@ +package ted + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + "time" + "tm/internal/tender" + "tm/pkg/logger" + "tm/pkg/mongo" + "tm/pkg/notification" +) + +// ScraperConfig holds configuration for the TED scraper +type Config struct { + BaseURL string `mapstructure:"base_url"` + UserAgent string `mapstructure:"user_agent"` + DownloadDir string `mapstructure:"download_dir"` + MaxRetries int `mapstructure:"max_retries"` + MaxConcurrency int `mapstructure:"max_concurrency"` + Timeout time.Duration `mapstructure:"timeout"` + RetryDelay time.Duration `mapstructure:"retry_delay"` + CleanupAfter time.Duration `mapstructure:"cleanup_after"` + ScrapingInterval string `mapstructure:"scraping_interval"` +} + +// TEDScraper handles downloading and parsing TED XML files +type TEDScraper struct { + notify notification.SDK + config *Config + logger logger.Logger + httpClient *http.Client + xmlParser *TEDParser + mongoManager *mongo.ConnectionManager + tenderRepo tender.TenderRepository +} + +// NewTEDScraper creates a new TED scraper instance +func NewTEDScraper( + config *Config, + logger logger.Logger, + mongoManager *mongo.ConnectionManager, + tenderRepo tender.TenderRepository, + notify notification.SDK, +) *TEDScraper { + httpClient := &http.Client{ + Timeout: config.Timeout, + } + + return &TEDScraper{ + notify: notify, + config: config, + logger: logger, + httpClient: httpClient, + xmlParser: NewTEDParser(), + mongoManager: mongoManager, + tenderRepo: tenderRepo, + } +} + +// downloadDailyPackage downloads a file from a URL +func (s *TEDScraper) DownloadDailyPackage(ctx context.Context, ojs string) (*FileProcessingResult, error) { + var ( + result *FileProcessingResult + err error + ) + + url := fmt.Sprintf("%s/packages/daily/%s", s.config.BaseURL, ojs) + + s.logger.Info("Downloading TED file", map[string]interface{}{ + "url": url, + "ojs": ojs, + }) + + for attempt := 1; attempt <= s.config.MaxRetries; attempt++ { + result, err = s.downloadWithRetry(ctx, url, ojs) + if err == nil { + break + } + + if attempt < s.config.MaxRetries { + s.logger.Warn("Download attempt failed, retrying", map[string]interface{}{ + "attempt": attempt, + "error": err.Error(), + "url": url, + }) + + select { + case <-ctx.Done(): + return result, ctx.Err() + case <-time.After(s.config.RetryDelay): + // Continue to next attempt + } + } + } + + return result, err +} + +// FileProcessingResult represents the result of processing a single file +type FileProcessingResult struct { + OJS string `json:"ojs"` + ProcessedCount int `json:"processed_count"` + SuccessCount int `json:"success_count"` + Errors []string `json:"errors"` + ProcessedAt int64 `json:"processed_at"` +} + +func (s *TEDScraper) downloadWithRetry(ctx context.Context, url string, ojs string) (*FileProcessingResult, error) { + // Create a longer timeout context for processing large files + processCtx, cancel := context.WithTimeout(ctx, s.config.Timeout) + defer cancel() + + // Create HTTP request + req, err := http.NewRequestWithContext(processCtx, "GET", url, nil) + if err != nil { + return &FileProcessingResult{}, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", s.config.UserAgent) + + s.logger.Info("Starting download", map[string]interface{}{ + "url": url, + "ojs": ojs, + }) + + // Make request + resp, err := s.httpClient.Do(req) + if err != nil { + return &FileProcessingResult{}, fmt.Errorf("failed to download file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return &FileProcessingResult{}, fmt.Errorf("HTTP error: %d %s", resp.StatusCode, resp.Status) + } + + s.logger.Info("Download completed, starting processing", map[string]interface{}{ + "url": url, + "ojs": ojs, + "content_length": resp.Header.Get("Content-Length"), + }) + + return s.tarGzFileProcessor(processCtx, resp.Body, ojs) +} + +// tarGzFileProcessor extracts and processes XML files from a tar.gz archive +func (s *TEDScraper) tarGzFileProcessor(ctx context.Context, reader io.Reader, ojs string) (*FileProcessingResult, error) { + // Create gzip reader + gzipReader, err := gzip.NewReader(reader) + if err != nil { + return &FileProcessingResult{}, fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzipReader.Close() + + // Create tar reader + tarReader := tar.NewReader(gzipReader) + + // Process each file in the tar archive + fileCount := 0 + result := &FileProcessingResult{ + OJS: ojs, + ProcessedCount: 0, + SuccessCount: 0, + Errors: make([]string, 0), + ProcessedAt: time.Now().Unix(), + } + for { + // Check context for cancellation before each tar entry + select { + case <-ctx.Done(): + s.logger.Warn("Context cancelled during tar processing", map[string]interface{}{ + "processed_files": fileCount, + "error": ctx.Err().Error(), + }) + return &FileProcessingResult{}, ctx.Err() + default: + } + + header, err := tarReader.Next() + if err == io.EOF { + break // End of archive + } + if err != nil { + s.logger.Warn("Error reading tar entry", map[string]interface{}{ + "error": err.Error(), + "processed_files": fileCount, + }) + // If it's a timeout error, return it immediately + if ctx.Err() != nil { + return &FileProcessingResult{}, fmt.Errorf("timeout during tar processing: %w", err) + } + continue + } + fileCount++ + + // Skip directories and non-XML files + if header.Typeflag != tar.TypeReg { + continue + } + + fileName := filepath.Base(header.Name) + if !strings.HasSuffix(strings.ToLower(fileName), ".xml") { + continue + } + + // Log progress every 10 files or for first file + if result.ProcessedCount == 0 || result.ProcessedCount%10 == 0 { + s.logger.Info("Processing XML file from tar.gz", map[string]interface{}{ + "file_name": fileName, + "size": header.Size, + "processed_count": result.ProcessedCount, + "total_files_seen": fileCount, + "ojs": ojs, + }) + } else { + s.logger.Debug("Processing XML file from tar.gz", map[string]interface{}{ + "file_name": fileName, + "processed_count": result.ProcessedCount, + "ojs": ojs, + }) + } + + // Read file content + content, err := io.ReadAll(tarReader) + if err != nil { + errMsg := fmt.Sprintf("failed to read XML file %s: %v", fileName, err) + result.Errors = append(result.Errors, errMsg) + continue + } + + // Parse and process XML content + if err := s.processXMLContent(ctx, content, fileName, result); err != nil { + errMsg := fmt.Sprintf("failed to process XML file %s: %v", fileName, err) + result.Errors = append(result.Errors, errMsg) + } + + result.ProcessedCount++ + + // Check context for cancellation + select { + case <-ctx.Done(): + return result, ctx.Err() + default: + // Continue processing + } + } + + go s.notify.SendNotification( + context.Background(), + ¬ification.NotificationRequest{ + EventType: notification.EventTypeEmail, + Title: "TED scraper completed", + Message: GenerateFileProcessingEmailTemplate( + &FileProcessingEmailTemplateData{ + OJS: ojs, + ProcessedCount: result.ProcessedCount, + SuccessCount: result.SuccessCount, + Errors: result.Errors, + ProcessedAt: result.ProcessedAt, + }), + Priority: "important", + Methods: notification.NotificationMethods{ + Email: "nakhostin.nima1998@gmail.com", + }, + UserID: "ted-scraper", + Type: "alert", + }) + + return result, nil +} + +func (s *TEDScraper) processXMLContent(ctx context.Context, content []byte, fileName string, result *FileProcessingResult) error { + s.logger.Debug("Processing XML content", map[string]interface{}{ + "file_name": fileName, + "content_size": len(content), + "first_1000_chars": string(content[:min(1000, len(content))]), + }) + + // Parse XML using the flexible TED parser that detects notice type + _, id, noticeType, err := s.xmlParser.Parse(content) + if err != nil { + s.logger.Error("XML parsing failed", map[string]interface{}{ + "file_name": fileName, + "error": err.Error(), + "content_preview": string(content[:min(500, len(content))]), + }) + return fmt.Errorf("failed to parse XML: %w", err) + } + + // Log detailed parsed information based on notice type + s.logger.Info("Successfully parsed notice", map[string]interface{}{ + "notice_id": id, + "notice_type": noticeType, + "file_name": fileName, + }) + + result.SuccessCount++ + return nil +} + +func (s *TEDScraper) Run(ctx context.Context) error { + now := time.Now().Local() + s.logger.Info("Running periodic scraping", map[string]interface{}{ + "time": now.Format(time.RFC3339), + }) + + // get ojs + ojs, err := GetOJS(now.Year(), now.Format("02/01/2006")) + if err != nil { + s.logger.Error("Failed to get OJS", map[string]interface{}{ + "error": err.Error(), + }) + return err + } + + // download daily package + result, err := s.DownloadDailyPackage(ctx, ojs) + if err != nil { + s.logger.Error("Failed to download daily package", map[string]interface{}{ + "error": err.Error(), + }) + return err + } + + // process daily package + s.logger.Info("Processed daily package successfully", map[string]interface{}{ + "result": result, + "time": now.Format(time.RFC3339), + "ojs": ojs, + }) + + return nil +} + +// parseDate parses various date formats used in TED XML +func (s *TEDScraper) parseDate(dateStr string) (time.Time, error) { + if dateStr == "" { + return time.Time{}, fmt.Errorf("empty date string") + } + + // Common TED date formats + formats := []string{ + "2006-01-02+07:00", + "2006-01-02-07:00", + "2006-01-02T15:04:05+07:00", + "2006-01-02T15:04:05-07:00", + "2006-01-02T15:04:05Z", + "2006-01-02Z", + "2006-01-02", + time.RFC3339, + time.RFC3339Nano, + } + + for _, format := range formats { + if parsedTime, err := time.Parse(format, dateStr); err == nil { + return parsedTime, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse date: %s", dateStr) +} + +// parseDateToUnixMilli converts string date to Unix milliseconds (int64) +func (s *TEDScraper) parseDateToUnixMilli(dateStr string) int64 { + if dateStr == "" { + return 0 + } + + if parsedTime, err := s.parseDate(dateStr); err == nil { + return parsedTime.UnixMilli() + } + + return 0 +} + +// parseTimeToUnixMilli converts string time to Unix milliseconds (int64) +func (s *TEDScraper) parseTimeToUnixMilli(timeStr string) int64 { + if timeStr == "" { + return 0 + } + + // If time string is just time without date, use today's date + if !strings.Contains(timeStr, "T") && !strings.Contains(timeStr, " ") { + // Assume it's just a time like "15:04:05" + today := time.Now().Format("2006-01-02") + timeStr = fmt.Sprintf("%sT%s", today, timeStr) + } + + if parsedTime, err := s.parseDate(timeStr); err == nil { + return parsedTime.UnixMilli() + } + + return 0 +} + +// unixMilliToDateString converts Unix milliseconds to date string for TenderID generation +func (s *TEDScraper) unixMilliToDateString(unixMilli int64) string { + if unixMilli == 0 { + return "00000000" + } + + return time.UnixMilli(unixMilli).Format("20060102") +} + +func (s *TEDScraper) calculateSubmissionDeadline(publicationDate time.Time) time.Time { + deadline := publicationDate + workingHoursAdded := 0 + + for workingHoursAdded < 48 { + deadline = deadline.Add(1 * time.Hour) + + if deadline.Weekday() != time.Saturday && deadline.Weekday() != time.Sunday { + workingHoursAdded++ + } + } + + return deadline +} + +func (s *TEDScraper) calculateApplicationDeadline(tenderDeadline time.Time) time.Time { + applicationDeadline := tenderDeadline + workingDaysSubtracted := 0 + + for workingDaysSubtracted < 7 { + applicationDeadline = applicationDeadline.AddDate(0, 0, -1) + + if applicationDeadline.Weekday() != time.Saturday && applicationDeadline.Weekday() != time.Sunday { + workingDaysSubtracted++ + } + } + + return applicationDeadline +}