Merge branch 'develop' into documents-scraped

This commit is contained in:
m.nazemi
2026-07-12 21:46:03 +03:30
14 changed files with 2013 additions and 18 deletions
+1
View File
@@ -14,4 +14,5 @@ var (
ErrInvalidCompanyID = errors.New("invalid company ID")
ErrUnauthorized = errors.New("unauthorized to perform this action")
ErrInvalidData = errors.New("invalid tender approval data")
ErrSubmissionSyncFailed = errors.New("failed to sync tender submission workflow")
)
+5 -1
View File
@@ -60,11 +60,15 @@ func (h *Handler) ToggleTenderApproval(c echo.Context) error {
return response.BadRequest(c, "Company ID is required", "")
}
tenderApproval, err := h.service.ToggleTenderApproval(c.Request().Context(), form, companyID)
customerID, _ := customer.GetCustomerIDFromContext(c)
tenderApproval, err := h.service.ToggleTenderApproval(c.Request().Context(), form, companyID, customerID)
if err != nil {
if errors.Is(err, ErrTenderNotFound) {
return response.NotFound(c, "Tender not found")
}
if errors.Is(err, ErrSubmissionSyncFailed) {
return response.InternalServerError(c, "Failed to sync tender submission workflow")
}
if err.Error() == "tender approval already exists for this tender and company" {
return response.Conflict(c, err.Error())
}
+59 -9
View File
@@ -11,6 +11,13 @@ import (
"tm/pkg/response"
)
// SubmissionLifecycleHook synchronizes tender submission workflow with approval changes.
type SubmissionLifecycleHook interface {
OnApprovalSubmitted(ctx context.Context, tenderID, companyID, customerID string) error
OnApprovalRemoved(ctx context.Context, tenderID, companyID string) error
OnApprovalRejected(ctx context.Context, tenderID, companyID, changedBy string) error
}
// Service defines business logic for tender approval operations
type Service interface {
// Core tender approval operations
@@ -34,7 +41,7 @@ type Service interface {
SearchTenderApprovalsByCriteria(ctx context.Context, criteria *TenderApprovalSearchCriteria) ([]*TenderApproval, error)
// Business operations
ToggleTenderApproval(ctx context.Context, req *ToggleTenderApprovalForm, companyID string) (*TenderApprovalWithTenderResponse, error)
ToggleTenderApproval(ctx context.Context, req *ToggleTenderApprovalForm, companyID, customerID string) (*TenderApprovalWithTenderResponse, error)
// Statistics and analytics
GetTenderApprovalStats(ctx context.Context) (*TenderApprovalStatsResponse, error)
@@ -51,17 +58,19 @@ type Service interface {
// tenderApprovalService implements the Service interface
type tenderApprovalService struct {
repository Repository
tenderService tender.Service
logger logger.Logger
repository Repository
tenderService tender.Service
logger logger.Logger
submissionHook SubmissionLifecycleHook
}
// NewService creates a new tender approval service
func NewService(repository Repository, tenderService tender.Service, logger logger.Logger) Service {
func NewService(repository Repository, tenderService tender.Service, logger logger.Logger, submissionHook SubmissionLifecycleHook) Service {
return &tenderApprovalService{
repository: repository,
tenderService: tenderService,
logger: logger,
repository: repository,
tenderService: tenderService,
logger: logger,
submissionHook: submissionHook,
}
}
@@ -853,7 +862,7 @@ func (s *tenderApprovalService) ListTenderApprovalsWithTender(ctx context.Contex
return meta, nil
}
func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *ToggleTenderApprovalForm, companyID string) (*TenderApprovalWithTenderResponse, error) {
func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *ToggleTenderApprovalForm, companyID, customerID string) (*TenderApprovalWithTenderResponse, error) {
companyID = strings.TrimSpace(companyID)
if companyID == "" {
return nil, ErrInvalidCompanyID
@@ -899,6 +908,15 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T
return nil, err
}
if err := s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, true); err != nil {
if deleteErr := s.repository.Delete(ctx, approval.GetID()); deleteErr != nil {
s.logger.Error("Failed to rollback tender approval after submission sync failure", map[string]interface{}{
"tender_approval_id": approval.GetID(),
"error": deleteErr.Error(),
})
}
return nil, ErrSubmissionSyncFailed
}
return s.toggleResponseWithTender(ctx, approval)
}
@@ -917,6 +935,9 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T
existing.Status = "unrejected"
}
if err := s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, false); err != nil {
return nil, ErrSubmissionSyncFailed
}
return existing.ToResponseWithTender(nil), nil
}
@@ -939,9 +960,38 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T
return nil, err
}
if err := s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, true); err != nil {
return nil, ErrSubmissionSyncFailed
}
return s.toggleResponseWithTender(ctx, existing)
}
func (s *tenderApprovalService) syncSubmissionAfterToggle(ctx context.Context, tenderID, companyID, customerID string, status ApprovalStatus, active bool) error {
if s.submissionHook == nil {
return nil
}
var err error
if !active {
err = s.submissionHook.OnApprovalRemoved(ctx, tenderID, companyID)
} else if status == ApprovalStatusSubmitted {
err = s.submissionHook.OnApprovalSubmitted(ctx, tenderID, companyID, customerID)
} else if status == ApprovalStatusRejected {
err = s.submissionHook.OnApprovalRejected(ctx, tenderID, companyID, customerID)
}
if err != nil {
s.logger.Error("Failed to sync tender submission workflow after approval toggle", map[string]interface{}{
"tender_id": tenderID,
"company_id": companyID,
"status": status,
"active": active,
"error": err.Error(),
})
}
return err
}
func (s *tenderApprovalService) toggleResponseWithTender(ctx context.Context, approval *TenderApproval) (*TenderApprovalWithTenderResponse, error) {
if approval == nil {
return nil, ErrInvalidData
+168
View File
@@ -0,0 +1,168 @@
package tender_submission
import (
"time"
"tm/pkg/mongo"
)
// SubmissionStatus represents the current step in the tender submission workflow.
type SubmissionStatus string
const (
StatusOpportunity SubmissionStatus = "opportunity"
StatusValidating SubmissionStatus = "validating"
StatusPartnerPending SubmissionStatus = "partner_pending"
StatusApplying SubmissionStatus = "applying"
StatusSentWaiting SubmissionStatus = "sent_waiting"
StatusRejected SubmissionStatus = "rejected"
StatusContract SubmissionStatus = "contract"
StatusNotApplied SubmissionStatus = "not_applied"
)
// SubmissionStage groups statuses into high-level workflow stages for UI display.
type SubmissionStage string
const (
StageOpportunity SubmissionStage = "opportunity"
StageInProgress SubmissionStage = "in_progress"
StageSubmitted SubmissionStage = "submitted"
StageNotApplied SubmissionStage = "not_applied"
)
// StatusHistory records a status change in the submission workflow.
type StatusHistory struct {
Status SubmissionStatus `bson:"status" json:"status"`
Reason string `bson:"reason,omitempty" json:"reason,omitempty"`
ChangedBy string `bson:"changed_by" json:"changed_by"`
ChangedAt int64 `bson:"changed_at" json:"changed_at"`
Description string `bson:"description,omitempty" json:"description,omitempty"`
}
// TenderSubmission tracks a company's tender submission workflow.
type TenderSubmission struct {
mongo.Model `bson:",inline"`
TenderID string `bson:"tender_id" json:"tender_id"`
CompanyID string `bson:"company_id" json:"company_id"`
CustomerID string `bson:"customer_id,omitempty" json:"customer_id,omitempty"`
Status SubmissionStatus `bson:"status" json:"status"`
Stage SubmissionStage `bson:"stage" json:"stage"`
History []StatusHistory `bson:"status_history" json:"status_history"`
}
func StageForStatus(status SubmissionStatus) SubmissionStage {
switch status {
case StatusOpportunity:
return StageOpportunity
case StatusValidating, StatusPartnerPending, StatusApplying:
return StageInProgress
case StatusSentWaiting, StatusRejected, StatusContract:
return StageSubmitted
case StatusNotApplied:
return StageNotApplied
default:
return StageOpportunity
}
}
func IsTerminalStatus(status SubmissionStatus) bool {
switch status {
case StatusRejected, StatusContract, StatusNotApplied:
return true
default:
return false
}
}
func AllowedNextStatuses(current SubmissionStatus) []SubmissionStatus {
transitions := map[SubmissionStatus][]SubmissionStatus{
StatusOpportunity: {StatusValidating, StatusNotApplied},
StatusValidating: {StatusPartnerPending, StatusApplying, StatusNotApplied},
StatusPartnerPending: {StatusApplying, StatusNotApplied},
StatusApplying: {StatusSentWaiting, StatusNotApplied},
StatusSentWaiting: {StatusRejected, StatusContract},
StatusRejected: {},
StatusContract: {},
StatusNotApplied: {},
}
return transitions[current]
}
func (ts *TenderSubmission) GetID() string {
return ts.ID.Hex()
}
func (ts *TenderSubmission) SetCreatedAt(timestamp int64) {
ts.CreatedAt = timestamp
}
func (ts *TenderSubmission) SetUpdatedAt(timestamp int64) {
ts.UpdatedAt = timestamp
}
func (ts *TenderSubmission) AddStatusChange(status SubmissionStatus, reason, changedBy, description string) {
ts.History = append(ts.History, StatusHistory{
Status: status,
Reason: reason,
ChangedBy: changedBy,
ChangedAt: time.Now().Unix(),
Description: description,
})
ts.Status = status
ts.Stage = StageForStatus(status)
ts.SetUpdatedAt(time.Now().Unix())
}
// ReopenForApproval resets a terminal submission when the tender is approved again.
// This is only used by the approval sync path, not by user-initiated status updates.
func (ts *TenderSubmission) ReopenForApproval(changedBy, description string) {
ts.AddStatusChange(StatusOpportunity, "approval_resubmitted", changedBy, description)
}
func NewTenderSubmission(tenderID, companyID, customerID string) *TenderSubmission {
now := time.Now().Unix()
ts := &TenderSubmission{
TenderID: tenderID,
CompanyID: companyID,
CustomerID: customerID,
Status: StatusOpportunity,
Stage: StageOpportunity,
}
ts.SetCreatedAt(now)
ts.SetUpdatedAt(now)
ts.AddStatusChange(StatusOpportunity, "", customerID, "Submission workflow started")
return ts
}
func (ts *TenderSubmission) ToResponse() *TenderSubmissionResponse {
history := make([]StatusHistoryResponse, len(ts.History))
for i, h := range ts.History {
history[i] = StatusHistoryResponse{
Status: string(h.Status),
Reason: h.Reason,
ChangedBy: h.ChangedBy,
ChangedAt: h.ChangedAt,
Description: h.Description,
}
}
return &TenderSubmissionResponse{
ID: ts.GetID(),
TenderID: ts.TenderID,
CompanyID: ts.CompanyID,
CustomerID: ts.CustomerID,
Status: string(ts.Status),
Stage: string(ts.Stage),
History: history,
CreatedAt: ts.CreatedAt,
UpdatedAt: ts.UpdatedAt,
}
}
func (ts *TenderSubmission) ToResponseWithTender(tender *TenderDetails) *TenderSubmissionWithTenderResponse {
resp := ts.ToResponse()
return &TenderSubmissionWithTenderResponse{
TenderSubmissionResponse: *resp,
Tender: tender,
}
}
+98
View File
@@ -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)
}
}
+46
View File
@@ -0,0 +1,46 @@
package tender_submission
import (
"errors"
"fmt"
"strings"
)
var (
ErrTenderSubmissionNotFound = errors.New("tender submission not found")
ErrTenderSubmissionExists = errors.New("tender submission already exists for this tender and company")
ErrInvalidStatus = errors.New("invalid tender submission status")
ErrInvalidStage = errors.New("invalid tender submission stage")
ErrTenderNotFound = errors.New("tender not found")
ErrApprovalRequired = errors.New("tender must be approved before starting submission workflow")
ErrInvalidStatusTransition = errors.New("invalid status transition")
ErrTerminalStatus = errors.New("tender submission is in a terminal state and cannot be updated")
ErrConcurrentUpdate = errors.New("tender submission was modified concurrently")
)
// InvalidStatusTransitionError is returned when a status change is not allowed.
type InvalidStatusTransitionError struct {
CurrentStatus string
NewStatus string
Allowed []string
}
func (e *InvalidStatusTransitionError) Error() string {
if len(e.Allowed) == 0 {
return fmt.Sprintf("Tender submission status is already %s and cannot be changed", e.CurrentStatus)
}
return fmt.Sprintf(
"Cannot change tender submission status from %s to %s. Allowed next statuses: %s",
e.CurrentStatus,
e.NewStatus,
strings.Join(e.Allowed, ", "),
)
}
func NewInvalidStatusTransitionError(currentStatus, newStatus string, allowed []string) *InvalidStatusTransitionError {
return &InvalidStatusTransitionError{
CurrentStatus: currentStatus,
NewStatus: newStatus,
Allowed: allowed,
}
}
+100
View File
@@ -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"`
}
+334
View File
@@ -0,0 +1,334 @@
package tender_submission
import (
"errors"
"strings"
"tm/internal/customer"
"tm/pkg/logger"
"tm/pkg/response"
"github.com/labstack/echo/v4"
)
// Handler handles HTTP requests for tender submission operations.
type Handler struct {
service Service
logger logger.Logger
}
func NewHandler(service Service, logger logger.Logger) *Handler {
return &Handler{service: service, logger: logger}
}
// ListCompanySubmissions lists tender submissions for the authenticated company.
// @Summary List company tender submissions
// @Description Retrieve paginated tender submission workflows for the authenticated company
// @Tags TenderSubmissions
// @Accept json
// @Produce json
// @Param status query []string false "Filter by status"
// @Param stage query []string false "Filter by stage"
// @Param created_from query int false "Filter by created date from (Unix timestamp)"
// @Param created_to query int false "Filter by created date to (Unix timestamp)"
// @Param limit query int false "Page size (default 20, max 100)"
// @Param offset query int false "Pagination offset"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionListResponse}
// @Failure 401 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tender-submissions [get]
func (h *Handler) ListCompanySubmissions(c echo.Context) error {
companyID, ok := c.Get("company_id").(string)
if !ok || companyID == "" {
return response.Unauthorized(c, "Unauthorized")
}
form, err := response.Parse[ListTenderSubmissionsForm](c)
if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
result, err := h.service.ListByCompanyWithTender(c.Request().Context(), companyID, form)
if err != nil {
return response.InternalServerError(c, "Failed to list tender submissions")
}
return response.SuccessWithMeta(c, result.Submissions, result.Metadata, "Tender submissions retrieved successfully")
}
// EnsureSubmission ensures a submission workflow exists for an approved tender.
// @Summary Ensure tender submission workflow
// @Description Create or return the submission workflow for an approved tender
// @Tags TenderSubmissions
// @Accept json
// @Produce json
// @Param tender_id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionWithTenderResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tender-submissions/tender/{tender_id}/ensure [post]
func (h *Handler) EnsureSubmission(c echo.Context) error {
companyID, err := h.resolveCompanyID(c)
if err != nil {
if errors.Is(err, customer.ErrCompanyNotAssigned) {
return response.Forbidden(c, "Company is not assigned to customer")
}
return response.BadRequest(c, "Company ID is required", "")
}
customerID, _ := customer.GetCustomerIDFromContext(c)
tenderID := strings.TrimSpace(c.Param("tender_id"))
if tenderID == "" {
return response.BadRequest(c, "Tender ID is required", "")
}
result, err := h.service.EnsureForApprovedTender(c.Request().Context(), tenderID, companyID, customerID)
if err != nil {
if errors.Is(err, ErrTenderNotFound) {
return response.NotFound(c, "Tender not found")
}
if errors.Is(err, ErrApprovalRequired) {
return response.BadRequest(c, "Tender must be approved before starting submission workflow", "")
}
return response.InternalServerError(c, "Failed to ensure tender submission")
}
return response.Success(c, result, "Tender submission ensured successfully")
}
// GetSubmissionByID retrieves a tender submission by ID for the authenticated company.
// @Summary Get tender submission by ID
// @Description Retrieve a tender submission workflow with tender details for the authenticated company
// @Tags TenderSubmissions
// @Accept json
// @Produce json
// @Param id path string true "Submission ID"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionWithTenderResponse}
// @Failure 401 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tender-submissions/{id} [get]
func (h *Handler) GetSubmissionByID(c echo.Context) error {
companyID, ok := c.Get("company_id").(string)
if !ok || companyID == "" {
return response.Unauthorized(c, "Unauthorized")
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return response.BadRequest(c, "Submission ID is required", "")
}
result, err := h.service.GetByIDForCompanyWithTender(c.Request().Context(), id, companyID)
if err != nil {
if errors.Is(err, ErrTenderSubmissionNotFound) {
return response.NotFound(c, "Tender submission not found")
}
return response.InternalServerError(c, "Failed to get tender submission")
}
return response.Success(c, result, "Tender submission retrieved successfully")
}
// GetSubmissionByTender retrieves a submission by tender and company.
// @Summary Get tender submission by tender
// @Description Retrieve the submission workflow for a specific tender and company
// @Tags TenderSubmissions
// @Accept json
// @Produce json
// @Param tender_id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionWithTenderResponse}
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tender-submissions/tender/{tender_id} [get]
func (h *Handler) GetSubmissionByTender(c echo.Context) error {
companyID, ok := c.Get("company_id").(string)
if !ok || companyID == "" {
return response.Unauthorized(c, "Unauthorized")
}
tenderID := strings.TrimSpace(c.Param("tender_id"))
if tenderID == "" {
return response.BadRequest(c, "Tender ID is required", "")
}
result, err := h.service.GetByTenderAndCompanyWithTender(c.Request().Context(), tenderID, companyID)
if err != nil {
if errors.Is(err, ErrTenderNotFound) || errors.Is(err, ErrTenderSubmissionNotFound) {
return response.NotFound(c, "Tender submission not found")
}
return response.InternalServerError(c, "Failed to get tender submission")
}
return response.Success(c, result, "Tender submission retrieved successfully")
}
// UpdateSubmissionStatus updates the workflow status of a submission.
// @Summary Update tender submission status
// @Description Move a tender submission to the next workflow step
// @Tags TenderSubmissions
// @Accept json
// @Produce json
// @Param id path string true "Submission ID"
// @Param body body UpdateSubmissionStatusForm true "Status update"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionWithTenderResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 422 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tender-submissions/{id}/status [patch]
func (h *Handler) UpdateSubmissionStatus(c echo.Context) error {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return response.BadRequest(c, "Submission ID is required", "")
}
form, err := response.Parse[UpdateSubmissionStatusForm](c)
if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
companyID, err := h.resolveCompanyID(c)
if err != nil {
if errors.Is(err, customer.ErrCompanyNotAssigned) {
return response.Forbidden(c, "Company is not assigned to customer")
}
return response.BadRequest(c, "Company ID is required", "")
}
customerID, _ := customer.GetCustomerIDFromContext(c)
result, err := h.service.UpdateStatus(c.Request().Context(), id, form, companyID, customerID)
if err != nil {
if errors.Is(err, ErrTenderSubmissionNotFound) {
return response.NotFound(c, "Tender submission not found")
}
var transitionErr *InvalidStatusTransitionError
if errors.As(err, &transitionErr) {
return response.ValidationError(c, "Invalid status transition", err.Error())
}
if errors.Is(err, ErrTerminalStatus) {
return response.ValidationError(c, "Submission is in a terminal state", err.Error())
}
if errors.Is(err, ErrConcurrentUpdate) {
return response.Conflict(c, "Tender submission was modified concurrently")
}
return response.InternalServerError(c, "Failed to update tender submission status")
}
return response.Success(c, result, "Tender submission status updated successfully")
}
// GetCompanySubmissionStats returns submission statistics for the company.
// @Summary Get company tender submission statistics
// @Description Retrieve submission counts grouped by stage and status
// @Tags TenderSubmissions
// @Accept json
// @Produce json
// @Success 200 {object} response.APIResponse{data=CompanySubmissionStatsResponse}
// @Failure 401 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tender-submissions/stats [get]
func (h *Handler) GetCompanySubmissionStats(c echo.Context) error {
companyID, ok := c.Get("company_id").(string)
if !ok || companyID == "" {
return response.Unauthorized(c, "Unauthorized")
}
stats, err := h.service.GetCompanyStats(c.Request().Context(), companyID)
if err != nil {
return response.InternalServerError(c, "Failed to get submission statistics")
}
return response.Success(c, stats, "Submission statistics retrieved successfully")
}
// AdminListSubmissions lists tender submissions for admin users.
// @Summary Admin list tender submissions
// @Description Retrieve paginated tender submissions across companies
// @Tags Admin-TenderSubmissions
// @Accept json
// @Produce json
// @Param company_id query string false "Filter by company ID"
// @Param tender_id query string false "Filter by tender ID"
// @Param status query []string false "Filter by status"
// @Param stage query []string false "Filter by stage"
// @Param limit query int false "Page size"
// @Param offset query int false "Pagination offset"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionListResponse}
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /admin/v1/tender-submissions [get]
func (h *Handler) AdminListSubmissions(c echo.Context) error {
form, err := response.Parse[ListTenderSubmissionsForm](c)
if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
result, err := h.service.AdminListWithTender(c.Request().Context(), form)
if err != nil {
return response.InternalServerError(c, "Failed to list tender submissions")
}
return response.SuccessWithMeta(c, result.Submissions, result.Metadata, "Tender submissions retrieved successfully")
}
// AdminGetSubmissionByID retrieves a submission by ID for admin users.
// @Summary Admin get tender submission by ID
// @Description Retrieve a tender submission workflow with tender details
// @Tags Admin-TenderSubmissions
// @Accept json
// @Produce json
// @Param id path string true "Submission ID"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionWithTenderResponse}
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /admin/v1/tender-submissions/{id} [get]
func (h *Handler) AdminGetSubmissionByID(c echo.Context) error {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return response.BadRequest(c, "Submission ID is required", "")
}
result, err := h.service.GetByIDWithTender(c.Request().Context(), id)
if err != nil {
if errors.Is(err, ErrTenderSubmissionNotFound) {
return response.NotFound(c, "Tender submission not found")
}
return response.InternalServerError(c, "Failed to get tender submission")
}
return response.Success(c, result, "Tender submission retrieved successfully")
}
// AdminGetSubmissionStats returns global submission statistics.
// @Summary Admin get tender submission statistics
// @Description Retrieve global submission counts grouped by stage and status
// @Tags Admin-TenderSubmissions
// @Accept json
// @Produce json
// @Success 200 {object} response.APIResponse{data=AdminSubmissionStatsResponse}
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /admin/v1/tender-submissions/stats [get]
func (h *Handler) AdminGetSubmissionStats(c echo.Context) error {
stats, err := h.service.GetGlobalStats(c.Request().Context())
if err != nil {
return response.InternalServerError(c, "Failed to get submission statistics")
}
return response.Success(c, stats, "Submission statistics retrieved successfully")
}
func (h *Handler) resolveCompanyID(c echo.Context) (string, error) {
requestedCompanyID := strings.TrimSpace(c.QueryParam("company_id"))
activeCompanyID, _ := c.Get("company_id").(string)
assignedCompanyIDs, _ := c.Get("company_ids").([]string)
return customer.PickAssignedCompanyID(activeCompanyID, assignedCompanyIDs, requestedCompanyID)
}
+346
View File
@@ -0,0 +1,346 @@
package tender_submission
import (
"context"
"errors"
"time"
"tm/pkg/logger"
"tm/pkg/mongo"
"go.mongodb.org/mongo-driver/v2/bson"
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
)
const maxListLimit = 100
// Repository defines data access for tender submissions.
type Repository interface {
Create(ctx context.Context, submission *TenderSubmission) error
GetByID(ctx context.Context, id string) (*TenderSubmission, error)
GetByTenderAndCompany(ctx context.Context, tenderID, companyID string) (*TenderSubmission, error)
Update(ctx context.Context, submission *TenderSubmission) error
UpdateIfUpdatedAt(ctx context.Context, submission *TenderSubmission, expectedUpdatedAt int64) error
Delete(ctx context.Context, id string) error
ListByCompany(ctx context.Context, companyID string, statuses []SubmissionStatus, stages []SubmissionStage, from, to *int64, limit, offset int, sortBy, sortOrder string) ([]*TenderSubmission, int64, error)
Search(ctx context.Context, tenderID, companyID *string, statuses []SubmissionStatus, stages []SubmissionStage, from, to *int64, limit, offset int, sortBy, sortOrder string) ([]*TenderSubmission, int64, error)
GetCompanyStats(ctx context.Context, companyID string) (*CompanySubmissionStatsResponse, error)
GetGlobalStats(ctx context.Context) (*AdminSubmissionStatsResponse, error)
}
type tenderSubmissionRepository struct {
ormRepo mongo.Repository[TenderSubmission]
collection *mongodriver.Collection
logger logger.Logger
}
func NewRepository(mongoManager *mongo.ConnectionManager, logger logger.Logger) Repository {
indexes := []mongo.Index{
*mongo.CreateUniqueIndex("tender_company_idx", bson.D{{Key: "tender_id", Value: 1}, {Key: "company_id", Value: 1}}),
*mongo.NewIndex("tender_id_idx", bson.D{{Key: "tender_id", Value: 1}}),
*mongo.NewIndex("company_id_idx", bson.D{{Key: "company_id", Value: 1}}),
*mongo.NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}),
*mongo.NewIndex("stage_idx", bson.D{{Key: "stage", Value: 1}}),
*mongo.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
*mongo.NewIndex("updated_at_idx", bson.D{{Key: "updated_at", Value: -1}}),
}
if err := mongoManager.CreateIndexes("tender_submissions", indexes); err != nil {
logger.Warn("Failed to create tender submission indexes", map[string]interface{}{
"error": err.Error(),
})
}
collection := mongoManager.GetCollection("tender_submissions")
ormRepo := mongo.NewRepository[TenderSubmission](collection, logger)
return &tenderSubmissionRepository{ormRepo: ormRepo, collection: collection, logger: logger}
}
func (r *tenderSubmissionRepository) Create(ctx context.Context, submission *TenderSubmission) error {
now := time.Now().Unix()
submission.SetCreatedAt(now)
submission.SetUpdatedAt(now)
if err := r.ormRepo.Create(ctx, submission); err != nil {
if mongo.IsDuplicateKeyError(err) {
return ErrTenderSubmissionExists
}
r.logger.Error("Failed to create tender submission", map[string]interface{}{
"error": err.Error(),
"tender_id": submission.TenderID,
"company_id": submission.CompanyID,
})
return err
}
r.logger.Info("Tender submission created", map[string]interface{}{
"submission_id": submission.GetID(),
"tender_id": submission.TenderID,
"company_id": submission.CompanyID,
"status": submission.Status,
})
return nil
}
func (r *tenderSubmissionRepository) GetByID(ctx context.Context, id string) (*TenderSubmission, error) {
submission, err := r.ormRepo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, mongo.ErrDocumentNotFound) {
return nil, ErrTenderSubmissionNotFound
}
r.logger.Error("Failed to get tender submission by ID", map[string]interface{}{
"error": err.Error(),
"submission_id": id,
})
return nil, err
}
return submission, nil
}
func (r *tenderSubmissionRepository) GetByTenderAndCompany(ctx context.Context, tenderID, companyID string) (*TenderSubmission, error) {
filter := bson.M{"tender_id": tenderID, "company_id": companyID}
submission, err := r.ormRepo.FindOne(ctx, filter)
if err != nil {
if errors.Is(err, mongo.ErrDocumentNotFound) {
return nil, ErrTenderSubmissionNotFound
}
r.logger.Error("Failed to get tender submission by tender and company", map[string]interface{}{
"error": err.Error(),
"tender_id": tenderID,
"company_id": companyID,
})
return nil, err
}
return submission, nil
}
func (r *tenderSubmissionRepository) Update(ctx context.Context, submission *TenderSubmission) error {
submission.SetUpdatedAt(time.Now().Unix())
if err := r.ormRepo.Update(ctx, submission); err != nil {
if errors.Is(err, mongo.ErrDocumentNotFound) {
return ErrTenderSubmissionNotFound
}
r.logger.Error("Failed to update tender submission", map[string]interface{}{
"error": err.Error(),
"submission_id": submission.GetID(),
})
return err
}
return nil
}
func (r *tenderSubmissionRepository) UpdateIfUpdatedAt(ctx context.Context, submission *TenderSubmission, expectedUpdatedAt int64) error {
objectID, err := bson.ObjectIDFromHex(submission.GetID())
if err != nil {
return err
}
newUpdatedAt := time.Now().Unix()
submission.SetUpdatedAt(newUpdatedAt)
filter := bson.M{"_id": objectID, "updated_at": expectedUpdatedAt}
result, err := r.collection.UpdateOne(ctx, filter, bson.M{"$set": submission})
if err != nil {
r.logger.Error("Failed to update tender submission with optimistic lock", map[string]interface{}{
"error": err.Error(),
"submission_id": submission.GetID(),
})
return err
}
if result.MatchedCount == 0 {
return ErrConcurrentUpdate
}
return nil
}
func (r *tenderSubmissionRepository) Delete(ctx context.Context, id string) error {
if err := r.ormRepo.Delete(ctx, id); err != nil {
if errors.Is(err, mongo.ErrDocumentNotFound) {
return ErrTenderSubmissionNotFound
}
r.logger.Error("Failed to delete tender submission", map[string]interface{}{
"error": err.Error(),
"submission_id": id,
})
return err
}
return nil
}
func (r *tenderSubmissionRepository) ListByCompany(ctx context.Context, companyID string, statuses []SubmissionStatus, stages []SubmissionStage, from, to *int64, limit, offset int, sortBy, sortOrder string) ([]*TenderSubmission, int64, error) {
companyIDPtr := companyID
return r.Search(ctx, nil, &companyIDPtr, statuses, stages, from, to, limit, offset, sortBy, sortOrder)
}
func (r *tenderSubmissionRepository) Search(ctx context.Context, tenderID, companyID *string, statuses []SubmissionStatus, stages []SubmissionStage, from, to *int64, limit, offset int, sortBy, sortOrder string) ([]*TenderSubmission, int64, error) {
limit = capListLimit(limit)
if offset < 0 {
offset = 0
}
filter := bson.M{}
if tenderID != nil && *tenderID != "" {
filter["tender_id"] = *tenderID
}
if companyID != nil && *companyID != "" {
filter["company_id"] = *companyID
}
if len(statuses) > 0 {
filter["status"] = bson.M{"$in": statuses}
}
if len(stages) > 0 {
filter["stage"] = bson.M{"$in": stages}
}
if from != nil || to != nil {
dateFilter := bson.M{}
if from != nil {
dateFilter["$gte"] = *from
}
if to != nil {
dateFilter["$lte"] = *to
}
filter["created_at"] = dateFilter
}
if sortBy == "" {
sortBy = "updated_at"
}
if sortOrder == "" {
sortOrder = "desc"
}
pagination := mongo.NewPaginationBuilder().Limit(limit).Skip(offset)
if sortOrder == "asc" {
pagination = pagination.SortAsc(sortBy)
} else {
pagination = pagination.SortDesc(sortBy)
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination.Build())
if err != nil {
r.logger.Error("Failed to search tender submissions", map[string]interface{}{
"error": err.Error(),
})
return nil, 0, err
}
items := make([]*TenderSubmission, len(result.Items))
for i := range result.Items {
items[i] = &result.Items[i]
}
return items, result.TotalCount, nil
}
func (r *tenderSubmissionRepository) GetCompanyStats(ctx context.Context, companyID string) (*CompanySubmissionStatsResponse, error) {
pipeline := mongodriver.Pipeline{
{{Key: "$match", Value: bson.M{"company_id": companyID}}},
{{Key: "$facet", Value: bson.M{
"total": bson.A{bson.M{"$count": "count"}},
"by_stage": bson.A{bson.M{"$group": bson.M{"_id": "$stage", "count": bson.M{"$sum": 1}}}},
"by_status": bson.A{bson.M{"$group": bson.M{"_id": "$status", "count": bson.M{"$sum": 1}}}},
}}},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
stats := &CompanySubmissionStatsResponse{
CompanyID: companyID,
ByStage: map[string]int64{},
ByStatus: map[string]int64{},
LastUpdated: time.Now().Unix(),
}
if len(results) == 0 {
return stats, nil
}
facet := results[0]
if totalArr, ok := facet["total"].(bson.A); ok && len(totalArr) > 0 {
if doc, ok := totalArr[0].(bson.M); ok {
stats.Total = int64Value(doc["count"])
}
}
stats.ByStage = countMapFromFacet(facet["by_stage"])
stats.ByStatus = countMapFromFacet(facet["by_status"])
return stats, nil
}
func (r *tenderSubmissionRepository) GetGlobalStats(ctx context.Context) (*AdminSubmissionStatsResponse, error) {
pipeline := mongodriver.Pipeline{
{{Key: "$facet", Value: bson.M{
"total": bson.A{bson.M{"$count": "count"}},
"by_stage": bson.A{bson.M{"$group": bson.M{"_id": "$stage", "count": bson.M{"$sum": 1}}}},
"by_status": bson.A{bson.M{"$group": bson.M{"_id": "$status", "count": bson.M{"$sum": 1}}}},
}}},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
stats := &AdminSubmissionStatsResponse{
ByStage: map[string]int64{},
ByStatus: map[string]int64{},
LastUpdated: time.Now().Unix(),
}
if len(results) == 0 {
return stats, nil
}
facet := results[0]
if totalArr, ok := facet["total"].(bson.A); ok && len(totalArr) > 0 {
if doc, ok := totalArr[0].(bson.M); ok {
stats.Total = int64Value(doc["count"])
}
}
stats.ByStage = countMapFromFacet(facet["by_stage"])
stats.ByStatus = countMapFromFacet(facet["by_status"])
return stats, nil
}
func countMapFromFacet(raw interface{}) map[string]int64 {
out := map[string]int64{}
arr, ok := raw.(bson.A)
if !ok {
return out
}
for _, item := range arr {
doc, ok := item.(bson.M)
if !ok {
continue
}
key, _ := doc["_id"].(string)
if key == "" {
continue
}
out[key] = int64Value(doc["count"])
}
return out
}
func int64Value(v interface{}) int64 {
switch n := v.(type) {
case int32:
return int64(n)
case int64:
return n
case float64:
return int64(n)
default:
return 0
}
}
func capListLimit(limit int) int {
if limit <= 0 {
return 20
}
if limit > maxListLimit {
return maxListLimit
}
return limit
}
+451
View File
@@ -0,0 +1,451 @@
package tender_submission
import (
"context"
"errors"
"strings"
"tm/internal/tender"
"tm/internal/tender_approval"
"tm/pkg/logger"
"tm/pkg/response"
)
// TenderApprovalReader verifies that a tender is approved before workflow actions.
type TenderApprovalReader interface {
GetByTenderAndCompany(ctx context.Context, tenderID, companyID string) (*tender_approval.TenderApproval, error)
}
// Service defines tender submission business logic.
type Service interface {
EnsureForApprovedTender(ctx context.Context, tenderID, companyID, customerID string) (*TenderSubmissionWithTenderResponse, error)
GetByIDWithTender(ctx context.Context, id string) (*TenderSubmissionWithTenderResponse, error)
GetByIDForCompanyWithTender(ctx context.Context, id, companyID string) (*TenderSubmissionWithTenderResponse, error)
GetByTenderAndCompanyWithTender(ctx context.Context, tenderID, companyID string) (*TenderSubmissionWithTenderResponse, error)
ListByCompanyWithTender(ctx context.Context, companyID string, form *ListTenderSubmissionsForm) (*TenderSubmissionListResponse, error)
UpdateStatus(ctx context.Context, id string, form *UpdateSubmissionStatusForm, companyID, changedBy string) (*TenderSubmissionWithTenderResponse, error)
GetCompanyStats(ctx context.Context, companyID string) (*CompanySubmissionStatsResponse, error)
GetGlobalStats(ctx context.Context) (*AdminSubmissionStatsResponse, error)
AdminListWithTender(ctx context.Context, form *ListTenderSubmissionsForm) (*TenderSubmissionListResponse, error)
OnApprovalSubmitted(ctx context.Context, tenderID, companyID, customerID string) error
OnApprovalRemoved(ctx context.Context, tenderID, companyID string) error
OnApprovalRejected(ctx context.Context, tenderID, companyID, changedBy string) error
}
type tenderSubmissionService struct {
repository Repository
approvalReader TenderApprovalReader
tenderService tender.Service
logger logger.Logger
}
func NewService(repository Repository, approvalReader TenderApprovalReader, tenderService tender.Service, logger logger.Logger) Service {
return &tenderSubmissionService{
repository: repository,
approvalReader: approvalReader,
tenderService: tenderService,
logger: logger,
}
}
func (s *tenderSubmissionService) EnsureForApprovedTender(ctx context.Context, tenderID, companyID, customerID string) (*TenderSubmissionWithTenderResponse, error) {
resolvedTenderID, err := s.tenderService.ResolveMongoID(ctx, tenderID)
if err != nil {
s.logger.Warn("Failed to resolve tender for submission ensure", map[string]interface{}{
"tender_ref": tenderID,
"company_id": companyID,
"error": err.Error(),
})
return nil, ErrTenderNotFound
}
if err := s.requireSubmittedApproval(ctx, resolvedTenderID, companyID); err != nil {
return nil, err
}
submission, err := s.repository.GetByTenderAndCompany(ctx, resolvedTenderID, companyID)
if err != nil {
if !errors.Is(err, ErrTenderSubmissionNotFound) {
return nil, err
}
submission = NewTenderSubmission(resolvedTenderID, companyID, customerID)
if err := s.repository.Create(ctx, submission); err != nil {
if !errors.Is(err, ErrTenderSubmissionExists) {
return nil, err
}
submission, err = s.repository.GetByTenderAndCompany(ctx, resolvedTenderID, companyID)
if err != nil {
return nil, err
}
}
}
return s.responseWithTender(ctx, submission, true)
}
func (s *tenderSubmissionService) GetByIDWithTender(ctx context.Context, id string) (*TenderSubmissionWithTenderResponse, error) {
submission, err := s.repository.GetByID(ctx, id)
if err != nil {
return nil, err
}
return s.responseWithTender(ctx, submission, true)
}
func (s *tenderSubmissionService) GetByIDForCompanyWithTender(ctx context.Context, id, companyID string) (*TenderSubmissionWithTenderResponse, error) {
submission, err := s.repository.GetByID(ctx, id)
if err != nil {
return nil, err
}
if submission.CompanyID != companyID {
return nil, ErrTenderSubmissionNotFound
}
return s.responseWithTender(ctx, submission, true)
}
func (s *tenderSubmissionService) GetByTenderAndCompanyWithTender(ctx context.Context, tenderID, companyID string) (*TenderSubmissionWithTenderResponse, error) {
resolvedTenderID, err := s.tenderService.ResolveMongoID(ctx, tenderID)
if err != nil {
return nil, ErrTenderNotFound
}
submission, err := s.repository.GetByTenderAndCompany(ctx, resolvedTenderID, companyID)
if err != nil {
return nil, err
}
return s.responseWithTender(ctx, submission, true)
}
func (s *tenderSubmissionService) ListByCompanyWithTender(ctx context.Context, companyID string, form *ListTenderSubmissionsForm) (*TenderSubmissionListResponse, error) {
limit, offset, sortBy, sortOrder := listParams(form)
statuses := parseStatuses(form.Status)
stages := parseStages(form.Stage)
submissions, total, err := s.repository.ListByCompany(ctx, companyID, statuses, stages, form.From, form.To, limit, offset, sortBy, sortOrder)
if err != nil {
s.logger.Error("Failed to list tender submissions", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
return nil, errors.New("failed to list tender submissions")
}
return &TenderSubmissionListResponse{
Submissions: s.enrichSubmissions(ctx, submissions, false),
Metadata: listMetadata(total, limit, offset),
}, nil
}
func (s *tenderSubmissionService) AdminListWithTender(ctx context.Context, form *ListTenderSubmissionsForm) (*TenderSubmissionListResponse, error) {
limit, offset, sortBy, sortOrder := listParams(form)
statuses := parseStatuses(form.Status)
stages := parseStages(form.Stage)
submissions, total, err := s.repository.Search(ctx, form.TenderID, form.CompanyID, statuses, stages, form.From, form.To, limit, offset, sortBy, sortOrder)
if err != nil {
s.logger.Error("Failed to admin list tender submissions", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("failed to list tender submissions")
}
return &TenderSubmissionListResponse{
Submissions: s.enrichSubmissions(ctx, submissions, false),
Metadata: listMetadata(total, limit, offset),
}, nil
}
func (s *tenderSubmissionService) UpdateStatus(ctx context.Context, id string, form *UpdateSubmissionStatusForm, companyID, changedBy string) (*TenderSubmissionWithTenderResponse, error) {
s.logger.Info("Updating tender submission status", map[string]interface{}{
"submission_id": id,
"company_id": companyID,
"new_status": form.Status,
"changed_by": changedBy,
})
submission, err := s.repository.GetByID(ctx, id)
if err != nil {
return nil, err
}
if companyID != "" && submission.CompanyID != companyID {
return nil, ErrTenderSubmissionNotFound
}
expectedUpdatedAt := submission.UpdatedAt
if err := s.validateStatusTransition(submission.Status, form.Status); err != nil {
s.logger.Error("Invalid tender submission status transition", map[string]interface{}{
"submission_id": id,
"current_status": submission.Status,
"new_status": form.Status,
"error": err.Error(),
})
return nil, err
}
submission.AddStatusChange(form.Status, form.Reason, changedBy, form.Description)
if err := s.repository.UpdateIfUpdatedAt(ctx, submission, expectedUpdatedAt); err != nil {
if errors.Is(err, ErrConcurrentUpdate) {
return nil, ErrConcurrentUpdate
}
return nil, errors.New("failed to update tender submission status")
}
s.logger.Info("Tender submission status updated", map[string]interface{}{
"submission_id": id,
"status": submission.Status,
"stage": submission.Stage,
})
return s.responseWithTender(ctx, submission, true)
}
func (s *tenderSubmissionService) GetCompanyStats(ctx context.Context, companyID string) (*CompanySubmissionStatsResponse, error) {
stats, err := s.repository.GetCompanyStats(ctx, companyID)
if err != nil {
s.logger.Error("Failed to get company submission stats", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
return nil, errors.New("failed to get submission statistics")
}
return stats, nil
}
func (s *tenderSubmissionService) GetGlobalStats(ctx context.Context) (*AdminSubmissionStatsResponse, error) {
stats, err := s.repository.GetGlobalStats(ctx)
if err != nil {
s.logger.Error("Failed to get global submission stats", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("failed to get submission statistics")
}
return stats, nil
}
func (s *tenderSubmissionService) OnApprovalSubmitted(ctx context.Context, tenderID, companyID, customerID string) error {
existing, err := s.repository.GetByTenderAndCompany(ctx, tenderID, companyID)
if err != nil && !errors.Is(err, ErrTenderSubmissionNotFound) {
return err
}
if existing != nil {
if IsTerminalStatus(existing.Status) {
existing.ReopenForApproval(customerID, "Tender re-approved")
return s.repository.Update(ctx, existing)
}
return nil
}
submission := NewTenderSubmission(tenderID, companyID, customerID)
if err := s.repository.Create(ctx, submission); err != nil {
if errors.Is(err, ErrTenderSubmissionExists) {
existing, getErr := s.repository.GetByTenderAndCompany(ctx, tenderID, companyID)
if getErr != nil {
return getErr
}
if IsTerminalStatus(existing.Status) {
existing.ReopenForApproval(customerID, "Tender re-approved")
return s.repository.Update(ctx, existing)
}
return nil
}
s.logger.Error("Failed to create submission on approval", map[string]interface{}{
"tender_id": tenderID,
"company_id": companyID,
"error": err.Error(),
})
return err
}
s.logger.Info("Submission workflow created from approval", map[string]interface{}{
"tender_id": tenderID,
"company_id": companyID,
})
return nil
}
func (s *tenderSubmissionService) OnApprovalRemoved(ctx context.Context, tenderID, companyID string) error {
submission, err := s.repository.GetByTenderAndCompany(ctx, tenderID, companyID)
if err != nil {
if errors.Is(err, ErrTenderSubmissionNotFound) {
return nil
}
return err
}
if submission.Status != StatusOpportunity {
return nil
}
if err := s.repository.Delete(ctx, submission.GetID()); err != nil {
s.logger.Error("Failed to remove opportunity submission after approval toggle", map[string]interface{}{
"submission_id": submission.GetID(),
"error": err.Error(),
})
return err
}
return nil
}
func (s *tenderSubmissionService) OnApprovalRejected(ctx context.Context, tenderID, companyID, changedBy string) error {
submission, err := s.repository.GetByTenderAndCompany(ctx, tenderID, companyID)
if err != nil {
if errors.Is(err, ErrTenderSubmissionNotFound) {
return nil
}
return err
}
if IsTerminalStatus(submission.Status) {
return nil
}
if err := s.transitionSubmission(submission, StatusNotApplied, "approval_rejected", changedBy, "Tender approval was rejected"); err != nil {
var transitionErr *InvalidStatusTransitionError
if errors.Is(err, ErrTerminalStatus) || errors.As(err, &transitionErr) {
s.logger.Warn("Skipping approval rejection sync due to invalid transition", map[string]interface{}{
"tender_id": tenderID,
"company_id": companyID,
"current_status": submission.Status,
"target_status": StatusNotApplied,
})
return nil
}
return err
}
return s.repository.Update(ctx, submission)
}
func (s *tenderSubmissionService) transitionSubmission(submission *TenderSubmission, next SubmissionStatus, reason, changedBy, description string) error {
if err := s.validateStatusTransition(submission.Status, next); err != nil {
return err
}
submission.AddStatusChange(next, reason, changedBy, description)
return nil
}
func (s *tenderSubmissionService) requireSubmittedApproval(ctx context.Context, tenderID, companyID string) error {
approval, err := s.approvalReader.GetByTenderAndCompany(ctx, tenderID, companyID)
if err != nil {
if errors.Is(err, tender_approval.ErrTenderApprovalNotFound) {
return ErrApprovalRequired
}
return err
}
if approval.Status != tender_approval.ApprovalStatusSubmitted {
return ErrApprovalRequired
}
return nil
}
func (s *tenderSubmissionService) validateStatusTransition(current, next SubmissionStatus) error {
if IsTerminalStatus(current) {
return ErrTerminalStatus
}
for _, allowed := range AllowedNextStatuses(current) {
if allowed == next {
return nil
}
}
allowed := AllowedNextStatuses(current)
allowedStrings := make([]string, len(allowed))
for i, status := range allowed {
allowedStrings[i] = string(status)
}
return NewInvalidStatusTransitionError(string(current), string(next), allowedStrings)
}
func (s *tenderSubmissionService) responseWithTender(ctx context.Context, submission *TenderSubmission, fullDetails bool) (*TenderSubmissionWithTenderResponse, error) {
tenderResp, err := s.tenderService.GetByID(ctx, submission.TenderID, "")
if err != nil {
s.logger.Warn("Failed to load tender for submission response", map[string]interface{}{
"tender_id": submission.TenderID,
"error": err.Error(),
})
return submission.ToResponseWithTender(nil), nil
}
var details *TenderDetails
if fullDetails {
details = fullTenderDetailsFromResponse(tenderResp)
} else {
details = listTenderDetailsFromResponse(tenderResp)
}
return submission.ToResponseWithTender(details), nil
}
func listParams(form *ListTenderSubmissionsForm) (limit, offset int, sortBy, sortOrder string) {
limit = 20
offset = 0
sortBy = "updated_at"
sortOrder = "desc"
if form == nil {
return limit, offset, sortBy, sortOrder
}
if form.Limit != nil && *form.Limit > 0 {
limit = *form.Limit
}
if limit > maxListLimit {
limit = maxListLimit
}
if form.Offset != nil && *form.Offset >= 0 {
offset = *form.Offset
}
if form.SortBy != nil && strings.TrimSpace(*form.SortBy) != "" {
sortBy = strings.TrimSpace(*form.SortBy)
}
if form.SortOrder != nil && strings.TrimSpace(*form.SortOrder) != "" {
sortOrder = strings.TrimSpace(*form.SortOrder)
}
return limit, offset, sortBy, sortOrder
}
func parseStatuses(values []string) []SubmissionStatus {
if len(values) == 0 {
return nil
}
out := make([]SubmissionStatus, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
out = append(out, SubmissionStatus(value))
}
return out
}
func parseStages(values []string) []SubmissionStage {
if len(values) == 0 {
return nil
}
out := make([]SubmissionStage, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
out = append(out, SubmissionStage(value))
}
return out
}
func listMetadata(total int64, limit, offset int) *response.Meta {
pages := int(total) / limit
if limit > 0 && int(total)%limit > 0 {
pages++
}
page := 1
if limit > 0 {
page = (offset / limit) + 1
}
return &response.Meta{
Total: int(total),
Limit: limit,
Offset: offset,
Page: page,
Pages: pages,
}
}
+261
View File
@@ -0,0 +1,261 @@
package tender_submission
import (
"context"
"errors"
"testing"
"tm/pkg/logger"
)
type mockRepository struct {
byTenderCompany map[string]*TenderSubmission
createErr error
updateErr error
deleteErr error
}
func submissionKey(tenderID, companyID string) string {
return tenderID + ":" + companyID
}
func newMockRepository() *mockRepository {
return &mockRepository{byTenderCompany: map[string]*TenderSubmission{}}
}
func (m *mockRepository) Create(_ context.Context, submission *TenderSubmission) error {
if m.createErr != nil {
return m.createErr
}
key := submissionKey(submission.TenderID, submission.CompanyID)
if _, exists := m.byTenderCompany[key]; exists {
return ErrTenderSubmissionExists
}
copy := *submission
m.byTenderCompany[key] = &copy
return nil
}
func (m *mockRepository) GetByID(_ context.Context, id string) (*TenderSubmission, error) {
for _, submission := range m.byTenderCompany {
if submission.GetID() == id {
copy := *submission
return &copy, nil
}
}
return nil, ErrTenderSubmissionNotFound
}
func (m *mockRepository) GetByTenderAndCompany(_ context.Context, tenderID, companyID string) (*TenderSubmission, error) {
submission, ok := m.byTenderCompany[submissionKey(tenderID, companyID)]
if !ok {
return nil, ErrTenderSubmissionNotFound
}
copy := *submission
return &copy, nil
}
func (m *mockRepository) Update(_ context.Context, submission *TenderSubmission) error {
if m.updateErr != nil {
return m.updateErr
}
key := submissionKey(submission.TenderID, submission.CompanyID)
if _, ok := m.byTenderCompany[key]; !ok {
return ErrTenderSubmissionNotFound
}
copy := *submission
m.byTenderCompany[key] = &copy
return nil
}
func (m *mockRepository) UpdateIfUpdatedAt(ctx context.Context, submission *TenderSubmission, expectedUpdatedAt int64) error {
current, err := m.GetByTenderAndCompany(ctx, submission.TenderID, submission.CompanyID)
if err != nil {
return err
}
if current.UpdatedAt != expectedUpdatedAt {
return ErrConcurrentUpdate
}
return m.Update(ctx, submission)
}
func (m *mockRepository) Delete(_ context.Context, id string) error {
if m.deleteErr != nil {
return m.deleteErr
}
for key, submission := range m.byTenderCompany {
if submission.GetID() == id {
delete(m.byTenderCompany, key)
return nil
}
}
return ErrTenderSubmissionNotFound
}
func (m *mockRepository) ListByCompany(context.Context, string, []SubmissionStatus, []SubmissionStage, *int64, *int64, int, int, string, string) ([]*TenderSubmission, int64, error) {
return nil, 0, nil
}
func (m *mockRepository) Search(context.Context, *string, *string, []SubmissionStatus, []SubmissionStage, *int64, *int64, int, int, string, string) ([]*TenderSubmission, int64, error) {
return nil, 0, nil
}
func (m *mockRepository) GetCompanyStats(context.Context, string) (*CompanySubmissionStatsResponse, error) {
return nil, nil
}
func (m *mockRepository) GetGlobalStats(context.Context) (*AdminSubmissionStatsResponse, error) {
return nil, nil
}
func (m *mockRepository) seed(submission *TenderSubmission) {
copy := *submission
m.byTenderCompany[submissionKey(submission.TenderID, submission.CompanyID)] = &copy
}
func newTestService(repo Repository) *tenderSubmissionService {
return &tenderSubmissionService{
repository: repo,
approvalReader: nil,
tenderService: nil,
logger: testLogger{},
}
}
type testLogger struct{}
func (testLogger) Debug(string, map[string]interface{}) {}
func (testLogger) Info(string, map[string]interface{}) {}
func (testLogger) Warn(string, map[string]interface{}) {}
func (testLogger) Error(string, map[string]interface{}) {}
func (testLogger) Fatal(string, map[string]interface{}) {}
func (testLogger) WithFields(map[string]interface{}) logger.Logger {
return testLogger{}
}
func (testLogger) Sync() error { return nil }
func TestOnApprovalSubmittedReopensTerminalSubmission(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
existing := NewTenderSubmission("tender-1", "company-1", "customer-1")
existing.AddStatusChange(StatusNotApplied, "approval_rejected", "customer-1", "Rejected")
repo.seed(existing)
if err := service.OnApprovalSubmitted(context.Background(), "tender-1", "company-1", "customer-2"); err != nil {
t.Fatalf("OnApprovalSubmitted() error = %v", err)
}
updated, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if updated.Status != StatusOpportunity {
t.Fatalf("status = %q, want %q", updated.Status, StatusOpportunity)
}
}
func TestOnApprovalSubmittedCreatesWhenMissing(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
if err := service.OnApprovalSubmitted(context.Background(), "tender-1", "company-1", "customer-1"); err != nil {
t.Fatalf("OnApprovalSubmitted() error = %v", err)
}
created, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if created.Status != StatusOpportunity {
t.Fatalf("status = %q, want %q", created.Status, StatusOpportunity)
}
}
func TestOnApprovalRejectedUsesValidatedTransition(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
existing := NewTenderSubmission("tender-1", "company-1", "customer-1")
existing.AddStatusChange(StatusValidating, "", "customer-1", "Accepted")
repo.seed(existing)
if err := service.OnApprovalRejected(context.Background(), "tender-1", "company-1", "customer-1"); err != nil {
t.Fatalf("OnApprovalRejected() error = %v", err)
}
updated, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if updated.Status != StatusNotApplied {
t.Fatalf("status = %q, want %q", updated.Status, StatusNotApplied)
}
}
func TestOnApprovalRejectedSkipsInvalidTransitionFromSentWaiting(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
existing := NewTenderSubmission("tender-1", "company-1", "customer-1")
existing.AddStatusChange(StatusValidating, "", "customer-1", "Accepted")
existing.AddStatusChange(StatusApplying, "", "customer-1", "Ready")
existing.AddStatusChange(StatusSentWaiting, "", "customer-1", "Submitted")
repo.seed(existing)
if err := service.OnApprovalRejected(context.Background(), "tender-1", "company-1", "customer-1"); err != nil {
t.Fatalf("OnApprovalRejected() error = %v", err)
}
updated, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if updated.Status != StatusSentWaiting {
t.Fatalf("status = %q, want %q", updated.Status, StatusSentWaiting)
}
}
func TestOnApprovalRemovedDeletesOpportunityOnly(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
opportunity := NewTenderSubmission("tender-1", "company-1", "customer-1")
repo.seed(opportunity)
if err := service.OnApprovalRemoved(context.Background(), "tender-1", "company-1"); err != nil {
t.Fatalf("OnApprovalRemoved() error = %v", err)
}
if _, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1"); !errors.Is(err, ErrTenderSubmissionNotFound) {
t.Fatalf("expected submission to be deleted, got err = %v", err)
}
inProgress := NewTenderSubmission("tender-2", "company-1", "customer-1")
inProgress.AddStatusChange(StatusValidating, "", "customer-1", "Accepted")
repo.seed(inProgress)
if err := service.OnApprovalRemoved(context.Background(), "tender-2", "company-1"); err != nil {
t.Fatalf("OnApprovalRemoved() error = %v", err)
}
updated, err := repo.GetByTenderAndCompany(context.Background(), "tender-2", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if updated.Status != StatusValidating {
t.Fatalf("status = %q, want %q", updated.Status, StatusValidating)
}
}
func TestGetByIDForCompanyWithTenderEnforcesOwnership(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
submission := NewTenderSubmission("tender-1", "company-1", "customer-1")
repo.seed(submission)
_, err := service.GetByIDForCompanyWithTender(context.Background(), submission.GetID(), "company-2")
if !errors.Is(err, ErrTenderSubmissionNotFound) {
t.Fatalf("expected not found for other company, got %v", err)
}
}
@@ -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
}