Add tender submission functionality and related routes
- Introduced the `tender_submission` domain, including entity, repository, service, and handler implementations for managing tender submissions. - Added new routes for both admin and public access to tender submissions, allowing for listing, ensuring, and retrieving submissions by ID and tender. - Enhanced the tender approval service to synchronize submission workflows with approval changes, ensuring proper state management. - Implemented validation and response structures for tender submission operations, improving API consistency and usability. - Added unit tests for tender submission status transitions and workflow logic, ensuring robust functionality. This update enhances the tender management system by providing comprehensive support for tender submissions, improving overall workflow and user experience.
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
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)
|
||||
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 {
|
||||
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) 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
|
||||
}
|
||||
|
||||
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.Update(ctx, submission); err != nil {
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
|
||||
submission := NewTenderSubmission(tenderID, companyID, customerID)
|
||||
if err := s.repository.Create(ctx, submission); err != 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
|
||||
}
|
||||
|
||||
submission.AddStatusChange(StatusNotApplied, "approval_rejected", changedBy, "Tender approval was rejected")
|
||||
return s.repository.Update(ctx, submission)
|
||||
}
|
||||
|
||||
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 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user