fa3d466579
- Added a new error for submission sync failures, improving error handling in the tender approval process. - Updated the `ToggleTenderApproval` method to handle the new error, providing clearer responses for sync issues. - Introduced a method to reopen terminal submissions when tender approvals are resubmitted, enhancing submission state management. - Implemented optimistic locking in the repository to prevent concurrent updates, improving data integrity. - Added unit tests for new submission handling logic, ensuring robust functionality and error management. This update strengthens the tender approval workflow and submission handling, ensuring better error reporting and state management.
452 lines
15 KiB
Go
452 lines
15 KiB
Go
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,
|
|
}
|
|
}
|