diff --git a/cmd/web/main.go b/cmd/web/main.go index ebc33e3..5d750a5 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -119,6 +119,7 @@ import ( "tm/internal/notification" "tm/internal/tender" "tm/internal/tender_approval" + "tm/internal/tender_submission" "tm/internal/user" "tm/pkg/filestore" "tm/pkg/security" @@ -201,6 +202,7 @@ func main() { tenderRepository := tender.NewRepository(mongoManager, logger) feedbackRepo := feedback.NewRepository(mongoManager, logger) tenderApprovalRepository := tender_approval.NewRepository(mongoManager, logger) + tenderSubmissionRepository := tender_submission.NewRepository(mongoManager, logger) inquiryRepository := inquiry.NewRepository(mongoManager, logger) contactRepository := contact.NewContactRepository(mongoManager, logger) cmsRepository := cms.NewRepository(mongoManager, logger) @@ -210,7 +212,7 @@ func main() { cardRepository := kanban.NewCardRepository(mongoManager, logger) notificationRepository := notification.NewRepository(mongoManager, logger) 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 @@ -262,7 +264,8 @@ func main() { } } 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) flagService := assets.NewService(logger, conf.Assets.FlagsPath) notificationService := notification.NewService(notificationSDK, notificationRepository, userService, customerService, logger) @@ -276,7 +279,7 @@ func main() { auditLogRepository := auditlog.NewRepository(auditStore) auditLogService := auditlog.NewService(auditLogRepository, logger) 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 @@ -287,6 +290,7 @@ func main() { tenderHandler := tender.NewHandler(tenderService, logger) feedbackHandler := feedback.NewHandler(feedbackService, userHandler, customerHandler, logger) tenderApprovalHandler := tender_approval.NewHandler(tenderApprovalService, logger) + tenderSubmissionHandler := tender_submission.NewHandler(tenderSubmissionService, logger) inquiryHandler := inquiry.NewHandler(inquiryService, logger, hcaptchaVerifier, xssPolicy) flagHandler := assets.NewHandler(flagService, logger) notificationHandler := notification.NewHandler(notificationService) @@ -299,7 +303,7 @@ func main() { aiPipelineHandler := ai_pipeline.NewHandler(aiPipelineService) auditLogHandler := auditlog.NewHandler(auditLogService) 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 @@ -308,8 +312,8 @@ func main() { router.SetupCSPSecurity(e) // Register routes - router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, 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.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, tenderSubmissionHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, xssPolicy) router.RegisterDocumentScraperRoutes(e, documentScraperHandler, conf.DocumentScraper.APIKey) // Start server diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index 9ec7388..53771a5 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -17,6 +17,7 @@ import ( "tm/internal/notification" "tm/internal/tender" "tm/internal/tender_approval" + "tm/internal/tender_submission" "tm/internal/user" "tm/pkg/filestore" "tm/pkg/security" @@ -36,7 +37,7 @@ func SetupCSPSecurity(e *echo.Echo) { 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") // Admin Users Routes @@ -156,6 +157,15 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler 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 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") customerGP := v1.Group("/profile") @@ -317,6 +327,18 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende 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 companiesGP := v1.Group("/companies") { diff --git a/internal/tender_approval/errors.go b/internal/tender_approval/errors.go index e9291ea..c817d4b 100644 --- a/internal/tender_approval/errors.go +++ b/internal/tender_approval/errors.go @@ -14,4 +14,5 @@ var ( ErrInvalidCompanyID = errors.New("invalid company ID") ErrUnauthorized = errors.New("unauthorized to perform this action") ErrInvalidData = errors.New("invalid tender approval data") + ErrSubmissionSyncFailed = errors.New("failed to sync tender submission workflow") ) diff --git a/internal/tender_approval/handler.go b/internal/tender_approval/handler.go index 43ae803..b424f7c 100644 --- a/internal/tender_approval/handler.go +++ b/internal/tender_approval/handler.go @@ -60,11 +60,15 @@ func (h *Handler) ToggleTenderApproval(c echo.Context) error { 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 errors.Is(err, ErrTenderNotFound) { 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" { return response.Conflict(c, err.Error()) } diff --git a/internal/tender_approval/service.go b/internal/tender_approval/service.go index 5394c69..45c5deb 100644 --- a/internal/tender_approval/service.go +++ b/internal/tender_approval/service.go @@ -11,6 +11,13 @@ import ( "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 type Service interface { // Core tender approval operations @@ -34,7 +41,7 @@ type Service interface { SearchTenderApprovalsByCriteria(ctx context.Context, criteria *TenderApprovalSearchCriteria) ([]*TenderApproval, error) // 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 GetTenderApprovalStats(ctx context.Context) (*TenderApprovalStatsResponse, error) @@ -51,17 +58,19 @@ type Service interface { // tenderApprovalService implements the Service interface type tenderApprovalService struct { - repository Repository - tenderService tender.Service - logger logger.Logger + repository Repository + tenderService tender.Service + logger logger.Logger + submissionHook SubmissionLifecycleHook } // 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{ - repository: repository, - tenderService: tenderService, - logger: logger, + repository: repository, + tenderService: tenderService, + logger: logger, + submissionHook: submissionHook, } } @@ -853,7 +862,7 @@ func (s *tenderApprovalService) ListTenderApprovalsWithTender(ctx context.Contex 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) if companyID == "" { return nil, ErrInvalidCompanyID @@ -899,6 +908,15 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T 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) } @@ -917,6 +935,9 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T existing.Status = "unrejected" } + if err := s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, false); err != nil { + return nil, ErrSubmissionSyncFailed + } return existing.ToResponseWithTender(nil), nil } @@ -939,9 +960,38 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T return nil, err } + if err := s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, true); err != nil { + return nil, ErrSubmissionSyncFailed + } 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) { if approval == nil { return nil, ErrInvalidData diff --git a/internal/tender_submission/entity.go b/internal/tender_submission/entity.go new file mode 100644 index 0000000..4e7815b --- /dev/null +++ b/internal/tender_submission/entity.go @@ -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, + } +} diff --git a/internal/tender_submission/entity_test.go b/internal/tender_submission/entity_test.go new file mode 100644 index 0000000..97b35cf --- /dev/null +++ b/internal/tender_submission/entity_test.go @@ -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) + } +} diff --git a/internal/tender_submission/errors.go b/internal/tender_submission/errors.go new file mode 100644 index 0000000..e4b647a --- /dev/null +++ b/internal/tender_submission/errors.go @@ -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, + } +} diff --git a/internal/tender_submission/form.go b/internal/tender_submission/form.go new file mode 100644 index 0000000..60c6e34 --- /dev/null +++ b/internal/tender_submission/form.go @@ -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"` +} diff --git a/internal/tender_submission/handler.go b/internal/tender_submission/handler.go new file mode 100644 index 0000000..a7675c0 --- /dev/null +++ b/internal/tender_submission/handler.go @@ -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) +} diff --git a/internal/tender_submission/repository.go b/internal/tender_submission/repository.go new file mode 100644 index 0000000..2f7e388 --- /dev/null +++ b/internal/tender_submission/repository.go @@ -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 +} diff --git a/internal/tender_submission/service.go b/internal/tender_submission/service.go new file mode 100644 index 0000000..f28eb9e --- /dev/null +++ b/internal/tender_submission/service.go @@ -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, + } +} diff --git a/internal/tender_submission/service_test.go b/internal/tender_submission/service_test.go new file mode 100644 index 0000000..0d13007 --- /dev/null +++ b/internal/tender_submission/service_test.go @@ -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) + } +} diff --git a/internal/tender_submission/tender_details.go b/internal/tender_submission/tender_details.go new file mode 100644 index 0000000..324dfc6 --- /dev/null +++ b/internal/tender_submission/tender_details.go @@ -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 +}