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, } }