Compare commits
19 Commits
1df44ec8ca
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ee81c3581 | |||
| d55b2685ca | |||
| 04c4102170 | |||
| 0fde5772e2 | |||
| c35fa331f9 | |||
| c8616940ff | |||
| fa3d466579 | |||
| 0cce9ef1b5 | |||
| 5c93e0f01b | |||
| 10385e997b | |||
| adfd291245 | |||
| 26a593306d | |||
| c5e62a4061 | |||
| 81b0d94ba3 | |||
| 3e2700bc36 | |||
| aafae2a26f | |||
| 9d8ce12816 | |||
| b388af3518 | |||
| 932b0cf24e |
+10
-6
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -565,7 +565,6 @@ func (w *NoticeWorker) ToTender(n *notice.Notice) (*tender.Tender, error) {
|
|||||||
t.ProcurementProjectID = n.ProcurementProjectID
|
t.ProcurementProjectID = n.ProcurementProjectID
|
||||||
t.SourceFileURL = n.SourceFileURL
|
t.SourceFileURL = n.SourceFileURL
|
||||||
t.SourceFileName = n.SourceFileName
|
t.SourceFileName = n.SourceFileName
|
||||||
t.ContentXML = n.ContentXML
|
|
||||||
// Preserve document-scraping/summarization flags on the tender (managed by
|
// Preserve document-scraping/summarization flags on the tender (managed by
|
||||||
// downstream workers), but refresh the notice-side processing metadata.
|
// downstream workers), but refresh the notice-side processing metadata.
|
||||||
t.ProcessingMetadata.ScrapedAt = n.ProcessingMetadata.ScrapedAt
|
t.ProcessingMetadata.ScrapedAt = n.ProcessingMetadata.ScrapedAt
|
||||||
|
|||||||
@@ -441,6 +441,22 @@ func (r *repository) ClosingSoon(ctx context.Context, limit int, windowSec int64
|
|||||||
windowEnd := now + windowSec
|
windowEnd := now + windowSec
|
||||||
|
|
||||||
pipeline := mongo.Pipeline{
|
pipeline := mongo.Pipeline{
|
||||||
|
{{Key: "$match", Value: bson.M{
|
||||||
|
"$or": bson.A{
|
||||||
|
bson.M{
|
||||||
|
"$and": bson.A{
|
||||||
|
bson.M{"submission_deadline": bson.M{"$gt": 0}},
|
||||||
|
deadlineWithinWindowMatch("submission_deadline", now, windowEnd),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bson.M{
|
||||||
|
"$and": bson.A{
|
||||||
|
noSubmissionDeadlineClause(),
|
||||||
|
deadlineWithinWindowMatch("tender_deadline", now, windowEnd),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}},
|
||||||
{{Key: "$addFields", Value: bson.M{"effective_deadline": effectiveDeadlineExpr()}}},
|
{{Key: "$addFields", Value: bson.M{"effective_deadline": effectiveDeadlineExpr()}}},
|
||||||
{{Key: "$match", Value: bson.M{
|
{{Key: "$match", Value: bson.M{
|
||||||
"effective_deadline": bson.M{"$gt": now, "$lte": windowEnd},
|
"effective_deadline": bson.M{"$gt": now, "$lte": windowEnd},
|
||||||
@@ -580,6 +596,26 @@ func trendTimestampField(metric string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deadlineWithinWindowMatch matches raw deadline fields stored as Unix seconds or
|
||||||
|
// milliseconds. normalizeTimestampExpr treats values > 1e12 as ms; indexed pre-filters
|
||||||
|
// must accept both encodings to stay equivalent to filtering on normalized deadlines.
|
||||||
|
func deadlineWithinWindowMatch(field string, now, windowEnd int64) bson.M {
|
||||||
|
return bson.M{
|
||||||
|
"$or": bson.A{
|
||||||
|
bson.M{field: bson.M{"$gt": now, "$lte": windowEnd}},
|
||||||
|
bson.M{field: bson.M{"$gt": now * 1000, "$lte": windowEnd * 1000}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func noSubmissionDeadlineClause() bson.M {
|
||||||
|
return bson.M{"$or": bson.A{
|
||||||
|
bson.M{"submission_deadline": bson.M{"$exists": false}},
|
||||||
|
bson.M{"submission_deadline": nil},
|
||||||
|
bson.M{"submission_deadline": bson.M{"$lte": 0}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
// effectiveDeadlineExpr is used for closing-soon (submission first, per dashboard-api.md).
|
// effectiveDeadlineExpr is used for closing-soon (submission first, per dashboard-api.md).
|
||||||
func effectiveDeadlineExpr() bson.M {
|
func effectiveDeadlineExpr() bson.M {
|
||||||
return normalizeDeadlineFieldExpr(
|
return normalizeDeadlineFieldExpr(
|
||||||
|
|||||||
@@ -42,3 +42,30 @@ func TestFacetCurrencyValueDecodesBSOND(t *testing.T) {
|
|||||||
t.Fatalf("expected 12345.67, got %f", total)
|
t.Fatalf("expected 12345.67, got %f", total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeadlineWithinWindowMatchCoversSecondsAndMilliseconds(t *testing.T) {
|
||||||
|
now := int64(1_700_000_000)
|
||||||
|
windowEnd := now + 86_400
|
||||||
|
|
||||||
|
match := deadlineWithinWindowMatch("submission_deadline", now, windowEnd)
|
||||||
|
orClause, ok := match["$or"].(bson.A)
|
||||||
|
if !ok || len(orClause) != 2 {
|
||||||
|
t.Fatalf("expected two range branches, got %#v", match)
|
||||||
|
}
|
||||||
|
|
||||||
|
secRange, ok := orClause[0].(bson.M)["submission_deadline"].(bson.M)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected seconds range on submission_deadline, got %#v", orClause[0])
|
||||||
|
}
|
||||||
|
if secRange["$gt"] != now || secRange["$lte"] != windowEnd {
|
||||||
|
t.Fatalf("unexpected seconds range: %#v", secRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
msRange, ok := orClause[1].(bson.M)["submission_deadline"].(bson.M)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected milliseconds range on submission_deadline, got %#v", orClause[1])
|
||||||
|
}
|
||||||
|
if msRange["$gt"] != now*1000 || msRange["$lte"] != windowEnd*1000 {
|
||||||
|
t.Fatalf("unexpected milliseconds range: %#v", msRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+185
-68
@@ -60,19 +60,30 @@ type service struct {
|
|||||||
statisticsMu sync.Mutex
|
statisticsMu sync.Mutex
|
||||||
statistics map[int]cacheEntry[*StatisticsReportResponse]
|
statistics map[int]cacheEntry[*StatisticsReportResponse]
|
||||||
statisticsGroup singleflight.Group
|
statisticsGroup singleflight.Group
|
||||||
|
trendCache *widgetCache[*TrendResponse]
|
||||||
|
countriesCache *widgetCache[*CountriesResponse]
|
||||||
|
noticeTypesCache *widgetCache[*NoticeTypesResponse]
|
||||||
|
closingSoonCache *widgetCache[*ClosingSoonResponse]
|
||||||
|
recentCache *widgetCache[*RecentResponse]
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a dashboard service.
|
// NewService creates a dashboard service.
|
||||||
func NewService(repo Repository, log logger.Logger, redisClient redis.Client) Service {
|
func NewService(repo Repository, log logger.Logger, redisClient redis.Client) Service {
|
||||||
s := &service{
|
s := &service{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
logger: log,
|
logger: log,
|
||||||
redis: redisClient,
|
redis: redisClient,
|
||||||
summary: make(map[string]cacheEntry[*SummaryResponse]),
|
summary: make(map[string]cacheEntry[*SummaryResponse]),
|
||||||
statistics: make(map[int]cacheEntry[*StatisticsReportResponse]),
|
statistics: make(map[int]cacheEntry[*StatisticsReportResponse]),
|
||||||
|
trendCache: newWidgetCache[*TrendResponse](redisClient, log, "trend"),
|
||||||
|
countriesCache: newWidgetCache[*CountriesResponse](redisClient, log, "countries"),
|
||||||
|
noticeTypesCache: newWidgetCache[*NoticeTypesResponse](redisClient, log, "notice-types"),
|
||||||
|
closingSoonCache: newWidgetCache[*ClosingSoonResponse](redisClient, log, "closing-soon"),
|
||||||
|
recentCache: newWidgetCache[*RecentResponse](redisClient, log, "recent"),
|
||||||
}
|
}
|
||||||
go s.warmStatisticsCache()
|
go s.warmStatisticsCache()
|
||||||
go s.warmSummaryCache()
|
go s.warmSummaryCache()
|
||||||
|
go s.warmWidgetCaches()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,102 +140,134 @@ func (s *service) Summary(ctx context.Context, query SummaryQuery) (*SummaryResp
|
|||||||
func (s *service) Trend(ctx context.Context, query TrendQuery) (*TrendResponse, error) {
|
func (s *service) Trend(ctx context.Context, query TrendQuery) (*TrendResponse, error) {
|
||||||
days := trendDays(query.Days)
|
days := trendDays(query.Days)
|
||||||
metric := normalizeTrendMetric(query.Metric)
|
metric := normalizeTrendMetric(query.Metric)
|
||||||
startUnix := trendStartUnix(days)
|
cacheKey := fmt.Sprintf("%d:%s", days, metric)
|
||||||
|
|
||||||
s.logger.Info("Fetching dashboard trend", map[string]interface{}{
|
return s.trendCache.Get(ctx, cacheKey, func(loadCtx context.Context) (*TrendResponse, error) {
|
||||||
"days": days,
|
startUnix := trendStartUnix(days)
|
||||||
"metric": metric,
|
|
||||||
})
|
|
||||||
|
|
||||||
counts, err := s.repo.Trend(ctx, days, metric, startUnix)
|
s.logger.Info("Fetching dashboard trend", map[string]interface{}{
|
||||||
if err != nil {
|
"days": days,
|
||||||
s.logger.Error("Failed to fetch dashboard trend", map[string]interface{}{
|
"metric": metric,
|
||||||
"error": err.Error(),
|
|
||||||
})
|
})
|
||||||
return nil, fmt.Errorf("dashboard trend: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
series := fillTrendSeries(days, counts)
|
counts, err := s.repo.Trend(loadCtx, days, metric, startUnix)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to fetch dashboard trend", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("dashboard trend: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &TrendResponse{
|
return &TrendResponse{
|
||||||
Metric: metric,
|
Metric: metric,
|
||||||
Days: days,
|
Days: days,
|
||||||
Series: series,
|
Series: fillTrendSeries(days, counts),
|
||||||
}, nil
|
}, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) Countries(ctx context.Context, query CountriesQuery) (*CountriesResponse, error) {
|
func (s *service) Countries(ctx context.Context, query CountriesQuery) (*CountriesResponse, error) {
|
||||||
limit := countriesLimit(query.Limit)
|
limit := countriesLimit(query.Limit)
|
||||||
|
cacheKey := strconv.Itoa(limit)
|
||||||
|
|
||||||
s.logger.Info("Fetching dashboard countries", map[string]interface{}{
|
return s.countriesCache.Get(ctx, cacheKey, func(loadCtx context.Context) (*CountriesResponse, error) {
|
||||||
"limit": limit,
|
s.logger.Info("Fetching dashboard countries", map[string]interface{}{
|
||||||
})
|
"limit": limit,
|
||||||
|
|
||||||
out, err := s.repo.Countries(ctx, limit)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to fetch dashboard countries", map[string]interface{}{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
})
|
||||||
return nil, fmt.Errorf("dashboard countries: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out, nil
|
out, err := s.repo.Countries(loadCtx, limit)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to fetch dashboard countries", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("dashboard countries: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) NoticeTypes(ctx context.Context) (*NoticeTypesResponse, error) {
|
func (s *service) NoticeTypes(ctx context.Context) (*NoticeTypesResponse, error) {
|
||||||
s.logger.Info("Fetching dashboard notice types", nil)
|
return s.noticeTypesCache.Get(ctx, "all", func(loadCtx context.Context) (*NoticeTypesResponse, error) {
|
||||||
|
s.logger.Info("Fetching dashboard notice types", nil)
|
||||||
|
|
||||||
out, err := s.repo.NoticeTypes(ctx)
|
out, err := s.repo.NoticeTypes(loadCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to fetch dashboard notice types", map[string]interface{}{
|
s.logger.Error("Failed to fetch dashboard notice types", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return nil, fmt.Errorf("dashboard notice types: %w", err)
|
return nil, fmt.Errorf("dashboard notice types: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) ClosingSoon(ctx context.Context, query ClosingSoonQuery) (*ClosingSoonResponse, error) {
|
func (s *service) ClosingSoon(ctx context.Context, query ClosingSoonQuery) (*ClosingSoonResponse, error) {
|
||||||
limit := listLimit(query.Limit)
|
limit := listLimit(query.Limit)
|
||||||
windowHours := closingWindowHours(query.Window)
|
windowHours := closingWindowHours(query.Window)
|
||||||
windowSec := int64(windowHours) * 3600
|
windowSec := int64(windowHours) * 3600
|
||||||
|
cacheKey := fmt.Sprintf("%d:%d", limit, windowSec)
|
||||||
|
|
||||||
s.logger.Info("Fetching dashboard closing soon", map[string]interface{}{
|
return s.closingSoonCache.Get(ctx, cacheKey, func(loadCtx context.Context) (*ClosingSoonResponse, error) {
|
||||||
"limit": limit,
|
s.logger.Info("Fetching dashboard closing soon", map[string]interface{}{
|
||||||
"window": windowHours,
|
"limit": limit,
|
||||||
})
|
"window": windowHours,
|
||||||
|
|
||||||
items, err := s.repo.ClosingSoon(ctx, limit, windowSec)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to fetch dashboard closing soon", map[string]interface{}{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
})
|
||||||
return nil, fmt.Errorf("dashboard closing soon: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ClosingSoonResponse{Items: items}, nil
|
items, err := s.repo.ClosingSoon(loadCtx, limit, windowSec)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to fetch dashboard closing soon", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("dashboard closing soon: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ClosingSoonResponse{Items: items}, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) Recent(ctx context.Context, query RecentQuery) (*RecentResponse, error) {
|
func (s *service) Recent(ctx context.Context, query RecentQuery) (*RecentResponse, error) {
|
||||||
limit := listLimit(query.Limit)
|
limit := listLimit(query.Limit)
|
||||||
|
cursor := strings.TrimSpace(query.Cursor)
|
||||||
s.logger.Info("Fetching dashboard recent tenders", map[string]interface{}{
|
if cursor != "" {
|
||||||
"limit": limit,
|
s.logger.Info("Fetching dashboard recent tenders", map[string]interface{}{
|
||||||
})
|
"limit": limit,
|
||||||
|
|
||||||
items, nextCursor, err := s.repo.Recent(ctx, limit, strings.TrimSpace(query.Cursor))
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to fetch dashboard recent tenders", map[string]interface{}{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
})
|
||||||
return nil, fmt.Errorf("dashboard recent: %w", err)
|
|
||||||
|
items, nextCursor, err := s.repo.Recent(ctx, limit, cursor)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to fetch dashboard recent tenders", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("dashboard recent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RecentResponse{
|
||||||
|
Items: items,
|
||||||
|
NextCursor: nextCursor,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &RecentResponse{
|
cacheKey := strconv.Itoa(limit)
|
||||||
Items: items,
|
return s.recentCache.Get(ctx, cacheKey, func(loadCtx context.Context) (*RecentResponse, error) {
|
||||||
NextCursor: nextCursor,
|
s.logger.Info("Fetching dashboard recent tenders", map[string]interface{}{
|
||||||
}, nil
|
"limit": limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
items, nextCursor, err := s.repo.Recent(loadCtx, limit, "")
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to fetch dashboard recent tenders", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("dashboard recent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RecentResponse{
|
||||||
|
Items: items,
|
||||||
|
NextCursor: nextCursor,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) Statistics(ctx context.Context, query StatisticsQuery) (*StatisticsReportResponse, error) {
|
func (s *service) Statistics(ctx context.Context, query StatisticsQuery) (*StatisticsReportResponse, error) {
|
||||||
@@ -339,6 +382,80 @@ func (s *service) loadStatistics(ctx context.Context, days int) (*StatisticsRepo
|
|||||||
return out.(*StatisticsReportResponse), nil
|
return out.(*StatisticsReportResponse), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *service) warmWidgetCaches() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
defaultWindowSec := int64(defaultClosingWindowHours) * 3600
|
||||||
|
warmed := 0
|
||||||
|
|
||||||
|
if s.trendCache.WarmFromRedis(ctx, fmt.Sprintf("%d:%s", defaultTrendDays, "created")) {
|
||||||
|
warmed++
|
||||||
|
}
|
||||||
|
if s.countriesCache.WarmFromRedis(ctx, strconv.Itoa(defaultCountriesLimit)) {
|
||||||
|
warmed++
|
||||||
|
}
|
||||||
|
if s.noticeTypesCache.WarmFromRedis(ctx, "all") {
|
||||||
|
warmed++
|
||||||
|
}
|
||||||
|
if s.closingSoonCache.WarmFromRedis(ctx, fmt.Sprintf("%d:%d", defaultListLimit, defaultWindowSec)) {
|
||||||
|
warmed++
|
||||||
|
}
|
||||||
|
if s.recentCache.WarmFromRedis(ctx, strconv.Itoa(defaultListLimit)) {
|
||||||
|
warmed++
|
||||||
|
}
|
||||||
|
|
||||||
|
if warmed > 0 {
|
||||||
|
s.logger.Info("Dashboard widget caches warmed from Redis", map[string]interface{}{
|
||||||
|
"warmed": warmed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.trendCache.Reload(fmt.Sprintf("%d:%s", defaultTrendDays, "created"), func(loadCtx context.Context) (*TrendResponse, error) {
|
||||||
|
return s.loadTrend(loadCtx, TrendQuery{Days: defaultTrendDays, Metric: "created"})
|
||||||
|
})
|
||||||
|
go s.countriesCache.Reload(strconv.Itoa(defaultCountriesLimit), func(loadCtx context.Context) (*CountriesResponse, error) {
|
||||||
|
return s.repo.Countries(loadCtx, defaultCountriesLimit)
|
||||||
|
})
|
||||||
|
go s.noticeTypesCache.Reload("all", func(loadCtx context.Context) (*NoticeTypesResponse, error) {
|
||||||
|
return s.repo.NoticeTypes(loadCtx)
|
||||||
|
})
|
||||||
|
go s.closingSoonCache.Reload(fmt.Sprintf("%d:%d", defaultListLimit, defaultWindowSec), func(loadCtx context.Context) (*ClosingSoonResponse, error) {
|
||||||
|
items, err := s.repo.ClosingSoon(loadCtx, defaultListLimit, defaultWindowSec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ClosingSoonResponse{Items: items}, nil
|
||||||
|
})
|
||||||
|
go s.recentCache.Reload(strconv.Itoa(defaultListLimit), func(loadCtx context.Context) (*RecentResponse, error) {
|
||||||
|
items, nextCursor, err := s.repo.Recent(loadCtx, defaultListLimit, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &RecentResponse{
|
||||||
|
Items: items,
|
||||||
|
NextCursor: nextCursor,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) loadTrend(ctx context.Context, query TrendQuery) (*TrendResponse, error) {
|
||||||
|
days := trendDays(query.Days)
|
||||||
|
metric := normalizeTrendMetric(query.Metric)
|
||||||
|
startUnix := trendStartUnix(days)
|
||||||
|
|
||||||
|
counts, err := s.repo.Trend(ctx, days, metric, startUnix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TrendResponse{
|
||||||
|
Metric: metric,
|
||||||
|
Days: days,
|
||||||
|
Series: fillTrendSeries(days, counts),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *service) warmSummaryCache() {
|
func (s *service) warmSummaryCache() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
"tm/pkg/redis"
|
||||||
|
|
||||||
|
goredis "github.com/redis/go-redis/v9"
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// widgetCacheTTL is the fresh window before a background reload is triggered.
|
||||||
|
widgetCacheTTL = 60 * time.Second
|
||||||
|
// widgetStaleGraceTTL is how long stale entries may be served while reloading.
|
||||||
|
// Dashboard widgets have no write-side invalidation; expect up to this lag after
|
||||||
|
// data changes (e.g. a new tender may not appear in cached widgets immediately).
|
||||||
|
widgetStaleGraceTTL = 5 * time.Minute
|
||||||
|
widgetReloadTimeout = 2 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type widgetLoader[T any] func(ctx context.Context) (T, error)
|
||||||
|
|
||||||
|
type widgetCache[T any] struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]cacheEntry[T]
|
||||||
|
group singleflight.Group
|
||||||
|
redis redis.Client
|
||||||
|
logger logger.Logger
|
||||||
|
keyPrefix string
|
||||||
|
ttl time.Duration
|
||||||
|
staleGrace time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWidgetCache[T any](redisClient redis.Client, log logger.Logger, keyPrefix string) *widgetCache[T] {
|
||||||
|
return &widgetCache[T]{
|
||||||
|
entries: make(map[string]cacheEntry[T]),
|
||||||
|
redis: redisClient,
|
||||||
|
logger: log,
|
||||||
|
keyPrefix: keyPrefix,
|
||||||
|
ttl: widgetCacheTTL,
|
||||||
|
staleGrace: widgetStaleGraceTTL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *widgetCache[T]) Get(ctx context.Context, key string, load widgetLoader[T]) (T, error) {
|
||||||
|
var zero T
|
||||||
|
|
||||||
|
// Returned values are shared across concurrent callers via singleflight and stale
|
||||||
|
// hits. Callers must treat them as read-only through JSON serialization.
|
||||||
|
if value, ok := c.fresh(key); ok {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
if value, ok := c.stale(key); ok {
|
||||||
|
go c.Reload(key, load)
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
if value, ok := c.fromRedis(ctx, key); ok {
|
||||||
|
c.store(key, value)
|
||||||
|
go c.Reload(key, load)
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err, _ := c.group.Do(key, func() (interface{}, error) {
|
||||||
|
if value, ok := c.fresh(key); ok {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := load(context.WithoutCancel(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.store(key, result)
|
||||||
|
c.storeRedis(context.Background(), key, result)
|
||||||
|
return result, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.(T), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *widgetCache[T]) Reload(key string, load widgetLoader[T]) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), widgetReloadTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, _, _ = c.group.Do("reload:"+key, func() (interface{}, error) {
|
||||||
|
result, err := load(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if c.logger != nil {
|
||||||
|
c.logger.Warn("Failed to refresh dashboard widget cache", map[string]interface{}{
|
||||||
|
"widget": c.keyPrefix,
|
||||||
|
"key": key,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.store(key, result)
|
||||||
|
c.storeRedis(context.Background(), key, result)
|
||||||
|
return result, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *widgetCache[T]) WarmFromRedis(ctx context.Context, key string) bool {
|
||||||
|
value, ok := c.fromRedis(ctx, key)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.store(key, value)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *widgetCache[T]) fresh(key string) (T, bool) {
|
||||||
|
var zero T
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry, ok := c.entries[key]
|
||||||
|
if !ok || now.After(entry.expiresAt) {
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *widgetCache[T]) stale(key string) (T, bool) {
|
||||||
|
var zero T
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry, ok := c.entries[key]
|
||||||
|
if !ok || now.After(entry.staleUntil) {
|
||||||
|
if ok {
|
||||||
|
delete(c.entries, key)
|
||||||
|
}
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *widgetCache[T]) store(key string, value T) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.entries[key] = cacheEntry[T]{
|
||||||
|
expiresAt: now.Add(c.ttl),
|
||||||
|
staleUntil: now.Add(c.ttl + c.staleGrace),
|
||||||
|
value: value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *widgetCache[T]) redisKey(key string) string {
|
||||||
|
return fmt.Sprintf("dashboard:%s:%s", c.keyPrefix, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *widgetCache[T]) fromRedis(ctx context.Context, key string) (T, bool) {
|
||||||
|
var zero T
|
||||||
|
if c.redis == nil {
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := c.redis.Get(ctx, c.redisKey(key))
|
||||||
|
if err != nil {
|
||||||
|
if err != goredis.Nil && c.logger != nil {
|
||||||
|
c.logger.Warn("Failed to read dashboard widget cache from Redis", map[string]interface{}{
|
||||||
|
"widget": c.keyPrefix,
|
||||||
|
"key": key,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var value T
|
||||||
|
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||||
|
if c.logger != nil {
|
||||||
|
c.logger.Warn("Failed to decode dashboard widget cache from Redis", map[string]interface{}{
|
||||||
|
"widget": c.keyPrefix,
|
||||||
|
"key": key,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = c.redis.Del(ctx, c.redisKey(key))
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *widgetCache[T]) storeRedis(ctx context.Context, key string, value T) {
|
||||||
|
if c.redis == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
if c.logger != nil {
|
||||||
|
c.logger.Warn("Failed to encode dashboard widget cache for Redis", map[string]interface{}{
|
||||||
|
"widget": c.keyPrefix,
|
||||||
|
"key": key,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := c.ttl + c.staleGrace
|
||||||
|
if err := c.redis.Set(ctx, c.redisKey(key), string(encoded), ttl); err != nil && c.logger != nil {
|
||||||
|
c.logger.Warn("Failed to store dashboard widget cache in Redis", map[string]interface{}{
|
||||||
|
"widget": c.keyPrefix,
|
||||||
|
"key": key,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,8 +28,26 @@ const (
|
|||||||
DeliveryChannelInApp DeliveryChannel = "in_app"
|
DeliveryChannelInApp DeliveryChannel = "in_app"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EventTypeInApp identifies notifications stored for the in-app notification center.
|
// Persisted event types for stored notifications (admin list / notification center).
|
||||||
const EventTypeInApp = "in_app"
|
const (
|
||||||
|
EventTypeInApp = "in_app"
|
||||||
|
EventTypeEmail = "email"
|
||||||
|
EventTypePush = "push"
|
||||||
|
)
|
||||||
|
|
||||||
|
// persistedEventType maps a delivery channel to the event_type stored in MongoDB.
|
||||||
|
func persistedEventType(channel DeliveryChannel) (string, bool) {
|
||||||
|
switch channel {
|
||||||
|
case DeliveryChannelEmail:
|
||||||
|
return EventTypeEmail, true
|
||||||
|
case DeliveryChannelPush:
|
||||||
|
return EventTypePush, true
|
||||||
|
case DeliveryChannelInApp:
|
||||||
|
return EventTypeInApp, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DeliveryStatus represents the delivery status of notification
|
// DeliveryStatus represents the delivery status of notification
|
||||||
type NotificationStatus string
|
type NotificationStatus string
|
||||||
|
|||||||
@@ -99,13 +99,27 @@ func (s *notificationService) Send(ctx context.Context, req *NotificationRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *notificationService) sendToRecipient(ctx context.Context, recipient notificationRecipient, req *NotificationRequest, link, image string) {
|
func (s *notificationService) sendToRecipient(ctx context.Context, recipient notificationRecipient, req *NotificationRequest, link, image string) {
|
||||||
if err := s.persistNotification(ctx, recipient, req, link, image); err != nil {
|
// Always persist an in-app record so every send appears in the notification center.
|
||||||
|
if err := s.persistNotification(ctx, recipient, req, link, image, DeliveryChannelInApp); err != nil {
|
||||||
s.logger.Error("Failed to persist in-app notification", map[string]interface{}{
|
s.logger.Error("Failed to persist in-app notification", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
"user_id": recipient.UserID,
|
"user_id": recipient.UserID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, channel := range req.Channels {
|
||||||
|
if channel == DeliveryChannelInApp {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.persistNotification(ctx, recipient, req, link, image, channel); err != nil {
|
||||||
|
s.logger.Error("Failed to persist notification", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": recipient.UserID,
|
||||||
|
"channel": channel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
metadata := map[string]any{
|
metadata := map[string]any{
|
||||||
"tender": strings.TrimSpace(req.Tender),
|
"tender": strings.TrimSpace(req.Tender),
|
||||||
}
|
}
|
||||||
@@ -149,15 +163,26 @@ func (s *notificationService) sendToRecipient(ctx context.Context, recipient not
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *notificationService) persistNotification(ctx context.Context, recipient notificationRecipient, req *NotificationRequest, link, image string) error {
|
func (s *notificationService) persistNotification(ctx context.Context, recipient notificationRecipient, req *NotificationRequest, link, image string, channel DeliveryChannel) error {
|
||||||
methods := map[string]string{
|
eventType, ok := persistedEventType(channel)
|
||||||
"in_app": "true",
|
if !ok {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if slices.Contains(req.Channels, DeliveryChannelEmail) && recipient.Email != "" {
|
|
||||||
|
methods := map[string]string{}
|
||||||
|
switch channel {
|
||||||
|
case DeliveryChannelEmail:
|
||||||
|
if recipient.Email == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
methods["email"] = recipient.Email
|
methods["email"] = recipient.Email
|
||||||
}
|
case DeliveryChannelPush:
|
||||||
if slices.Contains(req.Channels, DeliveryChannelPush) && len(recipient.DeviceTokens) > 0 {
|
if len(recipient.DeviceTokens) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
methods["push"] = recipient.DeviceTokens[0]
|
methods["push"] = recipient.DeviceTokens[0]
|
||||||
|
case DeliveryChannelInApp:
|
||||||
|
methods["in_app"] = "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
status := string(DeliveryStatusSent)
|
status := string(DeliveryStatusSent)
|
||||||
@@ -173,7 +198,7 @@ func (s *notificationService) persistNotification(ctx context.Context, recipient
|
|||||||
Image: image,
|
Image: image,
|
||||||
Priority: string(req.Priority),
|
Priority: string(req.Priority),
|
||||||
Type: string(req.Type),
|
Type: string(req.Type),
|
||||||
EventType: EventTypeInApp,
|
EventType: eventType,
|
||||||
Status: status,
|
Status: status,
|
||||||
Methods: methods,
|
Methods: methods,
|
||||||
Metadata: map[string]any{
|
Metadata: map[string]any{
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,7 +88,6 @@ type Tender struct {
|
|||||||
Source TenderSource `bson:"source" json:"source"`
|
Source TenderSource `bson:"source" json:"source"`
|
||||||
SourceFileURL string `bson:"source_file_url" json:"source_file_url"`
|
SourceFileURL string `bson:"source_file_url" json:"source_file_url"`
|
||||||
SourceFileName string `bson:"source_file_name" json:"source_file_name"`
|
SourceFileName string `bson:"source_file_name" json:"source_file_name"`
|
||||||
ContentXML string `bson:"content_xml,omitempty" json:"content_xml,omitempty"`
|
|
||||||
ProcessingMetadata ProcessingMetadata `bson:"processing_metadata" json:"processing_metadata"`
|
ProcessingMetadata ProcessingMetadata `bson:"processing_metadata" json:"processing_metadata"`
|
||||||
// AIOverallSummary optional denormalized overall summary for search/list (e.g. synced from AI pipeline storage).
|
// AIOverallSummary optional denormalized overall summary for search/list (e.g. synced from AI pipeline storage).
|
||||||
AIOverallSummary string `bson:"ai_overall_summary,omitempty" json:"ai_overall_summary,omitempty"`
|
AIOverallSummary string `bson:"ai_overall_summary,omitempty" json:"ai_overall_summary,omitempty"`
|
||||||
|
|||||||
+74
-1
@@ -64,12 +64,85 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasListFilters reports whether the form applies any MongoDB list filters.
|
||||||
|
func (f *SearchForm) HasListFilters() bool {
|
||||||
|
if f == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if f.DocumentsScraped || f.OnlyActiveDeadlines || f.ExcludeRejectedTenders {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.Search != nil && strings.TrimSpace(*f.Search) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.Title != nil && strings.TrimSpace(*f.Title) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.Description != nil && strings.TrimSpace(*f.Description) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.NoticeType != nil && strings.TrimSpace(*f.NoticeType) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(f.NoticeTypes) > 0 || len(f.FormTypes) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.ProcurementType != nil && strings.TrimSpace(*f.ProcurementType) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.Country != nil && strings.TrimSpace(*f.Country) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.CountryCode != nil && strings.TrimSpace(*f.CountryCode) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(f.CountryCodes) > 0 || len(f.RegionCodes) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.MainClassification != nil && strings.TrimSpace(*f.MainClassification) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(f.Classifications) > 0 || len(f.CpvCodes) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.MinEstimatedValue != nil || f.MaxEstimatedValue != nil || f.Currency != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.CreatedAt != nil || f.CreatedAtFrom != nil || f.CreatedAtTo != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.TenderDeadline != nil || f.TenderDeadlineFrom != nil || f.TenderDeadlineTo != nil ||
|
||||||
|
f.DeadlineFrom != nil || f.DeadlineTo != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.PublicationDate != nil || f.PublicationDateFrom != nil || f.PublicationDateTo != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.SubmissionDeadline != nil || f.SubmissionDateAliasFrom != nil || f.SubmissionDateAliasTo != nil ||
|
||||||
|
f.SubmissionDateFrom != nil || f.SubmissionDateTo != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(f.Status) > 0 || len(f.Source) > 0 || len(f.Languages) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.BuyerOrganizationID != nil && strings.TrimSpace(*f.BuyerOrganizationID) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if f.NoticePublicationID != nil && strings.TrimSpace(*f.NoticePublicationID) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(f.ContractFolderIDsWithDocuments) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// SearchResponse represents the response for listing tenders
|
// SearchResponse represents the response for listing tenders
|
||||||
type SearchResponse struct {
|
type SearchResponse struct {
|
||||||
Tenders []TenderResponse `json:"tenders"`
|
Tenders []TenderResponse `json:"tenders"`
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -27,7 +30,6 @@ type TenderRepository interface {
|
|||||||
// GetLatestByContractFolderIDs returns the most recently updated tender per distinct contract_folder_id.
|
// GetLatestByContractFolderIDs returns the most recently updated tender per distinct contract_folder_id.
|
||||||
GetLatestByContractFolderIDs(ctx context.Context, contractFolderIDs []string) (map[string]*Tender, error)
|
GetLatestByContractFolderIDs(ctx context.Context, contractFolderIDs []string) (map[string]*Tender, error)
|
||||||
GetByProcurementProjectID(ctx context.Context, procurementProjectID string) (*Tender, error)
|
GetByProcurementProjectID(ctx context.Context, procurementProjectID string) (*Tender, error)
|
||||||
FindTendersWithContentXML(ctx context.Context, limit, skip int) ([]Tender, error)
|
|
||||||
GetTenderCountByCountry(ctx context.Context) (map[string]int64, error)
|
GetTenderCountByCountry(ctx context.Context) (map[string]int64, error)
|
||||||
GetTenderCountByType(ctx context.Context) (map[string]int64, error)
|
GetTenderCountByType(ctx context.Context) (map[string]int64, error)
|
||||||
GetTenderCountByClassification(ctx context.Context) (map[string]int64, error)
|
GetTenderCountByClassification(ctx context.Context) (map[string]int64, error)
|
||||||
@@ -130,6 +132,15 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Te
|
|||||||
*orm.NewIndex("main_classification_idx", bson.D{{Key: "main_classification", Value: 1}}),
|
*orm.NewIndex("main_classification_idx", bson.D{{Key: "main_classification", Value: 1}}),
|
||||||
*orm.NewIndex("publication_date_idx", bson.D{{Key: "publication_date", Value: -1}}),
|
*orm.NewIndex("publication_date_idx", bson.D{{Key: "publication_date", Value: -1}}),
|
||||||
*orm.NewIndex("tender_deadline_idx", bson.D{{Key: "tender_deadline", Value: 1}}),
|
*orm.NewIndex("tender_deadline_idx", bson.D{{Key: "tender_deadline", Value: 1}}),
|
||||||
|
*orm.NewIndex("submission_deadline_idx", bson.D{{Key: "submission_deadline", Value: 1}}),
|
||||||
|
*orm.NewIndex("closing_soon_submission_idx", bson.D{
|
||||||
|
{Key: "submission_deadline", Value: 1},
|
||||||
|
{Key: "status", Value: 1},
|
||||||
|
}),
|
||||||
|
*orm.NewIndex("closing_soon_tender_deadline_idx", bson.D{
|
||||||
|
{Key: "tender_deadline", Value: 1},
|
||||||
|
{Key: "status", Value: 1},
|
||||||
|
}),
|
||||||
*orm.NewIndex("estimated_value_idx", bson.D{{Key: "estimated_value", Value: -1}}),
|
*orm.NewIndex("estimated_value_idx", bson.D{{Key: "estimated_value", Value: -1}}),
|
||||||
*orm.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
|
*orm.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
|
||||||
// Stable keyset pagination for default admin list (created_at desc).
|
// Stable keyset pagination for default admin list (created_at desc).
|
||||||
@@ -172,6 +183,13 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Te
|
|||||||
{Key: "processing_metadata.documents_scraped", Value: 1},
|
{Key: "processing_metadata.documents_scraped", Value: 1},
|
||||||
{Key: "processing_metadata.documents_scraped_at", Value: -1},
|
{Key: "processing_metadata.documents_scraped_at", Value: -1},
|
||||||
}),
|
}),
|
||||||
|
// Panel list: documents_scraped=true sorted by created_at desc (keyset pagination).
|
||||||
|
*orm.NewIndex("documents_scraped_created_at_id_idx", bson.D{
|
||||||
|
{Key: "created_at", Value: -1},
|
||||||
|
{Key: "_id", Value: -1},
|
||||||
|
}).WithPartialFilterExpression(bson.M{
|
||||||
|
"processing_metadata.documents_scraped": true,
|
||||||
|
}),
|
||||||
*orm.NewIndex("scraped_documents_scraped_at_idx", bson.D{
|
*orm.NewIndex("scraped_documents_scraped_at_idx", bson.D{
|
||||||
{Key: "scraped_documents.scraped_at", Value: 1},
|
{Key: "scraped_documents.scraped_at", Value: 1},
|
||||||
}),
|
}),
|
||||||
@@ -660,27 +678,6 @@ func (r *tenderRepository) GetByProcurementProjectID(ctx context.Context, procur
|
|||||||
return &result.Items[0], nil
|
return &result.Items[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindTendersWithContentXML returns tenders that still have TED XML (for backfill after notices were deleted).
|
|
||||||
func (r *tenderRepository) FindTendersWithContentXML(ctx context.Context, limit, skip int) ([]Tender, error) {
|
|
||||||
filter := bson.M{"content_xml": bson.M{"$exists": true, "$ne": ""}}
|
|
||||||
pagination := orm.Pagination{
|
|
||||||
Limit: limit,
|
|
||||||
Skip: skip,
|
|
||||||
SortField: "_id",
|
|
||||||
SortOrder: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
|
|
||||||
if err != nil {
|
|
||||||
r.logger.Error("Failed to list tenders with content_xml", map[string]interface{}{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.Items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates an existing tender
|
// Update updates an existing tender
|
||||||
func (r *tenderRepository) Update(ctx context.Context, tender *Tender) error {
|
func (r *tenderRepository) Update(ctx context.Context, tender *Tender) error {
|
||||||
tender.UpdatedAt = time.Now().Unix()
|
tender.UpdatedAt = time.Now().Unix()
|
||||||
@@ -1386,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
|
||||||
}
|
}
|
||||||
@@ -1405,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}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package tender
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
goredis "github.com/redis/go-redis/v9"
|
||||||
|
"tm/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
searchListCacheTTL = 60 * time.Second
|
||||||
|
// searchListCacheStaleTTL bounds how long the default unfiltered list may lag after
|
||||||
|
// writes. Only isCacheableSearchList queries use this cache (~5 min max staleness).
|
||||||
|
searchListCacheStaleTTL = 5 * time.Minute
|
||||||
|
searchListCacheKeyPrefix = "tender:search:list:"
|
||||||
|
)
|
||||||
|
|
||||||
|
type searchListCacheEntry struct {
|
||||||
|
expiresAt time.Time
|
||||||
|
staleUntil time.Time
|
||||||
|
value *SearchResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
type cachedSearchListPayload struct {
|
||||||
|
Tenders []TenderResponse `json:"tenders"`
|
||||||
|
Metadata *response.Meta `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchResponseToCachePayload(resp *SearchResponse) *cachedSearchListPayload {
|
||||||
|
if resp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &cachedSearchListPayload{
|
||||||
|
Tenders: resp.Tenders,
|
||||||
|
Metadata: resp.Metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cachePayloadToSearchResponse(payload *cachedSearchListPayload) *SearchResponse {
|
||||||
|
if payload == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &SearchResponse{
|
||||||
|
Tenders: payload.Tenders,
|
||||||
|
Metadata: payload.Metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCacheableSearchList(form *SearchForm, pagination *response.Pagination) bool {
|
||||||
|
if form == nil || pagination == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if form.HasListFilters() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if form.CompanyID != nil && strings.TrimSpace(*form.CompanyID) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if form.CustomerID != nil && strings.TrimSpace(*form.CustomerID) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(form.CompanyIDs) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if pagination.Cursor != "" || pagination.IncludeTotal || pagination.Offset != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if pagination.Limit <= 0 || pagination.Limit > 20 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sortBy := pagination.SortBy
|
||||||
|
if sortBy == "" {
|
||||||
|
sortBy = "created_at"
|
||||||
|
}
|
||||||
|
sortOrder := pagination.SortOrder
|
||||||
|
if sortOrder == "" {
|
||||||
|
sortOrder = "desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBy == "created_at" && sortOrder == "desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchListCacheKey(pagination *response.Pagination, language string) string {
|
||||||
|
return fmt.Sprintf("%d:%s", pagination.Limit, strings.ToLower(strings.TrimSpace(language)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenderService) cachedSearchList(ctx context.Context, form *SearchForm, pagination *response.Pagination, language string, load func(context.Context) (*SearchResponse, error)) (*SearchResponse, error) {
|
||||||
|
if !isCacheableSearchList(form, pagination) {
|
||||||
|
return load(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached *SearchResponse values (including the Tenders slice) are shared across
|
||||||
|
// concurrent callers via singleflight and stale hits; treat as read-only.
|
||||||
|
cacheKey := searchListCacheKey(pagination, language)
|
||||||
|
redisKey := searchListCacheKeyPrefix + cacheKey
|
||||||
|
|
||||||
|
if cached, ok := s.getFreshSearchListCache(cacheKey); ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
if stale, ok := s.getStaleSearchListCache(cacheKey); ok {
|
||||||
|
go s.refreshSearchListCache(cacheKey, redisKey, load)
|
||||||
|
return stale, nil
|
||||||
|
}
|
||||||
|
if cached, ok := s.getRedisSearchListCache(ctx, redisKey); ok {
|
||||||
|
s.storeSearchListCache(cacheKey, cached)
|
||||||
|
go s.refreshSearchListCache(cacheKey, redisKey, load)
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err, _ := s.searchListCacheGroup.Do(cacheKey, func() (interface{}, error) {
|
||||||
|
if cached, ok := s.getFreshSearchListCache(cacheKey); ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := load(context.WithoutCancel(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.storeSearchListCache(cacheKey, result)
|
||||||
|
s.storeRedisSearchListCache(context.Background(), redisKey, result)
|
||||||
|
return result, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.(*SearchResponse), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenderService) refreshSearchListCache(cacheKey, redisKey string, load func(context.Context) (*SearchResponse, error)) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := load(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to refresh tender search list cache", map[string]interface{}{
|
||||||
|
"cache_key": cacheKey,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.storeSearchListCache(cacheKey, result)
|
||||||
|
s.storeRedisSearchListCache(context.Background(), redisKey, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenderService) getFreshSearchListCache(cacheKey string) (*SearchResponse, bool) {
|
||||||
|
s.searchListCacheMu.Lock()
|
||||||
|
defer s.searchListCacheMu.Unlock()
|
||||||
|
|
||||||
|
entry, ok := s.searchListCache[cacheKey]
|
||||||
|
if !ok || time.Now().After(entry.expiresAt) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return entry.value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenderService) getStaleSearchListCache(cacheKey string) (*SearchResponse, bool) {
|
||||||
|
s.searchListCacheMu.Lock()
|
||||||
|
defer s.searchListCacheMu.Unlock()
|
||||||
|
|
||||||
|
entry, ok := s.searchListCache[cacheKey]
|
||||||
|
if !ok || time.Now().After(entry.staleUntil) {
|
||||||
|
if ok {
|
||||||
|
delete(s.searchListCache, cacheKey)
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return entry.value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenderService) storeSearchListCache(cacheKey string, value *SearchResponse) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
s.searchListCacheMu.Lock()
|
||||||
|
defer s.searchListCacheMu.Unlock()
|
||||||
|
|
||||||
|
s.searchListCache[cacheKey] = searchListCacheEntry{
|
||||||
|
expiresAt: now.Add(searchListCacheTTL),
|
||||||
|
staleUntil: now.Add(searchListCacheTTL + searchListCacheStaleTTL),
|
||||||
|
value: value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenderService) getRedisSearchListCache(ctx context.Context, redisKey string) (*SearchResponse, bool) {
|
||||||
|
if s.redisClient == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := s.redisClient.Get(ctx, redisKey)
|
||||||
|
if err != nil {
|
||||||
|
if err != goredis.Nil {
|
||||||
|
s.logger.Warn("Failed to read tender search list cache from Redis", map[string]interface{}{
|
||||||
|
"cache_key": redisKey,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var cached cachedSearchListPayload
|
||||||
|
if err := json.Unmarshal([]byte(raw), &cached); err != nil {
|
||||||
|
s.logger.Warn("Failed to decode tender search list cache from Redis", map[string]interface{}{
|
||||||
|
"cache_key": redisKey,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
_ = s.redisClient.Del(ctx, redisKey)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachePayloadToSearchResponse(&cached), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenderService) storeRedisSearchListCache(ctx context.Context, redisKey string, value *SearchResponse) {
|
||||||
|
if s.redisClient == nil || value == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(searchResponseToCachePayload(value))
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to encode tender search list cache for Redis", map[string]interface{}{
|
||||||
|
"cache_key": redisKey,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.redisClient.Set(ctx, redisKey, string(encoded), searchListCacheTTL+searchListCacheStaleTTL); err != nil {
|
||||||
|
s.logger.Warn("Failed to store tender search list cache in Redis", map[string]interface{}{
|
||||||
|
"cache_key": redisKey,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-31
@@ -10,6 +10,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"tm/internal/company"
|
"tm/internal/company"
|
||||||
"tm/internal/customer"
|
"tm/internal/customer"
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
"tm/pkg/response"
|
"tm/pkg/response"
|
||||||
|
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AISummarizerClient defines the interface for on-demand AI operations.
|
// AISummarizerClient defines the interface for on-demand AI operations.
|
||||||
@@ -95,11 +97,18 @@ type tenderService struct {
|
|||||||
redisClient redis.Client
|
redisClient redis.Client
|
||||||
pageCacheTTL time.Duration
|
pageCacheTTL time.Duration
|
||||||
defaultLanguage string
|
defaultLanguage string
|
||||||
|
searchListCacheMu sync.Mutex
|
||||||
|
searchListCache map[string]searchListCacheEntry
|
||||||
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -136,6 +145,7 @@ func NewService(
|
|||||||
redisClient: redisClient,
|
redisClient: redisClient,
|
||||||
pageCacheTTL: recommendationPageCacheTTL,
|
pageCacheTTL: recommendationPageCacheTTL,
|
||||||
defaultLanguage: strings.ToLower(defaultLanguage),
|
defaultLanguage: strings.ToLower(defaultLanguage),
|
||||||
|
searchListCache: make(map[string]searchListCacheEntry),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -922,7 +932,21 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
|
|||||||
"to": form.DeadlineTo,
|
"to": form.DeadlineTo,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Profile keywords are not used for tender search.
|
language := s.pickResponseLanguage(form.Language)
|
||||||
|
return s.cachedSearchList(ctx, form, pagination, language, func(loadCtx context.Context) (*SearchResponse, error) {
|
||||||
|
return s.searchUncached(loadCtx, form, pagination, language)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -936,7 +960,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
|
|||||||
if form.CompanyID == nil || *form.CompanyID == "" {
|
if form.CompanyID == nil || *form.CompanyID == "" {
|
||||||
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
||||||
for _, tender := range page.Items {
|
for _, tender := range page.Items {
|
||||||
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(language))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SearchResponse{
|
return &SearchResponse{
|
||||||
@@ -955,7 +979,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
|
|||||||
// Continue without match percentage if company not found
|
// Continue without match percentage if company not found
|
||||||
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
||||||
for _, tender := range page.Items {
|
for _, tender := range page.Items {
|
||||||
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(language))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SearchResponse{
|
return &SearchResponse{
|
||||||
@@ -978,7 +1002,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
|
|||||||
|
|
||||||
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
tenderResponses := make([]TenderResponse, 0, len(page.Items))
|
||||||
for _, tender := range page.Items {
|
for _, tender := range page.Items {
|
||||||
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
|
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(language))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SearchResponse{
|
return &SearchResponse{
|
||||||
@@ -1748,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))
|
||||||
|
|||||||
@@ -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")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] = ©
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRepository) GetByID(_ context.Context, id string) (*TenderSubmission, error) {
|
||||||
|
for _, submission := range m.byTenderCompany {
|
||||||
|
if submission.GetID() == id {
|
||||||
|
copy := *submission
|
||||||
|
return ©, 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 ©, 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] = ©
|
||||||
|
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)] = ©
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user