Compare commits

...

12 Commits

Author SHA1 Message Date
m.nazemi fa16d4080a Merge pull request 'Add unit tests for backfilling notice location from buyer' (#53) from TM-485 into develop
continuous-integration/drone/push Build is passing
Reviewed-on: https://repo.ravanertebat.com/TM/tm_back/pulls/53
Reviewed-by: Hadi Barzegar <barzagarhadi@gmail.com>
2026-07-13 14:20:08 +03:30
Mazyar e4f7c4a04c Add unit tests for backfilling notice location from buyer
- Introduced `mapper_test.go` to validate the functionality of backfilling notice location details from the buyer organization.
- Implemented two test cases: one to ensure the location fields are correctly populated from the buyer when they are empty, and another to verify that existing values are not overwritten.
- Enhanced the `mapper.go` file by adding the `backfillNoticeLocationFromBuyer` function to facilitate this logic.

This update improves the reliability of notice data handling within the tender management system by ensuring accurate location information is maintained.
2026-07-13 13:44:31 +03:30
Mazyar 51a1a6aa82 Refactor AI pipeline terminology for consistency
continuous-integration/drone/push Build is passing
- Updated comments and logging messages in the worker and related files to replace "daily-run" with "auto run" for clarity and consistency.
- Adjusted the `WorkerConfig` struct to reflect the new terminology in configuration settings.
- Renamed functions and test cases to align with the updated terminology, enhancing code readability and maintainability.

This change improves the clarity of the AI pipeline's functionality within the tender management system.
2026-07-13 11:54:58 +03:30
m.nazemi 6ee81c3581 Merge pull request 'Add documents scraped filter functionality and related tests' (#51) from documents-scraped into develop
continuous-integration/drone/push Build is passing
Reviewed-on: https://repo.ravanertebat.com/TM/tm_back/pulls/51
2026-07-12 22:03:13 +03:30
Mazyar d55b2685ca Merge branch 'documents-scraped' of https://repo.ravanertebat.com/TM/tm_back into documents-scraped 2026-07-12 22:02:43 +03:30
Mazyar 04c4102170 Enhance documents scraped filter with cancellation handling and caching improvements
- Updated `documents_scraped_filter.go` to extend the caching mechanism for contract folder IDs, increasing the cache TTL to 15 minutes to prevent premature invalidation during slow scans.
- Modified the `cachedContractFolderIDsWithDocuments` method to detach from the caller's cancellation, ensuring that shared scans are not aborted by client disconnects or timeouts.
- Introduced a new test, `TestCachedContractFolderIDsSurvivesCallerCancellation`, in `documents_scraped_filter_test.go` to validate the behavior of the caching mechanism under cancellation scenarios.
- Enhanced the `scanStartedProcedureLister` to manage scan initiation and cancellation more effectively, improving the robustness of the document retrieval process.

This update improves the reliability and performance of the tender management system's document filtering capabilities, particularly in scenarios with long-running scans.
2026-07-12 21:54:21 +03:30
Mazyar 0fde5772e2 Add documents scraped filter functionality and related tests
- Introduced `documents_scraped_filter.go` to handle filtering tenders based on the presence of documents in MinIO, utilizing a caching mechanism to optimize performance.
- Implemented `enrichDocumentsScrapedFilter` and `cachedContractFolderIDsWithDocuments` methods in the `tenderService` for efficient retrieval of contract folder IDs with documents.
- Added unit tests in `documents_scraped_filter_test.go` to validate the new filtering logic and caching behavior, ensuring accurate results and error handling.
- Updated `SearchForm` documentation to clarify the caching aspect of `ContractFolderIDsWithDocuments`.
- Enhanced comments in the handler and repository to reflect the new filtering logic and its implications.

This update improves the tender search functionality by efficiently managing document presence checks, leading to better performance and user experience in the tender management system.
2026-07-12 21:54:21 +03:30
m.nazemi c35fa331f9 Merge branch 'develop' into documents-scraped 2026-07-12 21:46:03 +03:30
m.nazemi c8616940ff Merge pull request 'Add tender submission functionality and related routes' (#52) from tender-submission into develop
Reviewed-on: https://repo.ravanertebat.com/TM/tm_back/pulls/52
2026-07-12 21:45:23 +03:30
Mazyar fa3d466579 Enhance tender approval and submission handling
- Added a new error for submission sync failures, improving error handling in the tender approval process.
- Updated the `ToggleTenderApproval` method to handle the new error, providing clearer responses for sync issues.
- Introduced a method to reopen terminal submissions when tender approvals are resubmitted, enhancing submission state management.
- Implemented optimistic locking in the repository to prevent concurrent updates, improving data integrity.
- Added unit tests for new submission handling logic, ensuring robust functionality and error management.

This update strengthens the tender approval workflow and submission handling, ensuring better error reporting and state management.
2026-07-12 21:41:53 +03:30
Mazyar 0cce9ef1b5 Add tender submission functionality and related routes
- Introduced the `tender_submission` domain, including entity, repository, service, and handler implementations for managing tender submissions.
- Added new routes for both admin and public access to tender submissions, allowing for listing, ensuring, and retrieving submissions by ID and tender.
- Enhanced the tender approval service to synchronize submission workflows with approval changes, ensuring proper state management.
- Implemented validation and response structures for tender submission operations, improving API consistency and usability.
- Added unit tests for tender submission status transitions and workflow logic, ensuring robust functionality.

This update enhances the tender management system by providing comprehensive support for tender submissions, improving overall workflow and user experience.
2026-07-12 14:54:42 +03:30
Mazyar 5c93e0f01b Add documents scraped filter functionality and related tests
- Introduced `documents_scraped_filter.go` to handle filtering tenders based on the presence of documents in MinIO, utilizing a caching mechanism to optimize performance.
- Implemented `enrichDocumentsScrapedFilter` and `cachedContractFolderIDsWithDocuments` methods in the `tenderService` for efficient retrieval of contract folder IDs with documents.
- Added unit tests in `documents_scraped_filter_test.go` to validate the new filtering logic and caching behavior, ensuring accurate results and error handling.
- Updated `SearchForm` documentation to clarify the caching aspect of `ContractFolderIDsWithDocuments`.
- Enhanced comments in the handler and repository to reflect the new filtering logic and its implications.

This update improves the tender search functionality by efficiently managing document presence checks, leading to better performance and user experience in the tender management system.
2026-07-12 00:20:39 +03:30
28 changed files with 2555 additions and 91 deletions
+10 -6
View File
@@ -119,6 +119,7 @@ import (
"tm/internal/notification" "tm/internal/notification"
"tm/internal/tender" "tm/internal/tender"
"tm/internal/tender_approval" "tm/internal/tender_approval"
"tm/internal/tender_submission"
"tm/internal/user" "tm/internal/user"
"tm/pkg/filestore" "tm/pkg/filestore"
"tm/pkg/security" "tm/pkg/security"
@@ -201,6 +202,7 @@ func main() {
tenderRepository := tender.NewRepository(mongoManager, logger) tenderRepository := tender.NewRepository(mongoManager, logger)
feedbackRepo := feedback.NewRepository(mongoManager, logger) feedbackRepo := feedback.NewRepository(mongoManager, logger)
tenderApprovalRepository := tender_approval.NewRepository(mongoManager, logger) tenderApprovalRepository := tender_approval.NewRepository(mongoManager, logger)
tenderSubmissionRepository := tender_submission.NewRepository(mongoManager, logger)
inquiryRepository := inquiry.NewRepository(mongoManager, logger) inquiryRepository := inquiry.NewRepository(mongoManager, logger)
contactRepository := contact.NewContactRepository(mongoManager, logger) contactRepository := contact.NewContactRepository(mongoManager, logger)
cmsRepository := cms.NewRepository(mongoManager, logger) cmsRepository := cms.NewRepository(mongoManager, logger)
@@ -210,7 +212,7 @@ func main() {
cardRepository := kanban.NewCardRepository(mongoManager, logger) cardRepository := kanban.NewCardRepository(mongoManager, logger)
notificationRepository := notification.NewRepository(mongoManager, logger) notificationRepository := notification.NewRepository(mongoManager, logger)
logger.Info("Repositories initialized successfully", map[string]interface{}{ logger.Info("Repositories initialized successfully", map[string]interface{}{
"repositories": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "contact", "cms", "notice", "kanban_board", "kanban_column", "kanban_card", "notification"}, "repositories": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "tender_submission", "inquiry", "contact", "cms", "notice", "kanban_board", "kanban_column", "kanban_card", "notification"},
}) })
// Initialize validation services // Initialize validation services
@@ -262,7 +264,8 @@ func main() {
} }
} }
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger) feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger) tenderSubmissionService := tender_submission.NewService(tenderSubmissionRepository, tenderApprovalRepository, tenderService, logger)
tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger, tenderSubmissionService)
inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger, xssPolicy) inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger, xssPolicy)
flagService := assets.NewService(logger, conf.Assets.FlagsPath) flagService := assets.NewService(logger, conf.Assets.FlagsPath)
notificationService := notification.NewService(notificationSDK, notificationRepository, userService, customerService, logger) notificationService := notification.NewService(notificationSDK, notificationRepository, userService, customerService, logger)
@@ -276,7 +279,7 @@ func main() {
auditLogRepository := auditlog.NewRepository(auditStore) auditLogRepository := auditlog.NewRepository(auditStore)
auditLogService := auditlog.NewService(auditLogRepository, logger) auditLogService := auditlog.NewService(auditLogRepository, logger)
logger.Info("Services initialized successfully", map[string]interface{}{ logger.Info("Services initialized successfully", map[string]interface{}{
"services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "document_scraper", "dashboard", "ai_pipeline", "auditlog"}, "services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "tender_submission", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "document_scraper", "dashboard", "ai_pipeline", "auditlog"},
}) })
// Initialize handlers with services // Initialize handlers with services
@@ -287,6 +290,7 @@ func main() {
tenderHandler := tender.NewHandler(tenderService, logger) tenderHandler := tender.NewHandler(tenderService, logger)
feedbackHandler := feedback.NewHandler(feedbackService, userHandler, customerHandler, logger) feedbackHandler := feedback.NewHandler(feedbackService, userHandler, customerHandler, logger)
tenderApprovalHandler := tender_approval.NewHandler(tenderApprovalService, logger) tenderApprovalHandler := tender_approval.NewHandler(tenderApprovalService, logger)
tenderSubmissionHandler := tender_submission.NewHandler(tenderSubmissionService, logger)
inquiryHandler := inquiry.NewHandler(inquiryService, logger, hcaptchaVerifier, xssPolicy) inquiryHandler := inquiry.NewHandler(inquiryService, logger, hcaptchaVerifier, xssPolicy)
flagHandler := assets.NewHandler(flagService, logger) flagHandler := assets.NewHandler(flagService, logger)
notificationHandler := notification.NewHandler(notificationService) notificationHandler := notification.NewHandler(notificationService)
@@ -299,7 +303,7 @@ func main() {
aiPipelineHandler := ai_pipeline.NewHandler(aiPipelineService) aiPipelineHandler := ai_pipeline.NewHandler(aiPipelineService)
auditLogHandler := auditlog.NewHandler(auditLogService) auditLogHandler := auditlog.NewHandler(auditLogService)
logger.Info("Handlers initialized successfully", map[string]interface{}{ logger.Info("Handlers initialized successfully", map[string]interface{}{
"handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "filestore", "document_scraper", "dashboard", "ai_pipeline", "auditlog"}, "handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "tender_submission", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "filestore", "document_scraper", "dashboard", "ai_pipeline", "auditlog"},
}) })
// Initialize HTTP server // Initialize HTTP server
@@ -308,8 +312,8 @@ func main() {
router.SetupCSPSecurity(e) router.SetupCSPSecurity(e)
// Register routes // Register routes
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, dashboardHandler, aiPipelineHandler, auditLogHandler, xssPolicy) router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, tenderSubmissionHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, dashboardHandler, aiPipelineHandler, auditLogHandler, xssPolicy)
router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, xssPolicy) router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, tenderSubmissionHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, xssPolicy)
router.RegisterDocumentScraperRoutes(e, documentScraperHandler, conf.DocumentScraper.APIKey) router.RegisterDocumentScraperRoutes(e, documentScraperHandler, conf.DocumentScraper.APIKey)
// Start server // Start server
+24 -2
View File
@@ -17,6 +17,7 @@ import (
"tm/internal/notification" "tm/internal/notification"
"tm/internal/tender" "tm/internal/tender"
"tm/internal/tender_approval" "tm/internal/tender_approval"
"tm/internal/tender_submission"
"tm/internal/user" "tm/internal/user"
"tm/pkg/filestore" "tm/pkg/filestore"
"tm/pkg/security" "tm/pkg/security"
@@ -36,7 +37,7 @@ func SetupCSPSecurity(e *echo.Echo) {
e.POST("/api/v1/csp-report", security.CSPReportHandler) e.POST("/api/v1/csp-report", security.CSPReportHandler)
} }
func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, dashboardHandler *dashboard.Handler, aiPipelineHandler *ai_pipeline.Handler, auditLogHandler *auditlog.Handler, xssPolicy *security.XSSPolicy) { func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, tenderSubmissionHandler *tender_submission.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, dashboardHandler *dashboard.Handler, aiPipelineHandler *ai_pipeline.Handler, auditLogHandler *auditlog.Handler, xssPolicy *security.XSSPolicy) {
adminV1 := e.Group("/admin/v1") adminV1 := e.Group("/admin/v1")
// Admin Users Routes // Admin Users Routes
@@ -156,6 +157,15 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
tenderApprovalGP.GET("/status/:status", tenderApprovalHandler.GetTenderApprovalsByStatus) tenderApprovalGP.GET("/status/:status", tenderApprovalHandler.GetTenderApprovalsByStatus)
} }
// Admin Tender-Submissions Routes
tenderSubmissionGP := adminV1.Group("/tender-submissions")
{
tenderSubmissionGP.Use(userHandler.AuthMiddleware())
tenderSubmissionGP.GET("", tenderSubmissionHandler.AdminListSubmissions)
tenderSubmissionGP.GET("/stats", tenderSubmissionHandler.AdminGetSubmissionStats)
tenderSubmissionGP.GET("/:id", tenderSubmissionHandler.AdminGetSubmissionByID)
}
// Admin Inquiry Routes // Admin Inquiry Routes
inquiryGP := adminV1.Group("/inquiries") inquiryGP := adminV1.Group("/inquiries")
{ {
@@ -260,7 +270,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
} }
} }
func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, xssPolicy *security.XSSPolicy) { func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, tenderSubmissionHandler *tender_submission.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, xssPolicy *security.XSSPolicy) {
v1 := e.Group("/api/v1") v1 := e.Group("/api/v1")
customerGP := v1.Group("/profile") customerGP := v1.Group("/profile")
@@ -317,6 +327,18 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende
tenderApprovalGP.GET("/stats", tenderApprovalHandler.GetCompanyTenderApprovalStats) tenderApprovalGP.GET("/stats", tenderApprovalHandler.GetCompanyTenderApprovalStats)
} }
// Public Tender Submissions Routes
tenderSubmissionGP := v1.Group("/tender-submissions")
{
tenderSubmissionGP.Use(customerHandler.AuthMiddleware(), customerHandler.CompanyContextMiddleware())
tenderSubmissionGP.GET("", tenderSubmissionHandler.ListCompanySubmissions)
tenderSubmissionGP.GET("/stats", tenderSubmissionHandler.GetCompanySubmissionStats)
tenderSubmissionGP.POST("/tender/:tender_id/ensure", tenderSubmissionHandler.EnsureSubmission)
tenderSubmissionGP.GET("/tender/:tender_id", tenderSubmissionHandler.GetSubmissionByTender)
tenderSubmissionGP.PATCH("/:id/status", tenderSubmissionHandler.UpdateSubmissionStatus)
tenderSubmissionGP.GET("/:id", tenderSubmissionHandler.GetSubmissionByID)
}
// Public Company Routes // Public Company Routes
companiesGP := v1.Group("/companies") companiesGP := v1.Group("/companies")
{ {
+16 -16
View File
@@ -23,7 +23,7 @@ import (
"tm/pkg/schedule" "tm/pkg/schedule"
) )
// aiPipelineDailyRunMu ensures only one AI pipeline daily-run executes at a time (startup catch-up and cron). // aiPipelineDailyRunMu ensures only one AI pipeline auto run executes at a time (startup catch-up and cron).
var aiPipelineDailyRunMu sync.Mutex var aiPipelineDailyRunMu sync.Mutex
// Init Application Configuration // Init Application Configuration
@@ -98,8 +98,8 @@ func InitWorker(config Config, mongoManager *mongo.ConnectionManager, appLogger
"translation_enabled": config.Worker.TranslationEnabled, "translation_enabled": config.Worker.TranslationEnabled,
"translation_interval": config.Worker.TranslationInterval, "translation_interval": config.Worker.TranslationInterval,
"notification_interval": config.Worker.NotificationInterval, "notification_interval": config.Worker.NotificationInterval,
"ai_pipeline_daily_enabled": config.Worker.AIPipelineAutoEnabled, "ai_pipeline_auto_enabled": config.Worker.AIPipelineAutoEnabled,
"ai_pipeline_daily_interval": config.Worker.AIPipelineAutoInterval, "ai_pipeline_auto_interval": config.Worker.AIPipelineAutoInterval,
}) })
// Initialize repositories // Initialize repositories
noticeRepo := notice.NewRepository(mongoManager, appLogger) noticeRepo := notice.NewRepository(mongoManager, appLogger)
@@ -231,8 +231,8 @@ func InitWorker(config Config, mongoManager *mongo.ConnectionManager, appLogger
}) })
if !config.Worker.AIPipelineAutoEnabled { if !config.Worker.AIPipelineAutoEnabled {
appLogger.Info("AI pipeline daily-run worker disabled by configuration", map[string]interface{}{ appLogger.Info("AI pipeline auto worker disabled by configuration", map[string]interface{}{
"ai_pipeline_daily_enabled": false, "ai_pipeline_auto_enabled": false,
}) })
} else if aiClient != nil { } else if aiClient != nil {
dailyJobTracker := schedule.NewDailyJobTracker(mongoManager, appLogger) dailyJobTracker := schedule.NewDailyJobTracker(mongoManager, appLogger)
@@ -246,13 +246,13 @@ func InitWorker(config Config, mongoManager *mongo.ConnectionManager, appLogger
completed, checkErr := dailyJobTracker.IsCompleted(ctx, schedule.AIPipelineDailyJobName, today) completed, checkErr := dailyJobTracker.IsCompleted(ctx, schedule.AIPipelineDailyJobName, today)
if checkErr != nil { if checkErr != nil {
appLogger.Error("Failed to check AI pipeline daily-run completion status", map[string]interface{}{ appLogger.Error("Failed to check AI pipeline auto completion status", map[string]interface{}{
"trigger": trigger, "trigger": trigger,
"error": checkErr.Error(), "error": checkErr.Error(),
"date": today.Format("02/01/2006"), "date": today.Format("02/01/2006"),
}) })
} else if completed { } else if completed {
appLogger.Info("AI pipeline daily-run skipped: already completed today", map[string]interface{}{ appLogger.Info("AI pipeline auto skipped: already completed today", map[string]interface{}{
"trigger": trigger, "trigger": trigger,
"date": today.Format("02/01/2006"), "date": today.Format("02/01/2006"),
}) })
@@ -260,11 +260,11 @@ func InitWorker(config Config, mongoManager *mongo.ConnectionManager, appLogger
} }
if trigger == "startup" { if trigger == "startup" {
appLogger.Info("Running startup catch-up for today's AI pipeline daily-run", map[string]interface{}{ appLogger.Info("Running startup catch-up for today's AI pipeline auto run", map[string]interface{}{
"date": today.Format("02/01/2006"), "date": today.Format("02/01/2006"),
}) })
} else { } else {
appLogger.Info("Running scheduled AI pipeline daily-run", map[string]interface{}{ appLogger.Info("Running scheduled AI pipeline auto run", map[string]interface{}{
"date": today.Format("02/01/2006"), "date": today.Format("02/01/2006"),
}) })
} }
@@ -272,12 +272,12 @@ func InitWorker(config Config, mongoManager *mongo.ConnectionManager, appLogger
worker := workers.NewAIPipelineDailyWorker(appLogger, aiClient) worker := workers.NewAIPipelineDailyWorker(appLogger, aiClient)
if err := worker.Run(); err != nil { if err := worker.Run(); err != nil {
if errors.Is(err, ai_summarizer.ErrPipelineJobRunning) { if errors.Is(err, ai_summarizer.ErrPipelineJobRunning) {
appLogger.Info("AI pipeline daily-run deferred: job already running", map[string]interface{}{ appLogger.Info("AI pipeline auto deferred: job already running", map[string]interface{}{
"trigger": trigger, "trigger": trigger,
"date": today.Format("02/01/2006"), "date": today.Format("02/01/2006"),
}) })
} else { } else {
appLogger.Error("AI pipeline daily-run failed", map[string]interface{}{ appLogger.Error("AI pipeline auto failed", map[string]interface{}{
"trigger": trigger, "trigger": trigger,
"date": today.Format("02/01/2006"), "date": today.Format("02/01/2006"),
"error": err.Error(), "error": err.Error(),
@@ -287,14 +287,14 @@ func InitWorker(config Config, mongoManager *mongo.ConnectionManager, appLogger
} }
if markErr := dailyJobTracker.MarkCompleted(ctx, schedule.AIPipelineDailyJobName, today); markErr != nil { if markErr := dailyJobTracker.MarkCompleted(ctx, schedule.AIPipelineDailyJobName, today); markErr != nil {
appLogger.Error("Failed to mark AI pipeline daily-run as completed", map[string]interface{}{ appLogger.Error("Failed to mark AI pipeline auto as completed", map[string]interface{}{
"trigger": trigger, "trigger": trigger,
"date": today.Format("02/01/2006"), "date": today.Format("02/01/2006"),
"error": markErr.Error(), "error": markErr.Error(),
}) })
} }
appLogger.Info("AI pipeline daily-run completed successfully", map[string]interface{}{ appLogger.Info("AI pipeline auto completed successfully", map[string]interface{}{
"trigger": trigger, "trigger": trigger,
"date": today.Format("02/01/2006"), "date": today.Format("02/01/2006"),
}) })
@@ -305,11 +305,11 @@ func InitWorker(config Config, mongoManager *mongo.ConnectionManager, appLogger
} }
scheduler.AddJob(schedule.Job{ scheduler.AddJob(schedule.Job{
Name: "AI Pipeline Daily Run Job", Name: "AI Pipeline Auto Job",
Func: func() { runAIPipelineDaily("scheduled") }, Func: func() { runAIPipelineDaily("scheduled") },
Expr: config.Worker.AIPipelineAutoInterval, Expr: config.Worker.AIPipelineAutoInterval,
}) })
appLogger.Info("Scheduled AI pipeline daily-run worker", map[string]interface{}{ appLogger.Info("Scheduled AI pipeline auto worker", map[string]interface{}{
"interval": config.Worker.AIPipelineAutoInterval, "interval": config.Worker.AIPipelineAutoInterval,
}) })
@@ -317,7 +317,7 @@ func InitWorker(config Config, mongoManager *mongo.ConnectionManager, appLogger
runAIPipelineDaily("startup") runAIPipelineDaily("startup")
}() }()
} else { } else {
appLogger.Warn("AI summarizer client not available, AI pipeline daily-run worker is disabled", map[string]interface{}{}) appLogger.Warn("AI summarizer client not available, AI pipeline auto worker is disabled", map[string]interface{}{})
} }
// After a server restart, process any unprocessed notices (including today's) without blocking startup. // After a server restart, process any unprocessed notices (including today's) without blocking startup.
+3 -3
View File
@@ -36,12 +36,12 @@ type WorkerConfig struct {
TranslationEnabled bool `env:"WORKER_TRANSLATION_ENABLED" envDefault:"true"` TranslationEnabled bool `env:"WORKER_TRANSLATION_ENABLED" envDefault:"true"`
// NotificationInterval schedules promotion of due scheduled notifications from pending to sent. // NotificationInterval schedules promotion of due scheduled notifications from pending to sent.
NotificationInterval string `env:"WORKER_NOTIFICATION_INTERVAL" envDefault:"0 * * * * *"` NotificationInterval string `env:"WORKER_NOTIFICATION_INTERVAL" envDefault:"0 * * * * *"`
// AIPipelineAutoEnabled schedules the daily AI pipeline daily-run on the worker. // AIPipelineAutoEnabled schedules the daily AI pipeline auto run on the worker.
// On-demand pipeline routes on web are unaffected. // On-demand pipeline routes on web are unaffected.
AIPipelineAutoEnabled bool `env:"WORKER_AI_PIPELINE_AUTO_ENABLED" envDefault:"true"` AIPipelineAutoEnabled bool `env:"WORKER_AI_PIPELINE_AUTO_ENABLED" envDefault:"true"`
// AIPipelineAutoInterval runs daily-run once per day by default at 11:00 UTC (after TED scrape at 10:00). // AIPipelineAutoInterval runs pipeline auto once per day by default at 11:00 UTC (after TED scrape at 10:00).
AIPipelineAutoInterval string `env:"WORKER_AI_PIPELINE_AUTO_INTERVAL" envDefault:"0 0 11 * * *"` AIPipelineAutoInterval string `env:"WORKER_AI_PIPELINE_AUTO_INTERVAL" envDefault:"0 0 11 * * *"`
// RecommendationRefreshAfterPipelineEnabled refreshes cached company recommendations after the daily AI pipeline run. // RecommendationRefreshAfterPipelineEnabled refreshes cached company recommendations after the daily AI pipeline auto run.
RecommendationRefreshAfterPipelineEnabled bool `env:"WORKER_RECOMMENDATION_REFRESH" envDefault:"true"` RecommendationRefreshAfterPipelineEnabled bool `env:"WORKER_RECOMMENDATION_REFRESH" envDefault:"true"`
} }
+8 -8
View File
@@ -8,13 +8,13 @@ import (
"tm/pkg/logger" "tm/pkg/logger"
) )
// AIPipelineDailyWorker triggers the Opplens AI pipeline daily run (sync + full pipeline on newly synced tenders). // AIPipelineDailyWorker triggers the scheduled Opplens AI pipeline auto run.
type AIPipelineDailyWorker struct { type AIPipelineDailyWorker struct {
Logger logger.Logger Logger logger.Logger
AIClient *ai_summarizer.Client AIClient *ai_summarizer.Client
} }
// NewAIPipelineDailyWorker creates a worker that calls POST /pipeline/daily-run on the AI service. // NewAIPipelineDailyWorker creates a worker that calls POST /pipeline/auto on the AI service.
func NewAIPipelineDailyWorker(log logger.Logger, aiClient *ai_summarizer.Client) *AIPipelineDailyWorker { func NewAIPipelineDailyWorker(log logger.Logger, aiClient *ai_summarizer.Client) *AIPipelineDailyWorker {
return &AIPipelineDailyWorker{ return &AIPipelineDailyWorker{
Logger: log, Logger: log,
@@ -22,28 +22,28 @@ func NewAIPipelineDailyWorker(log logger.Logger, aiClient *ai_summarizer.Client)
} }
} }
// Run starts the pipeline daily-run job. A 409 response means a run is already in progress and is returned as an error // Run starts the pipeline auto job. A 409 response means a run is already in progress and is returned as an error
// so the daily job tracker does not mark today as completed before the run actually starts. // so the daily job tracker does not mark today as completed before the run actually starts.
func (w *AIPipelineDailyWorker) Run() error { func (w *AIPipelineDailyWorker) Run() error {
if w.AIClient == nil { if w.AIClient == nil {
w.Logger.Warn("AI pipeline daily worker skipped: AI client is not configured", map[string]interface{}{}) w.Logger.Warn("AI pipeline auto worker skipped: AI client is not configured", map[string]interface{}{})
return nil return nil
} }
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
w.Logger.Info("AI pipeline daily worker started", map[string]interface{}{}) w.Logger.Info("AI pipeline auto worker started", map[string]interface{}{})
resp, err := w.AIClient.PipelineDailyRun(ctx) resp, err := w.AIClient.PipelineAuto(ctx)
if err != nil { if err != nil {
w.Logger.Error("AI pipeline daily worker failed", map[string]interface{}{ w.Logger.Error("AI pipeline auto worker failed", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
return err return err
} }
w.Logger.Info("AI pipeline daily worker triggered successfully", map[string]interface{}{ w.Logger.Info("AI pipeline auto worker triggered successfully", map[string]interface{}{
"status": resp.Status, "status": resp.Status,
"report": resp.Report, "report": resp.Report,
}) })
@@ -13,7 +13,7 @@ import (
// AIPipelineStatusClient reports pipeline job status from the AI service. // AIPipelineStatusClient reports pipeline job status from the AI service.
type AIPipelineStatusClient interface { type AIPipelineStatusClient interface {
GetPipelineLastRun(ctx context.Context) (*ai_summarizer.PipelineReportResponse, error) GetPipelineLastAutoRun(ctx context.Context) (*ai_summarizer.PipelineReportResponse, error)
} }
const ( const (
@@ -22,7 +22,7 @@ const (
recommendationRefreshConcurrency = 3 recommendationRefreshConcurrency = 3
) )
// ScheduleRefreshCachedAIRecommendationsAfterPipeline waits for the AI daily pipeline to finish, // ScheduleRefreshCachedAIRecommendationsAfterPipeline waits for the AI pipeline auto run to finish,
// then re-fetches ranked tenders for every company that already has a recommendation cache. // then re-fetches ranked tenders for every company that already has a recommendation cache.
func (s *companyService) ScheduleRefreshCachedAIRecommendationsAfterPipeline() { func (s *companyService) ScheduleRefreshCachedAIRecommendationsAfterPipeline() {
if s.aiRecommendationClient == nil { if s.aiRecommendationClient == nil {
@@ -45,7 +45,7 @@ func (s *companyService) ScheduleRefreshCachedAIRecommendationsAfterPipeline() {
} }
func (s *companyService) refreshCachedAIRecommendationsAfterPipeline(ctx context.Context) error { func (s *companyService) refreshCachedAIRecommendationsAfterPipeline(ctx context.Context) error {
if err := s.waitForPipelineDailyRunCompletion(ctx); err != nil { if err := s.waitForPipelineAutoCompletion(ctx); err != nil {
return err return err
} }
@@ -95,7 +95,7 @@ func (s *companyService) refreshCachedAIRecommendationsAfterPipeline(ctx context
return nil return nil
} }
func (s *companyService) waitForPipelineDailyRunCompletion(ctx context.Context) error { func (s *companyService) waitForPipelineAutoCompletion(ctx context.Context) error {
if s.aiPipelineStatusClient == nil { if s.aiPipelineStatusClient == nil {
s.logger.Info("AI pipeline status client not configured; waiting before recommendation refresh", map[string]interface{}{ s.logger.Info("AI pipeline status client not configured; waiting before recommendation refresh", map[string]interface{}{
"delay_sec": int(recommendationPipelineWaitInitialDelay.Seconds()), "delay_sec": int(recommendationPipelineWaitInitialDelay.Seconds()),
@@ -134,9 +134,9 @@ func (s *companyService) waitForPipelineDailyRunCompletion(ctx context.Context)
} }
} }
report, err := s.aiPipelineStatusClient.GetPipelineLastRun(ctx) report, err := s.aiPipelineStatusClient.GetPipelineLastAutoRun(ctx)
if err != nil { if err != nil {
s.logger.Warn("Failed to read AI pipeline last-run status", map[string]interface{}{ s.logger.Warn("Failed to read AI pipeline last-auto-run status", map[string]interface{}{
"attempt": attempt, "attempt": attempt,
"error": err.Error(), "error": err.Error(),
}) })
@@ -147,8 +147,8 @@ func (s *companyService) waitForPipelineDailyRunCompletion(ctx context.Context)
continue continue
} }
if !isPipelineDailyRunInProgress(report) { if !isPipelineRunInProgress(report) {
s.logger.Info("AI pipeline daily-run finished; refreshing recommendation caches", map[string]interface{}{ s.logger.Info("AI pipeline auto run finished; refreshing recommendation caches", map[string]interface{}{
"attempt": attempt, "attempt": attempt,
"status": strings.TrimSpace(report.Status), "status": strings.TrimSpace(report.Status),
}) })
@@ -167,7 +167,7 @@ func (s *companyService) waitForPipelineDailyRunCompletion(ctx context.Context)
return nil return nil
} }
func isPipelineDailyRunInProgress(report *ai_summarizer.PipelineReportResponse) bool { func isPipelineRunInProgress(report *ai_summarizer.PipelineReportResponse) bool {
if report == nil { if report == nil {
return true return true
} }
@@ -6,7 +6,7 @@ import (
"tm/pkg/ai_summarizer" "tm/pkg/ai_summarizer"
) )
func TestIsPipelineDailyRunInProgress(t *testing.T) { func TestIsPipelineRunInProgress(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
report *ai_summarizer.PipelineReportResponse report *ai_summarizer.PipelineReportResponse
@@ -22,8 +22,8 @@ func TestIsPipelineDailyRunInProgress(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if got := isPipelineDailyRunInProgress(tt.report); got != tt.want { if got := isPipelineRunInProgress(tt.report); got != tt.want {
t.Fatalf("isPipelineDailyRunInProgress() = %v, want %v", got, tt.want) t.Fatalf("isPipelineRunInProgress() = %v, want %v", got, tt.want)
} }
}) })
} }
+120
View File
@@ -0,0 +1,120 @@
package tender
import (
"context"
"errors"
"fmt"
"strings"
"time"
"tm/pkg/ai_summarizer"
)
const (
// contractFoldersWithDocsCacheTTL avoids scanning MinIO on every documents_scraped list request.
// Kept >= resolve timeout so a slow scan is not immediately invalidated for waiters.
contractFoldersWithDocsCacheTTL = 15 * time.Minute
// contractFoldersWithDocsResolveTimeout bounds a single MinIO procedure scan.
contractFoldersWithDocsResolveTimeout = 3 * time.Minute
)
type contractFoldersWithDocsCacheEntry struct {
expiresAt time.Time
folderIDs []string
valid bool
}
// enrichDocumentsScrapedFilter resolves contract_folder_id values that currently have
// documents in MinIO and stores them on the search form for the repository filter.
func (s *tenderService) enrichDocumentsScrapedFilter(ctx context.Context, form *SearchForm) error {
if form == nil || !form.DocumentsScraped {
return nil
}
folderIDs, err := s.cachedContractFolderIDsWithDocuments(ctx)
if err != nil {
s.logger.Error("Failed to resolve tenders with scraped documents from MinIO", map[string]interface{}{
"error": err.Error(),
})
if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
return fmt.Errorf("failed to resolve tenders with scraped documents: MinIO connection unavailable: %w", err)
}
return fmt.Errorf("failed to resolve tenders with scraped documents: %w", err)
}
form.ContractFolderIDsWithDocuments = folderIDs
return nil
}
func (s *tenderService) cachedContractFolderIDsWithDocuments(ctx context.Context) ([]string, error) {
now := time.Now()
s.contractFoldersCacheMu.Lock()
if s.contractFoldersCache.valid && now.Before(s.contractFoldersCache.expiresAt) {
ids := append([]string(nil), s.contractFoldersCache.folderIDs...)
s.contractFoldersCacheMu.Unlock()
return ids, nil
}
s.contractFoldersCacheMu.Unlock()
v, err, _ := s.contractFoldersCacheGroup.Do("contract-folders-with-docs", func() (interface{}, error) {
s.contractFoldersCacheMu.Lock()
if s.contractFoldersCache.valid && time.Now().Before(s.contractFoldersCache.expiresAt) {
ids := append([]string(nil), s.contractFoldersCache.folderIDs...)
s.contractFoldersCacheMu.Unlock()
return ids, nil
}
s.contractFoldersCacheMu.Unlock()
// Detach from the caller's cancellation so a client disconnect or proxy timeout
// does not abort the shared singleflight scan for every waiter.
resolveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), contractFoldersWithDocsResolveTimeout)
defer cancel()
procedures, listErr := s.listProceduresWithDocuments(resolveCtx)
if listErr != nil {
return nil, listErr
}
folderIDs := contractFolderIDsFromProcedures(procedures)
s.contractFoldersCacheMu.Lock()
s.contractFoldersCache = contractFoldersWithDocsCacheEntry{
expiresAt: time.Now().Add(contractFoldersWithDocsCacheTTL),
folderIDs: folderIDs,
valid: true,
}
s.contractFoldersCacheMu.Unlock()
return append([]string(nil), folderIDs...), nil
})
if err != nil {
return nil, err
}
ids, ok := v.([]string)
if !ok {
return nil, fmt.Errorf("unexpected contract folder cache value type %T", v)
}
return append([]string(nil), ids...), nil
}
func contractFolderIDsFromProcedures(procedures []ai_summarizer.ProcedureDocumentsSummary) []string {
folderIDs := make([]string, 0, len(procedures))
seen := make(map[string]struct{}, len(procedures))
for _, proc := range procedures {
if proc.DocumentCount <= 0 {
continue
}
id := strings.TrimSpace(proc.ContractFolderID)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
folderIDs = append(folderIDs, id)
}
return folderIDs
}
@@ -0,0 +1,241 @@
package tender
import (
"context"
"errors"
"fmt"
"io"
"sync/atomic"
"testing"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"tm/pkg/ai_summarizer"
)
type mockProcedureDocumentLister struct {
procedures []ai_summarizer.ProcedureDocumentsSummary
err error
calls atomic.Int32
}
func (m *mockProcedureDocumentLister) ListProceduresWithDocuments(context.Context) ([]ai_summarizer.ProcedureDocumentsSummary, error) {
m.calls.Add(1)
if m.err != nil {
return nil, m.err
}
return m.procedures, nil
}
type stubProcedureDocumentStorage struct {
lister interface {
ListProceduresWithDocuments(ctx context.Context) ([]ai_summarizer.ProcedureDocumentsSummary, error)
}
}
func (s stubProcedureDocumentStorage) GetSummaryFromStorage(context.Context, string, string) (string, error) {
return "", ai_summarizer.ErrObjectNotFound
}
func (s stubProcedureDocumentStorage) GetDocumentSummariesFromStorage(context.Context, string, string) ([]ai_summarizer.DocumentSummary, error) {
return nil, nil
}
func (s stubProcedureDocumentStorage) GetTranslationFromStorage(context.Context, string, string, string) (ai_summarizer.StoredTranslation, error) {
return ai_summarizer.StoredTranslation{}, ai_summarizer.ErrTranslationNotReady
}
func (s stubProcedureDocumentStorage) ListDocuments(context.Context, string, string) ([]ai_summarizer.StoredDocument, error) {
return nil, nil
}
func (s stubProcedureDocumentStorage) DownloadDocument(context.Context, string) (io.ReadCloser, error) {
return nil, ai_summarizer.ErrObjectNotFound
}
func (s stubProcedureDocumentStorage) ListProceduresWithDocuments(ctx context.Context) ([]ai_summarizer.ProcedureDocumentsSummary, error) {
return s.lister.ListProceduresWithDocuments(ctx)
}
func TestBuildSearchFilterDocumentsScrapedUsesMinIOFolderIDs(t *testing.T) {
repo := &tenderRepository{}
filter := repo.buildSearchFilter(&SearchForm{
DocumentsScraped: true,
ContractFolderIDsWithDocuments: []string{"folder-a", "folder-b"},
})
in, ok := filter["contract_folder_id"].(bson.M)["$in"].([]string)
if !ok {
t.Fatalf("expected contract_folder_id $in filter, got %#v", filter)
}
if len(in) != 2 || in[0] != "folder-a" || in[1] != "folder-b" {
t.Fatalf("unexpected folder ids: %#v", in)
}
if filter["processing_metadata.documents_scraped"] != nil {
t.Fatalf("expected no stale mongo documents_scraped flag, got %#v", filter)
}
}
func TestContractFolderIDsSearchClauseChunksLargeLists(t *testing.T) {
folderIDs := make([]string, maxContractFolderInClause+2)
for i := range folderIDs {
folderIDs[i] = fmt.Sprintf("folder-%d", i)
}
clause := contractFolderIDsSearchClause(folderIDs)
or, ok := clause["$or"].([]bson.M)
if !ok || len(or) != 2 {
t.Fatalf("expected chunked $or filter, got %#v", clause)
}
}
func TestCachedContractFolderIDsWithDocumentsUsesCache(t *testing.T) {
lister := &mockProcedureDocumentLister{
procedures: []ai_summarizer.ProcedureDocumentsSummary{
{ContractFolderID: "folder-1", DocumentCount: 2},
{ContractFolderID: "folder-2", DocumentCount: 0},
{ContractFolderID: "folder-3", DocumentCount: 1},
},
}
svc := &tenderService{
aiSummarizerStorage: stubProcedureDocumentStorage{lister: lister},
}
first, err := svc.cachedContractFolderIDsWithDocuments(context.Background())
if err != nil {
t.Fatalf("first lookup failed: %v", err)
}
if len(first) != 2 || first[0] != "folder-1" || first[1] != "folder-3" {
t.Fatalf("unexpected folder ids: %#v", first)
}
if lister.calls.Load() != 1 {
t.Fatalf("expected one MinIO scan, got %d", lister.calls.Load())
}
second, err := svc.cachedContractFolderIDsWithDocuments(context.Background())
if err != nil {
t.Fatalf("second lookup failed: %v", err)
}
if len(second) != 2 {
t.Fatalf("unexpected cached folder ids: %#v", second)
}
if lister.calls.Load() != 1 {
t.Fatalf("expected cache hit without extra MinIO scan, got %d calls", lister.calls.Load())
}
}
func TestEnrichDocumentsScrapedFilterPropagatesMinIOError(t *testing.T) {
svc := &tenderService{
aiSummarizerStorage: stubProcedureDocumentStorage{
lister: &mockProcedureDocumentLister{
err: ai_summarizer.ErrMinIOUnavailable,
},
},
logger: noopTenderTestLogger{},
}
form := &SearchForm{DocumentsScraped: true}
err := svc.enrichDocumentsScrapedFilter(context.Background(), form)
if err == nil {
t.Fatal("expected MinIO error")
}
if !errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
t.Fatalf("expected wrapped MinIO unavailable error, got %v", err)
}
}
func TestContractFolderIDsFromProceduresDedupesAndSkipsEmpty(t *testing.T) {
ids := contractFolderIDsFromProcedures([]ai_summarizer.ProcedureDocumentsSummary{
{ContractFolderID: "a", DocumentCount: 1},
{ContractFolderID: "a", DocumentCount: 2},
{ContractFolderID: " ", DocumentCount: 1},
{ContractFolderID: "b", DocumentCount: 0},
})
if len(ids) != 1 || ids[0] != "a" {
t.Fatalf("unexpected ids: %#v", ids)
}
}
type scanStartedProcedureLister struct {
mockProcedureDocumentLister
started chan struct{}
}
func (m *scanStartedProcedureLister) ListProceduresWithDocuments(ctx context.Context) ([]ai_summarizer.ProcedureDocumentsSummary, error) {
if m.started != nil {
select {
case <-m.started:
default:
close(m.started)
}
}
time.Sleep(50 * time.Millisecond)
if err := ctx.Err(); err != nil {
return nil, err
}
return m.mockProcedureDocumentLister.ListProceduresWithDocuments(ctx)
}
func TestCachedContractFolderIDsSurvivesCallerCancellation(t *testing.T) {
started := make(chan struct{})
lister := &scanStartedProcedureLister{
mockProcedureDocumentLister: mockProcedureDocumentLister{
procedures: []ai_summarizer.ProcedureDocumentsSummary{
{ContractFolderID: "folder-1", DocumentCount: 1},
},
},
started: started,
}
svc := &tenderService{aiSummarizerStorage: stubProcedureDocumentStorage{lister: lister}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
var ids []string
go func() {
var err error
ids, err = svc.cachedContractFolderIDsWithDocuments(ctx)
errCh <- err
}()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("MinIO scan did not start")
}
cancel()
if err := <-errCh; err != nil {
t.Fatalf("expected shared scan to finish despite caller cancel: %v", err)
}
if len(ids) != 1 || ids[0] != "folder-1" {
t.Fatalf("unexpected folder ids: %#v", ids)
}
}
func TestContractFoldersWithDocsCacheExpires(t *testing.T) {
lister := &mockProcedureDocumentLister{
procedures: []ai_summarizer.ProcedureDocumentsSummary{
{ContractFolderID: "folder-1", DocumentCount: 1},
},
}
svc := &tenderService{aiSummarizerStorage: stubProcedureDocumentStorage{lister: lister}}
if _, err := svc.cachedContractFolderIDsWithDocuments(context.Background()); err != nil {
t.Fatalf("lookup failed: %v", err)
}
svc.contractFoldersCacheMu.Lock()
svc.contractFoldersCache.expiresAt = time.Now().Add(-time.Second)
svc.contractFoldersCacheMu.Unlock()
if _, err := svc.cachedContractFolderIDsWithDocuments(context.Background()); err != nil {
t.Fatalf("lookup after expiry failed: %v", err)
}
if lister.calls.Load() != 2 {
t.Fatalf("expected cache refresh after expiry, got %d calls", lister.calls.Load())
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ type SearchForm struct {
OnlyActiveDeadlines bool `query:"only_active_deadlines" valid:"optional"` OnlyActiveDeadlines bool `query:"only_active_deadlines" valid:"optional"`
Language *string `query:"lang" valid:"optional"` Language *string `query:"lang" valid:"optional"`
DocumentsScraped bool `query:"documents_scraped" valid:"optional"` DocumentsScraped bool `query:"documents_scraped" valid:"optional"`
// ContractFolderIDsWithDocuments is populated by the service from MinIO when DocumentsScraped is true. // ContractFolderIDsWithDocuments is populated by the service from a cached MinIO scan when DocumentsScraped is true.
ContractFolderIDsWithDocuments []string `query:"-" valid:"optional"` ContractFolderIDsWithDocuments []string `query:"-" valid:"optional"`
// ExcludeRejectedTenders hides company-rejected tenders from recommendation results. // ExcludeRejectedTenders hides company-rejected tenders from recommendation results.
ExcludeRejectedTenders bool `query:"-" valid:"optional"` ExcludeRejectedTenders bool `query:"-" valid:"optional"`
+2 -2
View File
@@ -160,7 +160,7 @@ func (h *TenderHandler) Delete(c echo.Context) error {
// @Param languages query []string false "Filter by languages (comma-separated)" // @Param languages query []string false "Filter by languages (comma-separated)"
// @Param buyer_organization_id query string false "Filter by buyer organization ID" // @Param buyer_organization_id query string false "Filter by buyer organization ID"
// @Param notice_publication_id query string false "Filter by TED notice publication ID" // @Param notice_publication_id query string false "Filter by TED notice publication ID"
// @Param documents_scraped query bool false "When true, only tenders with scraped documents (processing_metadata.documents_scraped)" // @Param documents_scraped query bool false "When true, only tenders whose contract_folder_id has documents in MinIO (cached scan)"
// @Param limit query int false "Number of items per page (default: 20, max: 100)" // @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). Prefer cursor for deep pages." // @Param offset query int false "Number of items to skip (default: 0). Prefer cursor for deep pages."
// @Param cursor query string false "Opaque cursor from previous page meta.next_cursor" // @Param cursor query string false "Opaque cursor from previous page meta.next_cursor"
@@ -271,7 +271,7 @@ func (h *TenderHandler) AdminRecommendTenders(c echo.Context) error {
// @Param languages query []string false "Filter by languages (comma-separated)" // @Param languages query []string false "Filter by languages (comma-separated)"
// @Param buyer_organization_id query string false "Filter by buyer organization ID" // @Param buyer_organization_id query string false "Filter by buyer organization ID"
// @Param notice_publication_id query string false "Filter by TED notice publication ID" // @Param notice_publication_id query string false "Filter by TED notice publication ID"
// @Param documents_scraped query bool false "When true, only tenders with scraped documents (processing_metadata.documents_scraped)" // @Param documents_scraped query bool false "When true, only tenders whose contract_folder_id has documents in MinIO (cached scan)"
// @Param limit query int false "Number of items per page (default: 20, max: 100)" // @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). Prefer cursor for deep pages." // @Param offset query int false "Number of items to skip (default: 0). Prefer cursor for deep pages."
// @Param cursor query string false "Opaque cursor from previous page meta.next_cursor" // @Param cursor query string false "Opaque cursor from previous page meta.next_cursor"
+32 -1
View File
@@ -13,6 +13,9 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
) )
// maxContractFolderInClause caps each MongoDB $in batch for documents_scraped MinIO cross-check filters.
const maxContractFolderInClause = 5000
// TenderRepository interface defines tender data access methods // TenderRepository interface defines tender data access methods
type TenderRepository interface { type TenderRepository interface {
// Tender CRUD operations // Tender CRUD operations
@@ -1380,7 +1383,7 @@ func (r *tenderRepository) buildSearchFilter(form *SearchForm) bson.M {
} }
if form.DocumentsScraped { if form.DocumentsScraped {
scrapedClause := bson.M{"processing_metadata.documents_scraped": true} scrapedClause := documentsScrapedSearchClause(form)
if len(filter) == 0 { if len(filter) == 0 {
return scrapedClause return scrapedClause
} }
@@ -1399,3 +1402,31 @@ func (r *tenderRepository) buildSearchFilter(form *SearchForm) bson.M {
return filter return filter
} }
// documentsScrapedSearchClause restricts list queries to tenders whose contract_folder_id
// currently has documents in MinIO (folder IDs are populated by the service layer).
func documentsScrapedSearchClause(form *SearchForm) bson.M {
if form == nil {
return bson.M{"contract_folder_id": bson.M{"$in": []string{}}}
}
return contractFolderIDsSearchClause(form.ContractFolderIDsWithDocuments)
}
func contractFolderIDsSearchClause(folderIDs []string) bson.M {
if len(folderIDs) == 0 {
return bson.M{"contract_folder_id": bson.M{"$in": []string{}}}
}
if len(folderIDs) <= maxContractFolderInClause {
return bson.M{"contract_folder_id": bson.M{"$in": folderIDs}}
}
or := make([]bson.M, 0, (len(folderIDs)+maxContractFolderInClause-1)/maxContractFolderInClause)
for i := 0; i < len(folderIDs); i += maxContractFolderInClause {
end := i + maxContractFolderInClause
if end > len(folderIDs) {
end = len(folderIDs)
}
or = append(or, bson.M{"contract_folder_id": bson.M{"$in": folderIDs[i:end]}})
}
return bson.M{"$or": or}
}
+6 -3
View File
@@ -2,14 +2,17 @@ package tender
import ( import (
"testing" "testing"
"go.mongodb.org/mongo-driver/v2/bson"
) )
func TestBuildSearchFilterDocumentsScrapedUsesMongoFlag(t *testing.T) { func TestBuildSearchFilterDocumentsScrapedWithoutFolderIDsMatchesNothing(t *testing.T) {
repo := &tenderRepository{} repo := &tenderRepository{}
filter := repo.buildSearchFilter(&SearchForm{DocumentsScraped: true}) filter := repo.buildSearchFilter(&SearchForm{DocumentsScraped: true})
if filter["processing_metadata.documents_scraped"] != true { in, ok := filter["contract_folder_id"].(bson.M)["$in"].([]string)
t.Fatalf("expected mongo documents_scraped flag, got %#v", filter) if !ok || len(in) != 0 {
t.Fatalf("expected empty contract_folder_id $in filter, got %#v", filter)
} }
} }
+15 -27
View File
@@ -100,11 +100,15 @@ type tenderService struct {
searchListCacheMu sync.Mutex searchListCacheMu sync.Mutex
searchListCache map[string]searchListCacheEntry searchListCache map[string]searchListCacheEntry
searchListCacheGroup singleflight.Group searchListCacheGroup singleflight.Group
contractFoldersCacheMu sync.Mutex
contractFoldersCache contractFoldersWithDocsCacheEntry
contractFoldersCacheGroup singleflight.Group
} }
const ( const (
aiSummaryEnrichmentTimeout = 2 * time.Second aiSummaryEnrichmentTimeout = 2 * time.Second
aiTranslationEnrichmentTimeout = 2 * time.Second aiTranslationEnrichmentTimeout = 2 * time.Second
recommendTranslationEnrichmentTimeout = 5 * time.Second recommendTranslationEnrichmentTimeout = 5 * time.Second
) )
@@ -935,6 +939,15 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
} }
func (s *tenderService) searchUncached(ctx context.Context, form *SearchForm, pagination *response.Pagination, language string) (*SearchResponse, error) { func (s *tenderService) searchUncached(ctx context.Context, form *SearchForm, pagination *response.Pagination, language string) (*SearchResponse, error) {
if form.DocumentsScraped {
if err := s.enrichDocumentsScrapedFilter(ctx, form); err != nil {
return nil, err
}
if len(form.ContractFolderIDsWithDocuments) == 0 {
return s.emptySearchResponse(pagination), nil
}
}
page, err := s.repository.Search(ctx, form, pagination) page, err := s.repository.Search(ctx, form, pagination)
if err != nil { if err != nil {
s.logger.Error("Failed to list tenders", map[string]interface{}{ s.logger.Error("Failed to list tenders", map[string]interface{}{
@@ -1759,31 +1772,6 @@ func (s *tenderService) listProceduresWithDocuments(ctx context.Context) ([]ai_s
return procedures, nil return procedures, nil
} }
func (s *tenderService) enrichDocumentsScrapedFilter(ctx context.Context, form *SearchForm) error {
procedures, err := s.listProceduresWithDocuments(ctx)
if err != nil {
s.logger.Error("Failed to resolve tenders with scraped documents from MinIO", map[string]interface{}{
"error": err.Error(),
})
if errors.Is(err, ai_summarizer.ErrMinIOUnavailable) {
return fmt.Errorf("failed to resolve tenders with scraped documents: MinIO connection unavailable: %w", err)
}
return fmt.Errorf("failed to resolve tenders with scraped documents: %w", err)
}
folderIDs := make([]string, 0, len(procedures))
for _, proc := range procedures {
if proc.DocumentCount <= 0 {
continue
}
if id := strings.TrimSpace(proc.ContractFolderID); id != "" {
folderIDs = append(folderIDs, id)
}
}
form.ContractFolderIDsWithDocuments = folderIDs
return nil
}
func (s *tenderService) resolveRecommendedTenders(ctx context.Context, recommendations []company.RecommendedTenderResponse) (map[string]Tender, error) { func (s *tenderService) resolveRecommendedTenders(ctx context.Context, recommendations []company.RecommendedTenderResponse) (map[string]Tender, error) {
out := make(map[string]Tender) out := make(map[string]Tender)
procRefs := make([]ProcedureReference, 0, len(recommendations)) procRefs := make([]ProcedureReference, 0, len(recommendations))
+1
View File
@@ -14,4 +14,5 @@ var (
ErrInvalidCompanyID = errors.New("invalid company ID") ErrInvalidCompanyID = errors.New("invalid company ID")
ErrUnauthorized = errors.New("unauthorized to perform this action") ErrUnauthorized = errors.New("unauthorized to perform this action")
ErrInvalidData = errors.New("invalid tender approval data") ErrInvalidData = errors.New("invalid tender approval data")
ErrSubmissionSyncFailed = errors.New("failed to sync tender submission workflow")
) )
+5 -1
View File
@@ -60,11 +60,15 @@ func (h *Handler) ToggleTenderApproval(c echo.Context) error {
return response.BadRequest(c, "Company ID is required", "") return response.BadRequest(c, "Company ID is required", "")
} }
tenderApproval, err := h.service.ToggleTenderApproval(c.Request().Context(), form, companyID) customerID, _ := customer.GetCustomerIDFromContext(c)
tenderApproval, err := h.service.ToggleTenderApproval(c.Request().Context(), form, companyID, customerID)
if err != nil { if err != nil {
if errors.Is(err, ErrTenderNotFound) { if errors.Is(err, ErrTenderNotFound) {
return response.NotFound(c, "Tender not found") return response.NotFound(c, "Tender not found")
} }
if errors.Is(err, ErrSubmissionSyncFailed) {
return response.InternalServerError(c, "Failed to sync tender submission workflow")
}
if err.Error() == "tender approval already exists for this tender and company" { if err.Error() == "tender approval already exists for this tender and company" {
return response.Conflict(c, err.Error()) return response.Conflict(c, err.Error())
} }
+59 -9
View File
@@ -11,6 +11,13 @@ import (
"tm/pkg/response" "tm/pkg/response"
) )
// SubmissionLifecycleHook synchronizes tender submission workflow with approval changes.
type SubmissionLifecycleHook interface {
OnApprovalSubmitted(ctx context.Context, tenderID, companyID, customerID string) error
OnApprovalRemoved(ctx context.Context, tenderID, companyID string) error
OnApprovalRejected(ctx context.Context, tenderID, companyID, changedBy string) error
}
// Service defines business logic for tender approval operations // Service defines business logic for tender approval operations
type Service interface { type Service interface {
// Core tender approval operations // Core tender approval operations
@@ -34,7 +41,7 @@ type Service interface {
SearchTenderApprovalsByCriteria(ctx context.Context, criteria *TenderApprovalSearchCriteria) ([]*TenderApproval, error) SearchTenderApprovalsByCriteria(ctx context.Context, criteria *TenderApprovalSearchCriteria) ([]*TenderApproval, error)
// Business operations // Business operations
ToggleTenderApproval(ctx context.Context, req *ToggleTenderApprovalForm, companyID string) (*TenderApprovalWithTenderResponse, error) ToggleTenderApproval(ctx context.Context, req *ToggleTenderApprovalForm, companyID, customerID string) (*TenderApprovalWithTenderResponse, error)
// Statistics and analytics // Statistics and analytics
GetTenderApprovalStats(ctx context.Context) (*TenderApprovalStatsResponse, error) GetTenderApprovalStats(ctx context.Context) (*TenderApprovalStatsResponse, error)
@@ -51,17 +58,19 @@ type Service interface {
// tenderApprovalService implements the Service interface // tenderApprovalService implements the Service interface
type tenderApprovalService struct { type tenderApprovalService struct {
repository Repository repository Repository
tenderService tender.Service tenderService tender.Service
logger logger.Logger logger logger.Logger
submissionHook SubmissionLifecycleHook
} }
// NewService creates a new tender approval service // NewService creates a new tender approval service
func NewService(repository Repository, tenderService tender.Service, logger logger.Logger) Service { func NewService(repository Repository, tenderService tender.Service, logger logger.Logger, submissionHook SubmissionLifecycleHook) Service {
return &tenderApprovalService{ return &tenderApprovalService{
repository: repository, repository: repository,
tenderService: tenderService, tenderService: tenderService,
logger: logger, logger: logger,
submissionHook: submissionHook,
} }
} }
@@ -853,7 +862,7 @@ func (s *tenderApprovalService) ListTenderApprovalsWithTender(ctx context.Contex
return meta, nil return meta, nil
} }
func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *ToggleTenderApprovalForm, companyID string) (*TenderApprovalWithTenderResponse, error) { func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *ToggleTenderApprovalForm, companyID, customerID string) (*TenderApprovalWithTenderResponse, error) {
companyID = strings.TrimSpace(companyID) companyID = strings.TrimSpace(companyID)
if companyID == "" { if companyID == "" {
return nil, ErrInvalidCompanyID return nil, ErrInvalidCompanyID
@@ -899,6 +908,15 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T
return nil, err return nil, err
} }
if err := s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, true); err != nil {
if deleteErr := s.repository.Delete(ctx, approval.GetID()); deleteErr != nil {
s.logger.Error("Failed to rollback tender approval after submission sync failure", map[string]interface{}{
"tender_approval_id": approval.GetID(),
"error": deleteErr.Error(),
})
}
return nil, ErrSubmissionSyncFailed
}
return s.toggleResponseWithTender(ctx, approval) return s.toggleResponseWithTender(ctx, approval)
} }
@@ -917,6 +935,9 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T
existing.Status = "unrejected" existing.Status = "unrejected"
} }
if err := s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, false); err != nil {
return nil, ErrSubmissionSyncFailed
}
return existing.ToResponseWithTender(nil), nil return existing.ToResponseWithTender(nil), nil
} }
@@ -939,9 +960,38 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T
return nil, err return nil, err
} }
if err := s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, true); err != nil {
return nil, ErrSubmissionSyncFailed
}
return s.toggleResponseWithTender(ctx, existing) return s.toggleResponseWithTender(ctx, existing)
} }
func (s *tenderApprovalService) syncSubmissionAfterToggle(ctx context.Context, tenderID, companyID, customerID string, status ApprovalStatus, active bool) error {
if s.submissionHook == nil {
return nil
}
var err error
if !active {
err = s.submissionHook.OnApprovalRemoved(ctx, tenderID, companyID)
} else if status == ApprovalStatusSubmitted {
err = s.submissionHook.OnApprovalSubmitted(ctx, tenderID, companyID, customerID)
} else if status == ApprovalStatusRejected {
err = s.submissionHook.OnApprovalRejected(ctx, tenderID, companyID, customerID)
}
if err != nil {
s.logger.Error("Failed to sync tender submission workflow after approval toggle", map[string]interface{}{
"tender_id": tenderID,
"company_id": companyID,
"status": status,
"active": active,
"error": err.Error(),
})
}
return err
}
func (s *tenderApprovalService) toggleResponseWithTender(ctx context.Context, approval *TenderApproval) (*TenderApprovalWithTenderResponse, error) { func (s *tenderApprovalService) toggleResponseWithTender(ctx context.Context, approval *TenderApproval) (*TenderApprovalWithTenderResponse, error) {
if approval == nil { if approval == nil {
return nil, ErrInvalidData return nil, ErrInvalidData
+168
View File
@@ -0,0 +1,168 @@
package tender_submission
import (
"time"
"tm/pkg/mongo"
)
// SubmissionStatus represents the current step in the tender submission workflow.
type SubmissionStatus string
const (
StatusOpportunity SubmissionStatus = "opportunity"
StatusValidating SubmissionStatus = "validating"
StatusPartnerPending SubmissionStatus = "partner_pending"
StatusApplying SubmissionStatus = "applying"
StatusSentWaiting SubmissionStatus = "sent_waiting"
StatusRejected SubmissionStatus = "rejected"
StatusContract SubmissionStatus = "contract"
StatusNotApplied SubmissionStatus = "not_applied"
)
// SubmissionStage groups statuses into high-level workflow stages for UI display.
type SubmissionStage string
const (
StageOpportunity SubmissionStage = "opportunity"
StageInProgress SubmissionStage = "in_progress"
StageSubmitted SubmissionStage = "submitted"
StageNotApplied SubmissionStage = "not_applied"
)
// StatusHistory records a status change in the submission workflow.
type StatusHistory struct {
Status SubmissionStatus `bson:"status" json:"status"`
Reason string `bson:"reason,omitempty" json:"reason,omitempty"`
ChangedBy string `bson:"changed_by" json:"changed_by"`
ChangedAt int64 `bson:"changed_at" json:"changed_at"`
Description string `bson:"description,omitempty" json:"description,omitempty"`
}
// TenderSubmission tracks a company's tender submission workflow.
type TenderSubmission struct {
mongo.Model `bson:",inline"`
TenderID string `bson:"tender_id" json:"tender_id"`
CompanyID string `bson:"company_id" json:"company_id"`
CustomerID string `bson:"customer_id,omitempty" json:"customer_id,omitempty"`
Status SubmissionStatus `bson:"status" json:"status"`
Stage SubmissionStage `bson:"stage" json:"stage"`
History []StatusHistory `bson:"status_history" json:"status_history"`
}
func StageForStatus(status SubmissionStatus) SubmissionStage {
switch status {
case StatusOpportunity:
return StageOpportunity
case StatusValidating, StatusPartnerPending, StatusApplying:
return StageInProgress
case StatusSentWaiting, StatusRejected, StatusContract:
return StageSubmitted
case StatusNotApplied:
return StageNotApplied
default:
return StageOpportunity
}
}
func IsTerminalStatus(status SubmissionStatus) bool {
switch status {
case StatusRejected, StatusContract, StatusNotApplied:
return true
default:
return false
}
}
func AllowedNextStatuses(current SubmissionStatus) []SubmissionStatus {
transitions := map[SubmissionStatus][]SubmissionStatus{
StatusOpportunity: {StatusValidating, StatusNotApplied},
StatusValidating: {StatusPartnerPending, StatusApplying, StatusNotApplied},
StatusPartnerPending: {StatusApplying, StatusNotApplied},
StatusApplying: {StatusSentWaiting, StatusNotApplied},
StatusSentWaiting: {StatusRejected, StatusContract},
StatusRejected: {},
StatusContract: {},
StatusNotApplied: {},
}
return transitions[current]
}
func (ts *TenderSubmission) GetID() string {
return ts.ID.Hex()
}
func (ts *TenderSubmission) SetCreatedAt(timestamp int64) {
ts.CreatedAt = timestamp
}
func (ts *TenderSubmission) SetUpdatedAt(timestamp int64) {
ts.UpdatedAt = timestamp
}
func (ts *TenderSubmission) AddStatusChange(status SubmissionStatus, reason, changedBy, description string) {
ts.History = append(ts.History, StatusHistory{
Status: status,
Reason: reason,
ChangedBy: changedBy,
ChangedAt: time.Now().Unix(),
Description: description,
})
ts.Status = status
ts.Stage = StageForStatus(status)
ts.SetUpdatedAt(time.Now().Unix())
}
// ReopenForApproval resets a terminal submission when the tender is approved again.
// This is only used by the approval sync path, not by user-initiated status updates.
func (ts *TenderSubmission) ReopenForApproval(changedBy, description string) {
ts.AddStatusChange(StatusOpportunity, "approval_resubmitted", changedBy, description)
}
func NewTenderSubmission(tenderID, companyID, customerID string) *TenderSubmission {
now := time.Now().Unix()
ts := &TenderSubmission{
TenderID: tenderID,
CompanyID: companyID,
CustomerID: customerID,
Status: StatusOpportunity,
Stage: StageOpportunity,
}
ts.SetCreatedAt(now)
ts.SetUpdatedAt(now)
ts.AddStatusChange(StatusOpportunity, "", customerID, "Submission workflow started")
return ts
}
func (ts *TenderSubmission) ToResponse() *TenderSubmissionResponse {
history := make([]StatusHistoryResponse, len(ts.History))
for i, h := range ts.History {
history[i] = StatusHistoryResponse{
Status: string(h.Status),
Reason: h.Reason,
ChangedBy: h.ChangedBy,
ChangedAt: h.ChangedAt,
Description: h.Description,
}
}
return &TenderSubmissionResponse{
ID: ts.GetID(),
TenderID: ts.TenderID,
CompanyID: ts.CompanyID,
CustomerID: ts.CustomerID,
Status: string(ts.Status),
Stage: string(ts.Stage),
History: history,
CreatedAt: ts.CreatedAt,
UpdatedAt: ts.UpdatedAt,
}
}
func (ts *TenderSubmission) ToResponseWithTender(tender *TenderDetails) *TenderSubmissionWithTenderResponse {
resp := ts.ToResponse()
return &TenderSubmissionWithTenderResponse{
TenderSubmissionResponse: *resp,
Tender: tender,
}
}
+98
View File
@@ -0,0 +1,98 @@
package tender_submission
import "testing"
func TestStageForStatus(t *testing.T) {
tests := []struct {
status SubmissionStatus
stage SubmissionStage
}{
{StatusOpportunity, StageOpportunity},
{StatusValidating, StageInProgress},
{StatusPartnerPending, StageInProgress},
{StatusApplying, StageInProgress},
{StatusSentWaiting, StageSubmitted},
{StatusRejected, StageSubmitted},
{StatusContract, StageSubmitted},
{StatusNotApplied, StageNotApplied},
}
for _, tt := range tests {
if got := StageForStatus(tt.status); got != tt.stage {
t.Fatalf("StageForStatus(%q) = %q, want %q", tt.status, got, tt.stage)
}
}
}
func TestAllowedNextStatuses(t *testing.T) {
tests := []struct {
current SubmissionStatus
next SubmissionStatus
allowed bool
}{
{StatusOpportunity, StatusValidating, true},
{StatusOpportunity, StatusNotApplied, true},
{StatusOpportunity, StatusApplying, false},
{StatusValidating, StatusPartnerPending, true},
{StatusValidating, StatusApplying, true},
{StatusValidating, StatusNotApplied, true},
{StatusPartnerPending, StatusApplying, true},
{StatusApplying, StatusSentWaiting, true},
{StatusSentWaiting, StatusContract, true},
{StatusSentWaiting, StatusRejected, true},
{StatusContract, StatusRejected, false},
{StatusNotApplied, StatusValidating, false},
}
for _, tt := range tests {
allowed := false
for _, candidate := range AllowedNextStatuses(tt.current) {
if candidate == tt.next {
allowed = true
break
}
}
if allowed != tt.allowed {
t.Fatalf("transition %q -> %q allowed=%v, want %v", tt.current, tt.next, allowed, tt.allowed)
}
}
}
func TestAddStatusChangeUpdatesStage(t *testing.T) {
submission := NewTenderSubmission("tender-1", "company-1", "customer-1")
submission.AddStatusChange(StatusValidating, "", "customer-1", "Accepted tender")
if submission.Status != StatusValidating {
t.Fatalf("status = %q, want %q", submission.Status, StatusValidating)
}
if submission.Stage != StageInProgress {
t.Fatalf("stage = %q, want %q", submission.Stage, StageInProgress)
}
if len(submission.History) != 2 {
t.Fatalf("history length = %d, want 2", len(submission.History))
}
}
func TestValidateStatusTransition(t *testing.T) {
service := &tenderSubmissionService{}
if err := service.validateStatusTransition(StatusOpportunity, StatusValidating); err != nil {
t.Fatalf("expected valid transition, got %v", err)
}
if err := service.validateStatusTransition(StatusContract, StatusRejected); err == nil {
t.Fatal("expected terminal status error")
}
err := service.validateStatusTransition(StatusOpportunity, StatusApplying)
if err == nil {
t.Fatal("expected invalid transition error")
}
transitionErr, ok := err.(*InvalidStatusTransitionError)
if !ok {
t.Fatalf("expected InvalidStatusTransitionError, got %T", err)
}
if transitionErr.CurrentStatus != string(StatusOpportunity) {
t.Fatalf("current status = %q", transitionErr.CurrentStatus)
}
}
+46
View File
@@ -0,0 +1,46 @@
package tender_submission
import (
"errors"
"fmt"
"strings"
)
var (
ErrTenderSubmissionNotFound = errors.New("tender submission not found")
ErrTenderSubmissionExists = errors.New("tender submission already exists for this tender and company")
ErrInvalidStatus = errors.New("invalid tender submission status")
ErrInvalidStage = errors.New("invalid tender submission stage")
ErrTenderNotFound = errors.New("tender not found")
ErrApprovalRequired = errors.New("tender must be approved before starting submission workflow")
ErrInvalidStatusTransition = errors.New("invalid status transition")
ErrTerminalStatus = errors.New("tender submission is in a terminal state and cannot be updated")
ErrConcurrentUpdate = errors.New("tender submission was modified concurrently")
)
// InvalidStatusTransitionError is returned when a status change is not allowed.
type InvalidStatusTransitionError struct {
CurrentStatus string
NewStatus string
Allowed []string
}
func (e *InvalidStatusTransitionError) Error() string {
if len(e.Allowed) == 0 {
return fmt.Sprintf("Tender submission status is already %s and cannot be changed", e.CurrentStatus)
}
return fmt.Sprintf(
"Cannot change tender submission status from %s to %s. Allowed next statuses: %s",
e.CurrentStatus,
e.NewStatus,
strings.Join(e.Allowed, ", "),
)
}
func NewInvalidStatusTransitionError(currentStatus, newStatus string, allowed []string) *InvalidStatusTransitionError {
return &InvalidStatusTransitionError{
CurrentStatus: currentStatus,
NewStatus: newStatus,
Allowed: allowed,
}
}
+100
View File
@@ -0,0 +1,100 @@
package tender_submission
import "tm/pkg/response"
// UpdateSubmissionStatusForm updates the workflow status of a tender submission.
type UpdateSubmissionStatusForm struct {
Status SubmissionStatus `json:"status" valid:"required,in(opportunity|validating|partner_pending|applying|sent_waiting|rejected|contract|not_applied)"`
Reason string `json:"reason,omitempty" valid:"optional,length(0|500)"`
Description string `json:"description,omitempty" valid:"optional,length(0|1000)"`
}
// ListTenderSubmissionsForm filters company tender submissions.
type ListTenderSubmissionsForm struct {
TenderID *string `query:"tender_id" valid:"optional"`
CompanyID *string `query:"company_id" valid:"optional"`
Status []string `query:"status" valid:"optional"`
Stage []string `query:"stage" valid:"optional"`
From *int64 `query:"created_from" valid:"optional"`
To *int64 `query:"created_to" valid:"optional"`
Limit *int `query:"limit" valid:"optional,range(1|100)"`
Offset *int `query:"offset" valid:"optional,range(0|1000000)"`
SortBy *string `query:"sort_by" valid:"optional,in(created_at|updated_at|status|stage)"`
SortOrder *string `query:"sort_order" valid:"optional,in(asc|desc)"`
}
// TenderSubmissionResponse is the API response for a tender submission.
type TenderSubmissionResponse struct {
ID string `json:"id"`
TenderID string `json:"tender_id"`
CompanyID string `json:"company_id"`
CustomerID string `json:"customer_id,omitempty"`
Status string `json:"status"`
Stage string `json:"stage"`
History []StatusHistoryResponse `json:"status_history"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// StatusHistoryResponse is the API representation of a status change.
type StatusHistoryResponse struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
ChangedBy string `json:"changed_by"`
ChangedAt int64 `json:"changed_at"`
Description string `json:"description,omitempty"`
}
// TenderSubmissionWithTenderResponse includes tender details.
type TenderSubmissionWithTenderResponse struct {
TenderSubmissionResponse
Tender *TenderDetails `json:"tender,omitempty"`
}
// TenderSubmissionListResponse is a paginated list of submissions with tender details.
type TenderSubmissionListResponse struct {
Submissions []*TenderSubmissionWithTenderResponse `json:"submissions"`
Metadata *response.Meta `json:"metadata"`
}
// TenderDetails represents essential tender information for submission responses.
type TenderDetails struct {
ID string `json:"id"`
NoticePublicationID string `json:"notice_publication_id"`
PublicationDate int64 `json:"publication_date"`
Title string `json:"title"`
Description string `json:"description"`
ProcurementTypeCode string `json:"procurement_type_code"`
ProcedureCode string `json:"procedure_code"`
EstimatedValue float64 `json:"estimated_value"`
Currency string `json:"currency"`
Duration string `json:"duration"`
DurationUnit string `json:"duration_unit"`
TenderDeadline int64 `json:"tender_deadline"`
SubmissionDeadline int64 `json:"submission_deadline"`
CountryCode string `json:"country_code"`
BuyerOrganization *OrganizationResponse `json:"buyer_organization"`
Status string `json:"status"`
}
// OrganizationResponse represents buyer organization info.
type OrganizationResponse struct {
Name string `json:"name"`
}
// CompanySubmissionStatsResponse contains submission counts grouped by stage and status.
type CompanySubmissionStatsResponse struct {
CompanyID string `json:"company_id"`
Total int64 `json:"total"`
ByStage map[string]int64 `json:"by_stage"`
ByStatus map[string]int64 `json:"by_status"`
LastUpdated int64 `json:"last_updated"`
}
// AdminSubmissionStatsResponse contains global submission statistics.
type AdminSubmissionStatsResponse struct {
Total int64 `json:"total"`
ByStage map[string]int64 `json:"by_stage"`
ByStatus map[string]int64 `json:"by_status"`
LastUpdated int64 `json:"last_updated"`
}
+334
View File
@@ -0,0 +1,334 @@
package tender_submission
import (
"errors"
"strings"
"tm/internal/customer"
"tm/pkg/logger"
"tm/pkg/response"
"github.com/labstack/echo/v4"
)
// Handler handles HTTP requests for tender submission operations.
type Handler struct {
service Service
logger logger.Logger
}
func NewHandler(service Service, logger logger.Logger) *Handler {
return &Handler{service: service, logger: logger}
}
// ListCompanySubmissions lists tender submissions for the authenticated company.
// @Summary List company tender submissions
// @Description Retrieve paginated tender submission workflows for the authenticated company
// @Tags TenderSubmissions
// @Accept json
// @Produce json
// @Param status query []string false "Filter by status"
// @Param stage query []string false "Filter by stage"
// @Param created_from query int false "Filter by created date from (Unix timestamp)"
// @Param created_to query int false "Filter by created date to (Unix timestamp)"
// @Param limit query int false "Page size (default 20, max 100)"
// @Param offset query int false "Pagination offset"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionListResponse}
// @Failure 401 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tender-submissions [get]
func (h *Handler) ListCompanySubmissions(c echo.Context) error {
companyID, ok := c.Get("company_id").(string)
if !ok || companyID == "" {
return response.Unauthorized(c, "Unauthorized")
}
form, err := response.Parse[ListTenderSubmissionsForm](c)
if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
result, err := h.service.ListByCompanyWithTender(c.Request().Context(), companyID, form)
if err != nil {
return response.InternalServerError(c, "Failed to list tender submissions")
}
return response.SuccessWithMeta(c, result.Submissions, result.Metadata, "Tender submissions retrieved successfully")
}
// EnsureSubmission ensures a submission workflow exists for an approved tender.
// @Summary Ensure tender submission workflow
// @Description Create or return the submission workflow for an approved tender
// @Tags TenderSubmissions
// @Accept json
// @Produce json
// @Param tender_id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionWithTenderResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tender-submissions/tender/{tender_id}/ensure [post]
func (h *Handler) EnsureSubmission(c echo.Context) error {
companyID, err := h.resolveCompanyID(c)
if err != nil {
if errors.Is(err, customer.ErrCompanyNotAssigned) {
return response.Forbidden(c, "Company is not assigned to customer")
}
return response.BadRequest(c, "Company ID is required", "")
}
customerID, _ := customer.GetCustomerIDFromContext(c)
tenderID := strings.TrimSpace(c.Param("tender_id"))
if tenderID == "" {
return response.BadRequest(c, "Tender ID is required", "")
}
result, err := h.service.EnsureForApprovedTender(c.Request().Context(), tenderID, companyID, customerID)
if err != nil {
if errors.Is(err, ErrTenderNotFound) {
return response.NotFound(c, "Tender not found")
}
if errors.Is(err, ErrApprovalRequired) {
return response.BadRequest(c, "Tender must be approved before starting submission workflow", "")
}
return response.InternalServerError(c, "Failed to ensure tender submission")
}
return response.Success(c, result, "Tender submission ensured successfully")
}
// GetSubmissionByID retrieves a tender submission by ID for the authenticated company.
// @Summary Get tender submission by ID
// @Description Retrieve a tender submission workflow with tender details for the authenticated company
// @Tags TenderSubmissions
// @Accept json
// @Produce json
// @Param id path string true "Submission ID"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionWithTenderResponse}
// @Failure 401 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tender-submissions/{id} [get]
func (h *Handler) GetSubmissionByID(c echo.Context) error {
companyID, ok := c.Get("company_id").(string)
if !ok || companyID == "" {
return response.Unauthorized(c, "Unauthorized")
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return response.BadRequest(c, "Submission ID is required", "")
}
result, err := h.service.GetByIDForCompanyWithTender(c.Request().Context(), id, companyID)
if err != nil {
if errors.Is(err, ErrTenderSubmissionNotFound) {
return response.NotFound(c, "Tender submission not found")
}
return response.InternalServerError(c, "Failed to get tender submission")
}
return response.Success(c, result, "Tender submission retrieved successfully")
}
// GetSubmissionByTender retrieves a submission by tender and company.
// @Summary Get tender submission by tender
// @Description Retrieve the submission workflow for a specific tender and company
// @Tags TenderSubmissions
// @Accept json
// @Produce json
// @Param tender_id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionWithTenderResponse}
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tender-submissions/tender/{tender_id} [get]
func (h *Handler) GetSubmissionByTender(c echo.Context) error {
companyID, ok := c.Get("company_id").(string)
if !ok || companyID == "" {
return response.Unauthorized(c, "Unauthorized")
}
tenderID := strings.TrimSpace(c.Param("tender_id"))
if tenderID == "" {
return response.BadRequest(c, "Tender ID is required", "")
}
result, err := h.service.GetByTenderAndCompanyWithTender(c.Request().Context(), tenderID, companyID)
if err != nil {
if errors.Is(err, ErrTenderNotFound) || errors.Is(err, ErrTenderSubmissionNotFound) {
return response.NotFound(c, "Tender submission not found")
}
return response.InternalServerError(c, "Failed to get tender submission")
}
return response.Success(c, result, "Tender submission retrieved successfully")
}
// UpdateSubmissionStatus updates the workflow status of a submission.
// @Summary Update tender submission status
// @Description Move a tender submission to the next workflow step
// @Tags TenderSubmissions
// @Accept json
// @Produce json
// @Param id path string true "Submission ID"
// @Param body body UpdateSubmissionStatusForm true "Status update"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionWithTenderResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 422 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tender-submissions/{id}/status [patch]
func (h *Handler) UpdateSubmissionStatus(c echo.Context) error {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return response.BadRequest(c, "Submission ID is required", "")
}
form, err := response.Parse[UpdateSubmissionStatusForm](c)
if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
companyID, err := h.resolveCompanyID(c)
if err != nil {
if errors.Is(err, customer.ErrCompanyNotAssigned) {
return response.Forbidden(c, "Company is not assigned to customer")
}
return response.BadRequest(c, "Company ID is required", "")
}
customerID, _ := customer.GetCustomerIDFromContext(c)
result, err := h.service.UpdateStatus(c.Request().Context(), id, form, companyID, customerID)
if err != nil {
if errors.Is(err, ErrTenderSubmissionNotFound) {
return response.NotFound(c, "Tender submission not found")
}
var transitionErr *InvalidStatusTransitionError
if errors.As(err, &transitionErr) {
return response.ValidationError(c, "Invalid status transition", err.Error())
}
if errors.Is(err, ErrTerminalStatus) {
return response.ValidationError(c, "Submission is in a terminal state", err.Error())
}
if errors.Is(err, ErrConcurrentUpdate) {
return response.Conflict(c, "Tender submission was modified concurrently")
}
return response.InternalServerError(c, "Failed to update tender submission status")
}
return response.Success(c, result, "Tender submission status updated successfully")
}
// GetCompanySubmissionStats returns submission statistics for the company.
// @Summary Get company tender submission statistics
// @Description Retrieve submission counts grouped by stage and status
// @Tags TenderSubmissions
// @Accept json
// @Produce json
// @Success 200 {object} response.APIResponse{data=CompanySubmissionStatsResponse}
// @Failure 401 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tender-submissions/stats [get]
func (h *Handler) GetCompanySubmissionStats(c echo.Context) error {
companyID, ok := c.Get("company_id").(string)
if !ok || companyID == "" {
return response.Unauthorized(c, "Unauthorized")
}
stats, err := h.service.GetCompanyStats(c.Request().Context(), companyID)
if err != nil {
return response.InternalServerError(c, "Failed to get submission statistics")
}
return response.Success(c, stats, "Submission statistics retrieved successfully")
}
// AdminListSubmissions lists tender submissions for admin users.
// @Summary Admin list tender submissions
// @Description Retrieve paginated tender submissions across companies
// @Tags Admin-TenderSubmissions
// @Accept json
// @Produce json
// @Param company_id query string false "Filter by company ID"
// @Param tender_id query string false "Filter by tender ID"
// @Param status query []string false "Filter by status"
// @Param stage query []string false "Filter by stage"
// @Param limit query int false "Page size"
// @Param offset query int false "Pagination offset"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionListResponse}
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /admin/v1/tender-submissions [get]
func (h *Handler) AdminListSubmissions(c echo.Context) error {
form, err := response.Parse[ListTenderSubmissionsForm](c)
if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
result, err := h.service.AdminListWithTender(c.Request().Context(), form)
if err != nil {
return response.InternalServerError(c, "Failed to list tender submissions")
}
return response.SuccessWithMeta(c, result.Submissions, result.Metadata, "Tender submissions retrieved successfully")
}
// AdminGetSubmissionByID retrieves a submission by ID for admin users.
// @Summary Admin get tender submission by ID
// @Description Retrieve a tender submission workflow with tender details
// @Tags Admin-TenderSubmissions
// @Accept json
// @Produce json
// @Param id path string true "Submission ID"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionWithTenderResponse}
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /admin/v1/tender-submissions/{id} [get]
func (h *Handler) AdminGetSubmissionByID(c echo.Context) error {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return response.BadRequest(c, "Submission ID is required", "")
}
result, err := h.service.GetByIDWithTender(c.Request().Context(), id)
if err != nil {
if errors.Is(err, ErrTenderSubmissionNotFound) {
return response.NotFound(c, "Tender submission not found")
}
return response.InternalServerError(c, "Failed to get tender submission")
}
return response.Success(c, result, "Tender submission retrieved successfully")
}
// AdminGetSubmissionStats returns global submission statistics.
// @Summary Admin get tender submission statistics
// @Description Retrieve global submission counts grouped by stage and status
// @Tags Admin-TenderSubmissions
// @Accept json
// @Produce json
// @Success 200 {object} response.APIResponse{data=AdminSubmissionStatsResponse}
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /admin/v1/tender-submissions/stats [get]
func (h *Handler) AdminGetSubmissionStats(c echo.Context) error {
stats, err := h.service.GetGlobalStats(c.Request().Context())
if err != nil {
return response.InternalServerError(c, "Failed to get submission statistics")
}
return response.Success(c, stats, "Submission statistics retrieved successfully")
}
func (h *Handler) resolveCompanyID(c echo.Context) (string, error) {
requestedCompanyID := strings.TrimSpace(c.QueryParam("company_id"))
activeCompanyID, _ := c.Get("company_id").(string)
assignedCompanyIDs, _ := c.Get("company_ids").([]string)
return customer.PickAssignedCompanyID(activeCompanyID, assignedCompanyIDs, requestedCompanyID)
}
+346
View File
@@ -0,0 +1,346 @@
package tender_submission
import (
"context"
"errors"
"time"
"tm/pkg/logger"
"tm/pkg/mongo"
"go.mongodb.org/mongo-driver/v2/bson"
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
)
const maxListLimit = 100
// Repository defines data access for tender submissions.
type Repository interface {
Create(ctx context.Context, submission *TenderSubmission) error
GetByID(ctx context.Context, id string) (*TenderSubmission, error)
GetByTenderAndCompany(ctx context.Context, tenderID, companyID string) (*TenderSubmission, error)
Update(ctx context.Context, submission *TenderSubmission) error
UpdateIfUpdatedAt(ctx context.Context, submission *TenderSubmission, expectedUpdatedAt int64) error
Delete(ctx context.Context, id string) error
ListByCompany(ctx context.Context, companyID string, statuses []SubmissionStatus, stages []SubmissionStage, from, to *int64, limit, offset int, sortBy, sortOrder string) ([]*TenderSubmission, int64, error)
Search(ctx context.Context, tenderID, companyID *string, statuses []SubmissionStatus, stages []SubmissionStage, from, to *int64, limit, offset int, sortBy, sortOrder string) ([]*TenderSubmission, int64, error)
GetCompanyStats(ctx context.Context, companyID string) (*CompanySubmissionStatsResponse, error)
GetGlobalStats(ctx context.Context) (*AdminSubmissionStatsResponse, error)
}
type tenderSubmissionRepository struct {
ormRepo mongo.Repository[TenderSubmission]
collection *mongodriver.Collection
logger logger.Logger
}
func NewRepository(mongoManager *mongo.ConnectionManager, logger logger.Logger) Repository {
indexes := []mongo.Index{
*mongo.CreateUniqueIndex("tender_company_idx", bson.D{{Key: "tender_id", Value: 1}, {Key: "company_id", Value: 1}}),
*mongo.NewIndex("tender_id_idx", bson.D{{Key: "tender_id", Value: 1}}),
*mongo.NewIndex("company_id_idx", bson.D{{Key: "company_id", Value: 1}}),
*mongo.NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}),
*mongo.NewIndex("stage_idx", bson.D{{Key: "stage", Value: 1}}),
*mongo.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
*mongo.NewIndex("updated_at_idx", bson.D{{Key: "updated_at", Value: -1}}),
}
if err := mongoManager.CreateIndexes("tender_submissions", indexes); err != nil {
logger.Warn("Failed to create tender submission indexes", map[string]interface{}{
"error": err.Error(),
})
}
collection := mongoManager.GetCollection("tender_submissions")
ormRepo := mongo.NewRepository[TenderSubmission](collection, logger)
return &tenderSubmissionRepository{ormRepo: ormRepo, collection: collection, logger: logger}
}
func (r *tenderSubmissionRepository) Create(ctx context.Context, submission *TenderSubmission) error {
now := time.Now().Unix()
submission.SetCreatedAt(now)
submission.SetUpdatedAt(now)
if err := r.ormRepo.Create(ctx, submission); err != nil {
if mongo.IsDuplicateKeyError(err) {
return ErrTenderSubmissionExists
}
r.logger.Error("Failed to create tender submission", map[string]interface{}{
"error": err.Error(),
"tender_id": submission.TenderID,
"company_id": submission.CompanyID,
})
return err
}
r.logger.Info("Tender submission created", map[string]interface{}{
"submission_id": submission.GetID(),
"tender_id": submission.TenderID,
"company_id": submission.CompanyID,
"status": submission.Status,
})
return nil
}
func (r *tenderSubmissionRepository) GetByID(ctx context.Context, id string) (*TenderSubmission, error) {
submission, err := r.ormRepo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, mongo.ErrDocumentNotFound) {
return nil, ErrTenderSubmissionNotFound
}
r.logger.Error("Failed to get tender submission by ID", map[string]interface{}{
"error": err.Error(),
"submission_id": id,
})
return nil, err
}
return submission, nil
}
func (r *tenderSubmissionRepository) GetByTenderAndCompany(ctx context.Context, tenderID, companyID string) (*TenderSubmission, error) {
filter := bson.M{"tender_id": tenderID, "company_id": companyID}
submission, err := r.ormRepo.FindOne(ctx, filter)
if err != nil {
if errors.Is(err, mongo.ErrDocumentNotFound) {
return nil, ErrTenderSubmissionNotFound
}
r.logger.Error("Failed to get tender submission by tender and company", map[string]interface{}{
"error": err.Error(),
"tender_id": tenderID,
"company_id": companyID,
})
return nil, err
}
return submission, nil
}
func (r *tenderSubmissionRepository) Update(ctx context.Context, submission *TenderSubmission) error {
submission.SetUpdatedAt(time.Now().Unix())
if err := r.ormRepo.Update(ctx, submission); err != nil {
if errors.Is(err, mongo.ErrDocumentNotFound) {
return ErrTenderSubmissionNotFound
}
r.logger.Error("Failed to update tender submission", map[string]interface{}{
"error": err.Error(),
"submission_id": submission.GetID(),
})
return err
}
return nil
}
func (r *tenderSubmissionRepository) UpdateIfUpdatedAt(ctx context.Context, submission *TenderSubmission, expectedUpdatedAt int64) error {
objectID, err := bson.ObjectIDFromHex(submission.GetID())
if err != nil {
return err
}
newUpdatedAt := time.Now().Unix()
submission.SetUpdatedAt(newUpdatedAt)
filter := bson.M{"_id": objectID, "updated_at": expectedUpdatedAt}
result, err := r.collection.UpdateOne(ctx, filter, bson.M{"$set": submission})
if err != nil {
r.logger.Error("Failed to update tender submission with optimistic lock", map[string]interface{}{
"error": err.Error(),
"submission_id": submission.GetID(),
})
return err
}
if result.MatchedCount == 0 {
return ErrConcurrentUpdate
}
return nil
}
func (r *tenderSubmissionRepository) Delete(ctx context.Context, id string) error {
if err := r.ormRepo.Delete(ctx, id); err != nil {
if errors.Is(err, mongo.ErrDocumentNotFound) {
return ErrTenderSubmissionNotFound
}
r.logger.Error("Failed to delete tender submission", map[string]interface{}{
"error": err.Error(),
"submission_id": id,
})
return err
}
return nil
}
func (r *tenderSubmissionRepository) ListByCompany(ctx context.Context, companyID string, statuses []SubmissionStatus, stages []SubmissionStage, from, to *int64, limit, offset int, sortBy, sortOrder string) ([]*TenderSubmission, int64, error) {
companyIDPtr := companyID
return r.Search(ctx, nil, &companyIDPtr, statuses, stages, from, to, limit, offset, sortBy, sortOrder)
}
func (r *tenderSubmissionRepository) Search(ctx context.Context, tenderID, companyID *string, statuses []SubmissionStatus, stages []SubmissionStage, from, to *int64, limit, offset int, sortBy, sortOrder string) ([]*TenderSubmission, int64, error) {
limit = capListLimit(limit)
if offset < 0 {
offset = 0
}
filter := bson.M{}
if tenderID != nil && *tenderID != "" {
filter["tender_id"] = *tenderID
}
if companyID != nil && *companyID != "" {
filter["company_id"] = *companyID
}
if len(statuses) > 0 {
filter["status"] = bson.M{"$in": statuses}
}
if len(stages) > 0 {
filter["stage"] = bson.M{"$in": stages}
}
if from != nil || to != nil {
dateFilter := bson.M{}
if from != nil {
dateFilter["$gte"] = *from
}
if to != nil {
dateFilter["$lte"] = *to
}
filter["created_at"] = dateFilter
}
if sortBy == "" {
sortBy = "updated_at"
}
if sortOrder == "" {
sortOrder = "desc"
}
pagination := mongo.NewPaginationBuilder().Limit(limit).Skip(offset)
if sortOrder == "asc" {
pagination = pagination.SortAsc(sortBy)
} else {
pagination = pagination.SortDesc(sortBy)
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination.Build())
if err != nil {
r.logger.Error("Failed to search tender submissions", map[string]interface{}{
"error": err.Error(),
})
return nil, 0, err
}
items := make([]*TenderSubmission, len(result.Items))
for i := range result.Items {
items[i] = &result.Items[i]
}
return items, result.TotalCount, nil
}
func (r *tenderSubmissionRepository) GetCompanyStats(ctx context.Context, companyID string) (*CompanySubmissionStatsResponse, error) {
pipeline := mongodriver.Pipeline{
{{Key: "$match", Value: bson.M{"company_id": companyID}}},
{{Key: "$facet", Value: bson.M{
"total": bson.A{bson.M{"$count": "count"}},
"by_stage": bson.A{bson.M{"$group": bson.M{"_id": "$stage", "count": bson.M{"$sum": 1}}}},
"by_status": bson.A{bson.M{"$group": bson.M{"_id": "$status", "count": bson.M{"$sum": 1}}}},
}}},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
stats := &CompanySubmissionStatsResponse{
CompanyID: companyID,
ByStage: map[string]int64{},
ByStatus: map[string]int64{},
LastUpdated: time.Now().Unix(),
}
if len(results) == 0 {
return stats, nil
}
facet := results[0]
if totalArr, ok := facet["total"].(bson.A); ok && len(totalArr) > 0 {
if doc, ok := totalArr[0].(bson.M); ok {
stats.Total = int64Value(doc["count"])
}
}
stats.ByStage = countMapFromFacet(facet["by_stage"])
stats.ByStatus = countMapFromFacet(facet["by_status"])
return stats, nil
}
func (r *tenderSubmissionRepository) GetGlobalStats(ctx context.Context) (*AdminSubmissionStatsResponse, error) {
pipeline := mongodriver.Pipeline{
{{Key: "$facet", Value: bson.M{
"total": bson.A{bson.M{"$count": "count"}},
"by_stage": bson.A{bson.M{"$group": bson.M{"_id": "$stage", "count": bson.M{"$sum": 1}}}},
"by_status": bson.A{bson.M{"$group": bson.M{"_id": "$status", "count": bson.M{"$sum": 1}}}},
}}},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
stats := &AdminSubmissionStatsResponse{
ByStage: map[string]int64{},
ByStatus: map[string]int64{},
LastUpdated: time.Now().Unix(),
}
if len(results) == 0 {
return stats, nil
}
facet := results[0]
if totalArr, ok := facet["total"].(bson.A); ok && len(totalArr) > 0 {
if doc, ok := totalArr[0].(bson.M); ok {
stats.Total = int64Value(doc["count"])
}
}
stats.ByStage = countMapFromFacet(facet["by_stage"])
stats.ByStatus = countMapFromFacet(facet["by_status"])
return stats, nil
}
func countMapFromFacet(raw interface{}) map[string]int64 {
out := map[string]int64{}
arr, ok := raw.(bson.A)
if !ok {
return out
}
for _, item := range arr {
doc, ok := item.(bson.M)
if !ok {
continue
}
key, _ := doc["_id"].(string)
if key == "" {
continue
}
out[key] = int64Value(doc["count"])
}
return out
}
func int64Value(v interface{}) int64 {
switch n := v.(type) {
case int32:
return int64(n)
case int64:
return n
case float64:
return int64(n)
default:
return 0
}
}
func capListLimit(limit int) int {
if limit <= 0 {
return 20
}
if limit > maxListLimit {
return maxListLimit
}
return limit
}
+451
View File
@@ -0,0 +1,451 @@
package tender_submission
import (
"context"
"errors"
"strings"
"tm/internal/tender"
"tm/internal/tender_approval"
"tm/pkg/logger"
"tm/pkg/response"
)
// TenderApprovalReader verifies that a tender is approved before workflow actions.
type TenderApprovalReader interface {
GetByTenderAndCompany(ctx context.Context, tenderID, companyID string) (*tender_approval.TenderApproval, error)
}
// Service defines tender submission business logic.
type Service interface {
EnsureForApprovedTender(ctx context.Context, tenderID, companyID, customerID string) (*TenderSubmissionWithTenderResponse, error)
GetByIDWithTender(ctx context.Context, id string) (*TenderSubmissionWithTenderResponse, error)
GetByIDForCompanyWithTender(ctx context.Context, id, companyID string) (*TenderSubmissionWithTenderResponse, error)
GetByTenderAndCompanyWithTender(ctx context.Context, tenderID, companyID string) (*TenderSubmissionWithTenderResponse, error)
ListByCompanyWithTender(ctx context.Context, companyID string, form *ListTenderSubmissionsForm) (*TenderSubmissionListResponse, error)
UpdateStatus(ctx context.Context, id string, form *UpdateSubmissionStatusForm, companyID, changedBy string) (*TenderSubmissionWithTenderResponse, error)
GetCompanyStats(ctx context.Context, companyID string) (*CompanySubmissionStatsResponse, error)
GetGlobalStats(ctx context.Context) (*AdminSubmissionStatsResponse, error)
AdminListWithTender(ctx context.Context, form *ListTenderSubmissionsForm) (*TenderSubmissionListResponse, error)
OnApprovalSubmitted(ctx context.Context, tenderID, companyID, customerID string) error
OnApprovalRemoved(ctx context.Context, tenderID, companyID string) error
OnApprovalRejected(ctx context.Context, tenderID, companyID, changedBy string) error
}
type tenderSubmissionService struct {
repository Repository
approvalReader TenderApprovalReader
tenderService tender.Service
logger logger.Logger
}
func NewService(repository Repository, approvalReader TenderApprovalReader, tenderService tender.Service, logger logger.Logger) Service {
return &tenderSubmissionService{
repository: repository,
approvalReader: approvalReader,
tenderService: tenderService,
logger: logger,
}
}
func (s *tenderSubmissionService) EnsureForApprovedTender(ctx context.Context, tenderID, companyID, customerID string) (*TenderSubmissionWithTenderResponse, error) {
resolvedTenderID, err := s.tenderService.ResolveMongoID(ctx, tenderID)
if err != nil {
s.logger.Warn("Failed to resolve tender for submission ensure", map[string]interface{}{
"tender_ref": tenderID,
"company_id": companyID,
"error": err.Error(),
})
return nil, ErrTenderNotFound
}
if err := s.requireSubmittedApproval(ctx, resolvedTenderID, companyID); err != nil {
return nil, err
}
submission, err := s.repository.GetByTenderAndCompany(ctx, resolvedTenderID, companyID)
if err != nil {
if !errors.Is(err, ErrTenderSubmissionNotFound) {
return nil, err
}
submission = NewTenderSubmission(resolvedTenderID, companyID, customerID)
if err := s.repository.Create(ctx, submission); err != nil {
if !errors.Is(err, ErrTenderSubmissionExists) {
return nil, err
}
submission, err = s.repository.GetByTenderAndCompany(ctx, resolvedTenderID, companyID)
if err != nil {
return nil, err
}
}
}
return s.responseWithTender(ctx, submission, true)
}
func (s *tenderSubmissionService) GetByIDWithTender(ctx context.Context, id string) (*TenderSubmissionWithTenderResponse, error) {
submission, err := s.repository.GetByID(ctx, id)
if err != nil {
return nil, err
}
return s.responseWithTender(ctx, submission, true)
}
func (s *tenderSubmissionService) GetByIDForCompanyWithTender(ctx context.Context, id, companyID string) (*TenderSubmissionWithTenderResponse, error) {
submission, err := s.repository.GetByID(ctx, id)
if err != nil {
return nil, err
}
if submission.CompanyID != companyID {
return nil, ErrTenderSubmissionNotFound
}
return s.responseWithTender(ctx, submission, true)
}
func (s *tenderSubmissionService) GetByTenderAndCompanyWithTender(ctx context.Context, tenderID, companyID string) (*TenderSubmissionWithTenderResponse, error) {
resolvedTenderID, err := s.tenderService.ResolveMongoID(ctx, tenderID)
if err != nil {
return nil, ErrTenderNotFound
}
submission, err := s.repository.GetByTenderAndCompany(ctx, resolvedTenderID, companyID)
if err != nil {
return nil, err
}
return s.responseWithTender(ctx, submission, true)
}
func (s *tenderSubmissionService) ListByCompanyWithTender(ctx context.Context, companyID string, form *ListTenderSubmissionsForm) (*TenderSubmissionListResponse, error) {
limit, offset, sortBy, sortOrder := listParams(form)
statuses := parseStatuses(form.Status)
stages := parseStages(form.Stage)
submissions, total, err := s.repository.ListByCompany(ctx, companyID, statuses, stages, form.From, form.To, limit, offset, sortBy, sortOrder)
if err != nil {
s.logger.Error("Failed to list tender submissions", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
return nil, errors.New("failed to list tender submissions")
}
return &TenderSubmissionListResponse{
Submissions: s.enrichSubmissions(ctx, submissions, false),
Metadata: listMetadata(total, limit, offset),
}, nil
}
func (s *tenderSubmissionService) AdminListWithTender(ctx context.Context, form *ListTenderSubmissionsForm) (*TenderSubmissionListResponse, error) {
limit, offset, sortBy, sortOrder := listParams(form)
statuses := parseStatuses(form.Status)
stages := parseStages(form.Stage)
submissions, total, err := s.repository.Search(ctx, form.TenderID, form.CompanyID, statuses, stages, form.From, form.To, limit, offset, sortBy, sortOrder)
if err != nil {
s.logger.Error("Failed to admin list tender submissions", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("failed to list tender submissions")
}
return &TenderSubmissionListResponse{
Submissions: s.enrichSubmissions(ctx, submissions, false),
Metadata: listMetadata(total, limit, offset),
}, nil
}
func (s *tenderSubmissionService) UpdateStatus(ctx context.Context, id string, form *UpdateSubmissionStatusForm, companyID, changedBy string) (*TenderSubmissionWithTenderResponse, error) {
s.logger.Info("Updating tender submission status", map[string]interface{}{
"submission_id": id,
"company_id": companyID,
"new_status": form.Status,
"changed_by": changedBy,
})
submission, err := s.repository.GetByID(ctx, id)
if err != nil {
return nil, err
}
if companyID != "" && submission.CompanyID != companyID {
return nil, ErrTenderSubmissionNotFound
}
expectedUpdatedAt := submission.UpdatedAt
if err := s.validateStatusTransition(submission.Status, form.Status); err != nil {
s.logger.Error("Invalid tender submission status transition", map[string]interface{}{
"submission_id": id,
"current_status": submission.Status,
"new_status": form.Status,
"error": err.Error(),
})
return nil, err
}
submission.AddStatusChange(form.Status, form.Reason, changedBy, form.Description)
if err := s.repository.UpdateIfUpdatedAt(ctx, submission, expectedUpdatedAt); err != nil {
if errors.Is(err, ErrConcurrentUpdate) {
return nil, ErrConcurrentUpdate
}
return nil, errors.New("failed to update tender submission status")
}
s.logger.Info("Tender submission status updated", map[string]interface{}{
"submission_id": id,
"status": submission.Status,
"stage": submission.Stage,
})
return s.responseWithTender(ctx, submission, true)
}
func (s *tenderSubmissionService) GetCompanyStats(ctx context.Context, companyID string) (*CompanySubmissionStatsResponse, error) {
stats, err := s.repository.GetCompanyStats(ctx, companyID)
if err != nil {
s.logger.Error("Failed to get company submission stats", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
return nil, errors.New("failed to get submission statistics")
}
return stats, nil
}
func (s *tenderSubmissionService) GetGlobalStats(ctx context.Context) (*AdminSubmissionStatsResponse, error) {
stats, err := s.repository.GetGlobalStats(ctx)
if err != nil {
s.logger.Error("Failed to get global submission stats", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("failed to get submission statistics")
}
return stats, nil
}
func (s *tenderSubmissionService) OnApprovalSubmitted(ctx context.Context, tenderID, companyID, customerID string) error {
existing, err := s.repository.GetByTenderAndCompany(ctx, tenderID, companyID)
if err != nil && !errors.Is(err, ErrTenderSubmissionNotFound) {
return err
}
if existing != nil {
if IsTerminalStatus(existing.Status) {
existing.ReopenForApproval(customerID, "Tender re-approved")
return s.repository.Update(ctx, existing)
}
return nil
}
submission := NewTenderSubmission(tenderID, companyID, customerID)
if err := s.repository.Create(ctx, submission); err != nil {
if errors.Is(err, ErrTenderSubmissionExists) {
existing, getErr := s.repository.GetByTenderAndCompany(ctx, tenderID, companyID)
if getErr != nil {
return getErr
}
if IsTerminalStatus(existing.Status) {
existing.ReopenForApproval(customerID, "Tender re-approved")
return s.repository.Update(ctx, existing)
}
return nil
}
s.logger.Error("Failed to create submission on approval", map[string]interface{}{
"tender_id": tenderID,
"company_id": companyID,
"error": err.Error(),
})
return err
}
s.logger.Info("Submission workflow created from approval", map[string]interface{}{
"tender_id": tenderID,
"company_id": companyID,
})
return nil
}
func (s *tenderSubmissionService) OnApprovalRemoved(ctx context.Context, tenderID, companyID string) error {
submission, err := s.repository.GetByTenderAndCompany(ctx, tenderID, companyID)
if err != nil {
if errors.Is(err, ErrTenderSubmissionNotFound) {
return nil
}
return err
}
if submission.Status != StatusOpportunity {
return nil
}
if err := s.repository.Delete(ctx, submission.GetID()); err != nil {
s.logger.Error("Failed to remove opportunity submission after approval toggle", map[string]interface{}{
"submission_id": submission.GetID(),
"error": err.Error(),
})
return err
}
return nil
}
func (s *tenderSubmissionService) OnApprovalRejected(ctx context.Context, tenderID, companyID, changedBy string) error {
submission, err := s.repository.GetByTenderAndCompany(ctx, tenderID, companyID)
if err != nil {
if errors.Is(err, ErrTenderSubmissionNotFound) {
return nil
}
return err
}
if IsTerminalStatus(submission.Status) {
return nil
}
if err := s.transitionSubmission(submission, StatusNotApplied, "approval_rejected", changedBy, "Tender approval was rejected"); err != nil {
var transitionErr *InvalidStatusTransitionError
if errors.Is(err, ErrTerminalStatus) || errors.As(err, &transitionErr) {
s.logger.Warn("Skipping approval rejection sync due to invalid transition", map[string]interface{}{
"tender_id": tenderID,
"company_id": companyID,
"current_status": submission.Status,
"target_status": StatusNotApplied,
})
return nil
}
return err
}
return s.repository.Update(ctx, submission)
}
func (s *tenderSubmissionService) transitionSubmission(submission *TenderSubmission, next SubmissionStatus, reason, changedBy, description string) error {
if err := s.validateStatusTransition(submission.Status, next); err != nil {
return err
}
submission.AddStatusChange(next, reason, changedBy, description)
return nil
}
func (s *tenderSubmissionService) requireSubmittedApproval(ctx context.Context, tenderID, companyID string) error {
approval, err := s.approvalReader.GetByTenderAndCompany(ctx, tenderID, companyID)
if err != nil {
if errors.Is(err, tender_approval.ErrTenderApprovalNotFound) {
return ErrApprovalRequired
}
return err
}
if approval.Status != tender_approval.ApprovalStatusSubmitted {
return ErrApprovalRequired
}
return nil
}
func (s *tenderSubmissionService) validateStatusTransition(current, next SubmissionStatus) error {
if IsTerminalStatus(current) {
return ErrTerminalStatus
}
for _, allowed := range AllowedNextStatuses(current) {
if allowed == next {
return nil
}
}
allowed := AllowedNextStatuses(current)
allowedStrings := make([]string, len(allowed))
for i, status := range allowed {
allowedStrings[i] = string(status)
}
return NewInvalidStatusTransitionError(string(current), string(next), allowedStrings)
}
func (s *tenderSubmissionService) responseWithTender(ctx context.Context, submission *TenderSubmission, fullDetails bool) (*TenderSubmissionWithTenderResponse, error) {
tenderResp, err := s.tenderService.GetByID(ctx, submission.TenderID, "")
if err != nil {
s.logger.Warn("Failed to load tender for submission response", map[string]interface{}{
"tender_id": submission.TenderID,
"error": err.Error(),
})
return submission.ToResponseWithTender(nil), nil
}
var details *TenderDetails
if fullDetails {
details = fullTenderDetailsFromResponse(tenderResp)
} else {
details = listTenderDetailsFromResponse(tenderResp)
}
return submission.ToResponseWithTender(details), nil
}
func listParams(form *ListTenderSubmissionsForm) (limit, offset int, sortBy, sortOrder string) {
limit = 20
offset = 0
sortBy = "updated_at"
sortOrder = "desc"
if form == nil {
return limit, offset, sortBy, sortOrder
}
if form.Limit != nil && *form.Limit > 0 {
limit = *form.Limit
}
if limit > maxListLimit {
limit = maxListLimit
}
if form.Offset != nil && *form.Offset >= 0 {
offset = *form.Offset
}
if form.SortBy != nil && strings.TrimSpace(*form.SortBy) != "" {
sortBy = strings.TrimSpace(*form.SortBy)
}
if form.SortOrder != nil && strings.TrimSpace(*form.SortOrder) != "" {
sortOrder = strings.TrimSpace(*form.SortOrder)
}
return limit, offset, sortBy, sortOrder
}
func parseStatuses(values []string) []SubmissionStatus {
if len(values) == 0 {
return nil
}
out := make([]SubmissionStatus, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
out = append(out, SubmissionStatus(value))
}
return out
}
func parseStages(values []string) []SubmissionStage {
if len(values) == 0 {
return nil
}
out := make([]SubmissionStage, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
out = append(out, SubmissionStage(value))
}
return out
}
func listMetadata(total int64, limit, offset int) *response.Meta {
pages := int(total) / limit
if limit > 0 && int(total)%limit > 0 {
pages++
}
page := 1
if limit > 0 {
page = (offset / limit) + 1
}
return &response.Meta{
Total: int(total),
Limit: limit,
Offset: offset,
Page: page,
Pages: pages,
}
}
+261
View File
@@ -0,0 +1,261 @@
package tender_submission
import (
"context"
"errors"
"testing"
"tm/pkg/logger"
)
type mockRepository struct {
byTenderCompany map[string]*TenderSubmission
createErr error
updateErr error
deleteErr error
}
func submissionKey(tenderID, companyID string) string {
return tenderID + ":" + companyID
}
func newMockRepository() *mockRepository {
return &mockRepository{byTenderCompany: map[string]*TenderSubmission{}}
}
func (m *mockRepository) Create(_ context.Context, submission *TenderSubmission) error {
if m.createErr != nil {
return m.createErr
}
key := submissionKey(submission.TenderID, submission.CompanyID)
if _, exists := m.byTenderCompany[key]; exists {
return ErrTenderSubmissionExists
}
copy := *submission
m.byTenderCompany[key] = &copy
return nil
}
func (m *mockRepository) GetByID(_ context.Context, id string) (*TenderSubmission, error) {
for _, submission := range m.byTenderCompany {
if submission.GetID() == id {
copy := *submission
return &copy, nil
}
}
return nil, ErrTenderSubmissionNotFound
}
func (m *mockRepository) GetByTenderAndCompany(_ context.Context, tenderID, companyID string) (*TenderSubmission, error) {
submission, ok := m.byTenderCompany[submissionKey(tenderID, companyID)]
if !ok {
return nil, ErrTenderSubmissionNotFound
}
copy := *submission
return &copy, nil
}
func (m *mockRepository) Update(_ context.Context, submission *TenderSubmission) error {
if m.updateErr != nil {
return m.updateErr
}
key := submissionKey(submission.TenderID, submission.CompanyID)
if _, ok := m.byTenderCompany[key]; !ok {
return ErrTenderSubmissionNotFound
}
copy := *submission
m.byTenderCompany[key] = &copy
return nil
}
func (m *mockRepository) UpdateIfUpdatedAt(ctx context.Context, submission *TenderSubmission, expectedUpdatedAt int64) error {
current, err := m.GetByTenderAndCompany(ctx, submission.TenderID, submission.CompanyID)
if err != nil {
return err
}
if current.UpdatedAt != expectedUpdatedAt {
return ErrConcurrentUpdate
}
return m.Update(ctx, submission)
}
func (m *mockRepository) Delete(_ context.Context, id string) error {
if m.deleteErr != nil {
return m.deleteErr
}
for key, submission := range m.byTenderCompany {
if submission.GetID() == id {
delete(m.byTenderCompany, key)
return nil
}
}
return ErrTenderSubmissionNotFound
}
func (m *mockRepository) ListByCompany(context.Context, string, []SubmissionStatus, []SubmissionStage, *int64, *int64, int, int, string, string) ([]*TenderSubmission, int64, error) {
return nil, 0, nil
}
func (m *mockRepository) Search(context.Context, *string, *string, []SubmissionStatus, []SubmissionStage, *int64, *int64, int, int, string, string) ([]*TenderSubmission, int64, error) {
return nil, 0, nil
}
func (m *mockRepository) GetCompanyStats(context.Context, string) (*CompanySubmissionStatsResponse, error) {
return nil, nil
}
func (m *mockRepository) GetGlobalStats(context.Context) (*AdminSubmissionStatsResponse, error) {
return nil, nil
}
func (m *mockRepository) seed(submission *TenderSubmission) {
copy := *submission
m.byTenderCompany[submissionKey(submission.TenderID, submission.CompanyID)] = &copy
}
func newTestService(repo Repository) *tenderSubmissionService {
return &tenderSubmissionService{
repository: repo,
approvalReader: nil,
tenderService: nil,
logger: testLogger{},
}
}
type testLogger struct{}
func (testLogger) Debug(string, map[string]interface{}) {}
func (testLogger) Info(string, map[string]interface{}) {}
func (testLogger) Warn(string, map[string]interface{}) {}
func (testLogger) Error(string, map[string]interface{}) {}
func (testLogger) Fatal(string, map[string]interface{}) {}
func (testLogger) WithFields(map[string]interface{}) logger.Logger {
return testLogger{}
}
func (testLogger) Sync() error { return nil }
func TestOnApprovalSubmittedReopensTerminalSubmission(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
existing := NewTenderSubmission("tender-1", "company-1", "customer-1")
existing.AddStatusChange(StatusNotApplied, "approval_rejected", "customer-1", "Rejected")
repo.seed(existing)
if err := service.OnApprovalSubmitted(context.Background(), "tender-1", "company-1", "customer-2"); err != nil {
t.Fatalf("OnApprovalSubmitted() error = %v", err)
}
updated, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if updated.Status != StatusOpportunity {
t.Fatalf("status = %q, want %q", updated.Status, StatusOpportunity)
}
}
func TestOnApprovalSubmittedCreatesWhenMissing(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
if err := service.OnApprovalSubmitted(context.Background(), "tender-1", "company-1", "customer-1"); err != nil {
t.Fatalf("OnApprovalSubmitted() error = %v", err)
}
created, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if created.Status != StatusOpportunity {
t.Fatalf("status = %q, want %q", created.Status, StatusOpportunity)
}
}
func TestOnApprovalRejectedUsesValidatedTransition(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
existing := NewTenderSubmission("tender-1", "company-1", "customer-1")
existing.AddStatusChange(StatusValidating, "", "customer-1", "Accepted")
repo.seed(existing)
if err := service.OnApprovalRejected(context.Background(), "tender-1", "company-1", "customer-1"); err != nil {
t.Fatalf("OnApprovalRejected() error = %v", err)
}
updated, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if updated.Status != StatusNotApplied {
t.Fatalf("status = %q, want %q", updated.Status, StatusNotApplied)
}
}
func TestOnApprovalRejectedSkipsInvalidTransitionFromSentWaiting(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
existing := NewTenderSubmission("tender-1", "company-1", "customer-1")
existing.AddStatusChange(StatusValidating, "", "customer-1", "Accepted")
existing.AddStatusChange(StatusApplying, "", "customer-1", "Ready")
existing.AddStatusChange(StatusSentWaiting, "", "customer-1", "Submitted")
repo.seed(existing)
if err := service.OnApprovalRejected(context.Background(), "tender-1", "company-1", "customer-1"); err != nil {
t.Fatalf("OnApprovalRejected() error = %v", err)
}
updated, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if updated.Status != StatusSentWaiting {
t.Fatalf("status = %q, want %q", updated.Status, StatusSentWaiting)
}
}
func TestOnApprovalRemovedDeletesOpportunityOnly(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
opportunity := NewTenderSubmission("tender-1", "company-1", "customer-1")
repo.seed(opportunity)
if err := service.OnApprovalRemoved(context.Background(), "tender-1", "company-1"); err != nil {
t.Fatalf("OnApprovalRemoved() error = %v", err)
}
if _, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1"); !errors.Is(err, ErrTenderSubmissionNotFound) {
t.Fatalf("expected submission to be deleted, got err = %v", err)
}
inProgress := NewTenderSubmission("tender-2", "company-1", "customer-1")
inProgress.AddStatusChange(StatusValidating, "", "customer-1", "Accepted")
repo.seed(inProgress)
if err := service.OnApprovalRemoved(context.Background(), "tender-2", "company-1"); err != nil {
t.Fatalf("OnApprovalRemoved() error = %v", err)
}
updated, err := repo.GetByTenderAndCompany(context.Background(), "tender-2", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if updated.Status != StatusValidating {
t.Fatalf("status = %q, want %q", updated.Status, StatusValidating)
}
}
func TestGetByIDForCompanyWithTenderEnforcesOwnership(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
submission := NewTenderSubmission("tender-1", "company-1", "customer-1")
repo.seed(submission)
_, err := service.GetByIDForCompanyWithTender(context.Background(), submission.GetID(), "company-2")
if !errors.Is(err, ErrTenderSubmissionNotFound) {
t.Fatalf("expected not found for other company, got %v", err)
}
}
@@ -0,0 +1,110 @@
package tender_submission
import (
"context"
"tm/internal/tender"
)
func listTenderDetailsFromResponse(resp *tender.TenderResponse) *TenderDetails {
if resp == nil {
return nil
}
details := &TenderDetails{
ID: resp.ID,
Title: resp.Title,
Description: resp.Description,
EstimatedValue: resp.EstimatedValue,
Currency: resp.Currency,
TenderDeadline: resp.TenderDeadline,
SubmissionDeadline: resp.SubmissionDeadline,
CountryCode: resp.CountryCode,
Status: string(resp.Status),
}
if resp.BuyerOrganization != nil && resp.BuyerOrganization.Name != "" {
details.BuyerOrganization = &OrganizationResponse{Name: resp.BuyerOrganization.Name}
}
return details
}
func fullTenderDetailsFromResponse(resp *tender.TenderResponse) *TenderDetails {
if resp == nil {
return nil
}
details := &TenderDetails{
ID: resp.ID,
NoticePublicationID: resp.NoticePublicationID,
PublicationDate: resp.PublicationDate,
Title: resp.Title,
Description: resp.Description,
ProcurementTypeCode: resp.ProcurementTypeCode,
ProcedureCode: resp.ProcedureCode,
EstimatedValue: resp.EstimatedValue,
Currency: resp.Currency,
Duration: resp.Duration,
DurationUnit: resp.DurationUnit,
TenderDeadline: resp.TenderDeadline,
SubmissionDeadline: resp.SubmissionDeadline,
CountryCode: resp.CountryCode,
Status: string(resp.Status),
}
if resp.BuyerOrganization != nil && resp.BuyerOrganization.Name != "" {
details.BuyerOrganization = &OrganizationResponse{Name: resp.BuyerOrganization.Name}
}
return details
}
func uniqueTenderIDs(submissions []*TenderSubmission) []string {
if len(submissions) == 0 {
return nil
}
seen := make(map[string]struct{}, len(submissions))
ids := make([]string, 0, len(submissions))
for _, submission := range submissions {
if submission == nil || submission.TenderID == "" {
continue
}
if _, ok := seen[submission.TenderID]; ok {
continue
}
seen[submission.TenderID] = struct{}{}
ids = append(ids, submission.TenderID)
}
return ids
}
func (s *tenderSubmissionService) loadTendersByIDs(ctx context.Context, tenderIDs []string) map[string]*tender.TenderResponse {
if len(tenderIDs) == 0 {
return map[string]*tender.TenderResponse{}
}
tenders, err := s.tenderService.GetByIDs(ctx, tenderIDs)
if err != nil {
s.logger.Error("Failed to batch load tenders for submissions", map[string]interface{}{
"count": len(tenderIDs),
"error": err.Error(),
})
return map[string]*tender.TenderResponse{}
}
return tenders
}
func (s *tenderSubmissionService) enrichSubmissions(ctx context.Context, submissions []*TenderSubmission, fullDetails bool) []*TenderSubmissionWithTenderResponse {
tenderMap := s.loadTendersByIDs(ctx, uniqueTenderIDs(submissions))
responses := make([]*TenderSubmissionWithTenderResponse, len(submissions))
for i, submission := range submissions {
var details *TenderDetails
if tenderResp, ok := tenderMap[submission.TenderID]; ok {
if fullDetails {
details = fullTenderDetailsFromResponse(tenderResp)
} else {
details = listTenderDetailsFromResponse(tenderResp)
}
}
responses[i] = submission.ToResponseWithTender(details)
}
return responses
}
+22
View File
@@ -226,6 +226,7 @@ func (s *TEDScraper) mapPriorInformationNoticeToTender(pin *PriorInformationNoti
t.BuyerOrganization = s.mapOrganization(buyerOrg, "buyer") t.BuyerOrganization = s.mapOrganization(buyerOrg, "buyer")
} }
} }
backfillNoticeLocationFromBuyer(t)
reviewID := pin.GetReviewOrganizationID() reviewID := pin.GetReviewOrganizationID()
if reviewID != "" { if reviewID != "" {
@@ -407,6 +408,7 @@ func (s *TEDScraper) mapToTender(cn *ContractNotice, sourceFileURL, sourceFileNa
t.BuyerOrganization = s.mapOrganization(buyerOrg, "buyer") t.BuyerOrganization = s.mapOrganization(buyerOrg, "buyer")
} }
} }
backfillNoticeLocationFromBuyer(t)
// Set review organization // Set review organization
reviewID := cn.GetReviewOrganizationID() reviewID := cn.GetReviewOrganizationID()
@@ -570,6 +572,7 @@ func (s *TEDScraper) mapContractAwardNoticeToTender(can *ContractAwardNotice, so
t.BuyerOrganization = s.mapOrganization(buyerOrg, "buyer") t.BuyerOrganization = s.mapOrganization(buyerOrg, "buyer")
} }
} }
backfillNoticeLocationFromBuyer(t)
// Set all organizations // Set all organizations
if orgs := can.GetOrganizations(); orgs != nil { if orgs := can.GetOrganizations(); orgs != nil {
@@ -667,6 +670,25 @@ func (s *TEDScraper) mapOrganization(tedOrg *Organization, role string) *notice.
return org return org
} }
func backfillNoticeLocationFromBuyer(t *notice.Notice) {
if t == nil || t.BuyerOrganization == nil {
return
}
buyer := t.BuyerOrganization.Address
if strings.TrimSpace(t.CountryCode) == "" && strings.TrimSpace(buyer.CountryCode) != "" {
t.CountryCode = strings.TrimSpace(buyer.CountryCode)
}
if strings.TrimSpace(t.CityName) == "" && strings.TrimSpace(buyer.CityName) != "" {
t.CityName = strings.TrimSpace(buyer.CityName)
}
if strings.TrimSpace(t.PostalCode) == "" && strings.TrimSpace(buyer.PostalZone) != "" {
t.PostalCode = strings.TrimSpace(buyer.PostalZone)
}
if strings.TrimSpace(t.RegionCode) == "" && strings.TrimSpace(buyer.CountrySubentityCode) != "" {
t.RegionCode = strings.TrimSpace(buyer.CountrySubentityCode)
}
}
func backfillNoticeEstimatedValueFromLots(t *notice.Notice) { func backfillNoticeEstimatedValueFromLots(t *notice.Notice) {
if t == nil || t.EstimatedValue > 0 { if t == nil || t.EstimatedValue > 0 {
return return
+64
View File
@@ -0,0 +1,64 @@
package ted
import (
"testing"
"tm/internal/notice"
)
func TestBackfillNoticeLocationFromBuyer(t *testing.T) {
n := &notice.Notice{
BuyerOrganization: &notice.Organization{
Address: notice.Address{
CountryCode: "POL",
CityName: "Warsaw",
PostalZone: "00-001",
CountrySubentityCode: "PL14",
},
},
}
backfillNoticeLocationFromBuyer(n)
if n.CountryCode != "POL" {
t.Fatalf("expected country POL, got %q", n.CountryCode)
}
if n.CityName != "Warsaw" {
t.Fatalf("expected city Warsaw, got %q", n.CityName)
}
if n.PostalCode != "00-001" {
t.Fatalf("expected postal 00-001, got %q", n.PostalCode)
}
if n.RegionCode != "PL14" {
t.Fatalf("expected region PL14, got %q", n.RegionCode)
}
}
func TestBackfillNoticeLocationFromBuyerDoesNotOverwrite(t *testing.T) {
n := &notice.Notice{
CountryCode: "DEU",
CityName: "Berlin",
PostalCode: "10115",
RegionCode: "DE300",
BuyerOrganization: &notice.Organization{
Address: notice.Address{
CountryCode: "POL",
CityName: "Warsaw",
PostalZone: "00-001",
CountrySubentityCode: "PL14",
},
},
}
backfillNoticeLocationFromBuyer(n)
if n.CountryCode != "DEU" {
t.Fatalf("expected country DEU, got %q", n.CountryCode)
}
if n.CityName != "Berlin" {
t.Fatalf("expected city Berlin, got %q", n.CityName)
}
if n.PostalCode != "10115" {
t.Fatalf("expected postal 10115, got %q", n.PostalCode)
}
if n.RegionCode != "DE300" {
t.Fatalf("expected region DE300, got %q", n.RegionCode)
}
}