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:
+10
-6
@@ -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
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
@@ -60,7 +60,8 @@ 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")
|
||||
|
||||
@@ -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,7 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, true)
|
||||
return s.toggleResponseWithTender(ctx, approval)
|
||||
}
|
||||
|
||||
@@ -917,6 +927,7 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T
|
||||
existing.Status = "unrejected"
|
||||
}
|
||||
|
||||
s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, false)
|
||||
return existing.ToResponseWithTender(nil), nil
|
||||
}
|
||||
|
||||
@@ -939,9 +950,35 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, true)
|
||||
return s.toggleResponseWithTender(ctx, existing)
|
||||
}
|
||||
|
||||
func (s *tenderApprovalService) syncSubmissionAfterToggle(ctx context.Context, tenderID, companyID, customerID string, status ApprovalStatus, active bool) {
|
||||
if s.submissionHook == nil {
|
||||
return
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *tenderApprovalService) toggleResponseWithTender(ctx context.Context, approval *TenderApproval) (*TenderApprovalWithTenderResponse, error) {
|
||||
if approval == nil {
|
||||
return nil, ErrInvalidData
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
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())
|
||||
}
|
||||
|
||||
func NewTenderSubmission(tenderID, companyID, customerID string) *TenderSubmission {
|
||||
now := time.Now().Unix()
|
||||
ts := &TenderSubmission{
|
||||
TenderID: tenderID,
|
||||
CompanyID: companyID,
|
||||
CustomerID: customerID,
|
||||
Status: StatusOpportunity,
|
||||
Stage: StageOpportunity,
|
||||
}
|
||||
ts.SetCreatedAt(now)
|
||||
ts.SetUpdatedAt(now)
|
||||
ts.AddStatusChange(StatusOpportunity, "", customerID, "Submission workflow started")
|
||||
return ts
|
||||
}
|
||||
|
||||
func (ts *TenderSubmission) ToResponse() *TenderSubmissionResponse {
|
||||
history := make([]StatusHistoryResponse, len(ts.History))
|
||||
for i, h := range ts.History {
|
||||
history[i] = StatusHistoryResponse{
|
||||
Status: string(h.Status),
|
||||
Reason: h.Reason,
|
||||
ChangedBy: h.ChangedBy,
|
||||
ChangedAt: h.ChangedAt,
|
||||
Description: h.Description,
|
||||
}
|
||||
}
|
||||
|
||||
return &TenderSubmissionResponse{
|
||||
ID: ts.GetID(),
|
||||
TenderID: ts.TenderID,
|
||||
CompanyID: ts.CompanyID,
|
||||
CustomerID: ts.CustomerID,
|
||||
Status: string(ts.Status),
|
||||
Stage: string(ts.Stage),
|
||||
History: history,
|
||||
CreatedAt: ts.CreatedAt,
|
||||
UpdatedAt: ts.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TenderSubmission) ToResponseWithTender(tender *TenderDetails) *TenderSubmissionWithTenderResponse {
|
||||
resp := ts.ToResponse()
|
||||
return &TenderSubmissionWithTenderResponse{
|
||||
TenderSubmissionResponse: *resp,
|
||||
Tender: tender,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package tender_submission
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStageForStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
status SubmissionStatus
|
||||
stage SubmissionStage
|
||||
}{
|
||||
{StatusOpportunity, StageOpportunity},
|
||||
{StatusValidating, StageInProgress},
|
||||
{StatusPartnerPending, StageInProgress},
|
||||
{StatusApplying, StageInProgress},
|
||||
{StatusSentWaiting, StageSubmitted},
|
||||
{StatusRejected, StageSubmitted},
|
||||
{StatusContract, StageSubmitted},
|
||||
{StatusNotApplied, StageNotApplied},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := StageForStatus(tt.status); got != tt.stage {
|
||||
t.Fatalf("StageForStatus(%q) = %q, want %q", tt.status, got, tt.stage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedNextStatuses(t *testing.T) {
|
||||
tests := []struct {
|
||||
current SubmissionStatus
|
||||
next SubmissionStatus
|
||||
allowed bool
|
||||
}{
|
||||
{StatusOpportunity, StatusValidating, true},
|
||||
{StatusOpportunity, StatusNotApplied, true},
|
||||
{StatusOpportunity, StatusApplying, false},
|
||||
{StatusValidating, StatusPartnerPending, true},
|
||||
{StatusValidating, StatusApplying, true},
|
||||
{StatusValidating, StatusNotApplied, true},
|
||||
{StatusPartnerPending, StatusApplying, true},
|
||||
{StatusApplying, StatusSentWaiting, true},
|
||||
{StatusSentWaiting, StatusContract, true},
|
||||
{StatusSentWaiting, StatusRejected, true},
|
||||
{StatusContract, StatusRejected, false},
|
||||
{StatusNotApplied, StatusValidating, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
allowed := false
|
||||
for _, candidate := range AllowedNextStatuses(tt.current) {
|
||||
if candidate == tt.next {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if allowed != tt.allowed {
|
||||
t.Fatalf("transition %q -> %q allowed=%v, want %v", tt.current, tt.next, allowed, tt.allowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddStatusChangeUpdatesStage(t *testing.T) {
|
||||
submission := NewTenderSubmission("tender-1", "company-1", "customer-1")
|
||||
submission.AddStatusChange(StatusValidating, "", "customer-1", "Accepted tender")
|
||||
|
||||
if submission.Status != StatusValidating {
|
||||
t.Fatalf("status = %q, want %q", submission.Status, StatusValidating)
|
||||
}
|
||||
if submission.Stage != StageInProgress {
|
||||
t.Fatalf("stage = %q, want %q", submission.Stage, StageInProgress)
|
||||
}
|
||||
if len(submission.History) != 2 {
|
||||
t.Fatalf("history length = %d, want 2", len(submission.History))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStatusTransition(t *testing.T) {
|
||||
service := &tenderSubmissionService{}
|
||||
|
||||
if err := service.validateStatusTransition(StatusOpportunity, StatusValidating); err != nil {
|
||||
t.Fatalf("expected valid transition, got %v", err)
|
||||
}
|
||||
|
||||
if err := service.validateStatusTransition(StatusContract, StatusRejected); err == nil {
|
||||
t.Fatal("expected terminal status error")
|
||||
}
|
||||
|
||||
err := service.validateStatusTransition(StatusOpportunity, StatusApplying)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid transition error")
|
||||
}
|
||||
transitionErr, ok := err.(*InvalidStatusTransitionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected InvalidStatusTransitionError, got %T", err)
|
||||
}
|
||||
if transitionErr.CurrentStatus != string(StatusOpportunity) {
|
||||
t.Fatalf("current status = %q", transitionErr.CurrentStatus)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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")
|
||||
)
|
||||
|
||||
// InvalidStatusTransitionError is returned when a status change is not allowed.
|
||||
type InvalidStatusTransitionError struct {
|
||||
CurrentStatus string
|
||||
NewStatus string
|
||||
Allowed []string
|
||||
}
|
||||
|
||||
func (e *InvalidStatusTransitionError) Error() string {
|
||||
if len(e.Allowed) == 0 {
|
||||
return fmt.Sprintf("Tender submission status is already %s and cannot be changed", e.CurrentStatus)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"Cannot change tender submission status from %s to %s. Allowed next statuses: %s",
|
||||
e.CurrentStatus,
|
||||
e.NewStatus,
|
||||
strings.Join(e.Allowed, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
func NewInvalidStatusTransitionError(currentStatus, newStatus string, allowed []string) *InvalidStatusTransitionError {
|
||||
return &InvalidStatusTransitionError{
|
||||
CurrentStatus: currentStatus,
|
||||
NewStatus: newStatus,
|
||||
Allowed: allowed,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package tender_submission
|
||||
|
||||
import "tm/pkg/response"
|
||||
|
||||
// UpdateSubmissionStatusForm updates the workflow status of a tender submission.
|
||||
type UpdateSubmissionStatusForm struct {
|
||||
Status SubmissionStatus `json:"status" valid:"required,in(opportunity|validating|partner_pending|applying|sent_waiting|rejected|contract|not_applied)"`
|
||||
Reason string `json:"reason,omitempty" valid:"optional,length(0|500)"`
|
||||
Description string `json:"description,omitempty" valid:"optional,length(0|1000)"`
|
||||
}
|
||||
|
||||
// ListTenderSubmissionsForm filters company tender submissions.
|
||||
type ListTenderSubmissionsForm struct {
|
||||
TenderID *string `query:"tender_id" valid:"optional"`
|
||||
CompanyID *string `query:"company_id" valid:"optional"`
|
||||
Status []string `query:"status" valid:"optional"`
|
||||
Stage []string `query:"stage" valid:"optional"`
|
||||
From *int64 `query:"created_from" valid:"optional"`
|
||||
To *int64 `query:"created_to" valid:"optional"`
|
||||
Limit *int `query:"limit" valid:"optional,range(1|100)"`
|
||||
Offset *int `query:"offset" valid:"optional,range(0|1000000)"`
|
||||
SortBy *string `query:"sort_by" valid:"optional,in(created_at|updated_at|status|stage)"`
|
||||
SortOrder *string `query:"sort_order" valid:"optional,in(asc|desc)"`
|
||||
}
|
||||
|
||||
// TenderSubmissionResponse is the API response for a tender submission.
|
||||
type TenderSubmissionResponse struct {
|
||||
ID string `json:"id"`
|
||||
TenderID string `json:"tender_id"`
|
||||
CompanyID string `json:"company_id"`
|
||||
CustomerID string `json:"customer_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Stage string `json:"stage"`
|
||||
History []StatusHistoryResponse `json:"status_history"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// StatusHistoryResponse is the API representation of a status change.
|
||||
type StatusHistoryResponse struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ChangedBy string `json:"changed_by"`
|
||||
ChangedAt int64 `json:"changed_at"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// TenderSubmissionWithTenderResponse includes tender details.
|
||||
type TenderSubmissionWithTenderResponse struct {
|
||||
TenderSubmissionResponse
|
||||
Tender *TenderDetails `json:"tender,omitempty"`
|
||||
}
|
||||
|
||||
// TenderSubmissionListResponse is a paginated list of submissions with tender details.
|
||||
type TenderSubmissionListResponse struct {
|
||||
Submissions []*TenderSubmissionWithTenderResponse `json:"submissions"`
|
||||
Metadata *response.Meta `json:"metadata"`
|
||||
}
|
||||
|
||||
// TenderDetails represents essential tender information for submission responses.
|
||||
type TenderDetails struct {
|
||||
ID string `json:"id"`
|
||||
NoticePublicationID string `json:"notice_publication_id"`
|
||||
PublicationDate int64 `json:"publication_date"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ProcurementTypeCode string `json:"procurement_type_code"`
|
||||
ProcedureCode string `json:"procedure_code"`
|
||||
EstimatedValue float64 `json:"estimated_value"`
|
||||
Currency string `json:"currency"`
|
||||
Duration string `json:"duration"`
|
||||
DurationUnit string `json:"duration_unit"`
|
||||
TenderDeadline int64 `json:"tender_deadline"`
|
||||
SubmissionDeadline int64 `json:"submission_deadline"`
|
||||
CountryCode string `json:"country_code"`
|
||||
BuyerOrganization *OrganizationResponse `json:"buyer_organization"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// OrganizationResponse represents buyer organization info.
|
||||
type OrganizationResponse struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// CompanySubmissionStatsResponse contains submission counts grouped by stage and status.
|
||||
type CompanySubmissionStatsResponse struct {
|
||||
CompanyID string `json:"company_id"`
|
||||
Total int64 `json:"total"`
|
||||
ByStage map[string]int64 `json:"by_stage"`
|
||||
ByStatus map[string]int64 `json:"by_status"`
|
||||
LastUpdated int64 `json:"last_updated"`
|
||||
}
|
||||
|
||||
// AdminSubmissionStatsResponse contains global submission statistics.
|
||||
type AdminSubmissionStatsResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
ByStage map[string]int64 `json:"by_stage"`
|
||||
ByStatus map[string]int64 `json:"by_status"`
|
||||
LastUpdated int64 `json:"last_updated"`
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
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.
|
||||
// @Summary Get tender submission by ID
|
||||
// @Description Retrieve a tender submission workflow with tender details
|
||||
// @Tags 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 /api/v1/tender-submissions/{id} [get]
|
||||
func (h *Handler) GetSubmissionByID(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")
|
||||
}
|
||||
|
||||
companyID, _ := c.Get("company_id").(string)
|
||||
if companyID != "" && result.CompanyID != companyID {
|
||||
return response.NotFound(c, "Tender submission not found")
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
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 {
|
||||
return h.GetSubmissionByID(c)
|
||||
}
|
||||
|
||||
// AdminGetSubmissionStats returns global submission statistics.
|
||||
// @Summary Admin get tender submission statistics
|
||||
// @Description Retrieve global submission counts grouped by stage and status
|
||||
// @Tags Admin-TenderSubmissions
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.APIResponse{data=AdminSubmissionStatsResponse}
|
||||
// @Failure 500 {object} response.APIResponse
|
||||
// @Security BearerAuth
|
||||
// @Router /admin/v1/tender-submissions/stats [get]
|
||||
func (h *Handler) AdminGetSubmissionStats(c echo.Context) error {
|
||||
stats, err := h.service.GetGlobalStats(c.Request().Context())
|
||||
if err != nil {
|
||||
return response.InternalServerError(c, "Failed to get submission statistics")
|
||||
}
|
||||
return response.Success(c, stats, "Submission statistics retrieved successfully")
|
||||
}
|
||||
|
||||
func (h *Handler) resolveCompanyID(c echo.Context) (string, error) {
|
||||
requestedCompanyID := strings.TrimSpace(c.QueryParam("company_id"))
|
||||
activeCompanyID, _ := c.Get("company_id").(string)
|
||||
assignedCompanyIDs, _ := c.Get("company_ids").([]string)
|
||||
|
||||
return customer.PickAssignedCompanyID(activeCompanyID, assignedCompanyIDs, requestedCompanyID)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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
|
||||
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]
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
ormRepo := mongo.NewRepository[TenderSubmission](mongoManager.GetCollection("tender_submissions"), logger)
|
||||
return &tenderSubmissionRepository{ormRepo: ormRepo, 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 {
|
||||
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 {
|
||||
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) Delete(ctx context.Context, id string) error {
|
||||
if err := r.ormRepo.Delete(ctx, id); err != nil {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package tender_submission
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"tm/internal/tender"
|
||||
)
|
||||
|
||||
func listTenderDetailsFromResponse(resp *tender.TenderResponse) *TenderDetails {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
details := &TenderDetails{
|
||||
ID: resp.ID,
|
||||
Title: resp.Title,
|
||||
Description: resp.Description,
|
||||
EstimatedValue: resp.EstimatedValue,
|
||||
Currency: resp.Currency,
|
||||
TenderDeadline: resp.TenderDeadline,
|
||||
SubmissionDeadline: resp.SubmissionDeadline,
|
||||
CountryCode: resp.CountryCode,
|
||||
Status: string(resp.Status),
|
||||
}
|
||||
if resp.BuyerOrganization != nil && resp.BuyerOrganization.Name != "" {
|
||||
details.BuyerOrganization = &OrganizationResponse{Name: resp.BuyerOrganization.Name}
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func fullTenderDetailsFromResponse(resp *tender.TenderResponse) *TenderDetails {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
details := &TenderDetails{
|
||||
ID: resp.ID,
|
||||
NoticePublicationID: resp.NoticePublicationID,
|
||||
PublicationDate: resp.PublicationDate,
|
||||
Title: resp.Title,
|
||||
Description: resp.Description,
|
||||
ProcurementTypeCode: resp.ProcurementTypeCode,
|
||||
ProcedureCode: resp.ProcedureCode,
|
||||
EstimatedValue: resp.EstimatedValue,
|
||||
Currency: resp.Currency,
|
||||
Duration: resp.Duration,
|
||||
DurationUnit: resp.DurationUnit,
|
||||
TenderDeadline: resp.TenderDeadline,
|
||||
SubmissionDeadline: resp.SubmissionDeadline,
|
||||
CountryCode: resp.CountryCode,
|
||||
Status: string(resp.Status),
|
||||
}
|
||||
if resp.BuyerOrganization != nil && resp.BuyerOrganization.Name != "" {
|
||||
details.BuyerOrganization = &OrganizationResponse{Name: resp.BuyerOrganization.Name}
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func uniqueTenderIDs(submissions []*TenderSubmission) []string {
|
||||
if len(submissions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(submissions))
|
||||
ids := make([]string, 0, len(submissions))
|
||||
for _, submission := range submissions {
|
||||
if submission == nil || submission.TenderID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[submission.TenderID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[submission.TenderID] = struct{}{}
|
||||
ids = append(ids, submission.TenderID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (s *tenderSubmissionService) loadTendersByIDs(ctx context.Context, tenderIDs []string) map[string]*tender.TenderResponse {
|
||||
if len(tenderIDs) == 0 {
|
||||
return map[string]*tender.TenderResponse{}
|
||||
}
|
||||
|
||||
tenders, err := s.tenderService.GetByIDs(ctx, tenderIDs)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to batch load tenders for submissions", map[string]interface{}{
|
||||
"count": len(tenderIDs),
|
||||
"error": err.Error(),
|
||||
})
|
||||
return map[string]*tender.TenderResponse{}
|
||||
}
|
||||
return tenders
|
||||
}
|
||||
|
||||
func (s *tenderSubmissionService) enrichSubmissions(ctx context.Context, submissions []*TenderSubmission, fullDetails bool) []*TenderSubmissionWithTenderResponse {
|
||||
tenderMap := s.loadTendersByIDs(ctx, uniqueTenderIDs(submissions))
|
||||
responses := make([]*TenderSubmissionWithTenderResponse, len(submissions))
|
||||
for i, submission := range submissions {
|
||||
var details *TenderDetails
|
||||
if tenderResp, ok := tenderMap[submission.TenderID]; ok {
|
||||
if fullDetails {
|
||||
details = fullTenderDetailsFromResponse(tenderResp)
|
||||
} else {
|
||||
details = listTenderDetailsFromResponse(tenderResp)
|
||||
}
|
||||
}
|
||||
responses[i] = submission.ToResponseWithTender(details)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
Reference in New Issue
Block a user