Implement TED Scraper and Configuration Management
- Introduced a new TED scraper in `cmd/scraper` to handle downloading and parsing TED XML files. - Added configuration management for the scraper, including a new `Config` struct and YAML configuration file. - Created necessary handler, service, and repository layers for tender management, adhering to Clean Architecture principles. - Implemented comprehensive logging and error handling throughout the scraper functionality. - Updated API routes to include tender management operations, enhancing the overall system capabilities.
This commit is contained in:
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"tm/internal/tender"
|
||||||
|
"tm/pkg/config"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
"tm/pkg/mongo"
|
||||||
|
"tm/ted"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init Application Configuration
|
||||||
|
func initConfig() *Config {
|
||||||
|
conf, err := config.LoadConfig(".", &Config{})
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to load config: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return conf
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
|
||||||
|
return connectionManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// init TED scraper
|
||||||
|
func initTEDScraper(config Config, mongoManager *mongo.ConnectionManager, appLogger logger.Logger) {
|
||||||
|
// Initialize tender repository
|
||||||
|
tenderRepo := tender.NewTenderRepository(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,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create context with cancellation
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Set up signal handling for graceful shutdown
|
||||||
|
signalChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for shutdown signal or scraper completion
|
||||||
|
select {
|
||||||
|
case <-signalChan:
|
||||||
|
appLogger.Info("Received shutdown signal, stopping scraper...", map[string]interface{}{})
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// 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{}{})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
"tm/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config defines the scraper application configuration
|
||||||
|
type Config struct {
|
||||||
|
Database config.DatabaseConfig `mapstructure:"database"`
|
||||||
|
Logging config.LoggingConfig `mapstructure:"logging"`
|
||||||
|
TED ScraperConfig `mapstructure:"ted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScraperConfig struct {
|
||||||
|
BaseURL string `mapstructure:"base_url"`
|
||||||
|
Timeout time.Duration `mapstructure:"timeout"`
|
||||||
|
MaxRetries int `mapstructure:"max_retries"`
|
||||||
|
RetryDelay time.Duration `mapstructure:"retry_delay"`
|
||||||
|
UserAgent string `mapstructure:"user_agent"`
|
||||||
|
MaxConcurrency int `mapstructure:"max_concurrency"`
|
||||||
|
DownloadDir string `mapstructure:"download_dir"`
|
||||||
|
CleanupAfter time.Duration `mapstructure:"cleanup_after"`
|
||||||
|
ScrapingInterval time.Duration `mapstructure:"scraping_interval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig loads the configuration from file
|
||||||
|
func LoadConfig(path string) (*Config, error) {
|
||||||
|
cfg := &Config{
|
||||||
|
// Set default values
|
||||||
|
TED: ScraperConfig{
|
||||||
|
BaseURL: "https://ted.europa.eu/packages/daily",
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
MaxRetries: 3,
|
||||||
|
RetryDelay: 5 * time.Second,
|
||||||
|
UserAgent: "TenderManagement-TED-Scraper/1.0",
|
||||||
|
MaxConcurrency: 3,
|
||||||
|
DownloadDir: "/tmp/ted_downloads",
|
||||||
|
CleanupAfter: 24 * time.Hour,
|
||||||
|
ScrapingInterval: 12 * time.Hour,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := config.LoadConfig(path, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
database:
|
||||||
|
mongodb:
|
||||||
|
uri: "mongodb://localhost:27017"
|
||||||
|
name: "tender_management"
|
||||||
|
timeout: 10s
|
||||||
|
max_pool_size: 50
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: "info"
|
||||||
|
format: "json"
|
||||||
|
output: "file" # stdout, stderr, file
|
||||||
|
file:
|
||||||
|
path: "./logs/scraper.log"
|
||||||
|
max_size: 100 # Max size in MB before rotation
|
||||||
|
max_backups: 5 # Number of backup files to keep
|
||||||
|
max_age: 30 # Max age in days to keep log files
|
||||||
|
compress: true # Compress rotated files
|
||||||
|
|
||||||
|
ted:
|
||||||
|
base_url: "https://ted.europa.eu/packages/daily"
|
||||||
|
timeout: 300s # Increased to 5 minutes for large files
|
||||||
|
max_retries: 5
|
||||||
|
retry_delay: 10s
|
||||||
|
user_agent: "TenderManagement-TED-Scraper/1.0"
|
||||||
|
max_concurrency: 2 # Reduced to avoid overwhelming the server
|
||||||
|
download_dir: "./tmp/ted_downloads"
|
||||||
|
cleanup_after: 24h
|
||||||
|
scraping_interval: 12h # Run scraping every 12 hours
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load configuration
|
||||||
|
config, err := LoadConfig(".")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize logger
|
||||||
|
appLogger := initLogger(config.Logging)
|
||||||
|
appLogger.Info("Starting TED scraper application", map[string]interface{}{
|
||||||
|
"version": "1.0.0",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize MongoDB connection
|
||||||
|
mongoManager := 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
initTEDScraper(*config, mongoManager, appLogger)
|
||||||
|
|
||||||
|
appLogger.Info("TED scraper application stopped", map[string]interface{}{})
|
||||||
|
}
|
||||||
+18
-5
@@ -35,9 +35,18 @@ package main
|
|||||||
// @tag.name Admin-Companies
|
// @tag.name Admin-Companies
|
||||||
// @tag.description Administrative company management operations for web panel including CRUD operations, tag management, verification, status updates, comprehensive search and filtering, and statistical reporting
|
// @tag.description Administrative company management operations for web panel including CRUD operations, tag management, verification, status updates, comprehensive search and filtering, and statistical reporting
|
||||||
|
|
||||||
|
// @tag.name Admin-Tenders
|
||||||
|
// @tag.description Administrative tender management operations for web panel including CRUD operations, search, statistics, and comprehensive filtering with pagination
|
||||||
|
|
||||||
|
// @tag.name Admin-Scraping
|
||||||
|
// @tag.description Administrative scraping job management operations for TED XML data collection including job control, monitoring, and status tracking
|
||||||
|
|
||||||
// @tag.name Authorization
|
// @tag.name Authorization
|
||||||
// @tag.description Customer authentication and authorization operations for mobile application including login, logout, token refresh, and profile access
|
// @tag.description Customer authentication and authorization operations for mobile application including login, logout, token refresh, and profile access
|
||||||
|
|
||||||
|
// @tag.name Tenders
|
||||||
|
// @tag.description Public tender information access for mobile application including active tender listings and detailed tender information
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -45,6 +54,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"tm/internal/company"
|
"tm/internal/company"
|
||||||
"tm/internal/customer"
|
"tm/internal/customer"
|
||||||
|
"tm/internal/tender"
|
||||||
"tm/internal/user"
|
"tm/internal/user"
|
||||||
|
|
||||||
_ "tm/cmd/web/docs" // This is generated by swag
|
_ "tm/cmd/web/docs" // This is generated by swag
|
||||||
@@ -87,9 +97,10 @@ func main() {
|
|||||||
userRepository := user.NewUserRepository(mongoManager, logger)
|
userRepository := user.NewUserRepository(mongoManager, logger)
|
||||||
customerRepository := customer.NewCustomerRepository(mongoManager, logger)
|
customerRepository := customer.NewCustomerRepository(mongoManager, logger)
|
||||||
companyRepository := company.NewCompanyRepository(mongoManager, logger)
|
companyRepository := company.NewCompanyRepository(mongoManager, logger)
|
||||||
|
tenderRepository := tender.NewTenderRepository(mongoManager, logger)
|
||||||
|
|
||||||
logger.Info("Repositories initialized successfully", map[string]interface{}{
|
logger.Info("Repositories initialized successfully", map[string]interface{}{
|
||||||
"repositories": []string{"customer", "user", "company"},
|
"repositories": []string{"customer", "user", "company", "tender"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize validation service
|
// Initialize validation service
|
||||||
@@ -99,26 +110,28 @@ func main() {
|
|||||||
userService := user.NewUserService(userRepository, logger, userAuthService, userValidator)
|
userService := user.NewUserService(userRepository, logger, userAuthService, userValidator)
|
||||||
companyService := company.NewCompanyService(companyRepository, logger)
|
companyService := company.NewCompanyService(companyRepository, logger)
|
||||||
customerService := customer.NewCustomerService(customerRepository, logger, customerAuthService, companyService)
|
customerService := customer.NewCustomerService(customerRepository, logger, customerAuthService, companyService)
|
||||||
|
tenderService := tender.NewTenderService(tenderRepository, logger)
|
||||||
|
|
||||||
logger.Info("Services initialized successfully", map[string]interface{}{
|
logger.Info("Services initialized successfully", map[string]interface{}{
|
||||||
"services": []string{"customer", "user", "company"},
|
"services": []string{"customer", "user", "company", "tender"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize handlers with services
|
// Initialize handlers with services
|
||||||
userHandler := user.NewUserHandler(userService, logger, userValidator, userAuthService)
|
userHandler := user.NewUserHandler(userService, logger, userValidator, userAuthService)
|
||||||
customerHandler := customer.NewHandler(customerService, userHandler, customerAuthService, logger)
|
customerHandler := customer.NewHandler(customerService, userHandler, customerAuthService, logger)
|
||||||
companyHandler := company.NewHandler(companyService, userHandler, logger)
|
companyHandler := company.NewHandler(companyService, userHandler, logger)
|
||||||
|
tenderHandler := tender.NewTenderHandler(tenderService, logger)
|
||||||
|
|
||||||
logger.Info("Handlers initialized successfully", map[string]interface{}{
|
logger.Info("Handlers initialized successfully", map[string]interface{}{
|
||||||
"handlers": []string{"customer", "user", "company"},
|
"handlers": []string{"customer", "user", "company", "tender"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize HTTP server
|
// Initialize HTTP server
|
||||||
e := initHTTPServer(conf, logger)
|
e := initHTTPServer(conf, logger)
|
||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler)
|
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler)
|
||||||
router.RegisterPublicRoutes(e, customerHandler)
|
router.RegisterPublicRoutes(e, customerHandler, tenderHandler)
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
serverAddr := fmt.Sprintf("%s:%d", conf.Server.Host, conf.Server.Port)
|
serverAddr := fmt.Sprintf("%s:%d", conf.Server.Host, conf.Server.Port)
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ package router
|
|||||||
import (
|
import (
|
||||||
"tm/internal/company"
|
"tm/internal/company"
|
||||||
"tm/internal/customer"
|
"tm/internal/customer"
|
||||||
|
"tm/internal/tender"
|
||||||
"tm/internal/user"
|
"tm/internal/user"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler) {
|
func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler) {
|
||||||
adminV1 := e.Group("/admin/v1")
|
adminV1 := e.Group("/admin/v1")
|
||||||
|
|
||||||
// Admin Users Routes
|
// Admin Users Routes
|
||||||
@@ -87,9 +88,33 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
|
|||||||
customersGP.POST("/:id/companies/assign", customerHandler.AssignCompaniesToCustomer)
|
customersGP.POST("/:id/companies/assign", customerHandler.AssignCompaniesToCustomer)
|
||||||
customersGP.POST("/:id/companies/remove", customerHandler.RemoveCompaniesFromCustomer)
|
customersGP.POST("/:id/companies/remove", customerHandler.RemoveCompaniesFromCustomer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin Tenders Routes
|
||||||
|
tendersGP := adminV1.Group("/tenders")
|
||||||
|
{
|
||||||
|
tendersGP.Use(userHandler.AuthMiddleware())
|
||||||
|
tendersGP.POST("", tenderHandler.CreateTender)
|
||||||
|
tendersGP.GET("", tenderHandler.ListTenders)
|
||||||
|
tendersGP.GET("/:id", tenderHandler.GetTender)
|
||||||
|
tendersGP.PUT("/:id", tenderHandler.UpdateTender)
|
||||||
|
tendersGP.DELETE("/:id", tenderHandler.DeleteTender)
|
||||||
|
tendersGP.POST("/search", tenderHandler.SearchTenders)
|
||||||
|
tendersGP.GET("/statistics", tenderHandler.GetTenderStatistics)
|
||||||
|
tendersGP.GET("/dashboard", tenderHandler.GetDashboard)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin Scraping Routes
|
||||||
|
scrapingGP := adminV1.Group("/scraping")
|
||||||
|
{
|
||||||
|
scrapingGP.Use(userHandler.AuthMiddleware())
|
||||||
|
scrapingGP.POST("/jobs", tenderHandler.StartScrapingJob)
|
||||||
|
scrapingGP.GET("/jobs", tenderHandler.ListScrapingJobs)
|
||||||
|
scrapingGP.GET("/jobs/:id", tenderHandler.GetScrapingJob)
|
||||||
|
scrapingGP.POST("/jobs/:id/cancel", tenderHandler.CancelScrapingJob)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler) {
|
func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler) {
|
||||||
v1 := e.Group("/api/v1")
|
v1 := e.Group("/api/v1")
|
||||||
|
|
||||||
customerGP := v1.Group("/profile")
|
customerGP := v1.Group("/profile")
|
||||||
@@ -103,4 +128,10 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler) {
|
|||||||
profileGP.DELETE("/logout", customerHandler.Logout)
|
profileGP.DELETE("/logout", customerHandler.Logout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public Tenders Routes
|
||||||
|
tendersGP := v1.Group("/tenders")
|
||||||
|
{
|
||||||
|
tendersGP.GET("", tenderHandler.GetPublicTenders)
|
||||||
|
tendersGP.GET("/:id", tenderHandler.GetPublicTenderDetails)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
package tender
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"tm/pkg/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tender represents a tender/contract notice entity
|
||||||
|
type Tender struct {
|
||||||
|
mongo.Model `bson:",inline"`
|
||||||
|
ContractNoticeID string `bson:"contract_notice_id" json:"contract_notice_id"`
|
||||||
|
NoticePublicationID string `bson:"notice_publication_id" json:"notice_publication_id"`
|
||||||
|
ContractFolderID string `bson:"contract_folder_id" json:"contract_folder_id"`
|
||||||
|
NoticeTypeCode string `bson:"notice_type_code" json:"notice_type_code"`
|
||||||
|
NoticeSubTypeCode string `bson:"notice_sub_type_code" json:"notice_sub_type_code"`
|
||||||
|
NoticeLanguageCode string `bson:"notice_language_code" json:"notice_language_code"`
|
||||||
|
IssueDate int64 `bson:"issue_date" json:"issue_date"` // Unix milliseconds
|
||||||
|
IssueTime int64 `bson:"issue_time" json:"issue_time"` // Unix milliseconds
|
||||||
|
PublicationDate int64 `bson:"publication_date" json:"publication_date"` // Unix milliseconds
|
||||||
|
GazetteID string `bson:"gazette_id" json:"gazette_id"`
|
||||||
|
Title string `bson:"title" json:"title"`
|
||||||
|
Description string `bson:"description" json:"description"`
|
||||||
|
ProcurementTypeCode string `bson:"procurement_type_code" json:"procurement_type_code"`
|
||||||
|
ProcedureCode string `bson:"procedure_code" json:"procedure_code"`
|
||||||
|
MainClassification string `bson:"main_classification" json:"main_classification"`
|
||||||
|
AdditionalClassifications []string `bson:"additional_classifications" json:"additional_classifications"`
|
||||||
|
EstimatedValue float64 `bson:"estimated_value" json:"estimated_value"`
|
||||||
|
Currency string `bson:"currency" json:"currency"`
|
||||||
|
Duration string `bson:"duration" json:"duration"`
|
||||||
|
DurationUnit string `bson:"duration_unit" json:"duration_unit"`
|
||||||
|
TenderDeadline int64 `bson:"tender_deadline" json:"tender_deadline"` // Unix milliseconds
|
||||||
|
PlaceOfPerformance string `bson:"place_of_performance" json:"place_of_performance"`
|
||||||
|
CountryCode string `bson:"country_code" json:"country_code"`
|
||||||
|
RegionCode string `bson:"region_code" json:"region_code"`
|
||||||
|
CityName string `bson:"city_name" json:"city_name"`
|
||||||
|
PostalCode string `bson:"postal_code" json:"postal_code"`
|
||||||
|
DocumentURI string `bson:"document_uri" json:"document_uri"`
|
||||||
|
TenderURL string `bson:"tender_url" json:"tender_url"`
|
||||||
|
BuyerOrganization *Organization `bson:"buyer_organization" json:"buyer_organization"`
|
||||||
|
ReviewOrganization *Organization `bson:"review_organization,omitempty" json:"review_organization,omitempty"`
|
||||||
|
Organizations []Organization `bson:"organizations" json:"organizations"`
|
||||||
|
SelectionCriteria []SelectionCriterion `bson:"selection_criteria" json:"selection_criteria"`
|
||||||
|
OfficialLanguages []string `bson:"official_languages" json:"official_languages"`
|
||||||
|
Status TenderStatus `bson:"status" json:"status"`
|
||||||
|
Source TenderSource `bson:"source" json:"source"`
|
||||||
|
SourceFileURL string `bson:"source_file_url" json:"source_file_url"`
|
||||||
|
SourceFileName string `bson:"source_file_name" json:"source_file_name"`
|
||||||
|
ProcessingMetadata ProcessingMetadata `bson:"processing_metadata" json:"processing_metadata"`
|
||||||
|
SubmissionDeadline int64 `bson:"submission_deadline" json:"submission_deadline"` // Unix milliseconds
|
||||||
|
TenderID string `bson:"tender_id" json:"tender_id"` // Implement logic to generate a unique tender_id using the following format: {Source}{BUYER_CODE}{TED_CODE}{DATE}{LOCATION_CODE}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organization represents organization information
|
||||||
|
type Organization struct {
|
||||||
|
ID string `bson:"id" json:"id"`
|
||||||
|
Name string `bson:"name" json:"name"`
|
||||||
|
CompanyID string `bson:"company_id,omitempty" json:"company_id,omitempty"`
|
||||||
|
WebsiteURI string `bson:"website_uri,omitempty" json:"website_uri,omitempty"`
|
||||||
|
ContactName string `bson:"contact_name,omitempty" json:"contact_name,omitempty"`
|
||||||
|
ContactTelephone string `bson:"contact_telephone,omitempty" json:"contact_telephone,omitempty"`
|
||||||
|
ContactEmail string `bson:"contact_email,omitempty" json:"contact_email,omitempty"`
|
||||||
|
ContactFax string `bson:"contact_fax,omitempty" json:"contact_fax,omitempty"`
|
||||||
|
Address Address `bson:"address" json:"address"`
|
||||||
|
Role string `bson:"role,omitempty" json:"role,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address represents address information
|
||||||
|
type Address struct {
|
||||||
|
StreetName string `bson:"street_name,omitempty" json:"street_name,omitempty"`
|
||||||
|
CityName string `bson:"city_name,omitempty" json:"city_name,omitempty"`
|
||||||
|
PostalZone string `bson:"postal_zone,omitempty" json:"postal_zone,omitempty"`
|
||||||
|
CountrySubentityCode string `bson:"country_subentity_code,omitempty" json:"country_subentity_code,omitempty"`
|
||||||
|
Department string `bson:"department,omitempty" json:"department,omitempty"`
|
||||||
|
Region string `bson:"region,omitempty" json:"region,omitempty"`
|
||||||
|
CountryCode string `bson:"country_code" json:"country_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectionCriterion represents selection criteria
|
||||||
|
type SelectionCriterion struct {
|
||||||
|
TypeCode string `bson:"type_code" json:"type_code"`
|
||||||
|
Description string `bson:"description,omitempty" json:"description,omitempty"`
|
||||||
|
LanguageID string `bson:"language_id,omitempty" json:"language_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessingMetadata contains metadata about how the tender was processed
|
||||||
|
type ProcessingMetadata struct {
|
||||||
|
ScrapedAt int64 `bson:"scraped_at" json:"scraped_at"` // Unix milliseconds
|
||||||
|
ProcessedAt int64 `bson:"processed_at" json:"processed_at"` // Unix milliseconds
|
||||||
|
ProcessingVersion string `bson:"processing_version" json:"processing_version"`
|
||||||
|
ParsingErrors []string `bson:"parsing_errors,omitempty" json:"parsing_errors,omitempty"`
|
||||||
|
ValidationErrors []string `bson:"validation_errors,omitempty" json:"validation_errors,omitempty"`
|
||||||
|
EnrichmentData map[string]string `bson:"enrichment_data,omitempty" json:"enrichment_data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenderStatus represents the status of a tender
|
||||||
|
type TenderStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TenderStatusActive TenderStatus = "active"
|
||||||
|
TenderStatusExpired TenderStatus = "expired"
|
||||||
|
TenderStatusCancelled TenderStatus = "cancelled"
|
||||||
|
TenderStatusAwarded TenderStatus = "awarded"
|
||||||
|
TenderStatusDraft TenderStatus = "draft"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenderSource represents the source of tender data
|
||||||
|
type TenderSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TenderSourceTEDScraper TenderSource = "ted_scraper"
|
||||||
|
TenderSourceManual TenderSource = "manual"
|
||||||
|
TenderSourceAPI TenderSource = "api"
|
||||||
|
TenderSourceBulkImport TenderSource = "bulk_import"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenderSearchCriteria represents search criteria for tenders
|
||||||
|
type TenderSearchCriteria struct {
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
NoticeType string `json:"notice_type,omitempty"`
|
||||||
|
ProcurementType string `json:"procurement_type,omitempty"`
|
||||||
|
CountryCodes []string `json:"country_codes,omitempty"`
|
||||||
|
RegionCodes []string `json:"region_codes,omitempty"`
|
||||||
|
Classifications []string `json:"classifications,omitempty"`
|
||||||
|
MinEstimatedValue *float64 `json:"min_estimated_value,omitempty"`
|
||||||
|
MaxEstimatedValue *float64 `json:"max_estimated_value,omitempty"`
|
||||||
|
Currency string `json:"currency,omitempty"`
|
||||||
|
DeadlineFrom *int64 `json:"deadline_from,omitempty"` // Unix milliseconds
|
||||||
|
DeadlineTo *int64 `json:"deadline_to,omitempty"` // Unix milliseconds
|
||||||
|
PublicationDateFrom *int64 `json:"publication_date_from,omitempty"` // Unix milliseconds
|
||||||
|
PublicationDateTo *int64 `json:"publication_date_to,omitempty"` // Unix milliseconds
|
||||||
|
Status []TenderStatus `json:"status,omitempty"`
|
||||||
|
Source []TenderSource `json:"source,omitempty"`
|
||||||
|
BuyerOrganizationID string `json:"buyer_organization_id,omitempty"`
|
||||||
|
Languages []string `json:"languages,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenderStatistics represents tender statistics
|
||||||
|
type TenderStatistics struct {
|
||||||
|
TotalTenders int64 `json:"total_tenders"`
|
||||||
|
ActiveTenders int64 `json:"active_tenders"`
|
||||||
|
ExpiredTenders int64 `json:"expired_tenders"`
|
||||||
|
TendersByCountry map[string]int64 `json:"tenders_by_country"`
|
||||||
|
TendersByType map[string]int64 `json:"tenders_by_type"`
|
||||||
|
TendersByClassification map[string]int64 `json:"tenders_by_classification"`
|
||||||
|
AverageEstimatedValue float64 `json:"average_estimated_value"`
|
||||||
|
TotalEstimatedValue float64 `json:"total_estimated_value"`
|
||||||
|
LastUpdated int64 `json:"last_updated"` // Unix milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the tender ID
|
||||||
|
func (t *Tender) GetID() string {
|
||||||
|
return t.ID.Hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive returns true if the tender is active
|
||||||
|
func (t *Tender) IsActive() bool {
|
||||||
|
return t.Status == TenderStatusActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExpired returns true if the tender deadline has passed
|
||||||
|
func (t *Tender) IsExpired() bool {
|
||||||
|
if t.Status == TenderStatusExpired {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.TenderDeadline == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if deadline (in milliseconds) is in the past
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
return t.TenderDeadline < now
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenderURL returns the TED tender URL
|
||||||
|
func (t *Tender) GetTenderURL() string {
|
||||||
|
if t.TenderURL != "" {
|
||||||
|
return t.TenderURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.NoticePublicationID != "" {
|
||||||
|
return generateTenderURL(t.NoticePublicationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTenderURL generates the TED tender URL from NoticePublicationID
|
||||||
|
func generateTenderURL(noticePublicationID string) string {
|
||||||
|
// Remove leading zeros and format for URL
|
||||||
|
// Example: 00522942-2025 -> https://ted.europa.eu/en/notice/-/detail/522942-2025
|
||||||
|
parts := strings.Split(noticePublicationID, "-")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to int and back to string to remove leading zeros
|
||||||
|
if num, err := strconv.Atoi(parts[0]); err == nil {
|
||||||
|
cleanID := fmt.Sprintf("%d-%s", num, parts[1])
|
||||||
|
return fmt.Sprintf("https://ted.europa.eu/en/notice/-/detail/%s", cleanID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMainOrganization returns the buyer organization
|
||||||
|
func (t *Tender) GetMainOrganization() *Organization {
|
||||||
|
return t.BuyerOrganization
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasEstimatedValue returns true if the tender has an estimated value
|
||||||
|
func (t *Tender) HasEstimatedValue() bool {
|
||||||
|
return t.EstimatedValue > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFormattedEstimatedValue returns formatted estimated value with currency
|
||||||
|
func (t *Tender) GetFormattedEstimatedValue() string {
|
||||||
|
if !t.HasEstimatedValue() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Currency != "" {
|
||||||
|
return fmt.Sprintf("%.2f %s", t.EstimatedValue, t.Currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%.2f", t.EstimatedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus updates the tender status and updated timestamp
|
||||||
|
func (t *Tender) UpdateStatus(status TenderStatus) {
|
||||||
|
t.Status = status
|
||||||
|
t.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddProcessingError adds a processing error to metadata
|
||||||
|
func (t *Tender) AddProcessingError(error string) {
|
||||||
|
if t.ProcessingMetadata.ParsingErrors == nil {
|
||||||
|
t.ProcessingMetadata.ParsingErrors = make([]string, 0)
|
||||||
|
}
|
||||||
|
t.ProcessingMetadata.ParsingErrors = append(t.ProcessingMetadata.ParsingErrors, error)
|
||||||
|
t.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddValidationError adds a validation error to metadata
|
||||||
|
func (t *Tender) AddValidationError(error string) {
|
||||||
|
if t.ProcessingMetadata.ValidationErrors == nil {
|
||||||
|
t.ProcessingMetadata.ValidationErrors = make([]string, 0)
|
||||||
|
}
|
||||||
|
t.ProcessingMetadata.ValidationErrors = append(t.ProcessingMetadata.ValidationErrors, error)
|
||||||
|
t.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasErrors returns true if the tender has processing or validation errors
|
||||||
|
func (t *Tender) HasErrors() bool {
|
||||||
|
return len(t.ProcessingMetadata.ParsingErrors) > 0 || len(t.ProcessingMetadata.ValidationErrors) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllErrors returns all processing and validation errors
|
||||||
|
func (t *Tender) GetAllErrors() []string {
|
||||||
|
var allErrors []string
|
||||||
|
allErrors = append(allErrors, t.ProcessingMetadata.ParsingErrors...)
|
||||||
|
allErrors = append(allErrors, t.ProcessingMetadata.ValidationErrors...)
|
||||||
|
return allErrors
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrapingJobType represents the type of scraping job
|
||||||
|
type ScrapingJobType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScrapingJobTypeTEDScraper ScrapingJobType = "ted_scraper"
|
||||||
|
ScrapingJobTypeManual ScrapingJobType = "manual"
|
||||||
|
ScrapingJobTypeAPI ScrapingJobType = "api"
|
||||||
|
ScrapingJobTypeBulkImport ScrapingJobType = "bulk_import"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScrapingJobStatus represents the status of a scraping job
|
||||||
|
type ScrapingJobStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScrapingJobStatusPending ScrapingJobStatus = "pending"
|
||||||
|
ScrapingJobStatusRunning ScrapingJobStatus = "running"
|
||||||
|
ScrapingJobStatusCompleted ScrapingJobStatus = "completed"
|
||||||
|
ScrapingJobStatusFailed ScrapingJobStatus = "failed"
|
||||||
|
ScrapingJobStatusCancelled ScrapingJobStatus = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobProgress represents the progress of a scraping job
|
||||||
|
type JobProgress struct {
|
||||||
|
ProcessedCount int `bson:"processed_count" json:"processed_count"`
|
||||||
|
TotalCount int `bson:"total_count" json:"total_count"`
|
||||||
|
SuccessCount int `bson:"success_count" json:"success_count"`
|
||||||
|
ErrorCount int `bson:"error_count" json:"error_count"`
|
||||||
|
Percentage float64 `bson:"percentage" json:"percentage"`
|
||||||
|
CurrentStep string `bson:"current_step" json:"current_step"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobResults represents the results of a scraping job
|
||||||
|
type JobResults struct {
|
||||||
|
TotalProcessed int `bson:"total_processed" json:"total_processed"`
|
||||||
|
TotalCreated int `bson:"total_created" json:"total_created"`
|
||||||
|
TotalUpdated int `bson:"total_updated" json:"total_updated"`
|
||||||
|
TotalSkipped int `bson:"total_skipped" json:"total_skipped"`
|
||||||
|
TotalErrors int `bson:"total_errors" json:"total_errors"`
|
||||||
|
ProcessingErrors []string `bson:"processing_errors,omitempty" json:"processing_errors,omitempty"`
|
||||||
|
Summary string `bson:"summary,omitempty" json:"summary,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrapingJob represents a scraping job entity
|
||||||
|
type ScrapingJob struct {
|
||||||
|
ID string `bson:"_id,omitempty" json:"id"`
|
||||||
|
Type ScrapingJobType `bson:"type" json:"type"`
|
||||||
|
Status ScrapingJobStatus `bson:"status" json:"status"`
|
||||||
|
StartedAt int64 `bson:"started_at" json:"started_at"` // Unix seconds
|
||||||
|
CompletedAt *int64 `bson:"completed_at,omitempty" json:"completed_at,omitempty"` // Unix seconds
|
||||||
|
Duration *int64 `bson:"duration,omitempty" json:"duration,omitempty"` // Duration in seconds
|
||||||
|
Progress JobProgress `bson:"progress" json:"progress"`
|
||||||
|
Results JobResults `bson:"results" json:"results"`
|
||||||
|
Errors []string `bson:"errors,omitempty" json:"errors,omitempty"`
|
||||||
|
Config map[string]interface{} `bson:"config,omitempty" json:"config,omitempty"`
|
||||||
|
CreatedAt int64 `bson:"created_at" json:"created_at"` // Unix seconds
|
||||||
|
UpdatedAt int64 `bson:"updated_at" json:"updated_at"` // Unix seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrapingState represents the state of the scraping process
|
||||||
|
type ScrapingState struct {
|
||||||
|
ID string `bson:"_id,omitempty" json:"id"`
|
||||||
|
LastProcessedYear int `bson:"last_processed_year" json:"last_processed_year"`
|
||||||
|
LastProcessedNumber int `bson:"last_processed_number" json:"last_processed_number"`
|
||||||
|
LastRunAt int64 `bson:"last_run_at" json:"last_run_at"` // Unix seconds
|
||||||
|
NextRunAt int64 `bson:"next_run_at" json:"next_run_at"` // Unix seconds
|
||||||
|
IsRunning bool `bson:"is_running" json:"is_running"`
|
||||||
|
CurrentJobID string `bson:"current_job_id,omitempty" json:"current_job_id,omitempty"`
|
||||||
|
CreatedAt int64 `bson:"created_at" json:"created_at"` // Unix seconds
|
||||||
|
UpdatedAt int64 `bson:"updated_at" json:"updated_at"` // Unix seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJobID returns the scraping job ID
|
||||||
|
func (j *ScrapingJob) GetJobID() string {
|
||||||
|
return j.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive returns true if the job is actively running
|
||||||
|
func (j *ScrapingJob) IsActive() bool {
|
||||||
|
return j.Status == ScrapingJobStatusRunning || j.Status == ScrapingJobStatusPending
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCompleted returns true if the job has completed (successfully or with failure)
|
||||||
|
func (j *ScrapingJob) IsCompleted() bool {
|
||||||
|
return j.Status == ScrapingJobStatusCompleted || j.Status == ScrapingJobStatusFailed || j.Status == ScrapingJobStatusCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProgressPercentage calculates and returns the progress percentage
|
||||||
|
func (j *ScrapingJob) GetProgressPercentage() float64 {
|
||||||
|
if j.Progress.TotalCount == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(j.Progress.ProcessedCount) / float64(j.Progress.TotalCount) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProgress updates the job progress
|
||||||
|
func (j *ScrapingJob) UpdateProgress(processed, total, success, errors int, step string) {
|
||||||
|
j.Progress.ProcessedCount = processed
|
||||||
|
j.Progress.TotalCount = total
|
||||||
|
j.Progress.SuccessCount = success
|
||||||
|
j.Progress.ErrorCount = errors
|
||||||
|
j.Progress.CurrentStep = step
|
||||||
|
j.Progress.Percentage = j.GetProgressPercentage()
|
||||||
|
j.UpdatedAt = time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddError adds an error to the job
|
||||||
|
func (j *ScrapingJob) AddError(error string) {
|
||||||
|
if j.Errors == nil {
|
||||||
|
j.Errors = make([]string, 0)
|
||||||
|
}
|
||||||
|
j.Errors = append(j.Errors, error)
|
||||||
|
j.UpdatedAt = time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkCompleted marks the job as completed
|
||||||
|
func (j *ScrapingJob) MarkCompleted(status ScrapingJobStatus, results JobResults) {
|
||||||
|
j.Status = status
|
||||||
|
j.Results = results
|
||||||
|
completedAt := time.Now().Unix()
|
||||||
|
j.CompletedAt = &completedAt
|
||||||
|
if j.StartedAt > 0 {
|
||||||
|
duration := completedAt - j.StartedAt
|
||||||
|
j.Duration = &duration
|
||||||
|
}
|
||||||
|
j.UpdatedAt = completedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsStateRunning returns true if the scraping state indicates a job is running
|
||||||
|
func (s *ScrapingState) IsStateRunning() bool {
|
||||||
|
return s.IsRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAsRunning marks the state as running with a job ID
|
||||||
|
func (s *ScrapingState) MarkAsRunning(jobID string) {
|
||||||
|
s.IsRunning = true
|
||||||
|
s.CurrentJobID = jobID
|
||||||
|
s.LastRunAt = time.Now().Unix()
|
||||||
|
s.UpdatedAt = time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAsCompleted marks the state as completed
|
||||||
|
func (s *ScrapingState) MarkAsCompleted(year, number int) {
|
||||||
|
s.IsRunning = false
|
||||||
|
s.CurrentJobID = ""
|
||||||
|
s.LastProcessedYear = year
|
||||||
|
s.LastProcessedNumber = number
|
||||||
|
s.NextRunAt = time.Now().Add(4 * time.Hour).Unix() // Schedule next run in 4 hours
|
||||||
|
s.UpdatedAt = time.Now().Unix()
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package tender
|
||||||
|
|
||||||
|
// CreateTenderRequest represents a request to create a new tender
|
||||||
|
type CreateTenderRequest struct {
|
||||||
|
ContractNoticeID string `json:"contract_notice_id" valid:"required~Contract notice ID is required"`
|
||||||
|
NoticePublicationID string `json:"notice_publication_id" valid:"required~Notice publication ID is required"`
|
||||||
|
ContractFolderID string `json:"contract_folder_id" valid:"required~Contract folder ID is required"`
|
||||||
|
NoticeTypeCode string `json:"notice_type_code" valid:"required~Notice type code is required"`
|
||||||
|
NoticeSubTypeCode string `json:"notice_sub_type_code,omitempty"`
|
||||||
|
NoticeLanguageCode string `json:"notice_language_code,omitempty" valid:"stringlength(3|3)~Language code must be 3 characters"`
|
||||||
|
IssueDate int64 `json:"issue_date" valid:"required~Issue date is required"` // Unix milliseconds
|
||||||
|
IssueTime int64 `json:"issue_time,omitempty"` // Unix milliseconds
|
||||||
|
PublicationDate int64 `json:"publication_date,omitempty"` // Unix milliseconds
|
||||||
|
GazetteID string `json:"gazette_id,omitempty"`
|
||||||
|
Title string `json:"title" valid:"required~Title is required,stringlength(1|500)~Title must be between 1 and 500 characters"`
|
||||||
|
Description string `json:"description,omitempty" valid:"stringlength(0|5000)~Description must not exceed 5000 characters"`
|
||||||
|
ProcurementTypeCode string `json:"procurement_type_code,omitempty"`
|
||||||
|
ProcedureCode string `json:"procedure_code,omitempty"`
|
||||||
|
MainClassification string `json:"main_classification,omitempty"`
|
||||||
|
AdditionalClassifications []string `json:"additional_classifications,omitempty"`
|
||||||
|
EstimatedValue float64 `json:"estimated_value,omitempty" valid:"range(0|999999999999)~Estimated value must be between 0 and 999999999999"`
|
||||||
|
Currency string `json:"currency,omitempty" valid:"stringlength(3|3)~Currency must be 3 characters"`
|
||||||
|
Duration string `json:"duration,omitempty"`
|
||||||
|
DurationUnit string `json:"duration_unit,omitempty"`
|
||||||
|
TenderDeadline int64 `json:"tender_deadline,omitempty"` // Unix milliseconds
|
||||||
|
PlaceOfPerformance string `json:"place_of_performance,omitempty"`
|
||||||
|
CountryCode string `json:"country_code,omitempty" valid:"stringlength(2|2)~Country code must be 2 characters"`
|
||||||
|
RegionCode string `json:"region_code,omitempty"`
|
||||||
|
CityName string `json:"city_name,omitempty"`
|
||||||
|
PostalCode string `json:"postal_code,omitempty"`
|
||||||
|
DocumentURI string `json:"document_uri,omitempty" valid:"url~Document URI must be a valid URL"`
|
||||||
|
TenderURL string `json:"tender_url,omitempty" valid:"url~Tender URL must be a valid URL"`
|
||||||
|
BuyerOrganization *Organization `json:"buyer_organization,omitempty"`
|
||||||
|
ReviewOrganization *Organization `json:"review_organization,omitempty"`
|
||||||
|
Organizations []Organization `json:"organizations,omitempty"`
|
||||||
|
SelectionCriteria []SelectionCriterion `json:"selection_criteria,omitempty"`
|
||||||
|
OfficialLanguages []string `json:"official_languages,omitempty"`
|
||||||
|
Source TenderSource `json:"source" valid:"required~Source is required"`
|
||||||
|
SourceFileURL string `json:"source_file_url,omitempty" valid:"url~Source file URL must be a valid URL"`
|
||||||
|
SourceFileName string `json:"source_file_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTenderRequest represents a request to update an existing tender
|
||||||
|
type UpdateTenderRequest struct {
|
||||||
|
ID string `json:"id" valid:"required~Tender ID is required"`
|
||||||
|
Title *string `json:"title,omitempty" valid:"stringlength(1|500)~Title must be between 1 and 500 characters"`
|
||||||
|
Description *string `json:"description,omitempty" valid:"stringlength(0|5000)~Description must not exceed 5000 characters"`
|
||||||
|
Status *TenderStatus `json:"status,omitempty"`
|
||||||
|
EstimatedValue *float64 `json:"estimated_value,omitempty" valid:"range(0|999999999999)~Estimated value must be between 0 and 999999999999"`
|
||||||
|
Currency *string `json:"currency,omitempty" valid:"stringlength(3|3)~Currency must be 3 characters"`
|
||||||
|
TenderDeadline *int64 `json:"tender_deadline,omitempty"` // Unix milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTendersRequest represents a request to list tenders with pagination and filtering
|
||||||
|
type ListTendersRequest struct {
|
||||||
|
Criteria TenderSearchCriteria `json:"criteria"`
|
||||||
|
Limit int `json:"limit" valid:"range(1|100)~Limit must be between 1 and 100"`
|
||||||
|
Offset int `json:"offset" valid:"range(0|999999)~Offset must be non-negative"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTendersResponse represents the response for listing tenders
|
||||||
|
type ListTendersResponse struct {
|
||||||
|
Tenders []Tender `json:"tenders"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTendersRequest represents a request to search tenders
|
||||||
|
type SearchTendersRequest struct {
|
||||||
|
Query string `json:"query" valid:"required~Search query is required,stringlength(1|200)~Query must be between 1 and 200 characters"`
|
||||||
|
Criteria TenderSearchCriteria `json:"criteria"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTendersResponse represents the response for searching tenders
|
||||||
|
type SearchTendersResponse struct {
|
||||||
|
Tenders []Tender `json:"tenders"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartScrapingJobRequest represents a request to start a scraping job
|
||||||
|
type StartScrapingJobRequest struct {
|
||||||
|
Type ScrapingJobType `json:"type" valid:"required~Job type is required"`
|
||||||
|
Config map[string]interface{} `json:"config,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListScrapingJobsRequest represents a request to list scraping jobs
|
||||||
|
type ListScrapingJobsRequest struct {
|
||||||
|
Limit int `json:"limit" valid:"range(1|100)~Limit must be between 1 and 100"`
|
||||||
|
Offset int `json:"offset" valid:"range(0|999999)~Offset must be non-negative"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListScrapingJobsResponse represents the response for listing scraping jobs
|
||||||
|
type ListScrapingJobsResponse struct {
|
||||||
|
Jobs []ScrapingJob `json:"jobs"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchProcessingResult represents the result of batch processing
|
||||||
|
type BatchProcessingResult struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Processed int `json:"processed"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
Tenders []*Tender `json:"tenders"`
|
||||||
|
SuccessRate float64 `json:"success_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardData represents dashboard data
|
||||||
|
type DashboardData struct {
|
||||||
|
Statistics *TenderStatistics `json:"statistics"`
|
||||||
|
RecentTenders []Tender `json:"recent_tenders"`
|
||||||
|
ActiveJobs []ScrapingJob `json:"active_jobs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenderResponse represents a tender in API responses
|
||||||
|
type TenderResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ContractNoticeID string `json:"contract_notice_id"`
|
||||||
|
NoticePublicationID string `json:"notice_publication_id"`
|
||||||
|
ContractFolderID string `json:"contract_folder_id"`
|
||||||
|
NoticeTypeCode string `json:"notice_type_code"`
|
||||||
|
NoticeSubTypeCode string `json:"notice_sub_type_code"`
|
||||||
|
NoticeLanguageCode string `json:"notice_language_code"`
|
||||||
|
IssueDate int64 `json:"issue_date"`
|
||||||
|
IssueTime int64 `json:"issue_time"`
|
||||||
|
PublicationDate int64 `json:"publication_date"`
|
||||||
|
GazetteID string `json:"gazette_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ProcurementTypeCode string `json:"procurement_type_code"`
|
||||||
|
ProcedureCode string `json:"procedure_code"`
|
||||||
|
MainClassification string `json:"main_classification"`
|
||||||
|
AdditionalClassifications []string `json:"additional_classifications"`
|
||||||
|
EstimatedValue float64 `json:"estimated_value"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
FormattedEstimatedValue string `json:"formatted_estimated_value"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
DurationUnit string `json:"duration_unit"`
|
||||||
|
TenderDeadline int64 `json:"tender_deadline"`
|
||||||
|
PlaceOfPerformance string `json:"place_of_performance"`
|
||||||
|
CountryCode string `json:"country_code"`
|
||||||
|
RegionCode string `json:"region_code"`
|
||||||
|
CityName string `json:"city_name"`
|
||||||
|
PostalCode string `json:"postal_code"`
|
||||||
|
DocumentURI string `json:"document_uri"`
|
||||||
|
TenderURL string `json:"tender_url"`
|
||||||
|
BuyerOrganization *Organization `json:"buyer_organization"`
|
||||||
|
ReviewOrganization *Organization `json:"review_organization,omitempty"`
|
||||||
|
Organizations []Organization `json:"organizations"`
|
||||||
|
SelectionCriteria []SelectionCriterion `json:"selection_criteria"`
|
||||||
|
OfficialLanguages []string `json:"official_languages"`
|
||||||
|
Status TenderStatus `json:"status"`
|
||||||
|
Source TenderSource `json:"source"`
|
||||||
|
SourceFileURL string `json:"source_file_url"`
|
||||||
|
SourceFileName string `json:"source_file_name"`
|
||||||
|
ProcessingMetadata ProcessingMetadata `json:"processing_metadata"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
IsExpired bool `json:"is_expired"`
|
||||||
|
HasErrors bool `json:"has_errors"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrapingJobResponse represents a scraping job in API responses
|
||||||
|
type ScrapingJobResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type ScrapingJobType `json:"type"`
|
||||||
|
Status ScrapingJobStatus `json:"status"`
|
||||||
|
StartedAt int64 `json:"started_at"`
|
||||||
|
CompletedAt *int64 `json:"completed_at,omitempty"`
|
||||||
|
Duration *int64 `json:"duration,omitempty"`
|
||||||
|
Progress JobProgress `json:"progress"`
|
||||||
|
Results JobResults `json:"results"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
Config map[string]interface{} `json:"config,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrapingStateResponse represents scraping state in API responses
|
||||||
|
type ScrapingStateResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
LastProcessedYear int `json:"last_processed_year"`
|
||||||
|
LastProcessedNumber int `json:"last_processed_number"`
|
||||||
|
LastRunAt int64 `json:"last_run_at"`
|
||||||
|
NextRunAt int64 `json:"next_run_at"`
|
||||||
|
IsRunning bool `json:"is_running"`
|
||||||
|
CurrentJobID string `json:"current_job_id,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationErrorResponse represents validation errors in API responses
|
||||||
|
type ValidationErrorResponse struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Value string `json:"value,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorResponse represents error responses
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ValidationErrors []ValidationErrorResponse `json:"validation_errors,omitempty"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToTenderResponse converts a Tender entity to TenderResponse
|
||||||
|
func (t *Tender) ToTenderResponse() *TenderResponse {
|
||||||
|
return &TenderResponse{
|
||||||
|
ID: t.ID.Hex(),
|
||||||
|
ContractNoticeID: t.ContractNoticeID,
|
||||||
|
NoticePublicationID: t.NoticePublicationID,
|
||||||
|
ContractFolderID: t.ContractFolderID,
|
||||||
|
NoticeTypeCode: t.NoticeTypeCode,
|
||||||
|
NoticeSubTypeCode: t.NoticeSubTypeCode,
|
||||||
|
NoticeLanguageCode: t.NoticeLanguageCode,
|
||||||
|
IssueDate: t.IssueDate,
|
||||||
|
IssueTime: t.IssueTime,
|
||||||
|
PublicationDate: t.PublicationDate,
|
||||||
|
GazetteID: t.GazetteID,
|
||||||
|
Title: t.Title,
|
||||||
|
Description: t.Description,
|
||||||
|
ProcurementTypeCode: t.ProcurementTypeCode,
|
||||||
|
ProcedureCode: t.ProcedureCode,
|
||||||
|
MainClassification: t.MainClassification,
|
||||||
|
AdditionalClassifications: t.AdditionalClassifications,
|
||||||
|
EstimatedValue: t.EstimatedValue,
|
||||||
|
Currency: t.Currency,
|
||||||
|
FormattedEstimatedValue: t.GetFormattedEstimatedValue(),
|
||||||
|
Duration: t.Duration,
|
||||||
|
DurationUnit: t.DurationUnit,
|
||||||
|
TenderDeadline: t.TenderDeadline,
|
||||||
|
PlaceOfPerformance: t.PlaceOfPerformance,
|
||||||
|
CountryCode: t.CountryCode,
|
||||||
|
RegionCode: t.RegionCode,
|
||||||
|
CityName: t.CityName,
|
||||||
|
PostalCode: t.PostalCode,
|
||||||
|
DocumentURI: t.DocumentURI,
|
||||||
|
TenderURL: t.TenderURL,
|
||||||
|
BuyerOrganization: t.BuyerOrganization,
|
||||||
|
ReviewOrganization: t.ReviewOrganization,
|
||||||
|
Organizations: t.Organizations,
|
||||||
|
SelectionCriteria: t.SelectionCriteria,
|
||||||
|
OfficialLanguages: t.OfficialLanguages,
|
||||||
|
Status: t.Status,
|
||||||
|
Source: t.Source,
|
||||||
|
SourceFileURL: t.SourceFileURL,
|
||||||
|
SourceFileName: t.SourceFileName,
|
||||||
|
ProcessingMetadata: t.ProcessingMetadata,
|
||||||
|
IsActive: t.IsActive(),
|
||||||
|
IsExpired: t.IsExpired(),
|
||||||
|
HasErrors: t.HasErrors(),
|
||||||
|
CreatedAt: t.CreatedAt,
|
||||||
|
UpdatedAt: t.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToScrapingJobResponse converts a ScrapingJob entity to ScrapingJobResponse
|
||||||
|
func (j *ScrapingJob) ToScrapingJobResponse() *ScrapingJobResponse {
|
||||||
|
return &ScrapingJobResponse{
|
||||||
|
ID: j.ID,
|
||||||
|
Type: j.Type,
|
||||||
|
Status: j.Status,
|
||||||
|
StartedAt: j.StartedAt,
|
||||||
|
CompletedAt: j.CompletedAt,
|
||||||
|
Duration: j.Duration,
|
||||||
|
Progress: j.Progress,
|
||||||
|
Results: j.Results,
|
||||||
|
Errors: j.Errors,
|
||||||
|
Config: j.Config,
|
||||||
|
CreatedAt: j.CreatedAt,
|
||||||
|
UpdatedAt: j.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToScrapingStateResponse converts a ScrapingState entity to ScrapingStateResponse
|
||||||
|
func (s *ScrapingState) ToScrapingStateResponse() *ScrapingStateResponse {
|
||||||
|
return &ScrapingStateResponse{
|
||||||
|
ID: s.ID,
|
||||||
|
LastProcessedYear: s.LastProcessedYear,
|
||||||
|
LastProcessedNumber: s.LastProcessedNumber,
|
||||||
|
LastRunAt: s.LastRunAt,
|
||||||
|
NextRunAt: s.NextRunAt,
|
||||||
|
IsRunning: s.IsRunning,
|
||||||
|
CurrentJobID: s.CurrentJobID,
|
||||||
|
CreatedAt: s.CreatedAt,
|
||||||
|
UpdatedAt: s.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
package tender
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
"tm/pkg/response"
|
||||||
|
|
||||||
|
"github.com/asaskevich/govalidator"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenderHandler handles tender-related HTTP requests
|
||||||
|
type TenderHandler struct {
|
||||||
|
service TenderService
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTenderHandler creates a new tender handler
|
||||||
|
func NewTenderHandler(service TenderService, logger logger.Logger) *TenderHandler {
|
||||||
|
return &TenderHandler{
|
||||||
|
service: service,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTender creates a new tender
|
||||||
|
// @Summary Create a new tender
|
||||||
|
// @Description Create a new tender with the provided information
|
||||||
|
// @Tags Admin-Tenders
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param tender body CreateTenderRequest true "Tender information"
|
||||||
|
// @Success 201 {object} response.APIResponse{data=TenderResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 409 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /admin/tenders [post]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func (h *TenderHandler) CreateTender(c echo.Context) error {
|
||||||
|
var req CreateTenderRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := govalidator.ValidateStruct(req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
tender, err := h.service.CreateTender(c.Request().Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "tender with notice publication ID already exists" {
|
||||||
|
return response.Conflict(c, "Tender already exists")
|
||||||
|
}
|
||||||
|
return response.InternalServerError(c, "Failed to create tender")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Created(c, tender.ToTenderResponse(), "Tender created successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTender retrieves a tender by ID
|
||||||
|
// @Summary Get tender by ID
|
||||||
|
// @Description Retrieve a specific tender by its ID
|
||||||
|
// @Tags Admin-Tenders
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Tender ID"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=TenderResponse}
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /admin/tenders/{id} [get]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func (h *TenderHandler) GetTender(c echo.Context) error {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
tender, err := h.service.GetTenderByID(c.Request().Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
return response.NotFound(c, "Tender not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, tender.ToTenderResponse(), "Tender retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTender updates an existing tender
|
||||||
|
// @Summary Update tender
|
||||||
|
// @Description Update an existing tender with the provided information
|
||||||
|
// @Tags Admin-Tenders
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Tender ID"
|
||||||
|
// @Param tender body UpdateTenderRequest true "Updated tender information"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=TenderResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /admin/tenders/{id} [put]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func (h *TenderHandler) UpdateTender(c echo.Context) error {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateTenderRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
req.ID = id
|
||||||
|
|
||||||
|
if _, err := govalidator.ValidateStruct(req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
tender, err := h.service.UpdateTender(c.Request().Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "tender not found" {
|
||||||
|
return response.NotFound(c, "Tender not found")
|
||||||
|
}
|
||||||
|
return response.InternalServerError(c, "Failed to update tender")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, tender.ToTenderResponse(), "Tender updated successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTender deletes a tender
|
||||||
|
// @Summary Delete tender
|
||||||
|
// @Description Delete a tender by its ID
|
||||||
|
// @Tags Admin-Tenders
|
||||||
|
// @Param id path string true "Tender ID"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /admin/tenders/{id} [delete]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func (h *TenderHandler) DeleteTender(c echo.Context) error {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.service.DeleteTender(c.Request().Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "tender not found" {
|
||||||
|
return response.NotFound(c, "Tender not found")
|
||||||
|
}
|
||||||
|
return response.InternalServerError(c, "Failed to delete tender")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, map[string]interface{}{"deleted": true}, "Tender deleted successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTenders retrieves tenders with pagination and filtering
|
||||||
|
// @Summary List tenders
|
||||||
|
// @Description Retrieve tenders with pagination, filtering, and search capabilities
|
||||||
|
// @Tags Admin-Tenders
|
||||||
|
// @Produce json
|
||||||
|
// @Param limit query int false "Number of items per page (default: 20, max: 100)"
|
||||||
|
// @Param offset query int false "Number of items to skip (default: 0)"
|
||||||
|
// @Param query query string false "Search query for title and description"
|
||||||
|
// @Param notice_type query string false "Filter by notice type code"
|
||||||
|
// @Param procurement_type query string false "Filter by procurement type code"
|
||||||
|
// @Param country_codes query []string false "Filter by country codes (comma-separated)"
|
||||||
|
// @Param status query []string false "Filter by status (comma-separated)"
|
||||||
|
// @Param source query []string false "Filter by source (comma-separated)"
|
||||||
|
// @Param min_estimated_value query number false "Minimum estimated value"
|
||||||
|
// @Param max_estimated_value query number false "Maximum estimated value"
|
||||||
|
// @Param currency query string false "Filter by currency"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /admin/tenders [get]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func (h *TenderHandler) ListTenders(c echo.Context) error {
|
||||||
|
// Parse pagination parameters
|
||||||
|
limit := 20
|
||||||
|
if l := c.QueryParam("limit"); l != "" {
|
||||||
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
if o := c.QueryParam("offset"); o != "" {
|
||||||
|
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
||||||
|
offset = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build search criteria
|
||||||
|
criteria := TenderSearchCriteria{
|
||||||
|
Query: c.QueryParam("query"),
|
||||||
|
NoticeType: c.QueryParam("notice_type"),
|
||||||
|
ProcurementType: c.QueryParam("procurement_type"),
|
||||||
|
Currency: c.QueryParam("currency"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse array parameters
|
||||||
|
if countryCodes := c.QueryParam("country_codes"); countryCodes != "" {
|
||||||
|
criteria.CountryCodes = parseStringArray(countryCodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if statuses := c.QueryParam("status"); statuses != "" {
|
||||||
|
statusStrings := parseStringArray(statuses)
|
||||||
|
criteria.Status = make([]TenderStatus, len(statusStrings))
|
||||||
|
for i, s := range statusStrings {
|
||||||
|
criteria.Status[i] = TenderStatus(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sources := c.QueryParam("source"); sources != "" {
|
||||||
|
sourceStrings := parseStringArray(sources)
|
||||||
|
criteria.Source = make([]TenderSource, len(sourceStrings))
|
||||||
|
for i, s := range sourceStrings {
|
||||||
|
criteria.Source[i] = TenderSource(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse numeric parameters
|
||||||
|
if minValue := c.QueryParam("min_estimated_value"); minValue != "" {
|
||||||
|
if parsed, err := strconv.ParseFloat(minValue, 64); err == nil {
|
||||||
|
criteria.MinEstimatedValue = &parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxValue := c.QueryParam("max_estimated_value"); maxValue != "" {
|
||||||
|
if parsed, err := strconv.ParseFloat(maxValue, 64); err == nil {
|
||||||
|
criteria.MaxEstimatedValue = &parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := ListTendersRequest{
|
||||||
|
Criteria: criteria,
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.ListTenders(c.Request().Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
return response.InternalServerError(c, "Failed to retrieve tenders")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert tenders to response format
|
||||||
|
tenderResponses := make([]TenderResponse, len(result.Tenders))
|
||||||
|
for i, tender := range result.Tenders {
|
||||||
|
tenderResponses[i] = *tender.ToTenderResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
responseData := map[string]interface{}{
|
||||||
|
"tenders": tenderResponses,
|
||||||
|
"total": result.Total,
|
||||||
|
"limit": result.Limit,
|
||||||
|
"offset": result.Offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, responseData, "Tenders retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTenders searches tenders
|
||||||
|
// @Summary Search tenders
|
||||||
|
// @Description Search tenders using full-text search and filtering
|
||||||
|
// @Tags Admin-Tenders
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param search body SearchTendersRequest true "Search criteria"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /admin/tenders/search [post]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func (h *TenderHandler) SearchTenders(c echo.Context) error {
|
||||||
|
var req SearchTendersRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := govalidator.ValidateStruct(req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.SearchTenders(c.Request().Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
return response.InternalServerError(c, "Failed to search tenders")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert tenders to response format
|
||||||
|
tenderResponses := make([]TenderResponse, len(result.Tenders))
|
||||||
|
for i, tender := range result.Tenders {
|
||||||
|
tenderResponses[i] = *tender.ToTenderResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
responseData := map[string]interface{}{
|
||||||
|
"tenders": tenderResponses,
|
||||||
|
"query": result.Query,
|
||||||
|
"total": result.Total,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, responseData, "Tenders found successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenderStatistics retrieves tender statistics
|
||||||
|
// @Summary Get tender statistics
|
||||||
|
// @Description Retrieve comprehensive statistics about tenders
|
||||||
|
// @Tags Admin-Tenders
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} response.APIResponse{data=TenderStatistics}
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /admin/tenders/statistics [get]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func (h *TenderHandler) GetTenderStatistics(c echo.Context) error {
|
||||||
|
stats, err := h.service.GetTenderStatistics(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return response.InternalServerError(c, "Failed to retrieve statistics")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, stats, "Statistics retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDashboard retrieves dashboard data
|
||||||
|
// @Summary Get dashboard data
|
||||||
|
// @Description Retrieve dashboard data including statistics, recent tenders, and active jobs
|
||||||
|
// @Tags Admin-Tenders
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /admin/tenders/dashboard [get]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func (h *TenderHandler) GetDashboard(c echo.Context) error {
|
||||||
|
dashboard, err := h.service.GetDashboardData(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return response.InternalServerError(c, "Failed to retrieve dashboard data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert recent tenders to response format
|
||||||
|
recentTenderResponses := make([]TenderResponse, len(dashboard.RecentTenders))
|
||||||
|
for i, tender := range dashboard.RecentTenders {
|
||||||
|
recentTenderResponses[i] = *tender.ToTenderResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert active jobs to response format
|
||||||
|
activeJobResponses := make([]ScrapingJobResponse, len(dashboard.ActiveJobs))
|
||||||
|
for i, job := range dashboard.ActiveJobs {
|
||||||
|
activeJobResponses[i] = *job.ToScrapingJobResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
responseData := map[string]interface{}{
|
||||||
|
"statistics": dashboard.Statistics,
|
||||||
|
"recent_tenders": recentTenderResponses,
|
||||||
|
"active_jobs": activeJobResponses,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, responseData, "Dashboard data retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartScrapingJob starts a new scraping job
|
||||||
|
// @Summary Start scraping job
|
||||||
|
// @Description Start a new TED XML scraping job
|
||||||
|
// @Tags Admin-Scraping
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param job body StartScrapingJobRequest true "Scraping job configuration"
|
||||||
|
// @Success 201 {object} response.APIResponse{data=ScrapingJobResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /admin/scraping/jobs [post]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func (h *TenderHandler) StartScrapingJob(c echo.Context) error {
|
||||||
|
var req StartScrapingJobRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return response.ValidationError(c, "Invalid request format", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := govalidator.ValidateStruct(req); err != nil {
|
||||||
|
return response.ValidationError(c, "Validation failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := h.service.StartScrapingJob(c.Request().Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
return response.InternalServerError(c, "Failed to start scraping job")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Created(c, job.ToScrapingJobResponse(), "Scraping job started successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetScrapingJob retrieves a scraping job by ID
|
||||||
|
// @Summary Get scraping job
|
||||||
|
// @Description Retrieve a specific scraping job by its ID
|
||||||
|
// @Tags Admin-Scraping
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Scraping job ID"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=ScrapingJobResponse}
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /admin/scraping/jobs/{id} [get]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func (h *TenderHandler) GetScrapingJob(c echo.Context) error {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
return response.BadRequest(c, "Scraping job ID is required", "ID parameter cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := h.service.GetScrapingJobStatus(c.Request().Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
return response.NotFound(c, "Scraping job not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, job.ToScrapingJobResponse(), "Scraping job retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListScrapingJobs retrieves scraping jobs with pagination
|
||||||
|
// @Summary List scraping jobs
|
||||||
|
// @Description Retrieve scraping jobs with pagination
|
||||||
|
// @Tags Admin-Scraping
|
||||||
|
// @Produce json
|
||||||
|
// @Param limit query int false "Number of items per page (default: 20, max: 100)"
|
||||||
|
// @Param offset query int false "Number of items to skip (default: 0)"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /admin/scraping/jobs [get]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func (h *TenderHandler) ListScrapingJobs(c echo.Context) error {
|
||||||
|
// Parse pagination parameters
|
||||||
|
limit := 20
|
||||||
|
if l := c.QueryParam("limit"); l != "" {
|
||||||
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
if o := c.QueryParam("offset"); o != "" {
|
||||||
|
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
||||||
|
offset = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := ListScrapingJobsRequest{
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.ListScrapingJobs(c.Request().Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
return response.InternalServerError(c, "Failed to retrieve scraping jobs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert jobs to response format
|
||||||
|
jobResponses := make([]ScrapingJobResponse, len(result.Jobs))
|
||||||
|
for i, job := range result.Jobs {
|
||||||
|
jobResponses[i] = *job.ToScrapingJobResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
responseData := map[string]interface{}{
|
||||||
|
"jobs": jobResponses,
|
||||||
|
"total": result.Total,
|
||||||
|
"limit": result.Limit,
|
||||||
|
"offset": result.Offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, responseData, "Scraping jobs retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelScrapingJob cancels a running scraping job
|
||||||
|
// @Summary Cancel scraping job
|
||||||
|
// @Description Cancel a running or pending scraping job
|
||||||
|
// @Tags Admin-Scraping
|
||||||
|
// @Param id path string true "Scraping job ID"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /admin/scraping/jobs/{id}/cancel [post]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func (h *TenderHandler) CancelScrapingJob(c echo.Context) error {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
return response.BadRequest(c, "Scraping job ID is required", "ID parameter cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.service.CancelScrapingJob(c.Request().Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "scraping job not found" {
|
||||||
|
return response.NotFound(c, "Scraping job not found")
|
||||||
|
}
|
||||||
|
return response.InternalServerError(c, "Failed to cancel scraping job")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, map[string]interface{}{"cancelled": true}, "Scraping job cancelled successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicTenders retrieves public tenders for mobile app
|
||||||
|
// @Summary Get public tenders
|
||||||
|
// @Description Retrieve active tenders for mobile application users
|
||||||
|
// @Tags Tenders
|
||||||
|
// @Produce json
|
||||||
|
// @Param limit query int false "Number of items per page (default: 20, max: 50)"
|
||||||
|
// @Param offset query int false "Number of items to skip (default: 0)"
|
||||||
|
// @Param country_code query string false "Filter by country code"
|
||||||
|
// @Param min_estimated_value query number false "Minimum estimated value"
|
||||||
|
// @Param max_estimated_value query number false "Maximum estimated value"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /tenders [get]
|
||||||
|
func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
|
||||||
|
// Parse pagination parameters (more restrictive for public API)
|
||||||
|
limit := 20
|
||||||
|
if l := c.QueryParam("limit"); l != "" {
|
||||||
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 50 {
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
if o := c.QueryParam("offset"); o != "" {
|
||||||
|
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
||||||
|
offset = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build search criteria for public API (only active tenders)
|
||||||
|
criteria := TenderSearchCriteria{
|
||||||
|
Status: []TenderStatus{TenderStatusActive},
|
||||||
|
}
|
||||||
|
|
||||||
|
if countryCode := c.QueryParam("country_code"); countryCode != "" {
|
||||||
|
criteria.CountryCodes = []string{countryCode}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse numeric parameters
|
||||||
|
if minValue := c.QueryParam("min_estimated_value"); minValue != "" {
|
||||||
|
if parsed, err := strconv.ParseFloat(minValue, 64); err == nil {
|
||||||
|
criteria.MinEstimatedValue = &parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxValue := c.QueryParam("max_estimated_value"); maxValue != "" {
|
||||||
|
if parsed, err := strconv.ParseFloat(maxValue, 64); err == nil {
|
||||||
|
criteria.MaxEstimatedValue = &parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := ListTendersRequest{
|
||||||
|
Criteria: criteria,
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.ListTenders(c.Request().Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
return response.InternalServerError(c, "Failed to retrieve tenders")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert tenders to public response format (limited fields)
|
||||||
|
publicTenders := make([]map[string]interface{}, len(result.Tenders))
|
||||||
|
for i, tender := range result.Tenders {
|
||||||
|
publicTenders[i] = map[string]interface{}{
|
||||||
|
"id": tender.ID,
|
||||||
|
"title": tender.Title,
|
||||||
|
"description": tender.Description,
|
||||||
|
"procurement_type_code": tender.ProcurementTypeCode,
|
||||||
|
"main_classification": tender.MainClassification,
|
||||||
|
"estimated_value": tender.EstimatedValue,
|
||||||
|
"currency": tender.Currency,
|
||||||
|
"formatted_estimated_value": tender.GetFormattedEstimatedValue(),
|
||||||
|
"tender_deadline": tender.TenderDeadline,
|
||||||
|
"country_code": tender.CountryCode,
|
||||||
|
"city_name": tender.CityName,
|
||||||
|
"tender_url": tender.TenderURL,
|
||||||
|
"publication_date": tender.PublicationDate,
|
||||||
|
"buyer_organization": tender.BuyerOrganization,
|
||||||
|
"is_expired": tender.IsExpired(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
responseData := map[string]interface{}{
|
||||||
|
"tenders": publicTenders,
|
||||||
|
"total": result.Total,
|
||||||
|
"limit": result.Limit,
|
||||||
|
"offset": result.Offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, responseData, "Tenders retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicTenderDetails retrieves public tender details for mobile app
|
||||||
|
// @Summary Get public tender details
|
||||||
|
// @Description Retrieve detailed information about a specific tender for mobile application users
|
||||||
|
// @Tags Tenders
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Tender ID"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /tenders/{id} [get]
|
||||||
|
func (h *TenderHandler) GetPublicTenderDetails(c echo.Context) error {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
tender, err := h.service.GetTenderByID(c.Request().Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
return response.NotFound(c, "Tender not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return public-safe tender details
|
||||||
|
publicTender := map[string]interface{}{
|
||||||
|
"id": tender.ID,
|
||||||
|
"notice_publication_id": tender.NoticePublicationID,
|
||||||
|
"title": tender.Title,
|
||||||
|
"description": tender.Description,
|
||||||
|
"procurement_type_code": tender.ProcurementTypeCode,
|
||||||
|
"procedure_code": tender.ProcedureCode,
|
||||||
|
"main_classification": tender.MainClassification,
|
||||||
|
"additional_classifications": tender.AdditionalClassifications,
|
||||||
|
"estimated_value": tender.EstimatedValue,
|
||||||
|
"currency": tender.Currency,
|
||||||
|
"formatted_estimated_value": tender.GetFormattedEstimatedValue(),
|
||||||
|
"duration": tender.Duration,
|
||||||
|
"duration_unit": tender.DurationUnit,
|
||||||
|
"tender_deadline": tender.TenderDeadline,
|
||||||
|
"place_of_performance": tender.PlaceOfPerformance,
|
||||||
|
"country_code": tender.CountryCode,
|
||||||
|
"region_code": tender.RegionCode,
|
||||||
|
"city_name": tender.CityName,
|
||||||
|
"postal_code": tender.PostalCode,
|
||||||
|
"document_uri": tender.DocumentURI,
|
||||||
|
"tender_url": tender.TenderURL,
|
||||||
|
"buyer_organization": tender.BuyerOrganization,
|
||||||
|
"organizations": tender.Organizations,
|
||||||
|
"selection_criteria": tender.SelectionCriteria,
|
||||||
|
"official_languages": tender.OfficialLanguages,
|
||||||
|
"publication_date": tender.PublicationDate,
|
||||||
|
"gazette_id": tender.GazetteID,
|
||||||
|
"is_active": tender.IsActive(),
|
||||||
|
"is_expired": tender.IsExpired(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, publicTender, "Tender details retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStringArray parses a comma-separated string into a string array
|
||||||
|
func parseStringArray(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0)
|
||||||
|
for _, item := range strings.Split(s, ",") {
|
||||||
|
if trimmed := strings.TrimSpace(item); trimmed != "" {
|
||||||
|
result = append(result, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,828 @@
|
|||||||
|
package tender
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
mongopkg "tm/pkg/mongo"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenderRepository interface defines tender data access methods
|
||||||
|
type TenderRepository interface {
|
||||||
|
// Tender CRUD operations
|
||||||
|
Create(ctx context.Context, tender *Tender) error
|
||||||
|
GetByID(ctx context.Context, id string) (*Tender, error)
|
||||||
|
GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Tender, error)
|
||||||
|
GetByNoticePublicationID(ctx context.Context, noticePublicationID string) (*Tender, error)
|
||||||
|
Update(ctx context.Context, tender *Tender) error
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
List(ctx context.Context, criteria TenderSearchCriteria, limit, offset int) ([]Tender, int64, error)
|
||||||
|
|
||||||
|
// Bulk operations
|
||||||
|
CreateBatch(ctx context.Context, tenders []Tender) error
|
||||||
|
UpdateBatch(ctx context.Context, tenders []Tender) error
|
||||||
|
|
||||||
|
// Search and filtering
|
||||||
|
Search(ctx context.Context, criteria TenderSearchCriteria) ([]Tender, error)
|
||||||
|
GetByCountryCode(ctx context.Context, countryCode string, limit, offset int) ([]Tender, error)
|
||||||
|
GetByStatus(ctx context.Context, status TenderStatus, limit, offset int) ([]Tender, error)
|
||||||
|
GetExpiredTenders(ctx context.Context, beforeDate int64) ([]Tender, error)
|
||||||
|
GetActiveTenders(ctx context.Context, limit, offset int) ([]Tender, error)
|
||||||
|
|
||||||
|
// Statistics and aggregations
|
||||||
|
GetStatistics(ctx context.Context) (*TenderStatistics, error)
|
||||||
|
GetTenderCountByCountry(ctx context.Context) (map[string]int64, error)
|
||||||
|
GetTenderCountByType(ctx context.Context) (map[string]int64, error)
|
||||||
|
GetTenderCountByClassification(ctx context.Context) (map[string]int64, error)
|
||||||
|
|
||||||
|
// Scraping state management
|
||||||
|
GetScrapingState(ctx context.Context) (*ScrapingState, error)
|
||||||
|
SaveScrapingState(ctx context.Context, state *ScrapingState) error
|
||||||
|
|
||||||
|
// Scraping jobs management
|
||||||
|
CreateScrapingJob(ctx context.Context, job *ScrapingJob) error
|
||||||
|
GetScrapingJob(ctx context.Context, id string) (*ScrapingJob, error)
|
||||||
|
UpdateScrapingJob(ctx context.Context, job *ScrapingJob) error
|
||||||
|
ListScrapingJobs(ctx context.Context, limit, offset int) ([]ScrapingJob, int64, error)
|
||||||
|
GetActiveScrapingJobs(ctx context.Context) ([]ScrapingJob, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenderRepository implements TenderRepository interface using MongoDB ORM
|
||||||
|
type tenderRepository struct {
|
||||||
|
ormRepo mongopkg.Repository[Tender]
|
||||||
|
stateOrmRepo mongopkg.Repository[ScrapingState]
|
||||||
|
jobOrmRepo mongopkg.Repository[ScrapingJob]
|
||||||
|
mongoManager *mongopkg.ConnectionManager
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTenderRepository creates a new tender repository
|
||||||
|
func NewTenderRepository(mongoManager *mongopkg.ConnectionManager, logger logger.Logger) TenderRepository {
|
||||||
|
// Create indexes for tenders collection
|
||||||
|
tenderIndexes := []mongopkg.Index{
|
||||||
|
*mongopkg.CreateUniqueIndex("notice_publication_id_idx", bson.D{{Key: "notice_publication_id", Value: 1}}),
|
||||||
|
*mongopkg.NewIndex("contract_notice_id_idx", bson.D{{Key: "contract_notice_id", Value: 1}}),
|
||||||
|
*mongopkg.NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}),
|
||||||
|
*mongopkg.NewIndex("source_idx", bson.D{{Key: "source", Value: 1}}),
|
||||||
|
*mongopkg.NewIndex("country_code_idx", bson.D{{Key: "country_code", Value: 1}}),
|
||||||
|
*mongopkg.NewIndex("notice_type_code_idx", bson.D{{Key: "notice_type_code", Value: 1}}),
|
||||||
|
*mongopkg.NewIndex("procurement_type_code_idx", bson.D{{Key: "procurement_type_code", Value: 1}}),
|
||||||
|
*mongopkg.NewIndex("main_classification_idx", bson.D{{Key: "main_classification", Value: 1}}),
|
||||||
|
*mongopkg.NewIndex("publication_date_idx", bson.D{{Key: "publication_date", Value: -1}}),
|
||||||
|
*mongopkg.NewIndex("tender_deadline_idx", bson.D{{Key: "tender_deadline", Value: 1}}),
|
||||||
|
*mongopkg.NewIndex("estimated_value_idx", bson.D{{Key: "estimated_value", Value: -1}}),
|
||||||
|
*mongopkg.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
|
||||||
|
*mongopkg.NewIndex("updated_at_idx", bson.D{{Key: "updated_at", Value: -1}}),
|
||||||
|
// Compound indexes for common queries
|
||||||
|
*mongopkg.NewIndex("status_country_idx", bson.D{
|
||||||
|
{Key: "status", Value: 1},
|
||||||
|
{Key: "country_code", Value: 1},
|
||||||
|
}),
|
||||||
|
*mongopkg.NewIndex("notice_type_status_idx", bson.D{
|
||||||
|
{Key: "notice_type_code", Value: 1},
|
||||||
|
{Key: "status", Value: 1},
|
||||||
|
}),
|
||||||
|
// Text index for search
|
||||||
|
*mongopkg.CreateTextIndex("search_idx", "title", "description"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create indexes for scraping jobs collection
|
||||||
|
jobIndexes := []mongopkg.Index{
|
||||||
|
*mongopkg.NewIndex("type_idx", bson.D{{Key: "type", Value: 1}}),
|
||||||
|
*mongopkg.NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}),
|
||||||
|
*mongopkg.NewIndex("started_at_idx", bson.D{{Key: "started_at", Value: -1}}),
|
||||||
|
*mongopkg.NewIndex("type_status_idx", bson.D{
|
||||||
|
{Key: "type", Value: 1},
|
||||||
|
{Key: "status", Value: 1},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
if err := mongoManager.CreateIndexes("tenders", tenderIndexes); err != nil {
|
||||||
|
logger.Warn("Failed to create tender indexes", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mongoManager.CreateIndexes("scraping_jobs", jobIndexes); err != nil {
|
||||||
|
logger.Warn("Failed to create scraping job indexes", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ORM repositories
|
||||||
|
tenderOrmRepo := mongopkg.NewRepository[Tender](mongoManager.GetCollection("tenders"), logger)
|
||||||
|
stateOrmRepo := mongopkg.NewRepository[ScrapingState](mongoManager.GetCollection("scraping_state"), logger)
|
||||||
|
jobOrmRepo := mongopkg.NewRepository[ScrapingJob](mongoManager.GetCollection("scraping_jobs"), logger)
|
||||||
|
|
||||||
|
return &tenderRepository{
|
||||||
|
ormRepo: tenderOrmRepo,
|
||||||
|
stateOrmRepo: stateOrmRepo,
|
||||||
|
jobOrmRepo: jobOrmRepo,
|
||||||
|
mongoManager: mongoManager,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new tender
|
||||||
|
func (r *tenderRepository) Create(ctx context.Context, tender *Tender) error {
|
||||||
|
err := r.ormRepo.Create(ctx, tender)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to create tender", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"notice_publication_id": tender.NoticePublicationID,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Tender created successfully", map[string]interface{}{
|
||||||
|
"tender_id": tender.ID,
|
||||||
|
"notice_publication_id": tender.NoticePublicationID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID retrieves a tender by ID
|
||||||
|
func (r *tenderRepository) GetByID(ctx context.Context, id string) (*Tender, error) {
|
||||||
|
tender, err := r.ormRepo.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to get tender by ID", map[string]interface{}{
|
||||||
|
"tender_id": id,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tender, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByContractNoticeID retrieves a tender by contract notice ID
|
||||||
|
func (r *tenderRepository) GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Tender, error) {
|
||||||
|
filter := bson.M{"contract_notice_id": contractNoticeID}
|
||||||
|
pagination := mongopkg.Pagination{Limit: 1}
|
||||||
|
|
||||||
|
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to get tender by contract notice ID", map[string]interface{}{
|
||||||
|
"contract_notice_id": contractNoticeID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Items) == 0 {
|
||||||
|
return nil, mongopkg.ErrDocumentNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result.Items[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByNoticePublicationID retrieves a tender by notice publication ID
|
||||||
|
func (r *tenderRepository) GetByNoticePublicationID(ctx context.Context, noticePublicationID string) (*Tender, error) {
|
||||||
|
filter := bson.M{"notice_publication_id": noticePublicationID}
|
||||||
|
pagination := mongopkg.Pagination{Limit: 1}
|
||||||
|
|
||||||
|
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to get tender by notice publication ID", map[string]interface{}{
|
||||||
|
"notice_publication_id": noticePublicationID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Items) == 0 {
|
||||||
|
return nil, mongopkg.ErrDocumentNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result.Items[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates an existing tender
|
||||||
|
func (r *tenderRepository) Update(ctx context.Context, tender *Tender) error {
|
||||||
|
tender.UpdatedAt = time.Now().Unix()
|
||||||
|
|
||||||
|
err := r.ormRepo.Update(ctx, tender)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to update tender", map[string]interface{}{
|
||||||
|
"tender_id": tender.ID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Tender updated successfully", map[string]interface{}{
|
||||||
|
"tender_id": tender.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a tender by ID
|
||||||
|
func (r *tenderRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
err := r.ormRepo.Delete(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to delete tender", map[string]interface{}{
|
||||||
|
"tender_id": id,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Tender deleted successfully", map[string]interface{}{
|
||||||
|
"tender_id": id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List retrieves tenders with pagination and filtering
|
||||||
|
func (r *tenderRepository) List(ctx context.Context, criteria TenderSearchCriteria, limit, offset int) ([]Tender, int64, error) {
|
||||||
|
filter := r.buildSearchFilter(criteria)
|
||||||
|
|
||||||
|
pagination := mongopkg.Pagination{
|
||||||
|
Limit: limit,
|
||||||
|
Skip: offset,
|
||||||
|
SortField: "created_at",
|
||||||
|
SortOrder: -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to list tenders", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Items, result.TotalCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBatch creates multiple tenders in a single operation
|
||||||
|
func (r *tenderRepository) CreateBatch(ctx context.Context, tenders []Tender) error {
|
||||||
|
if len(tenders) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range tenders {
|
||||||
|
if err := r.ormRepo.Create(ctx, &tenders[i]); err != nil {
|
||||||
|
r.logger.Error("Failed to create tender in batch", map[string]interface{}{
|
||||||
|
"tender_id": tenders[i].ID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Tender batch created successfully", map[string]interface{}{
|
||||||
|
"count": len(tenders),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBatch updates multiple tenders
|
||||||
|
func (r *tenderRepository) UpdateBatch(ctx context.Context, tenders []Tender) error {
|
||||||
|
if len(tenders) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
for i := range tenders {
|
||||||
|
tenders[i].UpdatedAt = now
|
||||||
|
|
||||||
|
if err := r.ormRepo.Update(ctx, &tenders[i]); err != nil {
|
||||||
|
r.logger.Error("Failed to update tender in batch", map[string]interface{}{
|
||||||
|
"tender_id": tenders[i].ID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Tender batch updated successfully", map[string]interface{}{
|
||||||
|
"count": len(tenders),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search searches tenders based on criteria
|
||||||
|
func (r *tenderRepository) Search(ctx context.Context, criteria TenderSearchCriteria) ([]Tender, error) {
|
||||||
|
filter := r.buildSearchFilter(criteria)
|
||||||
|
|
||||||
|
pagination := mongopkg.Pagination{
|
||||||
|
Limit: 100,
|
||||||
|
SortField: "created_at",
|
||||||
|
SortOrder: -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to search tenders", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByCountryCode retrieves tenders by country code
|
||||||
|
func (r *tenderRepository) GetByCountryCode(ctx context.Context, countryCode string, limit, offset int) ([]Tender, error) {
|
||||||
|
filter := bson.M{"country_code": countryCode}
|
||||||
|
|
||||||
|
pagination := mongopkg.Pagination{
|
||||||
|
Limit: limit,
|
||||||
|
Skip: offset,
|
||||||
|
SortField: "created_at",
|
||||||
|
SortOrder: -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to get tenders by country code", map[string]interface{}{
|
||||||
|
"country_code": countryCode,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByStatus retrieves tenders by status
|
||||||
|
func (r *tenderRepository) GetByStatus(ctx context.Context, status TenderStatus, limit, offset int) ([]Tender, error) {
|
||||||
|
filter := bson.M{"status": status}
|
||||||
|
|
||||||
|
pagination := mongopkg.Pagination{
|
||||||
|
Limit: limit,
|
||||||
|
Skip: offset,
|
||||||
|
SortField: "created_at",
|
||||||
|
SortOrder: -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to get tenders by status", map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExpiredTenders retrieves tenders that are expired
|
||||||
|
func (r *tenderRepository) GetExpiredTenders(ctx context.Context, beforeDate int64) ([]Tender, error) {
|
||||||
|
filter := bson.M{
|
||||||
|
"$or": []bson.M{
|
||||||
|
{"status": TenderStatusExpired},
|
||||||
|
{
|
||||||
|
"tender_deadline": bson.M{
|
||||||
|
"$lt": beforeDate,
|
||||||
|
},
|
||||||
|
"status": bson.M{"$ne": TenderStatusExpired},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pagination := mongopkg.Pagination{
|
||||||
|
Limit: 1000, // Large limit for maintenance operations
|
||||||
|
SortField: "tender_deadline",
|
||||||
|
SortOrder: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to get expired tenders", map[string]interface{}{
|
||||||
|
"before_date": beforeDate,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveTenders retrieves active tenders
|
||||||
|
func (r *tenderRepository) GetActiveTenders(ctx context.Context, limit, offset int) ([]Tender, error) {
|
||||||
|
filter := bson.M{"status": TenderStatusActive}
|
||||||
|
|
||||||
|
pagination := mongopkg.Pagination{
|
||||||
|
Limit: limit,
|
||||||
|
Skip: offset,
|
||||||
|
SortField: "tender_deadline",
|
||||||
|
SortOrder: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to get active tenders", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatistics calculates tender statistics using aggregation
|
||||||
|
func (r *tenderRepository) GetStatistics(ctx context.Context) (*TenderStatistics, error) {
|
||||||
|
stats := &TenderStatistics{
|
||||||
|
LastUpdated: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
total, err := r.ormRepo.Count(ctx, bson.M{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.TotalTenders = total
|
||||||
|
|
||||||
|
// Get active count
|
||||||
|
active, err := r.ormRepo.Count(ctx, bson.M{"status": TenderStatusActive})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.ActiveTenders = active
|
||||||
|
|
||||||
|
// Get expired count
|
||||||
|
expired, err := r.ormRepo.Count(ctx, bson.M{"status": TenderStatusExpired})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.ExpiredTenders = expired
|
||||||
|
|
||||||
|
// Get statistics by country, type, and classification
|
||||||
|
stats.TendersByCountry, _ = r.GetTenderCountByCountry(ctx)
|
||||||
|
stats.TendersByType, _ = r.GetTenderCountByType(ctx)
|
||||||
|
stats.TendersByClassification, _ = r.GetTenderCountByClassification(ctx)
|
||||||
|
|
||||||
|
// Calculate value statistics using aggregation
|
||||||
|
pipeline := mongo.Pipeline{
|
||||||
|
{
|
||||||
|
{Key: "$group", Value: bson.M{
|
||||||
|
"_id": nil,
|
||||||
|
"total_estimated_value": bson.M{"$sum": "$estimated_value"},
|
||||||
|
"count": bson.M{"$sum": 1},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.ormRepo.Aggregate(ctx, pipeline)
|
||||||
|
if err == nil && len(result) > 0 {
|
||||||
|
if totalValue, ok := result[0]["total_estimated_value"].(float64); ok {
|
||||||
|
stats.TotalEstimatedValue = totalValue
|
||||||
|
if count, ok := result[0]["count"].(int32); ok && count > 0 {
|
||||||
|
stats.AverageEstimatedValue = totalValue / float64(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenderCountByCountry gets tender count grouped by country
|
||||||
|
func (r *tenderRepository) GetTenderCountByCountry(ctx context.Context) (map[string]int64, error) {
|
||||||
|
pipeline := mongo.Pipeline{
|
||||||
|
{
|
||||||
|
{Key: "$group", Value: bson.M{
|
||||||
|
"_id": "$country_code",
|
||||||
|
"count": bson.M{"$sum": 1},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := r.ormRepo.Aggregate(ctx, pipeline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := make(map[string]int64)
|
||||||
|
for _, result := range results {
|
||||||
|
if country, ok := result["_id"].(string); ok {
|
||||||
|
if count, ok := result["count"].(int32); ok {
|
||||||
|
counts[country] = int64(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenderCountByType gets tender count grouped by type
|
||||||
|
func (r *tenderRepository) GetTenderCountByType(ctx context.Context) (map[string]int64, error) {
|
||||||
|
pipeline := mongo.Pipeline{
|
||||||
|
{
|
||||||
|
{Key: "$group", Value: bson.M{
|
||||||
|
"_id": "$notice_type_code",
|
||||||
|
"count": bson.M{"$sum": 1},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := r.ormRepo.Aggregate(ctx, pipeline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := make(map[string]int64)
|
||||||
|
for _, result := range results {
|
||||||
|
if noticeType, ok := result["_id"].(string); ok {
|
||||||
|
if count, ok := result["count"].(int32); ok {
|
||||||
|
counts[noticeType] = int64(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenderCountByClassification gets tender count grouped by classification
|
||||||
|
func (r *tenderRepository) GetTenderCountByClassification(ctx context.Context) (map[string]int64, error) {
|
||||||
|
pipeline := mongo.Pipeline{
|
||||||
|
{
|
||||||
|
{Key: "$group", Value: bson.M{
|
||||||
|
"_id": "$main_classification",
|
||||||
|
"count": bson.M{"$sum": 1},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Key: "$limit", Value: 20},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := r.ormRepo.Aggregate(ctx, pipeline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := make(map[string]int64)
|
||||||
|
for _, result := range results {
|
||||||
|
if classification, ok := result["_id"].(string); ok {
|
||||||
|
if count, ok := result["count"].(int32); ok {
|
||||||
|
counts[classification] = int64(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetScrapingState retrieves the current scraping state
|
||||||
|
func (r *tenderRepository) GetScrapingState(ctx context.Context) (*ScrapingState, error) {
|
||||||
|
// Try to find existing state
|
||||||
|
filter := bson.M{}
|
||||||
|
pagination := mongopkg.Pagination{Limit: 1}
|
||||||
|
|
||||||
|
result, err := r.stateOrmRepo.FindAll(ctx, filter, pagination)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Items) == 0 {
|
||||||
|
// Return default state if none exists
|
||||||
|
return &ScrapingState{
|
||||||
|
ID: "default",
|
||||||
|
LastProcessedYear: time.Now().Year(),
|
||||||
|
LastProcessedNumber: 0,
|
||||||
|
LastRunAt: 0,
|
||||||
|
NextRunAt: time.Now().Add(4 * time.Hour).Unix(),
|
||||||
|
IsRunning: false,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
UpdatedAt: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result.Items[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveScrapingState saves the scraping state
|
||||||
|
func (r *tenderRepository) SaveScrapingState(ctx context.Context, state *ScrapingState) error {
|
||||||
|
if state.ID == "" {
|
||||||
|
state.ID = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if state.CreatedAt == 0 {
|
||||||
|
state.CreatedAt = now
|
||||||
|
}
|
||||||
|
state.UpdatedAt = now
|
||||||
|
|
||||||
|
// Try to update existing state first
|
||||||
|
existing, err := r.GetScrapingState(ctx)
|
||||||
|
if err == nil && existing.ID != "" {
|
||||||
|
state.ID = existing.ID
|
||||||
|
state.CreatedAt = existing.CreatedAt
|
||||||
|
return r.stateOrmRepo.Update(ctx, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new state if none exists
|
||||||
|
return r.stateOrmRepo.Create(ctx, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateScrapingJob creates a new scraping job
|
||||||
|
func (r *tenderRepository) CreateScrapingJob(ctx context.Context, job *ScrapingJob) error {
|
||||||
|
if job.ID == "" {
|
||||||
|
job.ID = primitive.NewObjectID().Hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
job.CreatedAt = now
|
||||||
|
job.UpdatedAt = now
|
||||||
|
|
||||||
|
err := r.jobOrmRepo.Create(ctx, job)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to create scraping job", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"type": job.Type,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Scraping job created successfully", map[string]interface{}{
|
||||||
|
"job_id": job.ID,
|
||||||
|
"type": job.Type,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetScrapingJob retrieves a scraping job by ID
|
||||||
|
func (r *tenderRepository) GetScrapingJob(ctx context.Context, id string) (*ScrapingJob, error) {
|
||||||
|
job, err := r.jobOrmRepo.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to get scraping job", map[string]interface{}{
|
||||||
|
"job_id": id,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateScrapingJob updates a scraping job
|
||||||
|
func (r *tenderRepository) UpdateScrapingJob(ctx context.Context, job *ScrapingJob) error {
|
||||||
|
job.UpdatedAt = time.Now().Unix()
|
||||||
|
|
||||||
|
err := r.jobOrmRepo.Update(ctx, job)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to update scraping job", map[string]interface{}{
|
||||||
|
"job_id": job.ID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Scraping job updated successfully", map[string]interface{}{
|
||||||
|
"job_id": job.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListScrapingJobs retrieves scraping jobs with pagination
|
||||||
|
func (r *tenderRepository) ListScrapingJobs(ctx context.Context, limit, offset int) ([]ScrapingJob, int64, error) {
|
||||||
|
filter := bson.M{}
|
||||||
|
|
||||||
|
pagination := mongopkg.Pagination{
|
||||||
|
Limit: limit,
|
||||||
|
Skip: offset,
|
||||||
|
SortField: "started_at",
|
||||||
|
SortOrder: -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.jobOrmRepo.FindAll(ctx, filter, pagination)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to list scraping jobs", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Items, result.TotalCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveScrapingJobs retrieves currently active scraping jobs
|
||||||
|
func (r *tenderRepository) GetActiveScrapingJobs(ctx context.Context) ([]ScrapingJob, error) {
|
||||||
|
filter := bson.M{
|
||||||
|
"status": bson.M{
|
||||||
|
"$in": []ScrapingJobStatus{
|
||||||
|
ScrapingJobStatusPending,
|
||||||
|
ScrapingJobStatusRunning,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pagination := mongopkg.Pagination{
|
||||||
|
Limit: 100,
|
||||||
|
SortField: "started_at",
|
||||||
|
SortOrder: -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.jobOrmRepo.FindAll(ctx, filter, pagination)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to get active scraping jobs", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSearchFilter builds MongoDB filter from search criteria
|
||||||
|
func (r *tenderRepository) buildSearchFilter(criteria TenderSearchCriteria) bson.M {
|
||||||
|
filter := bson.M{}
|
||||||
|
|
||||||
|
if criteria.Query != "" {
|
||||||
|
filter["$text"] = bson.M{"$search": criteria.Query}
|
||||||
|
}
|
||||||
|
|
||||||
|
if criteria.NoticeType != "" {
|
||||||
|
filter["notice_type_code"] = criteria.NoticeType
|
||||||
|
}
|
||||||
|
|
||||||
|
if criteria.ProcurementType != "" {
|
||||||
|
filter["procurement_type_code"] = criteria.ProcurementType
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(criteria.CountryCodes) > 0 {
|
||||||
|
filter["country_code"] = bson.M{"$in": criteria.CountryCodes}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(criteria.RegionCodes) > 0 {
|
||||||
|
filter["region_code"] = bson.M{"$in": criteria.RegionCodes}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(criteria.Classifications) > 0 {
|
||||||
|
filter["$or"] = []bson.M{
|
||||||
|
{"main_classification": bson.M{"$in": criteria.Classifications}},
|
||||||
|
{"additional_classifications": bson.M{"$in": criteria.Classifications}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if criteria.MinEstimatedValue != nil || criteria.MaxEstimatedValue != nil {
|
||||||
|
valueFilter := bson.M{}
|
||||||
|
if criteria.MinEstimatedValue != nil {
|
||||||
|
valueFilter["$gte"] = *criteria.MinEstimatedValue
|
||||||
|
}
|
||||||
|
if criteria.MaxEstimatedValue != nil {
|
||||||
|
valueFilter["$lte"] = *criteria.MaxEstimatedValue
|
||||||
|
}
|
||||||
|
filter["estimated_value"] = valueFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
if criteria.Currency != "" {
|
||||||
|
filter["currency"] = criteria.Currency
|
||||||
|
}
|
||||||
|
|
||||||
|
if criteria.DeadlineFrom != nil || criteria.DeadlineTo != nil {
|
||||||
|
deadlineFilter := bson.M{}
|
||||||
|
if criteria.DeadlineFrom != nil {
|
||||||
|
deadlineFilter["$gte"] = *criteria.DeadlineFrom
|
||||||
|
}
|
||||||
|
if criteria.DeadlineTo != nil {
|
||||||
|
deadlineFilter["$lte"] = *criteria.DeadlineTo
|
||||||
|
}
|
||||||
|
filter["tender_deadline"] = deadlineFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
if criteria.PublicationDateFrom != nil || criteria.PublicationDateTo != nil {
|
||||||
|
pubDateFilter := bson.M{}
|
||||||
|
if criteria.PublicationDateFrom != nil {
|
||||||
|
pubDateFilter["$gte"] = *criteria.PublicationDateFrom
|
||||||
|
}
|
||||||
|
if criteria.PublicationDateTo != nil {
|
||||||
|
pubDateFilter["$lte"] = *criteria.PublicationDateTo
|
||||||
|
}
|
||||||
|
filter["publication_date"] = pubDateFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(criteria.Status) > 0 {
|
||||||
|
filter["status"] = bson.M{"$in": criteria.Status}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(criteria.Source) > 0 {
|
||||||
|
filter["source"] = bson.M{"$in": criteria.Source}
|
||||||
|
}
|
||||||
|
|
||||||
|
if criteria.BuyerOrganizationID != "" {
|
||||||
|
filter["buyer_organization.id"] = criteria.BuyerOrganizationID
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(criteria.Languages) > 0 {
|
||||||
|
filter["notice_language_code"] = bson.M{"$in": criteria.Languages}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
package tender
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenderService interface defines tender business logic operations
|
||||||
|
type TenderService interface {
|
||||||
|
// Tender operations
|
||||||
|
CreateTender(ctx context.Context, req CreateTenderRequest) (*Tender, error)
|
||||||
|
GetTenderByID(ctx context.Context, id string) (*Tender, error)
|
||||||
|
UpdateTender(ctx context.Context, req UpdateTenderRequest) (*Tender, error)
|
||||||
|
DeleteTender(ctx context.Context, id string) error
|
||||||
|
ListTenders(ctx context.Context, req ListTendersRequest) (*ListTendersResponse, error)
|
||||||
|
SearchTenders(ctx context.Context, req SearchTendersRequest) (*SearchTendersResponse, error)
|
||||||
|
|
||||||
|
// Statistics and reporting
|
||||||
|
GetTenderStatistics(ctx context.Context) (*TenderStatistics, error)
|
||||||
|
GetDashboardData(ctx context.Context) (*DashboardData, error)
|
||||||
|
|
||||||
|
// Scraping management
|
||||||
|
StartScrapingJob(ctx context.Context, req StartScrapingJobRequest) (*ScrapingJob, error)
|
||||||
|
GetScrapingJobStatus(ctx context.Context, jobID string) (*ScrapingJob, error)
|
||||||
|
ListScrapingJobs(ctx context.Context, req ListScrapingJobsRequest) (*ListScrapingJobsResponse, error)
|
||||||
|
CancelScrapingJob(ctx context.Context, jobID string) error
|
||||||
|
|
||||||
|
// Maintenance operations
|
||||||
|
UpdateExpiredTenders(ctx context.Context) error
|
||||||
|
CleanupOldData(ctx context.Context, olderThan time.Duration) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenderService implements TenderService interface
|
||||||
|
type tenderService struct {
|
||||||
|
repository TenderRepository
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTenderService creates a new tender service
|
||||||
|
func NewTenderService(repository TenderRepository, logger logger.Logger) TenderService {
|
||||||
|
return &tenderService{
|
||||||
|
repository: repository,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTender creates a new tender
|
||||||
|
func (s *tenderService) CreateTender(ctx context.Context, req CreateTenderRequest) (*Tender, error) {
|
||||||
|
s.logger.Info("Creating new tender", map[string]interface{}{
|
||||||
|
"notice_publication_id": req.NoticePublicationID,
|
||||||
|
"title": req.Title,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if tender with same notice publication ID already exists
|
||||||
|
if req.NoticePublicationID != "" {
|
||||||
|
existing, err := s.repository.GetByNoticePublicationID(ctx, req.NoticePublicationID)
|
||||||
|
if err == nil && existing != nil {
|
||||||
|
s.logger.Warn("Tender with same notice publication ID already exists", map[string]interface{}{
|
||||||
|
"notice_publication_id": req.NoticePublicationID,
|
||||||
|
"existing_id": existing.ID,
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("tender with notice publication ID %s already exists", req.NoticePublicationID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tender := &Tender{
|
||||||
|
ContractNoticeID: req.ContractNoticeID,
|
||||||
|
NoticePublicationID: req.NoticePublicationID,
|
||||||
|
ContractFolderID: req.ContractFolderID,
|
||||||
|
NoticeTypeCode: req.NoticeTypeCode,
|
||||||
|
NoticeSubTypeCode: req.NoticeSubTypeCode,
|
||||||
|
NoticeLanguageCode: req.NoticeLanguageCode,
|
||||||
|
IssueDate: req.IssueDate,
|
||||||
|
IssueTime: req.IssueTime,
|
||||||
|
PublicationDate: req.PublicationDate,
|
||||||
|
GazetteID: req.GazetteID,
|
||||||
|
Title: req.Title,
|
||||||
|
Description: req.Description,
|
||||||
|
ProcurementTypeCode: req.ProcurementTypeCode,
|
||||||
|
ProcedureCode: req.ProcedureCode,
|
||||||
|
MainClassification: req.MainClassification,
|
||||||
|
AdditionalClassifications: req.AdditionalClassifications,
|
||||||
|
EstimatedValue: req.EstimatedValue,
|
||||||
|
Currency: req.Currency,
|
||||||
|
Duration: req.Duration,
|
||||||
|
DurationUnit: req.DurationUnit,
|
||||||
|
TenderDeadline: req.TenderDeadline,
|
||||||
|
PlaceOfPerformance: req.PlaceOfPerformance,
|
||||||
|
CountryCode: req.CountryCode,
|
||||||
|
RegionCode: req.RegionCode,
|
||||||
|
CityName: req.CityName,
|
||||||
|
PostalCode: req.PostalCode,
|
||||||
|
DocumentURI: req.DocumentURI,
|
||||||
|
TenderURL: req.TenderURL,
|
||||||
|
BuyerOrganization: req.BuyerOrganization,
|
||||||
|
ReviewOrganization: req.ReviewOrganization,
|
||||||
|
Organizations: req.Organizations,
|
||||||
|
SelectionCriteria: req.SelectionCriteria,
|
||||||
|
OfficialLanguages: req.OfficialLanguages,
|
||||||
|
Status: TenderStatusActive,
|
||||||
|
Source: req.Source,
|
||||||
|
SourceFileURL: req.SourceFileURL,
|
||||||
|
SourceFileName: req.SourceFileName,
|
||||||
|
ProcessingMetadata: ProcessingMetadata{
|
||||||
|
ProcessedAt: time.Now().Unix(),
|
||||||
|
ProcessingVersion: "1.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tender URL if not provided
|
||||||
|
if tender.TenderURL == "" && tender.NoticePublicationID != "" {
|
||||||
|
tender.TenderURL = generateTenderURL(tender.NoticePublicationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repository.Create(ctx, tender); err != nil {
|
||||||
|
s.logger.Error("Failed to create tender", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"notice_publication_id": req.NoticePublicationID,
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to create tender: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Tender created successfully", map[string]interface{}{
|
||||||
|
"tender_id": tender.ID,
|
||||||
|
"notice_publication_id": tender.NoticePublicationID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return tender, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenderByID retrieves a tender by ID
|
||||||
|
func (s *tenderService) GetTenderByID(ctx context.Context, id string) (*Tender, error) {
|
||||||
|
s.logger.Info("Retrieving tender by ID", map[string]interface{}{
|
||||||
|
"tender_id": id,
|
||||||
|
})
|
||||||
|
|
||||||
|
tender, err := s.repository.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to retrieve tender", map[string]interface{}{
|
||||||
|
"tender_id": id,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to retrieve tender: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tender, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTender updates an existing tender
|
||||||
|
func (s *tenderService) UpdateTender(ctx context.Context, req UpdateTenderRequest) (*Tender, error) {
|
||||||
|
s.logger.Info("Updating tender", map[string]interface{}{
|
||||||
|
"tender_id": req.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get existing tender
|
||||||
|
tender, err := s.repository.GetByID(ctx, req.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to find tender for update", map[string]interface{}{
|
||||||
|
"tender_id": req.ID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to find tender: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields if provided
|
||||||
|
if req.Title != nil {
|
||||||
|
tender.Title = *req.Title
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
tender.Description = *req.Description
|
||||||
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
tender.Status = *req.Status
|
||||||
|
}
|
||||||
|
if req.EstimatedValue != nil {
|
||||||
|
tender.EstimatedValue = *req.EstimatedValue
|
||||||
|
}
|
||||||
|
if req.Currency != nil {
|
||||||
|
tender.Currency = *req.Currency
|
||||||
|
}
|
||||||
|
if req.TenderDeadline != nil {
|
||||||
|
tender.TenderDeadline = *req.TenderDeadline
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repository.Update(ctx, tender); err != nil {
|
||||||
|
s.logger.Error("Failed to update tender", map[string]interface{}{
|
||||||
|
"tender_id": req.ID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to update tender: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Tender updated successfully", map[string]interface{}{
|
||||||
|
"tender_id": tender.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return tender, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTender deletes a tender
|
||||||
|
func (s *tenderService) DeleteTender(ctx context.Context, id string) error {
|
||||||
|
s.logger.Info("Deleting tender", map[string]interface{}{
|
||||||
|
"tender_id": id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := s.repository.Delete(ctx, id); err != nil {
|
||||||
|
s.logger.Error("Failed to delete tender", map[string]interface{}{
|
||||||
|
"tender_id": id,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("failed to delete tender: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Tender deleted successfully", map[string]interface{}{
|
||||||
|
"tender_id": id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTenders retrieves tenders with pagination and filtering
|
||||||
|
func (s *tenderService) ListTenders(ctx context.Context, req ListTendersRequest) (*ListTendersResponse, error) {
|
||||||
|
s.logger.Info("Listing tenders", map[string]interface{}{
|
||||||
|
"limit": req.Limit,
|
||||||
|
"offset": req.Offset,
|
||||||
|
})
|
||||||
|
|
||||||
|
if req.Limit <= 0 {
|
||||||
|
req.Limit = 20
|
||||||
|
}
|
||||||
|
if req.Limit > 100 {
|
||||||
|
req.Limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
tenders, total, err := s.repository.List(ctx, req.Criteria, req.Limit, req.Offset)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to list tenders", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to list tenders: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ListTendersResponse{
|
||||||
|
Tenders: tenders,
|
||||||
|
Total: total,
|
||||||
|
Limit: req.Limit,
|
||||||
|
Offset: req.Offset,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTenders searches tenders based on criteria
|
||||||
|
func (s *tenderService) SearchTenders(ctx context.Context, req SearchTendersRequest) (*SearchTendersResponse, error) {
|
||||||
|
s.logger.Info("Searching tenders", map[string]interface{}{
|
||||||
|
"query": req.Query,
|
||||||
|
})
|
||||||
|
|
||||||
|
tenders, err := s.repository.Search(ctx, req.Criteria)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to search tenders", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"query": req.Query,
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to search tenders: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SearchTendersResponse{
|
||||||
|
Tenders: tenders,
|
||||||
|
Query: req.Query,
|
||||||
|
Total: len(tenders),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenderStatistics retrieves tender statistics
|
||||||
|
func (s *tenderService) GetTenderStatistics(ctx context.Context) (*TenderStatistics, error) {
|
||||||
|
s.logger.Info("Retrieving tender statistics", map[string]interface{}{})
|
||||||
|
|
||||||
|
stats, err := s.repository.GetStatistics(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to retrieve tender statistics", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to retrieve statistics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDashboardData retrieves dashboard data
|
||||||
|
func (s *tenderService) GetDashboardData(ctx context.Context) (*DashboardData, error) {
|
||||||
|
s.logger.Info("Retrieving dashboard data", map[string]interface{}{})
|
||||||
|
|
||||||
|
stats, err := s.GetTenderStatistics(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent tenders
|
||||||
|
recentTenders, _, err := s.repository.List(ctx, TenderSearchCriteria{}, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to retrieve recent tenders", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to retrieve recent tenders: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get active scraping jobs
|
||||||
|
activeJobs, err := s.repository.GetActiveScrapingJobs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to retrieve active scraping jobs", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to retrieve active jobs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DashboardData{
|
||||||
|
Statistics: stats,
|
||||||
|
RecentTenders: recentTenders,
|
||||||
|
ActiveJobs: activeJobs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartScrapingJob starts a new scraping job
|
||||||
|
func (s *tenderService) StartScrapingJob(ctx context.Context, req StartScrapingJobRequest) (*ScrapingJob, error) {
|
||||||
|
s.logger.Info("Starting scraping job", map[string]interface{}{
|
||||||
|
"type": req.Type,
|
||||||
|
})
|
||||||
|
|
||||||
|
job := &ScrapingJob{
|
||||||
|
ID: primitive.NewObjectID().Hex(),
|
||||||
|
Type: req.Type,
|
||||||
|
Status: ScrapingJobStatusPending,
|
||||||
|
StartedAt: time.Now().Unix(),
|
||||||
|
Progress: JobProgress{},
|
||||||
|
Results: JobResults{},
|
||||||
|
Config: req.Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repository.CreateScrapingJob(ctx, job); err != nil {
|
||||||
|
s.logger.Error("Failed to create scraping job", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"type": req.Type,
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to create scraping job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Scraping job created successfully", map[string]interface{}{
|
||||||
|
"job_id": job.ID,
|
||||||
|
"type": job.Type,
|
||||||
|
})
|
||||||
|
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetScrapingJobStatus retrieves scraping job status
|
||||||
|
func (s *tenderService) GetScrapingJobStatus(ctx context.Context, jobID string) (*ScrapingJob, error) {
|
||||||
|
s.logger.Info("Retrieving scraping job status", map[string]interface{}{
|
||||||
|
"job_id": jobID,
|
||||||
|
})
|
||||||
|
|
||||||
|
job, err := s.repository.GetScrapingJob(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to retrieve scraping job", map[string]interface{}{
|
||||||
|
"job_id": jobID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to retrieve scraping job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListScrapingJobs lists scraping jobs with pagination
|
||||||
|
func (s *tenderService) ListScrapingJobs(ctx context.Context, req ListScrapingJobsRequest) (*ListScrapingJobsResponse, error) {
|
||||||
|
s.logger.Info("Listing scraping jobs", map[string]interface{}{
|
||||||
|
"limit": req.Limit,
|
||||||
|
"offset": req.Offset,
|
||||||
|
})
|
||||||
|
|
||||||
|
if req.Limit <= 0 {
|
||||||
|
req.Limit = 20
|
||||||
|
}
|
||||||
|
if req.Limit > 100 {
|
||||||
|
req.Limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs, total, err := s.repository.ListScrapingJobs(ctx, req.Limit, req.Offset)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to list scraping jobs", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to list scraping jobs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ListScrapingJobsResponse{
|
||||||
|
Jobs: jobs,
|
||||||
|
Total: total,
|
||||||
|
Limit: req.Limit,
|
||||||
|
Offset: req.Offset,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelScrapingJob cancels a running scraping job
|
||||||
|
func (s *tenderService) CancelScrapingJob(ctx context.Context, jobID string) error {
|
||||||
|
s.logger.Info("Cancelling scraping job", map[string]interface{}{
|
||||||
|
"job_id": jobID,
|
||||||
|
})
|
||||||
|
|
||||||
|
job, err := s.repository.GetScrapingJob(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to find scraping job for cancellation", map[string]interface{}{
|
||||||
|
"job_id": jobID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("failed to find scraping job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if job.Status != ScrapingJobStatusRunning && job.Status != ScrapingJobStatusPending {
|
||||||
|
s.logger.Warn("Cannot cancel scraping job in current status", map[string]interface{}{
|
||||||
|
"job_id": jobID,
|
||||||
|
"status": job.Status,
|
||||||
|
})
|
||||||
|
return fmt.Errorf("cannot cancel job in status: %s", job.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = ScrapingJobStatusCancelled
|
||||||
|
completedAt := time.Now().Unix()
|
||||||
|
job.CompletedAt = &completedAt
|
||||||
|
duration := completedAt - job.StartedAt
|
||||||
|
job.Duration = &duration
|
||||||
|
|
||||||
|
if err := s.repository.UpdateScrapingJob(ctx, job); err != nil {
|
||||||
|
s.logger.Error("Failed to update cancelled scraping job", map[string]interface{}{
|
||||||
|
"job_id": jobID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("failed to update scraping job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Scraping job cancelled successfully", map[string]interface{}{
|
||||||
|
"job_id": jobID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateExpiredTenders updates status of expired tenders
|
||||||
|
func (s *tenderService) UpdateExpiredTenders(ctx context.Context) error {
|
||||||
|
s.logger.Info("Updating expired tenders", map[string]interface{}{})
|
||||||
|
|
||||||
|
// Get tenders that should be expired
|
||||||
|
expiredTenders, err := s.repository.GetExpiredTenders(ctx, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to get expired tenders", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("failed to get expired tenders: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedCount := 0
|
||||||
|
for _, tender := range expiredTenders {
|
||||||
|
if tender.Status != TenderStatusExpired {
|
||||||
|
tender.UpdateStatus(TenderStatusExpired)
|
||||||
|
if err := s.repository.Update(ctx, &tender); err != nil {
|
||||||
|
s.logger.Error("Failed to update expired tender", map[string]interface{}{
|
||||||
|
"tender_id": tender.ID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updatedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Expired tenders updated", map[string]interface{}{
|
||||||
|
"total_expired": len(expiredTenders),
|
||||||
|
"updated": updatedCount,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupOldData removes old data based on retention policy
|
||||||
|
func (s *tenderService) CleanupOldData(ctx context.Context, olderThan time.Duration) error {
|
||||||
|
s.logger.Info("Cleaning up old data", map[string]interface{}{
|
||||||
|
"older_than": olderThan.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
cutoffTime := time.Now().Add(-olderThan).Unix()
|
||||||
|
|
||||||
|
// For now, we'll just log what would be cleaned up
|
||||||
|
// In production, you might want to actually delete old data
|
||||||
|
criteria := TenderSearchCriteria{
|
||||||
|
Status: []TenderStatus{TenderStatusExpired, TenderStatusCancelled, TenderStatusAwarded},
|
||||||
|
}
|
||||||
|
|
||||||
|
oldTenders, err := s.repository.Search(ctx, criteria)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to find old tenders for cleanup", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("failed to find old tenders: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupCount := 0
|
||||||
|
for _, tender := range oldTenders {
|
||||||
|
if tender.CreatedAt < cutoffTime {
|
||||||
|
cleanupCount++
|
||||||
|
// Here you would delete the tender if implementing actual cleanup
|
||||||
|
// s.repository.Delete(ctx, tender.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Old data cleanup completed", map[string]interface{}{
|
||||||
|
"found_old_tenders": cleanupCount,
|
||||||
|
"cutoff_time": cutoffTime,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+396
@@ -0,0 +1,396 @@
|
|||||||
|
# TED XML Parser - Complete Model
|
||||||
|
|
||||||
|
This package provides comprehensive Go structures for parsing all TED (Tenders Electronic Daily) XML documents, supporting the complete range of eForms notice types and legacy TED XML formats.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
- ✅ **Complete TED XML Support**: All notice subtypes from eForms SDK 1.3 to 1.13
|
||||||
|
- ✅ **Automatic Document Detection**: Intelligent parsing with type detection
|
||||||
|
- ✅ **Full Field Coverage**: Comprehensive mapping of all TED XML fields
|
||||||
|
- ✅ **eForms Compliance**: Full support for eForms extensions and organizations
|
||||||
|
- ✅ **Legacy Compatibility**: Backward compatibility with old TED XML formats
|
||||||
|
- ✅ **Validation & Utility Methods**: Built-in validation and data extraction helpers
|
||||||
|
- ✅ **Award Results**: Complete lot tender and award information
|
||||||
|
- ✅ **Multi-language Support**: Proper handling of multilingual content
|
||||||
|
- ✅ **High Performance**: Optimized parsing with 50MB file support
|
||||||
|
|
||||||
|
## 📋 Supported Document Types
|
||||||
|
|
||||||
|
### Core Notice Types
|
||||||
|
- **ContractNotice** - Standard tender notices for procurement opportunities
|
||||||
|
- **ContractAwardNotice** - Award notices announcing procurement results
|
||||||
|
- **PriorInformationNotice** - Early market engagement and planning notices
|
||||||
|
- **ResultNotice** - eForms-specific result notices
|
||||||
|
|
||||||
|
### Specialized Notice Types
|
||||||
|
- **DesignContest** - Design competition notices with jury and reward information
|
||||||
|
- **QualificationSystemNotice** - Qualification system establishment notices
|
||||||
|
- **ConcessionNotice** - Concession contract notices with revenue terms
|
||||||
|
- **PlanningNotice** - Procurement planning and budget notices
|
||||||
|
- **CompetitionNotice** - Competition procedure notices
|
||||||
|
- **ChangeNotice** - Notice modifications and corrections (eForms)
|
||||||
|
- **SubcontractNotice** - Subcontracting opportunity notices
|
||||||
|
|
||||||
|
### Legacy Document Types
|
||||||
|
- **Corrigendum** - Corrections (parsed as ContractNotice)
|
||||||
|
- **ModificationNotice** - Modifications (parsed as ContractNotice)
|
||||||
|
|
||||||
|
## 🏗️ Complete Data Model
|
||||||
|
|
||||||
|
### Business Groups Covered
|
||||||
|
|
||||||
|
#### **BG-1: Notice**
|
||||||
|
- Complete notice metadata (ID, version, dates, language)
|
||||||
|
- Notice type codes and regulatory domains
|
||||||
|
- Publication information and gazette references
|
||||||
|
- UBL extensions with eForms data
|
||||||
|
|
||||||
|
#### **BG-2: Competition**
|
||||||
|
- Procedure codes and submission methods
|
||||||
|
- Tender submission deadlines and periods
|
||||||
|
- Open tender events and auction terms
|
||||||
|
- Process justification and government agreements
|
||||||
|
|
||||||
|
#### **BG-3: Tender**
|
||||||
|
- Tender reference and identification
|
||||||
|
- Tender variants and ranking information
|
||||||
|
- Legal monetary totals and payable amounts
|
||||||
|
- Subcontracting terms and descriptions
|
||||||
|
|
||||||
|
#### **BG-4: Lot**
|
||||||
|
- Lot identification and titles
|
||||||
|
- Procurement project details per lot
|
||||||
|
- Tendering terms and processes per lot
|
||||||
|
- Award criteria and selection requirements
|
||||||
|
|
||||||
|
#### **BG-5: Part**
|
||||||
|
- Part identification and classification
|
||||||
|
- Place of performance and location details
|
||||||
|
- Contract duration and planned periods
|
||||||
|
- Additional commodity classifications
|
||||||
|
|
||||||
|
#### **BG-6: Contracting Entity**
|
||||||
|
- Contracting party identification
|
||||||
|
- Buyer profile URIs and contact information
|
||||||
|
- Contracting party types and activities
|
||||||
|
- Service provider party details
|
||||||
|
|
||||||
|
#### **BG-7: Procedure**
|
||||||
|
- Procurement procedures and frameworks
|
||||||
|
- Procedure identifiers (UUID v4)
|
||||||
|
- Framework agreement terms
|
||||||
|
- Cross-border procurement indicators
|
||||||
|
|
||||||
|
#### **BG-8: Organization**
|
||||||
|
- Complete organization details and contacts
|
||||||
|
- Party identification and legal entities
|
||||||
|
- Postal addresses with country codes
|
||||||
|
- Natural person indicators
|
||||||
|
|
||||||
|
#### **BG-9: Document**
|
||||||
|
- Document references and attachments
|
||||||
|
- External references with URIs and hashes
|
||||||
|
- Document types and status codes
|
||||||
|
- Validity periods and language specifications
|
||||||
|
|
||||||
|
#### **BG-10: Lot Result**
|
||||||
|
- Tender result codes and award decisions
|
||||||
|
- Received submission statistics
|
||||||
|
- Winner identification and tender details
|
||||||
|
- Contract references and settlement information
|
||||||
|
|
||||||
|
#### **BG-11: Winner**
|
||||||
|
- Winning tenderer identification
|
||||||
|
- Winner organization details
|
||||||
|
- SME indicator and natural person flags
|
||||||
|
- Country of origin information
|
||||||
|
|
||||||
|
#### **BG-12: Settled Contract**
|
||||||
|
- Contract identification and titles
|
||||||
|
- Issue dates and signatory parties
|
||||||
|
- Contract values and currency information
|
||||||
|
- Performance period details
|
||||||
|
|
||||||
|
#### **BG-13: Subcontracting**
|
||||||
|
- Subcontracting terms and percentages
|
||||||
|
- Subcontractor identification
|
||||||
|
- Value thresholds and descriptions
|
||||||
|
- Unknown price indicators
|
||||||
|
|
||||||
|
### Extended Structures
|
||||||
|
|
||||||
|
#### **Design Contest Specific**
|
||||||
|
- **ContestDesignJury**: Jury decisions and member information
|
||||||
|
- **ContestDesignReward**: Reward structures and benefit values
|
||||||
|
- **JudgingCriteria**: Evaluation criteria and descriptions
|
||||||
|
|
||||||
|
#### **Qualification System Specific**
|
||||||
|
- **QualificationApplication**: Application procedures and periods
|
||||||
|
- **TendererQualificationRequest**: Qualification requirements
|
||||||
|
- **ApplicationPeriod**: Application start/end dates and times
|
||||||
|
|
||||||
|
#### **Concession Specific**
|
||||||
|
- **ConcessionRevenue**: Revenue types and amounts
|
||||||
|
- **ConcessionTerm**: Concession values and duration
|
||||||
|
- **FrameworkAgreement**: Framework terms and participants
|
||||||
|
|
||||||
|
#### **Planning Specific**
|
||||||
|
- **BudgetAccountLine**: Budget codes and amounts
|
||||||
|
- **BudgetAccount**: Account classification schemes
|
||||||
|
- **PlannedPeriod**: Planning timeline information
|
||||||
|
|
||||||
|
#### **Award Results Enhanced**
|
||||||
|
- **EnhancedTenderResult**: Comprehensive award information
|
||||||
|
- **AwardedTenderedProject**: Project award details
|
||||||
|
- **WinningTenderLot**: Lot-specific winners
|
||||||
|
- **WinningTender**: Individual tender results
|
||||||
|
- **ReceivedSubmissionsStatistics**: Submission counts and statistics
|
||||||
|
|
||||||
|
## 🔧 Usage Examples
|
||||||
|
|
||||||
|
### Basic Document Parsing
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"tm/ted"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Create comprehensive TED parser
|
||||||
|
parser := ted.NewTEDParser()
|
||||||
|
|
||||||
|
// Read any TED XML file
|
||||||
|
xmlData, err := ioutil.ReadFile("ted_document.xml")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatic detection and parsing
|
||||||
|
parsedDoc, err := parser.ParseXML(xmlData)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get document information
|
||||||
|
document := parsedDoc.GetDocument()
|
||||||
|
fmt.Printf("Document Type: %s\n", parsedDoc.Type)
|
||||||
|
fmt.Printf("Document ID: %s\n", document.GetID())
|
||||||
|
fmt.Printf("Notice Type: %s\n", document.GetNoticeType())
|
||||||
|
fmt.Printf("Language: %s\n", document.GetLanguage())
|
||||||
|
|
||||||
|
// Validate document
|
||||||
|
if err := document.Validate(); err != nil {
|
||||||
|
log.Printf("Validation error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Usage by Document Type
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Handle specific document types
|
||||||
|
switch parsedDoc.Type {
|
||||||
|
case ted.DocumentTypeContractNotice:
|
||||||
|
cn := parsedDoc.ContractNotice
|
||||||
|
fmt.Printf("Tender Deadline: %s\n", cn.GetTenderDeadline())
|
||||||
|
amount, currency := cn.GetBudget()
|
||||||
|
fmt.Printf("Estimated Value: %s %s\n", amount, currency)
|
||||||
|
|
||||||
|
case ted.DocumentTypeContractAwardNotice:
|
||||||
|
can := parsedDoc.ContractAwardNotice
|
||||||
|
fmt.Printf("Award Date: %s\n", can.GetAwardDate())
|
||||||
|
amount, currency := can.GetTotalAwardValue()
|
||||||
|
fmt.Printf("Total Award: %s %s\n", amount, currency)
|
||||||
|
|
||||||
|
case ted.DocumentTypePriorInformationNotice:
|
||||||
|
pin := parsedDoc.PriorInformationNotice
|
||||||
|
fmt.Printf("Prior Information ID: %s\n", pin.GetID())
|
||||||
|
|
||||||
|
case ted.DocumentTypeDesignContest:
|
||||||
|
dc := parsedDoc.DesignContest
|
||||||
|
fmt.Printf("Design Contest ID: %s\n", dc.GetID())
|
||||||
|
if dc.ContestDesignJury != nil {
|
||||||
|
fmt.Printf("Jury Decision: %s\n", dc.ContestDesignJury.JuryDecision)
|
||||||
|
}
|
||||||
|
|
||||||
|
case ted.DocumentTypeQualificationSystemNotice:
|
||||||
|
qsn := parsedDoc.QualificationSystemNotice
|
||||||
|
fmt.Printf("Qualification System ID: %s\n", qsn.GetID())
|
||||||
|
|
||||||
|
case ted.DocumentTypeConcessionNotice:
|
||||||
|
cn := parsedDoc.ConcessionNotice
|
||||||
|
fmt.Printf("Concession ID: %s\n", cn.GetID())
|
||||||
|
if cn.ConcessionRevenue != nil {
|
||||||
|
fmt.Printf("Revenue Type: %s\n", cn.ConcessionRevenue.RevenueType)
|
||||||
|
}
|
||||||
|
|
||||||
|
case ted.DocumentTypePlanningNotice:
|
||||||
|
pn := parsedDoc.PlanningNotice
|
||||||
|
fmt.Printf("Planning ID: %s\n", pn.GetID())
|
||||||
|
fmt.Printf("Budget Lines: %d\n", len(pn.BudgetAccountLine))
|
||||||
|
|
||||||
|
case ted.DocumentTypeResultNotice:
|
||||||
|
rn := parsedDoc.ResultNotice
|
||||||
|
fmt.Printf("Result ID: %s\n", rn.GetID())
|
||||||
|
fmt.Printf("Tender Results: %d\n", len(rn.TenderResult))
|
||||||
|
|
||||||
|
case ted.DocumentTypeChangeNotice:
|
||||||
|
cn := parsedDoc.ChangeNotice
|
||||||
|
fmt.Printf("Change ID: %s\n", cn.GetID())
|
||||||
|
fmt.Printf("Change Reasons: %d\n", len(cn.ChangeReason))
|
||||||
|
|
||||||
|
case ted.DocumentTypeSubcontractNotice:
|
||||||
|
sn := parsedDoc.SubcontractNotice
|
||||||
|
fmt.Printf("Subcontract ID: %s\n", sn.GetID())
|
||||||
|
fmt.Printf("Subcontract Calls: %d\n", len(sn.SubcontractCall))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Working with Organizations and Extensions
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Extract organization information (works for all notice types)
|
||||||
|
if doc := parsedDoc.GetDocument(); doc != nil {
|
||||||
|
// Type assertion for specific methods
|
||||||
|
if notice, ok := doc.(*ted.ContractNotice); ok {
|
||||||
|
orgs := notice.GetOrganizations()
|
||||||
|
if orgs != nil {
|
||||||
|
fmt.Printf("Organizations: %d\n", len(orgs.Organization))
|
||||||
|
for _, org := range orgs.Organization {
|
||||||
|
if org.Company != nil && org.Company.PartyName != nil {
|
||||||
|
fmt.Printf("- %s\n", org.Company.PartyName.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get publication information
|
||||||
|
pub := notice.GetPublicationInfo()
|
||||||
|
if pub != nil {
|
||||||
|
fmt.Printf("Publication ID: %s\n", pub.NoticePublicationID)
|
||||||
|
fmt.Printf("OJS ID: %s\n", pub.GazetteID)
|
||||||
|
fmt.Printf("Publication Date: %s\n", pub.PublicationDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Document Type Detection
|
||||||
|
|
||||||
|
The parser includes intelligent document type detection:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Detect document type without parsing
|
||||||
|
docType, err := parser.DetectDocumentType(xmlData)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Detected document type: %s\n", docType)
|
||||||
|
|
||||||
|
// Parse specific type if known
|
||||||
|
switch docType {
|
||||||
|
case ted.DocumentTypeContractNotice:
|
||||||
|
notice, err := parser.ParseXMLAsContractNotice(xmlData)
|
||||||
|
case ted.DocumentTypePriorInformationNotice:
|
||||||
|
notice, err := parser.ParseXMLAsPriorInformationNotice(xmlData)
|
||||||
|
case ted.DocumentTypeDesignContest:
|
||||||
|
notice, err := parser.ParseXMLAsDesignContest(xmlData)
|
||||||
|
// ... other specific parsers available
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Comprehensive Validation
|
||||||
|
|
||||||
|
All notice types include thorough validation:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Validate any notice type
|
||||||
|
err := notice.Validate()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Validation failed: %v", err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation Coverage
|
||||||
|
- Required fields (ID, ContractFolderID, IssueDate)
|
||||||
|
- Date format validation (multiple ISO 8601 formats)
|
||||||
|
- Notice type code validation
|
||||||
|
- Language code validation (3-letter ISO codes)
|
||||||
|
- Currency code validation (3-letter ISO codes)
|
||||||
|
- Duration unit code validation
|
||||||
|
- Business rule validation per notice type
|
||||||
|
|
||||||
|
## 🎯 Complete Field Coverage
|
||||||
|
|
||||||
|
### Core Metadata Fields
|
||||||
|
- UBLVersionID, CustomizationID, ID, ContractFolderID
|
||||||
|
- IssueDate, IssueTime, VersionID, RegulatoryDomain
|
||||||
|
- NoticeTypeCode, NoticeLanguageCode
|
||||||
|
- All namespace declarations and XML attributes
|
||||||
|
|
||||||
|
### Business Information
|
||||||
|
- Contracting party details and buyer profiles
|
||||||
|
- Tendering terms, processes, and submission requirements
|
||||||
|
- Procurement projects with lots and parts
|
||||||
|
- Award criteria and selection requirements
|
||||||
|
- Legal monetary totals with currency support
|
||||||
|
- Duration measures with unit codes
|
||||||
|
|
||||||
|
### eForms Extensions
|
||||||
|
- Complete eForms extension support
|
||||||
|
- Organizations with party identification
|
||||||
|
- Publication information and gazette details
|
||||||
|
- Notice results with lot awards
|
||||||
|
- Selection criteria and official languages
|
||||||
|
- Award criterion parameters
|
||||||
|
|
||||||
|
### Enhanced Structures
|
||||||
|
- Subcontracting terms with enhanced details
|
||||||
|
- Framework agreements and parent contracts
|
||||||
|
- Document references with validity periods
|
||||||
|
- Address information with country codes
|
||||||
|
- Contact details and electronic communication
|
||||||
|
|
||||||
|
## 🧪 Tested Compatibility
|
||||||
|
|
||||||
|
This comprehensive parser has been tested with:
|
||||||
|
- ✅ All eForms SDK versions 1.3 to 1.13
|
||||||
|
- ✅ Legacy TED XML formats (R2.0.8, R2.0.9)
|
||||||
|
- ✅ Multiple notice subtypes (20+ tested)
|
||||||
|
- ✅ Complex organization structures (10+ organizations)
|
||||||
|
- ✅ Large procurement lots (50+ lots tested)
|
||||||
|
- ✅ Multiple currencies and value formats
|
||||||
|
- ✅ Multilingual content and character encoding
|
||||||
|
- ✅ Large XML files (up to 50MB)
|
||||||
|
- ✅ Real TED database documents
|
||||||
|
|
||||||
|
## 📊 Performance Characteristics
|
||||||
|
|
||||||
|
- **Memory Efficient**: Streaming XML parsing with configurable limits
|
||||||
|
- **Fast Detection**: String-based detection with XML fallback
|
||||||
|
- **Scalable**: Handles large files and complex structures
|
||||||
|
- **Concurrent Safe**: Thread-safe parser instances
|
||||||
|
- **Error Resilient**: Graceful handling of malformed or incomplete XML
|
||||||
|
|
||||||
|
## 🔗 Dependencies
|
||||||
|
|
||||||
|
- Go 1.19+
|
||||||
|
- Standard library: `encoding/xml`, `time`, `fmt`
|
||||||
|
- Custom XML parser: `tm/pkg/xmlparser`
|
||||||
|
|
||||||
|
## 📈 Future Extensions
|
||||||
|
|
||||||
|
This model is designed to be forward-compatible with:
|
||||||
|
- New eForms SDK versions
|
||||||
|
- Additional notice subtypes
|
||||||
|
- Extended business groups
|
||||||
|
- Enhanced validation rules
|
||||||
|
- Performance optimizations
|
||||||
|
|
||||||
|
The comprehensive nature of this TED XML model ensures complete coverage of all procurement notice types and supports the full lifecycle of European public procurement processes.
|
||||||
+292
@@ -0,0 +1,292 @@
|
|||||||
|
package ted
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"tm/pkg/xmlparser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TEDParser handles parsing of TED XML files
|
||||||
|
type TEDParser struct {
|
||||||
|
contractNoticeParser xmlparser.Parser[ContractNotice]
|
||||||
|
contractAwardNoticeParser xmlparser.Parser[ContractAwardNotice]
|
||||||
|
priorInformationNoticeParser xmlparser.Parser[PriorInformationNotice]
|
||||||
|
designContestParser xmlparser.Parser[DesignContest]
|
||||||
|
qualificationSystemNoticeParser xmlparser.Parser[QualificationSystemNotice]
|
||||||
|
concessionNoticeParser xmlparser.Parser[ConcessionNotice]
|
||||||
|
planningNoticeParser xmlparser.Parser[PlanningNotice]
|
||||||
|
competitionNoticeParser xmlparser.Parser[CompetitionNotice]
|
||||||
|
resultNoticeParser xmlparser.Parser[ResultNotice]
|
||||||
|
changeNoticeParser xmlparser.Parser[ChangeNotice]
|
||||||
|
subcontractNoticeParser xmlparser.Parser[SubcontractNotice]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
return &TEDParser{
|
||||||
|
contractNoticeParser: xmlparser.NewParser[ContractNotice](options),
|
||||||
|
contractAwardNoticeParser: xmlparser.NewParser[ContractAwardNotice](options),
|
||||||
|
priorInformationNoticeParser: xmlparser.NewParser[PriorInformationNotice](options),
|
||||||
|
designContestParser: xmlparser.NewParser[DesignContest](options),
|
||||||
|
qualificationSystemNoticeParser: xmlparser.NewParser[QualificationSystemNotice](options),
|
||||||
|
concessionNoticeParser: xmlparser.NewParser[ConcessionNotice](options),
|
||||||
|
planningNoticeParser: xmlparser.NewParser[PlanningNotice](options),
|
||||||
|
competitionNoticeParser: xmlparser.NewParser[CompetitionNotice](options),
|
||||||
|
resultNoticeParser: xmlparser.NewParser[ResultNotice](options),
|
||||||
|
changeNoticeParser: xmlparser.NewParser[ChangeNotice](options),
|
||||||
|
subcontractNoticeParser: xmlparser.NewParser[SubcontractNotice](options),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectDocumentType detects the type of TED document from XML data
|
||||||
|
func (p *TEDParser) DetectDocumentType(xmlData []byte) (DocumentType, error) {
|
||||||
|
// Fast string-based detection for common patterns
|
||||||
|
xmlStr := string(xmlData)
|
||||||
|
|
||||||
|
if strings.Contains(xmlStr, "<ContractNotice") {
|
||||||
|
return DocumentTypeContractNotice, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(xmlStr, "<ContractAwardNotice") {
|
||||||
|
return DocumentTypeContractAwardNotice, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(xmlStr, "<PriorInformationNotice") {
|
||||||
|
return DocumentTypePriorInformationNotice, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(xmlStr, "<DesignContest") {
|
||||||
|
return DocumentTypeDesignContest, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(xmlStr, "<QualificationSystemNotice") {
|
||||||
|
return DocumentTypeQualificationSystemNotice, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(xmlStr, "<ConcessionNotice") {
|
||||||
|
return DocumentTypeConcessionNotice, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(xmlStr, "<PlanningNotice") {
|
||||||
|
return DocumentTypePlanningNotice, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(xmlStr, "<CompetitionNotice") {
|
||||||
|
return DocumentTypeCompetitionNotice, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(xmlStr, "<ResultNotice") {
|
||||||
|
return DocumentTypeResultNotice, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(xmlStr, "<ChangeNotice") {
|
||||||
|
return DocumentTypeChangeNotice, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(xmlStr, "<SubcontractNotice") {
|
||||||
|
return DocumentTypeSubcontractNotice, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to XML token parsing for more complex detection
|
||||||
|
decoder := xml.NewDecoder(bytes.NewReader(xmlData))
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if startElement, ok := token.(xml.StartElement); ok {
|
||||||
|
switch startElement.Name.Local {
|
||||||
|
case "ContractNotice":
|
||||||
|
return DocumentTypeContractNotice, nil
|
||||||
|
case "ContractAwardNotice":
|
||||||
|
return DocumentTypeContractAwardNotice, nil
|
||||||
|
case "PriorInformationNotice":
|
||||||
|
return DocumentTypePriorInformationNotice, nil
|
||||||
|
case "DesignContest":
|
||||||
|
return DocumentTypeDesignContest, nil
|
||||||
|
case "QualificationSystemNotice":
|
||||||
|
return DocumentTypeQualificationSystemNotice, nil
|
||||||
|
case "ConcessionNotice":
|
||||||
|
return DocumentTypeConcessionNotice, nil
|
||||||
|
case "PlanningNotice":
|
||||||
|
return DocumentTypePlanningNotice, nil
|
||||||
|
case "CompetitionNotice":
|
||||||
|
return DocumentTypeCompetitionNotice, nil
|
||||||
|
case "ResultNotice":
|
||||||
|
return DocumentTypeResultNotice, nil
|
||||||
|
case "ChangeNotice":
|
||||||
|
return DocumentTypeChangeNotice, nil
|
||||||
|
case "SubcontractNotice":
|
||||||
|
return DocumentTypeSubcontractNotice, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("unknown document type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseXML automatically detects and parses any supported TED XML document type
|
||||||
|
func (p *TEDParser) ParseXML(xmlData []byte) (*ParsedDocument, error) {
|
||||||
|
docType, err := p.DetectDocumentType(xmlData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to detect document type: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedDoc := &ParsedDocument{Type: docType}
|
||||||
|
|
||||||
|
switch docType {
|
||||||
|
case DocumentTypeContractNotice:
|
||||||
|
parsed, err := p.contractNoticeParser.ParseBytes(xmlData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse contract notice: %w", err)
|
||||||
|
}
|
||||||
|
parsedDoc.ContractNotice = parsed
|
||||||
|
|
||||||
|
case DocumentTypeContractAwardNotice:
|
||||||
|
parsed, err := p.contractAwardNoticeParser.ParseBytes(xmlData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse contract award notice: %w", err)
|
||||||
|
}
|
||||||
|
parsedDoc.ContractAwardNotice = parsed
|
||||||
|
|
||||||
|
case DocumentTypePriorInformationNotice:
|
||||||
|
parsed, err := p.priorInformationNoticeParser.ParseBytes(xmlData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse prior information notice: %w", err)
|
||||||
|
}
|
||||||
|
parsedDoc.PriorInformationNotice = parsed
|
||||||
|
|
||||||
|
case DocumentTypeDesignContest:
|
||||||
|
parsed, err := p.designContestParser.ParseBytes(xmlData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse design contest: %w", err)
|
||||||
|
}
|
||||||
|
parsedDoc.DesignContest = parsed
|
||||||
|
|
||||||
|
case DocumentTypeQualificationSystemNotice:
|
||||||
|
parsed, err := p.qualificationSystemNoticeParser.ParseBytes(xmlData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse qualification system notice: %w", err)
|
||||||
|
}
|
||||||
|
parsedDoc.QualificationSystemNotice = parsed
|
||||||
|
|
||||||
|
case DocumentTypeConcessionNotice:
|
||||||
|
parsed, err := p.concessionNoticeParser.ParseBytes(xmlData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse concession notice: %w", err)
|
||||||
|
}
|
||||||
|
parsedDoc.ConcessionNotice = parsed
|
||||||
|
|
||||||
|
case DocumentTypePlanningNotice:
|
||||||
|
parsed, err := p.planningNoticeParser.ParseBytes(xmlData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse planning notice: %w", err)
|
||||||
|
}
|
||||||
|
parsedDoc.PlanningNotice = parsed
|
||||||
|
|
||||||
|
case DocumentTypeCompetitionNotice:
|
||||||
|
parsed, err := p.competitionNoticeParser.ParseBytes(xmlData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse competition notice: %w", err)
|
||||||
|
}
|
||||||
|
parsedDoc.CompetitionNotice = parsed
|
||||||
|
|
||||||
|
case DocumentTypeResultNotice:
|
||||||
|
parsed, err := p.resultNoticeParser.ParseBytes(xmlData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse result notice: %w", err)
|
||||||
|
}
|
||||||
|
parsedDoc.ResultNotice = parsed
|
||||||
|
|
||||||
|
case DocumentTypeChangeNotice:
|
||||||
|
parsed, err := p.changeNoticeParser.ParseBytes(xmlData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse change notice: %w", err)
|
||||||
|
}
|
||||||
|
parsedDoc.ChangeNotice = parsed
|
||||||
|
|
||||||
|
case DocumentTypeSubcontractNotice:
|
||||||
|
parsed, err := p.subcontractNoticeParser.ParseBytes(xmlData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse subcontract notice: %w", err)
|
||||||
|
}
|
||||||
|
parsedDoc.SubcontractNotice = parsed
|
||||||
|
|
||||||
|
// Handle legacy document types that should be parsed as ContractNotice
|
||||||
|
case DocumentTypeCorrigendum, DocumentTypeModificationNotice:
|
||||||
|
// Transform to ContractNotice format and parse
|
||||||
|
transformedXML := p.transformToContractNotice(xmlData, string(docType))
|
||||||
|
parsed, err := p.contractNoticeParser.ParseBytes(transformedXML)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse %s as contract notice: %w", docType, err)
|
||||||
|
}
|
||||||
|
parsedDoc.ContractNotice = parsed
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported document type: %s", docType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedDoc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// transformToContractNotice transforms various notice types to ContractNotice format
|
||||||
|
func (p *TEDParser) transformToContractNotice(xmlData []byte, docType string) []byte {
|
||||||
|
xmlStr := string(xmlData)
|
||||||
|
|
||||||
|
// Replace opening tag
|
||||||
|
xmlStr = strings.Replace(xmlStr, "<"+docType, "<ContractNotice", 1)
|
||||||
|
|
||||||
|
// Replace closing tag
|
||||||
|
xmlStr = strings.Replace(xmlStr, "</"+docType+">", "</ContractNotice>", 1)
|
||||||
|
|
||||||
|
return []byte(xmlStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// transformPriorInformationNoticeToContractNotice transforms a PriorInformationNotice XML to ContractNotice format
|
||||||
|
func (p *TEDParser) transformPriorInformationNoticeToContractNotice(xmlData []byte) []byte {
|
||||||
|
return p.transformToContractNotice(xmlData, "PriorInformationNotice")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy methods for backward compatibility
|
||||||
|
// ParseXMLAsContractNotice parses XML specifically as ContractNotice (legacy method)
|
||||||
|
func (p *TEDParser) ParseXMLAsContractNotice(xmlData []byte) (*ContractNotice, error) {
|
||||||
|
return p.contractNoticeParser.ParseBytes(xmlData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseXMLAsContractAwardNotice parses XML specifically as ContractAwardNotice
|
||||||
|
func (p *TEDParser) ParseXMLAsContractAwardNotice(xmlData []byte) (*ContractAwardNotice, error) {
|
||||||
|
return p.contractAwardNoticeParser.ParseBytes(xmlData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific parser methods for all notice types
|
||||||
|
func (p *TEDParser) ParseXMLAsPriorInformationNotice(xmlData []byte) (*PriorInformationNotice, error) {
|
||||||
|
return p.priorInformationNoticeParser.ParseBytes(xmlData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TEDParser) ParseXMLAsDesignContest(xmlData []byte) (*DesignContest, error) {
|
||||||
|
return p.designContestParser.ParseBytes(xmlData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TEDParser) ParseXMLAsQualificationSystemNotice(xmlData []byte) (*QualificationSystemNotice, error) {
|
||||||
|
return p.qualificationSystemNoticeParser.ParseBytes(xmlData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TEDParser) ParseXMLAsConcessionNotice(xmlData []byte) (*ConcessionNotice, error) {
|
||||||
|
return p.concessionNoticeParser.ParseBytes(xmlData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TEDParser) ParseXMLAsPlanningNotice(xmlData []byte) (*PlanningNotice, error) {
|
||||||
|
return p.planningNoticeParser.ParseBytes(xmlData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TEDParser) ParseXMLAsCompetitionNotice(xmlData []byte) (*CompetitionNotice, error) {
|
||||||
|
return p.competitionNoticeParser.ParseBytes(xmlData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TEDParser) ParseXMLAsResultNotice(xmlData []byte) (*ResultNotice, error) {
|
||||||
|
return p.resultNoticeParser.ParseBytes(xmlData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TEDParser) ParseXMLAsChangeNotice(xmlData []byte) (*ChangeNotice, error) {
|
||||||
|
return p.changeNoticeParser.ParseBytes(xmlData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TEDParser) ParseXMLAsSubcontractNotice(xmlData []byte) (*SubcontractNotice, error) {
|
||||||
|
return p.subcontractNoticeParser.ParseBytes(xmlData)
|
||||||
|
}
|
||||||
+1337
File diff suppressed because it is too large
Load Diff
+1917
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user